SKY-11542 Add workflow block duplication (#6885)

Co-authored-by: Suchintan Singh <suchintan@skyvern.com>
This commit is contained in:
Suchintan 2026-06-28 23:02:34 -04:00 committed by GitHub
parent a293dbfae5
commit c755c5543d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1120 additions and 2 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

View file

@ -167,6 +167,7 @@ import { useRecordingStore } from "@/store/useRecordingStore";
import { useIsCanvasLocked } from "./controls/useIsCanvasLocked"; import { useIsCanvasLocked } from "./controls/useIsCanvasLocked";
import { BlockConfigSidebar } from "./panels/BlockConfigSidebar"; import { BlockConfigSidebar } from "./panels/BlockConfigSidebar";
import { STUDIO_COPILOT_TRANSITION_MS } from "../studio/constants"; import { STUDIO_COPILOT_TRANSITION_MS } from "../studio/constants";
import { duplicateBlockBelow } from "./workflowDuplicate";
// Grace period after nodesInitialized before we start tracking changes. // Grace period after nodesInitialized before we start tracking changes.
// Allows mount-time effects (ResizeObserver, visibility toggling) to settle. // Allows mount-time effects (ResizeObserver, visibility toggling) to settle.
@ -1099,6 +1100,31 @@ function FlowRenderer({
[onRequestDeleteNode, readOnly], [onRequestDeleteNode, readOnly],
); );
function duplicateNode(id: string) {
const result = duplicateBlockBelow({
nodes,
edges,
nodeId: id,
generateId: nanoid,
generateLabel: generateNodeLabel,
});
if (!result) {
return;
}
workflowChangesStore.setHasChanges(true);
postHog.capture("builder.block.duplicated", {
org_id: workflow.organization_id,
position: result.position,
source_block_id: id,
});
doLayout(result.nodes, result.edges);
useWorkflowPanelStore
.getState()
.setSelectedBlockId(result.duplicatedNodeId);
}
function transmuteNode(id: string, nodeType: string) { function transmuteNode(id: string, nodeType: string) {
const nodeToTransmute = nodes.find((node) => node.id === id); const nodeToTransmute = nodes.find((node) => node.id === id);
@ -1818,6 +1844,9 @@ function FlowRenderer({
// and flip `setHasChanges(true)` on the main editor's store // and flip `setHasChanges(true)` on the main editor's store
// while the user is only inspecting versions. // while the user is only inspecting versions.
requestDeleteNodeCallback: readOnly ? () => {} : requestDeleteNode, requestDeleteNodeCallback: readOnly ? () => {} : requestDeleteNode,
duplicateNodeCallback: readOnly
? () => {}
: (id: string) => setTimeout(() => duplicateNode(id), 0),
// setTimeout(..., 0) escapes the Radix dropdown's pointer-event // setTimeout(..., 0) escapes the Radix dropdown's pointer-event
// lockout: a synchronous mutation inside the menu's onSelect // lockout: a synchronous mutation inside the menu's onSelect
// races the menu's close animation and re-renders nodes while // races the menu's close animation and re-renders nodes while

View file

@ -17,20 +17,26 @@ import {
import { useRecordingStore } from "@/store/useRecordingStore"; import { useRecordingStore } from "@/store/useRecordingStore";
type Props = { type Props = {
duplicateDisabledReason?: string | null;
isDeletable?: boolean; isDeletable?: boolean;
isDuplicable?: boolean;
isScriptable?: boolean; isScriptable?: boolean;
isCanvasLocked?: boolean; isCanvasLocked?: boolean;
showScriptText?: string; showScriptText?: string;
onDelete?: () => void; onDelete?: () => void;
onDuplicate?: () => void;
onShowScript?: () => void; onShowScript?: () => void;
}; };
function NodeActionMenu({ function NodeActionMenu({
duplicateDisabledReason = null,
isDeletable = true, isDeletable = true,
isDuplicable = true,
isScriptable = false, isScriptable = false,
isCanvasLocked = false, isCanvasLocked = false,
showScriptText, showScriptText,
onDelete, onDelete,
onDuplicate,
onShowScript, onShowScript,
}: Props) { }: Props) {
const recordingStore = useRecordingStore(); const recordingStore = useRecordingStore();
@ -41,11 +47,34 @@ function NodeActionMenu({
: isCanvasLocked : isCanvasLocked
? "Unlock canvas to delete blocks" ? "Unlock canvas to delete blocks"
: null; : null;
const duplicateGated =
isRecording || isCanvasLocked || Boolean(duplicateDisabledReason);
const duplicateGateReason = isRecording
? "Stop recording to duplicate blocks"
: isCanvasLocked
? "Unlock canvas to duplicate blocks"
: duplicateDisabledReason;
if (!isDeletable && !isScriptable) { if (!isDeletable && !isDuplicable && !isScriptable) {
return null; return null;
} }
const duplicateItem =
isDuplicable && onDuplicate ? (
<DropdownMenuItem
disabled={duplicateGated}
onSelect={(event) => {
if (duplicateGated) {
event.preventDefault();
return;
}
onDuplicate();
}}
>
Duplicate Block
</DropdownMenuItem>
) : null;
const deleteItem = isDeletable ? ( const deleteItem = isDeletable ? (
<DropdownMenuItem <DropdownMenuItem
disabled={deleteGated} disabled={deleteGated}
@ -66,9 +95,21 @@ function NodeActionMenu({
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<DotsHorizontalIcon className="h-6 w-6 cursor-pointer" /> <DotsHorizontalIcon className="h-6 w-6 cursor-pointer" />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent align="end" collisionPadding={8}>
<DropdownMenuLabel>Block Actions</DropdownMenuLabel> <DropdownMenuLabel>Block Actions</DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{duplicateItem && duplicateGated && duplicateGateReason ? (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<span className="block">{duplicateItem}</span>
</TooltipTrigger>
<TooltipContent side="left">{duplicateGateReason}</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
duplicateItem
)}
{deleteItem && deleteGated && deleteGateReason ? ( {deleteItem && deleteGated && deleteGateReason ? (
<TooltipProvider delayDuration={200}> <TooltipProvider delayDuration={200}>
<Tooltip> <Tooltip>

View file

@ -27,6 +27,7 @@ import { useOnChange } from "@/hooks/useOnChange";
import { useAutoplayStore } from "@/store/useAutoplayStore"; import { useAutoplayStore } from "@/store/useAutoplayStore";
import { useNodeLabelChangeHandler } from "@/routes/workflows/hooks/useLabelChangeHandler"; import { useNodeLabelChangeHandler } from "@/routes/workflows/hooks/useLabelChangeHandler";
import { useDuplicateNodeCallback } from "@/routes/workflows/hooks/useDuplicateNodeCallback";
import { useRequestDeleteNodeCallback } from "@/routes/workflows/hooks/useRequestDeleteNodeCallback"; import { useRequestDeleteNodeCallback } from "@/routes/workflows/hooks/useRequestDeleteNodeCallback";
import { useTransmuteNodeCallback } from "@/routes/workflows/hooks/useTransmuteNodeCallback"; import { useTransmuteNodeCallback } from "@/routes/workflows/hooks/useTransmuteNodeCallback";
import { useToggleScriptForNodeCallback } from "@/routes/workflows/hooks/useToggleScriptForNodeCallback"; import { useToggleScriptForNodeCallback } from "@/routes/workflows/hooks/useToggleScriptForNodeCallback";
@ -259,6 +260,7 @@ function NodeHeader({
initialValue: blockLabel, initialValue: blockLabel,
}); });
const blockTitle = blockTitleOverride ?? workflowBlockTitle[type]; const blockTitle = blockTitleOverride ?? workflowBlockTitle[type];
const duplicateNodeCallback = useDuplicateNodeCallback();
const requestDeleteNodeCallback = useRequestDeleteNodeCallback(); const requestDeleteNodeCallback = useRequestDeleteNodeCallback();
const transmuteNodeCallback = useTransmuteNodeCallback(); const transmuteNodeCallback = useTransmuteNodeCallback();
const toggleScriptForNodeCallback = useToggleScriptForNodeCallback(); const toggleScriptForNodeCallback = useToggleScriptForNodeCallback();
@ -817,6 +819,12 @@ function NodeHeader({
const isReadOnlyScope = useWorkflowScopeReadOnly(); const isReadOnlyScope = useWorkflowScopeReadOnly();
const isCanvasLocked = useIsCanvasLocked(); const isCanvasLocked = useIsCanvasLocked();
const dragGatedByMode = isDragGatedByMode({ isRecording, isCanvasLocked }); const dragGatedByMode = isDragGatedByMode({ isRecording, isCanvasLocked });
const duplicateDisabledReason = isBlockFinallyGated(
blockLabel,
workflowSettingsStore.finallyBlockLabel,
)
? "Finally block must run last"
: null;
// Read-only canvases (compare/diff) drop the grip entirely - the handle // Read-only canvases (compare/diff) drop the grip entirely - the handle
// is inert there, so a faded button is just visual noise. // is inert there, so a faded button is just visual noise.
@ -1045,8 +1053,19 @@ function NodeHeader({
})} })}
> >
<NodeActionMenu <NodeActionMenu
duplicateDisabledReason={duplicateDisabledReason}
isDuplicable={
!isReadOnlyScope && Boolean(duplicateNodeCallback)
}
isScriptable={isScriptable} isScriptable={isScriptable}
isCanvasLocked={isCanvasLocked} isCanvasLocked={isCanvasLocked}
onDuplicate={
isReadOnlyScope || !duplicateNodeCallback
? undefined
: () => {
duplicateNodeCallback(nodeId);
}
}
onDelete={() => { onDelete={() => {
requestDeleteNodeCallback(nodeId, blockLabel); requestDeleteNodeCallback(nodeId, blockLabel);
}} }}

View file

@ -0,0 +1,562 @@
import type { Edge } from "@xyflow/react";
import { describe, expect, it } from "vitest";
import type { AppNode } from "./nodes";
import { duplicateBlockBelow } from "./workflowDuplicate";
function idGenerator(prefix = "new") {
let count = 0;
return () => `${prefix}-${++count}`;
}
function labelGenerator(existingLabels: Array<string>) {
const existing = new Set(existingLabels);
let index = 1;
while (existing.has(`block_${index}`)) {
index += 1;
}
return `block_${index}`;
}
function block(
id: string,
label: string,
overrides: Omit<Partial<AppNode>, "data"> & {
data?: Record<string, unknown>;
} = {},
): AppNode {
return {
id,
type: "navigation",
position: { x: 0, y: 0 },
data: {
debuggable: true,
editable: true,
label,
continueOnFailure: false,
model: null,
parameterKeys: [],
prompt: "",
url: "",
navigationGoal: "",
errorCodeMapping: "null",
allowDownloads: false,
downloadSuffix: null,
maxRetries: null,
maxStepsOverride: null,
totpIdentifier: null,
totpVerificationUrl: null,
disableCache: false,
completeCriterion: "",
terminateCriterion: "",
engine: null,
legacyV2Available: false,
includeActionHistoryInVerification: false,
maxSteps: 10,
...((overrides.data as Record<string, unknown> | undefined) ?? {}),
},
...overrides,
} as AppNode;
}
function edge(
id: string,
source: string,
target: string,
overrides: Partial<Edge> = {},
): Edge {
return {
id,
source,
target,
type: "edgeWithAddButton",
...overrides,
};
}
describe("duplicateBlockBelow", () => {
it("copies a block below itself and rewires the execution edge", () => {
const nodes = [block("a", "block_1"), block("b", "block_2")];
const edges = [edge("a-b", "a", "b")];
const result = duplicateBlockBelow({
nodes,
edges,
nodeId: "a",
generateId: idGenerator(),
generateLabel: labelGenerator,
});
expect(result?.duplicatedNodeId).toBe("new-1");
expect(result?.duplicatedLabel).toBe("block_3");
expect(result?.nodes.map((node) => node.id)).toEqual(["a", "new-1", "b"]);
expect(result?.nodes.find((node) => node.id === "new-1")?.data).toEqual(
expect.objectContaining({
label: "block_3",
navigationGoal: "",
}),
);
expect(result?.edges).toEqual(
expect.arrayContaining([
expect.objectContaining({ source: "a", target: "new-1" }),
expect.objectContaining({ source: "new-1", target: "b" }),
]),
);
expect(result?.edges.some((candidate) => candidate.id === "a-b")).toBe(
false,
);
});
it("preserves inactive conditional branch edges when duplicating within the active branch", () => {
const nodes = [
block("conditional", "block_1", {
type: "conditional",
data: {
debuggable: true,
editable: true,
label: "block_1",
continueOnFailure: false,
model: null,
branches: [
{
id: "branch-a",
criteria: null,
description: null,
is_default: false,
next_block_label: null,
},
{
id: "branch-b",
criteria: null,
description: null,
is_default: true,
next_block_label: null,
},
],
activeBranchId: "branch-a",
mergeLabel: null,
},
}),
block("a", "block_2", {
parentId: "conditional",
data: {
debuggable: true,
editable: true,
label: "block_2",
continueOnFailure: false,
model: null,
conditionalBranchId: "branch-a",
conditionalLabel: "block_1",
conditionalMergeLabel: null,
conditionalNodeId: "conditional",
},
}),
block("next", "block_3", { parentId: "conditional" }),
];
const edges = [
edge("a-next-active", "a", "next", {
data: {
conditionalBranchId: "branch-a",
conditionalNodeId: "conditional",
},
}),
edge("a-next-inactive", "a", "next", {
data: {
conditionalBranchId: "branch-b",
conditionalNodeId: "conditional",
},
}),
];
const result = duplicateBlockBelow({
nodes,
edges,
nodeId: "a",
generateId: idGenerator(),
generateLabel: labelGenerator,
});
expect(result?.edges).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: "a-next-inactive" }),
expect.objectContaining({
source: "a",
target: "new-1",
data: {
conditionalBranchId: "branch-a",
conditionalNodeId: "conditional",
},
}),
expect.objectContaining({
source: "new-1",
target: "next",
data: {
conditionalBranchId: "branch-a",
conditionalNodeId: "conditional",
},
}),
]),
);
expect(result?.edges.find((edge) => edge.source === "a")).toEqual(
expect.objectContaining({
target: "new-1",
data: {
conditionalBranchId: "branch-a",
conditionalNodeId: "conditional",
},
}),
);
expect(
result?.edges.some((candidate) => candidate.id === "a-next-active"),
).toBe(false);
});
it("duplicates container descendants, remaps branch ids, and rewrites internal output references", () => {
const nodes = [
block("conditional", "block_1", {
type: "conditional",
data: {
debuggable: true,
editable: true,
label: "block_1",
continueOnFailure: false,
model: null,
branches: [
{
id: "branch-a",
criteria: null,
description: null,
is_default: false,
next_block_label: null,
},
{
id: "branch-default",
criteria: null,
description: null,
is_default: true,
next_block_label: null,
},
],
activeBranchId: "branch-a",
mergeLabel: null,
},
}),
{
id: "start",
type: "start",
parentId: "conditional",
position: { x: 0, y: 0 },
data: {
withWorkflowSettings: false,
editable: true,
label: "__start_block__",
showCode: false,
parentNodeType: "conditional",
},
} as AppNode,
block("child", "block_2", {
hidden: false,
parentId: "conditional",
data: {
debuggable: true,
editable: true,
label: "block_2",
continueOnFailure: false,
model: null,
conditionalBranchId: "branch-a",
conditionalLabel: "block_1",
conditionalMergeLabel: null,
conditionalNodeId: "conditional",
navigationGoal: "Use {{ block_1_output }}",
parameterKeys: ["block_1_output"],
},
}),
{
id: "adder",
type: "nodeAdder",
parentId: "conditional",
position: { x: 0, y: 0 },
data: {},
} as AppNode,
block("after", "block_3"),
];
const edges = [
edge("conditional-after", "conditional", "after"),
edge("start-child", "start", "child", {
data: {
conditionalBranchId: "branch-a",
conditionalNodeId: "conditional",
},
}),
edge("child-adder", "child", "adder", {
type: "default",
data: {
conditionalBranchId: "branch-a",
conditionalNodeId: "conditional",
},
}),
];
const result = duplicateBlockBelow({
nodes,
edges,
nodeId: "conditional",
generateId: idGenerator(),
generateLabel: labelGenerator,
});
const clonedConditional = result?.nodes.find((node) => node.id === "new-1");
const clonedStart = result?.nodes.find((node) => node.id === "new-2");
const clonedChild = result?.nodes.find((node) => node.id === "new-3");
const clonedAdder = result?.nodes.find((node) => node.id === "new-4");
expect(clonedConditional?.data.label).toBe("block_4");
expect(clonedStart?.parentId).toBe("new-1");
expect(clonedChild?.parentId).toBe("new-1");
expect(clonedChild?.hidden).toBe(false);
expect(clonedAdder?.parentId).toBe("new-1");
expect(clonedChild?.data).toEqual(
expect.objectContaining({
conditionalBranchId: "new-5",
conditionalLabel: "block_4",
conditionalNodeId: "new-1",
label: "block_5",
navigationGoal: "Use {{ block_4_output }}",
parameterKeys: ["block_4_output"],
}),
);
expect(clonedConditional?.data).toEqual(
expect.objectContaining({
activeBranchId: "new-5",
branches: expect.arrayContaining([
expect.objectContaining({ id: "new-5" }),
expect.objectContaining({ id: "new-6" }),
]),
}),
);
expect(result?.edges).toEqual(
expect.arrayContaining([
expect.objectContaining({ source: "conditional", target: "new-1" }),
expect.objectContaining({ source: "new-1", target: "after" }),
expect.objectContaining({
source: "new-2",
target: "new-3",
data: {
conditionalBranchId: "new-5",
conditionalNodeId: "new-1",
},
}),
expect.objectContaining({
source: "new-3",
target: "new-4",
data: {
conditionalBranchId: "new-5",
conditionalNodeId: "new-1",
},
}),
]),
);
});
it("rewrites cloned code block references to cloned descendant outputs", () => {
const nodes = [
block("conditional", "block_1", {
type: "conditional",
data: {
debuggable: true,
editable: true,
label: "block_1",
continueOnFailure: false,
model: null,
branches: [
{
id: "branch-a",
criteria: null,
description: null,
is_default: true,
next_block_label: null,
},
],
activeBranchId: "branch-a",
mergeLabel: null,
},
}),
block("dependency", "block_2", {
parentId: "conditional",
data: {
debuggable: true,
editable: true,
label: "block_2",
continueOnFailure: false,
model: null,
conditionalBranchId: "branch-a",
conditionalLabel: "block_1",
conditionalMergeLabel: null,
conditionalNodeId: "conditional",
},
}),
block("code", "block_3", {
type: "codeBlock",
parentId: "conditional",
data: {
debuggable: true,
editable: true,
label: "block_3",
continueOnFailure: false,
model: null,
conditionalBranchId: "branch-a",
conditionalLabel: "block_1",
conditionalMergeLabel: null,
conditionalNodeId: "conditional",
code:
"result = block_2_output\n" +
"other = block_20_output\n" +
"prefixed = prefix_block_2_output\n" +
"nested = data['block_2_output']",
prompt: "Use block_2_output and {{ block_2_output }}",
parameterKeys: ["block_2_output"],
},
}),
];
const edges = [
edge("conditional-dependency", "conditional", "dependency"),
edge("dependency-code", "dependency", "code"),
];
const result = duplicateBlockBelow({
nodes,
edges,
nodeId: "conditional",
generateId: idGenerator(),
generateLabel: labelGenerator,
});
const clonedCode = result?.nodes.find((node) => node.id === "new-3");
expect(clonedCode?.data).toEqual(
expect.objectContaining({
code:
"result = block_5_output\n" +
"other = block_20_output\n" +
"prefixed = prefix_block_2_output\n" +
"nested = data['block_5_output']",
label: "block_6",
parameterKeys: ["block_5_output"],
prompt: "Use block_5_output and {{ block_5_output }}",
}),
);
});
it("preserves inactive conditional branch visibility in cloned descendants", () => {
const nodes = [
block("conditional", "block_1", {
type: "conditional",
data: {
debuggable: true,
editable: true,
label: "block_1",
continueOnFailure: false,
model: null,
branches: [
{
id: "branch-a",
criteria: null,
description: null,
is_default: false,
next_block_label: null,
},
{
id: "branch-b",
criteria: null,
description: null,
is_default: true,
next_block_label: null,
},
],
activeBranchId: "branch-a",
mergeLabel: null,
},
}),
block("active-child", "block_2", {
hidden: false,
parentId: "conditional",
data: {
debuggable: true,
editable: true,
label: "block_2",
continueOnFailure: false,
model: null,
conditionalBranchId: "branch-a",
conditionalLabel: "block_1",
conditionalMergeLabel: null,
conditionalNodeId: "conditional",
},
}),
block("inactive-child", "block_3", {
hidden: true,
parentId: "conditional",
data: {
debuggable: true,
editable: true,
label: "block_3",
continueOnFailure: false,
model: null,
conditionalBranchId: "branch-b",
conditionalLabel: "block_1",
conditionalMergeLabel: null,
conditionalNodeId: "conditional",
},
}),
block("after", "block_4"),
];
const edges = [
edge("conditional-after", "conditional", "after"),
edge("conditional-active", "conditional", "active-child", {
hidden: false,
data: {
conditionalBranchId: "branch-a",
conditionalNodeId: "conditional",
},
}),
edge("conditional-inactive", "conditional", "inactive-child", {
hidden: true,
data: {
conditionalBranchId: "branch-b",
conditionalNodeId: "conditional",
},
}),
];
const result = duplicateBlockBelow({
nodes,
edges,
nodeId: "conditional",
generateId: idGenerator(),
generateLabel: labelGenerator,
});
const clonedActive = result?.nodes.find((node) => node.id === "new-2");
const clonedInactive = result?.nodes.find((node) => node.id === "new-3");
const clonedInactiveEdge = result?.edges.find(
(edge) => edge.source === "new-1" && edge.target === "new-3",
);
expect(clonedActive?.hidden).toBe(false);
expect(clonedInactive?.hidden).toBe(true);
expect(clonedInactive?.data).toEqual(
expect.objectContaining({
conditionalBranchId: "new-5",
conditionalNodeId: "new-1",
}),
);
expect(clonedInactiveEdge).toEqual(
expect.objectContaining({
hidden: true,
data: expect.objectContaining({
conditionalBranchId: "new-5",
conditionalNodeId: "new-1",
}),
}),
);
});
});

View file

@ -0,0 +1,457 @@
import type { Edge } from "@xyflow/react";
import type { BranchContext } from "@/store/WorkflowPanelStore";
import { replaceJinjaReference } from "./jinjaReferences";
import type { AppNode, WorkflowBlockNode } from "./nodes";
import type { NodeBaseData } from "./nodes/types";
import { shouldKeepExistingEdgeForInsertion } from "./workflowInsertion";
type GenerateId = () => string;
type GenerateLabel = (existingLabels: Array<string>) => string;
type DuplicateBlockBelowOptions = {
nodes: Array<AppNode>;
edges: Array<Edge>;
nodeId: string;
generateId: GenerateId;
generateLabel: GenerateLabel;
};
type DuplicateBlockBelowResult = {
nodes: Array<AppNode>;
edges: Array<Edge>;
duplicatedNodeId: string;
duplicatedLabel: string;
position: number;
};
type BranchConditionLike = {
id: string;
};
type ConditionalDataLike = NodeBaseData & {
activeBranchId: string | null;
branches: Array<BranchConditionLike>;
mergeLabel?: string | null;
};
const SKIP_KEYS_FOR_REFERENCE_REWRITE = new Set([
"id",
"key",
"label",
"nodeId",
"parameterKeys",
"type",
]);
function clonePlain<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function isWorkflowBlockNodeLike(node: AppNode): node is WorkflowBlockNode {
return node.type !== "nodeAdder" && node.type !== "start";
}
function outputKey(label: string): string {
return `${label}_output`;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function branchKey(conditionalNodeId: string, branchId: string): string {
return `${conditionalNodeId}:${branchId}`;
}
function hasAncestor(
nodesById: Map<string, AppNode>,
node: AppNode,
ancestorId: string,
): boolean {
let currentParentId = node.parentId;
const visited = new Set<string>();
while (currentParentId && !visited.has(currentParentId)) {
if (currentParentId === ancestorId) {
return true;
}
visited.add(currentParentId);
currentParentId = nodesById.get(currentParentId)?.parentId;
}
return false;
}
function rewriteStringReferences(
value: string,
outputKeyMap: Map<string, string>,
): string {
let next = value;
outputKeyMap.forEach((newKey, oldKey) => {
next = replaceJinjaReference(next, oldKey, newKey);
// Code blocks receive upstream outputs as plain identifiers, while some
// serialized config stores the same output keys inside quoted strings.
next = next.replace(
new RegExp(
`(^|[^A-Za-z0-9_])${escapeRegExp(oldKey)}(?=$|[^A-Za-z0-9_])`,
"g",
),
(_match, prefix: string) => `${prefix}${newKey}`,
);
});
return next;
}
function rewriteReferencesInValue<T>(
value: T,
outputKeyMap: Map<string, string>,
): T {
if (typeof value === "string") {
return rewriteStringReferences(value, outputKeyMap) as T;
}
if (value === null || value === undefined) {
return value;
}
if (Array.isArray(value)) {
return value.map((item) =>
rewriteReferencesInValue(item, outputKeyMap),
) as T;
}
if (typeof value !== "object") {
return value;
}
const result: Record<string, unknown> = {};
Object.entries(value).forEach(([entryKey, entryValue]) => {
result[entryKey] = SKIP_KEYS_FOR_REFERENCE_REWRITE.has(entryKey)
? entryValue
: rewriteReferencesInValue(entryValue, outputKeyMap);
});
return result as T;
}
function rewriteOutputReferencesInData<T extends NodeBaseData>(
data: T,
outputKeyMap: Map<string, string>,
): T {
const rewritten = rewriteReferencesInValue(data, outputKeyMap) as T;
const mutable = rewritten as T & {
loopVariableReference?: string;
parameterKeys?: Array<string> | null;
};
if (Array.isArray(mutable.parameterKeys)) {
mutable.parameterKeys = mutable.parameterKeys.map(
(key) => outputKeyMap.get(key) ?? key,
);
}
if (mutable.loopVariableReference) {
mutable.loopVariableReference =
outputKeyMap.get(mutable.loopVariableReference) ??
mutable.loopVariableReference;
}
return mutable;
}
function isConditionalData(data: NodeBaseData): data is ConditionalDataLike {
const candidate = data as Partial<ConditionalDataLike>;
return Array.isArray(candidate.branches);
}
function getBranchContextForNode(
node: WorkflowBlockNode,
): BranchContext | undefined {
const data = node.data;
if (!data.conditionalNodeId || !data.conditionalBranchId) {
return undefined;
}
return {
conditionalNodeId: data.conditionalNodeId,
conditionalLabel: data.conditionalLabel ?? data.conditionalNodeId,
branchId: data.conditionalBranchId,
mergeLabel: data.conditionalMergeLabel ?? null,
};
}
function cloneEdgeData(
data: Edge["data"],
idMap: Map<string, string>,
branchIdMap: Map<string, string>,
): Edge["data"] {
if (!data || typeof data !== "object") {
return data;
}
const cloned = clonePlain(data) as Record<string, unknown>;
const conditionalNodeId = cloned.conditionalNodeId;
const conditionalBranchId = cloned.conditionalBranchId;
if (typeof conditionalNodeId === "string" && idMap.has(conditionalNodeId)) {
cloned.conditionalNodeId = idMap.get(conditionalNodeId);
if (typeof conditionalBranchId === "string") {
cloned.conditionalBranchId =
branchIdMap.get(branchKey(conditionalNodeId, conditionalBranchId)) ??
conditionalBranchId;
}
}
return cloned;
}
function makeEdge({
branch,
generateId,
source,
target,
type,
}: {
branch?: BranchContext;
generateId: GenerateId;
source: string;
target: string;
type: string;
}): Edge {
return {
id: generateId(),
type,
source,
target,
style: { strokeWidth: 2 },
data: branch
? {
conditionalBranchId: branch.branchId,
conditionalNodeId: branch.conditionalNodeId,
}
: undefined,
};
}
function findInsertionEdge(
node: WorkflowBlockNode,
edges: Array<Edge>,
subtreeIds: Set<string>,
): Edge | undefined {
const outgoingEdges = edges.filter((edge) => edge.source === node.id);
if (outgoingEdges.length === 0) {
return undefined;
}
const branch = getBranchContextForNode(node);
if (branch) {
const branchEdge = outgoingEdges.find((edge) => {
const edgeData = edge.data as
| { conditionalBranchId?: string; conditionalNodeId?: string }
| undefined;
return (
edgeData?.conditionalBranchId === branch.branchId &&
edgeData?.conditionalNodeId === branch.conditionalNodeId
);
});
if (branchEdge) {
return branchEdge;
}
}
return (
outgoingEdges.find((edge) => !subtreeIds.has(edge.target)) ??
outgoingEdges[0]
);
}
export function duplicateBlockBelow({
nodes,
edges,
nodeId,
generateId,
generateLabel,
}: DuplicateBlockBelowOptions): DuplicateBlockBelowResult | null {
const sourceNode = nodes.find((node) => node.id === nodeId);
if (!sourceNode || !isWorkflowBlockNodeLike(sourceNode)) {
return null;
}
const nodesById = new Map(nodes.map((node) => [node.id, node]));
const subtreeNodes = nodes.filter(
(node) => node.id === nodeId || hasAncestor(nodesById, node, nodeId),
);
const subtreeIds = new Set(subtreeNodes.map((node) => node.id));
const idMap = new Map<string, string>();
subtreeNodes.forEach((node) => {
idMap.set(node.id, generateId());
});
const existingLabels = nodes
.filter(isWorkflowBlockNodeLike)
.map((node) => node.data.label);
const labelMap = new Map<string, string>();
subtreeNodes.filter(isWorkflowBlockNodeLike).forEach((node) => {
const newLabel = generateLabel(existingLabels);
existingLabels.push(newLabel);
labelMap.set(node.data.label, newLabel);
});
const outputKeyMap = new Map<string, string>();
labelMap.forEach((newLabel, oldLabel) => {
outputKeyMap.set(outputKey(oldLabel), outputKey(newLabel));
});
const branchIdMap = new Map<string, string>();
subtreeNodes.filter(isWorkflowBlockNodeLike).forEach((node) => {
if (!isConditionalData(node.data)) {
return;
}
node.data.branches.forEach((branch) => {
branchIdMap.set(branchKey(node.id, branch.id), generateId());
});
});
const clonedNodes = subtreeNodes.map((node): AppNode => {
const cloned = clonePlain(node);
cloned.id = idMap.get(node.id)!;
cloned.position = { x: 0, y: 0 };
cloned.selected = false;
if (node.parentId) {
cloned.parentId = idMap.get(node.parentId) ?? node.parentId;
}
if (!isWorkflowBlockNodeLike(node) || !isWorkflowBlockNodeLike(cloned)) {
return cloned;
}
const clonedData = rewriteOutputReferencesInData(
clonePlain(node.data),
outputKeyMap,
);
clonedData.label = labelMap.get(node.data.label)!;
if (
clonedData.conditionalNodeId &&
idMap.has(clonedData.conditionalNodeId)
) {
const oldConditionalNodeId = clonedData.conditionalNodeId;
const oldConditionalNode = nodesById.get(oldConditionalNodeId);
clonedData.conditionalNodeId = idMap.get(oldConditionalNodeId)!;
if (oldConditionalNode && isWorkflowBlockNodeLike(oldConditionalNode)) {
clonedData.conditionalLabel =
labelMap.get(oldConditionalNode.data.label) ??
clonedData.conditionalLabel;
}
if (clonedData.conditionalBranchId) {
clonedData.conditionalBranchId =
branchIdMap.get(
branchKey(oldConditionalNodeId, clonedData.conditionalBranchId),
) ?? clonedData.conditionalBranchId;
}
}
if (
clonedData.conditionalMergeLabel &&
labelMap.has(clonedData.conditionalMergeLabel)
) {
clonedData.conditionalMergeLabel = labelMap.get(
clonedData.conditionalMergeLabel,
)!;
}
if (isConditionalData(clonedData)) {
const oldActiveBranchId = clonedData.activeBranchId;
clonedData.branches = clonedData.branches.map((branch) => ({
...branch,
id: branchIdMap.get(branchKey(node.id, branch.id)) ?? branch.id,
}));
clonedData.activeBranchId = oldActiveBranchId
? (branchIdMap.get(branchKey(node.id, oldActiveBranchId)) ??
oldActiveBranchId)
: oldActiveBranchId;
if (clonedData.mergeLabel && labelMap.has(clonedData.mergeLabel)) {
clonedData.mergeLabel = labelMap.get(clonedData.mergeLabel)!;
}
}
cloned.data = clonedData;
return cloned;
});
const internalEdges = edges
.filter(
(edge) => subtreeIds.has(edge.source) && subtreeIds.has(edge.target),
)
.map((edge): Edge => {
return {
...clonePlain(edge),
id: generateId(),
source: idMap.get(edge.source)!,
target: idMap.get(edge.target)!,
data: cloneEdgeData(edge.data, idMap, branchIdMap),
};
});
const insertionEdge = findInsertionEdge(sourceNode, edges, subtreeIds);
const next = insertionEdge?.target ?? null;
const branch = getBranchContextForNode(sourceNode);
const editedEdges = edges.filter((edge) =>
shouldKeepExistingEdgeForInsertion(edge, {
branch,
next,
previous: sourceNode.id,
}),
);
const duplicatedNodeId = idMap.get(sourceNode.id)!;
const insertionEdges = [
makeEdge({
branch,
generateId,
source: sourceNode.id,
target: duplicatedNodeId,
type: "edgeWithAddButton",
}),
];
if (next) {
insertionEdges.push(
makeEdge({
branch,
generateId,
source: duplicatedNodeId,
target: next,
type: insertionEdge?.type ?? "default",
}),
);
}
const sourceSubtreeIndexes = subtreeNodes.map((node) => nodes.indexOf(node));
const insertIndex = Math.max(...sourceSubtreeIndexes) + 1;
const nextNodes = [
...nodes.slice(0, insertIndex),
...clonedNodes,
...nodes.slice(insertIndex),
];
return {
nodes: nextNodes,
edges: [
...editedEdges.filter((edge) => edge.source !== sourceNode.id),
// Keep the inserted bridge as the first source-owned outgoing edge;
// branch-specific source edges are preserved immediately after it.
...insertionEdges,
...editedEdges.filter((edge) => edge.source === sourceNode.id),
...internalEdges,
],
duplicatedNodeId,
duplicatedLabel: labelMap.get(sourceNode.data.label)!,
position: insertIndex,
};
}

View file

@ -0,0 +1,8 @@
import { BlockActionContext } from "@/store/BlockActionContext";
import { useContext } from "react";
function useDuplicateNodeCallback() {
return useContext(BlockActionContext)?.duplicateNodeCallback;
}
export { useDuplicateNodeCallback };

View file

@ -1,6 +1,7 @@
import { createContext } from "react"; import { createContext } from "react";
type RequestDeleteNodeCallback = (id: string, label: string) => void; type RequestDeleteNodeCallback = (id: string, label: string) => void;
type DuplicateNodeCallback = (id: string) => void;
type TransmuteNodeCallback = (id: string, nodeName: string) => void; type TransmuteNodeCallback = (id: string, nodeName: string) => void;
type ToggleScriptForNodeCallback = (opts: { type ToggleScriptForNodeCallback = (opts: {
id?: string; id?: string;
@ -11,6 +12,7 @@ type ToggleScriptForNodeCallback = (opts: {
const BlockActionContext = createContext< const BlockActionContext = createContext<
| { | {
requestDeleteNodeCallback: RequestDeleteNodeCallback; requestDeleteNodeCallback: RequestDeleteNodeCallback;
duplicateNodeCallback: DuplicateNodeCallback;
transmuteNodeCallback: TransmuteNodeCallback; transmuteNodeCallback: TransmuteNodeCallback;
toggleScriptForNodeCallback?: ToggleScriptForNodeCallback; toggleScriptForNodeCallback?: ToggleScriptForNodeCallback;
} }