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..f911b89a7e --- /dev/null +++ b/core/modules/media/js/ajax.js @@ -0,0 +1,35 @@ +(function ($, Drupal, drupalSettings) { + + /** + * 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, + { + progress: { + type: 'throbber' + }, + url: '/media/add-modal', + dialogType: 'modal', + submit: { + file_ids: response.file_ids, + field_widget_id: response.field_widget_id, + dialogOptions: { + width: 800 + } + } + } + ); + + openDialogAjax.execute(); + }; + +})(jQuery, Drupal, drupalSettings); 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..9b7a906a41 100644 --- a/core/modules/media/media.module +++ b/core/modules/media/media.module @@ -5,13 +5,18 @@ * Provides media items. */ +use Drupal\Component\Utility\Unicode; use Drupal\Core\Access\AccessResult; 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\field\FieldConfigInterface; +use Drupal\file\FileInterface; +use Drupal\file\Plugin\Field\FieldType\FileItem; +use Drupal\media\Entity\MediaType; +use Drupal\media\MediaTypeInterface; /** * Implements hook_help(). @@ -75,6 +80,90 @@ function media_theme_suggestions_media(array $variables) { } /** + * TODO: Move into service! + * + * Get availalbe list of media types for file extension. + * + * @param string $extension + * Extension of file. + * + * @return array + * Returns associative array with (typeId => Label mapping). + */ +function media_get_types_by_extension($extension) { + static $media_types; + + if (!isset($media_types)) { + $media_types = \Drupal::entityTypeManager() + ->getStorage('media_type') + ->getQuery() + ->execute(); + + $media_types = array_filter($media_types, [ + \Drupal::entityTypeManager()->getAccessControlHandler('media'), + 'createAccess', + ]); + + $media_types = MediaType::loadMultiple($media_types); + + $media_types = array_filter($media_types, function (MediaTypeInterface $media_type) { + $item_class = $media_type + ->getSource() + ->getSourceFieldDefinition($media_type) + ->getItemDefinition() + ->getClass(); + + return is_a($item_class, FileItem::class, TRUE); + }); + } + + $extension = Unicode::strtolower($extension); + + $allowed_media_types = []; + + /** @var \Drupal\media\MediaTypeInterface $media_type */ + foreach ($media_types as $media_type) { + $extensions = $media_type + ->getSource() + ->getSourceFieldDefinition($media_type) + ->getSetting('file_extensions'); + + $extensions = Unicode::strtolower($extensions); + $extensions = preg_split('/\s+/', $extensions); + + if (in_array($extension, $extensions)) { + $allowed_media_types[$media_type->id()] = $media_type->label(); + } + } + + return $allowed_media_types; +} + +/** + * TODO: Move into some validation class, or whatever! + * + * 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); + + $availableTypes = media_get_types_by_extension($extension); + if (empty($availableTypes)) { + return [ + t('%filename could not be matched to any media types.', [ + '%filename' => $file->getFilename(), + ]), + ]; + } +} + +/** * Prepares variables for media templates. * * Default template: media.html.twig. diff --git a/core/modules/media/media.routing.yml b/core/modules/media/media.routing.yml index 9fbadeff29..774d4aedae 100644 --- a/core/modules/media/media.routing.yml +++ b/core/modules/media/media.routing.yml @@ -19,3 +19,10 @@ 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: + _permission: 'administer media' \ No newline at end of file diff --git a/core/modules/media/src/Ajax/OpenModalMediaForm.php b/core/modules/media/src/Ajax/OpenModalMediaForm.php new file mode 100644 index 0000000000..9635a08d67 --- /dev/null +++ b/core/modules/media/src/Ajax/OpenModalMediaForm.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/Form/MediaModalForm.php b/core/modules/media/src/Form/MediaModalForm.php new file mode 100644 index 0000000000..410a990d53 --- /dev/null +++ b/core/modules/media/src/Form/MediaModalForm.php @@ -0,0 +1,292 @@ +set('queued_fids', $form_state->getUserInput()['file_ids']); + $form_state->set('field_widget_id', $form_state->getUserInput()['field_widget_id']); + + $form_state->set('processed_media_ids', []); + + $form_state->setCached(TRUE); + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state) { + $form['#prefix'] = '
'; + $form['#suffix'] = '
'; + + // Initialize form state if there are no queued files. + if (empty($form_state->get('queued_fids'))) { + $this->initFormState($form_state); + } + $file_ids = $form_state->get('queued_fids'); + $file = $this->getFileById($file_ids[0]); + + // Create drop-down list with list of available media types. + $list_of_media_types = $this->getMediaTypeList($file_ids[0]); + $form['media_type'] = [ + '#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 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); + } + + // Prepare inner media form. + $form['#process'] = \Drupal::service('element_info')->getInfoProperty('form', '#process', []); + $form['#process'][] = '::processForm'; + + // Create media entity. + $media = $this->createMediaFromFile($selected_media_type, $file); + $form[static::INNER_FORM_ID] = $this->getInnerMediaForm($form, $form_state, $media); + + // Create action buttons for modal dialog. + $form['actions'] = [ + '#type' => 'actions', + ]; + + $form['actions']['submit_form'] = [ + '#type' => 'submit', + '#value' => $this->t('Save'), + '#validate' => ['::validateForm'], + '#submit' => ['::submitForm'], + '#ajax' => [ + 'callback' => '::ajaxSubmitForm', + 'wrapper' => 'media-modal-form', + 'event' => 'click', + ], + ]; + + return $form; + } + + protected static function createInnerFormState(FormStateInterface $form_state, FormInterface $form_object, $key) { + $inner_form_state = new FormState(); + $inner_form_state->setFormObject($form_object); + $form_state->set([static::INNER_FORM_STATE_KEY, $key], $inner_form_state); + + return $inner_form_state; + } + + protected static function getInnerFormState(FormStateInterface $form_state, $key) { + /** @var \Drupal\Core\Form\FormStateInterface $inner_form_state */ + $inner_form_state = $form_state->get([static::INNER_FORM_STATE_KEY, $key]); + + $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; + } + + public function processForm(array &$element, FormStateInterface &$form_state, array &$complete_form) { + $inner_form_state = static::getInnerFormState($form_state, static::INNER_FORM_ID); + + foreach ($inner_form_state->get('#process') as $callback) { + $element[static::INNER_FORM_ID] = call_user_func_array($inner_form_state->prepareCallback($callback), array( + &$element[static::INNER_FORM_ID], + &$inner_form_state, + &$complete_form, + )); + } + + return $element; + } + + protected function getInnerMediaForm(array $form, FormStateInterface $form_state, $media) { + $inner_form_object = \Drupal::entityTypeManager() + ->getFormObject('media', 'edit') + ->setEntity($media); + + $form_state->set('inner_form_object', $inner_form_object); + + $inner_form_state = static::createInnerFormState($form_state, $inner_form_object, static::INNER_FORM_ID); + + $inner_form = ['#parents' => [static::INNER_FORM_ID]]; + $inner_form = $inner_form_object->buildForm($inner_form, $inner_form_state); + + $inner_form['#type'] = 'container'; + $inner_form['#theme_wrappers'] = \Drupal::service('element_info') + ->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'][static::MAIN_SUBMIT_BUTTON])) { + $inner_form['#submit'] = $inner_form['actions'][static::MAIN_SUBMIT_BUTTON]['#submit']; + } + unset($inner_form['actions']); + } + + $inner_form['#id'] = 'media-modal-form-inner-form'; + + return $inner_form; + } + + public function validateForm(array &$form, FormStateInterface $form_state) { + /** @var \Drupal\Core\Form\FormValidatorInterface $form_validator */ + $form_validator = \Drupal::service('form_validator'); + + if ($form_state->getTriggeringElement()['#name'] === 'op') { + $inner_form_object = $form_state->get('inner_form_object'); + $inner_form_state = static::getInnerFormState($form_state, static::INNER_FORM_ID); + + // Pass through both the form elements validation and the form object + // validation. + $inner_form_object->validateForm($form[static::INNER_FORM_ID], $inner_form_state); + $form_validator->validateForm($inner_form_object->getFormId(), $form[static::INNER_FORM_ID], $inner_form_state); + + foreach ($inner_form_state->getErrors() as $error_element_path => $error) { + $form_state->setErrorByName(static::INNER_FORM_ID . '][' . $error_element_path, $error); + } + } + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + /** @var \Drupal\Core\Form\FormSubmitterInterface $form_submitter */ + $form_submitter = \Drupal::service('form_submitter'); + + $inner_form_state = static::getInnerFormState($form_state, static::INNER_FORM_ID); + $inner_form_state->setSubmitted(); + $form_submitter->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('inner_form_object')->getEntity()->id(); + $form_state->set('processed_media_ids', $processed_ids); + + if (!empty($file_ids)) { + $form_state->setRebuild(); + } + } + + protected function createMediaFromFile($type, $file) { + $media_item = Media::create([ + 'bundle' => $type, + ]); + + $configuration = $media_item->getSource()->getConfiguration(); + $media_item->set($configuration['source_field'], $file); + + return $media_item; + } + + protected function getFileById($fileId) { + /** @var \Drupal\file\FileStorage $file_storage */ + $file_storage = \Drupal::service('entity_type.manager')->getStorage('file'); + + return $file_storage->load($fileId); + } + + protected function getMediaTypeList($file_id) { + $file = $this->getFileById($file_id); + + $extension = pathinfo($file->getFilename(), PATHINFO_EXTENSION); + + return media_get_types_by_extension($extension); + } + + 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); + } + + return $response; + } + + public function ajaxChangeType(array &$form, FormStateInterface $form_state) { + $response = new AjaxResponse(); + + $selection_button_html = trim(drupal_render_root($form['media_type'])); + $response->addCommand(new ReplaceCommand('#media-modal-form-media-type-wrapper', $selection_button_html)); + + $media_form_html = trim(drupal_render_root($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..24e4bd473d --- /dev/null +++ b/core/modules/media/src/Plugin/Field/FieldWidget/MediaFileWidget.php @@ -0,0 +1,118 @@ + 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' => [], + ], + ]; + + $element_info = \Drupal::service('element_info')->getInfo('managed_file'); + $element['file']['#process'] = array_merge($element_info['#process'], [ + [static::class, 'processFileElement'], + ]); + + return $element; + } + + public static function processFileElement(array $element) { + $element['upload_button']['#ajax']['callback'][0] = static::class; + $element['upload_button']['#submit'][] = [static::class, 'onFileUpload']; + return $element; + } + + public static function onFileUpload(array &$form, FormStateInterface $form_state) { + $upload_button = $form_state->getTriggeringElement(); + $key = $upload_button['#array_parents']; + array_splice($key, -1, 1, ['#value', 'fids']); + $fids = NestedArray::getValue($form, $key); + + $form_state->set('file_ids', $fids); + } + + public static function uploadAjaxCallback(array &$form, FormStateInterface $form_state, Request $request) { + $response = new AjaxResponse(); + + $fileIds = $form_state->get('file_ids') ?: []; + + // TODO: Get correct ID for widget and send it, it's needed for modal to + // return data back to widget. + $response->addCommand(new OpenModalMediaForm($fileIds, 'test-widget-id')); + $response->setAttachments([ + 'library' => ['media/open_modal_media_form'], + ]); + + return $response; + } + +} 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..4b7686f247 --- /dev/null +++ b/core/modules/media/tests/src/FunctionalJavascript/MediaFileWidgetTest.php @@ -0,0 +1,105 @@ +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 ' . $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')); + + $this->getSession()->getPage()->attachFileToField($file_element->getAttribute('name'), __DIR__ . '/../../fixtures/example_1.jpeg'); + $this->waitUntilVisible('#drupal-modal', 10000); + $assert->elementExists('css', '#drupal-modal form'); + + // 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'])); + } + +}