Optimistic concurrency control

This commit is contained in:
Yuri Kuznetsov
2021-05-26 14:09:45 +03:00
parent 96dca60a2b
commit 6e9d29937d
22 changed files with 647 additions and 49 deletions

View File

@@ -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'])) {

View File

@@ -0,0 +1,78 @@
<?php
/************************************************************************
* 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.
************************************************************************/
namespace Espo\Core\FieldProcessing\VersionNumber;
use Espo\Core\{
Utils\Metadata,
};
use Espo\ORM\Entity;
class BeforeSaveProcessor
{
private $metadata;
public function __construct(Metadata $metadata)
{
$this->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);
}
}

View File

@@ -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);

View File

@@ -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();

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -0,0 +1,51 @@
<?php
/************************************************************************
* 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.
************************************************************************/
namespace Espo\Hooks\Common;
use Espo\ORM\Entity;
use Espo\Core\{
FieldProcessing\VersionNumber\BeforeSaveProcessor as Processor,
};
class VersionNumber
{
protected $processor;
public function __construct(Processor $processor)
{
$this->processor = $processor;
}
public function beforeSave(Entity $entity): void
{
$this->processor->process($entity);
}
}

View File

@@ -113,5 +113,6 @@
"collection": {
"orderBy": "order",
"order": "asc"
}
},
"optimisticConcurrencyControl": true
}

View File

@@ -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.",

View File

@@ -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": {

View File

@@ -87,5 +87,6 @@
"orderBy": "createdAt",
"order": "desc",
"textFilterFields": ["name", "subject"]
}
},
"optimisticConcurrencyControl": true
}

View File

@@ -120,5 +120,6 @@
"collection": {
"orderBy": "name",
"order": "asc"
}
},
"optimisticConcurrencyControl": true
}

View File

@@ -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();

View File

@@ -59,6 +59,7 @@ class FieldManager
'deleted',
'skipDuplicateCheck',
'isFollowed',
'versionNumber',
'null',
'false',
'true',

View File

@@ -1,42 +1,84 @@
<div class="row">
<div class="cell form-group col-md-6" data-name="name">
<label class="control-label" data-name="name">{{translate 'name' category='fields' scope='EntityManager'}}</label>
<label
class="control-label"
data-name="name"
>{{translate 'name' category='fields' scope='EntityManager'}}</label>
<div class="field" data-name="name">
{{{name}}}
</div>
</div>
<div class="cell form-group col-md-6" data-name="type">
<label class="control-label" data-name="type">{{translate 'type' category='fields' scope='EntityManager'}}</label>
<label
class="control-label"
data-name="type"
>{{translate 'type' category='fields' scope='EntityManager'}}</label>
<div class="field" data-name="type">
{{{type}}}
</div>
</div>
</div>
<div class="row">
<div class="cell form-group col-md-6" data-name="labelSingular">
<label class="control-label" data-name="labelSingular">{{translate 'labelSingular' category='fields' scope='EntityManager'}}</label>
<label
class="control-label"
data-name="labelSingular"
>{{translate 'labelSingular' category='fields' scope='EntityManager'}}</label>
<div class="field" data-name="labelSingular">
{{{labelSingular}}}
</div>
</div>
<div class="cell form-group col-md-6" data-name="labelPlural">
<label class="control-label" data-name="labelPlural">{{translate 'labelPlural' category='fields' scope='EntityManager'}}</label>
<label
class="control-label"
data-name="labelPlural"
>{{translate 'labelPlural' category='fields' scope='EntityManager'}}</label>
<div class="field" data-name="labelPlural">
{{{labelPlural}}}
</div>
</div>
</div>
<div class="row">
<div class="cell form-group col-md-6" data-name="iconClass">
<label
class="control-label"
data-name="iconClass"
>{{translate 'iconClass' category='fields' scope='EntityManager'}}</label>
<div class="field" data-name="iconClass">
{{{iconClass}}}
</div>
</div>
{{#if color}}
<div class="cell form-group col-md-6" data-name="color">
<label
class="control-label"
ata-name="color"
>{{translate 'color' category='fields' scope='EntityManager'}}</label>
<div class="field" data-name="color">
{{{color}}}
</div>
</div>
{{/if}}
</div>
<div class="row">
<div class="cell form-group col-md-6" data-name="disabled">
<label class="control-label" data-name="disabled">{{translate 'disabled' category='fields' scope='EntityManager'}}</label>
<label
class="control-label"
data-name="disabled"
>{{translate 'disabled' category='fields' scope='EntityManager'}}</label>
<div class="field" data-name="disabled">
{{{disabled}}}
</div>
</div>
{{#if stream}}
<div class="cell form-group col-md-6" data-name="stream">
<label class="control-label" data-name="stream">{{translate 'stream' category='fields' scope='EntityManager'}}</label>
<label
class="control-label"
data-name="stream"
>{{translate 'stream' category='fields' scope='EntityManager'}}</label>
<div class="field" data-name="stream">
{{{stream}}}
</div>
@@ -47,13 +89,19 @@
{{#if sortBy}}
<div class="row">
<div class="cell form-group col-md-6" data-name="sortBy">
<label class="control-label" data-name="sortBy">{{translate 'sortBy' category='fields' scope='EntityManager'}}</label>
<label
class="control-label"
data-name="sortBy"
>{{translate 'sortBy' category='fields' scope='EntityManager'}}</label>
<div class="field" data-name="sortBy">
{{{sortBy}}}
</div>
</div>
<div class="cell form-group col-md-6" data-name="sortDirection">
<label class="control-label" data-name="sortDirection">{{translate 'sortDirection' category='fields' scope='EntityManager'}}</label>
<label
class="control-label"
data-name="sortDirection"
>{{translate 'sortDirection' category='fields' scope='EntityManager'}}</label>
<div class="field" data-name="sortDirection">
{{{sortDirection}}}
</div>
@@ -64,13 +112,19 @@
{{#unless isNew}}
<div class="row">
<div class="cell form-group col-md-6" data-name="textFilterFields">
<label class="control-label" data-name="textFilterFields">{{translate 'textFilterFields' category='fields' scope='EntityManager'}}</label>
<label
class="control-label"
data-name="textFilterFields"
>{{translate 'textFilterFields' category='fields' scope='EntityManager'}}</label>
<div class="field" data-name="textFilterFields">
{{{textFilterFields}}}
</div>
</div>
<div class="cell form-group col-md-6" data-name="statusField">
<label class="control-label" data-name="statusField">{{translate 'statusField' category='fields' scope='EntityManager'}}</label>
<label
class="control-label"
data-name="statusField"
>{{translate 'statusField' category='fields' scope='EntityManager'}}</label>
<div class="field" data-name="statusField">
{{{statusField}}}
</div>
@@ -79,13 +133,19 @@
<div class="row">
<div class="cell form-group col-md-6" data-name="fullTextSearch">
<label class="control-label" data-name="fullTextSearch">{{translate 'fullTextSearch' category='fields' scope='EntityManager'}}</label>
<label
class="control-label"
data-name="fullTextSearch"
>{{translate 'fullTextSearch' category='fields' scope='EntityManager'}}</label>
<div class="field" data-name="fullTextSearch">
{{{fullTextSearch}}}
</div>
</div>
<div class="cell form-group col-md-6" data-name="countDisabled">
<label class="control-label" data-name="countDisabled">{{translate 'countDisabled' category='fields' scope='EntityManager'}}</label>
<label
class="control-label"
data-name="countDisabled"
>{{translate 'countDisabled' category='fields' scope='EntityManager'}}</label>
<div class="field" data-name="countDisabled">
{{{countDisabled}}}
</div>
@@ -93,36 +153,37 @@
</div>
<div class="row">
<div class="cell form-group col-md-6" data-name="kanbanViewMode">
<label class="control-label" data-name="kanbanViewMode">{{translate 'kanbanViewMode' category='fields' scope='EntityManager'}}</label>
<div class="field" data-name="kanbanViewMode">
{{{kanbanViewMode}}}
</div>
</div>
<div class="cell form-group col-md-6" data-name="kanbanStatusIgnoreList">
<label class="control-label" data-name="kanbanStatusIgnoreList">{{translate 'kanbanStatusIgnoreList' category='fields' scope='EntityManager'}}</label>
<div class="field" data-name="kanbanStatusIgnoreList">
{{{kanbanStatusIgnoreList}}}
</div>
</div>
<div class="cell form-group col-md-6" data-name="optimisticConcurrencyControl">
<label
class="control-label"
data-name="optimisticConcurrencyControl"
>{{translate 'optimisticConcurrencyControl' category='fields' scope='EntityManager'}}</label>
<div class="field" data-name="optimisticConcurrencyControl">
{{{optimisticConcurrencyControl}}}
</div>
</div>
</div>
<div class="row">
<div class="cell form-group col-md-6" data-name="kanbanViewMode">
<label
class="control-label"
data-name="kanbanViewMode"
>{{translate 'kanbanViewMode' category='fields' scope='EntityManager'}}</label>
<div class="field" data-name="kanbanViewMode">
{{{kanbanViewMode}}}
</div>
</div>
<div class="cell form-group col-md-6" data-name="kanbanStatusIgnoreList">
<label
class="control-label"
data-name="kanbanStatusIgnoreList"
>{{translate 'kanbanStatusIgnoreList' category='fields' scope='EntityManager'}}</label>
<div class="field" data-name="kanbanStatusIgnoreList">
{{{kanbanStatusIgnoreList}}}
</div>
</div>
</div>
{{/unless}}
<div class="row">
<div class="cell form-group col-md-6" data-name="iconClass">
<label class="control-label" data-name="iconClass">{{translate 'iconClass' category='fields' scope='EntityManager'}}</label>
<div class="field" data-name="iconClass">
{{{iconClass}}}
</div>
</div>
{{#if color}}
<div class="cell form-group col-md-6" data-name="color">
<label class="control-label" data-name="color">{{translate 'color' category='fields' scope='EntityManager'}}</label>
<div class="field" data-name="color">
{{{color}}}
</div>
</div>
{{/if}}
</div>

View File

@@ -0,0 +1,34 @@
<div class="margin-bottom-3x">
<p>{{translate 'resolveSaveConflict' category='messages'}}</p>
</div>
<table class="table" style="table-layout: fixed;">
<thead>
<tr>
<th width="25%">{{translate 'Field'}}</th>
<th width="25%">{{translate 'Resolution'}}</th>
<th>{{translate 'Value'}}</th>
</tr>
</thead>
<tbody>
{{#each dataList}}
<tr>
<td class="cell cell-nowrap">
<span>
{{translate field category='fields' scope=../entityType}}
</span>
</td>
<td class="cell">
<select class="form-control" data-name="resolution" data-field="{{field}}">
{{options ../resolutionList resolution field='saveConflictResolution'}}
</select>
</td>
<td class="cell">
<div data-name="field" data-field="{{field}}">
{{{var viewKey ../this}}}
</div>
</td>
</tr>
{{/each}}
</tbody>
</table>

View File

@@ -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) {

View File

@@ -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();
},
});
});

View File

@@ -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');

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}