From 978fadd6b90043d9cc9e1ca56b969500b4b44e73 Mon Sep 17 00:00:00 2001 From: Philip Okugbe Date: Sat, 26 Oct 2024 15:48:40 +0100 Subject: [PATCH] fix: improve sidebar page tree syncing (#407) * sync node deletion * tree sync improvements * fix cache bug * fix debounced page title * fix --- .../src/features/editor/title-editor.tsx | 11 +-- .../page/tree/components/space-tree.tsx | 2 +- .../page/tree/hooks/use-tree-mutation.ts | 49 +++++++++-- .../src/features/page/tree/utils/utils.ts | 22 +++++ .../src/features/websocket/types/types.ts | 37 ++++++++- .../websocket/use-query-subscription.ts | 11 ++- .../src/features/websocket/use-tree-socket.ts | 81 +++++++++++-------- 7 files changed, 163 insertions(+), 50 deletions(-) diff --git a/apps/client/src/features/editor/title-editor.tsx b/apps/client/src/features/editor/title-editor.tsx index 1bd4eb91..a02e597b 100644 --- a/apps/client/src/features/editor/title-editor.tsx +++ b/apps/client/src/features/editor/title-editor.tsx @@ -11,7 +11,6 @@ import { titleEditorAtom, } from "@/features/editor/atoms/editor-atoms"; import { - usePageQuery, useUpdatePageMutation, } from "@/features/page/queries/page-query"; import { useDebouncedValue } from "@mantine/hooks"; @@ -21,7 +20,7 @@ import { updateTreeNodeName } from "@/features/page/tree/utils"; import { useQueryEmit } from "@/features/websocket/use-query-emit.ts"; import { History } from "@tiptap/extension-history"; import { buildPageUrl } from "@/features/page/page.utils.ts"; -import { useNavigate, useParams } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; export interface TitleEditorProps { pageId: string; @@ -39,14 +38,15 @@ export function TitleEditor({ editable, }: TitleEditorProps) { const [debouncedTitleState, setDebouncedTitleState] = useState(null); - const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 1000); + const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 500); const updatePageMutation = useUpdatePageMutation(); const pageEditor = useAtomValue(pageEditorAtom); const [, setTitleEditor] = useAtom(titleEditorAtom); const [treeData, setTreeData] = useAtom(treeDataAtom); const emit = useQueryEmit(); - const navigate = useNavigate(); + const [activePageId, setActivePageId] = useState(pageId); + const titleEditor = useEditor({ extensions: [ @@ -74,6 +74,7 @@ export function TitleEditor({ onUpdate({ editor }) { const currentTitle = editor.getText(); setDebouncedTitleState(currentTitle); + setActivePageId(pageId); }, editable: editable, content: title, @@ -85,7 +86,7 @@ export function TitleEditor({ }, [title]); useEffect(() => { - if (debouncedTitle !== null) { + if (debouncedTitle !== null && activePageId === pageId) { updatePageMutation.mutate({ pageId: pageId, title: debouncedTitle, diff --git a/apps/client/src/features/page/tree/components/space-tree.tsx b/apps/client/src/features/page/tree/components/space-tree.tsx index f78485a1..67e0d0e5 100644 --- a/apps/client/src/features/page/tree/components/space-tree.tsx +++ b/apps/client/src/features/page/tree/components/space-tree.tsx @@ -207,7 +207,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) { overscanCount={10} dndRootElement={rootElement.current} onToggle={() => { - setOpenTreeNodes(treeApiRef.current.openState); + setOpenTreeNodes(treeApiRef.current?.openState); }} initialOpenState={openTreeNodes} > 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 index 0cc41a9d..e75bf478 100644 --- a/apps/client/src/features/page/tree/hooks/use-tree-mutation.ts +++ b/apps/client/src/features/page/tree/hooks/use-tree-mutation.ts @@ -21,6 +21,7 @@ import { generateJitteredKeyBetween } from "fractional-indexing-jittered"; import { SpaceTreeNode } from "@/features/page/tree/types.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts"; import { getSpaceUrl } from "@/lib/config.ts"; +import { useQueryEmit } from "@/features/websocket/use-query-emit.ts"; export function useTreeMutation(spaceId: string) { const [data, setData] = useAtom(treeDataAtom); @@ -31,6 +32,8 @@ export function useTreeMutation(spaceId: string) { const movePageMutation = useMovePageMutation(); const navigate = useNavigate(); const { spaceSlug } = useParams(); + const { pageSlug } = useParams(); + const emit = useQueryEmit(); const onCreate: CreateHandler = async ({ parentId, index, type }) => { const payload: { spaceId: string; parentPageId?: string } = { @@ -69,6 +72,17 @@ export function useTreeMutation(spaceId: string) { tree.create({ parentId, index, data }); setData(tree.data); + setTimeout(() => { + emit({ + operation: "addTreeNode", + payload: { + parentId, + index, + data + } + }); + }, 50); + const pageUrl = buildPageUrl( spaceSlug, createdPage.slugId, @@ -100,7 +114,7 @@ export function useTreeMutation(spaceId: string) { : 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 + // we have to access the node differently via currentTreeData[args.index]?.data?.position // this makes it possible to correctly sort children of a parent node that is not the root const afterPosition = @@ -147,11 +161,13 @@ export function useTreeMutation(spaceId: string) { if (childrenCount === 0) { tree.update({ id: previousParent.id, - changes: { ...previousParent.data, hasChildren: false } as any, + changes: { ... previousParent.data, hasChildren: false } as any, }); } } + //console.log() + setData(tree.data); const payload: IMovePage = { @@ -162,6 +178,13 @@ export function useTreeMutation(spaceId: string) { try { movePageMutation.mutateAsync(payload); + + setTimeout(() => { + emit({ + operation: "moveTreeNode", + payload: { id: draggedNodeId, parentId: args.parentId, index: args.index, position: newPosition }, + }); + }, 50); } catch (error) { console.error("Error moving page:", error); } @@ -182,12 +205,26 @@ export function useTreeMutation(spaceId: string) { try { await deletePageMutation.mutateAsync(args.ids[0]); - if (tree.find(args.ids[0])) { - tree.drop({ id: args.ids[0] }); - setData(tree.data); + const node = tree.find(args.ids[0]); + if (!node) { + return; } - navigate(getSpaceUrl(spaceSlug)); + tree.drop({ id: args.ids[0] }); + setData(tree.data); + + // navigate only if the current url is same as the deleted page + if (pageSlug && node.data.slugId === pageSlug.split('-')[1]) { + navigate(getSpaceUrl(spaceSlug)); + } + + setTimeout(() => { + emit({ + operation: "deleteTreeNode", + payload: { node: node.data } + }); + }, 50); + } catch (error) { console.error("Failed to delete page:", error); } diff --git a/apps/client/src/features/page/tree/utils/utils.ts b/apps/client/src/features/page/tree/utils/utils.ts index b00be65a..0dfe8ed4 100644 --- a/apps/client/src/features/page/tree/utils/utils.ts +++ b/apps/client/src/features/page/tree/utils/utils.ts @@ -100,6 +100,28 @@ export const updateTreeNodeIcon = ( }); }; +export const deleteTreeNode = ( + nodes: SpaceTreeNode[], + nodeId: string, +): SpaceTreeNode[] => { + return nodes + .map((node) => { + if (node.id === nodeId) { + return null; + } + + if (node.children && node.children.length > 0) { + return { + ...node, + children: deleteTreeNode(node.children, nodeId), + }; + } + return node; + }) + .filter((node) => node !== null); +}; + + export function buildTreeWithChildren(items: SpaceTreeNode[]): SpaceTreeNode[] { const nodeMap = {}; let result: SpaceTreeNode[] = []; diff --git a/apps/client/src/features/websocket/types/types.ts b/apps/client/src/features/websocket/types/types.ts index fc2754b4..583a9d0a 100644 --- a/apps/client/src/features/websocket/types/types.ts +++ b/apps/client/src/features/websocket/types/types.ts @@ -1,3 +1,5 @@ +import { SpaceTreeNode } from "@/features/page/tree/types.ts"; + export type InvalidateEvent = { operation: "invalidate"; entity: Array; @@ -11,4 +13,37 @@ export type UpdateEvent = { payload: Partial; }; -export type WebSocketEvent = InvalidateEvent | UpdateEvent; +export type DeleteEvent = { + operation: "deleteOne"; + entity: Array; + id: string; + payload?: Partial; +}; + +export type AddTreeNodeEvent = { + operation: "addTreeNode"; + payload: { + parentId: string; + index: number; + data: SpaceTreeNode; + }; +}; + +export type MoveTreeNodeEvent = { + operation: "moveTreeNode"; + payload: { + id: string; + parentId: string; + index: number; + position: string; + } +}; + +export type DeleteTreeNodeEvent = { + operation: "deleteTreeNode"; + payload: { + node: SpaceTreeNode + } +}; + +export type WebSocketEvent = InvalidateEvent | UpdateEvent | DeleteEvent | AddTreeNodeEvent | MoveTreeNodeEvent | DeleteTreeNodeEvent; diff --git a/apps/client/src/features/websocket/use-query-subscription.ts b/apps/client/src/features/websocket/use-query-subscription.ts index 1ce9f36e..576b6777 100644 --- a/apps/client/src/features/websocket/use-query-subscription.ts +++ b/apps/client/src/features/websocket/use-query-subscription.ts @@ -30,10 +30,13 @@ export const useQuerySubscription = () => { queryKeyId = data.id; } - queryClient.setQueryData([...data.entity, queryKeyId], { - ...queryClient.getQueryData([...data.entity, queryKeyId]), - ...data.payload, - }); + // only update if data was already in cache + if(queryClient.getQueryData([...data.entity, queryKeyId])){ + queryClient.setQueryData([...data.entity, queryKeyId], { + ...queryClient.getQueryData([...data.entity, queryKeyId]), + ...data.payload, + }); + } /* queryClient.setQueriesData( diff --git a/apps/client/src/features/websocket/use-tree-socket.ts b/apps/client/src/features/websocket/use-tree-socket.ts index 2fa4fb96..3c759a99 100644 --- a/apps/client/src/features/websocket/use-tree-socket.ts +++ b/apps/client/src/features/websocket/use-tree-socket.ts @@ -2,17 +2,15 @@ import { useEffect, useRef } from "react"; import { socketAtom } from "@/features/websocket/atoms/socket-atom.ts"; import { useAtom } from "jotai"; import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts"; -import { - updateTreeNodeIcon, - updateTreeNodeName, -} from "@/features/page/tree/utils"; import { WebSocketEvent } from "@/features/websocket/types"; import { SpaceTreeNode } from "@/features/page/tree/types.ts"; +import { useQueryClient } from "@tanstack/react-query"; +import { SimpleTree } from "react-arborist"; export const useTreeSocket = () => { const [socket] = useAtom(socketAtom); const [treeData, setTreeData] = useAtom(treeDataAtom); - + const queryClient = useQueryClient(); const initialTreeData = useRef(treeData); useEffect(() => { @@ -20,42 +18,59 @@ export const useTreeSocket = () => { }, [treeData]); useEffect(() => { - socket?.on("message", (event) => { - const data: WebSocketEvent = event; + socket?.on("message", (event: WebSocketEvent) => { const initialData = initialTreeData.current; - switch (data.operation) { - case "invalidate": - // nothing to do here - break; + const treeApi = new SimpleTree(initialData); + + switch (event.operation) { case "updateOne": - // Get the initial value of treeData - if (initialData && initialData.length > 0) { - let newTreeData: SpaceTreeNode[]; - - if (data.entity[0] === "pages") { - if (data.payload?.title !== undefined) { - newTreeData = updateTreeNodeName( - initialData, - data.id, - data.payload.title, - ); + if (event.entity[0] === "pages") { + if (treeApi.find(event.id)) { + if (event.payload?.title) { + treeApi.update({ id: event.id, changes: { name: event.payload.title } }); } - - if (data.payload?.icon !== undefined) { - newTreeData = updateTreeNodeIcon( - initialData, - data.id, - data.payload.icon, - ); - } - - if (newTreeData && newTreeData.length > 0) { - setTreeData(newTreeData); + if (event.payload?.icon) { + treeApi.update({ id: event.id, changes: { icon: event.payload.icon } }); } + setTreeData(treeApi.data); } } break; + case 'addTreeNode': + if (treeApi.find(event.payload.data.id)) return; + + treeApi.create({ parentId: event.payload.parentId, index: event.payload.index, data: event.payload.data }); + setTreeData(treeApi.data); + + break; + case 'moveTreeNode': + // move node + treeApi.move({ + id: event.payload.id, + parentId: event.payload.parentId, + index: event.payload.index + }); + + // update node position + treeApi.update({ + id: event.payload.id, + changes: { + position: event.payload.position, + } + }); + + setTreeData(treeApi.data); + + break; + case "deleteTreeNode": + treeApi.drop({ id: event.payload.node.id }); + setTreeData(treeApi.data); + + queryClient.invalidateQueries({ + queryKey: ['pages', event.payload.node.slugId].filter(Boolean), + }); + break; } }); }, [socket]);