3 namespace Drupal\entity_browser\Plugin\Field\FieldWidget;
5 use Drupal\Core\Entity\EntityInterface;
6 use Drupal\entity_browser\Element\EntityBrowserElement;
7 use Symfony\Component\Validator\ConstraintViolationInterface;
8 use Drupal\Component\Utility\Html;
9 use Drupal\Component\Utility\NestedArray;
10 use Drupal\Core\Entity\ContentEntityInterface;
11 use Drupal\Core\Entity\EntityTypeManagerInterface;
12 use Drupal\Core\Field\FieldDefinitionInterface;
13 use Drupal\Core\Field\FieldItemListInterface;
14 use Drupal\Core\Field\WidgetBase;
15 use Drupal\Core\Form\FormStateInterface;
16 use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
18 use Drupal\Core\Validation\Plugin\Validation\Constraint\NotNullConstraint;
19 use Drupal\entity_browser\FieldWidgetDisplayManager;
20 use Symfony\Component\DependencyInjection\ContainerInterface;
21 use Symfony\Component\EventDispatcher\EventDispatcherInterface;
22 use Symfony\Component\Validator\ConstraintViolation;
23 use Symfony\Component\Validator\ConstraintViolationListInterface;
24 use Drupal\Core\Extension\ModuleHandlerInterface;
27 * Plugin implementation of the 'entity_reference' widget for entity browser.
30 * id = "entity_browser_entity_reference",
31 * label = @Translation("Entity browser"),
32 * description = @Translation("Uses entity browser to select entities."),
33 * multiple_values = TRUE,
39 class EntityReferenceBrowserWidget extends WidgetBase implements ContainerFactoryPluginInterface {
42 * Entity type manager service.
44 * @var \Drupal\Core\Entity\EntityTypeManagerInterface
46 protected $entityTypeManager;
49 * Field widget display plugin manager.
51 * @var \Drupal\entity_browser\FieldWidgetDisplayManager
53 protected $fieldDisplayManager;
56 * The depth of the delete button.
58 * This property exists so it can be changed if subclasses.
62 protected static $deleteDepth = 4;
65 * The module handler interface.
67 * @var \Drupal\Core\Extension\ModuleHandlerInterface
69 protected $moduleHandler;
72 * Constructs widget plugin.
74 * @param string $plugin_id
75 * The plugin_id for the plugin instance.
76 * @param mixed $plugin_definition
77 * The plugin implementation definition.
78 * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
79 * The definition of the field to which the widget is associated.
80 * @param array $settings
81 * The widget settings.
82 * @param array $third_party_settings
83 * Any third party settings.
84 * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
85 * Entity type manager service.
86 * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $event_dispatcher
88 * @param \Drupal\entity_browser\FieldWidgetDisplayManager $field_display_manager
89 * Field widget display plugin manager.
90 * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
91 * The module handler service.
93 public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, array $third_party_settings, EntityTypeManagerInterface $entity_type_manager, EventDispatcherInterface $event_dispatcher, FieldWidgetDisplayManager $field_display_manager, ModuleHandlerInterface $module_handler) {
94 parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $third_party_settings);
95 $this->entityTypeManager = $entity_type_manager;
96 $this->fieldDisplayManager = $field_display_manager;
97 $this->moduleHandler = $module_handler;
103 public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
107 $configuration['field_definition'],
108 $configuration['settings'],
109 $configuration['third_party_settings'],
110 $container->get('entity_type.manager'),
111 $container->get('event_dispatcher'),
112 $container->get('plugin.manager.entity_browser.field_widget_display'),
113 $container->get('module_handler')
120 public static function defaultSettings() {
122 'entity_browser' => NULL,
124 'field_widget_display' => 'label',
125 'field_widget_edit' => TRUE,
126 'field_widget_remove' => TRUE,
127 'field_widget_display_settings' => [],
128 'selection_mode' => EntityBrowserElement::SELECTION_MODE_APPEND,
129 ) + parent::defaultSettings();
135 public function settingsForm(array $form, FormStateInterface $form_state) {
136 $element = parent::settingsForm($form, $form_state);
139 /** @var \Drupal\entity_browser\EntityBrowserInterface $browser */
140 foreach ($this->entityTypeManager->getStorage('entity_browser')->loadMultiple() as $browser) {
141 $browsers[$browser->id()] = $browser->label();
144 $element['entity_browser'] = [
145 '#title' => $this->t('Entity browser'),
147 '#default_value' => $this->getSetting('entity_browser'),
148 '#options' => $browsers,
151 $target_type = $this->fieldDefinition->getFieldStorageDefinition()->getSetting('target_type');
152 $entity_type = $this->entityTypeManager->getStorage($target_type)->getEntityType();
155 foreach ($this->fieldDisplayManager->getDefinitions() as $id => $definition) {
156 if ($this->fieldDisplayManager->createInstance($id)->isApplicable($entity_type)) {
157 $displays[$id] = $definition['label'];
161 $id = Html::getUniqueId('field-' . $this->fieldDefinition->getName() . '-display-settings-wrapper');
162 $element['field_widget_display'] = [
163 '#title' => $this->t('Entity display plugin'),
165 '#default_value' => $this->getSetting('field_widget_display'),
166 '#options' => $displays,
168 'callback' => array($this, 'updateSettingsAjax'),
173 $edit_button_access = TRUE;
174 if ($entity_type->id() == 'file') {
175 // For entities of type "file", it only makes sense to have the edit
176 // button if the module "file_entity" is present.
177 $edit_button_access = $this->moduleHandler->moduleExists('file_entity');
179 $element['field_widget_edit'] = [
180 '#title' => $this->t('Display Edit button'),
181 '#type' => 'checkbox',
182 '#default_value' => $this->getSetting('field_widget_edit'),
183 '#access' => $edit_button_access,
186 $element['field_widget_remove'] = [
187 '#title' => $this->t('Display Remove button'),
188 '#type' => 'checkbox',
189 '#default_value' => $this->getSetting('field_widget_remove'),
193 '#title' => $this->t('Show widget details as open by default'),
194 '#description' => $this->t('If marked, the fieldset container that wraps the browser on the entity form will be loaded initially expanded.'),
195 '#type' => 'checkbox',
196 '#default_value' => $this->getSetting('open'),
199 $element['selection_mode'] = [
200 '#title' => $this->t('Selection mode'),
201 '#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.'),
203 '#options' => EntityBrowserElement::getSelectionModeOptions(),
204 '#default_value' => $this->getSetting('selection_mode'),
207 $element['field_widget_display_settings'] = [
208 '#type' => 'fieldset',
209 '#title' => $this->t('Entity display plugin configuration'),
211 '#prefix' => '<div id="' . $id . '">',
212 '#suffix' => '</div>',
215 if ($this->getSetting('field_widget_display')) {
216 $element['field_widget_display_settings'] += $this->fieldDisplayManager
218 $form_state->getValue(
219 ['fields', $this->fieldDefinition->getName(), 'settings_edit_form', 'settings', 'field_widget_display'],
220 $this->getSetting('field_widget_display')
222 $form_state->getValue(
223 ['fields', $this->fieldDefinition->getName(), 'settings_edit_form', 'settings', 'field_widget_display_settings'],
224 $this->getSetting('field_widget_display_settings')
225 ) + ['entity_type' => $this->fieldDefinition->getFieldStorageDefinition()->getSetting('target_type')]
227 ->settingsForm($form, $form_state);
234 * Ajax callback that updates field widget display settings fieldset.
236 public function updateSettingsAjax(array $form, FormStateInterface $form_state) {
237 return $form['fields'][$this->fieldDefinition->getName()]['plugin']['settings_edit_form']['settings']['field_widget_display_settings'];
243 public function settingsSummary() {
244 $summary = $this->summaryBase();
245 $field_widget_display = $this->getSetting('field_widget_display');
247 if (!empty($field_widget_display)) {
248 $plugin = $this->fieldDisplayManager->getDefinition($field_widget_display);
249 $summary[] = $this->t('Entity display: @name', ['@name' => $plugin['label']]);
257 public function flagErrors(FieldItemListInterface $items, ConstraintViolationListInterface $violations, array $form, FormStateInterface $form_state) {
258 if ($violations->count() > 0) {
259 /** @var \Symfony\Component\Validator\ConstraintViolation $violation */
260 foreach ($violations as $offset => $violation) {
261 // The value of the required field is checked through the "not null"
262 // constraint, whose message is not very useful. We override it here for
264 if ($violation->getConstraint() instanceof NotNullConstraint) {
265 $violations->set($offset, new ConstraintViolation(
266 $this->t('@name field is required.', ['@name' => $items->getFieldDefinition()->getLabel()]),
269 $violation->getRoot(),
270 $violation->getPropertyPath(),
271 $violation->getInvalidValue(),
272 $violation->getPlural(),
273 $violation->getCode(),
274 $violation->getConstraint(),
275 $violation->getCause()
281 parent::flagErrors($items, $violations, $form, $form_state);
285 * Returns a key used to store the previously loaded entity.
287 * @param \Drupal\Core\Field\FieldItemListInterface $items
291 * A key for form state storage.
293 protected function getFormStateKey(FieldItemListInterface $items) {
294 return $items->getEntity()->uuid() . ':' . $items->getFieldDefinition()->getName();
300 public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) {
301 $entity_type = $this->fieldDefinition->getFieldStorageDefinition()->getSetting('target_type');
302 $entities = $this->formElementEntities($items, $element, $form_state);
304 // Get correct ordered list of entity IDs.
306 function (EntityInterface $entity) {
307 return $entity->id();
312 // We store current entity IDs as we might need them in future requests. If
313 // some other part of the form triggers an AJAX request with
314 // #limit_validation_errors we won't have access to the value of the
315 // target_id element and won't be able to build the form as a result of
316 // that. This will cause missing submit (Remove, Edit, ...) elements, which
317 // might result in unpredictable results.
318 $form_state->set(['entity_browser_widget', $this->getFormStateKey($items)], $ids);
320 $hidden_id = Html::getUniqueId('edit-' . $this->fieldDefinition->getName() . '-target-id');
321 $details_id = Html::getUniqueId('edit-' . $this->fieldDefinition->getName());
324 '#id' => $details_id,
325 '#type' => 'details',
326 '#open' => !empty($entities) || $this->getSetting('open'),
327 '#required' => $this->fieldDefinition->isRequired(),
328 // We are not using Entity browser's hidden element since we maintain
329 // selected entities in it during entire process.
333 // We need to repeat ID here as it is otherwise skipped when rendering.
334 '#attributes' => ['id' => $hidden_id],
335 '#default_value' => implode(' ', array_map(
336 function (EntityInterface $item) {
337 return $item->getEntityTypeId() . ':' . $item->id();
341 // #ajax is officially not supported for hidden elements but if we
342 // specify event manually it works.
344 'callback' => [get_class($this), 'updateWidgetCallback'],
345 'wrapper' => $details_id,
346 'event' => 'entity_browser_value_updated',
351 // Get configuration required to check entity browser availability.
352 $cardinality = $this->fieldDefinition->getFieldStorageDefinition()->getCardinality();
353 $selection_mode = $this->getSetting('selection_mode');
355 // Enable entity browser if requirements for that are fulfilled.
356 if (EntityBrowserElement::isEntityBrowserAvailable($selection_mode, $cardinality, count($ids))) {
357 $element['entity_browser'] = [
358 '#type' => 'entity_browser',
359 '#entity_browser' => $this->getSetting('entity_browser'),
360 '#cardinality' => $cardinality,
361 '#selection_mode' => $selection_mode,
362 '#default_value' => $entities,
363 '#entity_browser_validators' => ['entity_type' => ['type' => $entity_type]],
364 '#custom_hidden_id' => $hidden_id,
366 ['\Drupal\entity_browser\Element\EntityBrowserElement', 'processEntityBrowser'],
367 [get_called_class(), 'processEntityBrowser'],
372 $element['#attached']['library'][] = 'entity_browser/entity_reference';
374 $field_parents = $element['#field_parents'];
376 $element['current'] = $this->displayCurrentSelection($details_id, $field_parents, $entities);
382 * Render API callback: Processes the entity browser element.
384 public static function processEntityBrowser(&$element, FormStateInterface $form_state, &$complete_form) {
385 $uuid = key($element['#attached']['drupalSettings']['entity_browser']);
386 $element['#attached']['drupalSettings']['entity_browser'][$uuid]['selector'] = '#' . $element['#custom_hidden_id'];
393 public function massageFormValues(array $values, array $form, FormStateInterface $form_state) {
394 $entities = empty($values['target_id']) ? [] : explode(' ', trim($values['target_id']));
396 foreach ($entities as $entity) {
397 $return[]['target_id'] = explode(':', $entity)[1];
404 * AJAX form callback.
406 public static function updateWidgetCallback(array &$form, FormStateInterface $form_state) {
407 $trigger = $form_state->getTriggeringElement();
408 // AJAX requests can be triggered by hidden "target_id" element when
409 // entities are added or by one of the "Remove" buttons. Depending on that
410 // we need to figure out where root of the widget is in the form structure
411 // and use this information to return correct part of the form.
412 if (!empty($trigger['#ajax']['event']) && $trigger['#ajax']['event'] == 'entity_browser_value_updated') {
413 $parents = array_slice($trigger['#array_parents'], 0, -1);
415 elseif ($trigger['#type'] == 'submit' && strpos($trigger['#name'], '_remove_')) {
416 $parents = array_slice($trigger['#array_parents'], 0, -static::$deleteDepth);
419 return NestedArray::getValue($form, $parents);
425 public function errorElement(array $element, ConstraintViolationInterface $violation, array $form, FormStateInterface $form_state) {
426 if (($trigger = $form_state->getTriggeringElement())) {
427 // Can be triggered by "Remove" button.
428 if (end($trigger['#parents']) === 'remove_button') {
432 return parent::errorElement($element, $violation, $form, $form_state);
436 * Submit callback for remove buttons.
438 public static function removeItemSubmit(&$form, FormStateInterface $form_state) {
439 $triggering_element = $form_state->getTriggeringElement();
440 if (!empty($triggering_element['#attributes']['data-entity-id']) && isset($triggering_element['#attributes']['data-row-id'])) {
441 $id = $triggering_element['#attributes']['data-entity-id'];
442 $row_id = $triggering_element['#attributes']['data-row-id'];
443 $parents = array_slice($triggering_element['#parents'], 0, -static::$deleteDepth);
444 $array_parents = array_slice($triggering_element['#array_parents'], 0, -static::$deleteDepth);
446 // Find and remove correct entity.
447 $values = explode(' ', $form_state->getValue(array_merge($parents, ['target_id'])));
448 foreach ($values as $index => $item) {
449 if ($item == $id && $index == $row_id) {
450 array_splice($values, $index, 1);
455 $target_id_value = implode(' ', $values);
457 // Set new value for this widget.
458 $target_id_element = &NestedArray::getValue($form, array_merge($array_parents, ['target_id']));
459 $form_state->setValueForElement($target_id_element, $target_id_value);
460 NestedArray::setValue($form_state->getUserInput(), $target_id_element['#parents'], $target_id_value);
463 $form_state->setRebuild();
468 * Builds the render array for displaying the current results.
470 * @param string $details_id
471 * The ID for the details element.
472 * @param string[] $field_parents
474 * @param \Drupal\Core\Entity\ContentEntityInterface[] $entities
475 * Array of referenced entities.
478 * The render array for the current selection.
480 protected function displayCurrentSelection($details_id, $field_parents, $entities) {
482 $field_widget_display = $this->fieldDisplayManager->createInstance(
483 $this->getSetting('field_widget_display'),
484 $this->getSetting('field_widget_display_settings') + ['entity_type' => $this->fieldDefinition->getFieldStorageDefinition()->getSetting('target_type')]
488 '#theme_wrappers' => ['container'],
489 '#attributes' => ['class' => ['entities-list']],
490 'items' => array_map(
491 function (ContentEntityInterface $entity, $row_id) use ($field_widget_display, $details_id, $field_parents) {
492 $display = $field_widget_display->view($entity);
493 $edit_button_access = $this->getSetting('field_widget_edit');
494 if ($entity->getEntityTypeId() == 'file') {
495 // On file entities, the "edit" button shouldn't be visible unless
496 // the module "file_entity" is present, which will allow them to be
497 // edited on their own form.
498 $edit_button_access &= $this->moduleHandler->moduleExists('file_entity');
500 if (is_string($display)) {
501 $display = ['#markup' => $display];
504 '#theme_wrappers' => ['container'],
506 'class' => ['item-container', Html::getClass($field_widget_display->getPluginId())],
507 'data-entity-id' => $entity->getEntityTypeId() . ':' . $entity->id(),
508 'data-row-id' => $row_id,
510 'display' => $display,
513 '#value' => $this->t('Remove'),
515 'callback' => [get_class($this), 'updateWidgetCallback'],
516 'wrapper' => $details_id,
518 '#submit' => [[get_class($this), 'removeItemSubmit']],
519 '#name' => $this->fieldDefinition->getName() . '_remove_' . $entity->id() . '_' . $row_id . '_' . md5(json_encode($field_parents)),
520 '#limit_validation_errors' => [array_merge($field_parents, [$this->fieldDefinition->getName()])],
522 'data-entity-id' => $entity->getEntityTypeId() . ':' . $entity->id(),
523 'data-row-id' => $row_id,
525 '#access' => (bool) $this->getSetting('field_widget_remove'),
529 '#value' => $this->t('Edit'),
531 'url' => Url::fromRoute(
532 'entity_browser.edit_form', [
533 'entity_type' => $entity->getEntityTypeId(),
534 'entity' => $entity->id(),
539 'details_id' => $details_id,
543 '#access' => $edit_button_access,
548 empty($entities) ? [] : range(0, count($entities) - 1)
554 * Gets data that should persist across Entity Browser renders.
557 * Data that should persist after the Entity Browser is rendered.
559 protected function getPersistentData() {
562 'entity_type' => ['type' => $this->fieldDefinition->getFieldStorageDefinition()->getSetting('target_type')],
568 * Gets options that define where newly added entities are inserted.
571 * Mode labels indexed by key.
573 protected function selectionModeOptions() {
574 return ['append' => $this->t('Append'), 'prepend' => $this->t('Prepend')];
578 * Provides base for settings summary shared by all EB widgets.
581 * A short summary of the widget settings.
583 protected function summaryBase() {
586 $entity_browser_id = $this->getSetting('entity_browser');
587 if (empty($entity_browser_id)) {
588 return [$this->t('No entity browser selected.')];
591 if ($browser = $this->entityTypeManager->getStorage('entity_browser')->load($entity_browser_id)) {
592 $summary[] = $this->t('Entity browser: @browser', ['@browser' => $browser->label()]);
595 drupal_set_message($this->t('Missing entity browser!'), 'error');
596 return [$this->t('Missing entity browser!')];
600 $selection_mode = $this->getSetting('selection_mode');
601 $selection_mode_options = EntityBrowserElement::getSelectionModeOptions();
602 if (isset($selection_mode_options[$selection_mode])) {
603 $summary[] = $this->t('Selection mode: @selection_mode', ['@selection_mode' => $selection_mode_options[$selection_mode]]);
606 $summary[] = $this->t('Undefined selection mode.');
613 * Determines the entities used for the form element.
615 * @param \Drupal\Core\Field\FieldItemListInterface $items
616 * The field item to extract the entities from.
617 * @param array $element
619 * @param \Drupal\Core\Form\FormStateInterface $form_state
622 * @return \Drupal\Core\Entity\EntityInterface[]
623 * The list of entities for the form element.
625 protected function formElementEntities(FieldItemListInterface $items, array $element, FormStateInterface $form_state) {
627 $entity_type = $this->fieldDefinition->getFieldStorageDefinition()->getSetting('target_type');
628 $entity_storage = $this->entityTypeManager->getStorage($entity_type);
630 // Find IDs from target_id element (it stores selected entities in form).
631 // This was added to help solve a really edge casey bug in IEF.
632 if (($target_id_entities = $this->getEntitiesByTargetId($element, $form_state)) !== FALSE) {
633 return $target_id_entities;
636 // Determine if we're submitting and if submit came from this widget.
637 $is_relevant_submit = FALSE;
638 if (($trigger = $form_state->getTriggeringElement())) {
639 // Can be triggered by hidden target_id element or "Remove" button.
640 if (end($trigger['#parents']) === 'target_id' || (end($trigger['#parents']) === 'remove_button')) {
641 $is_relevant_submit = TRUE;
643 // In case there are more instances of this widget on the same page we
644 // need to check if submit came from this instance.
645 $field_name_key = end($trigger['#parents']) === 'target_id' ? 2 : static::$deleteDepth + 1;
646 $field_name_key = count($trigger['#parents']) - $field_name_key;
647 $is_relevant_submit &= ($trigger['#parents'][$field_name_key] === $this->fieldDefinition->getName()) &&
648 (array_slice($trigger['#parents'], 0, count($element['#field_parents'])) == $element['#field_parents']);
652 if ($is_relevant_submit) {
653 // Submit was triggered by hidden "target_id" element when entities were
654 // added via entity browser.
655 if (!empty($trigger['#ajax']['event']) && $trigger['#ajax']['event'] == 'entity_browser_value_updated') {
656 $parents = $trigger['#parents'];
658 // Submit was triggered by one of the "Remove" buttons. We need to walk
659 // few levels up to read value of "target_id" element.
660 elseif ($trigger['#type'] == 'submit' && strpos($trigger['#name'], $this->fieldDefinition->getName() . '_remove_') === 0) {
661 $parents = array_merge(array_slice($trigger['#parents'], 0, -static::$deleteDepth), ['target_id']);
664 if (isset($parents) && $value = $form_state->getValue($parents)) {
665 $entities = EntityBrowserElement::processEntityIds($value);
670 // IDs from a previous request might be saved in the form state.
671 elseif ($form_state->has([
672 'entity_browser_widget',
673 $this->getFormStateKey($items),
676 $stored_ids = $form_state->get([
677 'entity_browser_widget',
678 $this->getFormStateKey($items),
680 $indexed_entities = $entity_storage->loadMultiple($stored_ids);
682 // Selection can contain same entity multiple times. Since loadMultiple()
683 // returns unique list of entities, it's necessary to recreate list of
684 // entities in order to preserve selection of duplicated entities.
685 foreach ($stored_ids as $entity_id) {
686 if (isset($indexed_entities[$entity_id])) {
687 $entities[] = $indexed_entities[$entity_id];
692 // We are loading for for the first time so we need to load any existing
693 // values that might already exist on the entity. Also, remove any leftover
694 // data from removed entity references.
696 foreach ($items as $item) {
697 if (isset($item->target_id)) {
698 $entity = $entity_storage->load($item->target_id);
699 if (!empty($entity)) {
700 $entities[] = $entity;
711 public function calculateDependencies() {
712 $dependencies = parent::calculateDependencies();
714 // If an entity browser is being used in this widget, add it as a config
716 if ($browser_name = $this->getSetting('entity_browser')) {
717 $dependencies['config'][] = 'entity_browser.browser.' . $browser_name;
720 return $dependencies;
724 * Get selected elements from target_id element on form.
726 * @param array $element
728 * @param \Drupal\Core\Form\FormStateInterface $form_state
731 * @return \Drupal\Core\Entity\EntityInterface[]|false
732 * Return list of entities if they are available or false.
734 protected function getEntitiesByTargetId(array $element, FormStateInterface $form_state) {
735 $target_id_element_path = array_merge(
736 $element['#field_parents'],
737 [$this->fieldDefinition->getName(), 'target_id']
740 if (!NestedArray::keyExists($form_state->getUserInput(), $target_id_element_path)) {
744 // TODO Figure out how to avoid using raw user input.
745 $current_user_input = NestedArray::getValue($form_state->getUserInput(), $target_id_element_path);
746 if (!is_array($current_user_input)) {
747 $entities = EntityBrowserElement::processEntityIds($current_user_input);