Merge pull request 'feat(tree): Expand all / Collapse all for the space page tree' (#23) from feat/tree-expand-collapse-all-agent227 into develop

This commit was merged in pull request #23.
This commit is contained in:
claude_code
2026-06-20 17:40:29 +03:00
10 changed files with 572 additions and 309 deletions

View File

@@ -977,6 +977,9 @@
"Page menu": "Page menu",
"Expand": "Expand",
"Collapse": "Collapse",
"Expand all": "Expand all",
"Collapse all": "Collapse all",
"Couldn't expand the tree: {{reason}}": "Couldn't expand the tree: {{reason}}",
"Comment menu": "Comment menu",
"Group menu": "Group menu",
"Show hidden breadcrumbs": "Show hidden breadcrumbs",

View File

@@ -92,6 +92,14 @@ export async function getAllSidebarPages(
};
}
export async function getSpaceTree(params: {
spaceId: string;
pageId?: string;
}): Promise<IPage[]> {
const req = await api.post<{ items: IPage[] }>("/pages/tree", params);
return req.data.items;
}
export async function getPageBreadcrumbs(
pageId: string,
): Promise<Partial<IPage[]>> {

View File

@@ -1,8 +1,17 @@
import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Text } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import {
fetchAllAncestorChildren,
useGetRootSidebarPagesQuery,
@@ -16,10 +25,15 @@ import {
buildTree,
buildTreeWithChildren,
mergeRootTrees,
collectAllIds,
collectBranchIds,
} from "@/features/page/tree/utils/utils.ts";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { treeModel } from "@/features/page/tree/model/tree-model";
import { getPageBreadcrumbs } from "@/features/page/services/page-service.ts";
import {
getPageBreadcrumbs,
getSpaceTree,
} from "@/features/page/services/page-service.ts";
import { IPage } from "@/features/page/types/page.types.ts";
import { extractPageSlugId } from "@/lib";
import { isCompactPageTreeEnabled } from "@/lib/config.ts";
@@ -35,11 +49,21 @@ interface SpaceTreeProps {
readOnly: boolean;
}
export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
export type SpaceTreeApi = {
expandAll: () => Promise<void>;
collapseAll: () => void;
isExpanding: boolean;
};
const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
{ spaceId, readOnly },
ref,
) {
const { t } = useTranslation();
const { pageSlug } = useParams();
const compactTree = isCompactPageTreeEnabled();
const [data, setData] = useAtom(treeDataAtom);
const [isExpanding, setIsExpanding] = useState(false);
const { handleMove } = useTreeMutation(spaceId);
const {
data: pagesData,
@@ -192,6 +216,64 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
[data, spaceId],
);
const expandAll = useCallback(async () => {
const startSpaceId = spaceIdRef.current;
setIsExpanding(true);
try {
// One request: the entire space tree, permission-filtered server-side.
const items = await getSpaceTree({ spaceId: startSpaceId });
// Space switched mid-flight — abort merge/expand.
if (spaceIdRef.current !== startSpaceId) return;
const fullTree = buildTreeWithChildren(buildTree(items));
setData((prev) => {
// Replace current-space nodes with the full tree; keep other spaces intact.
const others = prev.filter((n) => n?.spaceId !== startSpaceId);
return [...others, ...fullTree];
});
// Open every branch node (node with children) of the current space only.
const branchIds = collectBranchIds(fullTree);
setOpenTreeNodes((prev) => {
const next = { ...prev };
for (const id of branchIds) next[id] = true;
return next;
});
} catch (err: any) {
// Never swallow: log full error + surface the real reason.
console.error("[tree] expandAll failed", err);
notifications.show({
color: "red",
message: t("Couldn't expand the tree: {{reason}}", {
reason:
err?.response?.data?.message ?? err?.message ?? String(err),
}),
});
} finally {
setIsExpanding(false);
}
}, [setData, setOpenTreeNodes, t]);
const collapseAll = useCallback(() => {
// The open-map is shared across spaces; collapse only current-space ids so
// other spaces' expanded state is left intact.
const ids = collectAllIds(filteredData);
setOpenTreeNodes((prev) => {
const next = { ...prev };
for (const id of ids) next[id] = false;
return next;
});
}, [filteredData, setOpenTreeNodes]);
useImperativeHandle(
ref,
() => ({ expandAll, collapseAll, isExpanding }),
[expandAll, collapseAll, isExpanding],
);
// Stable callbacks for DocTree. Without these, every parent render recreates
// the props and tears down every row's draggable/dropTarget subscription,
// defeating memo(DocTreeRow).
@@ -235,4 +317,6 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
)}
</div>
);
}
});
export default SpaceTree;

View File

@@ -216,3 +216,33 @@ export function mergeRootTrees(
return sortPositionKeys(merged);
}
// Collect every node id in the tree (roots, branches, leaves). Used by
// collapseAll to clear the open-state map for all current-space nodes.
export function collectAllIds(nodes: SpaceTreeNode[]): string[] {
const ids: string[] = [];
const walk = (list: SpaceTreeNode[]) => {
for (const n of list) {
ids.push(n.id);
if (n.children?.length) walk(n.children);
}
};
walk(nodes);
return ids;
}
// Collect ids of branch nodes (nodes that have children). Used by expandAll to
// open every branch in the open-state map; leaves need no entry.
export function collectBranchIds(nodes: SpaceTreeNode[]): string[] {
const ids: string[] = [];
const walk = (list: SpaceTreeNode[]) => {
for (const n of list) {
if (n.children?.length) {
ids.push(n.id);
walk(n.children);
}
}
};
walk(nodes);
return ids;
}

View File

@@ -7,6 +7,8 @@ import {
} from "@mantine/core";
import {
IconArrowDown,
IconChevronsDown,
IconChevronsUp,
IconDots,
IconEye,
IconEyeOff,
@@ -23,14 +25,16 @@ import {
useUnwatchSpaceMutation,
} from "@/features/space/queries/space-watcher-query.ts";
import classes from "./space-sidebar.module.css";
import React from "react";
import React, { useRef } from "react";
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
import { Link, useParams } from "react-router-dom";
import clsx from "clsx";
import { useDisclosure } from "@mantine/hooks";
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import SpaceTree from "@/features/page/tree/components/space-tree.tsx";
import SpaceTree, {
SpaceTreeApi,
} from "@/features/page/tree/components/space-tree.tsx";
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
import {
SpaceCaslAction,
@@ -57,6 +61,7 @@ export function SpaceSidebar() {
const spaceRules = space?.membership?.permissions;
const spaceAbility = useSpaceAbility(spaceRules);
const { handleCreate } = useTreeMutation(space?.id ?? "");
const treeRef = useRef<SpaceTreeApi | null>(null);
if (!space) {
return <></>;
@@ -100,6 +105,7 @@ export function SpaceSidebar() {
SpaceCaslSubject.Page,
)}
onSpaceSettings={openSettings}
treeRef={treeRef}
/>
{spaceAbility.can(
@@ -122,6 +128,7 @@ export function SpaceSidebar() {
<div className={classes.pages}>
<SpaceTree
ref={treeRef}
spaceId={space.id}
readOnly={spaceAbility.cannot(
SpaceCaslAction.Manage,
@@ -145,13 +152,25 @@ interface SpaceMenuProps {
spaceId: string;
canManagePages: boolean;
onSpaceSettings: () => void;
treeRef: React.RefObject<SpaceTreeApi | null>;
}
function SpaceMenu({
spaceId,
canManagePages,
onSpaceSettings,
treeRef,
}: SpaceMenuProps) {
const { t } = useTranslation();
const handleExpandAll = () => {
// Fire-and-forget: expandAll already surfaces its own error notification.
// The menu closes on click (consistent with Collapse all), so there is no
// in-menu loading state to track here.
treeRef.current?.expandAll();
};
const handleCollapseAll = () => {
treeRef.current?.collapseAll();
};
const { spaceSlug } = useParams();
const [importOpened, { open: openImportModal, close: closeImportModal }] =
useDisclosure(false);
@@ -201,6 +220,22 @@ function SpaceMenu({
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={handleExpandAll}
leftSection={<IconChevronsDown size={16} />}
>
{t("Expand all")}
</Menu.Item>
<Menu.Item
onClick={handleCollapseAll}
leftSection={<IconChevronsUp size={16} />}
>
{t("Collapse all")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
onClick={handleToggleFavorite}
leftSection={

View File

@@ -578,6 +578,49 @@ export class PageController {
);
}
@HttpCode(HttpStatus.OK)
@Post('/tree')
async getPagesTree(
@Body() dto: SidebarPageDto,
@AuthUser() user: User,
) {
if (!dto.spaceId && !dto.pageId) {
throw new BadRequestException(
'Either spaceId or pageId must be provided',
);
}
let spaceId = dto.spaceId;
if (dto.pageId) {
const page = await this.pageRepo.findById(dto.pageId);
if (!page) {
throw new ForbiddenException();
}
spaceId = page.spaceId;
}
const ability = await this.spaceAbility.createForUser(user, spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
const spaceCanEdit = ability.can(
SpaceCaslAction.Edit,
SpaceCaslSubject.Page,
);
const items = await this.pageService.getSidebarPagesTree(
spaceId,
user.id,
spaceCanEdit,
dto.pageId,
);
return { items };
}
@HttpCode(HttpStatus.OK)
@Post('move-to-space')
async movePageToSpace(

View File

@@ -1137,7 +1137,7 @@ export class PageService {
T extends { id: string; parentPageId: string | null },
>(
pages: T[],
rootPageId: string,
rootPageId: string | null,
userId: string,
spaceId?: string,
): Promise<T[]> {
@@ -1153,6 +1153,15 @@ export class PageService {
);
const accessibleSet = new Set(accessibleIds);
// When no explicit root is given (whole-space tree), every page whose
// parent is outside the returned set acts as a root (space root pages have
// parentPageId === null). This mirrors the single-root case below.
const pageIdSet = new Set(pageIds);
const isRoot = (page: T): boolean => {
if (rootPageId !== null) return page.id === rootPageId;
return !page.parentPageId || !pageIdSet.has(page.parentPageId);
};
// Prune: include a page only if it's accessible AND its parent chain to root is included
const includedIds = new Set<string>();
@@ -1166,7 +1175,7 @@ export class PageService {
if (!accessibleSet.has(page.id)) continue;
// Root page: include if accessible
if (page.id === rootPageId) {
if (isRoot(page)) {
includedIds.add(page.id);
changed = true;
continue;
@@ -1182,4 +1191,123 @@ export class PageService {
return pages.filter((p) => includedIds.has(p.id));
}
/**
* Whole subtree (pageId) or whole space tree (spaceId only) in a single
* query, permission-filtered, returned as a flat list matching the sidebar
* item shape (id, slugId, title, icon, position, parentPageId, spaceId,
* hasChildren, canEdit) ordered by position. content is never fetched.
*
* Reproduces the exact two-branch permission logic of getSidebarPages():
* - open space (no restrictions): every returned page is visible, canEdit =
* spaceCanEdit, hasChildren derived from the returned set.
* - restricted space: full descendant set is loaded, then per-page
* permissions applied via filterAccessibleTreePages (restricted-but-granted
* pages are kept; inaccessible subtrees pruned); canEdit is per-page AND
* spaceCanEdit;
* hasChildren is derived from the FINAL (post-prune, post-filter) set, so
* a node never advertises children the user cannot access — the same
* correction getSidebarPages does via getParentIdsWithAccessibleChildren.
*/
async getSidebarPagesTree(
spaceId: string,
userId: string,
spaceCanEdit?: boolean,
pageId?: string,
): Promise<
Array<
Pick<
Page,
| 'id'
| 'slugId'
| 'title'
| 'icon'
| 'position'
| 'parentPageId'
| 'spaceId'
> & { hasChildren: boolean; canEdit: boolean }
>
> {
const hasRestrictions =
await this.pagePermissionRepo.hasRestrictedPagesInSpace(spaceId);
// Seed: a single page subtree, or all root pages of the space.
// Always seed with the FULL (non-excluding) descendant set — in a restricted
// space the per-page filtering below (filterAccessibleTreePages) does the
// pruning, exactly like getSidebarPages. Seeding with *ExcludingRestricted
// would wrongly drop restricted pages the user has an explicit grant for
// (and never recurse into their children), diverging from the sidebar.
let pages: Array<{
id: string;
slugId: string;
title: string;
icon: string;
position: string;
parentPageId: string | null;
spaceId: string;
}>;
if (pageId) {
pages = await this.pageRepo.getPageAndDescendants(pageId, {
includeContent: false,
});
} else {
pages = await this.pageRepo.getSpaceDescendants(spaceId, {
includeContent: false,
});
}
let permissionMap: Map<string, boolean> | undefined;
if (hasRestrictions) {
// Fine-grained per-page permissions on top of restricted pruning.
pages = await this.filterAccessibleTreePages(
pages,
pageId ?? null,
userId,
spaceId,
);
// Per-page canEdit, same source as getSidebarPages.
const accessiblePages =
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions(
pages.map((p) => p.id),
userId,
);
permissionMap = new Map(accessiblePages.map((p) => [p.id, p.canEdit]));
}
// Derive hasChildren from the FINAL set: a node has children iff some
// returned row points to it as parent. In a restricted space this set is
// already pruned/filtered, so inaccessible children are not revealed.
const parentIds = new Set<string>();
for (const p of pages) {
if (p.parentPageId) parentIds.add(p.parentPageId);
}
const shaped = pages.map((p) => ({
id: p.id,
slugId: p.slugId,
title: p.title,
icon: p.icon,
position: p.position,
parentPageId: p.parentPageId,
spaceId: p.spaceId,
hasChildren: parentIds.has(p.id),
canEdit: hasRestrictions
? Boolean(permissionMap?.get(p.id)) && (spaceCanEdit ?? true)
: (spaceCanEdit ?? true),
}));
// Order by position with byte order, matching the sidebar's
// `position collate "C"` SQL ordering. position is non-null in returned
// rows; treat a null defensively as sorting last.
shaped.sort((a, b) => {
if (a.position == null) return b.position == null ? 0 : 1;
if (b.position == null) return -1;
return Buffer.compare(Buffer.from(a.position), Buffer.from(b.position));
});
return shaped;
}
}

View File

@@ -0,0 +1,179 @@
/**
* Pure-logic test for getSidebarPagesTree's shaping/permission logic.
*
* NOTE: We cannot import PageService directly here — its dependency chain
* imports `src/collaboration/collaboration.util` via a bare `src/...` path, and
* the server's jest config (package.json "jest".moduleNameMapper) has no
* `^src/(.*)$` mapping, so the module fails to resolve under jest. That is a
* pre-existing config gap unrelated to this feature. To still cover the
* load-bearing logic we replicate the exact shaping algorithm from
* PageService.getSidebarPagesTree below and assert against it. If the service
* logic changes, keep this mirror in sync.
*/
type RawPage = {
id: string;
slugId: string;
title: string;
icon: string;
position: string;
parentPageId: string | null;
spaceId: string;
};
// Mirror of the shaping/branch logic in PageService.getSidebarPagesTree.
function shapeTree(
pages: RawPage[],
opts: {
hasRestrictions: boolean;
spaceCanEdit?: boolean;
permissionMap?: Map<string, boolean>;
},
) {
const parentIds = new Set<string>();
for (const p of pages) {
if (p.parentPageId) parentIds.add(p.parentPageId);
}
const shaped = pages.map((p) => ({
id: p.id,
slugId: p.slugId,
title: p.title,
icon: p.icon,
position: p.position,
parentPageId: p.parentPageId,
spaceId: p.spaceId,
hasChildren: parentIds.has(p.id),
canEdit: opts.hasRestrictions
? Boolean(opts.permissionMap?.get(p.id)) && (opts.spaceCanEdit ?? true)
: (opts.spaceCanEdit ?? true),
}));
shaped.sort((a, b) => {
if (a.position == null) return b.position == null ? 0 : 1;
if (b.position == null) return -1;
return Buffer.compare(Buffer.from(a.position), Buffer.from(b.position));
});
return shaped;
}
const page = (
id: string,
parentPageId: string | null,
position: string,
): RawPage => ({
id,
slugId: `slug-${id}`,
title: `Page ${id}`,
icon: '',
position,
parentPageId,
spaceId: 'space-1',
});
describe('getSidebarPagesTree shaping logic', () => {
it('open space: canEdit = spaceCanEdit, hasChildren derived from set', () => {
const pages = [
page('root', null, 'a0'),
page('child', 'root', 'a0'),
page('leaf', 'child', 'a0'),
];
const result = shapeTree(pages, {
hasRestrictions: false,
spaceCanEdit: true,
});
const byId = new Map(result.map((p) => [p.id, p]));
expect(byId.get('root')!.hasChildren).toBe(true);
expect(byId.get('child')!.hasChildren).toBe(true);
expect(byId.get('leaf')!.hasChildren).toBe(false);
expect(result.every((p) => p.canEdit === true)).toBe(true);
});
it('open space: spaceCanEdit=false makes every node read-only', () => {
const pages = [page('root', null, 'a0'), page('child', 'root', 'a0')];
const result = shapeTree(pages, {
hasRestrictions: false,
spaceCanEdit: false,
});
expect(result.every((p) => p.canEdit === false)).toBe(true);
});
it('restricted space: hasChildren does not reveal pruned children', () => {
// Simulates the filterAccessibleTreePages result: "child" was pruned, so
// the returned set has no row with parent === root.
const prunedPages = [page('root', null, 'a0')];
const result = shapeTree(prunedPages, {
hasRestrictions: true,
spaceCanEdit: true,
permissionMap: new Map([['root', true]]),
});
expect(result).toHaveLength(1);
// root no longer advertises children the user cannot access.
expect(result[0].hasChildren).toBe(false);
});
it('restricted space: canEdit is per-page AND spaceCanEdit', () => {
const pages = [
page('root', null, 'a0'),
page('child', 'root', 'a0'),
];
const result = shapeTree(pages, {
hasRestrictions: true,
spaceCanEdit: true,
permissionMap: new Map([
['root', true],
['child', false],
]),
});
const byId = new Map(result.map((p) => [p.id, p]));
expect(byId.get('root')!.canEdit).toBe(true);
expect(byId.get('child')!.canEdit).toBe(false);
expect(byId.get('root')!.hasChildren).toBe(true);
});
it('restricted space: spaceCanEdit=false overrides per-page canEdit', () => {
const pages = [page('root', null, 'a0')];
const result = shapeTree(pages, {
hasRestrictions: true,
spaceCanEdit: false,
permissionMap: new Map([['root', true]]),
});
expect(result[0].canEdit).toBe(false);
});
it('orders by position (collate-C style ascending)', () => {
const pages = [
page('b', null, 'a1'),
page('c', null, 'a2'),
page('a', null, 'a0'),
];
const result = shapeTree(pages, {
hasRestrictions: false,
spaceCanEdit: true,
});
expect(result.map((p) => p.id)).toEqual(['a', 'b', 'c']);
});
it('shape contains exactly the sidebar item fields', () => {
const result = shapeTree([page('root', null, 'a0')], {
hasRestrictions: false,
spaceCanEdit: true,
});
expect(Object.keys(result[0]).sort()).toEqual(
[
'canEdit',
'hasChildren',
'icon',
'id',
'parentPageId',
'position',
'slugId',
'spaceId',
'title',
].sort(),
);
});
});

View File

@@ -672,4 +672,58 @@ export class PageRepo {
.execute()
);
}
/**
* Whole space tree (all root pages and their descendants) in a single
* recursive query. Mirrors getPageAndDescendants but seeded by every root
* page of the space (parentPageId IS NULL) instead of a single parent.
*/
async getSpaceDescendants(
spaceId: string,
opts: { includeContent: boolean },
) {
return this.db
.withRecursive('page_hierarchy', (db) =>
db
.selectFrom('pages')
.select([
'id',
'slugId',
'title',
'icon',
'position',
'parentPageId',
'spaceId',
'workspaceId',
'createdAt',
'updatedAt',
])
.$if(opts?.includeContent, (qb) => qb.select('content'))
.where('spaceId', '=', spaceId)
.where('parentPageId', 'is', null)
.where('deletedAt', 'is', null)
.unionAll((exp) =>
exp
.selectFrom('pages as p')
.select([
'p.id',
'p.slugId',
'p.title',
'p.icon',
'p.position',
'p.parentPageId',
'p.spaceId',
'p.workspaceId',
'p.createdAt',
'p.updatedAt',
])
.$if(opts?.includeContent, (qb) => qb.select('p.content'))
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id')
.where('p.deletedAt', 'is', null),
),
)
.selectFrom('page_hierarchy')
.selectAll()
.execute();
}
}

View File

@@ -1,301 +0,0 @@
# Дерево страниц: кнопки «Развернуть всё» / «Свернуть всё»
Статус: **план, код не менялся.** Фича клиент+сервер. По решению владельца выбран
**серверный путь**: эндпоинт отдаёт **всё поддерево/всё дерево спейса разом**
(«отдать всё»), а клиент за один-два запроса разворачивает дерево целиком. От
клиентского рекурсивного обхода по одному уровню — отказались (см. «Почему так»).
## Суть
В сайдбаре спейса (дерево «Pages») сейчас узлы разворачиваются/сворачиваются
только поодиночке кликом по шеврону. Есть шорткат `*` (разворачивает **сиблингов**
сфокусированного узла, паттерн WAI-ARIA tree), но глобального «развернуть/свернуть
всё дерево» нет.
Хотим: две команды в шапке дерева — **«Развернуть всё»** (раскрыть все ветки
текущего спейса) и **«Свернуть всё»** (схлопнуть до корней). Это навигационная
операция над видом — прав на запись не требует, доступна любому, кто видит спейс.
## Почему так (выбор архитектуры)
Дети узлов **загружаются лениво, по одному уровню**: у свёрнутой ветки
`hasChildren === true`, но `children === []`, а эндпоинт `/pages/sidebar-pages`
отдаёт **только прямых детей** одного `pageId`. «Развернуть всё» поверх такого
API = рекурсивный BFS на десятки-сотни HTTP-запросов (шторм запросов, лимиты,
долгий индикатор, защитный потолок). Это и был отвергнутый вариант.
**Решение — отдать всё одним запросом на сервере.** У бэкенда уже есть готовые
кирпичи для рекурсивной выборки поддерева с учётом прав (используются в
`movePageToSpace`):
- `pageRepo.getPageAndDescendants(parentPageId, { includeContent: false })`
([page.repo.ts:557](apps/server/src/database/repos/page/page.repo.ts#L557)) —
рекурсивный CTE: страница + все потомки одним запросом.
- `pageRepo.getPageAndDescendantsExcludingRestricted(parentPageId, opts)`
([page.repo.ts:612](apps/server/src/database/repos/page/page.repo.ts#L612)) —
то же, но **обрезает закрытые (restricted) поддеревья прямо в SQL** (один
запрос, не тянет лишнее).
- `pageService.filterAccessibleTreePages(allPages, rootId, userId, spaceId)`
([page.service.ts:1136](apps/server/src/core/page/services/page.service.ts#L1136))
— точечная фильтрация дерева по правам с сохранением целостности (для
per-page permissions сверх restricted-спейсов).
- `pageRepo.withHasChildren(eb)`
([page.repo.ts:539](apps/server/src/database/repos/page/page.repo.ts#L539)) —
вычисление `hasChildren` в SQL (при отдаче всего дерева `hasChildren` можно и
вывести на клиенте — у узла есть дети, если в ответе есть страница с
`parentPageId === id`).
Плюсы серверного пути: один-два запроса вместо сотен; предсказуемо даже на
тысячах страниц; права считаются на сервере (единый источник правды); на клиенте
нет BFS/ограничителя параллелизма/защитного потолка. Минус — нужна работа на
бэкенде (новый рекурсивный режим эндпоинта) и контроль размера ответа.
## Где сейчас живёт код (точные места)
### Клиент — фича `apps/client/src/features/page/tree/`
- **Состояние раскрытия** —
[open-tree-nodes-atom.ts](apps/client/src/features/page/tree/atoms/open-tree-nodes-atom.ts):
`openTreeNodesAtom`, тип `OpenMap = Record<string, boolean>` (id → раскрыт ли),
**персист в localStorage**, ключ `openTreeNodes:{workspaceId}:{userId}`.
**Карта общая для всех спейсов воркспейса.**
- **Данные дерева** —
[tree-data-atom.ts](apps/client/src/features/page/tree/atoms/tree-data-atom.ts):
`treeDataAtom: SpaceTreeNode[]`, накопительно по спейсам; на рендере
фильтруется по `spaceId`.
- **Модель узла** —
[types.ts](apps/client/src/features/page/tree/types.ts): `SpaceTreeNode`
(`id`, `spaceId`, `hasChildren`, `children`, `name`, `icon`, `position`,
`parentPageId`, `canEdit`, `slugId`).
- **Обёртка/тоггл/загрузка** —
[space-tree.tsx](apps/client/src/features/page/tree/components/space-tree.tsx):
`filteredData` (стр. 184-187, узлы текущего спейса), `handleToggle` (стр.
164-182, ленивая загрузка уровня), `spaceIdRef` (стр. 46-47, защита от гонок).
- **Модель-операции** —
[tree-model.ts](apps/client/src/features/page/tree/model/tree-model.ts):
`find`, `appendChildren`, `visible`, `siblingsOf`.
- **HTTP-загрузка** —
[page-query.ts](apps/client/src/features/page/queries/page-query.ts) +
[page-service.ts](apps/client/src/features/page/services/page-service.ts):
`getSidebarPages` / `getAllSidebarPages` (паджинируют **один уровень**),
`fetchAllAncestorChildren`, утилиты `buildTree` / `buildTreeWithChildren` /
`mergeRootTrees` ([utils.ts](apps/client/src/features/page/tree/utils/utils.ts)).
- **Шапка дерева (куда вешать команды)** —
[space-sidebar.tsx:117-149](apps/client/src/features/space/components/sidebar/space-sidebar.tsx#L117):
`SpaceMenu` (дропдаун на `IconDots`, стр. 172-281, уже с `Menu.Item`/
`Menu.Divider`) + кнопка «+» (Create page).
### Сервер — фича `apps/server/src/core/page/`
- **Эндпоинт сайдбара** —
[page.controller.ts:540](apps/server/src/core/page/page.controller.ts#L540)
`POST /pages/sidebar-pages` (`SidebarPageDto`: `spaceId | pageId`),
CASL-скоуп на спейс, отдаёт **один уровень**.
- **Сервис** —
[page.service.ts:304](apps/server/src/core/page/services/page.service.ts#L304)
`getSidebarPages(spaceId, pagination, pageId?, userId?, spaceCanEdit?)`:
выборка одного уровня + `withHasChildren` + **двухветочная фильтрация прав**
если в спейсе нет ограничений (`pagePermissionRepo.hasRestrictedPagesInSpace`)
`canEdit = spaceCanEdit`; иначе per-page фильтр через
`filterAccessiblePageIdsWithPermissions` + корректировка `hasChildren` по
`getParentIdsWithAccessibleChildren`. **Эту же логику прав надо повторить в
рекурсивном режиме.**
## Решение
### Серверная часть — «отдать всё поддерево» одним запросом
Добавить рекурсивный режим выдачи дерева. Варианты оформления (выбрать на ревью):
- флаг `recursive: true` (и опц. `depth`) к существующему `POST /pages/sidebar-pages`, **или**
- отдельный эндпоинт `POST /pages/tree` (`{ spaceId }` → всё дерево спейса;
`{ pageId }` → всё поддерево страницы).
Контракт ответа: **плоский список элементов в точно том же shape, что и текущий
`/pages/sidebar-pages`** (`id`, `slugId`, `title`, `icon`, `position`,
`parentPageId`, `spaceId`, `hasChildren`, `canEdit`), чтобы клиентские
`buildTree`/`buildTreeWithChildren` собрали дерево без изменений. Порядок — по
`position` (collate "C"), как сейчас.
Сервисный метод (эскиз), переиспользует существующие кирпичи:
```ts
// Whole subtree (pageId) or whole space tree (spaceId only) in a single query,
// permission-filtered, returned as a flat list matching the sidebar item shape.
async getSidebarPagesTree(spaceId, userId, spaceCanEdit, pageId?) {
const hasRestrictions = await this.pagePermissionRepo.hasRestrictedPagesInSpace(spaceId);
// Seed: a single page subtree, or all root pages of the space.
// - restricted space -> *ExcludingRestricted (prunes closed subtrees in SQL)
// - open space -> plain recursive descendants
// For the whole-space case add a space-rooted recursive CTE (seed:
// parentPageId is null AND spaceId = ? AND deletedAt is null), mirroring
// getPageAndDescendants/...ExcludingRestricted.
let pages = hasRestrictions
? await this.pageRepo.getSpaceDescendantsExcludingRestricted(spaceId, pageId, { includeContent: false })
: await this.pageRepo.getSpaceDescendants(spaceId, pageId, { includeContent: false });
// Fine-grained per-page permissions on top of restricted pruning.
if (hasRestrictions) {
pages = await this.filterAccessibleTreePages(pages, pageId ?? null, userId, spaceId);
}
// Derive hasChildren from the returned set; stamp canEdit (per-page when
// restricted, else spaceCanEdit). Same two-branch logic as getSidebarPages().
return shapeAsSidebarItems(pages, { hasRestrictions, spaceCanEdit /*, permissionMap */ });
}
```
Где `getSpaceDescendants` / `getSpaceDescendantsExcludingRestricted` — новые
тонкие обёртки над существующими рекурсивными CTE (для случая «всё дерево спейса»
— CTE, засеянный корнями спейса вместо одного `parentPageId`).
**Важно про права:** обязательно сохранить **обе ветки** фильтрации из
`getSidebarPages` (restricted / не-restricted) и корректировку `hasChildren`,
иначе рекурсивный эндпоинт начнёт отдавать страницы, к которым у пользователя нет
доступа. Это критичная грань — на ревью проверить отдельно.
### Клиентская часть — упрощённый `expandAll`
Поскольку дерево приходит целиком, BFS/параллелизм/потолок не нужны.
`page-service.ts` — новый вызов:
```ts
// Fetch the whole space tree (all roots + descendants) in one shot.
export async function getSpaceTree(params: { spaceId: string; pageId?: string }): Promise<IPage[]> {
const req = await api.post("/pages/tree", params); // or /sidebar-pages { recursive: true }
return req.data.items;
}
```
`space-tree.tsx` — превратить `SpaceTree` в `forwardRef` и выставить
`useImperativeHandle`:
```ts
export type SpaceTreeApi = {
expandAll: () => Promise<void>;
collapseAll: () => void;
isExpanding: boolean;
};
const expandAll = useCallback(async () => {
const startSpaceId = spaceIdRef.current;
setIsExpanding(true);
try {
// One request: the entire space tree, permission-filtered server-side.
const items = await getSpaceTree({ spaceId: startSpaceId });
if (spaceIdRef.current !== startSpaceId) return; // space switched — abort
const fullTree = buildTreeWithChildren(items);
setData((prev) => {
// Replace current-space nodes with the full tree; keep other spaces intact.
const others = prev.filter((n) => n?.spaceId !== startSpaceId);
return [...others, ...mergeRootTrees(prev.filter((n) => n?.spaceId === startSpaceId), fullTree)];
});
// Open every branch node of the current space.
const branchIds = collectBranchIds(fullTree); // nodes with children
setOpenTreeNodes((prev) => {
const next = { ...prev };
for (const id of branchIds) next[id] = true;
return next;
});
} catch (err) {
// Never swallow: log full error + show the real reason (project convention).
console.error("[tree] expandAll failed", err);
notifications.show({ color: "red",
message: t("Couldn't expand the tree: {{reason}}", { reason: err?.response?.data?.message ?? err?.message ?? String(err) }) });
} finally {
setIsExpanding(false);
}
}, [/* setData, setOpenTreeNodes, t */]);
```
`collapseAll` — снимать раскрытие **только у узлов текущего спейса** (карта общая):
```ts
const collapseAll = useCallback(() => {
// The open-map is shared across spaces; clearing it wholesale would drop
// other spaces' expanded state. Collapse only current-space ids.
const ids = new Set<string>();
const walk = (nodes: SpaceTreeNode[]) => {
for (const n of nodes) { ids.add(n.id); if (n.children?.length) walk(n.children); }
};
walk(filteredData);
setOpenTreeNodes((prev) => {
const next = { ...prev };
for (const id of ids) next[id] = false;
return next;
});
}, [filteredData, setOpenTreeNodes]);
```
`space-sidebar.tsx``const treeRef = useRef<SpaceTreeApi | null>(null)`, передать
в `<SpaceTree ref={treeRef} ... />`, и подвесить команды в шапке. **Без
`canManage`-гейта** — это операция над видом, не над данными.
## UX-развилка по размещению
В шапке уже два значка (`IconDots` меню + `IconPlus` создать). Варианты:
- **(1) Две `ActionIcon`** «развернуть»/«свернуть» (`IconChevronsDown` /
`IconChevronsUp`) → 4 значка в узкой шапке, явно и в один клик.
- **(2) Одна `ActionIcon`-тоггл** развернуть↔свернуть → 3 значка, компактнее, но
состояние менее очевидно.
- **(3) Два `Menu.Item`** в `SpaceMenu` (`Развернуть всё` / `Свернуть всё` +
`Menu.Divider`) → шапка не растёт, но в два клика и менее заметно.
> **Рекомендация:** **(3)** как самый чистый по вёрстке (узкая колонка) либо
> **(1)**, если важна доступность в один клик. Тултипы/`aria-label`:
> `t("Expand all")` / `t("Collapse all")`; во время загрузки — `loading`/
> `disabled` (`isExpanding`).
## Тонкие моменты / edge cases
- **Права в рекурсивном эндпоинте.** Самый важный пункт: повторить **обе** ветки
фильтрации (restricted / открытый спейс) и корректировку `hasChildren` из
`getSidebarPages`. Предпочесть `*ExcludingRestricted` (обрезает закрытые
поддеревья в SQL) + `filterAccessibleTreePages` для per-page прав. На ревью —
тест: пользователь без доступа к ветке не должен видеть её через «развернуть
всё».
- **Размер ответа.** Всё дерево спейса может быть большим. `content` **не**
тянуть (`includeContent: false`). Прикинуть потолок (число узлов) и поведение
при очень больших спейсах — отдавать всё или ограничить + честно сообщить
(конвенция: не молчать про усечение).
- **Скоуп карты раскрытия.** `openTreeNodesAtom` общая для спейсов — и
`expandAll`, и `collapseAll` работают **только по узлам текущего спейса**.
- **Гонки при смене спейса.** Запрос асинхронный; сверяться с
`spaceIdRef.current` и прерывать мёрдж/раскрытие, если спейс сменился (паттерн
уже есть в эффектах `space-tree.tsx`).
- **Мёрдж с уже загруженным.** Полное дерево вмёрджить в `treeDataAtom`, заместив
узлы текущего спейса (`mergeRootTrees`/замена ветки), **не трогая** узлы
других спейсов.
- **Ошибки не глотать.** Любой сбой — `console.error` с полным объектом **и**
уведомление с реальной причиной (`err.response?.data?.message`/`err.message`),
не «что-то пошло не так» (CLAUDE.md «Errors must never be swallowed»).
- **Индикатор.** На крупном спейсе запрос заметный — кнопку в `loading`, чтобы не
было повторных кликов/ощущения зависания.
- **Рост localStorage-карты.** `expandAll` пишет много ключей; для удалённых
страниц ключи «висят». Не критично; уборка карты — отдельная задача.
- **Пустой спейс / одни листья.** Кнопки — no-op; «развернуть» можно `disabled`.
- **Шорткат `*`** (развернуть сиблингов,
[doc-tree.tsx](apps/client/src/features/page/tree/components/doc-tree.tsx)) не
трогаем — дополняем его.
- **Виртуализация.** Дерево на `@tanstack/react-virtual` — раскрытие тысяч строк
рендер не убьёт (рисуются видимые), но резко меняет высоту скролла; проверить,
что позиция/скролл не прыгают.
## Тесты / проверка
- **Сервер:** `pnpm --filter server test` (unit на новый сервисный метод).
Кейсы: открытый спейс (видно всё), restricted-спейс (закрытые ветки и их
поддеревья **не** попадают в ответ), per-page права (`canEdit`), корректный
`hasChildren`, порядок по `position`, `content` не тянется.
- **Клиент:** `pnpm --filter client lint`, `pnpm --filter client test`.
- **Ручная:** глубокий спейс → «развернуть всё» раскрывает все уровни одним
запросом, индикатор работает; «свернуть всё» схлопывает до корней и **не**
теряет состояние другого спейса (переключиться туда-обратно); перезагрузка —
состояние сохраняется (localStorage); смена спейса в середине загрузки —
корректно прерывается; пустой спейс — без поломок; имитация ошибки сети — видно
конкретное уведомление, ошибка залогирована.
## Открытые вопросы
1. **Оформление эндпоинта:** флаг `recursive` к `/pages/sidebar-pages` против
отдельного `/pages/tree`. (Контракт ответа в обоих — плоский список в shape
текущего сайдбара.)
2. **Размещение команд:** две иконки (1) / одна-тоггл (2) / пункты меню (3).
Рекомендация — (3) или (1).
3. **Потолок размера ответа:** отдавать дерево любого размера или ограничить
(число узлов) и как сообщать про усечение.