diff --git a/core/lib/Drupal/Core/Ajax/OpenDialogUrlCommand.php b/core/lib/Drupal/Core/Ajax/OpenDialogUrlCommand.php
new file mode 100644
index 0000000000..7e36a33dcd
--- /dev/null
+++ b/core/lib/Drupal/Core/Ajax/OpenDialogUrlCommand.php
@@ -0,0 +1,51 @@
+ '70%',
+ ];
+
+ /**
+ * OpenDialogUrlCommand constructor.
+ *
+ * @param \Drupal\Core\Url|string $url
+ * The URL to open in the modal dialog.
+ * @param array $dialog_options
+ * (optional) Additional client-side options for the modal dialog.
+ */
+ public function __construct($url, array $dialog_options = []) {
+ $this->url = $url instanceof Url ? $url->toString() : $url;
+ if ($dialog_options) {
+ $this->dialogOptions = $dialog_options;
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function render() {
+ return [
+ 'command' => 'openDialogUrl',
+ 'url' => $this->url,
+ 'dialogOptions' => $this->dialogOptions,
+ ];
+ }
+
+}
diff --git a/core/misc/dialog/dialog.ajax.es6.js b/core/misc/dialog/dialog.ajax.es6.js
index 798673a1d8..6c8b44a809 100644
--- a/core/misc/dialog/dialog.ajax.es6.js
+++ b/core/misc/dialog/dialog.ajax.es6.js
@@ -95,6 +95,27 @@
};
/**
+ * Command to open an arbitrary URL in a modal dialog.
+ *
+ * @param {Drupal.Ajax} ajax
+ * The Drupal Ajax object.
+ * @param {object} response
+ * Object holding the server response.
+ * @param {number} [status]
+ * The HTTP status code.
+ */
+ Drupal.AjaxCommands.prototype.openDialogUrl = function (ajax, response, status) {
+ new Drupal.Ajax(null, null, {
+ url: response.url,
+ dialogType: 'modal',
+ dialog: response.dialogOptions,
+ progress: {
+ type: 'throbber'
+ }
+ }).execute();
+ };
+
+ /**
* Command to open a dialog.
*
* @param {Drupal.Ajax} ajax
diff --git a/core/misc/dialog/dialog.ajax.js b/core/misc/dialog/dialog.ajax.js
index 8277df3e51..15e11f39c4 100644
--- a/core/misc/dialog/dialog.ajax.js
+++ b/core/misc/dialog/dialog.ajax.js
@@ -59,6 +59,17 @@
}
};
+ Drupal.AjaxCommands.prototype.openDialogUrl = function (ajax, response, status) {
+ new Drupal.Ajax(null, null, {
+ url: response.url,
+ dialogType: 'modal',
+ dialog: response.dialogOptions,
+ progress: {
+ type: 'throbber'
+ }
+ }).execute();
+ };
+
Drupal.AjaxCommands.prototype.openDialog = function (ajax, response, status) {
if (!response.selector) {
return false;
diff --git a/core/modules/media/config/install/core.entity_form_display.media.file.add_file.yml b/core/modules/media/config/install/core.entity_form_display.media.file.add_file.yml
new file mode 100644
index 0000000000..e2ed5cefbd
--- /dev/null
+++ b/core/modules/media/config/install/core.entity_form_display.media.file.add_file.yml
@@ -0,0 +1,34 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - core.entity_form_mode.media.add_file
+ - field.field.media.file.field_media_file
+ - media.type.file
+ module:
+ - file
+id: media.file.add_file
+targetEntityType: media
+bundle: file
+mode: add_file
+content:
+ field_media_file:
+ settings:
+ progress_indicator: throbber
+ third_party_settings: { }
+ type: file_generic
+ weight: 1
+ region: content
+ name:
+ type: string_textfield
+ weight: 0
+ region: content
+ settings:
+ size: 60
+ placeholder: ''
+ third_party_settings: { }
+hidden:
+ created: true
+ path: true
+ status: true
+ uid: true
diff --git a/core/modules/media/config/install/core.entity_form_display.media.image.add_file.yml b/core/modules/media/config/install/core.entity_form_display.media.image.add_file.yml
new file mode 100644
index 0000000000..75be602501
--- /dev/null
+++ b/core/modules/media/config/install/core.entity_form_display.media.image.add_file.yml
@@ -0,0 +1,36 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - core.entity_form_mode.media.add_file
+ - field.field.media.image.field_media_image
+ - image.style.thumbnail
+ - media.type.image
+ module:
+ - image
+id: media.image.add_file
+targetEntityType: media
+bundle: image
+mode: add_file
+content:
+ field_media_image:
+ settings:
+ progress_indicator: throbber
+ preview_image_style: thumbnail
+ third_party_settings: { }
+ type: image_image
+ weight: 1
+ region: content
+ name:
+ type: string_textfield
+ weight: 0
+ region: content
+ settings:
+ size: 60
+ placeholder: ''
+ third_party_settings: { }
+hidden:
+ created: true
+ path: true
+ status: true
+ uid: true
diff --git a/core/modules/media/config/install/core.entity_form_mode.media.add_file.yml b/core/modules/media/config/install/core.entity_form_mode.media.add_file.yml
new file mode 100644
index 0000000000..ff7eccdeb5
--- /dev/null
+++ b/core/modules/media/config/install/core.entity_form_mode.media.add_file.yml
@@ -0,0 +1,9 @@
+langcode: en
+status: true
+dependencies:
+ module:
+ - media
+id: media.add_file
+label: 'File Upload'
+targetEntityType: media
+cache: true
diff --git a/core/modules/media/config/schema/media.schema.yml b/core/modules/media/config/schema/media.schema.yml
index dad518985a..1176376da8 100644
--- a/core/modules/media/config/schema/media.schema.yml
+++ b/core/modules/media/config/schema/media.schema.yml
@@ -40,6 +40,14 @@ field.formatter.settings.media_thumbnail:
type: field.formatter.settings.image
label: 'Media thumbnail field display format settings'
+field.widget.settings.media_file:
+ type: mapping
+ label: 'File widget settings'
+ mapping:
+ open:
+ type: boolean
+ label: 'Open by default'
+
media.source.*:
type: mapping
label: 'Media source settings'
diff --git a/core/modules/media/js/ajax.js b/core/modules/media/js/ajax.js
new file mode 100644
index 0000000000..6313650390
--- /dev/null
+++ b/core/modules/media/js/ajax.js
@@ -0,0 +1,32 @@
+(function ($, Drupal) {
+
+ 'use strict';
+
+ /**
+ * JavaScript handler for opening of modal add media form.
+ *
+ * TODO: es6.js!!!
+ *
+ * @param ajax
+ * @param response
+ * @param status
+ */
+ Drupal.AjaxCommands.prototype.openModalMediaForm = function (ajax, response, status) {
+ var openDialogAjax = new Drupal.Ajax(null, null, {
+ url: '/media/add-modal',
+ dialogType: 'modal',
+ submit: {
+ file_ids: response.file_ids,
+ field_widget_id: response.field_widget_id,
+ dialogOptions: {
+ width: 800
+ }
+ },
+ progress: {
+ type: 'throbber'
+ }
+ });
+
+ openDialogAjax.execute();
+ };
+})(jQuery, Drupal);
diff --git a/core/modules/media/media.libraries.yml b/core/modules/media/media.libraries.yml
index 72496a3233..297f7b27ca 100644
--- a/core/modules/media/media.libraries.yml
+++ b/core/modules/media/media.libraries.yml
@@ -11,3 +11,8 @@ type_form:
js/type_form.js: {}
dependencies:
- core/drupal.form
+
+open_modal_media_form:
+ version: VERSION
+ js:
+ js/ajax.js: {}
diff --git a/core/modules/media/media.module b/core/modules/media/media.module
index dc2d898e3c..744ffdd74d 100644
--- a/core/modules/media/media.module
+++ b/core/modules/media/media.module
@@ -6,12 +6,15 @@
*/
use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\field\FieldConfigInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Render\Element;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Url;
+use Drupal\file\FileInterface;
+use Drupal\media\Form\AddFileForm;
/**
* Implements hook_help().
@@ -96,3 +99,43 @@ function template_preprocess_media(array &$variables) {
$variables['content'][$key] = $variables['elements'][$key];
}
}
+
+/**
+ * Validate uploaded file for media field.
+ *
+ * @param \Drupal\file\FileInterface $file
+ * File that should be validated.
+ *
+ * @return array
+ * Result of validation.
+ */
+function media_validate_file_upload(FileInterface $file) {
+ $extension = pathinfo($file->getFilename(), PATHINFO_EXTENSION);
+
+ $availableMediaTypes = \Drupal::service('media_type.manager')
+ ->getTypesByExtension($extension);
+
+ if (empty($availableMediaTypes)) {
+ return [
+ t('%filename could not be matched to any media types.', [
+ '%filename' => $file->getFilename(),
+ ]),
+ ];
+ }
+
+ return [];
+}
+
+/**
+ * Implements hook_field_widget_WIDGET_TYPE_form_alter().
+ */
+function media_field_widget_file_generic_form_alter(array &$element, FormStateInterface $form_state, array $context) {
+ AddFileForm::alterFileWidget($element, $form_state);
+}
+
+/**
+ * Implements hook_field_widget_WIDGET_TYPE_form_alter().
+ */
+function media_field_widget_image_image_form_alter(array &$element, FormStateInterface $form_state, array $context) {
+ AddFileForm::alterFileWidget($element, $form_state);
+}
diff --git a/core/modules/media/media.routing.yml b/core/modules/media/media.routing.yml
index 9fbadeff29..e47a746ec0 100644
--- a/core/modules/media/media.routing.yml
+++ b/core/modules/media/media.routing.yml
@@ -19,3 +19,23 @@ entity.media.revision:
requirements:
_access_media_revision: 'view'
media: \d+
+
+entity.media.add_modal:
+ path: '/media/add-modal'
+ defaults:
+ _form: '\Drupal\media\Form\MediaModalForm'
+ requirements:
+ _entity_create_access: media
+
+entity.media.add_file:
+ path: '/media/add-file/{media_type}/{file}/{selector}'
+ defaults:
+ _entity_form: 'media.add_file'
+ options:
+ parameters:
+ media_type:
+ type: 'entity:media_type'
+ file:
+ type: 'entity:file'
+ requirements:
+ _entity_create_access: 'media'
diff --git a/core/modules/media/media.services.yml b/core/modules/media/media.services.yml
index f22f90a124..7896a10ce1 100644
--- a/core/modules/media/media.services.yml
+++ b/core/modules/media/media.services.yml
@@ -8,3 +8,8 @@ services:
arguments: ['@entity_type.manager']
tags:
- { name: access_check, applies_to: _access_media_revision }
+
+ media.type_guesser.file:
+ class: Drupal\media\FileGuesser
+ # TODO: Add a cache backend.
+ arguments: ['@entity_type.manager']
diff --git a/core/modules/media/src/Ajax/OpenModalMediaFormCommand.php b/core/modules/media/src/Ajax/OpenModalMediaFormCommand.php
new file mode 100644
index 0000000000..5106b6eaf9
--- /dev/null
+++ b/core/modules/media/src/Ajax/OpenModalMediaFormCommand.php
@@ -0,0 +1,50 @@
+fileIds = $fileIds;
+ $this->widgetId = $widgetId;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function render() {
+ return [
+ 'command' => 'openModalMediaForm',
+ 'file_ids' => $this->fileIds,
+ 'field_widget_id' => $this->widgetId,
+ ];
+ }
+
+}
diff --git a/core/modules/media/src/Entity/Media.php b/core/modules/media/src/Entity/Media.php
index d175572450..4217438b76 100644
--- a/core/modules/media/src/Entity/Media.php
+++ b/core/modules/media/src/Entity/Media.php
@@ -38,6 +38,7 @@
* "add" = "Drupal\media\MediaForm",
* "edit" = "Drupal\media\MediaForm",
* "delete" = "Drupal\Core\Entity\ContentEntityDeleteForm",
+ * "add_file" = "Drupal\media\Form\AddFileForm",
* },
* "translation" = "Drupal\content_translation\ContentTranslationHandler",
* "views_data" = "Drupal\media\MediaViewsData",
diff --git a/core/modules/media/src/FileGuesser.php b/core/modules/media/src/FileGuesser.php
new file mode 100644
index 0000000000..fd855f6d08
--- /dev/null
+++ b/core/modules/media/src/FileGuesser.php
@@ -0,0 +1,188 @@
+entityTypeManager = $entity_type_manager;
+ $this->cacheBackend = $cache_backend;
+ $this->useCaches = isset($cache_backend);
+ }
+
+ /**
+ * Returns the first media type ID that matches a file.
+ *
+ * @param \Drupal\file\FileInterface|int $file
+ * A file entity or ID.
+ * @param string[] $limit
+ * (optional) The media type IDs to choose from. If omitted, all media types
+ * will be considered.
+ *
+ * @return string|bool
+ * A media type ID, or FALSE if no media types matched.
+ */
+ public function guessForFile($file, array $limit = []) {
+ $media_types = $this->getTypesForFile($file, $limit);
+ return key($media_types);
+ }
+
+ /**
+ * Returns the first media type ID that matches a file extension.
+ *
+ * @param string $extension
+ * A file extension, without the leading period.
+ * @param string[] $limit
+ * (optional) The media type IDs to choose from. If omitted, all media types
+ * will be considered.
+ *
+ * @return string|bool
+ * A media type ID, or FALSE if no media types matched.
+ */
+ public function guessForExtension($extension, array $limit = []) {
+ $media_types = $this->getTypesByExtension($extension, $limit);
+ return key($media_types);
+ }
+
+ /**
+ * Returns all media types that match a file.
+ *
+ * @param \Drupal\file\FileInterface|int $file
+ * A file entity or ID.
+ * @param string[] $limit
+ * (optional) The media type IDs to choose from. If omitted, all media types
+ * will be considered.
+ *
+ * @throws \InvalidArgumentException if $file is not a file entity, or the ID
+ * of one.
+ *
+ * @return \Drupal\media\MediaTypeInterface[]
+ * All matching media types.
+ */
+ public function getTypesForFile($file, array $limit = []) {
+ if (is_numeric($file)) {
+ $file = $this->entityTypeManager->getStorage('file')->load($file);
+ }
+
+ if ($file instanceof FileInterface) {
+ $extension = pathinfo($file->getFilename(), PATHINFO_EXTENSION);
+
+ return $this->getTypesByExtension($extension, $limit);
+ }
+ else {
+ throw new \InvalidArgumentException('Expected file ID or entity');
+ }
+ }
+
+ /**
+ * Returns all media types that match a file extension.
+ *
+ * @param string $extension
+ * A file extension, without the leading period.
+ * @param string[] $limit
+ * (optional) The media type IDs to choose from. If omitted, all media types
+ * will be considered.
+ *
+ * @return \Drupal\media\MediaTypeInterface[]
+ * All matching media types.
+ */
+ public function getTypesByExtension($extension, array $limit = []) {
+ $cache = $this->cacheGet($extension);
+ if ($cache) {
+ return $cache->data;
+ }
+
+ $media_types = $this->getAllowedMediaTypes();
+ if ($limit) {
+ $media_types = array_intersect_key($media_types, array_flip($limit));
+ }
+
+ $filter = function (MediaTypeInterface $media_type) {
+ $item_class = $media_type
+ ->getSource()
+ ->getSourceFieldDefinition($media_type)
+ ->getItemDefinition()
+ ->getClass();
+
+ return is_a($item_class, FileItem::class, TRUE);
+ };
+ $media_types = array_filter($media_types, $filter);
+
+ $extension = Unicode::strtolower($extension);
+
+ $matches = [];
+ /** @var \Drupal\media\MediaTypeInterface $media_type */
+ foreach ($media_types as $id => $media_type) {
+ $extensions = $media_type
+ ->getSource()
+ ->getSourceFieldDefinition($media_type)
+ ->getSetting('file_extensions');
+
+ $allowed_extensions = preg_split('/\s+/', Unicode::strtolower($extensions));
+
+ if (in_array($extension, $allowed_extensions)) {
+ $matches[$id] = $media_type->label();
+ }
+ }
+ $this->cacheSet($extension, $matches);
+
+ return $matches;
+ }
+
+ /**
+ * Returns all media types that can be created by the current user.
+ *
+ * @return \Drupal\media\MediaTypeInterface[]
+ */
+ protected function getAllowedMediaTypes() {
+ if (isset($this->allowedTypes)) {
+ return $this->allowedTypes;
+ }
+
+ $storage = $this->entityTypeManager->getStorage('media_type');
+
+ $media_types = array_filter(
+ $storage->getQuery()->execute(),
+ [
+ $this->entityTypeManager->getAccessControlHandler('media'),
+ 'createAccess'
+ ]
+ );
+ $this->allowedTypes = $storage->loadMultiple($media_types);
+
+ return $this->allowedTypes;
+ }
+
+}
diff --git a/core/modules/media/src/Form/AddFileForm.php b/core/modules/media/src/Form/AddFileForm.php
new file mode 100644
index 0000000000..921b32fb5b
--- /dev/null
+++ b/core/modules/media/src/Form/AddFileForm.php
@@ -0,0 +1,184 @@
+hasValue('bundle')) {
+ $entity = $this->entityTypeManager
+ ->getStorage('media')
+ ->create([
+ 'bundle' => $form_state->getValue('bundle'),
+ ]);
+
+ $this->setEntity($entity);
+ }
+ return parent::init($form_state);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function prepareEntity() {
+ parent::prepareEntity();
+
+ /** @var \Drupal\media\MediaInterface $entity */
+ $entity = $this->getEntity();
+
+ $source_field = $entity->getSource()
+ ->getSourceFieldDefinition($entity->bundle->entity)
+ ->getName();
+
+ /** @var \Drupal\file\FileInterface $file */
+ $file = $this->getRouteMatch()->getParameter('file');
+ $entity->set($source_field, $file);
+
+ // Move the file to the location specified by the source field.
+ /** @var \Drupal\file\Plugin\Field\FieldType\FileItem $item */
+ $item = $entity->get($source_field)->first();
+ $destination = $item->getUploadLocation();
+ file_prepare_directory($destination, FILE_CREATE_DIRECTORY | FILE_MODIFY_PERMISSIONS);
+ $destination .= '/' . $file->getFilename();
+ if (!file_exists($destination)) {
+ // TODO: Use an injected file_system service for this.
+ $destination = file_unmanaged_move($file->getFileUri(), $destination);
+ }
+ $file->setFileUri($destination);
+ $file->save();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function validateForm(array &$form, FormStateInterface $form_state) {
+ $entity = parent::validateForm($form, $form_state);
+
+ // If the bundle has changed, reinitialize the entity form. This will call
+ // init(), which will replace the current entity with a new one of the
+ // selected type.
+ $chosen_bundle = $form_state->getValue('bundle');
+ if ($chosen_bundle && $chosen_bundle !== $this->getEntity()->bundle()) {
+ $storage = &$form_state->getStorage();
+ unset($storage['entity_form_initialized']);
+ }
+
+ return $entity;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function form(array $form, FormStateInterface $form_state) {
+ $form['fields'] = parent::form($form, $form_state);
+ $form['fields']['#prefix'] = '
';
+ $form['fields']['#suffix'] = '
';
+
+ $form['bundle'] = [
+ '#type' => 'select',
+ '#title' => $this->t('Media Type'),
+ '#required' => TRUE,
+ '#default_value' => $this->getEntity()->bundle(),
+ '#weight' => -100,
+ '#ajax' => [
+ 'wrapper' => 'entity-fields',
+ 'callback' => [static::class, 'onTypeSelect'],
+ ],
+ '#limit_validation_errors' => [
+ ['bundle'],
+ ],
+ '#submit' => [],
+ ];
+ foreach ($this->entityTypeManager->getStorage('media_type')->loadMultiple() as $id => $media_type) {
+ $form['bundle']['#options'][$id] = $media_type->label();
+ }
+
+ return $form;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function actions(array $form, FormStateInterface $form_state) {
+ $actions = parent::actions($form, $form_state);
+
+ $actions['submit']['#ajax'] = [
+ 'callback' => '::onSave',
+ ];
+
+ return $actions;
+ }
+
+ public function onSave(array &$form, FormStateInterface $form_state) {
+ $response = new AjaxResponse();
+
+ if ($form_state->getErrors()) {
+ return $response->addCommand(
+ new BeforeCommand('#entity-fields', ['#type' => 'status_messages'])
+ );
+ }
+
+ $route_match = $this->getRouteMatch();
+ $parameters = $route_match->getRawParameters()->all();
+ $query = $this->getRequest()->query;
+
+ $media_items = $query->get('media', []);
+ array_push($media_items, $this->getEntity()->id());
+
+ if ($query->has('files')) {
+ $query = [
+ 'files' => $query->get('files'),
+ 'media' => $media_items,
+ ];
+ $next_file_id = array_shift($query['files']);
+ $parameters['file'] = $next_file_id;
+ // TODO: Inject the media.type_guesser.file service.
+ $parameters['media_type'] = \Drupal::service('media.type_guesser.file')->guessForFile($next_file_id);
+
+ $url = Url::fromRoute($route_match->getRouteName(), $parameters, [
+ 'query' => $query,
+ ]);
+ $response->addCommand(new OpenDialogUrlCommand($url));
+ }
+ else {
+ $response
+ ->addCommand(new InvokeCommand(
+ 'input[data-drupal-selector="' . $parameters['selector'] . '"]',
+ 'val',
+ (array) implode(',', array_map('intval', $media_items))
+ ))
+ ->addCommand(new CloseModalDialogCommand());
+ }
+ return $response;
+ }
+
+ public static function alterFileWidget(array &$element, FormStateInterface $form_state) {
+ $self = static::class;
+
+ if ($form_state->getFormObject() instanceof $self) {
+ $element['#process'][] = [$self, 'hideRemoveButton'];
+ }
+ }
+
+ public static function hideRemoveButton(array $element) {
+ $element['remove_button']['#access'] = FALSE;
+ return $element;
+ }
+
+}
diff --git a/core/modules/media/src/Form/MediaModalForm.php b/core/modules/media/src/Form/MediaModalForm.php
new file mode 100644
index 0000000000..b83397ed59
--- /dev/null
+++ b/core/modules/media/src/Form/MediaModalForm.php
@@ -0,0 +1,580 @@
+renderer = $renderer;
+ $this->elementInfo = $element_info;
+ $this->formValidator = $form_validator;
+ $this->formSubmitter = $form_submitter;
+ $this->entityTypeManager = $entity_type_manager;
+ $this->mediaTypeManager = $media_type_manager;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container) {
+ return new static(
+ $container->get('renderer'),
+ $container->get('element_info'),
+ $container->get('form_validator'),
+ $container->get('form_submitter'),
+ $container->get('entity_type.manager'),
+ $container->get('media_type.manager')
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getFormId() {
+ return 'media_modal_form';
+ }
+
+ /**
+ * Init form state, it's executed for first time when dialog is open.
+ *
+ * @param \Drupal\Core\Form\FormStateInterface $form_state
+ * Form state.
+ */
+ protected function initFormState(FormStateInterface $form_state) {
+ // Get data provided by OpenModalMediaFormCommand.
+ $form_state->set('queued_fids', $form_state->getUserInput()['file_ids']);
+ $form_state->set('field_widget_id', $form_state->getUserInput()['field_widget_id']);
+
+ // Init clean state of processed media IDs.
+ $form_state->set('processed_media_ids', []);
+
+ $form_state->setCached(TRUE);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function buildForm(array $form, FormStateInterface $form_state) {
+ // Initialize form state if there are no queued files.
+ if (empty($form_state->get('queued_fids'))) {
+ $this->initFormState($form_state);
+ }
+
+ // Get file IDs that should be processed.
+ $file_ids = $form_state->get('queued_fids');
+
+ // Get list of available media types for file.
+ $file = $this->getFileById($file_ids[0]);
+ $list_of_media_types = $this->getMediaTypeList($file_ids[0]);
+
+ // Get selected media type.
+ $trigger_element = $form_state->getTriggeringElement();
+ if (!empty($trigger_element) && $trigger_element['#name'] === 'media_type') {
+ $selected_media_type = $trigger_element['#value'];
+ }
+ else {
+ reset($list_of_media_types);
+ $selected_media_type = key($list_of_media_types);
+ }
+
+ // Create media entity from file.
+ $media = $this->createMediaFromFile($selected_media_type, $file);
+
+ // Generate form.
+ $form['#prefix'] = '';
+ $form['#suffix'] = '
';
+
+ $form['status_messages'] = $this->getStatusMessagesElement($form_state);
+
+ $form['media_type'] = $this->getTypeSelectElement($list_of_media_types);
+
+ // Prepare inner media form.
+ $form['#process'] = $this->elementInfo->getInfoProperty('form', '#process', []);
+ $form['#process'][] = '::processForm';
+ $form[static::INNER_FORM_ID] = $this->getInnerMediaForm($form_state, $media);
+
+ // Create action buttons for modal dialog.
+ $form['actions'] = $this->getActionsElement();
+
+ return $form;
+ }
+
+ /**
+ * Creates build array for media type selector.
+ *
+ * Prefix is required in order to properly replace element over AJAX.
+ *
+ * @param array $list_of_media_types
+ * List of media types that should be displayed in select field.
+ *
+ * @return array
+ * Returns build array for media type selector.
+ */
+ protected function getTypeSelectElement(array $list_of_media_types) {
+ return [
+ '#id' => 'media-modal-form-media-type',
+ '#prefix' => '',
+ '#suffix' => '
',
+ '#type' => 'select',
+ '#title' => $this->t('Media type'),
+ '#options' => $list_of_media_types,
+ '#ajax' => [
+ 'callback' => '::ajaxChangeType',
+ 'wrapper' => 'media-modal-form',
+ ],
+ ];
+ }
+
+ /**
+ * Get build array for actions element.
+ *
+ * @return array
+ * Returns actions build array.
+ */
+ protected function getActionsElement() {
+ $actionsElement = [
+ '#type' => 'actions',
+ ];
+
+ $actionsElement['submit_form'] = [
+ '#type' => 'submit',
+ '#name' => 'media-modal-form-submit',
+ '#value' => $this->t('Save'),
+ '#validate' => ['::validateForm'],
+ '#submit' => ['::submitForm'],
+ '#ajax' => [
+ 'callback' => '::ajaxSubmitForm',
+ 'wrapper' => 'media-modal-form',
+ 'event' => 'click',
+ ],
+ ];
+
+ return $actionsElement;
+ }
+
+ /**
+ * Get status messages element.
+ *
+ * @param \Drupal\Core\Form\FormStateInterface $form_state
+ * Form state.
+ *
+ * @return array
+ * Returns status messages element.
+ */
+ protected function getStatusMessagesElement(FormStateInterface $form_state) {
+ $status_messages_element = [
+ '#prefix' => '',
+ '#suffix' => '
',
+ ];
+
+ if ($form_state->getErrors()) {
+ $status_messages_element['#type'] = 'status_messages';
+ }
+
+ return $status_messages_element;
+ }
+
+ /**
+ * Generate inner media form for provided media entity.
+ *
+ * @param \Drupal\Core\Form\FormStateInterface $form_state
+ * Form state.
+ * @param \Drupal\Core\Entity\EntityInterface $media
+ * Media entity that will be edited in inner form.
+ *
+ * @return array
+ * Returns render array for inner media form.
+ */
+ protected function getInnerMediaForm(FormStateInterface $form_state, EntityInterface $media) {
+ $inner_form_object = \Drupal::entityTypeManager()
+ ->getFormObject('media', 'edit')
+ ->setEntity($media);
+
+ $form_state->set(static::INNER_FORM_OBJECT_KEY, $inner_form_object);
+
+ $inner_form_state = static::createInnerFormState($form_state, $inner_form_object);
+
+ $inner_form = ['#parents' => [static::INNER_FORM_ID]];
+ $inner_form = $inner_form_object->buildForm($inner_form, $inner_form_state);
+
+ $inner_form['#prefix'] = '';
+ $inner_form['#suffix'] = '
';
+
+ $inner_form['#type'] = 'container';
+ $inner_form['#theme_wrappers'] = $this->elementInfo->getInfoProperty('container', '#theme_wrappers', []);
+ unset($inner_form['form_token']);
+
+ if (!empty($inner_form['#process'])) {
+ $inner_form_state->set('#process', $inner_form['#process']);
+ unset($inner_form['#process']);
+ }
+ else {
+ $inner_form_state->set('#process', []);
+ }
+
+ // Remove actions from subform, because wrapping form will handle it.
+ if (!empty($inner_form['actions'])) {
+ if (isset($inner_form['actions']['submit'])) {
+ $inner_form['#submit'] = $inner_form['actions']['submit']['#submit'];
+ }
+ unset($inner_form['actions']);
+ }
+ $inner_form['#element_validate'][] = '::validateForm';
+
+ return $inner_form;
+ }
+
+ /**
+ * Process inner form.
+ *
+ * @param array $form
+ * Form array.
+ * @param \Drupal\Core\Form\FormStateInterface $form_state
+ * Form state.
+ * @param array $complete_form
+ * Complete form.
+ *
+ * @return array
+ * Returns processed form.
+ */
+ public function processForm(array &$form, FormStateInterface &$form_state, array &$complete_form) {
+ $inner_form_state = static::getInnerFormState($form_state);
+
+ foreach ($inner_form_state->get('#process') as $callback) {
+ $form[static::INNER_FORM_ID] = call_user_func_array($inner_form_state->prepareCallback($callback), array(
+ &$form[static::INNER_FORM_ID],
+ &$inner_form_state,
+ &$complete_form,
+ ));
+ }
+
+ return $form;
+ }
+
+ /**
+ * Creates new inner form state.
+ *
+ * @param \Drupal\Core\Form\FormStateInterface $form_state
+ * Form state for outer form.
+ * @param \Drupal\Core\Form\FormInterface $inner_form_object
+ * Inner form object.
+ *
+ * @return \Drupal\Core\Form\FormState
+ * Returns created inner form state.
+ */
+ protected static function createInnerFormState(FormStateInterface $form_state, FormInterface $inner_form_object) {
+ $inner_form_state = new FormState();
+ $inner_form_state->setFormObject($inner_form_object);
+ $form_state->set([
+ static::INNER_FORM_STATE_KEY,
+ static::INNER_FORM_ID,
+ ], $inner_form_state);
+
+ return $inner_form_state;
+ }
+
+ /**
+ * Get inner form state.
+ *
+ * @param \Drupal\Core\Form\FormStateInterface $form_state
+ * Outer form state.
+ *
+ * @return \Drupal\Core\Form\FormStateInterface
+ * Returns inner form state.
+ */
+ protected static function getInnerFormState(FormStateInterface $form_state) {
+ /** @var \Drupal\Core\Form\FormStateInterface $inner_form_state */
+ $inner_form_state = $form_state->get([
+ static::INNER_FORM_STATE_KEY,
+ static::INNER_FORM_ID,
+ ]);
+
+ $inner_form_state->setCompleteForm($form_state->getCompleteForm());
+ $inner_form_state->setValues($form_state->getValues() ?: []);
+ $inner_form_state->setUserInput($form_state->getUserInput() ?: []);
+
+ return $inner_form_state;
+ }
+
+ /**
+ * Create media entity for file.
+ *
+ * @param string $type
+ * Media type.
+ * @param \Drupal\Core\Entity\EntityInterface $file
+ * File entity that will be used to create media entity.
+ *
+ * @return \Drupal\Core\Entity\EntityInterface
+ * Returns media entity.
+ */
+ protected function createMediaFromFile($type, EntityInterface $file) {
+ $media_item = Media::create([
+ 'bundle' => $type,
+ ]);
+
+ $configuration = $media_item->getSource()->getConfiguration();
+ $media_item->set($configuration['source_field'], $file);
+
+ return $media_item;
+ }
+
+ /**
+ * Get file entity for file ID.
+ *
+ * @param string $file_id
+ * File ID.
+ *
+ * @return \Drupal\Core\Entity\EntityInterface
+ * Returns file entity.
+ */
+ protected function getFileById($file_id) {
+ /** @var \Drupal\file\FileStorage $file_storage */
+ $file_storage = \Drupal::service('entity_type.manager')->getStorage('file');
+
+ return $file_storage->load($file_id);
+ }
+
+ /**
+ * Get media type list.
+ *
+ * @param string $file_id
+ * File ID.
+ *
+ * @return array
+ * Returns available list of media types for file extension.
+ */
+ protected function getMediaTypeList($file_id) {
+ /** @var \Drupal\file\FileInterface $file */
+ $file = $this->getFileById($file_id);
+
+ $extension = pathinfo($file->getFilename(), PATHINFO_EXTENSION);
+
+ return $this->mediaTypeManager->getTypesByExtension($extension);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function validateForm(array &$form, FormStateInterface $form_state) {
+ // Validate form only if submit button triggers AJAX request.
+ if ($form_state->getTriggeringElement()['#name'] === 'media-modal-form-submit') {
+ $inner_form_object = $form_state->get(static::INNER_FORM_OBJECT_KEY);
+ $inner_form_state = static::getInnerFormState($form_state);
+
+ // Clear status of inner form state.
+ $form_state->clearErrors();
+ $inner_form_state->clearErrors();
+ $inner_form_state->setValidationComplete(FALSE);
+
+ // Pass through both the form elements validation and the form object
+ // validation.
+ $inner_form_id = $inner_form_object->getFormId();
+ $this->invalidateInnerForm($form[static::INNER_FORM_ID]);
+ $inner_form_state->setUserInput($form_state->getUserInput()[static::INNER_FORM_ID]);
+ $inner_form_state->setValues($form_state->getValue(static::INNER_FORM_ID));
+ $this->formValidator->validateForm($inner_form_id, $form[static::INNER_FORM_ID], $inner_form_state);
+
+ foreach ($inner_form_state->getErrors() as $error_element_path => $error) {
+ // TODO: should be improved for inline error message to work.
+ $form_state->setErrorByName(static::INNER_FORM_ID . '][' . $error_element_path, $error);
+ }
+ }
+ }
+
+ protected function invalidateInnerForm(array &$elements) {
+ unset($elements['#validated']);
+
+ foreach (Element::children($elements) as $key) {
+ if (is_array($elements[$key])) {
+ $this->invalidateInnerForm($elements[$key]);
+ }
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function submitForm(array &$form, FormStateInterface $form_state) {
+ $inner_form_state = static::getInnerFormState($form_state);
+ $inner_form_state->setSubmitted();
+ $this->formSubmitter->doSubmitForm($form[static::INNER_FORM_ID], $inner_form_state);
+
+ $file_ids = $form_state->get('queued_fids');
+ array_shift($file_ids);
+ $form_state->set('queued_fids', $file_ids);
+
+ $processed_ids = $form_state->get('processed_media_ids');
+ $processed_ids[] = $form_state->get(static::INNER_FORM_OBJECT_KEY)
+ ->getEntity()
+ ->id();
+ $form_state->set('processed_media_ids', $processed_ids);
+
+ if (!empty($file_ids)) {
+ $form_state->setRebuild();
+ }
+ }
+
+ /**
+ * AJAX response after submitting of form.
+ *
+ * It can go in two directions:
+ * - when last file is processed dialog will be closed and created media IDs
+ * passed to caller component.
+ * - if there are more queued files, next file will be displayed in form.
+ *
+ * @param array $form
+ * Form array.
+ * @param \Drupal\Core\Form\FormStateInterface $form_state
+ * Form state.
+ *
+ * @return \Drupal\Core\Ajax\AjaxResponse
+ * Returns AJAX response.
+ */
+ public function ajaxSubmitForm(array &$form, FormStateInterface $form_state) {
+ $fileIds = $form_state->get('queued_fids');
+
+ // All files are processed, close dialog and send information to caller.
+ if (empty($fileIds)) {
+ $response = new AjaxResponse();
+
+ // TODO: Add exeuction of JS Command that will send processed media IDs to
+ // widget.
+ $response->addCommand(new CloseModalDialogCommand());
+ }
+ else {
+ $response = $this->ajaxChangeType($form, $form_state);
+
+ $selection_button_html = trim($this->renderer->renderRoot($form['media_type']));
+ $response->addCommand(new ReplaceCommand('#media-modal-form-media-type-wrapper', $selection_button_html));
+ $response->addCommand(new ReplaceCommand('#media-modal-form-status-messages', $this->getStatusMessagesElement($form_state)));
+ }
+
+ return $response;
+ }
+
+ /**
+ * AJAX response after changing media type for file.
+ *
+ * @param array $form
+ * Form array.
+ * @param \Drupal\Core\Form\FormStateInterface $form_state
+ * Form state.
+ *
+ * @return \Drupal\Core\Ajax\AjaxResponse
+ * Returns AJAX response with rebuilt type selector and inner form.
+ */
+ public function ajaxChangeType(array &$form, FormStateInterface $form_state) {
+ $response = new AjaxResponse();
+
+ $media_form_html = trim($this->renderer->renderRoot($form[static::INNER_FORM_ID]));
+ $response->addCommand(new ReplaceCommand('#media-modal-form-inner-form', $media_form_html));
+
+ return $response;
+ }
+
+}
diff --git a/core/modules/media/src/Plugin/Field/FieldWidget/MediaFileWidget.php b/core/modules/media/src/Plugin/Field/FieldWidget/MediaFileWidget.php
new file mode 100644
index 0000000000..2b3e8f84da
--- /dev/null
+++ b/core/modules/media/src/Plugin/Field/FieldWidget/MediaFileWidget.php
@@ -0,0 +1,133 @@
+ TRUE,
+ ];
+ return $settings + parent::defaultSettings();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function isApplicable(FieldDefinitionInterface $field_definition) {
+ if ($field_definition->getType() == 'entity_reference') {
+ return $field_definition->getFieldStorageDefinition()->getSetting('target_type') == 'media';
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) {
+ $field_definition = $items->getFieldDefinition();
+ $field_storage_definition = $field_definition->getFieldStorageDefinition();
+
+ $element = [
+ '#type' => 'details',
+ '#title' => $field_definition->getLabel(),
+ '#attributes' => [
+ 'title' => $field_definition->getLabel(),
+ ],
+ '#attached' => [
+ 'library' => [
+ 'core/drupal.dialog.ajax',
+ ],
+ ],
+ '#open' => $this->getSetting('open'),
+ ];
+
+ $cardinality = $field_storage_definition->getCardinality();
+
+ $element['file'] = [
+ '#type' => 'managed_file',
+ '#multiple' => $cardinality !== $field_storage_definition::CARDINALITY_UNLIMITED,
+ // Hide the file element if the field is at capacity.
+ '#access' => $items->isEmpty() || ($cardinality > 0 && count($items) < $cardinality),
+ '#upload_validators' => [
+ 'media_validate_file_upload' => [],
+ ],
+ ];
+ // TODO: Inject element_info service.
+ $element_info = \Drupal::service('element_info')->getInfo('managed_file');
+ $element['file']['#process'] = $element_info['#process'];
+ $element['file']['#process'][] = [static::class, 'processFileElement'];
+
+ $element['media_items']['#type'] = 'hidden';
+
+ return $element;
+ }
+
+ public static function processFileElement(array $element) {
+ $element['upload_button']['#ajax']['callback'][0] = static::class;
+ return $element;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function massageFormValues(array $values, array $form, FormStateInterface $form_state) {
+ return array_map(
+ function ($media_id) {
+ return [
+ 'target_id' => (int) $media_id,
+ ];
+ },
+ explode(',', $values['media_items'])
+ );
+ }
+
+ public static function uploadAjaxCallback(array &$form, FormStateInterface $form_state, Request $request) {
+ $response = ManagedFile::uploadAjaxCallback($form, $form_state, $request);
+
+ $trigger = $form_state->getTriggeringElement();
+ $complete_form = $form_state->getCompleteForm();
+ $widget = NestedArray::getValue($complete_form, array_slice($trigger['#array_parents'], 0, -2));
+
+ $files = $widget['file']['#value']['fids'];
+ $parameters = [
+ 'file' => array_shift($files),
+ 'selector' => $widget['media_items']['#attributes']['data-drupal-selector'],
+ ];
+ // TODO: Inject the media.type_guesser.file service.
+ $parameters['media_type'] = \Drupal::service('media.type_guesser.file')->guessForFile($parameters['file']);
+ $options = [
+ 'query' => [
+ 'files' => $files,
+ ],
+ ];
+ $url = Url::fromRoute('entity.media.add_file', $parameters, $options);
+
+ return $response->addCommand(new OpenDialogUrlCommand($url));
+ }
+
+}
diff --git a/core/modules/media/tests/src/FunctionalJavascript/MediaFileWidgetTest.php b/core/modules/media/tests/src/FunctionalJavascript/MediaFileWidgetTest.php
new file mode 100644
index 0000000000..7f2f792203
--- /dev/null
+++ b/core/modules/media/tests/src/FunctionalJavascript/MediaFileWidgetTest.php
@@ -0,0 +1,129 @@
+nodeType = $this->drupalCreateContentType();
+
+ $field_storage = FieldStorageConfig::create([
+ 'field_name' => 'field_media',
+ 'entity_type' => 'node',
+ 'type' => 'entity_reference',
+ 'settings' => [
+ 'target_type' => 'media',
+ ],
+ 'cardinality' => 3,
+ ]);
+ $field_storage->save();
+
+ FieldConfig::create([
+ 'label' => 'Media',
+ 'bundle' => $this->nodeType->id(),
+ 'field_storage' => $field_storage,
+ 'settings' => [
+ 'handler' => 'default:media',
+ 'handler_settings' => [
+ 'target_bundles' => NULL,
+ ],
+ ],
+ ])->save();
+
+ entity_get_form_display('node', $this->nodeType->id(), 'default')
+ ->setComponent('field_media', [
+ 'type' => 'media_file',
+ 'region' => 'content',
+ ])
+ ->save();
+ }
+
+ public function testWidget() {
+ $assert = $this->assertSession();
+
+ $account = $this->drupalCreateUser([
+ 'create media',
+ 'create ' . $this->nodeType->id() . ' content',
+ 'edit own ' . $this->nodeType->id() . ' content',
+ ]);
+ $this->drupalLogin($account);
+
+ $this->drupalGet('/node/add/' . $this->nodeType->id());
+ $wrapper = $assert->elementExists('css', 'details[open][title = "Media"]');
+ // Assert that multiple-cardinality media reference fields produce only
+ // a single multiple-value file upload element.
+ /** @var \Behat\Mink\Element\NodeElement[] $file_elements */
+ $file_elements = $wrapper->findAll('css', 'input[type = "file"]');
+ $this->assertCount(1, $file_elements);
+ $file_element = reset($file_elements);
+ $this->assertSame('files[field_media_file][]', $file_element->getAttribute('name'));
+ $this->assertTrue($file_element->hasAttribute('multiple'));
+
+ // Upload a file and assert that the media form shows up as expected.
+ $this->getSession()->getPage()->attachFileToField($file_element->getAttribute('name'), __DIR__ . '/../../fixtures/example_1.jpeg');
+ $this->waitUntilVisible('#drupal-modal', 30000);
+ $form_element = $assert->elementExists('css', '#drupal-modal form');
+ $assert->fieldValueEquals('Media type', 'image', $form_element);
+ $assert->fieldExists('Name', $form_element);
+ $fid = $assert->hiddenFieldExists('inner_form[field_media_image][0][fids]', $form_element)->getValue();
+ $this->assertNotEmpty($fid);
+ $this->assertTrue(is_numeric($fid));
+ $assert->buttonExists('Save', $form_element);
+
+ $buttons = $assert->elementExists('css', '#drupal-modal + .ui-dialog-buttonpane');
+ $save_button = $assert->buttonExists('Save', $buttons);
+ $save_button->press();
+
+ $errors_selector = '#drupal-modal form .messages--error';
+ $this->waitUntilVisible($errors_selector, 30000);
+ $assert->elementExists('named', ['content', 'Name field is required.'], $form_element);
+ $assert->elementExists('named', ['content', 'Alternative text field is required.'], $form_element);
+
+ $this->getSession()->executeScript('document.querySelector("' . $errors_selector . '").remove()');
+ $form_element->fillField('Name', 'Pastafazoul');
+ $save_button->press();
+ $this->waitUntilVisible($errors_selector, 30000);
+ $assert->elementNotExists('named', ['content', 'Name field is required.'], $form_element);
+ $assert->elementExists('named', ['content', 'Alternative text field is required.'], $form_element);
+
+ // Assert that the file element is hidden if the field is at capacity.
+ $node = $this->drupalCreateNode([
+ 'type' => $this->nodeType->id(),
+ 'uid' => $account->id(),
+ ]);
+ $media_type = $this->createMediaType()->id();
+ for ($i = 0; $i < 3; $i++) {
+ $media = Media::create([
+ 'bundle' => $media_type,
+ 'field_media_test' => $this->randomString(),
+ ]);
+ $media->save();
+ $node->field_media[$i] = $media;
+ }
+ $node->save();
+
+ $this->drupalGet('/node/' . $node->id() . '/edit');
+ $wrapper = $assert->elementExists('css', 'details[open][title = "Media"]');
+ $assert->elementNotExists('css', 'input[type = "file"]', $wrapper);
+ }
+
+}
diff --git a/core/modules/media/tests/src/Kernel/MediaFileWidgetTest.php b/core/modules/media/tests/src/Kernel/MediaFileWidgetTest.php
new file mode 100644
index 0000000000..559b3628b1
--- /dev/null
+++ b/core/modules/media/tests/src/Kernel/MediaFileWidgetTest.php
@@ -0,0 +1,54 @@
+ 'user',
+ 'field_name' => 'field_media',
+ 'type' => 'entity_reference',
+ 'settings' => [
+ 'target_type' => 'media',
+ ],
+ ]);
+ $field_storage->save();
+
+ $field = FieldConfig::create([
+ 'bundle' => 'user',
+ 'field_storage' => $field_storage,
+ 'settings' => [
+ 'handler' => 'default:media',
+ 'handler_settings' => [
+ 'target_bundles' => NULL,
+ ],
+ ],
+ ]);
+ $field->save();
+
+ $this->assertTrue(MediaFileWidget::isApplicable($field));
+
+ // Assert that the widget cannot be used on a string field, or an entity
+ // reference field that does not reference media items.
+ $fields = $this->container
+ ->get('entity_field.manager')
+ ->getBaseFieldDefinitions('user');
+
+ $this->assertFalse(MediaFileWidget::isApplicable($fields['name']));
+ $this->assertFalse(MediaFileWidget::isApplicable($fields['roles']));
+ }
+
+}