Tableau d'administration WordPress avec AJAX : WP_LIst_Table

WP_List_Table et AJAX : des tableaux WordPress réactifs

Dans cette deuxième partie de l’article Tableau WordPress intégré et WP_List_Table, nous nous penchons sur l’implémentation d’AJAX dans notre classe de tableau WordPress, afin de proposer une expérience utilisateur fluidifiée, mais en en conservant les fonctionalités principales, et notament sa pagination.

Je me suis basé sur l’article de Charlie Merland A way to implement Ajax in wp_list_Table et, comme bien souvent, sur la page du codex WordPress « AJAX in plugins », relatif à l’implémentation d’AJAX dans nos plugins.

Je précise quand même, en bon webmaster, que cet article s’adresse surtout aux webmestres ou développeurs WordPress désirant obtenir quelques éclaircissements sur les possibilités d’implémentation de AJAX dans les tableaux d’administration du CMS.
Si vous ne visualisez pas bien les éléments de la classe WP_List_Table, relisez rapidement l’article précédent Tableau WordPress intégré et WP_List_Table.

Recherche d’une solution d’implémentation AJAX : analyse de la méthode display()

Charlie pointe dans son article une « ligne encourageante mentionnant un champ nonce _ajax_fetch_list_nonce ».
OK, cela semble grandement mystérieux… et sacrément dépourvu d’explications. Allons voir ça.
En fait, la methode display() de la classe WP_Themes_List_Table propose un champ « input » de type « hidden » permettant de récupérer un nonce basé sur la classe pour sécuriser les échanges.

OK. Je pense que l’idée de l’article était de monter qu’à cet endroit, nous pouvions nous aussi insérer notre nonce, et c’est ce que nons allons faire. Mais bien sûr, nous lui attribuons le nom que nous voulons…
En revanche, Charlie insère deux autres champs cachés pour réinjecter les valeurs des variables de pagination « orderby » et « order », mais nous allons voir que, finalement, cela se révèle inutile puisque ces valeurs sont passées par défaut aux liens de la colonne primaire.
Nous surchargeons donc la méthode display(), mais uniquement pour insérer notre « nonce » de sécurité avec un champ de type « hidden » :


public function display()
{
	$nce = wp_create_nonce( 'jst_ajx_nce' );
	echo '';
	parent::display();
}

On rappelle ici la méthode parente pour éviter d’avoir à tout réécrire.

Une réponse AJAX pour réapprovisionner le tableau

Un coup d’œil sur la méthode de la classe mère nous renseigne sur son rôle : elle récupère les lignes du tableau WordPress avec une temporisation de sortie (grâce aux fonction PHP ob_start() et ob_get_clean()), ainsi que le nombre des pages et d’items en base de données.
Ce que Charlie nous propose est en fait de récupérer, en plus, les blocs d’en-têtes et de pagination (haut et bas) pour faciliter leur réinsertion via JavaScript (jQuery dans notre cas).
À titre indicatif, récupérer les blocs d’en-têtes du tableau permet principalement de mettre à jour facilement l’indicateur de tri correspondant à l’état de la colonne triée. Il paraît un peu surdimensionné de récupérer tout le bloc d’en-têtes pour ce visuel, mais j’avoue que là, c’est pratique.
Dans notre méthode ajax_response(), nous testons notre nonce pour valider la provenance de notre requête.
Nous traitons ensuite la requête avec un appel à prepare_items().
Nous utilisons la temporisation de sortie pour récupérer les 4 éléments dont nous avons besoin pour reconstruire notre tableau WordPress d’administration.
Nous réinjectons à l’identique dans la réponse, les éléments de pagination convertis (localisés). Un simple copier-coller de ce qu’on trouve dans la méthode parente fait l’affaire.


public function ajax_response()
{
	if( !wp_verify_nonce( $_REQUEST['nce'], 'jst_ajx_nce' ) )
	{
		return false;
	}
	$this->prepare_items();
	ob_start();
	$this->print_column_headers();
	$headers = ob_get_clean();
	ob_start();
	$this->display_rows_or_placeholder();
	$rows = ob_get_clean();
	ob_start();
	echo $this->pagination( 'top' );
	$tablenav_top = ob_get_clean();
	ob_start();
	echo $this->pagination( 'bottom' );
	$tablenav_bottom = ob_get_clean();
	
	$resp = [
		'rows'			=> $rows,
		'headers'		=> $headers,
		'pagination'	=> [
			'top'		=> $tablenav_top,
			'bottom'	=> $tablenav_bottom
		]
	];
	extract( $this->_pagination_args, EXTR_SKIP );
	if( isset( $total_items ) )
	{
		$resp[ 'total_items_i18n' ] = sprintf( 
				_n( '% item', '% items', $total_items ), 
				number_format_i18n( $total_items ) );
	}
	if( isset( $total_pages ) )
	{
		$resp[ 'total_pages' ] 		= $total_pages;
		$resp[ 'total_pages_i18n' ]	= number_format_i18n( $total_pages );
	}
	return $resp;
}

La récupération des éléments de pagination est importante, notamment dans le cas d’une suppression de lignes du tableau.

Inclusion du script AJAX et fonctions WordPress

WordPress nous fournit deux outils simples et efficaces pour implémenter proprement notre script JavaScript.
Nous nous servons, dans un premier temps, de la construction de notre page d’administration pour définir un hook ciblant précisément cette dernière, inclure notre fichier JavaScript et lui passer dynamiquement les paramètres dont il a besoin.
Nous ajoutons à la classe de notre plugin un premier hook sur admin_menu :


	add_action( "admin_menu", "add_pages" );

ainsi qu’une méthode add_pages(), son callback, chargée de créer la page d’administration.
La méthode WordPress add_menu_page() renverra un identifiant utile à la définition du second hook pour insérer notre JavaScript :


public function add_pages()
{
	$hook = add_menu_page(
		...
	);
	add_action( 'admin_print_scripts-'.$hook, [ $this, 'ajax_scripts' ] );
}

Nous utilisons ensuite le callback de « admin_print_script-{ID} » pour injecter notre traitement jQuery…


public function ajax_scripts()
{
	wp_enqueue_script( 
		'jst-ajax', plugin_dir_url( __FILE__ ).'/js/jst-ajax.js', 
		array( 'jquery' ), 
		true
	);

Enfin, au même endroit, nous proposons une méthode propre et dynamique de transmission de données au script en nous servant de la fonctionnalité de localisation de WordPress : wp_localize_script().
Nous calculons l’URL du fichier de prise en charge AJAX en fonction du protocole (pratique s’il vient à changer)…
Nous transmettons l’action sur laquelle nous réalisons notre hook (il est quand même préférable de ne pas le nomer « action », je le garde ainsi par souci de lisibilité) :


	$proto = isset( $_SERVER[ 'HTTPS' ] ) ? 'https' : 'http';
	$URL = admin_url( 'admin-ajax.php', $proto );
	
	$params = [
		'action' 	=>  'my_action', // Mais pourquois 'RAOUL' ? … parce que !
		'str'		=> 'RAOUL !',
		'URL'		=> $URL
	];
	wp_localize_script( 'jst-ajax', 'jst_ajax_prms', $params );
}

Les données transmises seront accessibles dans le script via la variable globale « jst_ajax_prms ».

Récupération de la requête : AJAX handler

Après avoir défini un hook longuement recherché et tout-à-fait personnel… Nous accrochons sur l’action correspondante pour intercepter les requêtes AJAX vers la zone d’administration :


	add_action( "wp_ajax_my_action", [ $this, 'ajaxhandler' ] );

Bon, je le fais dans le constructeur du plugin (pas celui de WP_List_Table). Je sais pertinemment que l’heure est à « l’industrialisation du développement WordPress » et que je ne devrais peut-être pas… mais vu l’envergure du projet, on me le pardonnera.

Prise en charge de la requête et réponse AJAX

C’est ici que devient utile notre singleton.
En l’invoquant, nous allons récupérer notre instance de classe depuis le callback de « wp_ajax_my_action », et appeler depuis cette dernière notre méthode surchargée ajax_response().
Je vous rappelle qu’elle commence par un appel à prepare_items(), qui récupère depuis $_REQUEST toutes les données transmises… y compris lorsqu’elles le sont via AJAX.


public function ajaxhandler()
{
	$this->list = CRM_Table::get_instance();
	echo json_encode( $this->list->ajax_response() );
	die();
}

Notre méthode chargée de prendre en charge la requête AJAX renvoie directement le résultat de notre fonction surchargée, ajax_response().

Reconstruction du tableau WordPress avec la réponse AJAX

La problématique est simple. Nous ne souhaitons reconstruire que les parties impactées par la requête AJAX précédemment émise.
L’exemple se concentre d’abord sur le cas de la pagination, puisqu’elle est potentiellement réinitialisée après les actions effectuées sur le tableau. Je vous livre ensuite un moyen de traiter les actions individuelles des lignes du tableau.
Charlie nous propose un objet littéral comprenant 3 méthodes. Il l’appelle « list », je préfère ici « process ».
J’en rajoute une quatrième pour clarifier le parsing d’URL.
La première permet récupérer le numéro de page demandée ainsi que les critères orderby et order, à l’écoute des évènements « click » sur les liens de colonnes et le champ input des blocs de pagination. Nous allons y ajouter une prise en charge des liens d’actions de ligne (« row actions »).
La deuxième découpe l’URL de l’attribut « href » des liens cliqués pour en extraire la chaîne de requête.
La troisième méthode va parser la query string des liens trouvés pour retourner la valeur des paramètres recherchés : paged, orderby, order ou post.
La quatrième fonction est chargée d’effectuer la requête AJAX, de récupérer la réponse et de recomposer le tableau WordPress (du moins, remplir les parties importantes).

Initialisation du script client

Occupons-nous de notre fichier JavaScript « jst-ajax.js ».
De façon très commune, autant pour éviter les conflits sur la variable globale « $ », que la pollution du contexte global, nous encapsulons notre script dans une fonction à laquelle on passe immédiatement l’objet jQuery.
La fonction globale définit un objet initialisé avec une méthode d’écoute des évènements/actions des utilisateurs init(), et appelle immédiatement cette méthode.


(function($){
	var process = {
		tempProcessData : {},
		init : function(){}
	};
	window.onload = function(){
		process.init();
	};
}( jQuery );

Pour en savoir davantage sur l’émulation de la POO en JavaScript, lisez cet article de MDN sur JavaScript orienté objet.

Écoute des évènements utilisateur

On s’intéresse donc ensuite à l’interception des actions utilisateur au sein de la méthode « init » de notre objet.
Je vais faire quelques changements utiles par rapport au script original.
D’abord, nous écoutons les évènements « clicks » depuis d’objet document, ce qui nous permet de conserver l’écouteur (le listener) actif, pour les actions répétées, alors que l’utilisation de :


$('.cible').on( 'click', function({...});

… « bug » dès la seconde action puisque la page n’est pas rechargée :

Votre devis en 48 H chrono !

Demandez à être rappelé !

Nous préciserons ensemble votre projet
de vive voix 


$( document ).on('click', '.manage-column.sortable a, .manage-column.sorted a', function(e){
	e.preventDefault()

L’idée ici est de parcourir l’URL des liens cliqués avec une méthode de parsing que nous allons créer, queryString(), pour récupérer les 3 paramètres importants pour notre pagination: « paged », « orderby » et « order. On encapsule ensuite ces données dans un littéral, pour appeler la fonction qui sera chargée de l’émission et la réception de la requête AJAX :


	var data = {
		'paged'		: process.__query( process.queryString( $(this) ), 'paged' ),
		'orderby'	: process.__query( process.queryString( $(this) ), 'orderby' ),
		'order'		: process.__query( process.queryString( $(this) ), 'order' ),

	};
	process.update( data );
});

On s’occupe ensuite des liens du bloc de pagination (toujours dans notre méthode init()).
Ils ne possèdent aucun des paramètres « orderby » ni « order » avant qu’un tri ai été effectué, mais ils les récupèrent après le premier clic sur l’une des colonnes triables.
La colonne primaire, quant à elle, possède toujours des liens intégrant ces paramètres de pagination.
Il nous faut donc un fallback sur cette colonne primaire pour le premier évènement « click » sur le bloc de pagination :


$(document).on( 'click', '.pagination-links a', function(e){
	e.preventDefault();
	var orderby = ( orderby = process.__query( process.queryString( $(this) ), 'orderby' ) ) ? 
	orderby : 
	process.__query( process.queryString( $('.column-primary a') ), 'orderby' );
			
	var order = ( order = process.__query( process.queryString( $(this), 'order' ) ) ) ?
	order : 
	process.__query( process.queryString( $('.column-primary a') ), 'order' );				
	var data = {
		'paged'		: process.__query( process.queryString( $( this ) ), 'paged' ),
		'orderby'	: orderby,
		'order'		: order
	};
	process.update( data );
});

Enfin, on se penche sur le champ <input> texte du bloc de pagination.
Ici encore Charlie nous propose une solution consistant à supprimer l’écoupe de la touche « ENTER » et de créer un timer avec setTimout() pour répondre aux entrées utilisateur sur ce champ.
C’est un choix. Le mien tend à conserver l’action sur la touche « ENTER ».
Je crée donc deux écouteurs d’évènements :

  • un pour keypress (qui intercepte l’action sur la touche ENTER et lance le processus AJAX avec un objet prédéfini « tempProcessData » ;
  • un autre pour l’évènement « keyup » chargé de récupérer le numéro de la page entré par l’utilisateur et les éléments depagination disponibles.

Je commence donc par créer ce littéral temporaire au niveau de mon objet principal :


var process = {
	tempProcessData : {},
	ini : function(){…

Ensuite, j’intercepte les entrées utilisateur, je cherche les parmètres « orderby » et « order » dans les liens adjacents et me reporte sur la colonne primaire si nécessaire. J’hydrate enfin ma variable temporaire avec ces données :


$(document).on( 'keyup','input[name=paged]', function(e){
	var orderby = 
	( orderby = process.__query( process.queryString( $('.tablenav.top .pagination-links a:eq(0)'), 'order' ) ) ) ? 
		orderby : 
		process.__query( process.queryString( $('.column-primary a') ), 'orderby' );
	var order = 
	( order = process.__query( process.queryString( $('.tablenav.top .pagination-links a:eq(0)'), 'order' ) ) ) ? 
		order : 
		process.__query( process.queryString( $('.column-primary a') ), 'order' );
	process.tempProcessData = {
		paged 	: parseInt( $(this).val() ) || 1,
		order 	: order,
		orderby : orderby
	};			
});

Puis, je prends en charge l’évènement « keypress » pour la touche « ENTER » et lance, si la une laveur « paged » y est trouvée, le procesus AJAX avec objet littéral précedent (test succinct, mais suffisant) :


$(document).on( 'keypress', 'input[name=paged]', function(e){
	if( e.which == 13 ){
		e.preventDefault();
		console.log( process.tempProcessData.paged );
		if( process.tempProcessData.paged ){
			console.log( process.tempProcessData );
			process.update( process.tempProcessData );
		}
		return false;
	}
});

Prétraitement des actions individuelles « row actions »

Ça c’est le mini bonus.
La problématique est la suivante. Nous avons, pour chaque ligne du tableau WordPress, 2 actions potentielles, entraînant des traitements AJAX (vous pourriez en avoir davantage, cela dépend de vous).
La valeur de ces actions dans les URL est assignée au paramètre « action » ; or ce paramètre de requête ($_REQUEST) est utilisé par notre hook pour intercepter les appels AJAX vers la page d’administration et déclencher le callback de traitement serveur.
De plus, notre méthode process_action() attend actuellement une valeur quelconque, renvoyée par $this->current_action(), et bien évidemment, l’action correspondant à notre hook n’est dans ce cas pas pertinente.
La solution nous est fournie en partie par le composant des actions groupées (bulk actions). Et oui, il en existe deux, et le module inférieur (en bas de tableau) nous offre un champ <select> nommé action2… nous allons nous accaparer temporairement cette action.
La méthode de current_action() de la class WP_List_Table recherche en priorité $_REQUEST[ ‘action’ ], qui ne nous intéresse pas. Nous allons donc devoir modifier notre méthode process_action() pour aller chercher ce paramètre « action2 » nous-mêmes, si l’action présente correspond à notre hook AJAX :


public function process_action()
{
	if( !isset( $_REQUEST[ 'post' ] ) )
	{
		return false;
	}
	$current_action = ( FALSE !== $this->current_action() || 'my_action' != $this->current_action() ) ? 
	$this->current_action() : 
		( isset( $_REQUEST[ 'action2' ] ) 
		&& is_array( $_REQUEST[ 'action2' ], [ 'delete', 'publish' ] ) ) ? 
		$_REQUEST[ 'action2' ] : 
		false;
	
	if( FALSE ===  $current_action )
	{
		return 'no action found';
	}
	...

Et voilà ! Notre méthode vérifie bien la validité de tous nos paramètres et de l’action attendue.
Elle et déclenche ensuite le processus correspondant sur la base de données ou, alternativement, un retour de fonction (aucune action n’est alors exécutée).
Du côté JavaScript, on récupère l’action correspondant au lien cliqué, l’ID du post concerné et les paramètres « order » et « orderby » issus de la colonne triée ou de la colonne primaire. On lance ensuite normalement le processus.
Ah au fait ! Si comme moi vous avez inséré un lien « preview » (pourquoi pas ?) parmi vos « row actions », vous pouvez tester qu’il ne s’agit pas de ce dernier avant de lancer le processus :


$(document).on( 'click', '.row-actions a', function(e){
	if( !process.__query( process.queryString( $(this) ), 'preview' ) ){
		e.preventDefault();
		var deletenonce = process.__query( process.queryString( $(this) ), 'jstdelnce' );
		var publishnonce = process.__query( process.queryString( $(this) ), 'jstpubnce' );
		var post = process.__query( process.queryString( $(this) ), 'post' );
		var orderby = ( !$('.sorted a').length ) ? 
			process.__query( process.queryString( $('.column-primary a') ), 'orderby' ) :
			process.__query( process.queryString( $('.sorted a:eq(0)') ), 'orderby' );
		var order = ( !$('.sorted a').length ) ? 
			process.__query( process.queryString( $('.column-primary a') ), 'order' ) :
			process.__query( process.queryString( $('.sorted a:eq(0)') ), 'order' );
		var data = {
			orderby	: orderby,
			order	: order,
			post 	: post
		};
		if( deletenonce ){
			data.jstdelnce = deletenonce;
			data.action2 = 'delete';
		}
		else if( publishnonce ){
			data.jstpubnce = publishnonce;
			data.action2 = 'publish';
		}
		process.update( data );
	}
}); 

Découpe de la chaîne de requête

Par souci de lisibilité, je crée une méthode queryString() servant à extraire la query sring (chaîne de requête) des liens cliqués.
Charly passe par la propriété « vanilla » search du lien, pour retouner cette chaîne depuis le point d’interrogation « ? ».
Pour rester cohérent avec le contexte jQuery, j’utilise la méthode .attr( ‘href’ ), mais l’utilisation de la propriété « search » des liens hypertextes est une méthode cool 😉 :


	queryString : function( link ){
		return link.attr( 'href' ).split( '?' )[1];
	}

Parsing de la chaîne de requête : split & boucle for, in

La méthode prend en argument la chaîne elle-même et le paramètre recherché.
On découpe la chaîne avec un split(), afin de récupérer les paires clé-valeur, délimitées par « & ».
On renvoie la valeur lorsque la clé correspond… simple :


__query	: function( queryStr, param ){
	var pairs = queryStr.split( "&" );
	for( var i=0; i< pairs.length; i++ ){
		var pair = pairs[i].split( "=" );
		console.log( pair );
		var k = pair[0], v = pair[1];
		if( param == k ) return v;
	}
	return false;
}

Requête et réponse AJAX pour un tableau réactif

La méthode update reçoit donc un littéral comprenant les paramètres de pagination. On y ajoute la valeur du champ utilisé pour le nonce WordPress et on utilise la méthode jQuery $.ajax() pour émettre notre requête et traiter la réponse asynchrone du serveur.
Comme nous l’avons vu, nous nous sommes contentés de retourner des blocs distincts d’informations, formatés en JSON, depuis ajax_response().
Nous parsons la réponse avec la méthode parse() de l’objet JSON, et nous testons la présence de chaque bloc HTML au sein de l’objet récupéré.
Vu que nous attendons des blocs complets de pagination, nous remplaçons purement et simplement ceux-ci.
Les en-têtes étant contenus dans une ligne de tableau HTML <tr>, nous la vidons avant d’y insérer les cellules HTML <th> reçues.
Même chose pour les lignes de données, qui sont contenues dans un <tbody> ayant pour ID « the_list », que nous vidons à son tour pour l’hydrater avec les nouvelles data :


update  : function( data ){

	data.action = jst_ajax_prms.action;
	data.nce = $( '#jst_ajnce').val();

	$.ajax({

		URL 		: jst_ajax_prms.url,
		data 		: data,
		success 	: function( resp ){
			
			resp = JSON.parse( resp );
			console.log( resp );
			
			if( resp.pagination && resp.pagination.top.length ){
				
				$(".tablenav.top .tablenav-pages").replaceWith( $(resp.pagination.top) );

			}
			if( resp.headers && resp.headers.length ){
				
				$("thead tr").empty().html( $(resp.headers) );
				$("tfoot tr").empty().html( $(resp.headers) );
			}
			if( resp.rows && resp.rows.length ){
				$('#the-list').empty().html( $(resp.rows) );
			}
			if( resp.pagination && resp.pagination.bottom.length ){
					
				$("tablenav.bottom .tablenav-pages").replaceWith( $(resp.pagination.bottom) );
			}
		}
});

Bon et bien on a fait le tour… c’est vrai que ça a l’air un peu énorme comme ça, pour un simple traitement AJAX, mais vous enlevez mes explications et les fichiers deviennent tout à fait lisibles… Mais si, je vous jure !!

Tableau WordPress et AJAX : une administration fluidifiée

L’expérience utilisateur ne concerne pas que le frontend.
Nous avons démontré qu’en utilisant pleinement les ressources de WP_List_Table, nous pouvons offrir à notre tableau WordPress des fonctionnalités riches et quasi illimitées, en conservant une intégration responsive et cohérente avec l’écosytème du backend WordPress.
Enfin, AJAX étant prévu dans WP_List_Table comme une surcouche, nous n’avons bien sûr pas à nous soucier d’un fallback en cas de non-activation de JavaScript au niveau client : tout est déjà présent.

Bon et bien si après ça vous avez besoin d’aide pour intégrer vos données à votre site web, n’hésitez à venir voir mes offres de création de site et maintenance web ;-)… mais là normalement, vous êtes parés !!