fix: page tree reordering

This commit is contained in:
Philipinho
2026-09-08 16:46:38 +01:00
parent 9b3e5dd3dc
commit 6205bbeb90
5 changed files with 108 additions and 70 deletions
@@ -33,6 +33,10 @@ import {
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts"; import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import { treeModel } from "@/features/page/tree/model/tree-model"; import { treeModel } from "@/features/page/tree/model/tree-model";
import {
spaceRoots,
updateSpaceRoots,
} from "@/features/page/tree/utils/utils.ts";
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts"; import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
import type { SpaceTreeNode } from "@/features/page/tree/types.ts"; import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
import classes from "@/features/page/tree/styles/tree.module.css"; import classes from "@/features/page/tree/styles/tree.module.css";
@@ -77,7 +81,10 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
const duplicatedPage = await duplicatePage({ pageId: node.id }); const duplicatedPage = await duplicatePage({ pageId: node.id });
// figure out parent + insertion index // figure out parent + insertion index
const siblings = treeModel.siblingsOf(data, node.id); const siblings = treeModel.siblingsOf(
spaceRoots(data, node.spaceId),
node.id,
);
const parentId = siblings?.parentId ?? null; const parentId = siblings?.parentId ?? null;
const currentIndex = siblings?.index ?? 0; const currentIndex = siblings?.index ?? 0;
const newIndex = currentIndex + 1; const newIndex = currentIndex + 1;
@@ -96,7 +103,9 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
}; };
setData((prev) => setData((prev) =>
treeModel.insert(prev, parentId, treeNodeData, newIndex), updateSpaceRoots(prev, node.spaceId, (roots) =>
treeModel.insert(roots, parentId, treeNodeData, newIndex),
),
); );
setTimeout(() => { setTimeout(() => {
@@ -16,6 +16,8 @@ import {
buildTree, buildTree,
buildTreeWithChildren, buildTreeWithChildren,
mergeRootTrees, mergeRootTrees,
spaceRoots,
updateSpaceRoots,
} from "@/features/page/tree/utils/utils.ts"; } from "@/features/page/tree/utils/utils.ts";
import { SpaceTreeNode } from "@/features/page/tree/types.ts"; import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { getPageTitle } from "@/features/page/page.utils"; import { getPageTitle } from "@/features/page/page.utils";
@@ -66,18 +68,14 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
const allItems = pagesData.pages.flatMap((page) => page.items); const allItems = pagesData.pages.flatMap((page) => page.items);
const treeData = buildTree(allItems); const treeData = buildTree(allItems);
setData((prev) => { // Keep nodes belonging to other spaces — filteredData filters by spaceId
// Keep nodes belonging to other spaces — filteredData filters by spaceId // for rendering, so accumulating is safe. Preserves lazy-loaded children
// for rendering, so accumulating is safe. Preserves lazy-loaded children // and open-state when the user returns to a previously-visited space.
// and open-state when the user returns to a previously-visited space. setData((prev) =>
const otherSpaces = prev.filter((n) => n?.spaceId !== spaceId); updateSpaceRoots(prev, spaceId, (roots) =>
const currentSpace = prev.filter((n) => n?.spaceId === spaceId); roots.length > 0 ? mergeRootTrees(roots, treeData) : treeData,
const refreshed = ),
currentSpace.length > 0 );
? mergeRootTrees(currentSpace, treeData)
: treeData;
return [...otherSpaces, ...refreshed];
});
setIsDataLoaded(true); setIsDataLoaded(true);
}, [pagesData, hasNextPage, spaceId]); }, [pagesData, hasNextPage, spaceId]);
@@ -183,7 +181,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
); );
const filteredData = useMemo( const filteredData = useMemo(
() => data.filter((node) => node?.spaceId === spaceId), () => spaceRoots(data, spaceId),
[data, spaceId], [data, spaceId],
); );
@@ -7,6 +7,10 @@ import { useNavigate, useParams } from "react-router-dom";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts"; import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import { treeModel } from "@/features/page/tree/model/tree-model"; import { treeModel } from "@/features/page/tree/model/tree-model";
import type { DropOp } from "@/features/page/tree/model/tree-model.types"; import type { DropOp } from "@/features/page/tree/model/tree-model.types";
import {
spaceRoots,
updateSpaceRoots,
} from "@/features/page/tree/utils/utils.ts";
import { dropOpToMovePayload } from "./drop-op-to-move-payload"; import { dropOpToMovePayload } from "./drop-op-to-move-payload";
import { SpaceTreeNode } from "@/features/page/tree/types.ts"; import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { IPage } from "@/features/page/types/page.types.ts"; import { IPage } from "@/features/page/types/page.types.ts";
@@ -45,7 +49,7 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
const handleMove = useCallback( const handleMove = useCallback(
async (sourceId: string, op: DropOp) => { async (sourceId: string, op: DropOp) => {
const before = store.get(treeDataAtom); const before = spaceRoots(store.get(treeDataAtom), spaceId);
const { tree: after, result } = treeModel.move(before, sourceId, op); const { tree: after, result } = treeModel.move(before, sourceId, op);
if (after === before) return; if (after === before) return;
@@ -80,12 +84,12 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
} as Partial<SpaceTreeNode>); } as Partial<SpaceTreeNode>);
} }
setData(optimistic); setData((prev) => updateSpaceRoots(prev, spaceId, () => optimistic));
try { try {
await movePageMutation.mutateAsync(payload); await movePageMutation.mutateAsync(payload);
} catch { } catch {
setData(before); setData((prev) => updateSpaceRoots(prev, spaceId, () => before));
notifications.show({ notifications.show({
message: t("Failed to move page"), message: t("Failed to move page"),
color: "red", color: "red",
@@ -157,7 +161,7 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
// tree (e.g. lazy-load children on expand) immediately before calling // tree (e.g. lazy-load children on expand) immediately before calling
// handleCreate hit a stale closure and compute lastIndex against the // handleCreate hit a stale closure and compute lastIndex against the
// pre-load tree, requiring a setTimeout-based wait at the call site. // pre-load tree, requiring a setTimeout-based wait at the call site.
const current = store.get(treeDataAtom); const current = spaceRoots(store.get(treeDataAtom), spaceId);
let lastIndex: number; let lastIndex: number;
if (parentId === null) { if (parentId === null) {
lastIndex = current.length; lastIndex = current.length;
@@ -166,7 +170,11 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
lastIndex = parent?.children?.length ?? 0; lastIndex = parent?.children?.length ?? 0;
} }
setData((prev) => treeModel.insert(prev, parentId, newNode, lastIndex)); setData((prev) =>
updateSpaceRoots(prev, spaceId, (roots) =>
treeModel.insert(roots, parentId, newNode, lastIndex),
),
);
setTimeout(() => { setTimeout(() => {
emit({ emit({
@@ -220,3 +220,21 @@ export function mergeRootTrees(
return sortPositionKeys(merged); return sortPositionKeys(merged);
} }
export function spaceRoots(
tree: SpaceTreeNode[],
spaceId: string,
): SpaceTreeNode[] {
return tree.filter((node) => node?.spaceId === spaceId);
}
export function updateSpaceRoots(
tree: SpaceTreeNode[],
spaceId: string,
update: (roots: SpaceTreeNode[]) => SpaceTreeNode[],
): SpaceTreeNode[] {
const roots = spaceRoots(tree, spaceId);
const next = update(roots);
if (next === roots) return tree;
return [...tree.filter((node) => node?.spaceId !== spaceId), ...next];
}
@@ -6,6 +6,7 @@ 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 { useQueryClient } from "@tanstack/react-query";
import { treeModel } from "@/features/page/tree/model/tree-model"; import { treeModel } from "@/features/page/tree/model/tree-model";
import { updateSpaceRoots } from "@/features/page/tree/utils/utils.ts";
import localEmitter from "@/lib/local-emitter.ts"; import localEmitter from "@/lib/local-emitter.ts";
export const useTreeSocket = () => { export const useTreeSocket = () => {
@@ -61,65 +62,69 @@ export const useTreeSocket = () => {
setTreeData((prev) => { setTreeData((prev) => {
if (treeModel.find(prev, event.payload.data.id)) return prev; if (treeModel.find(prev, event.payload.data.id)) return prev;
const newParentId = event.payload.parentId as string | null; const newParentId = event.payload.parentId as string | null;
let next = treeModel.insert( return updateSpaceRoots(prev, event.spaceId, (roots) => {
prev, let next = treeModel.insert(
newParentId, roots,
event.payload.data, newParentId,
event.payload.index, event.payload.data,
); event.payload.index,
// Mirror the emitter: flip new parent's hasChildren to true so );
// the chevron renders on the receiver. // Mirror the emitter: flip new parent's hasChildren to true so
if (newParentId) { // the chevron renders on the receiver.
next = treeModel.update(next, newParentId, { if (newParentId) {
hasChildren: true, next = treeModel.update(next, newParentId, {
} as Partial<SpaceTreeNode>); hasChildren: true,
} } as Partial<SpaceTreeNode>);
return next; }
return next;
});
}); });
break; break;
case "moveTreeNode": case "moveTreeNode":
setTreeData((prev) => { setTreeData((prev) =>
const sourceBefore = treeModel.find(prev, event.payload.id); updateSpaceRoots(prev, event.spaceId, (roots) => {
if (!sourceBefore) return prev; const sourceBefore = treeModel.find(roots, event.payload.id);
const oldParentId = if (!sourceBefore) return roots;
(sourceBefore as SpaceTreeNode).parentPageId ?? null; const oldParentId =
const newParentId = event.payload.parentId as string | null; (sourceBefore as SpaceTreeNode).parentPageId ?? null;
const newParentId = event.payload.parentId as string | null;
const placed = treeModel.place(prev, event.payload.id, { const placed = treeModel.place(roots, event.payload.id, {
parentId: newParentId, parentId: newParentId,
index: event.payload.index, index: event.payload.index,
}); });
// `place` silently returns the same reference if the destination // `place` silently returns the same reference if the destination
// parent isn't loaded on this client. Falling back to removing the // parent isn't loaded on this client. Falling back to removing the
// source keeps the UI consistent (the source will reappear when // source keeps the UI consistent (the source will reappear when
// the user expands the new parent and lazy-load fetches it). // the user expands the new parent and lazy-load fetches it).
if (placed === prev) { if (placed === roots) {
return treeModel.remove(prev, event.payload.id); return treeModel.remove(roots, event.payload.id);
} }
let next = treeModel.update(placed, event.payload.id, { let next = treeModel.update(placed, event.payload.id, {
position: event.payload.position, position: event.payload.position,
parentPageId: newParentId, parentPageId: newParentId,
} as Partial<SpaceTreeNode>); } as Partial<SpaceTreeNode>);
// Mirror the emitter's hasChildren bookkeeping so both clients // Mirror the emitter's hasChildren bookkeeping so both clients
// converge to the same chevron state. // converge to the same chevron state.
if (oldParentId) { if (oldParentId) {
const oldParent = treeModel.find(next, oldParentId); const oldParent = treeModel.find(next, oldParentId);
if (!oldParent?.children?.length) { if (!oldParent?.children?.length) {
next = treeModel.update(next, oldParentId, { next = treeModel.update(next, oldParentId, {
hasChildren: false, hasChildren: false,
} as Partial<SpaceTreeNode>);
}
}
if (newParentId) {
next = treeModel.update(next, newParentId, {
hasChildren: true,
} as Partial<SpaceTreeNode>); } as Partial<SpaceTreeNode>);
} }
}
if (newParentId) {
next = treeModel.update(next, newParentId, {
hasChildren: true,
} as Partial<SpaceTreeNode>);
}
return next; return next;
}); }),
);
break; break;
case "deleteTreeNode": case "deleteTreeNode":
setTreeData((prev) => { setTreeData((prev) => {