mirror of
https://github.com/docmost/docmost.git
synced 2025-11-16 01:51:09 +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,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);
|
||||
@ -0,0 +1,65 @@
|
||||
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
|
||||
import { useAtom } from "jotai/index";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionControlProps,
|
||||
ActionIcon,
|
||||
Center,
|
||||
rem,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
import React from "react";
|
||||
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
|
||||
import SpaceTree from "@/features/page/tree/components/space-tree.tsx";
|
||||
|
||||
export default function SpaceContent() {
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const { data: space } = useSpaceQuery(currentUser?.workspace.defaultSpaceId);
|
||||
|
||||
if (!space) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Accordion
|
||||
chevronPosition="left"
|
||||
maw={400}
|
||||
mx="auto"
|
||||
defaultValue={space.id}
|
||||
>
|
||||
<Accordion.Item key={space.id} value={space.id}>
|
||||
<AccordionControl>{space.name}</AccordionControl>
|
||||
<Accordion.Panel>
|
||||
<SpaceTree spaceId={space.id} />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionControl(props: AccordionControlProps) {
|
||||
const [tree] = useAtom(treeApiAtom);
|
||||
|
||||
function handleCreatePage() {
|
||||
//todo: create at the bottom
|
||||
tree?.create({ parentId: null, type: "internal", index: 0 });
|
||||
}
|
||||
|
||||
return (
|
||||
<Center>
|
||||
<Accordion.Control {...props} />
|
||||
{/* <ActionIcon size="lg" variant="subtle" color="gray">
|
||||
<IconDots size="1rem" />
|
||||
</ActionIcon> */}
|
||||
<Tooltip label="Create page" withArrow position="right">
|
||||
<ActionIcon variant="default" size={18} onClick={handleCreatePage}>
|
||||
<IconPlus style={{ width: rem(12), height: rem(12) }} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user