mirror of
https://github.com/espocrm/espocrm.git
synced 2026-06-28 06:56:05 +00:00
Optimistic concurrency control
This commit is contained in:
@@ -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'])) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
51
application/Espo/Hooks/Common/VersionNumber.php
Normal file
51
application/Espo/Hooks/Common/VersionNumber.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -113,5 +113,6 @@
|
||||
"collection": {
|
||||
"orderBy": "order",
|
||||
"order": "asc"
|
||||
}
|
||||
},
|
||||
"optimisticConcurrencyControl": true
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -87,5 +87,6 @@
|
||||
"orderBy": "createdAt",
|
||||
"order": "desc",
|
||||
"textFilterFields": ["name", "subject"]
|
||||
}
|
||||
},
|
||||
"optimisticConcurrencyControl": true
|
||||
}
|
||||
|
||||
@@ -120,5 +120,6 @@
|
||||
"collection": {
|
||||
"orderBy": "name",
|
||||
"order": "asc"
|
||||
}
|
||||
},
|
||||
"optimisticConcurrencyControl": true
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -59,6 +59,7 @@ class FieldManager
|
||||
'deleted',
|
||||
'skipDuplicateCheck',
|
||||
'isFollowed',
|
||||
'versionNumber',
|
||||
'null',
|
||||
'false',
|
||||
'true',
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
34
client/res/templates/modals/resolve-save-conflict.tpl
Normal file
34
client/res/templates/modals/resolve-save-conflict.tpl
Normal 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>
|
||||
@@ -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) {
|
||||
|
||||
184
client/src/views/modals/resolve-save-conflict.js
Normal file
184
client/src/views/modals/resolve-save-conflict.js
Normal 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();
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user