Drupal 8 - Field API

FieldType et FieldWidget, quelques subtilités

Pour continuer la série d'article sur la Création d'un module avec Drupal 8, nous allons maintenant nous pencher sur l'API Field.

Dans le cadre de la configuration globale de notre module, nous avons créé une nouvelle entité de type configuration, puis un élément de formulaire qui nous servira à rendre l'interface agréable pour les utilisateurs. Avant de mettre en place le formulaire de configuration avec ces éléments, nous avons encore besoin d'un plugin de type Field.

Introduction

Comme nous l'avions indiqué lors de l'analyse des besoins, notre module va fonctionner dans deux modes différents. Soit en mode automatique, en parsant les contenus et leur affectant les liens qu'il va pouvoir remplacer, soit en mode manuel, ou le rédacteur choisira de lui-même quels sont les mots qui seront succeptibles d'être remplacés par des liens.

Dans ce mode manuel, c'est le rédacteur qui choisit. Mais pour effectuer ce choix, il va être nécessaire de pouvoir le lui proposer. Avec Drupal 7, j'avais utilisé hook_field_extra_fields pour rajouter un élément de formulaire de type "liste". Avec Drupal 8, nous utiliserons tout simplement l'API Field car ça semble beaucoup plus logique, vu la tournure qu'a pris le noyau du système.

Nous allons donc créer:

  • un nouveau @FieldType qui va hériter de EntityReference, puisque ce champ va stocker des références à des entités;
  • un nouveau @FieldWidget qui va hériter de OptionsWidgetBase, puisque c'est une élément de #type select #multiple que nous voulons proposer au rédacteur.
  • un plugin de sélection qui s'appliquera (par défaut) à notre type de champ, et qui permettra de n'afficher que les liens internes correspondants aux bons modes (INTERNAL_LINK_MODE_ALL && INTERNAL_LINK_MODE_MANUAL)

Implémentation

@FieldType

Commençons par le nouveau type de champ. EntityReference me semble être tout indiqué. Notre champ va référencer des entités, l'implémentation du type de champ EntityReferenceItem va donc pouvoir être réutilisé, notre Plugin sera très simple à mettre en place.

Ouvrons notre terminal et commençons:

MacPro:drupal8 titouille$ drupal generate:plugin:fieldtype
 
 // Welcome to the Drupal Field Type Plugin generator
 Enter the module name [internal_link]:
 > 
 
 Enter the plugin class name [ExampleFieldType]:
 > InternalLinkItem
 
 Enter the plugin label [Internal link item]:
 > Internal link
 
 Enter the plugin id [internal_link_item]:
 > internal_link_field
 
 Enter the plugin Description [My Field Type]:
 > An entity field containing an internal link and related data.
 
 Enter the default field widget of this plugin [ ]:
 > internal_link_widget
 
 Enter the default field formatter of this plugin [ ]:
 > 
 
 Do you confirm generation? (yes/no) [yes]:
 > 
 
Generated or updated files
 Site path: /Users/titouille/Dev/web/htdocs/sabugo/drupal8
 1 - modules/custom/internal_link/src/Plugin/Field/FieldType/InternalLinkItem.php
 // cache:rebuild
 
 Rebuilding cache(s), wait a moment please.
 
 [OK] Done clearing cache(s).                                                                                           
 
MacPro:drupal8 titouille$ 

Un nouveau fichier a été généré, que nous nous empressons d'ouvrir pour en voir le contenu... Whow... c'est du lourd. En fait, nous n'allons avoir besoin que d'une infime partie du code s'y trouvant, et encore, nous allons le modifier.

La première chose va être de changer l'héritage de la classe. de FieldItemBase, nous allons passer à EntityReferenceItem.

Nous allons ensuite modifier la méthode defaultStorageSettings pour lui indiquer que le "target_type" est par défaut "internal_link". Oui, notre champ est sensé ne stocker que des entités de type internal_link.

Enfin, nous allons modifier la méthode storageSettingsForm pour faire en sorte que le champ contenant la valeur d'entité cible (target_type) soit désactivé par défaut. Ainsi, les administrateurs ne pourront pas changer cette option, nous sommes assurés que notre champ ne risque pas de changer de cible par mégarde.

Toutes les autres méthodes peuvent être supprimées en toute sécurité. Au final, voici notre @FieldType:

/**
 * @file
 * Contains \Drupal\internal_link\Plugin\Field\FieldType\InternalLinkItem.
 */
 
namespace Drupal\internal_link\Plugin\Field\FieldType;
 
use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem;
use Drupal\Core\Form\FormStateInterface;
 
/**
 * Plugin implementation of the 'internal_link_field' field type.
 *
 * @FieldType(
 *   id = "internal_link_field",
 *   label = @Translation("Internal link field"),
 *   description = @Translation("An entity field containing an internal link and related data."),
 *   default_widget = "internal_link_widget" * )
 */
class InternalLinkItem extends EntityReferenceItem {
  /**
   * {@inheritdoc}
   */
  public static function defaultStorageSettings() {
    return [
      'target_type' => 'internal_link',
    ] + parent::defaultStorageSettings();
  }
 
 
  /**
   * {@inheritdoc}
   */
  public function storageSettingsForm(array &$form, FormStateInterface $form_state, $has_data) {
    $element = parent::storageSettingsForm($form, $form_state, $has_data);
 
    $element['target_type']['#disabled'] = TRUE;
 
    return $element;
  }
}

Rien de compliqué. Notre type de champ est juste une extension du type de champ EntityReferenceItem et force l'utilisation du type d'entité internal_link.

@FieldWidget

Dégainons encore Drupal Console:

MacPro:drupal8 titouille$ drupal generate:plugin:fieldwidget
 
 // Welcome to the Drupal Field Widget Plugin generator
 Enter the module name [internal_link]:
 > 
 
 Enter the plugin class name [ExampleFieldWidget]:
 > InternalLinkWidget
 
 Enter the plugin label [Internal link widget]:
 > Internal link
 
 Enter the plugin id [internal_link_widget]:
 > 
 
 Enter the field type the plugin can be used with:
  [0 ] comment
  [1 ] email
  [2 ] link
  [3 ] field_ui:internal_link_field:user
  [4 ] boolean
  [5 ] field_ui:internal_link_field:node
  [6 ] datetime
  [7 ] internal_link_field
  [8 ] field_ui:internal_link_field:taxonomy_term
  [9 ] list_integer
  [10] list_float
  [11] decimal
  [12] integer
  [13] float
  [14] field_ui:entity_reference:node
  [15] file
  [16] image
  [17] entity_reference
  [18] field_ui:entity_reference:taxonomy_term
  [19] field_ui:entity_reference:user
  [20] list_string
  [21] string
  [22] string_long
  [23] text
  [24] text_long
  [25] text_with_summary
 > 7
 
 Do you confirm generation? (yes/no) [yes]:
 > 
 
Generated or updated files
 Site path: /Users/titouille/Dev/web/htdocs/sabugo/drupal8
 1 - modules/custom/internal_link/src/Plugin/Field/FieldWidget/InternalLinkWidget.php
 // cache:rebuild
 
 Rebuilding cache(s), wait a moment please.
 
 [OK] Done clearing cache(s).                                                                                           
 
MacPro:drupal8 titouille$ 

Cette fois encore, Drupal Console a fait son travail, il nous a généré un squelette de plugin type @FieldWidget. Nous l'ouvrons et regardons son contenu. Ici encore, beaucoup de choses inutiles.

Nous devons modifier l'annotation pour indiquer que notre widget prend en charge de multiples valeurs. Sans cette modification, notre widget affichera autant de listes déroulantes que la cardinalité du champ. En ajoutant cette indication, le widget ne s'affichera qu'une seule fois, et nous aurons la tâche de lui indiquer que son champ de #type select est #multiple.

Nous allons hériter notre classe de OptionsWidgetBase pour avoir accès aux fonctionnalités des champs de liste, et nous devrons implémenter la fonction "massageFormValues" pour faire en sorte que les données retournées par notre champ soit cohérente pour l'affichage. Utilisant un @fieldType de type EntityReferenceItem, les données sont indexées avec une clé "target_id" mais un champ de type "select" est sensé prendre en charge un simple tableau de clés/valeurs.

Voilà maintenant le code de notre @FieldWidget:

/**
 * @file
 * Contains \Drupal\internal_link\Plugin\Field\FieldWidget\InternalLinkWidget.
 */
 
namespace Drupal\internal_link\Plugin\Field\FieldWidget;
 
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Field\Plugin\Field\FieldWidget\OptionsWidgetBase;
 
/**
 * Plugin implementation of the 'internal_link_widget' widget.
 *
 * @FieldWidget(
 *   id = "internal_link_widget",
 *   label = @Translation("Internal link"),
 *   field_types = {
 *     "internal_link_field"
 *   },
 *   multiple_values = TRUE
 * )
 */
class InternalLinkWidget extends OptionsWidgetBase {
 
  /**
   * {@inheritdoc}
   */
  public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) {
    $element = parent::formElement($items, $delta, $element, $form, $form_state);
 
    // Retrieve options and current selection.
    $options = $this->getOptions($items->getEntity());
    $selected = $this->getSelectedOptions($items);
 
    // Reset selection to only one item if it's not a multiple field.
    if (!$this->multiple) {
      $selected = $selected ? reset($selected) : NULL;
    }
 
    // If required and there is one single option, preselect it.
    if ($this->required && count($options) == 1) {
      reset($options);
      $selected = [key($options)];
    }
    $element += [
      '#type' => 'select',
      '#title' => $element['#title'],
      '#default_value' => $selected,
      '#empty_option' => t('None'),
      '#empty_value' => '_none',
      '#options' => $options,
      '#multiple' => $this->multiple,
      '#required' => $element['#required'],
    ];
 
    return $element;
  }
 
  /**
   * {@inheritdoc}
   */
  public function massageFormValues(array $values, array $form, FormStateInterface $form_state) {
    foreach ($values as $key => $value) {
 
      // Even if we are on an option widget, we must remember that the field type
      // is an entity reference and store data in "target_id" column. We need to
      // return a basic associative array with key/id instead of complex array with
      // key/[target_id]=>id.
      if (is_array($value['target_id'])) {
        unset($values[$key]['target_id']);
        $values[$key] += $value['target_id'];
      }
    }
    return $values;
  }
}

La méthode formElement s'occupe de construire proprement le champ. Nous faisons appel à la méthode parent, puis nous contrôlons que la #default_value soit cohérente avec le statut #multiple et construisons l'élément final.

Enfin, la méthode masageFormValues retourne le bon tableau de valeurs en supprimant les indexs "target_id" non nécessaires.

Ici aussi, la puissance de l'héritage de classes nous permet de proposer un Widget de manière simplifiée plutôt que de devoir développer entièrement le sien.

@EntityReferenceSelection

Enfin, la cerise sur le gateau. Nous allons créer un plugin de type EntityReferenceSelection. Ce plugin se greffe automatiquement sur les @FieldType de type EntityReferenceItem pour construire la requête par défaut qui va récupérer les données selon le type d'entité (target_type) qui est sélectionné lors de la configuration. Dans notre cas, le type d'entité sera toujours internal_link, nous pouvons donc implémenter un plugin de sélection par défaut pour ce type. Sachant que notre champ ne sera utilisé que pour autoriser la sélection manuelle des liens internes par le rédacteur du contenu, nous savons par avance que nous devrons retourner uniquement les entités ayant pour mode INTERNAL_LINK_MODE_ALL et INTERNAL_LINK_MODE_MANUAL. Nous allons donc faire en sorte que ça soit le cas, en implémentant notre plugin de sélection dans ce sens.

Nous créons d'abord un répertoire src/Plugin/EntityReferenceSelection et nous y ajoutons un fichier InternalLinkSelection.php que nous implémentons de la manière suivante:

namespace Drupal\internal_link\Plugin\EntityReferenceSelection;
 
use Drupal\Core\Entity\Plugin\EntityReferenceSelection\DefaultSelection;
use Drupal\Core\Language\LanguageInterface;
 
/**
 * Internal link plugin implementation of the Entity Reference selection plugin
 * 
 * @EntityReferenceSelection(
 *   id = "default:internal_link",
 *   label = @Translation("Internal link"),
 *   entity_types = {"internal_link"},
 *   group = "default",
 *   weight = 5,
 * )
 */
class InternalLinkSelection extends DefaultSelection {
 
  /**
   * {@inheritdoc}
   */
  protected function buildEntityQuery($match = NULL, $match_operator = 'CONTAINS') {
    $query = parent::buildEntityQuery($match, $match_operator);
 
    $language = \Drupal::languageManager()->getCurrentLanguage(LanguageInterface::TYPE_CONTENT);
 
    $query->condition('mode', [INTERNAL_LINK_MODE_ALL, INTERNAL_LINK_MODE_MANUAL], 'IN', $language->getId());
 
    return $query;
  }
}

L'annotation indique l'identifiant (calqué sur d'autres plugins EntityReferenceSelection) ainsi que les types d'entité sur lesquels ce plugin doit être appliqué (internal_link).

La seule chose à faire est de surdéfinir la fonction buildEntityQuery et d'y construire la requête à partir du parent avant de rajouter notre propre condition concernant le "mode" de l'entité.

Ainsi notre type de champ EntityReference basé sur le type d'entité internal_link utilisera par défaut ce plugin de sélection, et nous retournera uniquement les liens internes valorisés avec les bons modes.

Conclusion

Trois éléments distincts: @FieldType, @FieldWidget, @EntityReferenceSelection, le tout en utilisant au maximum l'héritage de classes pour ne pas réinventer la roue. Voilà qui semble efficace.

Avec ces différents plugins, notre entité configurable et notre élément de formulaire, nous sommes enfin prêts à finaliser notre formulaire de configuration globale, yeah !

Ajouter un commentaire

CAPTCHA
Cette question permet de savoir si vous êtes un visiteur ou un robot de soumission de spams