ajax refactoring

This commit is contained in:
Yuri Kuznetsov
2023-07-05 17:15:18 +03:00
parent e512f272a5
commit 2a42cc8542
6 changed files with 291 additions and 142 deletions
+170 -61
View File
@@ -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.<string, *>} [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.<string, string>} [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.<string, *>} [options] Options.
* @returns {AjaxPromise<any>}
* @returns {AjaxPromise<any, XMLHttpRequest>}
*/
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.<string, *>} [options] Options.
* @returns {Promise<any>}
* @returns {Promise<any, XMLHttpRequest>}
*/
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.<string, *>} [options] Options.
* @returns {Promise<any>}
* @returns {Promise<any, XMLHttpRequest>}
*/
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.<string, *>} [options] Options.
* @returns {Promise<any>}
* @returns {Promise<any, XMLHttpRequest>}
*/
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.<string, *>} [options] Options.
* @returns {Promise<any>}
* @returns {Promise<any, XMLHttpRequest>}
*/
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.<string, *>} [options] Options.
* @returns {Promise<any>}
* @returns {Promise<any, XMLHttpRequest>}
*/
getRequest: function (url, data, options) {
return /** @type {Promise<any>} */ 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;
+102 -68
View File
@@ -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.<string, *> */ 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.<string, *>} 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.<string, *>} 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',
});
}
+10 -4
View File
@@ -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);
});
+4 -4
View File
@@ -1050,16 +1050,16 @@
}
/**
* Require a module or multiple modules.
* Require a module.
*
* @param {...string} id A module or modules to require.
* @returns {Promise<unknown>}
* @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()
);
});
+1 -1
View File
@@ -492,7 +492,7 @@ class Model {
* patch?: boolean,
* wait?: boolean,
* } & Object.<string, *>} [options] Options.
* @returns {Promise}
* @returns {Promise<Object.<string, *>>}
* @fires Model#sync
* @copyright Credits to Backbone.js.
*/
+4 -4
View File
@@ -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) {