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:
Fusion
2026-05-07 05:56:44 -07:00
committed by gsxdsm
parent 054404b175
commit d6e577f0a3
11 changed files with 535 additions and 23 deletions

View File

@@ -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");
});
});

View File

@@ -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 } });
});
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});