diff --git a/application/Espo/Controllers/EntityManager.php b/application/Espo/Controllers/EntityManager.php index b49497e3c5..e039b081f6 100644 --- a/application/Espo/Controllers/EntityManager.php +++ b/application/Espo/Controllers/EntityManager.php @@ -125,6 +125,9 @@ class EntityManager if (isset($data['countDisabled'])) { $params['countDisabled'] = $data['countDisabled']; } + if (isset($data['optimisticConcurrencyControl'])) { + $params['optimisticConcurrencyControl'] = $data['optimisticConcurrencyControl']; + } $params['kanbanViewMode'] = !empty($data['kanbanViewMode']); if (!empty($data['kanbanStatusIgnoreList'])) { diff --git a/application/Espo/Core/FieldProcessing/VersionNumber/BeforeSaveProcessor.php b/application/Espo/Core/FieldProcessing/VersionNumber/BeforeSaveProcessor.php new file mode 100644 index 0000000000..b6e095c466 --- /dev/null +++ b/application/Espo/Core/FieldProcessing/VersionNumber/BeforeSaveProcessor.php @@ -0,0 +1,78 @@ +metadata = $metadata; + } + + public function process(Entity $entity): void + { + $optimisticConcurrencyControl = $this->metadata + ->get(['entityDefs', $entity->getEntityType(), 'optimisticConcurrencyControl']); + + if (!$optimisticConcurrencyControl) { + return; + } + + if ($entity->isNew()) { + $entity->set('versionNumber', 1); + + return; + } + + $entity->clear('versionNumber'); + + if (!$entity->hasFetched('versionNumber')) { + return; + } + + $versionNumber = $entity->getFetched('versionNumber'); + + if ($versionNumber === null) { + $versionNumber = 0; + } + + $versionNumber++; + + $entity->set('versionNumber', $versionNumber); + } +} diff --git a/application/Espo/Core/Record/Service.php b/application/Espo/Core/Record/Service.php index ba85af3d48..ceef97280b 100644 --- a/application/Espo/Core/Record/Service.php +++ b/application/Espo/Core/Record/Service.php @@ -433,6 +433,7 @@ class Service implements Crud, unset($data->createdById); unset($data->createdByName); unset($data->createdAt); + unset($data->versionNumber); $this->filterInput($data); @@ -450,6 +451,7 @@ class Service implements Crud, unset($data->createdById); unset($data->createdByName); unset($data->createdAt); + unset($data->versionNumber); $this->filterInput($data); @@ -470,6 +472,44 @@ class Service implements Crud, { } + protected function processConcurrencyControl(Entity $entity, StdClass $data, int $versionNumber): void + { + if ($entity->get('versionNumber') === null) { + return; + } + + if ($versionNumber === $entity->get('versionNumber')) { + return; + } + + $attributeList = array_keys(get_object_vars($data)); + + $notMatchingAttributeList = []; + + foreach ($attributeList as $attribute) { + if ($entity->get($attribute) !== $data->$attribute) { + $notMatchingAttributeList[] = $attribute; + } + } + + if (empty($notMatchingAttributeList)) { + return; + } + + $values = (object) []; + + foreach ($notMatchingAttributeList as $attribute) { + $values->$attribute = $entity->get($attribute); + } + + $responseData = (object) [ + 'values' => $values, + 'versionNumber' => $entity->get('versionNumber'), + ]; + + throw ConflictSilent::createWithBody('modified', json_encode($responseData)); + } + protected function processDuplicateCheck(Entity $entity, StdClass $data): void { $duplicateList = $this->findDuplicates($entity, $data); @@ -618,6 +658,10 @@ class Service implements Crud, throw new ForbiddenSilent("No edit access."); } + if ($params->getVersionNumber() !== null) { + $this->processConcurrencyControl($entity, $data, $params->getVersionNumber()); + } + $entity->set($data); $this->processValidation($entity, $data); diff --git a/application/Espo/Core/Record/UpdateParams.php b/application/Espo/Core/Record/UpdateParams.php index 81b3761535..efafd1b2d7 100644 --- a/application/Espo/Core/Record/UpdateParams.php +++ b/application/Espo/Core/Record/UpdateParams.php @@ -33,6 +33,8 @@ class UpdateParams { private $skipDuplicateCheck = false; + private $versionNumber = null; + public function __construct() {} public function withSkipDuplicateCheck(bool $skipDuplicateCheck = true): self @@ -44,11 +46,25 @@ class UpdateParams return $obj; } + public function withVersionNumber(?int $versionNumber): self + { + $obj = clone $this; + + $obj->versionNumber = $versionNumber; + + return $obj; + } + public function skipDuplicateCheck(): bool { return $this->skipDuplicateCheck; } + public function getVersionNumber(): ?int + { + return $this->versionNumber; + } + public static function create(): self { return new self(); diff --git a/application/Espo/Core/Record/UpdateParamsFetcher.php b/application/Espo/Core/Record/UpdateParamsFetcher.php index a8f8254d00..4fef812ca2 100644 --- a/application/Espo/Core/Record/UpdateParamsFetcher.php +++ b/application/Espo/Core/Record/UpdateParamsFetcher.php @@ -48,7 +48,14 @@ class UpdateParamsFetcher $data->forceDuplicate ?? // legacy false; + $versionNumber = $request->getHeader('X-Version-Number'); + + if ($versionNumber !== null) { + $versionNumber = intval($versionNumber); + } + return UpdateParams::create() - ->withSkipDuplicateCheck($skipDuplicateCheck); + ->withSkipDuplicateCheck($skipDuplicateCheck) + ->withVersionNumber($versionNumber); } } diff --git a/application/Espo/Core/Utils/Database/Orm/Converter.php b/application/Espo/Core/Utils/Database/Orm/Converter.php index dcb7955270..da719fcf3e 100644 --- a/application/Espo/Core/Utils/Database/Orm/Converter.php +++ b/application/Espo/Core/Utils/Database/Orm/Converter.php @@ -495,6 +495,15 @@ class Converter } } + // @todo move to separate file + if ($this->metadata->get(['entityDefs', $entityType, 'optimisticConcurrencyControl'])) { + $ormMetadata[$entityType]['fields']['versionNumber'] = [ + 'type' => Entity::INT, + 'dbType' => 'bigint', + 'notExportable' => true, + ]; + } + return $ormMetadata; } diff --git a/application/Espo/Hooks/Common/VersionNumber.php b/application/Espo/Hooks/Common/VersionNumber.php new file mode 100644 index 0000000000..6fb1700a08 --- /dev/null +++ b/application/Espo/Hooks/Common/VersionNumber.php @@ -0,0 +1,51 @@ +processor = $processor; + } + + public function beforeSave(Entity $entity): void + { + $this->processor->process($entity); + } +} diff --git a/application/Espo/Modules/Crm/Resources/metadata/entityDefs/KnowledgeBaseArticle.json b/application/Espo/Modules/Crm/Resources/metadata/entityDefs/KnowledgeBaseArticle.json index 9831a2491c..b8568ce392 100644 --- a/application/Espo/Modules/Crm/Resources/metadata/entityDefs/KnowledgeBaseArticle.json +++ b/application/Espo/Modules/Crm/Resources/metadata/entityDefs/KnowledgeBaseArticle.json @@ -113,5 +113,6 @@ "collection": { "orderBy": "order", "order": "asc" - } + }, + "optimisticConcurrencyControl": true } diff --git a/application/Espo/Resources/i18n/en_US/EntityManager.json b/application/Espo/Resources/i18n/en_US/EntityManager.json index 7a0f513c81..8ab8cf35c4 100644 --- a/application/Espo/Resources/i18n/en_US/EntityManager.json +++ b/application/Espo/Resources/i18n/en_US/EntityManager.json @@ -37,7 +37,8 @@ "countDisabled": "Disable record count", "fullTextSearch": "Full-Text Search", "parentEntityTypeList": "Parent Entity Types", - "foreignLinkEntityTypeList": "Foreign Links" + "foreignLinkEntityTypeList": "Foreign Links", + "optimisticConcurrencyControl": "Optimistic concurrency control" }, "options": { "type": { @@ -70,6 +71,7 @@ "linkConflict": "Name conflict: link or field with the same name already exists." }, "tooltips": { + "optimisticConcurrencyControl": "Prevents writing conflicts.", "statusField": "Updates of this field are logged in stream.", "textFilterFields": "Fields used by text search.", "stream": "Whether entity has a Stream.", diff --git a/application/Espo/Resources/i18n/en_US/Global.json b/application/Espo/Resources/i18n/en_US/Global.json index 20c4b4f3a9..d5f5715a35 100644 --- a/application/Espo/Resources/i18n/en_US/Global.json +++ b/application/Espo/Resources/i18n/en_US/Global.json @@ -261,7 +261,10 @@ "Move Over": "Move Over", "Up": "Up", "Save & Continue Editing": "Save & Continue Editing", - "Save & New": "Save & New" + "Save & New": "Save & New", + "Field": "Field", + "Resolution": "Resolution", + "Resolve Conflict": "Resolve Conflict" }, "messages": { "pleaseWait": "Please wait...", @@ -325,6 +328,7 @@ "massUnfollowZeroResult": "Nothing got unfollowed", "erasePersonalDataConfirmation": "Checked fields will be erased permanently. Are you sure?", "maintenanceMode": "The application currently is in maintenance mode. Only admin users have access.\n\nMaintenance mode can be disabled at Administration → Settings.", + "resolveSaveConflict": "The record has been modified. You need to resolve the conflict before you can save the record.", "massPrintPdfMaxCountError": "Can't print more that {maxCount} records." }, "boolFilters": { @@ -665,6 +669,11 @@ "Fax": "Fax", "Home": "Home", "Other": "Other" + }, + "saveConflictResolution": { + "current": "Current", + "actual": "Actual", + "original": "Original" } }, "sets": { diff --git a/application/Espo/Resources/metadata/entityDefs/EmailTemplate.json b/application/Espo/Resources/metadata/entityDefs/EmailTemplate.json index 50d768036f..f38ff02cef 100644 --- a/application/Espo/Resources/metadata/entityDefs/EmailTemplate.json +++ b/application/Espo/Resources/metadata/entityDefs/EmailTemplate.json @@ -87,5 +87,6 @@ "orderBy": "createdAt", "order": "desc", "textFilterFields": ["name", "subject"] - } + }, + "optimisticConcurrencyControl": true } diff --git a/application/Espo/Resources/metadata/entityDefs/Template.json b/application/Espo/Resources/metadata/entityDefs/Template.json index 5134374344..7fae2f38fe 100644 --- a/application/Espo/Resources/metadata/entityDefs/Template.json +++ b/application/Espo/Resources/metadata/entityDefs/Template.json @@ -120,5 +120,6 @@ "collection": { "orderBy": "name", "order": "asc" - } + }, + "optimisticConcurrencyControl": true } \ No newline at end of file diff --git a/application/Espo/Tools/EntityManager/EntityManager.php b/application/Espo/Tools/EntityManager/EntityManager.php index 0840d0c9b3..d572103482 100644 --- a/application/Espo/Tools/EntityManager/EntityManager.php +++ b/application/Espo/Tools/EntityManager/EntityManager.php @@ -604,7 +604,6 @@ class EntityManager $this->getMetadata()->set('entityDefs', $name, $entityDefsData); } - if (isset($data['fullTextSearch'])) { $entityDefsData = [ 'collection' => [ @@ -625,6 +624,14 @@ class EntityManager $this->getMetadata()->set('entityDefs', $name, $entityDefsData); } + if (isset($data['optimisticConcurrencyControl'])) { + $entityDefsData = [ + 'optimisticConcurrencyControl' => $data['optimisticConcurrencyControl'], + ]; + + $this->getMetadata()->set('entityDefs', $name, $entityDefsData); + } + if (array_key_exists('kanbanStatusIgnoreList', $data)) { $scopeData['kanbanStatusIgnoreList'] = $data['kanbanStatusIgnoreList']; @@ -1544,6 +1551,7 @@ class EntityManager 'collection.orderBy', 'collection.order', 'collection.textFilterFields', + 'optimisticConcurrencyControl', ]); $this->getMetadata()->save(); diff --git a/application/Espo/Tools/FieldManager/FieldManager.php b/application/Espo/Tools/FieldManager/FieldManager.php index 620c735c7b..dc651884b1 100644 --- a/application/Espo/Tools/FieldManager/FieldManager.php +++ b/application/Espo/Tools/FieldManager/FieldManager.php @@ -59,6 +59,7 @@ class FieldManager 'deleted', 'skipDuplicateCheck', 'isFollowed', + 'versionNumber', 'null', 'false', 'true', diff --git a/client/res/templates/admin/entity-manager/modals/edit-entity.tpl b/client/res/templates/admin/entity-manager/modals/edit-entity.tpl index 6c94f771e6..39d22de8c5 100644 --- a/client/res/templates/admin/entity-manager/modals/edit-entity.tpl +++ b/client/res/templates/admin/entity-manager/modals/edit-entity.tpl @@ -1,42 +1,84 @@
- +
{{{name}}}
- +
{{{type}}}
+
- +
{{{labelSingular}}}
- +
{{{labelPlural}}}
+
+
+ +
+ {{{iconClass}}} +
+
+ {{#if color}} +
+ +
+ {{{color}}} +
+
+ {{/if}} +
+
- +
{{{disabled}}}
{{#if stream}}
- +
{{{stream}}}
@@ -47,13 +89,19 @@ {{#if sortBy}}
- +
{{{sortBy}}}
- +
{{{sortDirection}}}
@@ -64,13 +112,19 @@ {{#unless isNew}}
- +
{{{textFilterFields}}}
- +
{{{statusField}}}
@@ -79,13 +133,19 @@
- +
{{{fullTextSearch}}}
- +
{{{countDisabled}}}
@@ -93,36 +153,37 @@
-
- -
- {{{kanbanViewMode}}} -
-
-
- -
- {{{kanbanStatusIgnoreList}}} -
-
+
+ +
+ {{{optimisticConcurrencyControl}}} +
+
+
+ +
+
+ +
+ {{{kanbanViewMode}}} +
+
+
+ +
+ {{{kanbanStatusIgnoreList}}} +
+
{{/unless}} -
-
- -
- {{{iconClass}}} -
-
- {{#if color}} -
- -
- {{{color}}} -
-
- {{/if}} -
- diff --git a/client/res/templates/modals/resolve-save-conflict.tpl b/client/res/templates/modals/resolve-save-conflict.tpl new file mode 100644 index 0000000000..cb51c19571 --- /dev/null +++ b/client/res/templates/modals/resolve-save-conflict.tpl @@ -0,0 +1,34 @@ +
+

{{translate 'resolveSaveConflict' category='messages'}}

+
+ + + + + + + + + + + {{#each dataList}} + + + + + + {{/each}} + +
{{translate 'Field'}}{{translate 'Resolution'}}{{translate 'Value'}}
+ + {{translate field category='fields' scope=../entityType}} + + + + +
+ {{{var viewKey ../this}}} +
+
\ No newline at end of file diff --git a/client/src/views/admin/entity-manager/modals/edit-entity.js b/client/src/views/admin/entity-manager/modals/edit-entity.js index d12d66001e..08361e3b62 100644 --- a/client/src/views/admin/entity-manager/modals/edit-entity.js +++ b/client/src/views/admin/entity-manager/modals/edit-entity.js @@ -101,6 +101,11 @@ define('views/admin/entity-manager/modals/edit-entity', ['views/modal', 'model'] 'kanbanStatusIgnoreList', this.getMetadata().get(['scopes', scope, 'kanbanStatusIgnoreList']) || [] ); + + this.model.set( + 'optimisticConcurrencyControl', + this.getMetadata().get(['entityDefs', scope, 'optimisticConcurrencyControl']) || false + ); } }, @@ -420,7 +425,18 @@ define('views/admin/entity-manager/modals/edit-entity', ['views/modal', 'model'] }, translatedOptions: translatedStatusOptions }); + + this.createView('optimisticConcurrencyControl', 'views/fields/bool', { + model: model, + mode: 'edit', + el: this.options.el + ' .field[data-name="optimisticConcurrencyControl"]', + defs: { + name: 'optimisticConcurrencyControl', + }, + tooltip: true, + }); } + this.model.fetchedAttributes = this.model.getClonedAttributes(); }, @@ -566,6 +582,7 @@ define('views/admin/entity-manager/modals/edit-entity', ['views/modal', 'model'] arr.push('sortDirection'); arr.push('kanbanViewMode'); arr.push('kanbanStatusIgnoreList'); + arr.push('optimisticConcurrencyControl'); } if (this.hasColorField) { @@ -640,6 +657,7 @@ define('views/admin/entity-manager/modals/edit-entity', ['views/modal', 'model'] data.sortDirection = this.model.get('sortDirection'); data.kanbanViewMode = this.model.get('kanbanViewMode'); data.kanbanStatusIgnoreList = this.model.get('kanbanStatusIgnoreList'); + data.optimisticConcurrencyControl = this.model.get('optimisticConcurrencyControl'); } if (!this.isNew) { diff --git a/client/src/views/modals/resolve-save-conflict.js b/client/src/views/modals/resolve-save-conflict.js new file mode 100644 index 0000000000..d421ced29c --- /dev/null +++ b/client/src/views/modals/resolve-save-conflict.js @@ -0,0 +1,184 @@ +/************************************************************************ + * This file is part of EspoCRM. + * + * EspoCRM - Open Source CRM application. + * Copyright (C) 2014-2021 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko + * Website: https://www.espocrm.com + * + * EspoCRM is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * EspoCRM 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with EspoCRM. If not, see http://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 General Public License version 3. + * + * In accordance with Section 7(b) of the GNU General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +define('views/modals/resolve-save-conflict', ['views/modal'], function (Dep) { + + return Dep.extend({ + + backdrop: true, + + fitHeight: true, + + template: 'modals/resolve-save-conflict', + + resolutionList: [ + 'current', + 'actual', + 'original', + ], + + defaultResolution: 'current', + + data: function () { + var dataList = []; + + this.fieldList.forEach(function (item) { + var o = { + field: item, + viewKey: item + 'Field', + resolution: this.defaultResolution, + }; + + dataList.push(o); + }); + + return { + dataList: dataList, + entityType: this.entityType, + resolutionList: this.resolutionList, + }; + }, + + setup: function () { + this.headerHtml = this.getHelper().sanitizeHtml(this.translate('Resolve Conflict')); + + this.buttonList = [ + { + name: 'apply', + label: 'Apply', + style: 'danger', + }, + { + name: 'cancel', + label: 'Cancel', + }, + ]; + + this.entityType = this.model.entityType; + + this.originalModel = this.model; + + this.originalAttributes = Espo.Utils.cloneDeep(this.options.originalAttributes); + this.currentAttributes = Espo.Utils.cloneDeep(this.options.currentAttributes); + this.actualAttributes = Espo.Utils.cloneDeep(this.options.actualAttributes); + + var attributeList = this.options.attributeList; + + var fieldList = []; + + this.getFieldManager().getEntityTypeFieldList(this.entityType).forEach(function (field) { + var fieldAttributeList = this.getFieldManager() + .getEntityTypeFieldAttributeList(this.entityType, field); + + var intersect = attributeList.filter(value => fieldAttributeList.includes(value)); + + if (intersect.length) { + fieldList.push(field); + } + }, this); + + this.fieldList = fieldList; + + this.wait( + this.getModelFactory().create(this.entityType) + .then( + function (model) { + this.model = model; + + this.fieldList.forEach(function (field) { + this.setResolution(field, this.defaultResolution); + }, this); + + this.fieldList.forEach(function (field) { + this.createField(field); + }, this); + }.bind(this) + ) + ); + }, + + setResolution: function (field, resolution) { + var attributeList = this.getFieldManager() + .getEntityTypeFieldAttributeList(this.entityType, field); + + var values = {}; + + var source = this.currentAttributes; + + if (resolution === 'actual') { + source = this.actualAttributes; + } + else if (resolution === 'original') { + source = this.originalAttributes; + } + + for (let attribute of attributeList) { + values[attribute] = source[attribute] || null; + } + + this.model.set(values); + }, + + createField: function (field) { + var type = this.model.getFieldType(field); + + var viewName = + this.model.getFieldParam(field, 'view') || + this.getFieldManager().getViewName(type); + + this.createView(field + 'Field', viewName, { + readOnly: true, + model: this.model, + name: field, + el: this.getSelector() + ' [data-name="field"][data-field="' + field + '"]', + mode: 'list', + }); + }, + + afterRender: function () { + this.$el.find('[data-name="resolution"]').on('change', function (e) { + var $el = $(e.currentTarget); + + var field = $el.attr('data-field'); + var resolution = $el.val(); + + this.setResolution(field, resolution); + }.bind(this)); + }, + + actionApply: function () { + var attributes = this.model.attributes; + + this.originalModel.set(attributes); + + this.trigger('resolve'); + + this.close(); + }, + }); +}); diff --git a/client/src/views/record/base.js b/client/src/views/record/base.js index bfae9ded8a..8f1451bde9 100644 --- a/client/src/views/record/base.js +++ b/client/src/views/record/base.js @@ -606,6 +606,13 @@ define( }); } + var optimisticConcurrencyControl = this.getMetadata() + .get(['entityDefs', this.entityType, 'optimisticConcurrencyControl']); + + if (optimisticConcurrencyControl && this.model.get('versionNumber') !== null) { + headers['X-Version-Number'] = this.model.get('versionNumber'); + } + this.beforeSave(); this.trigger('before:save'); diff --git a/client/src/views/record/detail.js b/client/src/views/record/detail.js index f7bb5411c0..e5d5c0a63d 100644 --- a/client/src/views/record/detail.js +++ b/client/src/views/record/detail.js @@ -1526,6 +1526,59 @@ define('views/record/detail', ['views/record/base', 'view-record-helper'], funct }.bind(this)); }, + errorHandlerModified: function (data) { + Espo.Ui.notify(false); + + var versionNumber = data.versionNumber; + + var values = data.values || {}; + + var attributeList = Object.keys(values); + + var diffAttributeList = []; + + attributeList.forEach(function (attribute) { + if (this.attributes[attribute] !== values[attribute]) { + diffAttributeList.push(attribute); + } + }, this); + + if (diffAttributeList.length === 0) { + this.model.set('versionNumber', versionNumber, {silent: true}); + this.attributes.versionNumber = versionNumber; + + this.actionSave(); + + return; + } + + this.createView( + 'dialog', + 'views/modals/resolve-save-conflict', + { + model: this.model, + attributeList: diffAttributeList, + currentAttributes: Espo.Utils.cloneDeep(this.model.attributes), + originalAttributes: Espo.Utils.cloneDeep(this.attributes), + actualAttributes: Espo.Utils.cloneDeep(values), + } + ) + .then( + function (view) { + view.render(); + + this.listenTo(view, 'resolve', function () { + this.model.set('versionNumber', versionNumber, {silent: true}); + this.attributes.versionNumber = versionNumber; + + for (let attribute in values) { + this.setInitalAttributeValue(attribute, values[attribute]); + } + }, this); + }.bind(this) + ); + }, + setReadOnly: function () { if (!this.readOnlyLocked) { this.readOnly = true; diff --git a/frontend/less/espo/custom.less b/frontend/less/espo/custom.less index c56dea363d..7759e81bff 100644 --- a/frontend/less/espo/custom.less +++ b/frontend/less/espo/custom.less @@ -1258,7 +1258,9 @@ table.no-margin { } .list > table td, -.list > table th { +.list > table th, +table td.cell-nowrap, +table th.cell-nowrap { white-space: nowrap; text-overflow: ellipsis; overflow: hidden; diff --git a/frontend/less/espo/elements/type.less b/frontend/less/espo/elements/type.less index b6a9088e7a..f99f67ad20 100644 --- a/frontend/less/espo/elements/type.less +++ b/frontend/less/espo/elements/type.less @@ -14,6 +14,14 @@ margin-bottom: @table-cell-padding * 2; } +.margin-bottom-3x { + margin-bottom: @table-cell-padding * 3; +} + +.margin-bottom-4x { + margin-bottom: @table-cell-padding * 4; +} + .margin-bottom.margin { margin-top: 0; }