From e401f3aef24f827e168f524ee9468e439bf60338 Mon Sep 17 00:00:00 2001 From: Yuri Kuznetsov Date: Tue, 8 Apr 2025 14:18:30 +0300 Subject: [PATCH] read-only saved --- .../DynamicLogic/InputFilterProcessor.php | 90 +++++++++++++++++++ application/Espo/Core/Record/Service.php | 16 ++++ .../Espo/Resources/i18n/en_US/Admin.json | 1 + .../Espo/Tools/FieldManager/FieldManager.php | 18 ++++ .../Espo/Tools/MassUpdate/Processor.php | 38 +++++--- .../templates/admin/field-manager/edit.tpl | 6 ++ client/src/dynamic-logic.js | 80 ++++++++++++----- client/src/views/admin/field-manager/edit.js | 18 ++++ client/src/views/record/base.js | 19 +++- .../integration/Espo/Record/ReadOnlyTest.php | 46 ++++++++++ 10 files changed, 297 insertions(+), 35 deletions(-) create mode 100644 application/Espo/Core/Record/DynamicLogic/InputFilterProcessor.php diff --git a/application/Espo/Core/Record/DynamicLogic/InputFilterProcessor.php b/application/Espo/Core/Record/DynamicLogic/InputFilterProcessor.php new file mode 100644 index 0000000000..f08fd168f8 --- /dev/null +++ b/application/Espo/Core/Record/DynamicLogic/InputFilterProcessor.php @@ -0,0 +1,90 @@ +. + * + * 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> $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); + } + } +} diff --git a/application/Espo/Core/Record/Service.php b/application/Espo/Core/Record/Service.php index 7de9c6642b..3eb967cdf7 100644 --- a/application/Espo/Core/Record/Service.php +++ b/application/Espo/Core/Record/Service.php @@ -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); + } } diff --git a/application/Espo/Resources/i18n/en_US/Admin.json b/application/Espo/Resources/i18n/en_US/Admin.json index 490c63c7bf..85d1ab433e 100644 --- a/application/Espo/Resources/i18n/en_US/Admin.json +++ b/application/Espo/Resources/i18n/en_US/Admin.json @@ -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", diff --git a/application/Espo/Tools/FieldManager/FieldManager.php b/application/Espo/Tools/FieldManager/FieldManager.php index 29d5fd58fd..c883e8734a 100644 --- a/application/Espo/Tools/FieldManager/FieldManager.php +++ b/application/Espo/Tools/FieldManager/FieldManager.php @@ -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); diff --git a/application/Espo/Tools/MassUpdate/Processor.php b/application/Espo/Tools/MassUpdate/Processor.php index 5912e5af0a..2935a06e8c 100644 --- a/application/Espo/Tools/MassUpdate/Processor.php +++ b/application/Espo/Tools/MassUpdate/Processor.php @@ -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 $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); } /** diff --git a/client/res/templates/admin/field-manager/edit.tpl b/client/res/templates/admin/field-manager/edit.tpl index ca71fcd02d..24e1648992 100644 --- a/client/res/templates/admin/field-manager/edit.tpl +++ b/client/res/templates/admin/field-manager/edit.tpl @@ -74,6 +74,12 @@
{{{dynamicLogicInvalid}}}
{{/if}} + {{#if dynamicLogicReadOnlySaved}} +
+ +
{{{dynamicLogicReadOnlySaved}}}
+
+ {{/if}} diff --git a/client/src/dynamic-logic.js b/client/src/dynamic-logic.js index fbd660d062..d0a3779d81 100644 --- a/client/src/dynamic-logic.js +++ b/client/src/dynamic-logic.js @@ -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; diff --git a/client/src/views/admin/field-manager/edit.js b/client/src/views/admin/field-manager/edit.js index ffc2038b2d..79d6225fba 100644 --- a/client/src/views/admin/field-manager/edit.js +++ b/client/src/views/admin/field-manager/edit.js @@ -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'); } } diff --git a/client/src/views/record/base.js b/client/src/views/record/base.js index ae25da1e53..d2074a978a 100644 --- a/client/src/views/record/base.js +++ b/client/src/views/record/base.js @@ -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(); } diff --git a/tests/integration/Espo/Record/ReadOnlyTest.php b/tests/integration/Espo/Record/ReadOnlyTest.php index 60254d91fe..1baa635124 100644 --- a/tests/integration/Espo/Record/ReadOnlyTest.php +++ b/tests/integration/Espo/Record/ReadOnlyTest.php @@ -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); + } }