feat(canvas): implement dropped connection resolution and enhance connection handling
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react";
|
||||
|
||||
import { resolveDroppedConnectionTarget } from "@/components/canvas/canvas-helpers";
|
||||
|
||||
function createNode(overrides: Partial<RFNode> & Pick<RFNode, "id">): RFNode {
|
||||
return {
|
||||
id: overrides.id,
|
||||
position: { x: 0, y: 0 },
|
||||
data: {},
|
||||
...overrides,
|
||||
} as RFNode;
|
||||
}
|
||||
|
||||
function createEdge(
|
||||
overrides: Partial<RFEdge> & Pick<RFEdge, "id" | "source" | "target">,
|
||||
): RFEdge {
|
||||
return {
|
||||
...overrides,
|
||||
} as RFEdge;
|
||||
}
|
||||
|
||||
function makeNodeElement(id: string, rect: Partial<DOMRect> = {}): HTMLElement {
|
||||
const element = document.createElement("div");
|
||||
element.className = "react-flow__node";
|
||||
element.dataset.id = id;
|
||||
vi.spyOn(element, "getBoundingClientRect").mockReturnValue({
|
||||
x: 0,
|
||||
y: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: rect.width ?? 200,
|
||||
bottom: rect.height ?? 120,
|
||||
width: rect.width ?? 200,
|
||||
height: rect.height ?? 120,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect);
|
||||
return element;
|
||||
}
|
||||
|
||||
describe("resolveDroppedConnectionTarget", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("resolves a source-start body drop into a direct connection", () => {
|
||||
const sourceNode = createNode({
|
||||
id: "node-source",
|
||||
type: "image",
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
const targetNode = createNode({
|
||||
id: "node-target",
|
||||
type: "text",
|
||||
position: { x: 320, y: 200 },
|
||||
});
|
||||
const targetElement = makeNodeElement("node-target");
|
||||
Object.defineProperty(document, "elementsFromPoint", {
|
||||
value: vi.fn(() => [targetElement]),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const result = resolveDroppedConnectionTarget({
|
||||
point: { x: 340, y: 220 },
|
||||
fromNodeId: "node-source",
|
||||
fromHandleType: "source",
|
||||
nodes: [sourceNode, targetNode],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
sourceNodeId: "node-source",
|
||||
targetNodeId: "node-target",
|
||||
sourceHandle: undefined,
|
||||
targetHandle: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when the pointer is over the canvas background", () => {
|
||||
const sourceNode = createNode({
|
||||
id: "node-source",
|
||||
type: "image",
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
Object.defineProperty(document, "elementsFromPoint", {
|
||||
value: vi.fn(() => []),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const result = resolveDroppedConnectionTarget({
|
||||
point: { x: 10, y: 10 },
|
||||
fromNodeId: "node-source",
|
||||
fromHandleType: "source",
|
||||
nodes: [sourceNode],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the free compare slot when dropping on a compare node body", () => {
|
||||
const sourceNode = createNode({
|
||||
id: "node-source",
|
||||
type: "image",
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
const compareNode = createNode({
|
||||
id: "node-compare",
|
||||
type: "compare",
|
||||
position: { x: 320, y: 200 },
|
||||
});
|
||||
const compareElement = makeNodeElement("node-compare", {
|
||||
width: 500,
|
||||
height: 380,
|
||||
});
|
||||
Object.defineProperty(document, "elementsFromPoint", {
|
||||
value: vi.fn(() => [compareElement]),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const result = resolveDroppedConnectionTarget({
|
||||
point: { x: 380, y: 290 },
|
||||
fromNodeId: "node-source",
|
||||
fromHandleType: "source",
|
||||
nodes: [sourceNode, compareNode],
|
||||
edges: [
|
||||
createEdge({
|
||||
id: "edge-left",
|
||||
source: "node-source",
|
||||
target: "node-compare",
|
||||
targetHandle: "left",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
sourceNodeId: "node-source",
|
||||
targetNodeId: "node-compare",
|
||||
sourceHandle: undefined,
|
||||
targetHandle: "right",
|
||||
});
|
||||
});
|
||||
|
||||
it("reverses the connection when the drag starts from a target handle", () => {
|
||||
const droppedNode = createNode({
|
||||
id: "node-dropped",
|
||||
type: "text",
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
const sourceNode = createNode({
|
||||
id: "node-source",
|
||||
type: "image",
|
||||
position: { x: 320, y: 200 },
|
||||
});
|
||||
const droppedElement = makeNodeElement("node-dropped");
|
||||
Object.defineProperty(document, "elementsFromPoint", {
|
||||
value: vi.fn(() => [droppedElement]),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const result = resolveDroppedConnectionTarget({
|
||||
point: { x: 60, y: 60 },
|
||||
fromNodeId: "node-source",
|
||||
fromHandleId: "target-handle",
|
||||
fromHandleType: "target",
|
||||
nodes: [droppedNode, sourceNode],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
sourceNodeId: "node-dropped",
|
||||
targetNodeId: "node-source",
|
||||
sourceHandle: undefined,
|
||||
targetHandle: "target-handle",
|
||||
});
|
||||
});
|
||||
});
|
||||
316
components/canvas/__tests__/use-canvas-connections.test.tsx
Normal file
316
components/canvas/__tests__/use-canvas-connections.test.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React, { act, useEffect, useRef, useState } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
resolveDroppedConnectionTarget: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/canvas/canvas-helpers", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/components/canvas/canvas-helpers")
|
||||
>("@/components/canvas/canvas-helpers");
|
||||
|
||||
return {
|
||||
...actual,
|
||||
resolveDroppedConnectionTarget: mocks.resolveDroppedConnectionTarget,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/canvas/canvas-reconnect", () => ({
|
||||
useCanvasReconnectHandlers: () => ({
|
||||
onReconnectStart: vi.fn(),
|
||||
onReconnect: vi.fn(),
|
||||
onReconnectEnd: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { useCanvasConnections } from "@/components/canvas/use-canvas-connections";
|
||||
import type { DroppedConnectionTarget } from "@/components/canvas/canvas-helpers";
|
||||
|
||||
const asCanvasId = (id: string): Id<"canvases"> => id as Id<"canvases">;
|
||||
|
||||
const latestHandlersRef: {
|
||||
current: ReturnType<typeof useCanvasConnections> | null;
|
||||
} = { current: null };
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
type HookHarnessProps = {
|
||||
helperResult: DroppedConnectionTarget | null;
|
||||
runCreateEdgeMutation?: ReturnType<typeof vi.fn>;
|
||||
showConnectionRejectedToast?: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function HookHarness({
|
||||
helperResult,
|
||||
runCreateEdgeMutation = vi.fn(async () => undefined),
|
||||
showConnectionRejectedToast = vi.fn(),
|
||||
}: HookHarnessProps) {
|
||||
const [nodes] = useState<RFNode[]>([
|
||||
{ id: "node-source", type: "image", position: { x: 0, y: 0 }, data: {} },
|
||||
{ id: "node-target", type: "text", position: { x: 300, y: 200 }, data: {} },
|
||||
]);
|
||||
const [edges] = useState<RFEdge[]>([]);
|
||||
const nodesRef = useRef(nodes);
|
||||
const edgesRef = useRef(edges);
|
||||
const edgeReconnectSuccessful = useRef(true);
|
||||
const isReconnectDragActiveRef = useRef(false);
|
||||
const pendingConnectionCreatesRef = useRef(new Set<string>());
|
||||
const resolvedRealIdByClientRequestRef = useRef(new Map<string, Id<"nodes">>());
|
||||
const setEdges = vi.fn();
|
||||
const setEdgeSyncNonce = vi.fn();
|
||||
|
||||
useEffect(() => {
|
||||
nodesRef.current = nodes;
|
||||
}, [nodes]);
|
||||
|
||||
useEffect(() => {
|
||||
edgesRef.current = edges;
|
||||
}, [edges]);
|
||||
|
||||
useEffect(() => {
|
||||
mocks.resolveDroppedConnectionTarget.mockReturnValue(helperResult);
|
||||
}, [helperResult]);
|
||||
|
||||
const handlers = useCanvasConnections({
|
||||
canvasId: asCanvasId("canvas-1"),
|
||||
nodes,
|
||||
edges,
|
||||
nodesRef,
|
||||
edgesRef,
|
||||
edgeReconnectSuccessful,
|
||||
isReconnectDragActiveRef,
|
||||
pendingConnectionCreatesRef,
|
||||
resolvedRealIdByClientRequestRef,
|
||||
setEdges,
|
||||
setEdgeSyncNonce,
|
||||
screenToFlowPosition: (position) => position,
|
||||
syncPendingMoveForClientRequest: vi.fn(async () => undefined),
|
||||
runCreateEdgeMutation,
|
||||
runRemoveEdgeMutation: vi.fn(async () => undefined),
|
||||
runCreateNodeWithEdgeFromSourceOnlineOnly: vi.fn(async () => "node-1"),
|
||||
runCreateNodeWithEdgeToTargetOnlineOnly: vi.fn(async () => "node-1"),
|
||||
showConnectionRejectedToast,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
latestHandlersRef.current = handlers;
|
||||
}, [handlers]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("useCanvasConnections", () => {
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: Root | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
latestHandlersRef.current = null;
|
||||
vi.clearAllMocks();
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
});
|
||||
|
||||
it("creates an edge when a body drop lands on another node", async () => {
|
||||
const runCreateEdgeMutation = vi.fn(async () => undefined);
|
||||
const showConnectionRejectedToast = vi.fn();
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<HookHarness
|
||||
helperResult={{
|
||||
sourceNodeId: "node-source",
|
||||
targetNodeId: "node-target",
|
||||
sourceHandle: undefined,
|
||||
targetHandle: undefined,
|
||||
}}
|
||||
runCreateEdgeMutation={runCreateEdgeMutation}
|
||||
showConnectionRejectedToast={showConnectionRejectedToast}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.onConnectEnd(
|
||||
{ clientX: 400, clientY: 260 } as MouseEvent,
|
||||
{
|
||||
isValid: false,
|
||||
from: { x: 0, y: 0 },
|
||||
fromNode: { id: "node-source", type: "image" },
|
||||
fromHandle: { id: null, type: "source" },
|
||||
fromPosition: null,
|
||||
to: { x: 400, y: 260 },
|
||||
toHandle: null,
|
||||
toNode: null,
|
||||
toPosition: null,
|
||||
pointer: null,
|
||||
} as never,
|
||||
);
|
||||
});
|
||||
|
||||
expect(runCreateEdgeMutation).toHaveBeenCalledWith({
|
||||
canvasId: "canvas-1",
|
||||
sourceNodeId: "node-source",
|
||||
targetNodeId: "node-target",
|
||||
sourceHandle: undefined,
|
||||
targetHandle: undefined,
|
||||
});
|
||||
expect(showConnectionRejectedToast).not.toHaveBeenCalled();
|
||||
expect(latestHandlersRef.current?.connectionDropMenu).toBeNull();
|
||||
});
|
||||
|
||||
it("opens the node picker when the drop lands on the background", async () => {
|
||||
const runCreateEdgeMutation = vi.fn(async () => undefined);
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<HookHarness
|
||||
helperResult={null}
|
||||
runCreateEdgeMutation={runCreateEdgeMutation}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.onConnectEnd(
|
||||
{ clientX: 123, clientY: 456 } as MouseEvent,
|
||||
{
|
||||
isValid: false,
|
||||
from: { x: 0, y: 0 },
|
||||
fromNode: { id: "node-source", type: "image" },
|
||||
fromHandle: { id: null, type: "source" },
|
||||
fromPosition: null,
|
||||
to: { x: 123, y: 456 },
|
||||
toHandle: null,
|
||||
toNode: null,
|
||||
toPosition: null,
|
||||
pointer: null,
|
||||
} as never,
|
||||
);
|
||||
});
|
||||
|
||||
expect(runCreateEdgeMutation).not.toHaveBeenCalled();
|
||||
expect(latestHandlersRef.current?.connectionDropMenu).toEqual(
|
||||
expect.objectContaining({
|
||||
screenX: 123,
|
||||
screenY: 456,
|
||||
flowX: 123,
|
||||
flowY: 456,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an invalid body drop without opening the menu", async () => {
|
||||
const runCreateEdgeMutation = vi.fn(async () => undefined);
|
||||
const showConnectionRejectedToast = vi.fn();
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<HookHarness
|
||||
helperResult={{
|
||||
sourceNodeId: "node-source",
|
||||
targetNodeId: "node-source",
|
||||
sourceHandle: undefined,
|
||||
targetHandle: undefined,
|
||||
}}
|
||||
runCreateEdgeMutation={runCreateEdgeMutation}
|
||||
showConnectionRejectedToast={showConnectionRejectedToast}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.onConnectEnd(
|
||||
{ clientX: 300, clientY: 210 } as MouseEvent,
|
||||
{
|
||||
isValid: false,
|
||||
from: { x: 0, y: 0 },
|
||||
fromNode: { id: "node-source", type: "image" },
|
||||
fromHandle: { id: null, type: "source" },
|
||||
fromPosition: null,
|
||||
to: { x: 300, y: 210 },
|
||||
toHandle: null,
|
||||
toNode: null,
|
||||
toPosition: null,
|
||||
pointer: null,
|
||||
} as never,
|
||||
);
|
||||
});
|
||||
|
||||
expect(runCreateEdgeMutation).not.toHaveBeenCalled();
|
||||
expect(showConnectionRejectedToast).toHaveBeenCalledWith("self-loop");
|
||||
expect(latestHandlersRef.current?.connectionDropMenu).toBeNull();
|
||||
});
|
||||
|
||||
it("reverses the edge direction when the drag starts from a target handle", async () => {
|
||||
const runCreateEdgeMutation = vi.fn(async () => undefined);
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<HookHarness
|
||||
helperResult={{
|
||||
sourceNodeId: "node-target",
|
||||
targetNodeId: "node-source",
|
||||
sourceHandle: undefined,
|
||||
targetHandle: "target-handle",
|
||||
}}
|
||||
runCreateEdgeMutation={runCreateEdgeMutation}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.onConnectEnd(
|
||||
{ clientX: 200, clientY: 200 } as MouseEvent,
|
||||
{
|
||||
isValid: false,
|
||||
from: { x: 0, y: 0 },
|
||||
fromNode: { id: "node-source", type: "image" },
|
||||
fromHandle: { id: "target-handle", type: "target" },
|
||||
fromPosition: null,
|
||||
to: { x: 200, y: 200 },
|
||||
toHandle: null,
|
||||
toNode: null,
|
||||
toPosition: null,
|
||||
pointer: null,
|
||||
} as never,
|
||||
);
|
||||
});
|
||||
|
||||
expect(runCreateEdgeMutation).toHaveBeenCalledWith({
|
||||
canvasId: "canvas-1",
|
||||
sourceNodeId: "node-target",
|
||||
targetNodeId: "node-source",
|
||||
sourceHandle: undefined,
|
||||
targetHandle: "target-handle",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user