Drupal 8 - Autocomplete

Autocomplete, contrôleur et surdéfinition de champ

Dans le précédent article de la série sur la Création d'un module avec Drupal 8, nous avons pu voir comment surdéfinir le formulaire de notre type d'entité pour l'agrémenter de petites fonctionnalités en utilisant #states et les onglets verticaux.

Nous allons maintenant nous attaquer à une fonctionnalité un peu plus intéressante afin de proposer une expérience utilisateur plus riche, et par la même occasion intégrer le concept de "contrôleur".

Un champ autocomplete

Pour nos liens internes, nous avons la contrainte suivante: le champ "Word/Phrase" doit être unique. Nous ne devons pas indiquer deux fois le même mot à remplacer, car un des deux ne serait jamais utilisé.

Pour éviter ce genre de souci, nous pouvons transformer ce simple champ texte en champ de type autocomplete. Non pas pour que l'utilisateur sélectionne un mot de la liste retournée, mais plutôt pour qu'il voie les mots déjà existants afin de ne pas insérer le même mot plusieurs fois. Ce n'est pas le comportement standard pour l'utilisation d'un champ autocomplete mais rien ne nous empèche de le faire. Nous allons donc transformer notre champ texte pour qu'il devienne un champ autocomplete qui va afficher les résultats disponibles selon les lettres que l'utilisateur va taper.

Lors de la déclaration de ce champ dans la classe InternalLink, nous avions le code suivant:

    $fields['name'] = BaseFieldDefinition::create('string')
    ->setLabel(t('Word/Phrase'))
    ->setDescription(t('The word or phrase you wish to convert to a link. This field is case sensitive.'))
    ->setSettings([
      'max_length' => 100,
      'text_processing' => 0,
    ])
    ->setDefaultValue('')
    ->setTranslatable(TRUE)
    ->setDisplayOptions('view', [
      'label' => 'above',
      'type' => 'string',
      'weight' => 2,
    ])
    ->setDisplayOptions('form', [
      'type' => 'textfield',
      'weight' => 2,
    ])
    ->setDisplayConfigurable('view', TRUE)
    ->setDisplayConfigurable('form', TRUE);

J'avais tenté d'intégrer la partie autocomplete directement à ce niveau, mais je n'y étais pas arrivé. J'ai donc opté pour modifier le champ "après-coup" via la surdéfinition du formulaire.

Lorsque nous désirons intégrer un champ de type autocomplete, nous devons avoir ce qu'on appelle un callback. C'est une fonction qui va prendre en argument la chaine de caractère insérée par l'utilisateur et retourner les résultats correspondant à cette chaine. Avec Drupal 7, nous aurions simplement déclaré un hook_menu et nous aurions ajouté une entrée de type MENU_CALLBACK. Avec Drupal 8, le fonctionnement n'est plus pareil, et nous allons vite le comprendre.

Controller et Routing

Drupal 8 est basé sur le framework Symfony. Ce dernier utilise en grande partie le pattern MVC, pour Model-View-Controller. Ce modèle de conception est largement utilisé dans la programmation orientée objet. Le modèle fourni les données, la vue s'occupe de les afficher, et le contrôleur fait un pont entre les 2 premiers. L'intérêt me direz-vous ? il est très simple. Ces 3 éléments sont sensés pouvoir être modifiés indépendamment les uns des autres. Nous pouvons par exemple implémenter plusieurs vues pour une même donnée sans que le modèle ou le contrôleur ne subisse de changement. Un exemple ? les formatteurs de champs, tout simplement. Le champ (FieldType) n'a pas besoin de savoir quel formatteur il utilise lors du rendu à l'écran. C'est le formatteur qui va connaitre le format de données qu'il reçoit et s'occuper de l'afficher sous une forme particulière.

Dans le meilleur des mondes, le formatteur ne serait même pas sensé connaitre le type de champ, il ferait appel à des méthodes "génériques" pour récupérer les données et les afficher. Ainsi, le formatteur pourrait être utilisé par différents types de champ et s'afficher sans se soucier de la donnée qu'il affiche. Malheureusement, nous n'avons pas toujours cette possibilité, car les structures de données peuvent être plus ou moins complexes selon les besoins.

Passons maintenant à la notion de route. Symfony utilise cette notion pour déterminer la route que va posséder le contrôleur. Lorsqu'on prend la route X, on arrive sur le contrôleur X. Lorsqu'on prend la route Y, on arrive sur le contrôleur Y. A chaque route son contrôleur. Le contrôleur va retourner la vue demandée et nous aurons notre rendu.

Avec Drupal 8, la notion de routage est omniprésente. Le système possède des mécanismes internes qui génèrent des routes pour toutes les pages qui sont affichées et les callbacks de récupération de données. Mieux encore, nous pouvons nous même générer nos routes, associées à des contrôleurs.

Génération d'un contrôleur

Cette fois encore, Drupal Console va être utile. Nous allons utiliser la commande

drupal generate:controller

pour créer une route et son contrôleur associé. Notre contrôleur correspondra au callback pour notre champ autocomplete.

MacPro:drupal8 titouille$ drupal generate:controller
 
 // Welcome to the Drupal Controller generator
 Enter the module name [internal_link]:
 > 
 
 Enter the Controller class name [DefaultController]:
 > AutocompleteController
 
 Enter the Controller method title (leave empty and press enter when done) [ ]:
 > Autocomplete word
 
 Enter the action method name [hello]:
 > autocomplete_word
 
 Enter the route path [internal_link/hello/{name}]:
 > internal_link/autocomplete/word
 
 Enter the Controller method title (leave empty and press enter when done) [ ]:
 > 
 
 Do you want to generate a unit test class (yes/no) [yes]:
 > no
 
 Do you want to load services from the container (yes/no) [no]:
 > yes
 
Type the service name or use keyup or keydown.
This is optional, press enter to continue
 
 Enter your service [ ]:
 > internal_link.data
 Enter your service [ ]:
 > 
 
 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/Controller/AutocompleteController.php
 2 - modules/custom/internal_link/internal_link.routing.yml
 // router:rebuild
 
 Rebuilding routes, wait a moment please
 
 [OK] Done rebuilding route(s).                                                                                         
 
MacPro:drupal8 titouille$ 

Personnalisation du contrôleur

Le fichier internal_link.routing.yml généré contient les informations suivantes:

internal_link.autocomplete_controller_autocomplete_word:
  path: 'internal_link/autocomplete/word'
  defaults:
    _controller: '\Drupal\internal_link\Controller\AutocompleteController::autocomplete_word'
    _title: 'Autocomplete word'
  requirements:
    _permission: 'access content'
  • Il définit une route internal_link.autocomplete_controller_autocomplete_word,
  • => qui utilise un chemin internal_link/autocomplete/word,
  • => qui va utiliser le contrôleur AutocompleteController
  • => et se baser sur la permission "access content".

Nous allons tout de suite modifier la route en internal_link.autocomplete.word et la permission en "edit internal link entities". Ainsi, notre contrôleur sera accessible uniquement pour les utilisateurs possédant les droits d'édition sur notre type d'entité internal_link. Nous vidons le cache via Drupal Console pour être sur que la nouvelle route est prise en compte:

drupal cr all

Et nous passons à la suite. Notre classe contrôleur a été générée avec le code suivant:

/**
 * @file
 * Contains \Drupal\internal_link\Controller\AutocompleteController.
 */
 
namespace Drupal\internal_link\Controller;
 
use Drupal\Core\Controller\ControllerBase;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\internal_link\DataService;
 
/**
 * Class AutocompleteController.
 *
 * @package Drupal\internal_link\Controller
 */
class AutocompleteController extends ControllerBase {
 
  /**
   * Drupal\internal_link\DataService definition.
   *
   * @var Drupal\internal_link\DataService
   */
  protected $internal_link_data;
  /**
   * {@inheritdoc}
   */
  public function __construct(DataService $internal_link_data) {
    $this->internal_link_data = $internal_link_data;
  }
 
  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container) {
    return new static(
      $container->get('internal_link.data')
    );
  }
 
  /**
   * Autocomplete_word.
   *
   * @return string
   *   Return Hello string.
   */
  public function autocomplete_word() {
    return [
      '#type' => 'markup',
      '#markup' => $this->t('Implement method: autocomplete_word')
    ];
  }
}

Que nous allons nous empresser de modifier également pour qu'elle corresponde à nos besoins:

/**
 * @file
 * Contains \Drupal\internal_link\Controller\AutocompleteController.
 */
 
namespace Drupal\internal_link\Controller;
 
use Drupal\Core\Controller\ControllerBase;
use Drupal\internal_link\DataService;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
 
/**
 * Class AutocompleteController.
 *
 * @package Drupal\internal_link\Controller
 */
class AutocompleteController extends ControllerBase {
 
  /**
   * Drupal\internal_link\DataService definition.
   *
   * @var Drupal\internal_link\DataService
   */
  protected $dataService;
  /**
   * {@inheritdoc}
   */
  public function __construct(DataService $data_service) {
    $this->dataService = $data_service;
  }
 
  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container) {
    return new static(
      $container->get('internal_link.data')
    );
  }
 
  /**
   * Autocomplete word.
   *
   * @param Request $request
   *   The base request.
   *   
   * @return JsonResponse
   *   Return array of matching words as Json response.
   */
  public function autocomplete_word(Request $request) {
    $string = $request->get('q');
    $matches = [];
    if ($string) {
      $matches = $this->dataService->getNamesContains($string);
    }
    return new JsonResponse($matches);
  } 
}

Outre la syntaxe camelCase pour la variable protégée, nous modifions également la fonction autocomplete_word pour qu'elle prenne en argument une requête HTTP. Cette requête permet de récupérer les arguments de l'url, puis d'interroger la fonction getNamesContains de notre service de données afin de récupérer toutes les entités qui commenceraient par la même chaine de caractère et retourner la liste récupérée.

Si nous essayons maintenant d'accéder à l'url /internal_link/autocomplete/word?q=ab, nous devrions avoir pour unique résultat une déclaration de tableau : []. Notre contrôleur retourne pour le moment un tableau vide, puisqu'aucune entité de type internal_link n'a pour le moment été créée.

Retour au formulaire

Retournons maintenant sur notre classe InternalLinkForm pour y transformer le champ "Word/Phrase" en champ autocomplete et intégrer un processus de validation du choix utilisateur.

Surdéfinition de champ

Pour effectuer la transformation du champ "Word/Phrase", il suffit de rajouter le code suivant dans la fonction "form":

    // Add autocomplete route name for "word" field.
    // This behavior show to user existing data to
    // avoid filling word multiple times.
    if (isset($form['name']['widget'])) {
      $form['name']['widget'][0]['value']['#autocomplete_route_name'] = 'internal_link.autocomplete.word';
    }

Par cet ajout, nous indiquons que le champ "name" est maintenant de type autocomplete et que la route à suivre pour l'auto-suggestion est internal_link.autocomplete.word.

Il nous manque une dernière chose afin que le champ autocomplete soit totalement cohérent. L'utilisateur va pouvoir taper un début de mot, se verra proposé des suggestions qu'il devra si possible éviter d'utiliser. Mais si l'utilisateur clique sur une des suggestions, nous devrons lui indiquer que son choix n'est pas valide. Pour ça, nous allons devoir injecter notre service de données dans le formulaire, et surdéfinir la méthode de validation du formulaire.

Injection du service

Nous allons maintenant voir comment injecter des dépendances dans notre formulaire. Lors de la création du service DataService, nous avions pu voir que la propriété "arguments" était utilisée via un fichier YAML. Dans le cas présent, c'est une autre technique qui est utilisée. Cette technique est la technique par défaut pour l'injection de dépendances. La classe, ou un de ses parents, doit implémenter l'interface ContainerInjectionInterface, ce qui est le cas pour FormBase. Nous pouvons donc ajouter une méthode create de la manière suivante:

  public static function create(ContainerInterface $container) {
    return new static(
      $container->get('entity.manager'),
      $container->get('internal_link.data')
    );
  }

Vous noterez que nous passons également "entity.manager" car la classe ContentEntityForm défini déjà une fonction create a qui elle passe "entity.manager". Dans notre cas, nous passons donc "entity.manager" pour que ça colle avec la classe parent, et notre propre service "internal_link.data".

Nous rajoutons également une variable protégée $dataService et un constructeur avec le code suivant:

  protected $dataService;
 
  public function __construct(EntityManagerInterface $entity_manager, DataService $data_service) {
    parent::__construct($entity_manager);
    $this->dataService = $data_service;
  }

Le constructeur prend maintenant en compte les 2 services ajoutés par la méthode create. Le service "entity.manager" est passé à la classe parent, tandis que notre "dataService" est stocké dans la classe actuelle.

Validation des données

Enfin, nous surdéfinissions la méthode validateForm pour inclure une validation sur le champ "Word/Phrase" afin de s'assurer que l'utilisateur n'insère pas deux fois la même valeur:

  public function validateForm(array &$form, FormStateInterface $form_state) {
 
    // Launch global validation
    $entity = parent::validateForm($form, $form_state);
 
    // Check if name doesn't yet exists in current language.
    $name = $form_state->getValue(['name', 0, 'value'], '');
    $original_name = $this->entity->name->value;
    if ($name !== $original_name
        && $this->dataService->checkExistenceByName($name, $this->getFormLangcode($form_state))) {
      $form_state->setErrorByName('name', t('This word already exists. You cannot add same name more than one time.'));
    }
 
    return $entity;
  }

Cette fonction appelle d'abord la fonction parent afin de s'assurer que la validation de base est effectuée proprement. Elle va ensuite contrôler, via la fonction checkExistenceByName de notre service de données, que la valeur insérée dans le champ "name" n'existe pas déjà pour une autre entité internal_link. Si la validation échoue, un message d'erreur est retourné. Dans le cas contraire, la validation passe et l'entité est sauvegardée.

Au final, notre formulaire doit maintenant correspondre au code suivant:

/**
 * @file
 * Contains \Drupal\internal_link\Form\InternalLinkForm.
 */
 
namespace Drupal\internal_link\Form;
 
use Drupal\Core\Entity\ContentEntityForm;
use Drupal\Core\Entity\EntityManagerInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\internal_link\DataService;
use Symfony\Component\DependencyInjection\ContainerInterface;
 
/**
 * Form controller for Internal link edit forms.
 *
 * @ingroup internal_link
 */
class InternalLinkForm extends ContentEntityForm {
 
 
  /**
   * Drupal\internal_link\DataService definition.
   *
   * @var Drupal\internal_link\DataService
   */
  protected $dataService;
 
  /**
   * Constructs a ContentEntityForm object.
   *
   * @param \Drupal\Core\Entity\EntityManagerInterface $entity_manager
   *   The entity manager.
   */
  public function __construct(EntityManagerInterface $entity_manager, DataService $data_service) {
    parent::__construct($entity_manager);
    $this->dataService = $data_service;
  }
 
  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container) {
    return new static(
      $container->get('entity.manager'),
      $container->get('internal_link.data')
    );
  }
 
  /**
   * {@inheritdoc}
   */
  public function buildForm(array $form, FormStateInterface $form_state) {
    /* @var $entity \Drupal\internal_link\Entity\InternalLink */
    $form = parent::buildForm($form, $form_state);
    $entity = $this->entity;
 
    return $form;
  }
 
  /**
   * {@inheritDoc}
   * @see \Drupal\Core\Entity\ContentEntityForm::form()
   */
  public function form(array $form, FormStateInterface $form_state) {
 
    // Prepare a vertical tabs to store several fields
    $form['advanced'] = [
      '#type' => 'vertical_tabs',
      '#attributes' => ['class' => ['entity-meta']],
      '#weight' => 99,
    ];
 
    $form = parent::form($form, $form_state);
 
    // Internal link author information for administrators.
    $form['author'] = [
      '#type' => 'details',
      '#title' => t('Authoring information'),
      '#group' => 'advanced',
      '#attributes' => [
        'class' => ['internal-link-form-author'],
      ],
      '#weight' => 90,
      '#optional' => TRUE,
    ];
    if (isset($form['uid'])) {
      $form['uid']['#group'] = 'author';
    }
 
    if (isset($form['created'])) {
      $form['created']['#group'] = 'author';
    }
 
    // Internal link information for administrators.
    $form['internal_link'] = [
      '#type' => 'details',
      '#title' => t('Internal link information'),
      '#group' => 'advanced',
      '#attributes' => [
        'class' => ['internal-link-form-details'],
      ],
      '#weight' => 80,
      '#optional' => TRUE,
    ];
 
    if (isset($form['class'])) {
      $form['class']['#group'] = 'internal_link';
    }
    if (isset($form['rel'])) {
      $form['rel']['#group'] = 'internal_link';
    }
    if (isset($form['visibility'])) {
      $form['visibility']['#group'] = 'internal_link';
    }
    if (isset($form['except_list'])) {
      $form['except_list']['#group'] = 'internal_link';
    }
 
    // Add autocomplete route name for "word" field.
    // This behavior show to user existing data to
    // avoid filling word multiple times
    if (isset($form['name']['widget'])) {
      $form['name']['widget'][0]['value']['#autocomplete_route_name'] = 'internal_link.autocomplete.word';
    }
 
    if (isset($form['url']['widget'])) {
      $form['url']['#states'] = [
        'visible' => [
          ':input[name="url_type"]' => ['value' => 'standard'],
        ],
      ];
      $form['url']['widget'][0]['value']['#states'] = [
        'required' => [
          ':input[name="url_type"]' => ['value' => 'standard'],
        ],
      ];
    }
 
    if (isset($form['url_entity_type']['widget'])) {
      $form['url_entity_type']['#states'] = [
        'visible' => [
          ':input[name="url_type"]' => ['value' => 'entity'],
        ],
      ];
      $form['url_entity_type']['widget']['#states'] = [
        'required' => [
          ':input[name="url_type"]' => ['value' => 'entity'],
        ],
      ];
    }
 
    // 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']['widget'][0]['target_id']['#states'] = [
        'visible' => [
          ':input[name="url_type"]' => ['value' => 'entity'],
        ],
        'invisible' => [
          [
            [':input[name="url_type"]' => ['value' => 'standard'],],
            [':input[name="url_entity_type"]' => ['value' => '_none'],],
          ],
        ],
        'required' => [
          ':input[name="url_type"]' => ['value' => 'entity'],
        ]
      ];
    }
    return $form;
  }
 
 
  /**
   * 
   * {@inheritDoc}
   * @see \Drupal\Core\Entity\ContentEntityForm::validateForm()
   */
  public function validateForm(array &$form, FormStateInterface $form_state) {
 
    // Launch global validation
    $entity = parent::validateForm($form, $form_state);
 
    // Check if name doesn't yet exists in current language.
    $name = $form_state->getValue(['name', 0, 'value'], '');
    $original_name = $this->entity->name->value;
    if ($name !== $original_name
        && $this->dataService->checkExistenceByName($name, $this->getFormLangcode($form_state))) {
      $form_state->setErrorByName('name', t('This word already exists. You cannot add same name more than one time.'));
    }
 
    return $entity;
  }
 
 
  /**
   * {@inheritdoc}
   */
  public function save(array $form, FormStateInterface $form_state) {
    $entity = $this->entity;
    $status = parent::save($form, $form_state);
 
    switch ($status) {
      case SAVED_NEW:
        drupal_set_message($this->t('Created the %label Internal link.', [
          '%label' => $entity->label(),
        ]));
        break;
 
      default:
        drupal_set_message($this->t('Saved the %label Internal link.', [
          '%label' => $entity->label(),
        ]));
    }
    $form_state->setRedirect('entity.internal_link.canonical', ['internal_link' => $entity->id()]);
  }
}

Vous pouvez tenter de créer plusieurs entités de type internal_link et vous verrez que le champ Word/Phrase affiche maintenant des propositions (dès lors qu'au moins une entité a été créée et que la valeur insérée dans le champ commence par la même valeur que celle de l'entité créée, bien entendu).

Conclusion

Avec cet article, nous avons pu introduire différents éléments intéressants tels que la surdéfinition de champ, le routage, les contrôleurs et l'injection de dépendances. Ces mécanismes nous permettent d'avancer encore un peu avec Drupal 8.

Ajouter un commentaire

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