From 7dde35cfd0c2c2a31d59a6488ebc7f9314a9bebb Mon Sep 17 00:00:00 2001 From: Lee Rowlands Date: Thu, 10 Oct 2019 19:38:23 +1000 Subject: [PATCH] Issue #3086096 by oknate, phenaproxima, tedbow, Wim Leers, andrewmacpherson, droplet, bnjmnm: Add a generic Ajax Message command --- core/lib/Drupal/Core/Ajax/AnnounceCommand.php | 9 ++ core/lib/Drupal/Core/Ajax/MessageCommand.php | 101 ++++++++++++++ core/misc/ajax.es6.js | 26 ++++ core/misc/ajax.js | 7 + .../modules/ajax_test/ajax_test.routing.yml | 8 ++ .../src/Form/AjaxTestMessageCommandForm.php | 107 +++++++++++++++ .../Ajax/MessageCommandTest.php | 124 ++++++++++++++++++ .../Core/JsMessageTest.php | 2 +- 8 files changed, 383 insertions(+), 1 deletion(-) create mode 100644 core/lib/Drupal/Core/Ajax/MessageCommand.php create mode 100644 core/modules/system/tests/modules/ajax_test/src/Form/AjaxTestMessageCommandForm.php create mode 100644 core/tests/Drupal/FunctionalJavascriptTests/Ajax/MessageCommandTest.php diff --git a/core/lib/Drupal/Core/Ajax/AnnounceCommand.php b/core/lib/Drupal/Core/Ajax/AnnounceCommand.php index aa58bdbfee0..b32f4e62dc4 100644 --- a/core/lib/Drupal/Core/Ajax/AnnounceCommand.php +++ b/core/lib/Drupal/Core/Ajax/AnnounceCommand.php @@ -7,6 +7,15 @@ use Drupal\Core\Asset\AttachedAssets; /** * 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 00000000000..ba5b14e738e --- /dev/null +++ b/core/lib/Drupal/Core/Ajax/MessageCommand.php @@ -0,0 +1,101 @@ + '', + * ]); + * @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 1b26397c5c1..905559c921a 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 85cfa0739ca..7df25019876 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 875b7caa961..40937fa6f9c 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 00000000000..6c37a09a905 --- /dev/null +++ b/core/modules/system/tests/modules/ajax_test/src/Form/AjaxTestMessageCommandForm.php @@ -0,0 +1,107 @@ + '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 00000000000..22cbbab76ef --- /dev/null +++ b/core/tests/Drupal/FunctionalJavascriptTests/Ajax/MessageCommandTest.php @@ -0,0 +1,124 @@ +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 9aeb4b64986..89c0b1ec024 100644 --- a/core/tests/Drupal/FunctionalJavascriptTests/Core/JsMessageTest.php +++ b/core/tests/Drupal/FunctionalJavascriptTests/Core/JsMessageTest.php @@ -6,7 +6,7 @@ use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\js_message_test\Controller\JSMessageTestController; /** - * Tests core/drupal.messages library. + * Tests core/drupal.message library. * * @group Javascript */