Compare commits
1 Commits
develop
...
0c7d67fe2a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c7d67fe2a |
@@ -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",
|
||||
|
||||
@@ -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<IPage[]> {
|
||||
const req = await api.post<{ items: IPage[] }>("/pages/tree", params);
|
||||
return req.data.items;
|
||||
}
|
||||
|
||||
export async function getAllSidebarPages(
|
||||
params: SidebarPagesParams,
|
||||
): Promise<InfiniteData<IPagination<IPage>, unknown>> {
|
||||
|
||||
@@ -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<void>;
|
||||
collapseAll: () => void;
|
||||
isExpanding: boolean;
|
||||
};
|
||||
|
||||
const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(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) {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default SpaceTree;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<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) {
|
||||
return <></>;
|
||||
@@ -95,6 +104,8 @@ export function SpaceSidebar() {
|
||||
<Group gap="xs">
|
||||
<SpaceMenu
|
||||
spaceId={space.id}
|
||||
treeRef={treeRef}
|
||||
isExpanding={isTreeExpanding}
|
||||
canManagePages={spaceAbility.can(
|
||||
SpaceCaslAction.Manage,
|
||||
SpaceCaslSubject.Page,
|
||||
@@ -122,7 +133,9 @@ export function SpaceSidebar() {
|
||||
|
||||
<div className={classes.pages}>
|
||||
<SpaceTree
|
||||
ref={treeRef}
|
||||
spaceId={space.id}
|
||||
onExpandingChange={setIsTreeExpanding}
|
||||
readOnly={spaceAbility.cannot(
|
||||
SpaceCaslAction.Manage,
|
||||
SpaceCaslSubject.Page,
|
||||
@@ -143,11 +156,18 @@ export function SpaceSidebar() {
|
||||
|
||||
interface SpaceMenuProps {
|
||||
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;
|
||||
onSpaceSettings: () => void;
|
||||
}
|
||||
function SpaceMenu({
|
||||
spaceId,
|
||||
treeRef,
|
||||
isExpanding,
|
||||
canManagePages,
|
||||
onSpaceSettings,
|
||||
}: SpaceMenuProps) {
|
||||
@@ -226,6 +246,21 @@ function SpaceMenu({
|
||||
{isWatching ? t("Stop watching space") : t("Watch space")}
|
||||
</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 && (
|
||||
<>
|
||||
<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)
|
||||
@Post('move-to-space')
|
||||
async movePageToSpace(
|
||||
|
||||
@@ -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<T[]> {
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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