fix: improve sidebar page tree syncing (#407)

* sync node deletion

* tree sync improvements

* fix cache bug

* fix debounced page title

* fix
This commit is contained in:
Philip Okugbe
2024-10-26 15:48:40 +01:00
committed by GitHub
parent b57be9c736
commit 978fadd6b9
7 changed files with 163 additions and 50 deletions

View File

@ -11,7 +11,6 @@ import {
titleEditorAtom, titleEditorAtom,
} from "@/features/editor/atoms/editor-atoms"; } from "@/features/editor/atoms/editor-atoms";
import { import {
usePageQuery,
useUpdatePageMutation, useUpdatePageMutation,
} from "@/features/page/queries/page-query"; } from "@/features/page/queries/page-query";
import { useDebouncedValue } from "@mantine/hooks"; 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 { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
import { History } from "@tiptap/extension-history"; import { History } from "@tiptap/extension-history";
import { buildPageUrl } from "@/features/page/page.utils.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate } from "react-router-dom";
export interface TitleEditorProps { export interface TitleEditorProps {
pageId: string; pageId: string;
@ -39,14 +38,15 @@ export function TitleEditor({
editable, editable,
}: TitleEditorProps) { }: TitleEditorProps) {
const [debouncedTitleState, setDebouncedTitleState] = useState(null); const [debouncedTitleState, setDebouncedTitleState] = useState(null);
const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 1000); const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 500);
const updatePageMutation = useUpdatePageMutation(); const updatePageMutation = useUpdatePageMutation();
const pageEditor = useAtomValue(pageEditorAtom); const pageEditor = useAtomValue(pageEditorAtom);
const [, setTitleEditor] = useAtom(titleEditorAtom); const [, setTitleEditor] = useAtom(titleEditorAtom);
const [treeData, setTreeData] = useAtom(treeDataAtom); const [treeData, setTreeData] = useAtom(treeDataAtom);
const emit = useQueryEmit(); const emit = useQueryEmit();
const navigate = useNavigate(); const navigate = useNavigate();
const [activePageId, setActivePageId] = useState(pageId);
const titleEditor = useEditor({ const titleEditor = useEditor({
extensions: [ extensions: [
@ -74,6 +74,7 @@ export function TitleEditor({
onUpdate({ editor }) { onUpdate({ editor }) {
const currentTitle = editor.getText(); const currentTitle = editor.getText();
setDebouncedTitleState(currentTitle); setDebouncedTitleState(currentTitle);
setActivePageId(pageId);
}, },
editable: editable, editable: editable,
content: title, content: title,
@ -85,7 +86,7 @@ export function TitleEditor({
}, [title]); }, [title]);
useEffect(() => { useEffect(() => {
if (debouncedTitle !== null) { if (debouncedTitle !== null && activePageId === pageId) {
updatePageMutation.mutate({ updatePageMutation.mutate({
pageId: pageId, pageId: pageId,
title: debouncedTitle, title: debouncedTitle,

View File

@ -207,7 +207,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
overscanCount={10} overscanCount={10}
dndRootElement={rootElement.current} dndRootElement={rootElement.current}
onToggle={() => { onToggle={() => {
setOpenTreeNodes(treeApiRef.current.openState); setOpenTreeNodes(treeApiRef.current?.openState);
}} }}
initialOpenState={openTreeNodes} initialOpenState={openTreeNodes}
> >

View File

@ -21,6 +21,7 @@ import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
import { SpaceTreeNode } from "@/features/page/tree/types.ts"; import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts";
import { getSpaceUrl } from "@/lib/config.ts"; import { getSpaceUrl } from "@/lib/config.ts";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
export function useTreeMutation<T>(spaceId: string) { export function useTreeMutation<T>(spaceId: string) {
const [data, setData] = useAtom(treeDataAtom); const [data, setData] = useAtom(treeDataAtom);
@ -31,6 +32,8 @@ export function useTreeMutation<T>(spaceId: string) {
const movePageMutation = useMovePageMutation(); const movePageMutation = useMovePageMutation();
const navigate = useNavigate(); const navigate = useNavigate();
const { spaceSlug } = useParams(); const { spaceSlug } = useParams();
const { pageSlug } = useParams();
const emit = useQueryEmit();
const onCreate: CreateHandler<T> = async ({ parentId, index, type }) => { const onCreate: CreateHandler<T> = async ({ parentId, index, type }) => {
const payload: { spaceId: string; parentPageId?: string } = { const payload: { spaceId: string; parentPageId?: string } = {
@ -69,6 +72,17 @@ export function useTreeMutation<T>(spaceId: string) {
tree.create({ parentId, index, data }); tree.create({ parentId, index, data });
setData(tree.data); setData(tree.data);
setTimeout(() => {
emit({
operation: "addTreeNode",
payload: {
parentId,
index,
data
}
});
}, 50);
const pageUrl = buildPageUrl( const pageUrl = buildPageUrl(
spaceSlug, spaceSlug,
createdPage.slugId, createdPage.slugId,
@ -100,7 +114,7 @@ export function useTreeMutation<T>(spaceId: string) {
: tree.data; : tree.data;
// if there is a parentId, tree.find(args.parentId).children returns a SimpleNode array // 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 // this makes it possible to correctly sort children of a parent node that is not the root
const afterPosition = const afterPosition =
@ -147,11 +161,13 @@ export function useTreeMutation<T>(spaceId: string) {
if (childrenCount === 0) { if (childrenCount === 0) {
tree.update({ tree.update({
id: previousParent.id, id: previousParent.id,
changes: { ...previousParent.data, hasChildren: false } as any, changes: { ... previousParent.data, hasChildren: false } as any,
}); });
} }
} }
//console.log()
setData(tree.data); setData(tree.data);
const payload: IMovePage = { const payload: IMovePage = {
@ -162,6 +178,13 @@ export function useTreeMutation<T>(spaceId: string) {
try { try {
movePageMutation.mutateAsync(payload); movePageMutation.mutateAsync(payload);
setTimeout(() => {
emit({
operation: "moveTreeNode",
payload: { id: draggedNodeId, parentId: args.parentId, index: args.index, position: newPosition },
});
}, 50);
} catch (error) { } catch (error) {
console.error("Error moving page:", error); console.error("Error moving page:", error);
} }
@ -182,12 +205,26 @@ export function useTreeMutation<T>(spaceId: string) {
try { try {
await deletePageMutation.mutateAsync(args.ids[0]); await deletePageMutation.mutateAsync(args.ids[0]);
if (tree.find(args.ids[0])) { const node = tree.find(args.ids[0]);
tree.drop({ id: args.ids[0] }); if (!node) {
setData(tree.data); 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) { } catch (error) {
console.error("Failed to delete page:", error); console.error("Failed to delete page:", error);
} }

View File

@ -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[] { export function buildTreeWithChildren(items: SpaceTreeNode[]): SpaceTreeNode[] {
const nodeMap = {}; const nodeMap = {};
let result: SpaceTreeNode[] = []; let result: SpaceTreeNode[] = [];

View File

@ -1,3 +1,5 @@
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
export type InvalidateEvent = { export type InvalidateEvent = {
operation: "invalidate"; operation: "invalidate";
entity: Array<string>; entity: Array<string>;
@ -11,4 +13,37 @@ export type UpdateEvent = {
payload: Partial<any>; payload: Partial<any>;
}; };
export type WebSocketEvent = InvalidateEvent | UpdateEvent; export type DeleteEvent = {
operation: "deleteOne";
entity: Array<string>;
id: string;
payload?: Partial<any>;
};
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;

View File

@ -30,10 +30,13 @@ export const useQuerySubscription = () => {
queryKeyId = data.id; queryKeyId = data.id;
} }
queryClient.setQueryData([...data.entity, queryKeyId], { // only update if data was already in cache
...queryClient.getQueryData([...data.entity, queryKeyId]), if(queryClient.getQueryData([...data.entity, queryKeyId])){
...data.payload, queryClient.setQueryData([...data.entity, queryKeyId], {
}); ...queryClient.getQueryData([...data.entity, queryKeyId]),
...data.payload,
});
}
/* /*
queryClient.setQueriesData( queryClient.setQueriesData(

View File

@ -2,17 +2,15 @@ import { useEffect, useRef } from "react";
import { socketAtom } from "@/features/websocket/atoms/socket-atom.ts"; import { socketAtom } from "@/features/websocket/atoms/socket-atom.ts";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts"; 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 { WebSocketEvent } from "@/features/websocket/types";
import { SpaceTreeNode } from "@/features/page/tree/types.ts"; import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { useQueryClient } from "@tanstack/react-query";
import { SimpleTree } from "react-arborist";
export const useTreeSocket = () => { export const useTreeSocket = () => {
const [socket] = useAtom(socketAtom); const [socket] = useAtom(socketAtom);
const [treeData, setTreeData] = useAtom(treeDataAtom); const [treeData, setTreeData] = useAtom(treeDataAtom);
const queryClient = useQueryClient();
const initialTreeData = useRef(treeData); const initialTreeData = useRef(treeData);
useEffect(() => { useEffect(() => {
@ -20,42 +18,59 @@ export const useTreeSocket = () => {
}, [treeData]); }, [treeData]);
useEffect(() => { useEffect(() => {
socket?.on("message", (event) => { socket?.on("message", (event: WebSocketEvent) => {
const data: WebSocketEvent = event;
const initialData = initialTreeData.current; const initialData = initialTreeData.current;
switch (data.operation) { const treeApi = new SimpleTree<SpaceTreeNode>(initialData);
case "invalidate":
// nothing to do here switch (event.operation) {
break;
case "updateOne": case "updateOne":
// Get the initial value of treeData if (event.entity[0] === "pages") {
if (initialData && initialData.length > 0) { if (treeApi.find(event.id)) {
let newTreeData: SpaceTreeNode[]; if (event.payload?.title) {
treeApi.update({ id: event.id, changes: { name: event.payload.title } });
if (data.entity[0] === "pages") {
if (data.payload?.title !== undefined) {
newTreeData = updateTreeNodeName(
initialData,
data.id,
data.payload.title,
);
} }
if (event.payload?.icon) {
if (data.payload?.icon !== undefined) { treeApi.update({ id: event.id, changes: { icon: event.payload.icon } });
newTreeData = updateTreeNodeIcon(
initialData,
data.id,
data.payload.icon,
);
}
if (newTreeData && newTreeData.length > 0) {
setTreeData(newTreeData);
} }
setTreeData(treeApi.data);
} }
} }
break; 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]); }, [socket]);