Commit c27ac21a authored by bnjmnm's avatar bnjmnm
Browse files

Issue #1988968 by nod_, droplet, bnjmnm, viappidu, extect, jansete, olli,...

Issue #1988968 by nod_, droplet, bnjmnm, viappidu, extect, jansete, olli, martin107, pmagunia, bartlangelaan, Wim Leers, zrpnr, KapilV, yogeshmpawar, Spleshka, Phil Wolstenholme, DuaelFr, agata.guc, alwaysworking, dawid_nawrot, andriic, keithdoyle9, gapple, lauriii, Martijn de Wit, jefuri, JMOmandown, larowlan, rubens.arjr, borisson_, joseph.olstad, jberube, gilgabar, Poindexterous, Aless86, jessebeach, bojanz, phma, aheimlich, heddn, phenaproxima, acbramley, codebymikey, cmlara, das-peter, matthiasm11, acolden, xjm, jrockowitz, pianomansam, clairemistry, John Pitcairn: Drupal.ajax does not guarantee that "add new JS file to page" commands have finished before calling said JS
parent cec94495
......@@ -390,6 +390,8 @@ drupal.ajax:
- core/once
- core/jquery.once.bc
- core/tabbable
- core/loadjs
- core/es6-promise
drupal.announce:
version: VERSION
......
<?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,
];
}
}
......@@ -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);
......
......@@ -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.
*
......@@ -547,10 +554,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);
}
......@@ -960,6 +980,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.
*
......@@ -967,6 +1017,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.
......@@ -989,55 +1042,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,
},
),
),
)
);
};
/**
......@@ -1620,5 +1679,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);
......@@ -17,7 +17,7 @@ function _arrayWithoutHoles(arr) { if (Array.isArray(arr)) return _arrayLikeToAr
function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len = arr.length; for (var i = 0, arr2 = new Array(len); i < len; i++) { arr2[i] = arr[i]; } return arr2; }
(function ($, window, Drupal, drupalSettings, _ref) {
(function ($, window, Drupal, drupalSettings, loadjs, _ref) {
var isFocusable = _ref.isFocusable,
tabbable = _ref.tabbable;
Drupal.behaviors.AJAX = {
......@@ -238,11 +238,14 @@ function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len
}
}
return ajax.success(response, status);
return Promise.resolve(ajax.success(response, status)).then(function () {
ajax.ajaxing = false;
});
},
complete: function complete(xmlhttprequest, status) {
error: function error(xmlhttprequest, status, _error) {
ajax.ajaxing = false;
},
complete: function complete(xmlhttprequest, status) {
if (status === 'error' || status === 'parsererror') {
return ajax.error(xmlhttprequest, ajax.url);
}
......@@ -427,9 +430,24 @@ function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len
$('body').append(this.progress.element);
};
Drupal.Ajax.prototype.success = function (response, status) {
Drupal.Ajax.prototype.commandExecutionQueue = function (response, status) {
var _this = this;
var ajaxCommands = this.commands;
return Object.keys(response || {}).reduce(function (executionQueue, key) {
return executionQueue.then(function () {
var command = response[key].command;
if (command && ajaxCommands[command]) {
return ajaxCommands[command](_this, response[key], status);
}
});
}, Promise.resolve());
};
Drupal.Ajax.prototype.success = function (response, status) {
var _this2 = this;
if (this.progress.element) {
$(this.progress.element).remove();
}
......@@ -440,35 +458,36 @@ function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len
$(this.element).prop('disabled', false);
var elementParents = $(this.element).parents('[data-drupal-selector]').addBack().toArray();
var focusChanged = false;
Object.keys(response || {}).forEach(function (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;
}
}
var focusChanged = Object.keys(response || {}).some(function (key) {
var _response$key = response[key],
command = _response$key.command,
method = _response$key.method;
return command === 'focusFirst' || command === 'invoke' && method === 'focus';
});
return this.commandExecutionQueue(response, status).then(function () {
if (!focusChanged && _this2.element && !$(_this2.element).data('disable-refocus')) {
var target = false;
if (!focusChanged && this.element && !$(this.element).data('disable-refocus')) {
var target = false;
for (var n = elementParents.length - 1; !target && n >= 0; n--) {
target = document.querySelector("[data-drupal-selector=\"".concat(elementParents[n].getAttribute('data-drupal-selector'), "\"]"));
}
for (var n = elementParents.length - 1; !target && n >= 0; n--) {
target = document.querySelector("[data-drupal-selector=\"".concat(elementParents[n].getAttribute('data-drupal-selector'), "\"]"));
if (target) {
$(target).trigger('focus');
}
}
if (target) {
$(target).trigger('focus');
if (_this2.$form && document.body.contains(_this2.$form.get(0))) {
var settings = _this2.settings || drupalSettings;
Drupal.attachBehaviors(_this2.$form.get(0), settings);
}
}
if (this.$form && document.body.contains(this.$form.get(0))) {
var settings = this.settings || drupalSettings;
Drupal.attachBehaviors(this.$form.get(0), settings);
}
this.settings = null;
_this2.settings = null;
}).catch(function (error) {
return console.error(Drupal.t('An error occurred during the execution of the Ajax response: !error', {
'!error': error
}));
});
};
Drupal.Ajax.prototype.getEffect = function (response) {
......@@ -671,6 +690,38 @@ function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len
}
messages.add(response.message, response.messageOptions);
},
add_js: function add_js(ajax, response, status) {
var parentEl = document.querySelector(response.selector || 'body');
var settings = ajax.settings || drupalSettings;
var allUniqueBundleIds = response.data.map(function (script) {
var uniqueBundleId = script.src + ajax.instanceIndex;
loadjs(script.src, uniqueBundleId, {
async: false,
before: function before(path, scriptEl) {
Object.keys(script).forEach(function (attributeKey) {
scriptEl.setAttribute(attributeKey, script[attributeKey]);
});
parentEl.appendChild(scriptEl);
return false;
}
});
return uniqueBundleId;
});
return new Promise(function (resolve, reject) {
loadjs.ready(allUniqueBundleIds, {
success: function success() {
Drupal.attachBehaviors(parentEl, settings);
resolve();
},
error: function error(depsNotFound) {
var 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
......@@ -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);
......
......@@ -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();
......
......@@ -40,33 +40,17 @@
});
ajaxObject.success = function (response, status) {
var _this = this;
return Promise.resolve(Drupal.Ajax.prototype.success.call(ajaxObject, response, status)).then(function () {
var mediaLibraryContent = document.getElementById('media-library-content');
if (this.progress.element) {
$(this.progress.element).remove();
}
if (this.progress.object) {
this.progress.object.stopMonitoring();
}
if (mediaLibraryContent) {
var tabbableContent = tabbable(mediaLibraryContent);
$(this.element).prop('disabled', false);
Object.keys(response || {}).forEach(function (i) {
if (response[i].command && _this.commands[response[i].command]) {
_this.commands[response[i].command](_this, response[i], status);
if (tabbableContent.length) {