diff --git a/config/schema/entity_browser.schema.yml b/config/schema/entity_browser.schema.yml index 67a1d79..3f203ea 100644 --- a/config/schema/entity_browser.schema.yml +++ b/config/schema/entity_browser.schema.yml @@ -191,6 +191,23 @@ field.widget.settings.entity_browser_entity_reference: type: string label: 'Selection mode' +field.widget.settings.entity_browser_entity_reference_autocomplete: + type: field.widget.settings.entity_reference_autocomplete + label: 'Entity browser entity reference autocomplete widget' + mapping: + entity_browser: + type: string + label: 'Entity Browser' + selection_mode: + type: string + label: 'Selection mode' + preview_view_mode: + type: string + label: 'Preview view mode' + preview_remove_button: + type: boolean + label: 'Preview remove button' + entity_browser.field_widget_display.label: type: mapping label: 'Entity label display config' diff --git a/css/entity_browser.entity_browser_entity_reference_autocomplete.css b/css/entity_browser.entity_browser_entity_reference_autocomplete.css new file mode 100644 index 0000000..e7211d0 --- /dev/null +++ b/css/entity_browser.entity_browser_entity_reference_autocomplete.css @@ -0,0 +1,4 @@ +.entity-browser-entity-reference-autocomplete-entity-browser-wrapper > div, +.entity-browser-entity-reference-autocomplete-entity-browser-wrapper > input { + display: inline-block; +} diff --git a/entity_browser.libraries.yml b/entity_browser.libraries.yml index bf6d05d..6d80265 100644 --- a/entity_browser.libraries.yml +++ b/entity_browser.libraries.yml @@ -50,6 +50,14 @@ entity_reference: - core/drupal - core/jquery +entity_browser_entity_reference_autocomplete: + version: VERSION + css: + theme: + css/entity_browser.entity_browser_entity_reference_autocomplete.css: {} + js: + js/entity_browser.entity_browser_entity_reference_autocomplete.js: { } + file_browser: css: component: diff --git a/js/entity_browser.entity_browser_entity_reference_autocomplete.js b/js/entity_browser.entity_browser_entity_reference_autocomplete.js new file mode 100644 index 0000000..f7c98a5 --- /dev/null +++ b/js/entity_browser.entity_browser_entity_reference_autocomplete.js @@ -0,0 +1,73 @@ +/** + * @file entity_browser.entity_browser_entity_reference_autocomplete.js + * + * Defines the behavior related to entity browser link inside autocomplete. + */ + +(function ($, Drupal, drupalSettings, once) { + + 'use strict'; + + /** + * Registers behaviours related to entity browser link inside autocomplete. + */ + Drupal.behaviors.linkEntityBrowserInsideAutocomplete = { + attach: function (context) { + + $(once('register-entity-browser-link', 'input.form-autocomplete.entity-browser-link', context)) + .autocomplete(Drupal.autocomplete.options) + .each(function () { + var $this = $(this); + + // Add link to advanced search at the end of autocomplete suggestions. + // It opens entity browser. + $this.on('autocompleteresponse', function (event, ui) { + var has_entity_browser_link = $.grep(ui.content, function (e) { + return e.value === 'open-entity-browser'; + }).length; + + if (!has_entity_browser_link) { + ui.content.push({ + value: 'open-entity-browser', + label: '' + drupalSettings.entity_browser_entity_reference_autocomplete.entity_browser_link_label + '', + }); + } + }); + + // Make sure that we skip all actions that might be triggered by + // "autocompleteclose" event. The only thing that should happen is to + // open entity browser. + $this.on('autocompleteclose', function (event, ui) { + if (event.target.value === 'open-entity-browser' && event.target.hasAttribute('data-entity-browser-uuid')) { + var entity_browser = document.querySelector('[data-uuid="' + $(event.target).attr('data-entity-browser-uuid') + '"]'); + if (entity_browser) { + event.target.value = ''; + entity_browser.click(); + event.target.preventDefault(); + } + } + }); + }); + } + }; + + /** + * Overridden from core/misc/autocomplete.js to select new item. + */ + function selectHandler(event, ui) { + // Open entity browser. + if (ui.item.value === 'open-entity-browser') { + event.target.value = ui.item.value + } + else { + Drupal.autocomplete.options.__select(event, ui); + } + + return false; + } + + // Override the select handler from core. + Drupal.autocomplete.options.__select = Drupal.autocomplete.options.select; + Drupal.autocomplete.options.select = selectHandler; + +}(jQuery, Drupal, drupalSettings, once)); diff --git a/src/Plugin/Field/FieldWidget/EntityReferenceAutocompleteBrowserWidget.php b/src/Plugin/Field/FieldWidget/EntityReferenceAutocompleteBrowserWidget.php new file mode 100644 index 0000000..ea93252 --- /dev/null +++ b/src/Plugin/Field/FieldWidget/EntityReferenceAutocompleteBrowserWidget.php @@ -0,0 +1,836 @@ +entityTypeManager = $entity_type_manager; + $this->selectionStorage = $selection_storage; + $this->displayManager = $display_manager; + $this->entityRepository = $entity_repository; + $this->entityDisplayRepository = $entity_display_repository; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $plugin_id, + $plugin_definition, + $configuration['field_definition'], + $configuration['settings'], + $configuration['third_party_settings'], + $container->get('entity_type.manager'), + $container->get('entity_browser.selection_storage'), + $container->get('plugin.manager.entity_browser.display'), + $container->get('entity.repository'), + $container->get('entity_display.repository') + ); + } + + /** + * {@inheritdoc} + */ + public static function defaultSettings() { + return [ + 'entity_browser' => NULL, + 'selection_mode' => EntityBrowserElement::SELECTION_MODE_APPEND, + 'preview_view_mode' => '', + 'preview_remove_button' => FALSE, + ] + parent::defaultSettings(); + } + + /** + * {@inheritdoc} + */ + public function settingsForm(array $form, FormStateInterface $form_state) { + $element = parent::settingsForm($form, $form_state); + + $element['entity_browser'] = [ + '#title' => $this->t('Entity browser'), + '#type' => 'select', + '#default_value' => $this->getSetting('entity_browser'), + '#options' => [], + ]; + /** @var \Drupal\entity_browser\EntityBrowserInterface[] $entity_browsers */ + $entity_browsers = $this + ->entityTypeManager + ->getStorage('entity_browser') + ->loadMultiple(); + foreach ($entity_browsers as $entity_browser) { + $element['entity_browser']['#options'][$entity_browser->id()] = $entity_browser->label(); + } + + $element['selection_mode'] = [ + '#title' => $this->t('Selection mode'), + '#description' => $this->t('Determines how selection in entity browser will be handled. Will selection be appended/prepended or it will be replaced in case of editing.'), + '#type' => 'select', + '#options' => EntityBrowserElement::getSelectionModeOptions(), + '#default_value' => $this->getSetting('selection_mode'), + ]; + + $element['#element_validate'][] = [$this, 'validateSettingsForm']; + + $options = []; + $target_type = $this->fieldDefinition->getFieldStorageDefinition()->getSetting('target_type'); + $entity_type = $this->entityTypeManager->getStorage($target_type)->getEntityType(); + foreach ($this->entityDisplayRepository->getViewModeOptions($entity_type->id()) as $id => $view_mode_label) { + $options[$id] = $view_mode_label; + } + + $element['preview_view_mode'] = [ + '#type' => 'select', + '#title' => $this->t('Preview view mode'), + '#description' => $this->t('Select view mode to be used preview.'), + '#default_value' => $this->getSetting('preview_view_mode'), + '#options' => array_merge(['' => $this->t('Disabled')], $options), + ]; + + $element['preview_remove_button'] = [ + '#title' => $this->t('Display Remove button in preview'), + '#type' => 'checkbox', + '#default_value' => $this->getSetting('preview_remove_button'), + ]; + + return $element; + } + + /** + * Validate the settings form. + * + * @param array $element + * An associative array containing the structure of the element. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + * @param array $complete_form + * The complete form structure. + */ + public function validateSettingsForm(array $element, FormStateInterface $form_state, array $complete_form) { + $values = NestedArray::getValue($form_state->getValues(), $element['#parents']); + + /** @var \Drupal\entity_browser\Entity\EntityBrowser $entity_browser */ + $entity_browser = $this + ->entityTypeManager + ->getStorage('entity_browser') + ->load($values['entity_browser']); + + if ($values['selection_mode'] == EntityBrowserElement::SELECTION_MODE_EDIT && $entity_browser->getSelectionDisplay()->supportsPreselection() === FALSE) { + $tparams = [ + '%selection_mode' => EntityBrowserElement::getSelectionModeOptions()[EntityBrowserElement::SELECTION_MODE_EDIT], + '@browser_link' => $entity_browser->toLink($entity_browser->label(), 'edit-form')->toString(), + ]; + $form_state->setError($element['entity_browser'], $this->t('This widget requires the %selection_mode selection mode. Either choose another entity browser or update the @browser_link entity browser to use a selection display plugin that supports preselection.', $tparams)); + } + elseif ($entity_browser->getDisplay()->getPluginId() !== 'modal') { + $modal_definition = $this->displayManager->getDefinition('modal'); + $tparams = [ + '%display' => $modal_definition['label'], + '@browser_link' => $entity_browser->toLink($entity_browser->label(), 'edit-form')->toString(), + ]; + $form_state->setError($element['entity_browser'], $this->t('This widget requires the %display display. Either choose another entity browser or update the @browser_link entity browser to use the %display display.', $tparams)); + } + } + + /** + * {@inheritdoc} + */ + public function settingsSummary() { + $summary = parent::settingsSummary(); + + $entity_browser_id = $this->getSetting('entity_browser'); + if (empty($entity_browser_id)) { + return [$this->t('No entity browser selected.')]; + } + else { + $entity_browser = $this + ->entityTypeManager + ->getStorage('entity_browser') + ->load($entity_browser_id); + if ($entity_browser) { + $summary[] = $this->t('Entity browser: @browser', ['@browser' => $entity_browser->label()]); + } + else { + $this->messenger->addError($this->t('Missing entity browser!')); + return [$this->t('Missing entity browser!')]; + } + } + + return $summary; + } + + /** + * {@inheritdoc} + */ + public function form(FieldItemListInterface $items, array &$form, FormStateInterface $form_state, $get_delta = NULL) { + // If entities were selected in the entity browser, make sure they are + // applied to the entity reference autocomplete field. + // @todo Is this the correct place to do this? + $this->applyEntityBrowserSelection($form['#parents'], $this->fieldDefinition->getName(), $form_state); + + return parent::form($items, $form, $form_state, $get_delta); + } + + /** + * {@inheritdoc} + */ + public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) { + $element = parent::formElement($items, $delta, $element, $form, $form_state); + + $cardinality = $this + ->fieldDefinition + ->getFieldStorageDefinition() + ->getCardinality(); + if ($cardinality !== 1) { + return $element; + } + + $field_name = $this->fieldDefinition->getName(); + $id_prefix = implode('-', array_merge($form['#parents'], [$field_name])); + $element['#id'] = Html::getUniqueId($id_prefix . '-entity-browser-wrapper'); + $element['#type'] = 'container'; + $element['#attributes']['class'][] = 'entity-browser-entity-reference-autocomplete-entity-browser-wrapper'; + + $this->formElementPreview($items, $delta, $element, $form_state); + $this->addEntityBrowserElement($element); + + return $element; + } + + /** + * {@inheritdoc} + */ + protected function formMultipleElements(FieldItemListInterface $items, array &$form, FormStateInterface $form_state) { + $elements = parent::formMultipleElements($items, $form, $form_state); + + $cardinality = $this->fieldDefinition->getFieldStorageDefinition()->getCardinality(); + if ($cardinality == FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED && !$form_state->isProgrammed()) { + $field_name = $this->fieldDefinition->getName(); + $id_prefix = implode('-', array_merge($form['#parents'], [$field_name])); + $elements['#id'] = Html::getUniqueId($id_prefix . '-entity-browser-wrapper'); + if ($this->getSetting('preview_view_mode')) { + for ($delta = 0; $delta <= $elements['#max_delta']; $delta++) { + $element = &$elements[$delta]; + $element['#wrapper_id'] = $elements['#id']; + $element['#type'] = 'container'; + $element['#attributes']['class'][] = 'entity-browser-entity-reference-autocomplete-entity-browser-wrapper'; + $this->formElementPreview($items, $delta, $element, $form_state); + } + } + $this->addEntityBrowserElement($elements); + $elements['#prefix'] = '
' . $elements['#prefix']; + $elements['#suffix'] .= '
'; + $elements['#pre_render'][] = [$this, 'alignButtons']; + } + + for ($delta = 0; $delta <= $elements['#max_delta']; $delta++) { + $elements[$delta]['target_id']['#attributes']['class'][] = 'entity-browser-link'; + } + + return $elements; + } + + /** + * Adds preview to the form element. + * + * @param \Drupal\Core\Field\FieldItemListInterface $items + * Array of default values for this field. + * @param int $delta + * The order of item in the array of sub-elements. + * @param array $element + * The form element array containing basic properties for the widget. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + */ + protected function formElementPreview(FieldItemListInterface $items, $delta, array &$element, FormStateInterface $form_state) { + // We can stop here, if preview is not set on the widget configuration. + if (empty($this->getSetting('preview_view_mode'))) { + return $element; + } + + $element['target_id']['#field_name'] = $this->fieldDefinition->getName(); + $element['target_id']['#ajax'] = [ + 'callback' => [static::class, 'updateWidgetCallback'], + 'wrapper' => $element['#wrapper_id'] ?? $element['#id'], + 'event' => 'change autocompleteclose paste', + ]; + + $cardinality = $this + ->fieldDefinition + ->getFieldStorageDefinition() + ->getCardinality(); + $element['entity_preview'] = [ + '#type' => 'container', + '#attributes' => [ + 'class' => ['entity-preview'], + ], + '#weight' => $cardinality === 1 ? 0 : $element['target_id']['#weight'] + 1, + '#tree' => FALSE, + ]; + + // Get current value of the widget. First, check user input. If nothing + // found, check if default value exists. + $user_input = $form_state->getUserInput(); + $values = NestedArray::getValue($user_input, array_merge($element['target_id']['#field_parents'], [$element['target_id']['#field_name']])); + $target_id = NULL; + if (empty($values)) { + $target_id = $items[$delta]->getValue()['target_id'] ?? NULL; + } + elseif (!empty($values[$delta]['target_id'])) { + $target_id = EntityAutocomplete::extractEntityIdFromAutocompleteInput($values[$delta]['target_id']); + } + + if ($target_id) { + $target_entity = $this + ->entityTypeManager + ->getStorage($element['target_id']['#target_type']) + ->load($target_id); + + if ($target_entity) { + $element['entity_preview']['preview'] = [ + '#type' => 'fieldgroup', + 'value' => $this + ->entityTypeManager + ->getViewBuilder($element['target_id']['#target_type']) + ->view($target_entity, $this->getSetting('preview_view_mode')), + ]; + + // For multivalue fields we have remove ("x") button, no need to add + // second one just for preview. + if ($this->getSetting('preview_remove_button') && $cardinality === 1) { + $element['entity_preview']['preview']['preview_remove_button'] = [ + '#type' => 'submit', + '#value' => $this->t('Remove'), + '#ajax' => [ + 'callback' => [static::class, 'updateWidgetCallback'], + 'wrapper' => $element['#wrapper_id'] ?? $element['#id'], + ], + '#submit' => [[static::class, 'removeItemSubmit']], + '#name' => "{$element['target_id']['#field_name']}_{$delta}_preview_remove_button", + '#limit_validation_errors' => [array_merge($element['target_id']['#field_parents'], [$this->fieldDefinition->getName()])], + '#attributes' => ['class' => ['remove-button']], + '#access' => TRUE, + ]; + } + } + } + } + + /** + * Adds the entity browser element to the widget form. + * + * @param array $elements + * An associative array containing the structure of the element. + */ + protected function addEntityBrowserElement(array &$elements) { + $cardinality = $this->fieldDefinition->getFieldStorageDefinition()->getCardinality(); + $persistent_data = $this->getPersistentData(); + $selection_mode = $this->getSetting('selection_mode'); + + $elements['entity_browser'] = [ + '#type' => 'entity_browser', + '#entity_browser' => $this->getSetting('entity_browser'), + '#selection_mode' => $selection_mode, + '#cardinality' => $cardinality, + '#process' => [ + [EntityBrowserElement::class, 'processEntityBrowser'], + [$this, 'processEntityBrowser'], + ], + '#wrapper_id' => $elements['#wrapper_id'] ?? $elements['#id'], + '#entity_browser_validators' => $persistent_data['validators'], + '#widget_context' => $persistent_data['widget_context'], + '#weight' => 0, + ]; + $elements['#attached']['library'][] = 'entity_browser/entity_browser_entity_reference_autocomplete'; + $elements['#attached']['drupalSettings']['entity_browser_entity_reference_autocomplete'] = [ + 'entity_browser_link_label' => $this->t("Didn't find what you were looking for? Advanced search"), + ]; + } + + /** + * Gets data that should persist across Entity Browser renders. + * + * @return array + * Data that should persist after the Entity Browser is rendered. + */ + protected function getPersistentData() { + $settings = $this->fieldDefinition->getSettings(); + $handler = $settings['handler_settings']; + return [ + 'validators' => [ + 'entity_type' => ['type' => $settings['target_type']], + ], + 'widget_context' => [ + 'target_bundles' => !empty($handler['target_bundles']) ? $handler['target_bundles'] : [], + 'target_entity_type' => $settings['target_type'], + 'cardinality' => $this->fieldDefinition->getFieldStorageDefinition()->getCardinality(), + ], + ]; + } + + /** + * Process callback for the entity browser form element. + * + * @param array $element + * The form element to process. + * @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. + */ + public function processEntityBrowser(array &$element, FormStateInterface $form_state, array &$complete_form) { + // Trigger the AJAX callback whenever the entity browser is submitted. + // #ajax is officially not supported for hidden elements but if we specify + // the event manually it works. + $element['entity_ids']['#ajax'] = [ + 'callback' => [static::class, 'updateFieldWidgetAjaxCallback'], + 'wrapper' => $element['#wrapper_id'], + 'event' => 'entity_browser_value_updated', + ]; + + // Whenever the entity browser button is opened, we update the default + // selected entities with the ones referenced through the autocomplete + // textfields. + $element['entity_browser']['open_modal']['#element_validate'][] = [ + $this, + 'updateEntityBrowserDefaultSelectedEntities', + ]; + + // Store the entity browser instance UUID as a hidden value. This way we + // can identify which instance is being opened (since the instance always + // changes on form rebuild). + $entity_browser_uuid = $element['entity_browser']['open_modal']['#attributes']['data-uuid']; + $element['entity_browser']['uuid'] = [ + '#type' => 'hidden', + // @todo How can we retrieve the instance UUID in a more sane way? + '#value' => $entity_browser_uuid, + ]; + + $parents = array_slice($element['#array_parents'], 0, -1); + if (end($parents) === 0) { + $parents = array_slice($parents, 0, -1); + } + $complete_form_element = &NestedArray::getValue($complete_form, $parents); + for ($delta = 0; $delta <= $complete_form_element['#max_delta']; $delta++) { + $complete_form_element[$delta]['target_id']['#attributes']['data-entity-browser-uuid'] = $entity_browser_uuid; + } + + return $element; + } + + /** + * Ajax callback for the entity browser button. + * + * This returns the updated field widget content, with the entities selected + * in the entity browser taken into account. + * + * @param array $form + * The form where the settings form is being included in. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + * + * @return array + * The part of the form that needs to be updated (eg. the field widget + * element). + */ + public static function updateFieldWidgetAjaxCallback(array &$form, FormStateInterface $form_state) { + $triggering_element = $form_state->getTriggeringElement(); + $parents = array_slice($triggering_element['#array_parents'], 0, -2); + return NestedArray::getValue($form, $parents); + } + + /** + * Update the default selected entities of an entity browsers instance. + * + * @param array $form + * An associative array containing the structure of the form. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + */ + public function updateEntityBrowserDefaultSelectedEntities(array $form, FormStateInterface $form_state) { + // Only update the default selected entities for the field the entity + // browser is being opened for. + $triggering_element = $form_state->getTriggeringElement(); + if ($form['#name'] !== $triggering_element['#name']) { + return; + } + + // Get the entity browser instance UUID. + $user_input = $form_state->getUserInput(); + $uuid = NestedArray::getValue($user_input, array_merge(array_slice($form['#parents'], 0, -1), ['uuid'])); + if (!$uuid) { + return; + } + + // Save the selected entities (within the entity reference autocomplete + // fields) in the entity browsers persistent data. + $persistent_data = $this->selectionStorage->get($uuid); + $field_cardinality = $this + ->fieldDefinition + ->getFieldStorageDefinition() + ->getCardinality(); + // If the field cardinality is 1, the entity browser was added to the first + // child of the widget. In any other case, the entity browser was added to + // the widget itself. + // @see \Drupal\entity_browser\Plugin\Field\FieldWidget\EntityReferenceAutocompleteBrowserWidget::formElement() + // @see \Drupal\entity_browser\Plugin\Field\FieldWidget\EntityReferenceAutocompleteBrowserWidget::formMultipleElements() + $length = $field_cardinality === 1 ? -4 : -3; + $parents = array_slice($form['#parents'], 0, $length); + $field_name = array_pop($parents); + $persistent_data['selected_entities'] = $this->getSelectedEntities($parents, $field_name, $form_state); + $this->selectionStorage->set($uuid, $persistent_data); + } + + /** + * Pre-render callback for aligning the 'add more' and entity browser buttons. + * + * The default template explicitly repositions the 'add more' button. In order + * to align both, we transform the 'add more' button into a container element + * that contains the 'add more' and entity browser button. + * + * @param array $element + * An associative array containing the structure of the element. + * + * @return array + * The altered elements array. + * + * @see template_preprocess_field_multiple_value_form() + * @see field-multiple-value-form.html.twig + */ + public function alignButtons(array $element) { + $element['add_more'] = [ + '#type' => 'container', + '#attributes' => [ + 'class' => ['entity-browser-entity-reference-autocomplete-entity-browser-wrapper'], + ], + 'entity_browser' => $element['entity_browser'], + 'add_more' => $element['add_more'], + ]; + + unset($element['entity_browser']); + return $element; + } + + /** + * Apply the entity browser selection to the entity reference field. + * + * @param array $parents + * The array of #parents where the field lives in the form. + * @param string $field_name + * The field name. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + */ + protected function applyEntityBrowserSelection(array $parents, $field_name, FormStateInterface $form_state) { + // If no entity browser was used, we can stop right here. + $triggering_element = $form_state->getTriggeringElement(); + if (!$triggering_element || empty($triggering_element['#parents']) || !in_array($field_name, $triggering_element['#parents'], TRUE) || end($triggering_element['#parents']) !== 'entity_ids') { + return; + } + + $user_input = $form_state->getUserInput(); + $values = NestedArray::getValue($user_input, array_merge($parents, [$field_name])); + // First we remove all the existing delta's from the input. + $values = array_filter($values, function ($value) { + return !is_array($value) || !array_key_exists('target_id', $value); + }); + // Now add the selected entities to the input. + $selected_entities = $this->getSelectedEntities($parents, $field_name, $form_state); + foreach ($selected_entities as $delta => $entity) { + $values[$delta] = [ + 'target_id' => $this->getAutocompleteMatcherKey($entity), + '_weight' => $delta, + ]; + } + NestedArray::setValue($user_input, array_merge($parents, [$field_name]), $values); + $form_state->setUserInput($user_input); + + // The amount of selected entities may have changed. Therefore we need to + // update the 'items_count' property in the widgets state. Based on this + // property the amount of autocomplete textfields to be displayed is + // decided. + // @see \Drupal\Core\Field\WidgetBase::formMultipleElements() + $field_state = static::getWidgetState($parents, $field_name, $form_state); + if (!$field_state) { + $field_state = [ + 'items_count' => 0, + 'array_parents' => [], + ]; + } + $field_state['items_count'] = count($selected_entities); + static::setWidgetState($parents, $field_name, $form_state, $field_state); + } + + /** + * Gets the entity autocomplete matcher key for an entity. + * + * The key is what's submitted through the form when an entity is referenced + * through the entity reference autocomplete field. It contains the + * (translated) entity label and its IDs in the following structure: + * "$label ($entity_id)". This structure is required for validation to work + * in the entity autocomplete element. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity object. + * + * @return string + * A key that can be interpreted by the entity autocomplete matcher. + * + * @see \Drupal\Core\Entity\EntityAutocompleteMatcher::getMatches() + * @see \Drupal\Core\Entity\Element\EntityAutocomplete::validateEntityAutocomplete() + */ + protected function getAutocompleteMatcherKey(EntityInterface $entity) { + $label = Html::escape($this->entityRepository->getTranslationFromContext($entity)->label()); + $key = "{$label} ({$entity->id()})"; + // Strip things like starting/trailing white spaces, line breaks and + // tags. + $key = preg_replace('/\s\s+/', ' ', str_replace("\n", '', trim(Html::decodeEntities(strip_tags($key))))); + // Names containing commas or quotes must be wrapped in quotes. + $key = Tags::encode($key); + + return $key; + } + + /** + * Retrieves the selected entities. + * + * @param array $parents + * The array of #parents where the field lives in the form. + * @param string $field_name + * The field name. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * @param \Drupal\Core\Field\FieldItemListInterface|null $items + * An array of the field values. When creating a new entity this may be NULL + * or an empty array to use default values. + * + * @return \Drupal\Core\Entity\EntityInterface[] + * An array of entity objects, if any. + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + */ + public function getSelectedEntities(array $parents, $field_name, FormStateInterface $form_state, FieldItemListInterface $items = NULL) { + $triggering_element = $form_state->getTriggeringElement(); + if ($triggering_element && !empty($triggering_element['#parents']) && in_array($field_name, $triggering_element['#parents'], TRUE)) { + $parent = end($triggering_element['#parents']); + if ($parent === 'entity_ids') { + return $form_state->getValue(array_merge(array_slice($triggering_element['#parents'], 0, -1), ['entities'])); + } + } + + $user_input = $form_state->getUserInput(); + $values = NestedArray::getValue($user_input, array_merge($parents, [$field_name])); + if (!empty($values)) { + $values = array_filter($values, function ($value) { + return is_array($value) && array_key_exists('target_id', $value); + }); + + usort($values, function ($a, $b) { + return SortArray::sortByKeyInt($a, $b, '_weight'); + }); + + $entity_ids = array_map(function ($item) { + return is_array($item) && !empty($item['target_id']) + ? EntityAutocomplete::extractEntityIdFromAutocompleteInput($item['target_id']) + : NULL; + }, $values); + + $filtered_entity_ids = array_unique(array_filter($entity_ids)); + + if (!empty($filtered_entity_ids)) { + $entity_type_id = $this + ->fieldDefinition + ->getFieldStorageDefinition() + ->getSetting('target_type'); + + $entities = $this + ->entityTypeManager + ->getStorage($entity_type_id) + ->loadMultiple($filtered_entity_ids); + + // Selection can contain same entity multiple times. Since + // \Drupal\Core\Entity\EntityStorageInterface::loadMultiple() returns a + // unique list of entities, it's necessary to recreate list of entities + // in order to preserve selection of duplicated entities. + return array_filter(array_map(function ($entity_id) use ($entities) { + return $entities[$entity_id] ?? NULL; + }, array_values($entity_ids))); + } + } + + // We are loading for for the first time so we need to load any existing + // values that might already exist on the entity. Also, remove any leftover + // data from removed entity references. + if ($items) { + return array_filter($items->referencedEntities()); + } + + return []; + } + + /** + * {@inheritdoc} + */ + public function massageFormValues(array $values, array $form, FormStateInterface $form_state) { + $values = array_filter($values, function ($value) { + return empty($value['_original_delta']) || $value['_original_delta'] !== 'entity_browser'; + }); + + return parent::massageFormValues($values, $form, $form_state); + } + + /** + * {@inheritdoc} + */ + public static function trustedCallbacks() { + return [ + 'alignButtons', + ]; + } + + /** + * AJAX callback for the selected element preview. + * + * @param array $form + * An associative array containing the structure of the form. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + * + * @return array + * An associative array containing the structure of the element to replace + * the wrapper with. + */ + public static function updateWidgetCallback(array &$form, FormStateInterface $form_state) { + $triggering_element = $form_state->getTriggeringElement(); + $depth = end($triggering_element['#array_parents']) === 'preview_remove_button' + ? -3 + : -2; + + $element = NestedArray::getValue($form, array_merge( + array_slice($triggering_element['#array_parents'], 0, $depth), + )); + unset($element['_weight']); + unset($element['_actions']); + + return $element; + } + + /** + * Submit callback for remove buttons. + * + * @param array $form + * An associative array containing the structure of the form. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + */ + public static function removeItemSubmit(&$form, FormStateInterface $form_state) { + $triggering_element = $form_state->getTriggeringElement(); + if (end($triggering_element['#array_parents']) !== 'preview_remove_button') { + return; + } + + $target_id_element = &NestedArray::getValue($form, array_merge(array_slice($triggering_element['#array_parents'], 0, -3), ['target_id'])); + $form_state->setValueForElement($target_id_element, ''); + NestedArray::setValue($form_state->getUserInput(), $target_id_element['#parents'], ''); + $form_state->setRebuild(); + } + +}