Image mise en avant pour l'article

Drupal : créer une page de liste avec filtres, sans utiliser views ?

20 février 2023
Drupal
L’utilisation du module Views pour créer des pages de liste de contenus est la norme sur Drupal. Néanmoins, chez Adimeo, nous avons fait le choix de privilégier des requêtes en code et l'utilisation d'un type de contenu pour abriter nos listes de contenus.


En procédant ainsi, nous bénéficions de nombreux avantages en termes de SEO, UX de contribution, templating et plus...

Voici quelques exemples pour illustrer mes propos :

  • Une gestion des métadonnées plus fine et accessible facilement aux contributeurs pour modification ;
  • La possibilité d'avoir facilement des textes introductifs sur nos listes, ce qui permet d'éviter des pages avec très peu de contenu original. C’est top pour le SEO ;
  • Le contrôle simplifié du templating de la page et donc meilleure expérience d'intégration CSS ;
  • La gestion du multilingue simplifiée ;
  • Ne pas utiliser Views c'est aussi avoir la maitrise sur les conditions des requêtes (réduction de l'abstraction) ...
  • ... et s'entrainer à écrire du code. Pour des juniors, c'est un vecteur de progression rapide.

L’utilisation du module Views nécessite, assez rapidement, l’ajout des modules complémentaires, de faire des relations ou filtres contextuels qui impliquent des requêtes très complexes et donc beaucoup d'abstraction. De plus, les performances ne sont pas garanties et en cas d'évolution, nous sommes potentiellement bloqués par les limitations de Views.

Maintenant que vous connaissez les raisons de ce choix, je vous donne notre méthode pour créer une page de liste avec filtres sans difficulté. Allez c’est parti !

 

Posons les bases

Partons du principe que nous avons déjà un type de contenu "Actualité" avec le nom système "news" ainsi qu'un vocabulaire de taxonomie "Catégorie d'actualité" avec le nom système "news_category".

news_node-png

Template d’accroche d’une actualité

Pour notre page de liste d'actualités, nous allons avoir besoin de créer un template d'accroche (view mode teaser) pour les actualités.

Pour ce template, je choisis d'afficher les champs ci-dessous :

  • label(Titre du type de contenu qui est un champ par défaut lorsque l'on crée un node) ;
  • getCreatedTime() (Permet d'obtenir la date de publication du node) ;
  • field_cover ;
  • field_chapo ;
  • field_category(Champ qui fait référence au vocabulaire de taxonomie "news_category").

Dans le dossier templates, créons un sous dossier "news" pour y insérer le template custom d'accroche d'une actualité.

news_teaser_template

❗ À noter que les templates sont gérés ainsi : [entity_name]--[bundle_name]--[view_mode].

Voici donc à quoi ressemble notre template custom :

Capture d'écran d'un élément de code

Création de la page liste

Créons un nouveau type de contenu "Page de liste d'actualités" avec comme nom système "news_list".

news_list_node

Pour rester dans la simplicité, nous allons juste ajouter un champ d'introduction "field_chapo" pour ce node.

Même processus que pour le template d'accroche, nous allons l'ajouter dans le sous dossier news du dossier templates.

news_list_template

Voici à quoi ressemble notre template pour le moment :

Capture d'écran d'un bout de code

 

 

Création des filtres avec la Form API

Pour pouvoir filtrer nos contenus, nous allons devoir créer des filtres. Pour cet exemple, on va créer un filtre basé sur la catégorie/thématique du contenu ainsi qu'un filtre pour trier par date chronologique ou antéchronologique. Pour ce faire, nous utiliserons la Form API. Pour plus d'informations, je vous invite à lire l'article de Quentin Rinaldi : La Form API de Drupal, késako ?

Tout d'abord, créons un fichier NewsListFiltersForm.php dans le sous dossier "Form" du module custom "custom_news_list".


<?php

namespace Drupal\custom_news_list\Form;

use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;

/**
 * Provides a News list filters form.
 */
class NewsListFiltersForm extends FormBase {

  /**
   * @return string
   */
  public function getFormId(): string {
    return 'news_list_filters_form';
  }

  /**
   * @param array $form
   * @param FormStateInterface $form_state
   * @param array|NULL $optionsList
   * @return array
   */
  public function buildForm(array $form, FormStateInterface $form_state): array {

    $form['category'] = [
      '#type' => 'select',
      '#title' => $this->t('Catégories'),
      '#empty_option' => t('- Choisir une catégorie -'),
      '#options' => $this->getCategoriesListOptions('news_category'),
      '#default_value' => \Drupal::requestStack()->getCurrentRequest()->query->get('category'),
    ];

    $form['date'] = [
      '#type' => 'select',
      '#title' => $this->t('Trier'),
      '#options' => [
        'ASC' => $this->t('Plus ancien au plus récent'),
        'DESC' => $this->t('Plus récent au plus ancien'),
      ],
      '#default_value' => \Drupal::requestStack()->getCurrentRequest()->query->get('date'),
    ];

    $form['submit'] = [
      '#type' => 'submit',
      '#value' => $this->t('Filtrer'),
    ];

    return $form;
  }

  /**
   * @param array $form
   * @param FormStateInterface $form_state
   * @return void
   */
  public function submitForm(array &$form, FormStateInterface $form_state): void {
    if ($form_state->getValue('category')) {
      \Drupal::request()->query->set('category', $form_state->getValue('category'));
    }
    if ($form_state->getValue('date')) {
      \Drupal::request()->query->set('date', $form_state->getValue('date'));
    }
  }

}

Maintenant, intéressons-nous à cette ligne de la classe NewsListFiltersForm. '#options' => $this->getCategoriesListOptions('news_category').
Nous devons récupérer la liste des termes de taxonomies du vocabulaire "Catégorie d'actualité". Pour réaliser cela, nous allons créer une méthode "protected function getCategoriesListOptions(string $vid)" qui nous retournera la liste des termes sous la forme "clé => valeur" avec comme clé, le term id (tid) et comme valeur, le label du terme.

❗ Je tiens à préciser que nous utilisons normalement une structure Manager/Gateway/GatewayInterface avec de l'injection de dépendance.

> Commençons par créer une méthode qui se chargera de faire la requête pour récupérer les tids avec un paramètre "$vid" de type string.


protected function fetchTermByVid(string $vid): int|array {
  return \Drupal::entityTypeManager()->getStorage('taxonomy_term')->getQuery()
    ->condition('vid', $vid)
    ->condition('status', 1) // 1 = Statut publié
    ->sort('weight', 'ASC') // 'ASC' = Valeur par défaut
    ->execute();
}

Cette méthode nous retournera un tableau de tids triés par poids croissant.


> Créons maintenant la méthode getCategoriesListOptions(string $vid).


protected function getCategoriesListOptions(string $vid): array {
  $list = [];
  $tids = $this->fetchTermByVid($vid);
  if (!empty($tids)) {
    $terms = \Drupal::entityTypeManager()->getStorage('taxonomy_term')->loadMultiple($tids);
    foreach ($terms as $term) {
      $list[$term->id()] = $term->label();
    }
  }
  return $list;
}

Cette deuxième méthode nous retourne un tableau associatif ['clé' => 'valeur'].


Avant de continuer, voici un petit récapitulatif :

  1. À cette ligne du champ "category" de notre formulaire, '#options' => $this->getCategoriesListOptions('news_category'). Nous appelons la méthode getCategoriesListOptions avec comme argument "news_category" (le vocabulaire id ciblé) ;
  2. Nous appelons ensuite la deuxième méthode fetchTermByVid en lui passant également le vid ciblé qui nous retournera un tableau de terme ids ;
  3. Si le tableau n'est pas vide, alors nous récupérons les entités "taxonomy_term" à l'aide du service "EntityTypeManager" et de sa méthode loadMultiple(array $tids) ;
  4. Finalement, on boucle sur tous les termes récupérés et ont rempli un tableau (Initialisé à vide au début de notre méthode) avec comme clé $term->id() et comme valeur associée, $term->label().

 

Affichage du formulaire

Maintenant que nous avons créé le formulaire de filtres, nous devons l'afficher dans le template de notre page de liste. Pour ce faire, nous allons utiliser le service FormBuilder.

Créons un fichier .module nommé custom_news_list.module à la racine de notre dossier "custom_news_list". Dans ce fichier, nous allons utiliser un HOOK_preprocess_node pour transmettre des variables à notre template.


<?php

use Drupal\custom_news_list\Form\NewsListFiltersForm;

function custom_news_list_preprocess_node__news_list(&$variables): void {
  $newsListFilters = \Drupal::formBuilder()->getForm(NewsListFiltersForm::class);
  $variables['news_list_filters'] = $newsListFilters;
}

Remarque : On peut voir l'extension __news_list du hook preprocess node qui permet de cibler uniquement les nodes de type "news_list".

On fait appel au service FormBuilder puis utilise la méthode getForm() qui prend comme argument un nom ou une instance de classe qui implémente \Drupal\Core\Form\FormInterface.

Enfin, on crée une variable "news_list_filters" qui sera disponible dans notre template de page de liste qui maintenant, ressemble à ceci :

Capture d'écran un extrait de code

Nous devrions obtenir ceci lorsque que nous rafraichissons notre page de liste.

filters_0

Création de la requête pour récupérer les actualités

Pour afficher la liste des actualités, nous devons créer une fonction qui effectuera une requête pour récupérer les nodes de type "news".

❗ Comme mentionné précédemment dans cet article, cette fonction devrait idéalement être située dans une classe Gateway.


public function getAllPublishedNewsFiltered(array $filters = NULL): ?array {
  $categoryId = $filters['category'] ?? NULL;
  $dateSortedBy = $filters['date'] ?? 'DESC';

  $query = \Drupal::entityQuery('node');
  $query->condition('status', NodeInterface::PUBLISHED);
  $query->condition('type', 'news');
  $query->sort('created', $dateSortedBy);

  if (!is_null($categoryId)) {
    $query->condition('field_category', $categoryId, '=');
  }

  $query->pager(6);
  $nids = $query->execute();
  $nodeStorage = \Drupal::entityTypeManager()->getStorage('node');
  return !empty($nids) ? $nodeStorage->loadMultiple($nids) : NULL;
}

Nous pouvons voir que notre fonction prend un paramètre $filters de type tableau et avec comme valeur par défaut, NULL.

Au premier chargement de notre page de liste, aucun filtre n'est activé. Nous vérifions donc s'ils le sont et, en cas de non-initialisation, nous assignons une valeur par défaut. Pour faire cette vérification, nous utilisations l'opérateur de coalescence null"??".

Lorsque la clé "category" n'est pas présente dans le tableau "$filters", la variable "$categoryId" est attribuée la valeur NULL. De la même manière, si la clé "date" n'est pas détectée dans le tableau, la variable "$dateSortedBy" sera assignée la valeur "DESC".

Nous faisons ensuite la requête qui permet de récupérer les nids (node ids).


$query->condition('status', NodeInterface::PUBLISHED); 

Condition pour récupérer seulement les nodes publiés.



$query->condition('type', 'news'); 

Condition pour récupérer les nodes de type « News ».



$query->sort('created', $dateSortedBy);

Tri des nodes selon la date de création, en ordre décroissant par défaut ou croissant si le filtre le spécifie (DESC par défaut, ASC selon le choix du filtre).



if (!is_null($categoryId)) {
  $query->condition('field_category', $categoryId, '='); 
}

Si la clé "category" est définie dans le tableau $filters, nous ajoutons une condition d'égalité sur le terme id.



$query->pager(6);

Affichage de six actualités par page.



$nids = $query->execute();

Exécution de la requête.

Pour finir on utilise le service EntityTypeManager qui nous permettra de charger nos nodes "news" si la requête renvoie un tableau de "nids."

 

Construction du tableau de rendu

Nous arrivons au but, plus que quelques instants avant l'affichage des actualités. Pour ce faire, nous allons créer une dernière méthode (Qui idéalement se situerait dans une classe Manager).


public function getRenderedNewsList(array $filters = NULL): array {
  $builtNodes = [];
  $nodes = getAllPublishedNewsFiltered($filters);
  if ($nodes) {
    $viewBuilder = \Drupal::entityTypeManager()->getViewBuilder('node');
    $builtNodes = $viewBuilder->viewMultiple($nodes, 'teaser');
    $builtNodes['#cache']['tags'] = ['node_list:news'];
    $builtNodes['pager'] = [
      '#type' => 'pager'
    ];
  }
  return $builtNodes;
}

La méthode getRenderedNewsList() accepte un tableau de filtres nommé $filters en tant que paramètre, avec NULL comme valeur par défaut, similaire à la méthode getAllPublishedNewsFiltered().
On initialise un tableau vide $builtNodes qui sera alimenté par la suite si la méthode getAllPublishedNewsFiltered() nous retourne un résultat.
Si des résultats sont obtenus, le service EntityTypeManager est appelé et la fonction getViewBuilder() est utilisée en spécifiant "node" comme identifiant du type d'entité.

Le tableau de rendu (Render array) est construit en utilisant la fonction viewMultiple() avec les entités chargées en premier argument et le mode d'affichage souhaité en second. Le mode d'affichage "Teaser" est utilisé pour afficher les actualités, en utilisant le template précédemment créé.

Un cache tag est défini pour invalider le cache du bundle "news" chaque fois qu'une actualité est mise à jour, supprimée ou créée. Pour plus d'informations sur le cache, vous pouvez lire l'article d'Adam Carton de Wiart sur : « Le cache Drupal : pour une meilleure gestion des performances ! ».

Puis, nous initialisons le pager, et retournons le tableau de rendu.

 

Mise à jour du fichier .module

Pour récupérer les paramètres d'URL, nous allons utiliser le service RequestStack. Dans notre fonction custom_news_list_preprocess_node__news_list(&$variables) de notre fichier .module, ajoutons cette ligne de code :


$filters = Drupal::requestStack()->getCurrentRequest()->query->all();

La variable $filters est un tableau qui contient les paramètres d'URL s'ils existent.


Il ne nous reste plus qu'à transmettre ce tableau à notre méthode getRenderedNewsList(array $filters = NULL) et assigné le résultat à une variable que l'on pourra afficher dans le template de notre page de liste.


$variables['news_list'] = getRenderedNewsList($filters);

Notre fonction mise à jour ressemble donc à ceci :


function custom_news_list_preprocess_node__news_list(&$variables): void {
  $newsListFilters = \Drupal::formBuilder()->getForm(NewsListFiltersForm::class, getTermList('news_category'));
  $variables['news_list_filters'] = $newsListFilters;
  $filters = Drupal::requestStack()->getCurrentRequest()->query->all();
  $variables['news_list'] = \Drupal::service('custom_news_list.news_manager')->getRenderedNewsList($filters);
}

 

Mise à jour du template de notre page de liste

Nous y sommes 😁 !

Mettons à jour le template node--news-list.html.twig pour afficher la liste d'actualités.

Capture d'écran d'un extrait de code

Si le tableau de rendu est vide, alors on affiche un message "Aucun résultats...".

Maintenant, vous savez créer une page de liste avec filtres sur Drupal, sans utiliser views. 😉 Pour rappel, chez Adimeo, nous utilisons cette méthode car elle apporte de nombreux avantages en termes de SEO, d'UX de contribution, de templating, etc.


Crédit photo : scyther5

Image mise en avant pour l'article
Vivien Barbeau
Développeur Web
Webinar
Drupal 10, qu'est-ce qui change concrètement ?
Voir le webinar !
Vous maitrisez la technologie Drupal et vous souhaitez participer à une aventure humaine ?
Adimeo recrute un Développeur Drupal Senior capable d’encadrer des projets et d’accompagner des développeurs juniors...
Voir notre offre d'emploi !