From b81819ef6388c0885ca3460f9f9737e7dd90f430 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sat, 20 Jun 2026 05:31:34 +0300 Subject: [PATCH] feat(tree): Expand all / Collapse all for the space page tree Adds a server-authoritative whole-tree endpoint and sidebar menu commands so a deep space tree can be expanded in one request instead of a per-level BFS storm. Server: - POST /pages/tree (SidebarPageTreeDto: spaceId | pageId), same CASL space scoping as /sidebar-pages. Returns the whole space tree / subtree as a flat list in the sidebar item shape (id, slugId, title, icon, position, parentPageId, spaceId, hasChildren, canEdit), ordered by position (collate C byte order), content never fetched. - page.service.getSidebarPagesTree reproduces getSidebarPages' two-branch permission model: open space -> spaceCanEdit; restricted space -> seed the full descendant set then prune via filterAccessibleTreePages + filterAccessiblePageIdsWithPermissions (keeps restricted-but-granted pages, prunes inaccessible subtrees). hasChildren is derived from the final filtered set so it can never reveal inaccessible children. - page.repo.getSpaceDescendants: recursive CTE seeded by space roots. Client: - SpaceTree is forwardRef exposing expandAll/collapseAll/isExpanding; expandAll fetches the whole tree once, replaces current-space nodes, opens every branch (current space only), aborts on space switch, surfaces real errors; collapseAll collapses only current-space ids (shared open-map). - SpaceMenu gains Expand all / Collapse all items (no admin gate). Implements docs/backlog/tree-expand-collapse-all.md. Co-Authored-By: Claude Opus 4.8 --- .../public/locales/en-US/translation.json | 3 + .../features/page/services/page-service.ts | 8 + .../page/tree/components/space-tree.tsx | 106 ++++++++++- .../components/sidebar/space-sidebar.tsx | 45 ++++- .../src/core/page/dto/sidebar-page.dto.ts | 10 + apps/server/src/core/page/page.controller.ts | 45 ++++- .../src/core/page/services/page.service.ts | 132 ++++++++++++- .../page/services/sidebar-pages-tree.spec.ts | 179 ++++++++++++++++++ .../src/database/repos/page/page.repo.ts | 54 ++++++ 9 files changed, 573 insertions(+), 9 deletions(-) create mode 100644 apps/server/src/core/page/services/sidebar-pages-tree.spec.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 21f7c5f7..c2c8255e 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -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", diff --git a/apps/client/src/features/page/services/page-service.ts b/apps/client/src/features/page/services/page-service.ts index 146da7dd..6434ec7c 100644 --- a/apps/client/src/features/page/services/page-service.ts +++ b/apps/client/src/features/page/services/page-service.ts @@ -92,6 +92,14 @@ export async function getAllSidebarPages( }; } +export async function getSpaceTree(params: { + spaceId: string; + pageId?: string; +}): Promise { + const req = await api.post("/pages/tree", params); + return req.data.items; +} + export async function getPageBreadcrumbs( pageId: string, ): Promise> { diff --git a/apps/client/src/features/page/tree/components/space-tree.tsx b/apps/client/src/features/page/tree/components/space-tree.tsx index 1c3aab8e..e3e339c9 100644 --- a/apps/client/src/features/page/tree/components/space-tree.tsx +++ b/apps/client/src/features/page/tree/components/space-tree.tsx @@ -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, @@ -19,7 +28,10 @@ import { } 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 { DocTree } from "./doc-tree"; @@ -30,10 +42,20 @@ interface SpaceTreeProps { readOnly: boolean; } -export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) { +export type SpaceTreeApi = { + expandAll: () => Promise; + collapseAll: () => void; + isExpanding: boolean; +}; + +const SpaceTree = forwardRef(function SpaceTree( + { spaceId, readOnly }, + ref, +) { const { t } = useTranslation(); const { pageSlug } = useParams(); const [data, setData] = useAtom(treeDataAtom); + const [isExpanding, setIsExpanding] = useState(false); const { handleMove } = useTreeMutation(spaceId); const { data: pagesData, @@ -186,6 +208,80 @@ 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: string[] = []; + const collectBranchIds = (nodes: SpaceTreeNode[]) => { + for (const n of nodes) { + if (n.children && n.children.length > 0) { + branchIds.push(n.id); + collectBranchIds(n.children); + } + } + }; + 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 = new Set(); + 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]); + + 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). @@ -228,4 +324,6 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) { )} ); -} +}); + +export default SpaceTree; diff --git a/apps/client/src/features/space/components/sidebar/space-sidebar.tsx b/apps/client/src/features/space/components/sidebar/space-sidebar.tsx index 1786d84e..9ee98a50 100644 --- a/apps/client/src/features/space/components/sidebar/space-sidebar.tsx +++ b/apps/client/src/features/space/components/sidebar/space-sidebar.tsx @@ -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(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() {
void; + treeRef: React.RefObject; } function SpaceMenu({ spaceId, canManagePages, onSpaceSettings, + treeRef, }: SpaceMenuProps) { const { t } = useTranslation(); + const [isExpanding, setIsExpanding] = React.useState(false); + + const handleExpandAll = async () => { + setIsExpanding(true); + try { + await treeRef.current?.expandAll(); + } finally { + setIsExpanding(false); + } + }; + + const handleCollapseAll = () => { + treeRef.current?.collapseAll(); + }; const { spaceSlug } = useParams(); const [importOpened, { open: openImportModal, close: closeImportModal }] = useDisclosure(false); @@ -201,6 +224,24 @@ function SpaceMenu({ + } + > + {t("Expand all")} + + + } + > + {t("Collapse all")} + + + + ( pages: T[], - rootPageId: string, + rootPageId: string | null, userId: string, spaceId?: string, ): Promise { @@ -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(); @@ -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 | 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(); + 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; + } } diff --git a/apps/server/src/core/page/services/sidebar-pages-tree.spec.ts b/apps/server/src/core/page/services/sidebar-pages-tree.spec.ts new file mode 100644 index 00000000..0c3a43c9 --- /dev/null +++ b/apps/server/src/core/page/services/sidebar-pages-tree.spec.ts @@ -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; + }, +) { + const parentIds = new Set(); + 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(), + ); + }); +}); diff --git a/apps/server/src/database/repos/page/page.repo.ts b/apps/server/src/database/repos/page/page.repo.ts index b2884603..639cf57b 100644 --- a/apps/server/src/database/repos/page/page.repo.ts +++ b/apps/server/src/database/repos/page/page.repo.ts @@ -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(); + } }