toolshed-8.x-1.x-dev/modules/toolshed_search/src/Plugin/views/filter/EntitySelectionFilter.php
modules/toolshed_search/src/Plugin/views/filter/EntitySelectionFilter.php
<?php
namespace Drupal\toolshed_search\Plugin\views\filter;
use Drupal\Component\Plugin\Exception\PluginNotFoundException;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem;
use Drupal\Core\Field\TypedData\FieldItemDataDefinitionInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\TypedData\DataReferenceDefinitionInterface;
use Drupal\search_api\Item\FieldInterface;
use Drupal\search_api\ParseMode\ParseModePluginManager;
use Drupal\search_api\Plugin\views\filter\SearchApiFilterTrait;
use Drupal\search_api\Utility\DataTypeHelperInterface;
use Drupal\views\Plugin\views\filter\FilterPluginBase;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* A filter for selecting entities values of an entity type.
*
* @ingroup views_filter_handlers
*
* @ViewsFilter("toolshed_entity_selection")
*/
class EntitySelectionFilter extends FilterPluginBase implements ContainerFactoryPluginInterface {
use SearchApiFilterTrait;
/**
* The entity type this filter field belongs to.
*
* @var string|false|null
*/
protected string|false|null $entityTypeId;
/**
* Information about target entity to generate autocomplete values for.
*
* @var array|null
*/
protected ?array $targetEntityInfo;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected EntityTypeManagerInterface $entityTypeManager;
/**
* The Search API parse mode plugin manager for fulltext word parsing.
*
* @var \Drupal\search_api\ParseMode\ParseModePluginManager
*/
protected ParseModePluginManager $parseModeManager;
/**
* The Search API data type helper.
*
* @var \Drupal\search_api\Utility\DataTypeHelperInterface
*/
protected DataTypeHelperInterface $dataTypeHelper;
/**
* Create a new instance of the EntitySelectionFilter View plugin handler.
*
* @param array $configuration
* The filter plugin handler.
* @param string $plugin_id
* The plugin identifier.
* @param mixed $plugin_definition
* The plugin definition.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
* @param \Drupal\search_api\ParseMode\ParseModePluginManager $parse_mode_manager
* The fulltext search word parse mode manager.
* @param \Drupal\search_api\Utility\DataTypeHelperInterface $data_type_helper
* The Search API data type helper utility.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager, ParseModePluginManager $parse_mode_manager, DataTypeHelperInterface $data_type_helper) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->entityTypeManager = $entity_type_manager;
$this->parseModeManager = $parse_mode_manager;
$this->dataTypeHelper = $data_type_helper;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('entity_type.manager'),
$container->get('plugin.manager.search_api.parse_mode'),
$container->get('search_api.data_type_helper')
);
}
/**
* Gets the entity type ID for the entity that owns this field.
*
* Get the entity type of the field parent if the field is an entity field. If
* the field is not part of an entity, then return NULL.
*
* @return string|null
* The entity type machine name of the entity that has the real field of
* this filter. Returns NULL if the underlying field is not an entity field.
*/
protected function getEntityTypeId(): ?string {
if (!isset($this->entityTypeId)) {
$this->entityTypeId = FALSE;
if ($field = $this->getIndex()->getField($this->realField)) {
$dataDef = $field->getDataDefinition();
if ($dataDef instanceof FieldItemDataDefinitionInterface) {
$this->entityTypeId = $dataDef
->getFieldDefinition()
->getTargetEntityTypeId();
}
}
}
return $this->entityTypeId ?: NULL;
}
/**
* Get the entity type info of the target entity.
*
* The get the entity type and bundles to fetch autocomplete suggestions and
* filter against for this field's query.
*
* Contains the "entity_type" and "bundles" keys with the target entity info.
*
* @param \Drupal\search_api\Item\FieldInterface $field
* The field to determine the target entity info from.
*
* @return array
* The entity info for the entity type to target for the selection type.
*
* @throws \InvalidArgumentException
* When the filter is unable to determine a valid entity target.
*/
protected function getTargetEntityInfo(FieldInterface $field): array {
/** @var \Drupal\Core\Field\TypedData\FieldItemDataDefinitionInterface $dataDef */
$dataDef = $field->getDataDefinition();
if (is_a($dataDef->getClass(), EntityReferenceItem::class, TRUE)) {
$propDef = $dataDef->getPropertyDefinition('entity');
if ($propDef instanceof DataReferenceDefinitionInterface && $targetDef = $propDef->getTargetDefinition()) {
/** @var \Drupal\Core\Entity\TypedData\EntityDataDefinitionInterface $targetDef */
return [
'entity_type' => $targetDef->getEntityTypeId(),
'bundles' => $targetDef->getBundles() ?? $dataDef->getSetting('handler_settings')['target_bundles'] ?? [],
];
}
}
$error = sprintf('EntitySelectionFilter target entity cannot be determined for "%s" field.', $this->options['id']);
throw new \InvalidArgumentException($error);
}
/**
* Get the settings configured for this filter for autocomplete suggestions.
*
* @return array|null
* The settings for the autocomplete controller to setup the results.
*/
public function getAutocompleteSettings(): ?array {
$field = $this->getIndex()->getField($this->realField);
if ($settings = $this->getTargetEntityInfo($field)) {
$settings += [
'fulltext_op' => $this->options['expose']['fulltext_op'] ?? 'AND',
'fulltext_fields' => $this->options['expose']['autocomplete_fields'] ?: NULL,
'parse_mode' => $this->options['expose']['autocomplete_parse_mode'] ?? NULL,
'limit' => $this->options['expose']['autocomplete_limit'] ?? 12,
];
}
return $settings;
}
/**
* {@inheritdoc}
*/
protected function defineOptions(): array {
$options = parent::defineOptions();
$options['type'] = ['default' => 'search'];
$options['operator'] = ['default' => 'IN'];
// Exposed settings specifically for autocomplete settings.
$options['expose']['contains'] += [
'placeholder' => ['default' => NULL],
'allow_fulltext_search' => ['default' => TRUE],
'fulltext_fields' => ['default' => []],
'fulltext_op' => ['default' => 'AND'],
'autocomplete_fields' => ['default' => []],
'autocomplete_parse_mode' => ['default' => 'phrase'],
'autocomplete_limit' => ['default' => 12],
'autocomplete_min_length' => ['default' => 3],
'autocomplete_delay' => ['default' => 200],
];
return $options;
}
/**
* {@inheritdoc}
*/
public function buildExposeForm(&$form, FormStateInterface $form_state): void {
parent::buildExposeForm($form, $form_state);
$index = $this->getIndex();
$field = $index->getField($this->realField);
$textfields = $this->getFulltextFields();
if ($targetInfo = $this->getTargetEntityInfo($field)) {
$targetAcFields = $textfields['entity:' . $targetInfo['entity_type']] ?? [];
$targetEntityType = $this->entityTypeManager->getDefinition($targetInfo['entity_type']);
$form['expose']['autocomplete_fields'] = [
'#type' => 'select',
'#title' => $this->t('@entity_type fulltext fields', [
'@entity_type' => $targetEntityType->getLabel(),
]),
'#options' => $textfields['_global'] + $targetAcFields,
'#multiple' => TRUE,
'#required' => TRUE,
'#default_value' => $this->options['expose']['autocomplete_fields'],
'#description' => $this->t('Fulltext fields for matching the target entity in the autocomplete.'),
];
$form['expose']['autocomplete_min_length'] = [
'#type' => 'number',
'#title' => $this->t('Minimum keyword length'),
'#step' => 1,
'#min' => 1,
'#default_value' => $this->options['expose']['autocomplete_min_length'],
];
$form['expose']['autocomplete_delay'] = [
'#type' => 'number',
'#title' => $this->t('Time in milliseconds to delay after keypresses before retrieving autocomplete suggestions'),
'#step' => 25,
'#min' => 25,
'#field_suffix' => 'ms',
'#default_value' => $this->options['expose']['autocomplete_delay'],
];
}
$form['expose']['placeholder'] = [
'#type' => 'textfield',
'#title' => $this->t('Placeholder text'),
'#description' => $this->t('Placeholder text for the search text field'),
'#default_value' => $this->options['expose']['placeholder'],
];
$form['expose']['fulltext_op'] = [
'#type' => 'select',
'#title' => $this->t('Search matching operator'),
'#options' => [
'AND' => $this->t('Contains all these words'),
'OR' => $this->t('Contains any of these words'),
'NOT' => $this->t('Contains none of these words'),
],
'#required' => TRUE,
'#default_value' => $this->options['expose']['fulltext_op'],
];
$form['expose']['autocomplete_parse_mode'] = [
'#type' => 'select',
'#title' => $this->t('Text parse mode'),
'#options' => $this->getParseModes(),
'#default_value' => $this->options['expose']['autocomplete_parse_mode'],
];
if ($entityTypeId = $this->getEntityTypeId()) {
$form['expose']['allow_fulltext_search'] = [
'#type' => 'checkbox',
'#title' => $this->t('Allow text search when entity is not selected'),
'#required' => empty($targetInfo),
'#default_value' => $this->options['expose']['allow_fulltext_search'],
'#description' => $this->t('Allow search to a fulltext field fallback when an entity suggestion is not selected.'),
];
$entityTextfields = $textfields['entity:' . $entityTypeId] ?? [];
$form['expose']['fulltext_fields'] = [
'#type' => 'select',
'#title' => $this->t('Fallback search fulltext fields'),
'#multiple' => TRUE,
'#options' => $textfields['_global'] + $entityTextfields,
'#default_value' => $this->options['expose']['fulltext_fields'],
'#states' => [
'visible' => [
':input[name="options[expose][allow_fulltext_search]"]' => [
'checked' => TRUE,
],
],
'required' => [
':input[name="options[expose][allow_fulltext_search]"]' => [
'checked' => TRUE,
],
],
],
];
}
}
/**
* {@inheritdoc}
*/
protected function valueForm(&$form, FormStateInterface $form_state): void {
parent::valueForm($form, $form_state);
$field = $this->getIndex()->getField($this->realField);
$targetInfo = $this->getTargetEntityInfo($field);
if ($form_state->get('exposed')) {
$form['value'] = [
'#type' => 'toolshed_autocomplete',
'#title' => $this->t('Value'),
'#autocomplete_route_name' => 'toolshed_search.entity_autocomplete',
'#autocomplete_route_parameters' => [
'view' => $this->view->id(),
'display' => $this->view->current_display,
'filter_id' => $this->options['id'],
],
'#autocomplete_settings' => [
'separateValue' => TRUE,
'requireSelect' => empty($this->options['expose']['allow_fulltext_search']),
'minLength' => $this->options['expose']['autocomplete_min_length'],
'delay' => $this->options['expose']['autocomplete_delay'],
],
];
if (!empty($this->options['expose']['placeholder'])) {
$form['value']['#placeholder'] = $this->options['expose']['placeholder'];
}
$values = $this->extractExposeValue($this->view->getExposedInput());
if (is_array($values)) {
$entity = $this->entityTypeManager
->getStorage($targetInfo['entity_type'])
->load(reset($values));
if ($entity) {
$form['value']['#default_value'] = 'id:' . implode(',', $values);
$form['value']['#attributes']['data-text'] = $entity->label();
}
}
else {
$form['value']['#default_value'] = $values;
}
}
else {
if ($this->value) {
$value = is_array($this->value) ? reset($this->value) : $this->value;
$entity = $this->entityTypeManager
->getStorage($targetInfo['entity_type'])
->load($value);
}
$form['value'] = [
'#type' => 'entity_autocomplete',
'#title' => $this->t('Value'),
'#target_type' => $targetInfo['entity_type'],
'#default_value' => $entity ?? NULL,
];
if ($targetInfo['bundles']) {
$form['value']['#selection_settings']['target_bundles'] = $targetInfo['bundles'];
}
}
}
/**
* {@inheritdoc}
*/
public function operatorOptions($which = 'title'): array {
$options = [];
foreach ($this->operators() as $id => $info) {
$options[$id] = $info[$which];
}
return $options;
}
/**
* {@inheritdoc}
*/
public function acceptExposedInput($input): bool {
if ($values = $this->extractExposeValue($input)) {
$this->value = $values;
return TRUE;
}
return FALSE;
}
/**
* {@inheritdoc}
*/
public function query(): void {
if (!isset($this->value) || '' === $this->value) {
return;
}
$this->ensureMyTable();
$query = $this->getQuery();
$settings = $this->options['expose'];
if (is_array($this->value)) {
$query->addCondition($this->realField, $this->value, $this->options['operator']);
}
elseif ($settings['allow_fulltext_search'] && $settings['fulltext_fields']) {
$keys = $this->value;
try {
if ($parseModeId = $settings['autocomplete_parse_mode']) {
$parseConjunction = 'AND' == $settings['fulltext_op'] ? 'AND' : 'OR';
/** @var \Drupal\search_api\ParseMode\ParseModeInterface $parseMode */
$parseMode = $this->parseModeManager->createInstance($parseModeId);
$parseMode->setConjunction($parseConjunction);
$keys = $parseMode->parseInput($keys);
if ('NOT' === $settings['fulltext_op'] && is_array($keys)) {
$keys['#negation'] = TRUE;
}
}
}
catch (PluginNotFoundException $e) {
// No parse mode changes, because parse mode plugin is missing.
}
$op = 'NOT' === $settings['fulltext_op'] ? '<>' : '=';
if (count($settings['fulltext_fields']) > 1) {
$orCond = $query->createAndAddConditionGroup('OR');
foreach ($settings['fulltext_fields'] as $fulltextField) {
$orCond->addCondition($fulltextField, $keys, $op);
}
}
else {
$query->addCondition(reset($settings['fulltext_fields']), $keys, $op);
}
}
}
/**
* Get the available parse mode labels keyed by the plugin ID.
*
* @return \Drupal\Core\StringTranslation\TranslatableMarkup[]|string[]
* All non-hidden Search API parse mode plugin labels, keyed by the
* plugin ID. Array is compatible with the select form element "#options".
*/
protected function getParseModes(): array {
$parseModes = [];
foreach ($this->parseModeManager->getDefinitions() as $parseModeDef) {
if (empty($parseModeDef['no_ui'])) {
$parseModes[$parseModeDef['id']] = $parseModeDef['label'];
}
}
return $parseModes;
}
/**
* Get all the fulltext fields from the search index grouped by datasource.
*
* @return array
* An array of fulltext fields from the search index, grouped by their
* datasources.
*/
protected function getFulltextFields(): array {
$index = $this->getIndex();
$textfields = ['_global' => []];
foreach ($index->getFields() as $name => $field) {
if ($this->dataTypeHelper->isTextType($field->getType())) {
$sourceId = $field->getDatasourceId() ?? '_global';
$textfields[$sourceId][$name] = $field->getLabel();
}
}
return $textfields;
}
/**
* Returns information about the available operators for this filter.
*
* @return array[]
* An associative array mapping operator identifiers to their information.
* The operator information itself is an associative array with the
* following keys:
* - title: The translated title for the operator.
* - short: The translated short title for the operator.
* - values: The number of values the operator requires as input.
*/
public function operators(): array {
return [
'IN' => [
'title' => $this->t('Is one of'),
'short' => $this->t('in'),
'values' => 1,
],
'NOT IN' => [
'title' => $this->t('Is none of'),
'short' => $this->t('not in'),
'values' => 1,
],
];
}
/**
* Extract the filter value from the exposed input values.
*
* @param array $input
* The exposed input to extract the filter values from.
*
* @return array|string|null
* An array of entity IDs if values are entities, and a string if it
* just a text search. NULL if no valid value is available.
*/
protected function extractExposeValue(array $input = []): array|string|null {
if (!$this->options['exposed']) {
return NULL;
}
$id = $this->options['expose']['identifier'] ?? $this->options['id'];
if (!empty($input[$id])) {
if (preg_match('/^id:(\d+(?:,\d+)*)$/', $input[$id], $matches)) {
return array_map('intval', explode(',', $matches[1]));
}
elseif ($this->options['expose']['allow_fulltext_search']) {
return $input[$id];
}
}
return NULL;
}
}
