read-only saved

This commit is contained in:
Yuri Kuznetsov
2025-04-08 14:18:30 +03:00
parent 33914a9b61
commit e401f3aef2
10 changed files with 297 additions and 35 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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