diff --git a/apps/client/src/components/layouts/components/breadcrumb.tsx b/apps/client/src/components/layouts/components/breadcrumb.tsx index 122e736d..4836984c 100644 --- a/apps/client/src/components/layouts/components/breadcrumb.tsx +++ b/apps/client/src/components/layouts/components/breadcrumb.tsx @@ -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( - 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) => ( - )) - ); + )); - 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 [ - + {breadcrumbNodes[0].name} , - + @@ -74,18 +83,33 @@ export default function Breadcrumb() { , - + {getLastNthNode(2)?.name} , - + {getLastNthNode(1)?.name} , ]; } if (breadcrumbNodes) { - return breadcrumbNodes.map(node => ( - + return breadcrumbNodes.map((node) => ( + {node.name} )); @@ -98,7 +122,9 @@ export default function Breadcrumb() {
{breadcrumbNodes ? ( {getBreadcrumbItems()} - ) : (<>)} + ) : ( + <> + )}
); } diff --git a/apps/client/src/components/navbar/navbar.tsx b/apps/client/src/components/navbar/navbar.tsx index 8a38eb35..15e93f4f 100644 --- a/apps/client/src/components/navbar/navbar.tsx +++ b/apps/client/src/components/navbar/navbar.tsx @@ -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() {
- {/* */}
diff --git a/apps/client/src/features/page/queries/page-query.ts b/apps/client/src/features/page/queries/page-query.ts index db60215d..07ea0210 100644 --- a/apps/client/src/features/page/queries/page-query.ts +++ b/apps/client/src/features/page/queries/page-query.ts @@ -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 { }); } -export function useGetPagesQuery( - spaceId: string, -): UseQueryResult { - return useQuery({ - queryKey: ["pages", spaceId], - queryFn: () => getPages(spaceId), - staleTime: 5 * 60 * 1000, - }); -} - export function useRecentChangesQuery(): UseQueryResult { return useQuery({ queryKey: RECENT_CHANGES_KEY, @@ -44,6 +44,9 @@ export function useCreatePageMutation() { return useMutation>({ 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 { - 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({ + mutationFn: (data) => movePage(data), + }); +} + +export function useGetSidebarPagesQuery( + data: SidebarPagesParams, +): UseQueryResult, 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, + }); +} diff --git a/apps/client/src/features/page/services/page-service.ts b/apps/client/src/features/page/services/page-service.ts index ddd4e54c..e90a1cc1 100644 --- a/apps/client/src/features/page/services/page-service.ts +++ b/apps/client/src/features/page/services/page-service.ts @@ -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): Promise { const req = await api.post("/pages/create", data); - return req.data as IPage; + return req.data; } export async function getPageById(pageId: string): Promise { const req = await api.post("/pages/info", { pageId }); - return req.data as IPage; + return req.data; +} + +export async function updatePage(data: Partial): Promise { + const req = await api.post("/pages/update", data); + return req.data; +} + +export async function deletePage(pageId: string): Promise { + await api.post("/pages/delete", { pageId }); +} + +export async function movePage(data: IMovePage): Promise { + await api.post("/pages/move", data); } export async function getRecentChanges(): Promise { const req = await api.post("/pages/recent"); - return req.data as IPage[]; + return req.data; } -export async function getPages(spaceId: string): Promise { - const req = await api.post("/pages", { spaceId }); - return req.data as IPage[]; -} - -export async function getSpacePageOrder( - spaceId: string, -): Promise { - const req = await api.post("/pages/ordering", { - spaceId, - }); - return req.data as IWorkspacePageOrder[]; -} - -export async function updatePage(data: Partial): Promise { - const req = await api.post(`/pages/update`, data); - return req.data as IPage; -} - -export async function movePage(data: IMovePage): Promise { - await api.post("/pages/move", data); -} - -export async function deletePage(id: string): Promise { - await api.post("/pages/delete", { id }); +export async function getSidebarPages( + params: SidebarPagesParams, +): Promise> { + const req = await api.post("/pages/sidebar-pages", params); + return req.data; } diff --git a/apps/client/src/features/page/tree/atoms/tree-api-atom.ts b/apps/client/src/features/page/tree/atoms/tree-api-atom.ts index 05d2b3cb..f12106f9 100644 --- a/apps/client/src/features/page/tree/atoms/tree-api-atom.ts +++ b/apps/client/src/features/page/tree/atoms/tree-api-atom.ts @@ -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 | null>(null); +export const treeApiAtom = atom | null>(null); diff --git a/apps/client/src/features/page/tree/atoms/tree-data-atom.ts b/apps/client/src/features/page/tree/atoms/tree-data-atom.ts index 3d4c67f6..e3910cb8 100644 --- a/apps/client/src/features/page/tree/atoms/tree-data-atom.ts +++ b/apps/client/src/features/page/tree/atoms/tree-data-atom.ts @@ -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([]); +export const treeDataAtom = atom([]); diff --git a/apps/client/src/features/page/tree/atoms/workspace-page-order-atom.ts b/apps/client/src/features/page/tree/atoms/workspace-page-order-atom.ts deleted file mode 100644 index f20e9aff..00000000 --- a/apps/client/src/features/page/tree/atoms/workspace-page-order-atom.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { atomWithStorage } from "jotai/utils"; -import { IWorkspacePageOrder } from '@/features/page/types/page.types'; - -export const workspacePageOrderAtom = atomWithStorage("workspace-page-order", null); diff --git a/apps/client/src/features/page/component/space-content.tsx b/apps/client/src/features/page/tree/components/space-content.tsx similarity index 91% rename from apps/client/src/features/page/component/space-content.tsx rename to apps/client/src/features/page/tree/components/space-content.tsx index 8ddb10d5..6b41cf4c 100644 --- a/apps/client/src/features/page/component/space-content.tsx +++ b/apps/client/src/features/page/tree/components/space-content.tsx @@ -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() { {space.name} - + @@ -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 }); } diff --git a/apps/client/src/features/page/tree/page-tree.tsx b/apps/client/src/features/page/tree/components/space-tree.tsx similarity index 51% rename from apps/client/src/features/page/tree/page-tree.tsx rename to apps/client/src/features/page/tree/components/space-tree.tsx index 0335a35a..ed905962 100644 --- a/apps/client/src/features/page/tree/page-tree.tsx +++ b/apps/client/src/features/page/tree/components/space-tree.tsx @@ -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>(spaceId); - const [tree, setTree] = useAtom>(treeApiAtom); - const { data: pageOrderData } = useSpacePageOrder(spaceId); - const { data: pagesData, isLoading } = useGetPagesQuery(spaceId); + useTreeMutation>(spaceId); + const [treeAPi, setTreeApi] = useAtom>(treeApiAtom); + const { + data: pagesData, + hasNextPage, + fetchNextPage, + isFetching, + } = useGetRootSidebarPagesQuery({ + spaceId, + }); const rootElement = useRef(); 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 (
@@ -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} @@ -96,18 +103,70 @@ export default function PageTree({ spaceId }: PageTreeProps) { ); } +const queryClient = new QueryClient(); function Node({ node, style, dragHandle }: NodeRendererProps) { 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) { + 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) { 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) { }; 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) { ref={dragHandle} onClick={handleClick} > - + handleLoadChildren(node)} />
) { />
- - {node.isEditing ? ( - - ) : ( - node.data.name || "untitled" - )} - + {node.data.name || "untitled"}
- + handleLoadChildren(node)} + />
); } -function CreateNode({ node }: { node: NodeApi }) { - const [tree] = useAtom(treeApiAtom); +interface CreateNodeProps { + node: NodeApi; + 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 }) { ); } -function NodeMenu({ node }: { node: NodeApi }) { - const [tree] = useAtom(treeApiAtom); - - function handleDelete() { - tree?.delete(node); - } +function NodeMenu({ node }: { node: NodeApi }) { + const [treeApi] = useAtom(treeApiAtom); return ( @@ -220,16 +286,6 @@ function NodeMenu({ node }: { node: NodeApi }) { - } - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - node.edit(); - }} - > - Rename - } > @@ -244,28 +300,13 @@ function NodeMenu({ node }: { node: NodeApi }) { Copy link - - } - > - Move - - - - } - > - Archive - } - onClick={() => handleDelete()} + onClick={() => treeApi?.delete(node)} > Delete @@ -274,7 +315,11 @@ function NodeMenu({ node }: { node: NodeApi }) { ); } -function PageArrow({ node }: { node: NodeApi }) { +interface PageArrowProps { + node: NodeApi; + onExpandTree?: () => void; +} +function PageArrow({ node, onExpandTree }: PageArrowProps) { return ( }) { 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 ? ( ) : ( ) ) : ( - + ) ) : null} ); } - -function Input({ node }: { node: NodeApi }) { - return ( - e.currentTarget.select()} - onBlur={() => node.reset()} - onKeyDown={(e) => { - if (e.key === "Escape") node.reset(); - if (e.key === "Enter") node.submit(e.currentTarget.value); - }} - /> - ); -} diff --git a/apps/client/src/features/page/tree/hooks/use-persistence.ts b/apps/client/src/features/page/tree/hooks/use-persistence.ts deleted file mode 100644 index 609be350..00000000 --- a/apps/client/src/features/page/tree/hooks/use-persistence.ts +++ /dev/null @@ -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(spaceId: string) { - const [data, setData] = useAtom(treeDataAtom); - const createPageMutation = useCreatePageMutation(); - const updatePageMutation = useUpdatePageMutation(); - const deletePageMutation = useDeletePageMutation(); - - const navigate = useNavigate(); - - const tree = useMemo(() => new SimpleTree(data), [data]); - - const onMove: MoveHandler = (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 = ({ 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 = 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 = 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; -} diff --git a/apps/client/src/features/page/tree/hooks/use-tree-mutation.ts b/apps/client/src/features/page/tree/hooks/use-tree-mutation.ts new file mode 100644 index 00000000..9a69100c --- /dev/null +++ b/apps/client/src/features/page/tree/hooks/use-tree-mutation.ts @@ -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(spaceId: string) { + const [data, setData] = useAtom(treeDataAtom); + const tree = useMemo(() => new SimpleTree(data), [data]); + const createPageMutation = useCreatePageMutation(); + const updatePageMutation = useUpdatePageMutation(); + const deletePageMutation = useDeletePageMutation(); + const movePageMutation = useMovePageMutation(); + const navigate = useNavigate(); + + const onCreate: CreateHandler = 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 = (args: { + dragIds: string[]; + dragNodes: NodeApi[]; + parentId: string | null; + parentNode: NodeApi | 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 = ({ 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 = 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; +} diff --git a/apps/client/src/features/page/tree/styles/tree.module.css b/apps/client/src/features/page/tree/styles/tree.module.css index 72cb7cbe..59965abb 100644 --- a/apps/client/src/features/page/tree/styles/tree.module.css +++ b/apps/client/src/features/page/tree/styles/tree.module.css @@ -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 { diff --git a/apps/client/src/features/page/tree/types.ts b/apps/client/src/features/page/tree/types.ts index 8a0c8d17..69a53d29 100644 --- a/apps/client/src/features/page/tree/types.ts +++ b/apps/client/src/features/page/tree/types.ts @@ -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[]; }; diff --git a/apps/client/src/features/page/tree/utils/index.ts b/apps/client/src/features/page/tree/utils/index.ts index 42a42c96..0e556c4b 100644 --- a/apps/client/src/features/page/tree/utils/index.ts +++ b/apps/client/src/features/page/tree/utils/index.ts @@ -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"; diff --git a/apps/client/src/features/page/tree/utils/utils.ts b/apps/client/src/features/page/tree/utils/utils.ts new file mode 100644 index 00000000..1c6f4ec1 --- /dev/null +++ b/apps/client/src/features/page/tree/utils/utils.ts @@ -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 = {}; + + 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; + }); +}; diff --git a/apps/client/src/features/page/types/page.types.ts b/apps/client/src/features/page/types/page.types.ts index ab4cb96e..c4ec4bcb 100644 --- a/apps/client/src/features/page/types/page.types.ts +++ b/apps/client/src/features/page/types/page.types.ts @@ -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 } diff --git a/apps/server/src/core/page/dto/create-page.dto.ts b/apps/server/src/core/page/dto/create-page.dto.ts index f199c3a9..128fec16 100644 --- a/apps/server/src/core/page/dto/create-page.dto.ts +++ b/apps/server/src/core/page/dto/create-page.dto.ts @@ -1,10 +1,6 @@ import { IsOptional, IsString, IsUUID } from 'class-validator'; export class CreatePageDto { - @IsOptional() - @IsUUID() - pageId?: string; - @IsOptional() @IsString() title?: string; diff --git a/apps/server/src/core/page/dto/move-page.dto.ts b/apps/server/src/core/page/dto/move-page.dto.ts index d47c7642..72d25da2 100644 --- a/apps/server/src/core/page/dto/move-page.dto.ts +++ b/apps/server/src/core/page/dto/move-page.dto.ts @@ -1,18 +1,21 @@ -import { IsString, IsOptional, IsUUID } from 'class-validator'; +import { + IsString, + IsUUID, + IsOptional, + MinLength, + MaxLength, +} from 'class-validator'; export class MovePageDto { @IsUUID() pageId: string; - @IsOptional() @IsString() - after?: string; + @MinLength(5) + @MaxLength(12) + position: string; @IsOptional() @IsString() - before?: string; - - @IsOptional() - @IsString() - parentId?: string | null; + parentPageId?: string | null; } diff --git a/apps/server/src/core/page/dto/sidebar-page.dto.ts b/apps/server/src/core/page/dto/sidebar-page.dto.ts new file mode 100644 index 00000000..0606d855 --- /dev/null +++ b/apps/server/src/core/page/dto/sidebar-page.dto.ts @@ -0,0 +1,8 @@ +import { IsOptional, IsUUID } from 'class-validator'; +import { SpaceIdDto } from './page.dto'; + +export class SidebarPageDto extends SpaceIdDto { + @IsOptional() + @IsUUID() + pageId: string; +} diff --git a/apps/server/src/core/page/page.controller.ts b/apps/server/src/core/page/page.controller.ts index 94dc93e8..bd48509d 100644 --- a/apps/server/src/core/page/page.controller.ts +++ b/apps/server/src/core/page/page.controller.ts @@ -11,36 +11,29 @@ import { CreatePageDto } from './dto/create-page.dto'; import { UpdatePageDto } from './dto/update-page.dto'; import { MovePageDto } from './dto/move-page.dto'; import { PageHistoryIdDto, PageIdDto, SpaceIdDto } from './dto/page.dto'; -import { PageOrderingService } from './services/page-ordering.service'; import { PageHistoryService } from './services/page-history.service'; import { AuthUser } from '../../decorators/auth-user.decorator'; import { AuthWorkspace } from '../../decorators/auth-workspace.decorator'; import { JwtAuthGuard } from '../../guards/jwt-auth.guard'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { User, Workspace } from '@docmost/db/types/entity.types'; +import { SidebarPageDto } from './dto/sidebar-page.dto'; @UseGuards(JwtAuthGuard) @Controller('pages') export class PageController { constructor( private readonly pageService: PageService, - private readonly pageOrderService: PageOrderingService, private readonly pageHistoryService: PageHistoryService, ) {} - @HttpCode(HttpStatus.OK) - @Post() - async getSpacePages(@Body() spaceIdDto: SpaceIdDto) { - return this.pageService.getSidebarPagesBySpaceId(spaceIdDto.spaceId); - } - @HttpCode(HttpStatus.OK) @Post('/info') async getPage(@Body() pageIdDto: PageIdDto) { return this.pageService.findById(pageIdDto.pageId); } - @HttpCode(HttpStatus.CREATED) + @HttpCode(HttpStatus.OK) @Post('create') async create( @Body() createPageDto: CreatePageDto, @@ -72,12 +65,6 @@ export class PageController { // await this.pageService.restore(deletePageDto.id); } - @HttpCode(HttpStatus.OK) - @Post('move') - async movePage(@Body() movePageDto: MovePageDto) { - return this.pageOrderService.movePage(movePageDto); - } - @HttpCode(HttpStatus.OK) @Post('recent') async getRecentSpacePages( @@ -87,18 +74,6 @@ export class PageController { return this.pageService.getRecentSpacePages(spaceIdDto.spaceId, pagination); } - @HttpCode(HttpStatus.OK) - @Post('ordering') - async getSpacePageOrder(@Body() spaceIdDto: SpaceIdDto) { - return this.pageOrderService.getSpacePageOrder(spaceIdDto.spaceId); - } - - @HttpCode(HttpStatus.OK) - @Post('tree') - async spacePageTree(@Body() spaceIdDto: SpaceIdDto) { - return this.pageOrderService.convertToTree(spaceIdDto.spaceId); - } - // TODO: scope to workspaces @HttpCode(HttpStatus.OK) @Post('/history') @@ -111,7 +86,22 @@ export class PageController { @HttpCode(HttpStatus.OK) @Post('/history/details') - async get(@Body() dto: PageHistoryIdDto) { + async getPageHistoryInfo(@Body() dto: PageHistoryIdDto) { return this.pageHistoryService.findById(dto.historyId); } + + @HttpCode(HttpStatus.OK) + @Post('/sidebar-pages') + async getSidebarPages( + @Body() dto: SidebarPageDto, + @Body() pagination: PaginationOptions, + ) { + return this.pageService.getSidebarPages(dto, pagination); + } + + @HttpCode(HttpStatus.OK) + @Post('move') + async movePage(@Body() movePageDto: MovePageDto) { + return this.pageService.movePage(movePageDto); + } } diff --git a/apps/server/src/core/page/page.module.ts b/apps/server/src/core/page/page.module.ts index 057b9992..a347a1fb 100644 --- a/apps/server/src/core/page/page.module.ts +++ b/apps/server/src/core/page/page.module.ts @@ -2,13 +2,12 @@ import { Module } from '@nestjs/common'; import { PageService } from './services/page.service'; import { PageController } from './page.controller'; import { WorkspaceModule } from '../workspace/workspace.module'; -import { PageOrderingService } from './services/page-ordering.service'; import { PageHistoryService } from './services/page-history.service'; @Module({ imports: [WorkspaceModule], controllers: [PageController], - providers: [PageService, PageOrderingService, PageHistoryService], - exports: [PageService, PageOrderingService, PageHistoryService], + providers: [PageService, PageHistoryService], + exports: [PageService, PageHistoryService], }) export class PageModule {} diff --git a/apps/server/src/core/page/page.util.ts b/apps/server/src/core/page/page.util.ts deleted file mode 100644 index 20af1d5e..00000000 --- a/apps/server/src/core/page/page.util.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { KyselyTransaction } from '@docmost/db/types/kysely.types'; -import { MovePageDto } from './dto/move-page.dto'; -import { PageOrdering } from '@docmost/db/types/entity.types'; - -export enum OrderingEntity { - WORKSPACE = 'WORKSPACE', - SPACE = 'SPACE', - PAGE = 'PAGE', -} - -export type TreeNode = { - id: string; - title: string; - icon?: string; - children?: TreeNode[]; -}; - -export function orderPageList(arr: string[], payload: MovePageDto): void { - const { pageId: id, after, before } = payload; - - // Removing the item we are moving from the array first. - const index = arr.indexOf(id); - if (index > -1) arr.splice(index, 1); - - if (after) { - const afterIndex = arr.indexOf(after); - if (afterIndex > -1) { - arr.splice(afterIndex + 1, 0, id); - } else { - // Place the item at the end if the after ID is not found. - arr.push(id); - } - } else if (before) { - const beforeIndex = arr.indexOf(before); - if (beforeIndex > -1) { - arr.splice(beforeIndex, 0, id); - } else { - // Place the item at the end if the before ID is not found. - arr.push(id); - } - } else { - // If neither after nor before is provided, just add the id at the end - if (!arr.includes(id)) { - arr.push(id); - } - } -} - -/** - * Remove an item from an array and update the entity - * @param entity - The entity instance (Page or Space) - * @param arrayField - The name of the field which is an array - * @param itemToRemove - The item to remove from the array - * @param manager - EntityManager instance - */ -export async function removeFromArrayAndSave( - entity: PageOrdering, - arrayField: string, - itemToRemove: any, - trx: KyselyTransaction, -) { - const array = entity[arrayField]; - const index = array.indexOf(itemToRemove); - if (index > -1) { - array.splice(index, 1); - await trx - .updateTable('pageOrdering') - .set(entity) - .where('id', '=', entity.id) - .execute(); - } -} - -export function transformPageResult(result: any[]): any[] { - return result.map((row) => { - const processedRow = {}; - for (const key in row) { - //const newKey = key.split('_').slice(1).join('_'); - if (key === 'childrenIds' && !row[key]) { - processedRow[key] = []; - } else { - processedRow[key] = row[key]; - } - } - return processedRow; - }); -} diff --git a/apps/server/src/core/page/services/page-ordering.service.ts b/apps/server/src/core/page/services/page-ordering.service.ts deleted file mode 100644 index 32337b4f..00000000 --- a/apps/server/src/core/page/services/page-ordering.service.ts +++ /dev/null @@ -1,332 +0,0 @@ -import { - forwardRef, - Inject, - Injectable, - NotFoundException, -} from '@nestjs/common'; -import { MovePageDto } from '../dto/move-page.dto'; -import { - OrderingEntity, - orderPageList, - removeFromArrayAndSave, - TreeNode, -} from '../page.util'; -import { PageService } from './page.service'; -import { InjectKysely } from 'nestjs-kysely'; -import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; -import { executeTx } from '@docmost/db/utils'; -import { Page, PageOrdering } from '@docmost/db/types/entity.types'; -import { PageWithOrderingDto } from '../dto/page-with-ordering.dto'; - -@Injectable() -export class PageOrderingService { - constructor( - @Inject(forwardRef(() => PageService)) - private pageService: PageService, - @InjectKysely() private readonly db: KyselyDB, - ) {} - - // TODO: scope to workspace and space - async movePage(dto: MovePageDto, trx?: KyselyTransaction): Promise { - await executeTx( - this.db, - async (trx) => { - const movedPageId = dto.pageId; - - const movedPage = await trx - .selectFrom('pages as page') - .select(['page.id', 'page.spaceId', 'page.parentPageId']) - .where('page.id', '=', movedPageId) - .executeTakeFirst(); - - if (!movedPage) throw new NotFoundException('Moved page not found'); - - // if no parentId, it means the page is a root page or now a root page - if (!dto.parentId) { - // if it had a parent before being moved, we detach it from the previous parent - if (movedPage.parentPageId) { - await this.removeFromParent( - movedPage.parentPageId, - dto.pageId, - trx, - ); - } - const spaceOrdering = await this.getEntityOrdering( - movedPage.spaceId, - OrderingEntity.SPACE, - trx, - ); - - orderPageList(spaceOrdering.childrenIds, dto); - await trx - .updateTable('pageOrdering') - .set(spaceOrdering) - .where('id', '=', spaceOrdering.id) - .execute(); - } else { - const parentPageId = dto.parentId; - - let parentPageOrdering = await this.getEntityOrdering( - parentPageId, - OrderingEntity.PAGE, - trx, - ); - - if (!parentPageOrdering) { - parentPageOrdering = await this.createPageOrdering( - parentPageId, - OrderingEntity.PAGE, - movedPage.spaceId, - trx, - ); - } - - // Check if the parent was changed - if ( - movedPage.parentPageId && - movedPage.parentPageId !== parentPageId - ) { - //if yes, remove moved page from old parent's children - await this.removeFromParent( - movedPage.parentPageId, - dto.pageId, - trx, - ); - } - - // If movedPage didn't have a parent initially (was at root level), update the root level - if (!movedPage.parentPageId) { - await this.removeFromSpacePageOrder( - movedPage.spaceId, - dto.pageId, - trx, - ); - } - - // Modify the children list of the new parentPage and save - orderPageList(parentPageOrdering.childrenIds, dto); - await trx - .updateTable('pageOrdering') - .set(parentPageOrdering) - .where('id', '=', parentPageOrdering.id) - .execute(); - } - - // update the parent Id of the moved page - await trx - .updateTable('pages') - .set({ - parentPageId: movedPage.parentPageId || null, - }) - .where('id', '=', movedPage.id) - .execute(); - }, - trx, - ); - } - - async addPageToOrder( - spaceId: string, - pageId: string, - parentPageId?: string, - trx?: KyselyTransaction, - ) { - await executeTx( - this.db, - async (trx: KyselyTransaction) => { - if (parentPageId) { - await this.upsertOrdering( - parentPageId, - OrderingEntity.PAGE, - pageId, - spaceId, - trx, - ); - } else { - await this.addToSpacePageOrder(pageId, spaceId, trx); - } - }, - trx, - ); - } - - async addToSpacePageOrder( - pageId: string, - spaceId: string, - trx: KyselyTransaction, - ) { - await this.upsertOrdering( - spaceId, - OrderingEntity.SPACE, - pageId, - spaceId, - trx, - ); - } - - async upsertOrdering( - entityId: string, - entityType: string, - childId: string, - spaceId: string, - trx: KyselyTransaction, - ) { - let ordering = await this.getEntityOrdering(entityId, entityType, trx); - console.log(ordering); - console.log('oga1'); - - if (!ordering) { - ordering = await this.createPageOrdering( - entityId, - entityType, - spaceId, - trx, - ); - } - - if (!ordering.childrenIds.includes(childId)) { - ordering.childrenIds.unshift(childId); - console.log(childId); - console.log('childId above'); - await trx - .updateTable('pageOrdering') - .set(ordering) - .where('id', '=', ordering.id) - .execute(); - //await manager.save(PageOrdering, ordering); - } - } - - async removeFromParent( - parentId: string, - childId: string, - trx: KyselyTransaction, - ): Promise { - await this.removeChildFromOrdering( - parentId, - OrderingEntity.PAGE, - childId, - trx, - ); - } - - async removeFromSpacePageOrder( - spaceId: string, - pageId: string, - trx: KyselyTransaction, - ) { - await this.removeChildFromOrdering( - spaceId, - OrderingEntity.SPACE, - pageId, - trx, - ); - } - - async removeChildFromOrdering( - entityId: string, - entityType: string, - childId: string, - trx: KyselyTransaction, - ): Promise { - const ordering = await this.getEntityOrdering(entityId, entityType, trx); - - if (ordering && ordering.childrenIds.includes(childId)) { - await removeFromArrayAndSave(ordering, 'childrenIds', childId, trx); - } - } - - async removePageFromHierarchy( - page: Page, - trx: KyselyTransaction, - ): Promise { - if (page.parentPageId) { - await this.removeFromParent(page.parentPageId, page.id, trx); - } else { - await this.removeFromSpacePageOrder(page.spaceId, page.id, trx); - } - } - - async getEntityOrdering( - entityId: string, - entityType: string, - trx: KyselyTransaction, - ): Promise { - return trx - .selectFrom('pageOrdering') - .selectAll() - .where('entityId', '=', entityId) - .where('entityType', '=', entityType) - .forUpdate() - .executeTakeFirst(); - } - - async createPageOrdering( - entityId: string, - entityType: string, - spaceId: string, - trx: KyselyTransaction, - ): Promise { - await trx - .insertInto('pageOrdering') - .values({ - entityId, - entityType, - spaceId, - childrenIds: [], - }) - .onConflict((oc) => oc.columns(['entityId', 'entityType']).doNothing()) - .execute(); - - // Todo: maybe use returning above - return await this.getEntityOrdering(entityId, entityType, trx); - } - - async getSpacePageOrder( - spaceId: string, - ): Promise<{ id: string; childrenIds: string[]; spaceId: string }> { - return await this.db - .selectFrom('pageOrdering') - .select(['id', 'childrenIds', 'spaceId']) - .where('entityId', '=', spaceId) - .where('entityType', '=', OrderingEntity.SPACE) - .executeTakeFirst(); - } - - async convertToTree(spaceId: string): Promise { - const spaceOrder = await this.getSpacePageOrder(spaceId); - - const pageOrder = spaceOrder ? spaceOrder.childrenIds : undefined; - const pages = await this.pageService.getSidebarPagesBySpaceId(spaceId); - - const pageMap: { [id: string]: PageWithOrderingDto } = {}; - 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, - title: 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[]; - } -} diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts index a3fbdbf0..b85f81a2 100644 --- a/apps/server/src/core/page/services/page.service.ts +++ b/apps/server/src/core/page/services/page.service.ts @@ -1,25 +1,30 @@ import { - forwardRef, - Inject, + BadRequestException, Injectable, NotFoundException, } from '@nestjs/common'; import { CreatePageDto } from '../dto/create-page.dto'; import { UpdatePageDto } from '../dto/update-page.dto'; -import { PageOrderingService } from './page-ordering.service'; -import { PageWithOrderingDto } from '../dto/page-with-ordering.dto'; -import { transformPageResult } from '../page.util'; import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { Page } from '@docmost/db/types/entity.types'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; -import { PaginationResult } from '@docmost/db/pagination/pagination'; +import { + executeWithPagination, + PaginationResult, +} from '@docmost/db/pagination/pagination'; +import { InjectKysely } from 'nestjs-kysely'; +import { KyselyDB } from '@docmost/db/types/kysely.types'; +import { generateJitteredKeyBetween } from 'fractional-indexing-jittered'; +import { MovePageDto } from '../dto/move-page.dto'; +import { ExpressionBuilder } from 'kysely'; +import { DB } from '@docmost/db/types/db'; +import { SidebarPageDto } from '../dto/sidebar-page.dto'; @Injectable() export class PageService { constructor( private pageRepo: PageRepo, - @Inject(forwardRef(() => PageOrderingService)) - private pageOrderingService: PageOrderingService, + @InjectKysely() private readonly db: KyselyDB, ) {} async findById( @@ -27,7 +32,7 @@ export class PageService { includeContent?: boolean, includeYdoc?: boolean, ): Promise { - return this.pageRepo.findById(pageId, includeContent, includeYdoc); + return this.pageRepo.findById(pageId, { includeContent, includeYdoc }); } async create( @@ -38,33 +43,61 @@ export class PageService { // check if parent page exists if (createPageDto.parentPageId) { // TODO: make sure parent page belongs to same space and user has permissions + // make sure user has permission to parent. const parentPage = await this.pageRepo.findById( createPageDto.parentPageId, ); if (!parentPage) throw new NotFoundException('Parent page not found'); } - let pageId = undefined; - if (createPageDto.pageId) { - pageId = createPageDto.pageId; - delete createPageDto.pageId; + let pagePosition: string; + + const lastPageQuery = this.db + .selectFrom('pages') + .select(['id', 'position']) + .where('spaceId', '=', createPageDto.spaceId) + .orderBy('position', 'desc') + .limit(1); + + // todo: simplify code + if (createPageDto.parentPageId) { + // check for children of this page + const lastPage = await lastPageQuery + .where('parentPageId', '=', createPageDto.parentPageId) + .executeTakeFirst(); + + if (!lastPage) { + pagePosition = generateJitteredKeyBetween(null, null); + } else { + // if there is an existing page, we should get a position below it + pagePosition = generateJitteredKeyBetween(lastPage.position, null); + } + } else { + // for root page + const lastPage = await lastPageQuery + .where('parentPageId', 'is', null) + .executeTakeFirst(); + + // if no existing page, make this the first + if (!lastPage) { + pagePosition = generateJitteredKeyBetween(null, null); // we expect "a0" + } else { + // if there is an existing page, we should get a position below it + pagePosition = generateJitteredKeyBetween(lastPage.position, null); + } } - //TODO: should be in a transaction const createdPage = await this.pageRepo.insertPage({ - ...createPageDto, - id: pageId, + title: createPageDto.title, + position: pagePosition, + icon: createPageDto.icon, + parentPageId: createPageDto.parentPageId, + spaceId: createPageDto.spaceId, creatorId: userId, workspaceId: workspaceId, lastUpdatedById: userId, }); - await this.pageOrderingService.addPageToOrder( - createPageDto.spaceId, - pageId, - createPageDto.parentPageId, - ); - return createdPage; } @@ -103,12 +136,90 @@ export class PageService { ); } - async getSidebarPagesBySpaceId( - spaceId: string, - limit = 200, - ): Promise { - const pages = await this.pageRepo.getSpaceSidebarPages(spaceId, limit); - return transformPageResult(pages); + withHasChildren(eb: ExpressionBuilder) { + return eb + .selectFrom('pages as child') + .select((eb) => + eb + .case() + .when(eb.fn.countAll(), '>', 0) + .then(true) + .else(false) + .end() + .as('count'), + ) + .whereRef('child.parentPageId', '=', 'pages.id') + .limit(1) + .as('hasChildren'); + } + + async getSidebarPages( + dto: SidebarPageDto, + pagination: PaginationOptions, + ): Promise { + let query = this.db + .selectFrom('pages') + .select([ + 'id', + 'title', + 'icon', + 'position', + 'parentPageId', + 'spaceId', + 'creatorId', + ]) + .select((eb) => this.withHasChildren(eb)) + .orderBy('position', 'asc') + .where('spaceId', '=', dto.spaceId); + + if (dto.pageId) { + query = query.where('parentPageId', '=', dto.pageId); + } else { + query = query.where('parentPageId', 'is', null); + } + + const result = executeWithPagination(query, { + page: pagination.page, + perPage: 250, + }); + + return result; + } + + async movePage(dto: MovePageDto) { + // validate position value by attempting to generate a key + try { + generateJitteredKeyBetween(dto.position, null); + } catch (err) { + throw new BadRequestException('Invalid move position'); + } + + const movedPage = await this.pageRepo.findById(dto.pageId); + if (!movedPage) throw new NotFoundException('Moved page not found'); + + let parentPageId: string; + if (movedPage.parentPageId === dto.parentPageId) { + parentPageId = undefined; + } else { + // changing the page's parent + if (dto.parentPageId) { + const parentPage = await this.pageRepo.findById(dto.parentPageId); + if (!parentPage) throw new NotFoundException('Parent page not found'); + } + parentPageId = dto.parentPageId; + } + + await this.pageRepo.updatePage( + { + position: dto.position, + parentPageId: parentPageId, + }, + dto.pageId, + ); + + // TODO + // check for duplicates? + // permissions } async getRecentSpacePages( @@ -126,8 +237,8 @@ export class PageService { async forceDelete(pageId: string): Promise { await this.pageRepo.deletePage(pageId); } - - /* +} +/* // TODO: page deletion and restoration async delete(pageId: string): Promise { await this.dataSource.transaction(async (manager: EntityManager) => { @@ -217,4 +328,3 @@ export class PageService { } } */ -} diff --git a/apps/server/src/kysely/kysely-db.module.ts b/apps/server/src/kysely/kysely-db.module.ts index a4ad9509..39231749 100644 --- a/apps/server/src/kysely/kysely-db.module.ts +++ b/apps/server/src/kysely/kysely-db.module.ts @@ -12,7 +12,6 @@ import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; import { PageRepo } from './repos/page/page.repo'; import { CommentRepo } from './repos/comment/comment.repo'; import { PageHistoryRepo } from './repos/page/page-history.repo'; -import { PageOrderingRepo } from './repos/page/page-ordering.repo'; import { AttachmentRepo } from './repos/attachment/attachment.repo'; // https://github.com/brianc/node-postgres/issues/811 @@ -35,9 +34,9 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val)); if (environmentService.getEnv() !== 'development') return; if (event.level === 'query') { console.log(event.query.sql); - if (event.query.parameters.length > 0) { - console.log('parameters: ' + event.query.parameters); - } + //if (event.query.parameters.length > 0) { + //console.log('parameters: ' + event.query.parameters); + //} console.log('time: ' + event.queryDurationMillis); } }, @@ -53,7 +52,6 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val)); SpaceMemberRepo, PageRepo, PageHistoryRepo, - PageOrderingRepo, CommentRepo, AttachmentRepo, ], @@ -66,7 +64,6 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val)); SpaceMemberRepo, PageRepo, PageHistoryRepo, - PageOrderingRepo, CommentRepo, AttachmentRepo, ], diff --git a/apps/server/src/kysely/migrations/20240413T164028-add-position-to-pages.ts b/apps/server/src/kysely/migrations/20240413T164028-add-position-to-pages.ts new file mode 100644 index 00000000..18adc24a --- /dev/null +++ b/apps/server/src/kysely/migrations/20240413T164028-add-position-to-pages.ts @@ -0,0 +1,12 @@ +import { type Kysely } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .alterTable('pages') + .addColumn('position', 'varchar', (col) => col) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable('pages').dropColumn('position').execute(); +} diff --git a/apps/server/src/kysely/pagination/pagination-options.ts b/apps/server/src/kysely/pagination/pagination-options.ts index 73f6e38e..e0481910 100644 --- a/apps/server/src/kysely/pagination/pagination-options.ts +++ b/apps/server/src/kysely/pagination/pagination-options.ts @@ -23,13 +23,4 @@ export class PaginationOptions { @IsOptional() @IsString() query: string; - - get offset(): number { - return (this.page - 1) * this.limit; - } -} - -export enum PaginationSort { - ASC = 'asc', - DESC = 'desc', } diff --git a/apps/server/src/kysely/pagination/pagination.ts b/apps/server/src/kysely/pagination/pagination.ts index e4f7f230..9a299517 100644 --- a/apps/server/src/kysely/pagination/pagination.ts +++ b/apps/server/src/kysely/pagination/pagination.ts @@ -20,6 +20,10 @@ export async function executeWithPagination( experimental_deferredJoinPrimaryKey?: StringReference; }, ): Promise> { + if (opts.page < 1) { + opts.page = 1; + } + console.log('perpage', opts.perPage); qb = qb.limit(opts.perPage + 1).offset((opts.page - 1) * opts.perPage); const deferredJoinPrimaryKey = opts.experimental_deferredJoinPrimaryKey; diff --git a/apps/server/src/kysely/repos/page/page-history.repo.ts b/apps/server/src/kysely/repos/page/page-history.repo.ts index 2df3b797..8f8a23cd 100644 --- a/apps/server/src/kysely/repos/page/page-history.repo.ts +++ b/apps/server/src/kysely/repos/page/page-history.repo.ts @@ -72,7 +72,7 @@ export class PageHistoryRepo { .orderBy('createdAt', 'desc'); const result = executeWithPagination(query, { - page: pagination.offset, + page: pagination.page, perPage: pagination.limit, }); diff --git a/apps/server/src/kysely/repos/page/page-ordering.repo.ts b/apps/server/src/kysely/repos/page/page-ordering.repo.ts deleted file mode 100644 index 07e295fe..00000000 --- a/apps/server/src/kysely/repos/page/page-ordering.repo.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { InjectKysely } from 'nestjs-kysely'; -import { KyselyDB } from '../../types/kysely.types'; - -@Injectable() -export class PageOrderingRepo { - constructor(@InjectKysely() private readonly db: KyselyDB) {} -} diff --git a/apps/server/src/kysely/repos/page/page.repo.ts b/apps/server/src/kysely/repos/page/page.repo.ts index bb982bed..944f8251 100644 --- a/apps/server/src/kysely/repos/page/page.repo.ts +++ b/apps/server/src/kysely/repos/page/page.repo.ts @@ -23,6 +23,7 @@ export class PageRepo { 'icon', 'coverPhoto', 'key', + 'position', 'parentPageId', 'creatorId', 'lastUpdatedById', @@ -38,15 +39,17 @@ export class PageRepo { async findById( pageId: string, - withJsonContent?: boolean, - withYdoc?: boolean, + opts?: { + includeContent?: boolean; + includeYdoc?: boolean; + }, ): Promise { return await this.db .selectFrom('pages') .select(this.baseFields) .where('id', '=', pageId) - .$if(withJsonContent, (qb) => qb.select('content')) - .$if(withYdoc, (qb) => qb.select('ydoc')) + .$if(opts?.includeContent, (qb) => qb.select('content')) + .$if(opts?.includeYdoc, (qb) => qb.select('ydoc')) .executeTakeFirst(); } @@ -79,7 +82,7 @@ export class PageRepo { return db .insertInto('pages') .values(insertablePage) - .returningAll() + .returning(this.baseFields) .executeTakeFirst(); } @@ -101,26 +104,4 @@ export class PageRepo { return result; } - - async getSpaceSidebarPages(spaceId: string, limit: number) { - const pages = await this.db - .selectFrom('pages as page') - .leftJoin('pageOrdering as ordering', 'ordering.entityId', 'page.id') - .where('page.spaceId', '=', spaceId) - .select([ - 'page.id', - 'page.title', - 'page.icon', - 'page.parentPageId', - 'page.spaceId', - 'ordering.childrenIds', - 'page.creatorId', - 'page.createdAt', - ]) - .orderBy('page.updatedAt', 'desc') - .limit(limit) - .execute(); - - return pages; - } } diff --git a/apps/server/src/kysely/types/db.d.ts b/apps/server/src/kysely/types/db.d.ts index 79b6585b..733ef10d 100644 --- a/apps/server/src/kysely/types/db.d.ts +++ b/apps/server/src/kysely/types/db.d.ts @@ -91,18 +91,6 @@ export interface PageHistory { workspaceId: string; } -export interface PageOrdering { - childrenIds: string[]; - createdAt: Generated; - deletedAt: Timestamp | null; - entityId: string; - entityType: string; - id: Generated; - spaceId: string; - updatedAt: Generated; - workspaceId: string; -} - export interface Pages { content: Json | null; coverPhoto: string | null; @@ -118,6 +106,7 @@ export interface Pages { key: string | null; lastUpdatedById: string | null; parentPageId: string | null; + position: string | null; publishedAt: Timestamp | null; slug: string | null; spaceId: string; @@ -209,7 +198,6 @@ export interface DB { groups: Groups; groupUsers: GroupUsers; pageHistory: PageHistory; - pageOrdering: PageOrdering; pages: Pages; spaceMembers: SpaceMembers; spaces: Spaces; diff --git a/apps/server/src/kysely/types/entity.types.ts b/apps/server/src/kysely/types/entity.types.ts index 59cdd3da..e553bcde 100644 --- a/apps/server/src/kysely/types/entity.types.ts +++ b/apps/server/src/kysely/types/entity.types.ts @@ -8,7 +8,6 @@ import { Users, Workspaces, PageHistory as History, - PageOrdering as Ordering, GroupUsers, SpaceMembers, WorkspaceInvitations, @@ -63,11 +62,6 @@ export type PageHistory = Selectable; export type InsertablePageHistory = Insertable; export type UpdatablePageHistory = Updateable>; -// PageOrdering -export type PageOrdering = Selectable; -export type InsertablePageOrdering = Insertable; -export type UpdatablePageOrdering = Updateable>; - // Comment export type Comment = Selectable; export type InsertableComment = Insertable; diff --git a/package.json b/package.json index 0ee80da6..0233540d 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@tiptap/react": "^2.2.4", "@tiptap/starter-kit": "^2.2.4", "@tiptap/suggestion": "^2.2.4", + "fractional-indexing-jittered": "^0.9.1", "y-indexeddb": "^9.0.12", "yjs": "^13.6.14" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a09b898..45f15afb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,6 +98,9 @@ importers: '@tiptap/suggestion': specifier: ^2.2.4 version: 2.2.4(@tiptap/core@2.2.4)(@tiptap/pm@2.2.4) + fractional-indexing-jittered: + specifier: ^0.9.1 + version: 0.9.1 y-indexeddb: specifier: ^9.0.12 version: 9.0.12(yjs@13.6.14) @@ -7605,6 +7608,10 @@ packages: engines: {node: '>= 0.6'} dev: false + /fractional-indexing-jittered@0.9.1: + resolution: {integrity: sha512-qyzDZ7JXWf/yZT2rQDpQwFBbIaZS2o+zb0s740vqreXQ6bFQPd8tAy4D1gGN0CUeIcnNHjuvb0EaLnqHhGV/PA==} + dev: false + /fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} dev: true