diff --git a/client/src/ajax.js b/client/src/ajax.js index 534f53e97e..7633f98313 100644 --- a/client/src/ajax.js +++ b/client/src/ajax.js @@ -28,8 +28,29 @@ /** @module ajax */ -// noinspection JSUnusedGlobalSymbols +let isConfigured = false; +/** @type {number} */ +let defaultTimeout; +/** @type {string} */ +let apiUrl; +/** @type {Espo.Ajax~Handler} */ +let beforeSend; +/** @type {Espo.Ajax~Handler} */ +let onSuccess; +/** @type {Espo.Ajax~Handler} */ +let onError; +/** @type {Espo.Ajax~Handler} */ +let onTimeout; +/** + * @callback Espo.Ajax~Handler + * @param {XMLHttpRequest} [xhr] + * @param {Object.} [options] + */ + +const baseUrl = window.location.origin + window.location.pathname; + +// noinspection JSUnusedGlobalSymbols /** * Functions for API HTTP requests. */ @@ -44,40 +65,130 @@ const Ajax = Espo.Ajax = { * @property {Object.} [headers] A request headers. * @property {'json'|'text'} [dataType] A data type. * @property {string} [contentType] A content type. - * @property {boolean} [fullResponse] To resolve with `module:ajax.XhrWrapper`. + * @property {boolean} [resolveWithXhr] To resolve with `XMLHttpRequest`. */ /** * Request. * * @param {string} url An URL. - * @param {string} method An HTTP method. + * @param {'GET'|'POST'|'PUT'|'DELETE'|'PATCH'|'OPTIONS'} method An HTTP method. * @param {*} [data] Data. * @param {Espo.Ajax~Options & Object.} [options] Options. - * @returns {AjaxPromise} + * @returns {AjaxPromise} */ request: function (url, method, data, options) { options = options || {}; - options.type = method; - options.url = url; + let timeout = 'timeout' in options ? options.timeout : defaultTimeout; + let contentType = options.contentType || 'application/json'; + let body; - if (data) { - options.data = data; + if (options.data && !data) { + data = options.data; + } + + if (!['GET', 'OPTIONS'].includes(method) && data) { + body = data; + + if (contentType === 'application/json' && typeof data !== 'string') { + body = JSON.stringify(data); + } + } + + if (apiUrl) { + url = Espo.Utils.trimSlash(apiUrl) + '/' + url; + } + + let urlObj = new URL(baseUrl + url); + + if (method === 'GET' && data) { + for (let key in data) { + let value = data[key]; + + if (value == null) { + continue; + } + + urlObj.searchParams.append(key, value); + } + } + + let xhr = new Xhr(); + xhr.timeout = timeout; + xhr.open(method, urlObj); + xhr.setRequestHeader('Content-Type', contentType); + + if (options.headers) { + for (let key in options.headers) { + xhr.setRequestHeader(key, options.headers[key]); + } + } + + if (beforeSend) { + beforeSend(xhr, options); } let promiseWrapper = {}; let promise = new AjaxPromise((resolve, reject) => { - let xhr = $.ajax(options); + const onErrorGeneral = (isTimeout) => { + if (options.error) { + options.error(xhr, options); + } - xhr - .then((response, status, xhr) => { - let obj = options.fullResponse ? new XhrWrapper(xhr) : response; + reject(xhr, options); - resolve(obj); - }) - .fail(xhr => reject(xhr)); + if (isTimeout) { + if (onTimeout) { + onTimeout(xhr, options); + } + + return; + } + + if (onError) { + onError(xhr, options); + } + }; + + xhr.ontimeout = () => onErrorGeneral(true); + xhr.onerror = () => onErrorGeneral(); + + xhr.onload = () => { + if (xhr.status >= 400) { + onErrorGeneral(); + + return; + } + + let response = xhr.responseText; + + if ((options.dataType || 'json') === 'json') { + try { + response = JSON.parse(xhr.responseText); + } + catch (e) { + console.error('Could not parse API response.'); + + onErrorGeneral(); + } + } + + if (options.success) { + options.success(response); + } + + onSuccess(xhr, options); + + if (options.resolveWithXhr) { + response = xhr; + } + + resolve(response) + } + + xhr.send(body); if (promiseWrapper.promise) { promiseWrapper.promise.xhr = xhr; @@ -100,7 +211,7 @@ const Ajax = Espo.Ajax = { * @param {string} url An URL. * @param {*} [data] Data. * @param {Espo.Ajax~Options & Object.} [options] Options. - * @returns {Promise} + * @returns {Promise} */ postRequest: function (url, data, options) { if (data) { @@ -116,7 +227,7 @@ const Ajax = Espo.Ajax = { * @param {string} url An URL. * @param {*} [data] Data. * @param {Espo.Ajax~Options & Object.} [options] Options. - * @returns {Promise} + * @returns {Promise} */ patchRequest: function (url, data, options) { if (data) { @@ -132,7 +243,7 @@ const Ajax = Espo.Ajax = { * @param {string} url An URL. * @param {*} [data] Data. * @param {Espo.Ajax~Options & Object.} [options] Options. - * @returns {Promise} + * @returns {Promise} */ putRequest: function (url, data, options) { if (data) { @@ -148,7 +259,7 @@ const Ajax = Espo.Ajax = { * @param {string} url An URL. * @param {*} [data] Data. * @param {Espo.Ajax~Options & Object.} [options] Options. - * @returns {Promise} + * @returns {Promise} */ deleteRequest: function (url, data, options) { if (data) { @@ -164,11 +275,37 @@ const Ajax = Espo.Ajax = { * @param {string} url An URL. * @param {*} [data] Data. * @param {Espo.Ajax~Options & Object.} [options] Options. - * @returns {Promise} + * @returns {Promise} */ getRequest: function (url, data, options) { return /** @type {Promise} */ Ajax.request(url, 'GET', data, options); }, + + /** + * @internal + * @param {{ + * apiUrl: string, + * timeout: number, + * beforeSend: Espo.Ajax~Handler, + * onSuccess: Espo.Ajax~Handler, + * onError: Espo.Ajax~Handler, + * onTimeout: Espo.Ajax~Handler, + * }} options Options. + */ + configure: function (options) { + if (isConfigured) { + throw new Error("Ajax is already configured."); + } + + apiUrl = options.apiUrl; + defaultTimeout = options.timeout; + beforeSend = options.beforeSend; + onSuccess = options.onSuccess; + onError = options.onError; + onTimeout = options.onTimeout; + + isConfigured = true; + }, }; /** @@ -177,18 +314,24 @@ const Ajax = Espo.Ajax = { class AjaxPromise extends Promise { /** - * @type {JQueryXHR|null} + * @type {XMLHttpRequest|null} * @internal */ xhr = null isAborted = false - /** @deprecated Use `catch`. */ + /** + * @deprecated Use `catch`. + * @todo Remove in v9.0. + */ fail(...args) { return this.catch(args[0]); } - /** @deprecated Use `then`. */ + /** + * @deprecated Use `then` + * @todo Remove in v9.0. + */ done(...args) { return this.then(args[0]); } @@ -232,47 +375,13 @@ class AjaxPromise extends Promise { } /** - * @name module:ajax.XhrWrapper + * @name module:ajax.Xhr */ -class XhrWrapper { - +class Xhr extends XMLHttpRequest { /** - * @param {JQueryXHR} xhr + * To be set in an error handler to bypass default handling. */ - constructor(xhr) { - this.xhr = xhr; - } - - /** - * @param {string} name - * @return {string} - */ - getResponseHeader(name) { - return this.xhr.getResponseHeader(name); - } - - /** - * @return {Number} - */ - getStatus() { - return this.xhr.status; - } - - // noinspection JSUnusedGlobalSymbols - /** - * @return {*} - */ - getResponseParsedBody() { - return this.xhr.responseJSON; - } - - // noinspection JSUnusedGlobalSymbols - /** - * @return {string} - */ - getResponseBody() { - return this.xhr.responseText; - } + errorIsHandled = false } export default Ajax; diff --git a/client/src/app.js b/client/src/app.js index f4cc03a16e..9ba9171a98 100644 --- a/client/src/app.js +++ b/client/src/app.js @@ -882,8 +882,8 @@ class App { if (arr.length > 1) { logoutWait = this.appParams.logoutWait || false; - Ajax.postRequest('App/destroyAuthToken', {token: arr[1]}, {fullResponse: true}) - .then(xhr => { + Ajax.postRequest('App/destroyAuthToken', {token: arr[1]}, {resolveWithXhr: true}) + .then(/** XMLHttpRequest */xhr => { let redirectUrl = xhr.getResponseHeader('X-Logout-Redirect-Url'); if (redirectUrl) { @@ -1069,87 +1069,77 @@ class App { * @private */ setupAjax() { - $.ajaxSetup({ - beforeSend: (xhr, /** JQueryAjaxSettings & Object. */ options) => { - if (!options.local && this.apiUrl) { - options.url = Utils.trimSlash(this.apiUrl) + '/' + options.url; - } + /** + * @param {XMLHttpRequest} xhr + */ + const beforeSend = (xhr) => { + if (this.auth !== null) { + xhr.setRequestHeader('Authorization', 'Basic ' + this.auth); + xhr.setRequestHeader('Espo-Authorization', this.auth); + xhr.setRequestHeader('Espo-Authorization-By-Token', 'true'); + } - if (!options.local && this.basePath !== '') { - options.url = this.basePath + options.url; - } - - if (this.auth !== null) { - xhr.setRequestHeader('Authorization', 'Basic ' + this.auth); - xhr.setRequestHeader('Espo-Authorization', this.auth); - xhr.setRequestHeader('Espo-Authorization-By-Token', 'true'); - } - - if (this.anotherUser !== null) { - xhr.setRequestHeader('X-Another-User', this.anotherUser); - } - }, - dataType: 'json', - timeout: this.ajaxTimeout, - contentType: 'application/json', - }); + if (this.anotherUser !== null) { + xhr.setRequestHeader('X-Another-User', this.anotherUser); + } + }; let appTimestampChangeProcessed = false; - $(document).ajaxSuccess((e, xhr, options) => { + /** + * @param {XMLHttpRequest} xhr + * @param {Object.} options + */ + const onSuccess = (xhr, options) => { let appTimestampHeader = xhr.getResponseHeader('X-App-Timestamp'); - if (appTimestampHeader && !appTimestampChangeProcessed) { - let appTimestamp = parseInt(appTimestampHeader); - - // noinspection JSUnresolvedReference - let bypassAppReload = options.bypassAppReload; - - if ( - this.appTimestamp && - appTimestamp !== this.appTimestamp && - !bypassAppReload - ) { - appTimestampChangeProcessed = true; - - Ui - .confirm( - this.language.translate('confirmAppRefresh', 'messages'), - { - confirmText: this.language.translate('Refresh'), - cancelText: this.language.translate('Cancel'), - backdrop: 'static', - confirmStyle: 'success', - } - ) - .then(() => { - window.location.reload(); - - if (this.broadcastChannel) { - this.broadcastChannel.postMessage('reload'); - } - }); - } + if (!appTimestampHeader || appTimestampChangeProcessed) { + return; } - }); - $(document).ajaxError((e, xhr, options) => { - // To process after a promise-catch. + let appTimestamp = parseInt(appTimestampHeader); + // noinspection JSUnresolvedReference + let bypassAppReload = options.bypassAppReload; + + if ( + this.appTimestamp && + appTimestamp !== this.appTimestamp && + !bypassAppReload + ) { + appTimestampChangeProcessed = true; + + Ui + .confirm( + this.language.translate('confirmAppRefresh', 'messages'), + { + confirmText: this.language.translate('Refresh'), + cancelText: this.language.translate('Cancel'), + backdrop: 'static', + confirmStyle: 'success', + } + ) + .then(() => { + window.location.reload(); + + if (this.broadcastChannel) { + this.broadcastChannel.postMessage('reload'); + } + }); + } + }; + + /** + * @param {module:ajax.Xhr} xhr + * @param {Object.} options + */ + const onError = (xhr, options) => { setTimeout(() => { - // noinspection JSUnresolvedReference if (xhr.errorIsHandled) { return; } switch (xhr.status) { - case 0: - if (xhr.statusText === 'timeout') { - Ui.error(this.language.translate('Timeout'), true); - } - - break; - case 200: Ui.error(this.language.translate('Bad server response')); @@ -1227,6 +1217,50 @@ class App { console.error('Server side error ' + xhr.status + ': ' + statusReason); } }, 0); + }; + + const onTimeout = () => { + Ui.error(this.language.translate('Timeout'), true); + }; + + Ajax.configure({ + apiUrl: this.basePath + this.apiUrl, + timeout: this.ajaxTimeout, + beforeSend: beforeSend, + onSuccess: onSuccess, + onError: onError, + onTimeout: onTimeout, + }); + + // For backward compatibility. + // @todo Remove in v9.0. + $.ajaxSetup({ + beforeSend: (xhr, options) => { + console.error(`$.ajax is deprecated, support will be removed in v9.0. Use Espo.Ajax instead.`); + + // noinspection JSUnresolvedReference + if (!options.local && this.apiUrl) { + options.url = Utils.trimSlash(this.apiUrl) + '/' + options.url; + } + + // noinspection JSUnresolvedReference + if (!options.local && this.basePath !== '') { + options.url = this.basePath + options.url; + } + + if (this.auth !== null) { + xhr.setRequestHeader('Authorization', 'Basic ' + this.auth); + xhr.setRequestHeader('Espo-Authorization', this.auth); + xhr.setRequestHeader('Espo-Authorization-By-Token', 'true'); + } + + if (this.anotherUser !== null) { + xhr.setRequestHeader('X-Another-User', this.anotherUser); + } + }, + dataType: 'json', + timeout: this.ajaxTimeout, + contentType: 'application/json', }); } diff --git a/client/src/handlers/login/oidc.js b/client/src/handlers/login/oidc.js index 19a3c5463a..ec29e73ade 100644 --- a/client/src/handlers/login/oidc.js +++ b/client/src/handlers/login/oidc.js @@ -41,7 +41,10 @@ class OidcLoginHandler extends LoginHandler { Espo.Ui.notify(false); this.processWithData(data) - .then((code, nonce) => { + .then(info => { + let code = info.code; + let nonce = info.nonce; + let authString = Base64.encode('**oidc:' + code); let headers = { @@ -75,7 +78,7 @@ class OidcLoginHandler extends LoginHandler { * prompt: 'login'|'consent'|'select_account', * maxAge: ?Number, * }} data - * @return {Promise} + * @return {Promise<{code: string, nonce: string}>} */ processWithData(data) { let state = (Math.random() + 1).toString(36).substring(7); @@ -114,7 +117,7 @@ class OidcLoginHandler extends LoginHandler { * @param {string} url * @param {string} state * @param {string} nonce - * @return {Promise} + * @return {Promise<{code: string, nonce: string}>} */ processWindow(url, state, nonce) { let proxy = window.open(url, 'ConnectWithOAuth', 'location=0,status=0,width=800,height=800'); @@ -177,7 +180,10 @@ class OidcLoginHandler extends LoginHandler { window.clearInterval(interval); proxy.close(); - resolve(parsedData.code, nonce); + resolve({ + code: parsedData.code, + nonce: nonce, + }); } }, 300); }); diff --git a/client/src/loader.js b/client/src/loader.js index 2b1ac31dfc..d41e63c3e3 100644 --- a/client/src/loader.js +++ b/client/src/loader.js @@ -1050,16 +1050,16 @@ } /** - * Require a module or multiple modules. + * Require a module. * - * @param {...string} id A module or modules to require. - * @returns {Promise} + * @param {string} id A module to require. + * @returns {Promise<*>} */ requirePromise(id) { return new Promise((resolve, reject) => { this.require( id, - (...args) => resolve(...args), + arg => resolve(arg), () => reject() ); }); diff --git a/client/src/model.js b/client/src/model.js index 6cf650b2a7..30e71bdf78 100644 --- a/client/src/model.js +++ b/client/src/model.js @@ -492,7 +492,7 @@ class Model { * patch?: boolean, * wait?: boolean, * } & Object.} [options] Options. - * @returns {Promise} + * @returns {Promise>} * @fires Model#sync * @copyright Credits to Backbone.js. */ diff --git a/client/src/views/record/base.js b/client/src/views/record/base.js index 4221728c13..0511af4bb3 100644 --- a/client/src/views/record/base.js +++ b/client/src/views/record/base.js @@ -1087,9 +1087,6 @@ class BaseRecordView extends View { { patch: !model.isNew(), headers: headers, - // Can't use a promise-catch, as it's called - // after the default ajaxError callback. - error: (m, xhr) => onError(xhr, reject), }, ) .then(() => { @@ -1105,6 +1102,9 @@ class BaseRecordView extends View { model.trigger('after:save'); resolve(); + }) + .catch(xhr => { + onError(xhr, reject); }); }); } @@ -1112,7 +1112,7 @@ class BaseRecordView extends View { /** * Handle a save error. * - * @param {JQueryXHR} xhr XHR. + * @param {module:ajax.Xhr} xhr XHR. * @param {module:views/record/base~saveOptions} [options] Options. */ handleSaveError(xhr, options) {