diff --git a/core/lib/Drupal/Core/Ajax/AnnounceCommand.php b/core/lib/Drupal/Core/Ajax/AnnounceCommand.php index aa58bdbfee03172809fbd0fff0bcdbaf298f5005..b32f4e62dc40d909a6ad156e0ea08932a91081e3 100644 --- a/core/lib/Drupal/Core/Ajax/AnnounceCommand.php +++ b/core/lib/Drupal/Core/Ajax/AnnounceCommand.php @@ -7,6 +7,15 @@ /** * AJAX command for a JavaScript Drupal.announce() call. * + * Developers should be extra careful if this command and + * \Drupal\Core\Ajax\MessageCommand are included in the same response. By + * default, MessageCommmand will also call Drupal.announce() and announce the + * message to the screen reader (unless the option to suppress announcements is + * passed to the constructor). Manual testing with a screen reader is strongly + * recommended. + * + * @see \Drupal\Core\Ajax\MessageCommand + * * @ingroup ajax */ class AnnounceCommand implements CommandInterface, CommandWithAttachedAssetsInterface { diff --git a/core/lib/Drupal/Core/Ajax/MessageCommand.php b/core/lib/Drupal/Core/Ajax/MessageCommand.php new file mode 100644 index 0000000000000000000000000000000000000000..ba5b14e738e1f6670bdba60bcb6983e7f9382df5 --- /dev/null +++ b/core/lib/Drupal/Core/Ajax/MessageCommand.php @@ -0,0 +1,101 @@ +<?php + +namespace Drupal\Core\Ajax; + +use Drupal\Core\Asset\AttachedAssets; + +/** + * AJAX command for a JavaScript Drupal.message() call. + * + * Developers should be extra careful if this command and + * \Drupal\Core\Ajax\AnnounceCommand are included in the same response. Unless + * the `announce` option is set to an empty string (''), this command will + * result in the message being announced to screen readers. When combined with + * AnnounceCommand, this may result in unexpected behavior. Manual testing with + * a screen reader is strongly recommended. + * + * Here are examples of how to suppress announcements: + * @code + * $command = new MessageCommand("I won't be announced", NULL, [ + * 'announce' => '', + * ]); + * @endcode + * + * @see \Drupal\Core\Ajax\AnnounceCommand + * + * @ingroup ajax + */ +class MessageCommand implements CommandInterface, CommandWithAttachedAssetsInterface { + + /** + * The message text. + * + * @var string + */ + protected $message; + + /** + * Whether to clear previous messages. + * + * @var bool + */ + protected $clearPrevious; + + /** + * The query selector for the element the message will appear in. + * + * @var string + */ + protected $wrapperQuerySelector; + + /** + * The options passed to Drupal.message().add(). + * + * @var array + */ + protected $options; + + /** + * Constructs a MessageCommand object. + * + * @param string $message + * The text of the message. + * @param string|null $wrapper_query_selector + * The query selector of the element to display messages in when they + * should be displayed somewhere other than the default. + * @see Drupal.Message.defaultWrapper() + * @param array $options + * The options passed to Drupal.message().add(). + * @param bool $clear_previous + * If TRUE, previous messages will be cleared first. + */ + public function __construct($message, $wrapper_query_selector = NULL, array $options = [], $clear_previous = TRUE) { + $this->message = $message; + $this->wrapperQuerySelector = $wrapper_query_selector; + $this->options = $options; + $this->clearPrevious = $clear_previous; + } + + /** + * {@inheritdoc} + */ + public function render() { + return [ + 'command' => 'message', + 'message' => $this->message, + 'messageWrapperQuerySelector' => $this->wrapperQuerySelector, + 'messageOptions' => $this->options, + 'clearPrevious' => $this->clearPrevious, + ]; + } + + /** + * {@inheritdoc} + */ + public function getAttachedAssets() { + $assets = new AttachedAssets(); + $assets->setLibraries(['core/drupal.message']); + return $assets; + } + +} diff --git a/core/misc/ajax.es6.js b/core/misc/ajax.es6.js index 1b26397c5c1568acb0c402e67b6a12cd1f693d46..905559c921a57d7587d05b16cae96775e7f21924 100644 --- a/core/misc/ajax.es6.js +++ b/core/misc/ajax.es6.js @@ -1562,5 +1562,31 @@ } while (match); } }, + + /** + * Command to add a message to the message area. + * + * @param {Drupal.Ajax} [ajax] + * {@link Drupal.Ajax} object created by {@link Drupal.ajax}. + * @param {object} response + * The response from the Ajax request. + * @param {string} response.messageWrapperQuerySelector + * The zone where to add the message. If null, the default will be used. + * @param {string} response.message + * The message text. + * @param {string} response.messageOptions + * The options argument for Drupal.Message().add(). + * @param {bool} response.clearPrevious + * If true, clear previous messages. + */ + message(ajax, response) { + const messages = new Drupal.Message( + document.querySelector(response.messageWrapperQuerySelector), + ); + if (response.clearPrevious) { + messages.clear(); + } + messages.add(response.message, response.messageOptions); + }, }; })(jQuery, window, Drupal, drupalSettings); diff --git a/core/misc/ajax.js b/core/misc/ajax.js index 85cfa0739ca03ec46f539de07f5cc713327d12a2..7df250198765172e82a667380df1cd65af37b4e1 100644 --- a/core/misc/ajax.js +++ b/core/misc/ajax.js @@ -639,6 +639,13 @@ function _toConsumableArray(arr) { if (Array.isArray(arr)) { for (var i = 0, arr document.styleSheets[0].addImport(match[1]); } while (match); } + }, + message: function message(ajax, response) { + var messages = new Drupal.Message(document.querySelector(response.messageWrapperQuerySelector)); + if (response.clearPrevious) { + messages.clear(); + } + messages.add(response.message, response.messageOptions); } }; })(jQuery, window, Drupal, drupalSettings); \ No newline at end of file diff --git a/core/modules/system/tests/modules/ajax_test/ajax_test.routing.yml b/core/modules/system/tests/modules/ajax_test/ajax_test.routing.yml index 875b7caa9611354adea5796fbd96cba06eac4b54..40937fa6f9c15aad2b6f7fd31493fa6f624cd895 100644 --- a/core/modules/system/tests/modules/ajax_test/ajax_test.routing.yml +++ b/core/modules/system/tests/modules/ajax_test/ajax_test.routing.yml @@ -77,3 +77,11 @@ ajax_test.render_error: _controller: '\Drupal\ajax_test\Controller\AjaxTestController::renderError' requirements: _access: 'TRUE' + +ajax_test.message_form: + path: '/ajax-test/message' + defaults: + _title: 'Ajax Message Form' + _form: '\Drupal\ajax_test\Form\AjaxTestMessageCommandForm' + requirements: + _access: 'TRUE' diff --git a/core/modules/system/tests/modules/ajax_test/src/Form/AjaxTestMessageCommandForm.php b/core/modules/system/tests/modules/ajax_test/src/Form/AjaxTestMessageCommandForm.php new file mode 100644 index 0000000000000000000000000000000000000000..6c37a09a905bd25f450627a2cb59c313d7724d65 --- /dev/null +++ b/core/modules/system/tests/modules/ajax_test/src/Form/AjaxTestMessageCommandForm.php @@ -0,0 +1,107 @@ +<?php + +namespace Drupal\ajax_test\Form; + +use Drupal\Core\Ajax\AjaxResponse; +use Drupal\Core\Ajax\MessageCommand; +use Drupal\Core\Form\FormInterface; +use Drupal\Core\Form\FormStateInterface; + +/** + * Form for testing AJAX MessageCommand. + * + * @internal + */ +class AjaxTestMessageCommandForm implements FormInterface { + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'ajax_test_message_command_form'; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state) { + $form['alternate-message-container'] = [ + '#type' => 'container', + '#id' => 'alternate-message-container', + ]; + $form['button_default'] = [ + '#type' => 'submit', + '#name' => 'makedefaultmessage', + '#value' => 'Make Message In Default Location', + '#ajax' => [ + 'callback' => '::makeMessageDefault', + ], + ]; + $form['button_alternate'] = [ + '#type' => 'submit', + '#name' => 'makealternatemessage', + '#value' => 'Make Message In Alternate Location', + '#ajax' => [ + 'callback' => '::makeMessageAlternate', + ], + ]; + $form['button_warning'] = [ + '#type' => 'submit', + '#name' => 'makewarningmessage', + '#value' => 'Make Warning Message', + '#ajax' => [ + 'callback' => '::makeMessageWarning', + ], + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function validateForm(array &$form, FormStateInterface $form_state) { + + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + + } + + /** + * Callback for testing MessageCommand with default settings. + * + * @return \Drupal\Core\Ajax\AjaxResponse + * The AJAX response. + */ + public function makeMessageDefault() { + $response = new AjaxResponse(); + return $response->addCommand(new MessageCommand('I am a message in the default location.')); + } + + /** + * Callback for testing MessageCommand using an alternate message location. + * + * @return \Drupal\Core\Ajax\AjaxResponse + * The AJAX response. + */ + public function makeMessageAlternate() { + $response = new AjaxResponse(); + return $response->addCommand(new MessageCommand('I am a message in an alternate location.', '#alternate-message-container', [], FALSE)); + } + + /** + * Callback for testing MessageCommand with warning status. + * + * @return \Drupal\Core\Ajax\AjaxResponse + * The AJAX response. + */ + public function makeMessageWarning() { + $response = new AjaxResponse(); + return $response->addCommand(new MessageCommand('I am a warning message in the default location.', NULL, ['type' => 'warning', 'announce' => ''])); + } + +} diff --git a/core/tests/Drupal/FunctionalJavascriptTests/Ajax/MessageCommandTest.php b/core/tests/Drupal/FunctionalJavascriptTests/Ajax/MessageCommandTest.php new file mode 100644 index 0000000000000000000000000000000000000000..22cbbab76efbbbff307d892e2e470f9e461d71e7 --- /dev/null +++ b/core/tests/Drupal/FunctionalJavascriptTests/Ajax/MessageCommandTest.php @@ -0,0 +1,124 @@ +<?php + +namespace Drupal\FunctionalJavascriptTests\Ajax; + +use Drupal\FunctionalJavascriptTests\WebDriverTestBase; + +/** + * Tests adding messages via AJAX command. + * + * @group Ajax + */ +class MessageCommandTest extends WebDriverTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['ajax_test']; + + /** + * Test AJAX MessageCommand use in a form. + */ + public function testMessageCommand() { + $page = $this->getSession()->getPage(); + $assert_session = $this->assertSession(); + + $this->drupalGet('ajax-test/message'); + $page->pressButton('Make Message In Default Location'); + $this->waitForMessageVisible('I am a message in the default location.'); + $this->assertAnnounceContains('I am a message in the default location.'); + $assert_session->elementsCount('css', '.messages__wrapper .messages', 1); + + $page->pressButton('Make Message In Alternate Location'); + $this->waitForMessageVisible('I am a message in an alternate location.', '#alternate-message-container'); + $assert_session->pageTextContains('I am a message in the default location.'); + $this->assertAnnounceContains('I am a message in an alternate location.'); + $assert_session->elementsCount('css', '.messages__wrapper .messages', 1); + $assert_session->elementsCount('css', '#alternate-message-container .messages', 1); + + $page->pressButton('Make Warning Message'); + $this->waitForMessageVisible('I am a warning message in the default location.', NULL, 'warning'); + $assert_session->pageTextNotContains('I am a message in the default location.'); + $assert_session->elementsCount('css', '.messages__wrapper .messages', 1); + $assert_session->elementsCount('css', '#alternate-message-container .messages', 1); + + $this->drupalGet('ajax-test/message'); + // Test that by default, previous messages in a location are removed. + for ($i = 0; $i < 6; $i++) { + $page->pressButton('Make Message In Default Location'); + $this->waitForMessageVisible('I am a message in the default location.'); + $assert_session->elementsCount('css', '.messages__wrapper .messages', 1); + + $page->pressButton('Make Warning Message'); + $this->waitForMessageVisible('I am a warning message in the default location.', NULL, 'warning'); + // Test that setting MessageCommand::$option['announce'] => '' supresses + // screen reader announcement. + $this->assertAnnounceNotContains('I am a warning message in the default location.'); + $this->waitForMessageRemoved('I am a message in the default location.'); + $assert_session->elementsCount('css', '.messages__wrapper .messages', 1); + } + + // Test that if MessageCommand::clearPrevious is FALSE, messages will not + // be cleared. + $this->drupalGet('ajax-test/message'); + for ($i = 1; $i < 7; $i++) { + $page->pressButton('Make Message In Alternate Location'); + $expected_count = $page->waitFor(10, function () use ($i, $page) { + return count($page->findAll('css', '#alternate-message-container .messages')) === $i; + }); + $this->assertTrue($expected_count); + $this->assertAnnounceContains('I am a message in an alternate location.'); + } + } + + /** + * Asserts that a message of the expected type appears. + * + * @param string $message + * The expected message. + * @param string $selector + * The selector for the element in which to check for the expected message. + * @param string $type + * The expected type. + */ + protected function waitForMessageVisible($message, $selector = '[data-drupal-messages]', $type = 'status') { + $this->assertNotEmpty($this->assertSession()->waitForElementVisible('css', $selector . ' .messages--' . $type . ':contains("' . $message . '")')); + } + + /** + * Asserts that a message of the expected type is removed. + * + * @param string $message + * The expected message. + * @param string $selector + * The selector for the element in which to check for the expected message. + * @param string $type + * The expected type. + */ + protected function waitForMessageRemoved($message, $selector = '[data-drupal-messages]', $type = 'status') { + $this->assertNotEmpty($this->assertSession()->waitForElementRemoved('css', $selector . ' .messages--' . $type . ':contains("' . $message . '")')); + } + + /** + * Checks for inclusion of text in #drupal-live-announce. + * + * @param string $expected_message + * The text expected to be present in #drupal-live-announce. + */ + protected function assertAnnounceContains($expected_message) { + $assert_session = $this->assertSession(); + $this->assertNotEmpty($assert_session->waitForElement('css', "#drupal-live-announce:contains('$expected_message')")); + } + + /** + * Checks for absence of the given text from #drupal-live-announce. + * + * @param string $expected_message + * The text expected to be absent from #drupal-live-announce. + */ + protected function assertAnnounceNotContains($expected_message) { + $assert_session = $this->assertSession(); + $this->assertEmpty($assert_session->waitForElement('css', "#drupal-live-announce:contains('$expected_message')", 1000)); + } + +} diff --git a/core/tests/Drupal/FunctionalJavascriptTests/Core/JsMessageTest.php b/core/tests/Drupal/FunctionalJavascriptTests/Core/JsMessageTest.php index 9aeb4b64986aa380b8a1fdbc0e427b58a99d6080..89c0b1ec024eb3fd78f6105bf5c599c7e2d528e1 100644 --- a/core/tests/Drupal/FunctionalJavascriptTests/Core/JsMessageTest.php +++ b/core/tests/Drupal/FunctionalJavascriptTests/Core/JsMessageTest.php @@ -6,7 +6,7 @@ use Drupal\js_message_test\Controller\JSMessageTestController; /** - * Tests core/drupal.messages library. + * Tests core/drupal.message library. * * @group Javascript */