mirror of
https://github.com/docmost/docmost.git
synced 2025-11-13 07:42:37 +10:00
Rework sidebar pages
* Move sidebar pages from workspace to space level * Replace array sorting with lexicographical fractional indexing * Fixes and updates
This commit is contained in:
@ -1,8 +1,7 @@
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { treeDataAtom } from '@/features/page/tree/atoms/tree-data-atom';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { TreeNode } from '@/features/page/tree/types';
|
||||
import { findBreadcrumbPath } from '@/features/page/tree/utils';
|
||||
import { useAtomValue } from "jotai";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { findBreadcrumbPath } from "@/features/page/tree/utils";
|
||||
import {
|
||||
Button,
|
||||
Anchor,
|
||||
@ -10,18 +9,17 @@ import {
|
||||
Breadcrumbs,
|
||||
ActionIcon,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconDots,
|
||||
} from '@tabler/icons-react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import classes from './breadcrumb.module.css';
|
||||
} from "@mantine/core";
|
||||
import { IconDots } from "@tabler/icons-react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import classes from "./breadcrumb.module.css";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
|
||||
export default function Breadcrumb() {
|
||||
const treeData = useAtomValue(treeDataAtom);
|
||||
const [breadcrumbNodes, setBreadcrumbNodes] = useState<TreeNode[] | null>(
|
||||
null,
|
||||
);
|
||||
const [breadcrumbNodes, setBreadcrumbNodes] = useState<
|
||||
SpaceTreeNode[] | null
|
||||
>(null);
|
||||
const { pageId } = useParams();
|
||||
|
||||
useEffect(() => {
|
||||
@ -40,31 +38,42 @@ export default function Breadcrumb() {
|
||||
}
|
||||
}, [pageId, treeData]);
|
||||
|
||||
const HiddenNodesTooltipContent = () => (
|
||||
breadcrumbNodes?.slice(1, -2).map(node => (
|
||||
const HiddenNodesTooltipContent = () =>
|
||||
breadcrumbNodes?.slice(1, -2).map((node) => (
|
||||
<Button.Group orientation="vertical" key={node.id}>
|
||||
<Button
|
||||
justify="start"
|
||||
component={Link}
|
||||
to={`/p/${node.id}`}
|
||||
variant="default"
|
||||
style={{ border: 'none' }}
|
||||
style={{ border: "none" }}
|
||||
>
|
||||
<Text truncate="end">{node.name}</Text>
|
||||
</Button>
|
||||
</Button.Group>
|
||||
))
|
||||
);
|
||||
));
|
||||
|
||||
const getLastNthNode = (n: number) => breadcrumbNodes && breadcrumbNodes[breadcrumbNodes.length - n];
|
||||
const getLastNthNode = (n: number) =>
|
||||
breadcrumbNodes && breadcrumbNodes[breadcrumbNodes.length - n];
|
||||
|
||||
const getBreadcrumbItems = () => {
|
||||
if (breadcrumbNodes?.length > 3) {
|
||||
return [
|
||||
<Anchor component={Link} to={`/p/${breadcrumbNodes[0].id}`} underline="never" key={breadcrumbNodes[0].id}>
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={`/p/${breadcrumbNodes[0].id}`}
|
||||
underline="never"
|
||||
key={breadcrumbNodes[0].id}
|
||||
>
|
||||
{breadcrumbNodes[0].name}
|
||||
</Anchor>,
|
||||
<Popover width={250} position="bottom" withArrow shadow="xl" key="hidden-nodes">
|
||||
<Popover
|
||||
width={250}
|
||||
position="bottom"
|
||||
withArrow
|
||||
shadow="xl"
|
||||
key="hidden-nodes"
|
||||
>
|
||||
<Popover.Target>
|
||||
<ActionIcon c="gray" variant="transparent">
|
||||
<IconDots size={20} stroke={2} />
|
||||
@ -74,18 +83,33 @@ export default function Breadcrumb() {
|
||||
<HiddenNodesTooltipContent />
|
||||
</Popover.Dropdown>
|
||||
</Popover>,
|
||||
<Anchor component={Link} to={`/p/${getLastNthNode(2)?.id}`} underline="never" key={getLastNthNode(2)?.id}>
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={`/p/${getLastNthNode(2)?.id}`}
|
||||
underline="never"
|
||||
key={getLastNthNode(2)?.id}
|
||||
>
|
||||
{getLastNthNode(2)?.name}
|
||||
</Anchor>,
|
||||
<Anchor component={Link} to={`/p/${getLastNthNode(1)?.id}`} underline="never" key={getLastNthNode(1)?.id}>
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={`/p/${getLastNthNode(1)?.id}`}
|
||||
underline="never"
|
||||
key={getLastNthNode(1)?.id}
|
||||
>
|
||||
{getLastNthNode(1)?.name}
|
||||
</Anchor>,
|
||||
];
|
||||
}
|
||||
|
||||
if (breadcrumbNodes) {
|
||||
return breadcrumbNodes.map(node => (
|
||||
<Anchor component={Link} to={`/p/${node.id}`} underline="never" key={node.id}>
|
||||
return breadcrumbNodes.map((node) => (
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={`/p/${node.id}`}
|
||||
underline="never"
|
||||
key={node.id}
|
||||
>
|
||||
{node.name}
|
||||
</Anchor>
|
||||
));
|
||||
@ -98,7 +122,9 @@ export default function Breadcrumb() {
|
||||
<div className={classes.breadcrumb}>
|
||||
{breadcrumbNodes ? (
|
||||
<Breadcrumbs>{getBreadcrumbItems()}</Breadcrumbs>
|
||||
) : (<></>)}
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -20,9 +20,8 @@ import React from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import { SearchSpotlight } from "@/features/search/search-spotlight";
|
||||
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom";
|
||||
import PageTree from "@/features/page/tree/page-tree";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import SpaceContent from "@/features/page/component/space-content.tsx";
|
||||
import SpaceContent from "@/features/page/tree/components/space-content.tsx";
|
||||
|
||||
interface PrimaryMenuItem {
|
||||
icon: React.ElementType;
|
||||
@ -105,7 +104,6 @@ export function Navbar() {
|
||||
|
||||
<div className={classes.pages}>
|
||||
<SpaceContent />
|
||||
{/* <PageTree /> */}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@ -1,15 +1,25 @@
|
||||
import { useMutation, useQuery, UseQueryResult } from "@tanstack/react-query";
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQuery,
|
||||
UseQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
createPage,
|
||||
deletePage,
|
||||
getPageById,
|
||||
getPages,
|
||||
getSidebarPages,
|
||||
getRecentChanges,
|
||||
getSpacePageOrder,
|
||||
updatePage,
|
||||
movePage,
|
||||
} from "@/features/page/services/page-service";
|
||||
import { IPage, IWorkspacePageOrder } from "@/features/page/types/page.types";
|
||||
import {
|
||||
IMovePage,
|
||||
IPage,
|
||||
SidebarPagesParams,
|
||||
} from "@/features/page/types/page.types";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
|
||||
const RECENT_CHANGES_KEY = ["recentChanges"];
|
||||
|
||||
@ -22,16 +32,6 @@ export function usePageQuery(pageId: string): UseQueryResult<IPage, Error> {
|
||||
});
|
||||
}
|
||||
|
||||
export function useGetPagesQuery(
|
||||
spaceId: string,
|
||||
): UseQueryResult<IPage[], Error> {
|
||||
return useQuery({
|
||||
queryKey: ["pages", spaceId],
|
||||
queryFn: () => getPages(spaceId),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRecentChangesQuery(): UseQueryResult<IPage[], Error> {
|
||||
return useQuery({
|
||||
queryKey: RECENT_CHANGES_KEY,
|
||||
@ -44,6 +44,9 @@ export function useCreatePageMutation() {
|
||||
return useMutation<IPage, Error, Partial<IPage>>({
|
||||
mutationFn: (data) => createPage(data),
|
||||
onSuccess: (data) => {},
|
||||
onError: (error) => {
|
||||
notifications.show({ message: "Failed to create page", color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -60,16 +63,37 @@ export function useDeletePageMutation() {
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: "Page deleted successfully" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default function useSpacePageOrder(
|
||||
spaceId: string,
|
||||
): UseQueryResult<IWorkspacePageOrder> {
|
||||
return useQuery({
|
||||
queryKey: ["page-order", spaceId],
|
||||
queryFn: async () => {
|
||||
return await getSpacePageOrder(spaceId);
|
||||
onError: (error) => {
|
||||
notifications.show({ message: "Failed to delete page", color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useMovePageMutation() {
|
||||
return useMutation<void, Error, IMovePage>({
|
||||
mutationFn: (data) => movePage(data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useGetSidebarPagesQuery(
|
||||
data: SidebarPagesParams,
|
||||
): UseQueryResult<IPagination<IPage>, Error> {
|
||||
return useQuery({
|
||||
queryKey: ["sidebar-pages", data],
|
||||
queryFn: () => getSidebarPages(data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useGetRootSidebarPagesQuery(data: SidebarPagesParams) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["root-sidebar-pages", data.spaceId],
|
||||
queryFn: async ({ pageParam }) => {
|
||||
return getSidebarPages({ spaceId: data.spaceId, page: pageParam });
|
||||
},
|
||||
initialPageParam: 1,
|
||||
getPreviousPageParam: (firstPage) =>
|
||||
firstPage.meta.hasPrevPage ? firstPage.meta.page - 1 : undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.meta.hasNextPage ? lastPage.meta.page + 1 : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@ -2,47 +2,41 @@ import api from "@/lib/api-client";
|
||||
import {
|
||||
IMovePage,
|
||||
IPage,
|
||||
IWorkspacePageOrder,
|
||||
SidebarPagesParams,
|
||||
} from "@/features/page/types/page.types";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
|
||||
export async function createPage(data: Partial<IPage>): Promise<IPage> {
|
||||
const req = await api.post<IPage>("/pages/create", data);
|
||||
return req.data as IPage;
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getPageById(pageId: string): Promise<IPage> {
|
||||
const req = await api.post<IPage>("/pages/info", { pageId });
|
||||
return req.data as IPage;
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function updatePage(data: Partial<IPage>): Promise<IPage> {
|
||||
const req = await api.post<IPage>("/pages/update", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function deletePage(pageId: string): Promise<void> {
|
||||
await api.post("/pages/delete", { pageId });
|
||||
}
|
||||
|
||||
export async function movePage(data: IMovePage): Promise<void> {
|
||||
await api.post<void>("/pages/move", data);
|
||||
}
|
||||
|
||||
export async function getRecentChanges(): Promise<IPage[]> {
|
||||
const req = await api.post<IPage[]>("/pages/recent");
|
||||
return req.data as IPage[];
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getPages(spaceId: string): Promise<IPage[]> {
|
||||
const req = await api.post<IPage[]>("/pages", { spaceId });
|
||||
return req.data as IPage[];
|
||||
}
|
||||
|
||||
export async function getSpacePageOrder(
|
||||
spaceId: string,
|
||||
): Promise<IWorkspacePageOrder[]> {
|
||||
const req = await api.post<IWorkspacePageOrder[]>("/pages/ordering", {
|
||||
spaceId,
|
||||
});
|
||||
return req.data as IWorkspacePageOrder[];
|
||||
}
|
||||
|
||||
export async function updatePage(data: Partial<IPage>): Promise<IPage> {
|
||||
const req = await api.post<IPage>(`/pages/update`, data);
|
||||
return req.data as IPage;
|
||||
}
|
||||
|
||||
export async function movePage(data: IMovePage): Promise<void> {
|
||||
await api.post<IMovePage>("/pages/move", data);
|
||||
}
|
||||
|
||||
export async function deletePage(id: string): Promise<void> {
|
||||
await api.post("/pages/delete", { id });
|
||||
export async function getSidebarPages(
|
||||
params: SidebarPagesParams,
|
||||
): Promise<IPagination<IPage>> {
|
||||
const req = await api.post("/pages/sidebar-pages", params);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { atom } from "jotai";
|
||||
import { TreeApi } from 'react-arborist';
|
||||
import { TreeNode } from "../types";
|
||||
import { TreeApi } from "react-arborist";
|
||||
import { SpaceTreeNode } from "../types";
|
||||
|
||||
export const treeApiAtom = atom<TreeApi<TreeNode> | null>(null);
|
||||
export const treeApiAtom = atom<TreeApi<SpaceTreeNode> | null>(null);
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { atom } from "jotai";
|
||||
import { TreeNode } from '@/features/page/tree/types';
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types";
|
||||
|
||||
export const treeDataAtom = atom<TreeNode[]>([]);
|
||||
export const treeDataAtom = atom<SpaceTreeNode[]>([]);
|
||||
|
||||
@ -1,4 +0,0 @@
|
||||
import { atomWithStorage } from "jotai/utils";
|
||||
import { IWorkspacePageOrder } from '@/features/page/types/page.types';
|
||||
|
||||
export const workspacePageOrderAtom = atomWithStorage<IWorkspacePageOrder | null>("workspace-page-order", null);
|
||||
@ -12,7 +12,7 @@ import {
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
import React from "react";
|
||||
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
|
||||
import PageTree from "@/features/page/tree/page-tree.tsx";
|
||||
import SpaceTree from "@/features/page/tree/components/space-tree.tsx";
|
||||
|
||||
export default function SpaceContent() {
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
@ -33,7 +33,7 @@ export default function SpaceContent() {
|
||||
<Accordion.Item key={space.id} value={space.id}>
|
||||
<AccordionControl>{space.name}</AccordionControl>
|
||||
<Accordion.Panel>
|
||||
<PageTree spaceId={space.id} />
|
||||
<SpaceTree spaceId={space.id} />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
@ -45,6 +45,7 @@ function AccordionControl(props: AccordionControlProps) {
|
||||
const [tree] = useAtom(treeApiAtom);
|
||||
|
||||
function handleCreatePage() {
|
||||
//todo: create at the bottom
|
||||
tree?.create({ parentId: null, type: "internal", index: 0 });
|
||||
}
|
||||
|
||||
@ -1,73 +1,79 @@
|
||||
import { NodeApi, NodeRendererProps, Tree, TreeApi } from "react-arborist";
|
||||
import { useAtom } from "jotai";
|
||||
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
|
||||
import {
|
||||
useGetRootSidebarPagesQuery,
|
||||
useGetSidebarPagesQuery,
|
||||
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 {
|
||||
IconArrowsLeftRight,
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconCornerRightUp,
|
||||
IconDotsVertical,
|
||||
IconEdit,
|
||||
IconFileDescription,
|
||||
IconLink,
|
||||
IconPlus,
|
||||
IconPointFilled,
|
||||
IconStar,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
import clsx from "clsx";
|
||||
|
||||
import classes from "./styles/tree.module.css";
|
||||
import { ActionIcon, Menu, rem } from "@mantine/core";
|
||||
import { useAtom } from "jotai";
|
||||
import { FillFlexParent } from "./components/fill-flex-parent";
|
||||
import { TreeNode } from "./types";
|
||||
import { treeApiAtom } from "./atoms/tree-api-atom";
|
||||
import { usePersistence } from "@/features/page/tree/hooks/use-persistence";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { convertToTree, updateTreeNodeIcon } from "@/features/page/tree/utils";
|
||||
import useSpacePageOrder, {
|
||||
useGetPagesQuery,
|
||||
useUpdatePageMutation,
|
||||
} from "@/features/page/queries/page-query";
|
||||
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
|
||||
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
||||
import {
|
||||
buildTree,
|
||||
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 { QueryClient } from "@tanstack/react-query";
|
||||
import { SidebarPagesParams } from "@/features/page/types/page.types.ts";
|
||||
|
||||
interface PageTreeProps {
|
||||
interface SpaceTreeProps {
|
||||
spaceId: string;
|
||||
}
|
||||
|
||||
export default function PageTree({ spaceId }: PageTreeProps) {
|
||||
export default function SpaceTree({ spaceId }: SpaceTreeProps) {
|
||||
const { data, setData, controllers } =
|
||||
usePersistence<TreeApi<TreeNode>>(spaceId);
|
||||
const [tree, setTree] = useAtom<TreeApi<TreeNode>>(treeApiAtom);
|
||||
const { data: pageOrderData } = useSpacePageOrder(spaceId);
|
||||
const { data: pagesData, isLoading } = useGetPagesQuery(spaceId);
|
||||
useTreeMutation<TreeApi<SpaceTreeNode>>(spaceId);
|
||||
const [treeAPi, setTreeApi] = useAtom<TreeApi<SpaceTreeNode>>(treeApiAtom);
|
||||
const {
|
||||
data: pagesData,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
isFetching,
|
||||
} = useGetRootSidebarPagesQuery({
|
||||
spaceId,
|
||||
});
|
||||
const rootElement = useRef<HTMLDivElement>();
|
||||
const { pageId } = useParams();
|
||||
|
||||
const fetchAndSetTreeData = async () => {
|
||||
if (pageOrderData?.childrenIds) {
|
||||
try {
|
||||
if (!isLoading) {
|
||||
const treeData = convertToTree(pagesData, pageOrderData.childrenIds);
|
||||
setData(treeData);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error fetching tree data: ", err);
|
||||
}
|
||||
useEffect(() => {
|
||||
if (hasNextPage && !isFetching) {
|
||||
fetchNextPage();
|
||||
}
|
||||
};
|
||||
}, [hasNextPage, fetchNextPage, isFetching]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAndSetTreeData();
|
||||
}, [pageOrderData?.childrenIds, isLoading]);
|
||||
if (pagesData?.pages && !hasNextPage) {
|
||||
const allItems = pagesData.pages.flatMap((page) => page.items);
|
||||
const treeData = buildTree(allItems);
|
||||
setData(treeData);
|
||||
}
|
||||
}, [pagesData, hasNextPage]);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
tree?.select(pageId);
|
||||
tree?.scrollTo(pageId, "center");
|
||||
treeAPi?.select(pageId);
|
||||
treeAPi?.scrollTo(pageId, "auto");
|
||||
}, 200);
|
||||
}, [tree, pageId]);
|
||||
}, [treeAPi, pageId]);
|
||||
|
||||
return (
|
||||
<div ref={rootElement} className={classes.treeContainer}>
|
||||
@ -78,15 +84,16 @@ export default function PageTree({ spaceId }: PageTreeProps) {
|
||||
{...controllers}
|
||||
{...dimens}
|
||||
// @ts-ignore
|
||||
ref={(t) => setTree(t)}
|
||||
ref={(t) => setTreeApi(t)}
|
||||
openByDefault={false}
|
||||
disableMultiSelection={true}
|
||||
className={classes.tree}
|
||||
rowClassName={classes.row}
|
||||
padding={15}
|
||||
// padding={15}
|
||||
rowHeight={30}
|
||||
overscanCount={5}
|
||||
overscanCount={8}
|
||||
dndRootElement={rootElement.current}
|
||||
selectionFollowsFocus
|
||||
>
|
||||
{Node}
|
||||
</Tree>
|
||||
@ -96,18 +103,70 @@ export default function PageTree({ spaceId }: PageTreeProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
function Node({ node, style, dragHandle }: NodeRendererProps<any>) {
|
||||
const navigate = useNavigate();
|
||||
const updatePageMutation = useUpdatePageMutation();
|
||||
//const use = useGetExpandPageTreeQuery()
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const params: SidebarPagesParams = {
|
||||
pageId: node.data.id,
|
||||
spaceId: node.data.spaceId,
|
||||
};
|
||||
|
||||
const newChildren = await queryClient.fetchQuery({
|
||||
queryKey: ["sidebar-pages", params],
|
||||
queryFn: () => getSidebarPages(params),
|
||||
});
|
||||
|
||||
const childrenTree = buildTree(newChildren.items);
|
||||
|
||||
const updatedTreeData = updateTreeData(
|
||||
treeData,
|
||||
node.data.id,
|
||||
childrenTree,
|
||||
);
|
||||
|
||||
setTreeData(updatedTreeData);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch children:", error);
|
||||
}
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
navigate(`/p/${node.id}`);
|
||||
};
|
||||
|
||||
const handleUpdateNodeIcon = (nodeId, newIcon) => {
|
||||
const updatedTreeData = updateTreeNodeIcon(treeData, nodeId, newIcon);
|
||||
setTreeData(updatedTreeData);
|
||||
const handleUpdateNodeIcon = (nodeId: string, newIcon: string) => {
|
||||
const updatedTree = updateTreeNodeIcon(treeData, node.id, newIcon);
|
||||
setTreeData(updatedTree);
|
||||
};
|
||||
|
||||
const handleEmojiIconClick = (e) => {
|
||||
@ -115,7 +174,7 @@ function Node({ node, style, dragHandle }: NodeRendererProps<any>) {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleEmojiSelect = (emoji) => {
|
||||
const handleEmojiSelect = (emoji: { native: string }) => {
|
||||
handleUpdateNodeIcon(node.id, emoji.native);
|
||||
updatePageMutation.mutateAsync({ pageId: node.id, icon: emoji.native });
|
||||
};
|
||||
@ -126,6 +185,7 @@ function Node({ node, style, dragHandle }: NodeRendererProps<any>) {
|
||||
};
|
||||
|
||||
if (node.willReceiveDrop && node.isClosed) {
|
||||
handleLoadChildren(node);
|
||||
setTimeout(() => {
|
||||
if (node.state.willReceiveDrop) node.open();
|
||||
}, 650);
|
||||
@ -139,7 +199,7 @@ function Node({ node, style, dragHandle }: NodeRendererProps<any>) {
|
||||
ref={dragHandle}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<PageArrow node={node} />
|
||||
<PageArrow node={node} onExpandTree={() => handleLoadChildren(node)} />
|
||||
|
||||
<div onClick={handleEmojiIconClick} style={{ marginRight: "4px" }}>
|
||||
<EmojiPicker
|
||||
@ -155,28 +215,38 @@ function Node({ node, style, dragHandle }: NodeRendererProps<any>) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className={classes.text}>
|
||||
{node.isEditing ? (
|
||||
<Input node={node} />
|
||||
) : (
|
||||
node.data.name || "untitled"
|
||||
)}
|
||||
</span>
|
||||
<span className={classes.text}>{node.data.name || "untitled"}</span>
|
||||
|
||||
<div className={classes.actions}>
|
||||
<NodeMenu node={node} />
|
||||
<CreateNode node={node} />
|
||||
<CreateNode
|
||||
node={node}
|
||||
onExpandTree={() => handleLoadChildren(node)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateNode({ node }: { node: NodeApi<TreeNode> }) {
|
||||
const [tree] = useAtom(treeApiAtom);
|
||||
interface CreateNodeProps {
|
||||
node: NodeApi<SpaceTreeNode>;
|
||||
onExpandTree?: () => void;
|
||||
}
|
||||
function CreateNode({ node, onExpandTree }: CreateNodeProps) {
|
||||
const [treeApi] = useAtom(treeApiAtom);
|
||||
|
||||
function handleCreate() {
|
||||
tree?.create({ type: "internal", parentId: node.id, index: 0 });
|
||||
if (node.data.hasChildren && node.children.length === 0) {
|
||||
node.toggle();
|
||||
onExpandTree();
|
||||
|
||||
setTimeout(() => {
|
||||
treeApi?.create({ type: "internal", parentId: node.id, index: 0 });
|
||||
}, 500);
|
||||
} else {
|
||||
treeApi?.create({ type: "internal", parentId: node.id });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@ -194,12 +264,8 @@ function CreateNode({ node }: { node: NodeApi<TreeNode> }) {
|
||||
);
|
||||
}
|
||||
|
||||
function NodeMenu({ node }: { node: NodeApi<TreeNode> }) {
|
||||
const [tree] = useAtom(treeApiAtom);
|
||||
|
||||
function handleDelete() {
|
||||
tree?.delete(node);
|
||||
}
|
||||
function NodeMenu({ node }: { node: NodeApi<SpaceTreeNode> }) {
|
||||
const [treeApi] = useAtom(treeApiAtom);
|
||||
|
||||
return (
|
||||
<Menu shadow="md" width={200}>
|
||||
@ -220,16 +286,6 @@ function NodeMenu({ node }: { node: NodeApi<TreeNode> }) {
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconEdit style={{ width: rem(14), height: rem(14) }} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
node.edit();
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconStar style={{ width: rem(14), height: rem(14) }} />}
|
||||
>
|
||||
@ -244,28 +300,13 @@ function NodeMenu({ node }: { node: NodeApi<TreeNode> }) {
|
||||
Copy link
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
<IconCornerRightUp style={{ width: rem(14), height: rem(14) }} />
|
||||
}
|
||||
>
|
||||
Move
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
<IconArrowsLeftRight style={{ width: rem(14), height: rem(14) }} />
|
||||
}
|
||||
>
|
||||
Archive
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
c="red"
|
||||
leftSection={
|
||||
<IconTrash style={{ width: rem(14), height: rem(14) }} />
|
||||
}
|
||||
onClick={() => handleDelete()}
|
||||
onClick={() => treeApi?.delete(node)}
|
||||
>
|
||||
Delete
|
||||
</Menu.Item>
|
||||
@ -274,7 +315,11 @@ function NodeMenu({ node }: { node: NodeApi<TreeNode> }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PageArrow({ node }: { node: NodeApi<TreeNode> }) {
|
||||
interface PageArrowProps {
|
||||
node: NodeApi<SpaceTreeNode>;
|
||||
onExpandTree?: () => void;
|
||||
}
|
||||
function PageArrow({ node, onExpandTree }: PageArrowProps) {
|
||||
return (
|
||||
<ActionIcon
|
||||
size={20}
|
||||
@ -284,37 +329,20 @@ function PageArrow({ node }: { node: NodeApi<TreeNode> }) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
node.toggle();
|
||||
onExpandTree();
|
||||
}}
|
||||
>
|
||||
{node.isInternal ? (
|
||||
node.children && node.children.length > 0 ? (
|
||||
node.children && (node.children.length > 0 || node.data.hasChildren) ? (
|
||||
node.isOpen ? (
|
||||
<IconChevronDown stroke={2} size={18} />
|
||||
) : (
|
||||
<IconChevronRight stroke={2} size={18} />
|
||||
)
|
||||
) : (
|
||||
<IconChevronRight size={18} style={{ visibility: "hidden" }} />
|
||||
<IconPointFilled size={8} />
|
||||
)
|
||||
) : null}
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
|
||||
function Input({ node }: { node: NodeApi<TreeNode> }) {
|
||||
return (
|
||||
<input
|
||||
autoFocus
|
||||
name="name"
|
||||
type="text"
|
||||
placeholder="untitled"
|
||||
defaultValue={node.data.name}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
onBlur={() => node.reset()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") node.reset();
|
||||
if (e.key === "Enter") node.submit(e.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,127 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
CreateHandler,
|
||||
DeleteHandler,
|
||||
MoveHandler,
|
||||
RenameHandler,
|
||||
SimpleTree,
|
||||
} from "react-arborist";
|
||||
import { useAtom } from "jotai";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
|
||||
import { movePage } from "@/features/page/services/page-service";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { IMovePage } from "@/features/page/types/page.types";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { TreeNode } from "@/features/page/tree/types";
|
||||
import {
|
||||
useCreatePageMutation,
|
||||
useDeletePageMutation,
|
||||
useUpdatePageMutation,
|
||||
} from "@/features/page/queries/page-query";
|
||||
|
||||
interface Props {
|
||||
spaceId: string;
|
||||
}
|
||||
export function usePersistence<T>(spaceId: string) {
|
||||
const [data, setData] = useAtom(treeDataAtom);
|
||||
const createPageMutation = useCreatePageMutation();
|
||||
const updatePageMutation = useUpdatePageMutation();
|
||||
const deletePageMutation = useDeletePageMutation();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const tree = useMemo(() => new SimpleTree<TreeNode>(data), [data]);
|
||||
|
||||
const onMove: MoveHandler<T> = (args: {
|
||||
parentId;
|
||||
index;
|
||||
parentNode;
|
||||
dragNodes;
|
||||
dragIds;
|
||||
}) => {
|
||||
for (const id of args.dragIds) {
|
||||
tree.move({ id, parentId: args.parentId, index: args.index });
|
||||
}
|
||||
setData(tree.data);
|
||||
|
||||
const newDragIndex = tree.find(args.dragIds[0])?.childIndex;
|
||||
|
||||
const currentTreeData = args.parentId
|
||||
? tree.find(args.parentId).children
|
||||
: tree.data;
|
||||
const afterId = currentTreeData[newDragIndex - 1]?.id || null;
|
||||
const beforeId =
|
||||
(!afterId && currentTreeData[newDragIndex + 1]?.id) || null;
|
||||
|
||||
const params: IMovePage = {
|
||||
pageId: args.dragIds[0],
|
||||
after: afterId,
|
||||
before: beforeId,
|
||||
parentId: args.parentId || null,
|
||||
};
|
||||
|
||||
const payload = Object.fromEntries(
|
||||
Object.entries(params).filter(
|
||||
([key, value]) => value !== null && value !== undefined,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
movePage(payload as IMovePage);
|
||||
} catch (error) {
|
||||
console.error("Error moving page:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const onRename: RenameHandler<T> = ({ name, id }) => {
|
||||
tree.update({ id, changes: { name } as any });
|
||||
setData(tree.data);
|
||||
|
||||
try {
|
||||
updatePageMutation.mutateAsync({ pageId: id, title: name });
|
||||
} catch (error) {
|
||||
console.error("Error updating page title:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const onCreate: CreateHandler<T> = async ({ parentId, index, type }) => {
|
||||
const data = { id: uuidv4(), name: "" } as any;
|
||||
data.children = [];
|
||||
tree.create({ parentId, index, data });
|
||||
setData(tree.data);
|
||||
|
||||
const payload: { pageId: string; parentPageId?: string; spaceId: string } =
|
||||
{
|
||||
pageId: data.id,
|
||||
spaceId: spaceId,
|
||||
};
|
||||
if (parentId) {
|
||||
payload.parentPageId = parentId;
|
||||
}
|
||||
|
||||
try {
|
||||
await createPageMutation.mutateAsync(payload);
|
||||
navigate(`/p/${payload.pageId}`);
|
||||
} catch (error) {
|
||||
console.error("Error creating the page:", error);
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const onDelete: DeleteHandler<T> = async (args: { ids: string[] }) => {
|
||||
args.ids.forEach((id) => tree.drop({ id }));
|
||||
setData(tree.data);
|
||||
|
||||
try {
|
||||
await deletePageMutation.mutateAsync(args.ids[0]);
|
||||
navigate("/home");
|
||||
} catch (error) {
|
||||
console.error("Error deleting page:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const controllers = { onMove, onRename, onCreate, onDelete };
|
||||
|
||||
return { data, setData, controllers } as const;
|
||||
}
|
||||
166
apps/client/src/features/page/tree/hooks/use-tree-mutation.ts
Normal file
166
apps/client/src/features/page/tree/hooks/use-tree-mutation.ts
Normal file
@ -0,0 +1,166 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
CreateHandler,
|
||||
DeleteHandler,
|
||||
MoveHandler,
|
||||
NodeApi,
|
||||
RenameHandler,
|
||||
SimpleTree,
|
||||
} from "react-arborist";
|
||||
import { useAtom } from "jotai";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
import { IMovePage, IPage } from "@/features/page/types/page.types.ts";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
useCreatePageMutation,
|
||||
useDeletePageMutation,
|
||||
useMovePageMutation,
|
||||
useUpdatePageMutation,
|
||||
} from "@/features/page/queries/page-query.ts";
|
||||
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
|
||||
export function useTreeMutation<T>(spaceId: string) {
|
||||
const [data, setData] = useAtom(treeDataAtom);
|
||||
const tree = useMemo(() => new SimpleTree<SpaceTreeNode>(data), [data]);
|
||||
const createPageMutation = useCreatePageMutation();
|
||||
const updatePageMutation = useUpdatePageMutation();
|
||||
const deletePageMutation = useDeletePageMutation();
|
||||
const movePageMutation = useMovePageMutation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onCreate: CreateHandler<T> = async ({ parentId, index, type }) => {
|
||||
const payload: { spaceId: string; parentPageId?: string } = {
|
||||
spaceId: spaceId,
|
||||
};
|
||||
if (parentId) {
|
||||
payload.parentPageId = parentId;
|
||||
}
|
||||
|
||||
let createdPage: IPage;
|
||||
try {
|
||||
createdPage = await createPageMutation.mutateAsync(payload);
|
||||
} catch (err) {
|
||||
throw new Error("Failed to create page");
|
||||
}
|
||||
|
||||
const data = {
|
||||
id: createdPage.id,
|
||||
name: "",
|
||||
position: createdPage.position,
|
||||
children: [],
|
||||
} as any;
|
||||
|
||||
let lastIndex: number;
|
||||
if (parentId === null) {
|
||||
lastIndex = tree.data.length;
|
||||
} else {
|
||||
lastIndex = tree.find(parentId).children.length;
|
||||
}
|
||||
// to place the newly created node at the bottom
|
||||
index = lastIndex;
|
||||
|
||||
tree.create({ parentId, index, data });
|
||||
setData(tree.data);
|
||||
|
||||
navigate(`/p/${createdPage.id}`);
|
||||
return data;
|
||||
};
|
||||
|
||||
const onMove: MoveHandler<T> = (args: {
|
||||
dragIds: string[];
|
||||
dragNodes: NodeApi<T>[];
|
||||
parentId: string | null;
|
||||
parentNode: NodeApi<T> | null;
|
||||
index: number;
|
||||
}) => {
|
||||
const draggedNodeId = args.dragIds[0];
|
||||
|
||||
tree.move({
|
||||
id: draggedNodeId,
|
||||
parentId: args.parentId,
|
||||
index: args.index,
|
||||
});
|
||||
|
||||
const newDragIndex = tree.find(draggedNodeId)?.childIndex;
|
||||
|
||||
const currentTreeData = args.parentId
|
||||
? tree.find(args.parentId).children
|
||||
: tree.data;
|
||||
|
||||
// if there is a parentId, tree.find(args.parentId).children returns a SimpleNode array
|
||||
// we have to access the node differently viq currentTreeData[args.index]?.data?.position
|
||||
// this makes it possible to correctly sort children of a parent node that is not the root
|
||||
|
||||
const afterPosition =
|
||||
// @ts-ignore
|
||||
currentTreeData[newDragIndex - 1]?.position ||
|
||||
// @ts-ignore
|
||||
currentTreeData[args.index - 1]?.data?.position ||
|
||||
null;
|
||||
|
||||
const beforePosition =
|
||||
// @ts-ignore
|
||||
currentTreeData[newDragIndex + 1]?.position ||
|
||||
// @ts-ignore
|
||||
currentTreeData[args.index + 1]?.data?.position ||
|
||||
null;
|
||||
|
||||
let newPosition: string;
|
||||
|
||||
if (afterPosition && beforePosition && afterPosition === beforePosition) {
|
||||
// if after is equal to before, put it next to the after node
|
||||
newPosition = generateJitteredKeyBetween(afterPosition, null);
|
||||
} else {
|
||||
// if both are null then, it is the first index
|
||||
newPosition = generateJitteredKeyBetween(afterPosition, beforePosition);
|
||||
}
|
||||
|
||||
// update the node position in tree
|
||||
tree.update({
|
||||
id: draggedNodeId,
|
||||
changes: { position: newPosition } as any,
|
||||
});
|
||||
|
||||
setData(tree.data);
|
||||
|
||||
const payload: IMovePage = {
|
||||
pageId: draggedNodeId,
|
||||
position: newPosition,
|
||||
parentPageId: args.parentId,
|
||||
};
|
||||
|
||||
try {
|
||||
movePageMutation.mutateAsync(payload);
|
||||
} catch (error) {
|
||||
console.error("Error moving page:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const onRename: RenameHandler<T> = ({ name, id }) => {
|
||||
tree.update({ id, changes: { name } as any });
|
||||
setData(tree.data);
|
||||
|
||||
try {
|
||||
updatePageMutation.mutateAsync({ pageId: id, title: name });
|
||||
} catch (error) {
|
||||
console.error("Error updating page title:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete: DeleteHandler<T> = async (args: { ids: string[] }) => {
|
||||
try {
|
||||
await deletePageMutation.mutateAsync(args.ids[0]);
|
||||
|
||||
tree.drop({ id: args.ids[0] });
|
||||
setData(tree.data);
|
||||
|
||||
navigate("/home");
|
||||
} catch (error) {
|
||||
console.error("Failed to delete page:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const controllers = { onMove, onRename, onCreate, onDelete };
|
||||
return { data, setData, controllers } as const;
|
||||
}
|
||||
@ -20,7 +20,8 @@
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
||||
|
||||
&:hover {
|
||||
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
|
||||
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
|
||||
/*background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));*/
|
||||
}
|
||||
|
||||
|
||||
@ -64,7 +65,7 @@
|
||||
}
|
||||
|
||||
.row:focus .node:global(.isSelected) {
|
||||
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-6));
|
||||
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
|
||||
}
|
||||
|
||||
.row {
|
||||
@ -78,7 +79,7 @@
|
||||
|
||||
.row:focus .node {
|
||||
/** come back to this **/
|
||||
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
/* background-color: light-dark(var(--mantine-color-red-2), var(--mantine-color-dark-5));*/
|
||||
}
|
||||
|
||||
.icon {
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
export type TreeNode = {
|
||||
export type SpaceTreeNode = {
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
position: string;
|
||||
slug?: string;
|
||||
children: TreeNode[];
|
||||
spaceId: string;
|
||||
hasChildren: boolean;
|
||||
children: SpaceTreeNode[];
|
||||
};
|
||||
|
||||
@ -1,74 +1 @@
|
||||
import { IPage } from '@/features/page/types/page.types';
|
||||
import { TreeNode } from '@/features/page/tree/types';
|
||||
|
||||
export function convertToTree(pages: IPage[], pageOrder: string[]): TreeNode[] {
|
||||
const pageMap: { [id: string]: IPage } = {};
|
||||
pages.forEach(page => {
|
||||
pageMap[page.id] = page;
|
||||
});
|
||||
|
||||
function buildTreeNode(id: string): TreeNode | undefined {
|
||||
const page = pageMap[id];
|
||||
if (!page) return;
|
||||
|
||||
const node: TreeNode = {
|
||||
id: page.id,
|
||||
name: page.title,
|
||||
children: [],
|
||||
};
|
||||
|
||||
if (page.icon) node.icon = page.icon;
|
||||
|
||||
if (page.childrenIds && page.childrenIds.length > 0) {
|
||||
node.children = page.childrenIds.map(childId => buildTreeNode(childId)).filter(Boolean) as TreeNode[];
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
return pageOrder.map(id => buildTreeNode(id)).filter(Boolean) as TreeNode[];
|
||||
}
|
||||
|
||||
export function findBreadcrumbPath(tree: TreeNode[], pageId: string, path: TreeNode[] = []): TreeNode[] | null {
|
||||
for (const node of tree) {
|
||||
if (!node.name || node.name.trim() === "") {
|
||||
node.name = "untitled";
|
||||
}
|
||||
|
||||
if (node.id === pageId) {
|
||||
return [...path, node];
|
||||
}
|
||||
|
||||
if (node.children) {
|
||||
const newPath = findBreadcrumbPath(node.children, pageId, [...path, node]);
|
||||
if (newPath) {
|
||||
return newPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const updateTreeNodeName = (nodes: TreeNode[], nodeId: string, newName: string): TreeNode[] => {
|
||||
return nodes.map(node => {
|
||||
if (node.id === nodeId) {
|
||||
return { ...node, name: newName };
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
return { ...node, children: updateTreeNodeName(node.children, nodeId, newName) };
|
||||
}
|
||||
return node;
|
||||
});
|
||||
};
|
||||
|
||||
export const updateTreeNodeIcon = (nodes: TreeNode[], nodeId: string, newIcon: string): TreeNode[] => {
|
||||
return nodes.map(node => {
|
||||
if (node.id === nodeId) {
|
||||
return { ...node, icon: newIcon };
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
return { ...node, children: updateTreeNodeIcon(node.children, nodeId, newIcon) };
|
||||
}
|
||||
return node;
|
||||
});
|
||||
};
|
||||
export * from "./utils.ts";
|
||||
|
||||
99
apps/client/src/features/page/tree/utils/utils.ts
Normal file
99
apps/client/src/features/page/tree/utils/utils.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import { IPage } from "@/features/page/types/page.types.ts";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
|
||||
function sortPositionKeys(keys: any[]) {
|
||||
return keys.sort((a, b) => {
|
||||
if (a.position < b.position) return -1;
|
||||
if (a.position > b.position) return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
export function buildTree(pages: IPage[]): SpaceTreeNode[] {
|
||||
const pageMap: Record<string, SpaceTreeNode> = {};
|
||||
|
||||
const tree: SpaceTreeNode[] = [];
|
||||
|
||||
pages.forEach((page) => {
|
||||
pageMap[page.id] = {
|
||||
id: page.id,
|
||||
name: page.title,
|
||||
icon: page.icon,
|
||||
position: page.position,
|
||||
hasChildren: page.hasChildren,
|
||||
spaceId: page.spaceId,
|
||||
children: [],
|
||||
};
|
||||
});
|
||||
|
||||
pages.forEach((page) => {
|
||||
tree.push(pageMap[page.id]);
|
||||
});
|
||||
|
||||
return sortPositionKeys(tree);
|
||||
}
|
||||
|
||||
export function findBreadcrumbPath(
|
||||
tree: SpaceTreeNode[],
|
||||
pageId: string,
|
||||
path: SpaceTreeNode[] = [],
|
||||
): SpaceTreeNode[] | null {
|
||||
for (const node of tree) {
|
||||
if (!node.name || node.name.trim() === "") {
|
||||
node.name = "untitled";
|
||||
}
|
||||
|
||||
if (node.id === pageId) {
|
||||
return [...path, node];
|
||||
}
|
||||
|
||||
if (node.children) {
|
||||
const newPath = findBreadcrumbPath(node.children, pageId, [
|
||||
...path,
|
||||
node,
|
||||
]);
|
||||
if (newPath) {
|
||||
return newPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const updateTreeNodeName = (
|
||||
nodes: SpaceTreeNode[],
|
||||
nodeId: string,
|
||||
newName: string,
|
||||
): SpaceTreeNode[] => {
|
||||
return nodes.map((node) => {
|
||||
if (node.id === nodeId) {
|
||||
return { ...node, name: newName };
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
return {
|
||||
...node,
|
||||
children: updateTreeNodeName(node.children, nodeId, newName),
|
||||
};
|
||||
}
|
||||
return node;
|
||||
});
|
||||
};
|
||||
|
||||
export const updateTreeNodeIcon = (
|
||||
nodes: SpaceTreeNode[],
|
||||
nodeId: string,
|
||||
newIcon: string,
|
||||
): SpaceTreeNode[] => {
|
||||
return nodes.map((node) => {
|
||||
if (node.id === nodeId) {
|
||||
return { ...node, icon: newIcon };
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
return {
|
||||
...node,
|
||||
children: updateTreeNodeIcon(node.children, nodeId, newIcon),
|
||||
};
|
||||
}
|
||||
return node;
|
||||
});
|
||||
};
|
||||
@ -21,17 +21,20 @@ export interface IPage {
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
deletedAt: Date;
|
||||
position: string;
|
||||
hasChildren: boolean;
|
||||
}
|
||||
|
||||
export interface IMovePage {
|
||||
pageId: string;
|
||||
position?: string;
|
||||
after?: string;
|
||||
before?: string;
|
||||
parentId?: string;
|
||||
parentPageId?: string;
|
||||
}
|
||||
|
||||
export interface IWorkspacePageOrder {
|
||||
id: string;
|
||||
childrenIds: string[];
|
||||
workspaceId: string;
|
||||
export interface SidebarPagesParams {
|
||||
spaceId: string;
|
||||
pageId?: string;
|
||||
page?: number; // pagination
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user