Ajax-paged tables with graceful degradation in Drupal 7
Flummoxed, I started searching for a solution and finally stumbled upon Rahul Singla's blog explaining how to implement table pagers using ajax. However, Rahul's script did not handle graceful degradation, which was important for me. Hence, using Rahul's approach & script as a starting-point template and reading up a bit more about the PagerDefault and TableSort query extenders in Drupal, this is what I came up with:
How it works
The reason multiple pagers don't work on the same webpage is because Drupal needs some way to differentiate which pager to refresh. Though Drupal 7 does have a solution for this using the PagerDefault query extender (more on this later), using ajax for paging tables circumvents this problem by making each page-request a separate ajax request to a very specific server-side function.
The webpage initially loads blank and then separate ajax calls are made to refresh the various paged tables on the webpage. However, this approach breaks down when client-side Javascript is disabled. After loading the blank webpage, the client is unable to initiate the ajax calls to populate the table content.
We get around this problem by loading the webpage in a way that would work without Javascript. On the client-side we then use Javascript to set it up to work using ajax. Then ajax calls will only be used if Javascript is enabled.
The Server-side
On the server side, we use a separate function to render the content into the paged table. This function can be called either directly within the module code or can be called via a menu callback through the menu router system.
The initial rendering is done into the table on the server side when the form render array is generated, and subsequent ajax calls make use of the menu callback to receive other pages of the table.
Since the content of the paged table is initially rendered on the server side itself, we also attach the CSS class 'table-refreshed'
to the container to indicate that the content is already in place. This tells the client side script to just attach the ajax behaviors and not to initiate an ajax request on page-load for this container.
The Drupal module code
-
<?php
-
-
/**
-
* Generic function to add the JS files required to enable Ajax Pager
-
* functionality
-
*/
-
function ajaxpager_add_js($ajaxpager_settings) {
-
drupal_add_library('system', 'drupal.ajax');
-
drupal_add_js(drupal_get_path('module', 'ajaxpager') . '/jquery.url.js', 'file');
-
drupal_add_js(drupal_get_path('module', 'ajaxpager') . '/ajaxpager.js', 'file');
-
drupal_add_js($ajaxpager_settings, 'setting');
-
}
-
-
-
/**
-
* Implementation of hook_menu() for this example module
-
*/
-
function ajaxpager_menu() {
-
//In addition to the menu link for the page that contains
-
//the paged tables, we also create additional menu callbacks
-
//for each paged table.
-
//
-
-
$items = array();
-
-
//The main page where the multiple pagers will be displayed
-
$items['ajaxpager'] = array(
-
'title' => 'Ajax Pager page',
-
'type' => MENU_NORMAL_ITEM,
-
'page callback' => 'drupal_get_form',
-
'page arguments' => array('ajaxpager_initial_page'),
-
'access arguments' => array('access content')
-
);
-
-
//Menu callback for ajax response for the first pager
-
$items['ajaxpager/node_pager'] = array(
-
'title' => 'Ajax callback for listing Nodes',
-
'type' => MENU_CALLBACK,
-
'page callback' => 'ajaxpager_ajax_node_callback',
-
'access arguments' => array('access content'),
-
);
-
-
//Menu callback for ajax response for the second pager
-
$items['ajaxpager/user_pager'] = array(
-
'title' => 'Ajax callback for listing Users',
-
'type' => MENU_CALLBACK,
-
'page callback' => 'ajaxpager_ajax_user_callback',
-
'access arguments' => array('access content'),
-
);
-
-
return $items;
-
}
-
-
/**
-
* The page callback to be used to generate the initial form
-
* using Form API
-
*/
-
function ajaxpager_initial_page($form, $form_state) {
-
//The settings array is passed to the Client side using
-
//drupal_add_js. This array provides a list of all pagers
-
//on this page along with details of the Menu callbacks to
-
//be used for each pager
-
//
-
//Format of the array is:
-
// Array
-
// (
-
// ['ajaxpagers'] => Array
-
// (
-
// ['#<ContainerName1>'] => Array
-
// (
-
// 'divName' => '#<ContainerName1>',
-
// 'url' => '<Menu Callback URL for Ajax response 1>/nojs',
-
// ),
-
// ['#<ContainerName2>'] => Array
-
// (
-
// 'divName' => '#<ContainerName2>',
-
// 'url' => '<Menu Callback URL for Ajax response 2>/nojs',
-
// ),
-
// ...
-
//
-
// ['#<ContainerNameN>'] => Array
-
// (
-
// 'divName' => '#<ContainerNameN>',
-
// 'url' => '<Menu Callback URL for Ajax response N>/nojs',
-
// ),
-
// ),
-
// )
-
//
-
//The ContainerName MUST be prefixed by '#' in this array and must match with the Id tag for the
-
//corresponding container in the Form render array.
-
//
-
//The URL must have a suffix of 'nojs' as the last path component.
-
//
-
$ajaxpager_settings = array(
-
'ajaxpagers' => array(
-
'#node_pager' => array(
-
'divName' => '#node_pager',
-
'url' => 'ajaxpager/node_pager/nojs',
-
),
-
'#user_pager' => array(
-
'divName' => '#user_pager',
-
'url' => 'ajaxpager/user_pager/nojs',
-
),
-
),
-
);
-
-
$output['nodes'] = array(
-
'#type' => 'fieldset',
-
'#title' => t('Nodes'),
-
'#collapsible' => TRUE,
-
'#collapsed' => FALSE,
-
);
-
-
$output['nodes']['table'] = array(
-
'#type' => 'container',
-
'#id' => 'node_pager', //Don't prefix with # here.
-
'#attributes' => array('class' => array('table-refreshed')), //Add table-refreshed class as we are rendering
-
//the content on the server side itself
-
);
-
-
$output['nodes']['table']['content'] = array(
-
'#type' => 'markup',
-
'#markup' => ajaxpager_ajax_node_callback(),
-
);
-
-
$output['users'] = array(
-
'#type' => 'fieldset',
-
'#title' => t('Users'),
-
'#collapsible' => TRUE,
-
'#collapsed' => FALSE,
-
);
-
-
$output['users']['table'] = array(
-
'#type' => 'container',
-
'#id' => 'user_pager', //Don't prefix with # here.
-
'#attributes' => array('class' => array('table-refreshed')), //Add table-refreshed class as we are rendering
-
//the content on the server side itself
-
);
-
-
$output['users']['table']['content'] = array(
-
'#type' => 'markup',
-
'#markup' => ajaxpager_ajax_user_callback(),
-
);
-
-
ajaxpager_add_js($ajaxpager_settings); //Add the settings along with other required
-
//JS files into the form.
-
-
return $output;
-
}
In the code above you will see that the functions ajaxpager_ajax_node_callback
and ajaxpager_ajax_user_callback
are used to generate the content for the two containers node_pager
and user_pager
respectively. These functions are also setup as page callbacks for the Ajax response in the hook_menu()
implementation for the module.
This allows these functions to be called from within the module code as well as make a direct call to them through the menu router system. The actual implementation for these functions is provided below.
The callback functions for ajax response
-
<?php
-
-
/**
-
* Function to invoke the Ajax Pager custom command to render the paged table
-
*
-
* Drupal provides a set of Ajax commands that can be used while providing responses
-
* to ajax requests from the client. However, in this case we have created a custom
-
* ajax command on the client side and we ask Drupal to invoke that command.
-
*/
-
function ajaxpager_command_render_table($selector, $data, $method = 'nojs') {
-
//The $method indicates whether it is a response to an ajax request
-
//or a normal call.
-
//
-
if ($method == 'ajax') {
-
//If $method is 'ajax' then it is a response to an ajax request and we
-
//invoke the custom renderTable command on the client side to ensure
-
//that the response is written into the table on the client side using
-
//the selector provided.
-
//
-
if (is_array($data)) {
-
$data = drupal_render($data);
-
}
-
-
//If it is an ajax response, the response structure will need to include
-
//the command name, the selector that should be written into as well as
-
//the data to be written.
-
$commands[] = array(
-
'command' => 'renderTable',
-
'selector' => $selector,
-
'data' => $data,
-
);
-
-
$response = array('#type' => 'ajax', '#commands' => $commands);
-
ajax_deliver($response);
-
}
-
else {
-
//If $method is 'nojs', then it means that javascript is disabled on the
-
//client side and normal HTTP response is desired. In this case, the data
-
//is returned as is to allow rendering through the Form API
-
-
return $data;
-
}
-
}
-
-
-
/**
-
* The page callback used to generate content for the node_pager
-
* container within the form.
-
*
-
* This function is called either directly to populate the markup
-
* direction in the form render array, or to provide an ajax response
-
* to ajax calls made to the 'ajaxpager/node_pager' menu callback.
-
*/
-
function ajaxpager_ajax_node_callback($method = 'nojs') {
-
//The $method is assumed to be 'nojs' to support graceful degradation
-
-
$node_header = array(
-
array('data' => 'Title', 'field' => 'title', 'sort' => 'asc'),
-
array('data' => 'Type', 'field' => 'type'),
-
array('data' => 'Created', 'field' => 'created'),
-
array('data' => 'Published'),
-
);
-
-
//We query the nodes in the system and attach the query extenders
-
//for paging - PagerDefault - and sorting - TableSort - the table.
-
//The query is limited to 10 records per page and sorted by the
-
//$node_header by default.
-
//The query is marked with the element 0, indicating that it is
-
//the first pager on the page.
-
$node_rows = db_select('node', 'n')
-
->condition('status', 1)
-
->extend('PagerDefault')
-
->extend('TableSort')
-
->element(0)
-
->limit(10)
-
->orderByHeader($node_header)
-
->fields ('n')
-
->execute();
-
-
foreach ($node_rows as $node) {
-
$node_data[] = array(
-
$node->title,
-
$node->type,
-
format_date($node->created),
-
$node->status
-
);
-
}
-
-
//If no content has been created, then provide a user-friendly message.
-
if (empty($node_data)) {
-
$node_data[] = array(
-
array('data' => t('No content has been created as yet.'), 'colspan' => count($node_header)),
-
);
-
}
-
-
$node_table = theme('table', array('header' => $node_header, 'rows' => $node_data));
-
$node_table .= theme('pager', array('element' => 0)); //Use the same element which was used in the query
-
-
return ajaxpager_command_render_table('#node_pager', $node_table, $method); //Use the same selector as that used in the form for this container.
-
}
-
-
-
/**
-
* The page callback used to generate content for the user_pager
-
* container within the form.
-
*
-
* This function is called either directly to populate the markup
-
* direction in the form render array, or to provide an ajax response
-
* to ajax calls made to the 'ajaxpager/user_pager' menu callback.
-
*
-
* The function is exactly similar to ajaxpager_ajax_node_callback
-
* and can be generalized to handle different queries.
-
*/
-
function ajaxpager_ajax_user_callback($method = 'nojs') {
-
//The $method is assumed to be 'nojs' to support graceful degradation
-
-
$user_header = array(
-
array('data' => 'Name', 'field' => 'name', 'sort' => 'asc'),
-
array('data' => 'Email', 'field' => 'mail'),
-
array('data' => 'Created', 'field' => 'created'),
-
array('data' => 'Login', 'field' => 'login'),
-
array('data' => 'Status', 'field' => 'status'),
-
);
-
-
//We query the users in the system and attach the query extenders
-
//for paging - PagerDefault - and sorting - TableSort - the table.
-
//The query is limited to 10 records per page and sorted by the
-
//$user_header by default.
-
//The query is marked with the element 1, indicating that it is
-
//the second pager on the page.
-
$user_rows = db_select('users', 'u')
-
->extend('PagerDefault')
-
->extend('TableSort')
-
->element(1)
-
->limit(10)
-
->orderByHeader($user_header)
-
->fields ('u')
-
->execute();
-
-
foreach ($user_rows as $queried_user) {
-
$user_data[] = array(
-
$queried_user->name,
-
$queried_user->mail,
-
format_date($queried_user->created),
-
format_date($queried_user->login),
-
$queried_user->status
-
);
-
}
-
-
//If no content has been created, then provide a user-friendly message.
-
if (empty($user_data)) {
-
$user_data[] = array(
-
array('data' => t('There are no users in this system.'), 'colspan' => count($user_header)),
-
);
-
}
-
-
$user_table = theme('table', array('header' => $user_header, 'rows' => $user_data));
-
$user_table .= theme('pager', array('element' => 1)); //Use the same element which was used in the query
-
-
return ajaxpager_command_render_table('#user_pager', $user_table, $method); //Use the same selector as that used in the form for this container.
-
}
The functions ajaxpager_ajax_node_callback
and ajaxpager_ajax_user_callback
make use of a common function ajaxpager_command_render_table
to return the content they generate; this common function checks whether or not an ajax response is needed and structures the response accordingly. If an ajax response is desired, the function invokes a client-side command called renderTable
to render the content into the right container, as defined by the selector provided in the response. If a normal HTTP response is desired, the data is returned untouched.
The Client-side
On the client-side, we make use of Drupal's powerful Javascript and Ajax framework along with some jQuery magic to complete the ajax cycle.
Here we implement the custom Drupal ajax command renderTable
and pin it on to Drupal's ajax framework. This is the command that is invoked from the server-side code as part of each ajax response. Every time the user clicks on a link to go to a different page, Drupal's ajax framework is invoked, which in turn makes a call to the appropriate menu callback on the server. Once the ajax response is received, the client-side renderTable
command is invoked, which then renders the new content into the container.
The Javascript code
-
(function ($) {
-
-
/**
-
* We store all Javascript functions and objects within the AjaxPager object
-
*/
-
Drupal.AjaxPager = {};
-
-
/**
-
* Drupal Ajax Custom Command to refresh the HTML content of the Context Selector
-
*/
-
Drupal.AjaxPager.renderTable = function(ajax, response, status) {
-
$(response.selector).html(response.data);
-
$(response.selector).addClass('table-refreshed');
-
Drupal.attachBehaviors($(response.selector));
-
};
-
-
/**
-
* Adding a function to the Drupal ajax prototype. This function helps in
-
* initial refresh of the table if it hasn't been rendered from the server
-
* side.
-
*/
-
Drupal.ajax.prototype.initialRefresh = function() {
-
//This function simply takes the ajax options that are already
-
//loaded into the Drupal ajax object and intiates the ajax request
-
var ajaxer = this;
-
-
if(!ajaxer.ajaxing) {
-
try {
-
$.ajax(ajaxer.options);
-
}
-
catch (e) {
-
alert("Error occurred while processing ajax request to " + ajaxer.options.url);
-
return false;
-
}
-
}
-
else {
-
return false;
-
}
-
};
-
-
/**
-
* Adding the custom ajax command into the Drupal.ajax prototype
-
*/
-
Drupal.ajax.prototype.commands.renderTable = Drupal.AjaxPager.renderTable;
-
-
/**
-
* This is the main function that attaches behaviors to all links within the
-
* ajaxed table.
-
*/
-
Drupal.behaviors.ajaxPager = {
-
attach: function(context, settings) {
-
// This function may be call in different contexts
-
// 1. During the initial load of the page with the table already rendered
-
// on the server side
-
// 2. During the initial load of the page with the table not rendered
-
// from the server side
-
// 3. For re-attaching the behaviors after an ajax page request
-
//
-
-
if(context.selector !== undefined) {
-
//This is a ajax page request since there is a context (Scenario 3)
-
-
//Retrieve the URL and other settings from the settings provided at the server
-
//for that particular context.selector
-
div_settings = settings.ajaxpagers[context.selector];
-
load_settings = div_settings;
-
-
if($(context.selector).hasClass('table-refreshed')) {
-
//If the content has already been rendered, only then
-
//we can attach the behaviors to the links
-
-
load_settings.progress = {};
-
load_settings.progress.type = 'throbber';
-
-
i = 0;
-
$(context.selector + ' ul.pager li a')
-
.add(context.selector + ' th a')
-
.once('pager-ajax').each(function() {
-
-
//Select the individual links for the pager as well as
-
//the sorted header links
-
-
load_settings.url = this.href;
-
load_settings.event = 'click tap';
-
-
element_link = $(this);
-
-
//An element id is required for Drupal.ajax but the pager doesn't
-
//provide an id to the pager links. We must manually create them
-
element_id = 'link-' + i++ ;
-
element_link.attr('id', element_id);
-
-
//Add a Drupal.ajax to each link
-
Drupal.ajax[context.selector + '-' + element_id] = new Drupal.ajax(element_id, element_link, load_settings);
-
});
-
}
-
}
-
else {
-
//The context.selector will be undefined only during the initial page load
-
//Subsequent ajax page requests will always have a context.selector
-
-
//In case of the initial page load, we will have to loop through
-
//all available pagers on the page and ensure they are refreshed
-
//and behaviors attached to all links
-
//
-
//The settings.ajaxpagers object will have the list of all
-
//pagers on this page. This settings object is set on the server
-
//side with each page.
-
for (var i in settings.ajaxpagers) {
-
//Looping through all ajaxpagers for this page
-
-
div_settings = settings.ajaxpagers[i];
-
load_settings = div_settings;
-
-
if(!$(div_settings.divName).hasClass('table-refreshed')) {
-
//(Scenario 2)
-
//If the server has not rendered the content, it will not have
-
//the table-refreshed class. In this case we call the initialRefresh
-
//function to make the ajax call and render the content for this
-
//pager
-
-
load_settings.url = Drupal.settings.basePath + '?q=' + div_settings.url; //This should work for both Clean URLs as well as Unclean URLs
-
load_settings.event = 'onload'; //Even will be onload of the body
-
load_settings.keypress = false;
-
load_settings.prevent = false;
-
load_settings.progress = {}; //We don't need a progress indicator in this case
-
-
//By passing a null id and the document body itself as the object, we link the ajax to the document itself
-
Drupal.ajax[div_settings.divName] = new Drupal.ajax(null, $(document.body), load_settings);
-
Drupal.ajax[div_settings.divName].initialRefresh();
-
}
-
else {
-
//(Scenario 1)
-
//If the server has already rendered the content, it will have
-
//the table-refreshed class. In this case we simply
-
//attach the ajax behaviors
-
-
load_settings.progress = {};
-
load_settings.progress.type = 'throbber';
-
-
new_url = '';
-
clean_enabled = false;
-
-
//In this scenario, the server will have loaded the content into the pager
-
//assuming that JavaScript has been disabled. Hence, all URLs will refer
-
//directly to the base page URL and not the specific Ajax callback URL.
-
//
-
//This ensures that in case JavaScript is disabled, the table can still be
-
//paged by refreshing the entire page.
-
//
-
//If this script runs, then JavaScript is enabled on the client. We must
-
//manually change all the URLs to the ajax URLs
-
-
//To do this, we take just one URL using the .first jQuery call
-
//and parse that URL to derive the basepath for the URL
-
//We then use the the Ajax URL provided in the div_settings
-
//and derive the ajax callback to be used for this link
-
$(div_settings.divName + ' ul.pager li a')
-
.add(div_settings.divName + ' th a')
-
.first().each(function() {
-
base_string = $.url(this.href).attr('base'); //The basepath from the URL
-
query_string = $.url(this.href).attr('query'); //The Query String from the URL
-
-
if(query_string.substr(0,2) == 'q=') {
-
//Clean URLs aren't enabled
-
new_url = base_string +'/?q=' + div_settings.url;
-
}
-
else {
-
//Clean URLs are enabled
-
clean_enabled = true;
-
new_url = base_string +'/' + div_settings.url;
-
}
-
});
-
-
//Once the URL callback format has been derived, the same can be applied
-
//to all URLs
-
-
i = 0;
-
$(div_settings.divName + ' ul.pager li a')
-
.add(div_settings.divName + ' th a')
-
.once('pager-ajax').each(function() {
-
elem_query = $.url(this.href).attr('query');
-
-
//The Ajax callback URL format will be different when Clean URLs are enabled
-
//
-
//If Clean URLs are enabled then the querystring will only contain the page,
-
//sort and order
-
//
-
//If Clean URLs aren't enabled the querystring will contain the entire path
-
//itself - everything after the ?q= in the URL
-
load_settings.url = new_url + (clean_enabled ? '?' + elem_query : '&' + elem_query.substr(elem_query.indexOf('&') + 1));
-
load_settings.event = 'click tap';
-
-
element_link = $(this);
-
-
//An element id is required for Drupal.ajax but the pager doesn't
-
//provide an id to the pager links. We must manually create them
-
element_id = 'link-' + i++ ;
-
element_link.attr('id', element_id);
-
-
//Add a Drupal.ajax to each link
-
Drupal.ajax[div_settings.divName + '-' + element_id] = new Drupal.ajax(element_id, element_link, load_settings);
-
});
-
}
-
}
-
}
-
}
-
};
-
-
})(jQuery);
Graceful degradation
What happens if the user has client-side Javascript disabled? Since we need to degrade gracefully in such scenarios, all content generated from the server initially assumes that client-side Javascript is in fact disabled. Hence, all the links in the content — for different pages and for sorting the table using the header — will trigger a full page refresh and not an ajax request.
The Drupal Ajax framework
You will notice that the server side function ajaxpager_command_render_table
delivers an ajax response only if the variable $method
is set to 'ajax'
. You will probably also notice that the method is always set to 'nojs'
and never explicitly changed to 'ajax'
. How will this work then?
The answer to that is in the Drupal Ajax framework. When a URL containing the path segment nojs
is passed into the Drupal Ajax framework, it automatically replaces that path segment to ajax
. This means that a URL such as ajaxpager/node_pager/nojs
will be automatically converted by the framework to ajaxpager/node_pager/ajax
. Since the last segment of the path is passed into the page callback functions as a parameter, the $method
variable in our two callbacks receives a value of 'ajax'
.
Since this conversion of nojs
to ajax
is done by the client-side Drupal Ajax framework, it only happens when client-side Javascript is enabled. Otherwise, the original URL with the nojs
segment continues to be used and the server responds with a full HTTP response.
PagerDefault Query Extender
Another aspect of implementing graceful degradation lies in the query extender PagerDefault. This extender makes use of an attribute called element
which helps us differentiate the various pagers on a single Drupal webpage.
In the above example, I have used the element(0)
for the node_pager
and an element(1)
for the user_pager
. This helps differentiate the two pagers and ensures that even in the absence of an ajax callback, the correct pager gets refreshed.
Limitation
The only limitation that I am aware of with the above approach is to do with the TableSort query extender. Unlike PagerDefault, TableSort does not support elements. This means that if you have two tables on the same webpage and both tables have columns with the same title, then clicking on one will result in both tables getting sorted by the column of that name.
A workaround for this is to perhaps always ensure that the columns in your tables have different names.
Here is an example module that contains all the code that I have described above. You can install this on your Drupal installation to see it working. Do let me know if you found this write up useful. If there are questions, leave a comment and I will do my best to answer.