/************************************************************************ * 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;