diff --git a/core/core.libraries.yml b/core/core.libraries.yml index b26bf5fff6277b3f5f04b0a10dc83e1b2fd4230f..d7a0461893f95220e9bab0df0b27627947ab9763 100644 --- a/core/core.libraries.yml +++ b/core/core.libraries.yml @@ -370,6 +370,7 @@ drupal.ajax: - core/drupal.progress - core/once - core/tabbable + - core/loadjs drupal.announce: version: VERSION diff --git a/core/lib/Drupal/Core/Ajax/AddJsCommand.php b/core/lib/Drupal/Core/Ajax/AddJsCommand.php new file mode 100644 index 0000000000000000000000000000000000000000..5d6f623c4f9404f8bdb3c70b53e9fd1fc01a41cc --- /dev/null +++ b/core/lib/Drupal/Core/Ajax/AddJsCommand.php @@ -0,0 +1,61 @@ +<?php + +namespace Drupal\Core\Ajax; + +/** + * An AJAX command for adding JS to the page via AJAX. + * + * This command will make sure all the files are loaded before continuing + * executing the next AJAX command. This command is implemented by + * Drupal.AjaxCommands.prototype.add_js() defined in misc/ajax.js. + * + * @see misc/ajax.js + * + * @ingroup ajax + */ +class AddJsCommand implements CommandInterface { + + /** + * An array containing attributes of the scripts to be added to the page. + * + * @var string[] + */ + protected $scripts; + + /** + * A CSS selector string. + * + * If the command is a response to a request from an #ajax form element then + * this value will default to 'body'. + * + * @var string + */ + protected $selector; + + /** + * Constructs an AddJsCommand. + * + * @param array $scripts + * An array containing the attributes of the 'script' tags to be added to + * the page. i.e. `['src' => 'someURL', 'defer' => TRUE]` becomes + * `<script src="someURL" defer>`. + * @param string $selector + * A CSS selector of the element where the script tags will be appended. + */ + public function __construct(array $scripts, string $selector = 'body') { + $this->scripts = $scripts; + $this->selector = $selector; + } + + /** + * {@inheritdoc} + */ + public function render() { + return [ + 'command' => 'add_js', + 'selector' => $this->selector, + 'data' => $this->scripts, + ]; + } + +} diff --git a/core/lib/Drupal/Core/Ajax/AjaxResponseAttachmentsProcessor.php b/core/lib/Drupal/Core/Ajax/AjaxResponseAttachmentsProcessor.php index 4f6acbf9664f8992239bfeb61bb77be374f3edd4..eaeed18ef2cac1b3992a92b97b4d6c8de3e23ef6 100644 --- a/core/lib/Drupal/Core/Ajax/AjaxResponseAttachmentsProcessor.php +++ b/core/lib/Drupal/Core/Ajax/AjaxResponseAttachmentsProcessor.php @@ -174,11 +174,11 @@ protected function buildAttachmentsCommands(AjaxResponse $response, Request $req } if ($js_assets_header) { $js_header_render_array = $this->jsCollectionRenderer->render($js_assets_header); - $resource_commands[] = new PrependCommand('head', $this->renderer->renderPlain($js_header_render_array)); + $resource_commands[] = new AddJsCommand(array_column($js_header_render_array, '#attributes'), 'head'); } if ($js_assets_footer) { $js_footer_render_array = $this->jsCollectionRenderer->render($js_assets_footer); - $resource_commands[] = new AppendCommand('body', $this->renderer->renderPlain($js_footer_render_array)); + $resource_commands[] = new AddJsCommand(array_column($js_footer_render_array, '#attributes')); } foreach (array_reverse($resource_commands) as $resource_command) { $response->addCommand($resource_command, TRUE); diff --git a/core/misc/ajax.es6.js b/core/misc/ajax.es6.js index 08923ae52d04d7d9f567b67ecd3273546078d8d1..c8c8eb1cd39ebc1ceadf1e2dc7ab1a0c81481b13 100644 --- a/core/misc/ajax.es6.js +++ b/core/misc/ajax.es6.js @@ -11,7 +11,14 @@ * included to provide Ajax capabilities. */ -(function ($, window, Drupal, drupalSettings, { isFocusable, tabbable }) { +(function ( + $, + window, + Drupal, + drupalSettings, + loadjs, + { isFocusable, tabbable }, +) { /** * Attaches the Ajax behavior to each Ajax form element. * @@ -537,10 +544,23 @@ } } - return ajax.success(response, status); + return ( + // Ensure that the return of the success callback is a Promise. + // When the return is a Promise, using resolve will unwrap it, and + // when the return is not a Promise we make sure it can be used as + // one. This is useful for code that overrides the success method. + Promise.resolve(ajax.success(response, status)) + // Ajaxing status is back to false when all the AJAX commands have + // finished executing. + .then(() => { + ajax.ajaxing = false; + }) + ); }, - complete(xmlhttprequest, status) { + error(xmlhttprequest, status, error) { ajax.ajaxing = false; + }, + complete(xmlhttprequest, status) { if (status === 'error' || status === 'parsererror') { return ajax.error(xmlhttprequest, ajax.url); } @@ -950,6 +970,36 @@ $('body').append(this.progress.element); }; + /** + * Helper method to make sure commands are executed in sequence. + * + * @param {Array.<Drupal.AjaxCommands~commandDefinition>} response + * Drupal Ajax response. + * @param {number} status + * XMLHttpRequest status. + * + * @return {Promise} + * The promise that will resolve once all commands have finished executing. + */ + Drupal.Ajax.prototype.commandExecutionQueue = function (response, status) { + const ajaxCommands = this.commands; + return Object.keys(response || {}).reduce( + // Add all commands to a single execution queue. + (executionQueue, key) => + executionQueue.then(() => { + const { command } = response[key]; + if (command && ajaxCommands[command]) { + // When a command returns a promise, the remaining commands will not + // execute until that promise has been fulfilled. This is typically + // used to ensure JavaScript files added via the 'add_js' command + // have loaded before subsequent commands execute. + return ajaxCommands[command](this, response[key], status); + } + }), + Promise.resolve(), + ); + }; + /** * Handler for the form redirection completion. * @@ -957,6 +1007,9 @@ * Drupal Ajax response. * @param {number} status * XMLHttpRequest status. + * + * @return {Promise} + * The promise that will resolve once all commands have finished executing. */ Drupal.Ajax.prototype.success = function (response, status) { // Remove the progress element. @@ -979,55 +1032,61 @@ // Track if any command is altering the focus so we can avoid changing the // focus set by the Ajax command. - let focusChanged = false; - Object.keys(response || {}).forEach((i) => { - if (response[i].command && this.commands[response[i].command]) { - this.commands[response[i].command](this, response[i], status); - if ( - (response[i].command === 'invoke' && - response[i].method === 'focus') || - response[i].command === 'focusFirst' - ) { - focusChanged = true; - } - } + const focusChanged = Object.keys(response || {}).some((key) => { + const { command, method } = response[key]; + return ( + command === 'focusFirst' || (command === 'invoke' && method === 'focus') + ); }); - // If the focus hasn't be changed by the ajax commands, try to refocus the - // triggering element or one of its parents if that element does not exist - // anymore. - if ( - !focusChanged && - this.element && - !$(this.element).data('disable-refocus') - ) { - let target = false; - - for (let n = elementParents.length - 1; !target && n >= 0; n--) { - target = document.querySelector( - `[data-drupal-selector="${elementParents[n].getAttribute( - 'data-drupal-selector', - )}"]`, - ); - } - - if (target) { - $(target).trigger('focus'); - } - } - - // Reattach behaviors, if they were detached in beforeSerialize(). The - // attachBehaviors() called on the new content from processing the response - // commands is not sufficient, because behaviors from the entire form need - // to be reattached. - if (this.$form && document.body.contains(this.$form.get(0))) { - const settings = this.settings || drupalSettings; - Drupal.attachBehaviors(this.$form.get(0), settings); - } - - // Remove any response-specific settings so they don't get used on the next - // call by mistake. - this.settings = null; + return ( + this.commandExecutionQueue(response, status) + // If the focus hasn't been changed by the AJAX commands, try to refocus + // the triggering element or one of its parents if that element does not + // exist anymore. + .then(() => { + if ( + !focusChanged && + this.element && + !$(this.element).data('disable-refocus') + ) { + let target = false; + + for (let n = elementParents.length - 1; !target && n >= 0; n--) { + target = document.querySelector( + `[data-drupal-selector="${elementParents[n].getAttribute( + 'data-drupal-selector', + )}"]`, + ); + } + if (target) { + $(target).trigger('focus'); + } + } + // Reattach behaviors, if they were detached in beforeSerialize(). The + // attachBehaviors() called on the new content from processing the + // response commands is not sufficient, because behaviors from the + // entire form need to be reattached. + if (this.$form && document.body.contains(this.$form.get(0))) { + const settings = this.settings || drupalSettings; + Drupal.attachBehaviors(this.$form.get(0), settings); + } + // Remove any response-specific settings so they don't get used on the + // next call by mistake. + this.settings = null; + }) + .catch((error) => + // eslint-disable-next-line no-console + console.error( + Drupal.t( + 'An error occurred during the execution of the Ajax response: !error', + { + '!error': error, + }, + ), + ), + ) + ); }; /** @@ -1610,5 +1669,72 @@ } messages.add(response.message, response.messageOptions); }, + + /** + * Command to add JS. + * + * @param {Drupal.Ajax} [ajax] + * {@link Drupal.Ajax} object created by {@link Drupal.ajax}. + * @param {object} response + * The response from the Ajax request. + * @param {Array} response.data + * An array of objects of script attributes. + * @param {number} [status] + * The XMLHttpRequest status. + */ + add_js(ajax, response, status) { + const parentEl = document.querySelector(response.selector || 'body'); + const settings = ajax.settings || drupalSettings; + const allUniqueBundleIds = response.data.map((script) => { + // loadjs requires a unique ID, and an AJAX instance's `instanceIndex` + // is guaranteed to be unique. + // @see Drupal.behaviors.AJAX.detach + const uniqueBundleId = script.src + ajax.instanceIndex; + loadjs(script.src, uniqueBundleId, { + // The default loadjs behavior is to load script with async, in Drupal + // we need to explicitly tell scripts to load async, this is set in + // the before callback below if necessary. + async: false, + before(path, scriptEl) { + // This allows all attributes to be added, like defer, async and + // crossorigin. + Object.keys(script).forEach((attributeKey) => { + scriptEl.setAttribute(attributeKey, script[attributeKey]); + }); + + // By default, loadjs appends the script to the head. When scripts + // are loaded via AJAX, their location has no impact on + // functionality. But, since non-AJAX loaded scripts can choose + // their parent element, we provide that option here for the sake of + // consistency. + parentEl.appendChild(scriptEl); + // Return false to bypass loadjs' default DOM insertion mechanism. + return false; + }, + }); + return uniqueBundleId; + }); + // Returns the promise so that the next AJAX command waits on the + // completion of this one to execute, ensuring the JS is loaded before + // executing. + return new Promise((resolve, reject) => { + loadjs.ready(allUniqueBundleIds, { + success() { + Drupal.attachBehaviors(parentEl, settings); + // All JS files were loaded and new and old behaviors have + // been attached. Resolve the promise and let the remaining + // commands execute. + resolve(); + }, + error(depsNotFound) { + const message = Drupal.t( + `The following files could not be loaded: @dependencies`, + { '@dependencies': depsNotFound.join(', ') }, + ); + reject(message); + }, + }); + }); + }, }; -})(jQuery, window, Drupal, drupalSettings, window.tabbable); +})(jQuery, window, Drupal, drupalSettings, loadjs, window.tabbable); diff --git a/core/misc/ajax.js b/core/misc/ajax.js index b2f572c9d592236696b0b88e8c415d2f61696370..75c97f62abe894cbc8eef437c9d07539ce31c653 100644 --- a/core/misc/ajax.js +++ b/core/misc/ajax.js @@ -5,7 +5,7 @@ * @preserve **/ -(function ($, window, Drupal, drupalSettings, _ref) { +(function ($, window, Drupal, drupalSettings, loadjs, _ref) { let { isFocusable, tabbable @@ -229,12 +229,16 @@ } } - return ajax.success(response, status); + return Promise.resolve(ajax.success(response, status)).then(() => { + ajax.ajaxing = false; + }); }, - complete(xmlhttprequest, status) { + error(xmlhttprequest, status, error) { ajax.ajaxing = false; + }, + complete(xmlhttprequest, status) { if (status === 'error' || status === 'parsererror') { return ajax.error(xmlhttprequest, ajax.url); } @@ -414,6 +418,19 @@ $('body').append(this.progress.element); }; + Drupal.Ajax.prototype.commandExecutionQueue = function (response, status) { + const ajaxCommands = this.commands; + return Object.keys(response || {}).reduce((executionQueue, key) => executionQueue.then(() => { + const { + command + } = response[key]; + + if (command && ajaxCommands[command]) { + return ajaxCommands[command](this, response[key], status); + } + }), Promise.resolve()); + }; + Drupal.Ajax.prototype.success = function (response, status) { if (this.progress.element) { $(this.progress.element).remove(); @@ -425,35 +442,35 @@ $(this.element).prop('disabled', false); const elementParents = $(this.element).parents('[data-drupal-selector]').addBack().toArray(); - let focusChanged = false; - Object.keys(response || {}).forEach(i => { - if (response[i].command && this.commands[response[i].command]) { - this.commands[response[i].command](this, response[i], status); - - if (response[i].command === 'invoke' && response[i].method === 'focus' || response[i].command === 'focusFirst') { - focusChanged = true; - } - } + const focusChanged = Object.keys(response || {}).some(key => { + const { + command, + method + } = response[key]; + return command === 'focusFirst' || command === 'invoke' && method === 'focus'; }); + return this.commandExecutionQueue(response, status).then(() => { + if (!focusChanged && this.element && !$(this.element).data('disable-refocus')) { + let target = false; - if (!focusChanged && this.element && !$(this.element).data('disable-refocus')) { - let target = false; + for (let n = elementParents.length - 1; !target && n >= 0; n--) { + target = document.querySelector(`[data-drupal-selector="${elementParents[n].getAttribute('data-drupal-selector')}"]`); + } - for (let n = elementParents.length - 1; !target && n >= 0; n--) { - target = document.querySelector(`[data-drupal-selector="${elementParents[n].getAttribute('data-drupal-selector')}"]`); + if (target) { + $(target).trigger('focus'); + } } - if (target) { - $(target).trigger('focus'); + if (this.$form && document.body.contains(this.$form.get(0))) { + const settings = this.settings || drupalSettings; + Drupal.attachBehaviors(this.$form.get(0), settings); } - } - if (this.$form && document.body.contains(this.$form.get(0))) { - const settings = this.settings || drupalSettings; - Drupal.attachBehaviors(this.$form.get(0), settings); - } - - this.settings = null; + this.settings = null; + }).catch(error => console.error(Drupal.t('An error occurred during the execution of the Ajax response: !error', { + '!error': error + }))); }; Drupal.Ajax.prototype.getEffect = function (response) { @@ -664,7 +681,44 @@ } messages.add(response.message, response.messageOptions); + }, + + add_js(ajax, response, status) { + const parentEl = document.querySelector(response.selector || 'body'); + const settings = ajax.settings || drupalSettings; + const allUniqueBundleIds = response.data.map(script => { + const uniqueBundleId = script.src + ajax.instanceIndex; + loadjs(script.src, uniqueBundleId, { + async: false, + + before(path, scriptEl) { + Object.keys(script).forEach(attributeKey => { + scriptEl.setAttribute(attributeKey, script[attributeKey]); + }); + parentEl.appendChild(scriptEl); + return false; + } + + }); + return uniqueBundleId; + }); + return new Promise((resolve, reject) => { + loadjs.ready(allUniqueBundleIds, { + success() { + Drupal.attachBehaviors(parentEl, settings); + resolve(); + }, + + error(depsNotFound) { + const message = Drupal.t(`The following files could not be loaded: @dependencies`, { + '@dependencies': depsNotFound.join(', ') + }); + reject(message); + } + + }); + }); } }; -})(jQuery, window, Drupal, drupalSettings, window.tabbable); \ No newline at end of file +})(jQuery, window, Drupal, drupalSettings, loadjs, window.tabbable); \ No newline at end of file diff --git a/core/modules/ckeditor/tests/src/FunctionalJavascript/BigPipeRegressionTest.php b/core/modules/ckeditor/tests/src/FunctionalJavascript/BigPipeRegressionTest.php index da99c5c2ab0cea4e8d59f3fd239c5db70fd3b8ea..188aef4ff4853b666ccd7f8f5a65cba1e78fddcd 100644 --- a/core/modules/ckeditor/tests/src/FunctionalJavascript/BigPipeRegressionTest.php +++ b/core/modules/ckeditor/tests/src/FunctionalJavascript/BigPipeRegressionTest.php @@ -110,7 +110,7 @@ public function testCommentForm_2698811() { // Confirm that CKEditor loaded. $javascript = <<<JS (function(){ - return Object.keys(CKEDITOR.instances).length > 0; + return window.CKEDITOR && Object.keys(CKEDITOR.instances).length > 0; }()) JS; $this->assertJsCondition($javascript); diff --git a/core/modules/media_library/js/media_library.ui.es6.js b/core/modules/media_library/js/media_library.ui.es6.js index 95bc01ac3fe51236569a605eaf99a9727533c6c1..569b4431211038dcca7b7da386bda37dc3519500 100644 --- a/core/modules/media_library/js/media_library.ui.es6.js +++ b/core/modules/media_library/js/media_library.ui.es6.js @@ -83,37 +83,21 @@ // 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); + return Promise.resolve( + Drupal.Ajax.prototype.success.call(ajaxObject, response, status), + ).then(() => { + // Set focus to the first tabbable element in the media library + // content. + const mediaLibraryContent = document.getElementById( + 'media-library-content', + ); + if (mediaLibraryContent) { + const tabbableContent = tabbable(mediaLibraryContent); + if (tabbableContent.length) { + tabbableContent[0].focus(); + } } }); - - // Set focus to the first tabbable element in the media library - // content. - const mediaLibraryContent = document.getElementById( - 'media-library-content', - ); - if (mediaLibraryContent) { - const tabbableContent = tabbable(mediaLibraryContent); - if (tabbableContent.length) { - tabbableContent[0].focus(); - } - } - - // Remove any response-specific settings so they don't get used on - // the next call by mistake. - this.settings = null; }; ajaxObject.execute(); diff --git a/core/modules/media_library/js/media_library.ui.js b/core/modules/media_library/js/media_library.ui.js index 96ed3914fae333d66d7a928a740b63b7eb84f7bc..b7d5c61386e0ed86cb5e3ecae20839793d447a88 100644 --- a/core/modules/media_library/js/media_library.ui.js +++ b/core/modules/media_library/js/media_library.ui.js @@ -42,31 +42,17 @@ }); ajaxObject.success = function (response, status) { - if (this.progress.element) { - $(this.progress.element).remove(); - } + return Promise.resolve(Drupal.Ajax.prototype.success.call(ajaxObject, response, status)).then(() => { + const mediaLibraryContent = document.getElementById('media-library-content'); - if (this.progress.object) { - this.progress.object.stopMonitoring(); - } + if (mediaLibraryContent) { + const tabbableContent = tabbable(mediaLibraryContent); - $(this.element).prop('disabled', false); - Object.keys(response || {}).forEach(i => { - if (response[i].command && this.commands[response[i].command]) { - this.commands[response[i].command](this, response[i], status); + if (tabbableContent.length) { + tabbableContent[0].focus(); + } } }); - const mediaLibraryContent = document.getElementById('media-library-content'); - - if (mediaLibraryContent) { - const tabbableContent = tabbable(mediaLibraryContent); - - if (tabbableContent.length) { - tabbableContent[0].focus(); - } - } - - this.settings = null; }; ajaxObject.execute(); diff --git a/core/modules/quickedit/js/quickedit.es6.js b/core/modules/quickedit/js/quickedit.es6.js index fe0dc6a8b6691071ad77bfca977b6c71a34d8f44..d399a1323635a5c79cb8193d2e3bf20055e61d79 100644 --- a/core/modules/quickedit/js/quickedit.es6.js +++ b/core/modules/quickedit/js/quickedit.es6.js @@ -180,16 +180,9 @@ url: Drupal.url('quickedit/attachments'), submit: { 'editors[]': missingEditors }, }); - // Implement a scoped insert AJAX command: calls the callback after all AJAX - // command functions have been executed (hence the deferred calling). - const realInsert = Drupal.AjaxCommands.prototype.insert; - loadEditorsAjax.commands.insert = function (ajax, response, status) { - _.defer(callback); - realInsert(ajax, response, status); - }; // Trigger the AJAX request, which will should return AJAX commands to // insert any missing attachments. - loadEditorsAjax.execute(); + loadEditorsAjax.execute().then(callback); } /** diff --git a/core/modules/quickedit/js/quickedit.js b/core/modules/quickedit/js/quickedit.js index 5b7ef22fb998efabfe90b34a6ed069576aca306a..91d07d9acca7ff6b4d5d637c8dedd2c2915b15cb 100644 --- a/core/modules/quickedit/js/quickedit.js +++ b/core/modules/quickedit/js/quickedit.js @@ -82,15 +82,7 @@ 'editors[]': missingEditors } }); - const realInsert = Drupal.AjaxCommands.prototype.insert; - - loadEditorsAjax.commands.insert = function (ajax, response, status) { - _.defer(callback); - - realInsert(ajax, response, status); - }; - - loadEditorsAjax.execute(); + loadEditorsAjax.execute().then(callback); } function initializeEntityContextualLink(contextualLink) { diff --git a/core/modules/system/tests/modules/ajax_test/ajax_test.libraries.yml b/core/modules/system/tests/modules/ajax_test/ajax_test.libraries.yml index 9d390854bba261ee2619f096c7d04b5b12922cef..eb4ae93d92e463eebdb6350d5d614a560f6dffa2 100644 --- a/core/modules/system/tests/modules/ajax_test/ajax_test.libraries.yml +++ b/core/modules/system/tests/modules/ajax_test/ajax_test.libraries.yml @@ -33,3 +33,12 @@ focus.first: dependencies: - core/drupal - core/once + +command_promise: + version: VERSION + js: + js/command_promise-ajax.js: {} + dependencies: + - core/jquery + - core/drupal + - core/drupal.ajax 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 01bf512adb96d84a6b0a6cb745deb9cad36baf22..0ee765e664bc75f85d398e8080d44a1c869f1040 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 @@ -93,3 +93,11 @@ ajax_test.focus_first_form: _form: '\Drupal\ajax_test\Form\AjaxTestFocusFirstForm' requirements: _access: 'TRUE' + +ajax_test.promise: + path: '/ajax-test/promise-form' + defaults: + _title: 'Ajax Form Command Promise' + _form: '\Drupal\ajax_test\Form\AjaxTestFormPromise' + requirements: + _access: 'TRUE' diff --git a/core/modules/system/tests/modules/ajax_test/js/command_promise-ajax.es6.js b/core/modules/system/tests/modules/ajax_test/js/command_promise-ajax.es6.js new file mode 100644 index 0000000000000000000000000000000000000000..8f4e40d85ba5f2127ed00163ddde1c7e62ddbf69 --- /dev/null +++ b/core/modules/system/tests/modules/ajax_test/js/command_promise-ajax.es6.js @@ -0,0 +1,31 @@ +/** + * @file + * Testing behavior for the add_js command. + */ + +(($, Drupal) => { + /** + * Test Ajax execution Order. + * + * @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.selector + * A jQuery selector string. + * + * @return {Promise} + * The promise that will resolve once this command has finished executing. + */ + Drupal.AjaxCommands.prototype.ajaxCommandReturnPromise = function ( + ajax, + response, + ) { + return new Promise((resolve, reject) => { + setTimeout(() => { + this.insert(ajax, response); + resolve(); + }, Math.random() * 500); + }); + }; +})(jQuery, Drupal); diff --git a/core/modules/system/tests/modules/ajax_test/js/command_promise-ajax.js b/core/modules/system/tests/modules/ajax_test/js/command_promise-ajax.js new file mode 100644 index 0000000000000000000000000000000000000000..162ac73f3c65fb4c6ad07f4d078e0069b93dd431 --- /dev/null +++ b/core/modules/system/tests/modules/ajax_test/js/command_promise-ajax.js @@ -0,0 +1,17 @@ +/** +* DO NOT EDIT THIS FILE. +* See the following change record for more information, +* https://www.drupal.org/node/2815083 +* @preserve +**/ + +(($, Drupal) => { + Drupal.AjaxCommands.prototype.ajaxCommandReturnPromise = function (ajax, response) { + return new Promise((resolve, reject) => { + setTimeout(() => { + this.insert(ajax, response); + resolve(); + }, Math.random() * 500); + }); + }; +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/modules/system/tests/modules/ajax_test/src/Ajax/AjaxTestCommandReturnPromise.php b/core/modules/system/tests/modules/ajax_test/src/Ajax/AjaxTestCommandReturnPromise.php new file mode 100644 index 0000000000000000000000000000000000000000..fbb0dd94b7978d1ef158e8ff51b434e562ece331 --- /dev/null +++ b/core/modules/system/tests/modules/ajax_test/src/Ajax/AjaxTestCommandReturnPromise.php @@ -0,0 +1,26 @@ +<?php + +namespace Drupal\ajax_test\Ajax; + +use Drupal\Core\Ajax\AppendCommand; + +/** + * Test Ajax command. + */ +class AjaxTestCommandReturnPromise extends AppendCommand { + + /** + * Implements Drupal\Core\Ajax\CommandInterface:render(). + */ + public function render() { + + return [ + 'command' => 'ajaxCommandReturnPromise', + 'method' => 'append', + 'selector' => $this->selector, + 'data' => $this->getRenderedContent(), + 'settings' => $this->settings, + ]; + } + +} diff --git a/core/modules/system/tests/modules/ajax_test/src/Form/AjaxTestFormPromise.php b/core/modules/system/tests/modules/ajax_test/src/Form/AjaxTestFormPromise.php new file mode 100644 index 0000000000000000000000000000000000000000..038b0b5bcd7b140b8b2bc26eb68c1ad64a61403b --- /dev/null +++ b/core/modules/system/tests/modules/ajax_test/src/Form/AjaxTestFormPromise.php @@ -0,0 +1,73 @@ +<?php + +namespace Drupal\ajax_test\Form; + +use Drupal\ajax_test\Ajax\AjaxTestCommandReturnPromise; +use Drupal\Core\Ajax\AjaxResponse; +use Drupal\Core\Ajax\AppendCommand; +use Drupal\Core\Form\FormBase; +use Drupal\Core\Form\FormStateInterface; + +/** + * Test form for ajax_test_form_promise. + * + * @internal + */ +class AjaxTestFormPromise extends FormBase { + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'ajax_test_form_promise'; + } + + /** + * Form for testing the addition of various types of elements via Ajax. + */ + public function buildForm(array $form, FormStateInterface $form_state) { + $form['#attached']['library'][] = 'ajax_test/command_promise'; + $form['custom']['#prefix'] = '<div id="ajax_test_form_promise_wrapper">'; + $form['custom']['#suffix'] = '</div>'; + + // Button to test for the execution order of Ajax commands. + $form['test_execution_order_button'] = [ + '#type' => 'submit', + '#value' => $this->t('Execute commands button'), + '#button_type' => 'primary', + '#ajax' => [ + 'callback' => [static::class, 'executeCommands'], + 'progress' => [ + 'type' => 'throbber', + 'message' => NULL, + ], + 'wrapper' => 'ajax_test_form_promise_wrapper', + ], + ]; + return $form; + } + + /** + * Ajax callback for the "Execute commands button" button. + */ + public static function executeCommands(array $form, FormStateInterface $form_state) { + $selector = '#ajax_test_form_promise_wrapper'; + $response = new AjaxResponse(); + + $response->addCommand(new AppendCommand($selector, '1')); + $response->addCommand(new AjaxTestCommandReturnPromise($selector, '2')); + $response->addCommand(new AppendCommand($selector, '3')); + $response->addCommand(new AppendCommand($selector, '4')); + $response->addCommand(new AjaxTestCommandReturnPromise($selector, '5')); + + return $response; + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + // An empty implementation, as we never submit the actual form. + } + +} diff --git a/core/modules/system/tests/src/Functional/Ajax/FrameworkTest.php b/core/modules/system/tests/src/Functional/Ajax/FrameworkTest.php index de95fb5761684f511f8a9be6d5bf5940db277eb9..669037f82d05f0c86c0f6ad2228cd1311e183aa8 100644 --- a/core/modules/system/tests/src/Functional/Ajax/FrameworkTest.php +++ b/core/modules/system/tests/src/Functional/Ajax/FrameworkTest.php @@ -4,10 +4,9 @@ use Drupal\Component\Serialization\Json; use Drupal\Core\Ajax\AddCssCommand; +use Drupal\Core\Ajax\AddJsCommand; use Drupal\Core\Ajax\AlertCommand; -use Drupal\Core\Ajax\AppendCommand; use Drupal\Core\Ajax\HtmlCommand; -use Drupal\Core\Ajax\PrependCommand; use Drupal\Core\Ajax\SettingsCommand; use Drupal\Core\Asset\AttachedAssets; use Drupal\Core\EventSubscriber\MainContentViewSubscriber; @@ -61,8 +60,8 @@ public function testOrder() { [$js_assets_header, $js_assets_footer] = $asset_resolver->getJsAssets($assets, FALSE); $js_header_render_array = $js_collection_renderer->render($js_assets_header); $js_footer_render_array = $js_collection_renderer->render($js_assets_footer); - $expected_commands[2] = new PrependCommand('head', $js_header_render_array); - $expected_commands[3] = new AppendCommand('body', $js_footer_render_array); + $expected_commands[2] = new AddJsCommand(array_column($js_header_render_array, '#attributes'), 'head'); + $expected_commands[3] = new AddJsCommand(array_column($js_footer_render_array, '#attributes')); $expected_commands[4] = new HtmlCommand('body', 'Hello, world!'); // Verify AJAX command order — this should always be the order: diff --git a/core/tests/Drupal/Nightwatch/Tests/ajaxExecutionOrderTest.js b/core/tests/Drupal/Nightwatch/Tests/ajaxExecutionOrderTest.js new file mode 100644 index 0000000000000000000000000000000000000000..da54f6015a6c5a52527433f761f58d498ca3fd4e --- /dev/null +++ b/core/tests/Drupal/Nightwatch/Tests/ajaxExecutionOrderTest.js @@ -0,0 +1,34 @@ +module.exports = { + '@tags': ['core', 'ajax'], + before(browser) { + browser.drupalInstall().drupalLoginAsAdmin(() => { + browser + .drupalRelativeURL('/admin/modules') + .setValue('input[type="search"]', 'Ajax test') + .waitForElementVisible('input[name="modules[ajax_test][enable]"]', 1000) + .click('input[name="modules[ajax_test][enable]"]') + .submitForm('input[type="submit"]') // Submit module form. + .waitForElementVisible( + '.system-modules-confirm-form input[value="Continue"]', + 2000, + ) + .submitForm('input[value="Continue"]') // Confirm installation of dependencies. + .waitForElementVisible('.system-modules', 10000); + }); + }, + after(browser) { + browser.drupalUninstall(); + }, + 'Test Execution Order': (browser) => { + browser + .drupalRelativeURL('/ajax-test/promise-form') + .waitForElementVisible('body', 1000) + .click('[data-drupal-selector="edit-test-execution-order-button"]') + .waitForElementVisible('#ajax_test_form_promise_wrapper', 1000) + .assert.containsText( + '#ajax_test_form_promise_wrapper', + '12345', + 'Ajax commands execution order confirmed', + ); + }, +};