EntityAutocomplete.php

Same filename in other branches
  1. 9 core/lib/Drupal/Core/Entity/Element/EntityAutocomplete.php
  2. 10 core/lib/Drupal/Core/Entity/Element/EntityAutocomplete.php
  3. 11.x core/lib/Drupal/Core/Entity/Element/EntityAutocomplete.php

Namespace

Drupal\Core\Entity\Element

File

core/lib/Drupal/Core/Entity/Element/EntityAutocomplete.php

View source
<?php

namespace Drupal\Core\Entity\Element;

use Drupal\Component\Utility\Crypt;
use Drupal\Component\Utility\Tags;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityReferenceSelection\SelectionInterface;
use Drupal\Core\Entity\EntityReferenceSelection\SelectionWithAutocreateInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Render\Element\Textfield;
use Drupal\Core\Site\Settings;

/**
 * Provides an entity autocomplete form element.
 *
 * The autocomplete form element allows users to select one or multiple
 * entities, which can come from all or specific bundles of an entity type.
 *
 * Properties:
 * - #target_type: (required) The ID of the target entity type.
 * - #tags: (optional) TRUE if the element allows multiple selection. Defaults
 *   to FALSE.
 * - #default_value: (optional) The default entity or an array of default
 *   entities, depending on the value of #tags.
 * - #selection_handler: (optional) The plugin ID of the entity reference
 *   selection handler (a plugin of type EntityReferenceSelection). The default
 *   value is the lowest-weighted plugin that is compatible with #target_type.
 * - #selection_settings: (optional) An array of settings for the selection
 *   handler. Settings for the default selection handler
 *   \Drupal\Core\Entity\Plugin\EntityReferenceSelection\DefaultSelection are:
 *   - target_bundles: Array of bundles to allow (omit to allow all bundles).
 *   - sort: Array with 'field' and 'direction' keys, determining how results
 *     will be sorted. Defaults to unsorted.
 * - #autocreate: (optional) Array of settings used to auto-create entities
 *   that do not exist (omit to not auto-create entities). Elements:
 *   - bundle: (required) Bundle to use for auto-created entities.
 *   - uid: User ID to use as the author of auto-created entities. Defaults to
 *     the current user.
 * - #process_default_value: (optional) Set to FALSE if the #default_value
 *   property is processed and access checked elsewhere (such as by a Field API
 *   widget). Defaults to TRUE.
 * - #validate_reference: (optional) Set to FALSE if validation of the selected
 *   entities is performed elsewhere. Defaults to TRUE.
 *
 * Usage example:
 * @code
 * $form['my_element'] = [
 *  '#type' => 'entity_autocomplete',
 *  '#target_type' => 'node',
 *  '#tags' => TRUE,
 *  '#default_value' => $node,
 *  '#selection_handler' => 'default',
 *  '#selection_settings' => [
 *    'target_bundles' => ['article', 'page'],
 *   ],
 *  '#autocreate' => [
 *    'bundle' => 'article',
 *    'uid' => <a valid user ID>,
 *   ],
 * ];
 * @endcode
 *
 * @see \Drupal\Core\Entity\Plugin\EntityReferenceSelection\DefaultSelection
 *
 * @FormElement("entity_autocomplete")
 */
class EntityAutocomplete extends Textfield {
    
    /**
     * {@inheritdoc}
     */
    public function getInfo() {
        $info = parent::getInfo();
        $class = get_class($this);
        // Apply default form element properties.
        $info['#target_type'] = NULL;
        $info['#selection_handler'] = 'default';
        $info['#selection_settings'] = [];
        $info['#tags'] = FALSE;
        $info['#autocreate'] = NULL;
        // This should only be set to FALSE if proper validation by the selection
        // handler is performed at another level on the extracted form values.
        $info['#validate_reference'] = TRUE;
        // IMPORTANT! This should only be set to FALSE if the #default_value
        // property is processed at another level (e.g. by a Field API widget) and
        // its value is properly checked for access.
        $info['#process_default_value'] = TRUE;
        $info['#element_validate'] = [
            [
                $class,
                'validateEntityAutocomplete',
            ],
        ];
        array_unshift($info['#process'], [
            $class,
            'processEntityAutocomplete',
        ]);
        return $info;
    }
    
    /**
     * {@inheritdoc}
     */
    public static function valueCallback(&$element, $input, FormStateInterface $form_state) {
        // Process the #default_value property.
        if ($input === FALSE && isset($element['#default_value']) && $element['#process_default_value']) {
            if (is_array($element['#default_value']) && $element['#tags'] !== TRUE) {
                throw new \InvalidArgumentException('The #default_value property is an array but the form element does not allow multiple values.');
            }
            elseif (!empty($element['#default_value']) && !is_array($element['#default_value'])) {
                // Convert the default value into an array for easier processing in
                // static::getEntityLabels().
                $element['#default_value'] = [
                    $element['#default_value'],
                ];
            }
            if ($element['#default_value']) {
                if (!reset($element['#default_value']) instanceof EntityInterface) {
                    throw new \InvalidArgumentException('The #default_value property has to be an entity object or an array of entity objects.');
                }
                // Extract the labels from the passed-in entity objects, taking access
                // checks into account.
                return static::getEntityLabels($element['#default_value']);
            }
        }
        // Potentially the #value is set directly, so it contains the 'target_id'
        // array structure instead of a string.
        if ($input !== FALSE && is_array($input)) {
            $entity_ids = array_map(function (array $item) {
                return $item['target_id'];
            }, $input);
            $entities = \Drupal::entityTypeManager()->getStorage($element['#target_type'])
                ->loadMultiple($entity_ids);
            return static::getEntityLabels($entities);
        }
    }
    
    /**
     * Adds entity autocomplete functionality to a form element.
     *
     * @param array $element
     *   The form element to process. Properties used:
     *   - #target_type: The ID of the target entity type.
     *   - #selection_handler: The plugin ID of the entity reference selection
     *     handler.
     *   - #selection_settings: An array of settings that will be passed to the
     *     selection handler.
     * @param \Drupal\Core\Form\FormStateInterface $form_state
     *   The current state of the form.
     * @param array $complete_form
     *   The complete form structure.
     *
     * @return array
     *   The form element.
     *
     * @throws \InvalidArgumentException
     *   Exception thrown when the #target_type or #autocreate['bundle'] are
     *   missing.
     */
    public static function processEntityAutocomplete(array &$element, FormStateInterface $form_state, array &$complete_form) {
        // Nothing to do if there is no target entity type.
        if (empty($element['#target_type'])) {
            throw new \InvalidArgumentException('Missing required #target_type parameter.');
        }
        // Provide default values and sanity checks for the #autocreate parameter.
        if ($element['#autocreate']) {
            if (!isset($element['#autocreate']['bundle'])) {
                throw new \InvalidArgumentException("Missing required #autocreate['bundle'] parameter.");
            }
            // Default the autocreate user ID to the current user.
            $element['#autocreate']['uid'] = isset($element['#autocreate']['uid']) ? $element['#autocreate']['uid'] : \Drupal::currentUser()->id();
        }
        // Store the selection settings in the key/value store and pass a hashed key
        // in the route parameters.
        $selection_settings = isset($element['#selection_settings']) ? $element['#selection_settings'] : [];
        $data = serialize($selection_settings) . $element['#target_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);
        }
        $element['#autocomplete_route_name'] = 'system.entity_autocomplete';
        $element['#autocomplete_route_parameters'] = [
            'target_type' => $element['#target_type'],
            'selection_handler' => $element['#selection_handler'],
            'selection_settings_key' => $selection_settings_key,
        ];
        return $element;
    }
    
    /**
     * Form element validation handler for entity_autocomplete elements.
     */
    public static function validateEntityAutocomplete(array &$element, FormStateInterface $form_state, array &$complete_form) {
        $value = NULL;
        if (!empty($element['#value'])) {
            $options = $element['#selection_settings'] + [
                'target_type' => $element['#target_type'],
                'handler' => $element['#selection_handler'],
            ];
            
            /** @var /Drupal\Core\Entity\EntityReferenceSelection\SelectionInterface $handler */
            $handler = \Drupal::service('plugin.manager.entity_reference_selection')->getInstance($options);
            $autocreate = (bool) $element['#autocreate'] && $handler instanceof SelectionWithAutocreateInterface;
            // GET forms might pass the validated data around on the next request, in
            // which case it will already be in the expected format.
            if (is_array($element['#value'])) {
                $value = $element['#value'];
            }
            else {
                $input_values = $element['#tags'] ? Tags::explode($element['#value']) : [
                    $element['#value'],
                ];
                foreach ($input_values as $input) {
                    $match = static::extractEntityIdFromAutocompleteInput($input);
                    if ($match === NULL) {
                        // Try to get a match from the input string when the user didn't use
                        // the autocomplete but filled in a value manually.
                        $match = static::matchEntityByTitle($handler, $input, $element, $form_state, !$autocreate);
                    }
                    if ($match !== NULL) {
                        $value[] = [
                            'target_id' => $match,
                        ];
                    }
                    elseif ($autocreate) {
                        
                        /** @var \Drupal\Core\Entity\EntityReferenceSelection\SelectionWithAutocreateInterface $handler */
                        // Auto-create item. See an example of how this is handled in
                        // \Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem::presave().
                        $value[] = [
                            'entity' => $handler->createNewEntity($element['#target_type'], $element['#autocreate']['bundle'], $input, $element['#autocreate']['uid']),
                        ];
                    }
                }
            }
            // Check that the referenced entities are valid, if needed.
            if ($element['#validate_reference'] && !empty($value)) {
                // Validate existing entities.
                $ids = array_reduce($value, function ($return, $item) {
                    if (isset($item['target_id'])) {
                        $return[] = $item['target_id'];
                    }
                    return $return;
                });
                if ($ids) {
                    $valid_ids = $handler->validateReferenceableEntities($ids);
                    if ($invalid_ids = array_diff($ids, $valid_ids)) {
                        foreach ($invalid_ids as $invalid_id) {
                            $form_state->setError($element, t('The referenced entity (%type: %id) does not exist.', [
                                '%type' => $element['#target_type'],
                                '%id' => $invalid_id,
                            ]));
                        }
                    }
                }
                // Validate newly created entities.
                $new_entities = array_reduce($value, function ($return, $item) {
                    if (isset($item['entity'])) {
                        $return[] = $item['entity'];
                    }
                    return $return;
                });
                if ($new_entities) {
                    if ($autocreate) {
                        $valid_new_entities = $handler->validateReferenceableNewEntities($new_entities);
                        $invalid_new_entities = array_diff_key($new_entities, $valid_new_entities);
                    }
                    else {
                        // If the selection handler does not support referencing newly
                        // created entities, all of them should be invalidated.
                        $invalid_new_entities = $new_entities;
                    }
                    foreach ($invalid_new_entities as $entity) {
                        
                        /** @var \Drupal\Core\Entity\EntityInterface $entity */
                        $form_state->setError($element, t('This entity (%type: %label) cannot be referenced.', [
                            '%type' => $element['#target_type'],
                            '%label' => $entity->label(),
                        ]));
                    }
                }
            }
            // Use only the last value if the form element does not support multiple
            // matches (tags).
            if (!$element['#tags'] && !empty($value)) {
                $last_value = $value[count($value) - 1];
                $value = isset($last_value['target_id']) ? $last_value['target_id'] : $last_value;
            }
        }
        $form_state->setValueForElement($element, $value);
    }
    
    /**
     * Finds an entity from an autocomplete input without an explicit ID.
     *
     * The method will return an entity ID if one single entity unambiguously
     * matches the incoming input, and assign form errors otherwise.
     *
     * @param \Drupal\Core\Entity\EntityReferenceSelection\SelectionInterface $handler
     *   Entity reference selection plugin.
     * @param string $input
     *   Single string from autocomplete element.
     * @param array $element
     *   The form element to set a form error.
     * @param \Drupal\Core\Form\FormStateInterface $form_state
     *   The current form state.
     * @param bool $strict
     *   Whether to trigger a form error if an element from $input (eg. an entity)
     *   is not found.
     *
     * @return int|null
     *   Value of a matching entity ID, or NULL if none.
     */
    protected static function matchEntityByTitle(SelectionInterface $handler, $input, array &$element, FormStateInterface $form_state, $strict) {
        $entities_by_bundle = $handler->getReferenceableEntities($input, '=', 6);
        $entities = array_reduce($entities_by_bundle, function ($flattened, $bundle_entities) {
            return $flattened + $bundle_entities;
        }, []);
        $params = [
            '%value' => $input,
            '@value' => $input,
        ];
        if (empty($entities)) {
            if ($strict) {
                // Error if there are no entities available for a required field.
                $form_state->setError($element, t('There are no entities matching "%value".', $params));
            }
        }
        elseif (count($entities) > 5) {
            $params['@id'] = key($entities);
            // Error if there are more than 5 matching entities.
            $form_state->setError($element, t('Many entities are called %value. Specify the one you want by appending the id in parentheses, like "@value (@id)".', $params));
        }
        elseif (count($entities) > 1) {
            // More helpful error if there are only a few matching entities.
            $multiples = [];
            foreach ($entities as $id => $name) {
                $multiples[] = $name . ' (' . $id . ')';
            }
            $params['@id'] = $id;
            $form_state->setError($element, t('Multiple entities match this reference; "%multiple". Specify the one you want by appending the id in parentheses, like "@value (@id)".', [
                '%multiple' => strip_tags(implode('", "', $multiples)),
            ] + $params));
        }
        else {
            // Take the one and only matching entity.
            return key($entities);
        }
    }
    
    /**
     * Converts an array of entity objects into a string of entity labels.
     *
     * This method is also responsible for checking the 'view label' access on the
     * passed-in entities.
     *
     * @param \Drupal\Core\Entity\EntityInterface[] $entities
     *   An array of entity objects.
     *
     * @return string
     *   A string of entity labels separated by commas.
     */
    public static function getEntityLabels(array $entities) {
        
        /** @var \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository */
        $entity_repository = \Drupal::service('entity.repository');
        $entity_labels = [];
        foreach ($entities as $entity) {
            // Set the entity in the correct language for display.
            $entity = $entity_repository->getTranslationFromContext($entity);
            // Use the special view label, since some entities allow the label to be
            // viewed, even if the entity is not allowed to be viewed.
            $label = $entity->access('view label') ? $entity->label() : t('- Restricted access -');
            // Take into account "autocreated" entities.
            if (!$entity->isNew()) {
                $label .= ' (' . $entity->id() . ')';
            }
            // Labels containing commas or quotes must be wrapped in quotes.
            $entity_labels[] = Tags::encode($label);
        }
        return implode(', ', $entity_labels);
    }
    
    /**
     * Extracts the entity ID from the autocompletion result.
     *
     * @param string $input
     *   The input coming from the autocompletion result.
     *
     * @return mixed|null
     *   An entity ID or NULL if the input does not contain one.
     */
    public static function extractEntityIdFromAutocompleteInput($input) {
        $match = NULL;
        // Take "label (entity id)', match the ID from inside the parentheses.
        // @todo Add support for entities containing parentheses in their ID.
        // @see https://www.drupal.org/node/2520416
        if (preg_match("/.+\\s\\(([^\\)]+)\\)/", $input, $matches)) {
            $match = $matches[1];
        }
        return $match;
    }

}

Classes

Title Deprecated Summary
EntityAutocomplete Provides an entity autocomplete form element.

Buggy or inaccurate documentation? Please file an issue. Need support? Need help programming? Connect with the Drupal community.