From f7b99f9fb3f30aac583d229fcc40505c431c9b75 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Wed, 24 Jun 2026 06:13:09 +0300 Subject: [PATCH 1/2] feat(editor): recursive tree mode for the subpages node (#150) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `subpages` node showed only one level of direct children. Add a `recursive` attribute that renders the FULL descendant tree of the current page — fully expanded, unlimited depth. Default `false`, so every previously-inserted node stays flat (backward compatible). No backend changes: `POST /pages/tree` (via the `getSpaceTree` wrapper) already returns the whole subtree as a flat `IPage[]` (recursive CTE, permission-filtered); the nested tree is built on the client by `parentPageId`. - editor-ext `subpages.ts`: `recursive` attribute (parse/render `data-recursive`), shared by client + server so the collab ProseMirror schema keeps the attribute. - `getSpaceTree`: arg loosened to `{ spaceId?; pageId? }` (the endpoint accepts either); new `useGetPageTreeQuery(pageId)` react-query hook. - `subpages-view.tsx`: split into `FlatSubpages` (unchanged) and `RecursiveSubpages`; `buildSubtree` assembles the nested tree (cycle/self-parent guard, `sortPositionKeys` per level, root excluded) and a recursive `TreeNode` renders it (16px indent per depth, soft "showing N" note past 300 — data never capped). Shared/public context reads the already-nested shared tree, no `/pages/tree` request. - toggles: bubble-menu flat⇄tree button + a second slash-menu item "Page tree". Review follow-ups folded in: invalidate `["page-tree"]` from the create / update / move / delete cache helpers so an open recursive tree refreshes (no stale data); mode icon made reactive on editor transactions; `t` threaded into `TreeNode` (no per-node useTranslation); shared-subtree hook deduped to a thin alias. editor-ext build + client `tsc --noEmit` both clean. Backend untouched. Co-Authored-By: Claude Opus 4.8 --- .../components/slash-menu/menu-items.ts | 23 ++ .../components/subpages/subpages-menu.tsx | 76 ++++- .../components/subpages/subpages-view.tsx | 260 +++++++++++++++++- .../src/features/page/queries/page-query.ts | 24 ++ .../features/page/services/page-service.ts | 2 +- .../share/hooks/use-shared-page-subpages.ts | 8 + .../editor-ext/src/lib/subpages/subpages.ts | 16 +- 7 files changed, 392 insertions(+), 17 deletions(-) diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.ts index f73bb9ab..48ca94d5 100644 --- a/apps/client/src/features/editor/components/slash-menu/menu-items.ts +++ b/apps/client/src/features/editor/components/slash-menu/menu-items.ts @@ -524,6 +524,29 @@ const CommandGroups: SlashMenuGroupedItemsType = { editor.chain().focus().deleteRange(range).insertSubpages().run(); }, }, + { + title: "Page tree (child pages, recursive)", + description: "Render the full nested tree of all descendant pages", + searchTerms: [ + "subpages", + "child", + "children", + "nested", + "hierarchy", + "tree", + "recursive", + "toc", + ], + icon: IconSitemap, + command: ({ editor, range }: CommandProps) => { + editor + .chain() + .focus() + .deleteRange(range) + .insertSubpages({ recursive: true }) + .run(); + }, + }, { title: "Synced block", description: "Create a block that stays in sync across pages.", diff --git a/apps/client/src/features/editor/components/subpages/subpages-menu.tsx b/apps/client/src/features/editor/components/subpages/subpages-menu.tsx index a626e1ee..51af1edd 100644 --- a/apps/client/src/features/editor/components/subpages/subpages-menu.tsx +++ b/apps/client/src/features/editor/components/subpages/subpages-menu.tsx @@ -1,9 +1,9 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { posToDOMRect, findParentNode } from "@tiptap/react"; import { Node as PMNode } from "@tiptap/pm/model"; -import React, { useCallback } from "react"; -import { ActionIcon, Tooltip } from "@mantine/core"; -import { IconTrash } from "@tabler/icons-react"; +import React, { useCallback, useEffect, useState } from "react"; +import { ActionIcon, Group, Tooltip } from "@mantine/core"; +import { IconTrash, IconList, IconSitemap } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { Editor } from "@tiptap/core"; import { isEditorReady } from "@docmost/editor-ext"; @@ -47,6 +47,13 @@ export const SubpagesMenu = React.memo( return posToDOMRect(editor.view, selection.from, selection.to); }, [editor]); + const toggleRecursive = useCallback(() => { + const current = editor.getAttributes("subpages")?.recursive ?? false; + editor.commands.updateAttributes("subpages", { + recursive: !current, + }); + }, [editor]); + const deleteNode = useCallback(() => { const { selection } = editor.state; editor @@ -57,6 +64,25 @@ export const SubpagesMenu = React.memo( .run(); }, [editor]); + // The component is memoized on `editor` (a stable reference), so reading the + // attribute at render time would leave the mode icon/tooltip stale right + // after toggling. Track it in state synced on editor transactions; setState + // bails when the value is unchanged, so this does not re-render per keystroke. + const [isRecursive, setIsRecursive] = useState( + () => editor.getAttributes("subpages")?.recursive ?? false, + ); + useEffect(() => { + const sync = () => { + const value = editor.getAttributes("subpages")?.recursive ?? false; + setIsRecursive((prev) => (prev === value ? prev : value)); + }; + sync(); + editor.on("transaction", sync); + return () => { + editor.off("transaction", sync); + }; + }, [editor]); + return ( - - + - - - + + {isRecursive ? ( + + ) : ( + + )} + + + + + + + + + ); } diff --git a/apps/client/src/features/editor/components/subpages/subpages-view.tsx b/apps/client/src/features/editor/components/subpages/subpages-view.tsx index cfea74e1..bff057d3 100644 --- a/apps/client/src/features/editor/components/subpages/subpages-view.tsx +++ b/apps/client/src/features/editor/components/subpages/subpages-view.tsx @@ -1,7 +1,10 @@ import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; import { Stack, Text, Anchor, ActionIcon } from "@mantine/core"; import { IconFileDescription } from "@tabler/icons-react"; -import { useGetSidebarPagesQuery } from "@/features/page/queries/page-query"; +import { + useGetSidebarPagesQuery, + useGetPageTreeQuery, +} from "@/features/page/queries/page-query"; import { useMemo } from "react"; import { Link, useParams } from "react-router-dom"; import classes from "./subpages.module.css"; @@ -12,16 +15,194 @@ import { } from "@/features/page/page.utils.ts"; import { useTranslation } from "react-i18next"; import { sortPositionKeys } from "@/features/page/tree/utils/utils"; -import { useSharedPageSubpages } from "@/features/share/hooks/use-shared-page-subpages"; +import { + useSharedPageSubpages, + useSharedPageSubtree, +} from "@/features/share/hooks/use-shared-page-subpages"; +import { IPage } from "@/features/page/types/page.types"; +import { SharedPageTreeNode } from "@/features/share/utils"; + +// Threshold above which the recursive tree shows a small count note. We never +// cap the data — this is only an informational hint for very large trees. +const LARGE_TREE_THRESHOLD = 300; + +// Normalized node shared by the flat and recursive renderers so the same +// link/icon markup works for both API pages and shared-tree nodes. +interface SubpageNode { + id: string; + slugId: string; + title: string; + icon?: string; + children: SubpageNode[]; +} + +// Subpage node carrying `position` so each level can be sorted in place. +type SubpageNodeWithPos = SubpageNode & { + position: string; + children: SubpageNodeWithPos[]; +}; + +// Build a nested subtree from the flat IPage[] returned by /pages/tree. +function buildSubtree(pages: IPage[], rootId: string): SubpageNode[] { + const byId = new Map( + pages.map((p) => [ + p.id, + { + id: p.id, + slugId: p.slugId, + title: p.title, + icon: p.icon, + position: p.position, + children: [], + }, + ]), + ); + + for (const p of pages) { + const node = byId.get(p.id); + const parent = p.parentPageId ? byId.get(p.parentPageId) : undefined; + // Guard against cycles / self-parenting: never attach a node to itself or + // to the root, and only attach when the parent is actually present. + if (node && parent && p.id !== rootId) { + parent.children.push(node); + } + } + + const sortRecursive = (nodes: SubpageNodeWithPos[]) => { + const sorted = sortPositionKeys(nodes) as SubpageNodeWithPos[]; + sorted.forEach((n) => sortRecursive(n.children)); + return sorted; + }; + + const root = byId.get(rootId); + return root ? sortRecursive(root.children) : []; +} + +// Map shared-tree nodes (already nested) onto the normalized SubpageNode shape. +function mapSharedNodes(nodes: SharedPageTreeNode[]): SubpageNode[] { + return nodes.map((node) => ({ + id: node.value, + slugId: node.slugId, + title: node.name, + icon: node.icon, + children: node.children ? mapSharedNodes(node.children) : [], + })); +} + +// Count every descendant in a normalized subtree. +function countNodes(nodes: SubpageNode[]): number { + return nodes.reduce((acc, n) => acc + 1 + countNodes(n.children), 0); +} + +interface TreeNodeProps { + node: SubpageNode; + depth: number; + shareId?: string; + spaceSlug?: string; + // Threaded down from the variant component so a large tree does not create one + // i18n subscription (useTranslation) per rendered node. + t: (key: string) => string; +} + +// Recursive renderer for a single node and its descendants. Indents each level +// by depth * 16px and reuses the same link/icon markup as the flat list. +function TreeNode({ node, depth, shareId, spaceSlug, t }: TreeNodeProps) { + return ( + <> + + {node?.icon ? ( + {node.icon} + ) : ( + + + + )} + + + {node?.title || t("untitled")} + + + + {node.children.map((child) => ( + + ))} + + ); +} export default function SubpagesView(props: NodeViewProps) { const { editor } = props; const { spaceSlug, shareId } = useParams(); const { t } = useTranslation(); + const recursive: boolean = props.node.attrs.recursive ?? false; + //@ts-ignore const currentPageId = editor.storage.pageId; + if (recursive) { + return ( + + ); + } + + return ( + + ); +} + +interface SubpagesVariantProps { + currentPageId: string; + shareId?: string; + spaceSlug?: string; + t: (key: string) => string; +} + +function FlatSubpages({ + currentPageId, + shareId, + spaceSlug, + t, +}: SubpagesVariantProps) { // Get subpages from shared tree if we're in a shared context const sharedSubpages = useSharedPageSubpages(currentPageId); @@ -119,3 +300,78 @@ export default function SubpagesView(props: NodeViewProps) { ); } + +function RecursiveSubpages({ + currentPageId, + shareId, + spaceSlug, + t, +}: SubpagesVariantProps) { + // In a shared/public context reuse the already-loaded nested shared tree + // instead of issuing a /pages/tree request. + const sharedSubtree = useSharedPageSubtree(currentPageId); + + const { data, isLoading, error } = useGetPageTreeQuery( + shareId ? "" : currentPageId, + ); + + const tree = useMemo(() => { + if (shareId) { + return mapSharedNodes(sharedSubtree); + } + if (!data) return []; + return buildSubtree(data, currentPageId); + }, [data, shareId, sharedSubtree, currentPageId]); + + const total = useMemo(() => countNodes(tree), [tree]); + + if (isLoading && !shareId) { + return null; + } + + if (error && !shareId) { + return ( + + + {t("Failed to load subpages")} + + + ); + } + + if (tree.length === 0) { + return ( + +
+ + {t("No subpages")} + +
+
+ ); + } + + return ( + +
+ + {tree.map((node) => ( + + ))} + + {total > LARGE_TREE_THRESHOLD && ( + + {t("Showing")} {total} {t("subpages")} + + )} +
+
+ ); +} diff --git a/apps/client/src/features/page/queries/page-query.ts b/apps/client/src/features/page/queries/page-query.ts index eaa871f5..4e279621 100644 --- a/apps/client/src/features/page/queries/page-query.ts +++ b/apps/client/src/features/page/queries/page-query.ts @@ -21,6 +21,7 @@ import { getAllSidebarPages, getDeletedPages, restorePage, + getSpaceTree, } from "@/features/page/services/page-service"; import { IMovePage, @@ -303,6 +304,15 @@ export function useGetRootSidebarPagesQuery(data: SidebarPagesParams) { }); } +export function useGetPageTreeQuery(pageId: string) { + return useQuery({ + queryKey: ["page-tree", pageId], + queryFn: () => getSpaceTree({ pageId }), + enabled: !!pageId, + staleTime: 30 * 1000, + }); +} + export function usePageBreadcrumbsQuery( pageId: string, ): UseQueryResult, Error> { @@ -363,7 +373,18 @@ export function useDeletedPagesQuery( }); } +/** + * Invalidate every cached page-subtree (the recursive `subpages` node, issue + * #150). Called from each tree-structure cache helper below so a create / move / + * rename / delete (local OR websocket-echoed) refreshes any open recursive tree. + * Keyed loosely (`["page-tree"]` prefix) so all subtrees are caught. + */ +function invalidatePageTree() { + queryClient.invalidateQueries({ queryKey: ["page-tree"] }); +} + export function invalidateOnCreatePage(data: Partial) { + invalidatePageTree(); const newPage: Partial = { creatorId: data.creatorId, hasChildren: data.hasChildren, @@ -478,6 +499,7 @@ export function invalidateOnUpdatePage( title: string, icon: string, ) { + invalidatePageTree(); let queryKey: QueryKey = null; if (parentPageId === null) { queryKey = ["root-sidebar-pages", spaceId]; @@ -516,6 +538,7 @@ export function updateCacheOnMovePage( newParentId: string | null, pageData: Partial, ) { + invalidatePageTree(); // Remove page from old parent's cache const oldQueryKey = oldParentId === null @@ -633,6 +656,7 @@ export function updateCacheOnMovePage( } export function invalidateOnDeletePage(pageId: string) { + invalidatePageTree(); //update all sidebar pages const allSideBarMatches = queryClient.getQueriesData({ predicate: (query) => diff --git a/apps/client/src/features/page/services/page-service.ts b/apps/client/src/features/page/services/page-service.ts index 47b30fb7..ccae40c6 100644 --- a/apps/client/src/features/page/services/page-service.ts +++ b/apps/client/src/features/page/services/page-service.ts @@ -93,7 +93,7 @@ export async function getAllSidebarPages( } export async function getSpaceTree(params: { - spaceId: string; + spaceId?: string; pageId?: string; }): Promise { const req = await api.post<{ items: IPage[] }>("/pages/tree", params); diff --git a/apps/client/src/features/share/hooks/use-shared-page-subpages.ts b/apps/client/src/features/share/hooks/use-shared-page-subpages.ts index 6112d3fd..a9918218 100644 --- a/apps/client/src/features/share/hooks/use-shared-page-subpages.ts +++ b/apps/client/src/features/share/hooks/use-shared-page-subpages.ts @@ -27,3 +27,11 @@ export function useSharedPageSubpages(pageId: string | undefined) { return findSubpages(treeData); }, [treeData, pageId]); } + +// Recursive variant for the subpages node in a shared/public context. The shared +// tree (`sharedTreeDataAtom`) is ALREADY fully nested, so a page's `children` +// each carry their own nested `children` — exactly what the recursive renderer +// needs. The data is therefore identical to the flat hook; only the rendering +// differs (the recursive view walks `children` instead of showing one level). +// Thin alias to avoid duplicating the lookup. No `/pages/tree` request here. +export const useSharedPageSubtree = useSharedPageSubpages; diff --git a/packages/editor-ext/src/lib/subpages/subpages.ts b/packages/editor-ext/src/lib/subpages/subpages.ts index 6f5c1062..c7729d53 100644 --- a/packages/editor-ext/src/lib/subpages/subpages.ts +++ b/packages/editor-ext/src/lib/subpages/subpages.ts @@ -6,7 +6,9 @@ export interface SubpagesOptions { view: any; } -export interface SubpagesAttributes {} +export interface SubpagesAttributes { + recursive?: boolean; +} declare module "@tiptap/core" { interface Commands { @@ -31,6 +33,18 @@ export const Subpages = Node.create({ draggable: true, isolating: true, + addAttributes() { + return { + recursive: { + // Existing nodes stay flat -> backward compatible. + default: false, + parseHTML: (el) => el.getAttribute("data-recursive") === "true", + renderHTML: (attrs) => + attrs.recursive ? { "data-recursive": "true" } : {}, + }, + }; + }, + parseHTML() { return [ { From 623c89554a75bfc9ece57384e947f2be5fdbea05 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Wed, 24 Jun 2026 14:32:11 +0300 Subject: [PATCH 2/2] refactor(subpages): address PR #155 review - Extract buildSubtree/mapSharedNodes/countNodes/SubpageNode into subpages-view.utils.ts with a unit test (subpages-view.utils.test.ts) covering nesting, position order, missing/unreachable parent, self-parent guard, empty input, countNodes and mapSharedNodes remap. - Replace the manual useState + editor.on("transaction") subscription in subpages-menu.tsx with useEditorState (the idiom the sibling bubble menus use), so the mode icon/tooltip track the live recursive attribute without re-rendering on every keystroke. - i18n: add the 6 menu/tree strings and a pluralized "Showing {{count}} subpages" key to en-US and ru-RU. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../public/locales/en-US/translation.json | 9 +- .../public/locales/ru-RU/translation.json | 10 +- .../components/subpages/subpages-menu.tsx | 30 ++--- .../components/subpages/subpages-view.tsx | 80 ++---------- .../subpages/subpages-view.utils.test.ts | 114 ++++++++++++++++++ .../subpages/subpages-view.utils.ts | 83 +++++++++++++ 6 files changed, 232 insertions(+), 94 deletions(-) create mode 100644 apps/client/src/features/editor/components/subpages/subpages-view.utils.test.ts create mode 100644 apps/client/src/features/editor/components/subpages/subpages-view.utils.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 0a6a8d44..b622087b 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1300,5 +1300,12 @@ "Go to login page": "Go to login page", "Move to space": "Move to space", "Float left (wrap text)": "Float left (wrap text)", - "Float right (wrap text)": "Float right (wrap text)" + "Float right (wrap text)": "Float right (wrap text)", + "Switch to tree": "Switch to tree", + "Switch to flat list": "Switch to flat list", + "Toggle subpages display mode": "Toggle subpages display mode", + "Page tree (child pages, recursive)": "Page tree (child pages, recursive)", + "Render the full nested tree of all descendant pages": "Render the full nested tree of all descendant pages", + "Showing {{count}} subpages_one": "Showing {{count}} subpage", + "Showing {{count}} subpages_other": "Showing {{count}} subpages" } diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index 3659a1a9..038323a8 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -1152,5 +1152,13 @@ "Auto-detect": "Автоопределение", "Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью.", "Float left (wrap text)": "Обтекание слева", - "Float right (wrap text)": "Обтекание справа" + "Float right (wrap text)": "Обтекание справа", + "Switch to tree": "Переключить на дерево", + "Switch to flat list": "Переключить на плоский список", + "Toggle subpages display mode": "Переключить режим отображения подстраниц", + "Page tree (child pages, recursive)": "Дерево страниц (дочерние, рекурсивно)", + "Render the full nested tree of all descendant pages": "Показать полное вложенное дерево всех дочерних страниц", + "Showing {{count}} subpages_one": "Показано {{count}} подстраница", + "Showing {{count}} subpages_few": "Показано {{count}} подстраницы", + "Showing {{count}} subpages_many": "Показано {{count}} подстраниц" } diff --git a/apps/client/src/features/editor/components/subpages/subpages-menu.tsx b/apps/client/src/features/editor/components/subpages/subpages-menu.tsx index 51af1edd..05f05ea6 100644 --- a/apps/client/src/features/editor/components/subpages/subpages-menu.tsx +++ b/apps/client/src/features/editor/components/subpages/subpages-menu.tsx @@ -1,7 +1,7 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; -import { posToDOMRect, findParentNode } from "@tiptap/react"; +import { posToDOMRect, findParentNode, useEditorState } from "@tiptap/react"; import { Node as PMNode } from "@tiptap/pm/model"; -import React, { useCallback, useEffect, useState } from "react"; +import React, { useCallback } from "react"; import { ActionIcon, Group, Tooltip } from "@mantine/core"; import { IconTrash, IconList, IconSitemap } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; @@ -64,24 +64,14 @@ export const SubpagesMenu = React.memo( .run(); }, [editor]); - // The component is memoized on `editor` (a stable reference), so reading the - // attribute at render time would leave the mode icon/tooltip stale right - // after toggling. Track it in state synced on editor transactions; setState - // bails when the value is unchanged, so this does not re-render per keystroke. - const [isRecursive, setIsRecursive] = useState( - () => editor.getAttributes("subpages")?.recursive ?? false, - ); - useEffect(() => { - const sync = () => { - const value = editor.getAttributes("subpages")?.recursive ?? false; - setIsRecursive((prev) => (prev === value ? prev : value)); - }; - sync(); - editor.on("transaction", sync); - return () => { - editor.off("transaction", sync); - }; - }, [editor]); + // Subscribe to the live `recursive` attribute the standard way (as the + // sibling bubble menus do): useEditorState re-renders only when the selected + // value actually changes, so the mode icon/tooltip stay current after a + // toggle without re-rendering on every keystroke. + const isRecursive = useEditorState({ + editor, + selector: (ctx) => ctx.editor?.getAttributes("subpages")?.recursive ?? false, + }); return ( ( - pages.map((p) => [ - p.id, - { - id: p.id, - slugId: p.slugId, - title: p.title, - icon: p.icon, - position: p.position, - children: [], - }, - ]), - ); - - for (const p of pages) { - const node = byId.get(p.id); - const parent = p.parentPageId ? byId.get(p.parentPageId) : undefined; - // Guard against cycles / self-parenting: never attach a node to itself or - // to the root, and only attach when the parent is actually present. - if (node && parent && p.id !== rootId) { - parent.children.push(node); - } - } - - const sortRecursive = (nodes: SubpageNodeWithPos[]) => { - const sorted = sortPositionKeys(nodes) as SubpageNodeWithPos[]; - sorted.forEach((n) => sortRecursive(n.children)); - return sorted; - }; - - const root = byId.get(rootId); - return root ? sortRecursive(root.children) : []; -} - -// Map shared-tree nodes (already nested) onto the normalized SubpageNode shape. -function mapSharedNodes(nodes: SharedPageTreeNode[]): SubpageNode[] { - return nodes.map((node) => ({ - id: node.value, - slugId: node.slugId, - title: node.name, - icon: node.icon, - children: node.children ? mapSharedNodes(node.children) : [], - })); -} - -// Count every descendant in a normalized subtree. -function countNodes(nodes: SubpageNode[]): number { - return nodes.reduce((acc, n) => acc + 1 + countNodes(n.children), 0); -} - interface TreeNodeProps { node: SubpageNode; depth: number; @@ -194,7 +130,7 @@ interface SubpagesVariantProps { currentPageId: string; shareId?: string; spaceSlug?: string; - t: (key: string) => string; + t: (key: string, options?: Record) => string; } function FlatSubpages({ @@ -368,7 +304,7 @@ function RecursiveSubpages({ {total > LARGE_TREE_THRESHOLD && ( - {t("Showing")} {total} {t("subpages")} + {t("Showing {{count}} subpages", { count: total })} )} diff --git a/apps/client/src/features/editor/components/subpages/subpages-view.utils.test.ts b/apps/client/src/features/editor/components/subpages/subpages-view.utils.test.ts new file mode 100644 index 00000000..17760f47 --- /dev/null +++ b/apps/client/src/features/editor/components/subpages/subpages-view.utils.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect } from "vitest"; +import { + buildSubtree, + countNodes, + mapSharedNodes, + SubpageNode, +} from "./subpages-view.utils"; +import { IPage } from "@/features/page/types/page.types"; + +// Minimal IPage fixture — buildSubtree only reads id/slugId/title/icon/position/ +// parentPageId. `position` keys are fractional-indexing strings (lexicographic). +const page = (p: Partial & { id: string }): IPage => + ({ + slugId: `slug-${p.id}`, + title: `Title ${p.id}`, + icon: undefined, + position: "a0", + parentPageId: null, + ...p, + }) as IPage; + +const ids = (nodes: SubpageNode[]): string[] => nodes.map((n) => n.id); + +describe("buildSubtree", () => { + it("nests children under the root and excludes the root itself", () => { + const pages = [ + page({ id: "root" }), + page({ id: "a", parentPageId: "root", position: "a0" }), + page({ id: "b", parentPageId: "root", position: "a1" }), + page({ id: "a1", parentPageId: "a", position: "a0" }), + ]; + const tree = buildSubtree(pages, "root"); + // Root is not rendered; only its descendants. + expect(ids(tree)).toEqual(["a", "b"]); + expect(ids(tree[0].children)).toEqual(["a1"]); + expect(tree[1].children).toEqual([]); + }); + + it("sorts each level by position", () => { + const pages = [ + page({ id: "root" }), + page({ id: "z", parentPageId: "root", position: "a2" }), + page({ id: "x", parentPageId: "root", position: "a0" }), + page({ id: "y", parentPageId: "root", position: "a1" }), + ]; + expect(ids(buildSubtree(pages, "root"))).toEqual(["x", "y", "z"]); + }); + + it("returns [] when the root is absent from the page set", () => { + const pages = [page({ id: "a", parentPageId: "missing-root" })]; + expect(buildSubtree(pages, "missing-root")).toEqual([]); + }); + + it("silently drops a node whose parent is absent (unreachable parent)", () => { + const pages = [ + page({ id: "root" }), + page({ id: "ok", parentPageId: "root" }), + page({ id: "orphan", parentPageId: "ghost" }), // parent not in the set + ]; + expect(ids(buildSubtree(pages, "root"))).toEqual(["ok"]); + }); + + it("guards against self-parenting / attaching the root", () => { + const pages = [ + // A (defensive) self-parented root must not attach to itself. + page({ id: "root", parentPageId: "root" }), + page({ id: "a", parentPageId: "root" }), + ]; + const tree = buildSubtree(pages, "root"); + expect(ids(tree)).toEqual(["a"]); + }); + + it("returns [] for empty input", () => { + expect(buildSubtree([], "root")).toEqual([]); + }); +}); + +describe("countNodes", () => { + it("counts every descendant across all levels", () => { + const tree: SubpageNode[] = [ + { + id: "a", + slugId: "s", + title: "A", + children: [ + { id: "a1", slugId: "s", title: "A1", children: [] }, + { id: "a2", slugId: "s", title: "A2", children: [] }, + ], + }, + { id: "b", slugId: "s", title: "B", children: [] }, + ]; + expect(countNodes(tree)).toBe(4); + expect(countNodes([])).toBe(0); + }); +}); + +describe("mapSharedNodes", () => { + it("remaps value->id / name->title and keeps nested children", () => { + const shared = [ + { + value: "p1", + slugId: "s1", + name: "Parent", + icon: "📁", + children: [ + { value: "c1", slugId: "sc1", name: "Child", children: [] }, + ], + }, + ] as any; + const mapped = mapSharedNodes(shared); + expect(mapped[0]).toMatchObject({ id: "p1", slugId: "s1", title: "Parent", icon: "📁" }); + expect(mapped[0].children[0]).toMatchObject({ id: "c1", title: "Child" }); + }); +}); diff --git a/apps/client/src/features/editor/components/subpages/subpages-view.utils.ts b/apps/client/src/features/editor/components/subpages/subpages-view.utils.ts new file mode 100644 index 00000000..97843600 --- /dev/null +++ b/apps/client/src/features/editor/components/subpages/subpages-view.utils.ts @@ -0,0 +1,83 @@ +import { sortPositionKeys } from "@/features/page/tree/utils/utils"; +import { IPage } from "@/features/page/types/page.types"; +import { SharedPageTreeNode } from "@/features/share/utils"; + +// Normalized node shared by the flat and recursive subpages renderers so the +// same link/icon markup works for both API pages and shared-tree nodes. +export interface SubpageNode { + id: string; + slugId: string; + title: string; + icon?: string; + children: SubpageNode[]; +} + +// Subpage node carrying `position` so each level can be sorted in place. +export type SubpageNodeWithPos = SubpageNode & { + position: string; + children: SubpageNodeWithPos[]; +}; + +/** + * Build a nested subtree (the current page's descendants) from the flat `IPage[]` + * the `/pages/tree` endpoint returns. Attaches each node to its parent by + * `parentPageId`, drops the root itself, and sorts every level by `position`. + * + * Guards only against SELF-PARENTING and attaching the root (`p.id !== rootId`) — + * NOT against multi-node `parentPageId` cycles. Those cannot occur here: the + * server rejects cyclic moves, and the recursive `getPageAndDescendants` CTE that + * produces this list would itself loop before reaching the client, so the flat + * input is acyclic by construction. A node whose `parentPageId` points outside + * the result set (an unreachable parent) is silently dropped — it is, by + * definition, not a descendant of the root being rendered. + */ +export function buildSubtree(pages: IPage[], rootId: string): SubpageNode[] { + const byId = new Map( + pages.map((p) => [ + p.id, + { + id: p.id, + slugId: p.slugId, + title: p.title, + icon: p.icon, + position: p.position, + children: [], + }, + ]), + ); + + for (const p of pages) { + const node = byId.get(p.id); + const parent = p.parentPageId ? byId.get(p.parentPageId) : undefined; + if (node && parent && p.id !== rootId) { + parent.children.push(node); + } + } + + const sortRecursive = ( + nodes: SubpageNodeWithPos[], + ): SubpageNodeWithPos[] => { + const sorted = sortPositionKeys(nodes) as SubpageNodeWithPos[]; + sorted.forEach((n) => sortRecursive(n.children)); + return sorted; + }; + + const root = byId.get(rootId); + return root ? sortRecursive(root.children) : []; +} + +// Map shared-tree nodes (already nested) onto the normalized SubpageNode shape. +export function mapSharedNodes(nodes: SharedPageTreeNode[]): SubpageNode[] { + return nodes.map((node) => ({ + id: node.value, + slugId: node.slugId, + title: node.name, + icon: node.icon, + children: node.children ? mapSharedNodes(node.children) : [], + })); +} + +// Count every descendant in a normalized subtree. +export function countNodes(nodes: SubpageNode[]): number { + return nodes.reduce((acc, n) => acc + 1 + countNodes(n.children), 0); +}