Gifty #4 - Un nouveau type de champ

Dans cet article

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.

Les requêtes vont devoir être faites à l'envers : lors de la visualisation d'une liste-cadeau, nous ne chargeons pas les cadeaux référencés au sein de cette liste, mais les cadeaux qui référencent cette liste !

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
Lire la documentation et lire du code fonctionnellement approchant de la situation à mettre en place sont les deux astuces incontournables du développeur.

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.

Il aurait été tentant ici de créer un pseudo-field. Cette option aurait été tout à fait viable et beaucoup plus rapide. Je la démontrerais dans un prochain article. Par soucis de pérennité et d'information, je me lance ici dans la création d'un type de champ complet.

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>
Le code de notre formatter fonctionne parfaitement dans le cadre de notre projet. Toutefois, un certain nombre d'améliorations sont à envisager pour une exploitation en production.
  • 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 !

  • Une liste avec un cadeau
  • Ajouter un cadeau à la liste
  • Une liste avec des cadeaux paginés
Les fonctionnalités sont là. Dans un projet réel, du design serait maintenant plus que nécessaire !

Ajouter un commentaire

Votre nom sera affiché publiquement avec votre commentaire.
Votre email restera privé et n'est utilisé que pour vous notifier de l'approbation de ce commentaire.
Sur internet, vous pouvez être qui vous voulez. Soyez quelqu'un de bien :)