Drupal 8 - EntityReference

EntityReference, comment se faire passer pour celui qu’on n’est pas

Les deux précédents articles de notre série sur la Création d'un module avec Drupal 8 nous ont montré comment surdéfinir un formulaire et utiliser les principes de routes et de contrôleurs pour transformer un champ texte standard en champ autocomplete. Nous avons également vu comment intégrer une validation personnalisée de notre formulaire.

Eléments de formulaire

Nous allons maintenant nous attaquer à la partie la plus complexe de notre formulaire, à savoir les champs "Type d'entité" et "Entité" que nous avons inclu dans la classe InternalLink via la méthode baseFieldDefinitions. Cette inclusion s'est faite avec le code ci-après:

$entity_types = [];
    $etm = \Drupal::entityTypeManager();
    foreach($etm->getDefinitions() as $id => $definition) {
      if (is_a($definition , 'Drupal\Core\Entity\ContentEntityType')
          && $id != 'internal_link') {
        $entity_types[$id] = $definition->getLabel();
      }
    }
 
    $fields['url_entity_type'] = BaseFieldDefinition::create('list_string')
    ->setLabel(t('Entity type'))
    ->setDescription(t('The entity type to find url. Select an entity before search the matching URL.'))
    ->setSetting('allowed_values', $entity_types)
    ->setDisplayOptions('view', [
      'label' => 'above',
      'type' => 'string',
      'weight' => 5,
    ])
    ->setDisplayOptions('form', [
      'type' => 'options_select',
      'empty_value' => '_none',
      'empty_label' => t('None'),
      'weight' => 5,
    ])
    ->setDisplayConfigurable('form', TRUE);
 
 
    $fields['entity_id'] = BaseFieldDefinition::create('entity_reference')
    ->setLabel(t('Entity'))
    ->setDescription(t('The entity to link to.'))
    ->setTranslatable(TRUE)
    ->setDisplayOptions('view', [
      'label' => 'above',
      'type' => 'string',
      'weight' => 6,
    ])
    ->setDisplayOptions('form', [
      'type' => 'entity_reference_autocomplete',
      'weight' => 6,
      'settings' => [
        'match_operator' => 'CONTAINS',
        'size' => '60',
        'autocomplete_type' => 'tags',
        'placeholder' => '',
      ],
    ])
    ->setDisplayConfigurable('form', TRUE);

Le code ci-dessus intègre deux champs:

une liste déroulante contenant tous les types d'entité héritant de ContentEntityType, excepté le type d'entité internal_link

un champ de type entityReference permettant de rechercher parmi des entités référencées pour sélectionner une entité particulière.

Logique du champ entityReference

Le but de ces deux champs est de permettre à l'utilisateur de sélectionner un type d'entité particulier, puis d'effectuer une recherche dans les entités apparentées au type choisi.

Le problème est le suivant: lorsqu'on ajoute un champ de type entityReference dans Drupal 8, nous sommes sensé indiquer à quel type d'entité il doit se référer. Cette indication se fait à l'ajout du champ, et n'est plus modifiable dès lors que des données sont sélectionnées dans le champ. Seulement, dans la logique qu'on voudrait faire adopter à notre système, c'est la liste déroulante "Type d'entité" qui devrait déterminer à quel type d'entité est dédiée notre recherche d'entité.

Qui suis-je, que fais-je ?

La solution s'est avérée complexe car il a fallu bien comprendre le fonctionnement du champ EntityReference et les implications des différentes classes héritées, validateurs, etc... Au final, il a fallu "feinter" le système pour lui faire croire à la volée que le champ faisait référence à un type d'entité X ou Y.

Nous allons utiliser différentes notions: le callback ajax tout d'abord, puis le système de définition de champ et le système de définition de stockage de champ.

Surdéfinition du champ

Voici d'abord le code utilisé pour surdéfinir le champ en lui-même, intégré dans la fonction "form":

$rebuild = FALSE;
    $entity_type = $form_state->getValue(['url_entity_type', 0, 'value'], NULL);
    if (isset($entity_type)) {
      $rebuild = TRUE;
    }
    else {
      $entity_type = $this->entity->url_entity_type->value;
      if (!isset($entity_type) || empty($entity_type) || $entity_type === '_none') {
        $entity_type = 'node';
      }
    }
 
    // Prepare url field with wrapper (@see url_entity_type field below)
    // and add states to hide url when no entity type is selected.
    if (isset($form['entity_id']['widget'])) {
      $form['entity_id']['#prefix'] = '[div id="entity_id_wrapper"]';
      $form['entity_id']['#suffix'] = '[/div]';
 
      if (!$rebuild) {
        if (isset($form['entity_id']['widget'][0]['target_id']['#default_value'])) {
          $entity = $form['entity_id']['widget'][0]['target_id']['#default_value'];
          $entity_id = $entity->id();
          $entity = $this->entityTypeManager->getStorage($entity_type)->load($entity->id());
          $form['entity_id']['widget'][0]['target_id']['#default_value'] = $entity;
        }
      }
 
      // We set the field definition target_type setting
      // AND the field definition -> field storage definition target_type setting.
      // @see Drupal\Core\Entity\Plugin\Validation\Constraint\ValidReferenceContraintValidator::validate function line 100.
      // @see Drupal\Core\Entity\EntityReferenceSelection\SelectionPluginManager::getSelectionHandler.
      // In SelectionPluginManager, the target_type is extracted from field_definition->field_storage_definition.
      $this->entity->entity_id->getFieldDefinition()->setSetting('target_type', $entity_type);
      $this->entity->entity_id->getFieldDefinition()->getFieldStorageDefinition()->setSetting('target_type', $entity_type);
 
      $form['entity_id']['widget'][0]['target_id']['#target_type'] = $entity_type;
    }
    // Add ajax callback to entity type to rewrite 
    // url field with correct path to search url's.
    // @see urlEntityTypeCallback
    if (isset($form['url_entity_type']['widget'])) {
      $form['url_entity_type']['widget']['#ajax'] = [
        'callback' => '::urlEntityTypeCallback',
        'wrapper' => 'entity_id_wrapper',
      ];
    }

La variable $rebuild est valorisée pour savoir si le formulaire est en phase de reconstruction ou si c'est son premier affichage. Si il est en phase de recontruction, ça veut dire que l'utilisateur a sélectionné un type d'entité dans la liste déroulante.

Nous ajoutons un #prefix et un #suffix à notre champ (désolé mais le code HTML ne passe pas, j'ai du mettre des crochets à la place...). Nous créons ce qu'on appelle un "wrapper", qui sera utilisé par ajax pour reconstruire le champ lorsque le callback ajax sera appelé:

      $form['entity_id']['#prefix'] = '[div id="entity_id_wrapper"]';
      $form['entity_id']['#suffix'] = '[/div]';

Dans le cas ou nous somme à la phase de première construction du formulaire, nous sommes peut-être en mode édition et nous devons passer au champ une entité complète. Pour ce faire, nous récupérons l'identifiant de l'entité et la chargeons avant de la passer au champ:

      if (!$rebuild) {
        if (isset($form['entity_id']['widget'][0]['target_id']['#default_value'])) {
          $entity = $form['entity_id']['widget'][0]['target_id']['#default_value'];
          $entity_id = $entity->id();
          $entity = $this->entityTypeManager->getStorage($entity_type)->load($entity->id());
          $form['entity_id']['widget'][0]['target_id']['#default_value'] = $entity;
        }
      }

Dans tous les cas de figure (phase de reconstruction ou pas), nous allons "faire croire" au champ qu'il doit faire référence au type d'entité sélectionné ("node" par défaut ou selon la sélection utilisateur). Cette feinte se fait en modifiant la définition du champ (instance du champ dans le bundle) ainsi que la définition de stockage du champ (instance du champ dans le type d'entité). C'est sur ce point que j'ai passé du temps et j'ai du aller fureter dans différentes classes pour comprendre que la modification devait se faire aussi bien sur l'instance du champ au sein du bundle que sur l'instance du champ au niveau du type d'entité. Nous appliquons également le bon type d'entité sur le champ situé dans le formulaire. Au final, voici la partie la plus importante:

      $this->entity->entity_id->getFieldDefinition()->setSetting('target_type', $entity_type);
      $this->entity->entity_id->getFieldDefinition()->getFieldStorageDefinition()->setSetting('target_type', $entity_type);
 
      $form['entity_id']['widget'][0]['target_id']['#target_type'] = $entity_type;

Enfin, nous rajoutons un callback #ajax sur le champ "Type d'entité" pour qu'il déclenche une fonction lorsque l'utilisateur change sa valeur:

    if (isset($form['url_entity_type']['widget'])) {
      $form['url_entity_type']['widget']['#ajax'] = [
        'callback' => '::urlEntityTypeCallback',
        'wrapper' => 'entity_id_wrapper',
      ];
    }

Malgré tout ce code, nous sommes encore loin d'avoir terminé.

Implémentation du callback

Nous devons maintenant implémenter la fonction de callback que nous avons attachée au champ "Type d'entité". Ce callback est appelé à chaque fois que la valeur du champ est modifiée, et elle va nous permettre de vider et reconstruire le champ "Entité" en lui passant les bonnes informations.

Ce qui posait vraiment problème, c'est que d'une fois que le champ "Entité" était construit dans le formulaire, il contenait certaines informations comme par exemple le chemin "autocomplete" qui pointait vers un type d'entité particulier (node par défaut). Si l'utilisateur changeait le type d'entité à rechercher, et que le champ n'était pas reconstruit, la recherche continuait à s'effectuer sur le chemin initial, et n'était donc pas capable de retourner les bonnes informations. Le callback est codé de la manière suivante:

  public function urlEntityTypeCallback(array &$form, FormStateInterface $form_state) {
 
    // Retrieve entity type selected by user
    $entity_type = $form_state->getValue(['url_entity_type', 0, 'value'], '_none');
 
    if (!empty($entity_type) && $entity_type !== '_none') {
      // Get URL form element to apply modifications
      $element = $form['entity_id']['widget'][0]['target_id'];
 
      // Mimic the EntityAutocomplete:processEntityAutocomplete function
      // to add a settings key into the keyValue service and avoid 403
      // errors when asking for new autocomplete content type.
      // @see Drupal\Core\Entity\Element\EntityAutocomplete:processEntityAutocomplete
      $selection_settings = isset($element['#selection_settings']) ? $element['#selection_settings'] : [];
      $data = serialize($selection_settings) . $entity_type . $element['#selection_handler'];
      $selection_settings_key = Crypt::hmacBase64($data, Settings::getHashSalt());
      $key_value_storage = \Drupal::keyValue('entity_autocomplete');
      if (!$key_value_storage->has($selection_settings_key)) {
        $key_value_storage->set($selection_settings_key, $selection_settings);
      }
 
      // Rebuild path with new entity type an settings key
      $path = '/entity_reference_autocomplete/' . $entity_type . '/'.$element['#selection_handler'].'/' . $selection_settings_key;
 
      // Type of entity to search has changed, we reset the URL value
      // to allow user search a new one.
 
      $form['entity_id']['widget'][0]['target_id']['#value'] = '';
      if (!isset($form['entity_id']['widget'][0]['target_id']['#attributes'])) {
        $form['entity_id']['widget'][0]['target_id']['#attributes'] = [];
      }
      $form['entity_id']['widget'][0]['target_id']['#attributes'] += [
        'required' => 'required',
        'aria-required' => 'aria-required'
      ];
 
      // Set values for autocomplete
      $form['entity_id']['widget'][0]['target_id']['#attributes']['data-autocomplete-path'] = $path;
      $form['entity_id']['widget'][0]['target_id']['#target_type'] = $entity_type;
      $form['entity_id']['widget'][0]['target_id']['#autocomplete_route_parameters'] = [
        'target_type' => $entity_type,
        'selection_handler' => $element['#selection_handler'],
        'selection_settings_key' => $selection_settings_key,
      ];
 
    }
    else {
      // Hide url field element if no entity type is selected.
      // #states is fired "before" callback call, so when the
      // callback is executed, it return the url field displayed.
      $form['entity_id']['#attributes']['style'][] = 'display:none;';
    }
    // Finally, return URL element
    return $form['entity_id'];
  }

La première étape est de récupérer le type d'entité sélectionné par l'utilisateur, puis l'élément de formulaire "Entité":

    // Retrieve entity type selected by user
    $entity_type = $form_state->getValue(['url_entity_type', 0, 'value'], '_none');
 
    if (!empty($entity_type) && $entity_type !== '_none') {
      // Get URL form element to apply modifications
      $element = $form['entity_id']['widget'][0]['target_id'];

A partir de là, nous reproduisons à peu près le même code que la méthode originale de construction d'un champ entityReference autocomplete:

      $selection_settings = isset($element['#selection_settings']) ? $element['#selection_settings'] : [];
      $data = serialize($selection_settings) . $entity_type . $element['#selection_handler'];
      $selection_settings_key = Crypt::hmacBase64($data, Settings::getHashSalt());
      $key_value_storage = \Drupal::keyValue('entity_autocomplete');
      if (!$key_value_storage->has($selection_settings_key)) {
        $key_value_storage->set($selection_settings_key, $selection_settings);
      }

Nous recontruisons le "chemin" à attribuer au champ pour qu'il aille questionner le bon callback autocomplete:

      $path = '/entity_reference_autocomplete/' . $entity_type . '/'.$element['#selection_handler'].'/' . $selection_settings_key;

Nous vidons le champ "Entité" et rajoutons des attributs nécessaires:

      $form['entity_id']['widget'][0]['target_id']['#value'] = '';
      if (!isset($form['entity_id']['widget'][0]['target_id']['#attributes'])) {
        $form['entity_id']['widget'][0]['target_id']['#attributes'] = [];
      }
      $form['entity_id']['widget'][0]['target_id']['#attributes'] += [
        'required' => 'required',
        'aria-required' => 'aria-required'
      ];

Enfin, nous réattribuons toutes les valeurs calculées à notre champ afin qu'il pense être lié au type d'entité sélectionné par l'utilisateur:

      // Set values for autocomplete
      $form['entity_id']['widget'][0]['target_id']['#attributes']['data-autocomplete-path'] = $path;
      $form['entity_id']['widget'][0]['target_id']['#target_type'] = $entity_type;
      $form['entity_id']['widget'][0]['target_id']['#autocomplete_route_parameters'] = [
        'target_type' => $entity_type,
        'selection_handler' => $element['#selection_handler'],
        'selection_settings_key' => $selection_settings_key,
      ];

Si il n'y a pas de type d'entité sélectionné, nous masquons le champ "Entité". Au final, l'élément de formulaire est retourné pour que le processus ajax le ré-intègre dans le formulaire, avec les changements nécessaires.

Validation des données

Un point résiste à tout ce code. Si nous sauvegardons l'entité, il se peut que le système recherche un élément de la mauvaise entité. Nous devons encore une fois obliger le champ à penser qu'il est associé au bon type de contenu avant que la classe parent effectue la validation.

Il est donc nécessaire de modifier la fonction de validation du formulaire en ajoutant le code suivant AVANT l'appel au parent:

    // Retrieve url type and check if we must remove
    // entity or url data.
    $url_type = $form_state->getValue(['url_type', 0, 'value'], '_none');
 
    switch ($url_type) {
      case 'standard' :
        $form_state->setValue('url_entity_type', []);
        $form_state->setValue('entity_id', []);
        break;
      case 'entity' :
        $form_state->setValue(['url', 0, 'value'], '');
        break;
    }
 
    // Set correct target type to entity_id field
    // before launch global validation.
    $url_entity_type = $form_state->getValue(['url_entity_type', 0, 'value'], 'node');
 
    // We set the field definition target_type setting
    // AND the field definition -> field storage definition target_type setting.
    // @see Drupal\Core\Entity\Plugin\Validation\Constraint\ValidReferenceContraintValidator::validate function line 100.
    // @see Drupal\Core\Entity\EntityReferenceSelection\SelectionPluginManager::getSelectionHandler.
    // In SelectionPluginManager, the target_type is extracted from field_definition->field_storage_definition.
    $this->entity->entity_id->getFieldDefinition()->setSetting('target_type', $url_entity_type);
    $this->entity->entity_id->getFieldDefinition()->getFieldStorageDefinition()->setSetting('target_type', $url_entity_type);

Vous constaterez que nous commençons par vider les champs qui ne doivent pas être remplis, puis nous forçons encore une fois les définitions de champ et de stockage de champ avec le type d'entité sélectionné par l'utilisateur.

Ainsi, losrque la validation du champ EntityReference est appelée dans la classe parent, elle passe par les différents validateurs, récupère la valeur target_type soit dans la définition du champ (voir dans Drupal\Core\Entity\Plugin\Validation\Constraint\ValidReferenceContraintValidator::validate), soit dans la définition de stockage du champ (voir dans Drupal\Core\Entity\EntityReferenceSelection\SelectionPluginManager::getSelectionHandler) et considère que l'entité référencée correspond bien au type d'entité demandé.

Si vous naviguez sur le formulaire de création d'une entité internal_link, vous pouvez maintenant sélectionner le type d'url "entity", le type d'entité "utilisateur" et tapez la lettre "a" pour voir apparaitre "Anonyme" et peut-être "admin" si c'est le nom que vous avez donné au super-administrateur du système. Si vous avez déjà des contenus créés dans votre site, vous pouvez sélectionner le type d'entité "contenu" et vous verrez apparaitre vos contenus en commençant à taper leur nom dans le champ "Entité".

La cerise sur le gateau

Il reste un dernier problème, que nous allons rapidement résoudre. Dans le système, notre champ "Entité" continue à être un champ de type "EntityReference" et à chaque fois que nous modifions la valeur target_type de ses instances de champ et de stockage, cette valeur est sensée être figée. Que se passe-t-il alors lorsque nous sauvegardons un lien interne utilisant un type d'entité "contenu", puis que nous affichons un lien interne utilisant un type d'entité "utilisateur" ? L'affichage sera érroné car pour Drupal, le champ "Entité" utilise le type d'entité "contenu" mais référence une entité d'un autre type.

Ici encore, la solution est relativement simple. Faisons croire à notre entité internal_link que son champ correspond au bon choix. Pour feinter notre entité, nous allons simplement implémenter la méthode "postLoad" de la classe InternalLink:

  public static function postLoad(EntityStorageInterface $storage, array &$entities) {
 
    // Iterate on each entity and set 
    // correct target_type setting
    foreach ($entities as $id => $entity) {
      $url_entity_type = $entity->url_entity_type->value;
      if (!empty($url_entity_type) && $url_entity_type != '_none') {
        // We must set target type to the field definition (bundle instance)
        // and to the field-storage definition (entity-type instance)
        $current = $entity->entity_id->getFieldDefinition()->getSetting('target_type');
        if ($current != $url_entity_type) {
          $entity->entity_id->getFieldDefinition()->setSetting('target_type', $url_entity_type);
        }
        $current = $entity->entity_id->getFieldDefinition()->getFieldStorageDefinition()->getSetting('target_type');
        if ($current != $url_entity_type) {
          $entity->entity_id->getFieldDefinition()->getFieldStorageDefinition()->setSetting('target_type', $url_entity_type);
        }
      }
    }
  }

Cette méthode fait partie de la classe Entity, mais n'est pas implémentée à la base. Nous pouvons donc sans autre la rajouter dans notre entité et faire nos tests et nos modifications.

Nous ajoutons donc quelques tests afin de savoir si il est nécessaire de modifier le type d'entité du champ, pour que l'affichage soit entièrement fonctionnel également.

Conclusion

Cette partie semble facile mais il faut savoir que c'est une des parties qui m'a pris le plus de temps à comprendre. N'ayant à la base quasiment aucune connaissance du noyau D8, j'ai passé beaucoup de temps à remonter l'héritage des classes pour commencer à voir vers ou je devais m'orienter pour arriver à mon but. Je ne suis pas sûr que ma solution soit la meilleure ou la plus esthétique, mais je ne doute pas qu'elle va certainement tirer d'affaire plus d'un développeur qui voudra implémenter le même type de fonctionnalité ;-)

Ajouter un commentaire

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