cascading links
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* 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 <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\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<Entity>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* 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 <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\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<Entity>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -27,5 +27,10 @@
|
||||
"endsWith": "Ends With",
|
||||
"matches": "Matches (reg exp)"
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"localField": "Local Field",
|
||||
"foreignField": "Foreign Field",
|
||||
"matchRequired": "Match Required"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -60,8 +60,12 @@
|
||||
],
|
||||
"mandatoryValidationList": [
|
||||
"pattern",
|
||||
"columnsValid"
|
||||
"columnsValid",
|
||||
"cascadingSelect"
|
||||
],
|
||||
"validatorClassNameMap": {
|
||||
"cascadingSelect": "Espo\\Classes\\FieldValidators\\Common\\LinkMultiple\\CascadingSelect"
|
||||
},
|
||||
"notCreatable": true,
|
||||
"notSortable": true,
|
||||
"filter": true,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* 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 <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\Tools\DynamicLogic\CascadingFields;
|
||||
|
||||
readonly class Item
|
||||
{
|
||||
public function __construct(
|
||||
public string $localField,
|
||||
public string $foreignField,
|
||||
public bool $matchRequired,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* 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 <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\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<string, mixed>[] $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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* 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 <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\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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -78,6 +78,12 @@
|
||||
<div class="field" data-name="dynamicLogicReadOnlySaved">{{{dynamicLogicReadOnlySaved}}}</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if dynamicLogicCascading}}
|
||||
<div class="cell form-group" data-name="dynamicLogicCascading">
|
||||
<label class="control-label" data-name="dynamicLogicCascading">{{translate 'dynamicLogicCascading' scope='Admin' category='fields'}}</label>
|
||||
<div class="field" data-name="dynamicLogicCascading">{{{dynamicLogicCascading}}}</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+182
-43
@@ -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.<string, module:dynamic-logic~fieldDefs>} [fields] Fields.
|
||||
* @property {Object.<string, module:dynamic-logic~panelDefs>} [panels] Panels.
|
||||
* @property {Object.<string, *>} [options] Options.
|
||||
* @property {Object.<string, module:dynamic-logic~cascadingFieldDefs>} [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.<string, string[]>}
|
||||
*/
|
||||
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.<string, string[]>}
|
||||
*/
|
||||
buildCascadingClearDefs() {
|
||||
const fields = this.defs.cascadingFields ?? null;
|
||||
|
||||
if (!fields || Object.keys(fields).length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const model = this.recordView.model;
|
||||
|
||||
/** @type {Object.<string, string[]>} */
|
||||
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;
|
||||
|
||||
|
||||
@@ -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 <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.
|
||||
************************************************************************/
|
||||
|
||||
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.<string, module:search-manager~advancedFilter>}
|
||||
*/
|
||||
prepareFilters() {
|
||||
/**
|
||||
* @param {{localField: string, foreignField: string, matchRequired: boolean}} item
|
||||
* @return {Object.<string, module:search-manager~advancedFilter>|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.<string, *>}
|
||||
*/
|
||||
prepareCreateAttributes() {
|
||||
const attributes = {};
|
||||
|
||||
/**
|
||||
* @param {{localField: string, foreignField: string, matchRequired: boolean}} item
|
||||
* @return {Object.<string, *>}
|
||||
*/
|
||||
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`);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <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.
|
||||
************************************************************************/
|
||||
|
||||
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}}
|
||||
<table class="table" data-role="cascading-items-list">
|
||||
{{#each ids}}
|
||||
<tr data-id="{{id}}"><td>{{{lookup ../this this}}}</td></div>
|
||||
{{/each}}
|
||||
</table>
|
||||
{{/if}}
|
||||
|
||||
{{#unless ids.length}}
|
||||
{{#if isSet}}
|
||||
<span class="none-value">{{translate 'None'}}</span>
|
||||
{{else}}
|
||||
<span class="loading-value"></span>
|
||||
{{/if}}
|
||||
{{/unless}}
|
||||
`
|
||||
|
||||
// language=Handlebars
|
||||
editTemplateContent = `
|
||||
<style>
|
||||
table[data-role="cascading-items-list"] {
|
||||
border-top: 0;
|
||||
|
||||
> thead > tr > th {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
font-size: var(--13px);
|
||||
}
|
||||
</style>
|
||||
{{#if ids.length}}
|
||||
<table class="table" data-role="cascading-items-list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<div class="row small text-muted">
|
||||
<div class="col-md-5">{{localFieldLabel}}</div>
|
||||
<div class="col-md-5">{{foreignFieldLabel}}</div>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{{#each ids}}
|
||||
<tr data-id="{{id}}"><td>{{{lookup ../this this}}}</td></div>
|
||||
{{/each}}
|
||||
</table>
|
||||
{{/if}}
|
||||
|
||||
<div>
|
||||
<button
|
||||
class="btn btn-link btn-icon"
|
||||
data-action="addRow"
|
||||
title="{{translate 'Add'}}"
|
||||
><span class="fas fa-plus"></span></button>
|
||||
</div>
|
||||
`
|
||||
|
||||
/**
|
||||
* @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 = `
|
||||
<style></style>
|
||||
<div class="row">
|
||||
<div class=" {{columnClassName}} ">{{localField}}</div>
|
||||
<div class=" {{columnClassName}} ">{{foreignField}}</div>
|
||||
<div class="col-md-1 ">{{#if matchRequired}}*{{/if}}</div>
|
||||
{{#if isEditMode}}
|
||||
<div class="col-md-1" style="text-align: center;">
|
||||
<a
|
||||
role="button"
|
||||
data-action="removeRow"
|
||||
class="pull-right"
|
||||
title="{{translate 'Remove'}}"
|
||||
><span class="fas fa-times"></span></a>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
`
|
||||
|
||||
/**
|
||||
* @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 = `
|
||||
<div class="record no-side-margin">{{{record}}}</div>
|
||||
`
|
||||
|
||||
/**
|
||||
* @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)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ class IdFieldView extends VarcharFieldView {
|
||||
searchTypeList = [
|
||||
'equals',
|
||||
'notEquals',
|
||||
'isEmpty',
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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.<string, module:search-manager~advancedFilter>}
|
||||
*/
|
||||
_getCascadingFilters() {
|
||||
return this._createCascadeLinksHelper()?.prepareFilters() ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @return {Object.<string, *>}
|
||||
*/
|
||||
_getCascadingCreateAttributes() {
|
||||
return this._createCascadeLinksHelper()?.prepareCreateAttributes() ?? {};
|
||||
}
|
||||
}
|
||||
|
||||
export default LinkMultipleFieldView;
|
||||
|
||||
@@ -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.<string, module:search-manager~advancedFilter>}
|
||||
*/
|
||||
_getCascadingFilters() {
|
||||
return this._createCascadeLinksHelper()?.prepareFilters() ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @return {Object.<string, *>}
|
||||
*/
|
||||
_getCascadingCreateAttributes() {
|
||||
return this._createCascadeLinksHelper()?.prepareCreateAttributes() ?? {};
|
||||
}
|
||||
|
||||
actionSelectOneOf() {
|
||||
Espo.Ui.notifyWait();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.<string, *>} [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,
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* 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 <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 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user