mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-07-09 16:09:13 +00:00
SKY-11542 Add workflow block duplication (#6885)
Co-authored-by: Suchintan Singh <suchintan@skyvern.com>
This commit is contained in:
parent
a293dbfae5
commit
c755c5543d
11 changed files with 1120 additions and 2 deletions
BIN
docs/pr-screenshots/duplicate-block-menu-desktop.png
Normal file
BIN
docs/pr-screenshots/duplicate-block-menu-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
docs/pr-screenshots/duplicate-block-menu-mobile.png
Normal file
BIN
docs/pr-screenshots/duplicate-block-menu-mobile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
docs/pr-screenshots/oss-duplicate-menu.png
Normal file
BIN
docs/pr-screenshots/oss-duplicate-menu.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 115 KiB |
BIN
docs/pr-screenshots/oss-duplicate-result.png
Normal file
BIN
docs/pr-screenshots/oss-duplicate-result.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
|
|
@ -167,6 +167,7 @@ import { useRecordingStore } from "@/store/useRecordingStore";
|
|||
import { useIsCanvasLocked } from "./controls/useIsCanvasLocked";
|
||||
import { BlockConfigSidebar } from "./panels/BlockConfigSidebar";
|
||||
import { STUDIO_COPILOT_TRANSITION_MS } from "../studio/constants";
|
||||
import { duplicateBlockBelow } from "./workflowDuplicate";
|
||||
|
||||
// Grace period after nodesInitialized before we start tracking changes.
|
||||
// Allows mount-time effects (ResizeObserver, visibility toggling) to settle.
|
||||
|
|
@ -1099,6 +1100,31 @@ function FlowRenderer({
|
|||
[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) {
|
||||
const nodeToTransmute = nodes.find((node) => node.id === id);
|
||||
|
||||
|
|
@ -1818,6 +1844,9 @@ function FlowRenderer({
|
|||
// and flip `setHasChanges(true)` on the main editor's store
|
||||
// while the user is only inspecting versions.
|
||||
requestDeleteNodeCallback: readOnly ? () => {} : requestDeleteNode,
|
||||
duplicateNodeCallback: readOnly
|
||||
? () => {}
|
||||
: (id: string) => setTimeout(() => duplicateNode(id), 0),
|
||||
// setTimeout(..., 0) escapes the Radix dropdown's pointer-event
|
||||
// lockout: a synchronous mutation inside the menu's onSelect
|
||||
// races the menu's close animation and re-renders nodes while
|
||||
|
|
|
|||
|
|
@ -17,20 +17,26 @@ import {
|
|||
import { useRecordingStore } from "@/store/useRecordingStore";
|
||||
|
||||
type Props = {
|
||||
duplicateDisabledReason?: string | null;
|
||||
isDeletable?: boolean;
|
||||
isDuplicable?: boolean;
|
||||
isScriptable?: boolean;
|
||||
isCanvasLocked?: boolean;
|
||||
showScriptText?: string;
|
||||
onDelete?: () => void;
|
||||
onDuplicate?: () => void;
|
||||
onShowScript?: () => void;
|
||||
};
|
||||
|
||||
function NodeActionMenu({
|
||||
duplicateDisabledReason = null,
|
||||
isDeletable = true,
|
||||
isDuplicable = true,
|
||||
isScriptable = false,
|
||||
isCanvasLocked = false,
|
||||
showScriptText,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
onShowScript,
|
||||
}: Props) {
|
||||
const recordingStore = useRecordingStore();
|
||||
|
|
@ -41,11 +47,34 @@ function NodeActionMenu({
|
|||
: isCanvasLocked
|
||||
? "Unlock canvas to delete blocks"
|
||||
: 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;
|
||||
}
|
||||
|
||||
const duplicateItem =
|
||||
isDuplicable && onDuplicate ? (
|
||||
<DropdownMenuItem
|
||||
disabled={duplicateGated}
|
||||
onSelect={(event) => {
|
||||
if (duplicateGated) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
onDuplicate();
|
||||
}}
|
||||
>
|
||||
Duplicate Block
|
||||
</DropdownMenuItem>
|
||||
) : null;
|
||||
|
||||
const deleteItem = isDeletable ? (
|
||||
<DropdownMenuItem
|
||||
disabled={deleteGated}
|
||||
|
|
@ -66,9 +95,21 @@ function NodeActionMenu({
|
|||
<DropdownMenuTrigger asChild>
|
||||
<DotsHorizontalIcon className="h-6 w-6 cursor-pointer" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuContent align="end" collisionPadding={8}>
|
||||
<DropdownMenuLabel>Block Actions</DropdownMenuLabel>
|
||||
<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 ? (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { useOnChange } from "@/hooks/useOnChange";
|
|||
import { useAutoplayStore } from "@/store/useAutoplayStore";
|
||||
|
||||
import { useNodeLabelChangeHandler } from "@/routes/workflows/hooks/useLabelChangeHandler";
|
||||
import { useDuplicateNodeCallback } from "@/routes/workflows/hooks/useDuplicateNodeCallback";
|
||||
import { useRequestDeleteNodeCallback } from "@/routes/workflows/hooks/useRequestDeleteNodeCallback";
|
||||
import { useTransmuteNodeCallback } from "@/routes/workflows/hooks/useTransmuteNodeCallback";
|
||||
import { useToggleScriptForNodeCallback } from "@/routes/workflows/hooks/useToggleScriptForNodeCallback";
|
||||
|
|
@ -259,6 +260,7 @@ function NodeHeader({
|
|||
initialValue: blockLabel,
|
||||
});
|
||||
const blockTitle = blockTitleOverride ?? workflowBlockTitle[type];
|
||||
const duplicateNodeCallback = useDuplicateNodeCallback();
|
||||
const requestDeleteNodeCallback = useRequestDeleteNodeCallback();
|
||||
const transmuteNodeCallback = useTransmuteNodeCallback();
|
||||
const toggleScriptForNodeCallback = useToggleScriptForNodeCallback();
|
||||
|
|
@ -817,6 +819,12 @@ function NodeHeader({
|
|||
const isReadOnlyScope = useWorkflowScopeReadOnly();
|
||||
const isCanvasLocked = useIsCanvasLocked();
|
||||
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
|
||||
// is inert there, so a faded button is just visual noise.
|
||||
|
|
@ -1045,8 +1053,19 @@ function NodeHeader({
|
|||
})}
|
||||
>
|
||||
<NodeActionMenu
|
||||
duplicateDisabledReason={duplicateDisabledReason}
|
||||
isDuplicable={
|
||||
!isReadOnlyScope && Boolean(duplicateNodeCallback)
|
||||
}
|
||||
isScriptable={isScriptable}
|
||||
isCanvasLocked={isCanvasLocked}
|
||||
onDuplicate={
|
||||
isReadOnlyScope || !duplicateNodeCallback
|
||||
? undefined
|
||||
: () => {
|
||||
duplicateNodeCallback(nodeId);
|
||||
}
|
||||
}
|
||||
onDelete={() => {
|
||||
requestDeleteNodeCallback(nodeId, blockLabel);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { BlockActionContext } from "@/store/BlockActionContext";
|
||||
import { useContext } from "react";
|
||||
|
||||
function useDuplicateNodeCallback() {
|
||||
return useContext(BlockActionContext)?.duplicateNodeCallback;
|
||||
}
|
||||
|
||||
export { useDuplicateNodeCallback };
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { createContext } from "react";
|
||||
|
||||
type RequestDeleteNodeCallback = (id: string, label: string) => void;
|
||||
type DuplicateNodeCallback = (id: string) => void;
|
||||
type TransmuteNodeCallback = (id: string, nodeName: string) => void;
|
||||
type ToggleScriptForNodeCallback = (opts: {
|
||||
id?: string;
|
||||
|
|
@ -11,6 +12,7 @@ type ToggleScriptForNodeCallback = (opts: {
|
|||
const BlockActionContext = createContext<
|
||||
| {
|
||||
requestDeleteNodeCallback: RequestDeleteNodeCallback;
|
||||
duplicateNodeCallback: DuplicateNodeCallback;
|
||||
transmuteNodeCallback: TransmuteNodeCallback;
|
||||
toggleScriptForNodeCallback?: ToggleScriptForNodeCallback;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue