This repository has been archived on 2026-07-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
espocrm-base/client/src/views/fields/base.js
T
2025-06-19 20:37:19 +03:00

1716 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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 <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/fields/base */
import View from 'view';
import Select from 'ui/select';
import $ from 'jquery';
/**
* A base field view. Can be in different modes. Each mode uses a separate template.
*
* @todo Document events.
* @template TParams
*/
class BaseFieldView extends View {
/**
* @typedef {Object} module:views/fields/base~options
* @property {string} name A field name.
* @property {module:model} [model] A model.
* @property {module:views/fields/base~params | Object.<string, *>} [params] Parameters.
* @property {boolean} [inlineEditDisabled] Disable inline edit.
* @property {boolean} [readOnly] Read-only.
* @property {string} [labelText] A custom label text.
* @property {'detail'|'edit'|'list'|'search'} [mode] A mode.
* @property {import('view-record-helper').default} [recordHelper] A record helper.
*/
/**
* @typedef {Object} module:views/fields/base~params
* @property {boolean} [inlineEditDisabled] Disable inline edit.
* @property {boolean} [readOnly] Read-only.
*/
/**
* @param {module:views/fields/base~options | Object.<string, *>} options Options.
*/
constructor(options) {
super(options);
this.name = options.name;
this.labelText = options.labelText;
}
/**
* A field type.
*
* @type {string}
*/
type = 'base'
/**
* List mode template.
*
* @protected
* @type {string}
*/
listTemplate = 'fields/base/list'
// noinspection JSUnusedGlobalSymbols
/**
* List-link mode template.
*
* @protected
* @type {string}
*/
listLinkTemplate = 'fields/base/list-link'
/**
* Detail mode template.
*
* @protected
* @type {string}
*/
detailTemplate = 'fields/base/detail'
/**
* Edit mode template.
*
* @protected
* @type {string}
*/
editTemplate = 'fields/base/edit'
/**
* Search mode template.
*
* @protected
* @type {string}
*/
searchTemplate = 'fields/base/search'
// noinspection JSUnusedGlobalSymbols
/**
* @protected
* @type {string}
*/
listTemplateContent
// noinspection JSUnusedGlobalSymbols
/**
* @protected
* @type {string}
*/
detailTemplateContent
// noinspection JSUnusedGlobalSymbols
/**
* @protected
* @type {string}
*/
editTemplateContent
/**
* A validation list. A function returning true if non-valid, or a name.
* For the latter, there should be a `validate{Name}` method in the class.
*
* Functions are supported as of v8.3.
*
* @type {Array<(function (): boolean)|string>}
*/
validations = ['required']
/**
* @const
*/
MODE_LIST = 'list'
/**
* @const
*/
MODE_LIST_LINK = 'listLink'
/**
* @const
*/
MODE_DETAIL = 'detail'
/**
* @const
*/
MODE_EDIT = 'edit'
/**
* @const
*/
MODE_SEARCH = 'search'
/**
* A field name.
*
* @type {string}
*/
name
/**
* A field parameter list. To be used for custom fields not defined in metadata > fields.
*
* @type {string[]}
* @since 9.0.0
*/
paramList
/**
* Definitions.
*
* @type {Object}
*/
defs = null
/**
* Field params.
*
* @type {TParams & module:views/fields/base~params & Object.<string, *>}
*/
params = null
/**
* A mode.
*
* @type {'list'|'listLink'|'detail'|'edit'|'search'}
*/
mode = 'detail'
/**
* Search params.
*
* @type {Object.<string,*>|null}
*/
searchParams = null
/**
* Inline edit disabled.
*
* @type {boolean}
*/
inlineEditDisabled = false
/**
* Field is disabled.
*
* @type {boolean}
*/
disabled = false
/**
* Field is read-only.
*
* @type {boolean}
*/
readOnly = false
/**
* A label text.
*
* @type {string}
* @protected
*/
labelText
/**
* @type {string[]|null}
*/
attributeList = null
/**
* Attribute values before edit.
*
* @type {Object.<string, *>|{}}
*/
initialAttributes = null
/**
* @const
*/
VALIDATION_POPOVER_TIMEOUT = 3000
/**
* @type {(function():boolean)}
* @private
* @internal
*/
validateCallback
/**
* An element selector to point validation popovers to.
*
* @type {string}
* @protected
*/
validationElementSelector
/**
* A view-record helper.
*
* @type {module:view-record-helper}
*/
recordHelper
/**
* @type {JQuery|null}
* @private
* @internal
*/
$label = null
/**
* A form element.
*
* @type {JQuery|null}
* @protected
*/
$element = null
/**
* Is searchable once a search filter is added (no need to type or selecting anything).
* Actual for search mode.
*
* @public
* @type {boolean}
*/
initialSearchIsNotIdle = false
/**
* An entity type.
*
* @private
* @type {string|null}
*/
entityType = null
/**
* A last validation message;
*
* @type {?string}
*/
lastValidationMessage = null
/**
* Additional data.
*
* @type {Object.<string, *>}
*/
dataObject
/**
* Is the field required.
*
* @returns {boolean}
*/
isRequired() {
return this.params.required;
}
/**
* Get a cell element. Available only after the view is rendered.
*
* @private
* @returns {JQuery}
*/
get$cell() {
return this.$el.parent();
}
/**
* @protected
* @returns {HTMLElement|null}
*/
getCellElement() {
return this.get$cell().get(0) ?? null;
}
/**
* Is in inline-edit mode.
*
* @return {boolean}
*/
isInlineEditMode() {
return !!this._isInlineEditMode;
}
/**
* Set disabled.
*
* @param {boolean} [locked] Won't be able to set back.
*/
setDisabled(locked) {
this.disabled = true;
if (locked) {
this.disabledLocked = true;
}
}
/**
* Set not-disabled.
*/
setNotDisabled() {
if (this.disabledLocked) {
return;
}
this.disabled = false;
}
/**
* Set required.
*/
setRequired() {
this.params.required = true;
if (this.isEditMode()) {
if (this.isRendered()) {
this.showRequiredSign();
}
else {
this.once('after:render', () => {
this.showRequiredSign();
});
}
}
}
/**
* Set not required.
*/
setNotRequired() {
this.params.required = false;
this.get$cell().removeClass('has-error');
if (this.isEditMode()) {
if (this.isRendered()) {
this.hideRequiredSign();
}
else {
this.once('after:render', () => {
this.hideRequiredSign();
});
}
}
}
/**
* Set read-only.
*
* @param {boolean} [locked] Won't be able to set back.
* @return {Promise}
*/
setReadOnly(locked) {
if (this.readOnlyLocked) {
return Promise.reject();
}
this.readOnly = true;
if (locked) {
this.readOnlyLocked = true;
}
if (!this.isReady) {
this.mode = 'detail';
return Promise.resolve();
}
if (this.isEditMode()) {
if (this.isInlineEditMode()) {
return this.inlineEditClose();
}
return this.setDetailMode()
.then(() => this.reRender());
}
return Promise.resolve();
}
/**
* Set not read only.
*/
setNotReadOnly() {
if (this.readOnlyLocked) {
return;
}
this.readOnly = false;
}
/**
* Get a label element. Available only after the view is rendered.
*
* @return {JQuery}
*/
getLabelElement() {
if (this.$label && this.$label.get(0) && !document.contains(this.$label.get(0))) {
this.$label = undefined;
}
if (!this.$label || !this.$label.length) {
this.$label = this.$el.parent().children('label');
}
return this.$label;
}
/**
* Hide field and label. Available only after the view is rendered.
*/
hide() {
this.$el.addClass('hidden');
const $cell = this.get$cell();
$cell.children('label').addClass('hidden');
$cell.addClass('hidden-cell');
}
/**
* Show field and label. Available only after the view is rendered.
*/
show() {
this.$el.removeClass('hidden');
const $cell = this.get$cell();
$cell.children('label').removeClass('hidden');
$cell.removeClass('hidden-cell');
}
/**
* @inheritDoc
* @return {Record<string, *>}
*/
data() {
const data = {
scope: this.model.entityType || this.model.name,
name: this.name,
defs: this.defs,
params: this.params,
value: this.getValueForDisplay(),
};
if (this.isSearchMode()) {
data.searchParams = this.searchParams;
data.searchData = this.searchData;
data.searchValues = this.getSearchValues();
data.searchType = this.getSearchType();
data.searchTypeList = this.getSearchTypeList();
}
return data;
}
/**
* Get a value for display. Is available by using a `{value}` placeholder in templates.
*
* @return {*}
*/
getValueForDisplay() {
return this.model.get(this.name);
}
/**
* Is in list, detail or list-link mode.
*
* @returns {boolean}
*/
isReadMode() {
return this.mode === this.MODE_LIST ||
this.mode === this.MODE_DETAIL ||
this.mode === this.MODE_LIST_LINK;
}
/**
* Is in list or list-link mode.
*
* @returns {boolean}
*/
isListMode() {
return this.mode === this.MODE_LIST || this.mode === this.MODE_LIST_LINK;
}
/**
* Is in detail mode.
*
* @returns {boolean}
*/
isDetailMode() {
return this.mode === this.MODE_DETAIL;
}
/**
* Is in edit mode.
*
* @returns {boolean}
*/
isEditMode() {
return this.mode === this.MODE_EDIT;
}
/**
* Is in search mode.
*
* @returns {boolean}
*/
isSearchMode() {
return this.mode === this.MODE_SEARCH;
}
/**
* Set detail mode.
*
* @returns {Promise}
*/
setDetailMode() {
return this.setMode(this.MODE_DETAIL) || Promise.resolve();
}
/**
* Set edit mode.
*
* @returns {Promise}
*/
setEditMode() {
return this.setMode(this.MODE_EDIT) || Promise.resolve();
}
/**
* Set a mode.
*
* @internal
* @returns {Promise}
*/
setMode(mode) {
const modeIsChanged = this.mode !== mode && this.mode;
const modeBefore = this.mode;
this.mode = mode;
const property = mode + 'Template';
if (!(property in this)) {
this[property] = 'fields/' + Espo.Utils.camelCaseToHyphen(this.type) + '/' + this.mode;
}
if (!this._hasTemplateContent) {
this.setTemplate(this[property]);
}
const contentProperty = mode + 'TemplateContent';
if (!this._hasTemplateContent) {
if (contentProperty in this && this[contentProperty] != null) {
this.setTemplateContent(this[contentProperty]);
}
}
if (modeIsChanged) {
if (modeBefore) {
this.trigger('mode-changed');
}
return this._onModeSet();
}
return Promise.resolve();
}
/**
* Called on mode change and on value change before re-rendering.
* To be used for additional initialization that depends on field
* values or mode.
*
* @protected
* @returns {Promise|undefined}
*/
prepare() {}
/**
* @private
* @returns {Promise}
*/
_onModeSet() {
if (this.isListMode()) {
return this.onListModeSet() || Promise.resolve();
}
if (this.isDetailMode()) {
return this.onDetailModeSet() || Promise.resolve();
}
if (this.isEditMode()) {
return this.onEditModeSet() || Promise.resolve();
}
return Promise.resolve();
}
/**
* Additional initialization for the detail mode.
*
* @protected
* @returns {Promise|undefined}
*/
onDetailModeSet() {
return this.prepare();
}
/**
* Additional initialization for the edit mode.
*
* @protected
* @returns {Promise|undefined}
*/
onEditModeSet() {
return this.prepare();
}
/**
* Additional initialization for the list mode.
*
* @protected
* @returns {Promise|undefined}
*/
onListModeSet() {
return this.prepare();
}
/** @inheritDoc */
init() {
this.validations = Espo.Utils.clone(this.validations);
this.searchTypeList = Espo.Utils.clone(this.searchTypeList);
this._hasTemplateContent = !!this.templateContent;
this.defs = this.options.defs || {};
this.name = this.options.name || this.defs.name;
this.params = this.options.params || this.defs.params || {};
this.validateCallback = this.options.validateCallback;
this.fieldType = this.model.getFieldParam(this.name, 'type') || this.type;
this.entityType = this.model.entityType || this.model.name;
this.recordHelper = this.options.recordHelper;
this.dataObject = Espo.Utils.clone(this.options.dataObject || {});
if (!this.labelText) {
this.labelText = this.translate(this.name, 'fields', this.entityType);
}
const paramList = this.paramList || this.getFieldManager().getParamList(this.type).map(it => it.name);
paramList.forEach(name => {
if (name in this.params) {
return;
}
this.params[name] = this.model.getFieldParam(this.name, name);
if (typeof this.params[name] === 'undefined') {
this.params[name] = null;
}
});
const additionalParamList = ['inlineEditDisabled'];
additionalParamList.forEach((item) => {
this.params[item] = this.model.getFieldParam(this.name, item) || null;
});
this.readOnly = this.readOnly || this.params.readOnly ||
this.model.getFieldParam(this.name, 'readOnly') ||
this.model.getFieldParam(this.name, 'clientReadOnly');
if (
!this.model.isNew() &&
this.model.getFieldParam(this.name, 'readOnlyAfterCreate')
) {
this.readOnly = true;
}
this.readOnlyLocked = this.options.readOnlyLocked || this.readOnly;
this.inlineEditDisabled = this.options.inlineEditDisabled ||
this.params.inlineEditDisabled || this.inlineEditDisabled;
this.readOnly = this.readOnlyLocked || this.options.readOnly || false;
this.tooltip = this.options.tooltip || this.params.tooltip ||
this.model.getFieldParam(this.name, 'tooltip') || this.tooltip;
if (this.options.readOnlyDisabled) {
this.readOnly = false;
}
this.disabledLocked = this.options.disabledLocked || false;
this.disabled = this.disabledLocked || this.options.disabled || this.disabled;
let mode = this.options.mode || this.mode || this.MODE_DETAIL;
if (mode === this.MODE_EDIT && this.readOnly) {
mode = this.MODE_DETAIL;
}
this.mode = undefined;
this.wait(
this.setMode(mode)
);
if (this.isSearchMode()) {
this.searchParams = _.clone(this.options.searchParams || {});
this.searchData = {};
this.setupSearch();
this.events['keydown.' + this.cid] = /** JQueryKeyEventObject */e => {
if (Espo.Utils.getKeyFromKeyEvent(e) === 'Control+Enter') {
this.trigger('search');
}
};
}
this.on('highlight', () => {
const $cell = this.get$cell();
$cell.addClass('highlighted');
$cell.addClass('transition');
setTimeout(() => {
$cell.removeClass('highlighted');
}, 3000);
setTimeout(() => {
$cell.removeClass('transition');
}, 3000 + 2000);
});
this.on('invalid', () => {
const $cell = this.get$cell();
$cell.addClass('has-error');
this.$el.one('click', () => {
$cell.removeClass('has-error');
});
this.once('render', () => {
$cell.removeClass('has-error');
});
});
this.on('after:render', () => {
if (this.isEditMode()) {
if (this.hasRequiredMarker()) {
this.showRequiredSign();
return;
}
this.hideRequiredSign();
return;
}
if (this.hasRequiredMarker()) {
this.hideRequiredSign();
}
if (this.isSearchMode()) {
const $searchType = this.$el.find('select.search-type');
if ($searchType.length) {
Select.init($searchType, {matchAnyWord: true});
}
}
});
if ((this.isDetailMode() || this.isEditMode()) && this.tooltip) {
this.initTooltip();
}
if (this.isDetailMode()) {
if (!this.inlineEditDisabled) {
this.listenToOnce(this, 'after:render', () => this.initInlineEdit());
}
}
if (!this.isSearchMode()) {
this.attributeList = this.getAttributeList(); // for backward compatibility, to be removed
this.listenTo(this.model, 'change', (model, options) => {
if (options.ui && (!options.fromField || options.fromField === this.name)) {
return;
}
let changed = false;
for (const attribute of this.getAttributeList()) {
if (model.hasChanged(attribute)) {
changed = true;
break;
}
}
if (!changed) {
return;
}
if (options.fromField === this.name) {
return;
}
if (options.skipReRenderInEditMode && this.isEditMode()) {
return;
}
if (options.skipReRender) {
return;
}
if (this.isEditMode() && this.toSkipReRenderOnChange()) {
return;
}
const reRender = () => {
if (!this.isRendered() && !this.isBeingRendered()) {
return;
}
this.reRender();
if (options.highlight) {
this.trigger('highlight');
}
};
if (!this.isReady) {
this.once('ready', () => {
const promise = this.prepare();
if (promise) {
promise.then(() => reRender());
}
});
return;
}
const promise = this.prepare();
if (promise) {
promise.then(() => reRender());
return;
}
reRender();
});
this.listenTo(this, 'change', () => {
const attributes = this.fetch();
this.model.set(attributes, {
ui: true,
fromView: this,
fromField: this.name,
action: 'ui',
});
});
}
}
highlight() {
const $cell = this.get$cell();
$cell.addClass('highlighted');
}
/** @inheritDoc */
setupFinal() {
this.wait(
this._onModeSet()
);
}
/**
* @internal
* @private
*/
initTooltip() {
let $a;
this.once('after:render', () => {
$a = $('<a>')
.attr('role', 'button')
.attr('tabindex', '-1')
.addClass('text-muted field-info')
.append(
$('<span>').addClass('fas fa-info-circle')
);
const $label = this.getLabelElement();
$label.append(' ');
this.getLabelElement().append($a);
let tooltipText = this.options.tooltipText || this.tooltipText;
if (!tooltipText && typeof this.tooltip === 'string') {
const [scope, field] = this.tooltip.includes('.') ?
this.tooltip.split('.') :
[this.entityType, this.tooltip];
tooltipText = this.translate(field, 'tooltips', scope);
}
tooltipText = tooltipText || this.translate(this.name, 'tooltips', this.entityType) || '';
tooltipText = this.getHelper()
.transformMarkdownText(tooltipText, {linksInNewTab: true}).toString();
Espo.Ui.popover($a, {
placement: 'bottom',
content: tooltipText,
preventDestroyOnRender: true,
}, this);
});
}
/**
* Show a required-field sign.
*
* @private
*/
showRequiredSign() {
const $label = this.getLabelElement();
let $sign = $label.find('span.required-sign');
if ($label.length && !$sign.length) {
const $text = $label.find('span.label-text');
$('<span class="required-sign"> *</span>').insertAfter($text);
$sign = $label.find('span.required-sign');
}
$sign.show();
}
/**
* Hide a required-field sign.
*
* @private
*/
hideRequiredSign() {
const $label = this.getLabelElement();
const $sign = $label.find('span.required-sign');
$sign.hide();
}
/**
* Get search-params data.
*
* @protected
* @return {Object.<string,*>}
*/
getSearchParamsData() {
return this.searchParams.data || {};
}
/**
* Get search values.
*
* @protected
* @return {Object.<string,*>}
*/
getSearchValues() {
return this.getSearchParamsData().values || {};
}
/**
* Get a current search type.
*
* @protected
* @return {string}
*/
getSearchType() {
return this.getSearchParamsData().type || this.searchParams.type;
}
/**
* Get the search type list.
*
* @protected
* @returns {string[]}
*/
getSearchTypeList() {
return this.searchTypeList;
}
/**
* @private
* @internal
*/
initInlineEdit() {
const cell = this.getCellElement();
const edit = document.createElement('a');
edit.role = 'button';
edit.classList.add('pull-right', 'inline-edit-link' ,'hidden');
edit.append(
(() => {
const span = document.createElement('span');
span.classList.add('fas', 'fa-pencil-alt', 'fa-sm');
return span;
})()
)
if (!cell) {
this.listenToOnce(this, 'after:render', () => this.initInlineEdit());
return;
}
cell.prepend(edit);
edit.addEventListener('click', () => this.inlineEdit());
cell.addEventListener('mouseenter', e => {
e.stopPropagation();
if (this.disabled || this.readOnly) {
return;
}
if (this.isDetailMode()) {
edit.classList.remove('hidden');
}
});
cell.addEventListener('mouseleave', e => {
e.stopPropagation();
if (this.isDetailMode()) {
edit.classList.add('hidden');
}
});
this.on('after:render', () => {
if (!this.isDetailMode()) {
edit.classList.add('hidden');
}
});
}
/**
* Initializes a form element reference.
*
* @protected
*/
initElement() {
this.$element = this.$el.find('[data-name="' + this.name + '"]');
if (!this.$element.length) {
this.$element = this.$el.find('[name="' + this.name + '"]');
}
if (!this.$element.length) {
this.$element = this.$el.find('.main-element');
}
if (this.isEditMode()) {
this.$element.on('change', () => {
this.trigger('change');
});
}
}
/** @inheritDoc */
afterRender() {
if (this.isEditMode() || this.isSearchMode()) {
this.initElement();
}
if (this.isReadMode()) {
this.afterRenderRead();
}
if (this.isListMode()) {
this.afterRenderList();
}
if (this.isDetailMode()) {
this.afterRenderDetail();
}
if (this.isEditMode()) {
this.afterRenderEdit();
}
if (this.isSearchMode()) {
this.afterRenderSearch();
}
}
/**
* Called after the view is rendered in list or read mode.
*
* @protected
*/
afterRenderRead() {}
/**
* Called after the view is rendered in list mode.
*
* @protected
*/
afterRenderList() {}
/**
* Called after the view is rendered in detail mode.
*
* @protected
*/
afterRenderDetail() {}
/**
* Called after the view is rendered in edit mode.
*
* @protected
*/
afterRenderEdit() {}
/**
* Called after the view is rendered in search mode.
*
* @protected
*/
afterRenderSearch() {}
/**
* Initialization.
*/
setup() {}
/**
* Initialization for search mode.
*
* @protected
*/
setupSearch() {}
/**
* Get list of model attributes that relate to the field.
* Changing of any attributes makes the field to re-render.
*
* @return {string[]}
*/
getAttributeList() {
return this.getFieldManager().getAttributeList(this.fieldType, this.name);
}
/**
* Invoke inline-edit saving.
*
* @param {{[bypassClose]: boolean}} [options]
*/
inlineEditSave(options) {
options = options || {}
if (this.recordHelper) {
this.recordHelper.trigger('inline-edit-save', this.name, options);
return;
}
// Code below supposed not to be executed.
let data = this.fetch();
const model = this.model;
const prev = this.initialAttributes;
model.set(data, {silent: true});
data = model.attributes;
let attrs = false;
for (const attr in data) {
if (_.isEqual(prev[attr], data[attr])) {
continue;
}
(attrs || (attrs = {}))[attr] = data[attr];
}
if (!attrs) {
this.inlineEditClose();
}
const isInvalid = this.validateCallback ? this.validateCallback() : this.validate();
if (isInvalid) {
Espo.Ui.error(this.translate('Not valid'));
// @todo Revise.
model.set(prev, {silent: true});
return;
}
Espo.Ui.notify(this.translate('saving', 'messages'));
model
.save(/** @type Object */attrs, {patch: true})
.then(() => {
this.trigger('after:inline-save');
this.trigger('after:save');
model.trigger('after:save');
Espo.Ui.success(this.translate('Saved'));
})
.catch(() => {
Espo.Ui.error(this.translate('Error occurred'));
// @todo Revise.
model.set(prev, {silent: true});
this.reRender();
});
if (!options.bypassClose) {
this.inlineEditClose(true);
}
}
/**
* @public
*/
removeInlineEditLinks() {
const $cell = this.get$cell();
$cell.find('.inline-save-link').remove();
$cell.find('.inline-cancel-link').remove();
$cell.find('.inline-edit-link').addClass('hidden');
}
/**
* @private
*/
addInlineEditLinks() {
const $cell = this.get$cell();
const saveLink = document.createElement('a');
saveLink.role = 'button';
saveLink.tabIndex = -1;
saveLink.title = this.translate('Update') + ' · ' + 'Ctrl+Enter';
saveLink.innerHTML = `<span class="fas fa-check"></span>`;
saveLink.classList.add('inline-save-link');
const cancelLink = document.createElement('a');
cancelLink.role = 'button';
cancelLink.tabIndex = -1;
cancelLink.title = this.translate('Cancel') + ' · ' + 'Esc';
cancelLink.innerHTML = `<span class="fas fa-arrow-right-to-bracket"></span>`;
cancelLink.classList.add('inline-cancel-link');
$cell.prepend(saveLink);
$cell.prepend(cancelLink);
$cell.find('.inline-edit-link').addClass('hidden');
saveLink.onclick = () => this.inlineEditSave();
cancelLink.onclick = () => this.inlineEditClose();
}
/**
* @public
* @param {boolean} value
* @internal
*/
setIsInlineEditMode(value) {
this._isInlineEditMode = value;
}
/**
* Exist inline-edit mode.
*
* @param {boolean} [noReset]
* @return {Promise}
*/
inlineEditClose(noReset) {
this.trigger('inline-edit-off', {noReset: noReset});
this.$el.off('keydown.inline-edit');
this._isInlineEditMode = false;
if (!this.isEditMode()) {
return Promise.resolve();
}
if (!noReset) {
this.model.set(this.initialAttributes, {
skipReRenderInEditMode: true,
action: 'cancel-edit',
});
}
const promise = this.setDetailMode()
.then(() => this.reRender(true))
.then(() => this.removeInlineEditLinks());
this.trigger('after:inline-edit-off', {noReset: noReset});
return promise;
}
/**
* Switch to inline-edit mode.
*
* @return {Promise}
*/
async inlineEdit() {
if (this.recordHelper && this.recordHelper.isChanged()) {
await this.confirm(this.translate('changesLossConfirmation', 'messages'));
}
this.trigger('edit', this);
this.initialAttributes = this.model.getClonedAttributes();
this._isInlineEditMode = true;
this.trigger('inline-edit-on');
await this.setEditMode();
await this.reRender(true);
await this.addInlineEditLinks();
this.$el.on('keydown.inline-edit', e => {
const key = Espo.Utils.getKeyFromKeyEvent(e);
if (key === 'Control+Enter') {
e.stopPropagation();
if (document.activeElement instanceof HTMLInputElement) {
// Fields may need to fetch data first.
document.activeElement.dispatchEvent(new Event('change', {bubbles: true}));
}
this.fetchToModel();
this.inlineEditSave();
setTimeout(() => {
this.get$cell().focus();
}, 100);
return;
}
if (key === 'Escape') {
e.stopPropagation();
this.inlineEditClose()
.then(() => {
this.get$cell().focus();
});
return;
}
if (key === 'Control+KeyS') {
e.preventDefault();
e.stopPropagation();
this.fetchToModel();
this.inlineEditSave({bypassClose: true});
}
});
setTimeout(() => this.focusOnInlineEdit(), 10);
}
/**
* @protected
*/
focusOnInlineEdit() {
const $element = this.$element && this.$element.length ?
this.$element :
this.$el.find('.form-control').first();
if (!$element) {
return;
}
$element.first().focus();
}
/**
* Suspend a validation message.
*
* @internal
* @param {number} [time=200]
*/
suspendValidationMessage(time) {
this.validationMessageSuspended = true;
setTimeout(() => this.validationMessageSuspended = false, time || 200);
}
/**
* Show a validation message.
*
* @param {string} message A message.
* @param {string|JQuery|Element} [target] A target element or selector.
* @param {module:view} [view] A child view that contains the target. The closest view should to passed.
* Should be omitted if there is no child views or the target is not rendered by a child view.
*/
showValidationMessage(message, target, view) {
if (this.validationMessageSuspended) {
return;
}
let $el;
target = target || this.validationElementSelector || '.main-element';
if (typeof target === 'string' || target instanceof String) {
$el = this.$el.find(target);
} else {
$el = $(target);
}
if (!$el.length && this.$element) {
$el = this.$element;
}
if (!$el.length) {
$el = this.$el;
}
if ($el.length) {
const rect = $el.get(0).getBoundingClientRect();
this.lastValidationMessage = message;
if (rect.top === 0 && rect.bottom === 0 && rect.left === 0) {
return;
}
}
this._popoverMap = this._popoverMap || new WeakMap();
const element = $el.get(0);
if (!element) {
return;
}
if (this._popoverMap.has(element)) {
try {
this._popoverMap.get(element).detach();
}
catch (e) {}
}
const popover = Espo.Ui.popover($el, {
placement: 'bottom',
container: 'body',
content: this.getHelper().transformMarkdownText(message).toString(),
trigger: 'manual',
noToggleInit: true,
noHideOnOutsideClick: true,
}, view || this);
popover.show();
this._popoverMap.set(element, popover);
$el.closest('.field').one('mousedown click', () => popover.destroy());
this.once('render remove', () => popover.destroy());
this._timeoutMap = this._timeoutMap || new WeakMap();
if (this._timeoutMap.has(element)) {
clearTimeout(this._timeoutMap.get(element));
}
const timeout = setTimeout(() => {
popover.destroy();
}, this.VALIDATION_POPOVER_TIMEOUT);
this._timeoutMap.set(element, timeout);
}
/**
* Validate field values.
*
* @return {boolean} True if not valid.
*/
validate() {
this.lastValidationMessage = null;
for (const item of this.validations) {
let notValid = false;
if (typeof item === 'function') {
notValid = item();
} else {
const method = 'validate' + Espo.Utils.upperCaseFirst(item);
notValid = this[method].call(this);
}
if (notValid) {
this.trigger('invalid');
return true;
}
}
return false;
}
/**
* Get a label text.
*
* @returns {string}
*/
getLabelText() {
return this.labelText;
}
/**
* Validate required.
*
* @return {boolean}
*/
validateRequired() {
if (this.isRequired()) {
if (this.model.get(this.name) === '' || this.model.get(this.name) === null) {
const msg = this.translate('fieldIsRequired', 'messages')
.replace('{field}', this.getLabelText());
this.showValidationMessage(msg);
return true;
}
}
}
/**
* Defines whether the field should have a required-marker rendered.
*
* @protected
* @return {boolean}
*/
hasRequiredMarker() {
return this.isRequired();
}
/**
* Fetch field values to the model.
*/
fetchToModel() {
this.model.set(this.fetch(), {silent: true});
}
/**
* Fetch field values from DOM.
*
* @return {Object.<string, *>}
*/
fetch() {
if (!this.$element.length) {
return {};
}
const data = {};
data[this.name] = this.$element.val().trim();
return data;
}
/**
* Fetch search data from DOM.
*
* @return {Object.<string, *>|null}
*/
fetchSearch() {
const value = this.$element.val().toString().trim();
if (value) {
return {
type: 'equals',
value: value,
};
}
return null;
}
/**
* Fetch a search type from DOM.
*
* @return {string}
*/
fetchSearchType() {
return this.$el.find('select.search-type').val();
}
/**
* To skip re-render on change in edit mode.
*
* @protected
* @since 9.1.2
*/
toSkipReRenderOnChange() {
return false;
}
}
export default BaseFieldView;