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 <noreply@anthropic.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -92,6 +92,14 @@ export async function getAllSidebarPages(
|
||||
};
|
||||
}
|
||||
|
||||
export async function getSpaceTree(params: {
|
||||
spaceId: string;
|
||||
pageId?: string;
|
||||
}): Promise<IPage[]> {
|
||||
const req = await api.post("/pages/tree", params);
|
||||
return req.data.items;
|
||||
}
|
||||
|
||||
export async function getPageBreadcrumbs(
|
||||
pageId: string,
|
||||
): Promise<Partial<IPage[]>> {
|
||||
|
||||
@@ -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<void>;
|
||||
collapseAll: () => void;
|
||||
isExpanding: boolean;
|
||||
};
|
||||
|
||||
const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(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<string>();
|
||||
const walk = (nodes: SpaceTreeNode[]) => {
|
||||
for (const n of nodes) {
|
||||
ids.add(n.id);
|
||||
if (n.children?.length) walk(n.children);
|
||||
}
|
||||
};
|
||||
walk(filteredData);
|
||||
|
||||
setOpenTreeNodes((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const id of ids) next[id] = false;
|
||||
return next;
|
||||
});
|
||||
}, [filteredData, setOpenTreeNodes]);
|
||||
|
||||
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) {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default SpaceTree;
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconArrowDown,
|
||||
IconChevronsDown,
|
||||
IconChevronsUp,
|
||||
IconDots,
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
@@ -23,14 +25,16 @@ import {
|
||||
useUnwatchSpaceMutation,
|
||||
} from "@/features/space/queries/space-watcher-query.ts";
|
||||
import classes from "./space-sidebar.module.css";
|
||||
import React from "react";
|
||||
import React, { useRef } from "react";
|
||||
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import clsx from "clsx";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
|
||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||
import SpaceTree from "@/features/page/tree/components/space-tree.tsx";
|
||||
import SpaceTree, {
|
||||
SpaceTreeApi,
|
||||
} from "@/features/page/tree/components/space-tree.tsx";
|
||||
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
@@ -57,6 +61,7 @@ export function SpaceSidebar() {
|
||||
const spaceRules = space?.membership?.permissions;
|
||||
const spaceAbility = useSpaceAbility(spaceRules);
|
||||
const { handleCreate } = useTreeMutation(space?.id ?? "");
|
||||
const treeRef = useRef<SpaceTreeApi | null>(null);
|
||||
|
||||
if (!space) {
|
||||
return <></>;
|
||||
@@ -100,6 +105,7 @@ export function SpaceSidebar() {
|
||||
SpaceCaslSubject.Page,
|
||||
)}
|
||||
onSpaceSettings={openSettings}
|
||||
treeRef={treeRef}
|
||||
/>
|
||||
|
||||
{spaceAbility.can(
|
||||
@@ -122,6 +128,7 @@ export function SpaceSidebar() {
|
||||
|
||||
<div className={classes.pages}>
|
||||
<SpaceTree
|
||||
ref={treeRef}
|
||||
spaceId={space.id}
|
||||
readOnly={spaceAbility.cannot(
|
||||
SpaceCaslAction.Manage,
|
||||
@@ -145,13 +152,29 @@ interface SpaceMenuProps {
|
||||
spaceId: string;
|
||||
canManagePages: boolean;
|
||||
onSpaceSettings: () => void;
|
||||
treeRef: React.RefObject<SpaceTreeApi | null>;
|
||||
}
|
||||
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({
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
onClick={handleExpandAll}
|
||||
disabled={isExpanding}
|
||||
closeMenuOnClick={false}
|
||||
leftSection={<IconChevronsDown size={16} />}
|
||||
>
|
||||
{t("Expand all")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
onClick={handleCollapseAll}
|
||||
leftSection={<IconChevronsUp size={16} />}
|
||||
>
|
||||
{t("Collapse all")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item
|
||||
onClick={handleToggleFavorite}
|
||||
leftSection={
|
||||
|
||||
@@ -9,3 +9,13 @@ export class SidebarPageDto {
|
||||
@IsString()
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
export class SidebarPageTreeDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
spaceId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
pageId?: string;
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { Page, User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { SidebarPageDto } from './dto/sidebar-page.dto';
|
||||
import { SidebarPageDto, SidebarPageTreeDto } from './dto/sidebar-page.dto';
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
@@ -578,6 +578,49 @@ export class PageController {
|
||||
);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('/tree')
|
||||
async getPagesTree(
|
||||
@Body() dto: SidebarPageTreeDto,
|
||||
@AuthUser() user: User,
|
||||
) {
|
||||
if (!dto.spaceId && !dto.pageId) {
|
||||
throw new BadRequestException(
|
||||
'Either spaceId or pageId must be provided',
|
||||
);
|
||||
}
|
||||
|
||||
let spaceId = dto.spaceId;
|
||||
|
||||
if (dto.pageId) {
|
||||
const page = await this.pageRepo.findById(dto.pageId);
|
||||
if (!page) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
spaceId = page.spaceId;
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
const spaceCanEdit = ability.can(
|
||||
SpaceCaslAction.Edit,
|
||||
SpaceCaslSubject.Page,
|
||||
);
|
||||
|
||||
const items = await this.pageService.getSidebarPagesTree(
|
||||
spaceId,
|
||||
user.id,
|
||||
spaceCanEdit,
|
||||
dto.pageId,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('move-to-space')
|
||||
async movePageToSpace(
|
||||
|
||||
@@ -1137,7 +1137,7 @@ export class PageService {
|
||||
T extends { id: string; parentPageId: string | null },
|
||||
>(
|
||||
pages: T[],
|
||||
rootPageId: string,
|
||||
rootPageId: string | null,
|
||||
userId: string,
|
||||
spaceId?: string,
|
||||
): Promise<T[]> {
|
||||
@@ -1153,6 +1153,15 @@ export class PageService {
|
||||
);
|
||||
const accessibleSet = new Set(accessibleIds);
|
||||
|
||||
// When no explicit root is given (whole-space tree), every page whose
|
||||
// parent is outside the returned set acts as a root (space root pages have
|
||||
// parentPageId === null). This mirrors the single-root case below.
|
||||
const pageIdSet = new Set(pageIds);
|
||||
const isRoot = (page: T): boolean => {
|
||||
if (rootPageId !== null) return page.id === rootPageId;
|
||||
return !page.parentPageId || !pageIdSet.has(page.parentPageId);
|
||||
};
|
||||
|
||||
// Prune: include a page only if it's accessible AND its parent chain to root is included
|
||||
const includedIds = new Set<string>();
|
||||
|
||||
@@ -1166,7 +1175,7 @@ export class PageService {
|
||||
if (!accessibleSet.has(page.id)) continue;
|
||||
|
||||
// Root page: include if accessible
|
||||
if (page.id === rootPageId) {
|
||||
if (isRoot(page)) {
|
||||
includedIds.add(page.id);
|
||||
changed = true;
|
||||
continue;
|
||||
@@ -1182,4 +1191,123 @@ export class PageService {
|
||||
|
||||
return pages.filter((p) => includedIds.has(p.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole subtree (pageId) or whole space tree (spaceId only) in a single
|
||||
* query, permission-filtered, returned as a flat list matching the sidebar
|
||||
* item shape (id, slugId, title, icon, position, parentPageId, spaceId,
|
||||
* hasChildren, canEdit) ordered by position. content is never fetched.
|
||||
*
|
||||
* Reproduces the exact two-branch permission logic of getSidebarPages():
|
||||
* - open space (no restrictions): every returned page is visible, canEdit =
|
||||
* spaceCanEdit, hasChildren derived from the returned set.
|
||||
* - restricted space: full descendant set is loaded, then per-page
|
||||
* permissions applied via filterAccessibleTreePages (restricted-but-granted
|
||||
* pages are kept; inaccessible subtrees pruned); canEdit is per-page AND
|
||||
* spaceCanEdit;
|
||||
* hasChildren is derived from the FINAL (post-prune, post-filter) set, so
|
||||
* a node never advertises children the user cannot access — the same
|
||||
* correction getSidebarPages does via getParentIdsWithAccessibleChildren.
|
||||
*/
|
||||
async getSidebarPagesTree(
|
||||
spaceId: string,
|
||||
userId: string,
|
||||
spaceCanEdit?: boolean,
|
||||
pageId?: string,
|
||||
): Promise<
|
||||
Array<
|
||||
Pick<
|
||||
Page,
|
||||
| 'id'
|
||||
| 'slugId'
|
||||
| 'title'
|
||||
| 'icon'
|
||||
| 'position'
|
||||
| 'parentPageId'
|
||||
| 'spaceId'
|
||||
> & { hasChildren: boolean; canEdit: boolean }
|
||||
>
|
||||
> {
|
||||
const hasRestrictions =
|
||||
await this.pagePermissionRepo.hasRestrictedPagesInSpace(spaceId);
|
||||
|
||||
// Seed: a single page subtree, or all root pages of the space.
|
||||
// Always seed with the FULL (non-excluding) descendant set — in a restricted
|
||||
// space the per-page filtering below (filterAccessibleTreePages) does the
|
||||
// pruning, exactly like getSidebarPages. Seeding with *ExcludingRestricted
|
||||
// would wrongly drop restricted pages the user has an explicit grant for
|
||||
// (and never recurse into their children), diverging from the sidebar.
|
||||
let pages: Array<{
|
||||
id: string;
|
||||
slugId: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
position: string;
|
||||
parentPageId: string | null;
|
||||
spaceId: string;
|
||||
}>;
|
||||
|
||||
if (pageId) {
|
||||
pages = await this.pageRepo.getPageAndDescendants(pageId, {
|
||||
includeContent: false,
|
||||
});
|
||||
} else {
|
||||
pages = await this.pageRepo.getSpaceDescendants(spaceId, {
|
||||
includeContent: false,
|
||||
});
|
||||
}
|
||||
|
||||
let permissionMap: Map<string, boolean> | undefined;
|
||||
|
||||
if (hasRestrictions) {
|
||||
// Fine-grained per-page permissions on top of restricted pruning.
|
||||
pages = await this.filterAccessibleTreePages(
|
||||
pages,
|
||||
pageId ?? null,
|
||||
userId,
|
||||
spaceId,
|
||||
);
|
||||
|
||||
// Per-page canEdit, same source as getSidebarPages.
|
||||
const accessiblePages =
|
||||
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions(
|
||||
pages.map((p) => p.id),
|
||||
userId,
|
||||
);
|
||||
permissionMap = new Map(accessiblePages.map((p) => [p.id, p.canEdit]));
|
||||
}
|
||||
|
||||
// Derive hasChildren from the FINAL set: a node has children iff some
|
||||
// returned row points to it as parent. In a restricted space this set is
|
||||
// already pruned/filtered, so inaccessible children are not revealed.
|
||||
const parentIds = new Set<string>();
|
||||
for (const p of pages) {
|
||||
if (p.parentPageId) parentIds.add(p.parentPageId);
|
||||
}
|
||||
|
||||
const shaped = pages.map((p) => ({
|
||||
id: p.id,
|
||||
slugId: p.slugId,
|
||||
title: p.title,
|
||||
icon: p.icon,
|
||||
position: p.position,
|
||||
parentPageId: p.parentPageId,
|
||||
spaceId: p.spaceId,
|
||||
hasChildren: parentIds.has(p.id),
|
||||
canEdit: hasRestrictions
|
||||
? Boolean(permissionMap?.get(p.id)) && (spaceCanEdit ?? true)
|
||||
: (spaceCanEdit ?? true),
|
||||
}));
|
||||
|
||||
// Order by position with byte order, matching the sidebar's
|
||||
// `position collate "C"` SQL ordering. position is non-null in returned
|
||||
// rows; treat a null defensively as sorting last.
|
||||
shaped.sort((a, b) => {
|
||||
if (a.position == null) return b.position == null ? 0 : 1;
|
||||
if (b.position == null) return -1;
|
||||
return Buffer.compare(Buffer.from(a.position), Buffer.from(b.position));
|
||||
});
|
||||
|
||||
return shaped;
|
||||
}
|
||||
}
|
||||
|
||||
179
apps/server/src/core/page/services/sidebar-pages-tree.spec.ts
Normal file
179
apps/server/src/core/page/services/sidebar-pages-tree.spec.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Pure-logic test for getSidebarPagesTree's shaping/permission logic.
|
||||
*
|
||||
* NOTE: We cannot import PageService directly here — its dependency chain
|
||||
* imports `src/collaboration/collaboration.util` via a bare `src/...` path, and
|
||||
* the server's jest config (package.json "jest".moduleNameMapper) has no
|
||||
* `^src/(.*)$` mapping, so the module fails to resolve under jest. That is a
|
||||
* pre-existing config gap unrelated to this feature. To still cover the
|
||||
* load-bearing logic we replicate the exact shaping algorithm from
|
||||
* PageService.getSidebarPagesTree below and assert against it. If the service
|
||||
* logic changes, keep this mirror in sync.
|
||||
*/
|
||||
|
||||
type RawPage = {
|
||||
id: string;
|
||||
slugId: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
position: string;
|
||||
parentPageId: string | null;
|
||||
spaceId: string;
|
||||
};
|
||||
|
||||
// Mirror of the shaping/branch logic in PageService.getSidebarPagesTree.
|
||||
function shapeTree(
|
||||
pages: RawPage[],
|
||||
opts: {
|
||||
hasRestrictions: boolean;
|
||||
spaceCanEdit?: boolean;
|
||||
permissionMap?: Map<string, boolean>;
|
||||
},
|
||||
) {
|
||||
const parentIds = new Set<string>();
|
||||
for (const p of pages) {
|
||||
if (p.parentPageId) parentIds.add(p.parentPageId);
|
||||
}
|
||||
|
||||
const shaped = pages.map((p) => ({
|
||||
id: p.id,
|
||||
slugId: p.slugId,
|
||||
title: p.title,
|
||||
icon: p.icon,
|
||||
position: p.position,
|
||||
parentPageId: p.parentPageId,
|
||||
spaceId: p.spaceId,
|
||||
hasChildren: parentIds.has(p.id),
|
||||
canEdit: opts.hasRestrictions
|
||||
? Boolean(opts.permissionMap?.get(p.id)) && (opts.spaceCanEdit ?? true)
|
||||
: (opts.spaceCanEdit ?? true),
|
||||
}));
|
||||
|
||||
shaped.sort((a, b) => {
|
||||
if (a.position == null) return b.position == null ? 0 : 1;
|
||||
if (b.position == null) return -1;
|
||||
return Buffer.compare(Buffer.from(a.position), Buffer.from(b.position));
|
||||
});
|
||||
|
||||
return shaped;
|
||||
}
|
||||
|
||||
const page = (
|
||||
id: string,
|
||||
parentPageId: string | null,
|
||||
position: string,
|
||||
): RawPage => ({
|
||||
id,
|
||||
slugId: `slug-${id}`,
|
||||
title: `Page ${id}`,
|
||||
icon: '',
|
||||
position,
|
||||
parentPageId,
|
||||
spaceId: 'space-1',
|
||||
});
|
||||
|
||||
describe('getSidebarPagesTree shaping logic', () => {
|
||||
it('open space: canEdit = spaceCanEdit, hasChildren derived from set', () => {
|
||||
const pages = [
|
||||
page('root', null, 'a0'),
|
||||
page('child', 'root', 'a0'),
|
||||
page('leaf', 'child', 'a0'),
|
||||
];
|
||||
|
||||
const result = shapeTree(pages, {
|
||||
hasRestrictions: false,
|
||||
spaceCanEdit: true,
|
||||
});
|
||||
|
||||
const byId = new Map(result.map((p) => [p.id, p]));
|
||||
expect(byId.get('root')!.hasChildren).toBe(true);
|
||||
expect(byId.get('child')!.hasChildren).toBe(true);
|
||||
expect(byId.get('leaf')!.hasChildren).toBe(false);
|
||||
expect(result.every((p) => p.canEdit === true)).toBe(true);
|
||||
});
|
||||
|
||||
it('open space: spaceCanEdit=false makes every node read-only', () => {
|
||||
const pages = [page('root', null, 'a0'), page('child', 'root', 'a0')];
|
||||
const result = shapeTree(pages, {
|
||||
hasRestrictions: false,
|
||||
spaceCanEdit: false,
|
||||
});
|
||||
expect(result.every((p) => p.canEdit === false)).toBe(true);
|
||||
});
|
||||
|
||||
it('restricted space: hasChildren does not reveal pruned children', () => {
|
||||
// Simulates the filterAccessibleTreePages result: "child" was pruned, so
|
||||
// the returned set has no row with parent === root.
|
||||
const prunedPages = [page('root', null, 'a0')];
|
||||
const result = shapeTree(prunedPages, {
|
||||
hasRestrictions: true,
|
||||
spaceCanEdit: true,
|
||||
permissionMap: new Map([['root', true]]),
|
||||
});
|
||||
expect(result).toHaveLength(1);
|
||||
// root no longer advertises children the user cannot access.
|
||||
expect(result[0].hasChildren).toBe(false);
|
||||
});
|
||||
|
||||
it('restricted space: canEdit is per-page AND spaceCanEdit', () => {
|
||||
const pages = [
|
||||
page('root', null, 'a0'),
|
||||
page('child', 'root', 'a0'),
|
||||
];
|
||||
const result = shapeTree(pages, {
|
||||
hasRestrictions: true,
|
||||
spaceCanEdit: true,
|
||||
permissionMap: new Map([
|
||||
['root', true],
|
||||
['child', false],
|
||||
]),
|
||||
});
|
||||
const byId = new Map(result.map((p) => [p.id, p]));
|
||||
expect(byId.get('root')!.canEdit).toBe(true);
|
||||
expect(byId.get('child')!.canEdit).toBe(false);
|
||||
expect(byId.get('root')!.hasChildren).toBe(true);
|
||||
});
|
||||
|
||||
it('restricted space: spaceCanEdit=false overrides per-page canEdit', () => {
|
||||
const pages = [page('root', null, 'a0')];
|
||||
const result = shapeTree(pages, {
|
||||
hasRestrictions: true,
|
||||
spaceCanEdit: false,
|
||||
permissionMap: new Map([['root', true]]),
|
||||
});
|
||||
expect(result[0].canEdit).toBe(false);
|
||||
});
|
||||
|
||||
it('orders by position (collate-C style ascending)', () => {
|
||||
const pages = [
|
||||
page('b', null, 'a1'),
|
||||
page('c', null, 'a2'),
|
||||
page('a', null, 'a0'),
|
||||
];
|
||||
const result = shapeTree(pages, {
|
||||
hasRestrictions: false,
|
||||
spaceCanEdit: true,
|
||||
});
|
||||
expect(result.map((p) => p.id)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('shape contains exactly the sidebar item fields', () => {
|
||||
const result = shapeTree([page('root', null, 'a0')], {
|
||||
hasRestrictions: false,
|
||||
spaceCanEdit: true,
|
||||
});
|
||||
expect(Object.keys(result[0]).sort()).toEqual(
|
||||
[
|
||||
'canEdit',
|
||||
'hasChildren',
|
||||
'icon',
|
||||
'id',
|
||||
'parentPageId',
|
||||
'position',
|
||||
'slugId',
|
||||
'spaceId',
|
||||
'title',
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user