mirror of
https://github.com/docmost/docmost.git
synced 2025-11-13 01:02:43 +10:00
Updates to sidebar tree
* Maintain tree open state on route change and return * Load page tree ancestors and their children when a page is accessed directly * Show correct breadcrumb path * Add emoji to breadcrumbs * Backend api to get page breadcrumbs/ancestors
This commit is contained in:
@ -15,6 +15,13 @@ import { Link, useParams } from "react-router-dom";
|
||||
import classes from "./breadcrumb.module.css";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
|
||||
function getTitle(name: string, icon: string) {
|
||||
if (icon) {
|
||||
return `${icon} ${name}`;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
export default function Breadcrumb() {
|
||||
const treeData = useAtomValue(treeDataAtom);
|
||||
const [breadcrumbNodes, setBreadcrumbNodes] = useState<
|
||||
@ -48,7 +55,7 @@ export default function Breadcrumb() {
|
||||
variant="default"
|
||||
style={{ border: "none" }}
|
||||
>
|
||||
<Text truncate="end">{node.name}</Text>
|
||||
<Text truncate="end">{getTitle(node.name, node.icon)}</Text>
|
||||
</Button>
|
||||
</Button.Group>
|
||||
));
|
||||
@ -56,6 +63,8 @@ export default function Breadcrumb() {
|
||||
const getLastNthNode = (n: number) =>
|
||||
breadcrumbNodes && breadcrumbNodes[breadcrumbNodes.length - n];
|
||||
|
||||
// const getTitle = (title: string) => (title?.length > 0 ? title : "untitled");
|
||||
|
||||
const getBreadcrumbItems = () => {
|
||||
if (breadcrumbNodes?.length > 3) {
|
||||
return [
|
||||
@ -65,7 +74,7 @@ export default function Breadcrumb() {
|
||||
underline="never"
|
||||
key={breadcrumbNodes[0].id}
|
||||
>
|
||||
{breadcrumbNodes[0].name}
|
||||
{getTitle(breadcrumbNodes[0].name, breadcrumbNodes[0].icon)}
|
||||
</Anchor>,
|
||||
<Popover
|
||||
width={250}
|
||||
@ -89,7 +98,7 @@ export default function Breadcrumb() {
|
||||
underline="never"
|
||||
key={getLastNthNode(2)?.id}
|
||||
>
|
||||
{getLastNthNode(2)?.name}
|
||||
{getTitle(getLastNthNode(2)?.name, getLastNthNode(2)?.icon)}
|
||||
</Anchor>,
|
||||
<Anchor
|
||||
component={Link}
|
||||
@ -97,7 +106,7 @@ export default function Breadcrumb() {
|
||||
underline="never"
|
||||
key={getLastNthNode(1)?.id}
|
||||
>
|
||||
{getLastNthNode(1)?.name}
|
||||
{getTitle(getLastNthNode(1)?.name, getLastNthNode(1)?.icon)}
|
||||
</Anchor>,
|
||||
];
|
||||
}
|
||||
@ -110,7 +119,7 @@ export default function Breadcrumb() {
|
||||
underline="never"
|
||||
key={node.id}
|
||||
>
|
||||
{node.name}
|
||||
{getTitle(node.name, node.icon)}
|
||||
</Anchor>
|
||||
));
|
||||
}
|
||||
|
||||
@ -1,26 +1,31 @@
|
||||
.header,
|
||||
.footer {
|
||||
@media (max-width: 992px) {
|
||||
[data-layout='alt'] & {
|
||||
--_section-right: var(--app-shell-aside-offset, 0px);
|
||||
}
|
||||
@media (max-width: 992px) {
|
||||
[data-layout="alt"] & {
|
||||
--_section-right: var(--app-shell-aside-offset, 0px);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
.aside {
|
||||
@media (min-width: 993px) {
|
||||
background: var(--mantine-color-gray-light);
|
||||
@media (min-width: 993px) {
|
||||
background: var(--mantine-color-gray-light);
|
||||
|
||||
[data-layout='alt'] & {
|
||||
--_section-top: var(--_section-top, var(--app-shell-header-offset, 0px));
|
||||
--_section-height: var(
|
||||
--_section-height,
|
||||
calc(100dvh - var(--app-shell-header-offset, 0px) - var(--app-shell-footer-offset, 0px))
|
||||
);
|
||||
}
|
||||
[data-layout="alt"] & {
|
||||
--_section-top: var(--_section-top, var(--app-shell-header-offset, 0px));
|
||||
--_section-height: var(
|
||||
--_section-height,
|
||||
calc(
|
||||
100dvh - var(--app-shell-header-offset, 0px) -
|
||||
var(--app-shell-footer-offset, 0px)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 48em) {
|
||||
.navbar {
|
||||
width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,7 +15,8 @@ import { useMatchPath } from "@/hooks/use-match-path.tsx";
|
||||
import React from "react";
|
||||
|
||||
export default function Shell({ children }: { children: React.ReactNode }) {
|
||||
const [mobileOpened, { toggle: toggleMobile }] = useDisclosure();
|
||||
const [mobileOpened, { toggle: toggleMobile, close: closeMobile }] =
|
||||
useDisclosure();
|
||||
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
||||
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
|
||||
const matchPath = useMatchPath();
|
||||
@ -38,7 +39,7 @@ export default function Shell({ children }: { children: React.ReactNode }) {
|
||||
}}
|
||||
padding="md"
|
||||
>
|
||||
<AppShell.Header className={classes.header}>
|
||||
<AppShell.Header className={classes.header} withBorder={false}>
|
||||
<Group justify="space-between" h="100%" px="md" wrap="nowrap">
|
||||
<Group
|
||||
h="100%"
|
||||
@ -71,7 +72,7 @@ export default function Shell({ children }: { children: React.ReactNode }) {
|
||||
</Group>
|
||||
</AppShell.Header>
|
||||
|
||||
<AppShell.Navbar>
|
||||
<AppShell.Navbar className={classes.navbar} withBorder={false}>
|
||||
<Navbar />
|
||||
</AppShell.Navbar>
|
||||
|
||||
|
||||
@ -18,7 +18,7 @@ import { updateTreeNodeName } from "@/features/page/tree/utils";
|
||||
|
||||
export interface TitleEditorProps {
|
||||
pageId: string;
|
||||
title: any;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export function TitleEditor({ pageId, title }: TitleEditorProps) {
|
||||
|
||||
@ -2,6 +2,7 @@ import {
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
UseQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
@ -12,6 +13,7 @@ import {
|
||||
getRecentChanges,
|
||||
updatePage,
|
||||
movePage,
|
||||
getPageBreadcrumbs,
|
||||
} from "@/features/page/services/page-service";
|
||||
import {
|
||||
IMovePage,
|
||||
@ -20,6 +22,8 @@ import {
|
||||
} from "@/features/page/types/page.types";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { buildTree } from "@/features/page/tree/utils";
|
||||
|
||||
const RECENT_CHANGES_KEY = ["recentChanges"];
|
||||
|
||||
@ -51,9 +55,13 @@ export function useCreatePageMutation() {
|
||||
}
|
||||
|
||||
export function useUpdatePageMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<IPage, Error, Partial<IPage>>({
|
||||
mutationFn: (data) => updatePage(data),
|
||||
onSuccess: (data) => {},
|
||||
onSuccess: (data) => {
|
||||
// update page in cache
|
||||
queryClient.setQueryData(["pages", data.id], data);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -97,3 +105,23 @@ export function useGetRootSidebarPagesQuery(data: SidebarPagesParams) {
|
||||
lastPage.meta.hasNextPage ? lastPage.meta.page + 1 : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePageBreadcrumbsQuery(
|
||||
pageId: string,
|
||||
): UseQueryResult<Partial<IPage[]>, Error> {
|
||||
return useQuery({
|
||||
queryKey: ["breadcrumbs", pageId],
|
||||
queryFn: () => getPageBreadcrumbs(pageId),
|
||||
enabled: !!pageId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchAncestorChildren(params: SidebarPagesParams) {
|
||||
// not using a hook here, so we can call it inside a useEffect hook
|
||||
const response = await queryClient.fetchQuery({
|
||||
queryKey: ["sidebar-pages", params],
|
||||
queryFn: () => getSidebarPages(params),
|
||||
staleTime: 30 * 60 * 1000,
|
||||
});
|
||||
return buildTree(response.items);
|
||||
}
|
||||
|
||||
@ -40,3 +40,10 @@ export async function getSidebarPages(
|
||||
const req = await api.post("/pages/sidebar-pages", params);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getPageBreadcrumbs(
|
||||
pageId: string,
|
||||
): Promise<Partial<IPage[]>> {
|
||||
const req = await api.post("/pages/breadcrumbs", { pageId });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
@ -1,28 +0,0 @@
|
||||
import React, { ReactElement } from 'react';
|
||||
import { useElementSize } from '@mantine/hooks';
|
||||
import { useMergedRef } from '@mantine/hooks';
|
||||
|
||||
type Props = {
|
||||
children: (dimens: { width: number; height: number }) => ReactElement;
|
||||
};
|
||||
|
||||
const style = {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
};
|
||||
|
||||
export const FillFlexParent = React.forwardRef(function FillFlexParent(
|
||||
props: Props,
|
||||
forwardRef
|
||||
) {
|
||||
const { ref, width, height } = useElementSize();
|
||||
const mergedRef = useMergedRef(ref, forwardRef);
|
||||
return (
|
||||
<div style={style} ref={mergedRef}>
|
||||
{width && height ? props.children({ width, height }) : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@ -1,14 +1,14 @@
|
||||
import { NodeApi, NodeRendererProps, Tree, TreeApi } from "react-arborist";
|
||||
import { useAtom } from "jotai";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
|
||||
import {
|
||||
fetchAncestorChildren,
|
||||
useGetRootSidebarPagesQuery,
|
||||
useUpdatePageMutation,
|
||||
} from "@/features/page/queries/page-query.ts";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import classes from "@/features/page/tree/styles/tree.module.css";
|
||||
import { FillFlexParent } from "@/features/page/tree/components/fill-flex-parent.tsx";
|
||||
import { ActionIcon, Menu, rem } from "@mantine/core";
|
||||
import {
|
||||
IconChevronDown,
|
||||
@ -26,22 +26,32 @@ import clsx from "clsx";
|
||||
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
|
||||
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
||||
import {
|
||||
appendNodeChildren,
|
||||
buildTree,
|
||||
buildTreeWithChildren,
|
||||
updateTreeNodeIcon,
|
||||
} from "@/features/page/tree/utils/utils.ts";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import { getSidebarPages } from "@/features/page/services/page-service.ts";
|
||||
import { SidebarPagesParams } from "@/features/page/types/page.types.ts";
|
||||
import {
|
||||
getPageBreadcrumbs,
|
||||
getSidebarPages,
|
||||
} from "@/features/page/services/page-service.ts";
|
||||
import { IPage, SidebarPagesParams } from "@/features/page/types/page.types.ts";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { OpenMap } from "react-arborist/dist/main/state/open-slice";
|
||||
import { useElementSize, useMergedRef } from "@mantine/hooks";
|
||||
import { dfs } from "react-arborist/dist/module/utils";
|
||||
|
||||
interface SpaceTreeProps {
|
||||
spaceId: string;
|
||||
}
|
||||
|
||||
const openTreeNodesAtom = atom<OpenMap>({});
|
||||
|
||||
export default function SpaceTree({ spaceId }: SpaceTreeProps) {
|
||||
const { pageId } = useParams();
|
||||
const { data, setData, controllers } =
|
||||
useTreeMutation<TreeApi<SpaceTreeNode>>(spaceId);
|
||||
const [treeAPi, setTreeApi] = useAtom<TreeApi<SpaceTreeNode>>(treeApiAtom);
|
||||
const {
|
||||
data: pagesData,
|
||||
hasNextPage,
|
||||
@ -50,8 +60,13 @@ export default function SpaceTree({ spaceId }: SpaceTreeProps) {
|
||||
} = useGetRootSidebarPagesQuery({
|
||||
spaceId,
|
||||
});
|
||||
const [, setTreeApi] = useAtom<TreeApi<SpaceTreeNode>>(treeApiAtom);
|
||||
const treeApiRef = useRef<TreeApi<SpaceTreeNode>>();
|
||||
const [openTreeNodes, setOpenTreeNodes] = useAtom<OpenMap>(openTreeNodesAtom);
|
||||
const rootElement = useRef<HTMLDivElement>();
|
||||
const { pageId } = useParams();
|
||||
const { ref: sizeRef, width, height } = useElementSize();
|
||||
const mergedRef = useMergedRef(rootElement, sizeRef);
|
||||
const isDataLoaded = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasNextPage && !isFetching) {
|
||||
@ -63,67 +78,134 @@ export default function SpaceTree({ spaceId }: SpaceTreeProps) {
|
||||
if (pagesData?.pages && !hasNextPage) {
|
||||
const allItems = pagesData.pages.flatMap((page) => page.items);
|
||||
const treeData = buildTree(allItems);
|
||||
setData(treeData);
|
||||
if (data.length < 1) {
|
||||
//Thoughts
|
||||
// don't reset if there is data in state
|
||||
// we only expect to call this once on initial load
|
||||
// even if we decide to refetch, it should only update
|
||||
// and append root pages instead of resetting the entire tree
|
||||
// which looses async loaded children too
|
||||
setData(treeData);
|
||||
isDataLoaded.current = true;
|
||||
}
|
||||
}
|
||||
}, [pagesData, hasNextPage]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (isDataLoaded.current) {
|
||||
// check if pageId node is present in the tree
|
||||
const node = dfs(treeApiRef.current.root, pageId);
|
||||
if (node) {
|
||||
// if node is found, no need to traverse its ancestors
|
||||
return;
|
||||
}
|
||||
|
||||
// if not found, fetch and build its ancestors and their children
|
||||
if (!pageId) return;
|
||||
const ancestors = await getPageBreadcrumbs(pageId);
|
||||
|
||||
if (ancestors && ancestors?.length > 1) {
|
||||
let flatTreeItems = [...buildTree(ancestors)];
|
||||
|
||||
const fetchAndUpdateChildren = async (ancestor: IPage) => {
|
||||
// we don't want to fetch the children of the opened page
|
||||
if (ancestor.id === pageId) {
|
||||
return;
|
||||
}
|
||||
const children = await fetchAncestorChildren({
|
||||
pageId: ancestor.id,
|
||||
spaceId: ancestor.spaceId,
|
||||
});
|
||||
|
||||
flatTreeItems = [
|
||||
...flatTreeItems,
|
||||
...children.filter(
|
||||
(child) => !flatTreeItems.some((item) => item.id === child.id),
|
||||
),
|
||||
];
|
||||
};
|
||||
|
||||
const fetchPromises = ancestors.map((ancestor) =>
|
||||
fetchAndUpdateChildren(ancestor),
|
||||
);
|
||||
|
||||
// Wait for all fetch operations to complete
|
||||
Promise.all(fetchPromises).then(() => {
|
||||
// build tree with children
|
||||
const ancestorsTree = buildTreeWithChildren(flatTreeItems);
|
||||
// child of root page we're attaching the built ancestors to
|
||||
const rootChild = ancestorsTree[0];
|
||||
|
||||
// attach built ancestors to tree
|
||||
const updatedTree = appendNodeChildren(
|
||||
data,
|
||||
rootChild.id,
|
||||
rootChild.children,
|
||||
);
|
||||
setData(updatedTree);
|
||||
|
||||
setTimeout(() => {
|
||||
// focus on node and open all parents
|
||||
treeApiRef.current.select(pageId);
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [isDataLoaded.current, pageId]);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
treeAPi?.select(pageId, { align: "auto" });
|
||||
treeApiRef.current?.select(pageId, { align: "auto" });
|
||||
}, 200);
|
||||
}, [treeAPi, pageId]);
|
||||
}, [pageId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (treeApiRef.current) {
|
||||
// @ts-ignore
|
||||
setTreeApi(treeApiRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (openTreeNodes) {
|
||||
treeApiRef.current.state.nodes.open.unfiltered = openTreeNodes;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={rootElement} className={classes.treeContainer}>
|
||||
<FillFlexParent>
|
||||
{(dimens) => (
|
||||
<Tree
|
||||
data={data}
|
||||
{...controllers}
|
||||
{...dimens}
|
||||
// @ts-ignore
|
||||
ref={(t) => setTreeApi(t)}
|
||||
openByDefault={false}
|
||||
disableMultiSelection={true}
|
||||
className={classes.tree}
|
||||
rowClassName={classes.row}
|
||||
rowHeight={30}
|
||||
overscanCount={8}
|
||||
dndRootElement={rootElement.current}
|
||||
selectionFollowsFocus
|
||||
>
|
||||
{Node}
|
||||
</Tree>
|
||||
)}
|
||||
</FillFlexParent>
|
||||
<div ref={mergedRef} className={classes.treeContainer}>
|
||||
<Tree
|
||||
data={data}
|
||||
{...controllers}
|
||||
width={width}
|
||||
height={height}
|
||||
ref={treeApiRef}
|
||||
openByDefault={false}
|
||||
disableMultiSelection={true}
|
||||
className={classes.tree}
|
||||
rowClassName={classes.row}
|
||||
rowHeight={30}
|
||||
overscanCount={10}
|
||||
dndRootElement={rootElement.current}
|
||||
onToggle={() => {
|
||||
setOpenTreeNodes(treeApiRef.current.openState);
|
||||
}}
|
||||
>
|
||||
{Node}
|
||||
</Tree>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Node({ node, style, dragHandle }: NodeRendererProps<any>) {
|
||||
function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
||||
const navigate = useNavigate();
|
||||
const updatePageMutation = useUpdatePageMutation();
|
||||
const [treeData, setTreeData] = useAtom(treeDataAtom);
|
||||
|
||||
function updateTreeData(
|
||||
treeItems: SpaceTreeNode[],
|
||||
nodeId: string,
|
||||
children: SpaceTreeNode[],
|
||||
) {
|
||||
return treeItems.map((nodeItem) => {
|
||||
if (nodeItem.id === nodeId) {
|
||||
return { ...nodeItem, children };
|
||||
}
|
||||
if (nodeItem.children) {
|
||||
return {
|
||||
...nodeItem,
|
||||
children: updateTreeData(nodeItem.children, nodeId, children),
|
||||
};
|
||||
}
|
||||
return nodeItem;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleLoadChildren(node: NodeApi<SpaceTreeNode>) {
|
||||
if (!node.data.hasChildren) return;
|
||||
if (node.data.children && node.data.children.length > 0) {
|
||||
@ -139,11 +221,12 @@ function Node({ node, style, dragHandle }: NodeRendererProps<any>) {
|
||||
const newChildren = await queryClient.fetchQuery({
|
||||
queryKey: ["sidebar-pages", params],
|
||||
queryFn: () => getSidebarPages(params),
|
||||
staleTime: 30 * 60 * 1000,
|
||||
});
|
||||
|
||||
const childrenTree = buildTree(newChildren.items);
|
||||
|
||||
const updatedTreeData = updateTreeData(
|
||||
const updatedTreeData = appendNodeChildren(
|
||||
treeData,
|
||||
node.data.id,
|
||||
childrenTree,
|
||||
@ -160,11 +243,11 @@ function Node({ node, style, dragHandle }: NodeRendererProps<any>) {
|
||||
};
|
||||
|
||||
const handleUpdateNodeIcon = (nodeId: string, newIcon: string) => {
|
||||
const updatedTree = updateTreeNodeIcon(treeData, node.id, newIcon);
|
||||
const updatedTree = updateTreeNodeIcon(treeData, nodeId, newIcon);
|
||||
setTreeData(updatedTree);
|
||||
};
|
||||
|
||||
const handleEmojiIconClick = (e) => {
|
||||
const handleEmojiIconClick = (e: any) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
@ -213,9 +296,10 @@ function Node({ node, style, dragHandle }: NodeRendererProps<any>) {
|
||||
<span className={classes.text}>{node.data.name || "untitled"}</span>
|
||||
|
||||
<div className={classes.actions}>
|
||||
<NodeMenu node={node} />
|
||||
<NodeMenu node={node} treeApi={tree} />
|
||||
<CreateNode
|
||||
node={node}
|
||||
treeApi={tree}
|
||||
onExpandTree={() => handleLoadChildren(node)}
|
||||
/>
|
||||
</div>
|
||||
@ -226,11 +310,10 @@ function Node({ node, style, dragHandle }: NodeRendererProps<any>) {
|
||||
|
||||
interface CreateNodeProps {
|
||||
node: NodeApi<SpaceTreeNode>;
|
||||
treeApi: TreeApi<SpaceTreeNode>;
|
||||
onExpandTree?: () => void;
|
||||
}
|
||||
function CreateNode({ node, onExpandTree }: CreateNodeProps) {
|
||||
const [treeApi] = useAtom(treeApiAtom);
|
||||
|
||||
function CreateNode({ node, treeApi, onExpandTree }: CreateNodeProps) {
|
||||
function handleCreate() {
|
||||
if (node.data.hasChildren && node.children.length === 0) {
|
||||
node.toggle();
|
||||
@ -259,9 +342,11 @@ function CreateNode({ node, onExpandTree }: CreateNodeProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function NodeMenu({ node }: { node: NodeApi<SpaceTreeNode> }) {
|
||||
const [treeApi] = useAtom(treeApiAtom);
|
||||
|
||||
interface NodeMenuProps {
|
||||
node: NodeApi<SpaceTreeNode>;
|
||||
treeApi: TreeApi<SpaceTreeNode>;
|
||||
}
|
||||
function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
||||
return (
|
||||
<Menu shadow="md" width={200}>
|
||||
<Menu.Target>
|
||||
@ -283,6 +368,10 @@ function NodeMenu({ node }: { node: NodeApi<SpaceTreeNode> }) {
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconStar style={{ width: rem(14), height: rem(14) }} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
Favorite
|
||||
</Menu.Item>
|
||||
@ -291,6 +380,10 @@ function NodeMenu({ node }: { node: NodeApi<SpaceTreeNode> }) {
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconLink style={{ width: rem(14), height: rem(14) }} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
Copy link
|
||||
</Menu.Item>
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
width: 93%; /* not to overlap with scroll bar */
|
||||
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ export type SpaceTreeNode = {
|
||||
position: string;
|
||||
slug?: string;
|
||||
spaceId: string;
|
||||
parentPageId: string;
|
||||
hasChildren: boolean;
|
||||
children: SpaceTreeNode[];
|
||||
};
|
||||
|
||||
@ -22,6 +22,7 @@ export function buildTree(pages: IPage[]): SpaceTreeNode[] {
|
||||
position: page.position,
|
||||
hasChildren: page.hasChildren,
|
||||
spaceId: page.spaceId,
|
||||
parentPageId: page.parentPageId,
|
||||
children: [],
|
||||
};
|
||||
});
|
||||
@ -97,3 +98,59 @@ export const updateTreeNodeIcon = (
|
||||
return node;
|
||||
});
|
||||
};
|
||||
|
||||
export function buildTreeWithChildren(items: SpaceTreeNode[]): SpaceTreeNode[] {
|
||||
const nodeMap = {};
|
||||
let result: SpaceTreeNode[] = [];
|
||||
|
||||
// Create a reference object for each item with the specified structure
|
||||
items.forEach((item) => {
|
||||
nodeMap[item.id] = { ...item, children: [] };
|
||||
});
|
||||
|
||||
// Build the tree array
|
||||
items.forEach((item) => {
|
||||
const node = nodeMap[item.id];
|
||||
if (item.parentPageId !== null) {
|
||||
// Find the parent node and add the current node to its children
|
||||
nodeMap[item.parentPageId].children.push(node);
|
||||
} else {
|
||||
// If the item has no parent, it's a root node, so add it to the result array
|
||||
result.push(node);
|
||||
}
|
||||
});
|
||||
|
||||
result = sortPositionKeys(result);
|
||||
|
||||
// Recursively sort the children of each node
|
||||
function sortChildren(node: SpaceTreeNode) {
|
||||
if (node.children.length > 0) {
|
||||
node.hasChildren = true;
|
||||
node.children = sortPositionKeys(node.children);
|
||||
node.children.forEach(sortChildren);
|
||||
}
|
||||
}
|
||||
|
||||
result.forEach(sortChildren);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function appendNodeChildren(
|
||||
treeItems: SpaceTreeNode[],
|
||||
nodeId: string,
|
||||
children: SpaceTreeNode[],
|
||||
) {
|
||||
return treeItems.map((nodeItem) => {
|
||||
if (nodeItem.id === nodeId) {
|
||||
return { ...nodeItem, children };
|
||||
}
|
||||
if (nodeItem.children) {
|
||||
return {
|
||||
...nodeItem,
|
||||
children: appendNodeChildren(nodeItem.children, nodeId, children),
|
||||
};
|
||||
}
|
||||
return nodeItem;
|
||||
});
|
||||
}
|
||||
|
||||
@ -104,4 +104,10 @@ export class PageController {
|
||||
async movePage(@Body() movePageDto: MovePageDto) {
|
||||
return this.pageService.movePage(movePageDto);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('/breadcrumbs')
|
||||
async getPageBreadcrumbs(@Body() dto: PageIdDto) {
|
||||
return this.pageService.getPageBreadCrumbs(dto.pageId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -222,6 +222,59 @@ export class PageService {
|
||||
// permissions
|
||||
}
|
||||
|
||||
async getPageBreadCrumbs(childPageId: string) {
|
||||
const ancestors = await this.db
|
||||
.withRecursive('page_ancestors', (db) =>
|
||||
db
|
||||
.selectFrom('pages')
|
||||
.select([
|
||||
'id',
|
||||
'title',
|
||||
'icon',
|
||||
'position',
|
||||
'parentPageId',
|
||||
'spaceId',
|
||||
])
|
||||
.select((eb) => this.withHasChildren(eb))
|
||||
.where('id', '=', childPageId)
|
||||
.unionAll((exp) =>
|
||||
exp
|
||||
.selectFrom('pages as p')
|
||||
.select([
|
||||
'p.id',
|
||||
'p.title',
|
||||
'p.icon',
|
||||
'p.position',
|
||||
'p.parentPageId',
|
||||
'p.spaceId',
|
||||
])
|
||||
.select(
|
||||
exp
|
||||
.selectFrom('pages as child')
|
||||
.select((eb) =>
|
||||
eb
|
||||
.case()
|
||||
.when(eb.fn.countAll(), '>', 0)
|
||||
.then(true)
|
||||
.else(false)
|
||||
.end()
|
||||
.as('count'),
|
||||
)
|
||||
.whereRef('child.parentPageId', '=', 'id')
|
||||
.limit(1)
|
||||
.as('hasChildren'),
|
||||
)
|
||||
//.select((eb) => this.withHasChildren(eb))
|
||||
.innerJoin('page_ancestors as pa', 'pa.parentPageId', 'p.id'),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_ancestors')
|
||||
.selectAll()
|
||||
.execute();
|
||||
|
||||
return ancestors.reverse();
|
||||
}
|
||||
|
||||
async getRecentSpacePages(
|
||||
spaceId: string,
|
||||
pagination: PaginationOptions,
|
||||
|
||||
Reference in New Issue
Block a user