entityTypeManager = $entity_type_manager; $this->addAccess = $add_access; } /** * {@inheritdoc} */ public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { $settings = $configuration['field_definition']->getSettings()['handler_settings']; $target_bundles = isset($settings['target_bundles']) ? $settings['target_bundles'] : NULL; return new static( $plugin_id, $plugin_definition, $configuration['field_definition'], $configuration['settings'], $configuration['third_party_settings'], $container->get('entity_type.manager'), // @todo Use URL access in https://www.drupal.org/node/2956747 MediaLibraryUploadForm::create($container)->access($target_bundles)->isAllowed() ); } /** * {@inheritdoc} */ public static function isApplicable(FieldDefinitionInterface $field_definition) { return $field_definition->getSetting('target_type') === 'media'; } /** * {@inheritdoc} */ public function form(FieldItemListInterface $items, array &$form, FormStateInterface $form_state, $get_delta = NULL) { // Load the items for form rebuilds from the field state. $field_state = static::getWidgetState($form['#parents'], $this->fieldDefinition->getName(), $form_state); if (isset($field_state['items'])) { usort($field_state['items'], [SortArray::class, 'sortByWeightElement']); $items->setValue($field_state['items']); } return parent::form($items, $form, $form_state, $get_delta); } /** * {@inheritdoc} */ public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) { /** @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $items */ $referenced_entities = $items->referencedEntities(); $view_builder = $this->entityTypeManager->getViewBuilder('media'); $field_name = $this->fieldDefinition->getName(); $parents = $form['#parents']; $id_suffix = '-' . implode('-', $parents); $wrapper_id = $field_name . '-media-library-wrapper' . $id_suffix; $limit_validation_errors = [array_merge($parents, [$field_name])]; $settings = $this->getFieldSetting('handler_settings'); $element += [ '#type' => 'fieldset', '#cardinality' => $this->fieldDefinition->getFieldStorageDefinition()->getCardinality(), '#target_bundles' => isset($settings['target_bundles']) ? $settings['target_bundles'] : FALSE, '#attributes' => [ 'id' => $wrapper_id, 'class' => ['media-library-widget'], ], '#attached' => [ 'library' => ['media_library/widget'], ], ]; $element['selection'] = [ '#type' => 'container', '#attributes' => [ 'class' => [ 'js-media-library-selection', 'media-library-selection', ], ], ]; if (empty($referenced_entities)) { $element['empty_selection'] = [ '#markup' => $this->t('

No media items are selected.

'), ]; } else { $element['weight_toggle'] = [ '#type' => 'html_tag', '#tag' => 'button', '#value' => $this->t('Show media item weights'), '#attributes' => [ 'class' => [ 'link', 'media-library-widget__toggle-weight', 'js-media-library-widget-toggle-weight', ], 'title' => $this->t('Re-order media by numerical weight instead of dragging'), ], ]; } foreach ($referenced_entities as $delta => $media_item) { $element['selection'][$delta] = [ '#type' => 'container', '#attributes' => [ 'class' => [ 'media-library-item', 'js-media-library-item', ], ], 'preview' => [ '#type' => 'container', // @todo Make the view mode configurable in https://www.drupal.org/project/drupal/issues/2971209 'rendered_entity' => $view_builder->view($media_item, 'media_library'), 'remove_button' => [ '#type' => 'submit', '#name' => $field_name . '-' . $delta . '-media-library-remove-button' . $id_suffix, '#value' => $this->t('Remove'), '#attributes' => [ 'class' => ['media-library-item__remove'], ], '#ajax' => [ 'callback' => [static::class, 'updateWidget'], 'wrapper' => $wrapper_id, ], '#submit' => [[static::class, 'removeItem']], // Prevent errors in other widgets from preventing removal. '#limit_validation_errors' => $limit_validation_errors, ], ], 'target_id' => [ '#type' => 'hidden', '#value' => $media_item->id(), ], // This hidden value can be toggled visible for accessibility. 'weight' => [ '#type' => 'number', '#title' => $this->t('Weight'), '#default_value' => $delta, '#attributes' => [ 'class' => [ 'js-media-library-item-weight', 'media-library-item__weight', ], ], ], ]; } $cardinality_unlimited = ($element['#cardinality'] === FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED); $remaining = $element['#cardinality'] - count($referenced_entities); // Inform the user of how many items are remaining. if (!$cardinality_unlimited) { if ($remaining) { $cardinality_message = $this->formatPlural($remaining, 'One media item remaining.', '@count media items remaining.'); } else { $cardinality_message = $this->t('The maximum number of media items have been selected.'); } $element['#description'] .= '
' . $cardinality_message; } $query = [ 'media_library_widget_id' => $field_name . $id_suffix, 'media_library_allowed_types' => $element['#target_bundles'], 'media_library_remaining' => $cardinality_unlimited ? FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED : $remaining, ]; $dialog_options = Json::encode([ 'dialogClass' => 'media-library-widget-modal', 'height' => '75%', 'width' => '75%', 'title' => $this->t('Media library'), ]); // Add a button that will load the Media library in a modal using AJAX. $element['media_library_open_button'] = [ '#type' => 'link', '#title' => $this->t('Browse media'), '#name' => $field_name . '-media-library-open-button' . $id_suffix, // @todo Make the view configurable in https://www.drupal.org/project/drupal/issues/2971209 '#url' => Url::fromRoute('view.media_library.widget', [], [ 'query' => $query, ]), '#attributes' => [ 'class' => ['button', 'use-ajax', 'media-library-open-button'], 'data-dialog-type' => 'modal', 'data-dialog-options' => $dialog_options, ], // Prevent errors in other widgets from preventing addition. '#limit_validation_errors' => $limit_validation_errors, '#access' => $cardinality_unlimited || $remaining > 0, ]; $element['media_library_add_button'] = [ '#type' => 'link', '#title' => $this->t('Add media'), '#name' => $field_name . '-media-library-add-button' . $id_suffix, '#url' => Url::fromRoute('media_library.upload', [], [ 'query' => $query, ]), '#attributes' => [ 'class' => ['button', 'use-ajax', 'media-library-add-button'], 'data-dialog-type' => 'modal', 'data-dialog-options' => $dialog_options, ], // Prevent errors in other widgets from preventing addition. '#limit_validation_errors' => $limit_validation_errors, '#access' => $this->addAccess && ($cardinality_unlimited || $remaining > 0), ]; // This hidden field and button are used to add new items to the widget. $element['media_library_selection'] = [ '#type' => 'hidden', '#attributes' => [ // This is used to pass the selection from the modal to the widget. 'data-media-library-widget-value' => $field_name . $id_suffix, ], ]; // When a selection is made this hidden button is pressed to add new media // items based on the "media_library_selection" value. $element['media_library_update_widget'] = [ '#type' => 'submit', '#value' => $this->t('Update widget'), '#name' => $field_name . '-media-library-update' . $id_suffix, '#ajax' => [ 'callback' => [static::class, 'updateWidget'], 'wrapper' => $wrapper_id, ], '#attributes' => [ 'data-media-library-widget-update' => $field_name . $id_suffix, 'class' => ['js-hide'], ], '#validate' => [[static::class, 'validateItems']], '#submit' => [[static::class, 'updateItems']], // Prevent errors in other widgets from preventing updates. '#limit_validation_errors' => $limit_validation_errors, ]; return $element; } /** * {@inheritdoc} */ public function errorElement(array $element, ConstraintViolationInterface $error, array $form, FormStateInterface $form_state) { return isset($element['target_id']) ? $element['target_id'] : FALSE; } /** * {@inheritdoc} */ public function massageFormValues(array $values, array $form, FormStateInterface $form_state) { if (isset($values['selection'])) { usort($values['selection'], [SortArray::class, 'sortByWeightElement']); return $values['selection']; } return []; } /** * AJAX callback to update the widget when the selection changes. * * @param array $form * The form array. * @param \Drupal\Core\Form\FormStateInterface $form_state * The form state. * * @return array * An array representing the updated widget. */ public static function updateWidget(array $form, FormStateInterface $form_state) { $triggering_element = $form_state->getTriggeringElement(); // This callback is either invoked from the remove button or the update // button, which have different nesting levels. $length = end($triggering_element['#parents']) === 'remove_button' ? -4 : -1; if (count($triggering_element['#array_parents']) < abs($length)) { throw new \LogicException('The element that triggered the widget update was at an unexpected depth. Triggering element parents were: ' . implode(',', $triggering_element['#array_parents'])); } $parents = array_slice($triggering_element['#array_parents'], 0, $length); $element = NestedArray::getValue($form, $parents); // Always clear the textfield selection to prevent duplicate additions. $element['media_library_selection']['#value'] = ''; return $element; } /** * Submit callback for remove buttons. * * @param array $form * The form array. * @param \Drupal\Core\Form\FormStateInterface $form_state * The form state. */ public static function removeItem(array $form, FormStateInterface $form_state) { $triggering_element = $form_state->getTriggeringElement(); // Get the parents required to find the top-level widget element. if (count($triggering_element['#array_parents']) < 4) { 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'])); } $parents = array_slice($triggering_element['#array_parents'], 0, -4); // Get the delta of the item being removed. $delta = array_slice($triggering_element['#array_parents'], -3, 1)[0]; $element = NestedArray::getValue($form, $parents); // Get the field state. $path = $element['#parents']; $values = NestedArray::getValue($form_state->getValues(), $path); $field_state = static::getFieldState($element, $form_state); // Remove the item from the field state and update it. if (isset($values['selection'][$delta])) { array_splice($values['selection'], $delta, 1); $field_state['items'] = $values['selection']; static::setFieldState($element, $form_state, $field_state); } $form_state->setRebuild(); } /** * Validates that newly selected items can be added to the widget. * * Making an invalid selection from the view should not be possible, but we * still validate in case other selection methods (ex: upload) are valid. * * @param array $form * The form array. * @param \Drupal\Core\Form\FormStateInterface $form_state * The form state. */ public static function validateItems(array $form, FormStateInterface $form_state) { $button = $form_state->getTriggeringElement(); $element = NestedArray::getValue($form, array_slice($button['#array_parents'], 0, -1)); $field_state = static::getFieldState($element, $form_state); $media = static::getNewMediaItems($element, $form_state); if (empty($media)) { return; } // Check if more items were selected than we allow. $cardinality_unlimited = ($element['#cardinality'] === FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED); $selection = count($field_state['items']) + count($media); if (!$cardinality_unlimited && ($selection > $element['#cardinality'])) { $form_state->setError($element, \Drupal::translation()->formatPlural($element['#cardinality'], 'Only one item can be selected.', 'Only @count items can be selected.')); } // Validate that each selected media is of an allowed bundle. $all_bundles = \Drupal::service('entity_type.bundle.info')->getBundleInfo('media'); $bundle_labels = array_map(function ($bundle) use ($all_bundles) { return $all_bundles[$bundle]['label']; }, $element['#target_bundles']); foreach ($media as $media_item) { if ($element['#target_bundles'] && !in_array($media_item->bundle(), $element['#target_bundles'], TRUE)) { $form_state->setError($element, t('The media item "@label" is not of an accepted type. Allowed types: @types', [ '@label' => $media_item->label(), '@types' => implode(', ', $bundle_labels), ])); } } } /** * Updates the field state and flags the form for rebuild. * * @param array $form * The form array. * @param \Drupal\Core\Form\FormStateInterface $form_state * The form state. */ public static function updateItems(array $form, FormStateInterface $form_state) { $button = $form_state->getTriggeringElement(); $element = NestedArray::getValue($form, array_slice($button['#array_parents'], 0, -1)); $field_state = static::getFieldState($element, $form_state); $media = static::getNewMediaItems($element, $form_state); if (!empty($media)) { $weight = count($field_state['items']); foreach ($media as $media_item) { // Any ID can be passed to the widget, so we have to check access. if ($media_item->access('view')) { $field_state['items'][] = [ 'target_id' => $media_item->id(), 'weight' => $weight++, ]; } } static::setFieldState($element, $form_state, $field_state); } $form_state->setRebuild(); } /** * Gets newly selected media items. * * @param array $element * The wrapping element for this widget. * @param \Drupal\Core\Form\FormStateInterface $form_state * The current state of the form. * * @return \Drupal\media\MediaInterface[] * An array of selected media items. */ protected static function getNewMediaItems(array $element, FormStateInterface $form_state) { // Get the new media IDs passed to our hidden button. $values = $form_state->getValues(); $path = $element['#parents']; $value = NestedArray::getValue($values, $path); if (!empty($value['media_library_selection'])) { $ids = explode(',', $value['media_library_selection']); $ids = array_filter($ids, 'is_numeric'); if (!empty($ids)) { /** @var \Drupal\media\MediaInterface[] $media */ return Media::loadMultiple($ids); } } return []; } /** * Gets the field state for the widget. * * @param array $element * The wrapping element for this widget. * @param \Drupal\Core\Form\FormStateInterface $form_state * The current state of the form. * * @return array[] * An array of arrays with the following key/value pairs: * - items: (array) An array of selections. * - target_id: (int) A media entity ID. * - weight: (int) A weight for the selection. */ protected static function getFieldState(array $element, FormStateInterface $form_state) { // Default to using the current selection if the form is new. $path = $element['#parents']; $values = NestedArray::getValue($form_state->getValues(), $path); $selection = isset($values['selection']) ? $values['selection'] : []; $widget_state = static::getWidgetState($element['#field_parents'], $element['#field_name'], $form_state); $widget_state['items'] = isset($widget_state['items']) ? $widget_state['items'] : $selection; return $widget_state; } /** * Sets the field state for the widget. * * @param array $element * The wrapping element for this widget. * @param \Drupal\Core\Form\FormStateInterface $form_state * The current state of the form. * @param array[] $field_state * An array of arrays with the following key/value pairs: * - items: (array) An array of selections. * - target_id: (int) A media entity ID. * - weight: (int) A weight for the selection. */ protected static function setFieldState(array $element, FormStateInterface $form_state, array $field_state) { static::setWidgetState($element['#field_parents'], $element['#field_name'], $form_state, $field_state); } }