/************************************************************************ * 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 . * * 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.} [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.} 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.} */ params = null /** * A mode. * * @type {'list'|'listLink'|'detail'|'edit'|'search'} */ mode = 'detail' /** * Search params. * * @type {Object.|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.|{}} */ 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 {import('view-record-helper').default|null} */ recordHelper = null /** * @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.} */ 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} */ 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 ?? null; 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 = $('') .attr('role', 'button') .attr('tabindex', '-1') .addClass('text-muted field-info') .append( $('').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'); $(' *').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.} */ getSearchParamsData() { return this.searchParams.data || {}; } /** * Get search values. * * @protected * @return {Object.} */ 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 = ``; 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 = ``; 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}); if (this.recordHelper) { this.recordHelper.off('continue-inline-edit'); } 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({ message: this.translate('changesLossConfirmation', 'messages'), cancelCallback: this.recordHelper.trigger('continue-inline-edit'), }); } 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(); if (this.recordHelper) { this.recordHelper.on('continue-inline-edit', () => this.focusOnInlineEdit()) } 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.} */ fetch() { if (!this.$element.length) { return {}; } const data = {}; data[this.name] = this.$element.val().trim(); return data; } /** * Fetch search data from DOM. * * @return {Object.|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;