From 0053736f8fe729cb262062803300a31498849744 Mon Sep 17 00:00:00 2001 From: Kerem Yilmaz Date: Tue, 10 Sep 2024 07:07:56 -0700 Subject: [PATCH] Deletable nodes (#801) Co-authored-by: Muhammed Salih Altun --- skyvern-frontend/package-lock.json | 28 ++- skyvern-frontend/package.json | 1 + .../routes/workflows/editor/FlowRenderer.tsx | 228 +++++++++++------- .../nodes/CodeBlockNode/CodeBlockNode.tsx | 17 +- .../nodes/DownloadNode/DownloadNode.tsx | 15 +- .../nodes/FileParserNode/FileParserNode.tsx | 18 +- .../editor/nodes/LoopNode/LoopNode.tsx | 22 +- .../workflows/editor/nodes/NodeActionMenu.tsx | 36 +++ .../nodes/SendEmailNode/SendEmailNode.tsx | 19 +- .../editor/nodes/TaskNode/TaskNode.tsx | 19 +- .../nodes/TextPromptNode/TextPromptNode.tsx | 21 +- .../editor/nodes/UploadNode/UploadNode.tsx | 19 +- .../workflows/editor/workflowEditorUtils.ts | 145 ++++++++--- .../workflows/hooks/useDeleteNodeCallback.ts | 16 ++ .../src/store/DeleteNodeCallbackContext.ts | 9 + 15 files changed, 423 insertions(+), 190 deletions(-) create mode 100644 skyvern-frontend/src/routes/workflows/editor/nodes/NodeActionMenu.tsx create mode 100644 skyvern-frontend/src/routes/workflows/hooks/useDeleteNodeCallback.ts create mode 100644 skyvern-frontend/src/store/DeleteNodeCallbackContext.ts diff --git a/skyvern-frontend/package-lock.json b/skyvern-frontend/package-lock.json index bf0855a3..f6fba3fe 100644 --- a/skyvern-frontend/package-lock.json +++ b/skyvern-frontend/package-lock.json @@ -39,6 +39,7 @@ "embla-carousel-react": "^8.0.0", "express": "^4.19.2", "fetch-to-curl": "^0.6.0", + "nanoid": "^5.0.7", "open": "^10.1.0", "posthog-js": "^1.138.0", "react": "^18.2.0", @@ -5829,9 +5830,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.7.tgz", + "integrity": "sha512-oLxFY2gd2IqnjcYyOXD8XGCftpGtZP2AbHbOkthDkvRywH5ayNtPVy9YlOPcHckXzbLTCHpkb7FB+yuxKV13pQ==", "funding": [ { "type": "github", @@ -5839,10 +5840,10 @@ } ], "bin": { - "nanoid": "bin/nanoid.cjs" + "nanoid": "bin/nanoid.js" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": "^18 || >=20" } }, "node_modules/natural-compare": { @@ -6299,6 +6300,23 @@ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/posthog-js": { "version": "1.138.0", "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.138.0.tgz", diff --git a/skyvern-frontend/package.json b/skyvern-frontend/package.json index e04b1d6c..a75780c5 100644 --- a/skyvern-frontend/package.json +++ b/skyvern-frontend/package.json @@ -47,6 +47,7 @@ "embla-carousel-react": "^8.0.0", "express": "^4.19.2", "fetch-to-curl": "^0.6.0", + "nanoid": "^5.0.7", "open": "^10.1.0", "posthog-js": "^1.138.0", "react": "^18.2.0", diff --git a/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx b/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx index c7d8efcb..6af7079c 100644 --- a/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx @@ -13,7 +13,12 @@ import "@xyflow/react/dist/style.css"; import { WorkflowHeader } from "./WorkflowHeader"; import { AppNode, nodeTypes } from "./nodes"; import "./reactFlowOverrideStyles.css"; -import { createNode, getWorkflowBlocks, layout } from "./workflowEditorUtils"; +import { + createNode, + generateNodeLabel, + getWorkflowBlocks, + layout, +} from "./workflowEditorUtils"; import { useEffect, useState } from "react"; import { WorkflowParametersPanel } from "./panels/WorkflowParametersPanel"; import { edgeTypes } from "./edges"; @@ -26,6 +31,8 @@ import { } from "../types/workflowYamlTypes"; import { WorkflowParametersStateContext } from "./WorkflowParametersStateContext"; import { WorkflowParameterValueType } from "../types/workflowTypes"; +import { DeleteNodeCallbackContext } from "@/store/DeleteNodeCallbackContext"; +import { nanoid } from "nanoid"; function convertToParametersYAML( parameters: ParametersState, @@ -131,11 +138,12 @@ function FlowRenderer({ }: AddNodeProps) { const newNodes: Array = []; const newEdges: Array = []; - const index = parent - ? nodes.filter((node) => node.parentId === parent).length - : nodes.length; - const id = parent ? `${parent}-${index}` : String(index); - const node = createNode({ id, parentId: parent }, nodeType, String(index)); + const id = nanoid(); + const node = createNode( + { id, parentId: parent }, + nodeType, + generateNodeLabel(nodes.map((node) => node.data.label)), + ); newNodes.push(node); if (previous) { const newEdge = { @@ -163,6 +171,7 @@ function FlowRenderer({ } if (nodeType === "loop") { + // when loop node is first created it needs an adder node so nodes can be added inside the loop newNodes.push({ id: `${id}-nodeAdder`, type: "nodeAdder", @@ -183,6 +192,7 @@ function FlowRenderer({ ? nodes.indexOf(previousNode) : nodes.length - 1; + // creating some memory for no reason, maybe check it out later const newNodesAfter = [ ...nodes.slice(0, previousNodeIndex + 1), ...newNodes, @@ -190,6 +200,7 @@ function FlowRenderer({ ]; if (nodes.length === 0) { + // if there were no nodes before, add a nodeAdder node and connect it to the new node newNodesAfter.push({ id: `${id}-nodeAdder`, type: "nodeAdder", @@ -212,103 +223,142 @@ function FlowRenderer({ doLayout(newNodesAfter, [...editedEdges, ...newEdges]); } + function deleteNode(id: string) { + const node = nodes.find((node) => node.id === id); + if (!node) { + return; + } + const newNodes = nodes.filter((node) => node.id !== id); + const newEdges = edges.flatMap((edge) => { + if (edge.source === id) { + 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; + } + + doLayout(newNodes, newEdges); + } + return ( - { - const dimensionChanges = changes.filter( - (change) => change.type === "dimensions", - ); - const tempNodes = [...nodes]; - dimensionChanges.forEach((change) => { - const node = tempNodes.find((node) => node.id === change.id); - if (node) { - if (node.measured?.width) { - node.measured.width = change.dimensions?.width; - } - if (node.measured?.height) { - node.measured.height = change.dimensions?.height; + + { + const dimensionChanges = changes.filter( + (change) => change.type === "dimensions", + ); + const tempNodes = [...nodes]; + dimensionChanges.forEach((change) => { + const node = tempNodes.find((node) => node.id === change.id); + if (node) { + if (node.measured?.width) { + node.measured.width = change.dimensions?.width; + } + if (node.measured?.height) { + node.measured.height = change.dimensions?.height; + } } + }); + if (dimensionChanges.length > 0) { + doLayout(tempNodes, edges); } - }); - if (dimensionChanges.length > 0) { - doLayout(tempNodes, edges); - } - onNodesChange(changes); - }} - onEdgesChange={onEdgesChange} - nodeTypes={nodeTypes} - edgeTypes={edgeTypes} - colorMode="dark" - fitView - fitViewOptions={{ - maxZoom: 1, - }} - > - - - - { - if ( + onNodesChange(changes); + }} + onEdgesChange={onEdgesChange} + nodeTypes={nodeTypes} + edgeTypes={edgeTypes} + colorMode="dark" + fitView + fitViewOptions={{ + maxZoom: 1, + }} + > + + + + { - const blocksInYAMLConvertibleJSON = getWorkflowBlocks(nodes); - const parametersInYAMLConvertibleJSON = - convertToParametersYAML(parameters); - handleSave( - parametersInYAMLConvertibleJSON, - blocksInYAMLConvertibleJSON, - title, - ); - }} - /> - - {workflowPanelState.active && ( - - {workflowPanelState.content === "parameters" && ( - - )} - {workflowPanelState.content === "nodeLibrary" && ( + onParametersClick={() => { + if ( + workflowPanelState.active && + workflowPanelState.content === "parameters" + ) { + closeWorkflowPanel(); + } else { + setWorkflowPanelState({ + active: true, + content: "parameters", + }); + } + }} + onSave={() => { + const blocksInYAMLConvertibleJSON = getWorkflowBlocks(nodes); + const parametersInYAMLConvertibleJSON = + convertToParametersYAML(parameters); + handleSave( + parametersInYAMLConvertibleJSON, + blocksInYAMLConvertibleJSON, + title, + ); + }} + /> + + {workflowPanelState.active && ( + + {workflowPanelState.content === "parameters" && ( + + )} + {workflowPanelState.content === "nodeLibrary" && ( + { + addNode(props); + }} + /> + )} + + )} + {nodes.length === 0 && ( + { addNode(props); }} + first /> - )} - - )} - {nodes.length === 0 && ( - - { - addNode(props); - }} - first - /> - - )} - + + )} + + ); } diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/CodeBlockNode/CodeBlockNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/CodeBlockNode/CodeBlockNode.tsx index 7bb424d9..78bad11a 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/CodeBlockNode/CodeBlockNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/CodeBlockNode/CodeBlockNode.tsx @@ -1,12 +1,15 @@ -import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react"; -import type { CodeBlockNode } from "./types"; import { Label } from "@/components/ui/label"; -import { CodeIcon, DotsHorizontalIcon } from "@radix-ui/react-icons"; import { CodeEditor } from "@/routes/workflows/components/CodeEditor"; +import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback"; +import { CodeIcon } from "@radix-ui/react-icons"; +import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react"; import { EditableNodeTitle } from "../components/EditableNodeTitle"; +import { NodeActionMenu } from "../NodeActionMenu"; +import type { CodeBlockNode } from "./types"; function CodeBlockNode({ id, data }: NodeProps) { const { updateNodeData } = useReactFlow(); + const deleteNodeCallback = useDeleteNodeCallback(); return (
@@ -37,9 +40,11 @@ function CodeBlockNode({ id, data }: NodeProps) { Code Block
-
- -
+ { + deleteNodeCallback(id); + }} + />
diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/DownloadNode/DownloadNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/DownloadNode/DownloadNode.tsx index 0754cd23..db82dc1e 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/DownloadNode/DownloadNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/DownloadNode/DownloadNode.tsx @@ -1,12 +1,15 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { DotsHorizontalIcon, DownloadIcon } from "@radix-ui/react-icons"; +import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback"; +import { DownloadIcon } from "@radix-ui/react-icons"; import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react"; -import type { DownloadNode } from "./types"; import { EditableNodeTitle } from "../components/EditableNodeTitle"; +import { NodeActionMenu } from "../NodeActionMenu"; +import type { DownloadNode } from "./types"; function DownloadNode({ id, data }: NodeProps) { const { updateNodeData } = useReactFlow(); + const deleteNodeCallback = useDeleteNodeCallback(); return (
@@ -37,9 +40,11 @@ function DownloadNode({ id, data }: NodeProps) { Download Block
-
- -
+ { + deleteNodeCallback(id); + }} + />
diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/FileParserNode/FileParserNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/FileParserNode/FileParserNode.tsx index b708e46b..f918900a 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/FileParserNode/FileParserNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/FileParserNode/FileParserNode.tsx @@ -1,11 +1,15 @@ -import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react"; -import type { FileParserNode } from "./types"; -import { CursorTextIcon, DotsHorizontalIcon } from "@radix-ui/react-icons"; import { Input } from "@/components/ui/input"; +import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback"; +import { CursorTextIcon } from "@radix-ui/react-icons"; +import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react"; import { EditableNodeTitle } from "../components/EditableNodeTitle"; +import { NodeActionMenu } from "../NodeActionMenu"; +import type { FileParserNode } from "./types"; function FileParserNode({ id, data }: NodeProps) { const { updateNodeData } = useReactFlow(); + const deleteNodeCallback = useDeleteNodeCallback(); + return (
) { File Parser Block
-
- -
+ { + deleteNodeCallback(id); + }} + />
diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/LoopNode/LoopNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/LoopNode/LoopNode.tsx index 8650d51d..7a735c4b 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/LoopNode/LoopNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/LoopNode/LoopNode.tsx @@ -1,4 +1,8 @@ -import { DotsHorizontalIcon, UpdateIcon } from "@radix-ui/react-icons"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback"; +import { UpdateIcon } from "@radix-ui/react-icons"; +import type { Node } from "@xyflow/react"; import { Handle, NodeProps, @@ -6,15 +10,15 @@ import { useNodes, useReactFlow, } from "@xyflow/react"; -import type { LoopNode } from "./types"; -import { Label } from "@/components/ui/label"; -import { Input } from "@/components/ui/input"; -import type { Node } from "@xyflow/react"; import { EditableNodeTitle } from "../components/EditableNodeTitle"; +import { NodeActionMenu } from "../NodeActionMenu"; +import type { LoopNode } from "./types"; function LoopNode({ id, data }: NodeProps) { const { updateNodeData } = useReactFlow(); const nodes = useNodes(); + const deleteNodeCallback = useDeleteNodeCallback(); + const children = nodes.filter((node) => node.parentId === id); const furthestDownChild: Node | null = children.reduce( (acc, child) => { @@ -70,9 +74,11 @@ function LoopNode({ id, data }: NodeProps) { Loop Block
-
- -
+ { + deleteNodeCallback(id); + }} + />
diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/NodeActionMenu.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/NodeActionMenu.tsx new file mode 100644 index 00000000..34d149c7 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/NodeActionMenu.tsx @@ -0,0 +1,36 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { DotsHorizontalIcon } from "@radix-ui/react-icons"; + +type Props = { + onDelete: () => void; +}; + +function NodeActionMenu({ onDelete }: Props) { + return ( + + + + + + Block Actions + + { + onDelete(); + }} + > + Delete Block + + + + ); +} + +export { NodeActionMenu }; diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/SendEmailNode/SendEmailNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/SendEmailNode/SendEmailNode.tsx index eab775fc..721b8b3e 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/SendEmailNode/SendEmailNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/SendEmailNode/SendEmailNode.tsx @@ -1,13 +1,16 @@ -import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react"; -import type { SendEmailNode } from "./types"; -import { DotsHorizontalIcon, EnvelopeClosedIcon } from "@radix-ui/react-icons"; -import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { Separator } from "@/components/ui/separator"; +import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback"; +import { EnvelopeClosedIcon } from "@radix-ui/react-icons"; +import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react"; import { EditableNodeTitle } from "../components/EditableNodeTitle"; +import { NodeActionMenu } from "../NodeActionMenu"; +import type { SendEmailNode } from "./types"; function SendEmailNode({ id, data }: NodeProps) { const { updateNodeData } = useReactFlow(); + const deleteNodeCallback = useDeleteNodeCallback(); return (
@@ -38,9 +41,11 @@ function SendEmailNode({ id, data }: NodeProps) { Send Email Block
-
- -
+ { + deleteNodeCallback(id); + }} + />
diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/TaskNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/TaskNode.tsx index 784bbd73..45c5b8eb 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/TaskNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/TaskNode.tsx @@ -16,22 +16,21 @@ import { } from "@/components/ui/popover"; import { Switch } from "@/components/ui/switch"; import { CodeEditor } from "@/routes/workflows/components/CodeEditor"; -import { - DotsHorizontalIcon, - ListBulletIcon, - MixerVerticalIcon, -} from "@radix-ui/react-icons"; +import { ListBulletIcon, MixerVerticalIcon } from "@radix-ui/react-icons"; import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react"; import { useState } from "react"; +import { EditableNodeTitle } from "../components/EditableNodeTitle"; +import { NodeActionMenu } from "../NodeActionMenu"; import { TaskNodeDisplayModeSwitch } from "./TaskNodeDisplayModeSwitch"; import { TaskNodeParametersPanel } from "./TaskNodeParametersPanel"; import type { TaskNode, TaskNodeDisplayMode } from "./types"; -import { EditableNodeTitle } from "../components/EditableNodeTitle"; +import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback"; function TaskNode({ id, data }: NodeProps) { const { updateNodeData } = useReactFlow(); const [displayMode, setDisplayMode] = useState("basic"); const { editable } = data; + const deleteNodeCallback = useDeleteNodeCallback(); const basicContent = ( <> @@ -335,9 +334,11 @@ function TaskNode({ id, data }: NodeProps) { Task Block
-
- -
+ { + deleteNodeCallback(id); + }} + />
) { const { updateNodeData } = useReactFlow(); const { editable } = data; + const deleteNodeCallback = useDeleteNodeCallback(); return (
@@ -41,9 +44,11 @@ function TextPromptNode({ id, data }: NodeProps) { Text Prompt Block
-
- -
+ { + deleteNodeCallback(id); + }} + />
diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/UploadNode/UploadNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/UploadNode/UploadNode.tsx index 7a1e127a..6280d0b8 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/UploadNode/UploadNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/UploadNode/UploadNode.tsx @@ -1,12 +1,15 @@ -import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react"; -import type { UploadNode } from "./types"; -import { DotsHorizontalIcon, UploadIcon } from "@radix-ui/react-icons"; -import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback"; +import { UploadIcon } from "@radix-ui/react-icons"; +import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react"; import { EditableNodeTitle } from "../components/EditableNodeTitle"; +import { NodeActionMenu } from "../NodeActionMenu"; +import type { UploadNode } from "./types"; function UploadNode({ id, data }: NodeProps) { const { updateNodeData } = useReactFlow(); + const deleteNodeCallback = useDeleteNodeCallback(); return (
@@ -37,9 +40,11 @@ function UploadNode({ id, data }: NodeProps) { Upload Block
-
- -
+ { + deleteNodeCallback(id); + }} + />
diff --git a/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts b/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts index 7924db58..75fa1279 100644 --- a/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts +++ b/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts @@ -1,19 +1,21 @@ -import { Edge } from "@xyflow/react"; -import { AppNode } from "./nodes"; import Dagre from "@dagrejs/dagre"; +import { Edge } from "@xyflow/react"; +import { nanoid } from "nanoid"; import type { WorkflowBlock } from "../types/workflowTypes"; -import { nodeTypes } from "./nodes"; -import { taskNodeDefaultData } from "./nodes/TaskNode/types"; -import { LoopNode, loopNodeDefaultData } from "./nodes/LoopNode/types"; +import { BlockYAML } from "../types/workflowYamlTypes"; +import { REACT_FLOW_EDGE_Z_INDEX } from "./constants"; +import { AppNode, nodeTypes } from "./nodes"; import { codeBlockNodeDefaultData } from "./nodes/CodeBlockNode/types"; import { downloadNodeDefaultData } from "./nodes/DownloadNode/types"; -import { uploadNodeDefaultData } from "./nodes/UploadNode/types"; -import { sendEmailNodeDefaultData } from "./nodes/SendEmailNode/types"; -import { textPromptNodeDefaultData } from "./nodes/TextPromptNode/types"; import { fileParserNodeDefaultData } from "./nodes/FileParserNode/types"; -import { BlockYAML } from "../types/workflowYamlTypes"; +import { LoopNode, loopNodeDefaultData } from "./nodes/LoopNode/types"; import { NodeAdderNode } from "./nodes/NodeAdderNode/types"; -import { REACT_FLOW_EDGE_Z_INDEX } from "./constants"; +import { sendEmailNodeDefaultData } from "./nodes/SendEmailNode/types"; +import { taskNodeDefaultData } from "./nodes/TaskNode/types"; +import { textPromptNodeDefaultData } from "./nodes/TextPromptNode/types"; +import { uploadNodeDefaultData } from "./nodes/UploadNode/types"; + +export const NEW_NODE_LABEL_PREFIX = "Block "; function layoutUtil( nodes: Array, @@ -211,38 +213,84 @@ function convertToNode( } } -function getElements( +function generateNodeData(blocks: Array): Array<{ + id: string; + previous: string | null; + next: string | null; + parentId: string | null; + block: WorkflowBlock; +}> { + const idMap = new WeakMap(); + const stack = [...blocks]; + + while (stack.length > 0) { + const block = stack.pop()!; + const id = nanoid(); + idMap.set(block, id); + if (block.block_type === "for_loop") { + stack.push(...block.loop_blocks); + } + } + + return getNodeData(blocks, idMap, null); +} + +function getNodeData( blocks: Array, - parentId?: string, -): { nodes: Array; edges: Array } { + ids: WeakMap, + parentId: string | null, +): Array<{ + id: string; + previous: string | null; + next: string | null; + parentId: string | null; + block: WorkflowBlock; +}> { + const data: Array<{ + id: string; + previous: string | null; + next: string | null; + parentId: string | null; + block: WorkflowBlock; + }> = []; + + blocks.forEach((block, index) => { + const id = ids.get(block)!; + const previous = index === 0 ? null : ids.get(blocks[index - 1]!)!; + const next = + index === blocks.length - 1 ? null : ids.get(blocks[index + 1]!)!; + data.push({ id, previous, next, parentId, block }); + if (block.block_type === "for_loop") { + data.push(...getNodeData(block.loop_blocks, ids, id)); + } + }); + + return data; +} + +function getElements(blocks: Array): { + nodes: Array; + edges: Array; +} { + const data = generateNodeData(blocks); const nodes: Array = []; const edges: Array = []; - blocks.forEach((block, index) => { - const id = parentId ? `${parentId}-${index}` : String(index); - const nextId = parentId ? `${parentId}-${index + 1}` : String(index + 1); - nodes.push(convertToNode({ id, parentId }, block)); - if (block.block_type === "for_loop") { - const subElements = getElements(block.loop_blocks, id); - if (subElements.nodes.length === 0) { - nodes.push({ - id: `${id}-nodeAdder`, - type: "nodeAdder", - position: { x: 0, y: 0 }, - data: {}, - draggable: false, - connectable: false, - }); - } - nodes.push(...subElements.nodes); - edges.push(...subElements.edges); - } - if (index !== blocks.length - 1) { + data.forEach((d) => { + const node = convertToNode( + { + id: d.id, + parentId: d.parentId ?? undefined, + }, + d.block, + ); + nodes.push(node); + if (d.previous) { edges.push({ - id: `edge-${id}-${nextId}`, + id: nanoid(), type: "edgeWithAddButton", - source: id, - target: nextId, + source: d.previous, + target: d.id, style: { strokeWidth: 2, }, @@ -252,10 +300,11 @@ function getElements( }); if (nodes.length > 0) { + const lastNode = data.find((d) => d.next === null && d.parentId === null); edges.push({ id: "edge-nodeAdder", type: "default", - source: nodes[nodes.length - 1]!.id, + source: lastNode!.id, target: "nodeAdder", style: { strokeWidth: 2, @@ -277,9 +326,8 @@ function getElements( function createNode( identifiers: { id: string; parentId?: string }, nodeType: Exclude, - labelPostfix: string, // unique label requirement + label: string, ): AppNode { - const label = "Block " + labelPostfix; const common = { draggable: false, position: { x: 0, y: 0 }, @@ -496,4 +544,21 @@ function getWorkflowBlocks(nodes: Array): Array { ); } -export { getElements, layout, createNode, getWorkflowBlocks }; +function generateNodeLabel(existingLabels: Array) { + for (let i = 1; i < existingLabels.length + 2; i++) { + const label = NEW_NODE_LABEL_PREFIX + i; + if (!existingLabels.includes(label)) { + return label; + } + } + throw new Error("Failed to generate a new node label"); +} + +export { + createNode, + generateNodeData, + getElements, + getWorkflowBlocks, + layout, + generateNodeLabel, +}; diff --git a/skyvern-frontend/src/routes/workflows/hooks/useDeleteNodeCallback.ts b/skyvern-frontend/src/routes/workflows/hooks/useDeleteNodeCallback.ts new file mode 100644 index 00000000..915eba43 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/hooks/useDeleteNodeCallback.ts @@ -0,0 +1,16 @@ +import { DeleteNodeCallbackContext } from "@/store/DeleteNodeCallbackContext"; +import { useContext } from "react"; + +function useDeleteNodeCallback() { + const deleteNodeCallback = useContext(DeleteNodeCallbackContext); + + if (!deleteNodeCallback) { + throw new Error( + "useDeleteNodeCallback must be used within a DeleteNodeCallbackProvider", + ); + } + + return deleteNodeCallback; +} + +export { useDeleteNodeCallback }; diff --git a/skyvern-frontend/src/store/DeleteNodeCallbackContext.ts b/skyvern-frontend/src/store/DeleteNodeCallbackContext.ts new file mode 100644 index 00000000..eb762276 --- /dev/null +++ b/skyvern-frontend/src/store/DeleteNodeCallbackContext.ts @@ -0,0 +1,9 @@ +import { createContext } from "react"; + +type DeleteNodeCallback = (id: string) => void; + +const DeleteNodeCallbackContext = createContext( + undefined, +); + +export { DeleteNodeCallbackContext };