Files
espocrm/client/src/model.js
Yuri Kuznetsov f6a5f3cf06 fix model
2023-07-18 19:44:11 +03:00

1019 lines
24 KiB
JavaScript

/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM - Open Source CRM application.
* Copyright (C) 2014-2023 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
* Website: https://www.espocrm.com
*
* EspoCRM is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* EspoCRM is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with EspoCRM. If not, see http://www.gnu.org/licenses/.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU General Public License version 3.
*
* In accordance with Section 7(b) of the GNU General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
/** @module model */
import {Events, View as BullView} from 'bullbone';
import _ from 'underscore';
/**
* When attributes have changed.
*
* @event Model#change
* @param {Model} model A model.
* @param {Object.<string, *>} o Options.
*/
/**
* On sync with backend.
*
* @event Model#sync
* @param {Model} model A model.
* @param {Object} response Response from backend.
* @param {Object.<string, *>} o Options.
*/
/**
* Defs.
*
* @typedef module:model~defs
* @type {Object}
* @property {Object.<string, Object.<string, *>>} [fields] Fields.
* @property {Object.<string, Object.<string, *>>} [links] Links.
*/
/**
* A model.
*
* @mixes Bull.Events
*/
class Model {
/**
* A root URL. An ID will be appended. Used for syncing with backend.
*
* @type {string|null}
*/
urlRoot = null
/**
* A URL. If not empty, then will be used for syncing instead of `urlRoot`.
*
* @type {string|null}
*/
url = null
/**
* A name.
*
* @type {string|null}
*/
name = null
/**
* An entity type.
*
* @type {string|null}
*/
entityType = null
/**
* A last request promise.
*
* @type {module:ajax.AjaxPromise|null}
*/
lastSyncPromise = null
/** @private */
_pending
/** @private */
_changing
/**
* @param {Object.<string, *>|Model} [attributes]
* @param {{
* collection?: module:collection,
* entityType?: string,
* urlRoot?: string,
* url?: string,
* defs?: module:model~defs,
* user?: module:models/user,
* dateTime?: module:date-time,
* }} [options]
*/
constructor(attributes, options) {
options = options || {};
/**
* An ID attribute.
* @type {string}
*/
this.idAttribute = 'id';
/**
* A record ID.
* @type {string|null}
*/
this.id = null;
/**
* An instance ID.
* @type {string}
*/
this.cid = _.uniqueId('c');
/**
* Attribute values.
* @type {Object.<string, *>}
*/
this.attributes = {};
if (options.collection) {
this.collection = options.collection;
}
this.set(attributes || {});
/**
* Definitions.
*/
this.defs = options.defs || {};
if (!this.defs.fields) {
this.defs.fields = {};
}
if (options.entityType) {
this.entityType = options.entityType;
this.name = options.entityType;
this.urlRoot = options.entityType;
}
this.urlRoot = options.urlRoot || this.urlRoot;
this.url = options.url || this.url;
/** @private */
this.dateTime = options.dateTime || null;
/** @private */
this.changed = {};
/** @private */
this._previousAttributes = null;
}
/**
* @protected
* @param {string} [method] HTTP method.
* @param {Model} model
* @param {Object.<string, *>} [options]
* @returns {module:ajax.AjaxPromise|Promise}
*/
sync(method, model, options) {
const methodMap = {
'create': 'POST',
'update': 'PUT',
'patch': 'PUT',
'delete': 'DELETE',
'read': 'GET',
};
let httpMethod = methodMap[method];
if (!httpMethod) {
throw new Error(`Bad request method '${method}'.`);
}
options = options || {};
let url = this.composeSyncUrl();
if (!url) {
throw new Error(`No 'url'.`);
}
const data = model && ['create', 'update', 'patch'].includes(method) ?
(options.attributes || model.getClonedAttributes()) : null;
let error = options.error;
options.error = (xhr, textStatus, errorThrown) => {
options.textStatus = textStatus;
options.errorThrown = errorThrown;
if (error) {
error.call(options.context, xhr, textStatus, errorThrown);
}
};
let stringData = data ? JSON.stringify(data) : null;
const ajaxPromise = !options.bypassRequest ?
Espo.Ajax.request(url, httpMethod, stringData, options) :
Promise.resolve();
options.xhr = ajaxPromise.xhr;
model.trigger('request', url, httpMethod, data, ajaxPromise, options);
return ajaxPromise;
}
/**
* Set an attribute value.
*
* @param {(string|Object)} attribute An attribute name or a {key => value} object.
* @param {*} [value] A value or options if the first argument is an object.
* @param {{silent?: boolean} & Object.<string, *>} [options] Options. `silent` won't trigger a `change` event.
* @returns {this}
* @fires Model#change Unless `{silent: true}`.
*/
set(attribute, value, options) {
if (attribute == null) {
return this;
}
let attributes;
if (typeof attribute === 'object') {
return this.setMultiple(attribute, value);
}
attributes = {};
attributes[attribute] = value;
return this.setMultiple(attributes, options);
}
/**
* Set attributes values.
*
* @param {Object.<string, *>} attributes
* @param {{
* silent?: boolean,
* unset?: boolean,
* } & Object.<string, *>} [options] Options. `silent` won't trigger a `change` event.
* @return {this}
* @fires Model#change Unless `{silent: true}`.
* @copyright Credits to Backbone.js.
*/
setMultiple(attributes, options) {
if (this.idAttribute in attributes) {
this.id = attributes[this.idAttribute];
}
options = options || {};
let changes = [];
let changing = this._changing;
this._changing = true;
if (!changing) {
this._previousAttributes = _.clone(this.attributes);
this.changed = {};
}
let current = this.attributes;
let changed = this.changed;
let previous = this._previousAttributes;
for (let attribute in attributes) {
let value = attributes[attribute];
if (!_.isEqual(current[attribute], value)) {
changes.push(attribute);
}
if (!_.isEqual(previous[attribute], value)) {
changed[attribute] = value;
} else {
delete changed[attribute];
}
options.unset ?
delete current[attribute] :
current[attribute] = value;
}
if (!options.silent) {
if (changes.length) {
this._pending = options;
}
for (let i = 0; i < changes.length; i++) {
this.trigger('change:' + changes[i], this, current[changes[i]], options);
}
}
if (changing) {
return this;
}
if (!options.silent) {
// Changes can be recursively nested within `change` events.
while (this._pending) {
options = this._pending;
this._pending = false;
this.trigger('change', this, options);
}
}
this._pending = false;
this._changing = false;
return this;
}
/**
* Unset an attribute.
*
* @param {string} attribute An attribute.
* @param {{silent?: boolean} & Object.<string, *>} [options] Options.
* @return {Model}
*/
unset(attribute, options) {
options = {...options, unset: true};
let attributes = {};
attributes[attribute] = null;
return this.setMultiple(attributes, options);
}
/**
* Get an attribute value.
*
* @param {string} attribute An attribute name.
* @returns {*}
*/
get(attribute) {
if (attribute === this.idAttribute && this.id) {
return this.id;
}
return this.attributes[attribute];
}
/**
* Whether attribute is set.
*
* @param {string} attribute An attribute name.
* @returns {boolean}
*/
has(attribute) {
let value = this.get(attribute);
return typeof value !== 'undefined';
}
/**
* Removes all attributes from the model.
* Fires a `change` event unless `silent` is passed as an option.
*
* @param {{silent?: boolean} & Object.<string, *>} [options] Options.
*/
clear(options) {
let attributes = {};
for (let key in this.attributes) {
attributes[key] = void 0;
}
options = {...options, unset: true};
return this.set(attributes, options);
}
/**
* Whether is new.
*
* @returns {boolean}
*/
isNew() {
return !this.id;
}
/**
* Whether an attribute changed. To be called only within a 'change' event handler.
*
* @param {string} [attribute]
* @return {boolean}
*/
hasChanged(attribute) {
if (!attribute) {
return !_.isEmpty(this.changed);
}
return _.has(this.changed, attribute);
}
/**
* Get changed attribute values. To be called only within a 'change' event handler.
*
* @return {Object.<string, *>}
*/
changedAttributes() {
return this.hasChanged() ? _.clone(this.changed) : {};
}
/**
* Get previous attributes. To be called only within a 'change' event handler.
*
* @return {Object.<string, *>}
*/
previousAttributes() {
return _.clone(this._previousAttributes);
}
/**
* Get a previous attribute value. To be called only within a 'change' event handler.
*
* @param attribute
* @return {*}
*/
previous(attribute) {
if (!this._previousAttributes) {
return null;
}
return this._previousAttributes[attribute];
}
/**
* Fetch values from the backend.
*
* @param {Object.<string, *>} [options] Options.
* @returns {Promise}
* @fires Model#sync
*/
fetch(options) {
options = {...options};
let success = options.success;
options.success = response => {
let serverAttributes = this.prepareAttributes(response, options);
this.set(serverAttributes, options);
if (success) {
success.call(options.context, this, response, options);
}
this.trigger('sync', this, response, options);
};
this.lastSyncPromise = this.sync('read', this, options);
return this.lastSyncPromise;
}
/**
* Save values to the backend.
*
* @param {Object.<string, *>} [attributes] Attribute values.
* @param {{
* patch?: boolean,
* wait?: boolean,
* } & Object.<string, *>} [options] Options.
* @returns {Promise<Object.<string, *>>}
* @fires Model#sync
* @copyright Credits to Backbone.js.
*/
save(attributes, options) {
options = {...options};
if (attributes && !options.wait) {
this.setMultiple(attributes, options);
}
const success = options.success;
const setAttributes = this.attributes;
options.success = response => {
this.attributes = setAttributes;
let responseAttributes = this.prepareAttributes(response, options);
if (options.wait) {
responseAttributes = {...setAttributes, ...responseAttributes};
}
if (responseAttributes) {
this.setMultiple(responseAttributes, options);
}
if (success) {
success.call(options.context, this, response, options);
}
this.trigger('sync', this, response, options);
};
const error = options.error;
options.error = response => {
if (error) {
error.call(options.context, this, response, options);
}
this.trigger('error', this, response, options);
};
if (attributes && options.wait) {
// Set temporary attributes to properly find new IDs.
this.attributes = {...setAttributes, ...attributes};
}
let method = this.isNew() ?
'create' :
(options.patch ? 'patch' : 'update');
if (method === 'patch') {
options.attributes = attributes;
}
const result = this.sync(method, this, options);
this.attributes = setAttributes;
return result;
}
/**
* Delete the record in the backend.
*
* @param {{wait: boolean} & Object.<string, *>} [options] Options.
* @returns {Promise}
* @fires Model#sync
* @copyright Credits to Backbone.js.
*/
destroy(options) {
options = _.clone(options || {});
let success = options.success;
const destroy = () => {
this.stopListening();
this.trigger('destroy', this, this.collection, options);
};
options.success = response => {
if (options.wait) {
destroy();
}
if (success) {
success.call(options.context, this, response, options);
}
if (!this.isNew()) {
this.trigger('sync', this, response, options);
}
};
if (this.isNew()) {
_.defer(options.success);
if (!options.wait) {
destroy();
}
return Promise.resolve();
}
let error = options.error;
options.error = response => {
if (error) {
error.call(options.context, this, response, options);
}
this.trigger('error', this, response, options);
};
let result = this.sync('delete', this, options);
if (!options.wait) {
destroy();
}
return result;
}
/**
* Compose a URL for syncing.
*
* @protected
* @return {string}
*/
composeSyncUrl() {
if (this.url) {
return this.url;
}
let urlRoot = this.urlRoot;
if (!urlRoot && this.collection) {
urlRoot = this.collection.urlRoot
}
if (!urlRoot) {
throw new Error("No urlRoot.");
}
if (this.isNew()) {
return urlRoot;
}
let id = this.get(this.idAttribute);
return urlRoot.replace(/[^\/]$/, '$&/') + encodeURIComponent(id);
}
// noinspection JSUnusedLocalSymbols
/**
* Prepare attributes.
*
* @param {*} response A response from the backend.
* @param {Object.<string, *>} options Options.
* @return {*} Attributes.
* @internal
*/
prepareAttributes(response, options) {
return response;
}
/**
* Clone.
*
* @return {Model}
*/
clone() {
return new this.constructor(
Espo.Utils.cloneDeep(this.attributes),
{
entityType: this.entityType,
urlRoot: this.urlRoot,
url: this.url,
defs: this.defs,
dateTime: this.dateTime,
}
);
}
/**
* Set defs.
*
* @param {module:model~defs} defs
*/
setDefs(defs) {
this.defs = defs || {};
if (!this.defs.fields) {
this.defs.fields = {};
}
}
/**
* Get cloned attribute values.
*
* @returns {Object.<string, *>}
*/
getClonedAttributes() {
return Espo.Utils.cloneDeep(this.attributes);
}
/**
* Populate default values.
*/
populateDefaults() {
let defaultHash = {};
const fieldDefs = this.defs.fields;
for (let field in fieldDefs) {
let defaultValue = this.getFieldParam(field, 'default');
if (defaultValue !== null) {
try {
defaultValue = this.parseDefaultValue(defaultValue);
defaultHash[field] = defaultValue;
}
catch (e) {
console.error(e);
}
}
let defaultAttributes = this.getFieldParam(field, 'defaultAttributes');
if (defaultAttributes) {
for (let attribute in defaultAttributes) {
defaultHash[attribute] = defaultAttributes[attribute];
}
}
}
defaultHash = Espo.Utils.cloneDeep(defaultHash);
for (let attr in defaultHash) {
if (this.has(attr)) {
delete defaultHash[attr];
}
}
this.set(defaultHash, {silent: true});
}
/**
* @protected
* @param {*} defaultValue
* @returns {*}
*/
parseDefaultValue(defaultValue) {
if (
typeof defaultValue === 'string' &&
defaultValue.indexOf('javascript:') === 0
) {
let code = defaultValue.substring(11);
defaultValue = (new Function( "with(this) { " + code + "}")).call(this);
}
return defaultValue;
}
/**
* Get a link multiple column value.
*
* @param {string} field
* @param {string} column
* @param {string} id
* @returns {*}
*/
getLinkMultipleColumn(field, column, id) {
return ((this.get(field + 'Columns') || {})[id] || {})[column];
}
/**
* Set relate data (when creating a related record).
*
* @param {Object} data
*/
setRelate(data) {
let setRelate = options => {
let link = options.link;
let model = /** @type {module:model} */options.model;
if (!link || !model) {
throw new Error('Bad related options');
}
let type = this.defs.links[link].type;
switch (type) {
case 'belongsToParent':
this.set(link + 'Id', model.id);
this.set(link + 'Type', model.entityType);
this.set(link + 'Name', model.get('name'));
break;
case 'belongsTo':
this.set(link + 'Id', model.id);
this.set(link + 'Name', model.get('name'));
break;
case 'hasMany':
let ids = [];
ids.push(model.id);
let names = {};
names[model.id] = model.get('name');
this.set(link + 'Ids', ids);
this.set(link + 'Names', names);
break;
}
};
if (Object.prototype.toString.call(data) === '[object Array]') {
data.forEach(options => {
setRelate(options);
});
return;
}
setRelate(data);
}
/**
* Get a field type.
*
* @param {string} field
* @returns {string|null}
*/
getFieldType(field) {
if (!this.defs || !this.defs.fields) {
return null;
}
if (field in this.defs.fields) {
return this.defs.fields[field].type || null;
}
return null;
}
/**
* Get a field param.
*
* @param {string} field
* @param {string} param
* @returns {*}
*/
getFieldParam(field, param) {
if (!this.defs || !this.defs.fields) {
return null;
}
if (field in this.defs.fields) {
if (param in this.defs.fields[field]) {
return this.defs.fields[field][param];
}
}
return null;
}
/**
* Get a link type.
*
* @param {string} link
* @returns {string|null}
*/
getLinkType(link) {
if (!this.defs || !this.defs.links) {
return null;
}
if (link in this.defs.links) {
return this.defs.links[link].type || null;
}
return null;
}
/**
* Get a link param.
*
* @param {string} link A link.
* @param {string} param A param.
* @returns {*}
*/
getLinkParam(link, param) {
if (!this.defs || !this.defs.links) {
return null;
}
if (link in this.defs.links) {
if (param in this.defs.links[link]) {
return this.defs.links[link][param];
}
}
return null;
}
/**
* Is a field read-only.
*
* @param {string} field A field.
* @returns {bool}
*/
isFieldReadOnly(field) {
return this.getFieldParam(field, 'readOnly') || false;
}
/**
* If a field required.
*
* @param {string} field A field.
* @returns {bool}
*/
isRequired(field) {
return this.getFieldParam(field, 'required') || false;
}
/**
* Get IDs of a link-multiple field.
*
* @param {string} field A link-multiple field name.
* @returns {string[]}
*/
getLinkMultipleIdList(field) {
return this.get(field + 'Ids') || [];
}
/**
* Get team IDs.
*
* @returns {string[]}
*/
getTeamIdList() {
return this.get('teamsIds') || [];
}
/**
* Whether it has a field.
*
* @param {string} field A field.
* @returns {boolean}
*/
hasField(field) {
return ('defs' in this) && ('fields' in this.defs) && (field in this.defs.fields);
}
/**
* Whether has a link.
*
* @param {string} link A link.
* @returns {boolean}
*/
hasLink(link) {
return ('defs' in this) && ('links' in this.defs) && (link in this.defs.links);
}
/**
* @returns {boolean}
*/
isEditable() {
return true;
}
/**
* @returns {boolean}
*/
isRemovable() {
return true;
}
/**
* Get an entity type.
*
* @returns {string}
*/
getEntityType() {
return this.name;
}
/**
* Abort the last fetch.
*/
abortLastFetch() {
if (this.lastSyncPromise && this.lastSyncPromise.getReadyState() < 4) {
this.lastSyncPromise.abort();
}
}
// noinspection JSUnusedGlobalSymbols
/**
* @deprecated Use `getClonedAttributes`.
* @todo Remove in v9.0.
* @return {Object.<string, *>}
*/
toJSON() {
console.warn(`model.toJSON is deprecated. Use 'getClonedAttributes' instead.`);
return this.getClonedAttributes();
}
}
Object.assign(Model.prototype, Events);
Model.extend = BullView.extend;
export default Model;