Gifty #5 - Derniers réglages

Dans cet article

Cet article est le cinquiè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 activé, musique lancée, on est parti ! Alors, on ne se moque pas, on ne juge pas… mais ma session de code pour cette cinquième partie est accompagnée de l'OlymputaindePia, un live d'Ultra Vomit. Ouaip, j'admets que d'une session à l'autre on passe sur des genres très différents, c'est l'éclectisme me direz-vous !

Si vous avez tout lu jusque-là, de l'introduction de cette série jusqu'au chapitre précédent, vous avez peut-être la même sensation que moi. J'ai l'impression en effet d'avoir atteint l'objectif de cette série d'article avec un POC (Proof of Concept) suffisant d'un point de vue fonctionnel. J'ai même l'impression d'en avoir fait trop. Dans le premier article, nous avons mis en place le site. Le second article était au-delà de mon objectif principal puisque nous avons créé une page d'accueil. Les troisième et quatrième-bis articles ont été l'occasion de la mise en place fonctionnelle effective de ce qu'est une liste de cadeau et un cadeau associé. On notera que l'article quatre s'avère au final obsolète au vu de la réécriture en quatre bis. Nous pourrions donc nous arrêter là.

Il est vrai aussi que les screenshots en fin de l'article précédent laissent un sentiment d'inachevé. Dans cet article, nous allons donc fignoler : rendre un peu plus joli notre page liste-cadeau, améliorer la gestion des permissions liées à ces listes, et surtout, améliorer l'utilisabilité en limitant au maximum les rechargements de page, c'est à dire en utilisant AJAX au maximum sur cette page.

Amélioration graphique

Je ne suis pas certain que la mise en œuvre d'une librairie graphique pour nos listes-cadeaux nécessite réellement une description détaillée. Nous avions créé une mise en page spécialement pour la page d'accueil dans l'article dédié, il suffit ici de refaire la même chose.

Mise en forme

Dans la déclaration de mon fichier *.libraries.yml j'ajoute donc la déclaration d'une nouvelle librairie graphique.

1
2
3
4
5
6
7
8
9
10
11
giftlist:
  css:
    theme:
      css/content/giftlist.css: { }
      css/content/gift.css: { }
  js:
    js/giftlist.js: {}
  dependencies:
    - core/jquery
    - core/drupal
    - core/jquery.once

Cette bibliothèque sera injectée via la propriété #attached dans les preprocess des entités giftlist et gift. Je l'ajoute sur les deux afin d'être certain de sa disponibilité, que ce soit en cas de liste vide ou d'affichage d'un cadeau seul, le cas ne m'est pas encore apparu, mais sait-on jamais.

Inutile de décrire en détail le CSS associé : il s'agit de limiter la largeur de nos cadeaux et les afficher en grille. Une bordure, une légère ombre, un peu d'alignement et de couleur et le tour est joué.

Mise en page simple et efficace
En quelques minutes et lignes de CSS : une mise en page simple et efficace

Encore une fois, il y aurait du travail supplémentaire à faire pour rendre le tout opérationnel en production. Aussi bien au niveau du responsif en mobile que de la gestion d'une image par défaut ou encore de la compatibilité inter-navigateur, je n'ai pas vraiment testé grand-chose ici selon la règle du 80/20 : il me faudrait le triple du temps pour ces détails comparés au visuel propre mis en place en quelques minutes.

A noter que les icônes sont des svg libres d'usage en provenance de fontawesome.

Amélioration du formulaire d'édition

De la même façon que nous l'avions fait pour les placeholders dans l'article sur la mise en page de la page d'accueil, je crée une nouvelle fonction #process pour l'ensemble des champs pour en supprimer la description. Je modifie donc le hook introduit précédemment et rajoute ma nouvelle méthode. Elle réagira à un nouvel attribut du formulaire.

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
/**
 * Implements hook_element_info_alter().
 */
function gifty_theme_element_info_alter(array &$types) {
  foreach (array_keys($types) as $type) {
    $allowed_types = [
      'form',
      'textarea',
      'textfield',
      'tel',
      'email',
      'url',
      'file',
      'image',
      'managed_file',
      'number',
      'password',
      'password_confirm',
    ];
    if (in_array($type, $allowed_types)) {
      $types[$type]['#process'][] = 'gifty_theme_process_element_autoplaceholder';
      $types[$type]['#process'][] = 'gifty_theme_process_element_remove_description';
    }
  }
}
 
/**
 * Element process callback: remove all #description.
 *
 * @param array $element
 *   The render array element.
 * @param \Drupal\Core\Form\FormStateInterface $form_state
 *   The FormStateInterface object for this form
 *
 * @return array
 *   The processed element.
 */
function gifty_theme_process_element_remove_description($element, &$form_state) {
  $form = $form_state->getCompleteForm();
 
  if (isset($form['#form_remove_description'])) {
    if (isset($element['#description'])) {
      unset($element['#description']);
    }
  }
 
  return $element;
}
Formulaire de création d'un cadeau
Le formulaire de création d'un cadeau est nettement plus propre ainsi.

Limitation du nombre de caractères de la description

Afin de limiter efficacement la saisie du nombre de caractères dans la description, j'ajoute et configure le module Textfield Counter

Affichage simplifié de l'URL d'un cadeau

Vous avez certainement remarqué, sur le visuel en début d'article, que l'affichage du lien du cadeau ne montre que le nom de domaine et non l'intégralité d'un lien qui peut être complexe. Pour ce faire, j'ai créé un nouveau formateur de champ pour le champ de type lien.

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
<?php
 
namespace Drupal\gifty\Plugin\Field\FieldFormatter;
 
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\link\Plugin\Field\FieldFormatter\LinkFormatter;
 
/**
 * Plugin implementation of the 'link' formatter.
 *
 * @FieldFormatter(
 *   id = "link_domain",
 *   label = @Translation("Domain only link"),
 *   field_types = {
 *     "link"
 *   }
 * )
 */
class LinkDomainFormatter extends LinkFormatter {
 
  /**
   * {@inheritdoc}
   */
  public function viewElements(FieldItemListInterface $items, $langcode) {
    $elements = parent::viewElements($items, $langcode);
 
    foreach((array) $elements as $delta => $element) {
      $link = parse_url($elements[$delta]['#url']->toString());
      $elements[$delta]['#title'] = $link['host'];
    }
 
    return $elements;
  }
}

Ajout des liens d'édition

Avez-vous remarqué les liens "modifier" / "supprimer" associés à un cadeau ?

Ils sont créés en utilisant la même astuce que lors de l'article 4bis : un pseudo-champ ultra simple, à l'image de ce qui est fait pour les livres dans Drupal core.

Je commence par déclarer mon pseudo-champ dans le fichier gifty.module en utilisant le hook_entity_extra_field_info()

1
2
3
4
5
6
7
8
9
10
11
12
/**
 * Implements hook_entity_extra_field_info().
 */
function gifty_entity_extra_field_info() {
  $extra['gift']['gift']['display']['links'] = [
    'label' => t('Links'),
    'description' => t('Gift operation links'),
    'weight' => 100,
    'visible' => TRUE,
  ];
  return $extra;
}

La création de ces liens se fera, devinez-le… dans le fichier GiftViewBuilder.

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
<?php
 
namespace Drupal\gifty;
 
use Drupal\Component\Serialization\Json;
use Drupal\Core\Entity\EntityViewBuilder;
use Drupal\Core\Security\TrustedCallbackInterface;
use Drupal\Core\Url;
 
/**
 * View builder handler for gifts.
 */
class GiftViewBuilder extends EntityViewBuilder implements TrustedCallbackInterface {
 
  /**
   * {@inheritdoc}
   */
  public function buildComponents(array &$build, array $entities, array $displays, $view_mode) {
    parent::buildComponents($build, $entities, $displays, $view_mode);
 
    // Build the links on all entities.
    foreach ($entities as $id => $entity) {
      $bundle = $entity->bundle();
      $display = $displays[$bundle];
 
      // If the links pseudo-field is available on the display,
      // let's lazy-build the links.
      if ($display->getComponent('links')) {
        $build[$id]['links'] = [
          '#lazy_builder' => [
            get_called_class() . '::renderLinks', [
              $entity->id()
            ],
          ],
        ];
      }
    }
  }
 
  /**
   * #lazy_builder callback; builds a gift's links.
   *
   * @param string $entity_id
   *   The gift ID.
   *
   * @return array
   *   A renderable array representing the gift links.
   */
  public static function renderLinks($entity_id) {
    $links = [];
 
    // Get the gift entity.
    $storage = \Drupal::entityTypeManager()->getStorage('gift');
    $entity = $storage->load($entity_id);
 
    // Edit operation link.
    if ($entity->access('update')) {
      $links['gift-edit'] = [
        'title' => t('Edit'),
        'url' => $entity->toUrl('edit-form'),
      ];
    }
 
    // Delete operation link.
    if ($entity->access('delete')) {
      $links['gift-delete'] = [
        'title' => t('Delete'),
        'url' => $entity->toUrl('delete-form'),
      ];
    }
 
    // Book operation link.
    if ($entity->access('approve')) {
      $links['gift-approve'] = [
        'title' => t('Book it'),
        'url' => Url::fromRoute('entity.gift.approve', ['gift' => $entity->id()]),
      ];
    } else if (\Drupal::currentUser() != $entity->getOwnerId()) {
      $links['gift-forbidden'] = [
        'title' => t('Reserved'),
        'attributes' => [
          'title' => t('Someone already have booked this gift.'),
        ],
      ];
    }
 
    return [
      '#theme' => 'links__gift',
      '#links' => $links,
      '#attributes' => ['class' => ['links', 'inline']],
    ];
  }
 
  /**
   * {@inheritdoc}
   */
  public static function trustedCallbacks() {
    $callbacks = parent::trustedCallbacks();
    $callbacks[] = 'renderLinks';
    return $callbacks;
  }
}

Ces liens sont donc désormais corrélés aux permissions sur l'entité gift que nous devrons régler d'une manière ou d'une autre plus tard.

Ouverture des liens dans une popup

Je sais, je sais, ça va trop loin ! Inutile de faire tout cela si l'on s'en tient à notre objectif de départ. Mais je m'étais aussi fixé 10h que je n'ai pas encore épuisé, alors tant qu'à faire, faisons-nous plaisir ! Et mon petit kiff ici sera d'éviter au maximum à l'utilisateur un rechargement de page dans le cadre de l'utilisation de sa liste-cadeau. Nous allons donc ajaxifier les opérations en ouvrant les formulaires d'édition, ajout et suppression dans des popups modales.

Nos opérations d'édition et de suppression sur les cadeaux sont de simples liens. Depuis Drupal 8, il est extrêmement simple de transformer un lien pour en ouvrir la page de destination en modal. Il suffit de deux choses :

  • ajouter quelques classes sur le lien
  • être certain que la librairie JS soit incluse sur la page

Pour cela, je modifie mon callback de lazy-build des liens pour ajouter les attributs nécessaires. Voici l'exemple avec le lien d'édition. Tout ce qui est à ajouter tient donc dans l'élément #attributes.

1
2
3
4
5
6
7
8
9
$links['gift-edit'] = [
  'title' => t('Edit'),
  'url' => $entity->toUrl('edit-form'),
  'attributes' => [
        'class' => ['use-ajax'],
        'data-dialog-type' => 'modal',
        'data-dialog-options' => Json::encode(['width' => 800])
  ],
];
  • j'ajoute la classe 'use-ajax' au lien, et c'est tout ce qu'il faut ici !
  • j'ajoute un data attribut nommé dialog-type pour indiquer que je souhaite l'ouverture de la destination en fenêtre modale
  • et bien que ce soit optionnel, je passe ici une configuration de largeur pour ma fenêtre modale

En second lieu, je m'assure que la génération des liens incluse la libraire ajax permettant cette fenêtre modal. Pour cela, j'inclue la librairie définie par Drupal core/drupal.dialog.ajax via l'attribut classique #attached.

1
2
3
4
5
6
7
8
return [
  '#theme' => 'links__gift',
  '#links' => $links,
  '#attributes' => ['class' => ['links', 'inline']],
  '#attached' => [
    'library' => ['core/drupal.dialog.ajax'],
  ],
];

Simple non ?!

Ouverture d'un formulaire dans une popup

Pour notre formulaire de création d'un cadeau qui traine sur la page en dessous de la liste de cadeau, la situation est plus complexe. En effet, je souhaite un lien "ajouter un cadeau" dans une sorte de placeholder en dessous de la liste, ayant la taille d'un élément cadeau.

Lien d'ajout d'un cadeau
Le lien d'ajout d'un cadeau fait partie intégrante de la liste.

Pour faire cela, je n'ai d'autre choix que d'injecter un élément "bidon" (mon placeholder) comme dernier élément retourné par ma vue. Je dois donc interagir avec celle-ci après la requête de récupération des éléments, mais également après leur rendu. En effet, mon élément injecté n'étant pas de même nature, je ne peux pas compter sur la vue pour en faire le rendu toute seule. En résumé, j'utilise le hook_views_post_render.

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
/**
 * Implements hook_views_post_render().
 */
function gifty_views_post_render(\Drupal\views\ViewExecutable $view, &$output, \Drupal\views\Plugin\views\cache\CachePluginBase $cache) {
 
  if ($view->id() == 'giftlist_gifts') {
    
    // Fist add a col-x class to all elements
    // @see css for design.
    foreach ((array)$output['#rows'][0]['#rows'] as $id => $row) {
      $idx = $id % 3 + 1;
      $row['#attributes']['class'][] = "col-$idx";
      $output['#rows'][0]['#rows'][$id] = $row;
    }
 
    // Introduce a placeholder link to add form.
    $idx = count($output['#rows'][0]['#rows']) % 3 + 1;
    $output['#rows'][0]['#rows'][] = [
      '#type' => 'link',
      '#url' => \Drupal\Core\Url::fromRoute('entity.giftlist.add-gift', [
        'giftlist' => $view->argument['giftlist_id']->getValue()
      ]),
      '#title' => t('Add a gift'),
      '#attributes' => [
        'class' => ['use-ajax', 'gift', 'add--placeholder', "col-$idx"],
        'data-dialog-type' => 'modal',
        'data-dialog-options' => Json::encode(['width' => 800])
      ],
    ];
  }
}

On remarquera une petite complexification permettant l'ajout d'une classe qui m'a été nécessaire pour le design responsif du tout. Lorsque le nombre d'éléments dans la liste le nécessite, le placeholder passe à la ligne et devient plus fin, prenant toute la largeur de la liste.

Le placeholder se place en dessous de la liste suivant le nombre d'éléments de la liste
Le placeholder se place en dessous de la liste si le nombre d'éléments le nécessite.

La route entity.giftlist.add-gift est définie dans mon fichier gifty.routing.yml et se mappe sur la méthode suivante d'un controller créé pour l'occasion :

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
/**
 * Callback for opening the modal form.
 *
 * @param int $giftlist
 *   The giftlist ID to create gift for.
 * @param string $fieldname
 *   The giftlist ID to create gift for.
 *
 * @return AjaxResponse
 */
public function openModalAddForm(int $giftlist = NULL, $fieldname = NULL) {
  $response = new AjaxResponse();
 
  // Build a new appropriate gift.
  $gift = \Drupal\gifty\Entity\Gift::create([
    'giftlist_id' => $giftlist
  ]);
 
  // Get the modal form using the form builder.
  $modal_form = $this->entityFormBuilder->getForm($gift);
 
  // Add an AJAX command to open a modal dialog with the form as the content.
  $response->addCommand(new OpenModalDialogCommand($this->t('Add a new gift'), $modal_form, ['width' => '800']));
 
  return $response;
}

Son code est assez simple : il instancie un formulaire de création comme nous l'avons fait précédemment, mais fournit celui-ci au sein d'une commande ajax d'ouverture de fenêtre modale. Le résultat est naturellement l'ouverture d'une popup avec notre formulaire dedans.

Formulaire d'ajout au sein d'une popup
Formulaire d'ajout de cadeau affiché dans une popup.

Soumission AJAX des popups

Vous remarquerez que si l'on a réussi à ouvrir nos formulaires dans des popups, leur validation (l'ajout, l'édition ou la suppression de cadeau) est encore un processus qui nécessite un rechargement de page. Nous allons donc devoir modifier nos formulaires pour permettre à la réponse renvoyée d'être compatible avec un rafraichissement AJAX de la donnée qui correspond.

En d'autres termes :

  • valider la popup de confirmation de suppression doit supprimer le cadeau correspondant
  • sauvegarder la popup d'édition doit refléter les changements sur le cadeau sélectionné
  • sauvegarder la popup d'ajout doit ajouter le cadeau à la liste, selon le tri configuré : en haut, en bas, selon le prix, voir selon le tri choisi par l'utilisateur
Nous voulons permettre un fallback en cas de javascript non disponible : un mécanisme "à l'ancienne" sans popup et sans AJAX. Pour cela, nos modifications dans la soumission de nos formulaires devront être encapsulées via le code suivant :
1
2
3
4
5
6
use AjaxHelperTrait;
 
// Override to submit the form in AJAX.
if ($this->isAjax()) {
    // Our code here...
}

Dans la suite, je vais prendre l'exemple du formulaire de suppression, mais le travail est réalisé de la même façon pour les trois opérations.

Notre formulaire de suppression est classiquement une classe étendant ContentEntityDeleteForm. Nous allons surcharger la méthode buildForm pour injecter un callback supplémentaire pour la soumission du formulaire dans le cas où celui-ci venait à être affiché via un process ajax, donc dans notre popup.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
 * {@inheritdoc}
 */
public function buildForm(array $form, FormStateInterface $form_state) {
  $form = parent::buildForm($form, $form_state);
 
  // Override to submit the form in AJAX.
  if ($this->isAjax()) {
    $form['actions']['submit']['#ajax'] = [
      'callback' => [$this, 'submitModalFormAjax'],
      'event' => 'click',
    ];
    $form['actions']['cancel']['#attributes']['class'][] = 'dialog-cancel';
  }
  return $form;
}

submitModalFormAjax est une nouvelle méthode, appelée à la soumission de notre formulaire si celui-ci a été chargé en ajax.

Remarquez l'ajout de la classe dialog-cancel sur le bouton annulé de notre formulaire. Cela permettra à Drupal de mapper automatiquement l'évènement de fermeture de popup sur ce bouton, comme si l'utilisateur avait fermé la popup via la petite croix ajoutée par jQueryUI Dialog.

Notre callback de soumission fonctionne ainsi :

1
2
3
4
5
6
7
8
9
10
/**
 * AJAX callback handler that removes the deleted gift on screen.
 */
public function submitModalFormAjax(array $form, FormStateInterface $form_state) {
  $entity = $this->getEntity();
  $response = new AjaxResponse();
  $response->addCommand(new RemoveCommand('#gift-' . $entity->id()));
  $response->addCommand(new CloseDialogCommand());
  return $response;
}

Cette méthode est toute simple. Elle ajoute deux commandes ajax au résultat retourné à la page lorsque le formulaire est soumis.

  • une première commande permettant de supprimer l'élément ayant pour ID gift-X du HTML de la page
  • une seconde commande permettant de fermer la popup actuellement ouverte

L'édition et la création de cadeau fonctionnent de la même manière. Les principales différences viennent uniquement de ce qui est fait dans le nouveau handler ajouté :

  • en cas de formulaire non valide (donnée obligatoire manquante par exemple), nous devons remplacer le formulaire à l'écran par sa version présentant les erreurs
  • en cas d'édition, nous devons remplacer le cadeau modifiée par une nouvelle version de lui-même
  • dans les autres cas (dont l'ajout), nous allons effectuer le re-rendu complet de la liste cadeau pour afficher le nouveau cadeau dans la bonne position en fonction des filtres en cours
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
/**
 * AJAX callback handler tto edit / add gifts on screen based on current operation.
 */
public function submitModalFormAjax(array $form, FormStateInterface $form_state) {
  $response = new AjaxResponse();
 
  // If there are any form errors, re-display the form.
  if ($form_state->hasAnyErrors()) {
    $response->addCommand(new ReplaceCommand('#gift_form', $form));
  }
  else {
    if ($this->operation == 'edit') {
      // In edit mode, we can replace only the affected gift.
      $view_builder = $this->entityTypeManager->getViewBuilder('gift');
      $gift = $view_builder->view($this->entity, 'default');
      $response->addCommand(new ReplaceCommand('#gift-' . $this->entity->id(), $gift));
    } else {
      // Any other operation (default, add, etc..), we better rebuild everything.
      $giftlist = $this->entity->getGiftList();
      $gifties = GiftListViewBuilder::renderGifties($giftlist->id());
      $response->addCommand(new ReplaceCommand('#gifties', $gifties));
    }
    $response->addCommand(new CloseDialogCommand());
  }
 
  return $response;
}

Bilan

Encore une bonne heure de passée, mais le résultat en vaut la peine ! Non seulement la page est plus propre, mais son utilisation est relativement intuitive et peu invasive (aucun rechargement).

Evidemment, nous ne sommes pas non plus sur la réactivité d'un projet qui aurait été écrit en vue.js. Chaque action, même l'ouverture d'une popup, requiert l'intégralité d'une requête serveur, avec un bootstrap de Drupal et compagnie. A nous maintenant de rendre le tout suffisamment performant pour ne pas faire patienter des heures l'utilisateur lors de ces requêtes.

Je ne peux finir sans souligner l'incroyable simplicité de l'API Drupal qui nous a permis l'ajaxification complète de notre page sans une ligne de JS et quasi rien de PHP. Ce framework est complexe c'est vrai, mais incroyablement simple à utiliser une fois maitrisé.

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 :)