feat(FN-3086): enhance graph navigation with toolbar and keyboard controls
The merge introduces a new Cursor CLI plugin provider with dashboard authentication (FN-3396), enabling Fusion to bundle Cursor's CLI as a native AI model source alongside native shell guide and bridge contract documentation (FN-3577). It also completes the dependency graph plugin's navigation contr Fusion-Task-Id: FN-3086
This commit is contained in:
@@ -4,6 +4,10 @@ import type { Task } from "@fusion/core";
|
||||
import { DependencyGraph } from "../DependencyGraph";
|
||||
|
||||
const fitToGraph = vi.fn();
|
||||
const zoomIn = vi.fn();
|
||||
const zoomOut = vi.fn();
|
||||
const resetView = vi.fn();
|
||||
const handleKeyDown = vi.fn();
|
||||
|
||||
vi.mock("@fusion/dashboard/app/components/TaskCard", () => ({
|
||||
TaskCard: ({ task, onOpenDetail, disableDrag }: { task: Task; onOpenDetail: (task: Task) => void; disableDrag?: boolean }) => (
|
||||
@@ -14,13 +18,17 @@ vi.mock("@fusion/dashboard/app/components/TaskCard", () => ({
|
||||
vi.mock("../useGraphInteraction", () => ({
|
||||
useGraphInteraction: () => ({
|
||||
transform: "translate(0px, 0px) scale(1)",
|
||||
zoomIn: vi.fn(),
|
||||
zoomOut: vi.fn(),
|
||||
zoom: 1,
|
||||
transitioning: false,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
resetView,
|
||||
fitToGraph,
|
||||
onPointerDown: vi.fn(),
|
||||
onPointerMove: vi.fn(),
|
||||
onPointerUp: vi.fn(),
|
||||
onWheelZoom: vi.fn(),
|
||||
handleKeyDown,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -31,6 +39,10 @@ function createTask(id: string, column: Task["column"], dependencies: string[] =
|
||||
describe("DependencyGraph", () => {
|
||||
beforeEach(() => {
|
||||
fitToGraph.mockReset();
|
||||
zoomIn.mockReset();
|
||||
zoomOut.mockReset();
|
||||
resetView.mockReset();
|
||||
handleKeyDown.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -65,43 +77,38 @@ describe("DependencyGraph", () => {
|
||||
expect(screen.queryByTestId("graph-task-node-F")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders zero nodes and edges when only done tasks are provided", () => {
|
||||
const { container } = render(<DependencyGraph tasks={[createTask("A", "done", ["B"]), createTask("B", "done")]} onOpenTaskDetail={vi.fn()} />);
|
||||
|
||||
expect(container.querySelectorAll("[data-testid^='graph-task-node-']")).toHaveLength(0);
|
||||
expect(screen.queryAllByTestId("dependency-edge")).toHaveLength(0);
|
||||
it("auto-fits on initial load with active tasks", () => {
|
||||
render(<DependencyGraph tasks={[createTask("A", "todo")]} onOpenTaskDetail={vi.fn()} />);
|
||||
expect(fitToGraph).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders zero nodes and edges when only archived tasks are provided", () => {
|
||||
const { container } = render(
|
||||
<DependencyGraph tasks={[createTask("A", "archived", ["B"]), createTask("B", "archived")]} onOpenTaskDetail={vi.fn()} />,
|
||||
);
|
||||
|
||||
expect(container.querySelectorAll("[data-testid^='graph-task-node-']")).toHaveLength(0);
|
||||
expect(screen.queryAllByTestId("dependency-edge")).toHaveLength(0);
|
||||
it("forwards keyboard events to interaction hook", () => {
|
||||
render(<DependencyGraph tasks={[createTask("A", "todo")]} onOpenTaskDetail={vi.fn()} />);
|
||||
const viewport = document.querySelector(".dependency-graph__viewport");
|
||||
if (!viewport) throw new Error("missing viewport");
|
||||
fireEvent.keyDown(viewport, { key: "=", ctrlKey: true });
|
||||
expect(handleKeyDown).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops edge from in-review task to done dependency while keeping node", () => {
|
||||
const { container } = render(<DependencyGraph tasks={[createTask("A", "in-review", ["B"]), createTask("B", "done")]} onOpenTaskDetail={vi.fn()} />);
|
||||
|
||||
expect(screen.getByTestId("graph-task-node-A")).toBeTruthy();
|
||||
expect(screen.queryByTestId("graph-task-node-B")).toBeNull();
|
||||
expect(screen.queryAllByTestId("dependency-edge")).toHaveLength(0);
|
||||
expect(container.querySelector(".graph-task-node--in-review")).toBeTruthy();
|
||||
it("sets viewport tabIndex for keyboard focus", () => {
|
||||
render(<DependencyGraph tasks={[createTask("A", "todo")]} onOpenTaskDetail={vi.fn()} />);
|
||||
const viewport = document.querySelector(".dependency-graph__viewport");
|
||||
expect(viewport?.getAttribute("tabindex")).toBe("0");
|
||||
});
|
||||
|
||||
it("renders edge between in-progress task and in-review dependency", () => {
|
||||
render(<DependencyGraph tasks={[createTask("A", "in-progress", ["B"]), createTask("B", "in-review")]} onOpenTaskDetail={vi.fn()} />);
|
||||
|
||||
expect(screen.getByTestId("graph-task-node-A")).toBeTruthy();
|
||||
expect(screen.getByTestId("graph-task-node-B")).toBeTruthy();
|
||||
expect(screen.getAllByTestId("dependency-edge")).toHaveLength(1);
|
||||
expect(screen.getByTestId("graph-task-node-B").className).toContain("graph-task-node--in-review");
|
||||
it("renders toolbar controls", () => {
|
||||
render(<DependencyGraph tasks={[createTask("A", "todo")]} onOpenTaskDetail={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: "Zoom in" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Zoom out" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Fit to graph" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Reset view" })).toBeTruthy();
|
||||
expect(screen.getByText("100%")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders embedded cards with native dragging disabled", () => {
|
||||
render(<DependencyGraph tasks={[createTask("A", "in-progress")]} onOpenTaskDetail={vi.fn()} />);
|
||||
expect(screen.getByTestId("task-A").getAttribute("draggable")).toBe("false");
|
||||
it("fit-to-graph button triggers fitToGraph", () => {
|
||||
render(<DependencyGraph tasks={[createTask("A", "todo")]} onOpenTaskDetail={vi.fn()} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Fit to graph" }));
|
||||
expect(fitToGraph).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clicking a card triggers onOpenDetail", () => {
|
||||
@@ -110,10 +117,4 @@ describe("DependencyGraph", () => {
|
||||
fireEvent.click(screen.getByTestId("task-A"));
|
||||
expect(onOpenDetail).toHaveBeenCalledWith(expect.objectContaining({ id: "A" }));
|
||||
});
|
||||
|
||||
it("fit-to-screen button triggers fitToGraph", () => {
|
||||
render(<DependencyGraph tasks={[createTask("A", "todo")]} onOpenTaskDetail={vi.fn()} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Fit to screen" }));
|
||||
expect(fitToGraph).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { GraphToolbar } from "../GraphToolbar";
|
||||
|
||||
describe("GraphToolbar", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
it("renders controls and zoom percent", () => {
|
||||
render(
|
||||
<GraphToolbar
|
||||
zoom={1.25}
|
||||
onZoomIn={vi.fn()}
|
||||
onZoomOut={vi.fn()}
|
||||
onFitToGraph={vi.fn()}
|
||||
onResetView={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Zoom in" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Zoom out" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Fit to graph" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Reset view" })).toBeTruthy();
|
||||
expect(screen.getByText("125%")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("fires callbacks", () => {
|
||||
const onZoomIn = vi.fn();
|
||||
const onZoomOut = vi.fn();
|
||||
const onFitToGraph = vi.fn();
|
||||
const onResetView = vi.fn();
|
||||
|
||||
render(
|
||||
<GraphToolbar
|
||||
zoom={1}
|
||||
onZoomIn={onZoomIn}
|
||||
onZoomOut={onZoomOut}
|
||||
onFitToGraph={onFitToGraph}
|
||||
onResetView={onResetView}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Zoom in" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Zoom out" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Fit to graph" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Reset view" }));
|
||||
|
||||
expect(onZoomIn).toHaveBeenCalledOnce();
|
||||
expect(onZoomOut).toHaveBeenCalledOnce();
|
||||
expect(onFitToGraph).toHaveBeenCalledOnce();
|
||||
expect(onResetView).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("applies toolbar class", () => {
|
||||
render(
|
||||
<GraphToolbar zoom={1} onZoomIn={vi.fn()} onZoomOut={vi.fn()} onFitToGraph={vi.fn()} onResetView={vi.fn()} />,
|
||||
);
|
||||
expect(screen.getByTestId("graph-toolbar").className).toContain("graph-toolbar");
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { useGraphInteraction } from "../useGraphInteraction";
|
||||
|
||||
function createKeyEvent(
|
||||
key: string,
|
||||
options?: { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean; target?: EventTarget | null },
|
||||
) {
|
||||
return {
|
||||
key,
|
||||
ctrlKey: Boolean(options?.ctrlKey),
|
||||
metaKey: Boolean(options?.metaKey),
|
||||
shiftKey: Boolean(options?.shiftKey),
|
||||
target: options?.target ?? document.createElement("div"),
|
||||
preventDefault: vi.fn(),
|
||||
} as unknown as React.KeyboardEvent;
|
||||
}
|
||||
|
||||
describe("useGraphInteraction", () => {
|
||||
it("starts with default pan/zoom", () => {
|
||||
const { result } = renderHook(() => useGraphInteraction());
|
||||
expect(result.current.zoom).toBe(1);
|
||||
expect(result.current.zoomPercent).toBe(100);
|
||||
expect(result.current.pan).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
|
||||
@@ -23,14 +39,55 @@ describe("useGraphInteraction", () => {
|
||||
expect(result.current.zoom).toBe(3);
|
||||
});
|
||||
|
||||
it("fits single node", () => {
|
||||
it("keeps wheel zoom anchored to cursor position", () => {
|
||||
const { result } = renderHook(() => useGraphInteraction());
|
||||
|
||||
act(() => {
|
||||
result.current.onWheelZoom(-120, { x: 200, y: 150 }, 800, 600);
|
||||
});
|
||||
|
||||
expect(result.current.zoom).toBe(1.1);
|
||||
expect(result.current.pan.x).toBeCloseTo(-20, 5);
|
||||
expect(result.current.pan.y).toBeCloseTo(-15, 5);
|
||||
});
|
||||
|
||||
it("supports pinch zoom with stationary midpoint", () => {
|
||||
const { result } = renderHook(() => useGraphInteraction());
|
||||
|
||||
act(() => {
|
||||
result.current.onPointerDown(1, { x: 100, y: 100 });
|
||||
result.current.onPointerDown(2, { x: 200, y: 100 });
|
||||
result.current.onPointerMove(2, { x: 250, y: 100 }, 800, 600);
|
||||
});
|
||||
|
||||
expect(result.current.zoom).toBe(1.5);
|
||||
expect(result.current.pan).toEqual({ x: -50, y: -50 });
|
||||
});
|
||||
|
||||
it("applies animation state for fit and reset", () => {
|
||||
vi.useFakeTimers();
|
||||
const { result } = renderHook(() => useGraphInteraction());
|
||||
|
||||
act(() => {
|
||||
result.current.fitToGraph(new Map([["A", { x: 0, y: 0 }]]), 800, 600);
|
||||
});
|
||||
expect(result.current.transitioning).toBe(true);
|
||||
|
||||
expect(result.current.zoom).toBeGreaterThan(0.1);
|
||||
expect(result.current.zoom).toBeLessThanOrEqual(3);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(210);
|
||||
});
|
||||
expect(result.current.transitioning).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current.resetView();
|
||||
});
|
||||
expect(result.current.transitioning).toBe(true);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(210);
|
||||
});
|
||||
expect(result.current.transitioning).toBe(false);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("fits wide graph", () => {
|
||||
@@ -45,16 +102,52 @@ describe("useGraphInteraction", () => {
|
||||
expect(result.current.zoom).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it("fits tall graph", () => {
|
||||
it("handles keyboard shortcuts for zoom in/out, reset, fit, and escape", () => {
|
||||
const { result } = renderHook(() => useGraphInteraction());
|
||||
const positions = new Map([
|
||||
["A", { x: 0, y: 0 }],
|
||||
["B", { x: 500, y: 200 }],
|
||||
]);
|
||||
|
||||
act(() => {
|
||||
result.current.fitToGraph(new Map([
|
||||
["A", { x: 0, y: 0 }],
|
||||
["B", { x: 0, y: 2000 }],
|
||||
]), 800, 600);
|
||||
result.current.handleKeyDown(createKeyEvent("=", { ctrlKey: true }), 800, 600, positions);
|
||||
});
|
||||
expect(result.current.zoom).toBe(1.2);
|
||||
|
||||
act(() => {
|
||||
result.current.handleKeyDown(createKeyEvent("-", { ctrlKey: true }), 800, 600, positions);
|
||||
});
|
||||
expect(result.current.zoom).toBeCloseTo(1, 5);
|
||||
|
||||
act(() => {
|
||||
result.current.handleKeyDown(createKeyEvent("F", { ctrlKey: true, shiftKey: true }), 800, 600, positions, { nodeWidth: 280, nodeHeight: 100 });
|
||||
});
|
||||
expect(result.current.zoom).toBeLessThan(1);
|
||||
|
||||
act(() => {
|
||||
result.current.handleKeyDown(createKeyEvent("0", { ctrlKey: true }), 800, 600, positions);
|
||||
});
|
||||
expect(result.current.zoom).toBe(1);
|
||||
expect(result.current.pan).toEqual({ x: 0, y: 0 });
|
||||
|
||||
act(() => {
|
||||
result.current.zoomIn();
|
||||
result.current.handleKeyDown(createKeyEvent("Escape"), 800, 600, positions);
|
||||
});
|
||||
expect(result.current.zoom).toBe(1);
|
||||
expect(result.current.pan).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
|
||||
it("does not run shortcuts when focused on editable targets", () => {
|
||||
const { result } = renderHook(() => useGraphInteraction());
|
||||
const input = document.createElement("input");
|
||||
const positions = new Map([["A", { x: 0, y: 0 }]]);
|
||||
|
||||
act(() => {
|
||||
result.current.handleKeyDown(createKeyEvent("=", { ctrlKey: true, target: input }), 800, 600, positions);
|
||||
});
|
||||
|
||||
expect(result.current.zoom).toBeLessThan(1);
|
||||
expect(result.current.zoom).toBe(1);
|
||||
});
|
||||
|
||||
it("resets when positions are empty", () => {
|
||||
@@ -71,19 +164,4 @@ describe("useGraphInteraction", () => {
|
||||
expect(result.current.zoom).toBe(1);
|
||||
expect(result.current.pan).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
|
||||
it("resetView restores defaults", () => {
|
||||
const { result } = renderHook(() => useGraphInteraction());
|
||||
|
||||
act(() => {
|
||||
result.current.zoomIn();
|
||||
result.current.onPointerDown(1, { x: 0, y: 0 });
|
||||
result.current.onPointerMove(1, { x: 200, y: 200 }, 800, 600);
|
||||
result.current.onPointerUp(1);
|
||||
result.current.resetView();
|
||||
});
|
||||
|
||||
expect(result.current.zoom).toBe(1);
|
||||
expect(result.current.pan).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user