Drupal 8 - module Contributor

Theming d'entité et entité parent

Tags: 

Aujourd'hui j'ai été confronté à un petit problème assez particulier concernant les entités et les champs avec Drupal 8. En général, les problématiques sont plus ou moins simples à régler, et dans ce cas j'ai passé du temps pour imaginer la solution la plus viable. Je ne sais pas si ma solution est la plus intéressante mais dans tous les cas, elle est fonctionnelle.

Introduction

Le problème est simple et complexe à la fois. Imaginons un type d'entité "Contributor". Un contributeur est une personne qui peut "contribuer" à des articles écrits par des auteurs. Lorsqu'un contributeur est associé à un article, une petite bannière sera affichée dans l'article pour indiquer qu'il a contribué à l'écriture de l'article.

Par rapport a mes besoins, un contributeur n'est pas forcément un compte enregistré dans le système. Il peut correspondre à un simple nom/prénom accompagné d'une image (avatar).

Chaque contributeur peut contribuer pour un ou plusieurs auteurs, et selon l'auteur pour lequel il contribue, il aura une "position" par rapport à cet auteur, exemple:

  • Contributeur 1 contribue pour l'auteur A en tant que "Editeur en chef";
  • Contributeur 1 contribue pour l'auteur B en tant que "Pigiste";
  • Contributeur 1 contribue pour l'auteur C en tant que "Bloggeur indépendant";

La structure adoptée à été la suivante: un type d'entité spécifique "contributor" qui possède les champs de base

  • full name: le nom complet du contributeur à afficher;
     
  • avatar: l'image associée au contributeur;
     
  • target user: éventuellement, un champ de type entity reference permettant de cibler un utilisateur dans le système, correspondant au contributeur;
     
  • contributed users: une liste (entity reference) d'auteurs pour lesquels le contributeur peut contribuer;

Mon champ "contributed users" est un champ entity reference un peu spécial. J'ai créé un champ en héritant du champ "dynamic entity reference", pour y inclure la possibilité d'avoir une liste déroulante permettant de choisir un terme de taxonomie pour chaque référence. Lorsqu'on crée le champ, on choisi la taxonomie à associer, ce qui permet de générer la liste contenant les termes que l'on pourra associer.

Champ spécial dynamic entity reference taxonomy

Voilà pour la base. Maintenant, il faut pouvoir associer un article à un contributeur. Pour ce faire, j'ai simplement rajouté un champ entity reference standard dans le type de contenu article, en indiquant qu'il référence des entités de type contributor.

Nous avons donc un schéma qui pourrait ressembler à ça:

  • Article
    • titre (textfield)
    • ...
    • Auteur (entity reference)
    • Contributeur(s) (entity reference)
      • Nom complet
      • Utilisateur cible dans le système
      • Peut contribuer pour
        • Auteur 1 avec position X
        • Auteur 2 avec position Y
        • Auteur 3 avec position Z

Problématiques

Si vous avez suivi l'introduction, vous devez déjà pouvoir identifier une ou plusieurs problématiques.

Limitation des contributeurs dans les propositions

Si je pars du principe qu'un contributeur est lié à un ou plusieurs auteurs, je dois déjà pouvoir "limiter" la liste de contributeurs dans mon champ "entity reference" du type de contenu article. Lorsqu'un auteur va vouloir associer son article avec un contributeur, il va taper le début du nom du contributeur dans le champ autocomplete, et la liste retournée ne devrait contenir que des contributeurs qui sont autorisé à contribuer pour ses articles. Ici, un simple plugin de type "EntityReferenceSelection" entre en jeu pour limiter les résultats retournés.

Liaison vers l'utilisateur existant dans le système

Je veux pouvoir faire en sorte de placer un lien sur l'avatar et le nom du contributeur, dans le cas ou ce dernier est lié à un véritable utilisateur du système (champ target user). Par défaut, le lien pointe vers l'entité contributor, alors que idéalement, cette entité n'est pas sensée être "accessible" pour les visiteurs, c'est vers l'utilisateur final qu'un potentiel lien devrait pointer.

Affichage de la position correcte par rapport à l'auteur

C'est là que résidait mon véritable problème...

Premièrement, je veux que le terme de taxonomie associé ainsi que sa description soient disponible en tant que pseudo-champs et qu'on puisse choisir leur poids d'affichage dans les affichages de vue (view-display).

Deuxièmement, pour récupérer le bon terme de taxonomie à afficher, il est nécessaire de connaître l'article auquel le contributeur est associé, afin d'accéder à l'auteur de l'article, ce qui permettrait enfin de récupérer le terme adéquat dans la liste des auteurs pour lesquels le contributeur peut contribuer.

Author <= Article => Contributor(s)

L'article est la pièce maîtresse, c'est lui qui possède les informations de liaison entre l'auteur et le(s) potentiel(s) contributeur(s).

Cette dernière problématique m'a posé des soucis car il y avait différentes solutions envisageables.

Je pouvais par exemple créer des formateurs de champs spécialisés pour traiter les informations (lien à affecter, terme de taxonomie à récupérer) pour chaque champ de mon entité contributor, mais dans ce cas, j'aurai par la suite été limité à ces formateurs, ce qui me dérangeait un peu. Déjà, un formateur pour les images, ce n'est pas ce qu'il y a de mieux à faire, tout ça pour pouvoir modifier un lien... Et si je veux par la suite utiliser un formateur spécialisé (par exemple responsive image) je vous raconte pas la galère...

Une autre solution aurait pu être de créer un formateur spécifique pour toute l'entité contributor. Ce formateur aurait pu être appliqué au niveau du champ entity reference de l'article. Mais là encore, ça me semblait très limitant. Imaginons par exemple que je veuille par la suite avoir plusieurs affichages différenciés, j'aurai du créer plusieurs formateurs pour l'entité. Là encore, ça ne me semblait pas être une solution très générique et réutilisable, surtout qu'il existe déjà un formateur "Entité rendue" pour les champs de type entity reference, qui permet beaucoup de souplesse dans son utilisation.

Les solutions

Au final, après pas mal de recherches et de tests, j'ai fini par résoudre mes problématiques avec différents hooks.

Pseudo-champs position

Pour obtenir des pseudo-champs "position" dans la partie display, j'ai utilisé hook_entity_extra_field_info. Ce hook permet justement de rajouter des pseudo-champs, que ça soit au niveau view-display ou form-display. Dans mon cas, je ne désirai qu'un affichage au niveau view-display: 

function contributors_entity_extra_field_info() {
  $extra = [];
  $extra['contributor']['contributor']['display']['position_name'] = [
    'label' => t('Position name'),
    'description' => t('The position of the contributor.'),
    'weight' => 10,
    'visible' => TRUE,
  ];
  $extra['contributor']['contributor']['display']['position_description'] = [
    'label' => t('Position description'),
    'description' => t('The position description of the contributor.'),
    'weight' => 12,
    'visible' => TRUE,
  ];
 
  return $extra;
}

Le tableau $extra est rempli de manière à ce que les pseudo-champs position_name et position_description soient ajoutés au niveau display. J'aurais pu utiliser form à la place de display pour créer des pseudo-champs au niveau du formulaire. Bien entendu, ces champs ne sont disponible que pour le type d'entité contributor. Voilà le résultat au niveau display:

View display avec les pseudo-champs ajoutés via hook_entity_extra_field_info

Je peux ensuite utiliser hook_entity_view pour affecter des valeurs à ces pseudo-champs. Nous verrons un peu plus tard le code de hook_entity_view, car il nous manque encore un élément essentiel pour l'implémenter.

Connaître l'entité parent détenant le contributeur

L'élément essentiel, c'est l'entité parent détenant la liaison avec le contributeur... L'article, quoi... Comme expliqué auparavant, un contributeur peut contribuer pour plusieurs auteurs. Lorsque l'auteur A ajoute le contributeur 1 à un article X, la "position" du contributeur (Editeur en chef) n'est pas la même que lorsque l'auteur B ajoute le contributeur 1 à un article Y (position Pigiste).

Avant de pouvoir implémenter hook_entity_view pour afficher le bon terme, nous devons donc connaître l'entité parent (l'article) vers laquelle l'entité contributeur est liée. Le problème ici, c'est que seul le champ entity reference est capable de déterminer cette information. Nous n'avons pas accès à la liaison lorsque nous sommes au niveau de l'entité contributor qui s'affiche. Cette dernière ne connait rien de son environnement d'affichage, elle se connait juste elle-même avec ses informations d'entité.

La première idée fut de se dire que je pouvais récupérer l'entité en cours d'affichage via le service RouteMatch. D'accord, mais si nous sommes sur un listing d'entité ? Le RouteMatch ne saura pas nous retourner chaque entité, il est juste capable de nous indiquer la route en cours d'affichage. Ce n'était donc pas une solution cohérente. La solution résidait dans hook_preprocess_field. Vu que seul le champ entity reference connait le parent ainsi que le contributeur, c'est à son niveau que je pouvais traiter l'information.

J'ai d'abord rajouté une variable protégée $targetEntity ainsi que les getter/setter correspondants dans mon entité contributor:

  /**
   * The target entity
   * @var \Drupal\Core\Entity\FieldableEntityInterface
   */
  protected $targetEntity;
 
  /**
   * Set the target entity.
   * 
   * @param FieldableEntityInterface $target_entity
   */
  public function setTargetEntity(FieldableEntityInterface $target_entity) {
    $this->targetEntity = $target_entity;
  }
  /**
   * Get the target entity.
   * 
   * @return \Drupal\Core\Entity\FieldableEntityInterface
   */
  public function getTargetEntity() {
    return $this->targetEntity;
  }

Cette variable allait me permettre de stocker de manière temporaire l'information cruciale, qui pourra être réutilisée dans la suite du processus. J'ai ensuite implémenté hook_preprocess_field pour traiter l'information:

/**
 * Preprocess entity reference fields that hold contributors entities
 * to add in each contributor the target entity.
 * 
 * @param array $variables
 */
function contributors_preprocess_field($variables) {
 
  // Get the field type manager to check the plugin class of the field.
  $field_type_manager = \Drupal::service('plugin.manager.field.field_type');
 
  // Be sure to process only entity references items. We store the contributors
  // in a basic entity reference field.
  $er_class = EntityReferenceItem::class;
  $class = $field_type_manager->getPluginClass($variables['element']['#field_type']);
  if ($class === $er_class || is_subclass_of($class, $er_class)) {
 
    $field_name = $variables['element']['#field_name'];
 
    // Get current field entity and check if it target 'contributors' entity.
    /** @var \Drupal\Core\Entity\FieldableEntityInterface $entity */
    $entity = $variables['element']['#object'];
    $def = $entity->get($field_name)->getFieldDefinition()->getFieldStorageDefinition();
    if ($def->getSetting('target_type') == 'contributor') {
 
      // Contributor is the target of the current field ?
      // Retrieve references and set target entity (entity hold the field)
      // in each contributor.
 
      /** @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $items */
      $items = $variables['element']['#items'];
      foreach ($items->referencedEntities() as $referenced_entity) {
        if ($referenced_entity instanceof ContributorInterface) {
          $referenced_entity->setTargetEntity($entity);
        }
      }
    }
  }
}

Grosso-modo, je teste d'abord pour savoir si le champ en cours de preprocess est un champ de type entity reference. Si c'est le cas, je récupère la définition de stockage du champ pour contrôler si ce champ cible des entités de type contributor. Si le contrôle est valide, je vais stocker dans la variable $targetEntity l'entité parent (l'article).

Il faut bien comprendre que le stockage de l'entité parent est temporaire et sera écrasée pour chaque entité affichée. Mais elle ne sera écrasée qu'une fois le processus d'affichage d'un article effectué. Ce qui veut dire que lors du passage dans hook_entity_view, j'aurais la bonne entité cible, et je pourrai récupérer le bon terme de taxonomie associé à l'auteur de l'article.

Pour être clair, si nous imaginons une liste d'articles affichés, la pile d'exécution passera par ces différents processus pour le premier article: 

  • hook_preprocess_field
  • hook_entity_view
  • template_preprocess_contributor

Puis recommencera pour le second article, puis le troisième, et ainsi de suite. La variable $targetEntity sera donc valorisée proprement lors de chaque exécution de processus pour une entité particulière.

Voici l'implémentation de hook_entity_view:

/**
 * Implements hook_entity_view()
 * to retrieve contributor taxonomy data from the
 * target entity if set.
 *  
 * @param array $build
 *   The build array.
 * @param EntityInterface $entity
 *   The current entity viewed.
 * @param EntityViewDisplayInterface $display
 *   The display informations.
 * @param string $view_mode
 *   The current view mode.
 * 
 * @see contributors_preprocess_field().
 */
function contributors_entity_view(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display, $view_mode) {
 
  if ($entity instanceof ContributorInterface) {
 
    // Retrieve target if set (@see contributors_preprocess_field()).
    $target_entity = $entity->getTargetEntity();
    if (isset($target_entity)) {
      // Get owner of target entity and iterate over the list
      // of contributed users to retrieve correct associated term.
      $owner_id = $target_entity->getOwner()->id();
      $v = $entity->get('contribute_to_user')->getValue();
      $tid = 0;
      foreach ($v as $key => $value) {
        if ($value['target_id'] == $owner_id) {
          $tid = $value['tid'];
          break;
        }
      }
 
      // If term found, retrieve it and get translated values.
      if ($tid != 0) {
        $term = \Drupal::entityTypeManager()->getStorage('taxonomy_term')->load($tid);
        $language = \Drupal::languageManager()->getCurrentLanguage(LanguageInterface::TYPE_CONTENT);
        $translation = \Drupal::service('entity.repository')->getTranslationFromContext($term, $language->getId());
        $build['position_name'] = [
          '#type' => 'markup',
          '#markup' => $translation->getName(),
        ];
 
        $description = trim($translation->get('description')->value);
        if (!empty($description)) {
          $build['position_description'] = [
            '#type' => 'markup',
            '#markup' => $description,
          ];
        }
      }
    }
  }
}

Dans cette implémentation, je contrôle que j'ai bien affaire à une entité de type contributor pour en extraire la cible (l'article). Je récupère à partir de la cible son auteur pour ensuite trouver le bon terme de taxonomie dans la liste des auteurs du contributeur. Enfin, je traite le terme pour récupérer la bonne valeur (traduite si nécessaire) et je rempli mes pseudo-champs créé avec hook_entity_extra_field_info. That's all...

Ré-affectation du lien vers l'utilisateur cible du système

Le dernier réglage concerne le lien sur l'image et le nom du contributeur. Souvenez-vous: si le formateur de l'image ou du nom affiché applique un lien, et que le contributeur est lié à un utilisateur existant du système, alors le lien doit pointer vers cet utilisateur et non pas vers l'entité contributeur. Si pas de liaison vers un utilisateur existant, alors pas de lien.

J'ai utilisé l'implémentation du preprocess de l'entité elle même (générée automatiquement avec Drupal Console) pour effectuer ce dernier point:

/**
 * Prepares variables for Contributor templates.
 *
 * Default template: contributor.html.twig.
 *
 * @param array $variables
 *   An associative array containing:
 *   - elements: An associative array containing the user information and any
 *   - attributes: HTML attributes for the containing element.
 */
function template_preprocess_contributor(array &$variables) {
  // Fetch Contributor Entity Object.
 
  /** @var \Drupal\contributors\Entity\ContributorInterface $contributor */
  $contributor = $variables['elements']['#contributor'];
 
  /** @var \Drupal\user\Entity\User $target_user */
  if ($target_user = $contributor->get('target_user')->entity) {
 
    // Check if links must be rewritten.
 
    if ($variables['elements']['name'][0]['#type'] == 'link') {
      $variables['elements']['name'][0]['#url'] = $target_user->toUrl();
    }
    if (isset($variables['elements']['picture'][0]['#url'])) {
      $variables['elements']['picture'][0]['#url'] = $target_user->toUrl();
    }
  }
  else {
 
    // Check if links must be removed.
 
    if ($variables['elements']['name'][0]['#type'] == 'link') {
 
      $variables['elements']['name'][0] = $variables['elements']['name'][0]['#title'];
    }
    if (isset($variables['elements']['picture'][0]['#url'])) {
      unset($variables['elements']['picture'][0]['#url']);
    }
  }
 
  // Helpful $content variable for templates.
  foreach (Element::children($variables['elements']) as $key) {
    $variables['content'][$key] = $variables['elements'][$key];
  }
}

Sachant que les différents champs de l'entité contributeur sont des champs de base (qui sont "livré" avec l'entité, ils n'ont pas été rajouté via la gestion des champs d'une entité), je peux aisément traiter ces informations comme bon me semble. Le problème aurait été plus complexe si ces champs ne faisaient pas partie intégrante de l'entité.

Je traite donc les champs nécessaires avant affichage afin de contrôler que le lien, si il doit être affiché, corresponde bien à l'utilisateur cible du système.

Conclusion

Le principe semble relativement simple finalement. Vu que seul le champ faisant la liaison entre l'article et le ou les contributeurs détient toutes les informations (entité cible, contributeur(s)), c'est à son niveau que le traitement est sensé s'effectuer. Encore faut-il le savoir, et trouver la bonne manière d'implémenter les processus.

Il existe certainement d'autres moyens pour récupérer les bonnes informations, mais celui-ci me semblait être relativement élégant, même si il implique, comme trop souvent encore, l'utilisation de hooks dont on aimerait pouvoir se passer avec Drupal 8.

Ajouter un commentaire

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