mirror of
https://github.com/docmost/docmost.git
synced 2025-11-13 11:42:37 +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 classes from "./breadcrumb.module.css";
|
||||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
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() {
|
export default function Breadcrumb() {
|
||||||
const treeData = useAtomValue(treeDataAtom);
|
const treeData = useAtomValue(treeDataAtom);
|
||||||
const [breadcrumbNodes, setBreadcrumbNodes] = useState<
|
const [breadcrumbNodes, setBreadcrumbNodes] = useState<
|
||||||
@ -48,7 +55,7 @@ export default function Breadcrumb() {
|
|||||||
variant="default"
|
variant="default"
|
||||||
style={{ border: "none" }}
|
style={{ border: "none" }}
|
||||||
>
|
>
|
||||||
<Text truncate="end">{node.name}</Text>
|
<Text truncate="end">{getTitle(node.name, node.icon)}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Button.Group>
|
</Button.Group>
|
||||||
));
|
));
|
||||||
@ -56,6 +63,8 @@ export default function Breadcrumb() {
|
|||||||
const getLastNthNode = (n: number) =>
|
const getLastNthNode = (n: number) =>
|
||||||
breadcrumbNodes && breadcrumbNodes[breadcrumbNodes.length - n];
|
breadcrumbNodes && breadcrumbNodes[breadcrumbNodes.length - n];
|
||||||
|
|
||||||
|
// const getTitle = (title: string) => (title?.length > 0 ? title : "untitled");
|
||||||
|
|
||||||
const getBreadcrumbItems = () => {
|
const getBreadcrumbItems = () => {
|
||||||
if (breadcrumbNodes?.length > 3) {
|
if (breadcrumbNodes?.length > 3) {
|
||||||
return [
|
return [
|
||||||
@ -65,7 +74,7 @@ export default function Breadcrumb() {
|
|||||||
underline="never"
|
underline="never"
|
||||||
key={breadcrumbNodes[0].id}
|
key={breadcrumbNodes[0].id}
|
||||||
>
|
>
|
||||||
{breadcrumbNodes[0].name}
|
{getTitle(breadcrumbNodes[0].name, breadcrumbNodes[0].icon)}
|
||||||
</Anchor>,
|
</Anchor>,
|
||||||
<Popover
|
<Popover
|
||||||
width={250}
|
width={250}
|
||||||
@ -89,7 +98,7 @@ export default function Breadcrumb() {
|
|||||||
underline="never"
|
underline="never"
|
||||||
key={getLastNthNode(2)?.id}
|
key={getLastNthNode(2)?.id}
|
||||||
>
|
>
|
||||||
{getLastNthNode(2)?.name}
|
{getTitle(getLastNthNode(2)?.name, getLastNthNode(2)?.icon)}
|
||||||
</Anchor>,
|
</Anchor>,
|
||||||
<Anchor
|
<Anchor
|
||||||
component={Link}
|
component={Link}
|
||||||
@ -97,7 +106,7 @@ export default function Breadcrumb() {
|
|||||||
underline="never"
|
underline="never"
|
||||||
key={getLastNthNode(1)?.id}
|
key={getLastNthNode(1)?.id}
|
||||||
>
|
>
|
||||||
{getLastNthNode(1)?.name}
|
{getTitle(getLastNthNode(1)?.name, getLastNthNode(1)?.icon)}
|
||||||
</Anchor>,
|
</Anchor>,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -110,7 +119,7 @@ export default function Breadcrumb() {
|
|||||||
underline="never"
|
underline="never"
|
||||||
key={node.id}
|
key={node.id}
|
||||||
>
|
>
|
||||||
{node.name}
|
{getTitle(node.name, node.icon)}
|
||||||
</Anchor>
|
</Anchor>
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,26 +1,31 @@
|
|||||||
.header,
|
.header,
|
||||||
.footer {
|
.footer {
|
||||||
@media (max-width: 992px) {
|
@media (max-width: 992px) {
|
||||||
[data-layout='alt'] & {
|
[data-layout="alt"] & {
|
||||||
--_section-right: var(--app-shell-aside-offset, 0px);
|
--_section-right: var(--app-shell-aside-offset, 0px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.aside {
|
.aside {
|
||||||
@media (min-width: 993px) {
|
@media (min-width: 993px) {
|
||||||
background: var(--mantine-color-gray-light);
|
background: var(--mantine-color-gray-light);
|
||||||
|
|
||||||
[data-layout='alt'] & {
|
[data-layout="alt"] & {
|
||||||
--_section-top: var(--_section-top, var(--app-shell-header-offset, 0px));
|
--_section-top: var(--_section-top, var(--app-shell-header-offset, 0px));
|
||||||
--_section-height: var(
|
--_section-height: var(
|
||||||
--_section-height,
|
--_section-height,
|
||||||
calc(100dvh - var(--app-shell-header-offset, 0px) - var(--app-shell-footer-offset, 0px))
|
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";
|
import React from "react";
|
||||||
|
|
||||||
export default function Shell({ children }: { children: React.ReactNode }) {
|
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 [desktopOpened] = useAtom(desktopSidebarAtom);
|
||||||
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
|
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
|
||||||
const matchPath = useMatchPath();
|
const matchPath = useMatchPath();
|
||||||
@ -38,7 +39,7 @@ export default function Shell({ children }: { children: React.ReactNode }) {
|
|||||||
}}
|
}}
|
||||||
padding="md"
|
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 justify="space-between" h="100%" px="md" wrap="nowrap">
|
||||||
<Group
|
<Group
|
||||||
h="100%"
|
h="100%"
|
||||||
@ -71,7 +72,7 @@ export default function Shell({ children }: { children: React.ReactNode }) {
|
|||||||
</Group>
|
</Group>
|
||||||
</AppShell.Header>
|
</AppShell.Header>
|
||||||
|
|
||||||
<AppShell.Navbar>
|
<AppShell.Navbar className={classes.navbar} withBorder={false}>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
</AppShell.Navbar>
|
</AppShell.Navbar>
|
||||||
|
|
||||||
|
|||||||
@ -18,7 +18,7 @@ import { updateTreeNodeName } from "@/features/page/tree/utils";
|
|||||||
|
|
||||||
export interface TitleEditorProps {
|
export interface TitleEditorProps {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
title: any;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TitleEditor({ pageId, title }: TitleEditorProps) {
|
export function TitleEditor({ pageId, title }: TitleEditorProps) {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import {
|
|||||||
useInfiniteQuery,
|
useInfiniteQuery,
|
||||||
useMutation,
|
useMutation,
|
||||||
useQuery,
|
useQuery,
|
||||||
|
useQueryClient,
|
||||||
UseQueryResult,
|
UseQueryResult,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
@ -12,6 +13,7 @@ import {
|
|||||||
getRecentChanges,
|
getRecentChanges,
|
||||||
updatePage,
|
updatePage,
|
||||||
movePage,
|
movePage,
|
||||||
|
getPageBreadcrumbs,
|
||||||
} from "@/features/page/services/page-service";
|
} from "@/features/page/services/page-service";
|
||||||
import {
|
import {
|
||||||
IMovePage,
|
IMovePage,
|
||||||
@ -20,6 +22,8 @@ import {
|
|||||||
} from "@/features/page/types/page.types";
|
} from "@/features/page/types/page.types";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { IPagination } from "@/lib/types.ts";
|
import { IPagination } from "@/lib/types.ts";
|
||||||
|
import { queryClient } from "@/main.tsx";
|
||||||
|
import { buildTree } from "@/features/page/tree/utils";
|
||||||
|
|
||||||
const RECENT_CHANGES_KEY = ["recentChanges"];
|
const RECENT_CHANGES_KEY = ["recentChanges"];
|
||||||
|
|
||||||
@ -51,9 +55,13 @@ export function useCreatePageMutation() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useUpdatePageMutation() {
|
export function useUpdatePageMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
return useMutation<IPage, Error, Partial<IPage>>({
|
return useMutation<IPage, Error, Partial<IPage>>({
|
||||||
mutationFn: (data) => updatePage(data),
|
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,
|
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);
|
const req = await api.post("/pages/sidebar-pages", params);
|
||||||
return req.data;
|
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 { 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 { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
|
||||||
import {
|
import {
|
||||||
|
fetchAncestorChildren,
|
||||||
useGetRootSidebarPagesQuery,
|
useGetRootSidebarPagesQuery,
|
||||||
useUpdatePageMutation,
|
useUpdatePageMutation,
|
||||||
} from "@/features/page/queries/page-query.ts";
|
} from "@/features/page/queries/page-query.ts";
|
||||||
import React, { useEffect, useRef } from "react";
|
import React, { useEffect, useRef } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import classes from "@/features/page/tree/styles/tree.module.css";
|
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 { ActionIcon, Menu, rem } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
@ -26,22 +26,32 @@ import clsx from "clsx";
|
|||||||
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
|
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
|
||||||
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
||||||
import {
|
import {
|
||||||
|
appendNodeChildren,
|
||||||
buildTree,
|
buildTree,
|
||||||
|
buildTreeWithChildren,
|
||||||
updateTreeNodeIcon,
|
updateTreeNodeIcon,
|
||||||
} 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 { getSidebarPages } from "@/features/page/services/page-service.ts";
|
import {
|
||||||
import { SidebarPagesParams } from "@/features/page/types/page.types.ts";
|
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 { 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 {
|
interface SpaceTreeProps {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openTreeNodesAtom = atom<OpenMap>({});
|
||||||
|
|
||||||
export default function SpaceTree({ spaceId }: SpaceTreeProps) {
|
export default function SpaceTree({ spaceId }: SpaceTreeProps) {
|
||||||
|
const { pageId } = useParams();
|
||||||
const { data, setData, controllers } =
|
const { data, setData, controllers } =
|
||||||
useTreeMutation<TreeApi<SpaceTreeNode>>(spaceId);
|
useTreeMutation<TreeApi<SpaceTreeNode>>(spaceId);
|
||||||
const [treeAPi, setTreeApi] = useAtom<TreeApi<SpaceTreeNode>>(treeApiAtom);
|
|
||||||
const {
|
const {
|
||||||
data: pagesData,
|
data: pagesData,
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
@ -50,8 +60,13 @@ export default function SpaceTree({ spaceId }: SpaceTreeProps) {
|
|||||||
} = useGetRootSidebarPagesQuery({
|
} = useGetRootSidebarPagesQuery({
|
||||||
spaceId,
|
spaceId,
|
||||||
});
|
});
|
||||||
|
const [, setTreeApi] = useAtom<TreeApi<SpaceTreeNode>>(treeApiAtom);
|
||||||
|
const treeApiRef = useRef<TreeApi<SpaceTreeNode>>();
|
||||||
|
const [openTreeNodes, setOpenTreeNodes] = useAtom<OpenMap>(openTreeNodesAtom);
|
||||||
const rootElement = useRef<HTMLDivElement>();
|
const rootElement = useRef<HTMLDivElement>();
|
||||||
const { pageId } = useParams();
|
const { ref: sizeRef, width, height } = useElementSize();
|
||||||
|
const mergedRef = useMergedRef(rootElement, sizeRef);
|
||||||
|
const isDataLoaded = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasNextPage && !isFetching) {
|
if (hasNextPage && !isFetching) {
|
||||||
@ -63,67 +78,134 @@ export default function SpaceTree({ spaceId }: SpaceTreeProps) {
|
|||||||
if (pagesData?.pages && !hasNextPage) {
|
if (pagesData?.pages && !hasNextPage) {
|
||||||
const allItems = pagesData.pages.flatMap((page) => page.items);
|
const allItems = pagesData.pages.flatMap((page) => page.items);
|
||||||
const treeData = buildTree(allItems);
|
const treeData = buildTree(allItems);
|
||||||
|
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);
|
setData(treeData);
|
||||||
|
isDataLoaded.current = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [pagesData, hasNextPage]);
|
}, [pagesData, hasNextPage]);
|
||||||
|
|
||||||
useEffect(() => {
|
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(() => {
|
setTimeout(() => {
|
||||||
treeAPi?.select(pageId, { align: "auto" });
|
// focus on node and open all parents
|
||||||
|
treeApiRef.current.select(pageId);
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [isDataLoaded.current, pageId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
treeApiRef.current?.select(pageId, { align: "auto" });
|
||||||
}, 200);
|
}, 200);
|
||||||
}, [treeAPi, pageId]);
|
}, [pageId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (treeApiRef.current) {
|
||||||
|
// @ts-ignore
|
||||||
|
setTreeApi(treeApiRef.current);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (openTreeNodes) {
|
||||||
|
treeApiRef.current.state.nodes.open.unfiltered = openTreeNodes;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={rootElement} className={classes.treeContainer}>
|
<div ref={mergedRef} className={classes.treeContainer}>
|
||||||
<FillFlexParent>
|
|
||||||
{(dimens) => (
|
|
||||||
<Tree
|
<Tree
|
||||||
data={data}
|
data={data}
|
||||||
{...controllers}
|
{...controllers}
|
||||||
{...dimens}
|
width={width}
|
||||||
// @ts-ignore
|
height={height}
|
||||||
ref={(t) => setTreeApi(t)}
|
ref={treeApiRef}
|
||||||
openByDefault={false}
|
openByDefault={false}
|
||||||
disableMultiSelection={true}
|
disableMultiSelection={true}
|
||||||
className={classes.tree}
|
className={classes.tree}
|
||||||
rowClassName={classes.row}
|
rowClassName={classes.row}
|
||||||
rowHeight={30}
|
rowHeight={30}
|
||||||
overscanCount={8}
|
overscanCount={10}
|
||||||
dndRootElement={rootElement.current}
|
dndRootElement={rootElement.current}
|
||||||
selectionFollowsFocus
|
onToggle={() => {
|
||||||
|
setOpenTreeNodes(treeApiRef.current.openState);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{Node}
|
{Node}
|
||||||
</Tree>
|
</Tree>
|
||||||
)}
|
|
||||||
</FillFlexParent>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Node({ node, style, dragHandle }: NodeRendererProps<any>) {
|
function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const updatePageMutation = useUpdatePageMutation();
|
const updatePageMutation = useUpdatePageMutation();
|
||||||
const [treeData, setTreeData] = useAtom(treeDataAtom);
|
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>) {
|
async function handleLoadChildren(node: NodeApi<SpaceTreeNode>) {
|
||||||
if (!node.data.hasChildren) return;
|
if (!node.data.hasChildren) return;
|
||||||
if (node.data.children && node.data.children.length > 0) {
|
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({
|
const newChildren = await queryClient.fetchQuery({
|
||||||
queryKey: ["sidebar-pages", params],
|
queryKey: ["sidebar-pages", params],
|
||||||
queryFn: () => getSidebarPages(params),
|
queryFn: () => getSidebarPages(params),
|
||||||
|
staleTime: 30 * 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const childrenTree = buildTree(newChildren.items);
|
const childrenTree = buildTree(newChildren.items);
|
||||||
|
|
||||||
const updatedTreeData = updateTreeData(
|
const updatedTreeData = appendNodeChildren(
|
||||||
treeData,
|
treeData,
|
||||||
node.data.id,
|
node.data.id,
|
||||||
childrenTree,
|
childrenTree,
|
||||||
@ -160,11 +243,11 @@ function Node({ node, style, dragHandle }: NodeRendererProps<any>) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateNodeIcon = (nodeId: string, newIcon: string) => {
|
const handleUpdateNodeIcon = (nodeId: string, newIcon: string) => {
|
||||||
const updatedTree = updateTreeNodeIcon(treeData, node.id, newIcon);
|
const updatedTree = updateTreeNodeIcon(treeData, nodeId, newIcon);
|
||||||
setTreeData(updatedTree);
|
setTreeData(updatedTree);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEmojiIconClick = (e) => {
|
const handleEmojiIconClick = (e: any) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
};
|
};
|
||||||
@ -213,9 +296,10 @@ function Node({ node, style, dragHandle }: NodeRendererProps<any>) {
|
|||||||
<span className={classes.text}>{node.data.name || "untitled"}</span>
|
<span className={classes.text}>{node.data.name || "untitled"}</span>
|
||||||
|
|
||||||
<div className={classes.actions}>
|
<div className={classes.actions}>
|
||||||
<NodeMenu node={node} />
|
<NodeMenu node={node} treeApi={tree} />
|
||||||
<CreateNode
|
<CreateNode
|
||||||
node={node}
|
node={node}
|
||||||
|
treeApi={tree}
|
||||||
onExpandTree={() => handleLoadChildren(node)}
|
onExpandTree={() => handleLoadChildren(node)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -226,11 +310,10 @@ function Node({ node, style, dragHandle }: NodeRendererProps<any>) {
|
|||||||
|
|
||||||
interface CreateNodeProps {
|
interface CreateNodeProps {
|
||||||
node: NodeApi<SpaceTreeNode>;
|
node: NodeApi<SpaceTreeNode>;
|
||||||
|
treeApi: TreeApi<SpaceTreeNode>;
|
||||||
onExpandTree?: () => void;
|
onExpandTree?: () => void;
|
||||||
}
|
}
|
||||||
function CreateNode({ node, onExpandTree }: CreateNodeProps) {
|
function CreateNode({ node, treeApi, onExpandTree }: CreateNodeProps) {
|
||||||
const [treeApi] = useAtom(treeApiAtom);
|
|
||||||
|
|
||||||
function handleCreate() {
|
function handleCreate() {
|
||||||
if (node.data.hasChildren && node.children.length === 0) {
|
if (node.data.hasChildren && node.children.length === 0) {
|
||||||
node.toggle();
|
node.toggle();
|
||||||
@ -259,9 +342,11 @@ function CreateNode({ node, onExpandTree }: CreateNodeProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NodeMenu({ node }: { node: NodeApi<SpaceTreeNode> }) {
|
interface NodeMenuProps {
|
||||||
const [treeApi] = useAtom(treeApiAtom);
|
node: NodeApi<SpaceTreeNode>;
|
||||||
|
treeApi: TreeApi<SpaceTreeNode>;
|
||||||
|
}
|
||||||
|
function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
||||||
return (
|
return (
|
||||||
<Menu shadow="md" width={200}>
|
<Menu shadow="md" width={200}>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
@ -283,6 +368,10 @@ function NodeMenu({ node }: { node: NodeApi<SpaceTreeNode> }) {
|
|||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<IconStar style={{ width: rem(14), height: rem(14) }} />}
|
leftSection={<IconStar style={{ width: rem(14), height: rem(14) }} />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Favorite
|
Favorite
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
@ -291,6 +380,10 @@ function NodeMenu({ node }: { node: NodeApi<SpaceTreeNode> }) {
|
|||||||
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<IconLink style={{ width: rem(14), height: rem(14) }} />}
|
leftSection={<IconLink style={{ width: rem(14), height: rem(14) }} />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Copy link
|
Copy link
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|||||||
@ -15,7 +15,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 100%;
|
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));
|
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,7 @@ export type SpaceTreeNode = {
|
|||||||
position: string;
|
position: string;
|
||||||
slug?: string;
|
slug?: string;
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
|
parentPageId: string;
|
||||||
hasChildren: boolean;
|
hasChildren: boolean;
|
||||||
children: SpaceTreeNode[];
|
children: SpaceTreeNode[];
|
||||||
};
|
};
|
||||||
|
|||||||
@ -22,6 +22,7 @@ export function buildTree(pages: IPage[]): SpaceTreeNode[] {
|
|||||||
position: page.position,
|
position: page.position,
|
||||||
hasChildren: page.hasChildren,
|
hasChildren: page.hasChildren,
|
||||||
spaceId: page.spaceId,
|
spaceId: page.spaceId,
|
||||||
|
parentPageId: page.parentPageId,
|
||||||
children: [],
|
children: [],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@ -97,3 +98,59 @@ export const updateTreeNodeIcon = (
|
|||||||
return node;
|
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) {
|
async movePage(@Body() movePageDto: MovePageDto) {
|
||||||
return this.pageService.movePage(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
|
// 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(
|
async getRecentSpacePages(
|
||||||
spaceId: string,
|
spaceId: string,
|
||||||
pagination: PaginationOptions,
|
pagination: PaginationOptions,
|
||||||
|
|||||||
Reference in New Issue
Block a user