Cet article est le quatrième de la suite "10h pour créer un business en ligne". Je recommande évidemment d'avoir lu les articles précédents pour continuer. Pour rappel, ce défi consiste en la création d'une version alpha (ou POC - Proof Of Concept) d'un outil de gestion de liste-cadeaux en ligne, du type liste de mariage, de naissance ou de Noël et autres.
00:00:00
Chronomètre en ligne et Spotify, vous connaissez désormais ma routine de travail ! Pour cette session, ce sera Funkadelic qui m'accompagnera, à commencer par le titre Maggot Brain.
Cela fait désormais plusieurs jours que végète le projet, ma fille entre dans sa 6ème semaine, et nom de Dieu ! Je sais pas si c'est pour tous les bébés comme ça, mais nous passons par une phase bien compliquée ou mademoiselle mange ou pleure, mais elle a supprimé l'étape dormir (c'est pas essentiel : elle tient de son père à ce sujet !) Quoi qu'il en soit, c'est donc un bon moment plus tard que je reprend ce défi, et donc l'écriture de cet article avec. Puisque j'évoque l'article, remontez vos bretelles et servez-vous un café : ce sera sûrement le plus technique de la série ! Pour ceux qui ne boivent pas de café, je vous propose dans l'article suivant une méthode plus simple qui remplacera celle-ci, donc bon… finalement vous pouvez sauter l'article :)
Réflexions
Lors du dernier article en partie 3, nous nous sommes quittés sur la mise en place d'entités sur mesure pour notre projet : giftlist qui représente une liste-cadeau et gift qui représente un cadeau au sein de cette liste.
Si nous lancions le projet avec le code dans l'état décrit dans cette précédente partie, nous aurions droit à un bon gros plantage des familles, la faute au champ de type gift qui avait été ajouté comme base field de l'entité giftlist. J'avais alors indiqué que ce type de champ n'existe pas, et pour cause, nous allons le mettre en place seulement maintenant !
D'instinct, on se dit que ça risque d'être un peu compliqué. Et si dans votre tête vous avez pensé "simple : c'est un champ référence à une entité" vous êtes tombé dans le panneau !
Effectivement, trivialement, on pourrait pencher sur un champ type entity reference où l'entité giftlist aurait une liste d'IDs référençant des cadeaux (gift). Cela aurait super bien marché si les cadeaux étaient ajoutés lors de l'édition de la liste-cadeau : c'est en effet ainsi que fonctionne le champ entity reference. Dans notre cas, ce champ ressemble beaucoup plus à des commentaires sur des articles : rien à configurer durant la création de l'entité liste-cadeau (giftlist), en revanche la visualisation de cette entité reprend la liste des cadeaux associés à laquelle on ajoute un formulaire permettant - côté visualisation - d'ajouter un nouveau cadeau en plus. Comme un commentaire !
Notre champ d'un type nouveau : "gift" va donc se baser en grande partie sur le champ type "commentaire". A savoir que notre entité gift va devoir posséder lui une entity reference sur la liste-cadeau et non l'inverse. giftlist n'a donc pas une liste d'ID de cadeaux associés, c'est gift qui a un ID de la giftlist à laquelle il appartient. Cela peut sembler un peu inhabituel, mais c'est aussi ainsi que fonctionne le champ commentaire.
La distinction est importante pour l'aspect "création" d'un nouvel élément gift au sein d'une liste-cadeau. Puisque le formulaire de création est disponible depuis la visualisation de la liste, l'ID de cette liste est disponible. Le formulaire peut donc avoir cet ID directement disponible pour la création du commentaire. L'ajout d'un élément cadeau peut donc se faire ainsi :
- chargement de la liste
- création du formulaire d'ajout d'un cadeau avec l'ID de la liste disponible en entrée
- l'utilisateur renseigne les données du cadeau
- le cadeau est sauvegardé avec la référence à sa liste pour être retrouvé au prochain chargement
- (optionnel) le cadeau est rendu graphiquement et directement ajouté en bout de liste en ajax
Si cela avait été en sens inverse, il aurait fallu deux requêtes BDD pour cette même opération:
- chargement de la liste
- création du formulaire d'ajout d'un cadeau
- l'utilisateur renseigne les données du cadeau
- le cadeau est sauvegardé, un ID est créé
- la liste doit être re-sauvegardée à son tour pour ajouter le nouvel ID à la liste des cadeaux référencés
- (optionnel) le cadeau est rendu graphiquement et directement ajouté en bout de liste en ajax
Cette nouvelle opération coûte donc une requête BDD de plus et nécessite un mécanisme pour garantir l'atomicité du tout, c'est à dire que l'ensemble s'effectue en cohérence et sans erreur. Que se passe-t-il si la resauvegarde de la liste échoue ? Le cadeau doit-il être supprimé ? Forcément, sinon il resterait en BDD des données cadeaux incohérentes et rattachées à aucune liste : impossible !
Création du type de champ
Toutes ces réflexions conduisent à la création d'un type de champ ressemblant étrangement au champ type commentaire qui existe dans Drupal. D'ailleurs, je dois avouer être beaucoup plus à l'aise avec la création d'entités personnalisées que de nouveaux types de champ, la faute à l'habitude. Il est très courant de créer de nouvelles entités dans un projet, tandis que créer un nouveau type de champ complet (pas juste une surcharge du widget d'un type existant) est somme toute assez rare.
Dans ce genre de cas, voici comment je procède :
- Je recherche un module, un élément de Drupal core, ou un tuto, sur quelque chose approchant et dont je pourrais m'inspirer : ici la classe
CommentItem.php
de core qui définit le type de champ commentaire. - Je lis la documentation en ligne de Drupal, ici Create a custom field type
Un nouveau type de champ se fait par l'usage d'un plugin FieldType
approprié, via la mise en place de l'annotation qui correspond, sur une classe qui - par convention - sera placée dans le dossier src/Plugin/Field/FieldType
au sein du module que vous aurez créé par avance, ici mon module gifty.
Le type de champ se déclare par annotation et s'adjoint d'au moins un widget et un formatter associé. Ceux-ci peuvent être entièrement créés pour l'occasion, ou utiliser des classes existantes en provenance de Drupal ou d'un module. Le widget définit la manière dont se comportera le champ côté édition de l'entité à laquelle il est attaché, tandis que le formatter définit la manière dont est rendue le champ côté visualisation. Pour notre part, nous n'avons besoin de rien côté édition. Côté visualisation, nous afficherons la liste de cadeau dans le style d'affichage souhaité, et accompagnée d'un formulaire de création pour ajouter un nouveau cadeau.
Mon nouveau type de champ est défini dans une classe GiftItem.php
. Je ne suis pas certain de l'intérêt de copier le code complet de cette classe ici qui est hyper courte et minimale. En revanche, l'astuce à noter est la suivante :
Ce champ ne sera jamais vide : nous surchargeons donc sa méthode isEmpty()
pour toujours renvoyer faux. La valeur du champ sera hardcodée à un booléen valant toujours TRUE. En effet, nous avons vu qu'il n'y a aucune donnée à sauvegarder ici, mais nous devons toujours effectuer le rendu du formatter qui lui affichera la liste des cadeaux référençant l'entité, ainsi que son formulaire de création. C'est vraiment, je pense, l'astuce à retenir de cet article !
Widget par défaut
Pour ce type de champ, et c'est une première pour Drupal, il n'y a rien à afficher dans le widget. Il n'existe encore dans Drupal core aucun widget purement vide. J'en crée donc un pour l'occasion, héritant de WidgetBase
. Son seul code est la méthode formElement
qui ajoutera au formulaire un champ caché harcodant la valeur à enregistrer pour notre champ à un boolean vrai. On ne peut pas faire plus simple comme implémentation ici !
Le formatter de champ
Voilà, c'est là que nous entrons dans ce qui finalement sera le plus complexe dans la mise en place de ce champ !
Il nous faut un formatter qui permette à l'administrateur de choisir un type de rendu pour l'entité gift, et qui à la visualisation affiche la liste des cadeaux ainsi qu'un formulaire de création d'un nouveau cadeau.
Je pense que le plus simple pour en parler est ici de donner le code source créé pour l'occasion.
Cliquez pour ouvrir/fermer ce bloc
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 <?php namespace Drupal\gifty\Plugin\Field\FieldFormatter; use Drupal\Core\Entity\Entity\EntityViewDisplay; use Drupal\Core\Entity\EntityDisplayRepositoryInterface; use Drupal\Core\Entity\EntityFormBuilderInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Field\FieldDefinitionInterface; use Drupal\Core\Field\FieldItemListInterface; use Drupal\Core\Field\FormatterBase; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Drupal\Core\Routing\RouteMatchInterface; use Symfony\Component\DependencyInjection\ContainerInterface; /** * Plugin implementation of the 'gift' formatter. * * @FieldFormatter( * id = "gift_default", * label = @Translation("Gift"), * field_types = { * "gift" * }, * quickedit = { * "editor" = "disabled" * } * ) */ class GiftDefaultFormatter extends FormatterBase implements ContainerFactoryPluginInterface { /** * The gift storage. * * @var \Drupal\gifty\GiftStorageInterface */ protected $storage; /** * The gift render controller. * * @var \Drupal\Core\Entity\EntityViewBuilderInterface */ protected $viewBuilder; /** * The entity type manager. * * @var EntityTypeManagerInterface */ protected $entityTypeManager; /** * The entity form builder. * * @var \Drupal\Core\Entity\EntityFormBuilderInterface */ protected $entityFormBuilder; /** * The entity display repository. * * @var \Drupal\Core\Entity\EntityDisplayRepositoryInterface */ protected $entityDisplayRepository; /** * @var \Drupal\Core\Routing\RouteMatchInterface */ protected $routeMatch; /** * {@inheritdoc} */ public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { return new static( $plugin_id, $plugin_definition, $configuration['field_definition'], $configuration['settings'], $configuration['label'], $configuration['view_mode'], $configuration['third_party_settings'], $container->get('entity_type.manager'), $container->get('entity.form_builder'), $container->get('entity_display.repository'), $container->get('current_route_match') ); } /** * Constructs a new GiftDefaultFormatter. * * @param string $plugin_id * The plugin_id for the formatter. * @param mixed $plugin_definition * The plugin implementation definition. * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition * The definition of the field to which the formatter is associated. * @param array $settings * The formatter settings. * @param string $label * The formatter label display setting. * @param string $view_mode * The view mode. * @param array $third_party_settings * Third party settings. * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager * The entity type manager. * @param \Drupal\Core\Entity\EntityFormBuilderInterface $entity_form_builder * The entity form builder. * @param \Drupal\Core\Entity\EntityDisplayRepositoryInterface $entity_display_repository * The entity display repository. * @param \Drupal\Core\Routing\RouteMatchInterface $route_match * The route match object. */ public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, $label, $view_mode, array $third_party_settings, EntityTypeManagerInterface $entity_type_manager, EntityFormBuilderInterface $entity_form_builder, EntityDisplayRepositoryInterface $entity_display_repository, RouteMatchInterface $route_match) { parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $label, $view_mode, $third_party_settings); $this->entityTypeManager = $entity_type_manager; $this->viewBuilder = $entity_type_manager->getViewBuilder('gift'); $this->storage = $entity_type_manager->getStorage('gift'); $this->entityFormBuilder = $entity_form_builder; $this->entityDisplayRepository = $entity_display_repository; $this->routeMatch = $route_match; } /** * {@inheritdoc} */ public static function defaultSettings() { return [ 'view_mode' => 'default', 'gifts_per_page' => 50, 'pager_id' => 0, ] + parent::defaultSettings(); } /** * {@inheritdoc} */ function viewElements(FieldItemListInterface $items, $langcode) { $elements = [ 'gifts' => [], ]; $entity = $items->getEntity(); $field_name = $this->fieldDefinition->getName(); $gifts_per_page = $this->getSetting('gifts_per_page'); $pager_id = $this->getSetting('pager_id'); $gifts = $this->storage->loadGifts($entity, $field_name, $gifts_per_page, $pager_id); if ($gifts) { $build = $this->viewBuilder->viewMultiple($gifts, $this->getSetting('view_mode')); $build['pager']['#type'] = 'pager'; $build['pager']['#route_name'] = $this->routeMatch->getRouteObject(); $build['pager']['#route_parameters'] = $this->routeMatch->getRawParameters()->all(); if ($this->getSetting('pager_id')) { $build['pager']['#element'] = $this->getSetting('pager_id'); } // @todo add id wrapper for AJAX refresh. $elements['gifts'] += $build; } // Add gift form. $gift = $this->entityTypeManager->getStorage('gift')->create([ 'giftlist_id' => $entity->id(), 'field_name' => $field_name, ]); // @todo render this form via lazyBuilder. $elements['gift_form'] = $this->entityFormBuilder->getForm($gift); // @todo add AJAX to refresh the list of gifts without page reload after creation. return $elements; } /** * {@inheritdoc} */ public function settingsForm(array $form, FormStateInterface $form_state) { $element = []; $view_modes = $this->getViewModes(); $element['view_mode'] = [ '#type' => 'select', '#title' => $this->t('Gifts view mode'), '#description' => $this->t('Select the view mode used to show the list of gifts.'), '#default_value' => $this->getSetting('view_mode'), '#options' => $view_modes, // Only show the select element when there are more than one options. '#access' => count($view_modes) > 1, ]; $element['gifts_per_page'] = [ '#type' => 'number', '#title' => t('Gifts per page'), '#default_value' => $this->getSetting('gifts_per_page'), '#required' => TRUE, '#min' => 1, '#max' => 1000, ]; $element['pager_id'] = [ '#type' => 'select', '#title' => $this->t('Pager ID'), '#options' => range(0, 10), '#default_value' => $this->getSetting('pager_id'), '#description' => $this->t("Unless you're experiencing problems with pagers related to this field, you should leave this at 0. If using multiple pagers on one page you may need to set this number to a higher value so as not to conflict within the ?page= array. Large values will add a lot of commas to your URLs, so avoid if possible."), ]; return $element; } /** * {@inheritdoc} */ public function settingsSummary() { // View mode summary. $view_mode = $this->getSetting('view_mode'); $view_modes = $this->getViewModes(); $view_mode_label = isset($view_modes[$view_mode]) ? $view_modes[$view_mode] : 'default'; $summary = [$this->t('Gift view mode: @mode', ['@mode' => $view_mode_label])]; // Gift per page summary. $gifts_per_page = $this->getSetting('gifts_per_page'); $summary[] = $this->t('Gifts per page: @gifts_per_page', ['@gifts_per_page' => $gifts_per_page]); // Pager id. if ($pager_id = $this->getSetting('pager_id')) { $summary[] = $this->t('Pager ID: @id', ['@id' => $pager_id]); } return $summary; } /** * {@inheritdoc} */ public function calculateDependencies() { $dependencies = parent::calculateDependencies(); if ($mode = $this->getSetting('view_mode')) { if ($display = EntityViewDisplay::load("gift.$mode")) { $dependencies[$display->getConfigDependencyKey()][] = $display->getConfigDependencyName(); } } return $dependencies; } /** * Provides a list of gift view modes. * * @return array * Associative array keyed by view mode key and having the view mode label * as value. */ protected function getViewModes() { return $this->entityDisplayRepository->getViewModeOptions('gift'); } }
Ce code est à la fois simple à suivre, et complexe par le nombre de dépendances entrant en jeu, notamment vers le service de storage de l'entité, le repository pour les styles d'affichage, la génération dynamique de formulaire, etc… Le but ici n'étant pas de créer un tuto sur ces sujets, on pourra noter le render array finalement assez simple mis en place pour le champ : une liste de cadeaux rendus selon le style choisi et un formulaire de création. L'affichage de l'ensemble se fait donc au moyen d'un template très simple.
Cliquez pour ouvrir/fermer ce bloc
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 <section{{ attributes }}> {% if gifts and not label_hidden %} {{ title_prefix }} <h2{{ title_attributes }}>{{ label }}</h2> {{ title_suffix }} {% endif %} {{ gifts }} {% if gift_form %} <h2{{ content_attributes }}>{{ 'Add new gift'|t }}</h2> {{ gift_form }} {% endif %} </section>
- Du fait du passage nécessaire du giftlist_id au formulaire, la génération de celui-ci ne peut pas être mise en cache de manière optimale. Il est donc conseillé en production de passer par un lazyBuilder pour différer le rendu de celui-ci et ainsi accélérer le rendu général de la page
- Ce formulaire crée un nouveau cadeau et l'ajoute à la page. Le rechargement de cette dernière semble donc inutile et pourrait être remplacé par l'ajout en AJAX du cadeau en fin de liste.
Bilan
Aussi simple et pauvre que peut apparaître une solution, elle reflète rarement le temps de la mettre au point. C'est exactement ce qu'il s'est passé ici. Résumé en quelque mots d'article, il m'aura tout de même fallu une heure et demi pour mettre tout ça au point. Je confesse certes volontiers que j'ai rarement le besoin de créer des types de champs custom, mais tout de même ! Et puis pour être honnête, j'ai commencé par faire exactement ce contre quoi je mettais en garde dans l'article précédent: faire rentrer des ronds dans des carrés ! J'ai d'abord essayé de construire quelque chose à partir de entity reference, avant de construire entièrement le champ comme présenté ici.
Mon manque de réflexion initial a montré aussi ses limites ici puisqu'une partie non négligeable du temps a été perdu en modification des base fields de mes entités, et donc en désinstallation / réinstallation de mon module gifty un bon nombre de fois !
Toutefois, entre l'article précédent et celui-ci, en 3h sur les deux sessions cumulées, nous avons mis au point la base fonctionnelle solide de ce projet : nous pouvons créer des listes et associer des cadeaux à ces listes. C'est la base minimale, le MVP (minimal viable product ou produit viable minimal). Moins que ça, il n'y a rien, à partir de maintenant, ce n'est plus que de la valeur ajoutée à un service basique existant !
Je compte mettre à profit ma prochaine heure de développement sur le sujet pour finaliser le système de permission et améliorer l'interface. Nous serons alors fortement proche de quelque chose de bien !
Pour l'instant, et sans avoir encore pris le temps de design, nous avons des listes, et des cadeaux, mais c'est visuellement pas ouf !
Ajouter un commentaire