From 7cb9faed1305686af32ad0573fc8e024cc2e2637 Mon Sep 17 00:00:00 2001 From: Yuri Kuznetsov Date: Thu, 26 Jun 2025 20:23:41 +0300 Subject: [PATCH] open current category --- .../Espo/Core/Controllers/RecordTree.php | 39 +++++++++----- application/Espo/Services/RecordTree.php | 52 +++++++------------ .../CategoryTree/Record/ReadTreeParams.php | 43 +++++++++++++++ client/src/collections/tree.js | 26 ++++++++++ client/src/controllers/record-tree.js | 28 +++++++--- client/src/views/list-with-categories.js | 10 +++- client/src/views/record/list-tree-item.js | 43 ++++++++------- client/src/views/record/list-tree.js | 35 +++++++++++++ 8 files changed, 202 insertions(+), 74 deletions(-) create mode 100644 application/Espo/Tools/CategoryTree/Record/ReadTreeParams.php diff --git a/application/Espo/Core/Controllers/RecordTree.php b/application/Espo/Core/Controllers/RecordTree.php index 69ccf73772..a2e32116cb 100644 --- a/application/Espo/Core/Controllers/RecordTree.php +++ b/application/Espo/Core/Controllers/RecordTree.php @@ -33,15 +33,14 @@ 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 Espo\Tools\CategoryTree\Record\ReadTreeParams; use RuntimeException; use stdClass; @@ -56,7 +55,6 @@ class RecordTree extends Record * Get a category tree. * * @throws BadRequest - * @throws Error * @throws Forbidden * @throws NotFound * @noinspection PhpUnused @@ -66,30 +64,45 @@ class RecordTree extends Record $selectParams = $this->fetchSearchParamsFromRequest($request); $parentId = $request->getQueryParam('parentId'); + $currentId = $request->getQueryParam('currentId'); $maxDepth = $request->getQueryParam('maxDepth'); $onlyNotEmpty = (bool) $request->getQueryParam('onlyNotEmpty'); + if ($parentId && $currentId) { + throw new BadRequest("Cannot have both parentId and currentId set."); + } + if ($maxDepth !== null) { $maxDepth = (int) $maxDepth; } - $collection = $this->getRecordTreeService()->getTree( - $parentId, - [ - 'where' => $selectParams->getWhere(), - 'onlyNotEmpty' => $onlyNotEmpty, - ], - $maxDepth + $params = new ReadTreeParams( + where: $selectParams->getWhere(), + onlyNotEmpty: $onlyNotEmpty, + currentId: $currentId, + maxDepth: $maxDepth, + parentId: $parentId, ); + $service = $this->getRecordTreeService(); + + $collection = $service->getTree($params); + if (!$collection) { - throw new Error(); + throw new RuntimeException(); + } + + $openPath = null; + + if ($params->currentId) { + $openPath = $service->getTreeItemPath($params->currentId); } return (object) [ 'list' => $collection->getValueMapList(), - 'path' => $this->getRecordTreeService()->getTreeItemPath($parentId), - 'data' => $this->getRecordTreeService()->getCategoryData($parentId), + 'path' => $service->getTreeItemPath($parentId), + 'data' => $service->getCategoryData($parentId), + 'openPath' => $openPath, ]; } diff --git a/application/Espo/Services/RecordTree.php b/application/Espo/Services/RecordTree.php index 7b1b4e1036..55383a7759 100644 --- a/application/Espo/Services/RecordTree.php +++ b/application/Espo/Services/RecordTree.php @@ -48,6 +48,7 @@ use Espo\Core\Acl\Exceptions\NotImplemented; use ArrayAccess; use Espo\Tools\CategoryTree\Move\LoopReferenceChecker; +use Espo\Tools\CategoryTree\Record\ReadTreeParams; use stdClass; /** @@ -60,56 +61,45 @@ class RecordTree extends Record private ?Entity $seed = null; - /** @var ?string */ - protected $subjectEntityType = null; - /** @var ?string */ - protected $categoryField = null; + protected ?string $subjectEntityType = null; + protected ?string $categoryField = null; /** - * @param array{where?: ?WhereItem, onlyNotEmpty?: bool} $params * @return ?Collection * @throws Forbidden * @throws BadRequest */ - public function getTree( - ?string $parentId = null, - array $params = [], - ?int $maxDepth = null - ): ?Collection { - + public function getTree(ReadTreeParams $params): ?Collection + { if (!$this->acl->check($this->entityType, Table::ACTION_READ)) { throw new Forbidden(); } - /** @noinspection PhpRedundantOptionalArgumentInspection */ - return $this->getTreeInternal($parentId, $params, $maxDepth, 0); + $path = $params->currentId ? $this->getTreeItemPath($params->currentId) : null; + + return $this->getTreeInternal($params->parentId, $params, $path); } /** - * @param array{where?: ?WhereItem, onlyNotEmpty?: bool} $params + * @param string[] $path * @return ?Collection * @throws BadRequest * @throws Forbidden */ - private function getTreeInternal( - ?string $parentId = null, - array $params = [], - ?int $maxDepth = null, - int $level = 0 - ): ?Collection { - - if (!$maxDepth) { - $maxDepth = self::MAX_DEPTH; - } + private function getTreeInternal(?string $parentId, ReadTreeParams $params, ?array $path, int $level = 0): ?Collection + { + $maxDepth = $params->maxDepth ?? self::MAX_DEPTH; if ($level === $maxDepth) { - return null; + if ($path === null || !in_array($parentId, $path)) { + return null; + } } $searchParams = SearchParams::create(); - if (isset($params['where'])) { - $searchParams = $searchParams->withWhere($params['where']); + if ($params->where) { + $searchParams = $searchParams->withWhere($params->where); } $selectBuilder = $this->selectBuilderFactory @@ -118,9 +108,7 @@ class RecordTree extends Record ->withStrictAccessControl() ->withSearchParams($searchParams) ->buildQueryBuilder() - ->where([ - 'parentId' => $parentId, - ]); + ->where(['parentId' => $parentId]); $selectBuilder->order([]); @@ -141,7 +129,7 @@ class RecordTree extends Record ->find(); if ( - (!empty($params['onlyNotEmpty']) || $filterItems) && + ($params->onlyNotEmpty || $filterItems) && $collection instanceof ArrayAccess ) { foreach ($collection as $i => $entity) { @@ -152,7 +140,7 @@ class RecordTree extends Record } foreach ($collection as $entity) { - $childList = $this->getTreeInternal($entity->getId(), $params, $maxDepth, $level + 1); + $childList = $this->getTreeInternal($entity->getId(), $params, $path, $level + 1); $entity->set('childList', $childList?->getValueMapList()); } diff --git a/application/Espo/Tools/CategoryTree/Record/ReadTreeParams.php b/application/Espo/Tools/CategoryTree/Record/ReadTreeParams.php new file mode 100644 index 0000000000..5801bcbf9e --- /dev/null +++ b/application/Espo/Tools/CategoryTree/Record/ReadTreeParams.php @@ -0,0 +1,43 @@ +. + * + * 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\Record; + +use Espo\Core\Select\Where\Item; + +readonly class ReadTreeParams +{ + public function __construct( + public ?Item $where = null, + public bool $onlyNotEmpty = false, + public ?string $currentId = null, + public ?int $maxDepth = null, + public ?string $parentId = null, + ) {} +} diff --git a/client/src/collections/tree.js b/client/src/collections/tree.js index 88dc6561df..569849379e 100644 --- a/client/src/collections/tree.js +++ b/client/src/collections/tree.js @@ -32,6 +32,26 @@ import Collection from 'collection'; class TreeCollection extends Collection { + /** + * @type {string} + */ + parentId + + /** + * @type {string|null} + */ + currentId = null + + /** + * @type {string[]|null} + */ + path + + /** + * @type {string[]|null} + */ + openPath + /** * @return {TreeCollection} */ @@ -55,6 +75,8 @@ class TreeCollection extends Collection { seed.reset(); this.path = response.path; + this.openPath = response.openPath ?? null; + /** * @type {{ * id: string, @@ -111,6 +133,10 @@ class TreeCollection extends Collection { options.data.parentId = this.parentId; } + if (this.currentId) { + options.data.currentId = this.currentId; + } + return super.fetch(options); } diff --git a/client/src/controllers/record-tree.js b/client/src/controllers/record-tree.js index c16ad1c0a0..010f413e6c 100644 --- a/client/src/controllers/record-tree.js +++ b/client/src/controllers/record-tree.js @@ -27,6 +27,7 @@ ************************************************************************/ import RecordController from 'controllers/record'; +import TreeCollection from 'collections/tree'; class RecordTreeController extends RecordController { @@ -49,14 +50,27 @@ class RecordTreeController extends RecordController { } // noinspection JSUnusedGlobalSymbols - actionListTree() { - this.getCollection().then(collection => { - collection.url = collection.entityType + '/action/listTree'; + /** + * + * @param {{ + * currentId?: string, + * }} options + */ + async actionListTree(options) { + const currentId = options.currentId; - this.main(this.getViewName('listTree'), { - scope: this.name, - collection: collection - }); + const collection = await this.getCollection(); + + if (!(collection instanceof TreeCollection)) { + throw new Error("Wrong collection."); + } + + collection.url = `${collection.entityType}/action/listTree`; + collection.currentId = currentId ?? null; + + this.main(this.getViewName('listTree'), { + scope: this.name, + collection: collection, }); } diff --git a/client/src/views/list-with-categories.js b/client/src/views/list-with-categories.js index be06e60d9d..f302dbddfc 100644 --- a/client/src/views/list-with-categories.js +++ b/client/src/views/list-with-categories.js @@ -77,6 +77,7 @@ class ListWithCategories extends ListView { super.setup(); this.addActionHandler('toggleExpandedFromNavigation', () => this.actionToggleExpandedFromNavigation()); + this.addActionHandler('manageCategories', () => this.actionManageCategories()); this.defaultMaxSize = this.collection.maxSize; @@ -736,14 +737,19 @@ class ListWithCategories extends ListView { return this.currentCategoryId; } - // noinspection JSUnusedGlobalSymbols /** * @private */ actionManageCategories() { this.clearCategoryViews(); - this.getRouter().navigate('#' + this.categoryScope, {trigger: true}); + let url =`#${this.categoryScope}`; + + if (this.currentCategoryId) { + url += `/listTree/currentId=${this.currentCategoryId}`; + } + + this.getRouter().navigate(url, {trigger: true}); } /** diff --git a/client/src/views/record/list-tree-item.js b/client/src/views/record/list-tree-item.js index 3bac8bb799..b22200c6fb 100644 --- a/client/src/views/record/list-tree-item.js +++ b/client/src/views/record/list-tree-item.js @@ -242,7 +242,10 @@ class ListTreeRecordItemView extends View { }); } - unfold() { + /** + * Unfold. + */ + async unfold() { if (this.createDisabled) { this.once('children-created', () => { if (!this.model.lastAreChecked) { @@ -263,34 +266,34 @@ class ListTreeRecordItemView extends View { return; } - this.getCollectionFactory().create(this.scope, collection => { - collection.url = this.collection.url; - collection.parentId = this.model.id; + const collection = await this.getCollectionFactory().create(this.scope); - Espo.Ui.notifyWait(); + collection.url = this.collection.url; + collection.parentId = this.model.id; - this.listenToOnce(collection, 'sync', () => { - Espo.Ui.notify(false); + Espo.Ui.notifyWait(); - this.model.set('childCollection', collection); + this.listenToOnce(collection, 'sync', () => { + Espo.Ui.notify(false); - this.createChildren(); + this.model.set('childCollection', collection); - this.isUnfolded = true; + this.createChildren(); - if (collection.length || !this.createDisabled) { - this.afterUnfold(); + this.isUnfolded = true; - this.trigger('after:unfold'); - } else { - this.isEnd = true; + if (collection.length || !this.createDisabled) { + this.afterUnfold(); - this.afterIsEnd(); - } - }); + this.trigger('after:unfold'); + } else { + this.isEnd = true; - collection.fetch(); + this.afterIsEnd(); + } }); + + await collection.fetch(); } fold() { @@ -376,7 +379,7 @@ class ListTreeRecordItemView extends View { } /** - * @return module:views/record/list-tree + * @return {module:views/record/list-tree} */ getChildrenView() { return /** @type module:views/record/list-tree */this.getView('children'); diff --git a/client/src/views/record/list-tree.js b/client/src/views/record/list-tree.js index a732f5736c..4ed3cb28c5 100644 --- a/client/src/views/record/list-tree.js +++ b/client/src/views/record/list-tree.js @@ -174,6 +174,41 @@ class ListTreeRecordView extends ListRecordView { } }); } + + if (this.level === 0) { + this.once('after:render', () => { + const collection = /** @type {import('collections/tree').default} */this.collection; + + if (collection.openPath) { + /** + * @param {ListTreeRecordView} view + * @param {string[]} path + */ + const open = async (view, path) => { + path = [...path]; + const id = path.shift() + + const itemView = view.getItemViews().find(view => view.model.id === id); + + if (!itemView) { + return; + } + + await itemView.unfold(); + + if (!path.length) { + return; + } + + await open(itemView.getChildrenView(), path); + } + + open(this, collection.openPath); + + collection.openPath = null; + } + }); + } } onRemove() {