diff --git a/application/Espo/Core/Controllers/RecordTree.php b/application/Espo/Core/Controllers/RecordTree.php index 66f267056b..69ccf73772 100644 --- a/application/Espo/Core/Controllers/RecordTree.php +++ b/application/Espo/Core/Controllers/RecordTree.php @@ -29,15 +29,20 @@ namespace Espo\Core\Controllers; +use Espo\Core\Acl\Table; use Espo\Core\Exceptions\Forbidden; use Espo\Core\Exceptions\BadRequest; use Espo\Core\Exceptions\Error; use Espo\Core\Exceptions\NotFound; use Espo\Core\ORM\Entity; +use Espo\Core\Templates\Entities\CategoryTree; use Espo\Services\RecordTree as Service; use Espo\Core\Api\Request; +use Espo\Tools\CategoryTree\Move\MoveParams; +use Espo\Tools\CategoryTree\MoveService; +use RuntimeException; use stdClass; class RecordTree extends Record @@ -58,11 +63,6 @@ class RecordTree extends Record */ public function getActionListTree(Request $request): stdClass { - if (method_exists($this, 'actionListTree')) { - // For backward compatibility. - return (object) $this->actionListTree($request->getRouteParams(), $request->getParsedBody(), $request); - } - $selectParams = $this->fetchSearchParamsFromRequest($request); $parentId = $request->getQueryParam('parentId'); @@ -101,7 +101,7 @@ class RecordTree extends Record */ public function getActionLastChildrenIdList(Request $request): array { - if (!$this->acl->check($this->name, 'read')) { + if (!$this->acl->check($this->name, Table::ACTION_READ)) { throw new Forbidden(); } @@ -110,6 +110,68 @@ class RecordTree extends Record return $this->getRecordTreeService()->getLastChildrenIdList($parentId); } + /** + * @throws Forbidden + * @throws BadRequest + * @throws NotFound + * @throws Error + * @noinspection PhpUnused + */ + public function postActionMove(Request $request): bool + { + if (!$this->acl->check($this->name, Table::ACTION_EDIT)) { + throw new Forbidden(); + } + + $id = $request->getParsedBody()->id ?? null; + $referenceId = $request->getParsedBody()->referenceId ?? null; + $type = $request->getParsedBody()->type ?? null; + + if (!is_string($id)) { + throw new BadRequest("Bad id."); + } + + if (!is_string($referenceId)) { + throw new BadRequest("Bad referenceId."); + } + + $typeInternal = match ($type) { + 'into' => MoveParams::TYPE_INTO, + 'before' => MoveParams::TYPE_BEFORE, + 'after' => MoveParams::TYPE_AFTER, + default => null, + }; + + if ($typeInternal === null) { + throw new BadRequest("Bad type."); + } + + $entity = $this->getRecordService()->getEntity($id); + + if (!$entity) { + throw new NotFound("Record not found."); + } + + if (!$entity instanceof CategoryTree) { + throw new RuntimeException("Non-tree entity."); + } + + if (!$this->acl->checkEntityEdit($entity)) { + throw new Forbidden("No edit access."); + } + + $params = new MoveParams( + type: $typeInternal, + referenceId: $referenceId, + ); + + $service = $this->injectableFactory->create(MoveService::class); + + $service->move($entity, $params); + + return true; + } + /** * @return Service */ @@ -117,7 +179,9 @@ class RecordTree extends Record { $service = $this->getRecordService(); - assert($service instanceof Service); + if (!$service instanceof Service) { + throw new RuntimeException(); + } return $service; } diff --git a/application/Espo/Core/Utils/Client/DevModeJsFileListProvider.php b/application/Espo/Core/Utils/Client/DevModeJsFileListProvider.php index b4feb58ee4..ecda960dbe 100644 --- a/application/Espo/Core/Utils/Client/DevModeJsFileListProvider.php +++ b/application/Espo/Core/Utils/Client/DevModeJsFileListProvider.php @@ -86,7 +86,14 @@ class DevModeJsFileListProvider $amdId = $item->amdId ?? null; if ($amdId) { - return 'client/lib/original/' . $amdId . '.js'; + $file = $amdId; + + if (str_starts_with($amdId, '@')) { + $file = substr($amdId, 1); + $file = str_replace('/', '-', $file); + } + + return 'client/lib/original/' . $file . '.js'; } $src = $item->src ?? null; diff --git a/application/Espo/Modules/Crm/Resources/metadata/entityDefs/DocumentFolder.json b/application/Espo/Modules/Crm/Resources/metadata/entityDefs/DocumentFolder.json index 0148939fb0..0973fc0bdb 100644 --- a/application/Espo/Modules/Crm/Resources/metadata/entityDefs/DocumentFolder.json +++ b/application/Espo/Modules/Crm/Resources/metadata/entityDefs/DocumentFolder.json @@ -78,7 +78,7 @@ } }, "collection": { - "orderBy": "parent", + "orderBy": "name", "order": "asc" }, "additionalTables": { diff --git a/application/Espo/Modules/Crm/Resources/metadata/entityDefs/KnowledgeBaseCategory.json b/application/Espo/Modules/Crm/Resources/metadata/entityDefs/KnowledgeBaseCategory.json index 03388e9453..a5a467e01c 100644 --- a/application/Espo/Modules/Crm/Resources/metadata/entityDefs/KnowledgeBaseCategory.json +++ b/application/Espo/Modules/Crm/Resources/metadata/entityDefs/KnowledgeBaseCategory.json @@ -35,6 +35,7 @@ "order": { "type": "int", "minValue": 1, + "readOnly": true, "disableFormatting": true, "textFilterDisabled": true }, diff --git a/application/Espo/Modules/Crm/Resources/metadata/entityDefs/TargetListCategory.json b/application/Espo/Modules/Crm/Resources/metadata/entityDefs/TargetListCategory.json index 3edce22786..efabe4af36 100644 --- a/application/Espo/Modules/Crm/Resources/metadata/entityDefs/TargetListCategory.json +++ b/application/Espo/Modules/Crm/Resources/metadata/entityDefs/TargetListCategory.json @@ -7,6 +7,7 @@ "order": { "type": "int", "minValue": 1, + "readOnly": true, "textFilterDisabled": true }, "description": { diff --git a/application/Espo/Resources/metadata/app/jsLibs.json b/application/Espo/Resources/metadata/app/jsLibs.json index 4dc60a7e5b..e75af12fdb 100644 --- a/application/Espo/Resources/metadata/app/jsLibs.json +++ b/application/Espo/Resources/metadata/app/jsLibs.json @@ -77,6 +77,9 @@ "exportsTo": "window", "exportsAs": "Selectize" }, + "@shopify/draggable": { + "devPath": "client/lib/original/shopify-draggable.js" + }, "autonumeric": {}, "intl-tel-input": { "exportsTo": "window", diff --git a/application/Espo/Resources/metadata/entityDefs/EmailTemplateCategory.json b/application/Espo/Resources/metadata/entityDefs/EmailTemplateCategory.json index e177cd40f3..41ab5fcdc7 100644 --- a/application/Espo/Resources/metadata/entityDefs/EmailTemplateCategory.json +++ b/application/Espo/Resources/metadata/entityDefs/EmailTemplateCategory.json @@ -7,6 +7,7 @@ "order": { "type": "int", "minValue": 1, + "readOnly": true, "textFilterDisabled": true }, "description": { diff --git a/application/Espo/Tools/CategoryTree/Move/MoveParams.php b/application/Espo/Tools/CategoryTree/Move/MoveParams.php new file mode 100644 index 0000000000..ae91ebbdb2 --- /dev/null +++ b/application/Espo/Tools/CategoryTree/Move/MoveParams.php @@ -0,0 +1,42 @@ +. + * + * 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\CategoryTree\Move; + +readonly class MoveParams +{ + public const TYPE_INTO = 0; + public const TYPE_BEFORE = 1; + public const TYPE_AFTER = 2; + + public function __construct( + public int $type, + public ?string $referenceId = null, + ) {} +} diff --git a/application/Espo/Tools/CategoryTree/MoveService.php b/application/Espo/Tools/CategoryTree/MoveService.php new file mode 100644 index 0000000000..e62b36c2eb --- /dev/null +++ b/application/Espo/Tools/CategoryTree/MoveService.php @@ -0,0 +1,234 @@ +. + * + * 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\CategoryTree; + +use Espo\Core\Acl; +use Espo\Core\Exceptions\Error; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\NotFound; +use Espo\Core\Templates\Entities\CategoryTree; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; +use Espo\ORM\Query\Part\Expression as Expr; +use Espo\ORM\Query\UpdateBuilder; +use Espo\ORM\Repository\Option\SaveOption; +use Espo\Tools\CategoryTree\Move\MoveParams; + +class MoveService +{ + private const ATTR_PARENT_ID = 'parentId'; + private const ATTR_ORDER = 'order'; + + public function __construct( + private EntityManager $entityManager, + private Acl $acl, + ) {} + + /** + * @throws NotFound + * @throws Forbidden + * @throws Error + */ + public function move(CategoryTree $entity, MoveParams $params): void + { + $hasOrder = $entity->hasAttribute(self::ATTR_ORDER); + + if (!$hasOrder && $params->type !== MoveParams::TYPE_INTO) { + throw new Error("Order not supported."); + } + + $entityType = $entity->getEntityType(); + + $reference = null; + + if ($params->referenceId) { + $reference = $this->entityManager->getEntityById($entityType, $params->referenceId); + + if (!$reference) { + throw new NotFound("No reference record found."); + } + } + + if ($params->type === MoveParams::TYPE_INTO) { + $this->processInto($reference, $entity, $params); + + return; + } + + if (!$reference) { + throw new Error("No reference."); + } + + $parentId = $reference->get(self::ATTR_PARENT_ID); + + if ($parentId !== $entity->get(self::ATTR_PARENT_ID) && $parentId) { + $parent = $this->entityManager->getEntityById($entityType, $parentId); + + if ($parent && !$this->acl->checkEntityEdit($parent)) { + throw new Forbidden("No edit access to target category."); + } + + if ($parent) { + $this->checkReferenceNoLoop($parent, $entity); + } + } + + if ($params->type === MoveParams::TYPE_AFTER) { + $this->processAfter($reference, $entity); + + return; + } + + $this->processBefore($reference, $entity); + } + + private function incrementAfter(Entity $reference): void + { + $update = UpdateBuilder::create() + ->in($reference->getEntityType()) + ->where([ + self::ATTR_PARENT_ID => $reference->get(self::ATTR_PARENT_ID), + self::ATTR_ORDER . '>' => $reference->get(self::ATTR_ORDER), + ]) + ->set([ + self::ATTR_ORDER => Expr::add(Expr::column(self::ATTR_ORDER), 2) + ]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($update); + } + + private function decrementBefore(Entity $reference): void + { + $update = UpdateBuilder::create() + ->in($reference->getEntityType()) + ->where([ + self::ATTR_PARENT_ID => $reference->get(self::ATTR_PARENT_ID), + self::ATTR_ORDER . '<' => $reference->get(self::ATTR_ORDER), + ]) + ->set([ + self::ATTR_ORDER => Expr::subtract(Expr::column(self::ATTR_ORDER), 2) + ]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($update); + } + + private function rearrange(Entity $reference): void + { + $entities = $this->entityManager + ->getRDBRepository($reference->getEntityType()) + ->where([ + self::ATTR_PARENT_ID => $reference->get(self::ATTR_PARENT_ID), + ]) + ->order(self::ATTR_ORDER) + ->find(); + + foreach ($entities as $i => $entity) { + $entity->set(self::ATTR_ORDER, $i + 1); + + $this->entityManager->saveEntity($entity, [SaveOption::SKIP_ALL => true]); + } + } + + private function processAfter(Entity $reference, CategoryTree $entity): void + { + $this->incrementAfter($reference); + $this->rearrange($reference); + + $order = ($reference->get(self::ATTR_ORDER) ?? 0) + 1; + + $entity->set(self::ATTR_ORDER, $order); + $entity->set(self::ATTR_PARENT_ID, $reference->get(self::ATTR_PARENT_ID)); + + $this->entityManager->saveEntity($entity); + + $this->rearrange($reference); + } + + private function processBefore(Entity $reference, CategoryTree $entity): void + { + $this->decrementBefore($reference); + + $order = ($reference->get(self::ATTR_ORDER) ?? 0) - 1; + + $entity->set(self::ATTR_ORDER, $order); + $entity->set(self::ATTR_PARENT_ID, $reference->get(self::ATTR_PARENT_ID)); + + $this->entityManager->saveEntity($entity); + + $this->rearrange($reference); + } + + /** + * @throws Forbidden + */ + private function processInto(?Entity $reference, CategoryTree $entity, MoveParams $params): void + { + if ($reference && !$this->acl->checkEntityEdit($reference)) { + throw new Forbidden("No edit access to target category."); + } + + if ($reference) { + $this->checkReferenceNoLoop($reference, $entity); + } + + $entity->setMultiple([ + self::ATTR_PARENT_ID => $params->referenceId, + self::ATTR_ORDER => null, + ]); + + $this->entityManager->saveEntity($entity); + } + + /** + * @throws Forbidden + */ + private function checkReferenceNoLoop(Entity $reference, CategoryTree $entity): void + { + $parentId = $reference->get(self::ATTR_PARENT_ID); + + if (!$parentId) { + return; + } + + if ($parentId === $entity->getId()) { + throw new Forbidden("Cannot move. Circle reference."); + } + + $parent = $this->entityManager->getEntityById($entity->getEntityType(), $parentId); + + if (!$parent) { + return; + } + + $this->checkReferenceNoLoop($parent, $entity); + } +} diff --git a/client/res/templates/record/list-tree.tpl b/client/res/templates/record/list-tree.tpl index b00595ef7d..ecc8d7691d 100644 --- a/client/res/templates/record/list-tree.tpl +++ b/client/res/templates/record/list-tree.tpl @@ -11,7 +11,10 @@
{{translate 'No Data'}}
{{/if}} -
+