diff --git a/core/core.libraries.yml b/core/core.libraries.yml index 918a36afd309cc9772aedb0c1f6b33cf1d56c186..86879cfdee29485337a266c1d2efa786e6fb40ee 100644 --- a/core/core.libraries.yml +++ b/core/core.libraries.yml @@ -233,6 +233,14 @@ drupal.machine-name: - core/drupalSettings - core/drupal.form +drupal.message: + version: VERSION + js: + misc/message.js: {} + dependencies: + - core/drupal + - core/drupal.announce + drupal.progress: version: VERSION js: diff --git a/core/lib/Drupal/Core/Render/Element/StatusMessages.php b/core/lib/Drupal/Core/Render/Element/StatusMessages.php index d8627343eea01bb766de507d518bd0c139492fa7..e02c91aef0cc132db09200a441e61b9ba6e61c8d 100644 --- a/core/lib/Drupal/Core/Render/Element/StatusMessages.php +++ b/core/lib/Drupal/Core/Render/Element/StatusMessages.php @@ -32,6 +32,7 @@ public function getInfo() { '#pre_render' => [ get_class() . '::generatePlaceholder', ], + '#include_fallback' => FALSE, ]; } @@ -45,7 +46,7 @@ public function getInfo() { * The updated renderable array containing the placeholder. */ public static function generatePlaceholder(array $element) { - $element = [ + $build = [ '#lazy_builder' => [get_class() . '::renderMessages', [$element['#display']]], '#create_placeholder' => TRUE, ]; @@ -53,7 +54,17 @@ public static function generatePlaceholder(array $element) { // Directly create a placeholder as we need this to be placeholdered // regardless if this is a POST or GET request. // @todo remove this when https://www.drupal.org/node/2367555 lands. - return \Drupal::service('render_placeholder_generator')->createPlaceholder($element); + $build = \Drupal::service('render_placeholder_generator')->createPlaceholder($build); + + if ($element['#include_fallback']) { + return [ + 'fallback' => [ + '#markup' => '<div data-drupal-messages-fallback class="hidden"></div>', + ], + 'messages' => $build, + ]; + } + return $build; } /** diff --git a/core/lib/Drupal/Core/Render/Plugin/DisplayVariant/SimplePageVariant.php b/core/lib/Drupal/Core/Render/Plugin/DisplayVariant/SimplePageVariant.php index 9296d1d1e7a9511f3af4530da3c407a040838161..7dc930695d0f302863b7a39447e409b0d055876b 100644 --- a/core/lib/Drupal/Core/Render/Plugin/DisplayVariant/SimplePageVariant.php +++ b/core/lib/Drupal/Core/Render/Plugin/DisplayVariant/SimplePageVariant.php @@ -54,6 +54,7 @@ public function build() { 'messages' => [ '#type' => 'status_messages', '#weight' => -1000, + '#include_fallback' => TRUE, ], 'page_title' => [ '#type' => 'page_title', diff --git a/core/misc/message.es6.js b/core/misc/message.es6.js new file mode 100644 index 0000000000000000000000000000000000000000..252d629310fb6462988e7efea0141ab8ea0de4dc --- /dev/null +++ b/core/misc/message.es6.js @@ -0,0 +1,256 @@ +/** + * @file + * Message API. + */ +(Drupal => { + /** + * @typedef {class} Drupal.Message~messageDefinition + */ + + /** + * Constructs a new instance of the Drupal.Message class. + * + * This provides a uniform interface for adding and removing messages to a + * specific location on the page. + * + * @param {HTMLElement} messageWrapper + * The zone where to add messages. If no element is provided an attempt is + * made to determine a default location. + * + * @return {Drupal.Message~messageDefinition} + * Class to add and remove messages. + */ + Drupal.Message = class { + constructor(messageWrapper = null) { + this.messageWrapper = messageWrapper; + } + + /** + * Attempt to determine the default location for + * inserting JavaScript messages or create one if needed. + * + * @return {HTMLElement} + * The default destination for JavaScript messages. + */ + static defaultWrapper() { + let wrapper = document.querySelector('[data-drupal-messages]'); + if (!wrapper) { + wrapper = document.querySelector('[data-drupal-messages-fallback]'); + wrapper.removeAttribute('data-drupal-messages-fallback'); + wrapper.setAttribute('data-drupal-messages', ''); + wrapper.removeAttribute('class'); + } + return wrapper.innerHTML === '' + ? Drupal.Message.messageInternalWrapper(wrapper) + : wrapper.firstElementChild; + } + + /** + * Provide an object containing the available message types. + * + * @return {Object} + * An object containing message type strings. + */ + static getMessageTypeLabels() { + return { + status: Drupal.t('Status message'), + error: Drupal.t('Error message'), + warning: Drupal.t('Warning message'), + }; + } + + /** + * Sequentially adds a message to the message area. + * + * @name Drupal.Message~messageDefinition.add + * + * @param {string} message + * The message to display + * @param {object} [options] + * The context of the message. + * @param {string} [options.id] + * The message ID, it can be a simple value: `'filevalidationerror'` + * or several values separated by a space: `'mymodule formvalidation'` + * which can be used as an explicit selector for a message. + * @param {string} [options.type=status] + * Message type, can be either 'status', 'error' or 'warning'. + * @param {string} [options.announce] + * Screen-reader version of the message if necessary. To prevent a message + * being sent to Drupal.announce() this should be an emptry string. + * @param {string} [options.priority] + * Priority of the message for Drupal.announce(). + * + * @return {string} + * ID of message. + */ + add(message, options = {}) { + if (!this.messageWrapper) { + this.messageWrapper = Drupal.Message.defaultWrapper(); + } + if (!options.hasOwnProperty('type')) { + options.type = 'status'; + } + + if (typeof message !== 'string') { + throw new Error('Message must be a string.'); + } + + // Send message to screen reader. + Drupal.Message.announce(message, options); + /** + * Use the provided index for the message or generate a pseudo-random key + * to allow message deletion. + */ + options.id = options.id + ? String(options.id) + : `${options.type}-${Math.random() + .toFixed(15) + .replace('0.', '')}`; + + // Throw an error if an unexpected message type is used. + if (!Drupal.Message.getMessageTypeLabels().hasOwnProperty(options.type)) { + throw new Error( + `The message type, ${ + options.type + }, is not present in Drupal.Message.getMessageTypeLabels().`, + ); + } + + this.messageWrapper.appendChild( + Drupal.theme('message', { text: message }, options), + ); + + return options.id; + } + + /** + * Select a message based on id. + * + * @name Drupal.Message~messageDefinition.select + * + * @param {string} id + * The message id to delete from the area. + * + * @return {Element} + * Element found. + */ + select(id) { + return this.messageWrapper.querySelector( + `[data-drupal-message-id^="${id}"]`, + ); + } + + /** + * Removes messages from the message area. + * + * @name Drupal.Message~messageDefinition.remove + * + * @param {string} id + * Index of the message to remove, as returned by + * {@link Drupal.Message~messageDefinition.add}. + * + * @return {number} + * Number of removed messages. + */ + remove(id) { + return this.messageWrapper.removeChild(this.select(id)); + } + + /** + * Removes all messages from the message area. + * + * @name Drupal.Message~messageDefinition.clear + */ + clear() { + Array.prototype.forEach.call( + this.messageWrapper.querySelectorAll('[data-drupal-message-id]'), + message => { + this.messageWrapper.removeChild(message); + }, + ); + } + + /** + * Helper to call Drupal.announce() with the right parameters. + * + * @param {string} message + * Displayed message. + * @param {object} options + * Additional data. + * @param {string} [options.announce] + * Screen-reader version of the message if necessary. To prevent a message + * being sent to Drupal.announce() this should be `''`. + * @param {string} [options.priority] + * Priority of the message for Drupal.announce(). + * @param {string} [options.type] + * Message type, can be either 'status', 'error' or 'warning'. + */ + static announce(message, options) { + if ( + !options.priority && + (options.type === 'warning' || options.type === 'error') + ) { + options.priority = 'assertive'; + } + /** + * If screen reader message is not disabled announce screen reader + * specific text or fallback to the displayed message. + */ + if (options.announce !== '') { + Drupal.announce(options.announce || message, options.priority); + } + } + + /** + * Function for creating the internal message wrapper element. + * + * @param {HTMLElement} messageWrapper + * The message wrapper. + * + * @return {HTMLElement} + * The internal wrapper DOM element. + */ + static messageInternalWrapper(messageWrapper) { + const innerWrapper = document.createElement('div'); + innerWrapper.setAttribute('class', 'messages__wrapper'); + messageWrapper.insertAdjacentElement('afterbegin', innerWrapper); + return innerWrapper; + } + }; + + /** + * Theme function for a message. + * + * @param {object} message + * The message object. + * @param {string} message.text + * The message text. + * @param {object} options + * The message context. + * @param {string} options.type + * The message type. + * @param {string} options.id + * ID of the message, for reference. + * + * @return {HTMLElement} + * A DOM Node. + */ + Drupal.theme.message = ({ text }, { type, id }) => { + const messagesTypes = Drupal.Message.getMessageTypeLabels(); + const messageWrapper = document.createElement('div'); + + messageWrapper.setAttribute('class', `messages messages--${type}`); + messageWrapper.setAttribute( + 'role', + type === 'error' || type === 'warning' ? 'alert' : 'status', + ); + messageWrapper.setAttribute('data-drupal-message-id', id); + messageWrapper.setAttribute('data-drupal-message-type', type); + + messageWrapper.setAttribute('aria-label', messagesTypes[type]); + + messageWrapper.innerHTML = `${text}`; + + return messageWrapper; + }; +})(Drupal); diff --git a/core/misc/message.js b/core/misc/message.js new file mode 100644 index 0000000000000000000000000000000000000000..38bc7b3a3af62066729ff1a45b0ab1c8c6d3dca5 --- /dev/null +++ b/core/misc/message.js @@ -0,0 +1,132 @@ +/** +* DO NOT EDIT THIS FILE. +* See the following change record for more information, +* https://www.drupal.org/node/2815083 +* @preserve +**/ +var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +(function (Drupal) { + Drupal.Message = function () { + function _class() { + var messageWrapper = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : null; + + _classCallCheck(this, _class); + + this.messageWrapper = messageWrapper; + } + + _createClass(_class, [{ + key: 'add', + value: function add(message) { + var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; + + if (!this.messageWrapper) { + this.messageWrapper = Drupal.Message.defaultWrapper(); + } + if (!options.hasOwnProperty('type')) { + options.type = 'status'; + } + + if (typeof message !== 'string') { + throw new Error('Message must be a string.'); + } + + Drupal.Message.announce(message, options); + + options.id = options.id ? String(options.id) : options.type + '-' + Math.random().toFixed(15).replace('0.', ''); + + if (!Drupal.Message.getMessageTypeLabels().hasOwnProperty(options.type)) { + throw new Error('The message type, ' + options.type + ', is not present in Drupal.Message.getMessageTypeLabels().'); + } + + this.messageWrapper.appendChild(Drupal.theme('message', { text: message }, options)); + + return options.id; + } + }, { + key: 'select', + value: function select(id) { + return this.messageWrapper.querySelector('[data-drupal-message-id^="' + id + '"]'); + } + }, { + key: 'remove', + value: function remove(id) { + return this.messageWrapper.removeChild(this.select(id)); + } + }, { + key: 'clear', + value: function clear() { + var _this = this; + + Array.prototype.forEach.call(this.messageWrapper.querySelectorAll('[data-drupal-message-id]'), function (message) { + _this.messageWrapper.removeChild(message); + }); + } + }], [{ + key: 'defaultWrapper', + value: function defaultWrapper() { + var wrapper = document.querySelector('[data-drupal-messages]'); + if (!wrapper) { + wrapper = document.querySelector('[data-drupal-messages-fallback]'); + wrapper.removeAttribute('data-drupal-messages-fallback'); + wrapper.setAttribute('data-drupal-messages', ''); + wrapper.removeAttribute('class'); + } + return wrapper.innerHTML === '' ? Drupal.Message.messageInternalWrapper(wrapper) : wrapper.firstElementChild; + } + }, { + key: 'getMessageTypeLabels', + value: function getMessageTypeLabels() { + return { + status: Drupal.t('Status message'), + error: Drupal.t('Error message'), + warning: Drupal.t('Warning message') + }; + } + }, { + key: 'announce', + value: function announce(message, options) { + if (!options.priority && (options.type === 'warning' || options.type === 'error')) { + options.priority = 'assertive'; + } + + if (options.announce !== '') { + Drupal.announce(options.announce || message, options.priority); + } + } + }, { + key: 'messageInternalWrapper', + value: function messageInternalWrapper(messageWrapper) { + var innerWrapper = document.createElement('div'); + innerWrapper.setAttribute('class', 'messages__wrapper'); + messageWrapper.insertAdjacentElement('afterbegin', innerWrapper); + return innerWrapper; + } + }]); + + return _class; + }(); + + Drupal.theme.message = function (_ref, _ref2) { + var text = _ref.text; + var type = _ref2.type, + id = _ref2.id; + + var messagesTypes = Drupal.Message.getMessageTypeLabels(); + var messageWrapper = document.createElement('div'); + + messageWrapper.setAttribute('class', 'messages messages--' + type); + messageWrapper.setAttribute('role', type === 'error' || type === 'warning' ? 'alert' : 'status'); + messageWrapper.setAttribute('data-drupal-message-id', id); + messageWrapper.setAttribute('data-drupal-message-type', type); + + messageWrapper.setAttribute('aria-label', messagesTypes[type]); + + messageWrapper.innerHTML = '' + text; + + return messageWrapper; + }; +})(Drupal); \ No newline at end of file diff --git a/core/modules/big_pipe/tests/modules/big_pipe_test/src/BigPipePlaceholderTestCases.php b/core/modules/big_pipe/tests/modules/big_pipe_test/src/BigPipePlaceholderTestCases.php index f274356139da1eac955f5e3ac31aa187db71d204..d1ba348e12062983bd6922a6ef64fc73abbd333b 100644 --- a/core/modules/big_pipe/tests/modules/big_pipe_test/src/BigPipePlaceholderTestCases.php +++ b/core/modules/big_pipe/tests/modules/big_pipe_test/src/BigPipePlaceholderTestCases.php @@ -88,11 +88,11 @@ public static function cases(ContainerInterface $container = NULL, AccountInterf 'command' => 'insert', 'method' => 'replaceWith', 'selector' => '[data-big-pipe-placeholder-id="callback=Drupal%5CCore%5CRender%5CElement%5CStatusMessages%3A%3ArenderMessages&args%5B0%5D&token=_HAdUpwWmet0TOTe2PSiJuMntExoshbm1kh2wQzzzAA"]', - 'data' => ' <div role="contentinfo" aria-label="Status message" class="messages messages--status">' . "\n" . ' <h2 class="visually-hidden">Status message</h2>' . "\n" . ' Hello from BigPipe!' . "\n" . ' </div>' . "\n ", + 'data' => '<div data-drupal-messages>' . "\n" . ' <div role="contentinfo" aria-label="Status message" class="messages messages--status">' . "\n" . ' <h2 class="visually-hidden">Status message</h2>' . "\n" . ' Hello from BigPipe!' . "\n" . ' </div>' . "\n" . ' </div>' . "\n", 'settings' => NULL, ], ]; - $status_messages->embeddedHtmlResponse = '<div role="contentinfo" aria-label="Status message" class="messages messages--status">' . "\n" . ' <h2 class="visually-hidden">Status message</h2>' . "\n" . ' Hello from BigPipe!' . "\n" . ' </div>' . "\n \n"; + $status_messages->embeddedHtmlResponse = '<div data-drupal-messages-fallback class="hidden"></div><div data-drupal-messages>' . "\n" . ' <div role="contentinfo" aria-label="Status message" class="messages messages--status">' . "\n" . ' <h2 class="visually-hidden">Status message</h2>' . "\n" . ' Hello from BigPipe!' . "\n" . ' </div>' . "\n" . ' </div>' . "\n"; } // 2. Real-world example of HTML attribute value placeholder: form action. diff --git a/core/modules/block/src/Plugin/DisplayVariant/BlockPageVariant.php b/core/modules/block/src/Plugin/DisplayVariant/BlockPageVariant.php index 2dad5e6a344441b20f57aeed94f5c4d99885e1c1..e95ccd2c7b4078b603baf1ceab559547b5ecebaf 100644 --- a/core/modules/block/src/Plugin/DisplayVariant/BlockPageVariant.php +++ b/core/modules/block/src/Plugin/DisplayVariant/BlockPageVariant.php @@ -177,6 +177,7 @@ public function build() { $build['content']['messages'] = [ '#weight' => -1000, '#type' => 'status_messages', + '#include_fallback' => TRUE, ]; } diff --git a/core/modules/block/tests/src/Unit/Plugin/DisplayVariant/BlockPageVariantTest.php b/core/modules/block/tests/src/Unit/Plugin/DisplayVariant/BlockPageVariantTest.php index 5d3a20b6a6d4cad83dc13dd8f5939e357a908aed..a5e2e72e846b8aa5a95ccd7d66eaf50bd6ba6310 100644 --- a/core/modules/block/tests/src/Unit/Plugin/DisplayVariant/BlockPageVariantTest.php +++ b/core/modules/block/tests/src/Unit/Plugin/DisplayVariant/BlockPageVariantTest.php @@ -153,6 +153,7 @@ public function providerBuild() { 'messages' => [ '#weight' => -1000, '#type' => 'status_messages', + '#include_fallback' => TRUE, ], ], ], @@ -185,6 +186,7 @@ public function providerBuild() { 'messages' => [ '#weight' => -1000, '#type' => 'status_messages', + '#include_fallback' => TRUE, ], ], ], @@ -253,6 +255,7 @@ public function testBuildWithoutMainContent() { 'messages' => [ '#weight' => -1000, '#type' => 'status_messages', + '#include_fallback' => TRUE, ], ], ]; diff --git a/core/modules/system/src/Plugin/Block/SystemMessagesBlock.php b/core/modules/system/src/Plugin/Block/SystemMessagesBlock.php index 99901957e761e66271548a06a0ec7e18e972429e..f737b8fd5db6ce603afe0aae3862b7e51094b35c 100644 --- a/core/modules/system/src/Plugin/Block/SystemMessagesBlock.php +++ b/core/modules/system/src/Plugin/Block/SystemMessagesBlock.php @@ -31,7 +31,10 @@ public function defaultConfiguration() { * {@inheritdoc} */ public function build() { - return ['#type' => 'status_messages']; + return [ + '#type' => 'status_messages', + '#include_fallback' => TRUE, + ]; } /** diff --git a/core/modules/system/src/Tests/JsMessageTestCases.php b/core/modules/system/src/Tests/JsMessageTestCases.php new file mode 100644 index 0000000000000000000000000000000000000000..05e1040dd341b8d9b22f881b0d04d16780f42e51 --- /dev/null +++ b/core/modules/system/src/Tests/JsMessageTestCases.php @@ -0,0 +1,32 @@ +<?php + +namespace Drupal\system\Tests; + +/** + * Test cases for JS Messages tests. + */ +class JsMessageTestCases { + + /** + * Gets the test types. + * + * @return string[] + * The test types. + */ + public static function getTypes() { + return ['status', 'error', 'warning']; + } + + /** + * Gets the test messages selectors. + * + * @return string[] + * The test test messages selectors. + * + * @see core/modules/system/tests/themes/test_messages/templates/status-messages.html.twig + */ + public static function getMessagesSelectors() { + return ['', '[data-drupal-messages-other]']; + } + +} diff --git a/core/modules/system/templates/status-messages.html.twig b/core/modules/system/templates/status-messages.html.twig index 6ad802e1436610fd165a7bebc3b2748fb58196e9..1385bbb7346814af7532513d96870e62fb5240e1 100644 --- a/core/modules/system/templates/status-messages.html.twig +++ b/core/modules/system/templates/status-messages.html.twig @@ -21,25 +21,27 @@ * @ingroup themeable */ #} +<div data-drupal-messages> {% for type, messages in message_list %} <div role="contentinfo" aria-label="{{ status_headings[type] }}"{{ attributes|without('role', 'aria-label') }}> {% if type == 'error' %} <div role="alert"> {% endif %} - {% if status_headings[type] %} - <h2 class="visually-hidden">{{ status_headings[type] }}</h2> - {% endif %} - {% if messages|length > 1 %} - <ul> - {% for message in messages %} - <li>{{ message }}</li> - {% endfor %} - </ul> - {% else %} - {{ messages|first }} - {% endif %} + {% if status_headings[type] %} + <h2 class="visually-hidden">{{ status_headings[type] }}</h2> + {% endif %} + {% if messages|length > 1 %} + <ul> + {% for message in messages %} + <li>{{ message }}</li> + {% endfor %} + </ul> + {% else %} + {{ messages|first }} + {% endif %} {% if type == 'error' %} </div> {% endif %} </div> {% endfor %} +</div> diff --git a/core/modules/system/tests/modules/js_message_test/js/js_message_test.es6.js b/core/modules/system/tests/modules/js_message_test/js/js_message_test.es6.js new file mode 100644 index 0000000000000000000000000000000000000000..08d250a335685b19015bab9c9fac8592ffe45f2c --- /dev/null +++ b/core/modules/system/tests/modules/js_message_test/js/js_message_test.es6.js @@ -0,0 +1,122 @@ +/** + * @file + * Testing behavior for JSMessageTest. + */ + +(($, { behaviors }, { testMessages }) => { + // Message types. + const indexes = {}; + testMessages.types.forEach(type => { + indexes[type] = []; + }); + + // Message storage. + const messageObjects = { + default: { + zone: new Drupal.Message(), + indexes, + }, + multiple: [], + }; + + testMessages.selectors.filter(Boolean).forEach(selector => { + messageObjects[selector] = { + zone: new Drupal.Message(document.querySelector(selector)), + indexes, + }; + }); + + /** + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Add click listeners that show and remove links with context and type. + */ + behaviors.js_message_test = { + attach() { + $('[data-drupal-messages-area]') + .once('messages-details') + .on('click', '[data-action]', e => { + const $target = $(e.currentTarget); + const type = $target.attr('data-type'); + const area = + $target + .closest('[data-drupal-messages-area]') + .attr('data-drupal-messages-area') || 'default'; + const message = messageObjects[area].zone; + const action = $target.attr('data-action'); + + if (action === 'add') { + messageObjects[area].indexes[type].push( + message.add( + `This is a message of the type, ${type}. You be the the judge of its importance.`, + { type }, + ), + ); + } else if (action === 'remove') { + message.remove(messageObjects[area].indexes[type].pop()); + } + }); + $('[data-action="add-multiple"]') + .once('add-multiple') + .on('click', () => { + /** + * Add several of different types to make sure message type doesn't + * cause issues in the API. + */ + [0, 1, 2, 3, 4, 5].forEach(i => { + messageObjects.multiple.push( + messageObjects.default.zone.add( + `This is message number ${i} of the type, ${ + testMessages.types[i % testMessages.types.length] + }. You be the the judge of its importance.`, + { type: testMessages.types[i % testMessages.types.length] }, + ), + ); + }); + }); + $('[data-action="remove-multiple"]') + .once('remove-multiple') + .on('click', () => { + messageObjects.multiple.forEach(messageIndex => + messageObjects.default.zone.remove(messageIndex), + ); + messageObjects.multiple = []; + }); + $('[data-action="add-multiple-error"]') + .once('add-multiple-error') + .on('click', () => { + // Use the same number of elements to facilitate things on the PHP side. + [0, 1, 2, 3, 4, 5].forEach(i => + messageObjects.default.zone.add(`Msg-${i}`, { type: 'error' }), + ); + messageObjects.default.zone.add( + `Msg-${testMessages.types.length * 2}`, + { type: 'status' }, + ); + }); + $('[data-action="remove-type"]') + .once('remove-type') + .on('click', () => { + Array.prototype.map + .call( + document.querySelectorAll('[data-drupal-message-id^="error"]'), + element => element.getAttribute('data-drupal-message-id'), + ) + .forEach(id => messageObjects.default.zone.remove(id)); + }); + $('[data-action="clear-all"]') + .once('clear-all') + .on('click', () => { + messageObjects.default.zone.clear(); + }); + $('[data-action="id-no-status"]') + .once('id-no-status') + .on('click', () => { + messageObjects.default.zone.add('Msg-id-no-status', { + id: 'my-special-id', + }); + }); + }, + }; +})(jQuery, Drupal, drupalSettings); diff --git a/core/modules/system/tests/modules/js_message_test/js/js_message_test.js b/core/modules/system/tests/modules/js_message_test/js/js_message_test.js new file mode 100644 index 0000000000000000000000000000000000000000..33c49799911932b89280e7b5ab51ef4543681e35 --- /dev/null +++ b/core/modules/system/tests/modules/js_message_test/js/js_message_test.js @@ -0,0 +1,81 @@ +/** +* DO NOT EDIT THIS FILE. +* See the following change record for more information, +* https://www.drupal.org/node/2815083 +* @preserve +**/ + +(function ($, _ref, _ref2) { + var behaviors = _ref.behaviors; + var testMessages = _ref2.testMessages; + + var indexes = {}; + testMessages.types.forEach(function (type) { + indexes[type] = []; + }); + + var messageObjects = { + default: { + zone: new Drupal.Message(), + indexes: indexes + }, + multiple: [] + }; + + testMessages.selectors.filter(Boolean).forEach(function (selector) { + messageObjects[selector] = { + zone: new Drupal.Message(document.querySelector(selector)), + indexes: indexes + }; + }); + + behaviors.js_message_test = { + attach: function attach() { + $('[data-drupal-messages-area]').once('messages-details').on('click', '[data-action]', function (e) { + var $target = $(e.currentTarget); + var type = $target.attr('data-type'); + var area = $target.closest('[data-drupal-messages-area]').attr('data-drupal-messages-area') || 'default'; + var message = messageObjects[area].zone; + var action = $target.attr('data-action'); + + if (action === 'add') { + messageObjects[area].indexes[type].push(message.add('This is a message of the type, ' + type + '. You be the the judge of its importance.', { type: type })); + } else if (action === 'remove') { + message.remove(messageObjects[area].indexes[type].pop()); + } + }); + $('[data-action="add-multiple"]').once('add-multiple').on('click', function () { + [0, 1, 2, 3, 4, 5].forEach(function (i) { + messageObjects.multiple.push(messageObjects.default.zone.add('This is message number ' + i + ' of the type, ' + testMessages.types[i % testMessages.types.length] + '. You be the the judge of its importance.', { type: testMessages.types[i % testMessages.types.length] })); + }); + }); + $('[data-action="remove-multiple"]').once('remove-multiple').on('click', function () { + messageObjects.multiple.forEach(function (messageIndex) { + return messageObjects.default.zone.remove(messageIndex); + }); + messageObjects.multiple = []; + }); + $('[data-action="add-multiple-error"]').once('add-multiple-error').on('click', function () { + [0, 1, 2, 3, 4, 5].forEach(function (i) { + return messageObjects.default.zone.add('Msg-' + i, { type: 'error' }); + }); + messageObjects.default.zone.add('Msg-' + testMessages.types.length * 2, { type: 'status' }); + }); + $('[data-action="remove-type"]').once('remove-type').on('click', function () { + Array.prototype.map.call(document.querySelectorAll('[data-drupal-message-id^="error"]'), function (element) { + return element.getAttribute('data-drupal-message-id'); + }).forEach(function (id) { + return messageObjects.default.zone.remove(id); + }); + }); + $('[data-action="clear-all"]').once('clear-all').on('click', function () { + messageObjects.default.zone.clear(); + }); + $('[data-action="id-no-status"]').once('id-no-status').on('click', function () { + messageObjects.default.zone.add('Msg-id-no-status', { + id: 'my-special-id' + }); + }); + } + }; +})(jQuery, Drupal, drupalSettings); \ No newline at end of file diff --git a/core/modules/system/tests/modules/js_message_test/js_message_test.info.yml b/core/modules/system/tests/modules/js_message_test/js_message_test.info.yml new file mode 100644 index 0000000000000000000000000000000000000000..e8bc73b06598ed80e5d07ef20314f5fc7b705b41 --- /dev/null +++ b/core/modules/system/tests/modules/js_message_test/js_message_test.info.yml @@ -0,0 +1,6 @@ +name: 'JS Message test module' +type: module +description: 'Module for the JSMessageTest test.' +package: Testing +version: VERSION +core: 8.x diff --git a/core/modules/system/tests/modules/js_message_test/js_message_test.libraries.yml b/core/modules/system/tests/modules/js_message_test/js_message_test.libraries.yml new file mode 100644 index 0000000000000000000000000000000000000000..57501a9ce034cf96ef71ba4ed1081fc4d9c7f078 --- /dev/null +++ b/core/modules/system/tests/modules/js_message_test/js_message_test.libraries.yml @@ -0,0 +1,8 @@ +show_message: + version: VERSION + js: + js/js_message_test.js: {} + dependencies: + - core/drupalSettings + - core/drupal.message + - core/jquery.once diff --git a/core/modules/system/tests/modules/js_message_test/js_message_test.routing.yml b/core/modules/system/tests/modules/js_message_test/js_message_test.routing.yml new file mode 100644 index 0000000000000000000000000000000000000000..4c325e84611ec4018ee5c5eb8dbc507132d304f2 --- /dev/null +++ b/core/modules/system/tests/modules/js_message_test/js_message_test.routing.yml @@ -0,0 +1,7 @@ +js_message_test.links: + path: '/js_message_test_link' + defaults: + _controller: '\Drupal\js_message_test\Controller\JSMessageTestController::messageLinks' + _title: 'JsMessageLinks' + requirements: + _access: 'TRUE' diff --git a/core/modules/system/tests/modules/js_message_test/src/Controller/JSMessageTestController.php b/core/modules/system/tests/modules/js_message_test/src/Controller/JSMessageTestController.php new file mode 100644 index 0000000000000000000000000000000000000000..57d38284f7346a6dc27aedbf85c4da8d107ef097 --- /dev/null +++ b/core/modules/system/tests/modules/js_message_test/src/Controller/JSMessageTestController.php @@ -0,0 +1,139 @@ +<?php + +namespace Drupal\js_message_test\Controller; + +use Drupal\system\Tests\JsMessageTestCases; + +/** + * Test Controller to show message links. + */ +class JSMessageTestController { + + /** + * Displays links to show messages via Javascript. + * + * @return array + * Render array for links. + */ + public function messageLinks() { + $buttons = []; + foreach (JsMessageTestCases::getMessagesSelectors() as $messagesSelector) { + $buttons[$messagesSelector] = [ + '#type' => 'details', + '#open' => TRUE, + '#title' => "Message area: $messagesSelector", + '#attributes' => [ + 'data-drupal-messages-area' => $messagesSelector, + ], + ]; + foreach (JsMessageTestCases::getTypes() as $type) { + $buttons[$messagesSelector]["add-$type"] = [ + '#type' => 'html_tag', + '#tag' => 'button', + '#value' => "Add $type", + '#attributes' => [ + 'type' => 'button', + 'id' => "add-$messagesSelector-$type", + 'data-type' => $type, + 'data-action' => 'add', + ], + ]; + $buttons[$messagesSelector]["remove-$type"] = [ + '#type' => 'html_tag', + '#tag' => 'button', + '#value' => "Remove $type", + '#attributes' => [ + 'type' => 'button', + 'id' => "remove-$messagesSelector-$type", + 'data-type' => $type, + 'data-action' => 'remove', + ], + ]; + } + } + // Add alternative message area. + $buttons[JsMessageTestCases::getMessagesSelectors()[1]]['messages-other-area'] = [ + '#type' => 'html_tag', + '#tag' => 'div', + '#attributes' => [ + 'data-drupal-messages-other' => TRUE, + ], + ]; + $buttons['add-multiple'] = [ + '#type' => 'html_tag', + '#tag' => 'button', + '#value' => "Add multiple", + '#attributes' => [ + 'type' => 'button', + 'id' => 'add-multiple', + 'data-action' => 'add-multiple', + ], + ]; + $buttons['remove-multiple'] = [ + '#type' => 'html_tag', + '#tag' => 'button', + '#value' => "Remove multiple", + '#attributes' => [ + 'type' => 'button', + 'id' => 'remove-multiple', + 'data-action' => 'remove-multiple', + ], + ]; + $buttons['add-multiple-error'] = [ + '#type' => 'html_tag', + '#tag' => 'button', + '#value' => "Add multiple 'error' and one 'status'", + '#attributes' => [ + 'type' => 'button', + 'id' => 'add-multiple-error', + 'data-action' => 'add-multiple-error', + ], + ]; + $buttons['remove-type'] = [ + '#type' => 'html_tag', + '#tag' => 'button', + '#value' => "Remove 'error' type", + '#attributes' => [ + 'type' => 'button', + 'id' => 'remove-type', + 'data-action' => 'remove-type', + ], + ]; + $buttons['clear-all'] = [ + '#type' => 'html_tag', + '#tag' => 'button', + '#value' => "Clear all", + '#attributes' => [ + 'type' => 'button', + 'id' => 'clear-all', + 'data-action' => 'clear-all', + ], + ]; + + $buttons['id-no-status'] = [ + '#type' => 'html_tag', + '#tag' => 'button', + '#value' => "Id no status", + '#attributes' => [ + 'type' => 'button', + 'id' => 'id-no-status', + 'data-action' => 'id-no-status', + ], + ]; + + return $buttons + [ + '#attached' => [ + 'library' => [ + 'js_message_test/show_message', + ], + 'drupalSettings' => [ + 'testMessages' => [ + 'selectors' => JsMessageTestCases::getMessagesSelectors(), + 'types' => JsMessageTestCases::getTypes(), + ], + ], + ], + ]; + } + +} diff --git a/core/modules/system/tests/themes/test_messages/templates/status-messages.html.twig b/core/modules/system/tests/themes/test_messages/templates/status-messages.html.twig new file mode 100644 index 0000000000000000000000000000000000000000..42f105d3df855f4dbf1637f4c1c54d261e0df083 --- /dev/null +++ b/core/modules/system/tests/themes/test_messages/templates/status-messages.html.twig @@ -0,0 +1,41 @@ +{# +/** + * @file + * Test templates file with extra messages div. + */ +#} +<div data-drupal-messages> +{% block messages %} +{% for type, messages in message_list %} + {% + set classes = [ + 'messages', + 'messages--' ~ type, + ] + %} + <div role="contentinfo" aria-label="{{ status_headings[type] }}"{{ attributes.addClass(classes)|without('role', 'aria-label') }}> + {% if type == 'error' %} + <div role="alert"> + {% endif %} + {% if status_headings[type] %} + <h2 class="visually-hidden">{{ status_headings[type] }}</h2> + {% endif %} + {% if messages|length > 1 %} + <ul class="messages__list"> + {% for message in messages %} + <li class="messages__item">{{ message }}</li> + {% endfor %} + </ul> + {% else %} + {{ messages|first }} + {% endif %} + {% if type == 'error' %} + </div> + {% endif %} + </div> + {# Remove type specific classes. #} + {% set attributes = attributes.removeClass(classes) %} +{% endfor %} +{% endblock messages %} +</div> +<div data-drupal-messages-other></div> diff --git a/core/modules/system/tests/themes/test_messages/test_messages.info.yml b/core/modules/system/tests/themes/test_messages/test_messages.info.yml new file mode 100644 index 0000000000000000000000000000000000000000..ea88438d98ea15af32a38026296b136410cb8525 --- /dev/null +++ b/core/modules/system/tests/themes/test_messages/test_messages.info.yml @@ -0,0 +1,6 @@ +name: 'Theme test messages' +type: theme +description: 'Test theme which provides another div for messages.' +version: VERSION +core: 8.x +base theme: classy diff --git a/core/profiles/demo_umami/themes/umami/templates/components/messages/status-messages.html.twig b/core/profiles/demo_umami/themes/umami/templates/components/messages/status-messages.html.twig index a5094701b4b41671d4e9416aff220336fbf00e54..1a65511a3d5d107beaed66cb686aa26544f15758 100644 --- a/core/profiles/demo_umami/themes/umami/templates/components/messages/status-messages.html.twig +++ b/core/profiles/demo_umami/themes/umami/templates/components/messages/status-messages.html.twig @@ -19,6 +19,7 @@ * - class: HTML classes. */ #} +<div data-drupal-messages> {% block messages %} {% for type, messages in message_list %} {% @@ -53,3 +54,4 @@ {% set attributes = attributes.removeClass(classes) %} {% endfor %} {% endblock messages %} +</div> diff --git a/core/tests/Drupal/FunctionalJavascriptTests/Core/JsMessageTest.php b/core/tests/Drupal/FunctionalJavascriptTests/Core/JsMessageTest.php new file mode 100644 index 0000000000000000000000000000000000000000..9b3a9548ac0a78f7a0b7ed393d264df0c7e1c208 --- /dev/null +++ b/core/tests/Drupal/FunctionalJavascriptTests/Core/JsMessageTest.php @@ -0,0 +1,119 @@ +<?php + +namespace Drupal\FunctionalJavascriptTests\Core; + +use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use Drupal\system\Tests\JsMessageTestCases; + +/** + * Tests core/drupal.messages library. + * + * @group Javascript + */ +class JsMessageTest extends WebDriverTestBase { + + /** + * {@inheritdoc} + */ + public static $modules = ['js_message_test']; + + /** + * {@inheritdoc} + */ + protected function setUp() { + parent::setUp(); + + // Enable the theme. + \Drupal::service('theme_installer')->install(['test_messages']); + $theme_config = \Drupal::configFactory()->getEditable('system.theme'); + $theme_config->set('default', 'test_messages'); + $theme_config->save(); + } + + /** + * Test click on links to show messages and remove messages. + */ + public function testAddRemoveMessages() { + $web_assert = $this->assertSession(); + $this->drupalGet('js_message_test_link'); + + $current_messages = []; + foreach (JsMessageTestCases::getMessagesSelectors() as $messagesSelector) { + $web_assert->elementExists('css', $messagesSelector); + foreach (JsMessageTestCases::getTypes() as $type) { + $this->click('[id="add-' . $messagesSelector . '-' . $type . '"]'); + $selector = "$messagesSelector .messages.messages--$type"; + $msg_element = $web_assert->waitForElementVisible('css', $selector); + $this->assertNotEmpty($msg_element, "Message element visible: $selector"); + $web_assert->elementContains('css', $selector, "This is a message of the type, $type. You be the the judge of its importance."); + $current_messages[$selector] = "This is a message of the type, $type. You be the the judge of its importance."; + $this->assertCurrentMessages($current_messages, $messagesSelector); + } + // Remove messages 1 by 1 and confirm the messages are expected. + foreach (JsMessageTestCases::getTypes() as $type) { + $this->click('[id="remove-' . $messagesSelector . '-' . $type . '"]'); + $selector = "$messagesSelector .messages.messages--$type"; + // The message for this selector should not be on the page. + unset($current_messages[$selector]); + $this->assertCurrentMessages($current_messages, $messagesSelector); + } + } + + $messagesSelector = JsMessageTestCases::getMessagesSelectors()[0]; + $current_messages = []; + $types = JsMessageTestCases::getTypes(); + $nb_messages = count($types) * 2; + for ($i = 0; $i < $nb_messages; $i++) { + $current_messages[] = "This is message number $i of the type, {$types[$i % count($types)]}. You be the the judge of its importance."; + } + // Test adding multiple messages at once. + // @see processMessages() + $this->click('[id="add-multiple"]'); + $this->assertCurrentMessages($current_messages, $messagesSelector); + $this->click('[id="remove-multiple"]'); + $this->assertCurrentMessages([], $messagesSelector); + + $current_messages = []; + for ($i = 0; $i < $nb_messages; $i++) { + $current_messages[] = "Msg-$i"; + } + // The last message is of a different type and shouldn't get cleared. + $last_message = 'Msg-' . count($current_messages); + $current_messages[] = $last_message; + $this->click('[id="add-multiple-error"]'); + $this->assertCurrentMessages($current_messages, $messagesSelector); + $this->click('[id="remove-type"]'); + $this->assertCurrentMessages([$last_message], $messagesSelector); + $this->click('[id="clear-all"]'); + $this->assertCurrentMessages([], $messagesSelector); + + // Confirm that when adding a message with an "id" specified but no status + // that it receives the default status. + $this->click('[id="id-no-status"]'); + $no_status_msg = 'Msg-id-no-status'; + $this->assertCurrentMessages([$no_status_msg], $messagesSelector); + $web_assert->elementTextContains('css', "$messagesSelector .messages--status[data-drupal-message-id=\"my-special-id\"]", $no_status_msg); + + } + + /** + * Asserts that currently shown messages match expected messages. + * + * @param array $expected_messages + * Expected messages. + * @param string $messagesSelector + * The css selector for the containing messages element. + */ + protected function assertCurrentMessages(array $expected_messages, $messagesSelector) { + $expected_messages = array_values($expected_messages); + $current_messages = []; + if ($message_divs = $this->getSession()->getPage()->findAll('css', "$messagesSelector .messages")) { + foreach ($message_divs as $message_div) { + /** @var \Behat\Mink\Element\NodeElement $message_div */ + $current_messages[] = $message_div->getText(); + } + } + $this->assertEquals($expected_messages, $current_messages); + } + +} diff --git a/core/themes/bartik/css/components/messages.css b/core/themes/bartik/css/components/messages.css index 15a550d0b96484fc69a96b5f7aa740b67e94b405..7018da3a00fb21190aa929b9be0581ccc4584369 100644 --- a/core/themes/bartik/css/components/messages.css +++ b/core/themes/bartik/css/components/messages.css @@ -4,10 +4,15 @@ */ .messages__wrapper { - padding: 20px 0 5px 8px; + padding: 0 0 0 8px; font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; - margin: 8px 0; } [dir="rtl"] .messages__wrapper { - padding: 20px 8px 5px 0; + padding: 0 8px 0 0; +} +.messages:first-child { + margin-top: 28px; +} +.messages:last-child { + margin-bottom: 13px; } diff --git a/core/themes/classy/templates/misc/status-messages.html.twig b/core/themes/classy/templates/misc/status-messages.html.twig index 2115b76f536c6e93c1acaf338b654e55f985095b..7dda6c040c49d7fc9bde68e76182fc5487183c4f 100644 --- a/core/themes/classy/templates/misc/status-messages.html.twig +++ b/core/themes/classy/templates/misc/status-messages.html.twig @@ -19,6 +19,7 @@ * - class: HTML classes. */ #} +<div data-drupal-messages> {% block messages %} {% for type, messages in message_list %} {% @@ -51,3 +52,4 @@ {% set attributes = attributes.removeClass(classes) %} {% endfor %} {% endblock messages %} +</div> diff --git a/core/themes/stable/templates/misc/status-messages.html.twig b/core/themes/stable/templates/misc/status-messages.html.twig index 41d3fbd318ae4bfc788146f1090e879859e6828f..969631d2d06eaffc1289c0ac682c6196e9576a55 100644 --- a/core/themes/stable/templates/misc/status-messages.html.twig +++ b/core/themes/stable/templates/misc/status-messages.html.twig @@ -19,6 +19,7 @@ * - class: HTML classes. */ #} +<div data-drupal-messages> {% for type, messages in message_list %} <div role="contentinfo" aria-label="{{ status_headings[type] }}"{{ attributes|without('role', 'aria-label') }}> {% if type == 'error' %} @@ -41,3 +42,4 @@ {% endif %} </div> {% endfor %} +</div>