Avant, j'implémentais des hook, mais ça, c'était avant...
Cette fois, je commence sérieusement avec Drupal 8. Pour me faire un peu la main, je me suis aventuré dans l'implémentation d'un module de type "Field" que j'avais déjà implémenté en Drupal 7, à savoir un champ "Listing". Une petite explication sur ce champ se trouve ici. Dans les grandes lignes, c'est un champ qui peut servir à inclure des informations sous forme de liste label/description, permettant un affichage en mode "tableau" avec 2 colonnes.
Toute la complexité de l'article ne réside pas dans l'explication en elle-même, mais plutôt dans la manière de transmettre ces explications en étant assez large pour que vous puissiez comprendre les fondements, sans pour autant trop s'étaler sur des détails que vous pourrez découvrir par vous-même. Je vais donc tenter cet exercice de style, en espérant que cet article vous permettra de mieux comprendre la création de module avec Drupal 8.
Commençons : à l'époque, avec Drupal7, la création d'un module de type "Field" s'apparentait à la déclaration de plusieurs fonctions de type "hook" :
- hook_field_info
- hook_field_is_empty
- hook_field_formatter_info
- hook_field_formatter_view
- hook_field_widget_info
- hook_field_widget_form
Ces quelques fonctions permettaient d'indiquer à Drupal qu'on déclare un module de type "Field", qu'on déclare un "widget" ainsi qu'un "formatteur".
Avec Drupal 8, les choses se passent de manière totalement différentes. L'utilisation de hook, bien que toujours existante pour pas mal de fonctionnalités, n'est plus vraiment de mise avec un module de type "Field".
Drupal 8 introduit beaucoup de nouvelles notions issues de la POO (programmation orientée objet) comme les notions d'héritage, par exemple, que nous allons voir un peu plus tard.
Tout d'abord, quelques éléments importants :
- Drupal 8 utilise symfony en arrière-plan. Symfony est un framework qui a fait ses preuves au fil du temps, donc stable et tout à fait adapté pour se baser dessus afin de créer des systèmes tels que Drupal.
- Symfony utilise un pattern nommé "MVC", qui est l'acronyme de Model-View-Controller. Dans ce pattern, le code est décomposé en 3 éléments : le modèle, qui s'occupe de gérer les données, la vue, qui va proposer un affichage des données, et enfin le controleur, qui s'occupe de synchroniser les 2 premiers. Si vous désirez en savoir + au sujet du pattern MVC, vous trouverez une explication de base sur wikipedia.
- MVC est un modèle qui découple fortement les différents aspects du code. En général, le modèle n'a pas besoin de connaître la vue, la vue n'a pas besoin de connaître le modèle. Plusieurs vues peuvent se baser sur les mêmes données et c'est le contrôleur qui décidera dans quel cas telle ou telle vue doit être utilisée. De même le modèle peut radicalement changer sans avoir à modifier une quelconque ligne de code de la vue. Bref... C'est un modèle de construction intéressant à bien des niveaux pour obtenir un code clair, découpé en multiples fichiers qui possèdent chacun une partie distincte de l'implémentation finale.
- Drupal 8 se base sur la norme PSR-4 pour la structuration des répertoires. Un peu de lecture à ce propos ici.
- L'utilisation de namespaces : le "namespace" permet de définir un "espace de nommage" dans lequel notre module va exister. Un namespace cohérent pour les modules débute par {module_name}/lib/Drupal/
Vous vous demandez peut-être pourquoi je vous explique tout ça ? Parce que c'est justement cette logique de découpe qui va dorénavant être utilisée avec Drupal 8.
Revenons-en au module. Voici comment se présente la structure de répertoire pour ce dernier :
Comme vous le voyez, j'ai placé mon module dans /sites/all/modules/custom. J'utilise depuis longtemps cette hiérarchie car elle me permet de séparer les modules de contribution (/sites/all/modules/contrib) de mes propres modules, afin de retrouver facilement mes "petits".
La création d'un module se fait maintenant d'une manière beaucoup plus "complexe" à première vue, mais au final elle est plus intuitive lorsqu'on a déjà travaillé en programmation orientée objet, qui plus est avec des framework implémentant le pattern MVC.
Lorsque je travaillais beaucoup avec le langage ActionScript 3 pour créer des applications Flash et Flex, j'ai longtemps travaillé avec le framework PureMVC qui était une couche de base pour utiliser le pattern MVC. J'ai souvenir d'une discussion avec François aka Ceone, ou nous disions que le passage d'AS2 à AS3 amenait une redéfinition complète de la manière de travailler. AS2 était très procédural, peu de fichiers mais beaucoup de code contenu dans chaque fichier, alors qu'AS3, avec une programmation orientée objet, était à l'inverse une manière de travailler avec énormément de fichiers, mais peu de code dans chaque fichier. On passait d'une logique "fourre tout" à une logique "catégorisée" qui définissait les limites de chaque classe à implémenter.
Bref. Lorsqu'on regarde la structure de répertoire, on peut y voir les éléments suivants :
- Un répertoire /config/schema qui va définir le schéma de base de données du module
- Un répertoire /templates qui va permettre de stocker les templates twig à utiliser dans notre module
- Un répertoire /lib/Drupal/listing qui va contenir le code de notre module. Vu que c'est un module de type "Field", on déclare ensuite un répertoire "Plugin/Field" qui va contenir des sous-répertoires pour nos différents besoins : FieldFormatter, FieldType, FieldWidget.
A la place d'utiliser des hook pour définir nos fonctionnalités, ce sont maintenant ces répertoires dans "Plugin/Field" qui indiquent à Drupal quels sont les fichiers utilisables.
Enfin, on peut voir un fichier listing.info.yml, qui correspond anciennement au fichier .info dans Drupal 7, et le fichier .module qui définit la base du module.
C'est maintenant que ça commence à se corser... Reprenons le module : un champ de type liste avec label / description. La description proposait un champ de type textarea qui devait permettre à l'utilisateur final de sélectionner le format à utiliser (Plain text, Advanced HTML, Full HTML). C'est ce point en particulier qui m'a posé quelques problèmes lors de l'implémentation avec D8. Lorsque j'avais créé ce module en D7, j'avais utilisé hook_presave pour séparer le texte du champ de son format. Mais point de hook_presave avec D8. J'ai eu des problèmes car le système ne reconnaissait pas, ou plutôt le champ textare ne renvoyait pas le bon format de données. En lieu et place de renvoyer une chaine de caractère (le contenu du textarea) il fournissait au système un tableau contenant ET la chaine de caractères, ET le format à utiliser.
Le problème s'était également posé avec D7, et hook_presave avait servi à décomposer les informations, de la manière suivante :
/** * Implementation of hook_field_presave * * Used to split description into value/format * * @param $entity_type * @param $entity * @param $field * @param $instance * @param $langcode * @param $items */ function listing_field_presave($entity_type, $entity, $field, $instance, $langcode, &$items) { foreach ($items as $delta => $value) { _listing_process($items[$delta], $delta, $field, $entity); } } /** * Prepares the item description and format for storage. * * @param $item * @param $delta * @param $field * @param $entity */ function _listing_process(&$item, $delta = 0, $field, $entity) { //dsm( $item ); if( isset( $item['description'] ) ) { if( is_array( $item['description'] ) ) { $item['format'] = $item['description']['format']; $item['description'] = $item['description']['value']; } else { $item['format'] = $item['format']; $item['description'] = $item['description']; } } }
Avec Drupal 8, il était plus complexe de faire cette décomposition, mais pas impossible. Il aurait certainement fallu surdéfinir la fonction de sauvegarde de l'information pour qu'il teste si la valeur était de type string ou array, et faire le nécessaire, comme je l'avais fait avec hook_presave.
Néanmoins, et c'est là que les choses deviennent intéressantes, Drupal 8 utilise une logique orientée objet. Pour faire simple, plutôt que de déclarer un module et d'implémenter des hook, on déclare un module et des classes qui peuvent hériter d'autres classes. C'est très commun en programmation orientée objet. Et c'est ce qu'on va être amené à faire très souvent avec D8.
Chaque fichier qui se trouve dans les sous-répertoires de Plugin/Field sont en réalité des classes, qui héritent de classes supérieures, et donc de leurs variables, fonctions et comportements.
Dans mon cas, après une petite réflexion, je me suis rendu compte que mon champ "Liste" se rapprochait du champ "Body" qui possède un textarea pour le contenu, mais également un autre textarea plus basique pour la partie "résumé". Pourquoi dès lors ne pas s'inspirer de ce champ "Body" pour déclarer mon propre champ. J'ai donc inspecté un peu le champ "Body" (qu'on trouve dans /core/modules/text/lib/Drupal/text/Plugin/Field). Les fichiers intéressants sont les suivants :
- FieldFormatter/TextDefaultFormatter.php
- FieldType/TextItemBase.php
- FieldType/TextWithSummaryItem.php
- FieldWidget/TextareaWidget.php
- FieldWidget/TextareaWithSummaryWidget.php
Le formatteur n'est pas vraiment intéressant en soi, mis à part qu'il hérite de FormatterBase et qu'il surdéfini la fonction viewElements pour y inclure le nécessaire à l'affichage final
TextItemBase.php est une classe de base pour tous les champs de type texte. On peut voir qu'elle hérite de FieldItemBase et "implémente" l'interface PrepareCacheInterface. Une interface, c'est une sorte de squelette de classe qui défini des fonctions qui devront nécessairement être déclarées dans les classes qui l'implémentent. Cette manière de faire permet d'obliger le développeur à déclarer certaines fonctions, afin que les classes qu'ils crée puissent par exemple être utilisées avec le pattern Factory. Mais plus basiquement, l'utilisation d'interface permet de ne pas oublier de déclarer des méthodes nécessaires au bon fonctionnement de la classe.
TextWithSummaryItem.php est une classe qui hérite de la classe TextItemBase. ça veut dire que cette classe va posséder toutes les propriétés, méthodes et fonctionnalités de la classe parent. Puis le développeur va pouvoir surdéfinir certaines méthodes pour y ajouter les comportements désirés.
TextareaWidget est, tout comme TextItemBase, une classe qui sera utilisée comme classe "parent" de TextareaWithSummaryWidget. Elle déclare les éléments de base, puis TextareaWithSummaryWidget va déclarer les spécialisations pour un champ textarea incluant une valeur de sommaire.
Voilà pour la base. En regardant d'un peu plus près la classe TextareaWithSummaryWidget, on peut se rendre compte qu'elle surdéfinit la fonction formElement pour y ajouter un champ "summary".
/** * {@inheritdoc} */ function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, array &$form_state) { $element = parent::formElement($items, $delta, $element, $form, $form_state); $display_summary = $items[$delta]->summary || $this->getFieldSetting('display_summary'); $element['summary'] = array( '#type' => $display_summary ? 'textarea' : 'value', '#default_value' => $items[$delta]->summary, '#title' => t('Summary'), '#rows' => $this->getSetting('summary_rows'), '#description' => t('Leave blank to use trimmed value of full text as the summary.'), '#attached' => array( 'library' => array('text/drupal.text'), ), '#attributes' => array('class' => array('text-summary')), '#prefix' => '<div class="text-summary-wrapper">', '#suffix' => '</div>', '#weight' => -10, ); return $element; }
La première ligne fait appel à la fonction parent::formElement et lui passe tous les arguments. Cette déclaration appelle la fonction du même nom dans la classe parent, c'est à dire TextareaWidget. Dans cette classe parent, la fonction formElement déclare le champ textarea comme faisant partie du formulaire. Puis les lignes suivantes vont rajouter le champ "summary" avec les valeurs désirées.
Peut-être que vous commencez à comprendre ou je veux en venir ?? Dans mon cas, je veux un champ de type "textarea" (la description), et qu'il soit complété avec un champ de type "textfield" (le label).
Pourquoi ne pas utiliser la même logique ?? Ma classe ListingDefaultWidget pourrait, tout comme la classe TextareaWithSummaryWidget, hériter de la classe TextareaWidget, qui déclare tout le nécessaire pour un textarea. Puis j'utiliserai formElement pour déclarer mon textfield additionel :-) Idem pour la classe ListingItem ! faisons la hériter de la classe TextItemBase, et rajoutons-y le nécessaire pour nos besoins.
Pourquoi réinventer la roue alors qu'elle existe déjà ?? Le noyau de Drupal 8 déclare des classes qui contiennent tout ce qu'il faut pour un textarea, incluant même une fonctionnalité de mise en cache (TextItemBase:getCacheData). ça serait du gachis de recommencer à zéro alors qu'il me suffit d'hériter de ces classes, vous ne pensez pas ?
Là, je sens que vous commencez à comprendre, et à vous dire "Whaouw, on peut faire ça ? En fait c'est trop cool"... Les principes de l'héritage en POO sont là. Déclarer des classes génériques et pouvoir les réutiliser dans des classes de plus en plus spécialisées. Ici j'hérite de classes du noyau, mais nous pouvons imaginer créer nos propres classes génériques réutilisables, puis en hériter dans nos modules pour éviter la redondance de code. Et ça, c'était impossible, avec Drupal 7, car la notion d'héritage, voire même simplement de POO étaient quasi innexistantes. Il y a bien quelques modules qui déclaraient des classes (je pense notamment au module Flag) mais je ne pense pas que beaucoup de développeurs aient hérité de la classe Flag pour leurs propres besoins. Alors qu'avec Drupal 8, ça va devenir un comportement habituel.
Maintenant, prenons le code de mon module et décortiquons le pour comprendre un peu mieux.
FieldType/ListingItem.php
namespace Drupal\listing\Plugin\Field\FieldType; use Drupal\text\Plugin\Field\FieldType\TextItemBase; use Drupal\Core\Field\FieldStorageDefinitionInterface; use Drupal\Core\TypedData\DataDefinition; /** * Plugin implementation of the 'listing' field type * * @FieldType( * id = "listing", * label = @Translation("Listing"), * description = @Translation("This field store a keyed pair of label/description in the database."), * default_widget = "listing_default", * default_formatter = "listing_default" * ) */ class ListingItem extends TextItemBase { /** * {@inheritdoc} */ public static function schema(FieldStorageDefinitionInterface $field_definition) { return array( 'columns' => array( 'label' => array( 'description' => 'The label of the listing item', 'type' => 'varchar', 'length' => 255, 'not null' => FALSE, ), 'value' => array( 'description' => 'The content of the listing item', 'type' => 'text', 'size' => 'big', 'not null' => FALSE, ), 'format' => array( 'type' => 'varchar', 'length' => 255, 'not null' => FALSE, ), ), 'indexes' => array( 'format' => array('format'), ), ); } /** * {@inheritdoc} */ public static function defaultInstanceSettings() { return array( 'text_processing' => 0, ) + parent::defaultInstanceSettings(); } /** * {@inheritdoc} */ public static function propertyDefinitions(FieldStorageDefinitionInterface $field_definition) { // retrieve parent definition $properties = parent::propertyDefinitions($field_definition); // add label definition $properties['label'] = DataDefinition::create('string') ->setLabel(t('Label')); return $properties; } /** * {@inheritdoc} */ public function isEmpty() { $empty = parent::isEmpty(); $label = $this->get('label')->getValue(); return $empty && (!isset($label) || empty($label)); } /** * {@inheritdoc} */ public function instanceSettingsForm(array $form, array &$form_state) { $element = array(); $element['text_processing'] = array( '#type' => 'radios', '#title' => t('Text processing'), '#default_value' => intval($this->getSetting('text_processing')), '#options' => array( t('Plain text'), t('Filtered text (user selects text format)'), ), ); return $element; } }
On commence par déclarer l'espace de nom. Il correspond à la hiérarchie de répertoire.
On "importe" ensuite les classes nécessaires au bon fonctionnement de notre classe. Exemple : ma classe va hériter de TextItemBase, il est donc nécessaire d'indiquer qu'on va l'utiliser, et de l'inclure en donnant son namespace complet :
use Drupal\text\Plugin\Field\FieldType\TextItemBase;
On va ensuite déclarer notre classe en indiquant son nom, et en utilisant le mot clé "extends" pour indiquer de quelle classe elle va hériter (pour peu qu'on veuille la faire hériter d'une classe, bien entendu).
Vous avez certainement remarqué qu'il y a un commentaire au dessus de la déclaration de la classe. Ce commentaire n'est pas là juste pour faire joli. Il contient des méta-données que Drupal va utiliser pour comprendre à quoi sert la classe. la partie @FieldType indique à Drupal que cette classe est une déclaration de type de champ, et lui indique :
- l'identifiant du champ (id = "listing"),
- le nom du champ (label = @Translation("Listing"),
- la description
- Le widget par défaut
- Le formatteur par défaut
Ne négligez pas cette déclaration, sinon votre module ne sera pas reconnu en tant que module de type "Field".
La première fonction déclarée est schema. Elle définit le schéma de la base de données que notre champ va utiliser. Un champ label de type "varchar", un champ "value" de type "text" et un champ "format" de type "varchar". Le champ sera donc à même de stocker un label, une description ainsi qu'un format d'entrée pour la description.
La fonction defaultInstanceSettings fournit la configuration par défaut de l'instance.
La fonction propertyDefinitions permet d'indiquer des propriétés de champ qui seront visibles dans la gestion/configuration du champ
La fonction isEmpty va indiquer dans quelles circonstances le champ doit être considéré comme vide.
Enfin, la fonction instanceSettingsForm définit les éléments de formulaires à afficher lors de la configuration du champ. Ici, ça sera un bouton radio qui permettra au webmaster de choisir le type de format d'entrée de base du champ : est-ce que ça sera un champ de texte simple, ou un champ qui autorisera des formats d'entrées particuliers ?
Vous constaterez qu'il n'y a pas grand chose dans ma classe. Quelques déclarations de fonctions (je devrais plutôt parler de surdéfinition, même...), rien de plus. Et chaque fonction est lisible.
Passons à la suite : FieldWidget/ListingDefaultWidget.php
namespace Drupal\listing\Plugin\Field\FieldWidget; use Drupal\text\Plugin\Field\FieldWidget\TextareaWidget; use Drupal\Core\Field\FieldItemListInterface; use Symfony\Component\Validator\ConstraintViolationInterface; /** * Plugin implementation of the 'listing_default' widget * * @FieldWidget( * id = "listing_default", * label = @Translation("Listing"), * field_types = { * "listing" * } * ) */ class ListingDefaultWidget extends TextareaWidget { public static function defaultSettings() { return array( 'size' => 60, 'label_placeholder' => t('Label'), 'placeholder' => t('Description'), ) + parent::defaultSettings(); } public function settingsForm(array $form, array &$form_state) { $element = parent::settingsForm($form, $form_state); $element['size'] = array( '#type' => 'number', '#title' => t('Size of textfield'), '#default_value' => $this->getSetting('size'), '#required' => TRUE, '#min' => 1, ); $element['label_placeholder'] = array( '#type' => 'textfield', '#title' => t('Label placeholder'), '#default_value' => $this->getSetting('label_placeholder'), '#description' => t('Text that will be shown inside the label field until a value is entered. This hint is usually a sample value or a brief description of the expected format.'), ); return $element; } public function settingsSummary() { $summary = parent::settingsSummary(); $summary[] = t('Textfield size: !size', array('!size' => $this->getSetting('size'))); $label_placeholder = $this->getSetting('label_placeholder'); if (!empty($label_placeholder)) { $summary[] = t('Label placeholder: @placeholder', array('@placeholder' => $label_placeholder)); } return $summary; } /** * {@inheritdoc} */ public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, array &$form_state) { $element = parent::formElement($items, $delta, $element, $form, $form_state); $element['label'] = array( '#type' => 'textfield', '#title' => t('Label'), '#default_value' => isset($items[$delta]->label) ? $items[$delta]->label : NULL, '#placeholder' => $this->getSetting('label_placeholder'), '#size' => $this->getSetting('size'), '#attributes' => array('class' => array('text-full')), ); return $element; } }
Encore une fois : déclaration du namespace et import des classes nécessaires. Vous voyez que la classe hérite de TextareaWidget, et qu'il y a également un commentaire contenant des méta-données avant la déclaration de la classe.
Ici aussi, quelques fonctions sont surdéfinies, rien de plus.
defaultSettings est une fonction qui indique la configuration par défaut du champ
settingsForm va déclarer les champs indiqués dans defaultSettings pour les inclure dans le formulaire de configuration.
settingsSummary va permettre de "résumer" la configuration qui sera choisie par le webmaster
Enfin, formElement déclare le champ "label" qui vient s'ajouter au champ textarea, ce dernier étant déjà implémenté dans la classe parent.
Voici enfin la déclaration du formatteur par défaut : FieldFormatter/ListingDefaultFormatter.php
namespace Drupal\listing\Plugin\Field\FieldFormatter; use Drupal\Core\Field\FormatterBase; use Drupal\Core\Field\FieldItemListInterface; /** * Plugin implementation of the 'listing_default' formatter. * * @FieldFormatter( * id = "listing_default", * label = @Translation("Listing"), * field_types = { * "listing" * } * ) */ class ListingDefaultFormatter extends FormatterBase { /** * {@inheritdoc} */ public function viewElements(FieldItemListInterface $items) { $elements = array(); $list = array(); // iterate on each item to display it foreach ($items as $delta =--> $item) { // prepare each item by using a template $source = array( '#theme' => 'listing_default', '#label' => check_plain($item->label), '#value' => $item->processed, ); // add each item into an array $list[] = drupal_render($source); } // process final array as item list $elements = array( '#theme' => 'item_list', '#items' => $list, ); return $elements; } }
On prend les mêmes et on recommence... Déclaration du namespace, import des classes nécessaires, déclaration des méta-données sous forme de commentaire, déclaration de la classe, et enfin, surdéfinition de la fonction "viewElements" pour formatter les données selon mes besoins.
Dans mon cas, j'ai utilisé un template d'affichage afin de permettre au webmaster de le modifier si nécessaire. Je thème ensuite le tout sous forme de liste. Le template a été déclaré dans le fichier .module via hook_theme :
Et c'est un template "twig" :
{% if label %}<div class="listing-label">{{ label }}</div> {% endif %} {% if value %} <div class="listing-description">{{ value }}</div> {% endif %}
Voilà !! Mon plugin est implémenté ! Sachez que j'ai intentionnellement été vague sur les fonctions et le code, car le but de cet article n'était pas de détailler mon module "Liste" mais plutôt de vous donner un aperçu de ce qui est faisable avec Drupal 8. Et cet aperçu est très limité en regard de toutes les possibilités que cette nouvelle mouture apporte. J'espère vous avoir amené une certaine réflexion sur la manière d'implémenter des modules et de réutiliser du code, que ça soit le votre ou celui de modules du noyau ou encore de modules de contribution