mirror of
https://github.com/espocrm/espocrm.git
synced 2026-06-28 15:06:06 +00:00
read-only saved
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://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 Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Core\Record\DynamicLogic;
|
||||
|
||||
use Espo\Core\Utils\FieldUtil;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\ORM\Entity;
|
||||
use Espo\Tools\DynamicLogic\ConditionChecker;
|
||||
use Espo\Tools\DynamicLogic\ConditionCheckerFactory;
|
||||
use Espo\Tools\DynamicLogic\Exceptions\BadCondition;
|
||||
use Espo\Tools\DynamicLogic\Item;
|
||||
use RuntimeException;
|
||||
use stdClass;
|
||||
|
||||
class InputFilterProcessor
|
||||
{
|
||||
public function __construct(
|
||||
private Metadata $metadata,
|
||||
private FieldUtil $fieldUtil,
|
||||
private ConditionCheckerFactory $conditionCheckerFactory,
|
||||
) {}
|
||||
|
||||
public function process(Entity $entity, stdClass $input): void
|
||||
{
|
||||
/** @var array<string, array<string, mixed>> $fieldsDefs */
|
||||
$fieldsDefs = $this->metadata->get("logicDefs.{$entity->getEntityType()}.fields") ?? [];
|
||||
|
||||
$checker = null;
|
||||
|
||||
foreach ($fieldsDefs as $field => $defs) {
|
||||
if ($defs['readOnlySaved'] ?? null) {
|
||||
$checker ??= $this->conditionCheckerFactory->create($entity);
|
||||
|
||||
$this->processField($entity, $input, $field, $checker);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function processField(Entity $entity, stdClass $input, string $field, ConditionChecker $checker): void
|
||||
{
|
||||
/** @var ?stdClass[] $group */
|
||||
$group = $this->metadata
|
||||
->getObjects("logicDefs.{$entity->getEntityType()}.fields.$field.readOnlySaved.conditionGroup");
|
||||
|
||||
if (!$group) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$item = Item::fromGroupDefinition($group);
|
||||
|
||||
if (!$checker->check($item)) {
|
||||
return;
|
||||
}
|
||||
} catch (BadCondition $e) {
|
||||
throw new RuntimeException($e->getMessage(), 0, $e);
|
||||
}
|
||||
|
||||
foreach ($this->fieldUtil->getAttributeList($entity->getEntityType(), $field) as $attribute) {
|
||||
unset($input->$attribute);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ use Espo\Core\Record\ActionHistory\ActionLogger;
|
||||
use Espo\Core\Record\ConcurrencyControl\OptimisticProcessor;
|
||||
use Espo\Core\Record\Defaults\Populator as DefaultsPopulator;
|
||||
use Espo\Core\Record\Defaults\PopulatorFactory as DefaultsPopulatorFactory;
|
||||
use Espo\Core\Record\DynamicLogic\InputFilterProcessor;
|
||||
use Espo\Core\Record\Formula\Processor as FormulaProcessor;
|
||||
use Espo\Core\Record\Input\Data;
|
||||
use Espo\Core\Record\Input\Filter;
|
||||
@@ -703,6 +704,8 @@ class Service implements Crud,
|
||||
$this->loadAdditionalFields($entity);
|
||||
}
|
||||
|
||||
$this->filterInputReadOnlySaved($entity, $data);
|
||||
|
||||
if (!$this->acl->check($entity, AclTable::ACTION_EDIT)) {
|
||||
throw new ForbiddenSilent("No edit access.");
|
||||
}
|
||||
@@ -1719,4 +1722,17 @@ class Service implements Crud,
|
||||
throw new ForbiddenSilent("No create access.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter input by the read-only-pre-save dynamic logic.
|
||||
*
|
||||
* @since 9.1.0
|
||||
* @internal
|
||||
*/
|
||||
public function filterInputReadOnlySaved(Entity $entity, stdClass $data): void
|
||||
{
|
||||
$processor = $this->injectableFactory->create(InputFilterProcessor::class);
|
||||
|
||||
$processor->process($entity, $data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,6 +203,7 @@
|
||||
"dynamicLogicRequired": "Conditions making field required",
|
||||
"dynamicLogicOptions": "Conditional options",
|
||||
"dynamicLogicInvalid": "Conditions making field invalid",
|
||||
"dynamicLogicReadOnlySaved": "Saved state conditions making field read-only",
|
||||
"probabilityMap": "Stage Probabilities (%)",
|
||||
"notActualOptions": "Not Actual Options",
|
||||
"activeOptions": "Active Options",
|
||||
|
||||
@@ -387,6 +387,24 @@ class FieldManager
|
||||
}
|
||||
}
|
||||
|
||||
if (array_key_exists('dynamicLogicReadOnlySaved', $fieldDefs)) {
|
||||
if (!is_null($fieldDefs['dynamicLogicReadOnlySaved'])) {
|
||||
$this->prepareLogicDefsFields($logicDefs, $name);
|
||||
|
||||
$logicDefs['fields'][$name]['readOnlySaved'] = $fieldDefs['dynamicLogicReadOnlySaved'];
|
||||
|
||||
$logicDefsToBeSet = true;
|
||||
} else {
|
||||
if ($this->metadata->get(['logicDefs', $scope, 'fields', $name, 'readOnlySaved'])) {
|
||||
$this->prepareLogicDefsFields($logicDefs, $name);
|
||||
|
||||
$logicDefs['fields'][$name]['readOnlySaved'] = null;
|
||||
|
||||
$logicDefsToBeSet = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($logicDefsToBeSet) {
|
||||
$this->metadata->set('logicDefs', $scope, $logicDefs);
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
namespace Espo\Tools\MassUpdate;
|
||||
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Error;
|
||||
use Espo\Core\FieldProcessing\LinkMultiple\ListLoader as LinkMultipleLoader;
|
||||
use Espo\Core\FieldProcessing\Loader\Params as LoaderParams;
|
||||
use Espo\Core\MassAction\QueryBuilder;
|
||||
@@ -46,6 +45,7 @@ use Espo\Core\Record\Service;
|
||||
use Espo\Core\Utils\FieldUtil;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Utils\Log;
|
||||
use Espo\Core\Utils\ObjectUtil;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\ORM\Entity;
|
||||
use Espo\Repositories\Attachment as AttachmentRepository;
|
||||
@@ -75,7 +75,6 @@ class Processor
|
||||
/**
|
||||
* @throws BadRequest
|
||||
* @throws Forbidden
|
||||
* @throws Error
|
||||
*/
|
||||
public function process(Params $params, Data $data): Result
|
||||
{
|
||||
@@ -111,7 +110,13 @@ class Processor
|
||||
$count = 0;
|
||||
|
||||
foreach ($collection as $i => $entity) {
|
||||
$itemResult = $this->processEntity($entity, $filteredData, $i, $copyFieldList, $service);
|
||||
$itemResult = $this->processEntity(
|
||||
entity: $entity,
|
||||
data: $filteredData,
|
||||
i: $i,
|
||||
fieldToCopyList: $copyFieldList,
|
||||
service: $service,
|
||||
);
|
||||
|
||||
if (!$itemResult) {
|
||||
continue;
|
||||
@@ -157,20 +162,27 @@ class Processor
|
||||
* @param string[] $fieldToCopyList
|
||||
* @param Service<Entity> $service
|
||||
*/
|
||||
private function processEntity(Entity $entity, Data $data, int $i, array $fieldToCopyList, Service $service): bool
|
||||
{
|
||||
private function processEntity(
|
||||
Entity $entity,
|
||||
Data $data,
|
||||
int $i,
|
||||
array $fieldToCopyList,
|
||||
Service $service
|
||||
): bool {
|
||||
|
||||
$service->loadAdditionalFields($entity);
|
||||
|
||||
if (!$this->acl->check($entity, Table::ACTION_EDIT)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$values = $this->prepareItemValueMap($entity, $data, $i, $fieldToCopyList);
|
||||
|
||||
// Needed for link check.
|
||||
$this->linkMultipleLoader->process(
|
||||
$entity,
|
||||
LoaderParams::create()
|
||||
->withSelect($data->getAttributeList())
|
||||
);
|
||||
$service->filterInputReadOnlySaved($entity, $values);
|
||||
|
||||
if (count(get_object_vars($values)) === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$entity->set($values);
|
||||
|
||||
@@ -217,7 +229,9 @@ class Processor
|
||||
{
|
||||
$dataModified = $this->copy($entity->getEntityType(), $data, $i, $copyFieldList);
|
||||
|
||||
return $this->valueMapPreparator->prepare($entity, $dataModified);
|
||||
$values = $this->valueMapPreparator->prepare($entity, $dataModified);
|
||||
|
||||
return ObjectUtil::clone($values);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -74,6 +74,12 @@
|
||||
<div class="field" data-name="dynamicLogicInvalid">{{{dynamicLogicInvalid}}}</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if dynamicLogicReadOnlySaved}}
|
||||
<div class="cell form-group" data-name="dynamicLogicReadOnlySaved">
|
||||
<label class="control-label" data-name="dynamicLogicReadOnlySaved">{{translate 'dynamicLogicReadOnlySaved' scope='Admin' category='fields'}}</label>
|
||||
<div class="field" data-name="dynamicLogicReadOnlySaved">{{{dynamicLogicReadOnlySaved}}}</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -58,7 +58,12 @@ class DynamicLogic {
|
||||
* @type {string[]}
|
||||
* @private
|
||||
*/
|
||||
this.fieldTypeList = ['visible', 'required', 'readOnly'];
|
||||
this.fieldTypeList = [
|
||||
'visible',
|
||||
'required',
|
||||
'readOnlySaved',
|
||||
'readOnly',
|
||||
];
|
||||
|
||||
/**
|
||||
* @type {string[]}
|
||||
@@ -74,34 +79,58 @@ class DynamicLogic {
|
||||
const fields = this.defs.fields || {};
|
||||
|
||||
Object.keys(fields).forEach(field => {
|
||||
const item = (fields[field] || {});
|
||||
/** @type {Record} */
|
||||
const item = fields[field] || {};
|
||||
|
||||
let readOnlyIsProcessed = false;
|
||||
|
||||
this.fieldTypeList.forEach(type => {
|
||||
if (!(type in item)) {
|
||||
if (!(type in item) || !item[type]) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!item[type]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const typeItem = (item[type] || {});
|
||||
/** @type {Record} */
|
||||
const typeItem = item[type] || {};
|
||||
|
||||
if (!typeItem.conditionGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'readOnlySaved') {
|
||||
if (this.recordView.model.isNew()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.checkConditionGroupInternal(typeItem.conditionGroup, 'and', true)) {
|
||||
this.makeFieldReadOnlyTrue(field);
|
||||
|
||||
readOnlyIsProcessed = true;
|
||||
} else {
|
||||
this.makeFieldReadOnlyFalse(field);
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const result = this.checkConditionGroupInternal(typeItem.conditionGroup);
|
||||
|
||||
if (type === 'required') {
|
||||
if (type === 'required' && !readOnlyIsProcessed) {
|
||||
result ?
|
||||
this.makeFieldRequiredTrue(field) :
|
||||
this.makeFieldRequiredFalse(field);
|
||||
} else if (type === 'readOnly') {
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'readOnly') {
|
||||
result ?
|
||||
this.makeFieldReadOnlyTrue(field) :
|
||||
this.makeFieldReadOnlyFalse(field);
|
||||
} else if (type === 'visible') {
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'visible') {
|
||||
result ?
|
||||
this.makeFieldVisibleTrue(field) :
|
||||
this.makeFieldVisibleFalse(field);
|
||||
@@ -190,11 +219,12 @@ class DynamicLogic {
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} data A condition group.
|
||||
* @param {'and'|'or'|'not'} [type='and'] A type.
|
||||
* @param {Object} data
|
||||
* @param {'and'|'or'|'not'} [type='and']
|
||||
* @param {boolean} [preSave]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
checkConditionGroupInternal(data, type) {
|
||||
checkConditionGroupInternal(data, type, preSave = false) {
|
||||
type = type || 'and';
|
||||
|
||||
let list;
|
||||
@@ -206,7 +236,7 @@ class DynamicLogic {
|
||||
result = true;
|
||||
|
||||
for (const i in list) {
|
||||
if (!this.checkCondition(list[i])) {
|
||||
if (!this.checkCondition(list[i], preSave)) {
|
||||
result = false;
|
||||
|
||||
break;
|
||||
@@ -216,7 +246,7 @@ class DynamicLogic {
|
||||
list = data || [];
|
||||
|
||||
for (const i in list) {
|
||||
if (this.checkCondition(list[i])) {
|
||||
if (this.checkCondition(list[i], preSave)) {
|
||||
result = true;
|
||||
|
||||
break;
|
||||
@@ -224,7 +254,7 @@ class DynamicLogic {
|
||||
}
|
||||
} else if (type === 'not') {
|
||||
if (data) {
|
||||
result = !this.checkCondition(data);
|
||||
result = !this.checkCondition(data, preSave);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,9 +264,10 @@ class DynamicLogic {
|
||||
/**
|
||||
* @private
|
||||
* @param {string} attribute
|
||||
* @param {boolean} preSave
|
||||
* @return {*}
|
||||
*/
|
||||
getAttributeValue(attribute) {
|
||||
getAttributeValue(attribute, preSave) {
|
||||
if (attribute.startsWith('$')) {
|
||||
if (attribute === '$user.id') {
|
||||
return this.recordView.getUser().id;
|
||||
@@ -247,6 +278,10 @@ class DynamicLogic {
|
||||
}
|
||||
}
|
||||
|
||||
if (preSave) {
|
||||
return this.recordView.attributes[attribute];
|
||||
}
|
||||
|
||||
if (!this.recordView.model.has(attribute)) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -256,16 +291,17 @@ class DynamicLogic {
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} defs Definitions.
|
||||
* @param {Object} defs
|
||||
* @param {boolean} preSave
|
||||
* @returns {boolean}
|
||||
*/
|
||||
checkCondition(defs) {
|
||||
checkCondition(defs, preSave) {
|
||||
defs = defs || {};
|
||||
|
||||
const type = defs.type || 'equals';
|
||||
|
||||
if (['or', 'and', 'not'].includes(type)) {
|
||||
return this.checkConditionGroupInternal(defs.value, /** @type {'or'|'and'|'not'} */ type);
|
||||
return this.checkConditionGroupInternal(defs.value, /** @type {'or'|'and'|'not'} */type, preSave);
|
||||
}
|
||||
|
||||
const attribute = defs.attribute;
|
||||
@@ -275,7 +311,7 @@ class DynamicLogic {
|
||||
return false;
|
||||
}
|
||||
|
||||
const setValue = this.getAttributeValue(attribute);
|
||||
const setValue = this.getAttributeValue(attribute, preSave);
|
||||
|
||||
if (type === 'equals') {
|
||||
return setValue === value;
|
||||
|
||||
@@ -537,6 +537,22 @@ class FieldManagerEditView extends View {
|
||||
this.hasDynamicLogicPanel = true;
|
||||
}
|
||||
|
||||
if (!defs.dynamicLogicReadOnlySavedDisabled && !readOnly) {
|
||||
const dynamicLogicReadOnlySaved = this.getMetadata()
|
||||
.get(['logicDefs', this.scope, 'fields', this.field, 'readOnlySaved']);
|
||||
|
||||
this.model.set('dynamicLogicReadOnlySaved', dynamicLogicReadOnlySaved);
|
||||
|
||||
promiseList.push(
|
||||
this.createFieldView(null, 'dynamicLogicReadOnlySaved', null, {
|
||||
view: 'views/admin/field-manager/fields/dynamic-logic-conditions',
|
||||
scope: this.scope,
|
||||
})
|
||||
);
|
||||
|
||||
this.hasDynamicLogicPanel = true;
|
||||
}
|
||||
|
||||
return Promise.all(promiseList);
|
||||
}
|
||||
|
||||
@@ -577,12 +593,14 @@ class FieldManagerEditView extends View {
|
||||
this.hideField('dynamicLogicRequired');
|
||||
this.hideField('dynamicLogicOptions');
|
||||
this.hideField('dynamicLogicInvalid');
|
||||
this.hideField('dynamicLogicPreSave');
|
||||
}
|
||||
else {
|
||||
this.showField('dynamicLogicReadOnly');
|
||||
this.showField('dynamicLogicRequired');
|
||||
this.showField('dynamicLogicOptions');
|
||||
this.showField('dynamicLogicInvalid');
|
||||
this.showField('dynamicLogicPreSave');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -748,7 +748,24 @@ class BaseRecordView extends View {
|
||||
|
||||
this.dynamicLogic = new DynamicLogic(this.dynamicLogicDefs, this);
|
||||
|
||||
this.listenTo(this.model, 'change', () => this.processDynamicLogic());
|
||||
this.listenTo(this.model, 'sync', (m, a, /** Record */o) => {
|
||||
if (o.action !== 'save' && o.action !== 'fetch') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Pre-save attributes not yet prepared.
|
||||
setTimeout(() => this.processDynamicLogic(), 0);
|
||||
});
|
||||
|
||||
this.listenTo(this.model, 'change', (m, /** Record */o) => {
|
||||
if (o.action === 'save' || o.action === 'fetch') {
|
||||
// To be processed by the 'sync' handler.
|
||||
return;
|
||||
}
|
||||
|
||||
this.processDynamicLogic();
|
||||
});
|
||||
|
||||
this.processDynamicLogic();
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
|
||||
namespace tests\integration\Espo\Record;
|
||||
|
||||
use Espo\Core\FieldValidation\Type;
|
||||
use Espo\Core\Record\CreateParams;
|
||||
use Espo\Core\Record\ServiceContainer;
|
||||
use Espo\Core\Record\UpdateParams;
|
||||
@@ -75,4 +76,49 @@ class ReadOnlyTest extends BaseTestCase
|
||||
$this->assertEquals('Test', $account->get('name'));
|
||||
$this->assertEquals('Hello', $account->get('billingAddressCity'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnhandledExceptionInspection
|
||||
*/
|
||||
public function testReadOnlyDynamicLogic(): void
|
||||
{
|
||||
$metadata = $this->getMetadata();
|
||||
|
||||
$metadata->set('logicDefs', Account::ENTITY_TYPE, [
|
||||
'fields' => [
|
||||
'description' => [
|
||||
'readOnlySaved' => [
|
||||
'conditionGroup' => [
|
||||
[
|
||||
'type' => 'equals',
|
||||
'attribute' => 'type',
|
||||
'value' => 'Customer',
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]);
|
||||
|
||||
$metadata->save();
|
||||
|
||||
$this->reCreateApplication();
|
||||
|
||||
$service = $this->getContainer()->getByClass(ServiceContainer::class)->getByClass(Account::class);
|
||||
|
||||
$account = $service->create((object) [
|
||||
'name' => 'Test',
|
||||
'description' => '1',
|
||||
], CreateParams::create());
|
||||
|
||||
$service->update($account->getId(), (object) [
|
||||
'type' => 'Customer',
|
||||
], UpdateParams::create());
|
||||
|
||||
$account = $service->update($account->getId(), (object) [
|
||||
'description' => '2',
|
||||
], UpdateParams::create());
|
||||
|
||||
$this->assertEquals('1', $account->getValueMap()->description);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user