feat(FN-3941): separate graph pan from selection-gated drag
Separates graph panning from selection-gated drag by updating `useNodeDrag` and `useGraphInteraction` hooks to handle the two interaction modes independently, with `GraphTaskNode` now gating drag on selection state. Tests were added across the graph interaction, node drag, persistence, and integrati Fusion-Task-Id: FN-3941
This commit is contained in:
@@ -70,6 +70,7 @@ describe("DependencyGraph persistence", () => {
|
||||
const { unmount } = render(<DependencyGraph tasks={[createTask("A")]} projectId="p1" />);
|
||||
|
||||
const node = screen.getByTestId("graph-task-node-A");
|
||||
fireEvent.click(node);
|
||||
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 });
|
||||
|
||||
@@ -8,6 +8,9 @@ const zoomIn = vi.fn();
|
||||
const zoomOut = vi.fn();
|
||||
const resetView = vi.fn();
|
||||
const handleKeyDown = vi.fn();
|
||||
const onPointerDown = vi.fn();
|
||||
const onPointerMove = vi.fn();
|
||||
const onPointerUp = vi.fn();
|
||||
|
||||
vi.mock("@fusion/dashboard/app/components/TaskCard", () => ({
|
||||
TaskCard: ({ task, onOpenDetail, disableDrag }: { task: Task; onOpenDetail: (task: Task) => void; disableDrag?: boolean }) => (
|
||||
@@ -24,9 +27,9 @@ vi.mock("../useGraphInteraction", () => ({
|
||||
zoomOut,
|
||||
resetView,
|
||||
fitToGraph,
|
||||
onPointerDown: vi.fn(),
|
||||
onPointerMove: vi.fn(),
|
||||
onPointerUp: vi.fn(),
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
onWheelZoom: vi.fn(),
|
||||
handleKeyDown,
|
||||
}),
|
||||
@@ -43,6 +46,9 @@ describe("DependencyGraph", () => {
|
||||
zoomOut.mockReset();
|
||||
resetView.mockReset();
|
||||
handleKeyDown.mockReset();
|
||||
onPointerDown.mockReset();
|
||||
onPointerMove.mockReset();
|
||||
onPointerUp.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -125,4 +131,26 @@ describe("DependencyGraph", () => {
|
||||
fireEvent.click(screen.getByTestId("task-A"));
|
||||
expect(onOpenTaskDetail).toHaveBeenCalledWith("A");
|
||||
});
|
||||
|
||||
it("requires selection before node drag class is enabled", () => {
|
||||
render(<DependencyGraph tasks={[createTask("A", "todo")]} onOpenTaskDetail={vi.fn()} />);
|
||||
const node = screen.getByTestId("graph-task-node-A");
|
||||
|
||||
expect(node.className).not.toContain("graph-node--draggable");
|
||||
fireEvent.click(node);
|
||||
expect(node.className).toContain("graph-node--draggable");
|
||||
});
|
||||
|
||||
it("allows pane panning from an unselected node surface", () => {
|
||||
render(<DependencyGraph tasks={[createTask("A", "todo")]} onOpenTaskDetail={vi.fn()} />);
|
||||
const node = screen.getByTestId("graph-task-node-A");
|
||||
|
||||
fireEvent.pointerDown(node, { pointerId: 1, clientX: 10, clientY: 10, isPrimary: true });
|
||||
fireEvent.pointerMove(node, { pointerId: 1, clientX: 30, clientY: 20, isPrimary: true });
|
||||
fireEvent.pointerUp(node, { pointerId: 1, clientX: 30, clientY: 20, isPrimary: true });
|
||||
|
||||
expect(onPointerDown).toHaveBeenCalled();
|
||||
expect(onPointerMove).toHaveBeenCalled();
|
||||
expect(onPointerUp).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ function props(overrides: Partial<React.ComponentProps<typeof GraphTaskNode>> =
|
||||
projectId: "p1",
|
||||
position: { x: 0, y: 0 },
|
||||
scale: 1,
|
||||
isSelected: false,
|
||||
isHighlighted: false,
|
||||
isDimmed: false,
|
||||
onNodePositionChange: vi.fn(),
|
||||
@@ -42,7 +43,7 @@ afterEach(() => {
|
||||
describe("GraphTaskNode drag", () => {
|
||||
it("does not open detail after drag threshold is exceeded", () => {
|
||||
const onOpenDetail = vi.fn();
|
||||
render(<GraphTaskNode {...props({ onOpenDetail })} />);
|
||||
render(<GraphTaskNode {...props({ onOpenDetail, isSelected: true })} />);
|
||||
const node = screen.getByTestId("graph-task-node-FN-1");
|
||||
|
||||
fireEvent.pointerDown(node, { pointerId: 1, clientX: 10, clientY: 10, isPrimary: true });
|
||||
@@ -55,7 +56,7 @@ describe("GraphTaskNode drag", () => {
|
||||
|
||||
it("applies dragging class only after threshold move", () => {
|
||||
const onNodePositionChange = vi.fn();
|
||||
render(<GraphTaskNode {...props({ onNodePositionChange })} />);
|
||||
render(<GraphTaskNode {...props({ onNodePositionChange, isSelected: true })} />);
|
||||
const node = screen.getByTestId("graph-task-node-FN-1");
|
||||
|
||||
fireEvent.pointerDown(node, { pointerId: 1, clientX: 10, clientY: 10, isPrimary: true });
|
||||
@@ -71,7 +72,7 @@ describe("GraphTaskNode drag", () => {
|
||||
});
|
||||
|
||||
it("composes highlight and dragging classes", () => {
|
||||
render(<GraphTaskNode {...props({ isHighlighted: true })} />);
|
||||
render(<GraphTaskNode {...props({ isSelected: true, isHighlighted: true })} />);
|
||||
const node = screen.getByTestId("graph-task-node-FN-1");
|
||||
fireEvent.pointerDown(node, { pointerId: 1, clientX: 0, clientY: 0, isPrimary: true });
|
||||
fireEvent.pointerMove(node, { pointerId: 1, clientX: 10, clientY: 10, isPrimary: true });
|
||||
@@ -79,4 +80,17 @@ describe("GraphTaskNode drag", () => {
|
||||
expect(node.className).toContain("graph-task-node--highlighted");
|
||||
expect(node.className).toContain("graph-node--dragging");
|
||||
});
|
||||
|
||||
it("does not drag when node is not selected", () => {
|
||||
const onNodePositionChange = vi.fn();
|
||||
render(<GraphTaskNode {...props({ onNodePositionChange, isSelected: false })} />);
|
||||
const node = screen.getByTestId("graph-task-node-FN-1");
|
||||
|
||||
fireEvent.pointerDown(node, { pointerId: 1, clientX: 10, clientY: 10, isPrimary: true });
|
||||
fireEvent.pointerMove(node, { pointerId: 1, clientX: 25, clientY: 25, isPrimary: true });
|
||||
|
||||
expect(node.className).not.toContain("graph-node--dragging");
|
||||
expect(node.className).not.toContain("graph-node--draggable");
|
||||
expect(onNodePositionChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,6 +44,18 @@ describe("dependency graph interactions", () => {
|
||||
expect(result.current.zoom).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("keeps single-pointer moves as pan-only without zoom changes", () => {
|
||||
const { result } = renderHook(() => useGraphInteraction());
|
||||
|
||||
act(() => {
|
||||
result.current.onPointerDown(1, { x: 10, y: 10 });
|
||||
result.current.onPointerMove(1, { x: 40, y: 30 }, 800, 600);
|
||||
});
|
||||
|
||||
expect(result.current.pan).toEqual({ x: 30, y: 20 });
|
||||
expect(result.current.zoom).toBe(1);
|
||||
});
|
||||
|
||||
it("fit-to-graph computes bounds from actual node positions", () => {
|
||||
const { result } = renderHook(() => useGraphInteraction());
|
||||
const positions = new Map([
|
||||
@@ -78,6 +90,7 @@ describe("dependency graph interactions", () => {
|
||||
task={createTask("A")}
|
||||
position={{ x: 0, y: 0 }}
|
||||
scale={1}
|
||||
isSelected={true}
|
||||
isHighlighted={false}
|
||||
isDimmed={false}
|
||||
onNodePositionChange={onNodePositionChange}
|
||||
|
||||
@@ -64,6 +64,7 @@ describe("dependency graph position persistence", () => {
|
||||
|
||||
render(<DependencyGraph tasks={[createTask("A")]} projectId="p1" />);
|
||||
const node = screen.getByTestId("graph-task-node-A");
|
||||
fireEvent.click(node);
|
||||
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 });
|
||||
@@ -75,6 +76,7 @@ describe("dependency graph position persistence", () => {
|
||||
it("clearing localStorage causes fresh auto-layout on remount", () => {
|
||||
const { unmount } = render(<DependencyGraph tasks={[createTask("A")]} projectId="p1" />);
|
||||
const node = screen.getByTestId("graph-task-node-A");
|
||||
fireEvent.click(node);
|
||||
fireEvent.pointerDown(node, { pointerId: 1, isPrimary: true, clientX: 10, clientY: 10 });
|
||||
fireEvent.pointerMove(node, { pointerId: 1, isPrimary: true, clientX: 40, clientY: 50 });
|
||||
fireEvent.pointerUp(node, { pointerId: 1, isPrimary: true, clientX: 40, clientY: 50 });
|
||||
|
||||
@@ -64,6 +64,21 @@ describe("useGraphInteraction", () => {
|
||||
expect(result.current.pan).toEqual({ x: -50, y: -50 });
|
||||
});
|
||||
|
||||
it("continues with pan after pinch when one pointer remains", () => {
|
||||
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);
|
||||
result.current.onPointerUp(2);
|
||||
result.current.onPointerMove(1, { x: 120, y: 130 }, 800, 600);
|
||||
});
|
||||
|
||||
expect(result.current.zoom).toBe(1.5);
|
||||
expect(result.current.pan).toEqual({ x: 20, y: 30 });
|
||||
});
|
||||
|
||||
it("applies animation state for fit and reset", () => {
|
||||
vi.useFakeTimers();
|
||||
const { result } = renderHook(() => useGraphInteraction());
|
||||
|
||||
@@ -25,7 +25,7 @@ describe("useNodeDrag", () => {
|
||||
const onPositionChange = vi.fn();
|
||||
const onDragStateChange = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useNodeDrag({ taskId: "A", position: { x: 10, y: 10 }, scale: 1, onPositionChange, onDragStateChange }),
|
||||
useNodeDrag({ taskId: "A", position: { x: 10, y: 10 }, scale: 1, canDrag: true, onPositionChange, onDragStateChange }),
|
||||
);
|
||||
|
||||
act(() => result.current.onPointerDown(pointerEvent({ clientX: 10, clientY: 20 })));
|
||||
@@ -42,7 +42,7 @@ describe("useNodeDrag", () => {
|
||||
it("stays click-only below threshold", () => {
|
||||
const onPositionChange = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useNodeDrag({ taskId: "A", position: { x: 0, y: 0 }, scale: 1, onPositionChange }),
|
||||
useNodeDrag({ taskId: "A", position: { x: 0, y: 0 }, scale: 1, canDrag: true, onPositionChange }),
|
||||
);
|
||||
|
||||
act(() => result.current.onPointerDown(pointerEvent()));
|
||||
@@ -56,7 +56,7 @@ describe("useNodeDrag", () => {
|
||||
it("divides pointer delta by zoom scale", () => {
|
||||
const onPositionChange = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useNodeDrag({ taskId: "A", position: { x: 0, y: 0 }, scale: 2, onPositionChange }),
|
||||
useNodeDrag({ taskId: "A", position: { x: 0, y: 0 }, scale: 2, canDrag: true, onPositionChange }),
|
||||
);
|
||||
|
||||
act(() => result.current.onPointerDown(pointerEvent()));
|
||||
@@ -68,7 +68,7 @@ describe("useNodeDrag", () => {
|
||||
it("cancels drag cleanly on pointer cancel", () => {
|
||||
const onPositionChange = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useNodeDrag({ taskId: "A", position: { x: 0, y: 0 }, scale: 1, onPositionChange }),
|
||||
useNodeDrag({ taskId: "A", position: { x: 0, y: 0 }, scale: 1, canDrag: true, onPositionChange }),
|
||||
);
|
||||
|
||||
act(() => result.current.onPointerDown(pointerEvent()));
|
||||
@@ -78,4 +78,17 @@ describe("useNodeDrag", () => {
|
||||
act(() => result.current.onPointerCancel(pointerEvent({ clientX: 8, clientY: 0 })));
|
||||
expect(result.current.isDragging).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores pointer interactions when dragging is disabled", () => {
|
||||
const onPositionChange = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useNodeDrag({ taskId: "A", position: { x: 0, y: 0 }, scale: 1, canDrag: false, onPositionChange }),
|
||||
);
|
||||
|
||||
act(() => result.current.onPointerDown(pointerEvent()));
|
||||
act(() => result.current.onPointerMove(pointerEvent({ clientX: 8, clientY: 0 })));
|
||||
|
||||
expect(result.current.isDragging).toBe(false);
|
||||
expect(onPositionChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user