Un formulaire de recherche multi-critères

Un formulaire de recherche multi-critères, ou recherche avancée, est un outil qui se distingue du module natif de WordPress en permettant à un utilisateur d’utiliser des options de recherche additionnelles et ainsi d’obtenir des résultats plus précis.

L’objet de ce tutoriel est de voir ensemble comment il est possible de créer un tel formulaire dans WordPress.

La recherche multi-critères de WordPress
Comment concevoir un module de recherche multi-critères sur WordPress

Comment fonctionne la recherche dans WordPress ?

Je pense qu’avant de voir comment faire évoluer les outils de recherche fournis par le CMS, il est intéressant de faire un rappel sur le fonctionnement de la recherche native.

Lorsqu’on parle de la « recherche par défaut de WordPress », on évoque trois choses :

Le formulaire de recherche

Si l’on suit l’expérience de l’utilisateur, la première chose que l’on rencontre est un formulaire. Celui-ci est simple, composé d’un champ libre dont l’attribut name est s. Comme il peut se trouver dans de nombreux gabarits, il est d’usage de l’appeler via la fonction get_search_form(). Un code html par défaut sera renvoyé par WordPress a cet endroit.

Pour personnaliser ce formulaire il est possible de créer un fichier searchform.php à la racine du thème. La seule chose nécessaire pour le faire fonctionner est qu’il envoie, lors de sa soumission, une variable s au CMS. Il faut donc qu’un des champs du formulaire ai cet attribut.

Les méthodes de la WP_Query

Lorsque WordPress charge une page, il exécute la méthode WP_Query::query() dont le rôle va être de récupérer toutes les variables disponibles pour déterminer le contenu à afficher. Si la variable s est présente, WordPress va comprendre que le contenu qui lui est demandé est une liste de résultats de recherche.

À partir de là il va successivement procéder à plusieurs actions :

  1. Découper la requête de recherche en termes et supprimer les mots communs. Les développeurs peuvent d’ailleurs filtrer les chaînes à exclure grâce au hook wp_search_stopwords.
    1. Si ce résultat n’est pas significatif, trop long, ou que la WP_Query a un paramètre sentence dont la valeur n’est pas nulle, il va comprendre que l’on recherche une expression exacte…
    2. … sinon il comprend que l’on recherche un ou plusieurs de ces termes.
  2. Il va rechercher ces termes dans les post_title et post_content de tous les types de contenus dont la propriété exclude_from_search est égal à false. Par défaut il va faire une recherche de type LIKE, c’est à dire que les mots seront trouvés même s’ils sont inclus dans d’autres chaînes de caractères. Il est possible d’éviter cela en poussant la variable exact avec une valeur non nulle dans la WP_query.
  3. Si la recherche était une expression de plusieurs mots, et qu’aucun ordre particulier n’est spécifié dans la requête, alors il va organiser les résultats par pertinence :
    1. En premier les résultats dont la chaîne recherchée se trouve exactement dans le titre ;
    2. ensuite les résultats dont les termes demandés sont tous contenus dans le titre ;
    3. puis les contenus dont le titre contient certains mots recherchés ;
    4. viennent après les résultats qui contiennent la chaîne exacte dans leur contenu ;
    5. et pour finir les contenus dont certains termes se retrouvent dans le post_content.

Voilà donc comment procède WordPress pour trouver et organiser les contenus à afficher lorsqu’un paramètre s est poussé dans l’URL. Nous avons bien sûr la possibilité d’intervenir à différents niveaux, et nous en reparlerons plus loin.

Le template d’affichage des résultats de recherche

Pour afficher les différents contenus correspondant à notre requête, le CMS va sélectionner un template adapté. Il va regarder s’il dispose d’un gabarit search.php, et si ce n’est pas le cas il utilisera index.php.

Quelques fonctions sont très utiles pour donner à l’utilisateur des informations sur sa recherche :

Une pratique courante est de mettre en emphase les occurrences des termes recherchés dans la liste des résultats. Pour ce faire il faut filtrer les titres et les extraits pour wrapper ces termes dans des balises mark que l’on pourra ensuite styler en CSS.

<?php
add_filter( 'the_title', 'willy_emphase_search_words' );
add_filter( 'the_excerpt', 'willy_emphase_search_words' );
function willy_emphase_search_words( $content ) {
    if ( is_search() 
      && in_the_loop() 
      && is_main_query() ) {
        if ( preg_match_all( '/".*?("|$)|((?<=[\t ",+])|^)[^\t ",+]+/', get_search_query(), $matches ) ) {
            $stopwords = explode( ',', _x( 'about,an,are,as,at,be,by,com,for,from,how,in,is,it,of,on,or,that,the,this,to,was,what,when,where,who,will,with,www',
            'Comma-separated list of search stopwords in your language' ) );
            $terms = array_diff( $matches[0], $stopwords );
            $content = str_ireplace( $terms, array_map( 'willy_add_mark', $terms ), $content );
        }
    }
    return $content;
}

function willy_add_mark( $elem ) {
    return '<mark>' . $elem . '</mark>';
}

Nous venons de faire un rapide récapitulatif du processus habituel d’une recherche WordPress. Je pense qu’il est essentiel de comprendre ce cheminement afin de pouvoir l’altérer.

Notre moteur de recherche multi-critères

Pour la suite du tutoriel, je vous propose de prendre un exemple qui nous servira d’illustration sur la mise en place de ce système.

Dans ma pratique de développeur WordPress, j’ai souvent besoin de concevoir des formulaires de recherche multi-critères pour les sites d’agences immobilières. Sur ce genre de sites, les utilisateurs ont besoin de sélectionner des logements non pas par mots-clés mais par critères.

Logement sera notre type de contenu personnalisé, et nos critères seront liés aux propriétés de ces contenus. Dressons en une liste :

Le choix d’utiliser des métadonnées pour les aménagements peut paraitre étrange dans le sens où l’on aurait pu opter pour une taxonomie. Ce choix découle du fait que l’agent immobilier ne sait pas systématiquement si un bien dispose de tel ou tel aménagement. L’intérêt des utilisateurs – qu’ils soient administrateurs ou visiteurs – est de récolter un maximum de résultats pouvant correspondre à leur recherche . Par exemple, lors d’une recherche avec l’équipement piscine, il faudra retourner les logements dont on sait qu’ils disposent d’une piscine ainsi que ceux dont on ignore s’ils disposent d’une piscine. En d’autre terme il faudra juste exclure ceux dont est sûr qu’ils n’ont pas de piscine.

Pour mettre en place notre module de recherche à facette nous allons intervenir à chacune de ces 3 étapes :

  1. Nous allons ajouter des champs au formulaire afin de passer plusieurs « conditions » à l’instance de WP_Query qui va traiter cette requête
  2. … puis modifier les arguments de la WP_Query en fonction de ces nouvelles instructions de recherche…
  3. … et enfin voir comment servir un template alternatif pour cette recherche personnalisée.

Un formulaire sur mesure pour pousser nos variables dans la recherche

Débutons par la conception du formulaire de recherche !

Il existe un filtre pour appliquer des modifications au code renvoyé par get_search_form() mais il ne nous permet pas de modifier simplement le contenu du formulaire. L’approche que nous allons donc employer est de créer un template personnalisé pour le module de recherche.

Comme nous l’avons vu, il suffit de créer un fichier searchform.php à la racine de notre thème. Dans ce document, insérons la base de ce qui fera notre nouveau formulaire de recherche.

<form action="<?php echo home_url( '/' ); ?>">
	<!-- Ici on affiche le champ « s »
	mais nous aurions pu également en faire 
	un champ de type hidden avec une valeur vide-->
	<p>
		<label for="s">Rechercher</label>
		<input type="text" name="s" value="<?php the_search_query(); ?>" id="s">
	</p>
	<button type="submit">Rechercher</button>
</form>

Deux choses sont à retenir : il doit y avoir un input dont l’attribut name est s (cela peut être un champ masqué) et lors de la soumission les variables de ce bloc seront envoyées en GET à la racine du site.

Rajoutons maintenant deux autres champs de type « number » pour que l’utilisateur puisse saisir sa fourchette prix :

<p>
	<label for="prix-mini">Prix minimum</label>
	<input type="number" name="prix-mini" min="0" value="<?php 
	if ( isset( $_GET['prix-mini'] ) && $_GET['prix-mini'] ) {
		echo intval( $_GET['prix-mini'] );
	} ?>" id="prix-mini">
</p>

<p>
	<label for="prix-maxi">Prix maximum</label>
	<input type="number" name="prix-maxi" min="0" value="<?php 
	if ( isset( $_GET['prix-maxi'] ) && $_GET['prix-maxi'] ) {
		echo intval( $_GET['prix-maxi'] );
	} ?>" id="prix-maxi">
</p>

Selon le nombre de biens immobiliers du site, et leur amplitude de prix, il peut être astucieux de ne pas mettre de prix minimum : le seul vrai critère de prix est le budget max de l’internaute ; le prix minimum lui sert juste à éliminer des logements dont le prix est logiquement trop faible par rapport à ses attentes – qui dépendent d’autres critères. Il risque donc de passer à côté des bonnes affaires. À méditer…

Comme vous avez pu le constater, il faut prévoir, dans les différents champs, d’afficher les valeurs du formulaire soumis. Ainsi vous pourrez inclure ce formulaire sur la page des résultats de recherche et permettre à vos utilisateurs de modifier leurs critères.

Pour simplifier la récupération de ces données, dans la suite de l’article, nous allons utiliser la fonction get_query_var(). Elle permet de retourner la valeur des variables connues de WordPress. Donc au lieu de faire $truc = ( isset( $_GET['truc'] ) && $_GET['truc'] != '' ) ? $_GET['truc'] : false; il suffira de faire $truc = get_query_var('truc');. Plus clair, n’est-ce pas ? ;-)

Déclarons les variables que nous allons utiliser dans le fichier functions.php en insérant le code suivant :

<?php
add_filter( 'query_vars', 'willy_add_query_vars' );
function willy_add_query_vars( $vars ){
	$vars[] = "ville";
	$vars[] = "chambres";
	$vars[] = "quartiers";
	$vars[] = "prix-mini";
	$vars[] = "prix-maxi";
	$vars[] = "equipements";
	return $vars;
}

Les fonctions utiles pour concevoir des éléments de formulaire

Pour les autres critères de filtre du moteur de recherche, nous allons utiliser des fonctions mises à disposition par WordPress.

Une fonction pour les critères sous forme de liste

Nos biens sont organisés par villes – qui correspondent à des termes de taxonomie – et pour permettre aux utilisateurs de sélectionner certaines localisations nous utilisons wp_dropdown_categories().

Cette fonction permet de concevoir un select de termes de taxonomie. Pour construire ce champ, il faut lui passer un tableau de paramètres comme ceci :

<p>
<label for="ville">Ville</label>
<?php 
	$args = array(
		'show_option_all'   => __( 'Toutes les villes' ), 
		'orderby'           => 'name', 
		'order'             => 'ASC',
		'show_count'        => 1,
		'hide_empty'        => 1, 
		'echo'              => 0,
		'name'              => 'ville', 
		'id'                => 'ville',
		'hierarchical'      => true,
		'depth'             => 1,
		'taxonomy'          => 'localisation',
		'hide_if_empty'     => true, 
		'value_field'       => 'slug',
	);

	// Y-a-t'il une ville actuellement sélectionnée ?
	if ( get_query_var( 'ville' ) 
	    && ( $t = term_exists( get_query_var( 'ville' ), 'localisation' ) ) ) {
		$args['selected'] = get_query_var( 'ville' ) ;
	}

	$list = wp_dropdown_categories( $args );

	// Afficher la liste s'il existe des villes associées à des contenus
	if ( $list ) {
		echo $list;
	} ?>
</p>

Notre formulaire permet maintenant à l’internaute de choisir la ville qui l’intéresse.

Les listes personnalisées

Le nombre de chambres et les équipements ne sont pas des termes de taxonomie. Il faut donc concevoir des fonctions sur mesure afin de filtrer selon ces caractéristiques.

Voici le code qui permet de construire le sélecteur du nombre de chambres :

<p>
	<label for="chambres">Nombre de chambres</label>	
	<select name="chambres" id="chambres">
		<option value=""><?php _e( 'Indifférent' ); ?></option>
		<?php 
		// Valeur antiérieure ?
		$nb_chambres = intval( get_query_var( 'chambres' ) );

		// On boucle
		for ( $i = 1; $i <= 10; $i++ ) { 
			echo '<option value="' . $i . '" ' 
			// on utilise selected() pour assigner… 
			// … selected="selected" s'il y a lieu
			. selected( $i, $nb_chambres, false ) 
			. '>' . sprintf( _n( '%d chambre', '%d chambres', $i ), $i ) . '</option>';
		} ?>
	</select>
</p>

Voici le code pour concevoir les checkbox des équipements :

<?php 
$equips = array(
	'balcon'  => __( 'Balcon' ),
	'garage'  => __( 'Garage' ),
	'jardin'  => __( 'Jardin' ),
	'piscine' => __( 'Piscine' ),
    );
foreach ( $equips as $key => $equip ) {
	echo '<p>';
		echo '<input type="checkbox" name="equipements[]" '
		. 'id="equipment[' . $key . ']" value="' . $key . '"'
		// Si l'équipement doit être pré-coché, d'une précédente recherche…
		// … alors la fonciton checked renverra checked="checked"
		. checked( in_array( $key, get_query_var( 'equipements' ) ), true, false ) . '> ';
		echo '<label for="equipment[' . $key . ']">' . $equip . '</label>';
	echo '</p>';
} ?>

Vous avez sans doute remarqué que j’ai utilisé deux fonctions intéressantes : selected() et checked(). Celles-ci servent à reporter les valeurs de la précédente recherche. Elles assignent respectivement aux options et inputs les attributs selected="selected" et checked="checked" si les valeurs qui leurs sont passées en paramètre correspondent.

Des critères à disposition sous forme de checkbox

Au niveau du sélecteur de localisation, nous n’avons listé que les termes de top niveau, qui correspondent aux villes. On va maintenant proposer aux internautes, dès qu’ils ont sélectionné une ville, de pouvoir cocher les quartiers de cette ville qui les intéressent.

Seulement voilà : il n’est pas possible de faire de select multiple avec wp_dropdown_categories(). Dans le codex il est conseillé d’utiliser wp_terms_checklist() mais cette fonction est pensée pour le back office et n’est pas aussi souple. Elle risque de nous imposer trop de contraintes pour la suite.

Ce que je propose est donc de concevoir la liste des quartiers en utilisant notre propre fonction PHP. Celle-ci doit être insérée dans votre fichier functions.php.

<?php
function wp_quartiers_dropdown( $args = array() ) {

	$out = array();

	$taxonomie = 'localisation';
	// Les possibles arguments de la fonction sont current et ville
	$current  = is_array( $args['current'] ) ? $args['current'] : array();
	if ( isset( $args['ville'] )
	  && ( $ville = term_exists( $args['ville'], $taxonomie ) ) ) {
	  	// on récupère les quartiers de la ville demandée
		$quartiers = get_terms( $taxonomie, array(
		    'child_of' => $ville['term_taxonomy_id'],
		    ) );
		// pour chque quartier on fait une checkbox
		foreach ( $quartiers as $quartier ) {
			$out[] = '<p>';
			$out[] = '<input type="checkbox" value="' . esc_attr( $quartier->slug ) 
				. '" name="quartiers[]" id="quartiers[' . $quartier->term_id . ']"' 
				// et on vérifie si ce quartier soit être pré-coché
				. checked( in_array( $quartier->slug, $current ), true, false ) . '>';
			$out[] = '<label for="quartiers[' . $quartier->term_id . ']">' 
				. esc_html( $quartier->name ) . '</label>';
			$out[] = '</p>';
		}
	}

	return implode ( PHP_EOL, $out );
}

Il ne reste plus qu’à l’utiliser dans notre formulaire.

<!-- suite du formulaire… -->
<div id="quartier-wrapper">	
<?php
if ( get_query_var('ville') ) {
	$args = array( 'ville' => get_query_var( 'ville' ) );
	if ( get_query_var( 'quartiers' ) ) {
		$args['current'] = get_query_var( 'quartiers' );
	}
	echo wp_quartiers_dropdown( $args );
} ?>
</div>

Nous savons maintenant comment générer la liste de checkbox pour les quartiers de chaque ville, et c’est ce que nous faisons si une variable ville est définie. Mais comment afficher (modifier) cette liste lorsque l’on sélectionne une (autre) ville ?

Charger les quartiers lorsqu’une ville est sélectionnée

La solution est simplement d’associer en javascript la modification de la valeur du champ ville avec une action pour récupérer la liste à afficher, en ajax.

Il faut créer un fichier javascript pour écouter l’événement modification de la valeur ville et déclencher une requête ajax. Celle-ci transmet juste la valeur de la ville à un script php qui lui renvoie la checklist correspondante.

Voici le script PHP à insérer dans le fichier functions.php de votre thème :

<?php
add_action( 'wp_ajax_select_quartiers', 'willy_select_quartiers' );
add_action( 'wp_ajax_nopriv_select_quartiers', 'willy_select_quartiers' );
function willy_select_quartiers() {
	$ville = $_GET['ville'];
	if ( $ville ) {
		$args = array( 'ville' => strval( $ville ) );
		$checklist = wp_quartiers_dropdown( $args );
		wp_send_json_success( array( 'checklist' => $checklist ) );
	} else {
		wp_send_json_error();
	}
}

add_action( 'wp_enqueue_scripts', 'willy_register_select_quartiers' );
function willy_register_select_quartiers() {
	wp_enqueue_script( 'checkbox-quartiers', get_template_directory_uri() . '/js/checkbox-quartiers.js', array( 'jquery' ) );
	wp_enqueue_script( 'checkbox-quartiers' );
	wp_localize_script( 'checkbox-quartiers', 'adminAjax', admin_url( 'admin-ajax.php' ) );
}

Et voici le fichier javascript à placer dans le répertoire js de votre thème :

jQuery(document).ready( function($) {
	if ( document.getElementById('ville') ) {
		$ville = $('#ville');
		$ville.on( 'change', function() {
			$.ajax({
				url: adminAjax,
				method: 'GET',
				data: {
					action: 'select_quartiers',
					ville: $ville.val(),
				},
				success: function( data ) {
					if ( data.success ) {
						$('#quartier-wrapper').html( data.data.checklist );
					}
				}
			})
		} );
	}
});

Utiliser ces variables avec le filtre pre_get_posts

Nous avons maintenant un formulaire efficace qui permet à vos utilisateurs de saisir leur différents critères de recherche. Mais pour l’instant ces champs supplémentaires n’ont aucune influence sur la recherche…

Pour remédier à ça il faut créer une fonction PHP, sur le hook pre_get_posts. Ce filtre s’applique à chaque instance de WP_Query, avant de construire la requête qui va chercher les informations dans la base MySQL.

Avec cette fonction nous allons donc pouvoir intercepter les variables potentiellement transmises par le formulaire et altérer le comportement de la requête WordPress en conséquence.

Dans notre fonction filtre, la première chose à faire est de s’assurer que la requête que nous allons modifier correspond bien à la main query de la recherche et qu’un de nos arguments a été passé en paramètre. Si tel est le cas, nous indiquons que notre recherche portera sur des logements.

<?php
add_filter( 'pre_get_posts', 'willy_pre_get_posts' );
function willy_pre_get_posts( $q ) {
	// $q est l'objet de la class WP_Query
	// S'il s'agit de la main query…
	if ( $q->is_main_query() 
	  // … sur une page de recherche…
	  && is_search() 
	  // … multi-critères
	  && ( $q->get( 'ville' ) 
	    || $q->get( 'quartiers' ) 
	    || $q->get( 'prix-mini' ) 
	    || $q->get( 'prix-maxi' ) 
	    || $q->get( 'chambres' ) 
	    || $q->get( 'equipements' ) ) ) {
		
		// Ça nous concerne donc !
		$q->set( 'post_type', 'logement' );
		
	}
	return $q;
}

À partir de maintenant on peut modifier les arguments de la WP_Query pour prendre en compte les nouveaux critères. Tous les codes de cette partie de l’article iront à l’intérieur de cette condition.

Ajouter une tax_query pour rechercher les logements par localisation

Il est probable que lors de la modification de la requête de recherche nous ayons à pousser plusieurs conditions sous la forme de tax_query. La bonne pratique est de créer un tableau $tax_queries dans lequel nous allons insérer chacune de nos tax_query. Dans notre exemple c’est facultatif, mais cela deviendra nécessaire si nous ajoutons d’autres taxonomies (type de logement, par exemple).

Ici, le seul critère de recherche interagissant avec des termes de taxonomies est la localisation. Si les quartiers d’une ville ont été précisés nous allons rechercher les biens correspondant précisément à ceux-là, sinon nous rechercherons tous les logements de la ville.

<?php
// on récupère ou on créer un tableau tax_query
$tax_queries = $q->get( 'tax_query', array() );

// Si des quartiers sont demandés
if ( $q->get( 'quartiers' ) ) {
	$tax_queries[] = array(
		'taxonomy' => 'localisation',
		'terms'    => array_filter( (array) $q->get( 'quartiers' ) ),
		'field'    => 'slug',
	    );
} 
// sinon, si des villes sont demandées
elseif ( $q->get( 'ville' ) ) {
	$tax_queries[] = array(
		'taxonomy' => 'localisation',
		'terms'    => $q->get( 'ville' ),
		'field'    => 'slug',
	    );
}

Pour finir, si la variable $tax_queries n’est pas vide, nous l’ajoutons à la query.

<?php
if ( ! empty( $tax_queries ) ) {
	// S'il y a plus de deux tax_query, 
	// on indique que les deux doivent être remplies
	if ( isset( $tax_queries[1] ) ) {
		$tax_queries['relation'] = 'AND';
	}
	// On pousse notre tax_query
	$q->set( 'tax_query', $tax_queries );
}

Ajout d’un meta_query à la recherche

Tout comme pour la tax_query, nous allons potentiellement ajouter de nombreuses conditions sur les métadonnées des biens immobiliers. Il faut donc créer une variable $meta_queries dans laquelle nous pousserons nos conditions et que nous ajouterons à la fin à notre requête.

Nombre de chambres

Cette première condition est assez simple. Nous allons demander à WordPress de renvoyer tous les logements dont le nombre de chambre est au moins égal à ce qui a été demandé par l’internaute. S’il y a plus de chambres, c’est du bonus…

<?php
// on récupère ou on créer un tableau meta_query
$meta_queries = $q->get( 'meta_query', array() );

// Si un nombre de chambres est spécifié
if ( $q->get( 'chambres' ) ) {
	$meta_queries[] = array(
		'key'     => 'chambres',
		'value'   => $q->get( 'chambres' ),
		'compare' => '>=',
		'type'    => 'NUMERIC',
	    );
}

Fourchette de prix

Selon ce que l’utilisateur à saisi, nous pouvons faire trois conditions différentes sur le prix : supérieur au prix minimum, inférieur au prix maximum ou compris dans la fourchette.

<?php
// Si un prix maximum et minimum sont précisés
if ( $q->get( 'prix-mini' ) && $q->get( 'prix-maxi' ) ) {
	$meta_queries[] = array(
		'key'     => 'prix',
		'value'   => array( $q->get( 'prix-mini' ),  $q->get( 'prix-maxi' ) ),
		'compare' => 'BETWEEN',
		'type'    => 'NUMERIC',
	    );
} 
// si juste le prix mini est spécifié
elseif ( $q->get( 'prix-mini' ) ) {
	$meta_queries[] = array(
		'key'     => 'prix',
		'value'   => $q->get( 'prix-mini' ),
		'compare' => '>=',
		'type'    => 'NUMERIC',
	    );
} 
// si on a juste un prix maxi
elseif ( $q->get( 'prix-maxi' ) ) {
	$meta_queries[] = array(
		'key'     => 'prix',
		'value'   => $q->get( 'prix-maxi' ),
		'compare' => '<=',
		'type'    => 'NUMERIC',
	    );
}

Aménagements

Pour les aménagements des biens immobiliers, on récupère ceux qui sont requis –en vérifiant qu’il s’agit de vraies options – et on exclut de la recherche les logements qui ne les ont pas.

<?php
foreach ( array(
	'balcon',
	'garage',
	'jardin',
	'piscine',
    ) as $equipement ) {
	// Pour chaque équipement, s'il se trouve dans la query_var $equipements
	if ( is_array( $q->get( 'equipements' ) ) 
	  && in_array( $equipement, $q->get( 'equipements' ) ) ) {
		// on exclue les biens qui n'ont pas cet équipement
		$meta_queries[] = array(
			'key'     => $equipement,
			'value'   => 'non',
			'compare' => '!=',
		    );
	} 
}

Pour finir avec les meta, même principe qu’avec les taxonomies : on ajoute les meta_queries à la requête :

<?php
if ( ! empty( $meta_queries ) ) {
	// S'il y a plus de deux meta_query, 
	// on indique que les deux doivent être remplies
	if ( isset( $meta_queries[1] ) ) {
		$meta_queries['relation'] = 'AND';
	}
	// On pousse notre tax_query
	$q->set( 'meta_query', $meta_queries );
}

Utiliser des variables pour filtrer directement WP_Query

Dans certains cas, le filtre pre_get_posts ne suffira pas pour filtrer les résultats de recherche. Par exemple imaginons que sur le site de notre agence immobilière nous ne présentons plus les biens sous forme de logements, mais sous forme de programmes immobiliers (un programme est un groupe de logements ayant les mêmes équipements collectifs, mais des surfaces et prix différents).

On aurait donc un Custom Post Type Programme avec, dans son détail, plusieurs lots ayant chacun une superficie et un prix. Ces lots sont stockés sous la forme de métadonnées saisies via un champ repeater. Voici un dump pour mieux comprendre l’exemple :

<?php 
$post_meta = array(
  'lots' => 
  array ( 0 => '3' ),
  'lots_0_surface' => 
  array ( 0 => '66.86' ),
  'lots_0_prix' => 
  array ( 0 => '192000' ),
  'lots_1_surface' => 
  array ( 0 => '67.03' ),
  'lots_1_prix' => 
  array ( 0 => '192000' ),
  'lots_2_surface' => 
  array ( 0 => '67.03' ),
  'lots_2_prix' => 
  array ( 0 => '194000' ),
);

Via notre moteur de recherche nous indiquerions un prix qu’il faudrait comparer à l’ensemble des lots d’un programme. Mais nous venons de voir que le nom de chaque meta prix change. Comment faire une meta_query lorsque la clé de la meta varie ?

La classe WP_Meta_Query ne permet les comparaisons de type LIKE uniquement sur les valeurs des meta, par sur leur noms… Il va donc falloir ruser !

L’astuce consiste à utiliser le filtre pre_get_posts pour créer une nouvelle query_var et re-intervenir un instant plus tard, alors que la requête SQL est en cours de construction. Ici nous aurons la main pour donner à WordPress des instructions personnalisées pour assembler la requête SQL.

Pour cela nous allons découvrir 3 filtres : posts_join,posts_where et posts_groupby. Regardons le code, je vous l’explique ensuite…

<?php
add_filter( 'pre_get_posts', 'willy_push_price' );
function willy_push_price( $q ) {
	$prix = array();
	// prix mini
	if ( $q->get( 'prix-mini' )
	  && ( (int) $q->get( 'prix-mini' ) > 0 ) ) {
		$prix['min'] = intval( $q->get( 'prix-mini' ) );
	}

	// prix maxi
	if ( $q->get( 'prix-maxi' )
	  && ( (int) $q->get( 'prix-maxi' ) > 0 ) ) {
		$prix['max'] = intval( $q->get( 'prix-maxi' ) );
	}

	if ( ! empty( $prix ) ) {
		$q->set( 'prix', $prix );
	}
}

add_filter( 'posts_join', 'willy_prix_join', 10, 2 );
function willy_prix_join( $join, $q ) { // $q est l'instance de WP_Query
	if ( isset( $q->query_vars['prix'] ) ) {
		global $wpdb;
		// Dans les modifications des requêtes SQL… 
		// … il faut toujours faire précéder un ajout d'une expace
		$join .= " INNER JOIN $wpdb->postmeta AS prix ON ( $wpdb->posts.ID = prix.post_id )";
	}
	return $join;
}

add_filter( 'posts_where', 'willy_prix_where', 10, 2 );
function willy_prix_where( $where, $q ) {
	if ( isset( $q->query_vars['prix'] ) ) {
		global $wpdb;
		$where .= " AND prix.meta_key LIKE('lot_%_prix')";
		if ( isset( $q->query_vars['prix']['min'] ) ) {
			$where .= " AND prix.meta_value >= " . intval( $q->query_vars['prix']['min'] );
		}		
		if ( isset( $q->query_vars['prix']['max'] ) ) {
			$where .= " AND prix.meta_value <= " . intval( $q->query_vars['prix']['max'] );
		}
	}
	return $where;
}

add_filter( 'posts_groupby', 'willy_prix_groupeby', 10, 2 );
function willy_prix_groupeby( $groupby, $q ) {
	if ( isset( $q->query_vars['prix'] ) ) {
		global $wpdb;
		$groupby .= "$wpdb->posts.ID";
	}
	return $groupby;
}

La première étape est d’intercepter les variables $_GET[‘prix-mini’] et $_GET[‘prix-maxi’] et d’en faire une seule auery_var. Cette étape serait presque facultative, mais en procédant ainsi on permet à d’autres fonctions de modifier ces valeurs ultérieurement de façon simple #bonnePratique.

Le filtre posts_joinnous sert ensuite pour dire à WordPress de faire une nouvelle jointure sur la table des postmeta.

Avec posts_where nous indiquons que l’on souhaite comparer des meta dont le nom est lot_%_prix % désigne n’importe quelle chaîne. La valeur de ces meta doit être comprise dans la fourchette imposée par l’internaute.

Avec posts_groupeby on rassemble les résultats retournés par cette opération et qui correspondent aux mêmes programmes immobiliers.

Et voilà, nous venons de constater la puissance des filtres de la WP_Query !
Il est possible de presque tout faire grâce à eux. Souvenez-vous nous en avions parlé dans l’article sur les requêtes géolocalisées et nous en reparlerons sûrement bientôt ;-)

Un gabarit spécifique pour ce type de recherches

Nous avons notre formulaire et nous avons les bons contenus ; il ne nous reste qu’à voir où les afficher !

Par défaut cette recherche utilisera le gabarit search.php mais il est probable que vous ayez deux formulaires de recherche distincts : un simple pour le blog, et celui que l’on vient d’imaginer pour nos biens immobiliers. L’apparence des résultats de recherche devra être différente.

Nous allons donc demander à WordPress de charger un template alternatif si des variables telles que prix-mini, prix-maxi, ville, amenagements… existent. Le hook qui permet de choisir un template est template_include.

<?php
add_action( 'template_include', 'willy_recherche_template' );
function willy_recherche_template( $template ) {
	// S'il s'agit d'une recherche
	if ( is_search()
	  // et plus précisemment d'une recherche multi-critères
	  && ( get_query_var( 'ville' ) 
		|| get_query_var( 'chambres' ) 
		|| get_query_var( 'quartiers' ) 
		|| get_query_var( 'prix-mini' )
		|| get_query_var( 'prix-maxi' )
		|| get_query_var( 'equipements' ) ) ) {
			// on essaye de charger notre template de résultats de recherche
			$new_template = locate_template( array( 'recherche-multicriteres.php' ) );
			if ( '' != $new_template ) {
				return $new_template ;
			}
	}
	return $template;
}

Dans ce snippet, la fonction locate_template est importante puisqu’elle vérifie que le gabarit existe dans le thème courant, et dans le thème parent le cas échéant.

Rediriger certaines pages de résultats de recherche

Si l’internaute ne saisit que peu de critères, il est possible que les pages de résultats affichent le même contenu que d’autre page du site. Par exemple, s’il demande tous les biens immobiliers situées à Nantes, la page des résultats de recherche sera identique à la page de la catégorie Nantes puisque l’on y trouvera exactement les mêmes logements.

Je suggère donc de rediriger les requêtes de recherche mono-critères vers les pages de termes de taxonomie, si elles existent (dans notre exemple ce ne sera le cas que pour la taxonomie localisation).

Voici le code PHP qui permet de faire cette redirection (à insérer dans le fichier functions.php de votre thème) :

<?php
add_action( 'template_redirect', 'willy_redirect_recherche_ville' );
function willy_redirect_recherche_ville() {
	// s'il s'agit d'une recherche avec la ville de choisie
	if ( is_search() && get_query_var( 'ville' ) 
      // mais que les autres champs son vides
      && ! get_query_var( 'chambres' ) 
      && ! get_query_var( 'quartiers' ) 
      && ! get_query_var( 'prix-mini' ) 
      && ! get_query_var( 'prix-maxi' ) 
      && ! get_query_var( 'equipements' ) ) {
		// et que la ville existe
		if ( $ville = term_exists( get_query_var( 'ville' ) , 'localisation' ) ) {
			// alors on redirige l'utilisateur sur la page du terme de taxonomie
			wp_redirect( get_term_link( $ville['term_taxonomy_id'], 'localisation' ), 303 );
			exit();
		}
	}
}

Cette astuce ne contourne pas un quelconque problème de contenu dupliqué, puisque logiquement votre page de résultat de recherche est en no-index. Il s’agit plutôt d’une optimisation de l’expérience utilisateur : vous lui servez exactement le contenu qu’il demande 🙂

Une vraie URL pour la recherche

Pour finir sur la création d’un moteur de recherche multi-critères, il me reste un point à aborder avec vous : l’URL de la page de recherche.

Par défaut, WordPress affiche les résultats de recherche sur une page dont l’URL ressemble à http://exemple.com/?s=&prix-mini=40000&prix-maxi=50000&ville=nantes. Nous n’allons pas afficher tous les paramètres de façon plus lisibles – c’est possible, mais pas très pertinent – , par contre nous allons changer le préfixe de la recherche pour rendre l’URL un peu plus explicite : http://exemple.com/recherche-logements/?prix-mini=40000&prix-maxi=5000&ville=nantes.

Pour ne pas complètement réinventer la roue, nous allons nous inspirer de l’astuce de Jonathan Buttigieg pour personnaliser l’URL de la recherche à facettes uniquement :

<?php
add_action('template_redirect', 'willy_search_url_rewrite_rule');
function willy_search_url_rewrite_rule() {
    if ( isset( $_GET['s'] ) 
      && ( get_query_var( 'ville' ) 
		|| get_query_var( 'chambres' ) 
		|| get_query_var( 'quartiers' ) 
		|| get_query_var( 'prix-mini' )
		|| get_query_var( 'prix-maxi' )
		|| get_query_var( 'equipements' ) ) ) {

    		$q_args = array();
    		foreach( array( 
    		    'ville',
    		    'quartiers',
    		    'chambres',
				'prix-mini',
				'prix-maxi',
				'equipements' ) as $q_arg ) {
    			if ( $a = get_query_var( $q_arg, false ) ) {
    				$q_args[ $q_arg ] = $a;
    			}
    		}

    		$search_url = add_query_arg( $q_args, $search_url );
			wp_redirect( $search_url );
			exit();
    }
}
 
add_filter( 'search_rewrite_rules', 'willy_search_rewrite_rules' );
function willy_search_rewrite_rules( $search_rewrite ) {
    $search_rewrite['recherche-logements/?$'] = 'index.php?s=';
    return $search_rewrite;
}

Dans cette solution, nous n’avons pas modifié le préfixe de la recherche globale, nous avons juste ajouté de nouveaux motifs de réécriture via le hook search_rewrite_rules.

Pour que cette modification de la structure des URL soit prise en compte, il faut régénérer les permaliens.

Nous pouvons même aller plus loin en réalisant ce genre d’URL : http://exemple.com/recherche-logements/nantes/centre-ville+beaulieu/?prix-mini=40000&prix-maxi=5000

<?php
add_action('template_redirect', 'willy_search_url_rewrite_rule');
function willy_search_url_rewrite_rule() {
    if ( ( isset( $_GET['s'] ) || isset( $_GET['ville'] ) || isset( $_GET['quartiers'] ) )
      && ( get_query_var( 'ville' ) 
        || get_query_var( 'chambres' ) 
        || get_query_var( 'quartiers' ) 
        || get_query_var( 'prix-mini' )
        || get_query_var( 'prix-maxi' )
        || get_query_var( 'equipements' ) ) ) {

            $search_url = home_url( 'recherche-logements/' );
            if ( $ville = get_query_var( 'ville', false ) ) {
                $search_url .= strval( $ville ) . '/';
            }
            if ( $quartiers = get_query_var( 'quartiers', false ) ) {
                $search_url .= implode( '+', $quartiers ) . '/';
            }

            $q_args = array();
            foreach( array( 
                'chambres',
                'prix-mini',
                'prix-maxi',
                'equipements' ) as $q_arg ) {
                if ( $a = get_query_var( $q_arg, false ) ) {
                    $q_args[ $q_arg ] = $a;
                }
            }

            $search_url = add_query_arg( $q_args, $search_url );
            wp_redirect( $search_url );
            exit();
    }
}

add_filter( 'search_rewrite_rules', 'willy_search_rewrite_rules' );
function willy_search_rewrite_rules( $search_rewrite ) {
    $search_rewrite['recherche-logements/([^/]+)/([^/]+)/?$'] = 'index.php?s=&ville=$matches[1]&quartiers=$matches[2]';
    $search_rewrite['recherche-logements/([^/]+)/?$'] = 'index.php?s=&ville=$matches[1]';
    $search_rewrite['recherche-logements/?$'] = 'index.php?s=';
    return $search_rewrite;
}

Il est possible de mettre l’URL sous cette forme car on sait que pour sélectionner des quartiers, l’utilisateur doit d’abord choisir une ville.

À noter également que pour appliquer cette structure d’URL nous avons modifié la forme de la variable quartiers qui n’est plus un tableau mais une chaîne de slugs séparés par le signe +. Là où l’on utilise sa valeur il est donc nécessaire de procéder à un explode( '+', get_query_var( 'quartiers' ) ) (dans le formulaire lorsque l’on récupère les quartiers courants, ainsi que dans pre_get_post au moment d’écrire la tax_query).

Conclusion

Ce tutoriel est maintenant fini, et vous avez toutes les cartes en main pour concevoir vos propres moteurs de recherche multi-critères.

Si cet article vous a plu, et que vous l’avez utilisé pour un site, n’hésitez pas à partager vos créations en commentaire !

Contacter l'auteur :

willy bahuaud

Je suis Willy Bahuaud, intégrateur et développeur, spécialiste de WordPress.
Besoin de mes services ? Écrivez-moi !

15 commentaires

  1. Par madvic — Il y a 1 année
    Encore un article au petits oignons !! Merci
  2. Par Julien Maury — Il y a 1 année
    Décidément j’aime tes articles de fond Willy.

    Préciser aux lecteurs de ne surtout pas oublier l’espace avant de rajouter une condition avec le filtre posts_where,  » AND », (évidemment bien fait dans l’exemple ici). Si vous l’oubliez WP votre requête , SQL donc, sera donc fausse puisque vous concaténez.

    Par contre dans le premier code il y a un wp _redirect() sans exit ni die() il faut le rajouter c’est toujours plus safe.

  3. Par Julien Maury — Il y a 1 année
  4. Par Willy Bahuaud — Il y a 1 année
    Hello, merci, ça me fais plaisir de savoir que mes articles reçoivent un bon accueil.
    Pour le die() tu as tout à fait raison, le seul moment où l’on peut s’en passer c’est lorsque l’on fait un wp_send_json() (qui inclut un wp_die()).
  5. Par Julien Maury — Il y a 1 année
    oui mais c’est encore autre chose, là il s’agit de redirection c’est pour ça que le die() est d’autant plus important mais c’est plus un oubli ici qu’autre chose vu que tu le mets ailleurs.
  6. Par Greg — Il y a 1 année
    ??
    (ouais, je fais simple et direct aujourd’hui)
  7. Par Greg — Il y a 1 année
    Les formulaires de recherche multi-critères, c’est un besoin très courant et qui pourtant ne fait rarement l’objet d’un tuto.

    Après une première lecture et maintenant une seconde, il est temps de se lancer !

    Merci à toi ;-)

  8. Par Frank — Il y a 1 année
    Super tuto Willy, ça m’a bien aidé !

    Un grand merci ?

  9. Par Marc-Aurèle Geffroy — Il y a 1 année
    Super tuto ! (Bien que légèrement trop technique pour moi…)
    J’ai réussi à faire à peu près ce que je voulais en n’allant cependant pas jusqu’à la réécriture des url et la redirection sur les templates dédiés !

    J’ai cependant un problème qui persiste et que je n’arrive pas à identifier. Mes urls sont de cette forme :
    http://localhost/bacasable/?s=&departement=loire-atlantique&ville%5B%5D=nantes

    D’où provient ce %5B%5D ?

  10. Par Willy Bahuaud — Il y a 1 année
    Les %5B%5D correspondent aux caractères [] qui ont été encodés (probablement lors de la redirection)… Ces caractères servent à indiquer que la variable $_GET ville est un tableau et qu’elle peut donc contenir plusieurs valeurs. Il faut veiller à ne pas encoder ces caractères 🙂
  11. Par marie — Il y a 1 année
    merci Willy pour ce tuto, c’est simplement magique !!

    Marie

  12. Par Gaillien — Il y a 3 mois
    Bonjour,
    Votre tuto est super, je cherchais justement une telle fonction. Cependant, il semble exister un soucis avec votre syntaxe sur la ligne $tax_queries = $q->get( ‘tax_query’, array() ); qui retourne Fatal error: Call to a member function get() on null. Je me suis plongé dans le codex WordPress, mais à mon âge (70) j’ais un peu de mal avec la version anglaise. Il semblerait que cette syntaxe aurait évoluée avec les versions de php (ou WP ?). J’utilise les dernières versions de php, html5, et WP. Qu’en est-il vraiment ?
    Merci.
  13. Par Willy Bahuaud — Il y a 3 mois
    Je pense que le soucis est lié au fait que la variable $q n’est pas passé en paramètre du hook. Du coup PHP appelle la fonction get() d’une variable qu’il ne connait pas 🙂
    Il suffit donc de la passer en paramètre.
  14. Par Caroline — Il y a 3 mois
    Bonjour,

    Je découvre ton site et je trouve ce tuto génial et il correspond parfaitement à mon besoin. Cependant, j’aurai aimé savoir comment sont organisés tes éléments dans wordpress, car j’ai du mal à bien tout comprendre.
    Par exemple, moi j’aimerai que ce formulaire de recherche aille chercher dans un custom post type (appelé « Biens ») que j’ai conçu. Mais je ne comprends pas si il faut que je créée des taxonomies sur lesquelles baser ma recherche, où si le formulaire peut aller chercher directement dans les champs (conçu avec ACF) de mon custom post type.
    C’est également pour une agence immobilière..

    Si tu pouvais m’aider, car ton tuto est super, mais on a pas de vision d’ensemble de ton code (ton searchform.php comme ton function.php), du coup j’ai du mal à tout associer.

    Merci beaucoup et continues comme ça !

  15. Par Isabelle — Il y a 1 mois
    Super ce tutoriel!

    Comment ferait-on pour enlever les taxonomies qui n’ont pas de match en lien avec une sélection d’une autre taxonomie?

    Par exemple, j’ai deux filtres. Si au filtre 1, je fais un choix, j’aimerais que le filtre 2 n’affiche que les items qui ont un post en considérant le choix du filtre 1.

    Possible ?

    Merci!
    Isabelle

Commenter