PHP Classes

File: modules/system/assets/js/snowboard/ajax/Request.js

Recommend this page to a friend!
  Packages of Luke Towers   Winter   modules/system/assets/js/snowboard/ajax/Request.js   Download  
File: modules/system/assets/js/snowboard/ajax/Request.js
Role: Auxiliary data
Content type: text/plain
Description: Auxiliary data
Class: Winter
Content management system that uses MVC
Author: By
Last change:
Date: 7 months ago
Size: 30,274 bytes
 

Contents

Class file image Download
import PluginBase from '../abstracts/PluginBase'; /** * Request plugin. * * This is the default AJAX handler which will run using the `fetch()` method that is default in modern browsers. * * @copyright 2021 Winter. * @author Ben Thomson <git@alfreido.com> */ export default class Request extends PluginBase { /** * Constructor. * * The constructor accepts 2 or 3 parameters. * * If 2 parameters are provided, the first parameter is the handler name and the second * parameter is the options. This assumes that this is a detached AJAX request not connected to * an element. * * If 3 parameters are provided, the first parameter is an element or a selector, and the second * and third parameters are the handler and options, respectively. * * @param {HTMLElement|string} element * @param {string|Object} handler * @param {Object} options */ construct(element, handler, options) { if (typeof element === 'string') { // Allow the element to be a handler name. // This assumes the request is being made against no element, and the handler parameter // will contain options. if (this.isHandlerName(element)) { this.element = null; this.handler = element; this.options = handler || {}; } else { const matchedElement = document.querySelector(element); if (matchedElement === null) { throw new Error(`No element was found with the given selector: ${element}`); } this.element = matchedElement; this.handler = handler; this.options = options || {}; } } else { this.element = element; this.handler = handler; this.options = options || {}; } this.fetchOptions = {}; this.responseData = null; this.responseError = null; this.cancelled = false; this.checkRequest(); if (!this.snowboard.globalEvent('ajaxSetup', this)) { this.cancelled = true; return; } if (this.element) { const event = new Event('ajaxSetup', { cancelable: true }); event.request = this; this.element.dispatchEvent(event); if (event.defaultPrevented) { this.cancelled = true; return; } } if (!this.doClientValidation()) { this.cancelled = true; return; } if (this.confirm) { this.doConfirm().then((confirmed) => { if (confirmed) { this.doAjax().then( (response) => { if (response.cancelled) { this.cancelled = true; this.complete(); return; } this.responseData = response; this.processUpdate(response).then( () => { if (response.X_WINTER_SUCCESS === false) { this.processError(response); } else { this.processResponse(response); } }, ); }, (error) => { this.responseError = error; this.processError(error); }, ); } }); } else { this.doAjax().then( (response) => { if (response.cancelled) { this.cancelled = true; this.complete(); return; } this.responseData = response; this.processUpdate(response).then( () => { if (response.X_WINTER_SUCCESS === false) { this.processError(response); } else { this.processResponse(response); } }, ); }, (error) => { this.responseError = error; this.processError(error); }, ); } } /** * Dependencies for this plugin. * * @returns {string[]} */ dependencies() { return ['cookie', 'jsonParser']; } /** * Validates the element and handler given in the request. */ checkRequest() { if (this.element && this.element instanceof Element === false) { throw new Error('The element provided must be an Element instance'); } if (this.handler === undefined) { throw new Error('The AJAX handler name is not specified.'); } if (!this.isHandlerName(this.handler)) { throw new Error('Invalid AJAX handler name. The correct handler name format is: "onEvent".'); } } /** * Creates a Fetch request. * * This method is made available for plugins to extend or override the default fetch() settings with their own. * * @returns {Promise} */ getFetch() { this.fetchOptions = (this.options.fetchOptions !== undefined && typeof this.options.fetchOptions === 'object') ? this.options.fetchOptions : { method: 'POST', headers: this.headers, body: this.data, redirect: 'follow', mode: 'same-origin', }; this.snowboard.globalEvent('ajaxFetchOptions', this.fetchOptions, this); return fetch(this.url, this.fetchOptions); } /** * Run client-side validation on the form, if available. * * @returns {boolean} */ doClientValidation() { if (this.options.browserValidate === true && this.form) { if (this.form.checkValidity() === false) { this.form.reportValidity(); return false; } } return true; } /** * Executes the AJAX query. * * Returns a Promise object for when the AJAX request is completed. * * @returns {Promise} */ doAjax() { // Allow plugins to cancel the AJAX request before sending if (this.snowboard.globalEvent('ajaxBeforeSend', this) === false) { return Promise.resolve({ cancelled: true, }); } const ajaxPromise = new Promise((resolve, reject) => { this.getFetch().then( (response) => { if (!response.ok && response.status !== 406) { if (response.headers.has('Content-Type') && response.headers.get('Content-Type').includes('/json')) { response.json().then( (responseData) => { if (responseData.message && responseData.exception) { reject(this.renderError( responseData.message, responseData.exception, responseData.file, responseData.line, responseData.trace, )); } else { reject(responseData); } }, (error) => { reject(this.renderError(`Unable to parse JSON response: ${error}`)); }, ); } else { response.text().then( (responseText) => { reject(this.renderError(responseText)); }, (error) => { reject(this.renderError(`Unable to process response: ${error}`)); }, ); } return; } if (response.headers.has('Content-Type') && response.headers.get('Content-Type').includes('/json')) { response.json().then( (responseData) => { resolve({ ...responseData, X_WINTER_SUCCESS: response.status !== 406, X_WINTER_RESPONSE_CODE: response.status, }); }, (error) => { reject(this.renderError(`Unable to parse JSON response: ${error}`)); }, ); } else { response.text().then( (responseData) => { resolve(responseData); }, (error) => { reject(this.renderError(`Unable to process response: ${error}`)); }, ); } }, (responseError) => { reject(this.renderError(`Unable to retrieve a response from the server: ${responseError}`)); }, ); }); this.snowboard.globalEvent('ajaxStart', ajaxPromise, this); if (this.element) { const event = new Event('ajaxPromise'); event.promise = ajaxPromise; this.element.dispatchEvent(event); } return ajaxPromise; } /** * Prepares for updating the partials from the AJAX response. * * If any partials are returned from the AJAX response, this method will also action the partial updates. * * Returns a Promise object which tracks when the partial update is complete. * * @param {Object} response * @returns {Promise} */ processUpdate(response) { return new Promise((resolve, reject) => { if (typeof this.options.beforeUpdate === 'function') { if (this.options.beforeUpdate.apply(this, [response]) === false) { resolve(); return; } } // Extract partial information const partials = {}; Object.entries(response).forEach((entry) => { const [key, value] = entry; if (key.substr(0, 8) !== 'X_WINTER') { partials[key] = value; } }); if (Object.keys(partials).length === 0) { if (response.X_WINTER_ASSETS) { this.processAssets(response.X_WINTER_ASSETS).then( () => { resolve(); }, () => { reject(); }, ); } else { resolve(); } return; } const promises = this.snowboard.globalPromiseEvent('ajaxBeforeUpdate', response, this); promises.then( async () => { if (response.X_WINTER_ASSETS) { await this.processAssets(response.X_WINTER_ASSETS); } this.doUpdate(partials).then( () => { // Allow for HTML redraw window.requestAnimationFrame(() => resolve()); }, () => { reject(); }, ); }, () => { resolve(); }, ); }); } /** * Updates the partials with the given content. * * @param {Object} partials * @returns {Promise} */ doUpdate(partials) { return new Promise((resolve) => { const affected = []; Object.entries(partials).forEach((entry) => { const [partial, content] = entry; let selector = (this.options.update && this.options.update[partial]) ? this.options.update[partial] : partial; let mode = 'replace'; if (selector.substr(0, 1) === '@') { mode = 'append'; selector = selector.substr(1); } else if (selector.substr(0, 1) === '^') { mode = 'prepend'; selector = selector.substr(1); } else if (selector.substr(0, 1) !== '#' && selector.substr(0, 1) !== '.') { mode = 'noop'; } const elements = document.querySelectorAll(selector); if (elements.length > 0) { elements.forEach((element) => { switch (mode) { case 'append': element.innerHTML += content; break; case 'prepend': element.innerHTML = content + element.innerHTML; break; case 'noop': break; case 'replace': default: element.innerHTML = content; break; } affected.push(element); // Fire update event for each element that is updated this.snowboard.globalEvent('ajaxUpdate', element, content, this); const event = new Event('ajaxUpdate'); event.content = content; element.dispatchEvent(event); }); } }); this.snowboard.globalEvent('ajaxUpdateComplete', affected, this); resolve(); }); } /** * Processes the response data. * * This fires off all necessary processing functions depending on the response, ie. if there's any flash * messages to handle, or any redirects to be undertaken. * * @param {Object} response * @returns {void} */ processResponse(response) { if (this.options.success && typeof this.options.success === 'function') { if (this.options.success(this.responseData, this) === false) { return; } } // Allow plugins to cancel any further response handling if (this.snowboard.globalEvent('ajaxSuccess', this.responseData, this) === false) { return; } // Allow the element to cancel any further response handling if (this.element) { const event = new Event('ajaxDone', { cancelable: true }); event.responseData = this.responseData; event.request = this; this.element.dispatchEvent(event); if (event.defaultPrevented) { return; } } if (this.flash && response.X_WINTER_FLASH_MESSAGES) { this.processFlashMessages(response.X_WINTER_FLASH_MESSAGES); } // Check for a redirect from the response, or use the redirect as specified in the options. if (this.redirect || response.X_WINTER_REDIRECT) { this.processRedirect(this.redirect || response.X_WINTER_REDIRECT); return; } this.complete(); } /** * Processes an error response from the AJAX request. * * This fires off all necessary processing functions depending on the error response, ie. if there's any error or * validation messages to handle. * * @param {Object|Error} error */ processError(error) { if (this.options.error && typeof this.options.error === 'function') { if (this.options.error(this.responseError, this) === false) { return; } } // Allow plugins to cancel any further error handling if (this.snowboard.globalEvent('ajaxError', this.responseError, this) === false) { return; } // Allow the element to cancel any further error handling if (this.element) { const event = new Event('ajaxFail', { cancelable: true }); event.responseError = this.responseError; event.request = this; this.element.dispatchEvent(event); if (event.defaultPrevented) { return; } } if (error instanceof Error) { this.processErrorMessage(error.message); } else { let skipError = false; // Process validation errors if (error.X_WINTER_ERROR_FIELDS) { skipError = this.processValidationErrors(error.X_WINTER_ERROR_FIELDS); } if (error.X_WINTER_ERROR_MESSAGE && !skipError) { this.processErrorMessage(error.X_WINTER_ERROR_MESSAGE); } } this.complete(); } /** * Processes a redirect response. * * By default, this processor will simply redirect the user in their browser. * * Plugins can augment this functionality from the `ajaxRedirect` event. You may also override this functionality on * a per-request basis through the `handleRedirectResponse` callback option. If a `false` is returned from either, the * redirect will be cancelled. * * @param {string} url * @returns {void} */ processRedirect(url) { // Run a custom per-request redirect handler. If false is returned, don't run the redirect. if (typeof this.options.handleRedirectResponse === 'function') { if (this.options.handleRedirectResponse.apply(this, [url]) === false) { return; } } // Allow plugins to cancel the redirect if (this.snowboard.globalEvent('ajaxRedirect', url, this) === false) { return; } // Indicate that the AJAX request is finished if we're still on the current page // so that the loading indicator for redirects that just change the hash value of // the URL instead of leaving the page will properly stop. // @see https://github.com/octobercms/october/issues/2780 window.addEventListener('popstate', () => { if (this.element) { const event = document.createEvent('CustomEvent'); event.eventName = 'ajaxRedirected'; this.element.dispatchEvent(event); } }, { once: true, }); window.location.assign(url); } /** * Processes an error message. * * By default, this processor will simply alert the user through a simple `alert()` call. * * Plugins can augment this functionality from the `ajaxErrorMessage` event. You may also override this functionality * on a per-request basis through the `handleErrorMessage` callback option. If a `false` is returned from either, the * error message handling will be cancelled. * * @param {string} message * @returns {void} */ processErrorMessage(message) { // Run a custom per-request handler for error messages. If false is returned, do not process the error messages // any further. if (typeof this.options.handleErrorMessage === 'function') { if (this.options.handleErrorMessage.apply(this, [message]) === false) { return; } } // Allow plugins to cancel the error message being shown if (this.snowboard.globalEvent('ajaxErrorMessage', message, this) === false) { return; } // By default, show a browser error message window.alert(message); } /** * Processes flash messages from the response. * * By default, no flash message handling will occur. * * Plugins can augment this functionality from the `ajaxFlashMessages` event. You may also override this functionality * on a per-request basis through the `handleFlashMessages` callback option. If a `false` is returned from either, the * flash message handling will be cancelled. * * @param {Object} messages * @returns */ processFlashMessages(messages) { // Run a custom per-request flash handler. If false is returned, don't show the flash message if (typeof this.options.handleFlashMessages === 'function') { if (this.options.handleFlashMessages.apply(this, [messages]) === false) { return; } } this.snowboard.globalEvent('ajaxFlashMessages', messages, this); } /** * Processes validation errors for fields. * * By default, no validation error handling will occur. * * Plugins can augment this functionality from the `ajaxValidationErrors` event. You may also override this functionality * on a per-request basis through the `handleValidationErrors` callback option. If a `false` is returned from either, the * validation error handling will be cancelled. * * @param {Object} fields * @returns */ processValidationErrors(fields) { if (typeof this.options.handleValidationErrors === 'function') { if (this.options.handleValidationErrors.apply(this, [this.form, fields]) === false) { return true; } } // Allow plugins to cancel the validation errors being handled if (this.snowboard.globalEvent('ajaxValidationErrors', this.form, fields, this) === false) { return true; } return false; } /** * Processes assets returned by an AJAX request. * * By default, no asset processing will occur and this will return a resolved Promise. * * Plugins can augment this functionality from the `ajaxLoadAssets` event. This event is considered blocking, and * allows assets to be loaded or processed before continuing with any additional functionality. * * @param {Object} assets * @returns {Promise} */ processAssets(assets) { return this.snowboard.globalPromiseEvent('ajaxLoadAssets', assets); } /** * Confirms the request with the user before proceeding. * * This is an asynchronous method. By default, it will use the browser's `confirm()` method to query the user to * confirm the action. This method will return a Promise with a boolean value depending on whether the user confirmed * or not. * * Plugins can augment this functionality from the `ajaxConfirmMessage` event. You may also override this functionality * on a per-request basis through the `handleConfirmMessage` callback option. If a `false` is returned from either, * the confirmation is assumed to have been denied. * * @returns {Promise} */ async doConfirm() { // Allow for a custom handler for the confirmation, per request. if (typeof this.options.handleConfirmMessage === 'function') { if (this.options.handleConfirmMessage.apply(this, [this.confirm]) === false) { return false; } return true; } // If no plugins have customised the confirmation, use a simple browser confirmation. if (this.snowboard.listensToEvent('ajaxConfirmMessage').length === 0) { return window.confirm(this.confirm); } // Run custom plugin confirmations const promises = this.snowboard.globalPromiseEvent('ajaxConfirmMessage', this.confirm, this); try { const fulfilled = await promises; if (fulfilled) { return true; } } catch (e) { return false; } return false; } /** * Fires off completion events for the Request. */ complete() { if (this.options.complete && typeof this.options.complete === 'function') { this.options.complete(this.responseData, this); } this.snowboard.globalEvent('ajaxDone', this.responseData, this); if (this.element) { const event = new Event('ajaxAlways'); event.request = this; event.responseData = this.responseData; event.responseError = this.responseError; this.element.dispatchEvent(event); } // Fire off the destructor this.destruct(); } get form() { if (this.options.form) { if (typeof this.options.form === 'string') { return document.querySelector(this.options.form); } return this.options.form; } if (!this.element) { return null; } if (this.element.tagName === 'FORM') { return this.element; } return this.element.closest('form'); } get context() { return { handler: this.handler, options: this.options, }; } get headers() { const headers = { 'X-Requested-With': 'XMLHttpRequest', // Keeps compatibility with jQuery AJAX 'X-WINTER-REQUEST-HANDLER': this.handler, 'X-WINTER-REQUEST-PARTIALS': this.extractPartials(this.options.update || []), }; if (this.flash) { headers['X-WINTER-REQUEST-FLASH'] = 1; } if (this.xsrfToken) { headers['X-XSRF-TOKEN'] = this.xsrfToken; } return headers; } get loading() { return this.options.loading || false; } get url() { return this.options.url || window.location.href; } get redirect() { return (this.options.redirect && this.options.redirect.length) ? this.options.redirect : null; } get flash() { return this.options.flash || false; } get files() { if (this.options.files === true) { if (FormData === undefined) { this.snowboard.debug('This browser does not support file uploads'); return false; } return true; } return false; } get xsrfToken() { return this.snowboard.cookie().get('XSRF-TOKEN'); } get data() { const data = (typeof this.options.data === 'object') ? this.options.data : {}; const formData = new FormData(this.form || undefined); if (Object.keys(data).length > 0) { this.createFormData(formData, data); } return formData; } /** * Recursively adds data to a FormData object. * * This method is used internally to recursively add data to a FormData object, ensuring that * objects and arrays are correctly prefixed and added as POST data. * * @param {FormData} formData * @param {Object} data * @param {string} prefix * @returns {void} */ createFormData(formData, data, prefix = '') { if (data === null || data === undefined) { return; } if (typeof data !== 'object') { formData.append(prefix, data); return; } if (Array.isArray(data) && prefix !== '') { data.forEach((item, index) => { this.createFormData(formData, item, `${prefix}[${index}]`); }); return; } Object.entries(data).forEach((entry) => { const [key, value] = entry; this.createFormData( formData, value, (prefix !== '') ? `${prefix}[${key}]` : key, ); }); } get confirm() { return this.options.confirm || false; } /** * Extracts partials. * * @param {Object} update * @returns {string} */ extractPartials(update) { return Object.keys(update).join('&'); } /** * Renders an error with useful debug information. * * This method is used internally when the AJAX request could not be completed or processed correctly due to an error. * * @param {string} message * @param {string} exception * @param {string} file * @param {Number} line * @param {string[]} trace * @returns {Error} */ renderError(message, exception, file, line, trace) { const error = new Error(message); error.exception = exception || null; error.file = file || null; error.line = line || null; error.trace = trace || []; return error; } /** * Checks a given string to see if it is a valid AJAX handler name. * * @param {String} name * @returns {Boolean} */ isHandlerName(name) { return /^(?:\w+:{2})?on[A-Z0-9]/.test(name); } }