From 74a3e9f6bd57f1cf18fbe7191744936d9bcf1242 Mon Sep 17 00:00:00 2001 From: Yurii Date: Mon, 2 Mar 2026 17:57:33 +0200 Subject: [PATCH] cascading links --- .../Common/Link/CascadingSelect.php | 98 +++ .../Common/LinkMultiple/CascadingSelect.php | 110 ++++ .../Espo/Resources/i18n/en_US/Admin.json | 1 + .../Resources/i18n/en_US/DynamicLogic.json | 5 + .../Espo/Resources/metadata/fields/link.json | 6 +- .../metadata/fields/linkMultiple.json | 6 +- .../Resources/metadata/fields/linkOne.json | 6 +- .../DynamicLogic/CascadingFields/Item.php | 39 ++ .../CascadingFields/ItemsProvider.php | 68 ++ .../CascadingFields/ValidationHelper.php | 106 ++++ .../Espo/Tools/FieldManager/FieldManager.php | 20 + .../templates/admin/field-manager/edit.tpl | 6 + client/src/dynamic-logic.js | 225 +++++-- client/src/helpers/field/cascade-links.js | 349 +++++++++++ client/src/views/admin/field-manager/edit.js | 19 + .../fields/dynamic-logic-cascading.js | 581 ++++++++++++++++++ client/src/views/fields/id.js | 1 + client/src/views/fields/link-multiple.js | 158 +++-- client/src/views/fields/link.js | 131 ++-- client/src/views/record/base.js | 7 +- client/src/views/record/detail.js | 11 +- .../DynamicLogic/CascadingFieldsTest.php | 243 ++++++++ 22 files changed, 2023 insertions(+), 173 deletions(-) create mode 100644 application/Espo/Classes/FieldValidators/Common/Link/CascadingSelect.php create mode 100644 application/Espo/Classes/FieldValidators/Common/LinkMultiple/CascadingSelect.php create mode 100644 application/Espo/Tools/DynamicLogic/CascadingFields/Item.php create mode 100644 application/Espo/Tools/DynamicLogic/CascadingFields/ItemsProvider.php create mode 100644 application/Espo/Tools/DynamicLogic/CascadingFields/ValidationHelper.php create mode 100644 client/src/helpers/field/cascade-links.js create mode 100644 client/src/views/admin/field-manager/fields/dynamic-logic-cascading.js create mode 100644 tests/integration/Espo/Tools/DynamicLogic/CascadingFieldsTest.php diff --git a/application/Espo/Classes/FieldValidators/Common/Link/CascadingSelect.php b/application/Espo/Classes/FieldValidators/Common/Link/CascadingSelect.php new file mode 100644 index 0000000000..76597efc52 --- /dev/null +++ b/application/Espo/Classes/FieldValidators/Common/Link/CascadingSelect.php @@ -0,0 +1,98 @@ +. + * + * 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\Classes\FieldValidators\Common\Link; + +use Espo\Core\Field\Link; +use Espo\Core\FieldValidation\Validator; +use Espo\Core\FieldValidation\Validator\Data; +use Espo\Core\FieldValidation\Validator\Failure; +use Espo\Core\ORM\Entity as CoreEntity; +use Espo\ORM\Defs; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; +use Espo\Tools\DynamicLogic\CascadingFields\ItemsProvider; +use Espo\Tools\DynamicLogic\CascadingFields\ValidationHelper; + +/** + * @implements Validator + */ +class CascadingSelect implements Validator +{ + public function __construct( + private EntityManager $entityManager, + private Defs $defs, + private ValidationHelper $helper, + private ItemsProvider $itemsProvider, + ) {} + + public function validate(Entity $entity, string $field, Data $data): ?Failure + { + if (!$entity instanceof CoreEntity) { + return null; + } + + $items = $this->itemsProvider->get($entity->getEntityType(), $field); + + if (!$items) { + return null; + } + + $linkValue = $entity->getValueObject($field); + + if (!$linkValue instanceof Link) { + return null; + } + + $entityType = $this->defs + ->getEntity($entity->getEntityType()) + ->tryGetRelation($field) + ?->tryGetForeignEntityType(); + + if (!$entityType) { + return null; + } + + $valueEntity = $this->entityManager->getEntityById($entityType, $linkValue->getId()); + + if (!$valueEntity instanceof CoreEntity) { + return null; + } + + foreach ($items as $item) { + $itemFailure = $this->helper->validateItem($entity, $valueEntity, $item); + + if ($itemFailure) { + return $itemFailure; + } + } + + return null; + } +} diff --git a/application/Espo/Classes/FieldValidators/Common/LinkMultiple/CascadingSelect.php b/application/Espo/Classes/FieldValidators/Common/LinkMultiple/CascadingSelect.php new file mode 100644 index 0000000000..58843d9632 --- /dev/null +++ b/application/Espo/Classes/FieldValidators/Common/LinkMultiple/CascadingSelect.php @@ -0,0 +1,110 @@ +. + * + * 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\Classes\FieldValidators\Common\LinkMultiple; + +use Espo\Core\Field\LinkMultiple; +use Espo\Core\FieldValidation\Validator; +use Espo\Core\FieldValidation\Validator\Data; +use Espo\Core\FieldValidation\Validator\Failure; +use Espo\Core\ORM\Entity as CoreEntity; +use Espo\ORM\Defs; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; +use Espo\ORM\Name\Attribute; +use Espo\Tools\DynamicLogic\CascadingFields\ItemsProvider; +use Espo\Tools\DynamicLogic\CascadingFields\ValidationHelper; + +/** + * @implements Validator + */ +class CascadingSelect implements Validator +{ + public function __construct( + private EntityManager $entityManager, + private Defs $defs, + private ValidationHelper $helper, + private ItemsProvider $itemsProvider, + ) {} + + public function validate(Entity $entity, string $field, Data $data): ?Failure + { + if (!$entity instanceof CoreEntity) { + return null; + } + + $items = $this->itemsProvider->get($entity->getEntityType(), $field); + + if (!$items) { + return null; + } + + $linkValue = $entity->getValueObject($field); + + if (!$linkValue instanceof LinkMultiple || !$linkValue->getIdList()) { + return null; + } + + $entityType = $this->defs + ->getEntity($entity->getEntityType()) + ->tryGetRelation($field) + ?->tryGetForeignEntityType(); + + if (!$entityType) { + return null; + } + + $valueEntities = $this->entityManager + ->getRDBRepository($entityType) + ->where([ + Attribute::ID => $linkValue->getIdList(), + ]) + ->find(); + + if (!count($valueEntities)) { + return null; + } + + foreach ($valueEntities as $valueEntity) { + if (!$valueEntity instanceof CoreEntity) { + continue; + } + + foreach ($items as $item) { + $itemFailure = $this->helper->validateItem($entity, $valueEntity, $item); + + if ($itemFailure) { + return $itemFailure; + } + } + } + + return null; + } +} diff --git a/application/Espo/Resources/i18n/en_US/Admin.json b/application/Espo/Resources/i18n/en_US/Admin.json index 29d62547fc..70979703bf 100644 --- a/application/Espo/Resources/i18n/en_US/Admin.json +++ b/application/Espo/Resources/i18n/en_US/Admin.json @@ -205,6 +205,7 @@ "dynamicLogicOptions": "Conditional options", "dynamicLogicInvalid": "Conditions making field invalid", "dynamicLogicReadOnlySaved": "Saved state conditions making field read-only", + "dynamicLogicCascading": "Cascading select", "probabilityMap": "Stage Probabilities (%)", "notActualOptions": "Not Actual Options", "activeOptions": "Active Options", diff --git a/application/Espo/Resources/i18n/en_US/DynamicLogic.json b/application/Espo/Resources/i18n/en_US/DynamicLogic.json index b20a691e31..c9f2de8f47 100644 --- a/application/Espo/Resources/i18n/en_US/DynamicLogic.json +++ b/application/Espo/Resources/i18n/en_US/DynamicLogic.json @@ -27,5 +27,10 @@ "endsWith": "Ends With", "matches": "Matches (reg exp)" } + }, + "fields": { + "localField": "Local Field", + "foreignField": "Foreign Field", + "matchRequired": "Match Required" } } diff --git a/application/Espo/Resources/metadata/fields/link.json b/application/Espo/Resources/metadata/fields/link.json index 834e98c3d5..2dc7d9b4f5 100644 --- a/application/Espo/Resources/metadata/fields/link.json +++ b/application/Espo/Resources/metadata/fields/link.json @@ -44,8 +44,12 @@ "pattern" ], "mandatoryValidationList": [ - "pattern" + "pattern", + "cascadingSelect" ], + "validatorClassNameMap": { + "cascadingSelect": "Espo\\Classes\\FieldValidators\\Common\\Link\\CascadingSelect" + }, "filter": true, "notCreatable": true, "valueFactoryClassName": "Espo\\Core\\Field\\Link\\LinkFactory", diff --git a/application/Espo/Resources/metadata/fields/linkMultiple.json b/application/Espo/Resources/metadata/fields/linkMultiple.json index b9ef44945b..45b0e638c5 100644 --- a/application/Espo/Resources/metadata/fields/linkMultiple.json +++ b/application/Espo/Resources/metadata/fields/linkMultiple.json @@ -60,8 +60,12 @@ ], "mandatoryValidationList": [ "pattern", - "columnsValid" + "columnsValid", + "cascadingSelect" ], + "validatorClassNameMap": { + "cascadingSelect": "Espo\\Classes\\FieldValidators\\Common\\LinkMultiple\\CascadingSelect" + }, "notCreatable": true, "notSortable": true, "filter": true, diff --git a/application/Espo/Resources/metadata/fields/linkOne.json b/application/Espo/Resources/metadata/fields/linkOne.json index 690f5bf8b5..7624849dc0 100644 --- a/application/Espo/Resources/metadata/fields/linkOne.json +++ b/application/Espo/Resources/metadata/fields/linkOne.json @@ -38,8 +38,12 @@ "pattern" ], "mandatoryValidationList": [ - "pattern" + "pattern", + "cascadingSelect" ], + "validatorClassNameMap": { + "cascadingSelect": "Espo\\Classes\\FieldValidators\\Common\\Link\\CascadingSelect" + }, "validatorClassName": "Espo\\Classes\\FieldValidators\\LinkType", "filter": true, "notCreatable": true diff --git a/application/Espo/Tools/DynamicLogic/CascadingFields/Item.php b/application/Espo/Tools/DynamicLogic/CascadingFields/Item.php new file mode 100644 index 0000000000..2c55c71d5f --- /dev/null +++ b/application/Espo/Tools/DynamicLogic/CascadingFields/Item.php @@ -0,0 +1,39 @@ +. + * + * 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\Tools\DynamicLogic\CascadingFields; + +readonly class Item +{ + public function __construct( + public string $localField, + public string $foreignField, + public bool $matchRequired, + ) {} +} diff --git a/application/Espo/Tools/DynamicLogic/CascadingFields/ItemsProvider.php b/application/Espo/Tools/DynamicLogic/CascadingFields/ItemsProvider.php new file mode 100644 index 0000000000..7f6e80a763 --- /dev/null +++ b/application/Espo/Tools/DynamicLogic/CascadingFields/ItemsProvider.php @@ -0,0 +1,68 @@ +. + * + * 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\Tools\DynamicLogic\CascadingFields; + +use Espo\Core\Utils\Metadata; + +class ItemsProvider +{ + public function __construct( + private Metadata $metadata, + ) {} + + /** + * @return Item[] + */ + public function get(string $entityType, string $field): array + { + /** @var array[] $rawItems */ + $rawItems = $this->metadata->get("logicDefs.$entityType.cascadingFields.$field.items") ?? []; + + $items = []; + + foreach ($rawItems as $raw) { + $localField = $raw['localField'] ?? null; + $foreignField = $raw['foreignField'] ?? null; + $matchRequired = $raw['matchRequired'] ?? false; + + if (!$localField || !$foreignField) { + continue; + } + + $items[] = new Item( + localField: $localField, + foreignField: $foreignField, + matchRequired: $matchRequired, + ); + } + + return $items; + } +} diff --git a/application/Espo/Tools/DynamicLogic/CascadingFields/ValidationHelper.php b/application/Espo/Tools/DynamicLogic/CascadingFields/ValidationHelper.php new file mode 100644 index 0000000000..3f352c8299 --- /dev/null +++ b/application/Espo/Tools/DynamicLogic/CascadingFields/ValidationHelper.php @@ -0,0 +1,106 @@ +. + * + * 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\Tools\DynamicLogic\CascadingFields; + +use Espo\Core\FieldValidation\Validator\Failure; +use Espo\Core\ORM\Entity as CoreEntity; +use Espo\Core\ORM\Type\FieldType; +use Espo\ORM\Defs; + +/** + * @internal + */ +class ValidationHelper +{ + public function __construct( + private Defs $defs, + ) {} + + public function validateItem(CoreEntity $entity, CoreEntity $valueEntity, Item $item): ?Failure + { + if (!$item->matchRequired) { + return null; + } + + $localIds = $this->getIds($entity, $item->localField); + $foreignIds = $this->getIds($valueEntity, $item->foreignField) ?? []; + + if (!$localIds) { + if ($foreignIds) { + return Failure::create(); + } + + return null; + } + + if ($this->getType($valueEntity, $item->foreignField) === FieldType::LINK_MULTIPLE) { + if (!array_diff($localIds, $foreignIds)) { + return null; + } + + return Failure::create(); + } + + if (array_intersect($localIds, $foreignIds)) { + return null; + } + + return Failure::create(); + } + + /** + * @return ?string[] + */ + private function getIds(CoreEntity $entity, string $field): ?array + { + $type = $this->getType($entity, $field); + + if ($type === FieldType::LINK_MULTIPLE) { + return $entity->getLinkMultipleIdList($field); + } + + $localId = $entity->get($field . 'Id'); + + if (!$localId) { + return null; + } + + return [$localId]; + } + + + private function getType(CoreEntity $entity, string $field): ?string + { + return $this->defs + ->getEntity($entity->getEntityType()) + ->tryGetField($field) + ?->getType(); + } +} diff --git a/application/Espo/Tools/FieldManager/FieldManager.php b/application/Espo/Tools/FieldManager/FieldManager.php index a5d908a874..ec52540d28 100644 --- a/application/Espo/Tools/FieldManager/FieldManager.php +++ b/application/Espo/Tools/FieldManager/FieldManager.php @@ -247,6 +247,8 @@ class FieldManager $type = $fieldDefs['type'] ?? $this->metadata->get(['entityDefs', $scope, 'fields', $name, 'type']); + $fieldDefs['type'] ??= $type; + $this->processHook('beforeSave', $type, $scope, $name, $fieldDefs, ['isNew' => $isNew]); if ($this->metadata->get(['fields', $type, 'translatedOptions'])) { @@ -405,6 +407,23 @@ class FieldManager } } + if (array_key_exists('dynamicLogicCascading', $fieldDefs)) { + if (!is_null($fieldDefs['dynamicLogicCascading'])) { + $logicDefs['cascadingFields'] ??= []; + $logicDefs['cascadingFields'][$name] = $fieldDefs['dynamicLogicCascading']; + + $logicDefsToBeSet = true; + } else if ( + $this->metadata->get(['logicDefs', $scope, 'cascadingFields', $name]) + ) { + $this->prepareLogicDefsFields($logicDefs, $name); + + $logicDefs['cascadingFields'][$name] = null; + + $logicDefsToBeSet = true; + } + } + if ($logicDefsToBeSet) { $this->metadata->set('logicDefs', $scope, $logicDefs); @@ -548,6 +567,7 @@ class FieldManager $this->metadata->delete('logicDefs', $scope, [ "fields.$name", "options.$name", + "cascadingFields.$name", ]); $this->metadata->save(); diff --git a/client/res/templates/admin/field-manager/edit.tpl b/client/res/templates/admin/field-manager/edit.tpl index eb098203c5..0396443a1c 100644 --- a/client/res/templates/admin/field-manager/edit.tpl +++ b/client/res/templates/admin/field-manager/edit.tpl @@ -78,6 +78,12 @@
{{{dynamicLogicReadOnlySaved}}}
{{/if}} + {{#if dynamicLogicCascading}} +
+ +
{{{dynamicLogicCascading}}}
+
+ {{/if}} diff --git a/client/src/dynamic-logic.js b/client/src/dynamic-logic.js index 312f333ebb..ad0690a490 100644 --- a/client/src/dynamic-logic.js +++ b/client/src/dynamic-logic.js @@ -29,54 +29,111 @@ /** @module dynamic-logic */ /** - * Dynamic logic. Handles form appearance and behaviour depending on conditions. + * @typedef {Record} module:dynamic-logic~defs + * @property {Object.} [fields] Fields. + * @property {Object.} [panels] Panels. + * @property {Object.} [options] Options. + * @property {Object.} [cascadingFields] Cascading fields. + */ + +/** + * @typedef {Record} module:dynamic-logic~fieldDefs + * @property {{conditionGroup: module:dynamic-logic~conditionGroup}} [visible] Visibility conditions. + * @property {{conditionGroup: module:dynamic-logic~conditionGroup}} [required] Requiring conditions. + * @property {{conditionGroup: module:dynamic-logic~conditionGroup}} [readOnly] Read-only conditions. + * @property {{conditionGroup: module:dynamic-logic~conditionGroup}} [readOnlySaved] Read-only saved conditions. + * @property {{conditionGroup: module:dynamic-logic~conditionGroup}} [invalid] Invalidity conditions. + */ + +/** + * @typedef {Record} module:dynamic-logic~panelDefs + * @property {{conditionGroup: module:dynamic-logic~conditionGroup}} [visible] Visibility conditions. + */ + +/** + * @typedef {Record} module:dynamic-logic~cascadingFieldDefs + * @property {{localField: string, foreignField: string, matchRequired: boolean}[]} [items] Visibility conditions. + */ + +/** + * @typedef {Record[]} module:dynamic-logic~conditionGroup + */ + +import {inject} from 'di'; +import FieldManager from 'field-manager'; + +/** + * Dynamic logic. Handles form appearance and behavior depending on conditions. * * @internal Instantiated in advanced-pack. */ class DynamicLogic { /** - * @param {Object} defs Definitions. - * @param {module:views/record/base} recordView A record view. + * @private + * @type {module:dynamic-logic~defs} defs Definitions. + */ + defs + + /** + * @private + * @type {import('views/record/base').default} + */ + recordView + + /** + * @private + * @type {string[]} + * @const + */ + fieldTypeList = [ + 'visible', + 'required', + 'readOnlySaved', + 'readOnly', + ] + + /** + * @private + * @type {string[]} + + */ + panelTypeList = [ + 'visible', + 'styled', + ] + + /** + * @private + * @type {Object.} + */ + cascadingClearDefs + + /** + * @private + * @type {FieldManager} + */ + @inject(FieldManager) + fieldManager + + /** + * @param {module:dynamic-logic~defs} defs Definitions. + * @param {import('views/record/base').default} recordView A record view. */ constructor(defs, recordView) { - - /** - * @type {Object} Definitions. - * @private - */ - this.defs = defs || {}; - - /** - * - * @type {module:views/record/base} - * @private - */ + this.defs = defs ?? {}; this.recordView = recordView; - /** - * @type {string[]} - * @private - */ - this.fieldTypeList = [ - 'visible', - 'required', - 'readOnlySaved', - 'readOnly', - ]; - - /** - * @type {string[]} - * @private - */ - this.panelTypeList = ['visible', 'styled']; + this.cascadingClearDefs = this.buildCascadingClearDefs(); } /** * Process. + * + * @param {{action?: string|'ui'}} [options] Options. */ - process() { - const fields = this.defs.fields || {}; + process(options = {}) { + const fields = this.defs.fields ?? {}; Object.keys(fields).forEach(field => { /** @type {Record} */ @@ -138,7 +195,7 @@ class DynamicLogic { }); }); - const panels = this.defs.panels || {}; + const panels = this.defs.panels ?? {}; Object.keys(panels).forEach(panel => { this.panelTypeList.forEach(type => { @@ -146,12 +203,12 @@ class DynamicLogic { }); }); - const options = this.defs.options || {}; + const optionsDefs = this.defs.options ?? {}; - Object.keys(options).forEach(field => { - const itemList = options[field]; + Object.keys(optionsDefs).forEach(field => { + const itemList = optionsDefs[field]; - if (!options[field]) { + if (!optionsDefs[field]) { return; } @@ -173,6 +230,88 @@ class DynamicLogic { this.resetOptionList(field); } }); + + if (options.action === 'ui') { + this.processCascadingClear(); + } + } + + /** + * @private + * @return {Object.} + */ + buildCascadingClearDefs() { + const fields = this.defs.cascadingFields ?? null; + + if (!fields || Object.keys(fields).length === 0) { + return {}; + } + + const model = this.recordView.model; + + /** @type {Object.} */ + const map = {}; + + for (const [field, defs] of Object.entries(fields)) { + const items = defs.items; + + if (!items || !items.length) { + continue; + } + + for (const item of items) { + if (!item.matchRequired) { + continue; + } + + const type = model.getFieldType(item.localField); + + const idAttribute = type === 'linkMultiple' ? item.localField + 'Ids' : item.localField + 'Id'; + + map[idAttribute] ??= []; + + const attributeList = this.fieldManager.getEntityTypeFieldAttributeList(model.entityType, field); + + map[idAttribute].push(...attributeList); + } + } + + for (const [attribute, a] of Object.entries(map)) { + map[attribute] = a.filter((it, i) => a.indexOf(it) === i); + } + + return map; + } + + /** + * @private + */ + processCascadingClear() { + const attributeList = Object.keys(this.cascadingClearDefs); + const model = this.recordView.model; + + const fieldsToUnset = []; + + for (const attribute of attributeList) { + if (model.hasChanged(attribute)) { + fieldsToUnset.push(...this.cascadingClearDefs[attribute]); + } + } + + if (!fieldsToUnset.length) { + return; + } + + const map = fieldsToUnset.reduce((p, it) => { + p[it] = null; + + return p; + }, {}) + + + setTimeout(() => { + model.setMultiple(map) + }, 0) } /** @@ -181,7 +320,7 @@ class DynamicLogic { * @private */ processPanel(panel, type) { - const panels = this.defs.panels || {}; + const panels = this.defs.panels ?? {}; const item = (panels[panel] || {}); if (!(type in item)) { @@ -564,8 +703,8 @@ class DynamicLogic { * @param {Object} item Condition definitions. */ addPanelVisibleCondition(name, item) { - this.defs.panels = this.defs.panels || {}; - this.defs.panels[name] = this.defs.panels[name] || {}; + this.defs.panels = this.defs.panels ?? {}; + this.defs.panels[name] = this.defs.panels[name] ?? {}; this.defs.panels[name].visible = item; @@ -579,8 +718,8 @@ class DynamicLogic { * @param {Object} item Condition definitions. */ addPanelStyledCondition(name, item) { - this.defs.panels = this.defs.panels || {}; - this.defs.panels[name] = this.defs.panels[name] || {}; + this.defs.panels = this.defs.panels ?? {}; + this.defs.panels[name] = this.defs.panels[name] ?? {}; this.defs.panels[name].styled = item; diff --git a/client/src/helpers/field/cascade-links.js b/client/src/helpers/field/cascade-links.js new file mode 100644 index 0000000000..8b89864578 --- /dev/null +++ b/client/src/helpers/field/cascade-links.js @@ -0,0 +1,349 @@ +/************************************************************************ + * This file is part of EspoCRM. + * + * EspoCRM – Open Source CRM application. + * Copyright (C) 2014-2026 EspoCRM, Inc. + * 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 . + * + * 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. + ************************************************************************/ + +import {inject} from 'di'; +import Metadata from 'metadata'; + +// noinspection JSUnusedGlobalSymbols +export default class CascadeLinksHelper { + + /** + * @type {Metadata} + * @private} + */ + @inject(Metadata) + metadata + + /** + * @param {{ + * model: import('model').default, + * foreignEntityType: string, + * items: { + * localField: string, + * foreignField: string, + * matchRequired: boolean, + * }[], + * }} options + */ + constructor(options) { + this.options = options; + + this.model = options.model; + } + + /** + * @private + * @param { + * { + * localField: string, + * foreignField: string, + * matchRequired: boolean, + * } + * } item + * @param {string|null} targetEntityType + * @return {{id: string, entityType: string, name: string}[]|null} + */ + prepareItemEntries(item, targetEntityType) { + const field = item.localField; + const foreignField = item.foreignField; + + if (!field || !foreignField) { + console.warn("Bad cascading fields definition."); + + return null; + } + + const type = this.model.getFieldType(field); + let entityType = this.model.getLinkParam(field, 'entity'); + + /** @type {{id: string, entityType: string, name: string}[]} */ + const entries = []; + + if (type === 'linkParent') { + entityType = this.model.get(field + 'Type'); + } + + if (!entityType) { + return null; + } + + if (targetEntityType !== entityType) { + return null; + } + + if (type === 'link' || type === 'linkParent' || type === 'linkOne') { + const id = this.model.get(field + 'Id'); + const name = this.model.get(field + 'Name'); + + if (!id) { + return null; + } + + entries.push({id, entityType, name}); + } else if (type === 'linkMultiple') { + /** @type {string[]} */ + const ids = this.model.get(field + 'Ids') ?? []; + const names = this.model.get(field + 'Names') ?? {}; + + entries.push( + ...ids.map(id => ({id, entityType, name: names[id]})) + ); + } else { + return null; + } + + return entries; + } + + /** + * @return {Object.} + */ + prepareFilters() { + /** + * @param {{localField: string, foreignField: string, matchRequired: boolean}} item + * @return {Object.|null} + */ + const prepareItem = (item) => { + const field = item.localField; + const foreignField = item.foreignField; + + if (!field || !foreignField) { + console.warn("Bad cascading fields definition."); + + return null; + } + + const linkEntityType = this.getLinkEntityType(foreignField); + const foreignType = this.getForeignType(foreignField); + + const entries = this.prepareItemEntries(item, linkEntityType); + + if (entries === null || !entries.length) { + return null; + } + + if (foreignType === 'link' || foreignType === 'linkOne') { + if (entries.length > 1) { + return { + [foreignField]: { + type: 'in', + attribute: foreignField + 'Id', + value: entries.map(it => it.id), + data: { + type: 'isOneOf', + oneOfIdList: entries.map(it => it.id), + oneOfNameHash: entries.reduce((p, it) => { + p[it.id] = it.name; + + return p; + }, {}), + }, + } + }; + } + + return { + [foreignField]: { + type: 'equals', + attribute: foreignField + 'Id', + value: entries[0].id, + data: { + type: 'equals', + idValue: entries[0].id, + nameValue: entries[0].name, + } + } + }; + } + + if (foreignType === 'linkMultiple') { + return { + [foreignField]: { + type: 'linkedWithAll', + attribute: foreignField, + value: entries.map(it => it.id), + data: { + type: 'allOf', + nameHash: entries.reduce((p, it) => { + p[it.id] = it.name; + + return p; + }, {}), + }, + } + }; + } + + // Not supported. + if (foreignType === 'linkParent') { + if (entries.length > 1) { + console.warn("Cascading fields do not support multiple matches for link-parent filters."); + + return null; + } + + return { + [foreignField]: { + type: 'and', + attribute: foreignField + 'Id', + value: [ + { + type: 'equals', + field: foreignField + 'Id', + value: entries[0].id, + }, + { + type: 'equals', + field: foreignField + 'Type', + value: entries[0].entityType, + } + ], + data: { + type: 'is', + idValue: entries[0].id, + nameValue: entries[0].name, + typeValue: entries[0].entityType, + } + } + }; + } + + return null; + }; + + const output = {}; + + for (const item of this.options.items) { + const itemOutput = prepareItem(item); + + if (itemOutput === null && item.matchRequired) { + return { + id: { + type: 'isNull', + attribute: 'id', + data: { + type: 'isEmpty', + }, + }, + }; + } + + Object.assign(output, itemOutput); + } + + return output; + } + + /** + * @return {Object.} + */ + prepareCreateAttributes() { + const attributes = {}; + + /** + * @param {{localField: string, foreignField: string, matchRequired: boolean}} item + * @return {Object.} + */ + const prepareItem = (item) => { + const field = item.localField; + const foreignField = item.foreignField; + + if (!field || !foreignField) { + console.warn("Bad cascading fields definition."); + + return null; + } + + const linkEntityType = this.getLinkEntityType(foreignField); + const foreignType = this.getForeignType(foreignField); + + const entries = this.prepareItemEntries(item, linkEntityType); + + if (entries === null || !entries.length) { + return null; + } + + if (foreignType === 'link' || foreignType === 'linkOne') { + return { + [foreignField + 'Id']: entries[0].id, + [foreignField + 'Name']: entries[0].name, + }; + } + + if (foreignType === 'linkMultiple') { + return { + [foreignField + 'Ids']: entries.map(it => it.id), + [foreignField + 'Names']: entries.reduce((p, it) => { + p[it.id] = it.name; + + return p; + }, {}), + } + } + + // Not supported. + if (foreignType === 'linkParent') { + return { + [foreignField + 'Id']: entries[0].id, + [foreignField + 'Name']: entries[0].name, + [foreignField + 'Type']: entries[0].entityType, + }; + } + }; + + for (const item of this.options.items) { + const itemOutput = prepareItem(item); + + if (itemOutput === null && item.matchRequired) { + continue; + } + + Object.assign(attributes, itemOutput); + } + + return attributes; + } + + /** + * @private + * @param {string} foreignField + * @return {string|null} + */ + getLinkEntityType(foreignField) { + return this.metadata.get(`entityDefs.${this.options.foreignEntityType}.links.${foreignField}.entity`); + } + + /** + * @private + * @param {string} foreignField + * @return {string|null} + */ + getForeignType(foreignField) { + return this.metadata.get(`entityDefs.${this.options.foreignEntityType}.fields.${foreignField}.type`); + } +} diff --git a/client/src/views/admin/field-manager/edit.js b/client/src/views/admin/field-manager/edit.js index 559ad6e660..fcc50010b8 100644 --- a/client/src/views/admin/field-manager/edit.js +++ b/client/src/views/admin/field-manager/edit.js @@ -596,6 +596,25 @@ class FieldManagerEditView extends View { this.hasDynamicLogicPanel = true; } + if (!defs.dynamicLogicCascadingDisabled && ['link', 'linkOne', 'linkMultiple'].includes(this.type)) { + const foreignScope = this.getMetadata().get(`entityDefs.${this.scope}.links.${this.field}.entity`); + + if (foreignScope) { + const value = this.getMetadata().get(['logicDefs', this.scope, 'cascadingFields', this.field]); + + this.model.set('dynamicLogicCascading', value); + + promiseList.push( + this.createFieldView(null, 'dynamicLogicCascading', null, { + view: 'views/admin/field-manager/fields/dynamic-logic-cascading', + scope: this.scope, + field: this.field, + foreignScope: foreignScope, + }) + ); + } + } + return Promise.all(promiseList); } diff --git a/client/src/views/admin/field-manager/fields/dynamic-logic-cascading.js b/client/src/views/admin/field-manager/fields/dynamic-logic-cascading.js new file mode 100644 index 0000000000..c4cbf96dec --- /dev/null +++ b/client/src/views/admin/field-manager/fields/dynamic-logic-cascading.js @@ -0,0 +1,581 @@ +/************************************************************************ + * This file is part of EspoCRM. + * + * EspoCRM – Open Source CRM application. + * Copyright (C) 2014-2026 EspoCRM, Inc. + * 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 . + * + * 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. + ************************************************************************/ + +import BaseFieldView from 'views/fields/base'; +import View from 'view'; +import Model from 'model'; +import Collection from 'collection'; +import ModalView from 'views/modal'; +import EditForModalRecordView from 'views/record/edit-for-modal'; +import EnumFieldView from 'views/fields/enum'; +import BoolFieldView from 'views/fields/bool'; + +// noinspection JSUnusedGlobalSymbols +export default class DynamicLogicCascadingFieldView extends BaseFieldView { + + // language=Handlebars + detailTemplateContent = ` + {{#if ids.length}} + + {{#each ids}} + + {{/each}} +
{{{lookup ../this this}}}
+ {{/if}} + + {{#unless ids.length}} + {{#if isSet}} + {{translate 'None'}} + {{else}} + + {{/if}} + {{/unless}} + ` + + // language=Handlebars + editTemplateContent = ` + + {{#if ids.length}} + + + + + + + {{#each ids}} + + {{/each}} +
+
+
{{localFieldLabel}}
+
{{foreignFieldLabel}}
+
+
{{{lookup ../this this}}}
+ {{/if}} + +
+ +
+ ` + + /** + * @private + * @type {import('collection').default} + */ + itemCollection + + /** + * @private + * @type {ItemView[]} + */ + itemViews + + /** + * @private + * @type {string} + */ + foreignScope + + data() { + return { + isSet: this.subModel.has('items'), + ids: this.itemCollection.models.map(m => m.id), + localFieldLabel: this.translate('localField', 'fields', 'DynamicLogic'), + foreignFieldLabel: this.translate('foreignField', 'fields', 'DynamicLogic'), + }; + } + + /** + * Prevents the change event from firing on sub-field change. + */ + initElement() {} + + setup() { + this.addActionHandler('addRow', () => this.addItem()); + + this.subModel = new Model(); + + this.foreignScope = this.params.foreignScope; + + const syncModels = () => { + const value = this.model.attributes[this.name]; + + if (value !== undefined) { + const items = value?.items ?? []; + + this.subModel.set('items', Espo.Utils.cloneDeep(items)); + } else { + this.subModel.unset('items'); + } + }; + + syncModels(); + + this.listenTo(this.model, 'change:' + this.name, (m, v, o) => { + if (o.fromView !== this) { + syncModels(); + } + + this.listenTo(this.subModel, 'change', (m, o) => { + if (o.ui) { + this.trigger('change'); + } + }); + }); + } + + async prepare() { + this.destroyItemViews(); + + this.itemCollection = new Collection(); + + const items = this.getItemsFromModel(); + + if (items === undefined) { + return; + } + + let mode = this.mode; + + if (mode !== 'detail' && mode !== 'edit') { + mode = 'detail'; + } + + const promiseList = []; + this.itemViews = []; + + for (const [i, item] of items.entries()) { + const model = new Model({...item, id: i.toString()}); + + this.listenTo(model, 'change', (m, /** Record */o) => { + if (o.ui) { + this.model.setMultiple({[this.name]: {items: this.getItemsFromCollection()}}, {ui: true}); + } + }); + + this.itemCollection.push(model); + + const view = new ItemView({ + model: model, + mode: mode, + onRemove: () => this.removeRow(i), + scope: this.params.scope, + foreignScope: this.foreignScope, + }); + + this.itemViews.push(view); + + const promise = this.assignView(view.model.id, view, `[data-id="${view.model.id}"]`); + + promiseList.push(promise); + } + + await Promise.all(promiseList); + } + + /** + * @private + */ + destroyItemViews() { + this.itemViews = []; + + if (this.itemCollection) { + this.itemCollection.models.forEach(model => this.clearView(model.id)); + } + } + + /** + * @private + * @return {{ + * localField: string, + * foreignField: string, + * matchRequired: boolean, + * }[]|undefined} + */ + getItemsFromModel() { + return Espo.Utils.cloneDeep(this.model.attributes?.[this.name]?.items ?? undefined); + } + + /** + * @private + * @return {{ + * localField: string, + * foreignField: string, + * matchRequired: boolean, + * }[]} + */ + getItemsFromCollection() { + return this.itemCollection.models.map(item => { + return { + localField: item.attributes.localField, + foreignField: item.attributes.foreignField, + matchRequired: item.attributes.matchRequired, + }; + }); + } + + /** + * @private + */ + async addItem() { + const view = new AddItemView({ + scope: this.params.scope, + foreignScope: this.foreignScope, + onApply: item => this.addRow(item), + }); + + await this.assignView('modal', view); + await view.render(); + } + + /** + * @private + * @param {{ + * localField: string, + * foreignField: string, + * matchRequired: boolean, + * }} item + */ + async addRow(item) { + const items = this.getItemsFromModel() ?? []; + + items.push(Espo.Utils.cloneDeep(item)); + + this.model.setMultiple({[this.name]: {items}}, {ui: true}); + + await this.prepare(); + await this.reRender(); + } + + /** + * @private + * @param {number} index + */ + async removeRow(index) { + const items = this.getItemsFromModel() || []; + + items.splice(index, 1); + + this.model.setMultiple({[this.name]: {items}}, {ui: true}); + + await this.prepare(); + await this.reRender(); + } + + fetch() { + if (!this.itemCollection) { + return {[this.name]: null}; + } + + const items = this.getItemsFromCollection().map(item => { + return { + localField: item.localField, + foreignField: item.foreignField, + matchRequired: item.matchRequired, + }; + }); + + if (!items.length) { + return {[this.name]: null}; + } + + return {[this.name]: {items}}; + } +} + +class ItemView extends View { + + // language=Handlebars + templateContent = ` + +
+
{{localField}}
+
{{foreignField}}
+
{{#if matchRequired}}*{{/if}}
+ {{#if isEditMode}} +
+ +
+ {{/if}} +
+ ` + + /** + * @type {import('model').default} + */ + model + + /** + * @type {'detail'|'edit'} + */ + mode + + /** + * @param {{ + * model: import('model').default, + * mode: 'detail'|'edit', + * onRemove: function(), + * scope: string, + * foreignScope: string, + * }} options + */ + constructor(options) { + super(); + + this.model = options.model; + this.mode = options.mode; + + this.options = options; + } + + data() { + + return { + isEditMode: this.mode === 'edit', + columnClassName: this.mode === 'edit' ? 'col-md-5' : 'col-md-5', + localField: this.translate(this.model.attributes.localField, 'fields', this.options.scope), + foreignField: this.translate(this.model.attributes.foreignField, 'fields', this.options.foreignScope), + matchRequired: this.model.attributes.matchRequired, + }; + } + + setup() { + this.addActionHandler('removeRow', () => this.options.onRemove()); + } +} + +class AddItemView extends ModalView { + + templateContent = ` +
{{{record}}}
+ ` + + /** + * @param {{ + * scope: string, + * foreignScope: string, + * onApply: function({ + * localField: string, + * foreignField: string, + * matchRequired: boolean, + * }), + * }} options + */ + constructor(options) { + super(options); + + this.options = options; + } + + setup() { + super.setup(); + + this.headerText = this.translate('Add'); + + this.buttonList = [ + { + name: 'apply', + label: 'Apply', + style: 'primary', + onClick: () => apply(), + }, + { + name: 'cancel', + label: 'Cancel', + onClick: () => this.close(), + } + ]; + + const model = new Model({ + localField: null, + foreignField: null, + matchRequired: false, + }); + + const scope = this.options.scope; + const foreignScope = this.options.foreignScope; + + const localFieldDefs = this.getMetadata().get(`entityDefs.${scope}.fields`) ?? {}; + const foreignFieldDefs = this.getMetadata().get(`entityDefs.${foreignScope}.fields`) ?? {}; + + const localFields = Object.keys(localFieldDefs) + .filter(field => { + /** @type {Record} */ + const defs = localFieldDefs[field]; + + if (defs.utility || defs.disabled) { + return false; + } + + if (!['link', 'linkParent', 'linkOne', 'linkMultiple'].includes(defs.type)) { + return false; + } + + return true; + }); + + const foreignFields = Object.keys(foreignFieldDefs) + .filter(field => { + /** @type {Record} */ + const defs = foreignFieldDefs[field]; + + if (defs.utility || defs.disabled) { + return false; + } + + if (!['link', 'linkOne', 'linkMultiple'].includes(defs.type)) { + return false; + } + + return true; + }); + + localFields.unshift(''); + foreignFields.unshift(''); + + const apply = () => { + if (recordView.validate()) { + return; + } + + this.options.onApply({ + localField: model.attributes.localField, + foreignField: model.attributes.foreignField, + matchRequired: model.attributes.matchRequired, + }); + + this.close(); + } + + const recordView = new EditForModalRecordView({ + model, + detailLayout: [ + { + rows: [ + [ + { + view: new EnumFieldView({ + name: 'localField', + params: { + required: true, + options: localFields, + isSorted: true, + }, + translatedOptions: localFields.reduce((o, it) => { + o[it] = this.translate(it, 'fields', this.options.scope); + + return o; + }, {}), + labelText: this.translate('localField', 'fields', 'DynamicLogic'), + }) + }, + { + view: new EnumFieldView({ + name: 'foreignField', + params: { + required: true, + }, + translatedOptions: foreignFields.reduce((o, it) => { + o[it] = this.translate(it, 'fields', this.options.foreignScope); + + return o; + }, {}), + labelText: this.translate('foreignField', 'fields', 'DynamicLogic'), + }) + }, + ], + [ + { + view: new BoolFieldView({ + name: 'matchRequired', + labelText: this.translate('matchRequired', 'fields', 'DynamicLogic'), + }) + }, + false + ] + ] + } + ], + }); + + this.assignView('record', recordView); + + this.listenTo(model, 'change:localField', (m, v, /** Record */ o) => { + if (!o.ui) { + return; + } + + const localField = model.attributes.localField; + + const fields = foreignFields.filter(it => { + if (it === '') { + return true; + } + + if (!localField) { + return false; + } + + const type = this.getMetadata().get(`entityDefs.${scope}.fields.${localField}.type`); + const entityType = this.getMetadata().get(`entityDefs.${scope}.links.${localField}.entity`); + const foreignEntityType = this.getMetadata().get(`entityDefs.${foreignScope}.links.${it}.entity`); + + if (type === 'linkParent') { + return true; + } + + return entityType === foreignEntityType; + }); + + recordView.setFieldOptionList('foreignField', fields); + + setTimeout(() => { + model.set('foreignField', null); + }, 0) + }); + } +} diff --git a/client/src/views/fields/id.js b/client/src/views/fields/id.js index cfea3aac3d..081d5693a8 100644 --- a/client/src/views/fields/id.js +++ b/client/src/views/fields/id.js @@ -33,6 +33,7 @@ class IdFieldView extends VarcharFieldView { searchTypeList = [ 'equals', 'notEquals', + 'isEmpty', ] } diff --git a/client/src/views/fields/link-multiple.js b/client/src/views/fields/link-multiple.js index a3518abd43..fb89e9331a 100644 --- a/client/src/views/fields/link-multiple.js +++ b/client/src/views/fields/link-multiple.js @@ -31,6 +31,7 @@ import BaseFieldView from 'views/fields/base'; import RecordModal from 'helpers/record-modal'; import Autocomplete from 'ui/autocomplete'; +import CascadeLinksHelper from 'helpers/field/cascade-links'; /** * A link-multiple field (for has-many relations). @@ -47,6 +48,9 @@ class LinkMultipleFieldView extends BaseFieldView { * Record * } [params] Parameters. * @property {boolean} [createDisabled] Disable create button in the select modal. + * @property {{ + * items: {localField: string, foreignField: string, matchRequired: boolean}[] + * }} [cascadingLogic] Cascading fields logic. As of 9.4.0. */ /** @@ -356,7 +360,10 @@ class LinkMultipleFieldView extends BaseFieldView { Object.keys(attributeMap).forEach(attr => attributes[attributeMap[attr]] = this.model.get(attr)); - return attributes; + return { + ...attributes, + ...this._getCascadingCreateAttributes(), + }; } /** @inheritDoc */ @@ -525,70 +532,39 @@ class LinkMultipleFieldView extends BaseFieldView { } : {}; - if (this.panelDefs.selectHandler) { - return new Promise(resolve => { - this._getSelectFilters().then(filters => { - if (filters.bool) { - url += '&' + $.param({boolFilterList: filters.bool}); - } - if (filters.primary) { - url += '&' + $.param({primaryFilter: filters.primary}); - } + return new Promise(async resolve => { + const filters = await this._getSelectFilters(); - const advanced = { - ...notSelectedFilter, - ...(filters.advanced || {}), - }; + if (filters.bool) { + url += '&' + $.param({boolFilterList: filters.bool}); + } - if (Object.keys(advanced).length) { - url += '&' + $.param({where: advanced}); - } + if (filters.primary) { + url += '&' + $.param({primaryFilter: filters.primary}); + } - const orderBy = filters.orderBy || this.panelDefs.selectOrderBy; - const orderDirection = filters.orderBy ? filters.order : this.panelDefs.selectOrderDirection; + const advanced = { + ...notSelectedFilter, + ...(filters.advanced ?? {}), + }; - if (orderBy) { - url += '&' + $.param({ - orderBy: orderBy, - order: orderDirection || 'asc', - }); - } + if (Object.keys(advanced).length) { + url += '&' + $.param({where: advanced}); + } - resolve(url); + const orderBy = filters.orderBy || this.panelDefs.selectOrderBy; + const orderDirection = filters.orderBy ? filters.order : this.panelDefs.selectOrderDirection; + + if (orderBy) { + url += '&' + $.param({ + orderBy: orderBy, + order: orderDirection || 'asc', }); - }); - } + } - const boolList = [ - ...(this.getSelectBoolFilterList() || []), - ...(this.panelDefs.selectBoolFilterList || []), - ]; - - if (boolList.length) { - url += '&' + $.param({'boolFilterList': boolList}); - } - - const primary = this.getSelectPrimaryFilterName() || this.panelDefs.selectPrimaryFilterName; - - if (primary) { - url += '&' + $.param({'primaryFilter': primary}); - } - - if (Object.keys(notSelectedFilter).length) { - url += '&' + $.param({'where': notSelectedFilter}); - } - - if (this.panelDefs.selectOrderBy) { - const direction = this.panelDefs.selectOrderDirection || 'asc'; - - url += '&' + $.param({ - orderBy: this.panelDefs.selectOrderBy, - order: direction, - }); - } - - return url; + resolve(url); + }); } /** @inheritDoc */ @@ -1150,7 +1126,7 @@ class LinkMultipleFieldView extends BaseFieldView { */ getCreateAttributesProvider() { return () => { - const attributes = this.getCreateAttributes() || {}; + const attributes = this.getCreateAttributes() ?? {}; if (!this.panelDefs.createHandler) { return Promise.resolve(attributes); @@ -1203,34 +1179,44 @@ class LinkMultipleFieldView extends BaseFieldView { if (!handler || this.isSearchMode()) { const boolFilterList = (localBoolFilterList || this.panelDefs.selectBoolFilterList) ? [ - ...(localBoolFilterList || []), - ...(this.panelDefs.selectBoolFilterList || []), + ...(localBoolFilterList ?? []), + ...(this.panelDefs.selectBoolFilterList ?? []), ] : undefined; + const advanced = { + ...(this.getSelectFilters() ?? {}), + ...(!this.isSearchMode() ? this._getCascadingFilters() : {}), + }; + return Promise.resolve({ - primary: this.getSelectPrimaryFilterName() || this.panelDefs.selectPrimaryFilterName, + primary: this.getSelectPrimaryFilterName() ?? this.panelDefs.selectPrimaryFilterName, bool: boolFilterList, - advanced: this.getSelectFilters() || undefined, + advanced: advanced, }); } - return new Promise(resolve => { + return new Promise(async resolve => { Espo.loader.requirePromise(handler) .then(Handler => new Handler(this.getHelper())) .then(/** module:handlers/select-related */handler => { return handler.getFilters(this.model); }) .then(filters => { - const advanced = {...(this.getSelectFilters() || {}), ...(filters.advanced || {})}; + const advanced = { + ...(this.getSelectFilters() || {}), + ...(filters.advanced || {}), + ...this._getCascadingFilters(), + }; + const primaryFilter = this.getSelectPrimaryFilterName() || filters.primary || this.panelDefs.selectPrimaryFilterName; const boolFilterList = (localBoolFilterList || filters.bool || this.panelDefs.selectBoolFilterList) ? [ - ...(localBoolFilterList || []), - ...(filters.bool || []), - ...(this.panelDefs.selectBoolFilterList || []), + ...(localBoolFilterList ?? []), + ...(filters.bool ?? []), + ...(this.panelDefs.selectBoolFilterList ?? []), ] : undefined; @@ -1285,6 +1271,44 @@ class LinkMultipleFieldView extends BaseFieldView { getOnEmptyAutocomplete() { return undefined; } + + /** + * @private + * @return {CascadeLinksHelper|null} + */ + _createCascadeLinksHelper() { + const items = this.options.cascadingLogic?.items ?? []; + + if (!items.length) { + return null; + } + + if (!this.foreignScope) { + return null; + } + + return new CascadeLinksHelper({ + model: this.model, + foreignEntityType: this.foreignScope, + items: items, + }); + } + + /** + * @private + * @return {Object.} + */ + _getCascadingFilters() { + return this._createCascadeLinksHelper()?.prepareFilters() ?? {}; + } + + /** + * @private + * @return {Object.} + */ + _getCascadingCreateAttributes() { + return this._createCascadeLinksHelper()?.prepareCreateAttributes() ?? {}; + } } export default LinkMultipleFieldView; diff --git a/client/src/views/fields/link.js b/client/src/views/fields/link.js index c2069c2dd7..5a78b72261 100644 --- a/client/src/views/fields/link.js +++ b/client/src/views/fields/link.js @@ -31,6 +31,7 @@ import BaseFieldView from 'views/fields/base'; import RecordModal from 'helpers/record-modal'; import Autocomplete from 'ui/autocomplete'; +import CascadeLinksHelper from 'helpers/field/cascade-links'; /** * A link field (belongs-to relation). @@ -47,6 +48,9 @@ class LinkFieldView extends BaseFieldView { * Record * } [params] Parameters. * @property {boolean} [createDisabled] Disable create button in the select modal. + * @property {{ + * items: {localField: string, foreignField: string, matchRequired: boolean}[] + * }} [cascadingFieldsLogic] Cascading fields logic. As of 9.4.0. */ /** @@ -355,7 +359,10 @@ class LinkFieldView extends BaseFieldView { Object.keys(attributeMap).forEach(attr => attributes[attributeMap[attr]] = this.model.get(attr)); - return attributes; + return { + ...attributes, + ...this._getCascadingCreateAttributes(), + }; } /** @inheritDoc */ @@ -631,61 +638,33 @@ class LinkFieldView extends BaseFieldView { url += '&select=' + select.join(','); } - if (this.panelDefs.selectHandler) { - return new Promise(resolve => { - this._getSelectFilters().then(filters => { - if (filters.bool) { - url += '&' + $.param({'boolFilterList': filters.bool}); - } + return new Promise(async resolve => { + const filters = await this._getSelectFilters(); - if (filters.primary) { - url += '&' + $.param({'primaryFilter': filters.primary}); - } + if (filters.bool) { + url += '&' + $.param({'boolFilterList': filters.bool}); + } - if (filters.advanced && Object.keys(filters.advanced).length) { - url += '&' + $.param({'where': filters.advanced}); - } + if (filters.primary) { + url += '&' + $.param({'primaryFilter': filters.primary}); + } - const orderBy = filters.orderBy || this.panelDefs.selectOrderBy; - const orderDirection = filters.orderBy ? filters.order : this.panelDefs.selectOrderDirection; + if (filters.advanced && Object.keys(filters.advanced).length) { + url += '&' + $.param({'where': filters.advanced}); + } - if (orderBy) { - url += '&' + $.param({ - orderBy: orderBy, - order: orderDirection || 'asc', - }); - } + const orderBy = filters.orderBy || this.panelDefs.selectOrderBy; + const orderDirection = filters.orderBy ? filters.order : this.panelDefs.selectOrderDirection; - resolve(url); + if (orderBy) { + url += '&' + $.param({ + orderBy: orderBy, + order: orderDirection || 'asc', }); - }); - } + } - const boolList = [ - ...(this.getSelectBoolFilterList() || []), - ...(this.panelDefs.selectBoolFilterList || []), - ]; - - const primary = this.getSelectPrimaryFilterName() || this.panelDefs.selectPrimaryFilterName; - - if (boolList.length) { - url += '&' + $.param({'boolFilterList': boolList}); - } - - if (primary) { - url += '&' + $.param({'primaryFilter': primary}); - } - - if (this.panelDefs.selectOrderBy) { - const direction = this.panelDefs.selectOrderDirection || 'asc'; - - url += '&' + $.param({ - orderBy: this.panelDefs.selectOrderBy, - order: direction, - }); - } - - return url; + resolve(url); + }); } /** @inheritDoc */ @@ -1139,7 +1118,7 @@ class LinkFieldView extends BaseFieldView { */ getCreateAttributesProvider() { return () => { - const attributes = this.getCreateAttributes() || {}; + const attributes = this.getCreateAttributes() ?? {}; if (!this.panelDefs.createHandler) { return Promise.resolve(attributes); @@ -1274,12 +1253,15 @@ class LinkFieldView extends BaseFieldView { ] : undefined; - const advanced = this.getSelectFilters() || {}; + const advanced = { + ...(this.getSelectFilters() ?? {}), + ...(!this.isSearchMode() ? this._getCascadingFilters() : {}), + }; this._applyAdditionalFilter(advanced); return Promise.resolve({ - primary: this.getSelectPrimaryFilterName() || this.panelDefs.selectPrimaryFilterName, + primary: this.getSelectPrimaryFilterName() ?? this.panelDefs.selectPrimaryFilterName, bool: boolFilterList, advanced: advanced, }); @@ -1292,7 +1274,12 @@ class LinkFieldView extends BaseFieldView { return handler.getFilters(this.model); }) .then(filters => { - const advanced = {...(this.getSelectFilters() || {}), ...(filters.advanced || {})}; + const advanced = { + ...(this.getSelectFilters() ?? {}), + ...(filters.advanced ?? {}), + ...this._getCascadingFilters(), + }; + const primaryFilter = this.getSelectPrimaryFilterName() || filters.primary || this.panelDefs.selectPrimaryFilterName; @@ -1320,6 +1307,44 @@ class LinkFieldView extends BaseFieldView { }); } + /** + * @private + * @return {CascadeLinksHelper|null} + */ + _createCascadeLinksHelper() { + const items = this.options.cascadingLogic?.items ?? []; + + if (!items.length) { + return null; + } + + if (!this.foreignScope) { + return null; + } + + return new CascadeLinksHelper({ + model: this.model, + foreignEntityType: this.foreignScope, + items: items, + }); + } + + /** + * @private + * @return {Object.} + */ + _getCascadingFilters() { + return this._createCascadeLinksHelper()?.prepareFilters() ?? {}; + } + + /** + * @private + * @return {Object.} + */ + _getCascadingCreateAttributes() { + return this._createCascadeLinksHelper()?.prepareCreateAttributes() ?? {}; + } + actionSelectOneOf() { Espo.Ui.notifyWait(); diff --git a/client/src/views/record/base.js b/client/src/views/record/base.js index b8b994f04f..fe73145b1f 100644 --- a/client/src/views/record/base.js +++ b/client/src/views/record/base.js @@ -762,7 +762,7 @@ class BaseRecordView extends View { return; } - this.processDynamicLogic(); + this.processDynamicLogic({action: o.action}); }); this.processDynamicLogic(); @@ -772,9 +772,10 @@ class BaseRecordView extends View { * Process dynamic logic. * * @protected + * @param {{action?: string|'ui'}} [options] Options. */ - processDynamicLogic() { - this.dynamicLogic.process(); + processDynamicLogic(options = {}) { + this.dynamicLogic.process(options); } /** diff --git a/client/src/views/record/detail.js b/client/src/views/record/detail.js index 77448345d5..fda09b3837 100644 --- a/client/src/views/record/detail.js +++ b/client/src/views/record/detail.js @@ -63,7 +63,7 @@ class DetailRecordView extends BaseRecordView { * @property {string} [inlineEditDisabled] Disable inline edit. * @property {boolean} [buttonsDisabled] Disable buttons. * @property {string} [navigateButtonsDisabled] - * @property {Object} [dynamicLogicDefs] + * @property {module:dynamic-logic~defs} [dynamicLogicDefs] * @property {module:view-record-helper} [recordHelper] A record helper. For a form state management. * @property {Object.} [attributes] * @property {module:views/record/detail~button[]} [buttonList] Buttons. @@ -451,8 +451,7 @@ class DetailRecordView extends BaseRecordView { * Dynamic logic. Can be overridden by an option parameter. * * @protected - * @type {Object} - * @todo Add typedef. + * @type {module:dynamic-logic~defs} */ dynamicLogicDefs = {} @@ -2002,7 +2001,7 @@ class DetailRecordView extends BaseRecordView { this.navigateButtonsDisabled = this.options.navigateButtonsDisabled || this.navigateButtonsDisabled; this.portalLayoutDisabled = this.options.portalLayoutDisabled || this.portalLayoutDisabled; - this.dynamicLogicDefs = this.options.dynamicLogicDefs || this.dynamicLogicDefs; + this.dynamicLogicDefs = this.options.dynamicLogicDefs ?? this.dynamicLogicDefs; this.accessControlDisabled = this.options.accessControlDisabled || this.accessControlDisabled; @@ -3273,6 +3272,10 @@ class DetailRecordView extends BaseRecordView { } } + if (this.dynamicLogicDefs?.cascadingFields?.[name]) { + o.cascadingLogic = this.dynamicLogicDefs?.cascadingFields?.[name]; + } + const cell = { name: name + 'Field', view: view, diff --git a/tests/integration/Espo/Tools/DynamicLogic/CascadingFieldsTest.php b/tests/integration/Espo/Tools/DynamicLogic/CascadingFieldsTest.php new file mode 100644 index 0000000000..2210673c6b --- /dev/null +++ b/tests/integration/Espo/Tools/DynamicLogic/CascadingFieldsTest.php @@ -0,0 +1,243 @@ +. + * + * 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 tests\integration\Espo\Tools\DynamicLogic; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Record\CreateParams; +use Espo\Core\Record\ServiceContainer; +use Espo\Modules\Crm\Entities\Account; +use Espo\Modules\Crm\Entities\Contact; +use Espo\Tools\EntityManager\EntityManager; +use Espo\Tools\FieldManager\FieldManager; +use Espo\Tools\LinkManager\LinkManager; +use tests\integration\Core\BaseTestCase; + +class CascadingFieldsTest extends BaseTestCase +{ + /** + * @noinspection PhpUnhandledExceptionInspection + */ + public function testLinkMultiple(): void + { + $emTool = $this->getInjectableFactory()->create(EntityManager::class); + $lmTool = $this->getInjectableFactory()->create(LinkManager::class); + $fmTool = $this->getInjectableFactory()->create(FieldManager::class); + + $emTool->create('MyTest', 'Base'); + + $lmTool->create([ + 'link' => 'accounts', + 'linkForeign' => 'tests', + 'linkType' => 'manyToMany', + 'entity' => 'CMyTest', + 'entityForeign' => Account::ENTITY_TYPE, + 'label' => 'Account', + 'labelForeign' => 'Test', + 'relationName' => 'testAccount', + 'linkMultipleField' => true, + 'linkMultipleFieldForeign' => true, + ]); + + $lmTool->create([ + 'link' => 'contacts', + 'linkForeign' => 'tests', + 'linkType' => 'manyToMany', + 'entity' => 'CMyTest', + 'entityForeign' => Contact::ENTITY_TYPE, + 'label' => 'Contact', + 'labelForeign' => 'Test', + 'relationName' => 'testContact', + 'linkMultipleField' => true, + 'linkMultipleFieldForeign' => true, + ]); + + $fmTool->update('CMyTest', 'contacts', [ + 'dynamicLogicCascading' => [ + 'items' => [ + [ + 'localField' => 'accounts', + 'foreignField' => 'accounts', + 'matchRequired' => true, + ] + ] + ] + ]); + + $this->reCreateApplication(); + + $em = $this->getEntityManager(); + + $a1 = $em->createEntity(Account::ENTITY_TYPE); + $a2 = $em->createEntity(Account::ENTITY_TYPE); + + $c1A12 = $em->createEntity(Contact::ENTITY_TYPE, [ + 'accountsIds' => [$a1->getId(), $a2->getId()], + 'accountId' => $a1->getId(), + ]); + + $c2A1 = $em->createEntity(Contact::ENTITY_TYPE, [ + 'accountsIds' => [$a1->getId()], + 'accountId' => $a1->getId(), + ]); + + $service = $this->getContainer()->getByClass(ServiceContainer::class)->get('CMyTest'); + + // + + $service->create((object) [ + 'name' => 't1', + 'accountsIds' => [$a1->getId()], + 'contactsIds' => [$c2A1->getId()], + ], CreateParams::create()); + + // + + $service->create((object) [ + 'name' => 't1', + 'accountsIds' => [$a1->getId(), $a2->getId()], + 'contactsIds' => [$c1A12->getId()], + ], CreateParams::create()); + + // + + $thrown = false; + + try { + $service->create((object) [ + 'name' => 't1', + 'accountsIds' => [$a1->getId(), $a2->getId()], + 'contactsIds' => [$c2A1->getId()], + ], CreateParams::create()); + } catch (BadRequest) { + $thrown = true; + } + + $this->assertTrue($thrown); + } + + /** + * @noinspection PhpUnhandledExceptionInspection + */ + public function testLink(): void + { + $emTool = $this->getInjectableFactory()->create(EntityManager::class); + $lmTool = $this->getInjectableFactory()->create(LinkManager::class); + $fmTool = $this->getInjectableFactory()->create(FieldManager::class); + + $emTool->create('MyTest', 'Base'); + + $lmTool->create([ + 'link' => 'account', + 'linkForeign' => 'tests', + 'linkType' => 'manyToOne', + 'entity' => 'CMyTest', + 'entityForeign' => Account::ENTITY_TYPE, + 'label' => 'Account', + 'labelForeign' => 'Test', + 'linkMultipleField' => true, + 'linkMultipleFieldForeign' => true, + ]); + + $lmTool->create([ + 'link' => 'contact', + 'linkForeign' => 'tests', + 'linkType' => 'manyToOne', + 'entity' => 'CMyTest', + 'entityForeign' => Contact::ENTITY_TYPE, + 'label' => 'Contact', + 'labelForeign' => 'Test', + 'linkMultipleField' => true, + 'linkMultipleFieldForeign' => true, + ]); + + $fmTool->update('CMyTest', 'contact', [ + 'dynamicLogicCascading' => [ + 'items' => [ + [ + 'localField' => 'account', + 'foreignField' => 'accounts', + 'matchRequired' => true, + ] + ] + ] + ]); + + $this->reCreateApplication(); + + $em = $this->getEntityManager(); + + $a1 = $em->createEntity(Account::ENTITY_TYPE); + $a2 = $em->createEntity(Account::ENTITY_TYPE); + + $c1A12 = $em->createEntity(Contact::ENTITY_TYPE, [ + 'accountsIds' => [$a1->getId(), $a2->getId()], + 'accountId' => $a1->getId(), + ]); + + $c2A1 = $em->createEntity(Contact::ENTITY_TYPE, [ + 'accountsIds' => [$a1->getId()], + 'accountId' => $a1->getId(), + ]); + + $service = $this->getContainer()->getByClass(ServiceContainer::class)->get('CMyTest'); + + // + + $service->create((object) [ + 'name' => 't1', + 'accountId' => $a1->getId(), + 'contactId' => $c2A1->getId(), + ], CreateParams::create()); + + // + + $service->create((object) [ + 'name' => 't1', + 'accountId' => $a2->getId(), + 'contactId' => $c1A12->getId(), + ], CreateParams::create()); + + // + + $thrown = false; + + try { + $service->create((object) [ + 'name' => 't1', + 'accountId' => $a2->getId(), + 'contactId' => $c2A1->getId(), + ], CreateParams::create()); + } catch (BadRequest) { + $thrown = true; + } + + $this->assertTrue($thrown); + } +}