Créer un système d'annonces simple avec Drupal 8

Développé initialement pour ce site, notre système d'annonce simple regroupe plusieurs concepts intéressants à mettre en valeur dans un article en deux parties. Ici, nous évoquerons la réflexion autour du stockage de l'information et la construction du formulaire.

Le besoin fonctionnel à l'origine de ce développement est relativement simple et se retrouve dans de nombreux projets : nous souhaitons pouvoir faire apparaître sur tout notre site un bandeau qui permettra de mettre en valeur, de façon bornée dans le temps, une information importante. Un peu d'analyse autour de cette demande nous permet de la détailler comme suit.

Une personne en charge du contenu du site doit pouvoir activer et configurer une annonce, qui apparaîtra toujours au même emplacement sur toutes les pages du site. La configuration doit permettre de choisir une date de démarrage et une date de fin d'apparition de l'annonce ainsi que son contenu.

Côté technique, la lecture de ce type de besoin soulève deux problématiques pour lesquelles il nous faudra faire preuve de vigilance :

  1. puisque cet élément est administrable, il faut qu'une interface facile d'accès et sécurisée soit mise en place ;
  2. puisque cette annonce va apparaître et disparaître sans nécessaire action humaine, il faut se méfier des problématiques liées au cache.

Saisie et stockage du contenu

En premier lieu, nous devons donc déterminer la manière dont sera stocké ce contenu car c'est de cela que découlera la façon dont il sera saisi. Plusieurs options s'offrent à nous :

  • une entité de contenu personnalisée,
  • un objet de configuration simple,
  • un bloc personnalisé,
  • un système de stockage à plat.

Choisir la bonne option

Puisque le besoin fonctionnel ne demande qu'à ce qu'une annonce soit configurée à la fois, l'utilisation d'une entité de contenu personnalisée semble un petit peu exagérée. En effet, générer une telle entité demande beaucoup de code (bien que l'on puisse utiliser un générateur pour aller plus vite) et nécessitera la création d'au moins une table dans la base de données, de nombreuses routes et contrôleurs pour l'administration. Cela aurait été tout à fait justifié s'il avait fallu pouvoir gérer plusieurs annonces à la fois mais dans notre cas cela représente trop de complexité.

Un objet de configuration simple pourrait également remplir son rôle pour le stockage mais, étant donné qu'il s'agit de permettre à la personne en charge du contenu de mettre en place cette annonce, nous ne conserverons pas cette option. En effet, nos procédures de déploiement provoquent par défaut un écrasement de la configuration de la production au profit de celle versionnée par l'équipe. Bien qu'il soit possible de préserver un ensemble d'objets de configuration lors d'un import, afin d'éviter à la personne en charge du contenu de perdre le fruit de son travail, cela nécessite d'y consacrer du temps et d'ajouter des modules au cœur. Dans le cadre de cette demande, nous avons d'autres options qui nous semblent plus judicieuses.

Un bloc personnalisé, basé sur un type de bloc spécialement conçu pour l'occasion, pourrait bien être une réponse pour sa simplicité de configuration et la mise à disposition d'une interface d'édition similaire à celle d'un nœud. Cette solution, bien que très alléchante, nous pose trois problèmes majeurs. Tout d'abord, nous n'avons pas le module Block Content actif sur notre site, ce qui signifie que nous ne l'activerions que pour un seul type de bloc qui ne contiendrait lui-même qu'un seul bloc. Deuxièmement, pour permettre à la personne en charge du contenu de modifier les champs de ce bloc, il aurait été nécessaire d'intégrer un module de la communauté supplémentaire, sinon elle aurait également pu modifier toute la configuration de tous les blocs. Enfin, la gestion très spécifique du cache dont nous avons fait mention précédemment aurait nécessité d'altérer en profondeur le fonctionnement des blocs personnalisés, ce qui nous semblait, une fois de plus, très disproportionné par rapport à la demande.

Finalement, c'est donc un système de stockage à plat, dans la State API, qui nous semble la meilleure option. En effet, le contenu de la State API est indépendant de la configuration et spécifique à l'environnement sur lequel il est défini. Son accès est très rapide et il ne nécessite aucune configuration particulière ni aucun module complémentaire. Son seul défaut par rapport aux entités de contenu personnalisées ou aux blocs personnalisés, c'est qu'il faut manuellement créer le formulaire qui permettra d'administrer l'annonce. Notez que cette option n'est viable que parce que les données que nous souhaitons stocker ne sont pas critiques pour le fonctionnement du site. Dans le cas contraire, conformément à ce qui est indiqué dans la documentation officielle, il nous aurait fallu trouver une autre solution.

Route et permissions

Entrons dans le vif du sujet ! Pour tout le code qui va suivre, il est important de noter qu'il prend place dans un module nommé hc_announce ; le namespace racine de tous les objets sera donc \Drupal\hc_announce et le nom du module sera utilisé, autant que possible, en préfixe de tous les noms machines qui pourraient entrer en collision avec d'autres modules.

En règle générale, je commence toujours par me poser la question des permissions. Dans notre cas, je souhaite pouvoir assigner le droit de modifier l'annonce indépendamment de toutes les autres permissions du site. Je vais donc créer un fichier hc_announce.permissions.yml contenant le code suivant :

administer announcement:
  title: 'Administer announcement'
  description: 'Allow to access the announcement settings form'

Ensuite, je passe à la route d'accès au formulaire, dans le fichier hc_announce.routing.yml j'ajoute l'entrée :

hc_announce.announcement_settings_form:
  path: '/admin/config/announcement'
  defaults:
    _form: '\Drupal\hc_announce\Form\AnnouncementSettingsForm'
    _title: 'Announcement settings'
  requirements:
    _permission: 'administer announcement'

Puis, comme je suis un peu perfectionniste et que je pense aux personnes en charge du contenu, j'ajoute cette route dans le menu d'administration grâce à une entrée dans le fichier hc_announce.links.menu.yml :

hc_announce.announcement_settings_form:
  title: Announcement
  parent: system.admin_config
  description: 'Announcement settings.'
  route_name: hc_announce.announcement_settings_form

Le lien "Announcement" apparaîtra donc dans le menu d'administration sous l'entrée "Configuration" matérialisée par la route parente system.admin_config.

Si vous voulez en savoir plus sur le fonctionnement des routes de Drupal 8, je vous renvoie à un ancien article issu de notre formation.

La base du formulaire

Ces actions préparatoires terminées, il est temps de passer aux choses sérieuses, le formulaire en lui même.

Il nous faut récupérer une date de début, une date de fin, un titre et un message au minimum. Pour des soucis pratiques, nous ajouterons un système pour activer ou désactiver l'annonce rapidement (c'est à dire sans avoir à jouer avec les dates). Pour clarifier l'utilisation de ce formulaire, nous n'afficherons les champs utiles que si la case à cocher pour activer l'annonce est cochée. C'est parti !

Nous créons donc le fichier src/Form/AnnouncementSettingsForm.php dans notre module avec le minimum pour que cela soit considéré comme un formulaire valide :

namespace Drupal\hc_announce\Form;

use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;

/**
 * Class AnnouncementSettingsForm.
 */
class AnnouncementSettingsForm extends FormBase {

  /**
   * {@inheritdoc}
   */
  public function getFormId() {
    return 'announcement_settings_form';
  }

  /**
   * {@inheritdoc}
   */
  public function buildForm(array $form, FormStateInterface $form_state) {

    $form['submit'] = [
      '#type' => 'submit',
      '#value' => $this->t('Submit'),
    ];

    return $form;
  }

  /**
   * {@inheritdoc}
   */
  public function submitForm(array &$form, FormStateInterface $form_state) {
    $this->messenger()->addStatus($this->t('The configuration options have been saved.'));
  }

}

Le résultat n'est pas encore exceptionnel mais il nous permet de valider que la route pointe bien sur notre classe.

Capture d'écran du formulaire ne présentant qu'un bouton de validation.

L'étape suivante consiste à étoffer la méthode buildForm() pour afficher tous les champs dont nous avons besoin.

  /**
   * {@inheritdoc}
   */
  public function buildForm(array $form, FormStateInterface $form_state) {

    $form['enabled'] = [
      '#type' => 'checkbox',
      '#title' => $this->t('Enable announcement'),
      '#default_value' => FALSE,
    ];

    $form['announcement'] = [
      '#type' => 'details',
      '#title' => $this->t('Announcement settings'),
      '#open' => TRUE,
      '#states' => [
        'visible' => [
          ':input[name="enabled"]' => ['checked' => TRUE],
        ],
      ],
    ];
    $form['announcement']['start_date'] = [
      '#type' => 'date',
      '#title' => $this->t('Start date'),
      '#default_value' => '',
      '#required' => TRUE,
    ];
    $form['announcement']['end_date'] = [
      '#type' => 'date',
      '#title' => $this->t('End date'),
      '#default_value' => '',
      '#required' => TRUE,
    ];
    $form['announcement']['title'] = [
      '#type' => 'textfield',
      '#title' => $this->t('Announcement title'),
      '#default_value' => '',
      '#maxlength' => 64,
      '#size' => 64,
    ];
    $form['announcement']['announcement'] = [
      '#type' => 'text_format',
      '#title' => $this->t('Announcement'),
      '#default_value' => '',
      '#required' => TRUE,
      '#format' => 'full_html',
    ];
    $form['submit'] = [
      '#type' => 'submit',
      '#value' => $this->t('Submit'),
    ];

    return $form;
  }

Ce qui nous donne un résultat déjà plus proche de nos besoins.

Capture d'écran du formulaire présentant tous les champs définis par le code : activation de l'annonce, date de début, de fin, titre et contenu.

Validation de la saisie

La magie de Drupal fait que les champs marqués comme required seront validés automatiquement, de même que les données relatives aux types de champs auront leur format vérifié avant soumission. Il ne nous reste qu'à vérifier que la date de fin est bien postérieure à la date de début et nous allons pour cela implémenter la méthode validateForm().

  /**
   * {@inheritdoc}
   */
  public function validateForm(array &$form, FormStateInterface $form_state) {
    $values = $form_state->getValues();
    if ($values['end_date'] < $values['start_date']) {
      $form_state->setErrorByName('end_date', $this->t('The ending date must be greater or equal than the starting date.'));
    }
  }

Le résultat avec le module du cœur Inline Form Error actif (vous devriez activer ce module sur tous vos projets par défaut) est plutôt sympa.

Champ date de fin en erreur indiquant que sa valeur doit être supérieur ou égale à la date de début.

Enregistrement et injection de dépendances

Pour finir, il va nous falloir enregistrer l'information dans la State API et nous assurer que le formulaire tiendra compte des données existantes lors de son prochain affichage. Contrairement aux formulaires de configuration qui proposent un ensemble de méthodes pour accéder aux objets de configuration, nous ne disposons d'aucune aide pour accéder à la State API. Pour y parvenir, nous pourrions faire appel directement à l'objet \Drupal qui contient toutes les méthodes d'assistance nécessaires mais, puisque nous aimons le travail bien fait et le respect des bonnes pratiques, mais aussi dans l'intérêt de l'exercice, nous allons faire appel à l'injection de dépendances.

Dans le cas d'un formulaire qui étend la classe FormBase, nous n'avons pas besoin de spécifier que nous souhaitons implémenter l'interface ContainerInjectionInterface car c'est déjà fait dans la hiérarchie de classe. Il nous suffit donc d'implémenter la méthode statique create() et de spécifier le constructeur de notre objet.

namespace Drupal\hc_announce\Form;

use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\Core\State\StateInterface;

/**
 * Class AnnouncementSettingsForm.
 */
class AnnouncementSettingsForm extends FormBase {

  /**
   * @var \Drupal\Core\State\StateInterface
   */
  protected $state;

  /**
   * Constructs a new AnnouncementSettingsForm object.
   *
   * @param \Drupal\Core\State\StateInterface $state
   */
  public function __construct(StateInterface $state) {
    $this->state = $state;
  }

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container) {
    return new static(
      $container->get('state')
    );
  }

// [...]

Le service de construction de formulaire, sachant que notre classe implémente ContainerInjectionInterface, instanciera notre objet par l'intermédiaire de sa méthode create(), en lui fournissant le conteneur de services en paramètres. C'est cette méthode qui créera l'instance du formulaire à proprement parler en lui fournissant les services dont elle a besoin pour fonctionner, dans notre cas, la State API.

Maintenant qu'on a notre service à disposition, rien n'est plus simple que de stocker les données saisies. Il nous faut juste un peu enrichir la méthode submitForm().

  /**
   * {@inheritdoc}
   */
  public function submitForm(array &$form, FormStateInterface $form_state) {
    $values = $form_state->getValues();

    $this->state->set('hc_announcement', [
      'enabled' => $values['enabled'],
      'start_date' => $values['start_date'],
      'end_date' => $values['end_date'],
      'title' => $values['title'],
      'announcement' => $values['announcement'],
    ]);

    $this->messenger()->addStatus($this->t('The configuration options have been saved.'));
  }

En faisant le choix de ne pas filtrer les données saisies, notamment pour le titre qui est un champ texte simple, nous gardons en tête qu'il faudra être vigilants lors de leur affichage. Heureusement, Twig est là pour nous aider à éviter les failles XSS ! Nous y reviendrons dans la seconde partie de cette série.

La dernière chose à faire est d'enrichir la méthode buildForm() pour afficher les données enregistrées dans la State API lors de l'affichage du formulaire. Je vous épargne la totalité du code pour vous montrer uniquement l'exemple sur le champ "enabled" :

  /**
   * {@inheritdoc}
   */
  public function buildForm(array $form, FormStateInterface $form_state) {
    $config = $this->state->get('hc_announcement');

    $form['enabled'] = [
      '#type' => 'checkbox',
      '#title' => $this->t('Enable announcement'),
      '#default_value' => !empty($config['enabled']) ? $config['enabled'] : FALSE,
    ];

// [...]

Lorsque vous repasserez sur tous les champs pour utiliser les valeurs stockées, n'oubliez pas le #format du champ "announcement".

Et voilà le travail !

Formulaire de configuration de l'annonce complet affichant tous les champs ainsi que leurs données actuelles.

 

Voilà déjà une bonne chose de faîte ! Dans la prochaine partie de cet article, nous aborderons l'affichage de ces données en front et la gestion du cache.

À très bientôt !

 

Crédit photo de couverture : Sue Cro

Votre commentaire

À propos de Edouard

Photo de Edouard habillé d'un Tee Shirt Drupal 8 bleu marine

Expert technique

Après un premier contact douloureux avec Drupal en 2009 en autodidacte, j'ai suivi une formation qui m'a convaincu de mon choix technologique et m'a vraiment mis en selle. Durant plusieurs années suite à cela j'ai accompagné des entreprises locales dans le développement de leurs projets de toutes sortes, de la simple vitrine à l'intranet social en passant par le projet e-commerce.

Nous rejoindre ?

Vous avez envie de rejoindre une équipe éthique ?
Vous avez envie de faire un partenariat ?