3 namespace Drupal\media_library\Plugin\Field\FieldWidget;
5 use Drupal\Component\Serialization\Json;
6 use Drupal\Component\Utility\NestedArray;
7 use Drupal\Component\Utility\SortArray;
8 use Drupal\Core\Entity\EntityTypeManagerInterface;
9 use Drupal\Core\Field\FieldDefinitionInterface;
10 use Drupal\Core\Field\FieldItemListInterface;
11 use Drupal\Core\Field\FieldStorageDefinitionInterface;
12 use Drupal\Core\Field\WidgetBase;
13 use Drupal\Core\Form\FormStateInterface;
14 use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
16 use Drupal\media\Entity\Media;
17 use Drupal\media_library\Form\MediaLibraryUploadForm;
18 use Symfony\Component\DependencyInjection\ContainerInterface;
19 use Symfony\Component\Validator\ConstraintViolationInterface;
22 * Plugin implementation of the 'media_library_widget' widget.
25 * id = "media_library_widget",
26 * label = @Translation("Media library"),
27 * description = @Translation("Allows you to select items from the media library."),
31 * multiple_values = TRUE,
36 class MediaLibraryWidget extends WidgetBase implements ContainerFactoryPluginInterface {
39 * Entity type manager service.
41 * @var \Drupal\Core\Entity\EntityTypeManagerInterface
43 protected $entityTypeManager;
46 * Indicates whether or not the add button should be shown.
50 protected $addAccess = FALSE;
53 * Constructs a MediaLibraryWidget widget.
55 * @param string $plugin_id
56 * The plugin_id for the plugin instance.
57 * @param mixed $plugin_definition
58 * The plugin implementation definition.
59 * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
60 * The definition of the field to which the widget is associated.
61 * @param array $settings
62 * The widget settings.
63 * @param array $third_party_settings
64 * Any third party settings.
65 * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
66 * Entity type manager service.
67 * @param bool $add_access
68 * Indicates whether or not the add button should be shown.
70 public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, array $third_party_settings, EntityTypeManagerInterface $entity_type_manager, $add_access) {
71 parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $third_party_settings);
72 $this->entityTypeManager = $entity_type_manager;
73 $this->addAccess = $add_access;
79 public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
80 $settings = $configuration['field_definition']->getSettings()['handler_settings'];
81 $target_bundles = isset($settings['target_bundles']) ? $settings['target_bundles'] : NULL;
85 $configuration['field_definition'],
86 $configuration['settings'],
87 $configuration['third_party_settings'],
88 $container->get('entity_type.manager'),
89 // @todo Use URL access in https://www.drupal.org/node/2956747
90 MediaLibraryUploadForm::create($container)->access($target_bundles)->isAllowed()
97 public static function isApplicable(FieldDefinitionInterface $field_definition) {
98 return $field_definition->getSetting('target_type') === 'media';
104 public function form(FieldItemListInterface $items, array &$form, FormStateInterface $form_state, $get_delta = NULL) {
105 // Load the items for form rebuilds from the field state.
106 $field_state = static::getWidgetState($form['#parents'], $this->fieldDefinition->getName(), $form_state);
107 if (isset($field_state['items'])) {
108 usort($field_state['items'], [SortArray::class, 'sortByWeightElement']);
109 $items->setValue($field_state['items']);
112 return parent::form($items, $form, $form_state, $get_delta);
118 public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) {
119 /** @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $items */
120 $referenced_entities = $items->referencedEntities();
121 $view_builder = $this->entityTypeManager->getViewBuilder('media');
122 $field_name = $this->fieldDefinition->getName();
123 $parents = $form['#parents'];
124 $id_suffix = '-' . implode('-', $parents);
125 $wrapper_id = $field_name . '-media-library-wrapper' . $id_suffix;
126 $limit_validation_errors = [array_merge($parents, [$field_name])];
128 $settings = $this->getFieldSetting('handler_settings');
130 '#type' => 'fieldset',
131 '#cardinality' => $this->fieldDefinition->getFieldStorageDefinition()->getCardinality(),
132 '#target_bundles' => isset($settings['target_bundles']) ? $settings['target_bundles'] : FALSE,
135 'class' => ['media-library-widget'],
138 'library' => ['media_library/widget'],
142 $element['selection'] = [
143 '#type' => 'container',
146 'js-media-library-selection',
147 'media-library-selection',
152 if (empty($referenced_entities)) {
153 $element['empty_selection'] = [
154 '#markup' => $this->t('<p>No media items are selected.</p>'),
158 $element['weight_toggle'] = [
159 '#type' => 'html_tag',
161 '#value' => $this->t('Show media item weights'),
165 'media-library-widget__toggle-weight',
166 'js-media-library-widget-toggle-weight',
168 'title' => $this->t('Re-order media by numerical weight instead of dragging'),
173 foreach ($referenced_entities as $delta => $media_item) {
174 $element['selection'][$delta] = [
175 '#type' => 'container',
178 'media-library-item',
179 'js-media-library-item',
183 '#type' => 'container',
184 // @todo Make the view mode configurable in https://www.drupal.org/project/drupal/issues/2971209
185 'rendered_entity' => $view_builder->view($media_item, 'media_library'),
188 '#name' => $field_name . '-' . $delta . '-media-library-remove-button' . $id_suffix,
189 '#value' => $this->t('Remove'),
191 'class' => ['media-library-item__remove'],
194 'callback' => [static::class, 'updateWidget'],
195 'wrapper' => $wrapper_id,
197 '#submit' => [[static::class, 'removeItem']],
198 // Prevent errors in other widgets from preventing removal.
199 '#limit_validation_errors' => $limit_validation_errors,
204 '#value' => $media_item->id(),
206 // This hidden value can be toggled visible for accessibility.
209 '#title' => $this->t('Weight'),
210 '#default_value' => $delta,
213 'js-media-library-item-weight',
214 'media-library-item__weight',
221 $cardinality_unlimited = ($element['#cardinality'] === FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED);
222 $remaining = $element['#cardinality'] - count($referenced_entities);
224 // Inform the user of how many items are remaining.
225 if (!$cardinality_unlimited) {
227 $cardinality_message = $this->formatPlural($remaining, 'One media item remaining.', '@count media items remaining.');
230 $cardinality_message = $this->t('The maximum number of media items have been selected.');
232 $element['#description'] .= '<br />' . $cardinality_message;
236 'media_library_widget_id' => $field_name . $id_suffix,
237 'media_library_allowed_types' => $element['#target_bundles'],
238 'media_library_remaining' => $cardinality_unlimited ? FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED : $remaining,
240 $dialog_options = Json::encode([
241 'dialogClass' => 'media-library-widget-modal',
244 'title' => $this->t('Media library'),
247 // Add a button that will load the Media library in a modal using AJAX.
248 $element['media_library_open_button'] = [
250 '#title' => $this->t('Browse media'),
251 '#name' => $field_name . '-media-library-open-button' . $id_suffix,
252 // @todo Make the view configurable in https://www.drupal.org/project/drupal/issues/2971209
253 '#url' => Url::fromRoute('view.media_library.widget', [], [
257 'class' => ['button', 'use-ajax', 'media-library-open-button'],
258 'data-dialog-type' => 'modal',
259 'data-dialog-options' => $dialog_options,
261 // Prevent errors in other widgets from preventing addition.
262 '#limit_validation_errors' => $limit_validation_errors,
263 '#access' => $cardinality_unlimited || $remaining > 0,
266 $element['media_library_add_button'] = [
268 '#title' => $this->t('Add media'),
269 '#name' => $field_name . '-media-library-add-button' . $id_suffix,
270 '#url' => Url::fromRoute('media_library.upload', [], [
274 'class' => ['button', 'use-ajax', 'media-library-add-button'],
275 'data-dialog-type' => 'modal',
276 'data-dialog-options' => $dialog_options,
278 // Prevent errors in other widgets from preventing addition.
279 '#limit_validation_errors' => $limit_validation_errors,
280 '#access' => $this->addAccess && ($cardinality_unlimited || $remaining > 0),
283 // This hidden field and button are used to add new items to the widget.
284 $element['media_library_selection'] = [
287 // This is used to pass the selection from the modal to the widget.
288 'data-media-library-widget-value' => $field_name . $id_suffix,
292 // When a selection is made this hidden button is pressed to add new media
293 // items based on the "media_library_selection" value.
294 $element['media_library_update_widget'] = [
296 '#value' => $this->t('Update widget'),
297 '#name' => $field_name . '-media-library-update' . $id_suffix,
299 'callback' => [static::class, 'updateWidget'],
300 'wrapper' => $wrapper_id,
303 'data-media-library-widget-update' => $field_name . $id_suffix,
304 'class' => ['js-hide'],
306 '#validate' => [[static::class, 'validateItems']],
307 '#submit' => [[static::class, 'updateItems']],
308 // Prevent errors in other widgets from preventing updates.
309 '#limit_validation_errors' => $limit_validation_errors,
318 public function errorElement(array $element, ConstraintViolationInterface $error, array $form, FormStateInterface $form_state) {
319 return isset($element['target_id']) ? $element['target_id'] : FALSE;
325 public function massageFormValues(array $values, array $form, FormStateInterface $form_state) {
326 if (isset($values['selection'])) {
327 usort($values['selection'], [SortArray::class, 'sortByWeightElement']);
328 return $values['selection'];
334 * AJAX callback to update the widget when the selection changes.
338 * @param \Drupal\Core\Form\FormStateInterface $form_state
342 * An array representing the updated widget.
344 public static function updateWidget(array $form, FormStateInterface $form_state) {
345 $triggering_element = $form_state->getTriggeringElement();
346 // This callback is either invoked from the remove button or the update
347 // button, which have different nesting levels.
348 $length = end($triggering_element['#parents']) === 'remove_button' ? -4 : -1;
349 if (count($triggering_element['#array_parents']) < abs($length)) {
350 throw new \LogicException('The element that triggered the widget update was at an unexpected depth. Triggering element parents were: ' . implode(',', $triggering_element['#array_parents']));
352 $parents = array_slice($triggering_element['#array_parents'], 0, $length);
353 $element = NestedArray::getValue($form, $parents);
354 // Always clear the textfield selection to prevent duplicate additions.
355 $element['media_library_selection']['#value'] = '';
360 * Submit callback for remove buttons.
364 * @param \Drupal\Core\Form\FormStateInterface $form_state
367 public static function removeItem(array $form, FormStateInterface $form_state) {
368 $triggering_element = $form_state->getTriggeringElement();
370 // Get the parents required to find the top-level widget element.
371 if (count($triggering_element['#array_parents']) < 4) {
372 throw new \LogicException('Expected the remove button to be more than four levels deep in the form. Triggering element parents were: ' . implode(',', $triggering_element['#array_parents']));
374 $parents = array_slice($triggering_element['#array_parents'], 0, -4);
375 // Get the delta of the item being removed.
376 $delta = array_slice($triggering_element['#array_parents'], -3, 1)[0];
377 $element = NestedArray::getValue($form, $parents);
379 // Get the field state.
380 $path = $element['#parents'];
381 $values = NestedArray::getValue($form_state->getValues(), $path);
382 $field_state = static::getFieldState($element, $form_state);
384 // Remove the item from the field state and update it.
385 if (isset($values['selection'][$delta])) {
386 array_splice($values['selection'], $delta, 1);
387 $field_state['items'] = $values['selection'];
388 static::setFieldState($element, $form_state, $field_state);
391 $form_state->setRebuild();
395 * Validates that newly selected items can be added to the widget.
397 * Making an invalid selection from the view should not be possible, but we
398 * still validate in case other selection methods (ex: upload) are valid.
402 * @param \Drupal\Core\Form\FormStateInterface $form_state
405 public static function validateItems(array $form, FormStateInterface $form_state) {
406 $button = $form_state->getTriggeringElement();
407 $element = NestedArray::getValue($form, array_slice($button['#array_parents'], 0, -1));
409 $field_state = static::getFieldState($element, $form_state);
410 $media = static::getNewMediaItems($element, $form_state);
415 // Check if more items were selected than we allow.
416 $cardinality_unlimited = ($element['#cardinality'] === FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED);
417 $selection = count($field_state['items']) + count($media);
418 if (!$cardinality_unlimited && ($selection > $element['#cardinality'])) {
419 $form_state->setError($element, \Drupal::translation()->formatPlural($element['#cardinality'], 'Only one item can be selected.', 'Only @count items can be selected.'));
422 // Validate that each selected media is of an allowed bundle.
423 $all_bundles = \Drupal::service('entity_type.bundle.info')->getBundleInfo('media');
424 $bundle_labels = array_map(function ($bundle) use ($all_bundles) {
425 return $all_bundles[$bundle]['label'];
426 }, $element['#target_bundles']);
427 foreach ($media as $media_item) {
428 if ($element['#target_bundles'] && !in_array($media_item->bundle(), $element['#target_bundles'], TRUE)) {
429 $form_state->setError($element, t('The media item "@label" is not of an accepted type. Allowed types: @types', [
430 '@label' => $media_item->label(),
431 '@types' => implode(', ', $bundle_labels),
438 * Updates the field state and flags the form for rebuild.
442 * @param \Drupal\Core\Form\FormStateInterface $form_state
445 public static function updateItems(array $form, FormStateInterface $form_state) {
446 $button = $form_state->getTriggeringElement();
447 $element = NestedArray::getValue($form, array_slice($button['#array_parents'], 0, -1));
449 $field_state = static::getFieldState($element, $form_state);
451 $media = static::getNewMediaItems($element, $form_state);
452 if (!empty($media)) {
453 $weight = count($field_state['items']);
454 foreach ($media as $media_item) {
455 // Any ID can be passed to the widget, so we have to check access.
456 if ($media_item->access('view')) {
457 $field_state['items'][] = [
458 'target_id' => $media_item->id(),
459 'weight' => $weight++,
463 static::setFieldState($element, $form_state, $field_state);
466 $form_state->setRebuild();
470 * Gets newly selected media items.
472 * @param array $element
473 * The wrapping element for this widget.
474 * @param \Drupal\Core\Form\FormStateInterface $form_state
475 * The current state of the form.
477 * @return \Drupal\media\MediaInterface[]
478 * An array of selected media items.
480 protected static function getNewMediaItems(array $element, FormStateInterface $form_state) {
481 // Get the new media IDs passed to our hidden button.
482 $values = $form_state->getValues();
483 $path = $element['#parents'];
484 $value = NestedArray::getValue($values, $path);
486 if (!empty($value['media_library_selection'])) {
487 $ids = explode(',', $value['media_library_selection']);
488 $ids = array_filter($ids, 'is_numeric');
490 /** @var \Drupal\media\MediaInterface[] $media */
491 return Media::loadMultiple($ids);
498 * Gets the field state for the widget.
500 * @param array $element
501 * The wrapping element for this widget.
502 * @param \Drupal\Core\Form\FormStateInterface $form_state
503 * The current state of the form.
506 * An array of arrays with the following key/value pairs:
507 * - items: (array) An array of selections.
508 * - target_id: (int) A media entity ID.
509 * - weight: (int) A weight for the selection.
511 protected static function getFieldState(array $element, FormStateInterface $form_state) {
512 // Default to using the current selection if the form is new.
513 $path = $element['#parents'];
514 $values = NestedArray::getValue($form_state->getValues(), $path);
515 $selection = isset($values['selection']) ? $values['selection'] : [];
517 $widget_state = static::getWidgetState($element['#field_parents'], $element['#field_name'], $form_state);
518 $widget_state['items'] = isset($widget_state['items']) ? $widget_state['items'] : $selection;
519 return $widget_state;
523 * Sets the field state for the widget.
525 * @param array $element
526 * The wrapping element for this widget.
527 * @param \Drupal\Core\Form\FormStateInterface $form_state
528 * The current state of the form.
529 * @param array[] $field_state
530 * An array of arrays with the following key/value pairs:
531 * - items: (array) An array of selections.
532 * - target_id: (int) A media entity ID.
533 * - weight: (int) A weight for the selection.
535 protected static function setFieldState(array $element, FormStateInterface $form_state, array $field_state) {
536 static::setWidgetState($element['#field_parents'], $element['#field_name'], $form_state, $field_state);