From b90ae3b30c372f9997723a683872d077928ce0ba Mon Sep 17 00:00:00 2001 From: Shuchang Zheng Date: Thu, 20 Mar 2025 10:03:03 -0700 Subject: [PATCH] Allow nested loop blocks (#1977) --- .../routes/workflows/editor/FlowRenderer.tsx | 13 ++++- .../editor/edges/EdgeWithAddButton.tsx | 2 - .../editor/nodes/LoopNode/LoopNode.tsx | 9 +++- .../nodes/NodeAdderNode/NodeAdderNode.tsx | 2 - .../workflows/editor/workflowEditorUtils.ts | 51 +++++++++++++++++-- 5 files changed, 66 insertions(+), 11 deletions(-) diff --git a/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx b/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx index 50906a41..c8495f65 100644 --- a/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx @@ -73,6 +73,7 @@ import { convertEchoParameters, createNode, defaultEdge, + descendants, generateNodeLabel, getAdditionalParametersForEmailBlock, getOutputParameterKey, @@ -408,12 +409,22 @@ function FlowRenderer({ if (!node || !isWorkflowBlockNode(node)) { return; } + const nodesToDelete = descendants(nodes, id); const deletedNodeLabel = node.data.label; - const newNodes = nodes.filter((node) => node.id !== id); + 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) { diff --git a/skyvern-frontend/src/routes/workflows/editor/edges/EdgeWithAddButton.tsx b/skyvern-frontend/src/routes/workflows/editor/edges/EdgeWithAddButton.tsx index 12974437..545f10af 100644 --- a/skyvern-frontend/src/routes/workflows/editor/edges/EdgeWithAddButton.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/edges/EdgeWithAddButton.tsx @@ -56,7 +56,6 @@ function EdgeWithAddButton({ size="icon" className="h-4 w-4 rounded-full transition-all hover:scale-150" onClick={() => { - const disableLoop = Boolean(sourceNode?.parentId); setWorkflowPanelState({ active: true, content: "nodeLibrary", @@ -64,7 +63,6 @@ function EdgeWithAddButton({ previous: source, next: target, parent: sourceNode?.parentId, - disableLoop, }, }); }} 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 e183096d..5f4aa8f6 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/LoopNode/LoopNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/LoopNode/LoopNode.tsx @@ -21,10 +21,15 @@ import type { LoopNode } from "./types"; import { useState } from "react"; import { useIsFirstBlockInWorkflow } from "../../hooks/useIsFirstNodeInWorkflow"; import { Checkbox } from "@/components/ui/checkbox"; +import { getLoopNodeWidth } from "../../workflowEditorUtils"; function LoopNode({ id, data }: NodeProps) { const { updateNodeData } = useReactFlow(); const nodes = useNodes(); + const node = nodes.find((n) => n.id === id); + if (!node) { + throw new Error("Node not found"); // not possible + } const [label, setLabel] = useNodeLabelChangeHandler({ id, initialValue: data.label, @@ -55,6 +60,7 @@ function LoopNode({ id, data }: NodeProps) { (furthestDownChild?.position.y ?? 0) + 24; + const loopNodeWidth = getLoopNodeWidth(node, nodes); function handleChange(key: string, value: unknown) { if (!data.editable) { return; @@ -78,8 +84,9 @@ function LoopNode({ id, data }: NodeProps) { className="opacity-0" />
diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/NodeAdderNode/NodeAdderNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/NodeAdderNode/NodeAdderNode.tsx index 46131376..92407c99 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/NodeAdderNode/NodeAdderNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/NodeAdderNode/NodeAdderNode.tsx @@ -27,7 +27,6 @@ function NodeAdderNode({ id, parentId }: NodeProps) { className="rounded-full bg-slate-50 p-2" onClick={() => { const previous = edges.find((edge) => edge.target === id)?.source; - const disableLoop = Boolean(parentId); setWorkflowPanelState({ active: true, content: "nodeLibrary", @@ -36,7 +35,6 @@ function NodeAdderNode({ id, parentId }: NodeProps) { next: id, parent: parentId, connectingEdgeType: "default", - disableLoop, }, }); }} diff --git a/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts b/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts index 8d57c980..bf561fe4 100644 --- a/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts +++ b/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts @@ -132,13 +132,36 @@ function layoutUtil( }; } +export function descendants(nodes: Array, id: string): Array { + const children = nodes.filter((n) => n.parentId === id); + return children.concat(...children.map((c) => descendants(nodes, c.id))); +} + +export function getLoopNodeWidth(node: AppNode, nodes: Array): number { + const maxNesting = maxNestingLevel(nodes); + const nestingLevel = getNestingLevel(node, nodes); + return 600 + (maxNesting - nestingLevel) * 50; +} + +function maxNestingLevel(nodes: Array): number { + return Math.max(...nodes.map((node) => getNestingLevel(node, nodes))); +} + +function getNestingLevel(node: AppNode, nodes: Array): number { + let level = 0; + let current = nodes.find((n) => n.id === node.parentId); + while (current) { + level++; + current = nodes.find((n) => n.id === current?.parentId); + } + return level; +} + function layout( nodes: Array, edges: Array, ): { nodes: Array; edges: Array } { - const loopNodes = nodes.filter( - (node) => node.type === "loop" && !node.parentId, - ); + const loopNodes = nodes.filter((node) => node.type === "loop"); const loopNodeChildren: Array> = loopNodes.map(() => []); loopNodes.forEach((node, index) => { @@ -151,7 +174,7 @@ function layout( const maxChildWidth = Math.max( ...childNodes.map((node) => node.measured?.width ?? 0), ); - const loopNodeWidth = 600; // 600 px + const loopNodeWidth = getLoopNodeWidth(node, nodes); const layouted = layoutUtil(childNodes, childEdges, { marginx: (loopNodeWidth - maxChildWidth) / 2, marginy: 225, @@ -1169,7 +1192,23 @@ function getOrderedChildrenBlocks( const children: Array = []; let currentNode: WorkflowBlockNode | undefined = firstChild; while (currentNode) { - children.push(getWorkflowBlock(currentNode)); + if (currentNode.type === "loop") { + const loopChildren = getOrderedChildrenBlocks( + nodes, + edges, + currentNode.id, + ); + children.push({ + block_type: "for_loop", + label: currentNode.data.label, + continue_on_failure: currentNode.data.continueOnFailure, + loop_blocks: loopChildren, + loop_variable_reference: currentNode.data.loopVariableReference, + complete_if_empty: currentNode.data.completeIfEmpty, + }); + } else { + children.push(getWorkflowBlock(currentNode)); + } const nextId = edges.find( (edge) => edge.source === currentNode?.id, )?.target; @@ -1991,12 +2030,14 @@ export { createNode, generateNodeData, generateNodeLabel, + getNestingLevel, getAdditionalParametersForEmailBlock, getAvailableOutputParameterKeys, getBlockNameOfOutputParameterKey, getDefaultValueForParameterType, getElements, getLabelForWorkflowParameterType, + maxNestingLevel, getWorkflowSettings, getOutputParameterKey, getPreviousNodeIds,