/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* 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 views/modals/edit */
import ModalView from 'views/modal';
import Backbone from 'backbone';
import DefaultsPopulator from 'helpers/model/defaults-populator';
/**
* A quick edit modal.
*/
class EditModalView extends ModalView {
template = 'modals/edit'
cssName = 'edit-modal'
/** @protected */
saveDisabled = false
/** @protected */
fullFormDisabled = false
/** @protected */
editView = null
escapeDisabled = true
className = 'dialog dialog-record'
/** @protected */
sideDisabled = false
/** @protected */
bottomDisabled = false
isCollapsible = true
/**
* @private
* @type {boolean}
*/
wasModified = false
/**
* @private
* @type {string}
*/
nameAttribute
shortcutKeys = {
/** @this EditModalView */
'Control+Enter': function (e) {
if (this.saveDisabled) {
return;
}
if (this.buttonList.findIndex(item => item.name === 'save' && !item.hidden && !item.disabled) === -1) {
return;
}
e.preventDefault();
e.stopPropagation();
if (document.activeElement instanceof HTMLInputElement) {
// Fields may need to fetch data first.
document.activeElement.dispatchEvent(new Event('change', {bubbles: true}));
}
this.actionSave();
},
/** @this EditModalView */
'Control+KeyS': function (e) {
if (this.saveDisabled) {
return;
}
if (this.buttonList.findIndex(item => item.name === 'save' && !item.hidden && !item.disabled) === -1) {
return;
}
e.preventDefault();
e.stopPropagation();
this.actionSaveAndContinueEditing();
},
/** @this EditModalView */
'Escape': async function (e) {
if (this.saveDisabled) {
return;
}
e.stopPropagation();
e.preventDefault();
// Refactor the following code so that it can be easily re-used in other edit modals.
const focusedFieldView = this.getRecordView().getFocusedFieldView();
if (focusedFieldView) {
this.model.set(focusedFieldView.fetch(), {skipReRender: true});
}
if (!this.getRecordView().isChanged) {
this.actionClose();
return;
}
await this.confirm(this.translate('confirmLeaveOutMessage', 'messages'));
this.actionClose();
},
/** @this EditModalView */
'Control+Backslash': function (e) {
this.getRecordView().handleShortcutKeyControlBackslash(e);
},
}
/**
* @typedef {Record} module:views/modals/edit~options
*
* @property {string} entityType An entity type.
* @property {string} [id] An ID.
* @property {string} [layoutName] A layout name.
* @property {Record} [attributes] Attributes.
* @property {model:model~setRelateItem | model:model~setRelateItem[]} [relate] A relate data.
* @property {import('view-record-helper')} [recordHelper] A record helper.
* @property {boolean} [saveDisabled] Disable save.
* @property {boolean} [fullFormDisabled] Disable full-form.
* @property {string} [headerText] A header text.
* @property {boolean} [focusForCreate] Focus for create.
* @property {string} [rootUrl] A root URL.
* @property {string} [returnUrl] A return URL.
* @property {{
* controller: string,
* action: string|null,
* options: {isReturn?: boolean} & Record,
* }} [returnDispatchParams] Return dispatch params.
* @property {string} [fullFormUrl] A full-form URL. As of v9.0.
*/
/**
* @param {module:views/modals/edit~options} options
*/
constructor(options) {
super(options);
}
setup() {
this.buttonList = [];
if ('saveDisabled' in this.options) {
this.saveDisabled = this.options.saveDisabled;
}
if (!this.saveDisabled) {
this.buttonList.push({
name: 'save',
label: 'Save',
style: 'primary',
title: 'Ctrl+Enter',
onClick: () => this.actionSave(),
});
}
this.fullFormDisabled = this.options.fullFormDisabled || this.fullFormDisabled;
this.layoutName = this.options.layoutName || this.layoutName;
if (!this.fullFormDisabled) {
this.buttonList.push({
name: 'fullForm',
label: 'Full Form',
onClick: () => this.actionFullForm(),
});
}
this.buttonList.push({
name: 'cancel',
label: 'Cancel',
title: 'Esc',
});
this.scope = this.scope || this.options.scope || this.options.entityType;
this.entityType = this.options.entityType || this.scope;
this.id = this.options.id;
this.nameAttribute = this.getMetadata().get(`clientDefs.${this.entityType}.nameAttribute`) || 'name';
if (this.options.headerText !== undefined) {
this.headerHtml = undefined;
this.headerText = this.options.headerText;
}
this.sourceModel = this.model;
this.waitForView('edit');
this.getModelFactory().create(this.entityType, async model => {
if (this.id) {
if (this.sourceModel) {
model = this.model = this.sourceModel.clone();
} else {
this.model = model;
model.id = this.id;
}
model.fetch()
.then(() => {
if (!this.headerText) {
this.headerHtml = this.composeHeaderHtml();
}
this.createRecordView(model);
});
return;
}
await new DefaultsPopulator().populate(model);
this.model = model;
if (this.options.relate) {
model.setRelate(this.options.relate);
}
if (this.options.attributes) {
model.set(this.options.attributes);
}
if (!this.headerText) {
this.headerHtml = this.composeHeaderHtml();
}
this.createRecordView(model);
});
this.listenTo(this.model, 'change', (m, o) => {
if (o.ui) {
this.wasModified = true;
}
});
}
/**
* @param {module:model} model
* @param {function} [callback]
*/
createRecordView(model, callback) {
const viewName =
this.editView ||
this.getMetadata().get(['clientDefs', model.entityType, 'recordViews', 'editSmall']) ||
this.getMetadata().get(['clientDefs', model.entityType, 'recordViews', 'editQuick']) ||
'views/record/edit-small';
const options = {
model: model,
fullSelector: this.containerSelector + ' .edit-container',
type: 'editSmall',
layoutName: this.layoutName || 'detailSmall',
buttonsDisabled: true,
sideDisabled: this.sideDisabled,
bottomDisabled: this.bottomDisabled,
focusForCreate: this.options.focusForCreate,
recordHelper: this.options.recordHelper,
webSocketDisabled: true,
exit: () => {},
};
this.handleRecordViewOptions(options);
this.createView('edit', viewName, options, callback)
.then(/** import('views/fields/base').default */view => {
this.listenTo(view, 'before:save', () => this.trigger('before:save', model));
if (this.options.relate && ('link' in this.options.relate)) {
const link = this.options.relate.link;
if (
model.hasField(link) &&
['link'].includes(model.getFieldType(link))
) {
view.setFieldReadOnly(link);
}
}
});
}
handleRecordViewOptions(options) {}
/**
* @return {module:views/record/edit}
*/
getRecordView() {
return this.getView('edit');
}
onBackdropClick() {
if (this.getRecordView().isChanged) {
return;
}
this.close();
}
/**
* @protected
* @return {string}
*/
composeHeaderHtml() {
let html;
if (!this.id) {
html = $('')
.text(this.getLanguage().translate('Create ' + this.scope, 'labels', this.scope))
.get(0).outerHTML;
} else {
const wrapper = document.createElement('span');
const scope = document.createElement('span');
scope.textContent = this.getLanguage().translate(this.scope, 'scopeNames');
const separator = document.createElement('span');
separator.classList.add('chevron-right');
const name = this.model.attributes[this.nameAttribute];
wrapper.append(
document.createTextNode(this.getLanguage().translate('Edit') + ' · '),
scope
);
if (name) {
wrapper.append(
' ',
separator,
' ',
name
);
}
html = wrapper.outerHTML;
}
if (!this.fullFormDisabled) {
const url = this.id ?
'#' + this.scope + '/edit/' + this.id :
'#' + this.scope + '/create';
html =
$('')
.attr('href', url)
.addClass('action')
.attr('title', this.translate('Full Form'))
.attr('data-action', 'fullForm')
.append(html)
.get(0).outerHTML;
}
html = this.getHelper().getScopeColorIconHtml(this.scope) + html;
return html;
}
/**
* @protected
* @param {{bypassClose?: boolean}} [data]
*/
async actionSave(data = {}) {
const editView = this.getRecordView();
const model = editView.model;
const disabledButtons = [];
this.buttonList.forEach(it => {
if (!it.name || it.disabled) {
return;
}
this.disableButton(it.name);
disabledButtons.push(it.name);
});
try {
await editView.save();
} catch (e) {
disabledButtons.forEach(it => this.enableButton(it));
return;
}
const wasNew = !this.id;
if (wasNew) {
this.id = model.id;
}
this.trigger('after:save', model, {bypassClose: data.bypassClose});
if (!data.bypassClose) {
this.dialog.close();
if (wasNew) {
const url = `#${this.scope}/view/${model.id}`;
const name = model.attributes[this.nameAttribute] || this.model.id;
const msg = this.translate('Created') + '\n' +
`[${name}](${url})`;
Espo.Ui.notify(msg, 'success', 4000, {suppress: true});
}
return;
}
$(this.containerElement).find('.modal-header .modal-title-text')
.html(this.composeHeaderHtml());
disabledButtons.forEach(it => this.enableButton(it));
}
actionSaveAndContinueEditing() {
this.actionSave({bypassClose: true});
}
actionFullForm() {
let url;
const router = this.getRouter();
let attributes;
let model;
let options;
if (!this.id) {
url = this.options.fullFormUrl || `#${this.scope}/create`;
attributes = this.getRecordView().fetch();
model = this.getRecordView().model;
attributes = {...attributes, ...model.getClonedAttributes()};
options = {
attributes: attributes,
relate: this.options.relate,
returnUrl: this.options.returnUrl || Backbone.history.fragment,
returnDispatchParams: this.options.returnDispatchParams || null,
};
if (this.options.rootUrl) {
options.rootUrl = this.options.rootUrl;
}
setTimeout(() => {
router.dispatch(this.scope, 'create', options);
router.navigate(url, {trigger: false});
}, 10);
}
else {
url = this.options.fullFormUrl || `#${this.scope}/edit/${this.id}`;
attributes = this.getRecordView().fetch();
model = this.getRecordView().model;
attributes = {...attributes, ...model.getClonedAttributes()};
options = {
attributes: attributes,
returnUrl: this.options.returnUrl || Backbone.history.fragment,
returnDispatchParams: this.options.returnDispatchParams || null,
model: this.sourceModel,
id: this.id,
};
if (this.options.rootUrl) {
options.rootUrl = this.options.rootUrl;
}
setTimeout(() => {
router.dispatch(this.scope, 'edit', options);
router.navigate(url, {trigger: false});
}, 10);
}
this.trigger('leave');
this.dialog.close();
}
async beforeCollapse() {
if (this.wasModified) {
this.getRecordView().setConfirmLeaveOut(false);
this.getRouter().addWindowLeaveOutObject(this);
}
}
afterExpand() {
if (this.wasModified) {
this.getRecordView().setConfirmLeaveOut(true);
}
this.getRouter().removeWindowLeaveOutObject(this);
}
}
export default EditModalView;