feat(FN-3090): add graph position persistence to dependency graph plugin
Merges graph position persistence into the dependency graph plugin (FN-3090) via a scoped storage layer, a dedicated position storage utility, and a React hook that preserves pan/zoom state across sessions. Also includes a responsive CSS fix for ScriptsModal and supporting test coverage in both the Fusion-Task-Id: FN-3090
This commit is contained in:
@@ -12,6 +12,7 @@ Plugin-provided top-level **Graph** dashboard view for Fusion.
|
||||
- **Interaction**: drag-to-pan canvas background, drag-to-reposition nodes, cursor-centered wheel zoom, pinch-to-zoom with stationary midpoint, keyboard shortcuts, zoom toolbar, reset, and fit-to-graph
|
||||
- **Fit-to-graph**: computes node bounding box with layout node dimensions and applies zoom/pan so the graph fits in viewport with padding
|
||||
- **Initial auto-fit**: when no saved scoped positions exist, the first non-empty render auto-fits once; subsequent updates preserve user navigation state
|
||||
- **Position persistence**: dragged node positions are stored per project in browser localStorage and restored on reload
|
||||
- **Animated transitions**: fit/reset operations animate `transform` (`var(--transition-normal)`), while continuous drag/wheel/pinch stays transition-free for responsiveness
|
||||
- **Node rendering**: each graph node renders the real dashboard `TaskCard` via `GraphTaskNode` (no duplicated card markup)
|
||||
- **In-progress behavior**: steps are visible by default and active-task glow (`agent-active`) is preserved because node cards reuse TaskCard directly
|
||||
@@ -23,6 +24,14 @@ Plugin-provided top-level **Graph** dashboard view for Fusion.
|
||||
- **Graph edge classes**: `.graph-edge--highlighted` and `.graph-edge--dimmed` are applied during dependency-chain emphasis states
|
||||
- **Drag behavior**: graph nodes pass `disableDrag={true}` to `TaskCard` so card-level HTML5 drag does not conflict with canvas pan/zoom
|
||||
|
||||
## Position persistence
|
||||
|
||||
- Storage key format: `kb:${projectId}:dependency-graph-positions` (falls back to `dependency-graph-positions` when no project is selected)
|
||||
- Read path: positions load on graph mount and whenever `projectId` changes, then merge with fresh auto-layout so new tasks still receive layout defaults
|
||||
- Write path: positions persist on drag end only (not on every drag frame), filtered to currently visible tasks for stale cleanup
|
||||
- Reset behavior: Fit to graph / Reset view clear persisted positions and re-apply auto-layout
|
||||
- Implementation detail: the plugin ships local `scopedStorage` helpers duplicated from dashboard `projectStorage` to stay plugin-isolated while preserving the same `kb:${projectId}:${baseKey}` convention
|
||||
|
||||
## Dependency chain highlighting
|
||||
|
||||
- **Hover** a node to highlight the full transitive upstream + downstream chain for that task.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { GraphTaskNode } from "./GraphTaskNode";
|
||||
import { GraphToolbar } from "./GraphToolbar";
|
||||
@@ -8,18 +8,12 @@ import { computeAutoLayout } from "./layout";
|
||||
import { useGraphData } from "./useGraphData";
|
||||
import { useGraphInteraction } from "./useGraphInteraction";
|
||||
import { useDependencyChain } from "./hooks/useDependencyChain";
|
||||
import { useGraphPositions } from "./hooks/useGraphPositions";
|
||||
import { mergePositions, type NodePositions } from "./utils/graphPositionStorage";
|
||||
import "./DependencyGraph.css";
|
||||
|
||||
const NODE_WIDTH = 280;
|
||||
const NODE_HEIGHT = 100;
|
||||
const POSITION_STORAGE_KEY = "fusion-plugin-dependency-graph:positions";
|
||||
|
||||
function getScopedPositionItem(projectId?: string): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
if (typeof window.localStorage?.getItem !== "function") return null;
|
||||
const key = projectId ? `kb:${projectId}:${POSITION_STORAGE_KEY}` : POSITION_STORAGE_KEY;
|
||||
return window.localStorage.getItem(key);
|
||||
}
|
||||
|
||||
export interface DependencyGraphProps {
|
||||
tasks: Task[];
|
||||
@@ -78,18 +72,20 @@ export function DependencyGraph({
|
||||
() => computeAutoLayout(graphData, { nodeWidth: NODE_WIDTH, nodeHeight: NODE_HEIGHT, horizontalGap: 40, verticalGap: 80 }),
|
||||
[graphData],
|
||||
);
|
||||
const visibleTaskIds = useMemo(() => new Set(filteredTasks.map((task) => task.id)), [filteredTasks]);
|
||||
const { savedPositions, persistPositions, clearSavedPositions } = useGraphPositions({ projectId, visibleTaskIds });
|
||||
const [positions, setPositions] = useState<Map<string, { x: number; y: number }>>(autoLayoutPositions);
|
||||
const [isNodeDragging, setIsNodeDragging] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setPositions((current) => {
|
||||
const next = new Map<string, { x: number; y: number }>();
|
||||
for (const [taskId, layoutPosition] of autoLayoutPositions.entries()) {
|
||||
next.set(taskId, current.get(taskId) ?? layoutPosition);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [autoLayoutPositions]);
|
||||
const autoLayoutRecord: NodePositions = {};
|
||||
for (const [taskId, position] of autoLayoutPositions.entries()) {
|
||||
autoLayoutRecord[taskId] = position;
|
||||
}
|
||||
|
||||
const merged = savedPositions ? mergePositions(autoLayoutRecord, savedPositions, visibleTaskIds) : autoLayoutRecord;
|
||||
setPositions(new Map(Object.entries(merged)));
|
||||
}, [autoLayoutPositions, savedPositions, visibleTaskIds]);
|
||||
|
||||
const {
|
||||
transform,
|
||||
@@ -110,7 +106,7 @@ export function DependencyGraph({
|
||||
if (initialFitDoneRef.current) return;
|
||||
if (filteredTasks.length === 0) return;
|
||||
|
||||
const hasSavedPositions = Boolean(getScopedPositionItem(projectId));
|
||||
const hasSavedPositions = Boolean(savedPositions && Object.keys(savedPositions).length > 0);
|
||||
if (hasSavedPositions) {
|
||||
initialFitDoneRef.current = true;
|
||||
return;
|
||||
@@ -121,7 +117,7 @@ export function DependencyGraph({
|
||||
|
||||
fitToGraph(positions, viewport.clientWidth, viewport.clientHeight, { nodeWidth: NODE_WIDTH, nodeHeight: NODE_HEIGHT });
|
||||
initialFitDoneRef.current = true;
|
||||
}, [filteredTasks.length, fitToGraph, positions, projectId]);
|
||||
}, [filteredTasks.length, fitToGraph, positions, savedPositions]);
|
||||
|
||||
const bounds = useMemo(() => {
|
||||
const values = Array.from(positions.values());
|
||||
@@ -131,6 +127,20 @@ export function DependencyGraph({
|
||||
return { width: maxX, height: maxY };
|
||||
}, [positions]);
|
||||
|
||||
const handleResetLayout = useCallback(() => {
|
||||
clearSavedPositions();
|
||||
const freshLayout = computeAutoLayout(graphData, { nodeWidth: NODE_WIDTH, nodeHeight: NODE_HEIGHT, horizontalGap: 40, verticalGap: 80 });
|
||||
setPositions(freshLayout);
|
||||
}, [clearSavedPositions, graphData]);
|
||||
|
||||
const handleNodeDragEnd = useCallback(() => {
|
||||
const positionRecord: NodePositions = {};
|
||||
for (const [taskId, position] of positions.entries()) {
|
||||
positionRecord[taskId] = position;
|
||||
}
|
||||
persistPositions(positionRecord);
|
||||
}, [persistPositions, positions]);
|
||||
|
||||
return (
|
||||
<section className="dependency-graph" data-testid="dependency-graph">
|
||||
<div
|
||||
@@ -230,6 +240,7 @@ export function DependencyGraph({
|
||||
});
|
||||
}}
|
||||
onNodeDragStateChange={setIsNodeDragging}
|
||||
onNodeDragEnd={handleNodeDragEnd}
|
||||
isHighlighted={highlightedTaskIds.size > 0 && highlightedTaskIds.has(node.task.id)}
|
||||
isDimmed={highlightedTaskIds.size > 0 && !highlightedTaskIds.has(node.task.id)}
|
||||
onOpenDetail={onOpenDetail ?? ((task) => onOpenTaskDetail?.(task.id))}
|
||||
@@ -274,11 +285,16 @@ export function DependencyGraph({
|
||||
zoomOut(viewport.clientWidth, viewport.clientHeight);
|
||||
}}
|
||||
onFitToGraph={() => {
|
||||
handleResetLayout();
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) return;
|
||||
fitToGraph(positions, viewport.clientWidth, viewport.clientHeight, { nodeWidth: NODE_WIDTH, nodeHeight: NODE_HEIGHT });
|
||||
const freshLayout = computeAutoLayout(graphData, { nodeWidth: NODE_WIDTH, nodeHeight: NODE_HEIGHT, horizontalGap: 40, verticalGap: 80 });
|
||||
fitToGraph(freshLayout, viewport.clientWidth, viewport.clientHeight, { nodeWidth: NODE_WIDTH, nodeHeight: NODE_HEIGHT });
|
||||
}}
|
||||
onResetView={() => {
|
||||
handleResetLayout();
|
||||
resetView();
|
||||
}}
|
||||
onResetView={resetView}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface GraphTaskNodeProps extends TaskCardBridgeProps, Pick<HTMLAttrib
|
||||
isDimmed?: boolean;
|
||||
onNodePositionChange: (taskId: string, position: GraphPosition) => void;
|
||||
onNodeDragStateChange?: (isDragging: boolean) => void;
|
||||
onNodeDragEnd?: () => void;
|
||||
}
|
||||
|
||||
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "merging-fix"]);
|
||||
@@ -60,6 +61,7 @@ export function GraphTaskNode({
|
||||
onClick,
|
||||
onNodePositionChange,
|
||||
onNodeDragStateChange,
|
||||
onNodeDragEnd,
|
||||
...taskCardProps
|
||||
}: GraphTaskNodeProps) {
|
||||
const { task, globalPaused, taskStuckTimeoutMs, lastFetchTimeMs } = taskCardProps;
|
||||
@@ -88,6 +90,7 @@ export function GraphTaskNode({
|
||||
scale,
|
||||
onPositionChange: onNodePositionChange,
|
||||
onDragStateChange: onNodeDragStateChange,
|
||||
onDragEnd: onNodeDragEnd,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { DependencyGraph } from "../DependencyGraph";
|
||||
|
||||
const fitToGraph = vi.fn();
|
||||
|
||||
vi.mock("@fusion/dashboard/app/components/TaskCard", () => ({
|
||||
TaskCard: ({ task, onOpenDetail }: { task: Task; onOpenDetail: (task: Task) => void }) => (
|
||||
<button data-testid={`task-${task.id}`} onClick={() => onOpenDetail(task)}>{task.id}</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../useGraphInteraction", () => ({
|
||||
useGraphInteraction: () => ({
|
||||
transform: "translate(0px, 0px) scale(1)",
|
||||
zoom: 1,
|
||||
transitioning: false,
|
||||
zoomIn: vi.fn(),
|
||||
zoomOut: vi.fn(),
|
||||
resetView: vi.fn(),
|
||||
fitToGraph,
|
||||
onPointerDown: vi.fn(),
|
||||
onPointerMove: vi.fn(),
|
||||
onPointerUp: vi.fn(),
|
||||
onWheelZoom: vi.fn(),
|
||||
handleKeyDown: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../layout", () => ({
|
||||
computeAutoLayout: ({ nodes }: { nodes: Array<{ task: { id: string } }> }) => {
|
||||
const map = new Map<string, { x: number; y: number }>();
|
||||
for (const node of nodes) {
|
||||
if (node.task.id === "A") map.set("A", { x: 0, y: 0 });
|
||||
if (node.task.id === "B") map.set("B", { x: 200, y: 0 });
|
||||
}
|
||||
return map;
|
||||
},
|
||||
}));
|
||||
|
||||
function createStorage() {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
store.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
store.delete(key);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createTask(id: string, column: Task["column"] = "todo"): Task {
|
||||
return { id, description: id, column, dependencies: [], steps: [], currentStep: 0, log: [] } as Task;
|
||||
}
|
||||
|
||||
describe("DependencyGraph persistence", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, "localStorage", { value: createStorage(), configurable: true });
|
||||
fitToGraph.mockReset();
|
||||
});
|
||||
|
||||
it("persists dragged node position across remount", () => {
|
||||
const { unmount } = render(<DependencyGraph tasks={[createTask("A")]} projectId="p1" />);
|
||||
|
||||
const node = screen.getByTestId("graph-task-node-A");
|
||||
fireEvent.pointerDown(node, { pointerId: 1, isPrimary: true, clientX: 10, clientY: 10 });
|
||||
fireEvent.pointerMove(node, { pointerId: 1, isPrimary: true, clientX: 30, clientY: 40 });
|
||||
fireEvent.pointerUp(node, { pointerId: 1, isPrimary: true, clientX: 30, clientY: 40 });
|
||||
|
||||
expect(window.localStorage.getItem("kb:p1:dependency-graph-positions")).toContain('"A":{"x":20,"y":30}');
|
||||
|
||||
unmount();
|
||||
render(<DependencyGraph tasks={[createTask("A")]} projectId="p1" />);
|
||||
|
||||
expect(screen.getByTestId("graph-task-node-A").getAttribute("style")).toContain("left: 20px");
|
||||
expect(screen.getByTestId("graph-task-node-A").getAttribute("style")).toContain("top: 30px");
|
||||
});
|
||||
|
||||
it("merges saved positions with auto-layout for new tasks", () => {
|
||||
window.localStorage.setItem("kb:p1:dependency-graph-positions", JSON.stringify({ A: { x: 25, y: 35 } }));
|
||||
|
||||
render(<DependencyGraph tasks={[createTask("A"), createTask("B")]} projectId="p1" />);
|
||||
|
||||
expect(screen.getByTestId("graph-task-node-A").getAttribute("style")).toContain("left: 25px");
|
||||
expect(screen.getByTestId("graph-task-node-B").getAttribute("style")).toContain("left: 200px");
|
||||
});
|
||||
|
||||
it("fit to graph clears saved positions and reapplies auto-layout", () => {
|
||||
window.localStorage.setItem("kb:p1:dependency-graph-positions", JSON.stringify({ A: { x: 25, y: 35 } }));
|
||||
|
||||
render(<DependencyGraph tasks={[createTask("A")]} projectId="p1" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Fit to graph" }));
|
||||
|
||||
expect(window.localStorage.getItem("kb:p1:dependency-graph-positions")).toBeNull();
|
||||
expect(screen.getByTestId("graph-task-node-A").getAttribute("style")).toContain("left: 0px");
|
||||
});
|
||||
|
||||
it("switching projects loads project-scoped positions", () => {
|
||||
window.localStorage.setItem("kb:p1:dependency-graph-positions", JSON.stringify({ A: { x: 11, y: 22 } }));
|
||||
window.localStorage.setItem("kb:p2:dependency-graph-positions", JSON.stringify({ A: { x: 33, y: 44 } }));
|
||||
|
||||
const { rerender } = render(<DependencyGraph tasks={[createTask("A")]} projectId="p1" />);
|
||||
expect(screen.getByTestId("graph-task-node-A").getAttribute("style")).toContain("left: 11px");
|
||||
|
||||
rerender(<DependencyGraph tasks={[createTask("A")]} projectId="p2" />);
|
||||
expect(screen.getByTestId("graph-task-node-A").getAttribute("style")).toContain("left: 33px");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { clearPositions, loadPositions, mergePositions, savePositions } from "../utils/graphPositionStorage";
|
||||
|
||||
function createStorage() {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
store.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
store.delete(key);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("graphPositionStorage", () => {
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.stubGlobal("window", { localStorage: createStorage() });
|
||||
});
|
||||
|
||||
it("loadPositions returns parsed positions from localStorage", () => {
|
||||
window.localStorage.setItem("kb:p1:dependency-graph-positions", JSON.stringify({ a: { x: 1, y: 2 } }));
|
||||
expect(loadPositions("p1")).toEqual({ a: { x: 1, y: 2 } });
|
||||
});
|
||||
|
||||
it("loadPositions returns empty object when localStorage is empty", () => {
|
||||
expect(loadPositions("p1")).toEqual({});
|
||||
});
|
||||
|
||||
it("loadPositions returns empty object for invalid json", () => {
|
||||
window.localStorage.setItem("kb:p1:dependency-graph-positions", "{oops");
|
||||
expect(loadPositions("p1")).toEqual({});
|
||||
});
|
||||
|
||||
it("loadPositions skips entries with invalid position shape", () => {
|
||||
window.localStorage.setItem(
|
||||
"kb:p1:dependency-graph-positions",
|
||||
JSON.stringify({
|
||||
good: { x: 1, y: 2 },
|
||||
badX: { x: "1", y: 2 },
|
||||
badY: { x: 1, y: null },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(loadPositions("p1")).toEqual({ good: { x: 1, y: 2 } });
|
||||
});
|
||||
|
||||
it("savePositions writes filtered positions json to scoped localStorage key", () => {
|
||||
savePositions({ a: { x: 1, y: 2 }, b: { x: 3, y: 4 } }, new Set(["a"]), "p1");
|
||||
expect(window.localStorage.getItem("kb:p1:dependency-graph-positions")).toBe(JSON.stringify({ a: { x: 1, y: 2 } }));
|
||||
});
|
||||
|
||||
it("clearPositions removes scoped localStorage key", () => {
|
||||
window.localStorage.setItem("kb:p1:dependency-graph-positions", JSON.stringify({ a: { x: 1, y: 2 } }));
|
||||
clearPositions("p1");
|
||||
expect(window.localStorage.getItem("kb:p1:dependency-graph-positions")).toBeNull();
|
||||
});
|
||||
|
||||
it("mergePositions prefers saved for overlap and keeps auto-layout for new tasks", () => {
|
||||
expect(
|
||||
mergePositions(
|
||||
{ a: { x: 1, y: 1 }, b: { x: 2, y: 2 } },
|
||||
{ a: { x: 10, y: 10 } },
|
||||
new Set(["a", "b"]),
|
||||
),
|
||||
).toEqual({ a: { x: 10, y: 10 }, b: { x: 2, y: 2 } });
|
||||
});
|
||||
|
||||
it("mergePositions omits non-visible ids", () => {
|
||||
expect(
|
||||
mergePositions(
|
||||
{ a: { x: 1, y: 1 }, hidden: { x: 9, y: 9 } },
|
||||
{ hidden: { x: 10, y: 10 } },
|
||||
new Set(["a"]),
|
||||
),
|
||||
).toEqual({ a: { x: 1, y: 1 } });
|
||||
});
|
||||
|
||||
it("mergePositions returns auto-layout unchanged when saved is empty", () => {
|
||||
expect(mergePositions({ a: { x: 1, y: 2 } }, {}, new Set(["a"]))).toEqual({ a: { x: 1, y: 2 } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getScopedItem, removeScopedItem, scopedKey, setScopedItem } from "../utils/scopedStorage";
|
||||
|
||||
function createStorage() {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
store.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
store.delete(key);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("scopedStorage", () => {
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.stubGlobal("window", { localStorage: createStorage() });
|
||||
});
|
||||
|
||||
it("scopedKey uses kb project prefix when project id is provided", () => {
|
||||
expect(scopedKey("baseKey", "project-1")).toBe("kb:project-1:baseKey");
|
||||
});
|
||||
|
||||
it("scopedKey falls back to unscoped key for undefined/null/empty project id", () => {
|
||||
expect(scopedKey("baseKey", undefined)).toBe("baseKey");
|
||||
expect(scopedKey("baseKey", null)).toBe("baseKey");
|
||||
expect(scopedKey("baseKey", "")).toBe("baseKey");
|
||||
});
|
||||
|
||||
it("getScopedItem reads from localStorage using scoped key", () => {
|
||||
window.localStorage.setItem("kb:project-1:baseKey", "value");
|
||||
expect(getScopedItem("baseKey", "project-1")).toBe("value");
|
||||
});
|
||||
|
||||
it("getScopedItem returns null when window is undefined", () => {
|
||||
vi.stubGlobal("window", undefined);
|
||||
expect(getScopedItem("baseKey", "project-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("setScopedItem writes to localStorage using scoped key", () => {
|
||||
setScopedItem("baseKey", "value", "project-1");
|
||||
expect(window.localStorage.getItem("kb:project-1:baseKey")).toBe("value");
|
||||
});
|
||||
|
||||
it("setScopedItem is a no-op when window is undefined", () => {
|
||||
vi.stubGlobal("window", undefined);
|
||||
expect(() => setScopedItem("baseKey", "value", "project-1")).not.toThrow();
|
||||
});
|
||||
|
||||
it("removeScopedItem removes from localStorage using scoped key", () => {
|
||||
window.localStorage.setItem("kb:project-1:baseKey", "value");
|
||||
removeScopedItem("baseKey", "project-1");
|
||||
expect(window.localStorage.getItem("kb:project-1:baseKey")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useGraphPositions } from "../hooks/useGraphPositions";
|
||||
import * as storage from "../utils/graphPositionStorage";
|
||||
|
||||
vi.mock("../utils/graphPositionStorage", () => ({
|
||||
loadPositions: vi.fn(),
|
||||
savePositions: vi.fn(),
|
||||
clearPositions: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("useGraphPositions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("loads saved positions on mount with project scope", () => {
|
||||
vi.mocked(storage.loadPositions).mockReturnValue({ a: { x: 1, y: 2 } });
|
||||
|
||||
const { result } = renderHook(() => useGraphPositions({ projectId: "p1", visibleTaskIds: new Set(["a"]) }));
|
||||
|
||||
expect(storage.loadPositions).toHaveBeenCalledWith("p1");
|
||||
expect(result.current.savedPositions).toEqual({ a: { x: 1, y: 2 } });
|
||||
});
|
||||
|
||||
it("reloads positions when project id changes", () => {
|
||||
vi.mocked(storage.loadPositions).mockReturnValueOnce({ a: { x: 1, y: 1 } }).mockReturnValueOnce({ b: { x: 2, y: 2 } });
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ projectId }) => useGraphPositions({ projectId, visibleTaskIds: new Set(["a", "b"]) }),
|
||||
{ initialProps: { projectId: "p1" } },
|
||||
);
|
||||
|
||||
rerender({ projectId: "p2" });
|
||||
|
||||
expect(storage.loadPositions).toHaveBeenNthCalledWith(1, "p1");
|
||||
expect(storage.loadPositions).toHaveBeenNthCalledWith(2, "p2");
|
||||
expect(result.current.savedPositions).toEqual({ b: { x: 2, y: 2 } });
|
||||
});
|
||||
|
||||
it("persistPositions writes scoped and filters non-visible ids", () => {
|
||||
vi.mocked(storage.loadPositions).mockReturnValue({});
|
||||
|
||||
const { result } = renderHook(() => useGraphPositions({ projectId: "p1", visibleTaskIds: new Set(["a"]) }));
|
||||
|
||||
act(() => {
|
||||
result.current.persistPositions({ a: { x: 1, y: 2 }, hidden: { x: 9, y: 9 } });
|
||||
});
|
||||
|
||||
expect(storage.savePositions).toHaveBeenCalledWith({ a: { x: 1, y: 2 }, hidden: { x: 9, y: 9 } }, new Set(["a"]), "p1");
|
||||
expect(result.current.savedPositions).toEqual({ a: { x: 1, y: 2 } });
|
||||
});
|
||||
|
||||
it("clearSavedPositions clears storage and resets state", () => {
|
||||
vi.mocked(storage.loadPositions).mockReturnValue({ a: { x: 1, y: 2 } });
|
||||
|
||||
const { result } = renderHook(() => useGraphPositions({ projectId: "p1", visibleTaskIds: new Set(["a"]) }));
|
||||
|
||||
act(() => {
|
||||
result.current.clearSavedPositions();
|
||||
});
|
||||
|
||||
expect(storage.clearPositions).toHaveBeenCalledWith("p1");
|
||||
expect(result.current.savedPositions).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { clearPositions, loadPositions, savePositions, type NodePositions } from "../utils/graphPositionStorage";
|
||||
|
||||
function filterVisiblePositions(positions: NodePositions, visibleTaskIds: Set<string>): NodePositions {
|
||||
const filtered: NodePositions = {};
|
||||
for (const [taskId, position] of Object.entries(positions)) {
|
||||
if (visibleTaskIds.has(taskId)) {
|
||||
filtered[taskId] = position;
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
export function useGraphPositions({
|
||||
projectId,
|
||||
visibleTaskIds,
|
||||
}: {
|
||||
projectId: string | undefined;
|
||||
visibleTaskIds: Set<string>;
|
||||
}): {
|
||||
savedPositions: NodePositions | null;
|
||||
persistPositions: (positions: NodePositions) => void;
|
||||
clearSavedPositions: () => void;
|
||||
} {
|
||||
const [savedPositions, setSavedPositions] = useState<NodePositions | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setSavedPositions(loadPositions(projectId));
|
||||
}, [projectId]);
|
||||
|
||||
const persistPositions = useCallback(
|
||||
(positions: NodePositions) => {
|
||||
savePositions(positions, visibleTaskIds, projectId);
|
||||
setSavedPositions(filterVisiblePositions(positions, visibleTaskIds));
|
||||
},
|
||||
[projectId, visibleTaskIds],
|
||||
);
|
||||
|
||||
const clearSavedPositions = useCallback(() => {
|
||||
clearPositions(projectId);
|
||||
setSavedPositions(null);
|
||||
}, [projectId]);
|
||||
|
||||
return { savedPositions, persistPositions, clearSavedPositions };
|
||||
}
|
||||
@@ -10,6 +10,7 @@ interface UseNodeDragOptions {
|
||||
scale: number;
|
||||
onPositionChange: (taskId: string, position: GraphPosition) => void;
|
||||
onDragStateChange?: (isDragging: boolean) => void;
|
||||
onDragEnd?: () => void;
|
||||
}
|
||||
|
||||
interface PendingState {
|
||||
@@ -18,7 +19,7 @@ interface PendingState {
|
||||
startPosition: GraphPosition;
|
||||
}
|
||||
|
||||
export function useNodeDrag({ taskId, position, scale, onPositionChange, onDragStateChange }: UseNodeDragOptions) {
|
||||
export function useNodeDrag({ taskId, position, scale, onPositionChange, onDragStateChange, onDragEnd }: UseNodeDragOptions) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const pendingRef = useRef<PendingState | null>(null);
|
||||
const positionRef = useRef(position);
|
||||
@@ -31,9 +32,10 @@ export function useNodeDrag({ taskId, position, scale, onPositionChange, onDragS
|
||||
setIsDragging(false);
|
||||
if (dragging) {
|
||||
onDragStateChange?.(false);
|
||||
onDragEnd?.();
|
||||
suppressClickRef.current = true;
|
||||
}
|
||||
}, [onDragStateChange]);
|
||||
}, [onDragEnd, onDragStateChange]);
|
||||
|
||||
const onPointerDown = useCallback((event: ReactPointerEvent<HTMLElement>) => {
|
||||
if (!event.isPrimary) return;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { getScopedItem, removeScopedItem, setScopedItem } from "./scopedStorage";
|
||||
|
||||
export type NodePositions = Record<string, { x: number; y: number }>;
|
||||
|
||||
const STORAGE_KEY = "dependency-graph-positions";
|
||||
|
||||
function isPosition(value: unknown): value is { x: number; y: number } {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const candidate = value as { x?: unknown; y?: unknown };
|
||||
return typeof candidate.x === "number" && Number.isFinite(candidate.x) && typeof candidate.y === "number" && Number.isFinite(candidate.y);
|
||||
}
|
||||
|
||||
export function loadPositions(projectId?: string): NodePositions {
|
||||
const raw = getScopedItem(STORAGE_KEY, projectId);
|
||||
if (!raw) return {};
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
if (!parsed || typeof parsed !== "object") return {};
|
||||
|
||||
const result: NodePositions = {};
|
||||
for (const [taskId, value] of Object.entries(parsed)) {
|
||||
if (isPosition(value)) {
|
||||
result[taskId] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function savePositions(positions: NodePositions, visibleTaskIds: Set<string>, projectId?: string): void {
|
||||
const filtered: NodePositions = {};
|
||||
for (const [taskId, position] of Object.entries(positions)) {
|
||||
if (visibleTaskIds.has(taskId) && isPosition(position)) {
|
||||
filtered[taskId] = position;
|
||||
}
|
||||
}
|
||||
|
||||
setScopedItem(STORAGE_KEY, JSON.stringify(filtered), projectId);
|
||||
}
|
||||
|
||||
export function clearPositions(projectId?: string): void {
|
||||
removeScopedItem(STORAGE_KEY, projectId);
|
||||
}
|
||||
|
||||
export function mergePositions(autoLayoutPositions: NodePositions, savedPositions: NodePositions, visibleTaskIds: Set<string>): NodePositions {
|
||||
const merged: NodePositions = {};
|
||||
|
||||
for (const [taskId, position] of Object.entries(autoLayoutPositions)) {
|
||||
if (visibleTaskIds.has(taskId) && isPosition(position)) {
|
||||
merged[taskId] = position;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [taskId, position] of Object.entries(savedPositions)) {
|
||||
if (visibleTaskIds.has(taskId) && isPosition(position)) {
|
||||
merged[taskId] = position;
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Duplicated from packages/dashboard/app/utils/projectStorage.ts for plugin isolation.
|
||||
// Keeps the same project-scoped key convention: kb:${projectId}:${baseKey}.
|
||||
|
||||
export function scopedKey(baseKey: string, projectId?: string | null): string {
|
||||
if (typeof projectId !== "string" || projectId.length === 0) {
|
||||
return baseKey;
|
||||
}
|
||||
|
||||
return `kb:${projectId}:${baseKey}`;
|
||||
}
|
||||
|
||||
export function getScopedItem(baseKey: string, projectId?: string | null): string | null {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getItem = window.localStorage?.getItem;
|
||||
if (typeof getItem !== "function") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getItem.call(window.localStorage, scopedKey(baseKey, projectId));
|
||||
}
|
||||
|
||||
export function setScopedItem(baseKey: string, value: string, projectId?: string | null): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const setItem = window.localStorage?.setItem;
|
||||
if (typeof setItem !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
setItem.call(window.localStorage, scopedKey(baseKey, projectId), value);
|
||||
}
|
||||
|
||||
export function removeScopedItem(baseKey: string, projectId?: string | null): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const removeItem = window.localStorage?.removeItem;
|
||||
if (typeof removeItem !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
removeItem.call(window.localStorage, scopedKey(baseKey, projectId));
|
||||
}
|
||||
Reference in New Issue
Block a user