X-Git-Url: http://www.aleph1.co.uk/gitweb/?a=blobdiff_plain;f=web%2Fcore%2Fmisc%2Fajax.es6.js;fp=web%2Fcore%2Fmisc%2Fajax.es6.js;h=6248e46efe6eb6f8c4e46ec0cbdae245c34ed67f;hb=9917807b03b64faf00f6a1f29dcb6eafc454efa5;hp=0000000000000000000000000000000000000000;hpb=aea91e65e895364e460983b890e295aa5d5540a5;p=yaffs-website diff --git a/web/core/misc/ajax.es6.js b/web/core/misc/ajax.es6.js new file mode 100644 index 000000000..6248e46ef --- /dev/null +++ b/web/core/misc/ajax.es6.js @@ -0,0 +1,1341 @@ +/** + * @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) { + /** + * 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 element_settings = settings.ajax[base]; + if (typeof element_settings.selector === 'undefined') { + element_settings.selector = `#${base}`; + } + $(element_settings.selector).once('drupal-ajax').each(function () { + element_settings.element = this; + element_settings.base = base; + Drupal.ajax(element_settings); + }); + } + + // Load all Ajax behaviors specified in the settings. + for (const base in settings.ajax) { + if (settings.ajax.hasOwnProperty(base)) { + loadAjaxBehavior(base); + } + } + + // Bind Ajax behaviors to all items showing the class. + $('.use-ajax').once('ajax').each(function () { + const element_settings = {}; + // Clicked links look better with the throbber than the progress bar. + element_settings.progress = { type: 'throbber' }; + + // For anchor tags, these will go to the target of the anchor rather + // than the usual location. + const href = $(this).attr('href'); + if (href) { + element_settings.url = href; + element_settings.event = 'click'; + } + element_settings.dialogType = $(this).data('dialog-type'); + element_settings.dialogRenderer = $(this).data('dialog-renderer'); + element_settings.dialog = $(this).data('dialog-options'); + element_settings.base = $(this).attr('id'); + element_settings.element = this; + Drupal.ajax(element_settings); + }); + + // This class means to submit the form to the action using Ajax. + $('.use-ajax-submit').once('ajax').each(function () { + const element_settings = {}; + + // Ajax submits specified in this manner automatically submit to the + // normal form action. + element_settings.url = $(this.form).attr('action'); + // Form submit button clicks need to tell the form what was clicked so + // it gets passed in the POST request. + element_settings.setClick = true; + // Form buttons use the 'click' event rather than mousedown. + element_settings.event = 'click'; + // Clicked form buttons look better with the throbber than the progress + // bar. + element_settings.progress = { type: 'throbber' }; + element_settings.base = $(this).attr('id'); + element_settings.element = this; + + Drupal.ajax(element_settings); + }); + }, + + detach(context, settings, trigger) { + if (trigger === 'unload') { + 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 pathText; + let responseText; + let readyStateText; + if (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('Debugging information follows.')}`; + 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': $.trim(xmlhttp.statusText) })}`; + } + 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': $.trim(xmlhttp.responseText) })}`; + } + 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'); + + // We don't need readyState except for status == 0. + 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'); + } + // 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; + } + + 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.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.} + * 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)); + }; + + /** + * Settings for an Ajax object. + * + * @typedef {object} Drupal.Ajax~element_settings + * + * @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~element_settings} element_settings + * Settings for this Ajax object. + */ + Drupal.Ajax = function (base, element, element_settings) { + const defaults = { + event: element ? 'mousedown' : null, + keypress: true, + selector: base ? `#${base}` : null, + effect: 'none', + speed: 'none', + method: 'replaceWith', + progress: { + type: 'throbber', + message: Drupal.t('Please wait...'), + }, + submit: { + js: true, + }, + }; + + $.extend(this, defaults, element_settings); + + /** + * @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~element_settings} + */ + this.element_settings = element_settings; + + // 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) { + this.url = this.$form.attr('action'); + } + } + + // 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#myfragment). + const originalUrl = this.url; + + /** + * Processed Ajax URL. + * + * @type {string} + */ + this.url = this.url.replace(/\/nojs(\/|$|\?|#)/g, '/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(element_settings, options) { + return ajax.beforeSerialize(element_settings, options); + }, + beforeSubmit(form_values, element_settings, options) { + ajax.ajaxing = true; + return ajax.beforeSubmit(form_values, element_settings, 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.'); + return ajax.error(xmlhttprequest, ajax.url, customMessage); + } + } + + return ajax.success(response, status); + }, + complete(xmlhttprequest, status) { + ajax.ajaxing = false; + if (status === 'error' || status === 'parsererror') { + return ajax.error(xmlhttprequest, ajax.url); + } + }, + dataType: 'json', + type: 'POST', + }; + + if (element_settings.dialog) { + ajax.options.data.dialogOptions = element_settings.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_${(element_settings.dialogType || 'ajax')}`; + if (element_settings.dialogRenderer) { + wrapper += `.${element_settings.dialogRenderer}`; + } + ajax.options.url += `${Drupal.ajax.WRAPPER_FORMAT}=${wrapper}`; + + + // Bind the ajaxSubmit function to the element event. + $(ajax.element).on(element_settings.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 })); + } + 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 (element_settings.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 (element_settings.prevent) { + $(ajax.element).on(element_settings.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}`); + // 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; + + // 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.element_settings.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; + } + + ajax.$form.ajaxSubmit(ajax.options); + } + else { + ajax.beforeSerialize(ajax.element, ajax.options); + $.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}`); + } + }; + + /** + * 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 `element_settings`. + * @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) { + 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; + }; + + /** + * Modify form values prior to form submission. + * + * @param {Array.} form_values + * Processed form values. + * @param {jQuery} element + * The form node as a jQuery object. + * @param {object} options + * jQuery.ajax options. + */ + Drupal.Ajax.prototype.beforeSubmit = function (form_values, 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'; + + // 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; + } + + // 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); + } + }; + + /** + * 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); + if (this.progress.message) { + progressBar.setProgress(-1, this.progress.message); + } + if (this.progress.url) { + progressBar.startMonitoring(this.progress.url, this.progress.interval || 1500); + } + this.progress.element = $(progressBar.element).addClass('ajax-progress ajax-progress-bar'); + this.progress.object = progressBar; + $(this.element).after(this.progress.element); + }; + + /** + * Sets the throbber progress indicator. + */ + Drupal.Ajax.prototype.setProgressIndicatorThrobber = function () { + this.progress.element = $('
 
'); + if (this.progress.message) { + this.progress.element.find('.throbber').after(`
${this.progress.message}
`); + } + $(this.element).after(this.progress.element); + }; + + /** + * Sets the fullscreen progress indicator. + */ + Drupal.Ajax.prototype.setProgressIndicatorFullscreen = function () { + this.progress.element = $('
 
'); + $('body').after(this.progress.element); + }; + + /** + * Handler for the form redirection completion. + * + * @param {Array.} response + * Drupal Ajax response. + * @param {number} status + * XMLHttpRequest status. + */ + 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); + + // 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
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. + let focusChanged = false; + for (const i in response) { + if (response.hasOwnProperty(i) && response[i].command && this.commands[response[i].command]) { + this.commands[response[i].command](this, response[i], status); + if (response[i].command === 'invoke' && response[i].method === 'focus') { + focusChanged = true; + } + } + } + + // If the focus hasn't be changed by the ajax commands, try to refocus the + // triggering element or one of its parents if that element does not exist + // anymore. + if (!focusChanged && this.element && !$(this.element).data('disable-refocus')) { + let target = false; + + for (let n = elementParents.length - 1; !target && n > 0; n--) { + target = document.querySelector(`[data-drupal-selector="${elementParents[n].getAttribute('data-drupal-selector')}"]`); + } + + if (target) { + $(target).trigger('focus'); + } + } + + // Reattach behaviors, if they were detached in beforeSerialize(). The + // attachBehaviors() called on the new content from processing the response + // commands is not sufficient, because behaviors from the entire form need + // to be reattached. + if (this.$form) { + 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; + }; + + /** + * 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#element_settings}. + * @param {string|number} [response.speed] + * Override the default value of {@link Drupal.Ajax#element_settings}. + * + * @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 = {}; + if (type === 'none') { + effect.showEffect = 'show'; + effect.hideEffect = 'hide'; + effect.showSpeed = ''; + } + else if (type === 'fade') { + effect.showEffect = 'fadeIn'; + effect.hideEffect = 'fadeOut'; + effect.showSpeed = speed; + } + else { + effect.showEffect = `${type}Toggle`; + effect.hideEffect = `${type}Toggle`; + effect.showSpeed = speed; + } + + 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(). + if (this.$form) { + const settings = this.settings || drupalSettings; + Drupal.attachBehaviors(this.$form.get(0), settings); + } + throw new Drupal.AjaxError(xmlhttprequest, uri, customMessage); + }; + + /** + * @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] + * A optional jQuery selector string. + * @param {object} [response.settings] + * An optional array of settings that will be used. + * @param {number} [status] + * The XMLHttpRequest status. + */ + insert(ajax, response, status) { + // 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); + let settings; + + // We don't know what response.data contains: it might be a string of text + // without HTML, so don't rely on jQuery correctly interpreting + // $(response.data) as new HTML rather than a CSS selector. Also, if + // response.data contains top-level text nodes, they get lost with either + // $(response.data) or $('
').replaceWith(response.data). + const $new_content_wrapped = $('
').html(response.data); + let $new_content = $new_content_wrapped.contents(); + + // For legacy reasons, the effects processing code assumes that + // $new_content consists of a single top-level element. Also, it has not + // been sufficiently tested whether attachBehaviors() can be successfully + // called with a context object that includes top-level text nodes. + // However, to give developers full control of the HTML appearing in the + // page, and to enable Ajax content to be inserted in places where
+ // elements are not allowed (e.g., within , , and + // parents), we check if the new content satisfies the requirement + // of a single top-level element, and only use the container
created + // above when it doesn't. For more information, please see + // https://www.drupal.org/node/736066. + if ($new_content.length !== 1 || $new_content.get(0).nodeType !== 1) { + $new_content = $new_content_wrapped; + } + + // If removing content from the wrapper, detach behaviors first. + switch (method) { + case 'html': + case 'replaceWith': + case 'replaceAll': + case 'empty': + case 'remove': + settings = response.settings || ajax.settings || drupalSettings; + Drupal.detachBehaviors($wrapper.get(0), settings); + } + + // Add the new content to the page. + $wrapper[method]($new_content); + + // Immediately hide the new content if we're using any effects. + if (effect.showEffect !== 'show') { + $new_content.hide(); + } + + // Determine which effect to use and what content will receive the + // effect, then show the new content. + if ($new_content.find('.ajax-new-content').length > 0) { + $new_content.find('.ajax-new-content').hide(); + $new_content.show(); + $new_content.find('.ajax-new-content')[effect.showEffect](effect.showSpeed); + } + else if (effect.showEffect !== 'show') { + $new_content[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 ($new_content.parents('html').length > 0) { + // Apply any settings from the returned JSON if available. + settings = response.settings || ajax.settings || drupalSettings; + Drupal.attachBehaviors($new_content.get(0), settings); + } + }, + + /** + * 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(); + }, + + /** + * 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(` * `); + } + } + }, + + /** + * 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, response.title); + }, + + /** + * 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) => { + // 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]; + } + } + }); + } + + if (response.merge) { + $.extend(true, drupalSettings, response.settings); + } + else { + ajax.settings = response.settings; + } + }, + + /** + * 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 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) { + // :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) { + $(`input[name="form_build_id"][value="${response.old}"]`).val(response.new); + }, + + /** + * Command to add css. + * + * Uses the proprietary addImport method if available as browsers which + * support that method ignore @import statements in dynamically added + * stylesheets. + * + * @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) { + // Add the styles in the normal way. + $('head').prepend(response.data); + // Add imports in the styles using the addImport method if available. + let match; + const importMatch = /^@import url\("(.*)"\);$/igm; + if (document.styleSheets[0].addImport && importMatch.test(response.data)) { + importMatch.lastIndex = 0; + do { + match = importMatch.exec(response.data); + document.styleSheets[0].addImport(match[1]); + } while (match); + } + }, + }; +}(jQuery, window, Drupal, drupalSettings));