open current category

This commit is contained in:
Yuri Kuznetsov
2025-06-26 20:23:41 +03:00
parent 7fa1a7f376
commit 7cb9faed13
8 changed files with 202 additions and 74 deletions
@@ -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,
];
}
+20 -32
View File
@@ -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<Entity>
* @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<Entity>
* @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());
}
@@ -0,0 +1,43 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
* 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\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,
) {}
}
+26
View File
@@ -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);
}
+21 -7
View File
@@ -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,
});
}
+8 -2
View File
@@ -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});
}
/**
+23 -20
View File
@@ -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');
+35
View File
@@ -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() {