Drupal 8: @EntityReferenceSelection — плагин автодополнения для сущностей

У меня уже есть материал по поводу добавления автодополнения, но он покрывает лишь в общих чертах как это работает и как пользоваться. В данном материале, мы рассмотрим один из способов детальнее.

В Drupal 8 появился такой FormElement как entity_autocomplete. Он позволяет добавлять автодополнение по сущностям с некоторыми настройками. Элемент является надстройкой над обычным автодополнением по маршруту со своим собственным system.entity_autocomplete.

Примерно элемент выглядит следующим образом:

$element['autocomplete'] = [
  '#title' => new TranslatableMarkup('Autocomplete'),
  '#type' => 'entity_autocomplete',
  '#target_type' => 'node',
  '#selection_settings' => [
    'target_bundles' => ['article', 'page'],
  ],
];

Всё просто и понятно, и даже настраиваемо.

Этот же элемент используется типом поля entity_reference и его виджетом по умолчанию "Автодополнение". Это тот самый, в котором вы вводите, например, теги материала в стандартной установке ядра.

Казалось бы, о чём тут писать, просто же как 2х2. Но вы знали что у этого элемента есть собственный тип плагинов? Вероятнее всего, вы с ним не сталкивались. Из коробки у него предоставляются плагины для сущностей, которые покрывают большинство потребностей многих сайтов, поэтому и нет причин искать.

Но когда появляется потребность как-то повлиять на работу этого элемента, данные плагины приходят на помощь. Если вы попытаетесь погуглить по этой задаче, вы найдете некорректные материалы и советы (по крайней мере из того что попалось мне). Все они советуют использовать альтер маршрутов, для того чтобы подменить контроллер system.entity_autocomplete, и в нём править результаты. Что в корне неверно, когда у нас есть под это целая система плагинов, с которой взаимодействует не только какой-то конкретный элемент, но и поля, а также прочие модули.

Аннотация @EntityReferenceSelection

В аннотации, как обычно, мы можем указывать различные настройки плагина. И аннотации данного типа плагинов стоит уделить внимание, чтобы ваши плагины начали работать.

/**
 * Provides specific access control for the file entity type.
 *
 * @EntityReferenceSelection(
 *   id = "default:file",
 *   label = @Translation("File selection"),
 *   entity_types = {"file"},
 *   group = "default",
 *   weight = 1
 * )
 */

В данной аннотации мы можем указать следующие настройки:

  • id: ID плагина.
  • label: Метка плагина.
  • group: Группа плагина, в пределах данного типа плагинов, об это подробнее чуть ниже.
  • entity_types: (опционально) Массив с машинными названиями сущностей, для которых данный плагин применим. Если данное значение не указывать, ядро считает данный плагин применимым ко всем типам сущностей.
  • weight: (опционально) Вес плагина среди других отобранных. По умолчанию 0.

Всё должно быть просто и понятно, но немножко задержимся на group. У данных плагинов есть деление на группы. По умолчанию, всегда используется группа " default".

Ядро предоставляет две группы (не считая broken для обработки битых плагинов):

  • default: Группа по умолчанию. Позволяет выбрать подтипы сущности, которые можно использовать в качестве связей, возможность установить настройку " Создавать связанные сущности, если они не существуют". Должна быть знакома всем, кто использует entity reference поля. Она просто делает запрос в БД.
  • views: Позволяет использовать views результаты в качестве источника данных для автозаполнения. Предоставляется Views. Имеет свои собственные настройки, отличные от default.

И тут самое интересное — каждая группа данных плагинов, может вести себя по своему, иметь собственные настройки и поведение, но в конечном итоге, они предоставляют результаты для автодополнения.

Если упростить, то group в данных плагинах — вариант получения данных для автодополнения, а entity_types - ограничивает действие на определенные типы сущностей, если это необходимо. Поэтому, group самый важный параметр. Он виляет на то, подключитесь ли вы к уже имеющейся группе плагинов или начнете делать свою (об этом тоже чуть ниже).

Объект плагина

Плагин должен расширять Drupal\Core\Entity\EntityReferenceSelection\SelectionPluginBase.

В своём плагине вы можете переопределить множество методов, но обратить внимание можно на:

  • defaultConfiguration(): Позволяет задать настройки плагина по умолчанию.
  • buildConfigurationForm(): Позволяет задать форму для настроек плагина. Данная форма (по умолчанию в ядре) используется на странице настроек поля entity_reference при выборе соответствующего плагина.
  • validateConfigurationForm(): Валидация своей формы.
  • entityQueryAlter(): Позволяет перекрыть основной запрос в БД перед его выполнением.
  • getReferenceableEntities(): Основной метод плагина. Именно он и отвечает за то, что будет показано в автодополнении.
  • countReferencebleEntitties(): Метод должен возвращать количество материалов подходящих под конкретное автодополнение.
  • validateReferenceableEntities(): Получает список ID сущностей, которые необходимо проверить что они существуют в момент проверки.

getReferenceableEntities()

Самым важным методом, как уже было написано, является getReferenceableEntities(). Он производит поиск и формирует результат для автодополнения, поэтому, остановимся на нём.

Он принимает 3 аргумента:

  • $match: То, по чему нужно искать результаты. Это то, что вводит пользователь в поле.
  • $match_operator: (по умолчанию 'CONTAINS') Оператор сравнения для запроса. Здесь может прийти любой оператор поэтому при написании запроса, нужно использовать данную переменную, а не хардкодить.
  • $limit: (по умолчанию 0) Позволяет ограничить кол-во результатов, которые необходимо вернуть. Опять, чтобы не хардкодить в запросе, используется переменная.

В качестве результата он должен возвращать массив из опций, которые будут показаны пользователю в выпадающем списке. Формат, возвращаемого массива должен быть следующего вида $options['ENTITY_BUNDLE']['ENTITY_ID'] = 'Label'.

Пример (по умолчанию, если вы не объявите свой)
  /**
   * {@inheritdoc}
   */
  public function getReferenceableEntities($match = NULL, $match_operator = 'CONTAINS', $limit = 0) {
    $target_type = $this->getConfiguration()['target_type'];

    $query = $this->buildEntityQuery($match, $match_operator);
    if ($limit > 0) {
      $query->range(0, $limit);
    }

    $result = $query->execute();

    if (empty($result)) {
      return [];
    }

    $options = [];
    $entities = $this->entityTypeManager->getStorage($target_type)->loadMultiple($result);
    foreach ($entities as $entity_id => $entity) {
      $bundle = $entity->bundle();
      $options[$bundle][$entity_id] = Html::escape($this->entityRepository->getTranslationFromContext($entity)->label());
    }

    return $options;
  }

Варианты создания плагина

Для того чтобы создавать данные плагины корректно, стоит разобраться как работает поиск и группировка.

Здесь стоит вернуться к аннотации, вспомнить про id, group и entity_types.

Поиск плагинов построен на двух условиях, и только удовлетворяя их, будут подхватываться ваши плагины. Можно выделить два способа создания данного плагина:

  1. Монолитный плагин. Данный плагин будет сам в себе. Примером такого плагина в ядре является views. Он сводится, по сути, к выбору представления, на которое и ложится вся логика. А плагин лишь прослойка. Ему не важно какой тип сущности нужно искать и т.д., всё это решается уже на уровне представления с учетом переданных настроек через #selection_settings, а не поля\плагина. Пример: Drupal\views\Plugin\EntityReferenceSelection\ViewsSelection.
  2. Базовый плагин + плагины сущностей. Подобный подход используется плагином default из ядра. Он имеет базовый плагин, в котором описана основная логика, а при помощи деритив создаются плагины под каждую сущность на сайте. Выходит, что у каждой сущности есть свой собственный плагин. Пример: Drupal\Core\Entity\Plugin\EntityReferenceSelection\DefaultSelection + Drupal\Core\Entity\Plugin\Derivative\DefaultSelectionDeriver.

Второй вариант гибче, за счёт того, что у каждой сущности свой плагин, мы можем создать похожий плагин с большим весом и перекрыть работу оригинала. В случае первого варианта, нам нечего перекрывать, но там и не нужна такая гибкость, так как работа передается стороннему API, а там мы уже также можем подлазить его средствами.

Как же их различает Drupal?

Чтобы создать монолитный плагин, вам достаточно чтобы в аннотации плагина id был равен group. Вы можете также сделать такой плагин привязанный к конкртетной сущности, указав entity_types, но по факту в этом нет смысла.

Если вы хотите создать плагин, который привязывается к каждой сущности. Вам нужно создать базовый плагин по принципу монолитного, при этом также создать и указать деритиву. При данном подходе id должен быть в формате {group}:{entity_type_id}. Самое важное здесь, чтобы вторая часть была равна entity_type_id, так как там стоят на это доп. проверки. Например, у плагинов default, для ноды создаётся плагин default:node.

Если вы не следуете хотябы одному из этих условий, данные плагины не будут видны в интерфейсах, но вы по прежнему сможете их использовать программно. Но так как вы теряете часть гибкости, лучше не принебрегать неймингу.

В целом, маловероятно что вам потребуется создавать абсолютно уникальный тип связей, поэтому, для переопределения важна лишь часть про второй вариант, так как он является основным для автодополнений сущностей.

Пример

Для примера мы возьмём реальный кейс. В Drupal Commerce 2 при редактировании заказа вы можете добавлять новые вариации товара к заказу. Данная форма также построена с entity_reference полем. Но у неё есть недостаток. Все стандартные (default) плагины ищут по названию сущности. Но работая с товарами, их проще искать через артикулы, нежели через названия, и тут то автокомплит становится неполноценным и надо вмешиваться. Погуглив, вы найдете всё те же советы по подмене контроллера всей это системы плагинов с подменой результатов на свои. Жесткач в общем. Мы так не будем делать.

У нас есть 2 способа решить данную задачу:

  1. По умолчанию, там используется, как и везде, default плагин. Сущность, по которой происходит поиск — вариации товара commerce_product_variation. Берем также во внимание как создаются и работают плагины default. Складываем 2 + 2 и получаем, что можем создать плагин, который подхватится default группой с большим весом и перекроет оригинальные от деритивы.
  2. Можно создать монолитный плагин, выбрать как способ связи, но это как-то слишком и потребует дополнительных действий. С другой стороны, это хорошее решение в случае, если вы не хотите чтобы данное переопределение влияло на другие автодополнения сайта по товарам.

Самым правильным, простым и лучшим здесь будет первый способ. Нам не нужно придумывать никакой сложной логики или настроек, нас вполне устроит стандартный плагин, нам лишь нужно скорректировать запрос. Наш плагин просто вклиниться в дествующую логику и будет работать везде где будет подобный автокомплит, что решит проблему не только в той форме, но и в целом по проекту. Его также будет легко переносить с проекта на проект.

Для примера были добавлены два товара, у каждого по два варианта:

  • T-Shirt Drupal
    • TS-DRUPAL-RED
    • TS-DRUPAL-BLUE
  • T-Shirt Symfony
    • TS-SYMFONY-RED
    • TS-SYMFONY-BLUE

Как видно, поиск по артикулам совершенно не работает, но отлично справляется с заголовком. Проблему усугублена тем, что автогенерация заголовков вариантам товаров отключена, в итоге, они вовсе получают название товара, из-за чего редактировать заказ очень проблематично.

Для того это чтобы исправить, мы создадим плагин с группой default, чтобы попасть в дефолтный способ автокомплита. Мы укажем что он применим только к сущности commerce_product_variation при помощи entity_types. Так как нам придётся переопределить запрос с универсального на конкретный (из-за привязки к полю сущности sku). Чтобы наш плагин обнаружился мы дадим ему id dummy:commerce_product_variation, где dummy - название модуля, а commerce_product_variation - тип сущности к которой мы хотим чтобы данный плагин цеплялся. Так данный плагин будет подхвачен полем автокомплита. Затем мы укажем weight больше 0, чтобы наш плагин имел больший приоритет перед оригинальным.

Наш плагин будет расширять Drupal\Core\Entity\Plugin\EntityReferenceSelection\DefaultSelection, так как нас устраивает стандартный автокомплит, мы лишь хотим слегка подредактировать.

src/Plugin/EntityReferenceSelection/ProductVariationWithSkuSelection.php
<?php

namespace Drupal\dummy\Plugin\EntityReferenceSelection;

use Drupal\Component\Utility\Html;
use Drupal\Core\Entity\Plugin\EntityReferenceSelection\DefaultSelection;

/**
 * Provides autocomplete selection for commerce order item with SKU support.
 *
 * @EntityReferenceSelection(
 *   id = "dummy:commerce_product_variation",
 *   label = @Translation("Order Item selection with SKU"),
 *   entity_types = {"commerce_product_variation"},
 *   group = "default",
 *   weight = 5
 * )
 */
class ProductVariationWithSkuSelection extends DefaultSelection {

  /**
   * {@inheritdoc}
   */
  public function getReferenceableEntities($match = NULL, $match_operator = 'CONTAINS', $limit = 0) {
    $target_type = $this->getConfiguration()['target_type'];

    // Pass 'NULL' for match to pass title build.
    $query = $this->buildEntityQuery(NULL, $match_operator);
    // Define our special condition, which will looking in 'title' and 'sku'.
    $or = $query->orConditionGroup();
    $or->condition('title', $match, $match_operator);
    $or->condition('sku', $match, $match_operator);
    $query->condition($or);

    if ($limit > 0) {
      $query->range(0, $limit);
    }

    $result = $query->execute();

    if (empty($result)) {
      return [];
    }

    $options = [];
    /** @var \Drupal\commerce_product\Entity\ProductVariationInterface[] $entities */
    $entities = $this->entityTypeManager->getStorage($target_type)->loadMultiple($result);
    foreach ($entities as $entity_id => $entity) {
      $bundle = $entity->bundle();
      $label = $this->entityRepository->getTranslationFromContext($entity)->label();
      $sku = $entity->getSku();
      // Pass SKU in label as well.
      $options[$bundle][$entity_id] = Html::escape("({$sku}) {$label}");
    }

    return $options;
  }

}

Мы переопределили единственный метод getReferenceableEntities() который и отвечает за результат. По сути, его содержание это копи-паст из оригинала, но с некоторыми корректировками:

  • В buildEntityQuery() вместо $match передается NULL, чтобы не был создан condition() по заголовку.
  • Далее мы сами создаем группу условий OR, в которой и указываем условия по заголовку и артикулу. Таким способом не важно что вводит юзер, поиск будет идти по одному из параметров, и если хотябы в одном найдётся, он получит результаты.
  • В цикле с формированием результатов, мы также меняем метку что увидит пользователь с label (id) на (sku) label (id). Если вас смущает синтаксис, можете указать любой. Также не стоит беспокоиться на счёт того что будет некорректно распарсена строка, ибо регулярка отвечающая за разбор данной строки на значение в Drupal\Core\Entity\Element\EntityAutocomplete::extractEntityIdFromAutocompleteInput() ищет её в конце строки.

Вы также можете теперь использовать в формах следующую конструкцию для добавления автодополнения с учетом плагина:

    $form['variations_autocomplete'] = [
      '#type' => 'entity_autocomplete',
      '#target_type' => 'commerce_product_variation',
    ];

Это работает потому что FormElement entity_autocomplete имеет параметр #selection_handler по умолчанию в default, таким образом, подхватывается наш плагин. Также, везде где использовался данный элемент и другими модулями, заработают подсказки.

Ссылки

Комментарии

Комментарии временно отключены

Слишком много спама. Нужно немного времени чтобы реализовать инструменты для модерации спама через Телеграм и вернуть их обратно.

phjester
чт, 20/07/2023 - 19:55

id, label, weight указываем в комментарии.
Комментарии - это весомая часть кода?

Niklan
сб, 22/07/2023 - 09:31

Это аннотация, да, она часть "кода" без которого оно не заработает.

Поделиться