show affected blocks in confirmation dialog when deleting blocks or parameters (#4519)

This commit is contained in:
Celal Zamanoglu
2026-01-23 00:33:18 +03:00
committed by GitHub
parent e7402026f0
commit 4648f2f35b
10 changed files with 543 additions and 325 deletions

View File

@@ -0,0 +1,93 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
type AffectedBlock = {
nodeId: string;
label: string;
hasParameterKeyReference: boolean;
hasJinjaReference: boolean;
};
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
affectedBlocks: AffectedBlock[];
onConfirm: () => void;
};
function DeleteConfirmationDialog({
open,
onOpenChange,
title,
description,
affectedBlocks,
onConfirm,
}: Props) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent onCloseAutoFocus={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription asChild>
<div className="space-y-3">
<p>{description}</p>
{affectedBlocks.length > 0 && (
<div className="rounded-md border border-amber-500/50 bg-amber-500/10 p-3">
<p className="mb-2 font-medium text-amber-500">
The following blocks reference this item and will be
updated:
</p>
<ul className="list-inside list-disc space-y-1 text-sm text-slate-300">
{affectedBlocks.map((block) => (
<li key={block.nodeId}>
<span className="font-medium">{block.label}</span>
<span className="text-slate-400">
{" "}
(
{[
block.hasParameterKeyReference &&
"parameter selector",
block.hasJinjaReference && "text field",
]
.filter(Boolean)
.join(", ")}
)
</span>
</li>
))}
</ul>
</div>
)}
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="secondary">Cancel</Button>
</DialogClose>
<Button
variant="destructive"
onClick={() => {
onConfirm();
}}
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { DeleteConfirmationDialog };
export type { AffectedBlock };

View File

@@ -89,6 +89,7 @@ import {
getWorkflowSettings,
layout,
removeJinjaReferenceFromNodes,
removeKeyFromNodesParameterKeys,
upgradeWorkflowDefinitionToVersionTwo,
} from "./workflowEditorUtils";
import { getWorkflowErrors } from "./workflowEditorUtils";
@@ -282,6 +283,11 @@ type Props = {
onMouseDownCapture?: () => void;
zIndex?: number;
onContainerResize?: number;
onRequestDeleteNode?: (
nodeId: string,
nodeLabel: string,
confirmCallback: () => void,
) => void;
};
function FlowRenderer({
@@ -300,6 +306,7 @@ function FlowRenderer({
onMouseDownCapture,
zIndex,
onContainerResize,
onRequestDeleteNode,
}: Props) {
const reactFlowInstance = useReactFlow();
const debugStore = useDebugStore();
@@ -347,11 +354,14 @@ function FlowRenderer({
);
});
function doLayout(nodes: Array<AppNode>, edges: Array<Edge>) {
const layoutedElements = layout(nodes, edges);
setNodes(layoutedElements.nodes);
setEdges(layoutedElements.edges);
}
const doLayout = useCallback(
(nodes: Array<AppNode>, edges: Array<Edge>) => {
const layoutedElements = layout(nodes, edges);
setNodes(layoutedElements.nodes);
setEdges(layoutedElements.edges);
},
[setNodes, setEdges],
);
useEffect(() => {
if (nodesInitialized) {
@@ -446,223 +456,92 @@ function FlowRenderer({
return true;
}
function deleteNode(id: string) {
const node = nodes.find((node) => node.id === id);
if (!node || !isWorkflowBlockNode(node)) {
return;
}
const nodesToDelete = descendants(nodes, id);
const deletedNodeLabel = node.data.label;
const newNodes = nodes.filter(
(node) => !nodesToDelete.includes(node) && node.id !== id,
);
const newEdges = edges.flatMap((edge) => {
if (edge.source === id) {
return [];
const deleteNode = useCallback(
(id: string) => {
const node = nodes.find((node) => node.id === id);
if (!node || !isWorkflowBlockNode(node)) {
return;
}
if (
nodesToDelete.some(
(node) => node.id === edge.source || node.id === edge.target,
)
) {
return [];
}
if (edge.target === id) {
const nextEdge = edges.find((edge) => edge.source === id);
if (nextEdge) {
// connect the old incoming edge to the next node if both of them exist
// also take the type of the old edge for plus button edge vs default
return [
{
...edge,
type: nextEdge.type,
target: nextEdge.target,
},
];
const nodesToDelete = descendants(nodes, id);
const deletedNodeLabel = node.data.label;
const newNodes = nodes.filter(
(node) => !nodesToDelete.includes(node) && node.id !== id,
);
const newEdges = edges.flatMap((edge) => {
if (edge.source === id) {
return [];
}
if (
nodesToDelete.some(
(node) => node.id === edge.source || node.id === edge.target,
)
) {
return [];
}
if (edge.target === id) {
const nextEdge = edges.find((edge) => edge.source === id);
if (nextEdge) {
// connect the old incoming edge to the next node if both of them exist
// also take the type of the old edge for plus button edge vs default
return [
{
...edge,
type: nextEdge.type,
target: nextEdge.target,
},
];
}
return [edge];
}
return [edge];
});
if (newNodes.every((node) => node.type === "nodeAdder")) {
// No user created nodes left, so return to the empty state.
doLayout([], []);
return;
}
return [edge];
});
if (newNodes.every((node) => node.type === "nodeAdder")) {
// No user created nodes left, so return to the empty state.
doLayout([], []);
return;
}
// Step 1: Remove inline {{ deleted_block_output }} references from all nodes
const deletedOutputKey = getOutputParameterKey(deletedNodeLabel);
const nodesWithRemovedInlineRefs = removeJinjaReferenceFromNodes(
newNodes,
deletedOutputKey,
);
// Step 1: Remove inline {{ deleted_block_output }} references from all nodes
const deletedOutputKey = getOutputParameterKey(deletedNodeLabel);
const nodesWithRemovedInlineRefs = removeJinjaReferenceFromNodes(
newNodes,
deletedOutputKey,
);
// Step 2: Remove from parameterKeys arrays and handle special cases
const newNodesWithUpdatedParameters = removeKeyFromNodesParameterKeys(
nodesWithRemovedInlineRefs,
deletedOutputKey,
deletedNodeLabel,
);
// Step 2: Remove from parameterKeys arrays and handle special cases
const newNodesWithUpdatedParameters = nodesWithRemovedInlineRefs.map(
(node) => {
// Clear finallyBlockLabel if the deleted block was the finally block
if (
node.type === "start" &&
node.data.withWorkflowSettings &&
node.data.finallyBlockLabel === deletedNodeLabel
) {
return {
...node,
data: {
...node.data,
finallyBlockLabel: null,
},
};
}
workflowChangesStore.setHasChanges(true);
// Handle parameterKeys - filter out the deleted output key
// Each node type needs a separate branch due to TypeScript union type limitations
if (node.type === "task") {
return {
...node,
data: {
...node.data,
parameterKeys: node.data.parameterKeys.filter(
(parameter) => parameter !== deletedOutputKey,
),
},
};
}
if (node.type === "textPrompt") {
return {
...node,
data: {
...node.data,
parameterKeys: node.data.parameterKeys.filter(
(parameter) => parameter !== deletedOutputKey,
),
},
};
}
if (node.type === "login") {
return {
...node,
data: {
...node.data,
parameterKeys: node.data.parameterKeys.filter(
(parameter) => parameter !== deletedOutputKey,
),
},
};
}
if (node.type === "navigation") {
return {
...node,
data: {
...node.data,
parameterKeys: node.data.parameterKeys.filter(
(parameter) => parameter !== deletedOutputKey,
),
},
};
}
if (node.type === "extraction") {
return {
...node,
data: {
...node.data,
parameterKeys: node.data.parameterKeys.filter(
(parameter) => parameter !== deletedOutputKey,
),
},
};
}
if (node.type === "fileDownload") {
return {
...node,
data: {
...node.data,
parameterKeys: node.data.parameterKeys.filter(
(parameter) => parameter !== deletedOutputKey,
),
},
};
}
if (node.type === "action") {
return {
...node,
data: {
...node.data,
parameterKeys: node.data.parameterKeys.filter(
(parameter) => parameter !== deletedOutputKey,
),
},
};
}
if (node.type === "http_request") {
return {
...node,
data: {
...node.data,
parameterKeys: node.data.parameterKeys.filter(
(parameter) => parameter !== deletedOutputKey,
),
},
};
}
if (node.type === "validation") {
return {
...node,
data: {
...node.data,
parameterKeys: node.data.parameterKeys.filter(
(parameter) => parameter !== deletedOutputKey,
),
},
};
}
if (node.type === "codeBlock") {
return {
...node,
data: {
...node.data,
parameterKeys:
node.data.parameterKeys?.filter(
(parameter) => parameter !== deletedOutputKey,
) ?? null,
},
};
}
if (node.type === "printPage") {
return {
...node,
data: {
...node.data,
parameterKeys: node.data.parameterKeys.filter(
(parameter) => parameter !== deletedOutputKey,
),
},
};
}
// Handle loop node's loopVariableReference (the active field displayed in UI).
// Note: loopValue is a legacy field populated during conversion for backward compatibility.
// It's not displayed in UI or sent to backend, so we only clean up loopVariableReference.
if (node.type === "loop") {
return {
...node,
data: {
...node.data,
loopVariableReference:
node.data.loopVariableReference === deletedOutputKey
? ""
: node.data.loopVariableReference,
},
};
}
doLayout(newNodesWithUpdatedParameters, newEdges);
},
[nodes, edges, doLayout, workflowChangesStore],
);
return node;
},
);
workflowChangesStore.setHasChanges(true);
// Use a ref to always have access to the latest deleteNode without causing re-renders
const deleteNodeRef = useRef(deleteNode);
useEffect(() => {
deleteNodeRef.current = deleteNode;
}, [deleteNode]);
doLayout(newNodesWithUpdatedParameters, newEdges);
}
// Callback for requesting node deletion (opens confirmation dialog in parent)
// Uses ref to avoid recreating on every nodes/edges change while still using latest deleteNode
const requestDeleteNode = useCallback(
(id: string, label: string) => {
if (onRequestDeleteNode) {
onRequestDeleteNode(id, label, () => deleteNodeRef.current(id));
} else {
// Fallback: delete directly if no confirmation handler provided
deleteNodeRef.current(id);
}
},
[onRequestDeleteNode],
);
function transmuteNode(id: string, nodeType: string) {
const nodeToTransmute = nodes.find((node) => node.id === id);
@@ -947,13 +826,7 @@ function FlowRenderer({
</Dialog>
<BlockActionContext.Provider
value={{
/**
* NOTE: defer deletion to next tick to allow React Flow's internal
* event handlers to complete; removes a console warning from the
* React Flow library
*/
deleteNodeCallback: (id: string) =>
setTimeout(() => deleteNode(id), 0),
requestDeleteNodeCallback: requestDeleteNode,
transmuteNodeCallback: (id: string, nodeName: string) =>
setTimeout(() => transmuteNode(id, nodeName), 0),
toggleScriptForNodeCallback: toggleScript,

View File

@@ -2,6 +2,7 @@ import { AxiosError } from "axios";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
MutableRefObject,
@@ -56,6 +57,7 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
import { toast } from "@/components/ui/use-toast";
import { DeleteConfirmationDialog } from "@/components/DeleteConfirmationDialog";
import { BrowserStream } from "@/components/BrowserStream";
import { statusIsFinalized } from "@/routes/tasks/types.ts";
import { CodeEditor } from "@/routes/workflows/components/CodeEditor";
@@ -83,7 +85,12 @@ import { WorkflowNodeLibraryPanel } from "./panels/WorkflowNodeLibraryPanel";
import { WorkflowParametersPanel } from "./panels/WorkflowParametersPanel";
import { WorkflowCacheKeyValuesPanel } from "./panels/WorkflowCacheKeyValuesPanel";
import { WorkflowComparisonPanel } from "./panels/WorkflowComparisonPanel";
import { getWorkflowErrors, getElements } from "./workflowEditorUtils";
import {
getWorkflowErrors,
getElements,
getAffectedBlocks,
getOutputParameterKey,
} from "./workflowEditorUtils";
import { WorkflowHeader } from "./WorkflowHeader";
import { WorkflowHistoryPanel } from "./panels/WorkflowHistoryPanel";
import { WorkflowVersion } from "../hooks/useWorkflowVersionsQuery";
@@ -259,6 +266,39 @@ function Workspace({
const recordingStore = useRecordingStore();
const cacheKey = workflow?.cache_key ?? "";
// Block delete confirmation dialog state
const [deleteBlockDialogState, setDeleteBlockDialogState] = useState<{
open: boolean;
nodeId: string | null;
nodeLabel: string | null;
}>({
open: false,
nodeId: null,
nodeLabel: null,
});
// Use a ref for the callback to avoid storing functions in state
const deleteConfirmCallbackRef = useRef<(() => void) | null>(null);
const affectedBlocksForDelete = useMemo(() => {
if (!deleteBlockDialogState.nodeLabel) {
return [];
}
const outputKey = getOutputParameterKey(deleteBlockDialogState.nodeLabel);
return getAffectedBlocks(nodes, outputKey);
}, [nodes, deleteBlockDialogState.nodeLabel]);
const handleRequestDeleteNode = useCallback(
(nodeId: string, nodeLabel: string, confirmCallback: () => void) => {
deleteConfirmCallbackRef.current = confirmCallback;
setDeleteBlockDialogState({
open: true,
nodeId,
nodeLabel,
});
},
[],
);
const [cacheKeyValue, setCacheKeyValue] = useState(
cacheKey === ""
? ""
@@ -1281,6 +1321,7 @@ function Workspace({
onEdgesChange={onEdgesChange}
initialTitle={initialTitle}
workflow={workflow}
onRequestDeleteNode={handleRequestDeleteNode}
/>
{/* sub panels */}
@@ -1446,6 +1487,7 @@ function Workspace({
initialTitle={initialTitle}
workflow={workflow}
onContainerResize={containerResizeTrigger}
onRequestDeleteNode={handleRequestDeleteNode}
/>
</div>
</div>
@@ -1705,6 +1747,33 @@ function Workspace({
}
}}
/>
<DeleteConfirmationDialog
open={deleteBlockDialogState.open}
onOpenChange={(open) => {
if (!open) {
deleteConfirmCallbackRef.current = null;
setDeleteBlockDialogState({
open: false,
nodeId: null,
nodeLabel: null,
});
}
}}
title="Delete Block"
description={`Are you sure you want to delete "${deleteBlockDialogState.nodeLabel}"?`}
affectedBlocks={affectedBlocksForDelete}
onConfirm={() => {
if (deleteConfirmCallbackRef.current) {
deleteConfirmCallbackRef.current();
}
deleteConfirmCallbackRef.current = null;
setDeleteBlockDialogState({
open: false,
nodeId: null,
nodeLabel: null,
});
}}
/>
</div>
);
}

View File

@@ -33,7 +33,7 @@ function NodeActionMenu({
}
return (
<DropdownMenu>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<DotsHorizontalIcon className="h-6 w-6 cursor-pointer" />
</DropdownMenuTrigger>

View File

@@ -15,7 +15,7 @@ import { useOnChange } from "@/hooks/useOnChange";
import { useAutoplayStore } from "@/store/useAutoplayStore";
import { useNodeLabelChangeHandler } from "@/routes/workflows/hooks/useLabelChangeHandler";
import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback";
import { useRequestDeleteNodeCallback } from "@/routes/workflows/hooks/useRequestDeleteNodeCallback";
import { useTransmuteNodeCallback } from "@/routes/workflows/hooks/useTransmuteNodeCallback";
import { useToggleScriptForNodeCallback } from "@/routes/workflows/hooks/useToggleScriptForNodeCallback";
import { useDebugSessionQuery } from "@/routes/workflows/hooks/useDebugSessionQuery";
@@ -180,7 +180,7 @@ function NodeHeader({
initialValue: blockLabel,
});
const blockTitle = workflowBlockTitle[type];
const deleteNodeCallback = useDeleteNodeCallback();
const requestDeleteNodeCallback = useRequestDeleteNodeCallback();
const transmuteNodeCallback = useTransmuteNodeCallback();
const toggleScriptForNodeCallback = useToggleScriptForNodeCallback();
const credentialGetter = useCredentialGetter();
@@ -625,7 +625,7 @@ function NodeHeader({
<NodeActionMenu
isScriptable={isScriptable}
onDelete={() => {
deleteNodeCallback(nodeId);
requestDeleteNodeCallback(nodeId, blockLabel);
}}
onShowScript={() =>
toggleScriptForNodeCallback({ id: nodeId, show: true })

View File

@@ -1,19 +1,9 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import { ParametersState } from "../types";
import { WorkflowParameterEditPanel } from "./WorkflowParameterEditPanel";
import { MixerVerticalIcon, PlusIcon } from "@radix-ui/react-icons";
import { Button } from "@/components/ui/button";
import { GarbageIcon } from "@/components/icons/GarbageIcon";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DialogClose } from "@radix-ui/react-dialog";
import {
DropdownMenu,
DropdownMenuContent,
@@ -22,7 +12,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useReactFlow } from "@xyflow/react";
import { useNodes, useReactFlow } from "@xyflow/react";
import { useWorkflowHasChangesStore } from "@/store/WorkflowHasChangesStore";
import { useWorkflowParametersStore } from "@/store/WorkflowParametersStore";
import { ScrollArea, ScrollAreaViewport } from "@/components/ui/scroll-area";
@@ -31,10 +21,14 @@ import {
WorkflowEditorParameterTypes,
} from "../../types/workflowTypes";
import {
getAffectedBlocks,
getLabelForWorkflowParameterType,
removeJinjaReferenceFromNodes,
removeKeyFromNodesParameterKeys,
replaceJinjaReferenceInNodes,
} from "../workflowEditorUtils";
import { DeleteConfirmationDialog } from "@/components/DeleteConfirmationDialog";
import { AppNode } from "../nodes";
const WORKFLOW_EDIT_PANEL_WIDTH = 20 * 16;
const WORKFLOW_EDIT_PANEL_GAP = 1 * 16;
@@ -62,8 +56,43 @@ function WorkflowParametersPanel({ onMouseDownCapture }: Props) {
parameter: null,
type: "workflow",
});
const [deleteDialogState, setDeleteDialogState] = useState<{
open: boolean;
parameterKey: string | null;
}>({
open: false,
parameterKey: null,
});
const nodes = useNodes<AppNode>();
const { setNodes } = useReactFlow();
const affectedBlocksForDelete = useMemo(() => {
if (!deleteDialogState.parameterKey) {
return [];
}
return getAffectedBlocks(nodes, deleteDialogState.parameterKey);
}, [nodes, deleteDialogState.parameterKey]);
const handleDeleteParameter = (parameterKey: string) => {
setWorkflowParameters(
workflowParameters.filter((p) => p.key !== parameterKey),
);
setHasChanges(true);
setNodes((nodes) => {
// Step 1: Remove inline {{ parameter.key }} references
const nodesWithRemovedRefs = removeJinjaReferenceFromNodes(
nodes,
parameterKey,
);
// Step 2: Remove from parameterKeys arrays
return removeKeyFromNodesParameterKeys(
nodesWithRemovedRefs,
parameterKey,
);
});
setDeleteDialogState({ open: false, parameterKey: null });
};
return (
<div
className="relative z-10 w-[25rem] rounded-xl border border-slate-700 bg-slate-950 p-5 shadow-xl"
@@ -156,77 +185,17 @@ function WorkflowParametersPanel({ onMouseDownCapture }: Props) {
});
}}
/>
<Dialog>
<DialogTrigger>
<GarbageIcon className="size-4 cursor-pointer text-destructive-foreground text-red-600" />
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you sure?</DialogTitle>
<DialogDescription>
This parameter will be deleted.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="secondary">Cancel</Button>
</DialogClose>
<Button
variant="destructive"
onClick={() => {
setWorkflowParameters(
workflowParameters.filter(
(p) => p.key !== parameter.key,
),
);
setHasChanges(true);
setNodes((nodes) => {
// Step 1: Remove inline {{ parameter.key }} references
const nodesWithRemovedRefs =
removeJinjaReferenceFromNodes(
nodes,
parameter.key,
);
// Step 2: Remove from parameterKeys arrays
return nodesWithRemovedRefs.map((node) => {
// All node types that have parameterKeys
if (
node.type === "task" ||
node.type === "textPrompt" ||
node.type === "login" ||
node.type === "navigation" ||
node.type === "extraction" ||
node.type === "fileDownload" ||
node.type === "action" ||
node.type === "http_request" ||
node.type === "validation" ||
node.type === "codeBlock" ||
node.type === "printPage"
) {
const parameterKeys = node.data
.parameterKeys as Array<string> | null;
return {
...node,
data: {
...node.data,
parameterKeys:
parameterKeys?.filter(
(key) => key !== parameter.key,
) ?? null,
},
};
}
return node;
});
});
}}
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<button
type="button"
onClick={() => {
setDeleteDialogState({
open: true,
parameterKey: parameter.key,
});
}}
>
<GarbageIcon className="size-4 cursor-pointer text-destructive-foreground text-red-600" />
</button>
</div>
</div>
);
@@ -235,6 +204,22 @@ function WorkflowParametersPanel({ onMouseDownCapture }: Props) {
</ScrollAreaViewport>
</ScrollArea>
</div>
<DeleteConfirmationDialog
open={deleteDialogState.open}
onOpenChange={(open) => {
if (!open) {
setDeleteDialogState({ open: false, parameterKey: null });
}
}}
title="Delete Parameter"
description={`Are you sure you want to delete "${deleteDialogState.parameterKey}"?`}
affectedBlocks={affectedBlocksForDelete}
onConfirm={() => {
if (deleteDialogState.parameterKey) {
handleDeleteParameter(deleteDialogState.parameterKey);
}
}}
/>
{operationPanelState.active && (
<div
className="absolute"

View File

@@ -2720,6 +2720,131 @@ function containsJinjaReference(text: string, key: string): boolean {
return regex.test(text);
}
/**
* Recursively checks if any string field in an object contains a jinja reference to a key.
* @param obj - The object to check
* @param key - The key to look for
* @param skipKeys - Set of keys to skip
* @param depth - Current recursion depth
* @returns True if any string field contains a jinja reference to the key
*/
function objectContainsJinjaReference(
obj: unknown,
key: string,
skipKeys: Set<string>,
depth: number = 0,
): boolean {
const MAX_DEPTH = 50;
if (depth > MAX_DEPTH || obj === null || obj === undefined) {
return false;
}
if (typeof obj === "string") {
return containsJinjaReference(obj, key);
}
if (Array.isArray(obj)) {
return obj.some((item) =>
objectContainsJinjaReference(item, key, skipKeys, depth + 1),
);
}
if (typeof obj === "object") {
for (const [objKey, value] of Object.entries(obj)) {
if (skipKeys.has(objKey)) {
continue;
}
if (objectContainsJinjaReference(value, key, skipKeys, depth + 1)) {
return true;
}
}
}
return false;
}
// Keys to skip when checking for jinja references (same as transform)
const SKIP_KEYS_FOR_JINJA_CHECK = new Set([
"label",
"key",
"type",
"id",
"nodeId",
"parameterKeys",
]);
/**
* Information about a block that references a parameter or block output.
*/
type AffectedBlock = {
nodeId: string;
label: string;
hasParameterKeyReference: boolean;
hasJinjaReference: boolean;
};
/**
* Finds all blocks that reference a given key (parameter or block output).
* Checks both parameterKeys arrays and jinja references in text fields.
* @param nodes - Array of workflow nodes
* @param key - The key to search for (e.g., "my_param" or "block_1_output")
* @returns Array of affected block information
*/
function getAffectedBlocks<T extends Node>(
nodes: T[],
key: string,
): AffectedBlock[] {
const affectedBlocks: AffectedBlock[] = [];
for (const node of nodes) {
// Skip non-block nodes (start, nodeAdder, etc.)
if (
!node.data ||
!("label" in node.data) ||
node.type === "start" ||
node.type === "nodeAdder"
) {
continue;
}
const label = node.data.label as string;
let hasParameterKeyReference = false;
let hasJinjaReference = false;
// Check parameterKeys array
const parameterKeys = node.data.parameterKeys as Array<string> | undefined;
if (parameterKeys?.includes(key)) {
hasParameterKeyReference = true;
}
// Check for loop node's loopVariableReference
if (node.type === "loop") {
const loopVarRef = node.data.loopVariableReference as string | undefined;
if (loopVarRef === key || containsJinjaReference(loopVarRef ?? "", key)) {
hasJinjaReference = true;
}
}
// Check jinja references in text fields
if (
objectContainsJinjaReference(node.data, key, SKIP_KEYS_FOR_JINJA_CHECK)
) {
hasJinjaReference = true;
}
if (hasParameterKeyReference || hasJinjaReference) {
affectedBlocks.push({
nodeId: node.id,
label,
hasParameterKeyReference,
hasJinjaReference,
});
}
}
return affectedBlocks;
}
// Maximum recursion depth to prevent stack overflow from malicious deeply nested objects
const MAX_TRANSFORM_DEPTH = 50;
@@ -2841,6 +2966,74 @@ function removeJinjaReferenceFromNodes<T extends Node>(
});
}
/**
* Removes a key from all nodes' parameterKeys arrays and handles special cases.
* Used when deleting a block output or parameter.
*
* @param nodes - Array of nodes to process
* @param keyToRemove - The key to remove from parameterKeys arrays
* @param deletedBlockLabel - Optional label of deleted block (for finallyBlockLabel cleanup)
* @returns New array of nodes with the key removed
*/
function removeKeyFromNodesParameterKeys<T extends Node>(
nodes: T[],
keyToRemove: string,
deletedBlockLabel?: string,
): T[] {
return nodes.map((node) => {
if (!node.data) {
return node;
}
// Handle start node's finallyBlockLabel
if (
node.type === "start" &&
deletedBlockLabel &&
(node.data as Record<string, unknown>).withWorkflowSettings &&
(node.data as Record<string, unknown>).finallyBlockLabel ===
deletedBlockLabel
) {
return {
...node,
data: {
...node.data,
finallyBlockLabel: null,
},
} as T;
}
// Handle loop node's loopVariableReference
if (node.type === "loop") {
const loopData = node.data as Record<string, unknown>;
return {
...node,
data: {
...node.data,
loopVariableReference:
loopData.loopVariableReference === keyToRemove
? ""
: loopData.loopVariableReference,
},
} as T;
}
// Handle parameterKeys for all other node types
const parameterKeys = (node.data as Record<string, unknown>)
.parameterKeys as Array<string> | null | undefined;
if (parameterKeys !== undefined) {
return {
...node,
data: {
...node.data,
parameterKeys: parameterKeys?.filter((key) => key !== keyToRemove),
},
} as T;
}
return node;
});
}
function getUpdatedNodesAfterLabelUpdateForParameterKeys(
id: string,
newLabel: string,
@@ -3845,6 +4038,7 @@ export {
createNode,
generateNodeData,
generateNodeLabel,
getAffectedBlocks,
getNestingLevel,
getAdditionalParametersForEmailBlock,
getAvailableOutputParameterKeys,
@@ -3866,5 +4060,8 @@ export {
isOutputParameterKey,
layout,
removeJinjaReferenceFromNodes,
removeKeyFromNodesParameterKeys,
replaceJinjaReferenceInNodes,
};
export type { AffectedBlock };

View File

@@ -1,16 +0,0 @@
import { BlockActionContext } from "@/store/BlockActionContext";
import { useContext } from "react";
function useDeleteNodeCallback() {
const deleteNodeCallback = useContext(BlockActionContext)?.deleteNodeCallback;
if (!deleteNodeCallback) {
throw new Error(
"useDeleteNodeCallback must be used within a BlockActionContextProvider",
);
}
return deleteNodeCallback;
}
export { useDeleteNodeCallback };

View File

@@ -0,0 +1,17 @@
import { BlockActionContext } from "@/store/BlockActionContext";
import { useContext } from "react";
function useRequestDeleteNodeCallback() {
const requestDeleteNodeCallback =
useContext(BlockActionContext)?.requestDeleteNodeCallback;
if (!requestDeleteNodeCallback) {
throw new Error(
"useRequestDeleteNodeCallback must be used within a BlockActionContextProvider",
);
}
return requestDeleteNodeCallback;
}
export { useRequestDeleteNodeCallback };

View File

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