Le problème
Quand on crée des entités avec Drupal, on crée en général ce qu'on appelle un "ListBuilder" associé, qui permet de lister les entités côté administration. On hérite généralement de la classe EntityListBuilder ou ConfigEntityListBuilder, puis on implémente les méthodes buildHeader et buildRow pour décider de ce que la liste va afficher.
Jusqu'ici, tout va bien. ça nous génère une liste avec une colonne "Actions" qui se présente sous la forme d'un bouton façon liste déroulante contenant les actions basiques : Modifier, Supprimer. En gros, ça mâche le travail et ça permet d'être efficace dans la construction de la partie administrative de notre entité.
Mais que se passe-t-il quand on a des actions spécifiques à implémenter pour notre entité ? Par exemple une action permettant d'activer ou désactiver l'entité, ou encore d'exécuter un processus particulier ?
Un cas concret
Je développe depuis un bon moment un système de création de contenu que j'ai nommé "Single Page". C'est une suite de modules qui permet de créer des contenus sous forme de "paragraphes", le tout intégré dans des sections. Ce système permet de créer des pages complètes avec pléthore de paragraphes, que ça soit des médias (images, bannières, galeries, vidéos) ou encore des contenus éditoriaux, des blocs Drupal, des boutons, etc...
Pour proposer une expérience utilisateur intéressante, j'ai intégré à mes "single pages" un système de mise en page (layouts) et un système de modèles (templates). Pour faire court, lorsqu'un utilisateur veut créer une nouvelle section, il va être redirigé vers une liste de modèles, il va choisir celui qu'il veut utiliser, et le formulaire de création s'affichera avec des données pré-remplies, qu'il n'aura plus qu'à modifier pour finaliser son contenu.
Le problème que je rencontre, c'est que la liste des layouts et des templates s'agrandit à force de vouloir proposer des affichages spécifiques, et que chaque site qui utilise le système n'a pas besoin d'avoir accès à tout le panel de layouts / templates.
Je voulais donc limiter leur utilisation en permettant d'activer / désactiver certains éléments. Pour ce faire, quoi de mieux que de proposer de nouvelles actions directement dans le ListBuilder, permettant ainsi de gérer le statut de chaque layout et de chaque template.
La solution
J'aurai pu créer une nouvelle colonne, avec un lien d'activation/désactivation, mais la méthode getOperations du ListBuilder est justement là pour gérer ce genre de cas. Il suffit de surdéfinir son implémentation dans la classe ListBuilder de notre entité pour y ajouter le nécessaire.
Alors, comment ça marche ?
C'est relativement simple. Dans l'ordre, il est nécessaire de créer un contrôleur, d'ajouter les routes nécessaires, et de surdéfinir la méthode getOperations().
Le contrôleur est très simple. Il va simplement permettre de modifier le statut de l'entité.
<?php
namespace Drupal\sp_columns_layout\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\sp_columns_layout\Entity\ColumnsLayoutInterface;
use Drupal\sp_columns_layout\Service\Schema\PreviewBuilder;
/**
* Defines the columns layout controller.
*/
class ColumnsLayoutController extends ControllerBase {
/**
* Toggle the columns layout status.
*
* @param \Drupal\sp_columns_layout\Entity\ColumnsLayoutInterface $sp_columns_layout
* The columns layout to change status.
*/
public function toggle(ColumnsLayoutInterface $sp_columns_layout) {
$sp_columns_layout->setStatus(!$sp_columns_layout->status());
$sp_columns_layout->save();
$this->messenger()->addStatus($this->t('Status updated.'));
return $this->redirect('entity.sp_columns_layout.collection');
}
}
Facile, non ? il prend en paramètre une entité du type désiré, puis le process va simplement switcher le statut de l'entité, sauvegarder l'entité, ajouter un message dans le messenger et rediriger l'utilisateur vers le ListBuilder.
Au niveau du routage, c'est pareil. Dans le fichier {module}.routing.yml, je déclare la route vers mon contrôleur, tout simplement :
sp_columns_layout.toggle_status:
path: '/admin/config/user-interface/single_page/layouts/{sp_columns_layout}/toggle'
defaults:
_controller: '\Drupal\sp_columns_layout\Controller\ColumnsLayoutController::toggle'
_title: 'Toggle status'
requirements:
_permission: 'administer single_page columns layouts'
options:
parameters:
sp_columns_layout:
type: entity:sp_columns_layout
Je définis le chemin, qui contient l'entité {sp_columns_layout}. Cet élément est important. C'est exactement l'identifiant de mon entité. Et dans le code de mon contrôleur ci-dessus, j'utilise EXACTEMENT le même identifiant. Drupal (Symfony) va faire matcher l'identifiant. Si j'utilisais autre chose dans mon contrôleur, alors il planterai lamentablement en me disant que le paramètre ne peut pas être trouvé. ça utilise en interne les paramètres nommés pour faire transiter et correspondre les informations.
J'indique ensuite le contrôleur qui sera appelé, ainsi que sa méthode. Je définis une permission (existante dans le fichier {module}.permissions.yml bien entendu) et enfin, je donne la définition de mon paramètre. Ici, le paramètre est nommé sp_columns_layout, et je spécifie que sont type est entity:sp_columns_layout. En gros, je dis : le paramètre nommé sp_columns_layout doit correspondre à une entité du type sp_columns_layout.
Là aussi, c'est relativement simple. La partie "options" n'est pas forcément obligatoire si Drupal peut reconnaitre exactement l'entité. Dans mon cas, elle l'est car je passe une entité ColumnsLayout (sp_columns_layout) mais le contrôleur, lui, prend en paramètre une interface ColumnsLayoutInterface. Dans ce cas, il ne sera pas capable de comprendre que ColumnsLayoutInterface est l'interface spécifique pour les entités ColumnsLayout. Je dois donc préciser de manière exacte via options:parameters le type d'entité de mon paramètre.
Enfin, j'implémente mon ListBuilder comme suivant :
<?php
namespace Drupal\sp_columns_layout;
use Drupal\Core\Config\Entity\ConfigEntityListBuilder;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Render\Markup;
use Drupal\Core\Url;
/**
* Defines the columns layout list builder.
*/
class ColumnsLayoutListBuilder extends ConfigEntityListBuilder {
/**
* {@inheritDoc}
*/
public function buildHeader() {
$header['label'] = $this->t('Label');
$header['description'] = $this->t('Description');
$header['theme'] = $this->t('Theme');
$header['status'] = $this->t('Status');
return $header + parent::buildHeader();
}
/**
* {@inheritDoc}
*/
public function buildRow(EntityInterface $entity) {
/** @var \Drupal\sp_columns_layout\Entity\ColumnsLayout $entity */
$row['label'] = $entity->getGroup() . ' - ' . $entity->label();
$row['description'] = Markup::create($entity->getDescription());
$row['theme'] = $entity->getTheme();
$row['status'] = $entity->status() ? $this->t('Enabled') : $this->t('Disabled');
return $row + parent::buildRow($entity);
}
/**
* {@inheritDoc}
*/
public function getOperations(EntityInterface $entity) {
/** @var \Drupal\sp_columns_layout\Entity\ColumnsLayoutInterface $entity */
$operations = parent::getOperations($entity);
$title = $this->t('Disable');
if (!$entity->status()) {
$title = $this->t('Enable');
}
$operations['disable'] = [
'title' => $title,
'url' => Url::fromRoute('sp_columns_layout.toggle_status', [
'sp_columns_layout' => $entity->id(),
]),
'weight' => 20,
];
uasort($operations, ['Drupal\Component\Utility\SortArray', 'sortByWeightElement']);
return $operations;
}
}Comme expliqué au début, j'implémente buildHeader() et buildRow() pour spécifier les informations à afficher dans la liste. Dans mon cas, j'affiche le label de mon entité, sa description, le thème qui correspond au layout et enfin, son état actuel : actif ou inactif. Puis j'appelle la méthode du parent pour qu'elle complète la ligne avec les éléments nécessaires à l'affichage final. La méthode parent::buildRow() va justement s'occuper de récupérer la colonne d'opérations à afficher.
Le petit plus ici, c'est la surdéfinition de la méthode getOperations() (celle justement appelée par parent::buildRow()).
Dans cette méthode, j'appelle également parent::getOperations() pour récupérer les opérations par défaut fournie par la classe parent, puis je vais ajouter ma propre opération supplémentaire, à savoir celle qui va permettre d'activer ou désactiver l'entité. Je teste le statut actuel pour déterminer le label à afficher, puis je rajoute l'opération sous forme d'une url qui appelle la route que j'ai créée auparavant, tout simplement. Dans l'url, j'indique le paramètre sp_columns_layout avec l'identifiant de mon entité pour qu'il soit "converti" (upcasting via paramConverter) et que le contrôleur récupère l'entité complète sur laquelle je vais pouvoir travailler.
Conclusion
Rien d'extraordinaire dans ce petit billet de blog, mais c'est toujours intéressant de voir et comprendre les mécanismes internes à Drupal pour étendre le comportement standard des entités sans avoir à réinventer la roue. Le paramConverter, les routes typées et la surdéfinition de getOperations() forment un trio efficace et cohérent pour ajouter de nouvelles fonctionnalités aux entités de façon simple.
Le genre de pattern qui évite de se perdre dans l'implémentation.