/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*
* 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 Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
/** @module controllers/record */
import Controller from 'controller';
/**
* A record controller.
* @template {module:model} TModel
*/
class RecordController extends Controller {
/** @inheritDoc */
defaultAction = 'list'
constructor(params, injections) {
super(params, injections);
/**
* @private
* @type {Object}
*/
this.collectionMap = {};
}
/** @inheritDoc */
checkAccess(action) {
if (this.getAcl().check(this.name, action)) {
return true;
}
return false;
}
/**
* Get a view name/path.
*
* @protected
* @param {'list'|'detail'|'edit'|'create'|'listRelated'|string} type A type.
* @returns {string}
*/
getViewName(type) {
return this.getMetadata().get(['clientDefs', this.name, 'views', type]) ||
'views/' + Espo.Utils.camelCaseToHyphen(type);
}
// noinspection JSUnusedGlobalSymbols
beforeList() {
this.handleCheckAccess('read');
}
/**
* @param {{
* isReturn?: boolean,
* primaryFilter?: string,
* } | Record} options
*/
actionList(options) {
const isReturn = options.isReturn || this.getRouter().backProcessed;
let key = 'list';
if (options.primaryFilter) {
key += 'Filter' + Espo.Utils.upperCaseFirst(options.primaryFilter);
}
if (!isReturn && this.getStoredMainView(key)) {
this.clearStoredMainView(key);
}
this.getCollection().then(collection => {
const mediator = {};
const abort = () => {
collection.abortLastFetch();
mediator.abort = true;
Espo.Ui.notify(false);
};
this.listenToOnce(this.baseController, 'action', abort);
this.listenToOnce(collection, 'sync', () => this.stopListening(this.baseController, 'action', abort));
const viewOptions = {
scope: this.name,
collection: collection,
params: options,
mediator: mediator,
};
const viewName = this.getViewName('list');
const params = {
useStored: isReturn,
key: key,
};
this.main(viewName, viewOptions, null, params);
});
}
beforeView() {
this.handleCheckAccess('read');
}
/**
* @protected
* @param {{
* returnUrl?: string,
* returnDispatchParams?: Record,
* } | Record} options
* @param {module:model} model
* @param {string|null} [view]
*/
createViewView(options, model, view) {
view = view || this.getViewName('detail');
this.main(view, {
scope: this.name,
model: model,
returnUrl: options.returnUrl,
returnDispatchParams: options.returnDispatchParams,
params: options,
});
}
/**
* @protected
* @param {module:model} model
* @param {Record} options
*/
prepareModelView(model, options) {}
// noinspection JSUnusedGlobalSymbols
/**
* @param {{
* model?: module:model,
* id?: string,
* isReturn?: boolean,
* isAfterCreate?: boolean,
* } | Record} options
*/
actionView(options) {
const id = options.id;
const isReturn = this.getRouter().backProcessed;
if (isReturn) {
if (this.lastViewActionOptions && this.lastViewActionOptions.id === id) {
options = Espo.Utils.clone(this.lastViewActionOptions);
if (options.model && options.model.get('deleted')) {
delete options.model;
}
}
options.isReturn = true;
}
else {
delete this.lastViewActionOptions;
}
this.lastViewActionOptions = options;
const createView = model => {
this.prepareModelView(model, options);
this.createViewView.call(this, options, model);
};
if ('model' in options) {
const model = options.model;
createView(model);
this.showLoadingNotification();
model.fetch()
.then(() => this.hideLoadingNotification())
.catch(xhr => {
if (
xhr.status === 403 &&
options.isAfterCreate
) {
this.hideLoadingNotification();
xhr.errorIsHandled = true;
model.trigger('fetch-forbidden');
}
});
this.listenToOnce(this.baseController, 'action', () => {
model.abortLastFetch();
this.hideLoadingNotification();
});
return;
}
this.getModel().then(model => {
model.id = id;
this.showLoadingNotification();
model.fetch({main: true})
.then(() => {
this.hideLoadingNotification();
if (model.get('deleted')) {
this.listenToOnce(model, 'after:restore-deleted', () => {
createView(model);
});
this.prepareModelView(model, options);
this.createViewView(options, model, 'views/deleted-detail');
return;
}
createView(model);
});
this.listenToOnce(this.baseController, 'action', () => {
model.abortLastFetch();
});
});
}
// noinspection JSUnusedGlobalSymbols
beforeCreate() {
this.handleCheckAccess('create');
}
// noinspection JSUnusedLocalSymbols
/**
* @protected
* @param {module:model} model
* @param {Record} options
*/
prepareModelCreate(model, options) {
this.listenToOnce(model, 'before:save', () => {
const key = 'list';
const stored = this.getStoredMainView(key);
if (!stored) {
return;
}
if (!('storeViewAfterCreate' in stored) || !stored.storeViewAfterCreate) {
this.clearStoredMainView(key);
}
});
this.listenToOnce(model, 'after:save', () => {
const key = 'list';
const stored = this.getStoredMainView(key);
if (!stored) {
return;
}
if (!('storeViewAfterCreate' in stored) || !stored.storeViewAfterCreate) {
return;
}
if (!('collection' in stored) || !stored.collection) {
return;
}
this.listenToOnce(stored, 'after:render', () => stored.collection.fetch());
});
}
/**
* @param {{
* options?: Record,
* relate?: model:model~setRelateItem | model:model~setRelateItem[],
* returnUrl?: string,
* returnDispatchParams?: Record,
* attributes?: Record,
* } | Record} [options]
*/
create(options) {
options = options || {};
const optionsOptions = options.options || {};
this.getModel().then(model => {
if (options.relate) {
model.setRelate(options.relate);
}
const o = {
scope: this.name,
model: model,
returnUrl: options.returnUrl,
returnDispatchParams: options.returnDispatchParams,
params: options,
};
for (const k in optionsOptions) {
o[k] = optionsOptions[k];
}
if (options.attributes) {
model.set(options.attributes);
}
this.prepareModelCreate(model, options);
this.main(this.getViewName('edit'), o);
});
}
/**
* @param {{
* options?: Record,
* relate?: model:model~setRelateItem | model:model~setRelateItem[],
* returnUrl?: string,
* returnDispatchParams?: Record,
* options?: Record,
* attributes?: Record,
* } | Record} [options]
*/
actionCreate(options) {
this.create(options);
}
// noinspection JSUnusedGlobalSymbols
beforeEdit() {
this.handleCheckAccess('edit');
}
// noinspection JSUnusedLocalSymbols
/**
* @protected
* @param {module:model} model
* @param {Record} options
*/
prepareModelEdit(model, options) {
this.listenToOnce(model, 'before:save', () => {
const key = 'list';
const stored = this.getStoredMainView(key);
if (!stored) {
return;
}
if (!('storeViewAfterUpdate' in stored) || !stored.storeViewAfterUpdate) {
this.clearStoredMainView(key);
}
});
}
/**
* @param {{
* id: string,
* options?: Record,
* model?: import('model').default,
* returnUrl?: string,
* attributes?: Record,
* highlightFieldList?: string[],
* returnDispatchParams?: Record,
* }} options
*/
actionEdit(options) {
const id = options.id;
const optionsOptions = options.options || {};
this.getModel().then(model => {
model.id = id;
if (options.model) {
model = options.model;
}
this.prepareModelEdit(model, options);
this.showLoadingNotification();
model
.fetch({main: true})
.then(() => {
this.hideLoadingNotification();
const o = {
scope: this.name,
model: model,
returnUrl: options.returnUrl,
returnDispatchParams: options.returnDispatchParams,
params: options,
};
for (const k in optionsOptions) {
o[k] = optionsOptions[k];
}
if (options.attributes) {
o.attributes = options.attributes;
}
if (options.highlightFieldList) {
o.highlightFieldList = options.highlightFieldList;
}
this.main(this.getViewName('edit'), o);
});
this.listenToOnce(this.baseController, 'action', () => {
model.abortLastFetch();
});
});
}
// noinspection JSUnusedGlobalSymbols
beforeMerge() {
this.handleCheckAccess('edit');
}
// noinspection JSUnusedGlobalSymbols
/**
* @param {{
* ids: string,
* collection: import('collection').default,
* }} options
*/
actionMerge(options) {
const ids = options.ids.split(',');
this.getModel().then((model) => {
const models = [];
const proceed = () => {
this.main('views/merge', {
models: models,
scope: this.name,
collection: options.collection
});
};
let i = 0;
ids.forEach(id => {
const current = model.clone();
current.id = id;
models.push(current);
this.listenToOnce(current, 'sync', () => {
i++;
if (i === ids.length) {
proceed();
}
});
current.fetch();
});
});
}
// noinspection JSUnusedGlobalSymbols
/**
* @param {{
* id: string,
* link: string,
* }} options
*/
actionRelated(options) {
const id = options.id;
const link = options.link;
const viewName = this.getViewName('listRelated');
let model;
this.getModel()
.then(m => {
model = m;
model.id = id;
return model.fetch({main: true});
})
.then(() => {
const foreignEntityType = model.getLinkParam(link, 'entity');
if (!foreignEntityType) {
this.baseController.error404();
throw new Error(`Bad link '${link}'.`);
}
return this.collectionFactory.create(foreignEntityType);
})
.then(collection => {
collection.url = model.entityType + '/' + id + '/' + link;
this.main(viewName, {
scope: this.name,
model: model,
collection: collection,
link: link,
});
})
}
/**
* Get a collection for the current controller.
*
* @protected
* @param {boolean} [usePreviouslyFetched=false] Use a previously fetched. @todo Revise.
* @return {Promise}
*/
getCollection(usePreviouslyFetched) {
if (!this.name) {
throw new Error('No collection for unnamed controller');
}
const entityType = this.entityType || this.name;
if (usePreviouslyFetched && entityType in this.collectionMap) {
const collection = this.collectionMap[entityType];
return Promise.resolve(collection);
}
return this.collectionFactory.create(entityType, collection => {
this.collectionMap[entityType] = collection;
this.listenTo(collection, 'sync', () => collection.isFetched = true);
});
}
/**
* Get a model for the current controller.
*
* @protected
* @param {Function} [callback]
* @param {Object} [context]
* @return {Promise}
*/
getModel(callback, context) {
context = context || this;
if (!this.name) {
throw new Error('No collection for unnamed controller');
}
const modelName = this.entityType || this.name;
return this.modelFactory.create(modelName, model => {
if (callback) {
callback.call(context, model);
}
});
}
}
export default RecordController;