You are here

class MediaLibrary in Media Library Form API Element 8

Same name and namespace in other branches
  1. 2.x src/Element/MediaLibrary.php \Drupal\media_library_form_element\Element\MediaLibrary

Provides a Media library form element.

The #default_value accepted by this element is an ID of a media object.

@FormElement("media_library")

Usage can include the following components:

$element['image'] = [ '#type' => 'media_library', '#allowed_bundles' => ['image'], '#title' => t('Upload your image'), '#default_value' => NULL|'1'|'2,3,1', '#description' => t('Upload or select your profile image.'), '#cardinality' => -1|1, ];

Hierarchy

Expanded class hierarchy of MediaLibrary

File

src/Element/MediaLibrary.php, line 33

Namespace

Drupal\media_library_form_element\Element
View source
class MediaLibrary extends FormElement {

  /**
   * Expand the media_library_element into it's required sub-elements.
   *
   * @param array $element
   *   The base form element render array.
   * @param \Drupal\Core\Form\FormStateInterface $form_state
   *   The form state object.
   * @param array $complete_form
   *   The complete form render array.
   *
   * @return array
   *   The form element render array.
   *
   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
   */
  public static function processMediaLibrary(array &$element, FormStateInterface $form_state, array &$complete_form) : array {
    $default_value = NULL;
    $referenced_entities = [];
    if (!empty($element['#value'])) {
      $default_value = $element['#value'];
    }
    if (!empty($default_value['media_selection_id'])) {
      $entity_ids = [
        $default_value['media_selection_id'],
      ];
    }
    else {
      $entity_ids = array_filter(explode(',', $default_value));
    }
    if (!empty($entity_ids)) {
      $referenced_entities = \Drupal::entityTypeManager()
        ->getStorage('media')
        ->loadMultiple($entity_ids);
    }
    $view_builder = \Drupal::entityTypeManager()
      ->getViewBuilder('media');
    $allowed_media_type_ids = $element['#allowed_bundles'];
    $parents = $element['#parents'];
    $field_name = array_pop($parents);
    $attributes = $element['#attributes'] ?? [];

    // Create an ID suffix from the parents to make sure each widget is unique.
    $id_suffix = $parents ? '-' . implode('-', $parents) : '';
    $field_widget_id = implode('', array_filter([
      $field_name,
      $id_suffix,
    ]));
    $wrapper_id = $field_name . '-media-library-wrapper' . $id_suffix;
    $limit_validation_errors = [
      array_merge($parents, [
        $field_name,
      ]),
    ];
    $element = array_merge($element, [
      '#target_bundles' => !empty($allowed_media_type_ids) ? $allowed_media_type_ids : FALSE,
      '#cardinality' => $element['#cardinality'] ?? 1,
      '#attributes' => [
        'id' => $wrapper_id,
        'class' => [
          'media-library-form-element',
        ],
      ],
      '#modal_selector' => '#modal-media-library',
      '#attached' => [
        'library' => [
          'media_library_form_element/media_library_form_element',
          'media_library/view',
        ],
      ],
    ]);
    if (empty($referenced_entities)) {
      $element['empty_selection'] = [
        '#type' => 'html_tag',
        '#tag' => 'p',
        '#value' => t('No media item selected.'),
        '#attributes' => [
          'class' => [
            'media-library-form-element-empty-text',
          ],
        ],
      ];
    }
    $element['selection'] = [
      '#type' => 'container',
      '#attributes' => [
        'class' => [
          'js-media-library-selection',
          'media-library-selection',
        ],
      ],
    ];
    foreach ($referenced_entities as $delta => $referenced_entity) {
      $element['selection'][$delta] = [
        '#type' => 'container',
        '#attributes' => [
          'class' => [
            'media-library-item',
            'media-library-item--grid',
            'js-media-library-item',
          ],
          // Add the tabindex '-1' to allow the focus to be shifted to the next
          // media item when an item is removed. We set focus to the container
          // because we do not want to set focus to the remove button
          // automatically.
          // @see ::updateFormElement()
          'tabindex' => '-1',
          // Add a data attribute containing the delta to allow us to easily
          // shift the focus to a specific media item.
          // @see ::updateFormElement()
          'data-media-library-item-delta' => $delta,
        ],
        'preview' => [
          '#type' => 'container',
          'remove_button' => [
            '#type' => 'submit',
            '#name' => $field_name . '-' . $delta . '-media-library-remove-button' . $id_suffix,
            '#value' => t('Remove'),
            '#media_id' => $referenced_entity
              ->id(),
            '#attributes' => [
              'class' => [
                'media-library-item__remove',
              ],
              'aria-label' => t('Remove @label', [
                '@label' => $referenced_entity
                  ->label(),
              ]),
            ],
            '#ajax' => [
              'callback' => [
                static::class,
                'updateFormElement',
              ],
              'wrapper' => $wrapper_id,
              'progress' => [
                'type' => 'throbber',
                'message' => t('Removing @label.', [
                  '@label' => $referenced_entity
                    ->label(),
                ]),
              ],
            ],
            '#submit' => [
              [
                static::class,
                'removeItem',
              ],
            ],
            // Prevent errors in other widgets from preventing removal.
            '#limit_validation_errors' => $limit_validation_errors,
          ],
          // @todo Make the view mode configurable in https://www.drupal.org/project/drupal/issues/2971209
          'rendered_entity' => $view_builder
            ->view($referenced_entity, 'media_library'),
          'target_id' => [
            '#type' => 'hidden',
            '#value' => $referenced_entity
              ->id(),
          ],
        ],
        'weight' => [
          '#type' => 'number',
          '#theme' => 'input__number__media_library_item_weight',
          '#title' => \Drupal::translation()
            ->translate('Weight'),
          '#default_value' => $delta,
          '#attributes' => [
            'class' => [
              'js-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 = \Drupal::translation()
          ->formatPlural($remaining, 'One media item remaining.', '@count media items remaining.');
      }
      else {
        $cardinality_message = \Drupal::translation()
          ->translate('The maximum number of media items have been selected.');
      }

      // Add a line break between the field message and the cardinality message.
      if (!empty($element['#description'])) {
        $element['#description'] .= '<br />' . $cardinality_message;
      }
      else {
        $element['#description'] = $cardinality_message;
      }
    }

    // Create a new media library URL with the correct state parameters.
    $selected_type_id = reset($allowed_media_type_ids);
    $remaining = $cardinality_unlimited ? FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED : $remaining;

    // This particular media library opener needs some extra metadata for its
    // \Drupal\media_library\MediaLibraryOpenerInterface::getSelectionResponse()
    // to be able to target the element
    // whose 'data-media-library-form-element-value'
    // attribute is the same as $field_widget_id. The entity ID, entity type ID,
    // bundle, field name are used for access checking.
    $opener_parameters = [
      'field_widget_id' => $field_widget_id,
      'field_name' => $field_name,
    ];
    $state = MediaLibraryState::create('media_library.opener.form_element', $allowed_media_type_ids, $selected_type_id, $remaining, $opener_parameters);

    // Add a button that will load the Media library in a modal using AJAX.
    $element['media_library_open_button'] = [
      '#type' => 'button',
      '#value' => t('Add media'),
      '#name' => $field_name . '-media-library-open-button' . $id_suffix,
      '#attributes' => [
        'class' => [
          'media-library-open-button',
          'js-media-library-open-button',
        ],
        // The jQuery UI dialog automatically moves focus to the first :tabbable
        // element of the modal, so we need to disable refocus on the button.
        'data-disable-refocus' => 'true',
      ],
      '#media_library_state' => $state,
      '#ajax' => [
        'callback' => [
          static::class,
          'openMediaLibrary',
        ],
        'progress' => [
          'type' => 'throbber',
          'message' => t('Opening media library.'),
        ],
      ],
      // Allow the media library to be opened even if there are form errors.
      '#limit_validation_errors' => [],
    ];

    // When the user returns from the modal to the widget, we want to shift the
    // focus back to the open button. If the user is not allowed to add more
    // items, the button needs to be disabled. Since we can't shift the focus to
    // disabled elements, the focus is set back to the open button via
    // JavaScript by adding the 'data-disabled-focus' attribute.
    // @see Drupal.behaviors.MediaLibraryWidgetDisableButton
    if (!$cardinality_unlimited && $remaining === 0) {
      $element['media_library_open_button']['#attributes']['data-disabled-focus'] = 'true';
      $element['media_library_open_button']['#attributes']['class'][] = 'visually-hidden';
    }

    // This hidden field and button are used to add new items to the widget.
    $element['media_library_selection'] = [
      '#type' => 'hidden',
      '#attributes' => array_merge([
        'data-media-library-form-element-value' => $field_widget_id,
      ], $attributes),
      '#default_value' => $element['#value'],
    ];

    // 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' => t('Update widget'),
      '#name' => $field_name . '-media-library-update' . $id_suffix,
      '#ajax' => [
        'callback' => [
          static::class,
          'updateFormElement',
        ],
        'wrapper' => $wrapper_id,
        'progress' => [
          'type' => 'throbber',
          'message' => t('Adding selection.'),
        ],
      ],
      '#attributes' => [
        'data-media-library-form-element-update' => $field_widget_id,
        'class' => [
          'js-hide',
        ],
      ],
      '#validate' => [
        [
          static::class,
          'validateItem',
        ],
      ],
      '#submit' => [
        [
          static::class,
          'updateItem',
        ],
      ],
      // Prevent errors in other widgets from preventing updates.
      // Exclude other validations in case there is no data yet.
      '#limit_validation_errors' => !empty($referenced_entities) ? $limit_validation_errors : [],
    ];
    return $element;
  }

  /**
   * Extract the proper portion of our default_value.
   *
   * @param array $element
   *   The render element.
   * @param \Drupal\Core\Form\FormStateInterface $form_state
   *   The form state object.
   * @param array $complete_form
   *   The complete form render array.
   */
  public static function elementValidateMediaLibrary(array &$element, FormStateInterface $form_state, array &$complete_form) {
    $value = NULL;
    if (!empty($element['#value'])) {
      $value = $element['#value'];
      if (isset($value['media_library_selection'])) {
        $value = $value['media_library_selection'];
      }
    }
    $form_state
      ->setValueForElement($element, $value);
  }

  /**
   * {@inheritdoc}
   */
  public static function valueCallback(&$element, $input, FormStateInterface $form_state) {
    $value = NULL;

    // Process the submission of our form element.
    if ($input !== FALSE && $input !== NULL && isset($input['media_library_selection'])) {
      $value = $input['media_library_selection'];
    }
    elseif ($input === FALSE) {
      if (!empty($element['#default_value'])) {

        // Remove the default value propery in case of AJAX removal.
        if ($form_state
          ->isSubmitted() && end($form_state
          ->getTriggeringElement()['#parents']) === 'remove_button') {
          $element['#default_value'] = NULL;
        }
        $value = $element['#default_value'];
      }
    }
    if (!empty($value)) {
      if (isset($value['target_id'])) {
        $value = $value['target_id'];
      }

      // Normalize 0 value.
      $value = $value === 0 ? '' : $value;
    }
    return $value;
  }

  /**
   * 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 AJAX response to update the selection.
   */
  public static function updateFormElement(array $form, FormStateInterface $form_state) : array {
    $triggering_element = $form_state
      ->getTriggeringElement();

    // This callback is either invoked from the remove button or the update
    // button, which have different nesting levels.
    $is_remove_button = end($triggering_element['#parents']) === 'remove_button';
    $length = $is_remove_button ? -4 : -1;
    if (count($triggering_element['#array_parents']) < abs($length)) {
      throw new \LogicException('The element that triggered the form element update was at an unexpected depth. Triggering element parents were: ' . implode(',', $triggering_element['#array_parents']));
    }
    $parents = array_slice($triggering_element['#array_parents'], 0, $length);
    return NestedArray::getValue($form, $parents);
  }

  /**
   * 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 validateItem(array $form, FormStateInterface $form_state) {
    $button = $form_state
      ->getTriggeringElement();
    $element = NestedArray::getValue($form, array_slice($button['#array_parents'], 0, -1));
    $media = static::getNewMediaItem($element, $form_state);
    if (empty($media)) {
      return;
    }

    // Validate that each selected media is of an allowed bundle.
    $all_bundles = \Drupal::service('entity_type.bundle.info')
      ->getBundleInfo('media');
    $bundle_labels = array_map(static function ($bundle) use ($all_bundles) {
      return $all_bundles[$bundle]['label'];
    }, $element['#target_bundles']);
    if ($element['#target_bundles'] && !in_array($media
      ->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
          ->label(),
        '@types' => implode(', ', $bundle_labels),
      ]));
    }
  }

  /**
   * Gets newly selected media item.
   *
   * @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 selected media item.
   */
  protected static function getNewMediaItem(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 [];
  }

  /**
   * Flags the form for rebuild.
   *
   * @param array $form
   *   The form array.
   * @param \Drupal\Core\Form\FormStateInterface $form_state
   *   The form state.
   */
  public static function updateItem(array $form, FormStateInterface $form_state) {
    $form_state
      ->setRebuild();
  }

  /**
   * Submit callback for remove buttons.
   *
   * @param array $form
   *   The form array.
   * @param \Drupal\Core\Form\FormStateInterface $form_state
   *   The form state.
   *
   * @return mixed
   *   The updated form element.
   */
  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);
    $element = NestedArray::getValue($form, $parents);
    $user_input_parents = array_slice($triggering_element['#parents'], 0, -4);
    $delta = array_slice($triggering_element['#array_parents'], -3, 1)[0];
    if (isset($element['selection'][$delta])) {
      unset($element['selection'][$delta]);
      $items = array_filter($element['selection'], static function ($k) {
        return is_numeric($k);
      }, ARRAY_FILTER_USE_KEY);
      $remaining_items = [];
      foreach ($items as $item) {

        /** @var \Drupal\media\Entity\Media $media_item */
        $media_item = $item['preview']['rendered_entity']['#media'];
        if ($media_item instanceof Media) {
          $remaining_items[] = $media_item
            ->id();
        }
      }
      $selection = implode(',', $remaining_items);

      // Remove our value.
      $element['media_library_selection']['#value'] = $selection;
      $element['media_library_selection']['#default_value'] = $selection;
      $element['#value'] = $selection;
      $element['#default_value'] = $selection;

      // Clear the formstate values.
      $form_state
        ->setValueForElement($element, $selection);

      // Clear formstate user input.
      $user_input = $form_state
        ->getUserInput();
      NestedArray::setValue($user_input, $user_input_parents, $selection);
      $form_state
        ->setUserInput($user_input);
      $form_state
        ->setValue($user_input_parents, $selection);
    }

    // Refresh the form element.
    $form_state
      ->setRebuild();
  }

  /**
   * AJAX callback to open the library modal.
   *
   * @param array $form
   *   The form array.
   * @param \Drupal\Core\Form\FormStateInterface $form_state
   *   The form state.
   *
   * @return \Drupal\Core\Ajax\AjaxResponse
   *   An AJAX response to open the media library.
   */
  public static function openMediaLibrary(array $form, FormStateInterface $form_state) {
    $triggering_element = $form_state
      ->getTriggeringElement();
    $library_ui = \Drupal::service('media_library.ui_builder')
      ->buildUi($triggering_element['#media_library_state']);
    $dialog_options = MediaLibraryUiBuilder::dialogOptions();
    return (new AjaxResponse())
      ->addCommand(new OpenModalDialogCommand($dialog_options['title'], $library_ui, $dialog_options, NULL, '#modal-media-library'));
  }

  /**
   * {@inheritdoc}
   */
  public function getInfo() {
    $class = get_class($this);
    return [
      '#input' => TRUE,
      '#tree' => TRUE,
      '#cardinality' => 1,
      '#allowed_bundles' => [],
      '#process' => [
        [
          $class,
          'processAjaxForm',
        ],
        [
          $class,
          'processMediaLibrary',
        ],
        [
          $class,
          'processGroup',
        ],
      ],
      '#pre_render' => [
        [
          $class,
          'preRenderGroup',
        ],
      ],
      '#element_validate' => [
        [
          $class,
          'elementValidateMediaLibrary',
        ],
      ],
      '#theme' => 'media_library_element',
    ];
  }

}

Members

Namesort descending Modifiers Type Description Overrides
DependencySerializationTrait::$_entityStorages protected property An array of entity type IDs keyed by the property name of their storages.
DependencySerializationTrait::$_serviceIds protected property An array of service IDs keyed by property name used for serialization.
DependencySerializationTrait::__sleep public function 1
DependencySerializationTrait::__wakeup public function 2
FormElement::processAutocomplete public static function Adds autocomplete functionality to elements.
FormElement::processPattern public static function #process callback for #pattern form element property.
FormElement::validatePattern public static function #element_validate callback for #pattern form element property.
MediaLibrary::elementValidateMediaLibrary public static function Extract the proper portion of our default_value.
MediaLibrary::getInfo public function Returns the element properties for this element. Overrides ElementInterface::getInfo
MediaLibrary::getNewMediaItem protected static function Gets newly selected media item.
MediaLibrary::openMediaLibrary public static function AJAX callback to open the library modal.
MediaLibrary::processMediaLibrary public static function Expand the media_library_element into it's required sub-elements.
MediaLibrary::removeItem public static function Submit callback for remove buttons.
MediaLibrary::updateFormElement public static function AJAX callback to update the widget when the selection changes.
MediaLibrary::updateItem public static function Flags the form for rebuild.
MediaLibrary::validateItem public static function Validates that newly selected items can be added to the widget.
MediaLibrary::valueCallback public static function Determines how user input is mapped to an element's #value property. Overrides FormElement::valueCallback
MessengerTrait::$messenger protected property The messenger. 29
MessengerTrait::messenger public function Gets the messenger. 29
MessengerTrait::setMessenger public function Sets the messenger.
PluginBase::$configuration protected property Configuration information passed into the plugin. 1
PluginBase::$pluginDefinition protected property The plugin implementation definition. 1
PluginBase::$pluginId protected property The plugin_id.
PluginBase::DERIVATIVE_SEPARATOR constant A string which is used to separate base plugin IDs from the derivative ID.
PluginBase::getBaseId public function Gets the base_plugin_id of the plugin instance. Overrides DerivativeInspectionInterface::getBaseId
PluginBase::getDerivativeId public function Gets the derivative_id of the plugin instance. Overrides DerivativeInspectionInterface::getDerivativeId
PluginBase::getPluginDefinition public function Gets the definition of the plugin implementation. Overrides PluginInspectionInterface::getPluginDefinition 3
PluginBase::getPluginId public function Gets the plugin_id of the plugin instance. Overrides PluginInspectionInterface::getPluginId
PluginBase::isConfigurable public function Determines if the plugin is configurable.
PluginBase::__construct public function Constructs a \Drupal\Component\Plugin\PluginBase object. 92
RenderElement::preRenderAjaxForm public static function Adds Ajax information about an element to communicate with JavaScript.
RenderElement::preRenderGroup public static function Adds members of this group as actual elements for rendering.
RenderElement::processAjaxForm public static function Form element processing handler for the #ajax form property. 1
RenderElement::processGroup public static function Arranges elements into groups.
RenderElement::setAttributes public static function Sets a form element's class attribute. Overrides ElementInterface::setAttributes
StringTranslationTrait::$stringTranslation protected property The string translation service. 1
StringTranslationTrait::formatPlural protected function Formats a string containing a count of items.
StringTranslationTrait::getNumberOfPlurals protected function Returns the number of plurals supported by a given language.
StringTranslationTrait::getStringTranslation protected function Gets the string translation service.
StringTranslationTrait::setStringTranslation public function Sets the string translation service to use. 2
StringTranslationTrait::t protected function Translates a string to the current language or to a given language.