From 0c7d67fe2a122b8c5f3fd0ddb521ec1eaacbc553 Mon Sep 17 00:00:00 2001 From: "glm5.2 agent 180" Date: Sat, 20 Jun 2026 14:44:25 +0300 Subject: [PATCH 1/2] feat(tree): add Expand all / Collapse all to the space page tree The space sidebar tree loaded children one level at a time, so there was no way to expand the whole tree at once - doing it client-side would mean recursively paging /pages/sidebar-pages hundreds of times. Add a server endpoint that returns the whole space tree in one permission-filtered request, and two menu items in the Space menu that drive it. Server: - POST /pages/tree (reuses SidebarPageDto, CASL Read gate) returns { items: [...] } - a flat list in the same shape as /pages/sidebar-pages (id, slugId, title, icon, position, parentPageId, spaceId, hasChildren, canEdit), sorted by position (collate 'C') then id. - pageRepo.getSpaceDescendants(spaceId, opts): recursive CTE seeded from space roots (parentPageId IS NULL). getSpaceDescendantsExcludingRestricted is also added (space-rooted variant of getPageAnd*ExcludingRestricted) but not used by the read endpoint - see below. - pageService.getSidebarPagesTree: fetches the whole space tree WITHOUT SQL restricted-pruning and lets filterAccessibleTreePages drop inaccessible pages + their subtrees. This mirrors the stepwise /pages/sidebar-pages behavior, so a restricted page the user HAS access to stays visible in expand-all (an earlier draft pruned in SQL and hid them - a behavioural regression). canEdit is per-page in restricted spaces (filterAccessiblePageIdsWithPermissions) and space-wide in open spaces. filterAccessibleTreePages is now public and accepts rootPageId=null to treat every top-level page as a root. - hasChildren is derived in JS (a page hasChildren iff some returned row has it as parentPageId) - O(n), no N subqueries. Client: - SpaceTree is now a forwardRef exposing { expandAll, collapseAll, isExpanding } via useImperativeHandle. expandAll fires one getSpaceTree request, merges the full nested tree into treeDataAtom (replace-and-merge so the authoritative server tree wins over stale partially-loaded roots), opens every branch id of the current space, and guards against space switches via spaceIdRef. Errors are logged AND surfaced in a notification with the real reason - never a generic string. collapseAll clears ONLY current-space ids from the shared open-map (does not disturb other spaces). - isExpanding is lifted to reactive state in SpaceSidebar and passed as a prop to SpaceMenu so the Expand item's disabled state actually updates (a ref mutation alone would not re-render). - Two Menu.Items inside SpaceMenu (Expand all / Collapse all), gated only by read access (not canManage) - these are view operations. --- .../public/locales/en-US/translation.json | 3 + .../features/page/services/page-service.ts | 11 ++ .../page/tree/components/space-tree.tsx | 100 ++++++++++++- .../src/features/page/tree/utils/utils.ts | 30 ++++ .../components/sidebar/space-sidebar.tsx | 39 +++++- apps/server/src/core/page/page.controller.ts | 46 ++++++ .../src/core/page/services/page.service.ts | 131 ++++++++++++++++- .../src/database/repos/page/page.repo.ts | 132 ++++++++++++++++++ 8 files changed, 482 insertions(+), 10 deletions(-) diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 21f7c5f7..6bf69c4c 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -29,9 +29,11 @@ "Choose your preferred color scheme.": "Choose your preferred color scheme.", "Choose your preferred interface language.": "Choose your preferred interface language.", "Choose your preferred page width.": "Choose your preferred page width.", + "Collapse all": "Collapse all", "Confirm": "Confirm", "Copy as Markdown": "Copy as Markdown", "Copy link": "Copy link", + "Couldn't expand the tree: {{reason}}": "Couldn't expand the tree: {{reason}}", "Create": "Create", "Create group": "Create group", "Create page": "Create page", @@ -68,6 +70,7 @@ "Enter your password": "Enter your password", "Error fetching page data.": "Error fetching page data.", "Error loading page history.": "Error loading page history.", + "Expand all": "Expand all", "Export": "Export", "Failed to create page": "Failed to create page", "Failed to delete page": "Failed to delete page", diff --git a/apps/client/src/features/page/services/page-service.ts b/apps/client/src/features/page/services/page-service.ts index 146da7dd..599d3570 100644 --- a/apps/client/src/features/page/services/page-service.ts +++ b/apps/client/src/features/page/services/page-service.ts @@ -69,6 +69,17 @@ export async function getSidebarPages( return req.data; } +// Fetch the whole space tree (or a single page's subtree) in one shot. Used by +// the "Expand all" command. Returns a flat list that buildTreeWithChildren turns +// into the nested tree. Permission-filtered server-side. +export async function getSpaceTree(params: { + spaceId: string; + pageId?: string; +}): Promise { + const req = await api.post<{ items: IPage[] }>("/pages/tree", params); + return req.data.items; +} + export async function getAllSidebarPages( params: SidebarPagesParams, ): Promise, unknown>> { 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..cd23f01c 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, @@ -15,11 +24,16 @@ import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts import { buildTree, buildTreeWithChildren, + collectAllIds, + collectBranchIds, mergeRootTrees, } 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"; @@ -28,9 +42,24 @@ import { SpaceTreeRow } from "./space-tree-row"; interface SpaceTreeProps { spaceId: string; readOnly: boolean; + // Lifted-state notifier: invoked with true at the start of expandAll and + // false in its finally. Lets the parent (SpaceSidebar) re-render and feed + // a reactive `isExpanding` prop to SpaceMenu, so the Expand-all item's + // disabled state updates while the menu is open. treeRef.current?.isExpanding + // alone does NOT trigger a re-render of the menu. + onExpandingChange?: (v: boolean) => void; } -export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) { +export type SpaceTreeApi = { + expandAll: () => Promise; + collapseAll: () => void; + isExpanding: boolean; +}; + +const SpaceTree = forwardRef(function SpaceTree( + { spaceId, readOnly, onExpandingChange }, + ref, +) { const { t } = useTranslation(); const { pageSlug } = useParams(); const [data, setData] = useAtom(treeDataAtom); @@ -43,6 +72,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) { } = useGetRootSidebarPagesQuery({ spaceId }); const [openTreeNodes, setOpenTreeNodes] = useAtom(openTreeNodesAtom); const [isDataLoaded, setIsDataLoaded] = useState(false); + const [isExpanding, setIsExpanding] = useState(false); const spaceIdRef = useRef(spaceId); spaceIdRef.current = spaceId; const { data: currentPage } = usePageQuery({ @@ -186,6 +216,66 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) { [data, spaceId], ); + const expandAll = useCallback(async () => { + const startSpaceId = spaceIdRef.current; + setIsExpanding(true); + onExpandingChange?.(true); + try { + // One request: the entire space tree, permission-filtered server-side. + const items: IPage[] = await getSpaceTree({ spaceId: startSpaceId }); + // Space switched mid-request — drop the result, don't mutate another space. + if (spaceIdRef.current !== startSpaceId) return; + + // IPage[] -> flat SpaceTreeNode[] -> nested tree. + const fullTree = buildTreeWithChildren(buildTree(items)); + setData((prev) => { + // fullTree is the server-authoritative complete tree; pass it first so + // its fully-nested children win, and any current root absent from the + // response is preserved defensively. Other spaces are untouched. + const others = prev.filter((n) => n?.spaceId !== startSpaceId); + const current = prev.filter((n) => n?.spaceId === startSpaceId); + return [...others, ...mergeRootTrees(fullTree, current)]; + }); + + // Open every branch node of the returned tree (leaves need no entry). + 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 the full error AND 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); + onExpandingChange?.(false); + } + }, [setData, setOpenTreeNodes, t, onExpandingChange]); + + 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 = 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). @@ -228,4 +318,6 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) { )} ); -} +}); + +export default SpaceTree; diff --git a/apps/client/src/features/page/tree/utils/utils.ts b/apps/client/src/features/page/tree/utils/utils.ts index 0c42f9b9..5d5c0bad 100644 --- a/apps/client/src/features/page/tree/utils/utils.ts +++ b/apps/client/src/features/page/tree/utils/utils.ts @@ -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; +} 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..46da648a 100644 --- a/apps/client/src/features/space/components/sidebar/space-sidebar.tsx +++ b/apps/client/src/features/space/components/sidebar/space-sidebar.tsx @@ -6,6 +6,8 @@ import { Tooltip, } from "@mantine/core"; import { + IconArrowsMaximize, + IconArrowsMinimize, IconArrowDown, IconDots, IconEye, @@ -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, useState } 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,11 @@ export function SpaceSidebar() { const spaceRules = space?.membership?.permissions; const spaceAbility = useSpaceAbility(spaceRules); const { handleCreate } = useTreeMutation(space?.id ?? ""); + const treeRef = useRef(null); + // Lifted mirror of SpaceTree's internal isExpanding, so SpaceSidebar (and + // SpaceMenu) re-render when expand-all starts/finishes. treeRef.current + // mutations do not trigger re-renders; this state does. + const [isTreeExpanding, setIsTreeExpanding] = useState(false); if (!space) { return <>; @@ -95,6 +104,8 @@ export function SpaceSidebar() { ; + // Reactive (lifted state), NOT treeRef.current?.isExpanding. The latter would + // never update the disabled state while the menu is open because ref mutations + // do not trigger re-renders. + isExpanding: boolean; canManagePages: boolean; onSpaceSettings: () => void; } function SpaceMenu({ spaceId, + treeRef, + isExpanding, canManagePages, onSpaceSettings, }: SpaceMenuProps) { @@ -226,6 +246,21 @@ function SpaceMenu({ {isWatching ? t("Stop watching space") : t("Watch space")} + + treeRef.current?.expandAll()} + leftSection={} + disabled={isExpanding} + > + {t("Expand all")} + + treeRef.current?.collapseAll()} + leftSection={} + > + {t("Collapse all")} + + {canManagePages && ( <> diff --git a/apps/server/src/core/page/page.controller.ts b/apps/server/src/core/page/page.controller.ts index 1f5163bd..c3b3c369 100644 --- a/apps/server/src/core/page/page.controller.ts +++ b/apps/server/src/core/page/page.controller.ts @@ -578,6 +578,52 @@ export class PageController { ); } + @HttpCode(HttpStatus.OK) + @Post('/tree') + async getTree( + @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, + ); + + // Returns the whole space tree (or a single page's subtree when pageId is + // given) as a flat list of sidebar items, permission-filtered. Used by the + // client's "Expand all" command so it can build the full tree in one request + // instead of recursively paging one level at a time. Wrapped in { items } + // for forward-compatible extensions. + const items = await this.pageService.getSidebarPagesTree( + spaceId, + user.id, + spaceCanEdit, + dto.pageId, + ); + return { items }; + } + @HttpCode(HttpStatus.OK) @Post('move-to-space') async movePageToSpace( diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts index cc1dfb24..6cfaed92 100644 --- a/apps/server/src/core/page/services/page.service.ts +++ b/apps/server/src/core/page/services/page.service.ts @@ -1132,12 +1132,16 @@ export class PageService { * 1. The user has access to it * 2. Its parent is also included (or it's the root page) * This ensures that if a middle page is inaccessible, its entire subtree is excluded. + * + * Used by both movePageToSpace (single-root subtree) and the expand-all tree + * endpoint (whole space). When rootPageId is null, every top-level page + * (parentPageId IS NULL) is treated as a root. */ - private async filterAccessibleTreePages< + async filterAccessibleTreePages< T extends { id: string; parentPageId: string | null }, >( pages: T[], - rootPageId: string, + rootPageId: string | null, userId: string, spaceId?: string, ): Promise { @@ -1165,8 +1169,12 @@ export class PageService { if (includedIds.has(page.id)) continue; if (!accessibleSet.has(page.id)) continue; - // Root page: include if accessible - if (page.id === rootPageId) { + // Root page: include if accessible. For the whole-space tree case + // (rootPageId === null), any top-level page counts as a root. + if ( + page.id === rootPageId || + (rootPageId === null && page.parentPageId === null) + ) { includedIds.add(page.id); changed = true; continue; @@ -1182,4 +1190,119 @@ export class PageService { return pages.filter((p) => includedIds.has(p.id)); } + + /** + * Return the whole space tree as a flat list of sidebar items, + * permission-filtered, mirroring the two-branch logic of getSidebarPages. + * Used by the expand-all endpoint so the client can build the full tree in + * one request instead of paging one level at a time. + * + * Always fetches the whole space tree WITHOUT SQL restricted-pruning: the + * regular /pages/sidebar-pages endpoint does not prune restricted pages in + * SQL either, it filters them per-user afterwards. Pruning in SQL (as + * getSpaceDescendantsExcludingRestricted does) would hide restricted pages + * the user HAS access to — a behavioural regression vs the normal sidebar. + * filterAccessibleTreePages below drops inaccessible pages AND their + * subtrees, preserving tree integrity. + * + * pageId is accepted for API symmetry with the single-level endpoint but is + * NOT used in v1: the whole space is always returned. Subtree mode is a + * future enhancement (TODO). + * + * hasChildren is derived from the returned set (a page has children iff + * another returned page lists it as parent), so leaves simply have no + * children in the response — no per-row subquery needed. + */ + async getSidebarPagesTree( + spaceId: string, + userId: string, + spaceCanEdit: boolean, + _pageId?: string, + ): Promise< + Array<{ + id: string; + slugId: string; + title: string; + icon: string; + position: string; + parentPageId: string | null; + spaceId: string; + hasChildren: boolean; + canEdit: boolean; + }> + > { + // Always fetch the whole space tree without SQL restricted-pruning (see + // the JSDoc above for why). filterAccessibleTreePages handles per-user + // access in the restricted branch below. + let pages = await this.pageRepo.getSpaceDescendants(spaceId, { + includeContent: false, + }); + + const hasRestrictions = + await this.pagePermissionRepo.hasRestrictedPagesInSpace(spaceId); + + if (hasRestrictions) { + // Per-user access filter + tree-integrity pruning: a middle page being + // inaccessible excludes its whole subtree. rootPageId=null treats every + // top-level page (parentPageId IS NULL) as a root. + pages = await this.filterAccessibleTreePages(pages, null, userId, spaceId); + + // Per-page canEdit on the accessible set. + const pageIds = pages.map((p) => p.id); + const accessibleWithPerms = + pageIds.length > 0 + ? await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions( + pageIds, + userId, + ) + : []; + const permMap = new Map( + accessibleWithPerms.map((p) => [p.id, p.canEdit]), + ); + pages = pages.map((p) => ({ + ...p, + canEdit: (permMap.get(p.id) ?? false) && spaceCanEdit, + })); + } else { + // Open space: every page is visible to space members, canEdit is the + // space-level edit ability. + pages = pages.map((p) => ({ ...p, canEdit: spaceCanEdit })); + } + + if (pages.length === 0) return []; + + // Derive hasChildren in JS from the returned set (O(n)): a page has + // children iff some returned page lists it as its parent. Avoids N + // withHasChildren subqueries the single-level endpoint uses. + const parentIds = new Set( + pages + .map((p) => p.parentPageId) + .filter((id): id is string => Boolean(id)), + ); + + const items = pages.map((p: any) => ({ + 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: p.canEdit, + })); + + // Sort by position collate "C" asc, then id asc — matches the sidebar's + // cursor-pagination order. Raw string comparison (< / >) matches PG + // collation "C" byte order; localeCompare would NOT (it is locale-aware). + items.sort((a, b) => { + if (a.position < b.position) return -1; + if (a.position > b.position) return 1; + if (a.id < b.id) return -1; + if (a.id > b.id) return 1; + return 0; + }); + + return items; + } } diff --git a/apps/server/src/database/repos/page/page.repo.ts b/apps/server/src/database/repos/page/page.repo.ts index b2884603..f998acf7 100644 --- a/apps/server/src/database/repos/page/page.repo.ts +++ b/apps/server/src/database/repos/page/page.repo.ts @@ -672,4 +672,136 @@ export class PageRepo { .execute() ); } + + // Space-rooted variant of getPageAndDescendants: seeds the recursive CTE from + // the space's root pages (parentPageId IS NULL) instead of a single page, so a + // single request returns the whole space tree. Used by the expand-all endpoint. + 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('parentPageId', 'is', null) + .where('spaceId', '=', spaceId) + .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(); + } + + // Space-rooted variant of getPageAndDescendantsExcludingRestricted: prunes + // restricted subtrees in SQL (stops traversing at restricted pages). Seeds from + // the space's root pages by default, or from a single page when pageId is given + // (single-subtree mode). Used by the expand-all endpoint for spaces that have + // page-level restrictions. + async getSpaceDescendantsExcludingRestricted( + spaceId: string, + pageId: string | null, + opts: { includeContent: boolean }, + ) { + return ( + this.db + .withRecursive('page_hierarchy', (db) => + db + .selectFrom('pages') + .leftJoin('pageAccess', 'pageAccess.pageId', 'pages.id') + .select([ + 'pages.id', + 'pages.slugId', + 'pages.title', + 'pages.icon', + 'pages.position', + 'pages.parentPageId', + 'pages.spaceId', + 'pages.workspaceId', + sql`page_access.id IS NOT NULL`.as('isRestricted'), + ]) + .$if(opts?.includeContent, (qb) => qb.select('pages.content')) + .where((eb) => + pageId + ? eb.and([ + eb('pages.id', '=', pageId), + eb('pages.deletedAt', 'is', null), + ]) + : eb.and([ + eb('pages.parentPageId', 'is', null), + eb('pages.spaceId', '=', spaceId), + eb('pages.deletedAt', 'is', null), + ]), + ) + .unionAll((exp) => + exp + .selectFrom('pages as p') + .innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id') + .leftJoin('pageAccess', 'pageAccess.pageId', 'p.id') + .select([ + 'p.id', + 'p.slugId', + 'p.title', + 'p.icon', + 'p.position', + 'p.parentPageId', + 'p.spaceId', + 'p.workspaceId', + sql`page_access.id IS NOT NULL`.as('isRestricted'), + ]) + .$if(opts?.includeContent, (qb) => qb.select('p.content')) + .where('p.deletedAt', 'is', null) + // Only recurse into children of non-restricted pages + .where('ph.isRestricted', '=', false), + ), + ) + .selectFrom('page_hierarchy') + .select([ + 'id', + 'slugId', + 'title', + 'icon', + 'position', + 'parentPageId', + 'spaceId', + 'workspaceId', + ]) + .$if(opts?.includeContent, (qb) => qb.select('content')) + // Filter out restricted pages from the result + .where('isRestricted', '=', false) + .execute() + ); + } } -- 2.49.1 From 9d18e20804416871d011319889acfa7ceab89502 Mon Sep 17 00:00:00 2001 From: "glm5.2 agent 180" Date: Sat, 20 Jun 2026 14:45:10 +0300 Subject: [PATCH 2/2] docs: remove implemented tree-expand-collapse-all backlog plan --- docs/backlog/tree-expand-collapse-all.md | 301 ----------------------- 1 file changed, 301 deletions(-) delete mode 100644 docs/backlog/tree-expand-collapse-all.md diff --git a/docs/backlog/tree-expand-collapse-all.md b/docs/backlog/tree-expand-collapse-all.md deleted file mode 100644 index 0fce6da1..00000000 --- a/docs/backlog/tree-expand-collapse-all.md +++ /dev/null @@ -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` (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 { - 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; - 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(); - 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(null)`, передать -в ``, и подвесить команды в шапке. **Без -`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. **Потолок размера ответа:** отдавать дерево любого размера или ограничить - (число узлов) и как сообщать про усечение. -- 2.49.1