feat(tree): Expand all / Collapse all for the space page tree #8
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,301 +0,0 @@
|
||||
# Дерево страниц: кнопки «Развернуть всё» / «Свернуть всё»
|
||||
|
||||
Статус: **план, код не менялся.** Фича клиент+сервер. По решению владельца выбран
|
||||
**серверный путь**: эндпоинт отдаёт **всё поддерево/всё дерево спейса разом**
|
||||
(«отдать всё»), а клиент за один-два запроса разворачивает дерево целиком. От
|
||||
клиентского рекурсивного обхода по одному уровню — отказались (см. «Почему так»).
|
||||
|
||||
## Суть
|
||||
|
||||
В сайдбаре спейса (дерево «Pages») сейчас узлы разворачиваются/сворачиваются
|
||||
только поодиночке кликом по шеврону. Есть шорткат `*` (разворачивает **сиблингов**
|
||||
сфокусированного узла, паттерн WAI-ARIA tree), но глобального «развернуть/свернуть
|
||||
всё дерево» нет.
|
||||
|
||||
Хотим: две команды в шапке дерева — **«Развернуть всё»** (раскрыть все ветки
|
||||
текущего спейса) и **«Свернуть всё»** (схлопнуть до корней). Это навигационная
|
||||
операция над видом — прав на запись не требует, доступна любому, кто видит спейс.
|
||||
|
||||
## Почему так (выбор архитектуры)
|
||||
|
||||
Дети узлов **загружаются лениво, по одному уровню**: у свёрнутой ветки
|
||||
`hasChildren === true`, но `children === []`, а эндпоинт `/pages/sidebar-pages`
|
||||
отдаёт **только прямых детей** одного `pageId`. «Развернуть всё» поверх такого
|
||||
API = рекурсивный BFS на десятки-сотни HTTP-запросов (шторм запросов, лимиты,
|
||||
долгий индикатор, защитный потолок). Это и был отвергнутый вариант.
|
||||
|
||||
**Решение — отдать всё одним запросом на сервере.** У бэкенда уже есть готовые
|
||||
кирпичи для рекурсивной выборки поддерева с учётом прав (используются в
|
||||
`movePageToSpace`):
|
||||
- `pageRepo.getPageAndDescendants(parentPageId, { includeContent: false })`
|
||||
([page.repo.ts:557](apps/server/src/database/repos/page/page.repo.ts#L557)) —
|
||||
рекурсивный CTE: страница + все потомки одним запросом.
|
||||
- `pageRepo.getPageAndDescendantsExcludingRestricted(parentPageId, opts)`
|
||||
([page.repo.ts:612](apps/server/src/database/repos/page/page.repo.ts#L612)) —
|
||||
то же, но **обрезает закрытые (restricted) поддеревья прямо в SQL** (один
|
||||
запрос, не тянет лишнее).
|
||||
- `pageService.filterAccessibleTreePages(allPages, rootId, userId, spaceId)`
|
||||
([page.service.ts:1136](apps/server/src/core/page/services/page.service.ts#L1136))
|
||||
— точечная фильтрация дерева по правам с сохранением целостности (для
|
||||
per-page permissions сверх restricted-спейсов).
|
||||
- `pageRepo.withHasChildren(eb)`
|
||||
([page.repo.ts:539](apps/server/src/database/repos/page/page.repo.ts#L539)) —
|
||||
вычисление `hasChildren` в SQL (при отдаче всего дерева `hasChildren` можно и
|
||||
вывести на клиенте — у узла есть дети, если в ответе есть страница с
|
||||
`parentPageId === id`).
|
||||
|
||||
Плюсы серверного пути: один-два запроса вместо сотен; предсказуемо даже на
|
||||
тысячах страниц; права считаются на сервере (единый источник правды); на клиенте
|
||||
нет BFS/ограничителя параллелизма/защитного потолка. Минус — нужна работа на
|
||||
бэкенде (новый рекурсивный режим эндпоинта) и контроль размера ответа.
|
||||
|
||||
## Где сейчас живёт код (точные места)
|
||||
|
||||
### Клиент — фича `apps/client/src/features/page/tree/`
|
||||
- **Состояние раскрытия** —
|
||||
[open-tree-nodes-atom.ts](apps/client/src/features/page/tree/atoms/open-tree-nodes-atom.ts):
|
||||
`openTreeNodesAtom`, тип `OpenMap = Record<string, boolean>` (id → раскрыт ли),
|
||||
**персист в localStorage**, ключ `openTreeNodes:{workspaceId}:{userId}`.
|
||||
⚠ **Карта общая для всех спейсов воркспейса.**
|
||||
- **Данные дерева** —
|
||||
[tree-data-atom.ts](apps/client/src/features/page/tree/atoms/tree-data-atom.ts):
|
||||
`treeDataAtom: SpaceTreeNode[]`, накопительно по спейсам; на рендере
|
||||
фильтруется по `spaceId`.
|
||||
- **Модель узла** —
|
||||
[types.ts](apps/client/src/features/page/tree/types.ts): `SpaceTreeNode`
|
||||
(`id`, `spaceId`, `hasChildren`, `children`, `name`, `icon`, `position`,
|
||||
`parentPageId`, `canEdit`, `slugId`).
|
||||
- **Обёртка/тоггл/загрузка** —
|
||||
[space-tree.tsx](apps/client/src/features/page/tree/components/space-tree.tsx):
|
||||
`filteredData` (стр. 184-187, узлы текущего спейса), `handleToggle` (стр.
|
||||
164-182, ленивая загрузка уровня), `spaceIdRef` (стр. 46-47, защита от гонок).
|
||||
- **Модель-операции** —
|
||||
[tree-model.ts](apps/client/src/features/page/tree/model/tree-model.ts):
|
||||
`find`, `appendChildren`, `visible`, `siblingsOf`.
|
||||
- **HTTP-загрузка** —
|
||||
[page-query.ts](apps/client/src/features/page/queries/page-query.ts) +
|
||||
[page-service.ts](apps/client/src/features/page/services/page-service.ts):
|
||||
`getSidebarPages` / `getAllSidebarPages` (паджинируют **один уровень**),
|
||||
`fetchAllAncestorChildren`, утилиты `buildTree` / `buildTreeWithChildren` /
|
||||
`mergeRootTrees` ([utils.ts](apps/client/src/features/page/tree/utils/utils.ts)).
|
||||
- **Шапка дерева (куда вешать команды)** —
|
||||
[space-sidebar.tsx:117-149](apps/client/src/features/space/components/sidebar/space-sidebar.tsx#L117):
|
||||
`SpaceMenu` (дропдаун на `IconDots`, стр. 172-281, уже с `Menu.Item`/
|
||||
`Menu.Divider`) + кнопка «+» (Create page).
|
||||
|
||||
### Сервер — фича `apps/server/src/core/page/`
|
||||
- **Эндпоинт сайдбара** —
|
||||
[page.controller.ts:540](apps/server/src/core/page/page.controller.ts#L540)
|
||||
`POST /pages/sidebar-pages` (`SidebarPageDto`: `spaceId | pageId`),
|
||||
CASL-скоуп на спейс, отдаёт **один уровень**.
|
||||
- **Сервис** —
|
||||
[page.service.ts:304](apps/server/src/core/page/services/page.service.ts#L304)
|
||||
`getSidebarPages(spaceId, pagination, pageId?, userId?, spaceCanEdit?)`:
|
||||
выборка одного уровня + `withHasChildren` + **двухветочная фильтрация прав** —
|
||||
если в спейсе нет ограничений (`pagePermissionRepo.hasRestrictedPagesInSpace`)
|
||||
→ `canEdit = spaceCanEdit`; иначе per-page фильтр через
|
||||
`filterAccessiblePageIdsWithPermissions` + корректировка `hasChildren` по
|
||||
`getParentIdsWithAccessibleChildren`. **Эту же логику прав надо повторить в
|
||||
рекурсивном режиме.**
|
||||
|
||||
## Решение
|
||||
|
||||
### Серверная часть — «отдать всё поддерево» одним запросом
|
||||
|
||||
Добавить рекурсивный режим выдачи дерева. Варианты оформления (выбрать на ревью):
|
||||
- флаг `recursive: true` (и опц. `depth`) к существующему `POST /pages/sidebar-pages`, **или**
|
||||
- отдельный эндпоинт `POST /pages/tree` (`{ spaceId }` → всё дерево спейса;
|
||||
`{ pageId }` → всё поддерево страницы).
|
||||
|
||||
Контракт ответа: **плоский список элементов в точно том же shape, что и текущий
|
||||
`/pages/sidebar-pages`** (`id`, `slugId`, `title`, `icon`, `position`,
|
||||
`parentPageId`, `spaceId`, `hasChildren`, `canEdit`), чтобы клиентские
|
||||
`buildTree`/`buildTreeWithChildren` собрали дерево без изменений. Порядок — по
|
||||
`position` (collate "C"), как сейчас.
|
||||
|
||||
Сервисный метод (эскиз), переиспользует существующие кирпичи:
|
||||
```ts
|
||||
// Whole subtree (pageId) or whole space tree (spaceId only) in a single query,
|
||||
// permission-filtered, returned as a flat list matching the sidebar item shape.
|
||||
async getSidebarPagesTree(spaceId, userId, spaceCanEdit, pageId?) {
|
||||
const hasRestrictions = await this.pagePermissionRepo.hasRestrictedPagesInSpace(spaceId);
|
||||
|
||||
// Seed: a single page subtree, or all root pages of the space.
|
||||
// - restricted space -> *ExcludingRestricted (prunes closed subtrees in SQL)
|
||||
// - open space -> plain recursive descendants
|
||||
// For the whole-space case add a space-rooted recursive CTE (seed:
|
||||
// parentPageId is null AND spaceId = ? AND deletedAt is null), mirroring
|
||||
// getPageAndDescendants/...ExcludingRestricted.
|
||||
let pages = hasRestrictions
|
||||
? await this.pageRepo.getSpaceDescendantsExcludingRestricted(spaceId, pageId, { includeContent: false })
|
||||
: await this.pageRepo.getSpaceDescendants(spaceId, pageId, { includeContent: false });
|
||||
|
||||
// Fine-grained per-page permissions on top of restricted pruning.
|
||||
if (hasRestrictions) {
|
||||
pages = await this.filterAccessibleTreePages(pages, pageId ?? null, userId, spaceId);
|
||||
}
|
||||
|
||||
// Derive hasChildren from the returned set; stamp canEdit (per-page when
|
||||
// restricted, else spaceCanEdit). Same two-branch logic as getSidebarPages().
|
||||
return shapeAsSidebarItems(pages, { hasRestrictions, spaceCanEdit /*, permissionMap */ });
|
||||
}
|
||||
```
|
||||
Где `getSpaceDescendants` / `getSpaceDescendantsExcludingRestricted` — новые
|
||||
тонкие обёртки над существующими рекурсивными CTE (для случая «всё дерево спейса»
|
||||
— CTE, засеянный корнями спейса вместо одного `parentPageId`).
|
||||
|
||||
**Важно про права:** обязательно сохранить **обе ветки** фильтрации из
|
||||
`getSidebarPages` (restricted / не-restricted) и корректировку `hasChildren`,
|
||||
иначе рекурсивный эндпоинт начнёт отдавать страницы, к которым у пользователя нет
|
||||
доступа. Это критичная грань — на ревью проверить отдельно.
|
||||
|
||||
### Клиентская часть — упрощённый `expandAll`
|
||||
|
||||
Поскольку дерево приходит целиком, BFS/параллелизм/потолок не нужны.
|
||||
|
||||
`page-service.ts` — новый вызов:
|
||||
```ts
|
||||
// Fetch the whole space tree (all roots + descendants) in one shot.
|
||||
export async function getSpaceTree(params: { spaceId: string; pageId?: string }): Promise<IPage[]> {
|
||||
const req = await api.post("/pages/tree", params); // or /sidebar-pages { recursive: true }
|
||||
return req.data.items;
|
||||
}
|
||||
```
|
||||
|
||||
`space-tree.tsx` — превратить `SpaceTree` в `forwardRef` и выставить
|
||||
`useImperativeHandle`:
|
||||
```ts
|
||||
export type SpaceTreeApi = {
|
||||
expandAll: () => Promise<void>;
|
||||
collapseAll: () => void;
|
||||
isExpanding: boolean;
|
||||
};
|
||||
|
||||
const expandAll = useCallback(async () => {
|
||||
const startSpaceId = spaceIdRef.current;
|
||||
setIsExpanding(true);
|
||||
try {
|
||||
// One request: the entire space tree, permission-filtered server-side.
|
||||
const items = await getSpaceTree({ spaceId: startSpaceId });
|
||||
if (spaceIdRef.current !== startSpaceId) return; // space switched — abort
|
||||
|
||||
const fullTree = buildTreeWithChildren(items);
|
||||
setData((prev) => {
|
||||
// Replace current-space nodes with the full tree; keep other spaces intact.
|
||||
const others = prev.filter((n) => n?.spaceId !== startSpaceId);
|
||||
return [...others, ...mergeRootTrees(prev.filter((n) => n?.spaceId === startSpaceId), fullTree)];
|
||||
});
|
||||
|
||||
// Open every branch node of the current space.
|
||||
const branchIds = collectBranchIds(fullTree); // nodes with children
|
||||
setOpenTreeNodes((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const id of branchIds) next[id] = true;
|
||||
return next;
|
||||
});
|
||||
} catch (err) {
|
||||
// Never swallow: log full error + show the real reason (project convention).
|
||||
console.error("[tree] expandAll failed", err);
|
||||
notifications.show({ color: "red",
|
||||
message: t("Couldn't expand the tree: {{reason}}", { reason: err?.response?.data?.message ?? err?.message ?? String(err) }) });
|
||||
} finally {
|
||||
setIsExpanding(false);
|
||||
}
|
||||
}, [/* setData, setOpenTreeNodes, t */]);
|
||||
```
|
||||
|
||||
`collapseAll` — снимать раскрытие **только у узлов текущего спейса** (карта общая):
|
||||
```ts
|
||||
const collapseAll = useCallback(() => {
|
||||
// The open-map is shared across spaces; clearing it wholesale would drop
|
||||
// other spaces' expanded state. Collapse only current-space ids.
|
||||
const ids = new Set<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]);
|
||||
```
|
||||
|
||||
`space-sidebar.tsx` — `const treeRef = useRef<SpaceTreeApi | null>(null)`, передать
|
||||
в `<SpaceTree ref={treeRef} ... />`, и подвесить команды в шапке. **Без
|
||||
`canManage`-гейта** — это операция над видом, не над данными.
|
||||
|
||||
## UX-развилка по размещению
|
||||
|
||||
В шапке уже два значка (`IconDots` меню + `IconPlus` создать). Варианты:
|
||||
- **(1) Две `ActionIcon`** «развернуть»/«свернуть» (`IconChevronsDown` /
|
||||
`IconChevronsUp`) → 4 значка в узкой шапке, явно и в один клик.
|
||||
- **(2) Одна `ActionIcon`-тоггл** развернуть↔свернуть → 3 значка, компактнее, но
|
||||
состояние менее очевидно.
|
||||
- **(3) Два `Menu.Item`** в `SpaceMenu` (`Развернуть всё` / `Свернуть всё` +
|
||||
`Menu.Divider`) → шапка не растёт, но в два клика и менее заметно.
|
||||
|
||||
> **Рекомендация:** **(3)** как самый чистый по вёрстке (узкая колонка) либо
|
||||
> **(1)**, если важна доступность в один клик. Тултипы/`aria-label`:
|
||||
> `t("Expand all")` / `t("Collapse all")`; во время загрузки — `loading`/
|
||||
> `disabled` (`isExpanding`).
|
||||
|
||||
## Тонкие моменты / edge cases
|
||||
|
||||
- **Права в рекурсивном эндпоинте.** Самый важный пункт: повторить **обе** ветки
|
||||
фильтрации (restricted / открытый спейс) и корректировку `hasChildren` из
|
||||
`getSidebarPages`. Предпочесть `*ExcludingRestricted` (обрезает закрытые
|
||||
поддеревья в SQL) + `filterAccessibleTreePages` для per-page прав. На ревью —
|
||||
тест: пользователь без доступа к ветке не должен видеть её через «развернуть
|
||||
всё».
|
||||
- **Размер ответа.** Всё дерево спейса может быть большим. `content` **не**
|
||||
тянуть (`includeContent: false`). Прикинуть потолок (число узлов) и поведение
|
||||
при очень больших спейсах — отдавать всё или ограничить + честно сообщить
|
||||
(конвенция: не молчать про усечение).
|
||||
- **Скоуп карты раскрытия.** `openTreeNodesAtom` общая для спейсов — и
|
||||
`expandAll`, и `collapseAll` работают **только по узлам текущего спейса**.
|
||||
- **Гонки при смене спейса.** Запрос асинхронный; сверяться с
|
||||
`spaceIdRef.current` и прерывать мёрдж/раскрытие, если спейс сменился (паттерн
|
||||
уже есть в эффектах `space-tree.tsx`).
|
||||
- **Мёрдж с уже загруженным.** Полное дерево вмёрджить в `treeDataAtom`, заместив
|
||||
узлы текущего спейса (`mergeRootTrees`/замена ветки), **не трогая** узлы
|
||||
других спейсов.
|
||||
- **Ошибки не глотать.** Любой сбой — `console.error` с полным объектом **и**
|
||||
уведомление с реальной причиной (`err.response?.data?.message`/`err.message`),
|
||||
не «что-то пошло не так» (CLAUDE.md «Errors must never be swallowed»).
|
||||
- **Индикатор.** На крупном спейсе запрос заметный — кнопку в `loading`, чтобы не
|
||||
было повторных кликов/ощущения зависания.
|
||||
- **Рост localStorage-карты.** `expandAll` пишет много ключей; для удалённых
|
||||
страниц ключи «висят». Не критично; уборка карты — отдельная задача.
|
||||
- **Пустой спейс / одни листья.** Кнопки — no-op; «развернуть» можно `disabled`.
|
||||
- **Шорткат `*`** (развернуть сиблингов,
|
||||
[doc-tree.tsx](apps/client/src/features/page/tree/components/doc-tree.tsx)) не
|
||||
трогаем — дополняем его.
|
||||
- **Виртуализация.** Дерево на `@tanstack/react-virtual` — раскрытие тысяч строк
|
||||
рендер не убьёт (рисуются видимые), но резко меняет высоту скролла; проверить,
|
||||
что позиция/скролл не прыгают.
|
||||
|
||||
## Тесты / проверка
|
||||
|
||||
- **Сервер:** `pnpm --filter server test` (unit на новый сервисный метод).
|
||||
Кейсы: открытый спейс (видно всё), restricted-спейс (закрытые ветки и их
|
||||
поддеревья **не** попадают в ответ), per-page права (`canEdit`), корректный
|
||||
`hasChildren`, порядок по `position`, `content` не тянется.
|
||||
- **Клиент:** `pnpm --filter client lint`, `pnpm --filter client test`.
|
||||
- **Ручная:** глубокий спейс → «развернуть всё» раскрывает все уровни одним
|
||||
запросом, индикатор работает; «свернуть всё» схлопывает до корней и **не**
|
||||
теряет состояние другого спейса (переключиться туда-обратно); перезагрузка —
|
||||
состояние сохраняется (localStorage); смена спейса в середине загрузки —
|
||||
корректно прерывается; пустой спейс — без поломок; имитация ошибки сети — видно
|
||||
конкретное уведомление, ошибка залогирована.
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
1. **Оформление эндпоинта:** флаг `recursive` к `/pages/sidebar-pages` против
|
||||
отдельного `/pages/tree`. (Контракт ответа в обоих — плоский список в shape
|
||||
текущего сайдбара.)
|
||||
2. **Размещение команд:** две иконки (1) / одна-тоггл (2) / пункты меню (3).
|
||||
Рекомендация — (3) или (1).
|
||||
3. **Потолок размера ответа:** отдавать дерево любого размера или ограничить
|
||||
(число узлов) и как сообщать про усечение.
|
||||
Reference in New Issue
Block a user