summaryrefslogtreecommitdiffstatshomepage
path: root/core/misc/ajax.js
diff options
context:
space:
mode:
Diffstat (limited to 'core/misc/ajax.js')
-rw-r--r--core/misc/ajax.js1390
1 files changed, 1203 insertions, 187 deletions
diff --git a/core/misc/ajax.js b/core/misc/ajax.js
index 75c97f62abe8..c8c8eb1cd39e 100644
--- a/core/misc/ajax.js
+++ b/core/misc/ajax.js
@@ -1,115 +1,255 @@
/**
-* DO NOT EDIT THIS FILE.
-* See the following change record for more information,
-* https://www.drupal.org/node/2815083
-* @preserve
-**/
-
-(function ($, window, Drupal, drupalSettings, loadjs, _ref) {
- let {
- isFocusable,
- tabbable
- } = _ref;
+ * @file
+ * Provides Ajax page updating via jQuery $.ajax.
+ *
+ * Ajax is a method of making a request via JavaScript while viewing an HTML
+ * page. The request returns an array of commands encoded in JSON, which is
+ * then executed to make any changes that are necessary to the page.
+ *
+ * Drupal uses this file to enhance form elements with `#ajax['url']` and
+ * `#ajax['wrapper']` properties. If set, this file will automatically be
+ * included to provide Ajax capabilities.
+ */
+
+(function (
+ $,
+ window,
+ Drupal,
+ drupalSettings,
+ loadjs,
+ { isFocusable, tabbable },
+) {
+ /**
+ * Attaches the Ajax behavior to each Ajax form element.
+ *
+ * @type {Drupal~behavior}
+ *
+ * @prop {Drupal~behaviorAttach} attach
+ * Initialize all {@link Drupal.Ajax} objects declared in
+ * `drupalSettings.ajax` or initialize {@link Drupal.Ajax} objects from
+ * DOM elements having the `use-ajax-submit` or `use-ajax` css class.
+ * @prop {Drupal~behaviorDetach} detach
+ * During `unload` remove all {@link Drupal.Ajax} objects related to
+ * the removed content.
+ */
Drupal.behaviors.AJAX = {
attach(context, settings) {
function loadAjaxBehavior(base) {
const elementSettings = settings.ajax[base];
-
if (typeof elementSettings.selector === 'undefined') {
elementSettings.selector = `#${base}`;
}
-
- once('drupal-ajax', $(elementSettings.selector)).forEach(el => {
+ // Use jQuery selector instead of a native selector for
+ // backwards compatibility.
+ once('drupal-ajax', $(elementSettings.selector)).forEach((el) => {
elementSettings.element = el;
elementSettings.base = base;
Drupal.ajax(elementSettings);
});
}
- Object.keys(settings.ajax || {}).forEach(base => loadAjaxBehavior(base));
+ // Load all Ajax behaviors specified in the settings.
+ Object.keys(settings.ajax || {}).forEach((base) =>
+ loadAjaxBehavior(base),
+ );
+
Drupal.ajax.bindAjaxLinks(document.body);
- once('ajax', '.use-ajax-submit').forEach(el => {
+
+ // This class means to submit the form to the action using Ajax.
+ once('ajax', '.use-ajax-submit').forEach((el) => {
const elementSettings = {};
+
+ // Ajax submits specified in this manner automatically submit to the
+ // normal form action.
elementSettings.url = $(el.form).attr('action');
+ // Form submit button clicks need to tell the form what was clicked so
+ // it gets passed in the POST request.
elementSettings.setClick = true;
+ // Form buttons use the 'click' event rather than mousedown.
elementSettings.event = 'click';
- elementSettings.progress = {
- type: 'throbber'
- };
+ // Clicked form buttons look better with the throbber than the progress
+ // bar.
+ elementSettings.progress = { type: 'throbber' };
elementSettings.base = el.id;
elementSettings.element = el;
+
Drupal.ajax(elementSettings);
});
},
detach(context, settings, trigger) {
if (trigger === 'unload') {
- Drupal.ajax.expired().forEach(instance => {
+ Drupal.ajax.expired().forEach((instance) => {
+ // Set this to null and allow garbage collection to reclaim
+ // the memory.
Drupal.ajax.instances[instance.instanceIndex] = null;
});
}
- }
-
+ },
};
+ /**
+ * Extends Error to provide handling for Errors in Ajax.
+ *
+ * @constructor
+ *
+ * @augments Error
+ *
+ * @param {XMLHttpRequest} xmlhttp
+ * XMLHttpRequest object used for the failed request.
+ * @param {string} uri
+ * The URI where the error occurred.
+ * @param {string} customMessage
+ * The custom message.
+ */
Drupal.AjaxError = function (xmlhttp, uri, customMessage) {
let statusCode;
let statusText;
let responseText;
-
if (xmlhttp.status) {
- statusCode = `\n${Drupal.t('An AJAX HTTP error occurred.')}\n${Drupal.t('HTTP Result Code: !status', {
- '!status': xmlhttp.status
- })}`;
+ statusCode = `\n${Drupal.t('An AJAX HTTP error occurred.')}\n${Drupal.t(
+ 'HTTP Result Code: !status',
+ {
+ '!status': xmlhttp.status,
+ },
+ )}`;
} else {
- statusCode = `\n${Drupal.t('An AJAX HTTP request terminated abnormally.')}`;
+ statusCode = `\n${Drupal.t(
+ 'An AJAX HTTP request terminated abnormally.',
+ )}`;
}
-
statusCode += `\n${Drupal.t('Debugging information follows.')}`;
- const pathText = `\n${Drupal.t('Path: !uri', {
- '!uri': uri
- })}`;
+ const pathText = `\n${Drupal.t('Path: !uri', { '!uri': uri })}`;
statusText = '';
-
+ // In some cases, when statusCode === 0, xmlhttp.statusText may not be
+ // defined. Unfortunately, testing for it with typeof, etc, doesn't seem to
+ // catch that and the test causes an exception. So we need to catch the
+ // exception here.
try {
statusText = `\n${Drupal.t('StatusText: !statusText', {
- '!statusText': xmlhttp.statusText.trim()
+ '!statusText': xmlhttp.statusText.trim(),
})}`;
- } catch (e) {}
+ } catch (e) {
+ // Empty.
+ }
responseText = '';
-
+ // Again, we don't have a way to know for sure whether accessing
+ // xmlhttp.responseText is going to throw an exception. So we'll catch it.
try {
responseText = `\n${Drupal.t('ResponseText: !responseText', {
- '!responseText': xmlhttp.responseText.trim()
+ '!responseText': xmlhttp.responseText.trim(),
})}`;
- } catch (e) {}
+ } catch (e) {
+ // Empty.
+ }
+ // Make the responseText more readable by stripping HTML tags and newlines.
responseText = responseText.replace(/<("[^"]*"|'[^']*'|[^'">])*>/gi, '');
responseText = responseText.replace(/[\n]+\s+/g, '\n');
- const readyStateText = xmlhttp.status === 0 ? `\n${Drupal.t('ReadyState: !readyState', {
- '!readyState': xmlhttp.readyState
- })}` : '';
- customMessage = customMessage ? `\n${Drupal.t('CustomMessage: !customMessage', {
- '!customMessage': customMessage
- })}` : '';
- this.message = statusCode + pathText + statusText + customMessage + responseText + readyStateText;
+
+ // We don't need readyState except for status == 0.
+ const readyStateText =
+ xmlhttp.status === 0
+ ? `\n${Drupal.t('ReadyState: !readyState', {
+ '!readyState': xmlhttp.readyState,
+ })}`
+ : '';
+
+ customMessage = customMessage
+ ? `\n${Drupal.t('CustomMessage: !customMessage', {
+ '!customMessage': customMessage,
+ })}`
+ : '';
+
+ /**
+ * Formatted and translated error message.
+ *
+ * @type {string}
+ */
+ this.message =
+ statusCode +
+ pathText +
+ statusText +
+ customMessage +
+ responseText +
+ readyStateText;
+
+ /**
+ * Used by some browsers to display a more accurate stack trace.
+ *
+ * @type {string}
+ */
this.name = 'AjaxError';
};
Drupal.AjaxError.prototype = new Error();
Drupal.AjaxError.prototype.constructor = Drupal.AjaxError;
+ /**
+ * Provides Ajax page updating via jQuery $.ajax.
+ *
+ * This function is designed to improve developer experience by wrapping the
+ * initialization of {@link Drupal.Ajax} objects and storing all created
+ * objects in the {@link Drupal.ajax.instances} array.
+ *
+ * @example
+ * Drupal.behaviors.myCustomAJAXStuff = {
+ * attach: function (context, settings) {
+ *
+ * var ajaxSettings = {
+ * url: 'my/url/path',
+ * // If the old version of Drupal.ajax() needs to be used those
+ * // properties can be added
+ * base: 'myBase',
+ * element: $(context).find('.someElement')
+ * };
+ *
+ * var myAjaxObject = Drupal.ajax(ajaxSettings);
+ *
+ * // Declare a new Ajax command specifically for this Ajax object.
+ * myAjaxObject.commands.insert = function (ajax, response, status) {
+ * $('#my-wrapper').append(response.data);
+ * alert('New content was appended to #my-wrapper');
+ * };
+ *
+ * // This command will remove this Ajax object from the page.
+ * myAjaxObject.commands.destroyObject = function (ajax, response, status) {
+ * Drupal.ajax.instances[this.instanceIndex] = null;
+ * };
+ *
+ * // Programmatically trigger the Ajax request.
+ * myAjaxObject.execute();
+ * }
+ * };
+ *
+ * @param {object} settings
+ * The settings object passed to {@link Drupal.Ajax} constructor.
+ * @param {string} [settings.base]
+ * Base is passed to {@link Drupal.Ajax} constructor as the 'base'
+ * parameter.
+ * @param {HTMLElement} [settings.element]
+ * Element parameter of {@link Drupal.Ajax} constructor, element on which
+ * event listeners will be bound.
+ *
+ * @return {Drupal.Ajax}
+ * The created Ajax object.
+ *
+ * @see Drupal.AjaxCommands
+ */
Drupal.ajax = function (settings) {
if (arguments.length !== 1) {
- throw new Error('Drupal.ajax() function must be called with one configuration object only');
+ throw new Error(
+ 'Drupal.ajax() function must be called with one configuration object only',
+ );
}
-
+ // Map those config keys to variables for the old Drupal.ajax function.
const base = settings.base || false;
const element = settings.element || false;
delete settings.base;
delete settings.element;
+ // By default do not display progress for ajax calls without an element.
if (!settings.progress && !element) {
settings.progress = false;
}
@@ -117,39 +257,128 @@
const ajax = new Drupal.Ajax(base, element, settings);
ajax.instanceIndex = Drupal.ajax.instances.length;
Drupal.ajax.instances.push(ajax);
+
return ajax;
};
+ /**
+ * Contains all created Ajax objects.
+ *
+ * @type {Array.<Drupal.Ajax|null>}
+ */
Drupal.ajax.instances = [];
+ /**
+ * List all objects where the associated element is not in the DOM
+ *
+ * This method ignores {@link Drupal.Ajax} objects not bound to DOM elements
+ * when created with {@link Drupal.ajax}.
+ *
+ * @return {Array.<Drupal.Ajax>}
+ * The list of expired {@link Drupal.Ajax} objects.
+ */
Drupal.ajax.expired = function () {
- return Drupal.ajax.instances.filter(instance => instance && instance.element !== false && !document.body.contains(instance.element));
+ return Drupal.ajax.instances.filter(
+ (instance) =>
+ instance &&
+ instance.element !== false &&
+ !document.body.contains(instance.element),
+ );
};
- Drupal.ajax.bindAjaxLinks = element => {
- once('ajax', '.use-ajax', element).forEach(ajaxLink => {
+ /**
+ * Bind Ajax functionality to links that use the 'use-ajax' class.
+ *
+ * @param {HTMLElement} element
+ * Element to enable Ajax functionality for.
+ */
+ Drupal.ajax.bindAjaxLinks = (element) => {
+ // Bind Ajax behaviors to all items showing the class.
+ once('ajax', '.use-ajax', element).forEach((ajaxLink) => {
const $linkElement = $(ajaxLink);
+
const elementSettings = {
- progress: {
- type: 'throbber'
- },
+ // Clicked links look better with the throbber than the progress bar.
+ progress: { type: 'throbber' },
dialogType: $linkElement.data('dialog-type'),
dialog: $linkElement.data('dialog-options'),
dialogRenderer: $linkElement.data('dialog-renderer'),
base: $linkElement.attr('id'),
- element: ajaxLink
+ element: ajaxLink,
};
const href = $linkElement.attr('href');
-
+ /**
+ * For anchor tags, these will go to the target of the anchor rather than
+ * the usual location.
+ */
if (href) {
elementSettings.url = href;
elementSettings.event = 'click';
}
-
Drupal.ajax(elementSettings);
});
};
+ /**
+ * Settings for an Ajax object.
+ *
+ * @typedef {object} Drupal.Ajax~elementSettings
+ *
+ * @prop {string} url
+ * Target of the Ajax request.
+ * @prop {?string} [event]
+ * Event bound to settings.element which will trigger the Ajax request.
+ * @prop {bool} [keypress=true]
+ * Triggers a request on keypress events.
+ * @prop {?string} selector
+ * jQuery selector targeting the element to bind events to or used with
+ * {@link Drupal.AjaxCommands}.
+ * @prop {string} [effect='none']
+ * Name of the jQuery method to use for displaying new Ajax content.
+ * @prop {string|number} [speed='none']
+ * Speed with which to apply the effect.
+ * @prop {string} [method]
+ * Name of the jQuery method used to insert new content in the targeted
+ * element.
+ * @prop {object} [progress]
+ * Settings for the display of a user-friendly loader.
+ * @prop {string} [progress.type='throbber']
+ * Type of progress element, core provides `'bar'`, `'throbber'` and
+ * `'fullscreen'`.
+ * @prop {string} [progress.message=Drupal.t('Please wait...')]
+ * Custom message to be used with the bar indicator.
+ * @prop {object} [submit]
+ * Extra data to be sent with the Ajax request.
+ * @prop {bool} [submit.js=true]
+ * Allows the PHP side to know this comes from an Ajax request.
+ * @prop {object} [dialog]
+ * Options for {@link Drupal.dialog}.
+ * @prop {string} [dialogType]
+ * One of `'modal'` or `'dialog'`.
+ * @prop {string} [prevent]
+ * List of events on which to stop default action and stop propagation.
+ */
+
+ /**
+ * Ajax constructor.
+ *
+ * The Ajax request returns an array of commands encoded in JSON, which is
+ * then executed to make any changes that are necessary to the page.
+ *
+ * Drupal uses this file to enhance form elements with `#ajax['url']` and
+ * `#ajax['wrapper']` properties. If set, this file will automatically be
+ * included to provide Ajax capabilities.
+ *
+ * @constructor
+ *
+ * @param {string} [base]
+ * Base parameter of {@link Drupal.Ajax} constructor
+ * @param {HTMLElement} [element]
+ * Element parameter of {@link Drupal.Ajax} constructor, element on which
+ * event listeners will be bound.
+ * @param {Drupal.Ajax~elementSettings} elementSettings
+ * Settings for this Ajax object.
+ */
Drupal.Ajax = function (base, element, elementSettings) {
const defaults = {
event: element ? 'mousedown' : null,
@@ -160,30 +389,58 @@
method: 'replaceWith',
progress: {
type: 'throbber',
- message: Drupal.t('Please wait...')
+ message: Drupal.t('Please wait...'),
},
submit: {
- js: true
- }
+ js: true,
+ },
};
+
$.extend(this, defaults, elementSettings);
+
+ /**
+ * @type {Drupal.AjaxCommands}
+ */
this.commands = new Drupal.AjaxCommands();
+
+ /**
+ * @type {bool|number}
+ */
this.instanceIndex = false;
+ // @todo Remove this after refactoring the PHP code to:
+ // - Call this 'selector'.
+ // - Include the '#' for ID-based selectors.
+ // - Support non-ID-based selectors.
if (this.wrapper) {
+ /**
+ * @type {string}
+ */
this.wrapper = `#${this.wrapper}`;
}
+ /**
+ * @type {HTMLElement}
+ */
this.element = element;
+
+ /**
+ * @type {Drupal.Ajax~elementSettings}
+ */
this.elementSettings = elementSettings;
+ // If there isn't a form, jQuery.ajax() will be used instead, allowing us to
+ // bind Ajax to links as well.
if (this.element && this.element.form) {
+ /**
+ * @type {jQuery}
+ */
this.$form = $(this.element.form);
}
+ // If no Ajax callback URL was given, use the link href or form action.
if (!this.url) {
const $element = $(this.element);
-
if ($element.is('a')) {
this.url = $element.attr('href');
} else if (this.element && element.form) {
@@ -191,142 +448,305 @@
}
}
+ // Replacing 'nojs' with 'ajax' in the URL allows for an easy method to let
+ // the server detect when it needs to degrade gracefully.
+ // There are four scenarios to check for:
+ // 1. /nojs/
+ // 2. /nojs$ - The end of a URL string.
+ // 3. /nojs? - Followed by a query (e.g. path/nojs?destination=foobar).
+ // 4. /nojs# - Followed by a fragment (e.g.: path/nojs#my-fragment).
const originalUrl = this.url;
- this.url = this.url.replace(/\/nojs(\/|$|\?|#)/, '/ajax$1');
+ /**
+ * Processed Ajax URL.
+ *
+ * @type {string}
+ */
+ this.url = this.url.replace(/\/nojs(\/|$|\?|#)/, '/ajax$1');
+ // If the 'nojs' version of the URL is trusted, also trust the 'ajax'
+ // version.
if (drupalSettings.ajaxTrustedUrl[originalUrl]) {
drupalSettings.ajaxTrustedUrl[this.url] = true;
}
+ // Set the options for the ajaxSubmit function.
+ // The 'this' variable will not persist inside of the options object.
const ajax = this;
+
+ /**
+ * Options for the jQuery.ajax function.
+ *
+ * @name Drupal.Ajax#options
+ *
+ * @type {object}
+ *
+ * @prop {string} url
+ * Ajax URL to be called.
+ * @prop {object} data
+ * Ajax payload.
+ * @prop {function} beforeSerialize
+ * Implement jQuery beforeSerialize function to call
+ * {@link Drupal.Ajax#beforeSerialize}.
+ * @prop {function} beforeSubmit
+ * Implement jQuery beforeSubmit function to call
+ * {@link Drupal.Ajax#beforeSubmit}.
+ * @prop {function} beforeSend
+ * Implement jQuery beforeSend function to call
+ * {@link Drupal.Ajax#beforeSend}.
+ * @prop {function} success
+ * Implement jQuery success function to call
+ * {@link Drupal.Ajax#success}.
+ * @prop {function} complete
+ * Implement jQuery success function to clean up ajax state and trigger an
+ * error if needed.
+ * @prop {string} dataType='json'
+ * Type of the response expected.
+ * @prop {string} type='POST'
+ * HTTP method to use for the Ajax request.
+ */
ajax.options = {
url: ajax.url,
data: ajax.submit,
-
beforeSerialize(elementSettings, options) {
return ajax.beforeSerialize(elementSettings, options);
},
-
beforeSubmit(formValues, elementSettings, options) {
ajax.ajaxing = true;
return ajax.beforeSubmit(formValues, elementSettings, options);
},
-
beforeSend(xmlhttprequest, options) {
ajax.ajaxing = true;
return ajax.beforeSend(xmlhttprequest, options);
},
-
success(response, status, xmlhttprequest) {
+ // Sanity check for browser support (object expected).
+ // When using iFrame uploads, responses must be returned as a string.
if (typeof response === 'string') {
response = $.parseJSON(response);
}
+ // Prior to invoking the response's commands, verify that they can be
+ // trusted by checking for a response header. See
+ // \Drupal\Core\EventSubscriber\AjaxResponseSubscriber for details.
+ // - Empty responses are harmless so can bypass verification. This
+ // avoids an alert message for server-generated no-op responses that
+ // skip Ajax rendering.
+ // - Ajax objects with trusted URLs (e.g., ones defined server-side via
+ // #ajax) can bypass header verification. This is especially useful
+ // for Ajax with multipart forms. Because IFRAME transport is used,
+ // the response headers cannot be accessed for verification.
if (response !== null && !drupalSettings.ajaxTrustedUrl[ajax.url]) {
if (xmlhttprequest.getResponseHeader('X-Drupal-Ajax-Token') !== '1') {
- const customMessage = Drupal.t('The response failed verification so will not be processed.');
+ const customMessage = Drupal.t(
+ 'The response failed verification so will not be processed.',
+ );
return ajax.error(xmlhttprequest, ajax.url, customMessage);
}
}
- return Promise.resolve(ajax.success(response, status)).then(() => {
- ajax.ajaxing = false;
- });
+ 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;
+ })
+ );
},
-
error(xmlhttprequest, status, error) {
ajax.ajaxing = false;
},
-
complete(xmlhttprequest, status) {
if (status === 'error' || status === 'parsererror') {
return ajax.error(xmlhttprequest, ajax.url);
}
},
-
dataType: 'json',
jsonp: false,
- type: 'POST'
+ type: 'POST',
};
if (elementSettings.dialog) {
ajax.options.data.dialogOptions = elementSettings.dialog;
}
+ // Ensure that we have a valid URL by adding ? when no query parameter is
+ // yet available, otherwise append using &.
if (ajax.options.url.indexOf('?') === -1) {
ajax.options.url += '?';
} else {
ajax.options.url += '&';
}
-
+ // If this element has a dialog type use if for the wrapper if not use 'ajax'.
let wrapper = `drupal_${elementSettings.dialogType || 'ajax'}`;
-
if (elementSettings.dialogRenderer) {
wrapper += `.${elementSettings.dialogRenderer}`;
}
-
ajax.options.url += `${Drupal.ajax.WRAPPER_FORMAT}=${wrapper}`;
+
+ // Bind the ajaxSubmit function to the element event.
$(ajax.element).on(elementSettings.event, function (event) {
- if (!drupalSettings.ajaxTrustedUrl[ajax.url] && !Drupal.url.isLocal(ajax.url)) {
- throw new Error(Drupal.t('The callback URL is not local and not trusted: !url', {
- '!url': ajax.url
- }));
+ if (
+ !drupalSettings.ajaxTrustedUrl[ajax.url] &&
+ !Drupal.url.isLocal(ajax.url)
+ ) {
+ throw new Error(
+ Drupal.t('The callback URL is not local and not trusted: !url', {
+ '!url': ajax.url,
+ }),
+ );
}
-
return ajax.eventResponse(this, event);
});
+ // If necessary, enable keyboard submission so that Ajax behaviors
+ // can be triggered through keyboard input as well as e.g. a mousedown
+ // action.
if (elementSettings.keypress) {
$(ajax.element).on('keypress', function (event) {
return ajax.keypressResponse(this, event);
});
}
+ // If necessary, prevent the browser default action of an additional event.
+ // For example, prevent the browser default action of a click, even if the
+ // Ajax behavior binds to mousedown.
if (elementSettings.prevent) {
$(ajax.element).on(elementSettings.prevent, false);
}
};
+ /**
+ * URL query attribute to indicate the wrapper used to render a request.
+ *
+ * The wrapper format determines how the HTML is wrapped, for example in a
+ * modal dialog.
+ *
+ * @const {string}
+ *
+ * @default
+ */
Drupal.ajax.WRAPPER_FORMAT = '_wrapper_format';
+
+ /**
+ * Request parameter to indicate that a request is a Drupal Ajax request.
+ *
+ * @const {string}
+ *
+ * @default
+ */
Drupal.Ajax.AJAX_REQUEST_PARAMETER = '_drupal_ajax';
+ /**
+ * Execute the ajax request.
+ *
+ * Allows developers to execute an Ajax request manually without specifying
+ * an event to respond to.
+ *
+ * @return {object}
+ * Returns the jQuery.Deferred object underlying the Ajax request. If
+ * pre-serialization fails, the Deferred will be returned in the rejected
+ * state.
+ */
Drupal.Ajax.prototype.execute = function () {
+ // Do not perform another ajax command if one is already in progress.
if (this.ajaxing) {
return;
}
try {
this.beforeSerialize(this.element, this.options);
+ // Return the jqXHR so that external code can hook into the Deferred API.
return $.ajax(this.options);
} catch (e) {
+ // Unset the ajax.ajaxing flag here because it won't be unset during
+ // the complete response.
this.ajaxing = false;
- window.alert(`An error occurred while attempting to process ${this.options.url}: ${e.message}`);
+ window.alert(
+ `An error occurred while attempting to process ${this.options.url}: ${e.message}`,
+ );
+ // For consistency, return a rejected Deferred (i.e., jqXHR's superclass)
+ // so that calling code can take appropriate action.
return $.Deferred().reject();
}
};
+ /**
+ * Handle a key press.
+ *
+ * The Ajax object will, if instructed, bind to a key press response. This
+ * will test to see if the key press is valid to trigger this event and
+ * if it is, trigger it for us and prevent other keypresses from triggering.
+ * In this case we're handling RETURN and SPACEBAR keypresses (event codes 13
+ * and 32. RETURN is often used to submit a form when in a textfield, and
+ * SPACE is often used to activate an element without submitting.
+ *
+ * @param {HTMLElement} element
+ * Element the event was triggered on.
+ * @param {jQuery.Event} event
+ * Triggered event.
+ */
Drupal.Ajax.prototype.keypressResponse = function (element, event) {
+ // Create a synonym for this to reduce code confusion.
const ajax = this;
- if (event.which === 13 || event.which === 32 && element.type !== 'text' && element.type !== 'textarea' && element.type !== 'tel' && element.type !== 'number') {
+ // Detect enter key and space bar and allow the standard response for them,
+ // except for form elements of type 'text', 'tel', 'number' and 'textarea',
+ // where the spacebar activation causes inappropriate activation if
+ // #ajax['keypress'] is TRUE. On a text-type widget a space should always
+ // be a space.
+ if (
+ event.which === 13 ||
+ (event.which === 32 &&
+ element.type !== 'text' &&
+ element.type !== 'textarea' &&
+ element.type !== 'tel' &&
+ element.type !== 'number')
+ ) {
event.preventDefault();
event.stopPropagation();
$(element).trigger(ajax.elementSettings.event);
}
};
+ /**
+ * Handle an event that triggers an Ajax response.
+ *
+ * When an event that triggers an Ajax response happens, this method will
+ * perform the actual Ajax call. It is bound to the event using
+ * bind() in the constructor, and it uses the options specified on the
+ * Ajax object.
+ *
+ * @param {HTMLElement} element
+ * Element the event was triggered on.
+ * @param {jQuery.Event} event
+ * Triggered event.
+ */
Drupal.Ajax.prototype.eventResponse = function (element, event) {
event.preventDefault();
event.stopPropagation();
+
+ // Create a synonym for this to reduce code confusion.
const ajax = this;
+ // Do not perform another Ajax command if one is already in progress.
if (ajax.ajaxing) {
return;
}
try {
if (ajax.$form) {
+ // If setClick is set, we must set this to ensure that the button's
+ // value is passed.
if (ajax.setClick) {
+ // Mark the clicked button. 'form.clk' is a special variable for
+ // ajaxSubmit that tells the system which element got clicked to
+ // trigger the submit. Without it there would be no 'op' or
+ // equivalent.
element.form.clk = element;
}
@@ -336,148 +756,358 @@
$.ajax(ajax.options);
}
} catch (e) {
+ // Unset the ajax.ajaxing flag here because it won't be unset during
+ // the complete response.
ajax.ajaxing = false;
- window.alert(`An error occurred while attempting to process ${ajax.options.url}: ${e.message}`);
+ window.alert(
+ `An error occurred while attempting to process ${ajax.options.url}: ${e.message}`,
+ );
}
};
+ /**
+ * Handler for the form serialization.
+ *
+ * Runs before the beforeSend() handler (see below), and unlike that one, runs
+ * before field data is collected.
+ *
+ * @param {object} [element]
+ * Ajax object's `elementSettings`.
+ * @param {object} options
+ * jQuery.ajax options.
+ */
Drupal.Ajax.prototype.beforeSerialize = function (element, options) {
+ // Allow detaching behaviors to update field values before collecting them.
+ // This is only needed when field values are added to the POST data, so only
+ // when there is a form such that this.$form.ajaxSubmit() is used instead of
+ // $.ajax(). When there is no form and $.ajax() is used, beforeSerialize()
+ // isn't called, but don't rely on that: explicitly check this.$form.
if (this.$form && document.body.contains(this.$form.get(0))) {
const settings = this.settings || drupalSettings;
Drupal.detachBehaviors(this.$form.get(0), settings, 'serialize');
}
+ // Inform Drupal that this is an AJAX request.
options.data[Drupal.Ajax.AJAX_REQUEST_PARAMETER] = 1;
+
+ // Allow Drupal to return new JavaScript and CSS files to load without
+ // returning the ones already loaded.
+ // @see \Drupal\Core\Theme\AjaxBasePageNegotiator
+ // @see \Drupal\Core\Asset\LibraryDependencyResolverInterface::getMinimalRepresentativeSubset()
+ // @see system_js_settings_alter()
const pageState = drupalSettings.ajaxPageState;
options.data['ajax_page_state[theme]'] = pageState.theme;
options.data['ajax_page_state[theme_token]'] = pageState.theme_token;
options.data['ajax_page_state[libraries]'] = pageState.libraries;
};
- Drupal.Ajax.prototype.beforeSubmit = function (formValues, element, options) {};
+ /**
+ * Modify form values prior to form submission.
+ *
+ * @param {Array.<object>} formValues
+ * Processed form values.
+ * @param {jQuery} element
+ * The form node as a jQuery object.
+ * @param {object} options
+ * jQuery.ajax options.
+ */
+ Drupal.Ajax.prototype.beforeSubmit = function (formValues, element, options) {
+ // This function is left empty to make it simple to override for modules
+ // that wish to add functionality here.
+ };
+ /**
+ * Prepare the Ajax request before it is sent.
+ *
+ * @param {XMLHttpRequest} xmlhttprequest
+ * Native Ajax object.
+ * @param {object} options
+ * jQuery.ajax options.
+ */
Drupal.Ajax.prototype.beforeSend = function (xmlhttprequest, options) {
+ // For forms without file inputs, the jQuery Form plugin serializes the
+ // form values, and then calls jQuery's $.ajax() function, which invokes
+ // this handler. In this circumstance, options.extraData is never used. For
+ // forms with file inputs, the jQuery Form plugin uses the browser's normal
+ // form submission mechanism, but captures the response in a hidden IFRAME.
+ // In this circumstance, it calls this handler first, and then appends
+ // hidden fields to the form to submit the values in options.extraData.
+ // There is no simple way to know which submission mechanism will be used,
+ // so we add to extraData regardless, and allow it to be ignored in the
+ // former case.
if (this.$form) {
options.extraData = options.extraData || {};
+
+ // Let the server know when the IFRAME submission mechanism is used. The
+ // server can use this information to wrap the JSON response in a
+ // TEXTAREA, as per http://jquery.malsup.com/form/#file-upload.
options.extraData.ajax_iframe_upload = '1';
- const v = $.fieldValue(this.element);
+ // The triggering element is about to be disabled (see below), but if it
+ // contains a value (e.g., a checkbox, textfield, select, etc.), ensure
+ // that value is included in the submission. As per above, submissions
+ // that use $.ajax() are already serialized prior to the element being
+ // disabled, so this is only needed for IFRAME submissions.
+ const v = $.fieldValue(this.element);
if (v !== null) {
options.extraData[this.element.name] = v;
}
}
+ // Disable the element that received the change to prevent user interface
+ // interaction while the Ajax request is in progress. ajax.ajaxing prevents
+ // the element from triggering a new request, but does not prevent the user
+ // from changing its value.
$(this.element).prop('disabled', true);
if (!this.progress || !this.progress.type) {
return;
}
- const progressIndicatorMethod = `setProgressIndicator${this.progress.type.slice(0, 1).toUpperCase()}${this.progress.type.slice(1).toLowerCase()}`;
-
- if (progressIndicatorMethod in this && typeof this[progressIndicatorMethod] === 'function') {
+ // Insert progress indicator.
+ const progressIndicatorMethod = `setProgressIndicator${this.progress.type
+ .slice(0, 1)
+ .toUpperCase()}${this.progress.type.slice(1).toLowerCase()}`;
+ if (
+ progressIndicatorMethod in this &&
+ typeof this[progressIndicatorMethod] === 'function'
+ ) {
this[progressIndicatorMethod].call(this);
}
};
- Drupal.theme.ajaxProgressThrobber = message => {
- const messageMarkup = typeof message === 'string' ? Drupal.theme('ajaxProgressMessage', message) : '';
+ /**
+ * An animated progress throbber and container element for AJAX operations.
+ *
+ * @param {string} [message]
+ * (optional) The message shown on the UI.
+ * @return {string}
+ * The HTML markup for the throbber.
+ */
+ Drupal.theme.ajaxProgressThrobber = (message) => {
+ // Build markup without adding extra white space since it affects rendering.
+ const messageMarkup =
+ typeof message === 'string'
+ ? Drupal.theme('ajaxProgressMessage', message)
+ : '';
const throbber = '<div class="throbber">&nbsp;</div>';
+
return `<div class="ajax-progress ajax-progress-throbber">${throbber}${messageMarkup}</div>`;
};
- Drupal.theme.ajaxProgressIndicatorFullscreen = () => '<div class="ajax-progress ajax-progress-fullscreen">&nbsp;</div>';
-
- Drupal.theme.ajaxProgressMessage = message => `<div class="message">${message}</div>`;
-
- Drupal.theme.ajaxProgressBar = $element => $('<div class="ajax-progress ajax-progress-bar"></div>').append($element);
-
+ /**
+ * An animated progress throbber and container element for AJAX operations.
+ *
+ * @return {string}
+ * The HTML markup for the throbber.
+ */
+ Drupal.theme.ajaxProgressIndicatorFullscreen = () =>
+ '<div class="ajax-progress ajax-progress-fullscreen">&nbsp;</div>';
+
+ /**
+ * Formats text accompanying the AJAX progress throbber.
+ *
+ * @param {string} message
+ * The message shown on the UI.
+ * @return {string}
+ * The HTML markup for the throbber.
+ */
+ Drupal.theme.ajaxProgressMessage = (message) =>
+ `<div class="message">${message}</div>`;
+
+ /**
+ * Provide a wrapper for the AJAX progress bar element.
+ *
+ * @param {jQuery} $element
+ * Progress bar element.
+ * @return {string}
+ * The HTML markup for the progress bar.
+ */
+ Drupal.theme.ajaxProgressBar = ($element) =>
+ $('<div class="ajax-progress ajax-progress-bar"></div>').append($element);
+
+ /**
+ * Sets the progress bar progress indicator.
+ */
Drupal.Ajax.prototype.setProgressIndicatorBar = function () {
- const progressBar = new Drupal.ProgressBar(`ajax-progress-${this.element.id}`, $.noop, this.progress.method, $.noop);
-
+ const progressBar = new Drupal.ProgressBar(
+ `ajax-progress-${this.element.id}`,
+ $.noop,
+ this.progress.method,
+ $.noop,
+ );
if (this.progress.message) {
progressBar.setProgress(-1, this.progress.message);
}
-
if (this.progress.url) {
- progressBar.startMonitoring(this.progress.url, this.progress.interval || 1500);
+ progressBar.startMonitoring(
+ this.progress.url,
+ this.progress.interval || 1500,
+ );
}
-
- this.progress.element = $(Drupal.theme('ajaxProgressBar', progressBar.element));
+ this.progress.element = $(
+ Drupal.theme('ajaxProgressBar', progressBar.element),
+ );
this.progress.object = progressBar;
$(this.element).after(this.progress.element);
};
+ /**
+ * Sets the throbber progress indicator.
+ */
Drupal.Ajax.prototype.setProgressIndicatorThrobber = function () {
- this.progress.element = $(Drupal.theme('ajaxProgressThrobber', this.progress.message));
+ this.progress.element = $(
+ Drupal.theme('ajaxProgressThrobber', this.progress.message),
+ );
$(this.element).after(this.progress.element);
};
+ /**
+ * Sets the fullscreen progress indicator.
+ */
Drupal.Ajax.prototype.setProgressIndicatorFullscreen = function () {
this.progress.element = $(Drupal.theme('ajaxProgressIndicatorFullscreen'));
$('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((executionQueue, key) => executionQueue.then(() => {
- const {
- command
- } = response[key];
-
- if (command && ajaxCommands[command]) {
- return ajaxCommands[command](this, response[key], status);
- }
- }), Promise.resolve());
+ 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.
+ *
+ * @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.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);
- const elementParents = $(this.element).parents('[data-drupal-selector]').addBack().toArray();
- 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;
- 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 (this.$form && document.body.contains(this.$form.get(0))) {
- const settings = this.settings || drupalSettings;
- Drupal.attachBehaviors(this.$form.get(0), settings);
- }
+ // Save element's ancestors tree so if the element is removed from the dom
+ // we can try to refocus one of its parents. Using addBack reverse the
+ // result array, meaning that index 0 is the highest parent in the hierarchy
+ // in this situation it is usually a <form> element.
+ const elementParents = $(this.element)
+ .parents('[data-drupal-selector]')
+ .addBack()
+ .toArray();
+
+ // Track if any command is altering the focus so we can avoid changing the
+ // focus set by the Ajax command.
+ const focusChanged = Object.keys(response || {}).some((key) => {
+ const { command, method } = response[key];
+ return (
+ command === 'focusFirst' || (command === 'invoke' && method === 'focus')
+ );
+ });
- this.settings = null;
- }).catch(error => console.error(Drupal.t('An error occurred during the execution of the Ajax response: !error', {
- '!error': error
- })));
+ 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,
+ },
+ ),
+ ),
+ )
+ );
};
+ /**
+ * Build an effect object to apply an effect when adding new HTML.
+ *
+ * @param {object} response
+ * Drupal Ajax response.
+ * @param {string} [response.effect]
+ * Override the default value of {@link Drupal.Ajax#elementSettings}.
+ * @param {string|number} [response.speed]
+ * Override the default value of {@link Drupal.Ajax#elementSettings}.
+ *
+ * @return {object}
+ * Returns an object with `showEffect`, `hideEffect` and `showSpeed`
+ * properties.
+ */
Drupal.Ajax.prototype.getEffect = function (response) {
const type = response.effect || this.effect;
const speed = response.speed || this.speed;
- const effect = {};
+ const effect = {};
if (type === 'none') {
effect.showEffect = 'show';
effect.hideEffect = 'hide';
@@ -495,41 +1125,164 @@
return effect;
};
+ /**
+ * Handler for the form redirection error.
+ *
+ * @param {object} xmlhttprequest
+ * Native XMLHttpRequest object.
+ * @param {string} uri
+ * Ajax Request URI.
+ * @param {string} [customMessage]
+ * Extra message to print with the Ajax error.
+ */
Drupal.Ajax.prototype.error = function (xmlhttprequest, uri, customMessage) {
+ // Remove the progress element.
if (this.progress.element) {
$(this.progress.element).remove();
}
-
if (this.progress.object) {
this.progress.object.stopMonitoring();
}
-
+ // Undo hide.
$(this.wrapper).show();
+ // Re-enable the element.
$(this.element).prop('disabled', false);
-
+ // Reattach behaviors, if they were detached in beforeSerialize(), and the
+ // form is still part of the document.
if (this.$form && document.body.contains(this.$form.get(0))) {
const settings = this.settings || drupalSettings;
Drupal.attachBehaviors(this.$form.get(0), settings);
}
-
throw new Drupal.AjaxError(xmlhttprequest, uri, customMessage);
};
- Drupal.theme.ajaxWrapperNewContent = ($newContent, ajax, response) => (response.effect || ajax.effect) !== 'none' && $newContent.filter(i => !($newContent[i].nodeName === '#comment' || $newContent[i].nodeName === '#text' && /^(\s|\n|\r)*$/.test($newContent[i].textContent))).length > 1 ? Drupal.theme('ajaxWrapperMultipleRootElements', $newContent) : $newContent;
-
- Drupal.theme.ajaxWrapperMultipleRootElements = $elements => $('<div></div>').append($elements);
-
+ /**
+ * Provide a wrapper for new content via Ajax.
+ *
+ * Wrap the inserted markup when inserting multiple root elements with an
+ * ajax effect.
+ *
+ * @param {jQuery} $newContent
+ * Response elements after parsing.
+ * @param {Drupal.Ajax} ajax
+ * {@link Drupal.Ajax} object created by {@link Drupal.ajax}.
+ * @param {object} response
+ * The response from the Ajax request.
+ *
+ * @deprecated in drupal:8.6.0 and is removed from drupal:10.0.0.
+ * Use data with desired wrapper.
+ *
+ * @see https://www.drupal.org/node/2940704
+ *
+ * @todo Add deprecation warning after it is possible. For more information
+ * see: https://www.drupal.org/project/drupal/issues/2973400
+ */
+ Drupal.theme.ajaxWrapperNewContent = ($newContent, ajax, response) =>
+ (response.effect || ajax.effect) !== 'none' &&
+ $newContent.filter(
+ (i) =>
+ !(
+ // We can not consider HTML comments or whitespace text as separate
+ // roots, since they do not cause visual regression with effect.
+ (
+ $newContent[i].nodeName === '#comment' ||
+ ($newContent[i].nodeName === '#text' &&
+ /^(\s|\n|\r)*$/.test($newContent[i].textContent))
+ )
+ ),
+ ).length > 1
+ ? Drupal.theme('ajaxWrapperMultipleRootElements', $newContent)
+ : $newContent;
+
+ /**
+ * Provide a wrapper for multiple root elements via Ajax.
+ *
+ * @param {jQuery} $elements
+ * Response elements after parsing.
+ *
+ * @deprecated in drupal:8.6.0 and is removed from drupal:10.0.0.
+ * Use data with desired wrapper.
+ *
+ * @see https://www.drupal.org/node/2940704
+ *
+ * @todo Add deprecation warning after it is possible. For more information
+ * see: https://www.drupal.org/project/drupal/issues/2973400
+ */
+ Drupal.theme.ajaxWrapperMultipleRootElements = ($elements) =>
+ $('<div></div>').append($elements);
+
+ /**
+ * @typedef {object} Drupal.AjaxCommands~commandDefinition
+ *
+ * @prop {string} command
+ * @prop {string} [method]
+ * @prop {string} [selector]
+ * @prop {string} [data]
+ * @prop {object} [settings]
+ * @prop {bool} [asterisk]
+ * @prop {string} [text]
+ * @prop {string} [title]
+ * @prop {string} [url]
+ * @prop {object} [argument]
+ * @prop {string} [name]
+ * @prop {string} [value]
+ * @prop {string} [old]
+ * @prop {string} [new]
+ * @prop {bool} [merge]
+ * @prop {Array} [args]
+ *
+ * @see Drupal.AjaxCommands
+ */
+
+ /**
+ * Provide a series of commands that the client will perform.
+ *
+ * @constructor
+ */
Drupal.AjaxCommands = function () {};
-
Drupal.AjaxCommands.prototype = {
+ /**
+ * Command to insert new content into the DOM.
+ *
+ * @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.data
+ * The data to use with the jQuery method.
+ * @param {string} [response.method]
+ * The jQuery DOM manipulation method to be used.
+ * @param {string} [response.selector]
+ * An optional jQuery selector string.
+ * @param {object} [response.settings]
+ * An optional array of settings that will be used.
+ */
insert(ajax, response) {
- const $wrapper = response.selector ? $(response.selector) : $(ajax.wrapper);
+ // Get information from the response. If it is not there, default to
+ // our presets.
+ const $wrapper = response.selector
+ ? $(response.selector)
+ : $(ajax.wrapper);
const method = response.method || ajax.method;
const effect = ajax.getEffect(response);
+
+ // Apply any settings from the returned JSON if available.
const settings = response.settings || ajax.settings || drupalSettings;
- let $newContent = $($.parseHTML(response.data, document, true));
- $newContent = Drupal.theme('ajaxWrapperNewContent', $newContent, ajax, response);
+ // Parse response.data into an element collection.
+ let $newContent = $($.parseHTML(response.data, document, true));
+ // For backward compatibility, in some cases a wrapper will be added. This
+ // behavior will be removed before Drupal 9.0.0. If different behavior is
+ // needed, the theme functions can be overridden.
+ // @see https://www.drupal.org/node/2940704
+ $newContent = Drupal.theme(
+ 'ajaxWrapperNewContent',
+ $newContent,
+ ajax,
+ response,
+ );
+
+ // If removing content from the wrapper, detach behaviors first.
switch (method) {
case 'html':
case 'replaceWith':
@@ -538,19 +1291,21 @@
case 'remove':
Drupal.detachBehaviors($wrapper.get(0), settings);
break;
-
default:
break;
}
+ // Add the new content to the page.
$wrapper[method]($newContent);
+ // Immediately hide the new content if we're using any effects.
if (effect.showEffect !== 'show') {
$newContent.hide();
}
+ // Determine which effect to use and what content will receive the
+ // effect, then show the new content.
const $ajaxNewContent = $newContent.find('.ajax-new-content');
-
if ($ajaxNewContent.length) {
$ajaxNewContent.hide();
$newContent.show();
@@ -559,7 +1314,11 @@
$newContent[effect.showEffect](effect.showSpeed);
}
+ // Attach all JavaScript behaviors to the new content, if it was
+ // successfully added to the page, this if statement allows
+ // `#ajax['wrapper']` to be optional.
if ($newContent.parents('html').length) {
+ // Attach behaviors to all element nodes.
$newContent.each((index, element) => {
if (element.nodeType === Node.ELEMENT_NODE) {
Drupal.attachBehaviors(element, settings);
@@ -568,29 +1327,88 @@
}
},
+ /**
+ * Command to remove a chunk from the page.
+ *
+ * @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.
+ * @param {object} [response.settings]
+ * An optional array of settings that will be used.
+ * @param {number} [status]
+ * The XMLHttpRequest status.
+ */
remove(ajax, response, status) {
const settings = response.settings || ajax.settings || drupalSettings;
- $(response.selector).each(function () {
- Drupal.detachBehaviors(this, settings);
- }).remove();
+ $(response.selector)
+ .each(function () {
+ Drupal.detachBehaviors(this, settings);
+ })
+ .remove();
},
+ /**
+ * Command to mark a chunk changed.
+ *
+ * @param {Drupal.Ajax} [ajax]
+ * {@link Drupal.Ajax} object created by {@link Drupal.ajax}.
+ * @param {object} response
+ * The JSON response object from the Ajax request.
+ * @param {string} response.selector
+ * A jQuery selector string.
+ * @param {bool} [response.asterisk]
+ * An optional CSS selector. If specified, an asterisk will be
+ * appended to the HTML inside the provided selector.
+ * @param {number} [status]
+ * The request status.
+ */
changed(ajax, response, status) {
const $element = $(response.selector);
-
if (!$element.hasClass('ajax-changed')) {
$element.addClass('ajax-changed');
-
if (response.asterisk) {
- $element.find(response.asterisk).append(` <abbr class="ajax-changed" title="${Drupal.t('Changed')}">*</abbr> `);
+ $element
+ .find(response.asterisk)
+ .append(
+ ` <abbr class="ajax-changed" title="${Drupal.t(
+ 'Changed',
+ )}">*</abbr> `,
+ );
}
}
},
+ /**
+ * Command to provide an alert.
+ *
+ * @param {Drupal.Ajax} [ajax]
+ * {@link Drupal.Ajax} object created by {@link Drupal.ajax}.
+ * @param {object} response
+ * The JSON response from the Ajax request.
+ * @param {string} response.text
+ * The text that will be displayed in an alert dialog.
+ * @param {number} [status]
+ * The XMLHttpRequest status.
+ */
alert(ajax, response, status) {
window.alert(response.text);
},
+ /**
+ * Command to provide triggers audio UAs to read the supplied text.
+ *
+ * @param {Drupal.Ajax} [ajax]
+ * {@link Drupal.Ajax} object created by {@link Drupal.ajax}.
+ * @param {object} response
+ * The JSON response from the Ajax request.
+ * @param {string} [response.text]
+ * The text that will be read.
+ * @param {string} [response.priority]
+ * An optional priority that will be used for the announcement.
+ */
announce(ajax, response) {
if (response.priority) {
Drupal.announce(response.text, response.priority);
@@ -599,22 +1417,70 @@
}
},
+ /**
+ * Command to set the window.location, redirecting the browser.
+ *
+ * @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.url
+ * The URL to redirect to.
+ * @param {number} [status]
+ * The XMLHttpRequest status.
+ */
redirect(ajax, response, status) {
window.location = response.url;
},
+ /**
+ * Command to provide the jQuery css() function.
+ *
+ * @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.
+ * @param {object} response.argument
+ * An array of key/value pairs to set in the CSS for the selector.
+ * @param {number} [status]
+ * The XMLHttpRequest status.
+ */
css(ajax, response, status) {
$(response.selector).css(response.argument);
},
+ /**
+ * Command to set the settings used for other commands in this response.
+ *
+ * This method will also remove expired `drupalSettings.ajax` settings.
+ *
+ * @param {Drupal.Ajax} [ajax]
+ * {@link Drupal.Ajax} object created by {@link Drupal.ajax}.
+ * @param {object} response
+ * The response from the Ajax request.
+ * @param {bool} response.merge
+ * Determines whether the additional settings should be merged to the
+ * global settings.
+ * @param {object} response.settings
+ * Contains additional settings to add to the global settings.
+ * @param {number} [status]
+ * The XMLHttpRequest status.
+ */
settings(ajax, response, status) {
const ajaxSettings = drupalSettings.ajax;
+ // Clean up drupalSettings.ajax.
if (ajaxSettings) {
- Drupal.ajax.expired().forEach(instance => {
+ Drupal.ajax.expired().forEach((instance) => {
+ // If the Ajax object has been created through drupalSettings.ajax
+ // it will have a selector. When there is no selector the object
+ // has been initialized with a special class name picked up by the
+ // Ajax behavior.
+
if (instance.selector) {
const selector = instance.selector.replace('#', '');
-
if (selector in ajaxSettings) {
delete ajaxSettings[selector];
}
@@ -629,96 +1495,246 @@
}
},
+ /**
+ * Command to attach data using jQuery's data API.
+ *
+ * @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.name
+ * The name or key (in the key value pair) of the data attached to this
+ * selector.
+ * @param {string} response.selector
+ * A jQuery selector string.
+ * @param {string|object} response.value
+ * The value of to be attached.
+ * @param {number} [status]
+ * The XMLHttpRequest status.
+ */
data(ajax, response, status) {
$(response.selector).data(response.name, response.value);
},
+ /**
+ * Command to focus the first tabbable element within a container.
+ *
+ * If no tabbable elements are found and the container is focusable, then
+ * focus will move to that container.
+ *
+ * @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 query selector string of the container to focus within.
+ * @param {number} [status]
+ * The XMLHttpRequest status.
+ */
focusFirst(ajax, response, status) {
let focusChanged = false;
const container = document.querySelector(response.selector);
-
if (container) {
+ // Find all tabbable elements within the container.
const tabbableElements = tabbable(container);
+ // Move focus to the first tabbable item found.
if (tabbableElements.length) {
tabbableElements[0].focus();
focusChanged = true;
} else if (isFocusable(container)) {
+ // If no tabbable elements are found, but the container is focusable,
+ // move focus to the container.
container.focus();
focusChanged = true;
}
}
+ // If no items were available to receive focus, return focus to the
+ // triggering element.
if (ajax.hasOwnProperty('element') && !focusChanged) {
ajax.element.focus();
}
},
+ /**
+ * Command to apply a jQuery method.
+ *
+ * @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.args
+ * An array of arguments to the jQuery method, if any.
+ * @param {string} response.method
+ * The jQuery method to invoke.
+ * @param {string} response.selector
+ * A jQuery selector string.
+ * @param {number} [status]
+ * The XMLHttpRequest status.
+ */
invoke(ajax, response, status) {
const $element = $(response.selector);
$element[response.method](...response.args);
},
+ /**
+ * Command to restripe a table.
+ *
+ * @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.
+ * @param {number} [status]
+ * The XMLHttpRequest status.
+ */
restripe(ajax, response, status) {
- $(response.selector).find('> tbody > tr:visible, > tr:visible').removeClass('odd even').filter(':even').addClass('odd').end().filter(':odd').addClass('even');
+ // :even and :odd are reversed because jQuery counts from 0 and
+ // we count from 1, so we're out of sync.
+ // Match immediate children of the parent element to allow nesting.
+ $(response.selector)
+ .find('> tbody > tr:visible, > tr:visible')
+ .removeClass('odd even')
+ .filter(':even')
+ .addClass('odd')
+ .end()
+ .filter(':odd')
+ .addClass('even');
},
+ /**
+ * Command to update a form's build ID.
+ *
+ * @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.old
+ * The old form build ID.
+ * @param {string} response.new
+ * The new form build ID.
+ * @param {number} [status]
+ * The XMLHttpRequest status.
+ */
update_build_id(ajax, response, status) {
- document.querySelectorAll(`input[name="form_build_id"][value="${response.old}"]`).forEach(item => {
- item.value = response.new;
- });
+ document
+ .querySelectorAll(
+ `input[name="form_build_id"][value="${response.old}"]`,
+ )
+ .forEach((item) => {
+ item.value = response.new;
+ });
},
+ /**
+ * Command to add css.
+ *
+ * @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.data
+ * A string that contains the styles to be added.
+ * @param {number} [status]
+ * The XMLHttpRequest status.
+ */
add_css(ajax, response, status) {
$('head').prepend(response.data);
},
+ /**
+ * 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));
-
+ const messages = new Drupal.Message(
+ document.querySelector(response.messageWrapperQuerySelector),
+ );
if (response.clearPrevious) {
messages.clear();
}
-
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 => {
+ 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) {
- Object.keys(script).forEach(attributeKey => {
+ // 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(', ')
- });
+ const message = Drupal.t(
+ `The following files could not be loaded: @dependencies`,
+ { '@dependencies': depsNotFound.join(', ') },
+ );
reject(message);
- }
-
+ },
});
});
- }
-
+ },
};
-})(jQuery, window, Drupal, drupalSettings, loadjs, window.tabbable); \ No newline at end of file
+})(jQuery, window, Drupal, drupalSettings, loadjs, window.tabbable);