fix(frontend/builder): Generate unique IDs for copied blocks (#11344)

## Changes 🏗️

- Clear backend_id when pasting blocks to prevent duplicate ID errors
- Add copy/paste functionality to new FlowEditor
- Ensure pasted blocks use newly generated UUIDs when saving

Fixes issue where copying and pasting blocks would fail with 'Unique
constraint failed' error because the old backend_id was being reused
instead of the new node ID.

## Checklist 📋

### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] Add any block to the builder, save and run the graph and wait for
it to finish, then copy and paste the first block and paste it, try to
use it and it should now work and not have any issues/errors



https://github.com/user-attachments/assets/c24f9a9a-8e4f-4988-8731-cddc34a0da13

---------

Co-authored-by: Reinier van der Leer <pwuts@agpt.co>
This commit is contained in:
Bently
2025-11-13 09:48:49 +00:00
committed by GitHub
parent 749be06599
commit d3d78660da
3 changed files with 149 additions and 6 deletions

View File

@@ -4,7 +4,7 @@ import CustomEdge from "../edges/CustomEdge";
import { useFlow } from "./useFlow";
import { useShallow } from "zustand/react/shallow";
import { useNodeStore } from "../../../stores/nodeStore";
import { useMemo } from "react";
import { useMemo, useEffect } from "react";
import { CustomNode } from "../nodes/CustomNode/CustomNode";
import { useCustomEdge } from "../edges/useCustomEdge";
import { useFlowRealtime } from "./useFlowRealtime";
@@ -12,7 +12,7 @@ import { GraphLoadingBox } from "./components/GraphLoadingBox";
import { BuilderActions } from "../../BuilderActions/BuilderActions";
import { RunningBackground } from "./components/RunningBackground";
import { useGraphStore } from "../../../stores/graphStore";
import { useCopyPasteKeyboard } from "../../../hooks/useCopyPasteKeyboard";
import { useCopyPaste } from "./useCopyPaste";
export const Flow = () => {
const nodes = useNodeStore(useShallow((state) => state.nodes));
@@ -28,7 +28,19 @@ export const Flow = () => {
// This hook is used for websocket realtime updates.
useFlowRealtime();
useCopyPasteKeyboard();
// Copy/paste functionality
const handleCopyPaste = useCopyPaste();
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
handleCopyPaste(event);
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [handleCopyPaste]);
const { isFlowContentLoading } = useFlow();
const { isGraphRunning } = useGraphStore();

View File

@@ -0,0 +1,128 @@
import { useCallback } from "react";
import { Node, Edge, useReactFlow } from "@xyflow/react";
import { Key, storage } from "@/services/storage/local-storage";
import { v4 as uuidv4 } from "uuid";
interface CopyableData {
nodes: Node[];
edges: Edge[];
}
export function useCopyPaste() {
const { setNodes, addEdges, getNodes, getEdges, getViewport } =
useReactFlow();
const handleCopyPaste = useCallback(
(event: KeyboardEvent) => {
// Prevent copy/paste if any modal is open or if the focus is on an input element
const activeElement = document.activeElement;
const isInputField =
activeElement?.tagName === "INPUT" ||
activeElement?.tagName === "TEXTAREA" ||
activeElement?.getAttribute("contenteditable") === "true";
if (isInputField) return;
if (event.ctrlKey || event.metaKey) {
// COPY: Ctrl+C or Cmd+C
if (event.key === "c" || event.key === "C") {
const selectedNodes = getNodes().filter((node) => node.selected);
const selectedNodeIds = new Set(selectedNodes.map((node) => node.id));
// Only copy edges where both source and target nodes are selected
const selectedEdges = getEdges().filter(
(edge) =>
edge.selected &&
selectedNodeIds.has(edge.source) &&
selectedNodeIds.has(edge.target),
);
const copiedData: CopyableData = {
nodes: selectedNodes.map((node) => ({
...node,
data: {
...node.data,
},
})),
edges: selectedEdges,
};
storage.set(Key.COPIED_FLOW_DATA, JSON.stringify(copiedData));
}
// PASTE: Ctrl+V or Cmd+V
if (event.key === "v" || event.key === "V") {
const copiedDataString = storage.get(Key.COPIED_FLOW_DATA);
if (copiedDataString) {
const copiedData = JSON.parse(copiedDataString) as CopyableData;
const oldToNewIdMap: Record<string, string> = {};
// Get fresh viewport values at paste time to ensure correct positioning
const { x, y, zoom } = getViewport();
const viewportCenter = {
x: (window.innerWidth / 2 - x) / zoom,
y: (window.innerHeight / 2 - y) / zoom,
};
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
copiedData.nodes.forEach((node: Node) => {
minX = Math.min(minX, node.position.x);
minY = Math.min(minY, node.position.y);
maxX = Math.max(maxX, node.position.x);
maxY = Math.max(maxY, node.position.y);
});
const offsetX = viewportCenter.x - (minX + maxX) / 2;
const offsetY = viewportCenter.y - (minY + maxY) / 2;
// Create new nodes with UNIQUE IDs using UUID
const pastedNodes = copiedData.nodes.map((node: Node) => {
const newNodeId = uuidv4(); // Generate unique UUID for each node
oldToNewIdMap[node.id] = newNodeId;
return {
...node,
id: newNodeId, // Assign the new unique ID
selected: true, // Select the pasted nodes
position: {
x: node.position.x + offsetX,
y: node.position.y + offsetY,
},
data: {
...node.data,
backend_id: undefined, // Clear backend_id so the new node.id is used when saving
status: undefined, // Clear execution status
nodeExecutionResult: undefined, // Clear execution results
},
};
});
// Create new edges with updated source/target IDs
const pastedEdges = copiedData.edges.map((edge) => {
const newSourceId = oldToNewIdMap[edge.source] ?? edge.source;
const newTargetId = oldToNewIdMap[edge.target] ?? edge.target;
return {
...edge,
id: `${newSourceId}_${edge.sourceHandle}_${newTargetId}_${edge.targetHandle}_${Date.now()}`,
source: newSourceId,
target: newTargetId,
};
});
// Deselect existing nodes and add pasted nodes
setNodes((existingNodes) => [
...existingNodes.map((node) => ({ ...node, selected: false })),
...pastedNodes,
]);
addEdges(pastedEdges);
}
}
}
},
[setNodes, addEdges, getNodes, getEdges, getViewport],
);
return handleCopyPaste;
}

View File

@@ -10,7 +10,6 @@ interface CopyableData {
export function useCopyPaste(getNextNodeId: () => string) {
const { setNodes, addEdges, getNodes, getEdges, getViewport } =
useReactFlow();
const { x, y, zoom } = getViewport();
const handleCopyPaste = useCallback(
(event: KeyboardEvent) => {
@@ -46,6 +45,8 @@ export function useCopyPaste(getNextNodeId: () => string) {
const copiedData = JSON.parse(copiedDataString) as CopyableData;
const oldToNewIdMap: Record<string, string> = {};
// Get fresh viewport values at paste time to ensure correct positioning
const { x, y, zoom } = getViewport();
const viewportCenter = {
x: (window.innerWidth / 2 - x) / zoom,
y: (window.innerHeight / 2 - y) / zoom,
@@ -70,13 +71,15 @@ export function useCopyPaste(getNextNodeId: () => string) {
oldToNewIdMap[node.id] = newNodeId;
return {
...node,
id: newNodeId,
id: newNodeId, // Generate unique ID for the pasted node
selected: true, // Select the pasted nodes so they're visible
position: {
x: node.position.x + offsetX,
y: node.position.y + offsetY,
},
data: {
...node.data,
backend_id: undefined, // Clear backend_id so the new node.id is used when saving
connections: node.data.connections || [], // Preserve connections
status: undefined,
executionResults: undefined,
@@ -129,7 +132,7 @@ export function useCopyPaste(getNextNodeId: () => string) {
}
}
},
[setNodes, addEdges, getNodes, getEdges, getNextNodeId, x, y, zoom],
[setNodes, addEdges, getNodes, getEdges, getNextNodeId, getViewport],
);
return handleCopyPaste;