diff options
Diffstat (limited to 'core/misc/ajax.js')
-rw-r--r-- | core/misc/ajax.js | 1390 |
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"> </div>'; + return `<div class="ajax-progress ajax-progress-throbber">${throbber}${messageMarkup}</div>`; }; - Drupal.theme.ajaxProgressIndicatorFullscreen = () => '<div class="ajax-progress ajax-progress-fullscreen"> </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"> </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); |