mirror of
https://github.com/espocrm/espocrm.git
synced 2026-06-28 23:16:03 +00:00
580 lines
17 KiB
JavaScript
580 lines
17 KiB
JavaScript
/************************************************************************
|
||
* This file is part of EspoCRM.
|
||
*
|
||
* EspoCRM – Open Source CRM application.
|
||
* Copyright (C) 2014-2026 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 <https://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 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';
|
||
import ActionItemSetup from 'helpers/action-item-setup';
|
||
|
||
/**
|
||
* 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().hasChanged()) {
|
||
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 {import('model').SetRelateItem | import('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;
|
||
}
|
||
|
||
await this.setupLate();
|
||
|
||
await model.fetch();
|
||
|
||
if (!this.headerText) {
|
||
this.headerHtml = this.composeHeaderHtml();
|
||
}
|
||
|
||
this.setupActionItems();
|
||
this.createRecordView(model);
|
||
|
||
return;
|
||
}
|
||
|
||
this.model = model;
|
||
|
||
await new DefaultsPopulator().populate(model);
|
||
|
||
if (this.options.relate) {
|
||
model.setRelate(this.options.relate);
|
||
}
|
||
|
||
if (this.options.attributes) {
|
||
model.set(this.options.attributes);
|
||
}
|
||
|
||
await this.setupLate();
|
||
|
||
if (!this.headerText) {
|
||
this.headerHtml = this.composeHeaderHtml();
|
||
}
|
||
|
||
this.setupActionItems();
|
||
this.createRecordView(model);
|
||
});
|
||
|
||
this.listenTo(this.model, 'change', (m, o) => {
|
||
if (o.ui) {
|
||
this.wasModified = true;
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Additional setup with the model ready.
|
||
*
|
||
* @protected
|
||
* @since 9.3.0
|
||
*/
|
||
async setupLate() {}
|
||
|
||
/**
|
||
* @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 {import('views/record/edit').default}
|
||
*/
|
||
getRecordView() {
|
||
return /** @type {import('views/record/edit').default} */this.getView('edit');
|
||
}
|
||
|
||
onBackdropClick() {
|
||
if (this.getRecordView().hasChanged()) {
|
||
return;
|
||
}
|
||
|
||
this.close();
|
||
}
|
||
|
||
/**
|
||
* @protected
|
||
* @return {string}
|
||
*/
|
||
composeHeaderHtml() {
|
||
let html;
|
||
|
||
if (!this.id) {
|
||
html = $('<span>')
|
||
.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 =
|
||
$('<a>')
|
||
.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}`;
|
||
let name = model.attributes[this.nameAttribute];
|
||
|
||
if (name === undefined) {
|
||
name = this.translate(this.scope, 'scopeNames');
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
/**
|
||
* @private
|
||
*/
|
||
setupActionItems() {
|
||
const actionItemSetup = new ActionItemSetup();
|
||
|
||
actionItemSetup.setup({
|
||
view: this,
|
||
type: 'modalEditActionList',
|
||
waitFunc: promise => this.wait(promise),
|
||
addFunc: item => this.addDropdownItem(item),
|
||
showFunc: name => this.showActionItem(name),
|
||
hideFunc: name => this.hideActionItem(name),
|
||
});
|
||
|
||
actionItemSetup.setup({
|
||
view: this,
|
||
type: 'recordControls.modalEditSide.buttons',
|
||
waitFunc: promise => this.wait(promise),
|
||
addFunc: item => {
|
||
this.addButton({
|
||
...item,
|
||
position: 'right',
|
||
})
|
||
},
|
||
showFunc: name => this.showActionItem(name),
|
||
hideFunc: name => this.hideActionItem(name),
|
||
enableFunc: name => this.enableButton(name),
|
||
disableFunc: name => this.disableButton(name),
|
||
});
|
||
}
|
||
}
|
||
|
||
export default EditModalView;
|