diff --git a/skyvern-frontend/src/routes/workflows/components/SourceParameterKeySelector.tsx b/skyvern-frontend/src/routes/workflows/components/SourceParameterKeySelector.tsx index d035a5f9..e26de241 100644 --- a/skyvern-frontend/src/routes/workflows/components/SourceParameterKeySelector.tsx +++ b/skyvern-frontend/src/routes/workflows/components/SourceParameterKeySelector.tsx @@ -1,6 +1,6 @@ import { useNodes } from "@xyflow/react"; import { useWorkflowParametersState } from "../editor/useWorkflowParametersState"; -import { AppNode } from "../editor/nodes"; +import { AppNode, isWorkflowBlockNode } from "../editor/nodes"; import { getOutputParameterKey } from "../editor/workflowEditorUtils"; import { Select, @@ -22,7 +22,7 @@ function SourceParameterKeySelector({ value, onChange }: Props) { .filter((parameter) => parameter.parameterType !== "credential") .map((parameter) => parameter.key); const outputParameterKeys = nodes - .filter((node) => node.type !== "nodeAdder") + .filter(isWorkflowBlockNode) .map((node) => getOutputParameterKey(node.data.label)); return ( diff --git a/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx b/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx index 138259d4..45d1d0ae 100644 --- a/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx @@ -31,18 +31,21 @@ import { import { WorkflowHeader } from "./WorkflowHeader"; import { WorkflowParametersStateContext } from "./WorkflowParametersStateContext"; import { edgeTypes } from "./edges"; -import { AppNode, nodeTypes } from "./nodes"; +import { AppNode, nodeTypes, WorkflowBlockNode } from "./nodes"; import { WorkflowNodeLibraryPanel } from "./panels/WorkflowNodeLibraryPanel"; import { WorkflowParametersPanel } from "./panels/WorkflowParametersPanel"; import "./reactFlowOverrideStyles.css"; import { convertEchoParameters, createNode, + defaultEdge, generateNodeLabel, getAdditionalParametersForEmailBlock, getOutputParameterKey, getWorkflowBlocks, layout, + nodeAdderNode, + startNode, } from "./workflowEditorUtils"; import { useWorkflowHasChangesStore } from "@/store/WorkflowHasChangesStore"; import { useBlocker, useParams } from "react-router-dom"; @@ -139,7 +142,7 @@ type Props = { }; export type AddNodeProps = { - nodeType: Exclude; + nodeType: NonNullable; previous: string | null; next: string | null; parent?: string; @@ -316,15 +319,11 @@ 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: nanoid(), - type: "nodeAdder", - parentId: id, - position: { x: 0, y: 0 }, - data: {}, - draggable: false, - connectable: false, - }); + const startNodeId = nanoid(); + const adderNodeId = nanoid(); + newNodes.push(startNode(startNodeId, id)); + newNodes.push(nodeAdderNode(adderNodeId, id)); + newEdges.push(defaultEdge(startNodeId, adderNodeId)); } const editedEdges = previous @@ -343,26 +342,6 @@ function FlowRenderer({ ...nodes.slice(previousNodeIndex + 1), ]; - 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", - position: { x: 0, y: 0 }, - data: {}, - draggable: false, - connectable: false, - }); - newEdges.push({ - id: `edge-0-${id}`, - type: "default", - source: id, - target: `${id}-nodeAdder`, - style: { - strokeWidth: 2, - }, - }); - } setHasChanges(true); doLayout(newNodesAfter, [...editedEdges, ...newEdges]); } @@ -619,16 +598,6 @@ function FlowRenderer({ )} )} - {nodes.length === 0 && ( - - { - addNode(props); - }} - first - /> - - )} diff --git a/skyvern-frontend/src/routes/workflows/editor/WorkflowEditor.tsx b/skyvern-frontend/src/routes/workflows/editor/WorkflowEditor.tsx index 710a0bdd..8545d490 100644 --- a/skyvern-frontend/src/routes/workflows/editor/WorkflowEditor.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/WorkflowEditor.tsx @@ -6,6 +6,7 @@ import { useParams } from "react-router-dom"; import { useWorkflowQuery } from "../hooks/useWorkflowQuery"; import { FlowRenderer } from "./FlowRenderer"; import { getElements } from "./workflowEditorUtils"; +import { LogoMinimized } from "@/components/LogoMinimized"; function WorkflowEditor() { const { workflowPermanentId } = useParams(); @@ -29,7 +30,7 @@ function WorkflowEditor() { if (isLoading) { return (
- Loading... +
); } diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/StartNode/StartNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/StartNode/StartNode.tsx new file mode 100644 index 00000000..61f907a4 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/StartNode/StartNode.tsx @@ -0,0 +1,19 @@ +import { Handle, Position } from "@xyflow/react"; + +function StartNode() { + return ( +
+ +
+ Start +
+
+ ); +} + +export { StartNode }; diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/StartNode/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/StartNode/types.ts new file mode 100644 index 00000000..19b3f0d2 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/StartNode/types.ts @@ -0,0 +1,5 @@ +import type { Node } from "@xyflow/react"; + +export type StartNodeData = Record; + +export type StartNode = Node; diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/types.ts index 79a3c91d..3a9ed512 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/types.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/types.ts @@ -20,8 +20,6 @@ export type TaskNodeData = NodeBaseData & { export type TaskNode = Node; -export type TaskNodeDisplayMode = "basic" | "advanced"; - export const taskNodeDefaultData: TaskNodeData = { url: "", navigationGoal: "", diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/index.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/index.ts index 0d5c9591..2898ae3d 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/index.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/index.ts @@ -17,8 +17,12 @@ import type { DownloadNode } from "./DownloadNode/types"; import { DownloadNode as DownloadNodeComponent } from "./DownloadNode/DownloadNode"; import type { NodeAdderNode } from "./NodeAdderNode/types"; import { NodeAdderNode as NodeAdderNodeComponent } from "./NodeAdderNode/NodeAdderNode"; +import { StartNode as StartNodeComponent } from "./StartNode/StartNode"; +import type { StartNode } from "./StartNode/types"; -export type AppNode = +export type UtilityNode = StartNode | NodeAdderNode; + +export type WorkflowBlockNode = | LoopNode | TaskNode | TextPromptNode @@ -26,8 +30,17 @@ export type AppNode = | CodeBlockNode | FileParserNode | UploadNode - | DownloadNode - | NodeAdderNode; + | DownloadNode; + +export function isUtilityNode(node: AppNode): node is UtilityNode { + return node.type === "nodeAdder" || node.type === "start"; +} + +export function isWorkflowBlockNode(node: AppNode): node is WorkflowBlockNode { + return node.type !== "nodeAdder" && node.type !== "start"; +} + +export type AppNode = UtilityNode | WorkflowBlockNode; export const nodeTypes = { loop: memo(LoopNodeComponent), @@ -39,4 +52,5 @@ export const nodeTypes = { upload: memo(UploadNodeComponent), download: memo(DownloadNodeComponent), nodeAdder: memo(NodeAdderNodeComponent), + start: memo(StartNodeComponent), }; diff --git a/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowNodeLibraryPanel.tsx b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowNodeLibraryPanel.tsx index 19ce984f..f734ce57 100644 --- a/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowNodeLibraryPanel.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowNodeLibraryPanel.tsx @@ -10,11 +10,11 @@ import { UpdateIcon, UploadIcon, } from "@radix-ui/react-icons"; -import { nodeTypes } from "../nodes"; +import { WorkflowBlockNode } from "../nodes"; import { AddNodeProps } from "../FlowRenderer"; const nodeLibraryItems: Array<{ - nodeType: Exclude; + nodeType: NonNullable; icon: JSX.Element; title: string; description: string; diff --git a/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts b/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts index 8f2781a1..828b12af 100644 --- a/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts +++ b/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts @@ -38,7 +38,7 @@ import { SMTP_USERNAME_PARAMETER_KEY, } from "./constants"; import { ParametersState } from "./FlowRenderer"; -import { AppNode, nodeTypes } from "./nodes"; +import { AppNode, isWorkflowBlockNode, WorkflowBlockNode } from "./nodes"; import { codeBlockNodeDefaultData } from "./nodes/CodeBlockNode/types"; import { downloadNodeDefaultData } from "./nodes/DownloadNode/types"; import { fileParserNodeDefaultData } from "./nodes/FileParserNode/types"; @@ -49,6 +49,7 @@ import { taskNodeDefaultData } from "./nodes/TaskNode/types"; import { textPromptNodeDefaultData } from "./nodes/TextPromptNode/types"; import { NodeBaseData } from "./nodes/types"; import { uploadNodeDefaultData } from "./nodes/UploadNode/types"; +import { StartNode } from "./nodes/StartNode/types"; export const NEW_NODE_LABEL_PREFIX = "block_"; @@ -313,6 +314,61 @@ function getNodeData( return data; } +export function defaultEdge(source: string, target: string) { + return { + id: nanoid(), + type: "default", + source, + target, + style: { + strokeWidth: 2, + }, + }; +} + +export function edgeWithAddButton(source: string, target: string) { + return { + id: nanoid(), + type: "edgeWithAddButton", + source, + target, + style: { + strokeWidth: 2, + }, + zIndex: REACT_FLOW_EDGE_Z_INDEX, + }; +} + +export function startNode(id: string, parentId?: string): StartNode { + const node: StartNode = { + id, + type: "start", + position: { x: 0, y: 0 }, + data: {}, + draggable: false, + connectable: false, + }; + if (parentId) { + node.parentId = parentId; + } + return node; +} + +export function nodeAdderNode(id: string, parentId?: string): NodeAdderNode { + const node: NodeAdderNode = { + id, + type: "nodeAdder", + position: { x: 0, y: 0 }, + data: {}, + draggable: false, + connectable: false, + }; + if (parentId) { + node.parentId = parentId; + } + return node; +} + function getElements(blocks: Array): { nodes: Array; edges: Array; @@ -331,64 +387,47 @@ function getElements(blocks: Array): { ); nodes.push(node); if (d.previous) { - edges.push({ - id: nanoid(), - type: "edgeWithAddButton", - source: d.previous, - target: d.id, - style: { - strokeWidth: 2, - }, - zIndex: REACT_FLOW_EDGE_Z_INDEX, - }); + edges.push(edgeWithAddButton(d.previous, d.id)); } }); const loopBlocks = data.filter((d) => d.block.block_type === "for_loop"); loopBlocks.forEach((block) => { + const startNodeId = nanoid(); + nodes.push(startNode(startNodeId, block.id)); const children = data.filter((b) => b.parentId === block.id); + if (children.length === 0) { + const adderNodeId = nanoid(); + nodes.push(nodeAdderNode(adderNodeId, block.id)); + edges.push(defaultEdge(startNodeId, adderNodeId)); + } else { + const firstChild = children.find((c) => c.previous === null)!; + edges.push(edgeWithAddButton(startNodeId, firstChild.id)); + } const lastChild = children.find((c) => c.next === null); - nodes.push({ - id: `${block.id}-nodeAdder`, - type: "nodeAdder", - position: { x: 0, y: 0 }, - data: {}, - draggable: false, - connectable: false, - parentId: block.id, - }); + const adderNodeId = nanoid(); + nodes.push(nodeAdderNode(adderNodeId, block.id)); if (lastChild) { - edges.push({ - id: `${block.id}-nodeAdder-edge`, - type: "default", - source: lastChild.id, - target: `${block.id}-nodeAdder`, - style: { - strokeWidth: 2, - }, - }); + edges.push(defaultEdge(lastChild.id, adderNodeId)); } }); - if (nodes.length > 0) { - const lastNode = data.find((d) => d.next === null && d.parentId === null); - edges.push({ - id: "edge-nodeAdder", - type: "default", - source: lastNode!.id, - target: "nodeAdder", - style: { - strokeWidth: 2, - }, - }); - nodes.push({ - id: "nodeAdder", - type: "nodeAdder", - position: { x: 0, y: 0 }, - data: {}, - draggable: false, - connectable: false, - }); + const startNodeId = nanoid(); + const adderNodeId = nanoid(); + + if (nodes.length === 0) { + nodes.push(startNode(startNodeId)); + nodes.push(nodeAdderNode(adderNodeId)); + edges.push(defaultEdge(startNodeId, adderNodeId)); + } else { + const firstNode = data.find( + (d) => d.previous === null && d.parentId === null, + ); + nodes.push(startNode(startNodeId)); + edges.push(edgeWithAddButton(startNodeId, firstNode!.id)); + const lastNode = data.find((d) => d.next === null && d.parentId === null)!; + edges.push(defaultEdge(lastNode.id, adderNodeId)); + nodes.push(nodeAdderNode(adderNodeId)); } return { nodes, edges }; @@ -396,7 +435,7 @@ function getElements(blocks: Array): { function createNode( identifiers: { id: string; parentId?: string }, - nodeType: Exclude, + nodeType: NonNullable, label: string, ): AppNode { const common = { @@ -503,9 +542,7 @@ function JSONParseSafe(json: string): Record | null { } } -function getWorkflowBlock( - node: Exclude, -): BlockYAML { +function getWorkflowBlock(node: WorkflowBlockNode): BlockYAML { const base = { label: node.data.label, continue_on_failure: node.data.continueOnFailure, @@ -616,22 +653,22 @@ function getWorkflowBlocksUtil(nodes: Array): Array { .filter((n) => n.parentId === node.id) .map((n) => { return getWorkflowBlock( - n as Exclude, + n as Exclude, ); }), }, ]; } return [ - getWorkflowBlock(node as Exclude), + getWorkflowBlock( + node as Exclude, + ), ]; }); } function getWorkflowBlocks(nodes: Array): Array { - return getWorkflowBlocksUtil( - nodes.filter((node) => node.type !== "nodeAdder"), - ); + return getWorkflowBlocksUtil(nodes.filter(isWorkflowBlockNode)); } function generateNodeLabel(existingLabels: Array) { @@ -892,7 +929,7 @@ function getAvailableOutputParameterKeys( previousNodeIds.includes(node.id), ); const labels = previousNodes - .filter((node) => node.type !== "nodeAdder") + .filter(isWorkflowBlockNode) .map((node) => node.data.label); const outputParameterKeys = labels.map((label) => getOutputParameterKey(label),