I’ve been playing with WordPress’ WP_List_Table for a while now, but since the first day the truly missing feature was an AJAX loading. And I finally managed to find time to put some work on it, I couldn’t find any real documentation about we actually do that, apart from a two-years old StackExchange question that doesn’t provide a full solution. So, here’s what I came up with.

Digging through the core

If you’re not the kind of archaeology guy, you may skip to the next part.

When you don’t find any worthy doc about something you’re trying to build on WordPress, a good thing is to search through the source code to find any clue: undocumented functions you may have missed, hooks you never heard about… I came across both. Looking for classes extending WP_List_Table and using a 'ajax' => true parameter, I quickly found /wp-admin/includes/class-wp-themes-list-table.php, and a encouraging line mentioning a _ajax_fetch_list_nonce Nonce field. This nonce lead me to the /wp-admin/includes/class-wp-themes-list-table.php file, and then again a line mentioning a _get_list_table() function that fetches an instance of a WP_List_Table class. That’s great, except that it’s a private function, meaning we can’t use it. Too bad. But, we have a full example of how to use AJAX: we need to use an AJAX action filter to create an instance of our custom WP_List_Table class, and then call the WP_List_Table->ajax_response() method. Yeah, it looks easy now that we know what to do!

Custom AJAX List Table Example

Beware, from here we’ll be using a plugin; all that follows suppose you know at least what you’re doing with WP_List_Table. We’ll implement a custom post type and add a new admin page for testing purpose; I will not detail these parts, I used Matt Van Andel‘s very well documented Custom List Table Example as a starting base and updated it to implement AJAX. You can see the full plugin file on GitHub.

Activate AJAX

The first thing to do is to add a parameter to the WP_List_Table __construct() method, indicating we’re using AJAX on this class:

function __construct() {

	global $status, $page;

	//Set parent defaults
	parent::__construct(
		array(
			//singular name of the listed records
			'singular'	=> 'movie',
			//plural name of the listed records
			'plural'	=> 'movies',
			//does this table support ajax?
			'ajax'		=> true
		)
	);

}

Now you can just forget about it, we will not be using it today. Truth to tell, I’m not even sure about what it really does; I guess it can become handy if you need to call specific actions whether using AJAX or not. I’m interested in any better explanation!

Edit: As Matt detailed in the comments, setting 'ajax' => true makes WP_List_Table automatically call the private _js_vars() method, which adds to the footer a set of useful Javascript variables.

Set order in pagination arguments

If we want to be able to sort tables through AJAX, will need a way to access the ordering values, order and orderby, at any time; fine, we’ll just add them to the class’ _pagination_args:

$this->set_pagination_args(
	array(
		//WE have to calculate the total number of items
		'total_items'	=> $total_items,
		//WE have to determine how many items to show on a page
		'per_page'	=> $per_page,
		//WE have to calculate the total number of pages
		'total_pages'	=> ceil( $total_items / $per_page ),
		// Set ordering values if needed (useful for AJAX)
		'orderby'	=> ! empty( $_REQUEST['orderby'] ) && '' != $_REQUEST['orderby'] ? $_REQUEST['orderby'] : 'title',
		'order'		=> ! empty( $_REQUEST['order'] ) && '' != $_REQUEST['order'] ? $_REQUEST['order'] : 'asc'
	)
);

This action should be put at the end of your WP_List_Table prepare_items() method.

Nonce and order inputs

This time we will add some more code to your class’ display() method; as its name let it figure it is the method that will render the final table. We want to add a Nonce field for security, and a couple of hidden input that will store the order and orderby values, so that we can use them later with jQuery.

function display() {

	wp_nonce_field( 'ajax-custom-list-nonce', '_ajax_custom_list_nonce' );

	echo '<input id="order" type="hidden" name="order" value="' . $this->_pagination_args['order'] . '" />';
	echo '<input id="orderby" type="hidden" name="orderby" value="' . $this->_pagination_args['orderby'] . '" />';

	parent::display();
}

The AJAX Response

This would be the most important part: the ajax_response() method, the very method that will gather and display the update table rows and elements.

The first thing we do is check the nonce, and only then, we prepare the items. The method will return an array in the form of a JSON string containing:

  • column_headers: string containing the table header’s HTML content
  • pagination: array containing top and bottom table navs’ HTML content
  • rows: string containing the requested table rows
  • total_items_i18n: string containing the translated number of items (unused here)
  • total_pages: int containing the number of pages (unused here)
  • total_pages_i18n: string containing the translated number of pages (unused here)

We use output buffering a few times; it may not always be a wise choice to do so, the point here is to avoid rewriting a couple of big methods. We need to call WP_List_Table->pagination() (two times, top and bottom) and WP_List_Table->print_column_headers() (one time), methods that both echo the HTML result instead of return it; for the sake of simplicity I prefer to use output buffering rather than copying the two methods in each class I have only to add a return option.

function ajax_response() {

	check_ajax_referer( 'ajax-custom-list-nonce', '_ajax_custom_list_nonce' );

	$this->prepare_items();

	extract( $this->_args );
	extract( $this->_pagination_args, EXTR_SKIP );

	ob_start();
	if ( ! empty( $_REQUEST['no_placeholder'] ) )
		$this->display_rows();
	else
		$this->display_rows_or_placeholder();
	$rows = ob_get_clean();

	ob_start();
	$this->print_column_headers();
	$headers = ob_get_clean();

	ob_start();
	$this->pagination('top');
	$pagination_top = ob_get_clean();

	ob_start();
	$this->pagination('bottom');
	$pagination_bottom = ob_get_clean();

	$response = array( 'rows' => $rows );
	$response['pagination']['top'] = $pagination_top;
	$response['pagination']['bottom'] = $pagination_bottom;
	$response['column_headers'] = $headers;

	if ( isset( $total_items ) )
		$response['total_items_i18n'] = sprintf( _n( '1 item', '%s items', $total_items ), number_format_i18n( $total_items ) );

	if ( isset( $total_pages ) ) {
		$response['total_pages'] = $total_pages;
		$response['total_pages_i18n'] = number_format_i18n( $total_pages );
	}

	die( json_encode( $response ) );
}

AJAX Callback

Almost done with PHP! All we need to do is handle our hook.

function _ajax_fetch_custom_list_callback() {

	$wp_list_table = new TT_Example_List_Table();
	$wp_list_table->ajax_response();
}
add_action('wp_ajax__ajax_fetch_custom_list', '_ajax_fetch_custom_list_callback');

The jQuery part

That’s the part that will capture links click events, send AJAX calls and handle the HTML update. It’s quite simple if not short:

(function($) {

list = {

	/**
	 * Register our triggers
	 * 
	 * We want to capture clicks on specific links, but also value change in
	 * the pagination input field. The links contain all the information we
	 * need concerning the wanted page number or ordering, so we'll just
	 * parse the URL to extract these variables.
	 * 
	 * The page number input is trickier: it has no URL so we have to find a
	 * way around. We'll use the hidden inputs added in TT_Example_List_Table::display()
	 * to recover the ordering variables, and the default paged input added
	 * automatically by WordPress.
	 */
	init: function() {

		// This will have its utility when dealing with the page number input
		var timer;
		var delay = 500;

		// Pagination links, sortable link
		$('.tablenav-pages a, .manage-column.sortable a, .manage-column.sorted a').on('click', function(e) {
			// We don't want to actually follow these links
			e.preventDefault();
			// Simple way: use the URL to extract our needed variables
			var query = this.search.substring( 1 );
			
			var data = {
				paged: list.__query( query, 'paged' ) || '1',
				order: list.__query( query, 'order' ) || 'asc',
				orderby: list.__query( query, 'orderby' ) || 'title'
			};
			list.update( data );
		});

		// Page number input
		$('input[name=paged]').on('keyup', function(e) {

			// If user hit enter, we don't want to submit the form
			// We don't preventDefault() for all keys because it would
			// also prevent to get the page number!
			if ( 13 == e.which )
				e.preventDefault();

			// This time we fetch the variables in inputs
			var data = {
				paged: parseInt( $('input[name=paged]').val() ) || '1',
				order: $('input[name=order]').val() || 'asc',
				orderby: $('input[name=orderby]').val() || 'title'
			};

			// Now the timer comes to use: we wait half a second after
			// the user stopped typing to actually send the call. If
			// we don't, the keyup event will trigger instantly and
			// thus may cause duplicate calls before sending the intended
			// value
			window.clearTimeout( timer );
			timer = window.setTimeout(function() {
				list.update( data );
			}, delay);
		});
	},

	/** AJAX call
	 * 
	 * Send the call and replace table parts with updated version!
	 * 
	 * @param    object    data The data to pass through AJAX
	 */
	update: function( data ) {
		$.ajax({
			// /wp-admin/admin-ajax.php
			url: ajaxurl,
			// Add action and nonce to our collected data
			data: $.extend(
				{
					_ajax_custom_list_nonce: $('#_ajax_custom_list_nonce').val(),
					action: '_ajax_fetch_custom_list',
				},
				data
			),
			// Handle the successful result
			success: function( response ) {

				// WP_List_Table::ajax_response() returns json
				var response = $.parseJSON( response );

				// Add the requested rows
				if ( response.rows.length )
					$('#the-list').html( response.rows );
				// Update column headers for sorting
				if ( response.column_headers.length )
					$('thead tr, tfoot tr').html( response.column_headers );
				// Update pagination for navigation
				if ( response.pagination.bottom.length )
					$('.tablenav.top .tablenav-pages').html( $(response.pagination.top).html() );
				if ( response.pagination.top.length )
					$('.tablenav.bottom .tablenav-pages').html( $(response.pagination.bottom).html() );

				// Init back our event handlers
				list.init();
			}
		});
	},

	/**
	 * Filter the URL Query to extract variables
	 * 
	 * @see http://css-tricks.com/snippets/javascript/get-url-variables/
	 * 
	 * @param    string    query The URL query part containing the variables
	 * @param    string    variable Name of the variable we want to get
	 * 
	 * @return   string|boolean The variable value if available, false else.
	 */
	__query: function( query, variable ) {

		var vars = query.split("&");
		for ( var i = 0; i <vars.length; i++ ) {
			var pair = vars[ i ].split("=");
			if ( pair[0] == variable )
				return pair[1];
		}
		return false;
	},
}

// Show time!
list.init();

})(jQuery);

And that’s about it

Voilà! You should now have nice AJAX loading List Table in your plugins and themes. This is a very basic example and should serve as a base to build extended List Tables, suggestions and advice are welcomed! A coming-soon feature on this will be an History update to change URL and keep track of the visited page. Until then, you can download the latest dev version on GitHub, fork on GitHub, leave a comment, buy me a beer… Your call!

Photo: WordPress Day by Andrew Abogado

Publié par Charlie

Être humain depuis 1986, développeur web, designer et photographe, je code pour le Web depuis 2000 et pour WordPress depuis 2008. Aventure, Histoire, sciences, musique, café ou personnages forts en caractère et dotés d'un nez en tout point remarquable sont parmi mes passions les plus dévorantes. Indépendant depuis 2010 je travaille avec des gens formidables dans le monde entier, de la Californie à l'Europe en passant par l'Australie et l'Asie. D'autres détails croustillants ?

Rejoindre la conversation

5 commentaires

  1. Hey Charlie,

    What a great writeup! Be sure you link to it from the appropriate/relevant WordPress Codex pages so that others can find it easily.

    A quick note about setting 'ajax' => true in your constructor…

    When the ‘ajax’ argument is set to true, WP_List_Table will automatically call it’s private _js_vars() method. This method then (again, automatically) adds a tag to your footer that makes a number of handy variables available to you as a JSON object named list_args.

    This includes (using hypothetical info for WordPress’s built-in Page post type)…

    list_args.class
    The child class name. e.g. « WP_Posts_List_Table »

    list_args.screen.id
    The child class name. e.g. « edit-page »

    list_args.screen.base
    The child class name. e.g. « edit »

    1. Hi Matt,

      Thanks for the note, I updated my post accordingly. I should have noticed _js_vars() wasn’t called out of nowhere!

      I think I’ll share this post on the Codex indeed, it just lacks a bunch of edits like History management or the search input capture. More fun coming!

    1. Thanks Christian! Don’t know if the core should feature this, but any way to make it more accessible by plugins would be great. Not gonna happen I guess, as WP_List_Table is supposed to be private and should not be used by developers…

  2. Charlie,

    That’s a very contentious issue and an opinion originating exclusively from Andrew Nacin (despite the fact that WP_List_Table was intended to be an API from the moment it was proposed). The WP_List_Table class is already extensively used by developers as the foundation for generating list tables, and that is exactly as it should be since it creates consistent, semantic list tables.

    The fear of calling it an API stems from an expectation that it is subject to change as WordPress core is extremely conservative when it comes to the long-term consistency of it’s code base (which is why much of core is still procedural instead of properly OO).

    Anyone afraid of the class’s non-canonical-API status can make a copy of the class, rename it (e.g. MY_List_Table), and extend that one instead of using the core version. In either case, this is a perfectly viable tutorial and a solid of example of best practice when it comes to list tables and AJAX.

Laisser un commentaire

Répondre à Matt van Andel Annuler la réponse

Votre adresse e-mail ne sera pas publiée.

*