Unverified Commit 280995f9 authored by lauriii's avatar lauriii

Issue #77245 by drpal, tedbow, nod_, phenaproxima, Wim Leers, googletorp,...

Issue #77245 by drpal, tedbow, nod_, phenaproxima, Wim Leers, googletorp, rteijeiro, vineet.osscube, tim.plunkett, idflood, joelpittet, pk188, lauriii, BarisW, lokapujya, chr.fritsch, droplet, andrewmacpherson, dmsmidt, dawehner, alexpott, jessebeach, NickWilde, DuaelFr, Cottser, seutje, samuel.mortenson: Provide a common API for displaying JavaScript messages
parent 3257ff16
......@@ -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:
......
......@@ -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;
}
/**
......
......@@ -54,6 +54,7 @@ public function build() {
'messages' => [
'#type' => 'status_messages',
'#weight' => -1000,
'#include_fallback' => TRUE,
],
'page_title' => [
'#type' => 'page_title',
......
/**
* @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);
/**
* 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
......@@ -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.
......
......@@ -177,6 +177,7 @@ public function build() {
$build['content']['messages'] = [
'#weight' => -1000,
'#type' => 'status_messages',
'#include_fallback' => TRUE,
];
}
......
......@@ -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,
],
],
];
......
......@@ -31,7 +31,10 @@ public function defaultConfiguration() {
* {@inheritdoc}
*/
public function build() {
return ['#type' => 'status_messages'];
return [
'#type' => 'status_messages',
'#include_fallback' => TRUE,
];
}
/**
......
<?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]'];
}
}
......@@ -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>
/**
* @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',
});
});
},
};