From 012172e51ea1862cd24c8e9c453042589aaabbc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=CC=81bor=20Hojtsy?= <gabor@hojtsy.hu> Date: Mon, 11 Feb 2019 11:30:07 +0100 Subject: [PATCH] =?UTF-8?q?Issue=20#3020716=20by=20seanB,=20lauriii,=20phe?= =?UTF-8?q?naproxima,=20G=C3=A1bor=20Hojtsy,=20dww,=20andrewmacpherson,=20?= =?UTF-8?q?alexpott,=20larowlan,=20xjm,=20benjifisher:=20Add=20vertical=20?= =?UTF-8?q?tabs=20style=20menu=20to=20media=20library?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../install/views.view.media_library.yml | 128 +++++++ .../config/schema/media_library.schema.yml | 10 + .../css/media_library.module.css | 27 +- .../media_library/css/media_library.theme.css | 137 +++++-- .../js/media_library.click_to_select.es6.js | 5 + .../media_library/js/media_library.ui.es6.js | 312 ++++++++++++++++ .../media_library/js/media_library.ui.js | 159 ++++++++ .../js/media_library.view.es6.js | 24 +- .../media_library/js/media_library.view.js | 6 +- .../js/media_library.widget.es6.js | 67 +--- .../media_library/js/media_library.widget.js | 28 -- .../media_library/media_library.install | 156 ++++++++ .../media_library/media_library.libraries.yml | 10 +- .../media_library/media_library.module | 66 +--- .../media_library/media_library.routing.yml | 6 + .../media_library/media_library.services.yml | 4 + .../src/Form/MediaLibraryUploadForm.php | 28 +- .../media_library/src/MediaLibraryState.php | 196 ++++++++++ .../src/MediaLibraryUiBuilder.php | 244 +++++++++++++ .../Field/FieldWidget/MediaLibraryWidget.php | 211 ++++++++++- .../views/field/MediaLibrarySelectForm.php | 51 ++- ...dia_library-update-widget-view-3020716.php | 111 ++++++ ...y_form_display.node.basic_page.default.yml | 7 + ...y_view_display.node.basic_page.default.yml | 9 + ...ode.basic_page.field_single_media_type.yml | 28 ++ ...d.storage.node.field_single_media_type.yml | 19 + .../MediaLibraryUpdateWidgetViewTest.php | 50 +++ .../FunctionalJavascript/MediaLibraryTest.php | 339 ++++++++++++++---- .../src/Kernel/MediaLibraryAccessTest.php | 53 +++ .../src/Kernel/MediaLibraryStateTest.php | 259 +++++++++++++ 30 files changed, 2443 insertions(+), 307 deletions(-) create mode 100644 core/modules/media_library/config/schema/media_library.schema.yml create mode 100644 core/modules/media_library/js/media_library.ui.es6.js create mode 100644 core/modules/media_library/js/media_library.ui.js create mode 100644 core/modules/media_library/media_library.services.yml create mode 100644 core/modules/media_library/src/MediaLibraryState.php create mode 100644 core/modules/media_library/src/MediaLibraryUiBuilder.php create mode 100644 core/modules/media_library/tests/fixtures/update/drupal-8.media_library-update-widget-view-3020716.php create mode 100644 core/modules/media_library/tests/modules/media_library_test/config/install/field.field.node.basic_page.field_single_media_type.yml create mode 100644 core/modules/media_library/tests/modules/media_library_test/config/install/field.storage.node.field_single_media_type.yml create mode 100644 core/modules/media_library/tests/src/Functional/Update/MediaLibraryUpdateWidgetViewTest.php create mode 100644 core/modules/media_library/tests/src/Kernel/MediaLibraryStateTest.php 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 32096a6f9cc0..b33c865e980b 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 @@ -529,11 +529,139 @@ display: defaults: fields: false access: false + filters: false + filter_groups: false + arguments: false display_description: '' access: type: perm options: perm: 'view media' + filters: + status: + id: status + table: media_field_data + field: status + relationship: none + group_type: group + admin_label: '' + operator: '=' + value: '1' + group: 1 + exposed: false + expose: + operator_id: '' + label: '' + description: '' + use_operator: false + operator: '' + identifier: '' + required: false + remember: false + multiple: false + remember_roles: + authenticated: authenticated + is_grouped: false + group_info: + label: '' + description: '' + identifier: '' + optional: true + widget: select + multiple: false + remember: false + default_group: All + default_group_multiple: { } + group_items: { } + entity_type: media + entity_field: status + plugin_id: boolean + name: + id: name + table: media_field_data + field: name + relationship: none + group_type: group + admin_label: '' + operator: contains + value: '' + group: 1 + exposed: true + expose: + operator_id: name_op + label: Name + description: '' + use_operator: false + operator: name_op + identifier: name + required: false + remember: false + multiple: false + remember_roles: + authenticated: authenticated + anonymous: '0' + administrator: '0' + is_grouped: false + group_info: + label: '' + description: '' + identifier: '' + optional: true + widget: select + multiple: false + remember: false + default_group: All + default_group_multiple: { } + group_items: { } + entity_type: media + entity_field: name + plugin_id: string + filter_groups: + operator: AND + groups: + 1: AND + 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 cache_metadata: max-age: -1 contexts: diff --git a/core/modules/media_library/config/schema/media_library.schema.yml b/core/modules/media_library/config/schema/media_library.schema.yml new file mode 100644 index 000000000000..544c18974b99 --- /dev/null +++ b/core/modules/media_library/config/schema/media_library.schema.yml @@ -0,0 +1,10 @@ +field.widget.settings.media_library_widget: + type: mapping + label: 'Media library widget settings' + mapping: + media_types: + type: sequence + label: 'Allowed media types, in display order' + sequence: + type: string + label: 'Media type ID' diff --git a/core/modules/media_library/css/media_library.module.css b/core/modules/media_library/css/media_library.module.css index 89b15800634a..694c5b33e9cf 100644 --- a/core/modules/media_library/css/media_library.module.css +++ b/core/modules/media_library/css/media_library.module.css @@ -2,6 +2,22 @@ * @file media_library.module.css */ +.media-library-wrapper { + display: flex; +} + +.media-library-menu { + margin: 0; + padding: 0; +} + +/* @todo Use a class instead of the li element. + https://www.drupal.org/project/drupal/issues/3029227 */ +.media-library-menu li { + list-style: none; + padding: 0; +} + .media-library-views-form > .form-actions { flex-basis: 100%; } @@ -33,10 +49,10 @@ .media-library-item .js-click-to-select-checkbox { position: absolute; - display: block; z-index: 1; top: 5px; right: 0; + display: block; } .media-library-item__status { @@ -69,6 +85,15 @@ pointer-events: none; } +.media-library-widget-modal .ui-dialog-buttonpane { + display: flex; + align-items: center; +} + +.media-library-widget-modal .ui-dialog-buttonpane .form-actions { + flex: 1; +} + @media screen and (max-width: 600px) { .media-library-view .form-actions { flex-basis: 100%; diff --git a/core/modules/media_library/css/media_library.theme.css b/core/modules/media_library/css/media_library.theme.css index b1bb0ab074da..61be1a750795 100644 --- a/core/modules/media_library/css/media_library.theme.css +++ b/core/modules/media_library/css/media_library.theme.css @@ -5,6 +5,84 @@ * @see https://www.drupal.org/project/drupal/issues/2980769 */ +.media-library-wrapper { + margin: -1em; +} + +/** + * @todo Reuse or build on vertical tabs styling for the media library menu. + * https://www.drupal.org/project/drupal/issues/3023767 + */ +.media-library-menu { + display: block; + width: 600px; + max-width: 20%; + margin: 0; + padding: 0; + border-bottom: 1px solid #ccc; + background-color: #e6e5e1; + line-height: 1; +} +[dir="rtl"] .media-library-menu { + margin: 0; +} + +/* @todo Use a class instead of the li element. + https://www.drupal.org/project/drupal/issues/3029227 */ +.media-library-menu li { + display: block; +} + +.media-library-menu__link { + position: relative; + display: block; + box-sizing: border-box; + padding: 10px 15px 15px; + text-decoration: none; + border-bottom: 1px solid #b3b2ad; + background-color: #f2f2f0; + text-shadow: 0 1px hsla(0, 0%, 100%, 0.6); +} + +.media-library-menu__link:active, +.media-library-menu__link:hover, +.media-library-menu__link:focus { + background: #fcfcfa; + text-shadow: none; +} + +.media-library-menu__link:focus, +.media-library-menu__link:active { + outline: none; +} + +.media-library-menu__link.active { + z-index: 1; + margin-right: -1px; + color: #000; + border-right: 1px solid #fcfcfa; + border-bottom: 1px solid #b3b2ad; + background-color: #fff; + box-shadow: 0 5px 5px -5px hsla(0, 0%, 0%, 0.3); +} +[dir="rtl"] .media-library-menu__link.active { + margin-right: 0; + margin-left: -1px; + border-right: 0; + border-left: 1px solid #fcfcfa; +} + +.media-library-content { + width: 100%; + padding: 1em; + border-left: 1px solid #b3b2ad; + outline: none; +} +[dir="rtl"] .media-library-content { + border-right: 1px solid #b3b2ad; + border-left: 0; +} + .media-library-views-form__header .form-item { margin-right: 8px; } @@ -15,12 +93,12 @@ .media-library-item { justify-content: center; + width: 180px; + margin: 16px 16px 2px 2px; + transition: border-color 0.2s, color 0.2s, background 0.2s; vertical-align: top; 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 { @@ -33,14 +111,14 @@ .media-library-view .media-library-view--form-actions { clear: left; - margin: 0.75em 0; align-self: flex-end; + margin: 0.75em 0; } .media-library-item .field--name-thumbnail { - background-color: #ebebeb; overflow: hidden; text-align: center; + background-color: #ebebeb; } .media-library-item .field--name-thumbnail img { @@ -52,10 +130,10 @@ .media-library-item.is-hover, .media-library-item.checked, .media-library-item.is-focus { - border-color: #40b6ff; + margin: 14px 14px 0 0; border-width: 3px; + border-color: #40b6ff; border-radius: 3px; - margin: 14px 14px 0 0; } .media-library-item.checked { @@ -76,11 +154,11 @@ } .media-library-item__status { + padding: 5px 10px; color: #e4e4e4; - font-style: italic; background: #666; - padding: 5px 10px; font-size: 12px; + font-style: italic; } .media-library-item .views-field-operations { @@ -88,20 +166,20 @@ } .media-library-item .views-field-operations .dropbutton-wrapper { - display: inline-block; position: absolute; right: 5px; bottom: 5px; + display: inline-block; } .media-library-item__attributes { position: absolute; bottom: 0; display: block; - padding: 5px; + overflow: hidden; max-width: calc(100% - 10px); max-height: calc(100% - 50px); - overflow: hidden; + padding: 5px; background: white; } @@ -111,10 +189,10 @@ .media-library-item__name a { display: block; - text-decoration: underline; + overflow: hidden; margin: 2px; white-space: nowrap; - overflow: hidden; + text-decoration: underline; text-overflow: ellipsis; } @@ -126,13 +204,13 @@ } .media-library-item__name a:focus { - border: 2px solid; margin: 0; + border: 2px solid; } .media-library-item__type { - font-size: 12px; color: #696969; + font-size: 12px; } .media-library-select-all { @@ -157,8 +235,8 @@ .media-library-widget__toggle-weight { position: absolute; - right: 5px; top: 5px; + right: 5px; } .media-library-item .form-item { @@ -181,13 +259,13 @@ height: 24px; margin: 5px; padding: 0; - background: url("../../../misc/icons/787878/ex.svg") #fff center no-repeat; - background-size: 16px 16px; + transition: 0.2s border-color; + color: transparent; border: 2px solid #ccc; border-radius: 20px; - color: transparent; + background: url("../../../misc/icons/787878/ex.svg") #fff center no-repeat; + background-size: 16px 16px; text-shadow: none; - transition: 0.2s border-color; } .media-library-item .media-library-item__remove:hover, @@ -202,7 +280,6 @@ .media-library-upload__media, .media-library-upload__file { display: flex; - flex-wrap: wrap; padding: 20px 0 20px 0; } @@ -224,12 +301,16 @@ } .media-library-upload__media-preview { - margin-right: 20px; - width: 220px; - background: #ebebeb; display: flex; - align-items: center; justify-content: center; + align-items: center; + width: 220px; + margin-right: 20px; + background: #ebebeb; +} +[dir="rtl"] .media-library-upload__media-preview { + margin-right: 0; + margin-left: 20px; } .media-library-upload__media-preview img { @@ -239,8 +320,8 @@ /* @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; + color: black; } @media screen and (max-width: 600px) { @@ -248,8 +329,8 @@ width: 150px; } .media-library-item .field--name-thumbnail img { - height: 150px; width: 150px; + height: 150px; } .media-library-item .views-field-operations .dropbutton-wrapper { position: relative; 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 90c58b665bb3..db42fe4790b9 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 @@ -5,6 +5,11 @@ (($, Drupal) => { /** * Allows users to select an element which checks a hidden checkbox. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches behavior for selecting media library item. */ Drupal.behaviors.ClickToSelect = { attach(context) { diff --git a/core/modules/media_library/js/media_library.ui.es6.js b/core/modules/media_library/js/media_library.ui.es6.js new file mode 100644 index 000000000000..d0aff3baf95c --- /dev/null +++ b/core/modules/media_library/js/media_library.ui.es6.js @@ -0,0 +1,312 @@ +/** + * @file media_library.widget.js + */ +(($, Drupal, window) => { + /** + * Wrapper object for the current state of the media library. + */ + Drupal.MediaLibrary = { + /** + * When a user interacts with the media library we want the selection to + * persist as long as the media library modal is opened. We temporarily + * store the selected items while the user filters the media library view or + * navigates to different tabs. + */ + currentSelection: [], + }; + + /** + * Warn users when clicking outgoing links from the library or widget. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches behavior to links in the media library. + */ + 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(); + } + }); + }, + }; + + /** + * Load media library content through AJAX. + * + * Standard AJAX links (using the 'use-ajax' class) replace the entire library + * dialog. When navigating to a media type through the vertical tabs, we only + * want to load the changed library content. This is not only more efficient, + * but also provides a more accessible user experience for screen readers. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches behavior to vertical tabs in the media library. + * + * @todo Remove when the AJAX system adds support for replacing a specific + * selector via a link. + * https://www.drupal.org/project/drupal/issues/3026636 + */ + Drupal.behaviors.MediaLibraryTabs = { + attach(context) { + const $menu = $('.js-media-library-menu'); + $menu + .find('a', context) + .once('media-library-menu-item') + .on('click', e => { + e.preventDefault(); + e.stopPropagation(); + + // Replace the library content. + const ajaxObject = Drupal.ajax({ + wrapper: 'media-library-content', + url: e.currentTarget.href, + dialogType: 'ajax', + progress: { + type: 'fullscreen', + message: Drupal.t('Please wait...'), + }, + }); + + // Override the AJAX success callback to shift focus to the media + // library content. + ajaxObject.success = function(response, status) { + // Remove the progress element. + if (this.progress.element) { + $(this.progress.element).remove(); + } + if (this.progress.object) { + this.progress.object.stopMonitoring(); + } + $(this.element).prop('disabled', false); + + // Execute the AJAX commands. + Object.keys(response || {}).forEach(i => { + if (response[i].command && this.commands[response[i].command]) { + this.commands[response[i].command](this, response[i], status); + } + }); + + // Set focus to the media library content. + document.getElementById('media-library-content').focus(); + + // Remove any response-specific settings so they don't get used on + // the next call by mistake. + this.settings = null; + }; + ajaxObject.execute(); + + // Set the active tab. + $menu.find('.active-tab').remove(); + $menu.find('a').removeClass('active'); + $(e.currentTarget) + .addClass('active') + .html( + Drupal.t( + '@title<span class="active-tab visually-hidden"> (active tab)</span>', + { '@title': $(e.currentTarget).html() }, + ), + ); + }); + }, + }; + + /** + * Update the media library selection when loaded or media items are selected. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches behavior to select media items. + */ + Drupal.behaviors.MediaLibraryItemSelection = { + attach(context, settings) { + const $form = $('.js-media-library-views-form', context); + const currentSelection = Drupal.MediaLibrary.currentSelection; + + if (!$form.length) { + return; + } + + const $mediaItems = $( + '.js-media-library-item input[type="checkbox"]', + $form, + ); + + // Update the selection array and the hidden form field when a media item + // is selected. + $mediaItems.once('media-item-change').on('change', e => { + const id = e.currentTarget.value; + + // Update the selection. + const position = currentSelection.indexOf(id); + if (e.currentTarget.checked) { + // Check if the ID is not already in the selection and add if needed. + if (position === -1) { + currentSelection.push(id); + } + } else if (position !== -1) { + // Remove the ID when it is in the current selection. + currentSelection.splice(position, 1); + } + + // Set the selection in the hidden form element. + $form + .find('#media-library-modal-selection') + .val(currentSelection.join()) + .trigger('change'); + }); + + /** + * Disable media items. + * + * @param {jQuery} $items + * A jQuery object representing the media items that should be disabled. + */ + function disableItems($items) { + $items + .prop('disabled', true) + .closest('.js-media-library-item') + .addClass('media-library-item--disabled'); + } + + /** + * Enable media items. + * + * @param {jQuery} $items + * A jQuery object representing the media items that should be enabled. + */ + function enableItems($items) { + $items + .prop('disabled', false) + .closest('.js-media-library-item') + .removeClass('media-library-item--disabled'); + } + + /** + * Update the number of selected items in the button pane. + * + * @param {number} remaining + * The number of remaining slots. + */ + function updateSelectionInfo(remaining) { + const $buttonPane = $( + '.media-library-widget-modal .ui-dialog-buttonpane', + ); + if (!$buttonPane.length) { + return; + } + + // Add the selection count. + const latestCount = Drupal.theme( + 'mediaLibrarySelectionCount', + Drupal.MediaLibrary.currentSelection, + remaining, + ); + const $existingCount = $buttonPane.find( + '.media-library-selected-count', + ); + if ($existingCount.length) { + $existingCount.replaceWith(latestCount); + } else { + $buttonPane.append(latestCount); + } + } + + // The hidden selection form field changes when the selection is updated. + $('#media-library-modal-selection', $form) + .once('media-library-selection-change') + .on('change', e => { + updateSelectionInfo(settings.media_library.selection_remaining); + + // Prevent users from selecting more items than allowed. + if ( + currentSelection.length === + settings.media_library.selection_remaining + ) { + disableItems($mediaItems.not(':checked')); + enableItems($mediaItems.filter(':checked')); + } else { + enableItems($mediaItems); + } + }); + + // Apply the current selection to the media library view. Changing the + // checkbox values triggers the change event for the media items. The + // change event handles updating the hidden selection field for the form. + currentSelection.forEach(value => { + $form + .find(`input[type="checkbox"][value="${value}"]`) + .prop('checked', true) + .trigger('change'); + }); + + // Hide selection button if nothing is selected. We can't use the + // context here because the dialog copies the select button. + $(window) + .once('media-library-toggle-buttons') + .on('dialog:aftercreate', () => { + updateSelectionInfo(settings.media_library.selection_remaining); + }); + }, + }; + + /** + * Clear the current selection. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches behavior to clear the selection when the library modal closes. + */ + Drupal.behaviors.MediaLibraryModalClearSelection = { + attach() { + $(window) + .once('media-library-clear-selection') + .on('dialog:afterclose', () => { + Drupal.MediaLibrary.currentSelection = []; + }); + }, + }; + + /** + * Theme function for the selection count. + * + * @param {Array.<number>} selection + * An array containing the selected media item IDs. + * @param {number} remaining + * The number of remaining slots. + * + * @return {string} + * The corresponding HTML. + */ + Drupal.theme.mediaLibrarySelectionCount = function(selection, remaining) { + // When the remaining number of items is -1, we allow an unlimited number of + // items. In that case we don't want to show the number of remaining slots. + let selectItemsText = Drupal.formatPlural( + remaining, + '@selected of @count item selected', + '@selected of @count items selected', + { + '@selected': selection.length, + }, + ); + if (remaining === -1) { + selectItemsText = Drupal.formatPlural( + selection.length, + '1 item selected', + '@count items selected', + ); + } + return `<div class="media-library-selected-count" aria-live="polite">${selectItemsText}</div>`; + }; +})(jQuery, Drupal, window); diff --git a/core/modules/media_library/js/media_library.ui.js b/core/modules/media_library/js/media_library.ui.js new file mode 100644 index 000000000000..163f989bdca8 --- /dev/null +++ b/core/modules/media_library/js/media_library.ui.js @@ -0,0 +1,159 @@ +/** +* DO NOT EDIT THIS FILE. +* See the following change record for more information, +* https://www.drupal.org/node/2815083 +* @preserve +**/ + +(function ($, Drupal, window) { + Drupal.MediaLibrary = { + currentSelection: [] + }; + + 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.MediaLibraryTabs = { + attach: function attach(context) { + var $menu = $('.js-media-library-menu'); + $menu.find('a', context).once('media-library-menu-item').on('click', function (e) { + e.preventDefault(); + e.stopPropagation(); + + var ajaxObject = Drupal.ajax({ + wrapper: 'media-library-content', + url: e.currentTarget.href, + dialogType: 'ajax', + progress: { + type: 'fullscreen', + message: Drupal.t('Please wait...') + } + }); + + ajaxObject.success = function (response, status) { + var _this = this; + + if (this.progress.element) { + $(this.progress.element).remove(); + } + if (this.progress.object) { + this.progress.object.stopMonitoring(); + } + $(this.element).prop('disabled', false); + + Object.keys(response || {}).forEach(function (i) { + if (response[i].command && _this.commands[response[i].command]) { + _this.commands[response[i].command](_this, response[i], status); + } + }); + + document.getElementById('media-library-content').focus(); + + this.settings = null; + }; + ajaxObject.execute(); + + $menu.find('.active-tab').remove(); + $menu.find('a').removeClass('active'); + $(e.currentTarget).addClass('active').html(Drupal.t('@title<span class="active-tab visually-hidden"> (active tab)</span>', { '@title': $(e.currentTarget).html() })); + }); + } + }; + + Drupal.behaviors.MediaLibraryItemSelection = { + attach: function attach(context, settings) { + var $form = $('.js-media-library-views-form', context); + var currentSelection = Drupal.MediaLibrary.currentSelection; + + if (!$form.length) { + return; + } + + var $mediaItems = $('.js-media-library-item input[type="checkbox"]', $form); + + $mediaItems.once('media-item-change').on('change', function (e) { + var id = e.currentTarget.value; + + var position = currentSelection.indexOf(id); + if (e.currentTarget.checked) { + if (position === -1) { + currentSelection.push(id); + } + } else if (position !== -1) { + currentSelection.splice(position, 1); + } + + $form.find('#media-library-modal-selection').val(currentSelection.join()).trigger('change'); + }); + + function disableItems($items) { + $items.prop('disabled', true).closest('.js-media-library-item').addClass('media-library-item--disabled'); + } + + function enableItems($items) { + $items.prop('disabled', false).closest('.js-media-library-item').removeClass('media-library-item--disabled'); + } + + function updateSelectionInfo(remaining) { + var $buttonPane = $('.media-library-widget-modal .ui-dialog-buttonpane'); + if (!$buttonPane.length) { + return; + } + + var latestCount = Drupal.theme('mediaLibrarySelectionCount', Drupal.MediaLibrary.currentSelection, remaining); + var $existingCount = $buttonPane.find('.media-library-selected-count'); + if ($existingCount.length) { + $existingCount.replaceWith(latestCount); + } else { + $buttonPane.append(latestCount); + } + } + + $('#media-library-modal-selection', $form).once('media-library-selection-change').on('change', function (e) { + updateSelectionInfo(settings.media_library.selection_remaining); + + if (currentSelection.length === settings.media_library.selection_remaining) { + disableItems($mediaItems.not(':checked')); + enableItems($mediaItems.filter(':checked')); + } else { + enableItems($mediaItems); + } + }); + + currentSelection.forEach(function (value) { + $form.find('input[type="checkbox"][value="' + value + '"]').prop('checked', true).trigger('change'); + }); + + $(window).once('media-library-toggle-buttons').on('dialog:aftercreate', function () { + updateSelectionInfo(settings.media_library.selection_remaining); + }); + } + }; + + Drupal.behaviors.MediaLibraryModalClearSelection = { + attach: function attach() { + $(window).once('media-library-clear-selection').on('dialog:afterclose', function () { + Drupal.MediaLibrary.currentSelection = []; + }); + } + }; + + Drupal.theme.mediaLibrarySelectionCount = function (selection, remaining) { + var selectItemsText = Drupal.formatPlural(remaining, '@selected of @count item selected', '@selected of @count items selected', { + '@selected': selection.length + }); + if (remaining === -1) { + selectItemsText = Drupal.formatPlural(selection.length, '1 item selected', '@count items selected'); + } + return '<div class="media-library-selected-count" aria-live="polite">' + selectItemsText + '</div>'; + }; +})(jQuery, Drupal, window); \ No newline at end of file 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 dcda58d62ecf..8bc7fc1c131a 100644 --- a/core/modules/media_library/js/media_library.view.es6.js +++ b/core/modules/media_library/js/media_library.view.es6.js @@ -4,13 +4,15 @@ (($, Drupal) => { /** * Adds hover effect to media items. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches behavior to add a class when hovering over media items. */ Drupal.behaviors.MediaLibraryHover = { attach(context) { - $( - '.media-library-item .js-click-to-select-trigger,.media-library-item .js-click-to-select-checkbox', - context, - ) + $('.js-click-to-select-trigger, .js-click-to-select-checkbox', context) .once('media-library-item-hover') .on('mouseover mouseout', ({ currentTarget, type }) => { $(currentTarget) @@ -22,10 +24,15 @@ /** * Adds focus effect to media items. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches behavior to add a focus effect to media items. */ Drupal.behaviors.MediaLibraryFocus = { attach(context) { - $('.media-library-item .js-click-to-select-checkbox input', context) + $('.js-click-to-select-checkbox input', context) .once('media-library-item-focus') .on('focus blur', ({ currentTarget, type }) => { $(currentTarget) @@ -37,10 +44,15 @@ /** * Adds checkbox to select all items in the library. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches behavior to select all media items. */ Drupal.behaviors.MediaLibrarySelectAll = { attach(context) { - const $view = $('.media-library-view', context).once( + const $view = $('.js-media-library-view', context).once( 'media-library-select-all', ); if ($view.length && $view.find('.media-library-item').length) { diff --git a/core/modules/media_library/js/media_library.view.js b/core/modules/media_library/js/media_library.view.js index 18facca93a05..73028f6af81d 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) { + $('.js-click-to-select-trigger, .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) { + $('.js-click-to-select-checkbox input', context).once('media-library-item-focus').on('focus blur', function (_ref2) { var currentTarget = _ref2.currentTarget, type = _ref2.type; @@ -30,7 +30,7 @@ Drupal.behaviors.MediaLibrarySelectAll = { attach: function attach(context) { - var $view = $('.media-library-view', context).once('media-library-select-all'); + var $view = $('.js-media-library-view', context).once('media-library-select-all'); if ($view.length && $view.find('.media-library-item').length) { var $checkbox = $('<input type="checkbox" class="form-checkbox" />').on('click', function (_ref3) { var currentTarget = _ref3.currentTarget; diff --git a/core/modules/media_library/js/media_library.widget.es6.js b/core/modules/media_library/js/media_library.widget.es6.js index a784cb3884cb..93878a0f6a34 100644 --- a/core/modules/media_library/js/media_library.widget.es6.js +++ b/core/modules/media_library/js/media_library.widget.es6.js @@ -4,6 +4,11 @@ (($, Drupal) => { /** * Allows users to re-order their selection with drag+drop. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches behavior to re-order selected media items. */ Drupal.behaviors.MediaLibraryWidgetSortable = { attach(context) { @@ -30,6 +35,11 @@ /** * Allows selection order to be set without drag+drop for accessibility. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches behavior to toggle the weight field for media items. */ Drupal.behaviors.MediaLibraryWidgetToggleWeight = { attach(context) { @@ -60,61 +70,4 @@ .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 index ad6fbd76e5de..f2fcf82b3b01 100644 --- a/core/modules/media_library/js/media_library.widget.js +++ b/core/modules/media_library/js/media_library.widget.js @@ -36,32 +36,4 @@ $('.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.install b/core/modules/media_library/media_library.install index 670d7a36c7c6..3123d8e954b7 100644 --- a/core/modules/media_library/media_library.install +++ b/core/modules/media_library/media_library.install @@ -76,3 +76,159 @@ function media_library_update_8701() { ]); $image_style->save(); } + +/** + * Updates the media library view widget display (contextual) filters. + */ +function media_library_update_8702() { + $view = \Drupal::configFactory()->getEditable('views.view.media_library'); + if ($view && $view->get('display.widget')) { + $view->set('display.widget.display_options.defaults.filters', FALSE); + $view->set('display.widget.display_options.defaults.filter_groups', FALSE); + $view->set('display.widget.display_options.defaults.arguments', FALSE); + $view->set('display.widget.display_options.filters', [ + 'status' => [ + 'id' => 'status', + 'table' => 'media_field_data', + 'field' => 'status', + 'relationship' => 'none', + 'group_type' => 'group', + 'admin_label' => '', + 'operator' => '=', + 'value' => '1', + 'group' => 1, + 'exposed' => FALSE, + 'expose' => [ + 'operator_id' => '', + 'label' => '', + 'description' => '', + 'use_operator' => FALSE, + 'operator' => '', + 'identifier' => '', + 'required' => FALSE, + 'remember' => FALSE, + 'multiple' => FALSE, + 'remember_roles' => [ + 'authenticated' => 'authenticated', + ], + ], + 'is_grouped' => FALSE, + 'group_info' => [ + 'label' => '', + 'description' => '', + 'identifier' => '', + 'optional' => TRUE, + 'widget' => 'select', + 'multiple' => FALSE, + 'remember' => FALSE, + 'default_group' => 'All', + 'default_group_multiple' => [], + 'group_items' => [], + ], + 'entity_type' => 'media', + 'entity_field' => 'status', + 'plugin_id' => 'boolean', + ], + 'name' => [ + 'id' => 'name', + 'table' => 'media_field_data', + 'field' => 'name', + 'relationship' => 'none', + 'group_type' => 'group', + 'admin_label' => '', + 'operator' => 'contains', + 'value' => '', + 'group' => 1, + 'exposed' => TRUE, + 'expose' => [ + 'operator_id' => 'name_op', + 'label' => 'Name', + 'description' => '', + 'use_operator' => FALSE, + 'operator' => 'name_op', + 'identifier' => 'name', + 'required' => FALSE, + 'remember' => FALSE, + 'multiple' => FALSE, + 'remember_roles' => [ + 'authenticated' => 'authenticated', + 'anonymous' => '0', + 'administrator' => '0', + ], + ], + 'is_grouped' => FALSE, + 'group_info' => [ + 'label' => '', + 'description' => '', + 'identifier' => '', + 'optional' => TRUE, + 'widget' => 'select', + 'multiple' => FALSE, + 'remember' => FALSE, + 'default_group' => 'All', + 'default_group_multiple' => [], + 'group_items' => [], + ], + 'entity_type' => 'media', + 'entity_field' => 'name', + 'plugin_id' => 'string', + ], + ]); + $view->set('display.widget.display_options.filter_groups', [ + 'operator' => 'AND', + 'groups' => [ + 1 => 'AND', + ], + ]); + $view->set('display.widget.display_options.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', + ], + ]); + $view->save(); + } +} diff --git a/core/modules/media_library/media_library.libraries.yml b/core/modules/media_library/media_library.libraries.yml index 848222543e4f..b7e0408d2f04 100644 --- a/core/modules/media_library/media_library.libraries.yml +++ b/core/modules/media_library/media_library.libraries.yml @@ -27,8 +27,14 @@ widget: js: js/media_library.widget.js: {} dependencies: - - core/drupal.ajax - core/jquery.ui.sortable + - core/jquery.once + +ui: + version: VERSION + js: + js/media_library.ui.js: {} + dependencies: + - core/drupal.ajax - 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 a0d12fa929e0..b5d5d1d3c761 100644 --- a/core/modules/media_library/media_library.module +++ b/core/modules/media_library/media_library.module @@ -22,10 +22,9 @@ use Drupal\image\Plugin\Field\FieldType\ImageItem; use Drupal\media\MediaTypeForm; use Drupal\media\MediaTypeInterface; +use Drupal\media_library\MediaLibraryState; 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; /** @@ -90,11 +89,7 @@ function media_library_views_post_render(ViewExecutable $view, &$output, CachePl 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', - ])); + $query = MediaLibraryState::fromRequest(\Drupal::request())->all(); // 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 @@ -217,49 +212,6 @@ function _media_library_media_type_form_submit(array &$form, FormStateInterface } } -/** - * 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(). */ @@ -303,20 +255,6 @@ function media_library_image_style_access(EntityInterface $entity, $operation, A } } -/** - * 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 []; -} - /** * Ensures that the given media type has a media_library form display. * diff --git a/core/modules/media_library/media_library.routing.yml b/core/modules/media_library/media_library.routing.yml index 1724760acb1e..8f0fb5f87e12 100644 --- a/core/modules/media_library/media_library.routing.yml +++ b/core/modules/media_library/media_library.routing.yml @@ -4,3 +4,9 @@ media_library.upload: _form: '\Drupal\media_library\Form\MediaLibraryUploadForm' requirements: _custom_access: '\Drupal\media_library\Form\MediaLibraryUploadForm::access' +media_library.ui: + path: '/media-library' + defaults: + _controller: 'media_library.ui_builder:buildUi' + requirements: + _custom_access: 'media_library.ui_builder:checkAccess' diff --git a/core/modules/media_library/media_library.services.yml b/core/modules/media_library/media_library.services.yml new file mode 100644 index 000000000000..9550f5190b98 --- /dev/null +++ b/core/modules/media_library/media_library.services.yml @@ -0,0 +1,4 @@ +services: + media_library.ui_builder: + class: Drupal\media_library\MediaLibraryUiBuilder + arguments: ['@entity_type.manager', '@request_stack', '@views.executable'] diff --git a/core/modules/media_library/src/Form/MediaLibraryUploadForm.php b/core/modules/media_library/src/Form/MediaLibraryUploadForm.php index e897a2178ccb..66670ffe1bcd 100644 --- a/core/modules/media_library/src/Form/MediaLibraryUploadForm.php +++ b/core/modules/media_library/src/Form/MediaLibraryUploadForm.php @@ -18,6 +18,8 @@ use Drupal\file\Plugin\Field\FieldType\FileItem; use Drupal\media\MediaInterface; use Drupal\media\MediaTypeInterface; +use Drupal\media_library\MediaLibraryState; +use Drupal\media_library\Plugin\Field\FieldWidget\MediaLibraryWidget; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; @@ -312,26 +314,24 @@ public function selectType(array &$form, FormStateInterface $form_state) { * * @return \Drupal\Core\Ajax\AjaxResponse * A command to send the selection to the current field widget. - * - * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException - * If the "media_library_widget_id" query parameter is not present. */ public function updateWidget(array &$form, FormStateInterface $form_state) { if ($form_state->getErrors()) { return $form; } - $widget_id = $this->getRequest()->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.'); - } + $mids = array_map(function (MediaInterface $media) { return $media->id(); }, $this->media); + // 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(',', $mids)])) - ->addCommand(new InvokeCommand("[data-media-library-widget-update=\"$widget_id\"]", 'trigger', ['mousedown'])) - ->addCommand(new CloseDialogCommand()); + $opener_id = MediaLibraryState::fromRequest($this->getRequest())->getOpenerId(); + if ($field_id = MediaLibraryWidget::getOpenerFieldId($opener_id)) { + return (new AjaxResponse()) + ->addCommand(new InvokeCommand("[data-media-library-widget-value=\"$field_id\"]", 'val', [implode(',', $mids)])) + ->addCommand(new InvokeCommand("[data-media-library-widget-update=\"$field_id\"]", 'trigger', ['mousedown'])) + ->addCommand(new CloseDialogCommand()); + } } /** @@ -484,9 +484,11 @@ protected function getTypes(array $allowed_types = NULL) { if (!isset($this->types)) { $media_type_storage = $this->entityTypeManager->getStorage('media_type'); if (!$allowed_types) { - $allowed_types = _media_library_get_allowed_types() ?: NULL; + $types = $media_type_storage->loadMultiple(MediaLibraryState::fromRequest($this->getRequest())->getAllowedTypeIds()); + } + else { + $types = $media_type_storage->loadMultiple($allowed_types); } - $types = $media_type_storage->loadMultiple($allowed_types); $types = $this->filterTypesWithFileSource($types); $types = $this->filterTypesWithCreateAccess($types); $this->types = $types; diff --git a/core/modules/media_library/src/MediaLibraryState.php b/core/modules/media_library/src/MediaLibraryState.php new file mode 100644 index 000000000000..ea740dba8003 --- /dev/null +++ b/core/modules/media_library/src/MediaLibraryState.php @@ -0,0 +1,196 @@ +<?php + +namespace Drupal\media_library; + +use Symfony\Component\HttpFoundation\ParameterBag; +use Symfony\Component\HttpFoundation\Request; + +/** + * A value object for the media library state. + * + * When the media library is opened it needs several parameters to work + * properly. The parameters are retrieved from the MediaLibraryState value + * object. Since the parameters are passed via the URL, the value object is + * extended from ParameterBag. This also allows an opener to add extra + * parameters if needed. The following parameters are needed to open the media + * library: + * - media_library_opener_id: The opener ID is used to describe the "thing" that + * opened the media library. Most of the time this is going to be a form + * field. + * - media_library_allowed_types: The media types available in the library can + * be restricted to a list of allowed types. This should be an array of media + * type IDs. + * - media_library_selected_type: The media library contains tabs to navigate + * between the different media types. The selected type contains the ID of the + * media type whose tab that should be opened. + * - media_library_remaining: When the opener wants to limit the amount of media + * items that can be selected, it can pass the number of remaining slots. When + * the number of remaining slots is a negative number, an unlimited amount of + * items can be selected. + * + * @internal + * This class is an internal part of the media library and should not be + * instantiated or used by external code. + */ +class MediaLibraryState extends ParameterBag { + + /** + * {@inheritdoc} + */ + public function __construct(array $parameters = []) { + $this->validateParameters($parameters['media_library_opener_id'], $parameters['media_library_allowed_types'], $parameters['media_library_selected_type'], $parameters['media_library_remaining']); + parent::__construct($parameters); + } + + /** + * Creates a new MediaLibraryState object. + * + * @param string $opener_id + * The opener ID. + * @param string[] $allowed_media_type_ids + * The allowed media type IDs. + * @param string $selected_type_id + * The selected media type ID. + * @param int $remaining_slots + * The number of remaining items the user is allowed to select or add in the + * library. + * + * @return \Drupal\media_library\MediaLibraryState + * A state object. + */ + public static function create($opener_id, array $allowed_media_type_ids, $selected_type_id, $remaining_slots) { + return new static([ + 'media_library_opener_id' => $opener_id, + 'media_library_allowed_types' => $allowed_media_type_ids, + 'media_library_selected_type' => $selected_type_id, + 'media_library_remaining' => $remaining_slots, + ]); + } + + /** + * Get the media library state from a request. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The request. + * + * @return \Drupal\media_library\MediaLibraryState + * A state object. + */ + public static function fromRequest(Request $request) { + // Create a MediaLibraryState object through the create method to make sure + // all validation runs. + $state = static::create( + $request->query->get('media_library_opener_id'), + $request->query->get('media_library_allowed_types'), + $request->query->get('media_library_selected_type'), + $request->query->get('media_library_remaining') + ); + // Once we have validated the required parameters, we restore the parameters + // from the request since there might be additional values. + $state->replace($request->query->all()); + return $state; + } + + /** + * Validate the required parameters for a new MediaLibraryState object. + * + * @param string $opener_id + * The opener ID. + * @param string[] $allowed_media_type_ids + * The allowed media type IDs. + * @param string $selected_type_id + * The selected media type ID. + * @param int $remaining_slots + * The number of remaining items the user is allowed to select or add in the + * library. + * + * @throws \InvalidArgumentException + * If one of the passed arguments is missing or does not pass the + * validation. + */ + protected function validateParameters($opener_id, array $allowed_media_type_ids, $selected_type_id, $remaining_slots) { + // The opener ID must be a non-empty string. + if (!is_string($opener_id) || empty(trim($opener_id))) { + throw new \InvalidArgumentException('The opener ID parameter is required and must be a string.'); + } + + // The allowed media type IDs must be an array of non-empty strings. + if (empty($allowed_media_type_ids) || !is_array($allowed_media_type_ids)) { + throw new \InvalidArgumentException('The allowed types parameter is required and must be an array of strings.'); + } + foreach ($allowed_media_type_ids as $allowed_media_type_id) { + if (!is_string($allowed_media_type_id) || empty(trim($allowed_media_type_id))) { + throw new \InvalidArgumentException('The allowed types parameter is required and must be an array of strings.'); + } + } + + // The selected type ID must be a non-empty string. + if (!is_string($selected_type_id) || empty(trim($selected_type_id))) { + throw new \InvalidArgumentException('The selected type parameter is required and must be a string.'); + } + // The selected type ID must be present in the list of allowed types. + if (!in_array($selected_type_id, $allowed_media_type_ids, TRUE)) { + throw new \InvalidArgumentException('The selected type parameter must be present in the list of allowed types.'); + } + + // The remaining slots must be numeric. + if (!is_numeric($remaining_slots)) { + throw new \InvalidArgumentException('The remaining slots parameter is required and must be numeric.'); + } + } + + /** + * Returns the ID of the opener of the media library. + * + * @return string + * The opener ID. + */ + public function getOpenerId() { + return $this->get('media_library_opener_id'); + } + + /** + * Returns the media type IDs which can be selected. + * + * @return string[] + * The media type IDs. + */ + public function getAllowedTypeIds() { + return $this->get('media_library_allowed_types'); + } + + /** + * Returns the selected media type. + * + * @return string + * The selected media type. + */ + public function getSelectedTypeId() { + return $this->get('media_library_selected_type'); + } + + /** + * Determines if additional media items can be selected. + * + * @return bool + * TRUE if additional items can be selected, otherwise FALSE. + */ + public function hasSlotsAvailable() { + return $this->getAvailableSlots() !== 0; + } + + /** + * Returns the number of additional media items that can be selected. + * + * When the value is not available in the URL the default is 0. When a + * negative integer is passed, an unlimited amount of media items can be + * selected. + * + * @return int + * The number of additional media items that can be selected. + */ + public function getAvailableSlots() { + return $this->getInt('media_library_remaining'); + } + +} diff --git a/core/modules/media_library/src/MediaLibraryUiBuilder.php b/core/modules/media_library/src/MediaLibraryUiBuilder.php new file mode 100644 index 000000000000..c97cc33764ad --- /dev/null +++ b/core/modules/media_library/src/MediaLibraryUiBuilder.php @@ -0,0 +1,244 @@ +<?php + +namespace Drupal\media_library; + +use Drupal\Core\Access\AccessResult; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\EventSubscriber\MainContentViewSubscriber; +use Drupal\Core\Session\AccountInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\Core\Url; +use Drupal\views\ViewExecutableFactory; +use Symfony\Component\HttpFoundation\RequestStack; + +/** + * Service which builds the media library. + * + * @internal + * This class is an internal part of the media library and should not be + * instantiated or used by external code. + */ +class MediaLibraryUiBuilder { + + use StringTranslationTrait; + + /** + * The entity type manager. + * + * @var \Drupal\Core\Entity\EntityTypeManagerInterface + */ + protected $entityTypeManager; + + /** + * The currently active request object. + * + * @var \Symfony\Component\HttpFoundation\Request + */ + protected $request; + + /** + * The views executable factory. + * + * @var \Drupal\views\ViewExecutableFactory + */ + protected $viewsExecutableFactory; + + /** + * Constructs a MediaLibraryUiBuilder instance. + * + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager + * The entity type manager. + * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack + * The request stack. + * @param \Drupal\views\ViewExecutableFactory $views_executable_factory + * The views executable factory. + */ + public function __construct(EntityTypeManagerInterface $entity_type_manager, RequestStack $request_stack, ViewExecutableFactory $views_executable_factory) { + $this->entityTypeManager = $entity_type_manager; + $this->request = $request_stack->getCurrentRequest(); + $this->viewsExecutableFactory = $views_executable_factory; + } + + /** + * Get media library dialog options. + * + * @return array + * The media library dialog options. + */ + public static function dialogOptions() { + return [ + 'dialogClass' => 'media-library-widget-modal', + 'title' => t('Media library'), + 'height' => '75%', + 'width' => '75%', + ]; + } + + /** + * Build the media library UI. + * + * @return array + * The render array for the media library. + */ + public function buildUi() { + $state = MediaLibraryState::fromRequest($this->request); + // When navigating to a media type through the vertical tabs, we only want + // to load the changed library content. This is not only more efficient, but + // also provides a more accessible user experience for screen readers. + if ($state->get('media_library_content') === '1') { + return $this->buildLibraryContent($state); + } + else { + return [ + '#type' => 'html_tag', + '#tag' => 'div', + '#attributes' => [ + 'id' => 'media-library-wrapper', + 'class' => ['media-library-wrapper'], + ], + 'menu' => $this->buildMediaTypeMenu($state), + 'content' => $this->buildLibraryContent($state), + '#attached' => [ + 'library' => ['media_library/ui'], + ], + ]; + } + } + + /** + * Build the media library content area. + * + * @param \Drupal\media_library\MediaLibraryState $state + * The current state of the media library, derived from the current request. + * + * @return array + * The render array for the media library. + */ + protected function buildLibraryContent(MediaLibraryState $state) { + return [ + '#type' => 'html_tag', + '#tag' => 'div', + '#attributes' => [ + 'id' => 'media-library-content', + 'class' => ['media-library-content'], + 'tabindex' => -1, + ], + 'view' => $this->buildMediaLibraryView($state), + ]; + } + + /** + * Check access to the media library. + * + * @param \Drupal\Core\Session\AccountInterface $account + * Run access checks for this account. + * + * @return \Drupal\Core\Access\AccessResult + * The access result. + */ + public function checkAccess(AccountInterface $account = NULL) { + // Deny access if the view or display are removed. + $view = $this->entityTypeManager->getStorage('view')->load('media_library'); + if (!$view) { + return AccessResult::forbidden('The media library view does not exist.') + ->setCacheMaxAge(0); + } + if (!$view->getDisplay('widget')) { + return AccessResult::forbidden('The media library widget display does not exist.') + ->addCacheableDependency($view); + } + return AccessResult::allowedIfHasPermission($account, 'view media') + ->addCacheableDependency($view); + } + + /** + * Get the media type menu for the media library. + * + * @param \Drupal\media_library\MediaLibraryState $state + * The current state of the media library, derived from the current request. + * + * @return array + * The render array for the media type menu. + */ + protected function buildMediaTypeMenu(MediaLibraryState $state) { + // Add the menu for each type if we have more than 1 media type enabled for + // the field. + $allowed_type_ids = $state->getAllowedTypeIds(); + if (count($allowed_type_ids) === 1) { + return []; + } + + // @todo: Add a class to the li element. + // https://www.drupal.org/project/drupal/issues/3029227 + $menu = [ + '#theme' => 'links', + '#links' => [], + '#attributes' => [ + 'class' => ['media-library-menu', 'js-media-library-menu'], + ], + ]; + + // Get the state parameters but remove the wrapper format. Also add the + // 'media_library_content' argument to fetch only the updated content for + // the tab. + // @see self::buildUi() + $state->remove(MainContentViewSubscriber::WRAPPER_FORMAT); + $state->add(['media_library_content' => 1]); + $query = $state->all(); + + $allowed_types = $this->entityTypeManager->getStorage('media_type')->loadMultiple($allowed_type_ids); + + $selected_type_id = $state->getSelectedTypeId(); + foreach ($allowed_types as $allowed_type_id => $allowed_type) { + $query['media_library_selected_type'] = $allowed_type_id; + + $title = $allowed_type->label(); + if ($allowed_type_id === $selected_type_id) { + $title = [ + '#markup' => $this->t('@title<span class="active-tab visually-hidden"> (active tab)</span>', ['@title' => $title]), + ]; + } + + $menu['#links']['media-library-menu-' . $allowed_type_id] = [ + 'title' => $title, + 'url' => Url::fromRoute('media_library.ui', [], [ + 'query' => $query, + ]), + 'attributes' => [ + 'class' => ['media-library-menu__link'], + ], + ]; + } + + // Set the active menu item. + $menu['#links']['media-library-menu-' . $selected_type_id]['attributes']['class'][] = 'active'; + + return $menu; + } + + /** + * Get the media library view. + * + * @param \Drupal\media_library\MediaLibraryState $state + * The current state of the media library, derived from the current request. + * + * @return array + * The render array for the media library view. + */ + protected function buildMediaLibraryView(MediaLibraryState $state) { + // @todo Make the view configurable in + // https://www.drupal.org/project/drupal/issues/2971209 + $view = $this->entityTypeManager->getStorage('view')->load('media_library'); + $view_executable = $this->viewsExecutableFactory->get($view); + $display_id = 'widget'; + + $args = [$state->getSelectedTypeId()]; + + $view_executable->setDisplay($display_id); + $view_executable->preExecute($args); + $view_executable->execute($display_id); + + return $view_executable->buildRenderable($display_id, $args, FALSE); + } + +} diff --git a/core/modules/media_library/src/Plugin/Field/FieldWidget/MediaLibraryWidget.php b/core/modules/media_library/src/Plugin/Field/FieldWidget/MediaLibraryWidget.php index ec4e1c501eb6..033c7da139ff 100644 --- a/core/modules/media_library/src/Plugin/Field/FieldWidget/MediaLibraryWidget.php +++ b/core/modules/media_library/src/Plugin/Field/FieldWidget/MediaLibraryWidget.php @@ -14,6 +14,8 @@ use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Drupal\Core\Url; use Drupal\media\Entity\Media; +use Drupal\media_library\MediaLibraryUiBuilder; +use Drupal\media_library\MediaLibraryState; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\Validator\ConstraintViolationInterface; @@ -41,6 +43,13 @@ class MediaLibraryWidget extends WidgetBase implements ContainerFactoryPluginInt */ protected $entityTypeManager; + /** + * The prefix to use with a field ID for media library opener IDs. + * + * @var string + */ + protected static $openerIdPrefix = 'field:'; + /** * Constructs a MediaLibraryWidget widget. * @@ -83,6 +92,155 @@ public static function isApplicable(FieldDefinitionInterface $field_definition) return $field_definition->getSetting('target_type') === 'media'; } + /** + * {@inheritdoc} + */ + public static function defaultSettings() { + return [ + 'media_types' => [], + ] + parent::defaultSettings(); + } + + /** + * Get the enabled media type IDs sorted by weight. + * + * @return string[] + * The media type IDs sorted by weight. + */ + protected function getAllowedMediaTypeIdsSorted() { + // Get the media type IDs sorted by the user in the settings form. + $sorted_media_type_ids = $this->getSetting('media_types'); + + // Get the configured media types from the field storage. + $handler_settings = $this->getFieldSetting('handler_settings'); + $allowed_media_type_ids = !empty($handler_settings['target_bundles']) ? $handler_settings['target_bundles'] : []; + + // When no target bundles are configured for the field, all are allowed. + if (!$allowed_media_type_ids) { + $allowed_media_type_ids = $this->entityTypeManager->getStorage('media_type')->getQuery()->execute(); + } + + // When the user did not sort the media types, return the media type IDs + // configured for the field. + if (empty($sorted_media_type_ids)) { + return $allowed_media_type_ids; + } + + // Some of the media types may no longer exist, and new media types may have + // been added that we don't yet know about. We need to make sure new media + // types are added to the list and remove media types that are no longer + // configured for the field. + $new_media_type_ids = array_diff($allowed_media_type_ids, $sorted_media_type_ids); + // Add new media type IDs to the list. + $sorted_media_type_ids = array_merge($sorted_media_type_ids, array_values($new_media_type_ids)); + // Remove media types that are no longer available. + $sorted_media_type_ids = array_intersect($sorted_media_type_ids, $allowed_media_type_ids); + + // Make sure the keys are numeric. + return array_values($sorted_media_type_ids); + } + + /** + * {@inheritdoc} + */ + public function settingsForm(array $form, FormStateInterface $form_state) { + $media_type_ids = $this->getAllowedMediaTypeIdsSorted(); + + if (count($media_type_ids) <= 1) { + return $form; + } + + $form['media_types'] = [ + '#type' => 'table', + '#header' => [ + $this->t('Tab order'), + $this->t('Weight'), + ], + '#tabledrag' => [ + [ + 'action' => 'order', + 'relationship' => 'sibling', + 'group' => 'weight', + ], + ], + '#value_callback' => [static::class, 'setMediaTypesValue'], + ]; + + $media_types = $this->entityTypeManager->getStorage('media_type')->loadMultiple($media_type_ids); + $weight = 0; + foreach ($media_types as $media_type_id => $media_type) { + $label = $media_type->label(); + $form['media_types'][$media_type_id] = [ + 'label' => ['#markup' => $label], + 'weight' => [ + '#type' => 'weight', + '#title' => t('Weight for @title', ['@title' => $label]), + '#title_display' => 'invisible', + '#default_value' => $weight, + '#attributes' => ['class' => ['weight']], + ], + '#weight' => $weight, + '#attributes' => ['class' => ['draggable']], + ]; + $weight++; + } + + return $form; + } + + /** + * Value callback to optimize the way the media type weights are stored. + * + * The tabledrag functionality needs a specific weight field, but we don't + * want to store this extra weight field in our settings. + * + * @param array $element + * An associative array containing the properties of the element. + * @param mixed $input + * The incoming input to populate the form element. If this is FALSE, + * the element's default value should be returned. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + * + * @return mixed + * The value to assign to the element. + */ + public static function setMediaTypesValue(array &$element, $input, FormStateInterface $form_state) { + if ($input === FALSE) { + return isset($element['#default_value']) ? $element['#default_value'] : []; + } + + // Sort the media types by weight value and set the value in the form state. + uasort($input, 'Drupal\Component\Utility\SortArray::sortByWeightElement'); + $sorted_media_type_ids = array_keys($input); + $form_state->setValue($element['#parents'], $sorted_media_type_ids); + + // We have to unset the child elements containing the weight fields for each + // media type to stop FormBuilder::doBuildForm() from processing the weight + // fields as well. + foreach ($sorted_media_type_ids as $media_type_id) { + unset($element[$media_type_id]); + } + + return $sorted_media_type_ids; + } + + /** + * {@inheritdoc} + */ + public function settingsSummary() { + $summary = []; + $media_type_labels = []; + $media_types = $this->entityTypeManager->getStorage('media_type')->loadMultiple($this->getAllowedMediaTypeIdsSorted()); + if (count($media_types) !== 1) { + foreach ($media_types as $media_type) { + $media_type_labels[] = $media_type->label(); + } + $summary[] = t('Tab order: @order', ['@order' => implode(', ', $media_type_labels)]); + } + return $summary; + } + /** * {@inheritdoc} */ @@ -106,7 +264,7 @@ public function formElement(FieldItemListInterface $items, $delta, array $elemen $view_builder = $this->entityTypeManager->getViewBuilder('media'); $field_name = $this->fieldDefinition->getName(); $parents = $form['#parents']; - $id_suffix = '-' . implode('-', $parents); + $id_suffix = $parents ? '-' . implode('-', $parents) : ''; $wrapper_id = $field_name . '-media-library-wrapper' . $id_suffix; $limit_validation_errors = [array_merge($parents, [$field_name])]; @@ -218,26 +376,24 @@ public function formElement(FieldItemListInterface $items, $delta, array $elemen $element['#description'] .= '<br />' . $cardinality_message; } - $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, - ]; - $dialog_options = Json::encode([ - 'dialogClass' => 'media-library-widget-modal', - 'height' => '75%', - 'width' => '75%', - 'title' => $this->t('Media library'), - ]); + // Create a new media library URL with the correct state parameters. + $allowed_media_type_ids = $this->getAllowedMediaTypeIdsSorted(); + $selected_type_id = reset($allowed_media_type_ids); + $remaining = $cardinality_unlimited ? FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED : $remaining; + // The opener ID is used by the select form and the upload form to add the + // selected/uploaded media items to the widget. + $opener_id = static::$openerIdPrefix . $field_name . $id_suffix; + + $state = MediaLibraryState::create($opener_id, $allowed_media_type_ids, $selected_type_id, $remaining); + $dialog_options = Json::encode(MediaLibraryUiBuilder::dialogOptions()); // Add a button that will load the Media library in a modal using AJAX. $element['media_library_open_button'] = [ '#type' => 'link', '#title' => $this->t('Add 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' => $query, + '#url' => $url = Url::fromRoute('media_library.ui', [], [ + 'query' => $state->all(), ]), '#attributes' => [ 'class' => ['button', 'use-ajax', 'media-library-open-button'], @@ -419,13 +575,15 @@ public static function updateItems(array $form, FormStateInterface $form_state) $media = static::getNewMediaItems($element, $form_state); if (!empty($media)) { - $weight = count($field_state['items']); + // Get the weight of the last items and count from there. + $last_element = end($field_state['items']); + $weight = $last_element ? $last_element['weight'] : 0; foreach ($media as $media_item) { // Any ID can be passed to the widget, so we have to check access. if ($media_item->access('view')) { $field_state['items'][] = [ 'target_id' => $media_item->id(), - 'weight' => $weight++, + 'weight' => ++$weight, ]; } } @@ -505,4 +663,23 @@ protected static function setFieldState(array $element, FormStateInterface $form static::setWidgetState($element['#field_parents'], $element['#field_name'], $form_state, $field_state); } + /** + * Get the field ID of the widget from an opener ID. + * + * @param string $opener_id + * The opener ID of the media library. + * + * @return string|null + * The field ID or NULL if the opener ID is not valid for the widget. + * + * @see \Drupal\media_library\MediaLibraryState + */ + public static function getOpenerFieldId($opener_id) { + // Media library widget opener IDs are always prefixed with 'field:' in . + if (preg_match('/^' . static::$openerIdPrefix . '([a-z0-9_-]+)$/', $opener_id, $matches)) { + return $matches[1]; + } + return NULL; + } + } diff --git a/core/modules/media_library/src/Plugin/views/field/MediaLibrarySelectForm.php b/core/modules/media_library/src/Plugin/views/field/MediaLibrarySelectForm.php index 72a4b50bbfcb..d1d6b4cad634 100644 --- a/core/modules/media_library/src/Plugin/views/field/MediaLibrarySelectForm.php +++ b/core/modules/media_library/src/Plugin/views/field/MediaLibrarySelectForm.php @@ -8,10 +8,11 @@ use Drupal\Core\Form\FormBuilderInterface; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Url; +use Drupal\media_library\MediaLibraryState; +use Drupal\media_library\Plugin\Field\FieldWidget\MediaLibraryWidget; use Drupal\views\Plugin\views\field\FieldPluginBase; use Drupal\views\Render\ViewsRenderPipelineMarkup; use Drupal\views\ResultRow; -use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; /** * Defines a field that outputs a checkbox and form for selecting media. @@ -45,10 +46,9 @@ public function render(ResultRow $values) { * 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; - } + $form['#attributes'] = [ + 'class' => ['media-library-views-form', 'js-media-library-views-form'], + ]; // Render checkboxes for all rows. $form[$this->options['id']]['#tree'] = TRUE; @@ -64,6 +64,17 @@ public function viewsForm(array &$form, FormStateInterface $form_state) { ]; } + // The selection is persistent across different pages in the media library + // and populated via JavaScript. + $selection_field_id = $this->options['id'] . '_selection'; + $form[$selection_field_id] = [ + '#type' => 'hidden', + '#attributes' => [ + // This is used to identify the hidden field in the form via JavaScript. + 'id' => 'media-library-modal-selection', + ], + ]; + // @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 @@ -80,7 +91,10 @@ public function viewsForm(array &$form, FormStateInterface $form_state) { ]; $form['actions']['submit']['#value'] = $this->t('Select media'); - $form['actions']['submit']['#field_id'] = $this->options['id']; + $form['actions']['submit']['#field_id'] = $selection_field_id; + $form['actions']['submit']['#attributes'] = [ + 'class' => ['media-library-select'], + ]; } /** @@ -95,17 +109,22 @@ public function viewsForm(array &$form, FormStateInterface $form_state) { * 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()); + $selected = array_filter(explode(',', $form_state->getValue($field_id, []))); + + $response = new AjaxResponse(); + $response->addCommand(new CloseDialogCommand()); + + $ids = implode(',', $selected); + + $opener_id = MediaLibraryState::fromRequest(\Drupal::request())->getOpenerId(); + if ($field_id = MediaLibraryWidget::getOpenerFieldId($opener_id)) { + $response + ->addCommand(new InvokeCommand("[data-media-library-widget-value=\"$field_id\"]", 'val', [$ids])) + ->addCommand(new InvokeCommand("[data-media-library-widget-update=\"$field_id\"]", 'trigger', ['mousedown'])); + } + + return $response; } /** diff --git a/core/modules/media_library/tests/fixtures/update/drupal-8.media_library-update-widget-view-3020716.php b/core/modules/media_library/tests/fixtures/update/drupal-8.media_library-update-widget-view-3020716.php new file mode 100644 index 000000000000..a07a69003ec5 --- /dev/null +++ b/core/modules/media_library/tests/fixtures/update/drupal-8.media_library-update-widget-view-3020716.php @@ -0,0 +1,111 @@ +<?php +// @codingStandardsIgnoreFile +/** + * @file + * Contains database additions to drupal-8.bare.standard.php.gz for testing + * the upgrade paths of the media library module widget view. + * + * @see https://www.drupal.org/project/drupal/issues/3020716 + */ + +use Drupal\Core\Database\Database; + +$connection = Database::getConnection(); + +// Set the schema version. +$connection->merge('key_value') + ->fields([ + 'value' => 'i:8000;', + 'name' => 'media_library', + 'collection' => 'system.schema', + ]) + ->condition('collection', 'system.schema') + ->condition('name', 'media_library') + ->execute(); + +// Update core.extension. +$extensions = $connection->select('config') + ->fields('config', ['data']) + ->condition('collection', '') + ->condition('name', 'core.extension') + ->execute() + ->fetchField(); +$extensions = unserialize($extensions); +$extensions['module']['media_library'] = 0; +$connection->update('config') + ->fields([ + 'data' => serialize($extensions), + 'collection' => '', + 'name' => 'core.extension', + ]) + ->condition('collection', '') + ->condition('name', 'core.extension') + ->execute(); + +// Insert media library config objects. +$connection->insert('config') +->fields(array( + 'collection', + 'name', + 'data', +)) +->values(array( + 'collection' => '', + 'name' => 'core.entity_form_display.media.file.media_library', + 'data' => 'a:11:{s:4:"uuid";s:36:"86ab9619-c970-4416-971d-e5c8614b3368";s:8:"langcode";s:2:"en";s:6:"status";b:1;s:12:"dependencies";a:1:{s:6:"config";a:3:{i:0;s:41:"core.entity_form_mode.media.media_library";i:1;s:39:"field.field.media.file.field_media_file";i:2;s:15:"media.type.file";}}s:5:"_core";a:1:{s:19:"default_config_hash";s:43:"RFmywOcuem167havmD4VLgBTO1Swq9hyA-_f5aYTi8c";}s:2:"id";s:24:"media.file.media_library";s:16:"targetEntityType";s:5:"media";s:6:"bundle";s:4:"file";s:4:"mode";s:13:"media_library";s:7:"content";a:1:{s:4:"name";a:5:{s:4:"type";s:16:"string_textfield";s:6:"weight";i:0;s:6:"region";s:7:"content";s:8:"settings";a:2:{s:4:"size";i:60;s:11:"placeholder";s:0:"";}s:20:"third_party_settings";a:0:{}}}s:6:"hidden";a:5:{s:7:"created";b:1;s:16:"field_media_file";b:1;s:4:"path";b:1;s:6:"status";b:1;s:3:"uid";b:1;}}', +)) +->values(array( + 'collection' => '', + 'name' => 'core.entity_form_display.media.image.media_library', + 'data' => 'a:11:{s:4:"uuid";s:36:"2bbea060-3cd8-4881-a3aa-c898d6619b16";s:8:"langcode";s:2:"en";s:6:"status";b:1;s:12:"dependencies";a:2:{s:6:"config";a:4:{i:0;s:41:"core.entity_form_mode.media.media_library";i:1;s:41:"field.field.media.image.field_media_image";i:2;s:21:"image.style.thumbnail";i:3;s:16:"media.type.image";}s:6:"module";a:1:{i:0;s:5:"image";}}s:5:"_core";a:1:{s:19:"default_config_hash";s:43:"PlyfyVZfALLkP7nbxLpaVKIDUWRioZghWpFDv0_rJ68";}s:2:"id";s:25:"media.image.media_library";s:16:"targetEntityType";s:5:"media";s:6:"bundle";s:5:"image";s:4:"mode";s:13:"media_library";s:7:"content";a:2:{s:17:"field_media_image";a:5:{s:4:"type";s:11:"image_image";s:6:"weight";i:1;s:6:"region";s:7:"content";s:8:"settings";a:2:{s:18:"progress_indicator";s:8:"throbber";s:19:"preview_image_style";s:9:"thumbnail";}s:20:"third_party_settings";a:0:{}}s:4:"name";a:5:{s:4:"type";s:16:"string_textfield";s:6:"weight";i:0;s:6:"region";s:7:"content";s:8:"settings";a:2:{s:4:"size";i:60;s:11:"placeholder";s:0:"";}s:20:"third_party_settings";a:0:{}}}s:6:"hidden";a:4:{s:7:"created";b:1;s:4:"path";b:1;s:6:"status";b:1;s:3:"uid";b:1;}}', +)) +->values(array( + 'collection' => '', + 'name' => 'core.entity_view_display.media.file.media_library', + 'data' => 'a:11:{s:4:"uuid";s:36:"67e6d857-8ecb-49f5-95e1-6b1c4306c31f";s:8:"langcode";s:2:"en";s:6:"status";b:1;s:12:"dependencies";a:2:{s:6:"config";a:4:{i:0;s:41:"core.entity_view_mode.media.media_library";i:1;s:39:"field.field.media.file.field_media_file";i:2;s:21:"image.style.thumbnail";i:3;s:15:"media.type.file";}s:6:"module";a:1:{i:0;s:5:"image";}}s:5:"_core";a:1:{s:19:"default_config_hash";s:43:"vhAK2lCOWK2paUpJawj7yiSLFO9wwsx6WE8_oDmvbwU";}s:2:"id";s:24:"media.file.media_library";s:16:"targetEntityType";s:5:"media";s:6:"bundle";s:4:"file";s:4:"mode";s:13:"media_library";s:7:"content";a:1:{s:9:"thumbnail";a:6:{s:4:"type";s:5:"image";s:6:"weight";i:0;s:6:"region";s:7:"content";s:5:"label";s:6:"hidden";s:8:"settings";a:2:{s:11:"image_style";s:9:"thumbnail";s:10:"image_link";s:0:"";}s:20:"third_party_settings";a:0:{}}}s:6:"hidden";a:4:{s:7:"created";b:1;s:16:"field_media_file";b:1;s:4:"name";b:1;s:3:"uid";b:1;}}', +)) +->values(array( + 'collection' => '', + 'name' => 'core.entity_view_display.media.image.media_library', + 'data' => 'a:11:{s:4:"uuid";s:36:"277ca98b-2ada-4251-ad69-aa73e72d60fe";s:8:"langcode";s:2:"en";s:6:"status";b:1;s:12:"dependencies";a:2:{s:6:"config";a:4:{i:0;s:41:"core.entity_view_mode.media.media_library";i:1;s:41:"field.field.media.image.field_media_image";i:2;s:18:"image.style.medium";i:3;s:16:"media.type.image";}s:6:"module";a:1:{i:0;s:5:"image";}}s:5:"_core";a:1:{s:19:"default_config_hash";s:43:"PaGXvzRcL9eII--JV4eCVfObjrNo0l-u1dB_WJtB9ig";}s:2:"id";s:25:"media.image.media_library";s:16:"targetEntityType";s:5:"media";s:6:"bundle";s:5:"image";s:4:"mode";s:13:"media_library";s:7:"content";a:1:{s:9:"thumbnail";a:6:{s:4:"type";s:5:"image";s:6:"weight";i:0;s:6:"region";s:7:"content";s:5:"label";s:6:"hidden";s:8:"settings";a:2:{s:11:"image_style";s:6:"medium";s:10:"image_link";s:0:"";}s:20:"third_party_settings";a:0:{}}}s:6:"hidden";a:4:{s:7:"created";b:1;s:17:"field_media_image";b:1;s:4:"name";b:1;s:3:"uid";b:1;}}', +)) +->values(array( + 'collection' => '', + 'name' => 'core.entity_view_mode.media.media_library', + 'data' => 'a:9:{s:4:"uuid";s:36:"20b2f1f7-a864-4d41-a15f-32f66789f73d";s:8:"langcode";s:2:"en";s:6:"status";b:1;s:12:"dependencies";a:2:{s:8:"enforced";a:1:{s:6:"module";a:1:{i:0;s:13:"media_library";}}s:6:"module";a:1:{i:0;s:5:"media";}}s:5:"_core";a:1:{s:19:"default_config_hash";s:43:"pkq0uj-IoqEQRBOP_ddUDV0ZJ-dKQ_fLcppsEDF2UO8";}s:2:"id";s:19:"media.media_library";s:5:"label";s:13:"Media library";s:16:"targetEntityType";s:5:"media";s:5:"cache";b:1;}', +)) +->values(array( + 'collection' => '', + 'name' => 'views.view.media_library', + 'data' => 'a:14:{s:4:"uuid";s:36:"3bc9cf0f-cb66-4dbe-8d7e-862cb85e5932";s:8:"langcode";s:2:"en";s:6:"status";b:1;s:12:"dependencies";a:3:{s:6:"config";a:1:{i:0;s:41:"core.entity_view_mode.media.media_library";}s:8:"enforced";a:1:{s:6:"module";a:1:{i:0;s:13:"media_library";}}s:6:"module";a:3:{i:0;s:5:"media";i:1;s:13:"media_library";i:2;s:4:"user";}}s:5:"_core";a:1:{s:19:"default_config_hash";s:43:"1F1cSZ5MlvxdwjdyrwnH2I8CWngOp8Pu2SXDzix2QUc";}s:2:"id";s:13:"media_library";s:5:"label";s:13:"Media library";s:6:"module";s:5:"views";s:11:"description";s:0:"";s:3:"tag";s:0:"";s:10:"base_table";s:16:"media_field_data";s:10:"base_field";s:3:"mid";s:4:"core";s:3:"8.x";s:7:"display";a:3:{s:7:"default";a:6:{s:14:"display_plugin";s:7:"default";s:2:"id";s:7:"default";s:13:"display_title";s:6:"Master";s:8:"position";i:0;s:15:"display_options";a:18:{s:6:"access";a:2:{s:4:"type";s:4:"perm";s:7:"options";a:1:{s:4:"perm";s:21:"access media overview";}}s:5:"cache";a:2:{s:4:"type";s:3:"tag";s:7:"options";a:0:{}}s:5:"query";a:2:{s:4:"type";s:11:"views_query";s:7:"options";a:5:{s:19:"disable_sql_rewrite";b:0;s:8:"distinct";b:0;s:7:"replica";b:0;s:13:"query_comment";s:0:"";s:10:"query_tags";a:0:{}}}s:12:"exposed_form";a:2:{s:4:"type";s:5:"basic";s:7:"options";a:7:{s:13:"submit_button";s:13:"Apply Filters";s:12:"reset_button";b:0;s:18:"reset_button_label";s:5:"Reset";s:19:"exposed_sorts_label";s:7:"Sort by";s:17:"expose_sort_order";b:0;s:14:"sort_asc_label";s:3:"Asc";s:15:"sort_desc_label";s:4:"Desc";}}s:5:"pager";a:2:{s:4:"type";s:4:"mini";s:7:"options";a:6:{s:14:"items_per_page";i:25;s:6:"offset";i:0;s:2:"id";i:0;s:11:"total_pages";N;s:6:"expose";a:7:{s:14:"items_per_page";b:0;s:20:"items_per_page_label";s:14:"Items per page";s:22:"items_per_page_options";s:13:"5, 10, 25, 50";s:26:"items_per_page_options_all";b:0;s:32:"items_per_page_options_all_label";s:7:"- All -";s:6:"offset";b:0;s:12:"offset_label";s:6:"Offset";}s:4:"tags";a:2:{s:8:"previous";s:6:"‹‹";s:4:"next";s:6:"››";}}}s:5:"style";a:2:{s:4:"type";s:7:"default";s:7:"options";a:3:{s:8:"grouping";a:0:{}s:9:"row_class";s:59:"media-library-item js-media-library-item js-click-to-select";s:17:"default_row_class";b:1;}}s:3:"row";a:2:{s:4:"type";s:6:"fields";s:7:"options";a:4:{s:22:"default_field_elements";b:1;s:6:"inline";a:0:{}s:9:"separator";s:0:"";s:10:"hide_empty";b:0;}}s:6:"fields";a:2:{s:15:"media_bulk_form";a:26:{s:2:"id";s:15:"media_bulk_form";s:5:"table";s:5:"media";s:5:"field";s:15:"media_bulk_form";s:12:"relationship";s:4:"none";s:10:"group_type";s:5:"group";s:11:"admin_label";s:0:"";s:5:"label";s:0:"";s:7:"exclude";b:0;s:5:"alter";a:26:{s:10:"alter_text";b:0;s:4:"text";s:0:"";s:9:"make_link";b:0;s:4:"path";s:0:"";s:8:"absolute";b:0;s:8:"external";b:0;s:14:"replace_spaces";b:0;s:9:"path_case";s:4:"none";s:15:"trim_whitespace";b:0;s:3:"alt";s:0:"";s:3:"rel";s:0:"";s:10:"link_class";s:0:"";s:6:"prefix";s:0:"";s:6:"suffix";s:0:"";s:6:"target";s:0:"";s:5:"nl2br";b:0;s:10:"max_length";i:0;s:13:"word_boundary";b:1;s:8:"ellipsis";b:1;s:9:"more_link";b:0;s:14:"more_link_text";s:0:"";s:14:"more_link_path";s:0:"";s:10:"strip_tags";b:0;s:4:"trim";b:0;s:13:"preserve_tags";s:0:"";s:4:"html";b:0;}s:12:"element_type";s:0:"";s:13:"element_class";s:27:"js-click-to-select-checkbox";s:18:"element_label_type";s:0:"";s:19:"element_label_class";s:0:"";s:19:"element_label_colon";b:0;s:20:"element_wrapper_type";s:0:"";s:21:"element_wrapper_class";s:0:"";s:23:"element_default_classes";b:1;s:5:"empty";s:0:"";s:10:"hide_empty";b:0;s:10:"empty_zero";b:0;s:16:"hide_alter_empty";b:1;s:12:"action_title";s:6:"Action";s:15:"include_exclude";s:7:"exclude";s:16:"selected_actions";a:0:{}s:11:"entity_type";s:5:"media";s:9:"plugin_id";s:9:"bulk_form";}s:15:"rendered_entity";a:24:{s:2:"id";s:15:"rendered_entity";s:5:"table";s:5:"media";s:5:"field";s:15:"rendered_entity";s:12:"relationship";s:4:"none";s:10:"group_type";s:5:"group";s:11:"admin_label";s:0:"";s:5:"label";s:0:"";s:7:"exclude";b:0;s:5:"alter";a:26:{s:10:"alter_text";b:0;s:4:"text";s:0:"";s:9:"make_link";b:0;s:4:"path";s:0:"";s:8:"absolute";b:0;s:8:"external";b:0;s:14:"replace_spaces";b:0;s:9:"path_case";s:4:"none";s:15:"trim_whitespace";b:0;s:3:"alt";s:0:"";s:3:"rel";s:0:"";s:10:"link_class";s:0:"";s:6:"prefix";s:0:"";s:6:"suffix";s:0:"";s:6:"target";s:0:"";s:5:"nl2br";b:0;s:10:"max_length";i:0;s:13:"word_boundary";b:1;s:8:"ellipsis";b:1;s:9:"more_link";b:0;s:14:"more_link_text";s:0:"";s:14:"more_link_path";s:0:"";s:10:"strip_tags";b:0;s:4:"trim";b:0;s:13:"preserve_tags";s:0:"";s:4:"html";b:0;}s:12:"element_type";s:0:"";s:13:"element_class";s:27:"media-library-item__content";s:18:"element_label_type";s:0:"";s:19:"element_label_class";s:0:"";s:19:"element_label_colon";b:0;s:20:"element_wrapper_type";s:0:"";s:21:"element_wrapper_class";s:0:"";s:23:"element_default_classes";b:1;s:5:"empty";s:0:"";s:10:"hide_empty";b:0;s:10:"empty_zero";b:0;s:16:"hide_alter_empty";b:1;s:9:"view_mode";s:13:"media_library";s:11:"entity_type";s:5:"media";s:9:"plugin_id";s:15:"rendered_entity";}}s:7:"filters";a:3:{s:6:"status";a:16:{s:2:"id";s:6:"status";s:5:"table";s:16:"media_field_data";s:5:"field";s:6:"status";s:12:"relationship";s:4:"none";s:10:"group_type";s:5:"group";s:11:"admin_label";s:0:"";s:8:"operator";s:1:"=";s:5:"value";s:1:"1";s:5:"group";i:1;s:7:"exposed";b:1;s:6:"expose";a:10:{s:11:"operator_id";s:0:"";s:5:"label";s:17:"Publishing status";s:11:"description";N;s:12:"use_operator";b:0;s:8:"operator";s:9:"status_op";s:10:"identifier";s:6:"status";s:8:"required";b:1;s:8:"remember";b:0;s:8:"multiple";b:0;s:14:"remember_roles";a:1:{s:13:"authenticated";s:13:"authenticated";}}s:10:"is_grouped";b:1;s:10:"group_info";a:10:{s:5:"label";s:9:"Published";s:11:"description";s:0:"";s:10:"identifier";s:6:"status";s:8:"optional";b:1;s:6:"widget";s:6:"select";s:8:"multiple";b:0;s:8:"remember";b:0;s:13:"default_group";s:3:"All";s:22:"default_group_multiple";a:0:{}s:11:"group_items";a:2:{i:1;a:3:{s:5:"title";s:9:"Published";s:8:"operator";s:1:"=";s:5:"value";s:1:"1";}i:2;a:3:{s:5:"title";s:11:"Unpublished";s:8:"operator";s:1:"=";s:5:"value";s:1:"0";}}}s:9:"plugin_id";s:7:"boolean";s:11:"entity_type";s:5:"media";s:12:"entity_field";s:6:"status";}s:4:"name";a:16:{s:2:"id";s:4:"name";s:5:"table";s:16:"media_field_data";s:5:"field";s:4:"name";s:12:"relationship";s:4:"none";s:10:"group_type";s:5:"group";s:11:"admin_label";s:0:"";s:8:"operator";s:8:"contains";s:5:"value";s:0:"";s:5:"group";i:1;s:7:"exposed";b:1;s:6:"expose";a:10:{s:11:"operator_id";s:7:"name_op";s:5:"label";s:4:"Name";s:11:"description";s:0:"";s:12:"use_operator";b:0;s:8:"operator";s:7:"name_op";s:10:"identifier";s:4:"name";s:8:"required";b:0;s:8:"remember";b:0;s:8:"multiple";b:0;s:14:"remember_roles";a:3:{s:13:"authenticated";s:13:"authenticated";s:9:"anonymous";s:1:"0";s:13:"administrator";s:1:"0";}}s:10:"is_grouped";b:0;s:10:"group_info";a:10:{s:5:"label";s:0:"";s:11:"description";s:0:"";s:10:"identifier";s:0:"";s:8:"optional";b:1;s:6:"widget";s:6:"select";s:8:"multiple";b:0;s:8:"remember";b:0;s:13:"default_group";s:3:"All";s:22:"default_group_multiple";a:0:{}s:11:"group_items";a:0:{}}s:11:"entity_type";s:5:"media";s:12:"entity_field";s:4:"name";s:9:"plugin_id";s:6:"string";}s:6:"bundle";a:16:{s:2:"id";s:6:"bundle";s:5:"table";s:16:"media_field_data";s:5:"field";s:6:"bundle";s:12:"relationship";s:4:"none";s:10:"group_type";s:5:"group";s:11:"admin_label";s:0:"";s:8:"operator";s:2:"in";s:5:"value";a:0:{}s:5:"group";i:1;s:7:"exposed";b:1;s:6:"expose";a:11:{s:11:"operator_id";s:9:"bundle_op";s:5:"label";s:10:"Media type";s:11:"description";s:0:"";s:12:"use_operator";b:0;s:8:"operator";s:9:"bundle_op";s:10:"identifier";s:4:"type";s:8:"required";b:0;s:8:"remember";b:0;s:8:"multiple";b:0;s:14:"remember_roles";a:3:{s:13:"authenticated";s:13:"authenticated";s:9:"anonymous";s:1:"0";s:13:"administrator";s:1:"0";}s:6:"reduce";b:0;}s:10:"is_grouped";b:0;s:10:"group_info";a:10:{s:5:"label";s:10:"Media type";s:11:"description";N;s:10:"identifier";s:6:"bundle";s:8:"optional";b:1;s:6:"widget";s:6:"select";s:8:"multiple";b:0;s:8:"remember";b:0;s:13:"default_group";s:3:"All";s:22:"default_group_multiple";a:0:{}s:11:"group_items";a:3:{i:1;a:0:{}i:2;a:0:{}i:3;a:0:{}}}s:11:"entity_type";s:5:"media";s:12:"entity_field";s:6:"bundle";s:9:"plugin_id";s:6:"bundle";}}s:5:"sorts";a:3:{s:7:"created";a:13:{s:2:"id";s:7:"created";s:5:"table";s:16:"media_field_data";s:5:"field";s:7:"created";s:12:"relationship";s:4:"none";s:10:"group_type";s:5:"group";s:11:"admin_label";s:0:"";s:5:"order";s:4:"DESC";s:7:"exposed";b:1;s:6:"expose";a:1:{s:5:"label";s:12:"Newest first";}s:11:"granularity";s:6:"second";s:11:"entity_type";s:5:"media";s:12:"entity_field";s:7:"created";s:9:"plugin_id";s:4:"date";}s:4:"name";a:12:{s:2:"id";s:4:"name";s:5:"table";s:16:"media_field_data";s:5:"field";s:4:"name";s:12:"relationship";s:4:"none";s:10:"group_type";s:5:"group";s:11:"admin_label";s:0:"";s:5:"order";s:3:"ASC";s:7:"exposed";b:1;s:6:"expose";a:1:{s:5:"label";s:10:"Name (A-Z)";}s:11:"entity_type";s:5:"media";s:12:"entity_field";s:4:"name";s:9:"plugin_id";s:8:"standard";}s:6:"name_1";a:12:{s:2:"id";s:6:"name_1";s:5:"table";s:16:"media_field_data";s:5:"field";s:4:"name";s:12:"relationship";s:4:"none";s:10:"group_type";s:5:"group";s:11:"admin_label";s:0:"";s:5:"order";s:4:"DESC";s:7:"exposed";b:1;s:6:"expose";a:1:{s:5:"label";s:10:"Name (Z-A)";}s:11:"entity_type";s:5:"media";s:12:"entity_field";s:4:"name";s:9:"plugin_id";s:8:"standard";}}s:5:"title";s:5:"Media";s:6:"header";a:0:{}s:6:"footer";a:0:{}s:5:"empty";a:1:{s:16:"area_text_custom";a:10:{s:2:"id";s:16:"area_text_custom";s:5:"table";s:5:"views";s:5:"field";s:16:"area_text_custom";s:12:"relationship";s:4:"none";s:10:"group_type";s:5:"group";s:11:"admin_label";s:0:"";s:5:"empty";b:1;s:8:"tokenize";b:0;s:7:"content";s:19:"No media available.";s:9:"plugin_id";s:11:"text_custom";}}s:13:"relationships";a:0:{}s:17:"display_extenders";a:0:{}s:8:"use_ajax";b:1;s:9:"css_class";s:40:"media-library-view js-media-library-view";}s:14:"cache_metadata";a:3:{s:7:"max-age";i:0;s:8:"contexts";a:5:{i:0;s:28:"languages:language_interface";i:1;s:3:"url";i:2;s:14:"url.query_args";i:3;s:22:"url.query_args:sort_by";i:4;s:16:"user.permissions";}s:4:"tags";a:5:{i:0;s:51:"config:core.entity_view_display.media.audio.default";i:1;s:50:"config:core.entity_view_display.media.file.default";i:2;s:51:"config:core.entity_view_display.media.image.default";i:3;s:58:"config:core.entity_view_display.media.remote_video.default";i:4;s:51:"config:core.entity_view_display.media.video.default";}}}s:4:"page";a:6:{s:14:"display_plugin";s:4:"page";s:2:"id";s:4:"page";s:13:"display_title";s:4:"Page";s:8:"position";i:1;s:15:"display_options";a:3:{s:17:"display_extenders";a:0:{}s:4:"path";s:19:"admin/content/media";s:4:"menu";a:8:{s:4:"type";s:3:"tab";s:5:"title";s:5:"Media";s:11:"description";s:49:"Allows users to browse and administer media items";s:8:"expanded";b:0;s:6:"parent";s:20:"system.admin_content";s:6:"weight";i:5;s:7:"context";s:1:"0";s:9:"menu_name";s:5:"admin";}}s:14:"cache_metadata";a:3:{s:7:"max-age";i:0;s:8:"contexts";a:5:{i:0;s:28:"languages:language_interface";i:1;s:3:"url";i:2;s:14:"url.query_args";i:3;s:22:"url.query_args:sort_by";i:4;s:16:"user.permissions";}s:4:"tags";a:5:{i:0;s:51:"config:core.entity_view_display.media.audio.default";i:1;s:50:"config:core.entity_view_display.media.file.default";i:2;s:51:"config:core.entity_view_display.media.image.default";i:3;s:58:"config:core.entity_view_display.media.remote_video.default";i:4;s:51:"config:core.entity_view_display.media.video.default";}}}s:6:"widget";a:6:{s:14:"display_plugin";s:4:"page";s:2:"id";s:6:"widget";s:13:"display_title";s:6:"Widget";s:8:"position";i:2;s:15:"display_options";a:6:{s:17:"display_extenders";a:0:{}s:4:"path";s:26:"admin/content/media-widget";s:6:"fields";a:2:{s:15:"rendered_entity";a:24:{s:2:"id";s:15:"rendered_entity";s:5:"table";s:5:"media";s:5:"field";s:15:"rendered_entity";s:12:"relationship";s:4:"none";s:10:"group_type";s:5:"group";s:11:"admin_label";s:0:"";s:5:"label";s:0:"";s:7:"exclude";b:0;s:5:"alter";a:26:{s:10:"alter_text";b:0;s:4:"text";s:0:"";s:9:"make_link";b:0;s:4:"path";s:0:"";s:8:"absolute";b:0;s:8:"external";b:0;s:14:"replace_spaces";b:0;s:9:"path_case";s:4:"none";s:15:"trim_whitespace";b:0;s:3:"alt";s:0:"";s:3:"rel";s:0:"";s:10:"link_class";s:0:"";s:6:"prefix";s:0:"";s:6:"suffix";s:0:"";s:6:"target";s:0:"";s:5:"nl2br";b:0;s:10:"max_length";i:0;s:13:"word_boundary";b:1;s:8:"ellipsis";b:1;s:9:"more_link";b:0;s:14:"more_link_text";s:0:"";s:14:"more_link_path";s:0:"";s:10:"strip_tags";b:0;s:4:"trim";b:0;s:13:"preserve_tags";s:0:"";s:4:"html";b:0;}s:12:"element_type";s:0:"";s:13:"element_class";s:27:"media-library-item__content";s:18:"element_label_type";s:0:"";s:19:"element_label_class";s:0:"";s:19:"element_label_colon";b:0;s:20:"element_wrapper_type";s:0:"";s:21:"element_wrapper_class";s:0:"";s:23:"element_default_classes";b:1;s:5:"empty";s:0:"";s:10:"hide_empty";b:0;s:10:"empty_zero";b:0;s:16:"hide_alter_empty";b:1;s:9:"view_mode";s:13:"media_library";s:11:"entity_type";s:5:"media";s:9:"plugin_id";s:15:"rendered_entity";}s:25:"media_library_select_form";a:23:{s:2:"id";s:25:"media_library_select_form";s:5:"table";s:5:"media";s:5:"field";s:25:"media_library_select_form";s:12:"relationship";s:4:"none";s:10:"group_type";s:5:"group";s:11:"admin_label";s:0:"";s:5:"label";s:0:"";s:7:"exclude";b:0;s:5:"alter";a:26:{s:10:"alter_text";b:0;s:4:"text";s:0:"";s:9:"make_link";b:0;s:4:"path";s:0:"";s:8:"absolute";b:0;s:8:"external";b:0;s:14:"replace_spaces";b:0;s:9:"path_case";s:4:"none";s:15:"trim_whitespace";b:0;s:3:"alt";s:0:"";s:3:"rel";s:0:"";s:10:"link_class";s:0:"";s:6:"prefix";s:0:"";s:6:"suffix";s:0:"";s:6:"target";s:0:"";s:5:"nl2br";b:0;s:10:"max_length";i:0;s:13:"word_boundary";b:1;s:8:"ellipsis";b:1;s:9:"more_link";b:0;s:14:"more_link_text";s:0:"";s:14:"more_link_path";s:0:"";s:10:"strip_tags";b:0;s:4:"trim";b:0;s:13:"preserve_tags";s:0:"";s:4:"html";b:0;}s:12:"element_type";s:0:"";s:13:"element_class";s:0:"";s:18:"element_label_type";s:0:"";s:19:"element_label_class";s:0:"";s:19:"element_label_colon";b:0;s:20:"element_wrapper_type";s:0:"";s:21:"element_wrapper_class";s:27:"js-click-to-select-checkbox";s:23:"element_default_classes";b:1;s:5:"empty";s:0:"";s:10:"hide_empty";b:0;s:10:"empty_zero";b:0;s:16:"hide_alter_empty";b:1;s:11:"entity_type";s:5:"media";s:9:"plugin_id";s:25:"media_library_select_form";}}s:8:"defaults";a:2:{s:6:"fields";b:0;s:6:"access";b:0;}s:19:"display_description";s:0:"";s:6:"access";a:2:{s:4:"type";s:4:"perm";s:7:"options";a:1:{s:4:"perm";s:10:"view media";}}}s:14:"cache_metadata";a:3:{s:7:"max-age";i:-1;s:8:"contexts";a:5:{i:0;s:28:"languages:language_interface";i:1;s:3:"url";i:2;s:14:"url.query_args";i:3;s:22:"url.query_args:sort_by";i:4;s:16:"user.permissions";}s:4:"tags";a:5:{i:0;s:51:"config:core.entity_view_display.media.audio.default";i:1;s:50:"config:core.entity_view_display.media.file.default";i:2;s:51:"config:core.entity_view_display.media.image.default";i:3;s:58:"config:core.entity_view_display.media.remote_video.default";i:4;s:51:"config:core.entity_view_display.media.video.default";}}}}}', +)) +->execute(); + +// Insert media library key_value entries. +$connection->insert('key_value') +->fields(array( + 'collection', + 'name', + 'value', +)) +->values(array( + 'collection' => 'config.entity.key_store.entity_view_display', + 'name' => 'uuid:67e6d857-8ecb-49f5-95e1-6b1c4306c31f', + 'value' => 'a:1:{i:0;s:49:"core.entity_view_display.media.file.media_library";}', +)) +->values(array( + 'collection' => 'config.entity.key_store.entity_view_display', + 'name' => 'uuid:277ca98b-2ada-4251-ad69-aa73e72d60fe', + 'value' => 'a:1:{i:0;s:50:"core.entity_view_display.media.image.media_library";}', +)) +->values(array( + 'collection' => 'config.entity.key_store.entity_view_mode', + 'name' => 'uuid:20b2f1f7-a864-4d41-a15f-32f66789f73d', + 'value' => 'a:1:{i:0;s:41:"core.entity_view_mode.media.media_library";}', +)) +->values(array( + 'collection' => 'config.entity.key_store.view', + 'name' => 'uuid:3bc9cf0f-cb66-4dbe-8d7e-862cb85e5932', + 'value' => 'a:1:{i:0;s:24:"views.view.media_library";}', +)) +->execute(); 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 index e18981cd8de3..f7fd27b327f6 100644 --- 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 @@ -3,6 +3,7 @@ status: true dependencies: config: - field.field.node.basic_page.field_twin_media + - field.field.node.basic_page.field_single_media_type - field.field.node.basic_page.field_unlimited_media - field.field.node.basic_page.field_noadd_media - node.type.basic_page @@ -25,6 +26,12 @@ content: settings: { } third_party_settings: { } region: content + field_single_media_type: + type: media_library_widget + weight: 124 + settings: { } + third_party_settings: { } + region: content field_unlimited_media: type: media_library_widget weight: 121 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 index a66daea429d3..17fb52793fca 100644 --- 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 @@ -22,6 +22,15 @@ content: link: false third_party_settings: { } region: content + field_single_media_type: + type: entity_reference_entity_view + weight: 101 + label: above + settings: + view_mode: default + link: false + third_party_settings: { } + region: content field_unlimited_media: type: entity_reference_entity_view weight: 101 diff --git a/core/modules/media_library/tests/modules/media_library_test/config/install/field.field.node.basic_page.field_single_media_type.yml b/core/modules/media_library/tests/modules/media_library_test/config/install/field.field.node.basic_page.field_single_media_type.yml new file mode 100644 index 000000000000..4565087a3829 --- /dev/null +++ b/core/modules/media_library/tests/modules/media_library_test/config/install/field.field.node.basic_page.field_single_media_type.yml @@ -0,0 +1,28 @@ +langcode: en +status: true +dependencies: + config: + - field.storage.node.field_single_media_type + - media.type.type_one + - media.type.type_two + - node.type.basic_page +id: node.basic_page.field_single_media_type +field_name: field_single_media_type +entity_type: node +bundle: basic_page +label: 'Single media type' +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: file +field_type: entity_reference diff --git a/core/modules/media_library/tests/modules/media_library_test/config/install/field.storage.node.field_single_media_type.yml b/core/modules/media_library/tests/modules/media_library_test/config/install/field.storage.node.field_single_media_type.yml new file mode 100644 index 000000000000..cd1485a897e3 --- /dev/null +++ b/core/modules/media_library/tests/modules/media_library_test/config/install/field.storage.node.field_single_media_type.yml @@ -0,0 +1,19 @@ +langcode: en +status: true +dependencies: + module: + - media + - node +id: node.field_single_media_type +field_name: field_single_media_type +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/src/Functional/Update/MediaLibraryUpdateWidgetViewTest.php b/core/modules/media_library/tests/src/Functional/Update/MediaLibraryUpdateWidgetViewTest.php new file mode 100644 index 000000000000..eeb31e0cc9c5 --- /dev/null +++ b/core/modules/media_library/tests/src/Functional/Update/MediaLibraryUpdateWidgetViewTest.php @@ -0,0 +1,50 @@ +<?php + +namespace Drupal\Tests\media_library\Functional\Update; + +use Drupal\FunctionalTests\Update\UpdatePathTestBase; + +/** + * Tests the media library module updates for the widget view. + * + * @group media_library + * @group legacy + */ +class MediaLibraryUpdateWidgetViewTest extends UpdatePathTestBase { + + /** + * {@inheritdoc} + */ + protected function setDatabaseDumpFiles() { + $this->databaseDumpFiles = [ + __DIR__ . '/../../../../../system/tests/fixtures/update/drupal-8.4.0.bare.standard.php.gz', + __DIR__ . '/../../../../../media/tests/fixtures/update/drupal-8.4.0-media_installed.php', + __DIR__ . '/../../../fixtures/update/drupal-8.media_library-update-widget-view-3020716.php', + ]; + } + + /** + * Tests that the media library view config is updated. + * + * @see media_library_update_8700() + */ + public function testMediaLibraryViewsConfig() { + $config = $this->config('views.view.media_library'); + $this->assertNull($config->get('display.widget.display_options.defaults.filters')); + $this->assertNull($config->get('display.widget.display_options.defaults.arguments')); + $this->assertArrayNotHasKey('filters', $config->get('display.widget.display_options')); + $this->assertArrayNotHasKey('arguments', $config->get('display.widget.display_options')); + + $this->runUpdates(); + + $config = $this->config('views.view.media_library'); + $this->assertFalse($config->get('display.widget.display_options.defaults.filters')); + $this->assertFalse($config->get('display.widget.display_options.defaults.arguments')); + $this->assertArrayHasKey('filters', $config->get('display.widget.display_options')); + $this->assertArrayHasKey('arguments', $config->get('display.widget.display_options')); + $this->assertSame('1', $config->get('display.widget.display_options.filters.status.value')); + $this->assertTrue($config->get('display.widget.display_options.filters.name.exposed')); + $this->assertSame('ignore', $config->get('display.widget.display_options.arguments.bundle.default_action')); + } + +} diff --git a/core/modules/media_library/tests/src/FunctionalJavascript/MediaLibraryTest.php b/core/modules/media_library/tests/src/FunctionalJavascript/MediaLibraryTest.php index 3b7ff1fab98d..64f3ff59b3a1 100644 --- a/core/modules/media_library/tests/src/FunctionalJavascript/MediaLibraryTest.php +++ b/core/modules/media_library/tests/src/FunctionalJavascript/MediaLibraryTest.php @@ -4,6 +4,7 @@ use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\media\Entity\Media; +use Drupal\media_library\MediaLibraryState; use Drupal\Tests\TestFileCreationTrait; use Drupal\user\Entity\Role; use Drupal\user\RoleInterface; @@ -20,7 +21,7 @@ class MediaLibraryTest extends WebDriverTestBase { /** * {@inheritdoc} */ - protected static $modules = ['block', 'media_library_test']; + protected static $modules = ['block', 'media_library_test', 'field_ui']; /** * {@inheritdoc} @@ -65,6 +66,7 @@ protected function setUp() { 'create media', 'delete any media', 'view media', + 'administer node form display', ]); $this->drupalLogin($user); $this->drupalPlaceBlock('local_tasks_block'); @@ -135,6 +137,40 @@ public function testAdministrationPage() { $assert_session->linkExists('Add media'); } + /** + * Tests that the widget access works as expected. + */ + public function testWidgetAccess() { + $assert_session = $this->assertSession(); + + $this->drupalLogout(); + + $role = Role::load(RoleInterface::ANONYMOUS_ID); + $role->revokePermission('view media'); + $role->save(); + + // Create a working state. + $allowed_types = ['type_one', 'type_two']; + $state = MediaLibraryState::create('test', $allowed_types, 'type_two', 2); + $url_options = ['query' => $state->all()]; + + // Verify that unprivileged users can't access the widget view. + $this->drupalGet('admin/content/media-widget', $url_options); + $assert_session->responseContains('Access denied'); + $this->drupalGet('media-library', $url_options); + $assert_session->responseContains('Access denied'); + + // Allow users with 'view media' permission to access the media library view + // and controller. + $this->grantPermissions($role, [ + 'view media', + ]); + $this->drupalGet('admin/content/media-widget', $url_options); + $assert_session->elementExists('css', '.view-media-library'); + $this->drupalGet('media-library', $url_options); + $assert_session->elementExists('css', '.view-media-library'); + } + /** * Tests that the Media library's widget works as expected. */ @@ -145,121 +181,291 @@ public function testWidget() { // Visit a node create page. $this->drupalGet('node/add/basic_page'); - // Verify that both media widget instances are present. + // Assert that media widget instances are present. $assert_session->pageTextContains('Unlimited media'); $assert_session->pageTextContains('Twin media'); + $assert_session->pageTextContains('Single media type'); - // Add to the unlimited cardinality field. - $unlimited_button = $assert_session->elementExists('css', '.media-library-open-button[href*="field_unlimited_media"]'); - $unlimited_button->click(); + // Assert generic media library elements. + $assert_session->elementExists('css', '.media-library-open-button[href*="field_unlimited_media"]')->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'); + $this->assertFalse($assert_session->elementExists('css', '.media-library-select-all')->isVisible()); + $page->find('css', '.ui-dialog-titlebar-close')->click(); + $assert_session->assertWaitOnAjaxRequest(); + + // Assert that the media type menu is available when more than 1 type is + // configured for the field. + $assert_session->elementExists('css', '.media-library-open-button[href*="field_unlimited_media"]')->click(); + $assert_session->assertWaitOnAjaxRequest(); + $menu = $assert_session->elementExists('css', '.media-library-menu'); + $assert_session->elementExists('named', ['link', 'Type One'], $menu); + $assert_session->elementNotExists('named', ['link', 'Type Two'], $menu); + $assert_session->elementExists('named', ['link', 'Type Three'], $menu); + $assert_session->elementNotExists('named', ['link', 'Type Four'], $menu); + $page->find('css', '.ui-dialog-titlebar-close')->click(); + $assert_session->assertWaitOnAjaxRequest(); + + // Assert that the media type menu is not available when only 1 type is + // configured for the field. + $assert_session->elementExists('css', '.media-library-open-button[href*="field_single_media_type"]')->click(); + $assert_session->assertWaitOnAjaxRequest(); + $assert_session->elementTextContains('css', '.media-library-selected-count', '0 of 1 item selected'); + // Select a media item, assert the hidden selection field contains the ID of + // the selected item. + $checkboxes = $page->findAll('css', '.media-library-view .js-click-to-select-checkbox input'); + $checkboxes[0]->click(); + $assert_session->hiddenFieldValueEquals('media-library-modal-selection', '4'); + $assert_session->elementTextContains('css', '.media-library-selected-count', '1 of 1 item selected'); + $assert_session->elementNotExists('css', '.media-library-menu'); + $page->find('css', '.ui-dialog-titlebar-close')->click(); + $assert_session->assertWaitOnAjaxRequest(); + + // Assert the menu links can be sorted through the widget configuration. + $assert_session->elementExists('css', '.media-library-open-button[href*="field_twin_media"]')->click(); + $assert_session->assertWaitOnAjaxRequest(); + $links = $page->findAll('css', '.media-library-menu a'); + $link_titles = []; + foreach ($links as $link) { + $link_titles[] = $link->getText(); + } + $expected_link_titles = ['Type One (active tab)', 'Type Two', 'Type Three', 'Type Four']; + $this->assertSame($link_titles, $expected_link_titles); + $this->drupalGet('admin/structure/types/manage/basic_page/form-display'); + $assert_session->buttonExists('field_twin_media_settings_edit')->press(); + $assert_session->assertWaitOnAjaxRequest(); + $assert_session->buttonExists('Show row weights')->press(); + $assert_session->fieldExists('fields[field_twin_media][settings_edit_form][settings][media_types][type_one][weight]')->selectOption(0); + $assert_session->fieldExists('fields[field_twin_media][settings_edit_form][settings][media_types][type_three][weight]')->selectOption(1); + $assert_session->fieldExists('fields[field_twin_media][settings_edit_form][settings][media_types][type_four][weight]')->selectOption(2); + $assert_session->fieldExists('fields[field_twin_media][settings_edit_form][settings][media_types][type_two][weight]')->selectOption(3); + $assert_session->buttonExists('Save')->press(); + $assert_session->assertWaitOnAjaxRequest(); + $assert_session->buttonExists('Hide row weights')->press(); + $this->drupalGet('node/add/basic_page'); + $assert_session->elementExists('css', '.media-library-open-button[href*="field_twin_media"]')->click(); + $assert_session->assertWaitOnAjaxRequest(); + $link_titles = array_map(function ($link) { + return $link->getText(); + }, $page->findAll('css', '.media-library-menu a')); + $this->assertSame($link_titles, ['Type One (active tab)', 'Type Three', 'Type Four', 'Type Two']); + $page->find('css', '.ui-dialog-titlebar-close')->click(); + $assert_session->assertWaitOnAjaxRequest(); + + // Assert media is only visible on the tab for the related media type. + $assert_session->elementExists('css', '.media-library-open-button[href*="field_unlimited_media"]')->click(); + $assert_session->assertWaitOnAjaxRequest(); $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. + $assert_session->elementExists('named', ['link', 'Type Three'])->click(); + $assert_session->assertWaitOnAjaxRequest(); + $assert_session->elementExists('named', ['link', 'Type Three (active tab)']); + $assert_session->pageTextNotContains('Dog'); + $assert_session->pageTextNotContains('Bear'); + $assert_session->pageTextNotContains('Turtle'); + $page->find('css', '.ui-dialog-titlebar-close')->click(); + $assert_session->assertWaitOnAjaxRequest(); + + // Assert the exposed name filter of the view. + $assert_session->elementExists('css', '.media-library-open-button[href*="field_unlimited_media"]')->click(); + $assert_session->assertWaitOnAjaxRequest(); $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); + $assert_session->pageTextContains('Dog'); + $assert_session->pageTextContains('Bear'); + $page->find('css', '.ui-dialog-titlebar-close')->click(); + $assert_session->assertWaitOnAjaxRequest(); + + // Assert the selection is persistent in the media library modal, and + // the number of selected items is displayed correctly. + $assert_session->elementExists('css', '.media-library-open-button[href*="field_twin_media"]')->click(); + $assert_session->assertWaitOnAjaxRequest(); + // Assert the number of selected items is displayed correctly. + $assert_session->elementExists('css', '.media-library-selected-count'); + $assert_session->elementTextContains('css', '.media-library-selected-count', '0 of 2 items selected'); + $assert_session->elementAttributeContains('css', '.media-library-selected-count', 'aria-live', 'polite'); + // Select a media item, assert the hidden selection field contains the ID of + // the selected item. + $checkboxes = $page->findAll('css', '.media-library-view .js-click-to-select-checkbox input'); $checkboxes[0]->click(); + $assert_session->hiddenFieldValueEquals('media-library-modal-selection', '4'); + // Assert the number of selected items is displayed correctly. + $assert_session->elementTextContains('css', '.media-library-selected-count', '1 of 2 items selected'); + // Select another item and assert the number of selected items is updated. $checkboxes[1]->click(); - $checkboxes[2]->click(); + $assert_session->elementTextContains('css', '.media-library-selected-count', '2 of 2 items selected'); + $assert_session->hiddenFieldValueEquals('media-library-modal-selection', '4,3'); + // Assert unselected items are disabled when the maximum allowed items are + // selected (cardinality for this field is 2). + $this->assertTrue($checkboxes[2]->hasAttribute('disabled')); + $this->assertTrue($checkboxes[3]->hasAttribute('disabled')); + // Assert the selected items are updated when deselecting an item. + $checkboxes[0]->click(); + $assert_session->elementTextContains('css', '.media-library-selected-count', '1 of 2 items selected'); + $assert_session->hiddenFieldValueEquals('media-library-modal-selection', '3'); + // Assert deselected items are available again. + $this->assertFalse($checkboxes[2]->hasAttribute('disabled')); + $this->assertFalse($checkboxes[3]->hasAttribute('disabled')); + // The selection should be persisted when navigating to other media types in + // the modal. + $assert_session->elementExists('named', ['link', 'Type Three'])->click(); + $assert_session->assertWaitOnAjaxRequest(); + $assert_session->elementExists('named', ['link', 'Type One'])->click(); + $assert_session->assertWaitOnAjaxRequest(); + $checkboxes = $page->findAll('css', '.media-library-view .js-click-to-select-checkbox input'); + $selected_checkboxes = []; + foreach ($checkboxes as $checkbox) { + if ($checkbox->isChecked()) { + $selected_checkboxes[] = $checkbox->getValue(); + } + } + $this->assertCount(1, $selected_checkboxes); + $assert_session->hiddenFieldValueEquals('media-library-modal-selection', implode(',', $selected_checkboxes)); + $assert_session->elementTextContains('css', '.media-library-selected-count', '1 of 2 items selected'); + // Add to selection from another type. + $assert_session->elementExists('named', ['link', 'Type Two'])->click(); + $assert_session->assertWaitOnAjaxRequest(); + $checkboxes = $page->findAll('css', '.media-library-view .js-click-to-select-checkbox input'); + $checkboxes[0]->click(); + // Assert the selection is updated correctly. + $assert_session->elementTextContains('css', '.media-library-selected-count', '2 of 2 items selected'); + $assert_session->hiddenFieldValueEquals('media-library-modal-selection', '3,8'); + // Assert unselected items are disabled when the maximum allowed items are + // selected (cardinality for this field is 2). + $this->assertFalse($checkboxes[0]->hasAttribute('disabled')); + $this->assertTrue($checkboxes[1]->hasAttribute('disabled')); + $this->assertTrue($checkboxes[2]->hasAttribute('disabled')); + $this->assertTrue($checkboxes[3]->hasAttribute('disabled')); + // Assert the checkboxes are also disabled on other pages. + $assert_session->elementExists('named', ['link', 'Type One'])->click(); + $assert_session->assertWaitOnAjaxRequest(); + $this->assertTrue($checkboxes[0]->hasAttribute('disabled')); + $this->assertFalse($checkboxes[1]->hasAttribute('disabled')); + $this->assertTrue($checkboxes[2]->hasAttribute('disabled')); + $this->assertTrue($checkboxes[3]->hasAttribute('disabled')); + // Select the items. $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->elementAttributeContains('css', '.media-library-item__remove', 'aria-label', 'Remove Dog'); - $assert_session->elementExists('css', '.media-library-item__remove')->click(); - $assert_session->assertWaitOnAjaxRequest(); $assert_session->pageTextNotContains('Dog'); $assert_session->pageTextContains('Cat'); - $assert_session->pageTextContains('Bear'); + $assert_session->pageTextContains('Turtle'); + $assert_session->pageTextNotContains('Snake'); - // 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(); + // Remove "Cat" (happens to be the first remove button on the page). + $assert_session->elementAttributeContains('css', '.media-library-item__remove', 'aria-label', 'Remove Cat'); + $assert_session->elementExists('css', '.media-library-item__remove')->click(); $assert_session->assertWaitOnAjaxRequest(); - // This field allows both media types. - $assert_session->pageTextContains('Media library'); - $assert_session->pageTextContains('Dog'); + $assert_session->pageTextNotContains('Cat'); $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')); + + // Open the media library again and select another item. + $assert_session->elementExists('css', '.media-library-open-button[href*="field_twin_media"]')->click(); + $assert_session->assertWaitOnAjaxRequest(); + $checkboxes = $page->findAll('css', '.media-library-view .js-click-to-select-checkbox input'); $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('Dog'); + $assert_session->pageTextNotContains('Cat'); $assert_session->pageTextContains('Turtle'); $assert_session->pageTextNotContains('Snake'); + // Assert we are not allowed to add more items to the field. + $assert_session->elementNotExists('css', '.media-library-open-button[href*="field_twin_media"]'); + + // Assert the selection is cleared when the modal is closed. + $assert_session->elementExists('css', '.media-library-open-button[href*="field_unlimited_media"]')->click(); + $assert_session->assertWaitOnAjaxRequest(); + // Nothing is selected yet. + $this->assertFalse($checkboxes[0]->isChecked()); + $this->assertFalse($checkboxes[1]->isChecked()); + $this->assertFalse($checkboxes[2]->isChecked()); + $this->assertFalse($checkboxes[3]->isChecked()); + $assert_session->elementTextContains('css', '.media-library-selected-count', '0 items selected'); + // Select the first 2 items. + $checkboxes = $page->findAll('css', '.media-library-view .js-click-to-select-checkbox input'); + $checkboxes[0]->click(); + $assert_session->elementTextContains('css', '.media-library-selected-count', '1 item selected'); + $checkboxes[1]->click(); + $assert_session->elementTextContains('css', '.media-library-selected-count', '2 items selected'); + $this->assertTrue($checkboxes[0]->isChecked()); + $this->assertTrue($checkboxes[1]->isChecked()); + $this->assertFalse($checkboxes[2]->isChecked()); + $this->assertFalse($checkboxes[3]->isChecked()); + // Close the dialog, reopen it and assert not is selected again. + $page->find('css', '.ui-dialog-titlebar-close')->click(); + $assert_session->assertWaitOnAjaxRequest(); + $assert_session->elementExists('css', '.media-library-open-button[href*="field_unlimited_media"]')->click(); + $assert_session->assertWaitOnAjaxRequest(); + $checkboxes = $page->findAll('css', '.media-library-view .js-click-to-select-checkbox input'); + $this->assertFalse($checkboxes[0]->isChecked()); + $this->assertFalse($checkboxes[1]->isChecked()); + $this->assertFalse($checkboxes[2]->isChecked()); + $this->assertFalse($checkboxes[3]->isChecked()); + $page->find('css', '.ui-dialog-titlebar-close')->click(); + $assert_session->assertWaitOnAjaxRequest(); + // 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', + 'field_twin_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('Cat'); + // This item was never selected. $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")'); + // "Dog" should come after "Turtle", since we changed the weight. + $assert_session->elementExists('css', '.field--name-field-twin-media > .field__items > .field__item:last-child:contains("Turtle")'); // Make sure everything that was selected shows up. - $assert_session->pageTextContains('Cat'); - $assert_session->pageTextContains('Bear'); - $assert_session->pageTextContains('Horse'); + $assert_session->pageTextContains('Dog'); $assert_session->pageTextContains('Turtle'); // Re-edit the content and make a new selection. $this->drupalGet('node/1/edit'); - $assert_session->pageTextNotContains('Dog'); - $assert_session->pageTextContains('Cat'); - $assert_session->pageTextContains('Bear'); - $assert_session->pageTextContains('Horse'); + $assert_session->pageTextContains('Dog'); + $assert_session->pageTextNotContains('Cat'); + $assert_session->pageTextNotContains('Bear'); + $assert_session->pageTextNotContains('Horse'); $assert_session->pageTextContains('Turtle'); - $unlimited_button = $assert_session->elementExists('css', '.media-library-open-button[href*="field_unlimited_media"]'); - $unlimited_button->click(); + $assert_session->pageTextNotContains('Snake'); + $assert_session->elementExists('css', '.media-library-open-button[href*="field_unlimited_media"]')->click(); $assert_session->assertWaitOnAjaxRequest(); $assert_session->pageTextContains('Media library'); - // Select the first media items (should be Dog, again). + // Select all media items of type one (should also contain Dog, again). $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(); + $checkboxes[3]->click(); $assert_session->elementExists('css', '.ui-dialog-buttonpane')->pressButton('Select media'); $assert_session->assertWaitOnAjaxRequest(); - // "Dog" and the existing selection should still exist. $assert_session->pageTextContains('Dog'); $assert_session->pageTextContains('Cat'); $assert_session->pageTextContains('Bear'); $assert_session->pageTextContains('Horse'); $assert_session->pageTextContains('Turtle'); + $assert_session->pageTextNotContains('Snake'); + $this->submitForm([], 'Save'); + $assert_session->pageTextContains('Dog'); + $assert_session->pageTextContains('Cat'); + $assert_session->pageTextContains('Bear'); + $assert_session->pageTextContains('Horse'); + $assert_session->pageTextContains('Turtle'); + $assert_session->pageTextNotContains('Snake'); } /** @@ -270,15 +476,8 @@ public function testWidgetAnonymous() { $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. + $role = Role::load(RoleInterface::ANONYMOUS_ID); $this->grantPermissions($role, [ 'access content', 'create basic_page content', @@ -289,8 +488,7 @@ public function testWidgetAnonymous() { $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->elementExists('css', '.media-library-open-button[href*="field_unlimited_media"]')->click(); $assert_session->assertWaitOnAjaxRequest(); // Select the first media item (should be Dog). @@ -343,8 +541,7 @@ public function testWidgetUpload() { $file_system = $this->container->get('file_system'); // Add to the twin media field. - $twin_button = $assert_session->elementExists('css', '.media-library-open-button[href*="field_twin_media"]'); - $twin_button->click(); + $assert_session->elementExists('css', '.media-library-open-button[href*="field_twin_media"]')->click(); $assert_session->assertWaitOnAjaxRequest(); $assert_session->pageTextContains('Media library'); $assert_session->elementExists('css', '#drupal-modal')->clickLink('Add media'); @@ -377,8 +574,7 @@ public function testWidgetUpload() { $assert_session->pageTextContains($png_image->filename); // Also make sure that we can upload to the unlimited cardinality field. - $unlimited_button = $assert_session->elementExists('css', '.media-library-open-button[href*="field_unlimited_media"]'); - $unlimited_button->click(); + $assert_session->elementExists('css', '.media-library-open-button[href*="field_unlimited_media"]')->click(); $assert_session->assertWaitOnAjaxRequest(); $assert_session->pageTextContains('Media library'); $assert_session->elementExists('css', '#drupal-modal')->clickLink('Add media'); @@ -400,8 +596,7 @@ public function testWidgetUpload() { $assert_session->pageTextContains('Unlimited Cardinality Image'); // Open the browser again to test type resolution. - $twin_button = $assert_session->elementExists('css', '.media-library-open-button[href*="field_twin_media"]'); - $twin_button->click(); + $assert_session->elementExists('css', '.media-library-open-button[href*="field_twin_media"]')->click(); $assert_session->assertWaitOnAjaxRequest(); $assert_session->pageTextContains('Media library'); $assert_session->elementExists('css', '#drupal-modal')->clickLink('Add media'); diff --git a/core/modules/media_library/tests/src/Kernel/MediaLibraryAccessTest.php b/core/modules/media_library/tests/src/Kernel/MediaLibraryAccessTest.php index f02347d7b93b..5f305b4f832e 100644 --- a/core/modules/media_library/tests/src/Kernel/MediaLibraryAccessTest.php +++ b/core/modules/media_library/tests/src/Kernel/MediaLibraryAccessTest.php @@ -2,9 +2,11 @@ namespace Drupal\Tests\media_library\Kernel; +use Drupal\Core\Access\AccessResult; use Drupal\image\Entity\ImageStyle; use Drupal\KernelTests\KernelTestBase; use Drupal\Tests\user\Traits\UserCreationTrait; +use Drupal\views\Views; /** * Tests the media library access. @@ -69,4 +71,55 @@ public function testMediaLibraryImageStyleAccess() { $this->assertFalse(ImageStyle::load('media_library')->access('delete', $user)); } + /** + * Tests the Media Library access. + */ + public function testMediaLibraryAccess() { + /** @var \Drupal\media_library\MediaLibraryUiBuilder $ui_builder */ + $ui_builder = $this->container->get('media_library.ui_builder'); + + $view_original = clone Views::getView('media_library'); + + // Create our test users. + $forbidden_account = $this->createUser([]); + $allowed_account = $this->createUser(['view media']); + + // Assert the 'view media' permission is needed to access the library and + // validate the cache dependencies. + $this->assertSame(AccessResult::forbidden()->isAllowed(), $ui_builder->checkAccess($forbidden_account)->isAllowed()); + $this->assertSame("The 'view media' permission is required.", $ui_builder->checkAccess($forbidden_account)->getReason()); + $this->assertSame($view_original->storage->getCacheTags(), $ui_builder->checkAccess($forbidden_account)->getCacheTags()); + $this->assertSame(['user.permissions'], $ui_builder->checkAccess($forbidden_account)->getCacheContexts()); + $this->assertSame(AccessResult::allowed()->isAllowed(), $ui_builder->checkAccess($allowed_account)->isAllowed()); + $this->assertSame($view_original->storage->getCacheTags(), $ui_builder->checkAccess($allowed_account)->getCacheTags()); + $this->assertSame(['user.permissions'], $ui_builder->checkAccess($allowed_account)->getCacheContexts()); + + // Assert that the media library access is denied when the view widget + // display is deleted. + $view_storage = Views::getView('media_library')->storage; + $displays = $view_storage->get('display'); + unset($displays['widget']); + $view_storage->set('display', $displays); + $view_storage->save(); + $this->assertSame(AccessResult::forbidden()->isAllowed(), $ui_builder->checkAccess($allowed_account)->isAllowed()); + $this->assertSame('The media library widget display does not exist.', $ui_builder->checkAccess($forbidden_account)->getReason()); + $this->assertSame($view_original->storage->getCacheTags(), $ui_builder->checkAccess($forbidden_account)->getCacheTags()); + $this->assertSame([], $ui_builder->checkAccess($forbidden_account)->getCacheContexts()); + + // Restore the original view and assert that the media library controller + // works again. + $view_original->storage->save(); + $this->assertSame(AccessResult::allowed()->isAllowed(), $ui_builder->checkAccess($allowed_account)->isAllowed()); + $this->assertSame($view_original->storage->getCacheTags(), $ui_builder->checkAccess($allowed_account)->getCacheTags()); + $this->assertSame(['user.permissions'], $ui_builder->checkAccess($allowed_account)->getCacheContexts()); + + // Assert that the media library access is denied when the entire media + // library view is deleted. + Views::getView('media_library')->storage->delete(); + $this->assertSame(AccessResult::forbidden()->isAllowed(), $ui_builder->checkAccess($allowed_account)->isAllowed()); + $this->assertSame('The media library view does not exist.', $ui_builder->checkAccess($forbidden_account)->getReason()); + $this->assertSame([], $ui_builder->checkAccess($forbidden_account)->getCacheTags()); + $this->assertSame([], $ui_builder->checkAccess($forbidden_account)->getCacheContexts()); + } + } diff --git a/core/modules/media_library/tests/src/Kernel/MediaLibraryStateTest.php b/core/modules/media_library/tests/src/Kernel/MediaLibraryStateTest.php new file mode 100644 index 000000000000..b3b027abb22b --- /dev/null +++ b/core/modules/media_library/tests/src/Kernel/MediaLibraryStateTest.php @@ -0,0 +1,259 @@ +<?php + +namespace Drupal\Tests\media_library\Kernel; + +use Drupal\KernelTests\KernelTestBase; +use Drupal\media_library\MediaLibraryState; +use Drupal\Tests\media\Traits\MediaTypeCreationTrait; + +/** + * Tests the media library state value object. + * + * @group media_library + * + * @coversDefaultClass \Drupal\media_library\MediaLibraryState + */ +class MediaLibraryStateTest extends KernelTestBase { + + use MediaTypeCreationTrait; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'media', + 'media_library', + 'file', + 'field', + 'image', + 'system', + 'views', + 'user', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp() { + parent::setUp(); + + $this->installEntitySchema('user'); + $this->installEntitySchema('file'); + $this->installSchema('file', 'file_usage'); + $this->installSchema('system', 'sequences'); + $this->installEntitySchema('media'); + $this->installConfig([ + 'field', + 'system', + 'file', + 'image', + 'media', + 'media_library', + ]); + + // Create some media types to validate against. + $this->createMediaType('file', ['id' => 'file']); + $this->createMediaType('image', ['id' => 'image']); + $this->createMediaType('video_file', ['id' => 'video']); + } + + /** + * Tests the media library state methods. + */ + public function testMethods() { + $opener_id = 'test'; + $allowed_media_type_ids = ['file', 'image']; + $selected_media_type_id = 'image'; + $remaining_slots = 2; + + $state = MediaLibraryState::create($opener_id, $allowed_media_type_ids, $selected_media_type_id, $remaining_slots); + $this->assertSame($opener_id, $state->getOpenerId()); + $this->assertSame($allowed_media_type_ids, $state->getAllowedTypeIds()); + $this->assertSame($selected_media_type_id, $state->getSelectedTypeId()); + $this->assertSame($remaining_slots, $state->getAvailableSlots()); + $this->assertTrue($state->hasSlotsAvailable()); + + $state = MediaLibraryState::create($opener_id, $allowed_media_type_ids, $selected_media_type_id, 0); + $this->assertFalse($state->hasSlotsAvailable()); + } + + /** + * Tests the media library state creation. + * + * @param string $opener_id + * The opener ID. + * @param string[] $allowed_media_type_ids + * The allowed media type IDs. + * @param string $selected_type_id + * The selected media type ID. + * @param int $remaining_slots + * The number of remaining items the user is allowed to select or add in the + * library. + * @param string $exception_message + * The expected exception message. + * + * @covers ::create + * @dataProvider providerCreate + */ + public function testCreate($opener_id, array $allowed_media_type_ids, $selected_type_id, $remaining_slots, $exception_message = '') { + if ($exception_message) { + $this->setExpectedException(\InvalidArgumentException::class, $exception_message); + } + $state = MediaLibraryState::create($opener_id, $allowed_media_type_ids, $selected_type_id, $remaining_slots); + $this->assertInstanceOf(MediaLibraryState::class, $state); + } + + /** + * Data provider for testCreate(). + * + * @return array + * The data sets to test. + */ + public function providerCreate() { + $test_data = []; + + // Assert no exception is thrown when we add the parameters as expected. + $test_data['valid parameters'] = [ + 'test', + ['file', 'image'], + 'image', + 2, + ]; + + // Assert an exception is thrown when the opener ID parameter is empty. + $test_data['empty opener ID'] = [ + '', + ['file', 'image'], + 'image', + 2, + 'The opener ID parameter is required and must be a string.', + ]; + // Assert an exception is thrown when the opener ID parameter is not a + // valid string. + $test_data['integer opener ID'] = [ + 1, + ['file', 'image'], + 'image', + 2, + 'The opener ID parameter is required and must be a string.', + ]; + $test_data['boolean opener ID'] = [ + TRUE, + ['file', 'image'], + 'image', + 2, + 'The opener ID parameter is required and must be a string.', + ]; + $test_data['spaces opener ID'] = [ + ' ', + ['file', 'image'], + 'image', + 2, + 'The opener ID parameter is required and must be a string.', + ]; + + // Assert an exception is thrown when the allowed types parameter is empty. + $test_data['empty allowed types'] = [ + 'test', + [], + 'image', + 2, + 'The allowed types parameter is required and must be an array of strings.', + ]; + // It is not possible to assert a non-array allowed types parameter, since + // that would throw a TypeError which is not a subclass of Exception. + // Continue asserting an exception is thrown when the allowed types + // parameter contains elements that are not a valid string. + $test_data['integer in allowed types'] = [ + 'test', + [1, 'image'], + 'image', + 2, + 'The allowed types parameter is required and must be an array of strings.', + ]; + $test_data['boolean in allowed types'] = [ + 'test', + [TRUE, 'image'], + 'image', + 2, + 'The allowed types parameter is required and must be an array of strings.', + ]; + $test_data['spaces in allowed types'] = [ + 'test', + [' ', 'image'], + 'image', + 2, + 'The allowed types parameter is required and must be an array of strings.', + ]; + + // Assert an exception is thrown when the selected type parameter is empty. + $test_data['empty selected type'] = [ + 'test', + ['file', 'image'], + '', + 2, + 'The selected type parameter is required and must be a string.', + ]; + // Assert an exception is thrown when the selected type parameter is not a + // valid string. + $test_data['numeric selected type'] = [ + 'test', + ['file', 'image'], + 1, + 2, + 'The selected type parameter is required and must be a string.', + ]; + $test_data['boolean selected type'] = [ + 'test', + ['file', 'image'], + TRUE, + 2, + 'The selected type parameter is required and must be a string.', + ]; + $test_data['spaces selected type'] = [ + 'test', + ['file', 'image'], + ' ', + 2, + 'The selected type parameter is required and must be a string.', + ]; + // Assert an exception is thrown when the selected type parameter is not in + // the list of allowed types. + $test_data['non-present selected type'] = [ + 'test', + ['file', 'image'], + 'video', + 2, + 'The selected type parameter must be present in the list of allowed types.', + ]; + + // Assert an exception is thrown when the remaining slots parameter is + // empty. + $test_data['empty remaining slots'] = [ + 'test', + ['file', 'image'], + 'image', + '', + 'The remaining slots parameter is required and must be numeric.', + ]; + // Assert an exception is thrown when the remaining slots parameter is + // not numeric. + $test_data['string remaining slots'] = [ + 'test', + ['file', 'image'], + 'image', + 'fail', + 'The remaining slots parameter is required and must be numeric.', + ]; + $test_data['boolean remaining slots'] = [ + 'test', + ['file', 'image'], + 'image', + TRUE, + 'The remaining slots parameter is required and must be numeric.', + ]; + + return $test_data; + } + +} -- GitLab