show affected blocks in confirmation dialog when deleting blocks or parameters (#4519)
This commit is contained in:
93
skyvern-frontend/src/components/DeleteConfirmationDialog.tsx
Normal file
93
skyvern-frontend/src/components/DeleteConfirmationDialog.tsx
Normal 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 };
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ function NodeActionMenu({
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<DotsHorizontalIcon className="h-6 w-6 cursor-pointer" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user