mirror of
https://github.com/docmost/docmost.git
synced 2025-11-13 11:42:37 +10:00
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:
@ -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,
|
||||||
|
|||||||
@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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[] = [];
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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]);
|
||||||
|
|||||||
Reference in New Issue
Block a user