feat(FN-3089): add draggable dependency graph nodes
Adds draggable interaction to dependency graph nodes (FN-3089), introducing a `useNodeDrag` hook to manage drag state and positioning, with corresponding tests and a new drag stylesheet; the `GraphTaskNode` and `DependencyGraph` components are updated to wire up the drag behavior. Fusion-Task-Id: FN-3089
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { GraphTaskNode } from "../GraphTaskNode";
|
||||
|
||||
function task(id = "FN-1"): Task {
|
||||
return { id, description: id, column: "todo", dependencies: [], steps: [], currentStep: 0, log: [] } as Task;
|
||||
}
|
||||
|
||||
function props(overrides: Partial<React.ComponentProps<typeof GraphTaskNode>> = {}): React.ComponentProps<typeof GraphTaskNode> {
|
||||
return {
|
||||
task: task(),
|
||||
projectId: "p1",
|
||||
position: { x: 0, y: 0 },
|
||||
scale: 1,
|
||||
isHighlighted: false,
|
||||
isDimmed: false,
|
||||
onNodePositionChange: vi.fn(),
|
||||
onNodeDragStateChange: vi.fn(),
|
||||
onOpenDetail: vi.fn(),
|
||||
addToast: vi.fn(),
|
||||
onUpdateTask: vi.fn(),
|
||||
onArchiveTask: vi.fn(),
|
||||
onUnarchiveTask: vi.fn(),
|
||||
onDeleteTask: vi.fn(),
|
||||
onRetryTask: vi.fn(),
|
||||
onOpenDetailWithTab: vi.fn(),
|
||||
onMoveTask: vi.fn(),
|
||||
onOpenMission: vi.fn(),
|
||||
taskStuckTimeoutMs: 1000,
|
||||
lastFetchTimeMs: Date.now(),
|
||||
workflowStepNameLookup: new Map<string, string>(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("GraphTaskNode drag", () => {
|
||||
it("applies dragging class only after threshold move", () => {
|
||||
const onNodePositionChange = vi.fn();
|
||||
render(<GraphTaskNode {...props({ onNodePositionChange })} />);
|
||||
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: 12, clientY: 12, isPrimary: true });
|
||||
expect(node.className).not.toContain("graph-node--dragging");
|
||||
|
||||
fireEvent.pointerMove(node, { pointerId: 1, clientX: 20, clientY: 20, isPrimary: true });
|
||||
expect(node.className).toContain("graph-node--dragging");
|
||||
expect(onNodePositionChange).toHaveBeenCalled();
|
||||
|
||||
fireEvent.pointerUp(node, { pointerId: 1, clientX: 20, clientY: 20, isPrimary: true });
|
||||
expect(node.className).not.toContain("graph-node--dragging");
|
||||
});
|
||||
|
||||
it("composes highlight and dragging classes", () => {
|
||||
render(<GraphTaskNode {...props({ 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 });
|
||||
|
||||
expect(node.className).toContain("graph-task-node--highlighted");
|
||||
expect(node.className).toContain("graph-node--dragging");
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,10 @@ function createTask(overrides: Partial<Task> = {}): Task {
|
||||
function createProps(task: Task) {
|
||||
return {
|
||||
task,
|
||||
position: { x: 0, y: 0 },
|
||||
scale: 1,
|
||||
onNodePositionChange: vi.fn(),
|
||||
onNodeDragStateChange: vi.fn(),
|
||||
projectId: "proj-1",
|
||||
onOpenDetail: vi.fn(),
|
||||
addToast: vi.fn(),
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { __internal, useNodeDrag } from "../hooks/useNodeDrag";
|
||||
|
||||
function pointerEvent(overrides: Partial<PointerEvent> = {}) {
|
||||
const target = {
|
||||
setPointerCapture: vi.fn(),
|
||||
releasePointerCapture: vi.fn(),
|
||||
hasPointerCapture: vi.fn(() => true),
|
||||
};
|
||||
return {
|
||||
isPrimary: true,
|
||||
pointerId: 1,
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
stopPropagation: vi.fn(),
|
||||
currentTarget: target,
|
||||
...overrides,
|
||||
} as unknown as React.PointerEvent<HTMLElement>;
|
||||
}
|
||||
|
||||
describe("useNodeDrag", () => {
|
||||
it("transitions pending to dragging and back on pointer up", () => {
|
||||
const onPositionChange = vi.fn();
|
||||
const onDragStateChange = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useNodeDrag({ taskId: "A", position: { x: 10, y: 10 }, scale: 1, onPositionChange, onDragStateChange }),
|
||||
);
|
||||
|
||||
act(() => result.current.onPointerDown(pointerEvent({ clientX: 10, clientY: 20 })));
|
||||
act(() => result.current.onPointerMove(pointerEvent({ clientX: 16, clientY: 26 })));
|
||||
expect(result.current.isDragging).toBe(true);
|
||||
expect(onPositionChange).toHaveBeenCalledWith("A", { x: 16, y: 16 });
|
||||
|
||||
act(() => result.current.onPointerUp(pointerEvent({ clientX: 16, clientY: 26 })));
|
||||
expect(result.current.isDragging).toBe(false);
|
||||
expect(onDragStateChange).toHaveBeenCalledWith(true);
|
||||
expect(onDragStateChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("stays click-only below threshold", () => {
|
||||
const onPositionChange = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useNodeDrag({ taskId: "A", position: { x: 0, y: 0 }, scale: 1, onPositionChange }),
|
||||
);
|
||||
|
||||
act(() => result.current.onPointerDown(pointerEvent()));
|
||||
act(() => result.current.onPointerMove(pointerEvent({ clientX: __internal.DRAG_THRESHOLD_PX - 1, clientY: 0 })));
|
||||
act(() => result.current.onPointerUp(pointerEvent({ clientX: __internal.DRAG_THRESHOLD_PX - 1, clientY: 0 })));
|
||||
|
||||
expect(result.current.isDragging).toBe(false);
|
||||
expect(onPositionChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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 }),
|
||||
);
|
||||
|
||||
act(() => result.current.onPointerDown(pointerEvent()));
|
||||
act(() => result.current.onPointerMove(pointerEvent({ clientX: 10, clientY: 6 })));
|
||||
|
||||
expect(onPositionChange).toHaveBeenCalledWith("A", { x: 5, y: 3 });
|
||||
});
|
||||
|
||||
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 }),
|
||||
);
|
||||
|
||||
act(() => result.current.onPointerDown(pointerEvent()));
|
||||
act(() => result.current.onPointerMove(pointerEvent({ clientX: 8, clientY: 0 })));
|
||||
expect(result.current.isDragging).toBe(true);
|
||||
|
||||
act(() => result.current.onPointerCancel(pointerEvent({ clientX: 8, clientY: 0 })));
|
||||
expect(result.current.isDragging).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user