diff --git a/core/modules/media/config/schema/media.schema.yml b/core/modules/media/config/schema/media.schema.yml index b9156b23f8..d0da71496b 100644 --- a/core/modules/media/config/schema/media.schema.yml +++ b/core/modules/media/config/schema/media.schema.yml @@ -66,3 +66,11 @@ media.source.field_aware: source_field: type: string label: 'Source field' + +field.widget.settings.media_upload: + type: mapping + label: 'Media upload field display format settings' + mapping: + thumbnail_image_style: + type: string + label: 'Thumbnail image style' diff --git a/core/modules/media/css/widget.css b/core/modules/media/css/widget.css new file mode 100644 index 0000000000..4cdd0c074c --- /dev/null +++ b/core/modules/media/css/widget.css @@ -0,0 +1,13 @@ +/** + * @file + * Tweaks for Media field widgets. + */ + +.media-upload-widget table td:nth-child(2) { + display: flex; + align-items: center; +} + +.media-upload-widget table a { + margin: 0 1rem 0 1rem; +} diff --git a/core/modules/media/js/commands.es6.js b/core/modules/media/js/commands.es6.js new file mode 100644 index 0000000000..e8521038cd --- /dev/null +++ b/core/modules/media/js/commands.es6.js @@ -0,0 +1,45 @@ +/** + * + * @file + * Defines AJAX commands used by the Media module. + */ + +(function ($, Drupal) { + + /** + * Ajax command to open the Media Bulk Upload form in a modal. + * + * @param {Drupal.Ajax} ajax + * {@link Drupal.Ajax} object created by {@link Drupal.ajax}. + * @param {object} response + * JSON response from the Ajax request. + * @param {number} [status] + * XMLHttpRequest status. + */ + Drupal.AjaxCommands.prototype.openMediaUploadModal = function (ajax, response, status) { + new Drupal.Ajax(null, null, { + url: response.url, + dialogType: 'modal', + dialog: response.dialogOptions, + progress: { + type: 'throbber' + } + }).execute(); + }; + + /** + * Ajax command to add Media items to a field widget. + * + * @param {Drupal.Ajax} ajax + * {@link Drupal.Ajax} object created by {@link Drupal.ajax}. + * @param {object} response + * JSON response from the Ajax request. + * @param {number} [status] + * XMLHttpRequest status. + */ + Drupal.AjaxCommands.prototype.addMediaToWidget = function (ajax, response, status) { + $(`[data-media-widget-value="${response.identifier}"]`).val(response.mids.join(',')); + $(`[data-media-widget-update="${response.identifier}"]`).trigger('mousedown'); + }; + +}(jQuery, Drupal)); diff --git a/core/modules/media/js/commands.js b/core/modules/media/js/commands.js new file mode 100644 index 0000000000..0b7c0624ab --- /dev/null +++ b/core/modules/media/js/commands.js @@ -0,0 +1,24 @@ +/** +* DO NOT EDIT THIS FILE. +* See the following change record for more information, +* https://www.drupal.org/node/2815083 +* @preserve +**/ + +(function ($, Drupal) { + Drupal.AjaxCommands.prototype.openMediaUploadModal = function (ajax, response, status) { + new Drupal.Ajax(null, null, { + url: response.url, + dialogType: 'modal', + dialog: response.dialogOptions, + progress: { + type: 'throbber' + } + }).execute(); + }; + + Drupal.AjaxCommands.prototype.addMediaToWidget = function (ajax, response, status) { + $('[data-media-widget-value="' + response.identifier + '"]').val(response.mids.join(',')); + $('[data-media-widget-update="' + response.identifier + '"]').trigger('mousedown'); + }; +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/modules/media/media.libraries.yml b/core/modules/media/media.libraries.yml index 72496a3233..73bc2acfeb 100644 --- a/core/modules/media/media.libraries.yml +++ b/core/modules/media/media.libraries.yml @@ -11,3 +11,18 @@ type_form: js/type_form.js: {} dependencies: - core/drupal.form + +commands: + version: VERSION + js: + js/commands.js: {} + dependencies: + - core/drupal.dialog.ajax + +widget: + version: VERSION + css: + theme: + css/widget.css: {} + dependencies: + - media/commands diff --git a/core/modules/media/media.links.action.yml b/core/modules/media/media.links.action.yml index e76ab06b73..01882bfb19 100644 --- a/core/modules/media/media.links.action.yml +++ b/core/modules/media/media.links.action.yml @@ -10,3 +10,10 @@ media.add: weight: 10 appears_on: - entity.media.collection + +media.bulk_upload: + route_name: media.bulk_upload + title: 'Bulk upload media' + weight: 20 + appears_on: + - entity.media.collection diff --git a/core/modules/media/media.routing.yml b/core/modules/media/media.routing.yml index 9fbadeff29..367b7b6b9e 100644 --- a/core/modules/media/media.routing.yml +++ b/core/modules/media/media.routing.yml @@ -5,6 +5,14 @@ entity.media.multiple_delete_confirm: requirements: _permission: 'administer media+delete any media' +media.bulk_upload: + path: '/admin/content/media/upload' + defaults: + _form: '\Drupal\media\Form\MediaBulkUploadForm' + _title: 'Bulk Upload Media' + requirements: + _permission: 'create media' + entity.media.revision: path: '/media/{media}/revisions/{media_revision}/view' defaults: diff --git a/core/modules/media/src/Ajax/AddMediaToWidget.php b/core/modules/media/src/Ajax/AddMediaToWidget.php new file mode 100644 index 0000000000..79b76eef1f --- /dev/null +++ b/core/modules/media/src/Ajax/AddMediaToWidget.php @@ -0,0 +1,61 @@ +"] + * An input element that will store new MIDs. + * - [data-media-widget-update=""] + * An AJAX button that will be clicked to inform the widget of an update. + * + * @ingroup ajax + */ +class AddMediaToWidget implements CommandInterface { + + /** + * Media IDs to pass to a field widget. + * + * @var array + */ + protected $mids; + + /** + * An identifier for the field widget. + * + * @var string + */ + protected $identifier; + + /** + * AddMediaToWidget constructor. + * + * @param array $mids + * Media IDs to pass to a field widget. + * @param string $identifier + * An identifier for the field widget. + */ + public function __construct(array $mids, $identifier) { + $this->mids = $mids; + $this->identifier = $identifier; + } + + /** + * {@inheritdoc} + */ + public function render() { + return [ + 'command' => 'addMediaToWidget', + 'mids' => $this->mids, + 'identifier' => $this->identifier, + ]; + } + +} diff --git a/core/modules/media/src/Ajax/OpenMediaUploadModal.php b/core/modules/media/src/Ajax/OpenMediaUploadModal.php new file mode 100644 index 0000000000..cc25ec30e5 --- /dev/null +++ b/core/modules/media/src/Ajax/OpenMediaUploadModal.php @@ -0,0 +1,75 @@ + '70%', + ]; + + /** + * OpenMediaUploadModal constructor. + * + * @param array $fids + * (optional) An array of File IDs, which skips the normal upload step. + * @param array $target_bundles + * (optional) An array of Media Type IDs, to restrict creation. + * @param string $modal_return_id + * (optional) A string used to identify where Media IDs should be returned. + * @param array $dialog_options + * (optional) Additional client-side options for the modal dialog. + */ + public function __construct(array $fids = NULL, array $target_bundles = NULL, $modal_return_id = NULL, $dialog_options = []) { + $query = []; + if ($fids) { + $query['fids'] = $fids; + } + if ($target_bundles) { + $query['target_bundles'] = $target_bundles; + } + if ($modal_return_id) { + $query['modal_return_id'] = $modal_return_id; + } + $url = Url::fromRoute('media.bulk_upload', [], [ + 'query' => $query, + ]); + $this->url = $url->toString(); + if ($dialog_options) { + $this->dialogOptions = array_merge($this->dialogOptions, $dialog_options); + } + } + + /** + * {@inheritdoc} + */ + public function render() { + return [ + 'command' => 'openMediaUploadModal', + 'url' => $this->url, + 'dialogOptions' => $this->dialogOptions, + ]; + } + +} diff --git a/core/modules/media/src/Form/MediaBulkUploadForm.php b/core/modules/media/src/Form/MediaBulkUploadForm.php new file mode 100644 index 0000000000..05d3f8989f --- /dev/null +++ b/core/modules/media/src/Form/MediaBulkUploadForm.php @@ -0,0 +1,498 @@ +entityTypeManager = $entityTypeManager; + $this->elementInfo = $element_info; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('entity_type.manager'), + $container->get('element_info') + ); + } + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'media_bulk_upload_form'; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state) { + // Every AJAX callback updates the entire form, so we need to wrap it. + $form['#prefix'] = '
'; + $form['#suffix'] = '
'; + + // If initial File IDs have been passed, we can skip the upload step. + $query = $this->getRequest()->query; + if (($fids = $query->get('fids')) && empty($form_state->get('total_files'))) { + $form_state->setValue('upload', $fids); + self::uploadButtonSubmit($form, $form_state); + } + + // Get all media types the current user can create. + $media_type_storage = $this->entityTypeManager->getStorage('media_type'); + /** @var \Drupal\media\MediaTypeInterface[] $types */ + $types = $media_type_storage->loadMultiple($query->get('target_bundles')); + $types = $this->filterTypesWithFileSource($types); + $types = $this->filterTypesWithCreateAccess($types); + + // This case is fairly rare and specific, so we show an informative message + // instead of returning a generic "Access Denied". + if (count($types) === 0) { + $form = [ + '#markup' => $this->t('

You do not have access to create media that uses files.

'), + ]; + return $form; + } + + $form['progress'] = $this->getFormProgress($form_state); + // Determine what step of the form we're on. + switch ($form_state->get('step')) { + case NULL: + $form = $this->buildUploadForm($form, $form_state, $types); + break; + + case 'select_media': + $form = $this->buildMediaTypeSelectionForm($form, $form_state, $types); + break; + + case 'show_media_form': + $form = $this->buildMediaForm($form, $form_state); + break; + + case 'finished': + $form = $this->buildFinishedForm($form, $form_state); + break; + } + return $form; + } + + /** + * Returns a progress bar representing the form process. + * + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * + * @return array + * A render array representing the form progress. + */ + protected function getFormProgress(FormStateInterface $form_state) { + $element = []; + $total = $form_state->get('total_files'); + if ($total > 1 && $form_state->get('step') !== 'finished') { + $files = $form_state->get('files'); + $remaining = ($total - count($files)); + $element = [ + '#theme' => 'progress_bar', + '#label' => $this->t('Processing @remaining of @total files', [ + '@remaining' => $remaining, + '@total' => $total, + ]), + '#percent' => floor((($remaining - 1) / $total) * 100), + ]; + } + return $element; + } + + /** + * Builds a form for selecting a media type for the next uploaded file. + * + * @param array $form + * The form render array. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * @param \Drupal\media\MediaTypeInterface[] $types + * Available Media Types. + * + * @return array + * The form render array. + */ + protected function buildUploadForm(array $form, FormStateInterface $form_state, array $types) { + $element_info = $this->elementInfo->getInfo('managed_file'); + $upload_validators = $this->mergeUploadValidators($types); + $form['upload'] = [ + '#type' => 'managed_file', + '#process' => array_merge($element_info['#process'], [[static::class, 'processUpload']]), + '#upload_validators' => $upload_validators, + '#multiple' => TRUE, + ]; + $form['upload_help'] = [ + '#theme' => 'file_upload_help', + '#description' => $this->t('Upload files here to start the bulk media creation process.'), + '#upload_validators' => $upload_validators, + ]; + return $form; + } + + /** + * Processes an upload (managed_file) element. + * + * @param array $element + * The upload element. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * + * @return array + * The processed upload element. + */ + public static function processUpload(array $element, FormStateInterface $form_state) { + $element['upload_button']['#submit'] = [[static::class, 'uploadButtonSubmit']]; + $element['upload_button']['#ajax'] = [ + 'callback' => [static::class, 'updateFormCallback'], + 'wrapper' => 'media-build-upload-form-wrapper', + ]; + // Hide the Managed File element's table display - we don't use it. + $element['remove_button']['#access'] = FALSE; + foreach ($element['#value']['fids'] as $fid) { + if (isset($element['file_' . $fid])) { + $element['file_' . $fid]['#access'] = FALSE; + } + } + return $element; + } + + /** + * Builds a form for selecting a media type for the next uploaded file. + * + * @param array $form + * The form render array. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * @param \Drupal\media\MediaTypeInterface[] $types + * Available media types. + * + * @return array + * The form render array. + */ + protected function buildMediaTypeSelectionForm(array $form, FormStateInterface $form_state, array $types) { + /** @var \Drupal\file\FileInterface $file */ + $file = $form_state->get('current_file'); + $valid_types = $this->filterTypesThatAcceptFile($file, $types); + $form['title'] = [ + '#markup' => t('

Select media type to create with @file

', [ + '@file' => $file->label(), + ]), + ]; + foreach ($valid_types as $type) { + $form[] = [ + '#type' => 'submit', + '#name' => $type->id(), + '#value' => $type->label(), + '#submit' => [[static::class, 'mediaTypeSelectSubmit']], + '#ajax' => [ + 'callback' => [static::class, 'updateFormCallback'], + 'wrapper' => 'media-build-upload-form-wrapper', + ], + ]; + } + return $form; + } + + /** + * Builds a form for selecting a media type for the next uploaded file. + * + * @param array $form + * The form render array. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * + * @return array + * The form render array. + */ + protected function buildMediaForm(array $form, FormStateInterface $form_state) { + /** @var \Drupal\Core\Entity\EntityFormInterface $media_form */ + $media_form = $form_state->get('media_form'); + $form['subform'] = $media_form->buildForm([], $form_state); + $form['subform']['actions']['submit'] = [ + '#type' => 'submit', + '#value' => $this->t('Save and continue'), + '#submit' => [[static::class, 'mediaFormSubmit']], + '#ajax' => [ + 'callback' => [static::class, 'mediaFormSubmitCallback'], + 'wrapper' => 'media-build-upload-form-wrapper', + ], + ]; + // \Drupal\Core\Entity\EntityForm::processForm is unneeded as the entity in + // this form is never uncached. + unset($form['subform']['#process'][0]); + // The entity in this form is always new, this entity builder is unneeded. + unset($form['subform']['#entity_builders']['update_form_langcode']); + return $form; + } + + /** + * Submit handler for the media form. + * + * @param array $form + * The form render array. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + */ + public static function mediaFormSubmit(array &$form, FormStateInterface $form_state) { + $files = $form_state->get('files'); + $media_items = $form_state->get('media_items') ?: []; + /** @var \Drupal\Core\Entity\EntityFormInterface $media_form */ + $media_form = $form_state->get('media_form'); + // Submit the media form instance and save the new media. + $media_form->submitForm($form, $form_state); + $media = $media_form->getEntity(); + $media->save(); + $media_items[] = $media; + $form_state->set('media_items', $media_items); + // Determine if there are more files to process. + if (empty($files)) { + $form_state->set('step', 'finished'); + } + else { + $form_state->set('current_file', array_shift($files)); + $form_state->set('files', $files); + $form_state->set('step', 'select_media'); + } + $form_state->setRebuild(); + } + + /** + * Submit handler for the media type select button. + * + * @param array $form + * The form render array. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * @param \Drupal\media\MediaTypeInterface $type + * (optional) A pre-selected media type, if available. + */ + public static function mediaTypeSelectSubmit(array &$form, FormStateInterface $form_state, $type = NULL) { + if (!$type) { + $media_type = $form_state->getTriggeringElement()['#name']; + $type = MediaType::load($media_type); + } + /** @var \Drupal\file\FileInterface $file */ + $file = $form_state->get('current_file'); + $form_state->set('step', 'show_media_form'); + $form_state->set('media_type', $type); + // Prepare the media form. + /** @var self $form_object */ + $form_object = $form_state->getFormObject(); + $media_form = $form_object->createMediaForm($file, $type); + $storage = $form_state->getStorage(); + // Remove storage values set by the previous media form - this prevents + // previously submitted values from appearing in new forms. + $storage = array_diff_key($storage, array_flip([ + 'entity_default_langcode', + 'langcode', + 'form_display', + 'entity_form_initialized', + 'field_storage', + ])); + $form_state->setStorage($storage); + $form_state->set('media_form', $media_form); + + $form_state->setRebuild(); + } + + /** + * Creates a media form object for a newly uploaded file. + * + * @param \Drupal\file\FileInterface $file + * A newly uploaded file. + * @param \Drupal\media\MediaTypeInterface $type + * A media Type. + * + * @return \Drupal\Core\Entity\EntityFormInterface + * A media form object. + */ + public function createMediaForm(FileInterface $file, MediaTypeInterface $type) { + // Move the temporary file to the correct destination. + $location = $this->getUploadLocationForType($type); + file_move($file, $location); + $media_form = $this->entityTypeManager->getFormObject('media', 'add'); + /** @var \Drupal\media\MediaInterface $media */ + $media = $this->entityTypeManager->getStorage('media')->create([ + 'bundle' => $type->id(), + ]); + $source_field = $type->getSource()->getSourceFieldDefinition($type); + $media->set($source_field->getName(), $file->id()); + $media_form->setEntity($media); + return $media_form; + } + + /** + * Submit handler for the upload button, inside the managed_file element. + * + * @param array $form + * The form render array. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + */ + public static function uploadButtonSubmit(array $form, FormStateInterface $form_state) { + $fids = $form_state->getValue('upload', []); + // Prepare form state storage to be used in future steps. + $files = File::loadMultiple($fids); + $form_state->set('total_files', count($fids)); + $form_state->set('current_file', array_shift($files)); + $form_state->set('files', $files); + $form_state->set('step', 'select_media'); + + $form_state->setRebuild(); + } + + /** + * Builds the confirmation page of the form. + * + * @param array $form + * The form render array. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * + * @return array + * The form render array. + */ + protected function buildFinishedForm(array $form, FormStateInterface $form_state) { + /** @var \Drupal\media\MediaInterface[] $media_items */ + $media_items = $form_state->get('media_items'); + $form['header'] = [ + '#markup' => $this->formatPlural(count($media_items), + '

One media item created:

', + '

@count media items created:

'), + ]; + $form['list'] = [ + '#theme' => 'item_list', + '#items' => [], + ]; + foreach ($media_items as $item) { + $form['list']['#items'][] = $item->toLink()->toRenderable(); + } + $form['actions']['#type'] = 'actions'; + $form['actions']['return'] = [ + '#type' => 'link', + '#attributes' => ['class' => ['button']], + '#url' => Url::fromRoute('entity.media.collection'), + '#title' => $this->t('Return to media listing'), + ]; + $form['actions']['upload'] = [ + '#type' => 'link', + '#attributes' => ['class' => ['button', 'button--primary']], + '#url' => Url::fromRoute('media.bulk_upload'), + '#title' => $this->t('Upload more'), + ]; + return $form; + } + + /** + * AJAX callback for refreshing the entire form. + * + * @param array $form + * The form render array. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * + * @return array + * The form render array. + */ + public static function updateFormCallback(array &$form, FormStateInterface $form_state) { + return $form; + } + + /** + * AJAX command that either refreshes the form, or closes the parent modal. + * + * @param array $form + * The form render array. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * + * @return array|\Drupal\Core\Ajax\AjaxResponse + * The form render array, or an AJAX response to return media IDs to a + * form element. + */ + public static function mediaFormSubmitCallback(array &$form, FormStateInterface $form_state) { + if ($form_state->get('step') === 'finished' && $identifier = \Drupal::request()->query->get('modal_return_id')) { + $ids = array_map(function (MediaInterface $media) { + return $media->id(); + }, $form_state->get('media_items')); + $response = new AjaxResponse(); + $response->addCommand(new AddMediaToWidget($ids, $identifier)); + $response->addCommand(new CloseModalDialogCommand()); + return $response; + } + else { + return self::updateFormCallback($form, $form_state); + } + } + + /** + * {@inheritdoc} + */ + public function validateForm(array &$form, FormStateInterface $form_state) { + if ($form_state->get('step') === 'show_media_form') { + /** @var \Drupal\Core\Entity\EntityFormInterface $media_form */ + $media_form = $form_state->get('media_form'); + $media_form->validateForm($form['subform'], $form_state); + } + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) {} + +} diff --git a/core/modules/media/src/MediaUploadTrait.php b/core/modules/media/src/MediaUploadTrait.php new file mode 100644 index 0000000000..61d1c45c66 --- /dev/null +++ b/core/modules/media/src/MediaUploadTrait.php @@ -0,0 +1,154 @@ +filterTypesWithFileSource($types); + foreach ($types as $type) { + $validators = $this->getUploadValidatorsForType($type); + $errors = file_validate($file, $validators); + if (empty($errors)) { + $valid_types[] = $type; + } + } + return $valid_types; + } + + /** + * Filters an array of media types that accept file sources. + * + * @param \Drupal\media\MediaTypeInterface[] $types + * An array of media types. + * + * @return \Drupal\media\MediaTypeInterface[] + * An array of media types that accept file sources. + */ + protected function filterTypesWithFileSource(array $types) { + $valid_types = []; + foreach ($types as $type) { + $source = $type->getSource(); + if (is_a($source, 'Drupal\media\Plugin\media\Source\File')) { + $valid_types[] = $type; + } + } + return $valid_types; + } + + /** + * Merges file upload validators for an array of media types. + * + * @param \Drupal\media\MediaTypeInterface[] $types + * An array of media types. + * + * @return array + * An array suitable for passing to file_save_upload() or the file field + * element's '#upload_validators' property. + */ + protected function mergeUploadValidators(array $types) { + $max_size = 0; + $extensions = []; + $types = $this->filterTypesWithFileSource($types); + foreach ($types as $type) { + $validators = $this->getUploadValidatorsForType($type); + $max_size = max($max_size, $validators['file_validate_size'][0]); + $extensions = array_unique(array_merge($extensions, explode(' ', $validators['file_validate_extensions'][0]))); + } + return [ + 'file_validate_extensions' => [implode(' ', $extensions)], + 'file_validate_size' => [$max_size], + ]; + } + + /** + * Gets upload validators for a given Media Type. + * + * @param \Drupal\media\MediaTypeInterface $type + * A Media Type. + * + * @return array + * An array suitable for passing to file_save_upload() or the file field + * element's '#upload_validators' property. + */ + protected function getUploadValidatorsForType(MediaTypeInterface $type) { + $file_item = $this->getFileItemForType($type); + return $file_item->getUploadValidators(); + } + + /** + * Gets upload destination for a given Media Type. + * + * @param \Drupal\media\MediaTypeInterface $type + * A Media Type. + * + * @return string + * An unsanitized file directory URI with tokens replaced. + */ + protected function getUploadLocationForType(MediaTypeInterface $type) { + $file_item = $this->getFileItemForType($type); + return $file_item->getUploadLocation(); + } + + /** + * Creates a file item for a given Media Type. + * + * @param \Drupal\media\MediaTypeInterface $type + * A Media Type. + * + * @return \Drupal\file\Plugin\Field\FieldType\FileItem + * The file item. + */ + protected function getFileItemForType(MediaTypeInterface $type) { + $source = $type->getSource(); + $source_data_definition = FieldItemDataDefinition::create($source->getSourceFieldDefinition($type)); + return new FileItem($source_data_definition); + } + + /** + * Filters an array of media types that can be created by the current user. + * + * @param \Drupal\media\MediaTypeInterface[] $types + * An array of media types. + * + * @return \Drupal\media\MediaTypeInterface[] + * An array of media types that accept file sources. + */ + protected function filterTypesWithCreateAccess(array $types) { + $valid_types = []; + $access_handler = $this->entityTypeManager->getAccessControlHandler('media'); + foreach ($types as $type) { + if ($access_handler->createAccess($type->id())) { + $valid_types[] = $type; + } + } + return $valid_types; + } + +} diff --git a/core/modules/media/src/Plugin/Field/FieldWidget/MediaUploadWidget.php b/core/modules/media/src/Plugin/Field/FieldWidget/MediaUploadWidget.php new file mode 100644 index 0000000000..d7a2e071ff --- /dev/null +++ b/core/modules/media/src/Plugin/Field/FieldWidget/MediaUploadWidget.php @@ -0,0 +1,564 @@ +entityTypeManager = $entityTypeManager; + $this->elementInfo = $element_info; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $plugin_id, + $plugin_definition, + $configuration['field_definition'], + $configuration['settings'], + $configuration['third_party_settings'], + $container->get('entity_type.manager'), + $container->get('element_info') + ); + } + + /** + * {@inheritdoc} + */ + public static function defaultSettings() { + return [ + 'thumbnail_image_style' => 'thumbnail', + ] + parent::defaultSettings(); + } + + /** + * {@inheritdoc} + */ + public function settingsForm(array $form, FormStateInterface $form_state) { + $element = parent::settingsForm($form, $form_state); + + $element['thumbnail_image_style'] = [ + '#title' => $this->t('Thumbnail image style'), + '#type' => 'select', + '#options' => image_style_options(FALSE), + '#empty_option' => '<' . $this->t('no thumbnail') . '>', + '#default_value' => $this->getSetting('thumbnail_image_style'), + '#description' => $this->t('The media thumbnail image style used in the widget.'), + '#weight' => 15, + ]; + + return $element; + } + + /** + * {@inheritdoc} + * + * The image style logic is a copied from the image_image widget. + */ + public function settingsSummary() { + $summary = parent::settingsSummary(); + + $image_styles = image_style_options(FALSE); + // Unset possible 'No defined styles' option. + unset($image_styles['']); + // Styles could be lost because of enabled/disabled modules that defines + // their styles in code. + $image_style_setting = $this->getSetting('thumbnail_image_style'); + if (isset($image_styles[$image_style_setting])) { + $preview_image_style = $this->t('Thumbnail image style: @style', ['@style' => $image_styles[$image_style_setting]]); + } + else { + $preview_image_style = $this->t('No thumbnail'); + } + + array_unshift($summary, $preview_image_style); + + return $summary; + } + + /** + * {@inheritdoc} + */ + public function formMultipleElements(FieldItemListInterface $items, array &$form, FormStateInterface $form_state) { + $field_name = $this->fieldDefinition->getName(); + $handler_settings = $this->fieldDefinition->getSetting('handler_settings'); + /** @var \Drupal\media\MediaTypeInterface[] $types */ + $types = MediaType::loadMultiple($handler_settings['target_bundles']); + $create_types = $this->filterTypesWithFileSource($types); + $create_types = $this->filterTypesWithCreateAccess($create_types); + + // Load the items for form rebuilds from the field state as they might not + // be in $form_state->getValues() because of validation limitations. Also, + // they are only passed in as $items when editing existing entities. + $field_state = static::getWidgetState($form['#parents'], $field_name, $form_state); + if (isset($field_state['items'])) { + $items->setValue($field_state['items']); + } + else { + $field_state['items'] = $items->getValue(); + $field_state['items_count'] = count($field_state['items']); + static::setWidgetState($form['#parents'], $field_name, $form_state, $field_state); + } + + $cardinality = $this->fieldDefinition->getFieldStorageDefinition()->getCardinality(); + $cardinality_remaining = $cardinality; + $cardinality_unlimited = $cardinality === FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED; + if (!$cardinality_unlimited) { + $cardinality_remaining = $cardinality - $field_state['items_count']; + } + + $elements = [ + '#type' => 'fieldset', + '#title' => $this->fieldDefinition->getLabel(), + '#attributes' => [ + 'id' => $this->getFieldWrapperId($field_name), + 'class' => ['media-upload-widget'], + ], + '#attached' => [ + 'library' => [ + 'media/widget', + ], + ], + '#target_bundles' => $handler_settings['target_bundles'], + ]; + if (($cardinality_unlimited || $cardinality_remaining > 0) && count($create_types) > 0) { + $upload_validators = $this->mergeUploadValidators($create_types); + + $element_info = $this->elementInfo->getInfo('managed_file'); + $elements['upload'] = [ + '#type' => 'managed_file', + '#title' => $this->t('Upload new media'), + '#process' => array_merge($element_info['#process'], [ + [static::class, 'processUpload'], + ]), + '#element_validate' => [[static::class, 'validateUploadCardinality']], + '#upload_validators' => $upload_validators, + '#multiple' => $cardinality_unlimited || $cardinality_remaining > 1, + '#wrapper_id' => $this->getFieldWrapperId($field_name), + '#cardinality' => $cardinality_remaining, + ]; + $elements['upload_help'] = [ + '#theme' => 'file_upload_help', + '#description' => $this->t('Upload files here to create new media.'), + '#upload_validators' => $upload_validators, + '#cardinality' => $cardinality_remaining, + ]; + // @todo Replace this with a button to open the media library. + $elements['use_existing'] = [ + '#type' => 'entity_autocomplete', + '#title' => $this->t('Use existing media'), + '#target_type' => $this->getFieldSetting('target_type'), + '#selection_handler' => $this->getFieldSetting('handler'), + '#selection_settings' => $handler_settings, + // Entity reference field items are handling validation themselves via + // the 'ValidReference' constraint. + '#validate_reference' => FALSE, + '#size' => 60, + '#placeholder' => $this->t('Search...'), + '#ajax' => [ + 'callback' => [static::class, 'useExistingCallback'], + 'wrapper' => $this->getFieldWrapperId($field_name), + 'event' => 'autocompleteclose', + ], + ]; + } + else { + $elements['upload_help'] = [ + '#markup' => $this->t('The limit of @count media has been reached.', [ + '@count' => $cardinality, + ]), + ]; + } + $elements['selection'] = [ + '#type' => 'textfield', + '#attributes' => [ + 'class' => ['visually-hidden'], + 'data-media-widget-value' => $field_name, + ], + ]; + $elements['update'] = [ + '#type' => 'submit', + '#value' => $this->t('Update widget'), + '#ajax' => [ + 'callback' => [static::class, 'updateWidget'], + 'wrapper' => $this->getFieldWrapperId($field_name), + ], + '#attributes' => [ + 'data-media-widget-update' => $field_name, + 'class' => ['visually-hidden'], + ], + '#submit' => [[static::class, 'updateItems']], + '#limit_validation_errors' => [array_merge($form['#parents'], [$field_name])], + ]; + $elements['table'] = parent::formMultipleElements($items, $form, $form_state); + if (isset($elements['table']['add_more'])) { + unset($elements['table']['add_more']); + } + return $elements; + } + + /** + * {@inheritdoc} + */ + public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) { + $field_name = $this->fieldDefinition->getName(); + + /** @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $items */ + $entities = $items->referencedEntities(); + if (isset($entities[$delta])) { + /** @var \Drupal\media\MediaInterface $media */ + $media = $entities[$delta]; + $element += [ + 'target_id' => [ + '#type' => 'value', + '#value' => $media->id(), + ], + 'thumbnail' => [ + '#theme' => 'image_style', + '#style_name' => $this->getSetting('thumbnail_image_style'), + '#uri' => $media->getSource()->getMetadata($media, 'thumbnail_uri'), + '#access' => !empty($this->getSetting('thumbnail_image_style')), + ], + 'label' => $media->toLink(NULL, 'canonical', [ + 'attributes' => ['target' => '_blank'], + ])->toRenderable(), + 'remove' => [ + '#type' => 'submit', + '#name' => 'media-upload-widget-' . $field_name . '-remove-' . $delta, + '#value' => $this->t('Remove'), + '#delta' => $delta, + '#ajax' => [ + 'callback' => [static::class, 'updateWidget'], + 'wrapper' => $this->getFieldWrapperId($field_name), + ], + '#submit' => [[static::class, 'removeItem']], + '#limit_validation_errors' => [array_merge($form['#parents'], [$field_name])], + ], + ]; + return $element; + } + return []; + } + + /** + * {@inheritdoc} + */ + public static function isApplicable(FieldDefinitionInterface $field_definition) { + if ($field_definition->getSetting('target_type') !== 'media') { + return FALSE; + } + + // If at least one target bundle accepts files, this widget can be used. + $handler_settings = $field_definition->getSetting('handler_settings'); + foreach ($handler_settings['target_bundles'] as $bundle) { + $media_type = MediaType::load($bundle); + if ($media_type) { + $source = $media_type->getSource(); + if (is_a($source, '\Drupal\media\Plugin\media\Source\File')) { + return TRUE; + } + } + } + + return FALSE; + } + + /** + * {@inheritdoc} + */ + public function extractFormValues(FieldItemListInterface $items, array $form, FormStateInterface $form_state) { + $field_name = $this->fieldDefinition->getName(); + + // Extract the values from $form_state->getValues(). + $path = array_merge($form['#parents'], [$field_name, 'table']); + $values = NestedArray::getValue($form_state->getValues(), $path, $key_exists); + $values = $values ?: []; + + usort($values, function ($a, $b) { + return SortArray::sortByKeyInt($a, $b, '_weight'); + }); + + // Let the widget massage the submitted values. + $values = $this->massageFormValues($values, $form, $form_state); + + // Assign the values and remove the empty ones. + $items->setValue($values); + $items->filterEmptyItems(); + } + + /** + * Removes an item from the table. + * + * @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) { + $button = $form_state->getTriggeringElement(); + + $element = NestedArray::getValue($form, array_slice($button['#array_parents'], 0, -3)); + $field_name = $element['#field_name']; + $parents = $element['#field_parents']; + $field_state = static::getWidgetState($parents, $field_name, $form_state); + + $path = array_merge($form['#parents'], [$field_name, 'table']); + $values = NestedArray::getValue($form_state->getValues(), $path); + + if (isset($values[$button['#delta']])) { + unset($values[$button['#delta']]); + } + + $values = array_values($values); + + $field_state['items'] = $values; + $field_state['items_count'] = count($field_state['items']); + static::setWidgetState($parents, $field_name, $form_state, $field_state); + + $form_state->setRebuild(); + } + + /** + * AJAX callback to update the entire form widget. + * + * @param array $form + * The form render array. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * + * @return array + * The element render array. + */ + public static function updateWidget(array &$form, FormStateInterface $form_state) { + $button = $form_state->getTriggeringElement(); + $form_state->setRebuild(); + $depth = -1; + if (strpos($button['#name'], 'remove') !== FALSE) { + $depth = -3; + } + $element = NestedArray::getValue($form, array_slice($button['#array_parents'], 0, $depth)); + return $element; + } + + /** + * Callback for when an Entity Reference autocomplete widget changes. + * + * @todo Remove this when the media library is complete. + * + * @param array $form + * The form render array. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * + * @return \Drupal\Core\Ajax\AjaxResponse + * The form render array. + */ + public static function useExistingCallback(array $form, FormStateInterface $form_state) { + $button = $form_state->getTriggeringElement(); + $form_state->setRebuild(); + $element = NestedArray::getValue($form, array_slice($button['#array_parents'], 0, -1)); + $field_name = $element['#field_name']; + $values = NestedArray::getValue($form_state->getValues(), array_merge($form['#parents'], [$field_name])); + + $response = new AjaxResponse(); + if (!empty($values['use_existing'])) { + // This is the same method the upload modal uses, and the same method the + // media library will eventually use. + $response->addCommand(new AddMediaToWidget([$values['use_existing']], $field_name)); + } + return $response; + } + + /** + * Updates the field state based on the form state and sets the form 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(); + + // Go one level up in the form, to the widgets container. + $element = NestedArray::getValue($form, array_slice($button['#array_parents'], 0, -1)); + $field_name = $element['#field_name']; + $parents = $element['#field_parents']; + $field_state = static::getWidgetState($parents, $field_name, $form_state); + + // Get the new media ids passed to our hidden button. + $values = $form_state->getValues(); + $path = array_merge($form['#parents'], [$field_name]); + $value = NestedArray::getValue($values, $path); + + if (!empty($value['selection'])) { + $ids = explode(',', $value['selection']); + /** @var \Drupal\media\MediaInterface[] $media */ + $media = Media::loadMultiple($ids); + + $field_state['items'] = isset($field_state['items']) ? $field_state['items'] : []; + + // Append the provided media to the existing selection. + foreach ($media as $media_item) { + if ($media && $media_item->access('view')) { + $field_state['items'][] = [ + 'target_id' => $media_item->id(), + ]; + } + } + } + + $field_state['items_count'] = count($field_state['items']); + static::setWidgetState($parents, $field_name, $form_state, $field_state); + + // Discard previously set values in the managed file element. + $input = $form_state->getUserInput(); + NestedArray::unsetValue($input, array_merge($path, ['upload'])); + $form_state->setUserInput($input); + + $form_state->setRebuild(); + } + + /** + * Processes an upload (managed_file) element. + * + * @param array $element + * The upload element. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * @param array $form + * The form render array. + * + * @return array + * The processed upload element. + */ + public static function processUpload(array $element, FormStateInterface $form_state, array $form) { + $element['upload_button']['#ajax'] = [ + 'callback' => [static::class, 'uploadAjaxCallback'], + 'wrapper' => $element['#wrapper_id'], + ]; + // We have our own table and remove button. + $element['remove_button']['#access'] = FALSE; + foreach ($element['#value']['fids'] as $fid) { + if (isset($element['file_' . $fid])) { + $element['file_' . $fid]['#access'] = FALSE; + } + } + return $element; + } + + /** + * Validates the cardinality of the upload element. + * + * @param array $element + * The form element. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + */ + public static function validateUploadCardinality(array $element, FormStateInterface $form_state) { + $is_unlimited = $element['#cardinality'] === FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED; + $over_limit = !empty($element['#value']['fids']) && $element['#cardinality'] - count($element['#value']['fids']) < 0; + if (!$is_unlimited && $over_limit) { + $error = \Drupal::translation()->formatPlural($element['#cardinality'], + 'Only one more file can be uploaded', + 'A maximum of @count files can be uploaded'); + $form_state->setError($element, $error); + } + } + + /** + * Opens the bulk upload modal when files are uploaded. + * + * @param array $form + * The form render array. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * + * @return \Drupal\Core\Ajax\AjaxResponse|array + * An AJAX response to open a modal, or an array if there were errors. + */ + public static function uploadAjaxCallback(array &$form, FormStateInterface $form_state) { + $triggering_element = $form_state->getTriggeringElement(); + $parents = array_slice($triggering_element['#array_parents'], 0, -2); + + $element = NestedArray::getValue($form, $parents); + $fids = $element['upload']['#value']['fids']; + if (empty($form_state->getErrors()) && !empty($fids)) { + $response = new AjaxResponse(); + $command = new OpenMediaUploadModal($fids, $element['#target_bundles'], $element['#field_name'], [ + 'title' => t('Upload new media'), + ]); + $response->addCommand($command); + return $response; + } + else { + $element['upload']['fids'] = []; + return $element; + } + } + + /** + * Returns the widget wrapper ID for AJAX callbacks to target. + * + * @param string $field_name + * The field name. + * + * @return string + * The widget wrapper ID. + */ + protected function getFieldWrapperId($field_name) { + return 'media-upload-widget-' . $field_name; + } + +}