You are here

MediaForm.php in GridStack 8.2

File

src/Plugin/gridstack/stylizer/MediaForm.php
View source
<?php

namespace Drupal\gridstack\Plugin\gridstack\stylizer;

use Drupal\Component\Serialization\Json;
use Drupal\Component\Utility\Html;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\AnnounceCommand;
use Drupal\Core\Ajax\ReplaceCommand;
use Drupal\Core\Field\FieldStorageDefinitionInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\media\Entity\Media;
use Drupal\media_library\MediaLibraryState;
use Drupal\media_library\Plugin\Field\FieldWidget\MediaLibraryWidget;
use Drupal\gridstack\GridStackDefault;

/**
 * Provides the media form for Layout Builder integration.
 *
 * @GridStackStylizer(
 *   id = "media_form",
 *   label = @Translation("Media Form")
 * )
 */
class MediaForm extends Style {

  /**
   * The media field settings.
   *
   * @var array
   */
  protected $fieldSettings = [];

  /**
   * The responsive image options.
   *
   * @var array
   */
  protected $responsiveImageOptions;

  /**
   * The current entity.
   *
   * @var object
   */
  protected $entity;

  /**
   * The field definition.
   *
   * @var \Drupal\Core\Field\FieldDefinitionInterface
   */
  protected $fieldDefinition;

  /**
   * Entity type to be displayed.
   *
   * @var string
   */
  protected $targetEntityType;

  /**
   * Bundle to be displayed.
   *
   * @var string
   */
  protected $bundle;

  /**
   * A list of field definitions eligible for configuration in this display.
   *
   * @var \Drupal\Core\Field\FieldDefinitionInterface[]
   */
  protected $fieldDefinitions;

  /**
   * Sets available field media settings.
   */
  protected function setFieldSettings(array $settings = []) {
    $this->fieldSettings = $settings;
    return $this;
  }

  /**
   * Returns the current entity.
   */
  protected function setEntity($entity) {
    $this->entity = $entity;
    return $this;
  }

  /**
   * Returns the current entity.
   */
  public function getEntity() {
    return $this->entity;
  }

  /**
   * Returns available field media definition.
   */
  public function getFieldDefinition($name = '') {
    if (!isset($this->fieldDefinition)) {
      $this
        ->setFieldName($name);
      if ($definitions = $this
        ->getFieldDefinitions()) {
        $this->fieldDefinition = isset($definitions[$name]) ? $definitions[$name] : [];
      }
      else {
        $this->fieldDefinition = $this
          ->getFieldData($name);
      }
    }
    return $this->fieldDefinition;
  }

  /**
   * Returns available field media definition.
   */
  public function getFieldCardinality($name = '') {
    if ($definition = $this
      ->getFieldDefinition($name)) {
      return $definition
        ->getFieldStorageDefinition()
        ->getCardinality();
    }
    return -1;
  }

  /**
   * Returns available field media settings.
   */
  public function getFieldSettings($name = '') {

    // @todo $field_settings['handler_settings']['target_bundles'];
    return empty($name) ? $this->fieldSettings : $this
      ->getFieldData($name, 'settings');
  }

  /**
   * Returns available field media for select options.
   */
  public function getLayoutFieldOptions() {
    return $this
      ->getFieldData('', 'options');
  }

  /**
   * Returns the available admin theme to fetch the media library styling.
   */
  public function getMediaLibraryTheme() {
    $admin_theme = $this->manager
      ->configLoad('admin', 'system.theme');
    if ($admin_theme == 'seven') {
      return 'seven/media_library';
    }
    elseif ($admin_theme == 'claro') {
      return 'claro/media_library.theme';
    }

    // Adminimal, Classy has no media library theme, skip.
    return FALSE;
  }

  /**
   * Return available entity data.
   */
  public function getEntityData($entity_form) {
    $extras = [];
    $id = NULL;
    $bundle = NULL;
    $entity = NULL;
    $target = NULL;
    $mode = NULL;

    /* @var \Drupal\layout_builder\Form\ConfigureSectionForm $storage (Layout Builder) */
    if (method_exists($entity_form, 'getSectionStorage') && ($storage = $entity_form
      ->getSectionStorage())) {
      $contexts = $storage
        ->getContextValues();
      if (isset($contexts['entity']) && ($entity = $contexts['entity'])) {
        $id = $entity
          ->id();
        $bundle = $entity
          ->bundle();
        $target = $entity
          ->getEntityTypeId();
        $mode = $contexts['view_mode'];
        $this
          ->setEntity($entity);
      }
      elseif (isset($contexts['display']) && ($display = $contexts['display'])) {
        $id = $display
          ->id();
        $bundle = $display
          ->getTargetBundle();
        $target = $display
          ->getTargetEntityTypeId();
        $mode = $contexts['view_mode'];
      }
    }
    elseif (method_exists($entity_form, 'getEntity') && ($entity = $entity_form
      ->getEntity())) {
      $id = $entity
        ->id();
      $bundle = $entity
        ->getTargetBundle();
      $target = $entity
        ->getTargetEntityTypeId();
      $mode = $entity
        ->getMode();
      $this
        ->setEntity($entity);
    }
    if ($bundle) {
      $extras = [
        'entity' => $entity,
        'bundle' => $bundle,
        'entity_id' => $id,
        'entity_type_id' => $target,
        'view_mode' => $mode,
      ];
      $this->targetEntityType = $target;
      $this->bundle = $bundle;
    }
    return $extras;
  }

  /**
   * Returns the selected media id, supports both upload and media library.
   */
  protected function saveMediaId(array $settings, FormStateInterface $form_state) {
    $mid = isset($settings['target_id']) ? $settings['target_id'] : '';
    return empty($settings['media_library_selection']) ? $mid : $settings['media_library_selection'];
  }

  /**
   * Returns the media data.
   */
  protected function getMediaData($media, $mid = '') {
    $data = [];
    if ($mid && is_null($media)) {
      $media = Media::load($mid);
    }
    if ($media) {
      $data['media_id'] = $media
        ->id();
      $data['media_bundle'] = $media
        ->bundle();
      $data['media_source'] = $media
        ->getSource()
        ->getPluginId();
      $data['source_field'] = $media
        ->getSource()
        ->getConfiguration()['source_field'];
      $source = $media
        ->getSource();
      $plugin_definition = $source
        ->getPluginDefinition();
      if ($uri = $source
        ->getMetadata($media, $plugin_definition['thumbnail_uri_metadata_attribute'])) {
        $data['uri'] = $uri;
        $data['image_url'] = \file_url_transform_relative(\file_create_url($uri));
      }
    }
    return $data;
  }

  /**
   * Returns Responsive image for select options.
   *
   * @todo use blazy or gridstack admin if any more complex need.
   */
  public function getResponsiveImageOptions() {
    if (!isset($this->responsiveImageOptions)) {
      $options = [];
      if ($this->manager
        ->getModuleHandler()
        ->moduleExists('responsive_image')) {
        $image_styles = $this->manager
          ->entityLoadMultiple('responsive_image_style');
        if (!empty($image_styles)) {
          foreach ($image_styles as $name => $image_style) {
            if ($image_style
              ->hasImageStyleMappings()) {
              $options[$name] = Html::escape($image_style
                ->label());
            }
          }
        }
      }
      $this->responsiveImageOptions = $options;
    }
    return $this->responsiveImageOptions;
  }

  /**
   * Returns Media Library form elements adapted from MediaLibraryWidget.
   */
  protected function mediaElement(array &$element, $optionset, FormStateInterface $form_state, array $settings, array $extras = []) {
    if (empty($settings['field_name'])) {
      return;
    }
    $data = [];
    $media = NULL;
    $context = $settings['_scope'];
    $delta = $settings['_delta'];
    $main = [
      'layout_settings',
      'settings',
      'styles',
    ];
    $extra = [
      'layout_settings',
      'regions',
      $context,
      'styles',
    ];
    $parents = $context == GridStackDefault::ROOT ? $main : $extra;

    // Add a button that will load the Media library in a modal using AJAX.
    // Create an ID suffix from the parents to make sure each widget is unique.
    $remaining = 1;
    $field_name = $settings['field_name'];
    $id_suffix = $parents ? '-' . implode('-', $parents) : '';
    $field_widget_id = implode(':', array_filter([
      $field_name,
      $id_suffix,
    ]));
    $wrapper_id = $field_name . '-media-library-wrapper' . $id_suffix;
    $view_builder = $this->manager
      ->getEntityTypeManager()
      ->getViewBuilder('media');
    $cardinality = $this
      ->getFieldCardinality($field_name);

    // Create a new media library URL with the correct state parameters.
    $allowed_media_type_ids = [
      'image',
      'remote_video',
      'video',
    ];
    $selected_type_id = reset($allowed_media_type_ids);
    $limit_validation_errors = [
      array_merge($parents, [
        $field_name,
      ]),
    ];

    // This particular media library opener needs some extra metadata.
    $opener_parameters = [
      'field_widget_id' => $field_widget_id,
      'entity_type_id' => $extras['entity_type_id'],
      'bundle' => $extras['bundle'],
      'field_name' => $field_name,
    ];
    $state = MediaLibraryState::create('media_library.opener.field_widget', $allowed_media_type_ids, $selected_type_id, $remaining, $opener_parameters);
    $form_state
      ->set('media_library_state', $state);
    $add = $this
      ->t('Add media');
    $mid = $this
      ->saveMediaId($settings, $form_state);
    $target_bundles = isset($this
      ->getFieldSettings($field_name)['target_bundles']) ? $this
      ->getFieldSettings($field_name)['target_bundles'] : [];
    $element += [
      '#type' => 'details',
      '#open' => TRUE,
      '#tree' => TRUE,
      '#title' => $this
        ->t('Styles'),
      '#cardinality' => $cardinality,
      '#delta' => $delta,
      '#target_bundles' => $target_bundles,
      '#attributes' => [
        'id' => $wrapper_id,
        'class' => [
          'js-media-library-widget',
          'form-wrapper--styles',
        ],
      ],
      '#attached' => [
        'library' => [
          'media_library/widget',
        ],
      ],
      '#parents' => $parents,
    ];
    if ($optionset
      ->isFramework()) {
      $element['#description'] = $this
        ->t('Requires <code>Min height</code> at <code>Preset classes</code>, else collapsed.');
    }

    // Do not use the global current_selection.
    $new_settings = self::getUserInputValues($element, $form_state);
    if ($new_settings) {
      $new_mid = $new_settings['media_library_selection'];
      if ($new_mid && $new_mid != $mid) {
        $mid = $new_mid;
      }
    }

    // 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_widget_id,
      ],
    ];
    if (empty($mid)) {
      $element['#attributes']['class'][] = 'ig-gs-media-empty';
    }
    $element['selection'] = [
      '#type' => 'container',
      '#theme_wrappers' => [
        'container__media_library_widget_selection',
      ],
      '#attributes' => [
        'class' => [
          'js-media-library-selection',
        ],
      ],
      '#prefix' => '<div class="form-wrapper form-wrapper--media">',
      '#field_name' => $field_name,
    ];
    $element['selection'][$delta] = [
      '#theme' => 'media_library_item__widget',
      '#attributes' => [
        'class' => [
          'js-media-library-item',
          'form-wrapper--media__item',
        ],
        'tabindex' => '-1',
        'data-media-library-item-delta' => $delta,
      ],
    ];
    $element['selection'][$delta]['#field_name'] = $field_name;
    $element['selection'][$delta]['thumbnail'] = [];
    if ($mid) {
      $add = $this
        ->t('Replace media');
      $media = Media::load($mid);
      $data = $this
        ->getMediaData($media);

      // @todo Make the view mode configurable.
      $element['selection'][$delta]['rendered_entity'] = $view_builder
        ->view($media, 'media_library');
      $element['selection'][$delta]['remove_button'] = [
        '#type' => 'submit',
        '#name' => $field_name . '-' . $delta . '-media-library-remove-button' . $id_suffix,
        '#value' => $this
          ->t('Remove'),
        '#media_id' => $media
          ->id(),
        '#attributes' => [
          'aria-label' => $this
            ->t('Remove @label', [
            '@label' => $media
              ->label(),
          ]),
          'class' => [
            'form-submit--gs-remove',
          ],
          'title' => $this
            ->t('Remove'),
        ],
        '#ajax' => [
          'callback' => [
            static::class,
            'updateWidget',
          ],
          'wrapper' => $wrapper_id,
          'progress' => [
            'type' => 'throbber',
            'message' => $this
              ->t('Removing @label.', [
              '@label' => $media
                ->label(),
            ]),
          ],
        ],
        '#submit' => [
          [
            static::class,
            'removeItem',
          ],
        ],
        // Prevent errors in other widgets from preventing removal.
        '#limit_validation_errors' => $limit_validation_errors,
      ];
    }
    $element['selection'][$delta]['target_id'] = [
      '#type' => 'hidden',
      '#default_value' => $mid,
      '#attributes' => [
        'data-gs-media-storage' => TRUE,
      ],
    ];

    // This hidden value can be toggled visible for accessibility.
    $element['selection'][$delta]['weight'] = [
      '#type' => 'number',
      '#theme' => 'input__number__media_library_item_weight',
      '#title' => $this
        ->t('Weight'),
      '#default_value' => $delta,
      '#attributes' => [
        'class' => [
          'js-media-library-item-weight',
        ],
      ],
    ];
    $element['open_button'] = [
      '#type' => 'button',
      '#value' => $add,
      '#name' => $field_name . '-media-library-open-button' . $id_suffix,
      '#attributes' => [
        'class' => [
          'js-media-library-open-button',
          'form-submit--gs-add-replace',
        ],
        // 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' => [
          MediaLibraryWidget::class,
          'openMediaLibrary',
        ],
        'progress' => [
          'type' => 'throbber',
          'message' => $this
            ->t('Opening media library.'),
        ],
      ],
      // Allow the media library to be opened even if there are form errors.
      '#limit_validation_errors' => [],
      '#attached' => [
        'library' => [
          'media_library/widget',
        ],
      ],
    ];

    // 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,
        'progress' => [
          'type' => 'throbber',
          'message' => $this
            ->t('Adding selection.'),
        ],
      ],
      '#attributes' => [
        'data-media-library-widget-update' => $field_widget_id,
        'class' => [
          'js-hide',
          'visually-hidden',
        ],
      ],
      // @todo '#validate' => [[MediaLibraryWidget::class, 'validateItems']],
      '#submit' => [
        [
          static::class,
          'addItems',
        ],
      ],
      // We need to prevent the widget from being validated when no media items
      // are selected. When a media field is added in a subform, entity
      // validation is triggered in EntityFormDisplay::validateFormValues().
      // Since the media item is not added to the form yet, this triggers errors
      // for required media fields.
      '#limit_validation_errors' => empty($mid) ? [] : $limit_validation_errors,
    ];
    $element['metadata'] = [
      '#type' => 'hidden',
      '#default_value' => $data ? Json::encode($data) : '',
      '#suffix' => '</div>',
    ];
  }

  /**
   * AJAX callback to update the widget when the selection changes.
   */
  public static function updateWidget(array $form, FormStateInterface $form_state) {
    $triggering_element = $form_state
      ->getTriggeringElement();
    $wrapper_id = $triggering_element['#ajax']['wrapper'];

    // 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 ? -3 : -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'] = '';

    // @todo $field_state = static::getFieldState($element, $form_state);
    // Announce the updated content to screen readers.
    if ($is_remove_button) {
      $announcement = t('Media has been removed.');

      // At least it works here.
      unset($element['selection'][$element['#delta']]);
      $element['open_button']['#value'] = t('Add media');
    }
    else {
      $new_items = count(static::getNewMediaItems($element, $form_state));
      $announcement = \Drupal::translation()
        ->formatPlural($new_items, 'Added one media item.', 'Added @count media items.');
    }
    $response = new AjaxResponse();
    $response
      ->addCommand(new ReplaceCommand("#{$wrapper_id}", $element));
    $response
      ->addCommand(new AnnounceCommand($announcement));
    return $response;
  }

  /**
   * Submit callback for remove buttons.
   */
  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, -3);
    $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);

    // Get the delta of the item being removed.
    $delta = array_slice($triggering_element['#array_parents'], -2, 1)[0];
    if (isset($values['selection'][$delta])) {

      // Add the weight of the removed item to the field state so we can shift
      // focus to the next/previous item in an easy way.
      $field_state['removed_item_weight'] = $values['selection'][$delta]['weight'];
      $field_state['removed_item_id'] = $triggering_element['#media_id'];
      unset($values['selection'][$delta]);
      $field_state['items'] = $values['selection'];
      static::setFieldState($element, $form_state, $field_state);
    }
    $form_state
      ->setRebuild();
  }

  /**
   * Updates the field state and flags the form for rebuild.
   */
  public static function addItems(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)) {

      // Get the weight of the last items and count from there.
      $last_element = end($field_state['items']);
      $weight = $last_element ? $last_element['weight'] : 0;
      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();
  }

  /**
   * Returns user input values.
   */
  protected static function getUserInputValues(array $element, FormStateInterface $form_state) {

    // Default to using the current selection if the form is new.
    $path = isset($element['#parents']) ? $element['#parents'] : [];

    // We need to use the actual user input, since when #limit_validation_errors
    // is used, the unvalidated user input is not added to the form state.
    // @see FormValidator::handleErrorsWithLimitedValidation()
    return NestedArray::getValue($form_state
      ->getUserInput(), $path);
  }

  /**
   * Gets newly selected media items.
   */
  protected static function getNewMediaItems(array $element, FormStateInterface $form_state) {

    // Get the new media IDs passed to our hidden button. We need to use the
    // actual user input, since when #limit_validation_errors is used, the
    // unvalidated user input is not added to the form state.
    // @see FormValidator::handleErrorsWithLimitedValidation()
    $value = self::getUserInputValues($element, $form_state);
    $mid = empty($value['media_library_selection']) ? '' : $value['media_library_selection'];
    return $mid ? Media::loadMultiple([
      $mid,
    ]) : [];
  }

  /**
   * Gets the field state for the widget.
   */
  protected static function getFieldState(array $element, FormStateInterface $form_state) {
    $values = self::getUserInputValues($element, $form_state);
    $selection = isset($values['selection']) ? $values['selection'] : [];
    $parents = isset($element['#parents']) ? $element['#parents'] : [];
    $parents = isset($element['#field_parents']) ? $element['#field_parents'] : $parents;
    $widget_state = MediaLibraryWidget::getWidgetState($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.
   */
  protected static function setFieldState(array $element, FormStateInterface $form_state, array $field_state) {

    // @todo the field_parents is just to sattisfy MediaLibraryWidget.
    // @todo $element.layout_settings.settings.styles['#field_name'|'select']
    $element['#field_parents'] = isset($element['#parents']) ? $element['#parents'] : [];
    MediaLibraryWidget::setWidgetState($element['#field_parents'], $element['#field_name'], $form_state, $field_state);
  }

  /**
   * Returns available field media data.
   */
  protected function getFieldData($name = '', $key = '') {
    $field_definitions = $this
      ->getFieldDefinitions();
    if (empty($field_definitions)) {
      return [];
    }
    $options = $definitions = $output = [];
    foreach ($field_definitions as $field_definition) {
      if ($field_definition
        ->getType() != 'entity_reference') {
        continue;
      }
      $field_settings = $field_definition
        ->getSettings();
      $field_name = $field_definition
        ->getName();
      if ($field_settings['handler'] == 'default:media') {
        if ($field_definition
          ->getFieldStorageDefinition()
          ->getCardinality() !== FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED) {
          continue;
        }
        $options[$field_name] = $field_name;

        // @todo move it to getFieldSettings() based on selected field.
        $this
          ->setFieldSettings($field_settings['handler_settings']);
        $definitions[$field_name] = $key == 'settings' && $name == $field_name ? $field_settings['handler_settings'] : $field_definition;
      }
    }
    if ($definitions) {
      $definitions = $name ? $definitions[$name] : $definitions;
      $output = $key == 'options' ? $options : $definitions;
    }
    return $output;
  }

  /**
   * Gets the definitions of the fields that are candidate for display.
   */
  protected function getFieldDefinitions() {
    if (!isset($this->fieldDefinitions)) {
      if ($this
        ->getEntity()) {
        $this->fieldDefinitions = $this
          ->getEntity()
          ->getFieldDefinitions();
      }
      else {
        $this->fieldDefinitions = empty($this->bundle) ? [] : \Drupal::service('entity_field.manager')
          ->getFieldDefinitions($this->targetEntityType, $this->bundle);
      }
    }
    return $this->fieldDefinitions;
  }

}

Classes

Namesort descending Description
MediaForm Provides the media form for Layout Builder integration.