diff --git a/core/modules/media/media.module b/core/modules/media/media.module index 20d12b856cc5259d896d477094ea7ab8721f5c7e..a95b972d66565a9af43f446fb5de719c898db858 100644 --- a/core/modules/media/media.module +++ b/core/modules/media/media.module @@ -243,31 +243,9 @@ function media_field_widget_multivalue_form_alter(array &$elements, FormStateInt // Retrieve the media bundle list and add information for the user based on // which bundles are available to be created or referenced. $settings = $context['items']->getFieldDefinition()->getSetting('handler_settings'); - $allowed_bundles = isset($settings['target_bundles']) ? $settings['target_bundles'] : []; - $access_handler = \Drupal::entityTypeManager()->getAccessControlHandler('media'); - $all_bundles = \Drupal::service('entity_type.bundle.info')->getBundleInfo('media'); - $bundle_labels = []; - $create_bundles = []; - foreach ($allowed_bundles as $bundle) { - $bundle_labels[] = $all_bundles[$bundle]['label']; - if ($access_handler->createAccess($bundle)) { - $create_bundles[] = $bundle; - if (count($create_bundles) > 1) { - // If the user has access to create more than 1 bundle then the - // individual media type form can not be used. - break; - } - } - } - - // Add a section about how to create media if the user has access to do so. - if (!empty($create_bundles)) { - if (count($create_bundles) === 1) { - $add_url = Url::fromRoute('entity.media.add_form', ['media_type' => $create_bundles[0]])->toString(); - } - elseif (count($create_bundles) > 1) { - $add_url = Url::fromRoute('entity.media.add_page')->toString(); - } + $allowed_bundles = !empty($settings['target_bundles']) ? $settings['target_bundles'] : []; + $add_url = _media_get_add_url($allowed_bundles); + if ($add_url) { $elements['#media_help']['#media_add_help'] = t('Create your media on the media add page (opens a new window), then add it by name to the field below.', [':add_page' => $add_url]); } @@ -308,6 +286,10 @@ function media_field_widget_multivalue_form_alter(array &$elements, FormStateInt if ($overview_url->access()) { $elements['#media_help']['#media_list_link'] = t('See the media list (opens a new window) to help locate media.', [':list_url' => $overview_url->toString()]); } + $all_bundles = \Drupal::service('entity_type.bundle.info')->getBundleInfo('media'); + $bundle_labels = array_map(function ($bundle) use ($all_bundles) { + return $all_bundles[$bundle]['label']; + }, $allowed_bundles); $elements['#media_help']['#allowed_types_help'] = t('Allowed media types: %types', ['%types' => implode(", ", $bundle_labels)]); } } @@ -336,3 +318,32 @@ function media_preprocess_media_reference_help(&$variables) { } } } + +/** + * Returns the appropriate URL to add media for the current user. + * + * @todo Remove in https://www.drupal.org/project/drupal/issues/2938116 + * + * @param string[] $allowed_bundles + * An array of bundles that should be checked for create access. + * + * @return bool|\Drupal\Core\Url + * The URL to add media, or FALSE if the user cannot create any media. + * + * @internal + * This function is internal and may be removed in a minor release. + */ +function _media_get_add_url($allowed_bundles) { + $access_handler = \Drupal::entityTypeManager()->getAccessControlHandler('media'); + $create_bundles = array_filter($allowed_bundles, [$access_handler, 'createAccess']); + + // Add a section about how to create media if the user has access to do so. + if (count($create_bundles) === 1) { + return Url::fromRoute('entity.media.add_form', ['media_type' => reset($create_bundles)])->toString(); + } + elseif (count($create_bundles) > 1) { + return Url::fromRoute('entity.media.add_page')->toString(); + } + + return FALSE; +} diff --git a/core/modules/media_library/config/install/views.view.media_library.yml b/core/modules/media_library/config/install/views.view.media_library.yml index 61ac13600c6efe88eb9366707922180f0ee3daaa..32096a6f9cc0203bb40cf1ceb3d276db24354183 100644 --- a/core/modules/media_library/config/install/views.view.media_library.yml +++ b/core/modules/media_library/config/install/views.view.media_library.yml @@ -8,6 +8,7 @@ dependencies: - media_library module: - media + - media_library - user id: media_library label: 'Media library' @@ -71,7 +72,7 @@ display: type: default options: grouping: { } - row_class: 'media-library-item js-click-to-select' + row_class: 'media-library-item js-media-library-item js-click-to-select' default_row_class: true row: type: fields @@ -118,7 +119,7 @@ display: preserve_tags: '' html: false element_type: '' - element_class: js-click-to-select__checkbox + element_class: js-click-to-select-checkbox element_label_type: '' element_label_class: '' element_label_colon: false @@ -376,51 +377,9 @@ display: content: 'No media available.' plugin_id: text_custom relationships: { } - arguments: - bundle: - id: bundle - table: media_field_data - field: bundle - relationship: none - group_type: group - admin_label: '' - default_action: ignore - exception: - value: all - title_enable: false - title: All - title_enable: false - title: '' - default_argument_type: fixed - default_argument_options: - argument: '' - default_argument_skip_url: false - summary_options: - base_path: '' - count: true - items_per_page: 25 - override: false - summary: - sort_order: asc - number_of_records: 0 - format: default_summary - specify_validation: false - validate: - type: none - fail: 'not found' - validate_options: { } - glossary: false - limit: 0 - case: none - path_case: none - transform_dash: false - break_phrase: false - entity_type: media - entity_field: bundle - plugin_id: string display_extenders: { } use_ajax: true - css_class: media-library-view + css_class: 'media-library-view js-media-library-view' cache_metadata: max-age: 0 contexts: @@ -456,3 +415,131 @@ display: - 'url.query_args:sort_by' - user.permissions tags: { } + # @todo Lock down access in https://www.drupal.org/node/2983179 + widget: + display_plugin: page + id: widget + display_title: Widget + position: 2 + display_options: + display_extenders: { } + path: admin/content/media-widget + fields: + rendered_entity: + id: rendered_entity + table: media + field: rendered_entity + relationship: none + group_type: group + admin_label: '' + label: '' + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: media-library-item__content + element_label_type: '' + element_label_class: '' + element_label_colon: false + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + view_mode: media_library + entity_type: media + plugin_id: rendered_entity + media_library_select_form: + id: media_library_select_form + table: media + field: media_library_select_form + relationship: none + group_type: group + admin_label: '' + label: '' + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: false + element_wrapper_type: '' + element_wrapper_class: js-click-to-select-checkbox + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + entity_type: media + plugin_id: media_library_select_form + defaults: + fields: false + access: false + display_description: '' + access: + type: perm + options: + perm: 'view media' + cache_metadata: + max-age: -1 + contexts: + - 'languages:language_interface' + - url + - url.query_args + - 'url.query_args:sort_by' + - user.permissions + tags: { } diff --git a/core/modules/media_library/css/media_library.module.css b/core/modules/media_library/css/media_library.module.css index 11ad56dd922e3fd31b30b8f5521b1df5bd47289d..89b15800634a733bd24b59721257500846f1d18f 100644 --- a/core/modules/media_library/css/media_library.module.css +++ b/core/modules/media_library/css/media_library.module.css @@ -2,22 +2,19 @@ * @file media_library.module.css */ -.media-library-page-form { - display: flex; - flex-wrap: wrap; -} - -.media-library-page-form > .form-actions { +.media-library-views-form > .form-actions { flex-basis: 100%; } -.media-library-page-form__header > div, +.media-library-views-form, +.media-library-selection, +.media-library-views-form__bulk_form, .media-library-view .form--inline { display: flex; flex-wrap: wrap; } -.media-library-page-form__header { +.media-library-views-form__header { flex-basis: 100%; } @@ -25,7 +22,7 @@ position: relative; } -.media-library-item .js-click-to-select__trigger { +.media-library-item .js-click-to-select-trigger { overflow: hidden; cursor: pointer; } @@ -34,7 +31,7 @@ align-self: flex-end; } -.media-library-item .js-click-to-select__checkbox { +.media-library-item .js-click-to-select-checkbox { position: absolute; display: block; z-index: 1; @@ -51,6 +48,25 @@ .media-library-select-all { flex-basis: 100%; + width: 100%; +} + +.media-library-view.view-display-id-widget .media-library-select-all { + display: none; +} + +.media-library-item--disabled { + pointer-events: none; +} + +.media-library-selection .media-library-item__preview { + cursor: move; +} + +/* @todo Remove or re-work in https://www.drupal.org/node/2985168 */ +.media-library-widget .media-library-item__name a, +.media-library-view.view-display-id-widget .media-library-item__name a { + pointer-events: none; } @media screen and (max-width: 600px) { diff --git a/core/modules/media_library/css/media_library.theme.css b/core/modules/media_library/css/media_library.theme.css index 0427143538e36cca88518bc430209ad86d519ee7..5aab3c5bbd17118340a3d677868267aabfb94505 100644 --- a/core/modules/media_library/css/media_library.theme.css +++ b/core/modules/media_library/css/media_library.theme.css @@ -5,7 +5,7 @@ * @see https://www.drupal.org/project/drupal/issues/2980769 */ -.media-library-page-form__header .form-item { +.media-library-views-form__header .form-item { margin-right: 8px; } @@ -16,21 +16,29 @@ .media-library-item { justify-content: center; vertical-align: top; - padding: 2px; - border: 1px solid #ebebeb; + border: 1px solid #dbdbdb; margin: 16px 16px 2px 2px; width: 180px; background: #fff; transition: border-color 0.2s, color 0.2s, background 0.2s; } +.media-library-view { + min-height: 300px; +} + .media-library-view .form-actions { margin: 0.75em 0; } +.media-library-view .media-library-view--form-actions { + clear: left; + margin: 0.75em 0; + align-self: flex-end; +} + .media-library-item .field--name-thumbnail { background-color: #ebebeb; - margin: 2px; overflow: hidden; text-align: center; } @@ -54,17 +62,17 @@ border-color: #0076c0; } -.media-library-item .js-click-to-select__checkbox input { +.media-library-item .js-click-to-select-checkbox input { width: 30px; height: 30px; } -.media-library-item .js-click-to-select__checkbox .form-item { +.media-library-item .js-click-to-select-checkbox .form-item { margin: 0; } .media-library-item__preview { - padding-bottom: 44px; + padding-bottom: 34px; } .media-library-item__status { @@ -90,9 +98,9 @@ position: absolute; bottom: 0; display: block; - padding: 10px; - max-width: calc(100% - 20px); - max-height: calc(100% - 60px); + padding: 5px; + max-width: calc(100% - 10px); + max-height: calc(100% - 50px); overflow: hidden; background: white; } @@ -135,6 +143,68 @@ margin-right: 10px; } +.media-library-item--disabled { + opacity: 0.5; +} + +.media-library-selection { + margin-bottom: 1.5rem; +} + +.media-library-widget { + position: relative; +} + +.media-library-widget__toggle-weight { + position: absolute; + right: 5px; + top: 5px; +} + +.media-library-item .form-item { + margin: 0.75em; +} + +.media-library-item__remove, +.media-library-item__remove:hover, +.media-library-item__remove:focus, +.media-library-item__remove.button, +.media-library-item__remove.button:disabled, +.media-library-item__remove.button:disabled:active, +.media-library-item__remove.button:hover, +.media-library-item__remove.button:focus { + position: absolute; + z-index: 1; + top: 0; + right: 0; + width: 24px; + height: 24px; + margin: 5px; + padding: 0; + background: url('../../../misc/icons/787878/ex.svg') #fff center no-repeat; + background-size: 16px 16px; + border: 2px solid #ccc; + border-radius: 20px; + color: transparent; + text-shadow: none; + transition: 0.2s border-color; +} + +.media-library-item__remove:hover, +.media-library-item__remove:focus, +.media-library-item__remove.button:hover, +.media-library-item__remove.button:focus, +.media-library-item__remove.button:disabled:active { + border-color: #40b6ff; +} + +/* @todo Remove or re-work in https://www.drupal.org/node/2985168 */ +.media-library-widget .media-library-item__name a, +.media-library-view.view-display-id-widget .media-library-item__name a { + color: black; + text-decoration: none; +} + @media screen and (max-width: 600px) { .media-library-item { width: 150px; diff --git a/core/modules/media_library/js/media_library.click_to_select.es6.js b/core/modules/media_library/js/media_library.click_to_select.es6.js index 321ffe0c73a9f9f056123d10fad095d832d2a1e4..d5179c34997c43084ac958eaf4fd276df378d56d 100644 --- a/core/modules/media_library/js/media_library.click_to_select.es6.js +++ b/core/modules/media_library/js/media_library.click_to_select.es6.js @@ -8,7 +8,7 @@ */ Drupal.behaviors.ClickToSelect = { attach(context) { - $('.js-click-to-select__trigger', context) + $('.js-click-to-select-trigger', context) .once('media-library-click-to-select') .on('click', (event) => { // Links inside the trigger should not be click-able. @@ -16,10 +16,10 @@ // Click the hidden checkbox when the trigger is clicked. const $input = $(event.currentTarget) .closest('.js-click-to-select') - .find('.js-click-to-select__checkbox input'); + .find('.js-click-to-select-checkbox input'); $input.prop('checked', !$input.prop('checked')).trigger('change'); }); - $('.js-click-to-select__checkbox input', context) + $('.js-click-to-select-checkbox input', context) .once('media-library-click-to-select') .on('change', ({ currentTarget }) => { $(currentTarget) diff --git a/core/modules/media_library/js/media_library.click_to_select.js b/core/modules/media_library/js/media_library.click_to_select.js index 4bc041c43e56a72a8047b1bc413d4e3d445e4cbf..01cbb1431b4c15081848868ce6e14c60b938a1cd 100644 --- a/core/modules/media_library/js/media_library.click_to_select.js +++ b/core/modules/media_library/js/media_library.click_to_select.js @@ -8,13 +8,13 @@ (function ($, Drupal) { Drupal.behaviors.ClickToSelect = { attach: function attach(context) { - $('.js-click-to-select__trigger', context).once('media-library-click-to-select').on('click', function (event) { + $('.js-click-to-select-trigger', context).once('media-library-click-to-select').on('click', function (event) { event.preventDefault(); - var $input = $(event.currentTarget).closest('.js-click-to-select').find('.js-click-to-select__checkbox input'); + var $input = $(event.currentTarget).closest('.js-click-to-select').find('.js-click-to-select-checkbox input'); $input.prop('checked', !$input.prop('checked')).trigger('change'); }); - $('.js-click-to-select__checkbox input', context).once('media-library-click-to-select').on('change', function (_ref) { + $('.js-click-to-select-checkbox input', context).once('media-library-click-to-select').on('change', function (_ref) { var currentTarget = _ref.currentTarget; $(currentTarget).closest('.js-click-to-select').toggleClass('checked', $(currentTarget).prop('checked')); diff --git a/core/modules/media_library/js/media_library.view.es6.js b/core/modules/media_library/js/media_library.view.es6.js index 26e9a5b2635ef05e7276c636a063207d9383f290..98b85c9ed1592e4818a83cb9415de937fa260880 100644 --- a/core/modules/media_library/js/media_library.view.es6.js +++ b/core/modules/media_library/js/media_library.view.es6.js @@ -7,7 +7,7 @@ */ Drupal.behaviors.MediaLibraryHover = { attach(context) { - $('.media-library-item .js-click-to-select__trigger,.media-library-item .js-click-to-select__checkbox', context).once('media-library-item-hover') + $('.media-library-item .js-click-to-select-trigger,.media-library-item .js-click-to-select-checkbox', context).once('media-library-item-hover') .on('mouseover mouseout', ({ currentTarget, type }) => { $(currentTarget).closest('.media-library-item').toggleClass('is-hover', type === 'mouseover'); }); @@ -19,7 +19,7 @@ */ Drupal.behaviors.MediaLibraryFocus = { attach(context) { - $('.media-library-item .js-click-to-select__checkbox input', context).once('media-library-item-focus') + $('.media-library-item .js-click-to-select-checkbox input', context).once('media-library-item-focus') .on('focus blur', ({ currentTarget, type }) => { $(currentTarget).closest('.media-library-item').toggleClass('is-focus', type === 'focus'); }); diff --git a/core/modules/media_library/js/media_library.view.js b/core/modules/media_library/js/media_library.view.js index 1cde60c3acfb633d4c792b2d70cba731a19d0e02..90573005711e30f24819b47a0bb8ed68e4158497 100644 --- a/core/modules/media_library/js/media_library.view.js +++ b/core/modules/media_library/js/media_library.view.js @@ -8,7 +8,7 @@ (function ($, Drupal) { Drupal.behaviors.MediaLibraryHover = { attach: function attach(context) { - $('.media-library-item .js-click-to-select__trigger,.media-library-item .js-click-to-select__checkbox', context).once('media-library-item-hover').on('mouseover mouseout', function (_ref) { + $('.media-library-item .js-click-to-select-trigger,.media-library-item .js-click-to-select-checkbox', context).once('media-library-item-hover').on('mouseover mouseout', function (_ref) { var currentTarget = _ref.currentTarget, type = _ref.type; @@ -19,7 +19,7 @@ Drupal.behaviors.MediaLibraryFocus = { attach: function attach(context) { - $('.media-library-item .js-click-to-select__checkbox input', context).once('media-library-item-focus').on('focus blur', function (_ref2) { + $('.media-library-item .js-click-to-select-checkbox input', context).once('media-library-item-focus').on('focus blur', function (_ref2) { var currentTarget = _ref2.currentTarget, type = _ref2.type; diff --git a/core/modules/media_library/js/media_library.widget.es6.js b/core/modules/media_library/js/media_library.widget.es6.js new file mode 100644 index 0000000000000000000000000000000000000000..654c2cde2871244fef91b9573f627364e35f3ee6 --- /dev/null +++ b/core/modules/media_library/js/media_library.widget.es6.js @@ -0,0 +1,93 @@ +/** + * @file media_library.widget.js + */ +(($, Drupal) => { + /** + * Allows users to re-order their selection with drag+drop. + */ + Drupal.behaviors.MediaLibraryWidgetSortable = { + attach(context) { + // Allow media items to be re-sorted with drag+drop in the widget. + $('.js-media-library-selection', context).once('media-library-sortable').sortable({ + tolerance: 'pointer', + helper: 'clone', + handle: '.js-media-library-item-preview', + stop: ({ target }) => { + // Update all the hidden "weight" fields. + $(target).children().each((index, child) => { + $(child).find('.js-media-library-item-weight').val(index); + }); + }, + }); + }, + }; + + /** + * Allows selection order to be set without drag+drop for accessibility. + */ + Drupal.behaviors.MediaLibraryWidgetToggleWeight = { + attach(context) { + const strings = { + show: Drupal.t('Show media item weights'), + hide: Drupal.t('Hide media item weights'), + }; + $('.js-media-library-widget-toggle-weight', context).once('media-library-toggle') + .on('click', (e) => { + e.preventDefault(); + $(e.currentTarget) + .toggleClass('active') + .text($(e.currentTarget).hasClass('active') ? strings.hide : strings.show) + .parent() + .find('.js-media-library-item-weight') + .parent() + .toggle(); + }) + .text(strings.show); + $('.js-media-library-item-weight', context).once('media-library-toggle').parent().hide(); + }, + }; + + /** + * Warn users when clicking outgoing links from the library or widget. + */ + Drupal.behaviors.MediaLibraryWidgetWarn = { + attach(context) { + $('.js-media-library-item a[href]', context).once('media-library-warn-link') + .on('click', (e) => { + const message = Drupal.t('Unsaved changes to the form will be lost. Are you sure you want to leave?'); + const confirmation = window.confirm(message); + if (!confirmation) { + e.preventDefault(); + } + }); + }, + }; + + /** + * Prevent users from selecting more items than allowed in the view. + */ + Drupal.behaviors.MediaLibraryWidgetRemaining = { + attach(context, settings) { + const $view = $('.js-media-library-view', context).once('media-library-remaining'); + $view.find('.js-media-library-item input[type="checkbox"]') + .on('change', () => { + if (settings.media_library && settings.media_library.selection_remaining) { + const $checkboxes = $view.find('.js-media-library-item input[type="checkbox"]'); + if ($checkboxes.filter(':checked').length === settings.media_library.selection_remaining) { + $checkboxes + .not(':checked') + .prop('disabled', true) + .closest('.js-media-library-item') + .addClass('media-library-item--disabled'); + } + else { + $checkboxes + .prop('disabled', false) + .closest('.js-media-library-item') + .removeClass('media-library-item--disabled'); + } + } + }); + }, + }; +})(jQuery, Drupal); diff --git a/core/modules/media_library/js/media_library.widget.js b/core/modules/media_library/js/media_library.widget.js new file mode 100644 index 0000000000000000000000000000000000000000..ad6fbd76e5de4491671a19952bf1039da2566e79 --- /dev/null +++ b/core/modules/media_library/js/media_library.widget.js @@ -0,0 +1,67 @@ +/** +* DO NOT EDIT THIS FILE. +* See the following change record for more information, +* https://www.drupal.org/node/2815083 +* @preserve +**/ + +(function ($, Drupal) { + Drupal.behaviors.MediaLibraryWidgetSortable = { + attach: function attach(context) { + $('.js-media-library-selection', context).once('media-library-sortable').sortable({ + tolerance: 'pointer', + helper: 'clone', + handle: '.js-media-library-item-preview', + stop: function stop(_ref) { + var target = _ref.target; + + $(target).children().each(function (index, child) { + $(child).find('.js-media-library-item-weight').val(index); + }); + } + }); + } + }; + + Drupal.behaviors.MediaLibraryWidgetToggleWeight = { + attach: function attach(context) { + var strings = { + show: Drupal.t('Show media item weights'), + hide: Drupal.t('Hide media item weights') + }; + $('.js-media-library-widget-toggle-weight', context).once('media-library-toggle').on('click', function (e) { + e.preventDefault(); + $(e.currentTarget).toggleClass('active').text($(e.currentTarget).hasClass('active') ? strings.hide : strings.show).parent().find('.js-media-library-item-weight').parent().toggle(); + }).text(strings.show); + $('.js-media-library-item-weight', context).once('media-library-toggle').parent().hide(); + } + }; + + Drupal.behaviors.MediaLibraryWidgetWarn = { + attach: function attach(context) { + $('.js-media-library-item a[href]', context).once('media-library-warn-link').on('click', function (e) { + var message = Drupal.t('Unsaved changes to the form will be lost. Are you sure you want to leave?'); + var confirmation = window.confirm(message); + if (!confirmation) { + e.preventDefault(); + } + }); + } + }; + + Drupal.behaviors.MediaLibraryWidgetRemaining = { + attach: function attach(context, settings) { + var $view = $('.js-media-library-view', context).once('media-library-remaining'); + $view.find('.js-media-library-item input[type="checkbox"]').on('change', function () { + if (settings.media_library && settings.media_library.selection_remaining) { + var $checkboxes = $view.find('.js-media-library-item input[type="checkbox"]'); + if ($checkboxes.filter(':checked').length === settings.media_library.selection_remaining) { + $checkboxes.not(':checked').prop('disabled', true).closest('.js-media-library-item').addClass('media-library-item--disabled'); + } else { + $checkboxes.prop('disabled', false).closest('.js-media-library-item').removeClass('media-library-item--disabled'); + } + } + }); + } + }; +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/modules/media_library/media_library.libraries.yml b/core/modules/media_library/media_library.libraries.yml index e32dbe074b496aa5b19cb47fede291b5d86ce54b..848222543e4f300c24ad758f601b2883105e2a28 100644 --- a/core/modules/media_library/media_library.libraries.yml +++ b/core/modules/media_library/media_library.libraries.yml @@ -21,5 +21,14 @@ view: dependencies: - media_library/style - media_library/click_to_select + +widget: + version: VERSION + js: + js/media_library.widget.js: {} + dependencies: + - core/drupal.ajax + - core/jquery.ui.sortable + - media_library/view - core/drupal.announce - core/jquery.once diff --git a/core/modules/media_library/media_library.module b/core/modules/media_library/media_library.module index 63b3cadb96a8e4f56030b9829f6a47f43be969ba..b72a4d47ce7c145669176863a4cdb695ec6be862 100644 --- a/core/modules/media_library/media_library.module +++ b/core/modules/media_library/media_library.module @@ -5,12 +5,17 @@ * Contains hook implementations for the media_library module. */ +use Drupal\Component\Utility\UrlHelper; +use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem; +use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Render\Element; use Drupal\Core\Routing\RouteMatchInterface; +use Drupal\Core\Template\Attribute; +use Drupal\views\Form\ViewsForm; use Drupal\views\Plugin\views\cache\CachePluginBase; +use Drupal\views\Plugin\views\query\QueryPluginBase; +use Drupal\views\Plugin\views\query\Sql; use Drupal\views\ViewExecutable; -use Drupal\Core\Template\Attribute; -use Drupal\Core\Form\FormStateInterface; -use Drupal\Core\Render\Element; /** * Implements hook_help(). @@ -43,6 +48,27 @@ function media_library_theme() { function media_library_views_post_render(ViewExecutable $view, &$output, CachePluginBase $cache) { if ($view->id() === 'media_library') { $output['#attached']['library'][] = 'media_library/view'; + if ($view->current_display === 'widget') { + $query = array_intersect_key(\Drupal::request()->query->all(), array_flip([ + 'media_library_widget_id', + 'media_library_allowed_types', + 'media_library_remaining', + ])); + // If the current query contains any parameters we use to contextually + // filter the view, ensure they persist across AJAX rebuilds. + // The ajax_path is shared for all AJAX views on the page, but our query + // parameters are prefixed and should not interfere with any other views. + // @todo Rework or remove this in https://www.drupal.org/node/2983451 + if (!empty($query)) { + $ajax_path = &$output['#attached']['drupalSettings']['views']['ajax_path']; + $parsed_url = UrlHelper::parse($ajax_path); + $query = array_merge($query, $parsed_url['query']); + $ajax_path = $parsed_url['path'] . '?' . UrlHelper::buildQuery($query); + if (isset($query['media_library_remaining'])) { + $output['#attached']['drupalSettings']['media_library']['selection_remaining'] = (int) $query['media_library_remaining']; + } + } + } } } @@ -59,7 +85,7 @@ function media_library_preprocess_media(&$variables) { 'language' => $media->language(), ]); $variables['preview_attributes'] = new Attribute(); - $variables['preview_attributes']->addClass('media-library-item__preview', 'js-click-to-select__trigger'); + $variables['preview_attributes']->addClass('media-library-item__preview', 'js-media-library-item-preview', 'js-click-to-select-trigger'); $variables['metadata_attributes'] = new Attribute(); $variables['metadata_attributes']->addClass('media-library-item__attributes'); $variables['status'] = $media->isPublished(); @@ -74,12 +100,10 @@ function media_library_preprocess_media(&$variables) { * @param \Drupal\Core\Form\FormStateInterface $form_state * The current state of the form. * - * @todo Remove in https://www.drupal.org/project/drupal/issues/2969660 + * @todo Remove in https://www.drupal.org/node/2983454 */ function media_library_form_views_form_media_library_page_alter(array &$form, FormStateInterface $form_state) { if (isset($form['media_bulk_form']) && isset($form['output'])) { - $form['#attributes']['class'][] = 'media-library-page-form'; - $form['header']['#attributes']['class'][] = 'media-library-page-form__header'; /** @var \Drupal\views\ViewExecutable $view */ $view = $form['output'][0]['#view']; foreach (Element::getVisibleChildren($form['media_bulk_form']) as $key) { @@ -93,6 +117,102 @@ function media_library_form_views_form_media_library_page_alter(array &$form, Fo } } +/** + * Implements hook_form_alter(). + */ +function media_library_form_alter(array &$form, FormStateInterface $form_state, $form_id) { + $form_object = $form_state->getFormObject(); + if ($form_object instanceof ViewsForm && strpos($form_object->getBaseFormId(), 'views_form_media_library') === 0) { + $form['#attributes']['class'][] = 'media-library-views-form'; + if (isset($form['header'])) { + $form['header']['#attributes']['class'][] = 'media-library-views-form__header'; + $form['header']['media_bulk_form']['#attributes']['class'][] = 'media-library-views-form__bulk_form'; + } + } + + // Add after build to fix media library views exposed filter's submit button. + if ($form_id === 'views_exposed_form' && $form['#id'] === 'views-exposed-form-media-library-widget') { + $form['#after_build'][] = '_media_library_views_form_media_library_after_build'; + } +} + +/** + * After build callback for views form media library. + */ +function _media_library_views_form_media_library_after_build(array $form, FormStateInterface $form_state) { + // Remove .form-actions from media library views exposed filter actions + // and replace with .media-library-view--form-actions. + // + // This prevents the views exposed filter's 'Apply filter' submit button from + // being moved into the dialog's buttons. + // @see \Drupal\Core\Render\Element\Actions::processActions + // @see Drupal.behaviors.dialog.prepareDialogButtons + if (($key = array_search('form-actions', $form['actions']['#attributes']['class'])) !== FALSE) { + unset($form['actions']['#attributes']['class'][$key]); + } + $form['actions']['#attributes']['class'][] = 'media-library-view--form-actions'; + return $form; +} + +/** + * Implements hook_views_query_alter(). + * + * Alters the widget view's query to only show media that can be selected, + * based on what types are allowed in the field settings. + * + * @todo Remove in https://www.drupal.org/node/2983454 + */ +function media_library_views_query_alter(ViewExecutable $view, QueryPluginBase $query) { + if ($query instanceof Sql && $view->id() === 'media_library' && $view->current_display === 'widget') { + $types = _media_library_get_allowed_types(); + if ($types) { + $entity_type = \Drupal::entityTypeManager()->getDefinition('media'); + $group = $query->setWhereGroup(); + $query->addWhere($group, $entity_type->getDataTable() . '.' . $entity_type->getKey('bundle'), $types, 'in'); + } + } +} + +/** + * Implements hook_form_FORM_ID_alter(). + * + * Limits the types available in the exposed filter to avoid users trying to + * filter by a type that is un-selectable. + * + * @see media_library_views_query_alter() + * + * @todo Remove in https://www.drupal.org/node/2983454 + */ +function media_library_form_views_exposed_form_alter(array &$form, FormStateInterface $form_state) { + if (isset($form['#id']) && $form['#id'] === 'views-exposed-form-media-library-widget') { + $types = _media_library_get_allowed_types(); + if ($types && isset($form['type']['#options'])) { + $keys = array_flip($types); + // Ensure that the default value (by default "All") persists. + if (isset($form['type']['#default_value'])) { + $keys[$form['type']['#default_value']] = TRUE; + } + $form['type']['#options'] = array_intersect_key($form['type']['#options'], $keys); + } + } +} + +/** + * Implements hook_field_ui_preconfigured_options_alter(). + */ +function media_library_field_ui_preconfigured_options_alter(array &$options, $field_type) { + // If the field is not an "entity_reference"-based field, bail out. + $class = \Drupal::service('plugin.manager.field.field_type')->getPluginClass($field_type); + if (!is_a($class, EntityReferenceItem::class, TRUE)) { + return; + } + + // Set the default field widget for media to be the Media library. + if (!empty($options['media'])) { + $options['media']['entity_form_display']['type'] = 'media_library_widget'; + } +} + /** * Implements hook_local_tasks_alter(). * @@ -107,3 +227,17 @@ function media_library_local_tasks_alter(&$local_tasks) { } } } + +/** + * Determines what types are allowed based on the current request. + * + * @return array + * An array of allowed types. + */ +function _media_library_get_allowed_types() { + $types = \Drupal::request()->query->get('media_library_allowed_types'); + if ($types && is_array($types)) { + return array_filter($types, 'is_string'); + } + return []; +} diff --git a/core/modules/media_library/media_library.views.inc b/core/modules/media_library/media_library.views.inc new file mode 100644 index 0000000000000000000000000000000000000000..7d1ead4c8faa5d901df7d254ced553e7ea0d981b --- /dev/null +++ b/core/modules/media_library/media_library.views.inc @@ -0,0 +1,22 @@ + t('Select media'), + 'help' => t('Provides a field for selecting media entities in our media library view'), + 'real field' => 'mid', + 'field' => [ + 'id' => 'media_library_select_form', + ], + ]; + return $data; +} diff --git a/core/modules/media_library/src/Plugin/Field/FieldWidget/MediaLibraryWidget.php b/core/modules/media_library/src/Plugin/Field/FieldWidget/MediaLibraryWidget.php new file mode 100644 index 0000000000000000000000000000000000000000..86aa2e979539707d5db4543caff052156604b585 --- /dev/null +++ b/core/modules/media_library/src/Plugin/Field/FieldWidget/MediaLibraryWidget.php @@ -0,0 +1,524 @@ +entityTypeManager = $entity_type_manager; + } + + /** + * {@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') + ); + } + + /** + * {@inheritdoc} + */ + public static function isApplicable(FieldDefinitionInterface $field_definition) { + return $field_definition->getSetting('target_type') === 'media'; + } + + /** + * {@inheritdoc} + */ + public function form(FieldItemListInterface $items, array &$form, FormStateInterface $form_state, $get_delta = NULL) { + // Load the items for form rebuilds from the field state. + $field_state = static::getWidgetState($form['#parents'], $this->fieldDefinition->getName(), $form_state); + if (isset($field_state['items'])) { + usort($field_state['items'], [SortArray::class, 'sortByWeightElement']); + $items->setValue($field_state['items']); + } + + return parent::form($items, $form, $form_state, $get_delta); + } + + /** + * {@inheritdoc} + */ + public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) { + /** @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $items */ + $referenced_entities = $items->referencedEntities(); + $view_builder = $this->entityTypeManager->getViewBuilder('media'); + $field_name = $this->fieldDefinition->getName(); + $parents = $form['#parents']; + $id_suffix = '-' . implode('-', $parents); + $wrapper_id = $field_name . '-media-library-wrapper' . $id_suffix; + $limit_validation_errors = [array_merge($parents, [$field_name])]; + + $settings = $this->getFieldSetting('handler_settings'); + $element += [ + '#type' => 'fieldset', + '#cardinality' => $this->fieldDefinition->getFieldStorageDefinition()->getCardinality(), + '#target_bundles' => isset($settings['target_bundles']) ? $settings['target_bundles'] : FALSE, + '#attributes' => [ + 'id' => $wrapper_id, + 'class' => ['media-library-widget'], + ], + '#attached' => [ + 'library' => ['media_library/widget'], + ], + ]; + + // @todo Remove in https://www.drupal.org/project/drupal/issues/2938116 + $allowed_bundles = !empty($element['#target_bundles']) ? $element['#target_bundles'] : []; + $add_url = _media_get_add_url($allowed_bundles); + if ($add_url) { + $element['create_help'] = [ + '#type' => 'container', + ]; + $element['create_help']['label'] = [ + '#type' => 'html_tag', + '#tag' => 'h4', + '#attributes' => [ + 'class' => ['label'], + ], + '#value' => $this->t('Create new media'), + ]; + $element['create_help']['description'] = [ + '#type' => 'html_tag', + '#tag' => 'div', + '#attributes' => [ + 'class' => ['description'], + ], + '#value' => $this->t('Create your media on the media add page (opens a new window), then select it in the library.', [':add_page' => $add_url]), + ]; + } + + $element['selection'] = [ + '#type' => 'container', + '#attributes' => [ + 'class' => [ + 'js-media-library-selection', + 'media-library-selection', + ], + ], + ]; + + if (empty($referenced_entities)) { + $element['empty_selection'] = [ + '#markup' => $this->t('

No media items are selected.

'), + ]; + } + else { + $element['weight_toggle'] = [ + '#type' => 'html_tag', + '#tag' => 'button', + '#value' => $this->t('Show media item weights'), + '#attributes' => [ + 'class' => [ + 'link', + 'media-library-widget__toggle-weight', + 'js-media-library-widget-toggle-weight', + ], + 'title' => $this->t('Re-order media by numerical weight instead of dragging'), + ], + ]; + } + + foreach ($referenced_entities as $delta => $media_item) { + $element['selection'][$delta] = [ + '#type' => 'container', + '#attributes' => [ + 'class' => [ + 'media-library-item', + 'js-media-library-item', + ], + ], + 'preview' => [ + '#type' => 'container', + // @todo Make the view mode configurable in https://www.drupal.org/project/drupal/issues/2971209 + 'rendered_entity' => $view_builder->view($media_item, 'media_library'), + 'remove_button' => [ + '#type' => 'submit', + '#name' => $field_name . '-' . $delta . '-media-library-remove-button' . $id_suffix, + '#value' => $this->t('Remove'), + '#attributes' => [ + 'class' => ['media-library-item__remove'], + ], + '#ajax' => [ + 'callback' => [static::class, 'updateWidget'], + 'wrapper' => $wrapper_id, + ], + '#submit' => [[static::class, 'removeItem']], + // Prevent errors in other widgets from preventing removal. + '#limit_validation_errors' => $limit_validation_errors, + ], + ], + 'target_id' => [ + '#type' => 'hidden', + '#value' => $media_item->id(), + ], + // This hidden value can be toggled visible for accessibility. + 'weight' => [ + '#type' => 'number', + '#title' => $this->t('Weight'), + '#default_value' => $delta, + '#attributes' => [ + 'class' => [ + 'js-media-library-item-weight', + 'media-library-item__weight', + ], + ], + ], + ]; + } + + $cardinality_unlimited = ($element['#cardinality'] === FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED); + $remaining = $element['#cardinality'] - count($referenced_entities); + + // Inform the user of how many items are remaining. + if (!$cardinality_unlimited) { + if ($remaining) { + $cardinality_message = $this->formatPlural($remaining, 'One media item remaining.', '@count media items remaining.'); + } + else { + $cardinality_message = $this->t('The maximum number of media items have been selected.'); + } + $element['#description'] .= '
' . $cardinality_message; + } + + // Add a button that will load the Media library in a modal using AJAX. + $element['media_library_open_button'] = [ + '#type' => 'link', + '#title' => $this->t('Browse media'), + '#name' => $field_name . '-media-library-open-button' . $id_suffix, + // @todo Make the view configurable in https://www.drupal.org/project/drupal/issues/2971209 + '#url' => Url::fromRoute('view.media_library.widget', [], [ + 'query' => [ + 'media_library_widget_id' => $field_name . $id_suffix, + 'media_library_allowed_types' => $element['#target_bundles'], + 'media_library_remaining' => $cardinality_unlimited ? FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED : $remaining, + ], + ]), + '#attributes' => [ + 'class' => ['button', 'use-ajax', 'media-library-open-button'], + 'data-dialog-type' => 'modal', + 'data-dialog-options' => Json::encode([ + 'dialogClass' => 'media-library-widget-modal', + 'height' => '75%', + 'width' => '75%', + 'title' => $this->t('Media library'), + ]), + ], + // Prevent errors in other widgets from preventing addition. + '#limit_validation_errors' => $limit_validation_errors, + '#access' => $cardinality_unlimited || $remaining > 0, + ]; + + // This hidden field and button are used to add new items to the widget. + $element['media_library_selection'] = [ + '#type' => 'hidden', + '#attributes' => [ + // This is used to pass the selection from the modal to the widget. + 'data-media-library-widget-value' => $field_name . $id_suffix, + ], + ]; + + // When a selection is made this hidden button is pressed to add new media + // items based on the "media_library_selection" value. + $element['media_library_update_widget'] = [ + '#type' => 'submit', + '#value' => $this->t('Update widget'), + '#name' => $field_name . '-media-library-update' . $id_suffix, + '#ajax' => [ + 'callback' => [static::class, 'updateWidget'], + 'wrapper' => $wrapper_id, + ], + '#attributes' => [ + 'data-media-library-widget-update' => $field_name . $id_suffix, + 'class' => ['js-hide'], + ], + '#validate' => [[static::class, 'validateItems']], + '#submit' => [[static::class, 'updateItems']], + // Prevent errors in other widgets from preventing updates. + '#limit_validation_errors' => $limit_validation_errors, + ]; + + return $element; + } + + /** + * {@inheritdoc} + */ + public function errorElement(array $element, ConstraintViolationInterface $error, array $form, FormStateInterface $form_state) { + return isset($element['target_id']) ? $element['target_id'] : FALSE; + } + + /** + * {@inheritdoc} + */ + public function massageFormValues(array $values, array $form, FormStateInterface $form_state) { + if (isset($values['selection'])) { + usort($values['selection'], [SortArray::class, 'sortByWeightElement']); + return $values['selection']; + } + return []; + } + + /** + * AJAX callback to update the widget when the selection changes. + * + * @param array $form + * The form array. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * + * @return array + * An array representing the updated widget. + */ + public static function updateWidget(array $form, FormStateInterface $form_state) { + $triggering_element = $form_state->getTriggeringElement(); + // This callback is either invoked from the remove button or the update + // button, which have different nesting levels. + $length = end($triggering_element['#parents']) === 'remove_button' ? -4 : -1; + if (count($triggering_element['#array_parents']) < abs($length)) { + throw new \LogicException('The element that triggered the widget update was at an unexpected depth. Triggering element parents were: ' . implode(',', $triggering_element['#array_parents'])); + } + $parents = array_slice($triggering_element['#array_parents'], 0, $length); + $element = NestedArray::getValue($form, $parents); + // Always clear the textfield selection to prevent duplicate additions. + $element['media_library_selection']['#value'] = ''; + return $element; + } + + /** + * Submit callback for remove buttons. + * + * @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) { + $triggering_element = $form_state->getTriggeringElement(); + + // Get the parents required to find the top-level widget element. + if (count($triggering_element['#array_parents']) < 4) { + throw new \LogicException('Expected the remove button to be more than four levels deep in the form. Triggering element parents were: ' . implode(',', $triggering_element['#array_parents'])); + } + $parents = array_slice($triggering_element['#array_parents'], 0, -4); + // Get the delta of the item being removed. + $delta = array_slice($triggering_element['#array_parents'], -3, 1)[0]; + $element = NestedArray::getValue($form, $parents); + + // Get the field state. + $path = $element['#parents']; + $values = NestedArray::getValue($form_state->getValues(), $path); + $field_state = static::getFieldState($element, $form_state); + + // Remove the item from the field state and update it. + if (isset($values['selection'][$delta])) { + array_splice($values['selection'], $delta, 1); + $field_state['items'] = $values['selection']; + static::setFieldState($element, $form_state, $field_state); + } + + $form_state->setRebuild(); + } + + /** + * Validates that newly selected items can be added to the widget. + * + * Making an invalid selection from the view should not be possible, but we + * still validate in case other selection methods (ex: upload) are valid. + * + * @param array $form + * The form array. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + */ + public static function validateItems(array $form, FormStateInterface $form_state) { + $button = $form_state->getTriggeringElement(); + $element = NestedArray::getValue($form, array_slice($button['#array_parents'], 0, -1)); + + $field_state = static::getFieldState($element, $form_state); + $media = static::getNewMediaItems($element, $form_state); + if (empty($media)) { + return; + } + + // Check if more items were selected than we allow. + $cardinality_unlimited = ($element['#cardinality'] === FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED); + $selection = count($field_state['items']) + count($media); + if (!$cardinality_unlimited && ($selection > $element['#cardinality'])) { + $form_state->setError($element, \Drupal::translation()->formatPlural($element['#cardinality'], 'Only one item can be selected.', 'Only @count items can be selected.')); + } + + // Validate that each selected media is of an allowed bundle. + $all_bundles = \Drupal::service('entity_type.bundle.info')->getBundleInfo('media'); + $bundle_labels = array_map(function ($bundle) use ($all_bundles) { + return $all_bundles[$bundle]['label']; + }, $element['#target_bundles']); + foreach ($media as $media_item) { + if ($element['#target_bundles'] && !in_array($media_item->bundle(), $element['#target_bundles'], TRUE)) { + $form_state->setError($element, t('The media item "@label" is not of an accepted type. Allowed types: @types', [ + '@label' => $media_item->label(), + '@types' => implode(', ', $bundle_labels), + ])); + } + } + } + + /** + * Updates the field state and flags the form for 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(); + $element = NestedArray::getValue($form, array_slice($button['#array_parents'], 0, -1)); + + $field_state = static::getFieldState($element, $form_state); + + $media = static::getNewMediaItems($element, $form_state); + if (!empty($media)) { + $weight = count($field_state['items']); + foreach ($media as $media_item) { + // Any ID can be passed to the widget, so we have to check access. + if ($media_item->access('view')) { + $field_state['items'][] = [ + 'target_id' => $media_item->id(), + 'weight' => $weight++, + ]; + } + } + static::setFieldState($element, $form_state, $field_state); + } + + $form_state->setRebuild(); + } + + /** + * Gets newly selected media items. + * + * @param array $element + * The wrapping element for this widget. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + * + * @return \Drupal\media\MediaInterface[] + * An array of selected media items. + */ + protected static function getNewMediaItems(array $element, FormStateInterface $form_state) { + // Get the new media IDs passed to our hidden button. + $values = $form_state->getValues(); + $path = $element['#parents']; + $value = NestedArray::getValue($values, $path); + + if (!empty($value['media_library_selection'])) { + $ids = explode(',', $value['media_library_selection']); + $ids = array_filter($ids, 'is_numeric'); + if (!empty($ids)) { + /** @var \Drupal\media\MediaInterface[] $media */ + return Media::loadMultiple($ids); + } + } + return []; + } + + /** + * Gets the field state for the widget. + * + * @param array $element + * The wrapping element for this widget. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + * + * @return array[] + * An array of arrays with the following key/value pairs: + * - items: (array) An array of selections. + * - target_id: (int) A media entity ID. + * - weight: (int) A weight for the selection. + */ + protected static function getFieldState(array $element, FormStateInterface $form_state) { + $widget_state = static::getWidgetState($element['#field_parents'], $element['#field_name'], $form_state); + $widget_state['items'] = isset($widget_state['items']) ? $widget_state['items'] : []; + return $widget_state; + } + + /** + * Sets the field state for the widget. + * + * @param array $element + * The wrapping element for this widget. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + * @param array[] $field_state + * An array of arrays with the following key/value pairs: + * - items: (array) An array of selections. + * - target_id: (int) A media entity ID. + * - weight: (int) A weight for the selection. + */ + protected static function setFieldState(array $element, FormStateInterface $form_state, array $field_state) { + static::setWidgetState($element['#field_parents'], $element['#field_name'], $form_state, $field_state); + } + +} diff --git a/core/modules/media_library/src/Plugin/views/field/MediaLibrarySelectForm.php b/core/modules/media_library/src/Plugin/views/field/MediaLibrarySelectForm.php new file mode 100644 index 0000000000000000000000000000000000000000..72a4b50bbfcbb9b8a9cb69e1080b4fd35d2da67c --- /dev/null +++ b/core/modules/media_library/src/Plugin/views/field/MediaLibrarySelectForm.php @@ -0,0 +1,128 @@ +options['id'] . '--' . $row->index . '-->'; + } + + /** + * {@inheritdoc} + */ + public function render(ResultRow $values) { + return ViewsRenderPipelineMarkup::create($this->getValue($values)); + } + + /** + * Form constructor for the media library select form. + * + * @param array $form + * An associative array containing the structure of the form. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + */ + public function viewsForm(array &$form, FormStateInterface $form_state) { + // Only add the bulk form options and buttons if there are results. + if (empty($this->view->result)) { + return; + } + + // Render checkboxes for all rows. + $form[$this->options['id']]['#tree'] = TRUE; + foreach ($this->view->result as $row_index => $row) { + $entity = $this->getEntity($row); + $form[$this->options['id']][$row_index] = [ + '#type' => 'checkbox', + '#title' => $this->t('Select @label', [ + '@label' => $entity->label(), + ]), + '#title_display' => 'invisible', + '#return_value' => $entity->id(), + ]; + } + + // @todo Remove in https://www.drupal.org/project/drupal/issues/2504115 + // Currently the default URL for all AJAX form elements is the current URL, + // not the form action. This causes bugs when this form is rendered from an + // AJAX path like /views/ajax, which cannot process AJAX form submits. + $url = parse_url($form['#action'], PHP_URL_PATH); + $query = \Drupal::request()->query->all(); + $query[FormBuilderInterface::AJAX_FORM_REQUEST] = TRUE; + $form['actions']['submit']['#ajax'] = [ + 'url' => Url::fromUserInput($url), + 'options' => [ + 'query' => $query, + ], + 'callback' => [static::class, 'updateWidget'], + ]; + + $form['actions']['submit']['#value'] = $this->t('Select media'); + $form['actions']['submit']['#field_id'] = $this->options['id']; + } + + /** + * Submit handler for the media library select form. + * + * @param array $form + * An associative array containing the structure of the form. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + * + * @return \Drupal\Core\Ajax\AjaxResponse + * A command to send the selection to the current field widget. + */ + public static function updateWidget(array &$form, FormStateInterface $form_state) { + $widget_id = \Drupal::request()->query->get('media_library_widget_id'); + if (!$widget_id || !is_string($widget_id)) { + throw new BadRequestHttpException('The "media_library_widget_id" query parameter is required and must be a string.'); + } + $field_id = $form_state->getTriggeringElement()['#field_id']; + $selected = array_values(array_filter($form_state->getValue($field_id, []))); + // Pass the selection to the field widget based on the current widget ID. + return (new AjaxResponse()) + ->addCommand(new InvokeCommand("[data-media-library-widget-value=\"$widget_id\"]", 'val', [implode(',', $selected)])) + ->addCommand(new InvokeCommand("[data-media-library-widget-update=\"$widget_id\"]", 'trigger', ['mousedown'])) + ->addCommand(new CloseDialogCommand()); + } + + /** + * {@inheritdoc} + */ + public function viewsFormValidate(array &$form, FormStateInterface $form_state) { + $selected = array_filter($form_state->getValue($this->options['id'])); + if (empty($selected)) { + $form_state->setErrorByName('', $this->t('No items selected.')); + } + } + + /** + * {@inheritdoc} + */ + public function clickSortable() { + return FALSE; + } + +} diff --git a/core/modules/media_library/tests/modules/media_library_test/config/install/core.entity_form_display.node.basic_page.default.yml b/core/modules/media_library/tests/modules/media_library_test/config/install/core.entity_form_display.node.basic_page.default.yml new file mode 100644 index 0000000000000000000000000000000000000000..071486373f519c2e890a7ea1127edb6783f324aa --- /dev/null +++ b/core/modules/media_library/tests/modules/media_library_test/config/install/core.entity_form_display.node.basic_page.default.yml @@ -0,0 +1,71 @@ +langcode: en +status: true +dependencies: + config: + - field.field.node.basic_page.field_twin_media + - field.field.node.basic_page.field_unlimited_media + - node.type.basic_page + module: + - media_library +id: node.basic_page.default +targetEntityType: node +bundle: basic_page +mode: default +content: + created: + type: datetime_timestamp + weight: 10 + region: content + settings: { } + third_party_settings: { } + field_twin_media: + type: media_library_widget + weight: 122 + settings: { } + third_party_settings: { } + region: content + field_unlimited_media: + type: media_library_widget + weight: 121 + settings: { } + third_party_settings: { } + region: content + promote: + type: boolean_checkbox + settings: + display_label: true + weight: 15 + region: content + third_party_settings: { } + status: + type: boolean_checkbox + settings: + display_label: true + weight: 120 + region: content + third_party_settings: { } + sticky: + type: boolean_checkbox + settings: + display_label: true + weight: 16 + region: content + third_party_settings: { } + title: + type: string_textfield + weight: -5 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } + uid: + type: entity_reference_autocomplete + weight: 5 + settings: + match_operator: CONTAINS + size: 60 + placeholder: '' + region: content + third_party_settings: { } +hidden: { } diff --git a/core/modules/media_library/tests/modules/media_library_test/config/install/core.entity_view_display.node.basic_page.default.yml b/core/modules/media_library/tests/modules/media_library_test/config/install/core.entity_view_display.node.basic_page.default.yml new file mode 100644 index 0000000000000000000000000000000000000000..b747e2ae8478e25f242ff9c5f443f9e4132eab5d --- /dev/null +++ b/core/modules/media_library/tests/modules/media_library_test/config/install/core.entity_view_display.node.basic_page.default.yml @@ -0,0 +1,38 @@ +langcode: en +status: true +dependencies: + config: + - field.field.node.basic_page.field_twin_media + - field.field.node.basic_page.field_unlimited_media + - node.type.basic_page + module: + - user +id: node.basic_page.default +targetEntityType: node +bundle: basic_page +mode: default +content: + field_twin_media: + type: entity_reference_entity_view + weight: 102 + label: above + settings: + view_mode: default + link: false + third_party_settings: { } + region: content + field_unlimited_media: + type: entity_reference_entity_view + weight: 101 + label: above + settings: + view_mode: default + link: false + third_party_settings: { } + region: content + links: + weight: 100 + settings: { } + third_party_settings: { } + region: content +hidden: { } diff --git a/core/modules/media_library/tests/modules/media_library_test/config/install/field.field.node.basic_page.field_twin_media.yml b/core/modules/media_library/tests/modules/media_library_test/config/install/field.field.node.basic_page.field_twin_media.yml new file mode 100644 index 0000000000000000000000000000000000000000..7d4d8cdc0a01ed74c83abb8bfda043759b2eec36 --- /dev/null +++ b/core/modules/media_library/tests/modules/media_library_test/config/install/field.field.node.basic_page.field_twin_media.yml @@ -0,0 +1,29 @@ +langcode: en +status: true +dependencies: + config: + - field.storage.node.field_twin_media + - media.type.type_one + - media.type.type_two + - node.type.basic_page +id: node.basic_page.field_twin_media +field_name: field_twin_media +entity_type: node +bundle: basic_page +label: 'Twin media' +description: '' +required: false +translatable: false +default_value: { } +default_value_callback: '' +settings: + handler: 'default:media' + handler_settings: + target_bundles: + type_one: type_one + type_two: type_two + sort: + field: _none + auto_create: false + auto_create_bundle: file +field_type: entity_reference diff --git a/core/modules/media_library/tests/modules/media_library_test/config/install/field.field.node.basic_page.field_unlimited_media.yml b/core/modules/media_library/tests/modules/media_library_test/config/install/field.field.node.basic_page.field_unlimited_media.yml new file mode 100644 index 0000000000000000000000000000000000000000..c1564e15c50a2b1ddefd4ca203732dfb31a109f4 --- /dev/null +++ b/core/modules/media_library/tests/modules/media_library_test/config/install/field.field.node.basic_page.field_unlimited_media.yml @@ -0,0 +1,27 @@ +langcode: en +status: true +dependencies: + config: + - field.storage.node.field_unlimited_media + - media.type.type_one + - node.type.basic_page +id: node.basic_page.field_unlimited_media +field_name: field_unlimited_media +entity_type: node +bundle: basic_page +label: 'Unlimited media' +description: '' +required: false +translatable: false +default_value: { } +default_value_callback: '' +settings: + handler: 'default:media' + handler_settings: + target_bundles: + type_one: type_one + sort: + field: _none + auto_create: false + auto_create_bundle: audio +field_type: entity_reference diff --git a/core/modules/media_library/tests/modules/media_library_test/config/install/field.storage.node.field_twin_media.yml b/core/modules/media_library/tests/modules/media_library_test/config/install/field.storage.node.field_twin_media.yml new file mode 100644 index 0000000000000000000000000000000000000000..c7b38e6a65682fe0237584c769405ea9529b5a6d --- /dev/null +++ b/core/modules/media_library/tests/modules/media_library_test/config/install/field.storage.node.field_twin_media.yml @@ -0,0 +1,19 @@ +langcode: en +status: true +dependencies: + module: + - media + - node +id: node.field_twin_media +field_name: field_twin_media +entity_type: node +type: entity_reference +settings: + target_type: media +module: core +locked: false +cardinality: 2 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/core/modules/media_library/tests/modules/media_library_test/config/install/field.storage.node.field_unlimited_media.yml b/core/modules/media_library/tests/modules/media_library_test/config/install/field.storage.node.field_unlimited_media.yml new file mode 100644 index 0000000000000000000000000000000000000000..a2391d2a10fac59cb8ec1e85dd4f374c04511348 --- /dev/null +++ b/core/modules/media_library/tests/modules/media_library_test/config/install/field.storage.node.field_unlimited_media.yml @@ -0,0 +1,19 @@ +langcode: en +status: true +dependencies: + module: + - media + - node +id: node.field_unlimited_media +field_name: field_unlimited_media +entity_type: node +type: entity_reference +settings: + target_type: media +module: core +locked: false +cardinality: -1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/core/modules/media_library/tests/modules/media_library_test/config/install/node.type.basic_page.yml b/core/modules/media_library/tests/modules/media_library_test/config/install/node.type.basic_page.yml new file mode 100644 index 0000000000000000000000000000000000000000..23abb54599c6dfe6517149dd96e1d0f8381eff8f --- /dev/null +++ b/core/modules/media_library/tests/modules/media_library_test/config/install/node.type.basic_page.yml @@ -0,0 +1,9 @@ +langcode: en +status: true +name: 'Basic Page' +type: basic_page +description: '' +help: '' +new_revision: true +preview_mode: 1 +display_submitted: true diff --git a/core/modules/media_library/tests/modules/media_library_test/media_library_test.info.yml b/core/modules/media_library/tests/modules/media_library_test/media_library_test.info.yml index 3ad1ae5f46ea498431a760d02abfed82069c12b9..41979c8eed148d2a7ccaf1a6b201b88e23534263 100644 --- a/core/modules/media_library/tests/modules/media_library_test/media_library_test.info.yml +++ b/core/modules/media_library/tests/modules/media_library_test/media_library_test.info.yml @@ -7,4 +7,5 @@ dependencies: - drupal:media_library - drupal:media_test_source - drupal:menu_ui + - drupal:node - drupal:path diff --git a/core/modules/media_library/tests/src/FunctionalJavascript/MediaLibraryTest.php b/core/modules/media_library/tests/src/FunctionalJavascript/MediaLibraryTest.php index f7852a4fb7bdd967612eb4e01c48fea2b44c4d89..ab6e8a55f8a14faed001fad6d146d0efbca8f326 100644 --- a/core/modules/media_library/tests/src/FunctionalJavascript/MediaLibraryTest.php +++ b/core/modules/media_library/tests/src/FunctionalJavascript/MediaLibraryTest.php @@ -4,6 +4,8 @@ use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\media\Entity\Media; +use Drupal\user\Entity\Role; +use Drupal\user\RoleInterface; /** * Contains Media library integration tests. @@ -26,20 +28,26 @@ protected function setUp() { // Create a few example media items for use in selection. $media = [ 'type_one' => [ - 'media_1', - 'media_2', + 'Horse', + 'Bear', + 'Cat', + 'Dog', ], 'type_two' => [ - 'media_3', - 'media_4', + 'Crocodile', + 'Lizard', + 'Snake', + 'Turtle', ], ]; + $time = time(); foreach ($media as $type => $names) { foreach ($names as $name) { $entity = Media::create(['name' => $name, 'bundle' => $type]); $source_field = $type === 'type_one' ? 'field_media_test' : 'field_media_test_1'; - $entity->set($source_field, $this->randomString()); + $entity->setCreatedTime(++$time); + $entity->set($source_field, $name); $entity->save(); } } @@ -47,7 +55,10 @@ protected function setUp() { // Create a user who can use the Media library. $user = $this->drupalCreateUser([ 'access administration pages', + 'access content', 'access media overview', + 'edit own basic_page content', + 'create basic_page content', 'create media', 'delete any media', 'view media', @@ -72,33 +83,33 @@ public function testAdministrationPage() { $assert_session->linkExists('Add media'); // Verify that media from two separate types is present. - $assert_session->pageTextContains('media_1'); - $assert_session->pageTextContains('media_3'); + $assert_session->pageTextContains('Dog'); + $assert_session->pageTextContains('Turtle'); // Test that users can filter by type. $page->selectFieldOption('Media type', 'Type One'); $page->pressButton('Apply Filters'); $assert_session->assertWaitOnAjaxRequest(); - $assert_session->pageTextContains('media_2'); - $assert_session->pageTextNotContains('media_4'); + $assert_session->pageTextContains('Dog'); + $assert_session->pageTextNotContains('Turtle'); $page->selectFieldOption('Media type', 'Type Two'); $page->pressButton('Apply Filters'); $assert_session->assertWaitOnAjaxRequest(); - $assert_session->pageTextNotContains('media_2'); - $assert_session->pageTextContains('media_4'); + $assert_session->pageTextNotContains('Dog'); + $assert_session->pageTextContains('Turtle'); // Test that selecting elements as a part of bulk operations works. $page->selectFieldOption('Media type', '- Any -'); $page->pressButton('Apply Filters'); $assert_session->assertWaitOnAjaxRequest(); // This tests that anchor tags clicked inside the preview are suppressed. - $this->getSession()->executeScript('jQuery(".js-click-to-select__trigger a")[0].click()'); + $this->getSession()->executeScript('jQuery(".js-click-to-select-trigger a")[0].click()'); $this->submitForm([], 'Apply to selected items'); - $assert_session->pageTextContains('media_1'); - $assert_session->pageTextNotContains('media_2'); + $assert_session->pageTextContains('Dog'); + $assert_session->pageTextNotContains('Cat'); $this->submitForm([], 'Delete'); - $assert_session->pageTextNotContains('media_1'); - $assert_session->pageTextContains('media_2'); + $assert_session->pageTextNotContains('Dog'); + $assert_session->pageTextContains('Cat'); // Test 'Select all media'. $this->getSession()->getPage()->checkField('Select all media'); @@ -106,9 +117,9 @@ public function testAdministrationPage() { $this->submitForm([], 'Apply to selected items'); $this->getSession()->getPage()->pressButton('Delete'); - $assert_session->pageTextNotContains('media_2'); - $assert_session->pageTextNotContains('media_3'); - $assert_session->pageTextNotContains('media_4'); + $assert_session->pageTextNotContains('Cat'); + $assert_session->pageTextNotContains('Turtle'); + $assert_session->pageTextNotContains('Snake'); // Test empty text. $assert_session->pageTextContains('No media available.'); @@ -121,4 +132,158 @@ public function testAdministrationPage() { $assert_session->linkExists('Add media'); } + /** + * Tests that the Media library's widget works as expected. + */ + public function testWidget() { + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + + // Visit a node create page. + $this->drupalGet('node/add/basic_page'); + + // Verify that both media widget instances are present. + $assert_session->pageTextContains('Unlimited media'); + $assert_session->pageTextContains('Twin media'); + + // Add to the unlimited cardinality field. + $unlimited_button = $assert_session->elementExists('css', '.media-library-open-button[href*="field_unlimited_media"]'); + $unlimited_button->click(); + $assert_session->assertWaitOnAjaxRequest(); + // Assert that only type_one media items exist, since this field only + // accepts items of that type. + $assert_session->pageTextContains('Media library'); + $assert_session->pageTextContains('Dog'); + $assert_session->pageTextContains('Bear'); + $assert_session->pageTextNotContains('Turtle'); + // Ensure that the "Select all" checkbox is not visible. + $this->assertFalse($assert_session->elementExists('css', '.media-library-select-all')->isVisible()); + // Use an exposed filter. + $session = $this->getSession(); + $session->getPage()->fillField('Name', 'Dog'); + $session->getPage()->pressButton('Apply Filters'); + $assert_session->assertWaitOnAjaxRequest(); + $assert_session->pageTextContains('Dog'); + $assert_session->pageTextNotContains('Bear'); + // Clear the exposed filter. + $session->getPage()->fillField('Name', ''); + $session->getPage()->pressButton('Apply Filters'); + $assert_session->assertWaitOnAjaxRequest(); + // Select the first three media items (should be Dog/Cat/Bear). + $checkbox_selector = '.media-library-view .js-click-to-select-checkbox input'; + $checkboxes = $page->findAll('css', $checkbox_selector); + $checkboxes[0]->click(); + $checkboxes[1]->click(); + $checkboxes[2]->click(); + $assert_session->elementExists('css', '.ui-dialog-buttonpane')->pressButton('Select media'); + $assert_session->assertWaitOnAjaxRequest(); + // Ensure that the selection completed successfully. + $assert_session->pageTextNotContains('Media library'); + $assert_session->pageTextContains('Dog'); + $assert_session->pageTextContains('Cat'); + $assert_session->pageTextContains('Bear'); + // Remove "Dog" (happens to be the first remove button on the page). + $assert_session->elementExists('css', '.media-library-item__remove')->click(); + $assert_session->assertWaitOnAjaxRequest(); + $assert_session->pageTextNotContains('Dog'); + $assert_session->pageTextContains('Cat'); + $assert_session->pageTextContains('Bear'); + + // Open another Media library on the same page. + $twin_button = $assert_session->elementExists('css', '.media-library-open-button[href*="field_twin_media"]'); + $twin_button->click(); + $assert_session->assertWaitOnAjaxRequest(); + // This field allows both media types. + $assert_session->pageTextContains('Media library'); + $assert_session->pageTextContains('Dog'); + $assert_session->pageTextContains('Turtle'); + // Attempt to select three items - the cardinality of this field is two so + // the third selection should be disabled. + $checkbox_selector = '.media-library-view .js-click-to-select-checkbox input'; + $checkboxes = $page->findAll('css', $checkbox_selector); + $this->assertFalse($checkboxes[5]->hasAttribute('disabled')); + $checkboxes[0]->click(); + $checkboxes[7]->click(); + $this->assertTrue($checkboxes[5]->hasAttribute('disabled')); + $assert_session->elementExists('css', '.ui-dialog-buttonpane')->pressButton('Select media'); + $assert_session->assertWaitOnAjaxRequest(); + // Ensure that the selection completed successfully, and we have only two + // media items of two different types. + $assert_session->pageTextNotContains('Media library'); + $assert_session->pageTextContains('Horse'); + $assert_session->pageTextContains('Turtle'); + $assert_session->pageTextNotContains('Snake'); + + // Finally, save the form. + $assert_session->elementExists('css', '.js-media-library-widget-toggle-weight')->click(); + $this->submitForm([ + 'title[0][value]' => 'My page', + 'field_unlimited_media[selection][0][weight]' => '2', + ], 'Save'); + $assert_session->pageTextContains('Basic Page My page has been created'); + // We removed this item earlier. + $assert_session->pageTextNotContains('Dog'); + // This item should not have been selected due to cardinality constraints. + $assert_session->pageTextNotContains('Snake'); + // "Cat" should come after "Bear", since we changed the weight. + $assert_session->elementExists('css', '.field--name-field-unlimited-media > .field__items > .field__item:last-child:contains("Cat")'); + // Make sure everything that was selected shows up. + $assert_session->pageTextContains('Cat'); + $assert_session->pageTextContains('Bear'); + $assert_session->pageTextContains('Horse'); + $assert_session->pageTextContains('Turtle'); + } + + /** + * Tests that the widget works as expected for anonymous users. + */ + public function testWidgetAnonymous() { + $assert_session = $this->assertSession(); + + $this->drupalLogout(); + + $role = Role::load(RoleInterface::ANONYMOUS_ID); + $role->revokePermission('view media'); + $role->save(); + + // Verify that unprivileged users can't access the widget view. + $this->drupalGet('admin/content/media-widget'); + $assert_session->responseContains('Access denied'); + + // Allow the anonymous user to create pages and view media. + $this->grantPermissions($role, [ + 'access content', + 'create basic_page content', + 'view media', + ]); + + // Ensure the widget works as an anonymous user. + $this->drupalGet('node/add/basic_page'); + + // Add to the unlimited cardinality field. + $unlimited_button = $assert_session->elementExists('css', '.media-library-open-button[href*="field_unlimited_media"]'); + $unlimited_button->click(); + $assert_session->assertWaitOnAjaxRequest(); + + // Select the first media item (should be Dog). + $checkbox_selector = '.media-library-view .js-click-to-select-checkbox input'; + $checkboxes = $this->getSession()->getPage()->findAll('css', $checkbox_selector); + $checkboxes[0]->click(); + $assert_session->elementExists('css', '.ui-dialog-buttonpane')->pressButton('Select media'); + $assert_session->assertWaitOnAjaxRequest(); + + // Ensure that the selection completed successfully. + $assert_session->pageTextNotContains('Media library'); + $assert_session->pageTextContains('Dog'); + + // Save the form. + $assert_session->elementExists('css', '.js-media-library-widget-toggle-weight')->click(); + $this->submitForm([ + 'title[0][value]' => 'My page', + 'field_unlimited_media[selection][0][weight]' => '0', + ], 'Save'); + $assert_session->pageTextContains('Basic Page My page has been created'); + $assert_session->pageTextContains('Dog'); + } + }