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
|
- **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
|
- **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
|
- **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
|
- **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)
|
- **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
|
- **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
|
- **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
|
- **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
|
## Dependency chain highlighting
|
||||||
|
|
||||||
- **Hover** a node to highlight the full transitive upstream + downstream chain for that task.
|
- **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 type { Task } from "@fusion/core";
|
||||||
import { GraphTaskNode } from "./GraphTaskNode";
|
import { GraphTaskNode } from "./GraphTaskNode";
|
||||||
import { GraphToolbar } from "./GraphToolbar";
|
import { GraphToolbar } from "./GraphToolbar";
|
||||||
@@ -8,18 +8,12 @@ import { computeAutoLayout } from "./layout";
|
|||||||
import { useGraphData } from "./useGraphData";
|
import { useGraphData } from "./useGraphData";
|
||||||
import { useGraphInteraction } from "./useGraphInteraction";
|
import { useGraphInteraction } from "./useGraphInteraction";
|
||||||
import { useDependencyChain } from "./hooks/useDependencyChain";
|
import { useDependencyChain } from "./hooks/useDependencyChain";
|
||||||
|
import { useGraphPositions } from "./hooks/useGraphPositions";
|
||||||
|
import { mergePositions, type NodePositions } from "./utils/graphPositionStorage";
|
||||||
import "./DependencyGraph.css";
|
import "./DependencyGraph.css";
|
||||||
|
|
||||||
const NODE_WIDTH = 280;
|
const NODE_WIDTH = 280;
|
||||||
const NODE_HEIGHT = 100;
|
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 {
|
export interface DependencyGraphProps {
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
@@ -78,18 +72,20 @@ export function DependencyGraph({
|
|||||||
() => computeAutoLayout(graphData, { nodeWidth: NODE_WIDTH, nodeHeight: NODE_HEIGHT, horizontalGap: 40, verticalGap: 80 }),
|
() => computeAutoLayout(graphData, { nodeWidth: NODE_WIDTH, nodeHeight: NODE_HEIGHT, horizontalGap: 40, verticalGap: 80 }),
|
||||||
[graphData],
|
[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 [positions, setPositions] = useState<Map<string, { x: number; y: number }>>(autoLayoutPositions);
|
||||||
const [isNodeDragging, setIsNodeDragging] = useState(false);
|
const [isNodeDragging, setIsNodeDragging] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPositions((current) => {
|
const autoLayoutRecord: NodePositions = {};
|
||||||
const next = new Map<string, { x: number; y: number }>();
|
for (const [taskId, position] of autoLayoutPositions.entries()) {
|
||||||
for (const [taskId, layoutPosition] of autoLayoutPositions.entries()) {
|
autoLayoutRecord[taskId] = position;
|
||||||
next.set(taskId, current.get(taskId) ?? layoutPosition);
|
}
|
||||||
}
|
|
||||||
return next;
|
const merged = savedPositions ? mergePositions(autoLayoutRecord, savedPositions, visibleTaskIds) : autoLayoutRecord;
|
||||||
});
|
setPositions(new Map(Object.entries(merged)));
|
||||||
}, [autoLayoutPositions]);
|
}, [autoLayoutPositions, savedPositions, visibleTaskIds]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
transform,
|
transform,
|
||||||
@@ -110,7 +106,7 @@ export function DependencyGraph({
|
|||||||
if (initialFitDoneRef.current) return;
|
if (initialFitDoneRef.current) return;
|
||||||
if (filteredTasks.length === 0) return;
|
if (filteredTasks.length === 0) return;
|
||||||
|
|
||||||
const hasSavedPositions = Boolean(getScopedPositionItem(projectId));
|
const hasSavedPositions = Boolean(savedPositions && Object.keys(savedPositions).length > 0);
|
||||||
if (hasSavedPositions) {
|
if (hasSavedPositions) {
|
||||||
initialFitDoneRef.current = true;
|
initialFitDoneRef.current = true;
|
||||||
return;
|
return;
|
||||||
@@ -121,7 +117,7 @@ export function DependencyGraph({
|
|||||||
|
|
||||||
fitToGraph(positions, viewport.clientWidth, viewport.clientHeight, { nodeWidth: NODE_WIDTH, nodeHeight: NODE_HEIGHT });
|
fitToGraph(positions, viewport.clientWidth, viewport.clientHeight, { nodeWidth: NODE_WIDTH, nodeHeight: NODE_HEIGHT });
|
||||||
initialFitDoneRef.current = true;
|
initialFitDoneRef.current = true;
|
||||||
}, [filteredTasks.length, fitToGraph, positions, projectId]);
|
}, [filteredTasks.length, fitToGraph, positions, savedPositions]);
|
||||||
|
|
||||||
const bounds = useMemo(() => {
|
const bounds = useMemo(() => {
|
||||||
const values = Array.from(positions.values());
|
const values = Array.from(positions.values());
|
||||||
@@ -131,6 +127,20 @@ export function DependencyGraph({
|
|||||||
return { width: maxX, height: maxY };
|
return { width: maxX, height: maxY };
|
||||||
}, [positions]);
|
}, [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 (
|
return (
|
||||||
<section className="dependency-graph" data-testid="dependency-graph">
|
<section className="dependency-graph" data-testid="dependency-graph">
|
||||||
<div
|
<div
|
||||||
@@ -230,6 +240,7 @@ export function DependencyGraph({
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onNodeDragStateChange={setIsNodeDragging}
|
onNodeDragStateChange={setIsNodeDragging}
|
||||||
|
onNodeDragEnd={handleNodeDragEnd}
|
||||||
isHighlighted={highlightedTaskIds.size > 0 && highlightedTaskIds.has(node.task.id)}
|
isHighlighted={highlightedTaskIds.size > 0 && highlightedTaskIds.has(node.task.id)}
|
||||||
isDimmed={highlightedTaskIds.size > 0 && !highlightedTaskIds.has(node.task.id)}
|
isDimmed={highlightedTaskIds.size > 0 && !highlightedTaskIds.has(node.task.id)}
|
||||||
onOpenDetail={onOpenDetail ?? ((task) => onOpenTaskDetail?.(task.id))}
|
onOpenDetail={onOpenDetail ?? ((task) => onOpenTaskDetail?.(task.id))}
|
||||||
@@ -274,11 +285,16 @@ export function DependencyGraph({
|
|||||||
zoomOut(viewport.clientWidth, viewport.clientHeight);
|
zoomOut(viewport.clientWidth, viewport.clientHeight);
|
||||||
}}
|
}}
|
||||||
onFitToGraph={() => {
|
onFitToGraph={() => {
|
||||||
|
handleResetLayout();
|
||||||
const viewport = viewportRef.current;
|
const viewport = viewportRef.current;
|
||||||
if (!viewport) return;
|
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>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export interface GraphTaskNodeProps extends TaskCardBridgeProps, Pick<HTMLAttrib
|
|||||||
isDimmed?: boolean;
|
isDimmed?: boolean;
|
||||||
onNodePositionChange: (taskId: string, position: GraphPosition) => void;
|
onNodePositionChange: (taskId: string, position: GraphPosition) => void;
|
||||||
onNodeDragStateChange?: (isDragging: boolean) => void;
|
onNodeDragStateChange?: (isDragging: boolean) => void;
|
||||||
|
onNodeDragEnd?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "merging-fix"]);
|
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "merging-fix"]);
|
||||||
@@ -60,6 +61,7 @@ export function GraphTaskNode({
|
|||||||
onClick,
|
onClick,
|
||||||
onNodePositionChange,
|
onNodePositionChange,
|
||||||
onNodeDragStateChange,
|
onNodeDragStateChange,
|
||||||
|
onNodeDragEnd,
|
||||||
...taskCardProps
|
...taskCardProps
|
||||||
}: GraphTaskNodeProps) {
|
}: GraphTaskNodeProps) {
|
||||||
const { task, globalPaused, taskStuckTimeoutMs, lastFetchTimeMs } = taskCardProps;
|
const { task, globalPaused, taskStuckTimeoutMs, lastFetchTimeMs } = taskCardProps;
|
||||||
@@ -88,6 +90,7 @@ export function GraphTaskNode({
|
|||||||
scale,
|
scale,
|
||||||
onPositionChange: onNodePositionChange,
|
onPositionChange: onNodePositionChange,
|
||||||
onDragStateChange: onNodeDragStateChange,
|
onDragStateChange: onNodeDragStateChange,
|
||||||
|
onDragEnd: onNodeDragEnd,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
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;
|
scale: number;
|
||||||
onPositionChange: (taskId: string, position: GraphPosition) => void;
|
onPositionChange: (taskId: string, position: GraphPosition) => void;
|
||||||
onDragStateChange?: (isDragging: boolean) => void;
|
onDragStateChange?: (isDragging: boolean) => void;
|
||||||
|
onDragEnd?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PendingState {
|
interface PendingState {
|
||||||
@@ -18,7 +19,7 @@ interface PendingState {
|
|||||||
startPosition: GraphPosition;
|
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 [isDragging, setIsDragging] = useState(false);
|
||||||
const pendingRef = useRef<PendingState | null>(null);
|
const pendingRef = useRef<PendingState | null>(null);
|
||||||
const positionRef = useRef(position);
|
const positionRef = useRef(position);
|
||||||
@@ -31,9 +32,10 @@ export function useNodeDrag({ taskId, position, scale, onPositionChange, onDragS
|
|||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
if (dragging) {
|
if (dragging) {
|
||||||
onDragStateChange?.(false);
|
onDragStateChange?.(false);
|
||||||
|
onDragEnd?.();
|
||||||
suppressClickRef.current = true;
|
suppressClickRef.current = true;
|
||||||
}
|
}
|
||||||
}, [onDragStateChange]);
|
}, [onDragEnd, onDragStateChange]);
|
||||||
|
|
||||||
const onPointerDown = useCallback((event: ReactPointerEvent<HTMLElement>) => {
|
const onPointerDown = useCallback((event: ReactPointerEvent<HTMLElement>) => {
|
||||||
if (!event.isPrimary) return;
|
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