Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 47 additions & 11 deletions src/components/builder/hooks/use-builder-graph-mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ export function useBuilderGraphMutations(options: {
} = options

const layoutTransitionTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pendingDragBatchRef = useRef<{
positions: Map<string, XYPosition>
previous: Workflow | null
flushScheduled: boolean
}>({ positions: new Map(), previous: null, flushScheduled: false })
const [isLayoutTransitioning, setIsLayoutTransitioning] = useState(false)

useEffect(() => {
Expand Down Expand Up @@ -184,25 +189,56 @@ export function useBuilderGraphMutations(options: {
[workflowId, workflow, saveToHistory, mutateWorkflow, safeFetch],
)

const flushPendingDragPositions = useCallback(async () => {
const batch = pendingDragBatchRef.current
batch.flushScheduled = false
if (!workflowId || !workflow || batch.positions.size === 0) {
batch.positions.clear()
batch.previous = null
return
}

const previous = batch.previous ?? workflow
const positionUpdates = new Map(batch.positions)
batch.positions.clear()
batch.previous = null

const updatedNodes = workflow.nodes.map((node) => {
const position = positionUpdates.get(node.id)
return position ? { ...node, position } : node
})

const response = await safeFetch(`/api/workflows/${workflowId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ nodes: updatedNodes }),
})
if (response.ok) {
saveToHistory(previous)
}
mutateWorkflow(workflowId)
}, [workflowId, workflow, saveToHistory, mutateWorkflow, safeFetch])

const handleNodeDragStop = useCallback(
async (_: React.MouseEvent, node: Node<WorkflowNodeData, NodeType>) => {
(_: React.MouseEvent, node: Node<WorkflowNodeData, NodeType>) => {
if (!workflowId || !workflow) return
const previous = workflow
const snappedPosition = {
x: Math.round(node.position.x / GRID_SIZE) * GRID_SIZE,
y: Math.round(node.position.y / GRID_SIZE) * GRID_SIZE,
}
const response = await safeFetch(`/api/workflows/${workflowId}/nodes/${node.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ position: snappedPosition }),
})
if (response.ok) {
saveToHistory(previous)
const batch = pendingDragBatchRef.current
if (batch.previous === null) {
batch.previous = workflow
}
batch.positions.set(node.id, snappedPosition)
if (!batch.flushScheduled) {
batch.flushScheduled = true
queueMicrotask(() => {
void flushPendingDragPositions()
})
}
mutateWorkflow(workflowId)
},
[workflowId, workflow, saveToHistory, mutateWorkflow, safeFetch],
[workflowId, workflow, flushPendingDragPositions],
)

const handleAddNode = useCallback(
Expand Down
94 changes: 94 additions & 0 deletions tests/components/use-builder-graph-mutations.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest"
import { renderHook, act } from "@testing-library/react"
import type { Node } from "@xyflow/react"
import { useBuilderGraphMutations } from "@/components/builder/hooks/use-builder-graph-mutations"
import type { WorkflowNodeData } from "@/components/builder/canvas-node"
import type { NodeType, Workflow } from "@/lib/workflow-types"

const workflow: Workflow = {
id: "wf-1",
name: "Test",
description: "",
nodes: [
{ id: "n1", type: "agent", position: { x: 0, y: 0 }, data: { label: "A" } },
{ id: "n2", type: "agent", position: { x: 200, y: 0 }, data: { label: "B" } },
],
connections: [],
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
}

function dragNode(
id: string,
position: { x: number; y: number },
): Node<WorkflowNodeData, NodeType> {
return {
id,
type: "agent",
position,
data: { label: id },
}
}

describe("useBuilderGraphMutations handleNodeDragStop", () => {
const saveToHistory = vi.fn()
const mutateWorkflow = vi.fn()
const safeFetch = vi.fn()
const toast = vi.fn()
const onNodesChange = vi.fn()
const onEdgesChange = vi.fn()

beforeEach(() => {
vi.clearAllMocks()
safeFetch.mockResolvedValue({ ok: true })
})

it("batches multi-select drag stops into one workflow PATCH", async () => {
const { result } = renderHook(() =>
useBuilderGraphMutations({
workflowId: "wf-1",
workflow,
edges: [],
saveToHistory,
mutateWorkflow,
safeFetch,
toast,
screenToFlowPosition: (pos) => pos,
onNodesChange,
onEdgesChange,
}),
)

act(() => {
result.current.handleNodeDragStop(
{} as React.MouseEvent,
dragNode("n1", { x: 41, y: 61 }),
)
result.current.handleNodeDragStop(
{} as React.MouseEvent,
dragNode("n2", { x: 241, y: 61 }),
)
})

await act(async () => {
await Promise.resolve()
})

expect(safeFetch).toHaveBeenCalledTimes(1)
expect(safeFetch).toHaveBeenCalledWith("/api/workflows/wf-1", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
nodes: [
{ ...workflow.nodes[0], position: { x: 40, y: 60 } },
{ ...workflow.nodes[1], position: { x: 240, y: 60 } },
],
}),
})
expect(saveToHistory).toHaveBeenCalledTimes(1)
expect(saveToHistory).toHaveBeenCalledWith(workflow)
expect(mutateWorkflow).toHaveBeenCalledWith("wf-1")
})
})
Loading