diff --git a/application/Espo/Core/Record/Duplicator/EntityDuplicator.php b/application/Espo/Core/Record/Duplicator/EntityDuplicator.php index a7d110748a..64a5062e24 100644 --- a/application/Espo/Core/Record/Duplicator/EntityDuplicator.php +++ b/application/Espo/Core/Record/Duplicator/EntityDuplicator.php @@ -29,6 +29,7 @@ namespace Espo\Core\Record\Duplicator; +use Espo\Core\Utils\Metadata; use Espo\ORM\Entity; use Espo\ORM\Defs; use Espo\ORM\Defs\FieldDefs; @@ -44,7 +45,8 @@ class EntityDuplicator public function __construct( private Defs $defs, private FieldDuplicatorFactory $fieldDuplicatorFactory, - private FieldUtil $fieldUtil + private FieldUtil $fieldUtil, + private Metadata $metadata ) {} public function duplicate(Entity $entity): stdClass @@ -68,7 +70,7 @@ class EntityDuplicator $entityType = $entity->getEntityType(); $field = $fieldDefs->getName(); - if ($fieldDefs->getParam('duplicateIgnore')) { + if ($this->toIgnoreField($entityType, $fieldDefs)) { $attributeList = $this->fieldUtil->getAttributeList($entityType, $field); foreach ($attributeList as $attribute) { @@ -90,4 +92,20 @@ class EntityDuplicator $valueMap->$attribute = $value; } } + + private function toIgnoreField(string $entityType, FieldDefs $fieldDefs): bool + { + $type = $fieldDefs->getType(); + + // @todo Use FieldType constants. + if (in_array($type, ['autoincrement', 'number'])) { + return true; + } + + if ($this->metadata->get(['scopes', $entityType, 'statusField']) === $fieldDefs->getName()) { + return true; + } + + return (bool) $fieldDefs->getParam('duplicateIgnore'); + } } diff --git a/application/Espo/Modules/Crm/Resources/metadata/entityDefs/Account.json b/application/Espo/Modules/Crm/Resources/metadata/entityDefs/Account.json index 55bf271855..d9c1c67d58 100644 --- a/application/Espo/Modules/Crm/Resources/metadata/entityDefs/Account.json +++ b/application/Espo/Modules/Crm/Resources/metadata/entityDefs/Account.json @@ -194,7 +194,6 @@ "type": "linkMultiple", "layoutDetailDisabled": true, "layoutListDisabled": true, - "layoutMassUpdateDisabled": true, "importDisabled": true, "exportDisabled": true, "noLoad": true diff --git a/application/Espo/Modules/Crm/Resources/metadata/entityDefs/Contact.json b/application/Espo/Modules/Crm/Resources/metadata/entityDefs/Contact.json index 860946f7cc..815a5b65dc 100644 --- a/application/Espo/Modules/Crm/Resources/metadata/entityDefs/Contact.json +++ b/application/Espo/Modules/Crm/Resources/metadata/entityDefs/Contact.json @@ -466,7 +466,6 @@ "type": "linkMultiple", "layoutDetailDisabled": true, "layoutListDisabled": true, - "layoutMassUpdateDisabled": true, "importDisabled": true, "noLoad": true }, diff --git a/application/Espo/Modules/Crm/Resources/metadata/entityDefs/Lead.json b/application/Espo/Modules/Crm/Resources/metadata/entityDefs/Lead.json index 868d52e354..de974cb9cd 100644 --- a/application/Espo/Modules/Crm/Resources/metadata/entityDefs/Lead.json +++ b/application/Espo/Modules/Crm/Resources/metadata/entityDefs/Lead.json @@ -216,7 +216,6 @@ "type": "linkMultiple", "layoutDetailDisabled": true, "layoutListDisabled": true, - "layoutMassUpdateDisabled": true, "importDisabled": true, "noLoad": true }, diff --git a/client/src/ui.js b/client/src/ui.js index 08e54f803a..5b468028af 100644 --- a/client/src/ui.js +++ b/client/src/ui.js @@ -886,6 +886,7 @@ Espo.Ui = { * @property {'manual'|'click'|'hover'|'focus'} [trigger='manual'] A trigger type. * @property {boolean} [noToggleInit=false] Skip init toggle on click. * @property {boolean} [preventDestroyOnRender=false] Don't destroy on re-render. + * @property {boolean} [noHideOnOutsideClick=false] Don't hide on clicking outside. * @property {function(): void} [onShow] On-show callback. * @property {function(): void} [onHide] On-hide callback. */ @@ -896,18 +897,18 @@ Espo.Ui = { * @param {Element|JQuery} element An element. * @param {Espo.Ui~PopoverOptions} o Options. * @param {module:view} [view] A view. + * @return {{hide: function(), destroy: function(), show: function(), detach: function()}} */ popover: function (element, o, view) { - let $el = $(element); - - let $body = $('body') - let content = o.content || Handlebars.Utils.escapeExpression(o.text || ''); + const $el = $(element); + const $body = $('body'); + const content = o.content || Handlebars.Utils.escapeExpression(o.text || ''); let isShown = false; let container = o.container; if (!container) { - let $modalBody = $el.closest('.modal-body'); + const $modalBody = $el.closest('.modal-body'); container = $modalBody.length ? $modalBody : 'body'; } @@ -929,25 +930,27 @@ Espo.Ui = { return; } - $body.off('click.popover-' + view.cid); - - $body.on('click.popover-' + view.cid, e => { - if ($(e.target).closest('.popover-content').get(0)) { - return; - } - - if ($.contains($el.get(0), e.target)) { - return; - } - - if ($el.get(0) === e.target) { - return; - } - + if (view && !o.noHideOnOutsideClick) { $body.off('click.popover-' + view.cid); - // noinspection JSUnresolvedReference - $el.popover('hide'); - }); + + $body.on('click.popover-' + view.cid, e => { + if ($(e.target).closest('.popover-content').get(0)) { + return; + } + + if ($.contains($el.get(0), e.target)) { + return; + } + + if ($el.get(0) === e.target) { + return; + } + + $body.off('click.popover-' + view.cid); + // noinspection JSUnresolvedReference + $el.popover('hide'); + }); + } if (o.onShow) { o.onShow(); @@ -968,26 +971,46 @@ Espo.Ui = { }); } - if (view) { - let hide = () => { - if (!isShown) { - return; - } + let isDetached = false; - // noinspection JSUnresolvedReference - $el.popover('hide'); - }; - - let destroy = () => { - // noinspection JSUnresolvedReference - $el.popover('destroy'); + const detach = () => { + if (view) { $body.off('click.popover-' + view.cid); view.off('remove', destroy); view.off('render', destroy); view.off('render', hide); - }; + } + isDetached = true; + }; + + const destroy = () => { + if (isDetached) { + return; + } + + // noinspection JSUnresolvedReference + $el.popover('destroy'); + + detach(); + }; + + const hide = () => { + if (!isShown) { + return; + } + + // noinspection JSUnresolvedReference + $el.popover('hide'); + }; + + const show = () => { + // noinspection JSUnresolvedReference + $el.popover('show'); + }; + + if (view) { view.once('remove', destroy); if (!o.preventDestroyOnRender) { @@ -998,6 +1021,13 @@ Espo.Ui = { view.on('render', hide); } } + + return { + hide: () => hide(), + destroy: () => destroy(), + show: () => show(), + detach: () => detach(), + }; }, /** diff --git a/client/src/ui/select.js b/client/src/ui/select.js index ed3659cf72..b5c15e0b79 100644 --- a/client/src/ui/select.js +++ b/client/src/ui/select.js @@ -74,21 +74,22 @@ const Select = { * @param {Element|JQuery} element An element. * @param {module:ui/select~Options} [options] Options. */ - init: function (element, options) { - let $el = $(element); + init: function (element, options = {}) { + const score = options.score; + const $el = $(element); options = Select.applyDefaultOptions(options || {}); - let plugins = []; + const plugins = []; Select.loadEspoSelectPlugin(); plugins.push('espo_select'); - let itemClasses = {}; + const itemClasses = {}; - let allowedValues = $el.children().toArray().map(item => { - let value = item.getAttributeNode('value').value; + const allowedValues = $el.children().toArray().map(item => { + const value = item.getAttributeNode('value').value; if (item.classList) { itemClasses[value] = item.classList.toString(); @@ -99,13 +100,13 @@ const Select = { let $relativeParent = null; - let $modalBody = $el.closest('.modal-body'); + const $modalBody = $el.closest('.modal-body'); if ($modalBody.length) { $relativeParent = $modalBody; } - let selectizeOptions = { + const selectizeOptions = { sortField: [{field: options.sortBy, direction: options.sortDirection}], load: options.load, loadThrottle: 1, @@ -125,7 +126,7 @@ const Select = { .get(0).outerHTML; }, option: function (data) { - let $div = $('
') + const $div = $('
') .addClass('option') .addClass(data.value === '' ? 'selectize-dropdown-emptyoptionlabel' : '') .addClass(itemClasses[data.value] || '') @@ -153,7 +154,7 @@ const Select = { if (!options.matchAnyWord) { /** @this Selectize */ selectizeOptions.score = function (search) { - let score = this.getScoreFunction(search); + const score = this.getScoreFunction(search); search = search.toLowerCase(); @@ -170,12 +171,12 @@ const Select = { if (options.matchAnyWord) { /** @this Selectize */ selectizeOptions.score = function (search) { - let score = this.getScoreFunction(search); + const score = this.getScoreFunction(search); search = search.toLowerCase(); return function (item) { - let text = item.text.toLowerCase(); + const text = item.text.toLowerCase(); if ( !text.split(' ').find(item => item.startsWith(search)) && @@ -190,7 +191,6 @@ const Select = { } if (options.score) { - let score = options.score; selectizeOptions.score = function (search) { return function (item) { @@ -209,7 +209,7 @@ const Select = { * @param {{noTrigger?: boolean}} [options] Options. */ focus: function (element, options) { - let $el = $(element); + const $el = $(element); options = options || {}; @@ -220,7 +220,7 @@ const Select = { return; } - let selectize = $el[0].selectize; + const selectize = $el[0].selectize; if (options.noTrigger) { selectize.focusNoTrigger = true; @@ -240,9 +240,9 @@ const Select = { * @param {{value: string, text: string}[]} options Options. */ setOptions: function (element, options) { - let $el = $(element); + const $el = $(element); - let selectize = $el.get(0).selectize; + const selectize = $el.get(0).selectize; selectize.clearOptions(true); selectize.load(callback => { @@ -264,7 +264,7 @@ const Select = { * @param {string} value A value. */ setValue: function ($el, value) { - let selectize = $el.get(0).selectize; + const selectize = $el.get(0).selectize; selectize.setValue(value, true); }, @@ -290,14 +290,14 @@ const Select = { applyDefaultOptions: function (options) { options = Espo.Utils.clone(options); - let defaults = { + const defaults = { selectOnTab: false, matchAnyWord: false, sortBy: '$order', sortDirection: 'asc', }; - for (let key in defaults) { + for (const key in defaults) { if (key in options) { continue; } @@ -320,10 +320,10 @@ const Select = { const KEY_BACKSPACE = 8; Selectize.define('espo_select', function () { - let self = this; + const self = this; this.setup = (function () { - let original = self.setup; + const original = self.setup; return function () { original.apply(this, arguments); @@ -367,7 +367,7 @@ const Select = { })();*/ this.refreshOptions = (function () { - let original = self.refreshOptions; + const original = self.refreshOptions; return function () { if (self.focusNoTrigger) { @@ -380,7 +380,7 @@ const Select = { })(); this.blur = (function () { - let original = self.blur; + const original = self.blur; return function () { // Prevent closing on mouse down. @@ -393,7 +393,7 @@ const Select = { })(); this.close = (function () { - let original = self.close; + const original = self.close; return function () { if (self.preventClose) { @@ -405,7 +405,7 @@ const Select = { })(); this.onOptionSelect = (function () { - let original = self.onOptionSelect; + const original = self.onOptionSelect; return function (e) { if (e.type === 'mousedown' || e.type === 'click') { @@ -428,10 +428,10 @@ const Select = { })(); this.open = (function() { - let original = self.open; + const original = self.open; return function () { - let toProcess = !(self.isLocked || self.isOpen); + const toProcess = !(self.isLocked || self.isOpen); original.apply(this, arguments); @@ -439,8 +439,8 @@ const Select = { return; } - let $dropdownContent = self.$dropdown.children().first(); - let $selected = $dropdownContent.find('.selected'); + const $dropdownContent = self.$dropdown.children().first(); + const $selected = $dropdownContent.find('.selected'); if (!$selected.length) { return; @@ -456,7 +456,7 @@ const Select = { })(); this.onMouseDown = (function() { - let original = self.onMouseDown; + const original = self.onMouseDown; return function (e) { // Prevent flicking when clicking on input. @@ -473,7 +473,7 @@ const Select = { })(); this.onFocus = (function() { - let original = self.onFocus; + const original = self.onFocus; return function (e) { if (self.preventReOpenOnFocus) { @@ -504,7 +504,7 @@ const Select = { }; this.onBlur = (function() { - let original = self.onBlur; + const original = self.onBlur; return function () { // Prevent closing on mouse down. @@ -521,7 +521,7 @@ const Select = { })(); this.onKeyDown = (function() { - let original = self.onKeyDown; + const original = self.onKeyDown; return function (e) { if (IS_MAC ? e.metaKey : e.ctrlKey) { @@ -558,7 +558,7 @@ const Select = { RegExp(/^\p{L}/, 'u').test(e.key) // is letter ) ) { - let keyCode = e.keyCode; + const keyCode = e.keyCode; e.keyCode = KEY_BACKSPACE; self.deleteSelection(e); e.keyCode = keyCode; @@ -578,30 +578,30 @@ const Select = { }; return function() { - let $control = self.$control; + const $control = self.$control; - let offset = this.settings.dropdownParent === 'body' ? + const offset = this.settings.dropdownParent === 'body' ? $control.offset() : $control.position(); offset.top += $control.outerHeight(true); - let dropdownHeight = self.$dropdown.prop('scrollHeight') + 5; - let controlPosTop = self.$control.get(0).getBoundingClientRect().top; - let wrapperHeight = self.$wrapper.height(); + const dropdownHeight = self.$dropdown.prop('scrollHeight') + 5; + const controlPosTop = self.$control.get(0).getBoundingClientRect().top; + const wrapperHeight = self.$wrapper.height(); - let controlPosBottom = self.$control.get(0).getBoundingClientRect().bottom; + const controlPosBottom = self.$control.get(0).getBoundingClientRect().bottom; - let boundaryTop = !this.settings.$relativeParent ? 0 : + const boundaryTop = !this.settings.$relativeParent ? 0 : this.settings.$relativeParent.get(0).getBoundingClientRect().top; - let position = + const position = controlPosTop + dropdownHeight + wrapperHeight > window.innerHeight && controlPosBottom - dropdownHeight - wrapperHeight >= boundaryTop ? POSITION.top : POSITION.bottom; - let styles = { + const styles = { width: $control.outerWidth(), left: offset.left, }; diff --git a/client/src/views/fields/base.js b/client/src/views/fields/base.js index 64cdb35568..f2f61dff90 100644 --- a/client/src/views/fields/base.js +++ b/client/src/views/fields/base.js @@ -1450,8 +1450,10 @@ class BaseFieldView extends View { * * @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) { + showValidationMessage(message, target, view) { if (this.validationMessageSuspended) { return; } @@ -1475,7 +1477,7 @@ class BaseFieldView extends View { } if ($el.length) { - let rect = $el.get(0).getBoundingClientRect(); + const rect = $el.get(0).getBoundingClientRect(); this.lastValidationMessage = message; @@ -1484,51 +1486,42 @@ class BaseFieldView extends View { } } - $el - .popover({ - placement: 'bottom', - container: 'body', - content: this.getHelper().transformMarkdownText(message).toString(), - html: true, - trigger: 'manual' - }) - .popover('show'); + this._popoverMap = this._popoverMap || new WeakMap(); + const element = $el.get(0); - let isDestroyed = false; + if (!element) { + return; + } - $el.closest('.field').one('mousedown click', () => { - if (isDestroyed) { - return; + if (this._popoverMap[element]) { + try { + this._popoverMap[element].detach(); } + catch (e) {} + } - $el.popover('destroy'); - isDestroyed = true; - }); + const popover = Espo.Ui.popover($el, { + placement: 'bottom', + container: 'body', + content: this.getHelper().transformMarkdownText(message).toString(), + trigger: 'manual', + noToggleInit: true, + noHideOnOutsideClick: true, + }, view || this); - this.once('render remove', () => { - if (isDestroyed) { - return; - } + popover.show(); - if ($el) { - $el.popover('destroy'); - isDestroyed = true; - } - }); + this._popoverMap[element] = popover; + + $el.closest('.field').one('mousedown click', () => popover.destroy()); + + this.once('render remove', () => popover.destroy()); if (this._timeout) { clearTimeout(this._timeout); } - this._timeout = setTimeout(() => { - if (isDestroyed) { - return; - } - - $el.popover('destroy'); - isDestroyed = true; - - }, this.VALIDATION_POPOVER_TIMEOUT); + this._timeout = setTimeout(() => popover.destroy(), this.VALIDATION_POPOVER_TIMEOUT); } /** diff --git a/client/src/views/modals/mass-update.js b/client/src/views/modals/mass-update.js index 027ee849ad..3683b002d2 100644 --- a/client/src/views/modals/mass-update.js +++ b/client/src/views/modals/mass-update.js @@ -28,6 +28,7 @@ import ModalView from 'views/modal'; import MassActionHelper from 'helpers/mass-action'; +import Select from 'ui/select'; class MassUpdateModalView extends ModalView { @@ -52,7 +53,7 @@ class MassUpdateModalView extends ModalView { events = { /** @this MassUpdateModalView */ 'click a[data-action="add-field"]': function (e) { - let field = $(e.currentTarget).data('name'); + const field = $(e.currentTarget).data('name'); this.addField(field); }, @@ -86,7 +87,7 @@ class MassUpdateModalView extends ModalView { this.hasActionMap = {}; - let totalCount = this.options.totalCount; + const totalCount = this.options.totalCount; this.helper = new MassActionHelper(this); @@ -139,11 +140,11 @@ class MassUpdateModalView extends ModalView { this.addedFieldList.push(name); - let label = this.getHelper().escapeString( + const label = this.getHelper().escapeString( this.translate(name, 'fields', this.entityType) ); - let $cell = + const $cell = $('
') .addClass('cell form-group') .attr('data-name', name) @@ -158,7 +159,7 @@ class MassUpdateModalView extends ModalView { .attr('data-name', name) ); - let $row = + const $row = $('
') .addClass('item grid-auto-fill-md') .attr('data-name', name) @@ -166,13 +167,13 @@ class MassUpdateModalView extends ModalView { this.$el.find('.fields-container').append($row); - let type = this.model.getFieldType(name); - let viewName = this.model.getFieldParam(name, 'view') || this.getFieldManager().getViewName(type); + const type = this.model.getFieldType(name); + const viewName = this.model.getFieldParam(name, 'view') || this.getFieldManager().getViewName(type); - let actionList = this.getMetadata().get(['entityDefs', this.entityType, name, 'massUpdateActionList']) || + const actionList = this.getMetadata().get(['entityDefs', this.entityType, name, 'massUpdateActionList']) || this.getMetadata().get(['fields', type, 'massUpdateActionList']); - let hasActionDropdown = actionList !== null; + const hasActionDropdown = actionList !== null; this.hasActionMap[name] = hasActionDropdown; @@ -192,13 +193,13 @@ class MassUpdateModalView extends ModalView { }); if (hasActionDropdown) { - let $select = + const $select = $('