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.
This commit is contained in:
@@ -29,9 +29,11 @@
|
|||||||
"Choose your preferred color scheme.": "Choose your preferred color scheme.",
|
"Choose your preferred color scheme.": "Choose your preferred color scheme.",
|
||||||
"Choose your preferred interface language.": "Choose your preferred interface language.",
|
"Choose your preferred interface language.": "Choose your preferred interface language.",
|
||||||
"Choose your preferred page width.": "Choose your preferred page width.",
|
"Choose your preferred page width.": "Choose your preferred page width.",
|
||||||
|
"Collapse all": "Collapse all",
|
||||||
"Confirm": "Confirm",
|
"Confirm": "Confirm",
|
||||||
"Copy as Markdown": "Copy as Markdown",
|
"Copy as Markdown": "Copy as Markdown",
|
||||||
"Copy link": "Copy link",
|
"Copy link": "Copy link",
|
||||||
|
"Couldn't expand the tree: {{reason}}": "Couldn't expand the tree: {{reason}}",
|
||||||
"Create": "Create",
|
"Create": "Create",
|
||||||
"Create group": "Create group",
|
"Create group": "Create group",
|
||||||
"Create page": "Create page",
|
"Create page": "Create page",
|
||||||
@@ -68,6 +70,7 @@
|
|||||||
"Enter your password": "Enter your password",
|
"Enter your password": "Enter your password",
|
||||||
"Error fetching page data.": "Error fetching page data.",
|
"Error fetching page data.": "Error fetching page data.",
|
||||||
"Error loading page history.": "Error loading page history.",
|
"Error loading page history.": "Error loading page history.",
|
||||||
|
"Expand all": "Expand all",
|
||||||
"Export": "Export",
|
"Export": "Export",
|
||||||
"Failed to create page": "Failed to create page",
|
"Failed to create page": "Failed to create page",
|
||||||
"Failed to delete page": "Failed to delete page",
|
"Failed to delete page": "Failed to delete page",
|
||||||
|
|||||||
@@ -69,6 +69,17 @@ export async function getSidebarPages(
|
|||||||
return req.data;
|
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<IPage[]> {
|
||||||
|
const req = await api.post<{ items: IPage[] }>("/pages/tree", params);
|
||||||
|
return req.data.items;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getAllSidebarPages(
|
export async function getAllSidebarPages(
|
||||||
params: SidebarPagesParams,
|
params: SidebarPagesParams,
|
||||||
): Promise<InfiniteData<IPagination<IPage>, unknown>> {
|
): Promise<InfiniteData<IPagination<IPage>, unknown>> {
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
import { useAtom } from "jotai";
|
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 { useParams } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Text } from "@mantine/core";
|
import { Text } from "@mantine/core";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
import {
|
import {
|
||||||
fetchAllAncestorChildren,
|
fetchAllAncestorChildren,
|
||||||
useGetRootSidebarPagesQuery,
|
useGetRootSidebarPagesQuery,
|
||||||
@@ -15,11 +24,16 @@ import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts
|
|||||||
import {
|
import {
|
||||||
buildTree,
|
buildTree,
|
||||||
buildTreeWithChildren,
|
buildTreeWithChildren,
|
||||||
|
collectAllIds,
|
||||||
|
collectBranchIds,
|
||||||
mergeRootTrees,
|
mergeRootTrees,
|
||||||
} from "@/features/page/tree/utils/utils.ts";
|
} from "@/features/page/tree/utils/utils.ts";
|
||||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
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 { IPage } from "@/features/page/types/page.types.ts";
|
||||||
import { extractPageSlugId } from "@/lib";
|
import { extractPageSlugId } from "@/lib";
|
||||||
import { DocTree } from "./doc-tree";
|
import { DocTree } from "./doc-tree";
|
||||||
@@ -28,9 +42,24 @@ import { SpaceTreeRow } from "./space-tree-row";
|
|||||||
interface SpaceTreeProps {
|
interface SpaceTreeProps {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
readOnly: boolean;
|
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<void>;
|
||||||
|
collapseAll: () => void;
|
||||||
|
isExpanding: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
|
||||||
|
{ spaceId, readOnly, onExpandingChange },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { pageSlug } = useParams();
|
const { pageSlug } = useParams();
|
||||||
const [data, setData] = useAtom(treeDataAtom);
|
const [data, setData] = useAtom(treeDataAtom);
|
||||||
@@ -43,6 +72,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
|||||||
} = useGetRootSidebarPagesQuery({ spaceId });
|
} = useGetRootSidebarPagesQuery({ spaceId });
|
||||||
const [openTreeNodes, setOpenTreeNodes] = useAtom(openTreeNodesAtom);
|
const [openTreeNodes, setOpenTreeNodes] = useAtom(openTreeNodesAtom);
|
||||||
const [isDataLoaded, setIsDataLoaded] = useState(false);
|
const [isDataLoaded, setIsDataLoaded] = useState(false);
|
||||||
|
const [isExpanding, setIsExpanding] = useState(false);
|
||||||
const spaceIdRef = useRef(spaceId);
|
const spaceIdRef = useRef(spaceId);
|
||||||
spaceIdRef.current = spaceId;
|
spaceIdRef.current = spaceId;
|
||||||
const { data: currentPage } = usePageQuery({
|
const { data: currentPage } = usePageQuery({
|
||||||
@@ -186,6 +216,66 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
|||||||
[data, spaceId],
|
[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
|
// Stable callbacks for DocTree. Without these, every parent render recreates
|
||||||
// the props and tears down every row's draggable/dropTarget subscription,
|
// the props and tears down every row's draggable/dropTarget subscription,
|
||||||
// defeating memo(DocTreeRow).
|
// defeating memo(DocTreeRow).
|
||||||
@@ -228,4 +318,6 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
export default SpaceTree;
|
||||||
|
|||||||
@@ -216,3 +216,33 @@ export function mergeRootTrees(
|
|||||||
|
|
||||||
return sortPositionKeys(merged);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
|
IconArrowsMaximize,
|
||||||
|
IconArrowsMinimize,
|
||||||
IconArrowDown,
|
IconArrowDown,
|
||||||
IconDots,
|
IconDots,
|
||||||
IconEye,
|
IconEye,
|
||||||
@@ -23,14 +25,16 @@ import {
|
|||||||
useUnwatchSpaceMutation,
|
useUnwatchSpaceMutation,
|
||||||
} from "@/features/space/queries/space-watcher-query.ts";
|
} from "@/features/space/queries/space-watcher-query.ts";
|
||||||
import classes from "./space-sidebar.module.css";
|
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 { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
|
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
|
||||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
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 { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
||||||
import {
|
import {
|
||||||
SpaceCaslAction,
|
SpaceCaslAction,
|
||||||
@@ -57,6 +61,11 @@ export function SpaceSidebar() {
|
|||||||
const spaceRules = space?.membership?.permissions;
|
const spaceRules = space?.membership?.permissions;
|
||||||
const spaceAbility = useSpaceAbility(spaceRules);
|
const spaceAbility = useSpaceAbility(spaceRules);
|
||||||
const { handleCreate } = useTreeMutation(space?.id ?? "");
|
const { handleCreate } = useTreeMutation(space?.id ?? "");
|
||||||
|
const treeRef = useRef<SpaceTreeApi | null>(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) {
|
if (!space) {
|
||||||
return <></>;
|
return <></>;
|
||||||
@@ -95,6 +104,8 @@ export function SpaceSidebar() {
|
|||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<SpaceMenu
|
<SpaceMenu
|
||||||
spaceId={space.id}
|
spaceId={space.id}
|
||||||
|
treeRef={treeRef}
|
||||||
|
isExpanding={isTreeExpanding}
|
||||||
canManagePages={spaceAbility.can(
|
canManagePages={spaceAbility.can(
|
||||||
SpaceCaslAction.Manage,
|
SpaceCaslAction.Manage,
|
||||||
SpaceCaslSubject.Page,
|
SpaceCaslSubject.Page,
|
||||||
@@ -122,7 +133,9 @@ export function SpaceSidebar() {
|
|||||||
|
|
||||||
<div className={classes.pages}>
|
<div className={classes.pages}>
|
||||||
<SpaceTree
|
<SpaceTree
|
||||||
|
ref={treeRef}
|
||||||
spaceId={space.id}
|
spaceId={space.id}
|
||||||
|
onExpandingChange={setIsTreeExpanding}
|
||||||
readOnly={spaceAbility.cannot(
|
readOnly={spaceAbility.cannot(
|
||||||
SpaceCaslAction.Manage,
|
SpaceCaslAction.Manage,
|
||||||
SpaceCaslSubject.Page,
|
SpaceCaslSubject.Page,
|
||||||
@@ -143,11 +156,18 @@ export function SpaceSidebar() {
|
|||||||
|
|
||||||
interface SpaceMenuProps {
|
interface SpaceMenuProps {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
|
treeRef: React.RefObject<SpaceTreeApi | null>;
|
||||||
|
// 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;
|
canManagePages: boolean;
|
||||||
onSpaceSettings: () => void;
|
onSpaceSettings: () => void;
|
||||||
}
|
}
|
||||||
function SpaceMenu({
|
function SpaceMenu({
|
||||||
spaceId,
|
spaceId,
|
||||||
|
treeRef,
|
||||||
|
isExpanding,
|
||||||
canManagePages,
|
canManagePages,
|
||||||
onSpaceSettings,
|
onSpaceSettings,
|
||||||
}: SpaceMenuProps) {
|
}: SpaceMenuProps) {
|
||||||
@@ -226,6 +246,21 @@ function SpaceMenu({
|
|||||||
{isWatching ? t("Stop watching space") : t("Watch space")}
|
{isWatching ? t("Stop watching space") : t("Watch space")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
|
<Menu.Divider />
|
||||||
|
<Menu.Item
|
||||||
|
onClick={() => treeRef.current?.expandAll()}
|
||||||
|
leftSection={<IconArrowsMaximize size={16} />}
|
||||||
|
disabled={isExpanding}
|
||||||
|
>
|
||||||
|
{t("Expand all")}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
onClick={() => treeRef.current?.collapseAll()}
|
||||||
|
leftSection={<IconArrowsMinimize size={16} />}
|
||||||
|
>
|
||||||
|
{t("Collapse all")}
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
{canManagePages && (
|
{canManagePages && (
|
||||||
<>
|
<>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|||||||
@@ -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)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('move-to-space')
|
@Post('move-to-space')
|
||||||
async movePageToSpace(
|
async movePageToSpace(
|
||||||
|
|||||||
@@ -1132,12 +1132,16 @@ export class PageService {
|
|||||||
* 1. The user has access to it
|
* 1. The user has access to it
|
||||||
* 2. Its parent is also included (or it's the root page)
|
* 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.
|
* 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 },
|
T extends { id: string; parentPageId: string | null },
|
||||||
>(
|
>(
|
||||||
pages: T[],
|
pages: T[],
|
||||||
rootPageId: string,
|
rootPageId: string | null,
|
||||||
userId: string,
|
userId: string,
|
||||||
spaceId?: string,
|
spaceId?: string,
|
||||||
): Promise<T[]> {
|
): Promise<T[]> {
|
||||||
@@ -1165,8 +1169,12 @@ export class PageService {
|
|||||||
if (includedIds.has(page.id)) continue;
|
if (includedIds.has(page.id)) continue;
|
||||||
if (!accessibleSet.has(page.id)) continue;
|
if (!accessibleSet.has(page.id)) continue;
|
||||||
|
|
||||||
// Root page: include if accessible
|
// Root page: include if accessible. For the whole-space tree case
|
||||||
if (page.id === rootPageId) {
|
// (rootPageId === null), any top-level page counts as a root.
|
||||||
|
if (
|
||||||
|
page.id === rootPageId ||
|
||||||
|
(rootPageId === null && page.parentPageId === null)
|
||||||
|
) {
|
||||||
includedIds.add(page.id);
|
includedIds.add(page.id);
|
||||||
changed = true;
|
changed = true;
|
||||||
continue;
|
continue;
|
||||||
@@ -1182,4 +1190,119 @@ export class PageService {
|
|||||||
|
|
||||||
return pages.filter((p) => includedIds.has(p.id));
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -672,4 +672,136 @@ export class PageRepo {
|
|||||||
.execute()
|
.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<boolean>`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<boolean>`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()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user