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 { 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 type { SpaceTreeNode } from "@/features/page/tree/types.ts";
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 });
// 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 currentIndex = siblings?.index ?? 0;
const newIndex = currentIndex + 1;
@@ -96,7 +103,9 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
};
setData((prev) =>
treeModel.insert(prev, parentId, treeNodeData, newIndex),
updateSpaceRoots(prev, node.spaceId, (roots) =>
treeModel.insert(roots, parentId, treeNodeData, newIndex),
),
);
setTimeout(() => {
@@ -16,6 +16,8 @@ import {
buildTree,
buildTreeWithChildren,
mergeRootTrees,
spaceRoots,
updateSpaceRoots,
} from "@/features/page/tree/utils/utils.ts";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
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 treeData = buildTree(allItems);
setData((prev) => {
// Keep nodes belonging to other spaces — filteredData filters by spaceId
// for rendering, so accumulating is safe. Preserves lazy-loaded children
// and open-state when the user returns to a previously-visited space.
const otherSpaces = prev.filter((n) => n?.spaceId !== spaceId);
const currentSpace = prev.filter((n) => n?.spaceId === spaceId);
const refreshed =
currentSpace.length > 0
? mergeRootTrees(currentSpace, treeData)
: treeData;
return [...otherSpaces, ...refreshed];
});
// Keep nodes belonging to other spaces — filteredData filters by spaceId
// for rendering, so accumulating is safe. Preserves lazy-loaded children
// and open-state when the user returns to a previously-visited space.
setData((prev) =>
updateSpaceRoots(prev, spaceId, (roots) =>
roots.length > 0 ? mergeRootTrees(roots, treeData) : treeData,
),
);
setIsDataLoaded(true);
}, [pagesData, hasNextPage, spaceId]);
@@ -183,7 +181,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
);
const filteredData = useMemo(
() => data.filter((node) => node?.spaceId === spaceId),
() => spaceRoots(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 { treeModel } from "@/features/page/tree/model/tree-model";
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 { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { IPage } from "@/features/page/types/page.types.ts";
@@ -45,7 +49,7 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
const handleMove = useCallback(
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);
if (after === before) return;
@@ -80,12 +84,12 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
} as Partial<SpaceTreeNode>);
}
setData(optimistic);
setData((prev) => updateSpaceRoots(prev, spaceId, () => optimistic));
try {
await movePageMutation.mutateAsync(payload);
} catch {
setData(before);
setData((prev) => updateSpaceRoots(prev, spaceId, () => before));
notifications.show({
message: t("Failed to move page"),
color: "red",
@@ -157,7 +161,7 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
// tree (e.g. lazy-load children on expand) immediately before calling
// handleCreate hit a stale closure and compute lastIndex against the
// 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;
if (parentId === null) {
lastIndex = current.length;
@@ -166,7 +170,11 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
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(() => {
emit({
@@ -220,3 +220,21 @@ export function mergeRootTrees(
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 { useQueryClient } from "@tanstack/react-query";
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";
export const useTreeSocket = () => {
@@ -61,65 +62,69 @@ export const useTreeSocket = () => {
setTreeData((prev) => {
if (treeModel.find(prev, event.payload.data.id)) return prev;
const newParentId = event.payload.parentId as string | null;
let next = treeModel.insert(
prev,
newParentId,
event.payload.data,
event.payload.index,
);
// Mirror the emitter: flip new parent's hasChildren to true so
// the chevron renders on the receiver.
if (newParentId) {
next = treeModel.update(next, newParentId, {
hasChildren: true,
} as Partial<SpaceTreeNode>);
}
return next;
return updateSpaceRoots(prev, event.spaceId, (roots) => {
let next = treeModel.insert(
roots,
newParentId,
event.payload.data,
event.payload.index,
);
// Mirror the emitter: flip new parent's hasChildren to true so
// the chevron renders on the receiver.
if (newParentId) {
next = treeModel.update(next, newParentId, {
hasChildren: true,
} as Partial<SpaceTreeNode>);
}
return next;
});
});
break;
case "moveTreeNode":
setTreeData((prev) => {
const sourceBefore = treeModel.find(prev, event.payload.id);
if (!sourceBefore) return prev;
const oldParentId =
(sourceBefore as SpaceTreeNode).parentPageId ?? null;
const newParentId = event.payload.parentId as string | null;
setTreeData((prev) =>
updateSpaceRoots(prev, event.spaceId, (roots) => {
const sourceBefore = treeModel.find(roots, event.payload.id);
if (!sourceBefore) return roots;
const oldParentId =
(sourceBefore as SpaceTreeNode).parentPageId ?? null;
const newParentId = event.payload.parentId as string | null;
const placed = treeModel.place(prev, event.payload.id, {
parentId: newParentId,
index: event.payload.index,
});
// `place` silently returns the same reference if the destination
// parent isn't loaded on this client. Falling back to removing the
// source keeps the UI consistent (the source will reappear when
// the user expands the new parent and lazy-load fetches it).
if (placed === prev) {
return treeModel.remove(prev, event.payload.id);
}
const placed = treeModel.place(roots, event.payload.id, {
parentId: newParentId,
index: event.payload.index,
});
// `place` silently returns the same reference if the destination
// parent isn't loaded on this client. Falling back to removing the
// source keeps the UI consistent (the source will reappear when
// the user expands the new parent and lazy-load fetches it).
if (placed === roots) {
return treeModel.remove(roots, event.payload.id);
}
let next = treeModel.update(placed, event.payload.id, {
position: event.payload.position,
parentPageId: newParentId,
} as Partial<SpaceTreeNode>);
let next = treeModel.update(placed, event.payload.id, {
position: event.payload.position,
parentPageId: newParentId,
} as Partial<SpaceTreeNode>);
// Mirror the emitter's hasChildren bookkeeping so both clients
// converge to the same chevron state.
if (oldParentId) {
const oldParent = treeModel.find(next, oldParentId);
if (!oldParent?.children?.length) {
next = treeModel.update(next, oldParentId, {
hasChildren: false,
// Mirror the emitter's hasChildren bookkeeping so both clients
// converge to the same chevron state.
if (oldParentId) {
const oldParent = treeModel.find(next, oldParentId);
if (!oldParent?.children?.length) {
next = treeModel.update(next, oldParentId, {
hasChildren: false,
} as Partial<SpaceTreeNode>);
}
}
if (newParentId) {
next = treeModel.update(next, newParentId, {
hasChildren: true,
} as Partial<SpaceTreeNode>);
}
}
if (newParentId) {
next = treeModel.update(next, newParentId, {
hasChildren: true,
} as Partial<SpaceTreeNode>);
}
return next;
});
return next;
}),
);
break;
case "deleteTreeNode":
setTreeData((prev) => {