diff --git a/skyvern-frontend/src/components/DeleteConfirmationDialog.tsx b/skyvern-frontend/src/components/DeleteConfirmationDialog.tsx new file mode 100644 index 00000000..ebc7e7ec --- /dev/null +++ b/skyvern-frontend/src/components/DeleteConfirmationDialog.tsx @@ -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 ( + + e.preventDefault()}> + + {title} + +
+

{description}

+ {affectedBlocks.length > 0 && ( +
+

+ The following blocks reference this item and will be + updated: +

+
    + {affectedBlocks.map((block) => ( +
  • + {block.label} + + {" "} + ( + {[ + block.hasParameterKeyReference && + "parameter selector", + block.hasJinjaReference && "text field", + ] + .filter(Boolean) + .join(", ")} + ) + +
  • + ))} +
+
+ )} +
+
+
+ + + + + + +
+
+ ); +} + +export { DeleteConfirmationDialog }; +export type { AffectedBlock }; diff --git a/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx b/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx index 308a3f7b..b3c5a15a 100644 --- a/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx @@ -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, edges: Array) { - const layoutedElements = layout(nodes, edges); - setNodes(layoutedElements.nodes); - setEdges(layoutedElements.edges); - } + const doLayout = useCallback( + (nodes: Array, edges: Array) => { + 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({ - setTimeout(() => deleteNode(id), 0), + requestDeleteNodeCallback: requestDeleteNode, transmuteNodeCallback: (id: string, nodeName: string) => setTimeout(() => transmuteNode(id, nodeName), 0), toggleScriptForNodeCallback: toggleScript, diff --git a/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx b/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx index c3e636dd..13cbc4d3 100644 --- a/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx @@ -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} /> @@ -1705,6 +1747,33 @@ function Workspace({ } }} /> + { + 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, + }); + }} + /> ); } diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/NodeActionMenu.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/NodeActionMenu.tsx index 8186b986..723f2cc8 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/NodeActionMenu.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/NodeActionMenu.tsx @@ -33,7 +33,7 @@ function NodeActionMenu({ } return ( - + diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/components/NodeHeader.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/components/NodeHeader.tsx index 627bc5ec..4d2515e2 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/components/NodeHeader.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/components/NodeHeader.tsx @@ -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({ { - deleteNodeCallback(nodeId); + requestDeleteNodeCallback(nodeId, blockLabel); }} onShowScript={() => toggleScriptForNodeCallback({ id: nodeId, show: true }) diff --git a/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowParametersPanel.tsx b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowParametersPanel.tsx index d7efd24b..cc44fd9b 100644 --- a/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowParametersPanel.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowParametersPanel.tsx @@ -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(); 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 (
- - - - - - - Are you sure? - - This parameter will be deleted. - - - - - - - - - - +
); @@ -235,6 +204,22 @@ function WorkflowParametersPanel({ onMouseDownCapture }: Props) { + { + 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 && (
, + 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( + 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 | 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( }); } +/** + * 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( + 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).withWorkflowSettings && + (node.data as Record).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; + 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) + .parameterKeys as Array | 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 }; diff --git a/skyvern-frontend/src/routes/workflows/hooks/useDeleteNodeCallback.ts b/skyvern-frontend/src/routes/workflows/hooks/useDeleteNodeCallback.ts deleted file mode 100644 index 6351aa48..00000000 --- a/skyvern-frontend/src/routes/workflows/hooks/useDeleteNodeCallback.ts +++ /dev/null @@ -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 }; diff --git a/skyvern-frontend/src/routes/workflows/hooks/useRequestDeleteNodeCallback.ts b/skyvern-frontend/src/routes/workflows/hooks/useRequestDeleteNodeCallback.ts new file mode 100644 index 00000000..b5fb34b1 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/hooks/useRequestDeleteNodeCallback.ts @@ -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 }; diff --git a/skyvern-frontend/src/store/BlockActionContext.ts b/skyvern-frontend/src/store/BlockActionContext.ts index f1877466..f5b07733 100644 --- a/skyvern-frontend/src/store/BlockActionContext.ts +++ b/skyvern-frontend/src/store/BlockActionContext.ts @@ -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; }