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 { 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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -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";
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue