diff --git a/skyvern-frontend/src/components/icons/GarbageIcon.tsx b/skyvern-frontend/src/components/icons/GarbageIcon.tsx new file mode 100644 index 0000000000..a250203ac2 --- /dev/null +++ b/skyvern-frontend/src/components/icons/GarbageIcon.tsx @@ -0,0 +1,24 @@ +type Props = { + className: string; +}; + +function GarbageIcon({ className }: Props) { + return ( + + + + ); +} + +export { GarbageIcon }; diff --git a/skyvern-frontend/src/components/icons/SaveIcon.tsx b/skyvern-frontend/src/components/icons/SaveIcon.tsx new file mode 100644 index 0000000000..58b9405cbf --- /dev/null +++ b/skyvern-frontend/src/components/icons/SaveIcon.tsx @@ -0,0 +1,21 @@ +function SaveIcon() { + return ( + + + + ); +} + +export { SaveIcon }; diff --git a/skyvern-frontend/src/components/ui/popover.tsx b/skyvern-frontend/src/components/ui/popover.tsx new file mode 100644 index 0000000000..7193a84084 --- /dev/null +++ b/skyvern-frontend/src/components/ui/popover.tsx @@ -0,0 +1,31 @@ +import * as React from "react"; +import * as PopoverPrimitive from "@radix-ui/react-popover"; + +import { cn } from "@/util/utils"; + +const Popover = PopoverPrimitive.Root; + +const PopoverTrigger = PopoverPrimitive.Trigger; + +const PopoverAnchor = PopoverPrimitive.Anchor; + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)); +PopoverContent.displayName = PopoverPrimitive.Content.displayName; + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; diff --git a/skyvern-frontend/src/routes/workflows/Workflows.tsx b/skyvern-frontend/src/routes/workflows/Workflows.tsx index 010378b39f..6dabc6217e 100644 --- a/skyvern-frontend/src/routes/workflows/Workflows.tsx +++ b/skyvern-frontend/src/routes/workflows/Workflows.tsx @@ -31,15 +31,29 @@ import { CounterClockwiseClockIcon, Pencil2Icon, PlayIcon, + PlusIcon, + ReloadIcon, } from "@radix-ui/react-icons"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate, useSearchParams } from "react-router-dom"; import { WorkflowsBetaAlertCard } from "./WorkflowsBetaAlertCard"; import { WorkflowTitle } from "./WorkflowTitle"; +import { WorkflowCreateYAMLRequest } from "./types/workflowYamlTypes"; +import { stringify as convertToYAML } from "yaml"; + +const emptyWorkflowRequest: WorkflowCreateYAMLRequest = { + title: "New Workflow", + description: "", + workflow_definition: { + blocks: [], + parameters: [], + }, +}; function Workflows() { const credentialGetter = useCredentialGetter(); const navigate = useNavigate(); + const queryClient = useQueryClient(); const [searchParams, setSearchParams] = useSearchParams(); const workflowsPage = searchParams.get("workflowsPage") ? Number(searchParams.get("workflowsPage")) @@ -79,6 +93,27 @@ function Workflows() { }, }); + const createNewWorkflowMutation = useMutation({ + mutationFn: async () => { + const client = await getClient(credentialGetter); + const yaml = convertToYAML(emptyWorkflowRequest); + return client.post< + typeof emptyWorkflowRequest, + { data: WorkflowApiResponse } + >("/workflows", yaml, { + headers: { + "Content-Type": "text/plain", + }, + }); + }, + onSuccess: (response) => { + queryClient.invalidateQueries({ + queryKey: ["workflows"], + }); + navigate(`/workflows/${response.data.workflow_permanent_id}`); + }, + }); + if (workflows?.length === 0 && workflowsPage === 1) { return ; } @@ -115,8 +150,21 @@ function Workflows() { return (
-
+

Workflows

+
diff --git a/skyvern-frontend/src/routes/workflows/components/CodeEditor.tsx b/skyvern-frontend/src/routes/workflows/components/CodeEditor.tsx index baeeeff30f..275ac2e258 100644 --- a/skyvern-frontend/src/routes/workflows/components/CodeEditor.tsx +++ b/skyvern-frontend/src/routes/workflows/components/CodeEditor.tsx @@ -1,4 +1,4 @@ -import CodeMirror from "@uiw/react-codemirror"; +import CodeMirror, { EditorView } from "@uiw/react-codemirror"; import { json } from "@codemirror/lang-json"; import { python } from "@codemirror/lang-python"; import { tokyoNightStorm } from "@uiw/codemirror-theme-tokyo-night-storm"; @@ -22,7 +22,10 @@ function CodeEditor({ className, fontSize = 8, }: Props) { - const extensions = language === "json" ? [json()] : [python()]; + const extensions = + language === "json" + ? [json(), EditorView.lineWrapping] + : [python(), EditorView.lineWrapping]; return ( { + return parameters.map((parameter) => { + if (parameter.parameterType === "workflow") { + return { + parameter_type: "workflow", + key: parameter.key, + description: parameter.description || null, + workflow_parameter_type: parameter.dataType, + default_value: null, + }; + } else { + return { + parameter_type: "bitwarden_login_credential", + key: parameter.key, + description: parameter.description || null, + bitwarden_collection_id: parameter.collectionId, + url_parameter_key: parameter.urlParameterKey, + bitwarden_client_id_aws_secret_key: "SKYVERN_BITWARDEN_CLIENT_ID", + bitwarden_client_secret_aws_secret_key: + "SKYVERN_BITWARDEN_CLIENT_SECRET", + bitwarden_master_password_aws_secret_key: + "SKYVERN_BITWARDEN_MASTER_PASSWORD", + }; + } + }); +} + +export type ParametersState = Array< + | { + key: string; + parameterType: "workflow"; + dataType: WorkflowParameterValueType; + description?: string; + } + | { + key: string; + parameterType: "credential"; + collectionId: string; + urlParameterKey: string; + description?: string; + } +>; type Props = { - title: string; + initialTitle: string; initialNodes: Array; initialEdges: Array; + initialParameters: ParametersState; + handleSave: ( + parameters: Array< + WorkflowParameterYAML | BitwardenLoginCredentialParameterYAML + >, + blocks: Array, + title: string, + ) => void; +}; + +export type AddNodeProps = { + nodeType: Exclude; + previous: string | null; + next: string | null; + parent?: string; + connectingEdgeType: string; }; -function FlowRenderer({ title, initialEdges, initialNodes }: Props) { - const [rightSidePanelOpen, setRightSidePanelOpen] = useState(false); - const [rightSidePanelContent, setRightSidePanelContent] = useState< - "parameters" | "nodeLibrary" | null - >(null); +function FlowRenderer({ + initialTitle, + initialEdges, + initialNodes, + initialParameters, + handleSave, +}: Props) { + const { workflowPanelState, setWorkflowPanelState, closeWorkflowPanel } = + useWorkflowPanelStore(); const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); + const [parameters, setParameters] = useState(initialParameters); + const [title, setTitle] = useState(initialTitle); const nodesInitialized = useNodesInitialized(); function doLayout(nodes: Array, edges: Array) { @@ -45,62 +122,173 @@ function FlowRenderer({ title, initialEdges, initialNodes }: Props) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [nodesInitialized]); + function addNode({ + nodeType, + previous, + next, + parent, + connectingEdgeType, + }: 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)); + newNodes.push(node); + if (previous) { + const newEdge = { + id: `edge-${previous}-${id}`, + type: "edgeWithAddButton", + source: previous, + target: id, + style: { + strokeWidth: 2, + }, + }; + newEdges.push(newEdge); + } + if (next) { + const newEdge = { + id: `edge-${id}-${next}`, + type: connectingEdgeType, + source: id, + target: next, + style: { + strokeWidth: 2, + }, + }; + newEdges.push(newEdge); + } + + if (nodeType === "loop") { + newNodes.push({ + id: `${id}-nodeAdder`, + type: "nodeAdder", + parentId: id, + position: { x: 0, y: 0 }, + data: {}, + draggable: false, + connectable: false, + }); + } + + const editedEdges = previous + ? edges.filter((edge) => edge.source !== previous) + : edges; + + const previousNode = nodes.find((node) => node.id === previous); + const previousNodeIndex = previousNode + ? nodes.indexOf(previousNode) + : nodes.length - 1; + + const newNodesAfter = [ + ...nodes.slice(0, previousNodeIndex + 1), + ...newNodes, + ...nodes.slice(previousNodeIndex + 1), + ]; + doLayout(newNodesAfter, [...editedEdges, ...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} - colorMode="dark" - fitView - fitViewOptions={{ - maxZoom: 1, - }} - > - - - - { - setRightSidePanelOpen((open) => !open); - setRightSidePanelContent("parameters"); - }} - /> - - {rightSidePanelOpen && ( - - {rightSidePanelContent === "parameters" && ( - - )} + onNodesChange(changes); + }} + onEdgesChange={onEdgesChange} + nodeTypes={nodeTypes} + edgeTypes={edgeTypes} + colorMode="dark" + fitView + fitViewOptions={{ + maxZoom: 1, + }} + > + + + + { + 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 + /> + + )} + + ); } diff --git a/skyvern-frontend/src/routes/workflows/editor/WorkflowEditor.tsx b/skyvern-frontend/src/routes/workflows/editor/WorkflowEditor.tsx index 36c1ea519c..d7f4fef135 100644 --- a/skyvern-frontend/src/routes/workflows/editor/WorkflowEditor.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/WorkflowEditor.tsx @@ -1,16 +1,75 @@ -import { ReactFlowProvider } from "@xyflow/react"; import { useParams } from "react-router-dom"; import { useWorkflowQuery } from "../hooks/useWorkflowQuery"; -import { FlowRenderer } from "./FlowRenderer"; import { getElements } from "./workflowEditorUtils"; +import { useMutation } from "@tanstack/react-query"; +import { + BlockYAML, + ParameterYAML, + WorkflowCreateYAMLRequest, +} from "../types/workflowYamlTypes"; +import { getClient } from "@/api/AxiosClient"; +import { useCredentialGetter } from "@/hooks/useCredentialGetter"; +import { stringify as convertToYAML } from "yaml"; +import { ReactFlowProvider } from "@xyflow/react"; +import { FlowRenderer } from "./FlowRenderer"; +import { toast } from "@/components/ui/use-toast"; +import { AxiosError } from "axios"; function WorkflowEditor() { const { workflowPermanentId } = useParams(); + const credentialGetter = useCredentialGetter(); const { data: workflow, isLoading } = useWorkflowQuery({ workflowPermanentId, }); + const saveWorkflowMutation = useMutation({ + mutationFn: async (data: { + parameters: Array; + blocks: Array; + title: string; + }) => { + if (!workflow || !workflowPermanentId) { + return; + } + const client = await getClient(credentialGetter); + const requestBody: WorkflowCreateYAMLRequest = { + title: data.title, + description: workflow.description, + proxy_location: workflow.proxy_location, + webhook_callback_url: workflow.webhook_callback_url, + totp_verification_url: workflow.totp_verification_url, + workflow_definition: { + parameters: data.parameters, + blocks: data.blocks, + }, + is_saved_task: workflow.is_saved_task, + }; + const yaml = convertToYAML(requestBody); + return client + .put(`/workflows/${workflowPermanentId}`, yaml, { + headers: { + "Content-Type": "text/plain", + }, + }) + .then((response) => response.data); + }, + onSuccess: () => { + toast({ + title: "Changes saved", + description: "Your changes have been saved", + variant: "success", + }); + }, + onError: (error: AxiosError) => { + toast({ + title: "Error", + description: error.message, + variant: "destructive", + }); + }, + }); + // TODO if (isLoading) { return ( @@ -30,9 +89,38 @@ function WorkflowEditor() {
+ parameter.parameter_type === "workflow" || + parameter.parameter_type === "bitwarden_login_credential", + ) + .map((parameter) => { + if (parameter.parameter_type === "workflow") { + return { + key: parameter.key, + parameterType: "workflow", + dataType: parameter.workflow_parameter_type, + }; + } else { + return { + key: parameter.key, + parameterType: "credential", + collectionId: parameter.bitwarden_collection_id, + urlParameterKey: parameter.url_parameter_key, + }; + } + })} + handleSave={(parameters, blocks, title) => { + saveWorkflowMutation.mutate({ + parameters, + blocks, + title, + }); + }} />
diff --git a/skyvern-frontend/src/routes/workflows/editor/WorkflowHeader.tsx b/skyvern-frontend/src/routes/workflows/editor/WorkflowHeader.tsx index 9ca0a646fb..0a01721ef3 100644 --- a/skyvern-frontend/src/routes/workflows/editor/WorkflowHeader.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/WorkflowHeader.tsx @@ -1,3 +1,4 @@ +import { SaveIcon } from "@/components/icons/SaveIcon"; import { Button } from "@/components/ui/button"; import { ChevronDownIcon, @@ -6,17 +7,22 @@ import { PlayIcon, } from "@radix-ui/react-icons"; import { useNavigate, useParams } from "react-router-dom"; +import { EditableNodeTitle } from "./nodes/components/EditableNodeTitle"; type Props = { title: string; parametersPanelOpen: boolean; onParametersClick: () => void; + onSave: () => void; + onTitleChange: (title: string) => void; }; function WorkflowHeader({ title, parametersPanelOpen, onParametersClick, + onSave, + onTitleChange, }: Props) { const { workflowPermanentId } = useParams(); const navigate = useNavigate(); @@ -24,17 +30,34 @@ function WorkflowHeader({ return (
-
{ - navigate("/workflows"); - }} - > - +
+
{ + navigate("/workflows"); + }} + > + +
+
+
{ + onSave(); + }} + > + +
+
-
- {title} +
+
+
+ + + ); +} + +export { EdgeWithAddButton }; diff --git a/skyvern-frontend/src/routes/workflows/editor/edges/index.ts b/skyvern-frontend/src/routes/workflows/editor/edges/index.ts new file mode 100644 index 0000000000..ba4884c4f7 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/editor/edges/index.ts @@ -0,0 +1,5 @@ +import { EdgeWithAddButton } from "./EdgeWithAddButton"; + +export const edgeTypes = { + edgeWithAddButton: EdgeWithAddButton, +}; 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 9e710fd8d4..7bb424d948 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/CodeBlockNode/CodeBlockNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/CodeBlockNode/CodeBlockNode.tsx @@ -1,10 +1,13 @@ -import { Handle, NodeProps, Position } from "@xyflow/react"; +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 { EditableNodeTitle } from "../components/EditableNodeTitle"; + +function CodeBlockNode({ id, data }: NodeProps) { + const { updateNodeData } = useReactFlow(); -function CodeBlockNode({ data }: NodeProps) { return (
) {
- {data.label} - Task Block + updateNodeData(id, { label: value })} + /> + Code Block
@@ -39,9 +46,11 @@ function CodeBlockNode({ data }: NodeProps) { { - if (!data.editable) return; - // TODO + onChange={(value) => { + if (!data.editable) { + return; + } + updateNodeData(id, { code: value }); }} className="nopan" /> diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/CodeBlockNode/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/CodeBlockNode/types.ts index eb06e30086..4f52cd2075 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/CodeBlockNode/types.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/CodeBlockNode/types.ts @@ -7,3 +7,9 @@ export type CodeBlockNodeData = { }; export type CodeBlockNode = Node; + +export const codeBlockNodeDefaultData: CodeBlockNodeData = { + editable: true, + label: "", + code: "", +} as const; 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 094430e219..0754cd234b 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/DownloadNode/DownloadNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/DownloadNode/DownloadNode.tsx @@ -1,10 +1,13 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { DotsHorizontalIcon, DownloadIcon } from "@radix-ui/react-icons"; -import { Handle, NodeProps, Position } from "@xyflow/react"; +import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react"; import type { DownloadNode } from "./types"; +import { EditableNodeTitle } from "../components/EditableNodeTitle"; + +function DownloadNode({ id, data }: NodeProps) { + const { updateNodeData } = useReactFlow(); -function DownloadNode({ data }: NodeProps) { return (
) {
- {data.label} + updateNodeData(id, { label: value })} + /> Download Block
@@ -39,11 +46,11 @@ function DownloadNode({ data }: NodeProps) { { + onChange={(event) => { if (!data.editable) { return; } - // TODO + updateNodeData(id, { url: event.target.value }); }} className="nopan" /> diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/DownloadNode/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/DownloadNode/types.ts index ecdf66bfe0..01ddc4dcdc 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/DownloadNode/types.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/DownloadNode/types.ts @@ -7,3 +7,9 @@ export type DownloadNodeData = { }; export type DownloadNode = Node; + +export const downloadNodeDefaultData: DownloadNodeData = { + editable: true, + label: "", + url: "SKYVERN_DOWNLOAD_DIRECTORY", +} as const; 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 eb25cad620..b708e46b7a 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/FileParserNode/FileParserNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/FileParserNode/FileParserNode.tsx @@ -1,9 +1,11 @@ -import { Handle, NodeProps, Position } from "@xyflow/react"; +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 { EditableNodeTitle } from "../components/EditableNodeTitle"; -function FileParserNode({ data }: NodeProps) { +function FileParserNode({ id, data }: NodeProps) { + const { updateNodeData } = useReactFlow(); return (
) {
- {data.label} + updateNodeData(id, { label: value })} + /> File Parser Block
@@ -38,11 +44,11 @@ function FileParserNode({ data }: NodeProps) { File URL { + onChange={(event) => { if (!data.editable) { return; } - // TODO + updateNodeData(id, { fileUrl: event.target.value }); }} className="nopan" /> diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/FileParserNode/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/FileParserNode/types.ts index 258c92193f..7e1e3fa2a0 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/FileParserNode/types.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/FileParserNode/types.ts @@ -7,3 +7,9 @@ export type FileParserNodeData = { }; export type FileParserNode = Node; + +export const fileParserNodeDefaultData: FileParserNodeData = { + editable: true, + label: "", + fileUrl: "", +} as const; 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 4d3724fb29..8650d51d66 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/LoopNode/LoopNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/LoopNode/LoopNode.tsx @@ -1,11 +1,19 @@ import { DotsHorizontalIcon, UpdateIcon } from "@radix-ui/react-icons"; -import { Handle, NodeProps, Position, useNodes } from "@xyflow/react"; +import { + Handle, + NodeProps, + Position, + 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"; function LoopNode({ id, data }: NodeProps) { + const { updateNodeData } = useReactFlow(); const nodes = useNodes(); const children = nodes.filter((node) => node.parentId === id); const furthestDownChild: Node | null = children.reduce( @@ -54,7 +62,11 @@ function LoopNode({ id, data }: NodeProps) {
- {data.label} + updateNodeData(id, { label: value })} + /> Loop Block
@@ -66,11 +78,11 @@ function LoopNode({ id, data }: NodeProps) { { + onChange={(event) => { if (!data.editable) { return; } - // TODO + updateNodeData(id, { loopValue: event.target.value }); }} placeholder="What value are you iterating over?" className="nopan" diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/LoopNode/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/LoopNode/types.ts index 79bf68958b..9b20991241 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/LoopNode/types.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/LoopNode/types.ts @@ -7,3 +7,9 @@ export type LoopNodeData = { }; export type LoopNode = Node; + +export const loopNodeDefaultData: LoopNodeData = { + editable: true, + label: "", + loopValue: "", +} as const; diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/NodeAdderNode/NodeAdderNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/NodeAdderNode/NodeAdderNode.tsx new file mode 100644 index 0000000000..92407c991e --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/NodeAdderNode/NodeAdderNode.tsx @@ -0,0 +1,48 @@ +import { Handle, NodeProps, Position, useEdges } from "@xyflow/react"; +import type { NodeAdderNode } from "./types"; +import { PlusIcon } from "@radix-ui/react-icons"; +import { useWorkflowPanelStore } from "@/store/WorkflowPanelStore"; + +function NodeAdderNode({ id, parentId }: NodeProps) { + const edges = useEdges(); + const setWorkflowPanelState = useWorkflowPanelStore( + (state) => state.setWorkflowPanelState, + ); + + return ( +
+ + +
{ + const previous = edges.find((edge) => edge.target === id)?.source; + setWorkflowPanelState({ + active: true, + content: "nodeLibrary", + data: { + previous: previous ?? null, + next: id, + parent: parentId, + connectingEdgeType: "default", + }, + }); + }} + > + +
+
+ ); +} + +export { NodeAdderNode }; diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/NodeAdderNode/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/NodeAdderNode/types.ts new file mode 100644 index 0000000000..ef71b6a041 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/NodeAdderNode/types.ts @@ -0,0 +1,5 @@ +import type { Node } from "@xyflow/react"; + +export type NodeAdderNodeData = Record; + +export type NodeAdderNode = Node; 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 5cc96cd619..eab775fccc 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/SendEmailNode/SendEmailNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/SendEmailNode/SendEmailNode.tsx @@ -1,12 +1,14 @@ -import { Handle, NodeProps, Position } from "@xyflow/react"; +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 { Separator } from "@/components/ui/separator"; -import { Switch } from "@/components/ui/switch"; +import { EditableNodeTitle } from "../components/EditableNodeTitle"; + +function SendEmailNode({ id, data }: NodeProps) { + const { updateNodeData } = useReactFlow(); -function SendEmailNode({ data }: NodeProps) { return (
) {
- {data.label} + updateNodeData(id, { label: value })} + /> Send Email Block
@@ -37,24 +43,42 @@ function SendEmailNode({ data }: NodeProps) {
- + { - if (!data.editable) return; - // TODO + onChange={(event) => { + if (!data.editable) { + return; + } + updateNodeData(id, { sender: event.target.value }); }} - value={data.recipients.join(", ")} + value={data.sender} placeholder="example@gmail.com" className="nopan" />
+
+ + { + if (!data.editable) { + return; + } + updateNodeData(id, { recipients: event.target.value }); + }} + value={data.recipients} + placeholder="example@gmail.com, example2@gmail.com..." + className="nopan" + /> +
{ - if (!data.editable) return; - // TODO + onChange={(event) => { + if (!data.editable) { + return; + } + updateNodeData(id, { subject: event.target.value }); }} value={data.subject} placeholder="What is the gist?" @@ -64,9 +88,11 @@ function SendEmailNode({ data }: NodeProps) {
{ - if (!data.editable) return; - // TODO + onChange={(event) => { + if (!data.editable) { + return; + } + updateNodeData(id, { body: event.target.value }); }} value={data.body} placeholder="What would you like to say?" @@ -77,21 +103,16 @@ function SendEmailNode({ data }: NodeProps) {
{ - if (!data.editable) return; - // TODO + value={data.fileAttachments} + onChange={(event) => { + if (!data.editable) { + return; + } + updateNodeData(id, { fileAttachments: event.target.value }); }} className="nopan" />
- -
- - -
); diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/SendEmailNode/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/SendEmailNode/types.ts index b5ba87ce25..bf2713ea66 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/SendEmailNode/types.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/SendEmailNode/types.ts @@ -1,12 +1,23 @@ import type { Node } from "@xyflow/react"; export type SendEmailNodeData = { - recipients: string[]; + recipients: string; subject: string; body: string; - fileAttachments: string[] | null; + fileAttachments: string; editable: boolean; label: string; + sender: string; }; export type SendEmailNode = Node; + +export const sendEmailNodeDefaultData: SendEmailNodeData = { + recipients: "", + subject: "", + body: "", + fileAttachments: "", + editable: true, + label: "", + sender: "", +} as const; 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 93f4082b45..0cd5e7ce7c 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/TaskNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/TaskNode.tsx @@ -1,23 +1,35 @@ -import { Handle, NodeProps, Position } from "@xyflow/react"; -import { useState } from "react"; -import { DotsHorizontalIcon, ListBulletIcon } from "@radix-ui/react-icons"; -import { TaskNodeDisplayModeSwitch } from "./TaskNodeDisplayModeSwitch"; -import type { TaskNodeDisplayMode } from "./types"; -import type { TaskNode } from "./types"; import { AutoResizingTextarea } from "@/components/AutoResizingTextarea/AutoResizingTextarea"; -import { Label } from "@/components/ui/label"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; -import { DataSchema } from "../../../components/DataSchema"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; import { Switch } from "@/components/ui/switch"; -import { TaskNodeErrorMapping } from "./TaskNodeErrorMapping"; +import { CodeEditor } from "@/routes/workflows/components/CodeEditor"; +import { + DotsHorizontalIcon, + ListBulletIcon, + MixerVerticalIcon, +} from "@radix-ui/react-icons"; +import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react"; +import { useState } from "react"; +import { TaskNodeDisplayModeSwitch } from "./TaskNodeDisplayModeSwitch"; +import { TaskNodeParametersPanel } from "./TaskNodeParametersPanel"; +import type { TaskNode, TaskNodeDisplayMode } from "./types"; +import { EditableNodeTitle } from "../components/EditableNodeTitle"; -function TaskNode({ data }: NodeProps) { +function TaskNode({ id, data }: NodeProps) { + const { updateNodeData } = useReactFlow(); const [displayMode, setDisplayMode] = useState("basic"); const { editable } = data; @@ -28,9 +40,12 @@ function TaskNode({ data }: NodeProps) { { - if (!editable) return; - // TODO + name="url" + onChange={(event) => { + if (!editable) { + return; + } + updateNodeData(id, { url: event.target.value }); }} placeholder="https://" /> @@ -38,9 +53,11 @@ function TaskNode({ data }: NodeProps) {
{ - if (!editable) return; - // TODO + onChange={(event) => { + if (!editable) { + return; + } + updateNodeData(id, { navigationGoal: event.target.value }); }} value={data.navigationGoal} placeholder="What are you looking to do?" @@ -63,9 +80,11 @@ function TaskNode({ data }: NodeProps) {
{ - if (!editable) return; - // TODO + onChange={(event) => { + if (!editable) { + return; + } + updateNodeData(id, { url: event.target.value }); }} value={data.url} placeholder="https://" @@ -75,9 +94,11 @@ function TaskNode({ data }: NodeProps) {
{ - if (!editable) return; - // TODO + onChange={(event) => { + if (!editable) { + return; + } + updateNodeData(id, { navigationGoal: event.target.value }); }} value={data.navigationGoal} placeholder="What are you looking to do?" @@ -96,28 +117,56 @@ function TaskNode({ data }: NodeProps) { Data Extraction Goal { - if (!editable) return; - // TODO + onChange={(event) => { + if (!editable) { + return; + } + updateNodeData(id, { + dataExtractionGoal: event.target.value, + }); }} value={data.dataExtractionGoal} placeholder="What outputs are you looking to get?" className="nopan" />
- { - if (!editable) return; - // TODO - }} - /> +
+
+ + { + if (!editable) { + return; + } + updateNodeData(id, { + dataSchema: checked ? "{}" : "null", + }); + }} + /> +
+ {data.dataSchema !== "null" && ( +
+ { + if (!editable) { + return; + } + updateNodeData(id, { dataSchema: value }); + }} + className="nowheel nopan" + /> +
+ )} +
Limits - +
@@ -142,10 +196,15 @@ function TaskNode({ data }: NodeProps) { type="number" placeholder="0" className="nopan w-44" + min="0" value={data.maxStepsOverride ?? 0} - onChange={() => { - if (!editable) return; - // TODO + onChange={(event) => { + if (!editable) { + return; + } + updateNodeData(id, { + maxStepsOverride: Number(event.target.value), + }); }} />
@@ -156,20 +215,49 @@ function TaskNode({ data }: NodeProps) {
{ - if (!editable) return; - // TODO + onCheckedChange={(checked) => { + if (!editable) { + return; + } + updateNodeData(id, { allowDownloads: checked }); }} />
- { - if (!editable) return; - // TODO - }} - /> +
+
+ + { + if (!editable) { + return; + } + updateNodeData(id, { + errorCodeMapping: checked ? "{}" : "null", + }); + }} + /> +
+ {data.errorCodeMapping !== "null" && ( +
+ { + if (!editable) { + return; + } + updateNodeData(id, { errorCodeMapping: value }); + }} + className="nowheel nopan" + /> +
+ )} +
@@ -198,7 +286,11 @@ function TaskNode({ data }: NodeProps) {
- {data.label} + updateNodeData(id, { label: value })} + /> Task Block
@@ -206,10 +298,28 @@ function TaskNode({ data }: NodeProps) { - +
+ + + + + + + { + updateNodeData(id, { parameterKeys }); + }} + /> + + +
+ {displayMode === "basic" && basicContent} {displayMode === "advanced" && advancedContent} diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/TaskNodeErrorMapping.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/TaskNodeErrorMapping.tsx deleted file mode 100644 index 553d00f5b2..0000000000 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/TaskNodeErrorMapping.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { Checkbox } from "@/components/ui/checkbox"; -import { Label } from "@/components/ui/label"; -import { CodeEditor } from "@/routes/workflows/components/CodeEditor"; - -type Props = { - value: Record | null; - onChange: (value: Record | null) => void; - disabled?: boolean; -}; - -function TaskNodeErrorMapping({ value, onChange, disabled }: Props) { - if (value === null) { - return ( -
- - { - onChange({}); - }} - /> -
- ); - } - - return ( -
-
- - { - onChange(null); - }} - /> -
-
- { - if (disabled) { - return; - } - // TODO - }} - className="nowheel nopan" - /> -
-
- ); -} - -export { TaskNodeErrorMapping }; diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/TaskNodeParametersPanel.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/TaskNodeParametersPanel.tsx new file mode 100644 index 0000000000..79b134288b --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/TaskNodeParametersPanel.tsx @@ -0,0 +1,50 @@ +import { Checkbox } from "@/components/ui/checkbox"; +import { useWorkflowParametersState } from "../../useWorkflowParametersState"; + +type Props = { + parameters: Array; + onParametersChange: (parameters: Array) => void; +}; + +function TaskNodeParametersPanel({ parameters, onParametersChange }: Props) { + const [workflowParameters] = useWorkflowParametersState(); + + return ( +
+
+

Parameters

+ + Check off the parameters you want to use in this task. + +
+
+ {workflowParameters.map((workflowParameter) => { + return ( +
+ { + if (checked) { + onParametersChange([...parameters, workflowParameter.key]); + } else { + onParametersChange( + parameters.filter( + (parameter) => parameter !== workflowParameter.key, + ), + ); + } + }} + /> + {workflowParameter.key} +
+ ); + })} +
+
+ ); +} + +export { TaskNodeParametersPanel }; 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 ac79c10d7b..2c372e2bca 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/types.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/types.ts @@ -4,15 +4,30 @@ export type TaskNodeData = { url: string; navigationGoal: string; dataExtractionGoal: string; - errorCodeMapping: Record | null; - dataSchema: Record | null; + errorCodeMapping: string; + dataSchema: string; maxRetries: number | null; maxStepsOverride: number | null; allowDownloads: boolean; editable: boolean; label: string; + parameterKeys: Array; }; export type TaskNode = Node; export type TaskNodeDisplayMode = "basic" | "advanced"; + +export const taskNodeDefaultData: TaskNodeData = { + url: "", + navigationGoal: "", + dataExtractionGoal: "", + errorCodeMapping: "null", + dataSchema: "null", + maxRetries: null, + maxStepsOverride: null, + allowDownloads: false, + editable: true, + label: "", + parameterKeys: [], +} as const; diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/TextPromptNode/TextPromptNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/TextPromptNode/TextPromptNode.tsx index 4ba1d98728..ffc260dca4 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/TextPromptNode/TextPromptNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/TextPromptNode/TextPromptNode.tsx @@ -1,12 +1,17 @@ import { CursorTextIcon, DotsHorizontalIcon } from "@radix-ui/react-icons"; -import { Handle, NodeProps, Position } from "@xyflow/react"; +import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react"; import type { TextPromptNode } from "./types"; import { Label } from "@/components/ui/label"; import { AutoResizingTextarea } from "@/components/AutoResizingTextarea/AutoResizingTextarea"; import { Separator } from "@/components/ui/separator"; -import { DataSchema } from "@/routes/workflows/components/DataSchema"; +import { Checkbox } from "@/components/ui/checkbox"; +import { CodeEditor } from "@/routes/workflows/components/CodeEditor"; +import { EditableNodeTitle } from "../components/EditableNodeTitle"; + +function TextPromptNode({ id, data }: NodeProps) { + const { updateNodeData } = useReactFlow(); + const { editable } = data; -function TextPromptNode({ data }: NodeProps) { return (
) {
- {data.label} + updateNodeData(id, { label: value })} + /> Text Prompt Block
@@ -39,9 +48,11 @@ function TextPromptNode({ data }: NodeProps) {
{ - if (!data.editable) return; - // TODO + onChange={(event) => { + if (!editable) { + return; + } + updateNodeData(id, { prompt: event.target.value }); }} value={data.prompt} placeholder="What do you want to generate?" @@ -49,13 +60,37 @@ function TextPromptNode({ data }: NodeProps) { />
- { - if (!data.editable) return; - // TODO - }} - /> +
+
+ + { + if (!editable) { + return; + } + updateNodeData(id, { + jsonSchema: checked ? "{}" : "null", + }); + }} + /> +
+ {data.jsonSchema !== "null" && ( +
+ { + if (!editable) { + return; + } + updateNodeData(id, { jsonSchema: value }); + }} + className="nowheel nopan" + /> +
+ )} +
); diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/TextPromptNode/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/TextPromptNode/types.ts index 26cbe902b3..b5ad84b4f2 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/TextPromptNode/types.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/TextPromptNode/types.ts @@ -2,9 +2,16 @@ import type { Node } from "@xyflow/react"; export type TextPromptNodeData = { prompt: string; - jsonSchema: Record | null; + jsonSchema: string; editable: boolean; label: string; }; export type TextPromptNode = Node; + +export const textPromptNodeDefaultData: TextPromptNodeData = { + editable: true, + label: "", + prompt: "", + jsonSchema: "null", +} as const; 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 67081f9df4..7a1e127a62 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/UploadNode/UploadNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/UploadNode/UploadNode.tsx @@ -1,10 +1,13 @@ -import { Handle, NodeProps, Position } from "@xyflow/react"; +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 { EditableNodeTitle } from "../components/EditableNodeTitle"; + +function UploadNode({ id, data }: NodeProps) { + const { updateNodeData } = useReactFlow(); -function UploadNode({ data }: NodeProps) { return (
) {
- {data.label} + updateNodeData(id, { label: value })} + /> Upload Block
@@ -39,11 +46,11 @@ function UploadNode({ data }: NodeProps) { { + onChange={(event) => { if (!data.editable) { return; } - // TODO + updateNodeData(id, { path: event.target.value }); }} className="nopan" /> diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/UploadNode/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/UploadNode/types.ts index cd0629a64c..88d2a918e2 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/UploadNode/types.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/UploadNode/types.ts @@ -7,3 +7,9 @@ export type UploadNodeData = { }; export type UploadNode = Node; + +export const uploadNodeDefaultData: UploadNodeData = { + editable: true, + label: "", + path: "SKYVERN_DOWNLOAD_DIRECTORY", +} as const; diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/components/EditableNodeTitle.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/components/EditableNodeTitle.tsx new file mode 100644 index 0000000000..5ecaca283a --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/components/EditableNodeTitle.tsx @@ -0,0 +1,62 @@ +import { Input } from "@/components/ui/input"; +import { cn } from "@/util/utils"; +import { useLayoutEffect, useRef } from "react"; + +type Props = { + value: string; + editable: boolean; + onChange: (value: string) => void; + className?: string; +}; + +function EditableNodeTitle({ value, editable, onChange, className }: Props) { + const ref = useRef(null); + + useLayoutEffect(() => { + // size the textarea correctly on first render + if (!ref.current) { + return; + } + ref.current.style.width = `${ref.current.scrollWidth + 2}px`; + }, []); + + function setSize() { + if (!ref.current) { + return; + } + ref.current.style.width = "auto"; + ref.current.style.width = `${ref.current.scrollWidth + 2}px`; + } + + return ( + { + if (!editable) { + event.currentTarget.value = value; + return; + } + onChange(event.target.value); + }} + onKeyDown={(event) => { + if (!editable) { + return; + } + if (event.key === "Enter") { + event.currentTarget.blur(); + } + if (event.key === "Escape") { + event.currentTarget.value = value; + event.currentTarget.blur(); + } + setSize(); + }} + onInput={setSize} + defaultValue={value} + /> + ); +} + +export { EditableNodeTitle }; diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/index.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/index.ts index 61dc08efab..0d5c9591ef 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/index.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/index.ts @@ -15,6 +15,8 @@ import type { UploadNode } from "./UploadNode/types"; import { UploadNode as UploadNodeComponent } from "./UploadNode/UploadNode"; 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"; export type AppNode = | LoopNode @@ -24,7 +26,8 @@ export type AppNode = | CodeBlockNode | FileParserNode | UploadNode - | DownloadNode; + | DownloadNode + | NodeAdderNode; export const nodeTypes = { loop: memo(LoopNodeComponent), @@ -35,4 +38,5 @@ export const nodeTypes = { fileParser: memo(FileParserNodeComponent), upload: memo(UploadNodeComponent), download: memo(DownloadNodeComponent), + nodeAdder: memo(NodeAdderNodeComponent), }; diff --git a/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowNodeLibraryPanel.tsx b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowNodeLibraryPanel.tsx new file mode 100644 index 0000000000..b96327c22b --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowNodeLibraryPanel.tsx @@ -0,0 +1,149 @@ +import { useWorkflowPanelStore } from "@/store/WorkflowPanelStore"; +import { + CodeIcon, + Cross2Icon, + CursorTextIcon, + DownloadIcon, + EnvelopeClosedIcon, + FileIcon, + ListBulletIcon, + PlusIcon, + UpdateIcon, + UploadIcon, +} from "@radix-ui/react-icons"; +import { nodeTypes } from "../nodes"; +import { AddNodeProps } from "../FlowRenderer"; + +const nodeLibraryItems: Array<{ + nodeType: Exclude; + icon: JSX.Element; + title: string; + description: string; +}> = [ + { + nodeType: "task", + icon: , + title: "Task Block", + description: "Takes actions or extracts information", + }, + { + nodeType: "loop", + icon: , + title: "For Loop Block", + description: "Repeats nested elements", + }, + { + nodeType: "textPrompt", + icon: , + title: "Text Prompt Block", + description: "Generates AI response", + }, + { + nodeType: "sendEmail", + icon: , + title: "Send Email Block", + description: "Sends an email", + }, + { + nodeType: "codeBlock", + icon: , + title: "Code Block", + description: "Executes Python code", + }, + { + nodeType: "fileParser", + icon: , + title: "File Parser Block", + description: "Downloads and parses a file", + }, + { + nodeType: "download", + icon: , + title: "Download Block", + description: "Downloads a file from S3", + }, + { + nodeType: "upload", + icon: , + title: "Upload Block", + description: "Uploads a file to S3", + }, +]; + +type Props = { + onNodeClick: (props: AddNodeProps) => void; + first?: boolean; +}; + +function WorkflowNodeLibraryPanel({ onNodeClick, first }: Props) { + const workflowPanelData = useWorkflowPanelStore( + (state) => state.workflowPanelState.data, + ); + const closeWorkflowPanel = useWorkflowPanelStore( + (state) => state.closeWorkflowPanel, + ); + + return ( +
+
+
+
+

Node Library

+ {!first && ( + { + closeWorkflowPanel(); + }} + /> + )} +
+ + {first + ? "Click on the node type to add your first node" + : "Click on the node type you want to add"} + +
+
+ {nodeLibraryItems.map((item) => { + return ( +
{ + onNodeClick({ + nodeType: item.nodeType, + next: workflowPanelData?.next ?? null, + parent: workflowPanelData?.parent, + previous: workflowPanelData?.previous ?? null, + connectingEdgeType: + workflowPanelData?.connectingEdgeType ?? + "edgeWithAddButton", + }); + closeWorkflowPanel(); + }} + > +
+
+ {item.icon} +
+
+ + {item.title} + + + {item.description} + +
+
+ +
+ ); + })} +
+
+
+ ); +} + +export { WorkflowNodeLibraryPanel }; diff --git a/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowParameterAddPanel.tsx b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowParameterAddPanel.tsx new file mode 100644 index 0000000000..f52330b8ca --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowParameterAddPanel.tsx @@ -0,0 +1,129 @@ +import { Cross2Icon } from "@radix-ui/react-icons"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { useState } from "react"; +import { WorkflowParameterValueType } from "../../types/workflowTypes"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Button } from "@/components/ui/button"; +import { ParametersState } from "../FlowRenderer"; + +type Props = { + type: "workflow" | "credential"; + onClose: () => void; + onSave: (value: ParametersState[number]) => void; +}; + +const workflowParameterTypeOptions = [ + { label: "string", value: WorkflowParameterValueType.String }, + { label: "number", value: WorkflowParameterValueType.Float }, + { label: "boolean", value: WorkflowParameterValueType.Boolean }, + { label: "file", value: WorkflowParameterValueType.FileURL }, + { label: "JSON", value: WorkflowParameterValueType.JSON }, +]; + +function WorkflowParameterAddPanel({ type, onClose, onSave }: Props) { + const [key, setKey] = useState(""); + const [urlParameterKey, setUrlParameterKey] = useState(""); + const [description, setDescription] = useState(""); + const [collectionId, setCollectionId] = useState(""); + const [parameterType, setParameterType] = + useState("string"); + + return ( +
+
+ + Add {type === "workflow" ? "Workflow" : "Credential"} Parameter + + +
+
+ + setKey(e.target.value)} /> +
+
+ + setDescription(e.target.value)} + /> +
+ {type === "workflow" && ( +
+ + +
+ )} + {type === "credential" && ( + <> +
+ + setUrlParameterKey(e.target.value)} + /> +
+
+ + setCollectionId(e.target.value)} + /> +
+ + )} +
+ +
+
+ ); +} + +export { WorkflowParameterAddPanel }; diff --git a/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowParameterEditPanel.tsx b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowParameterEditPanel.tsx new file mode 100644 index 0000000000..33c515d465 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowParameterEditPanel.tsx @@ -0,0 +1,149 @@ +import { Cross2Icon } from "@radix-ui/react-icons"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { useState } from "react"; +import { WorkflowParameterValueType } from "../../types/workflowTypes"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Button } from "@/components/ui/button"; +import { ParametersState } from "../FlowRenderer"; + +type Props = { + type: "workflow" | "credential"; + onClose: () => void; + onSave: (value: ParametersState[number]) => void; + initialValues: ParametersState[number]; +}; + +const workflowParameterTypeOptions = [ + { label: "string", value: WorkflowParameterValueType.String }, + { label: "number", value: WorkflowParameterValueType.Float }, + { label: "boolean", value: WorkflowParameterValueType.Boolean }, + { label: "file", value: WorkflowParameterValueType.FileURL }, + { label: "JSON", value: WorkflowParameterValueType.JSON }, +]; + +function WorkflowParameterEditPanel({ + type, + onClose, + onSave, + initialValues, +}: Props) { + const [key, setKey] = useState(initialValues.key); + const [urlParameterKey, setUrlParameterKey] = useState( + initialValues.parameterType === "credential" + ? initialValues.urlParameterKey + : "", + ); + const [description, setDescription] = useState( + initialValues.description || "", + ); + const [collectionId, setCollectionId] = useState( + initialValues.parameterType === "credential" + ? initialValues.collectionId + : "", + ); + const [parameterType, setParameterType] = + useState( + initialValues.parameterType === "workflow" + ? initialValues.dataType + : "string", + ); + + return ( +
+
+ + Edit {type === "workflow" ? "Workflow" : "Credential"} Parameter + + +
+
+ + setKey(e.target.value)} /> +
+
+ + setDescription(e.target.value)} + /> +
+ {type === "workflow" && ( +
+ + +
+ )} + {type === "credential" && ( + <> +
+ + setUrlParameterKey(e.target.value)} + /> +
+
+ + setCollectionId(e.target.value)} + /> +
+ + )} +
+ +
+
+ ); +} + +export { WorkflowParameterEditPanel }; diff --git a/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowParametersPanel.tsx b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowParametersPanel.tsx index e265c249b6..c377639157 100644 --- a/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowParametersPanel.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowParametersPanel.tsx @@ -1,45 +1,227 @@ -import { useParams } from "react-router-dom"; -import { useWorkflowQuery } from "../../hooks/useWorkflowQuery"; +import { useState } from "react"; +import { useWorkflowParametersState } from "../useWorkflowParametersState"; +import { WorkflowParameterAddPanel } from "./WorkflowParameterAddPanel"; +import { ParametersState } from "../FlowRenderer"; +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, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; -function WorkflowParametersPanel() { - const { workflowPermanentId } = useParams(); +const WORKFLOW_EDIT_PANEL_WIDTH = 20 * 16; +const WORKFLOW_EDIT_PANEL_GAP = 1 * 16; - const { data: workflow, isLoading } = useWorkflowQuery({ - workflowPermanentId, +function WorkflowParametersPanel() { + const [workflowParameters, setWorkflowParameters] = + useWorkflowParametersState(); + const [operationPanelState, setOperationPanelState] = useState<{ + active: boolean; + operation: "add" | "edit"; + parameter?: ParametersState[number] | null; + type: "workflow" | "credential"; + }>({ + active: false, + operation: "add", + parameter: null, + type: "workflow", }); - if (isLoading || !workflow) { - return null; - } - - const workflowParameters = workflow.workflow_definition.parameters.filter( - (parameter) => parameter.parameter_type === "workflow", - ); - return ( -
-
-

Workflow Parameters

- - Create placeholder values that you can link in nodes. You will be - prompted to fill them in before running your workflow. - -
-
- {workflowParameters.map((parameter) => { - return ( -
+
+
+

Workflow Parameters

+ + Create placeholder values that you can link in nodes. You will be + prompted to fill them in before running your workflow. + +
+ + + + + + Add Parameter + + { + setOperationPanelState({ + active: true, + operation: "add", + type: "workflow", + }); + }} + > + Workflow Parameter + + { + setOperationPanelState({ + active: true, + operation: "add", + type: "credential", + }); + }} > - {parameter.key} - - {parameter.workflow_parameter_type} - + Credential Parameter + + + + +
+ {workflowParameters.map((parameter) => { + return ( +
+
+ {parameter.key} + {parameter.parameterType === "workflow" ? ( + + {parameter.dataType} + + ) : ( + + {parameter.parameterType} + + )} +
+
+ { + setOperationPanelState({ + active: true, + operation: "edit", + parameter: parameter, + type: parameter.parameterType, + }); + }} + /> + + + + + + + Are you sure? + + This parameter will be deleted. + + + + + + + + + + +
+
+ ); + })} +
+
+ {operationPanelState.active && ( +
+ {operationPanelState.operation === "add" && ( +
+ { + setWorkflowParameters([...workflowParameters, parameter]); + setOperationPanelState({ + active: false, + operation: "add", + type: "workflow", + }); + }} + onClose={() => { + setOperationPanelState({ + active: false, + operation: "add", + type: "workflow", + }); + }} + />
- ); - })} -
+ )} + {operationPanelState.operation === "edit" && + operationPanelState.parameter && ( +
+ { + setWorkflowParameters( + workflowParameters.map((parameter) => { + if ( + parameter.key === operationPanelState.parameter?.key + ) { + return editedParameter; + } + return parameter; + }), + ); + setOperationPanelState({ + active: false, + operation: "edit", + parameter: null, + type: "workflow", + }); + }} + onClose={() => { + setOperationPanelState({ + active: false, + operation: "edit", + parameter: null, + type: "workflow", + }); + }} + /> +
+ )} +
+ )} ); } diff --git a/skyvern-frontend/src/routes/workflows/editor/useWorkflowParametersState.ts b/skyvern-frontend/src/routes/workflows/editor/useWorkflowParametersState.ts new file mode 100644 index 0000000000..3ca98d5a03 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/editor/useWorkflowParametersState.ts @@ -0,0 +1,14 @@ +import { useContext } from "react"; +import { WorkflowParametersStateContext } from "./WorkflowParametersStateContext"; + +function useWorkflowParametersState() { + const value = useContext(WorkflowParametersStateContext); + if (value === undefined) { + throw new Error( + "useWorkflowParametersState must be used within a WorkflowParametersStateProvider", + ); + } + return value; +} + +export { useWorkflowParametersState }; diff --git a/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts b/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts index 76a5dd15dc..b811197159 100644 --- a/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts +++ b/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts @@ -2,6 +2,18 @@ import { Edge } from "@xyflow/react"; import { AppNode } from "./nodes"; import Dagre from "@dagrejs/dagre"; import type { WorkflowBlock } from "../types/workflowTypes"; +import { nodeTypes } from "./nodes"; +import { taskNodeDefaultData } from "./nodes/TaskNode/types"; +import { LoopNode, loopNodeDefaultData } from "./nodes/LoopNode/types"; +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 { NodeAdderNode } from "./nodes/NodeAdderNode/types"; +import { REACT_FLOW_EDGE_Z_INDEX } from "./constants"; function layoutUtil( nodes: Array, @@ -52,8 +64,12 @@ function layout( (node) => node.id === edge.source || node.id === edge.target, ), ); + const maxChildWidth = Math.max( + ...childNodes.map((node) => node.measured?.width ?? 0), + ); + const loopNodeWidth = 60 * 16; // 60 rem const layouted = layoutUtil(childNodes, childEdges, { - marginx: 240, + marginx: (loopNodeWidth - maxChildWidth) / 2, marginy: 200, }); loopNodeChildren[index] = layouted.nodes; @@ -75,6 +91,8 @@ function convertToNode( ): AppNode { const common = { draggable: false, + position: { x: 0, y: 0 }, + connectable: false, }; switch (block.block_type) { case "task": { @@ -84,17 +102,17 @@ function convertToNode( type: "task", data: { label: block.label, - editable: false, + editable: true, url: block.url ?? "", navigationGoal: block.navigation_goal ?? "", dataExtractionGoal: block.data_extraction_goal ?? "", - dataSchema: block.data_schema ?? null, - errorCodeMapping: block.error_code_mapping ?? null, + dataSchema: JSON.stringify(block.data_schema, null, 2), + errorCodeMapping: JSON.stringify(block.error_code_mapping, null, 2), allowDownloads: block.complete_on_download ?? false, maxRetries: block.max_retries ?? null, maxStepsOverride: block.max_steps_per_run ?? null, + parameterKeys: block.parameters.map((p) => p.key), }, - position: { x: 0, y: 0 }, }; } case "code": { @@ -104,10 +122,9 @@ function convertToNode( type: "codeBlock", data: { label: block.label, - editable: false, + editable: true, code: block.code, }, - position: { x: 0, y: 0 }, }; } case "send_email": { @@ -117,13 +134,13 @@ function convertToNode( type: "sendEmail", data: { label: block.label, - editable: false, + editable: true, body: block.body, - fileAttachments: block.file_attachments, - recipients: block.recipients, + fileAttachments: block.file_attachments.join(", "), + recipients: block.recipients.join(", "), subject: block.subject, + sender: block.sender, }, - position: { x: 0, y: 0 }, }; } case "text_prompt": { @@ -133,11 +150,10 @@ function convertToNode( type: "textPrompt", data: { label: block.label, - editable: false, + editable: true, prompt: block.prompt, - jsonSchema: block.json_schema ?? null, + jsonSchema: JSON.stringify(block.json_schema, null, 2), }, - position: { x: 0, y: 0 }, }; } case "for_loop": { @@ -147,10 +163,9 @@ function convertToNode( type: "loop", data: { label: block.label, - editable: false, + editable: true, loopValue: block.loop_over.key, }, - position: { x: 0, y: 0 }, }; } case "file_url_parser": { @@ -160,10 +175,9 @@ function convertToNode( type: "fileParser", data: { label: block.label, - editable: false, + editable: true, fileUrl: block.file_url, }, - position: { x: 0, y: 0 }, }; } @@ -174,10 +188,9 @@ function convertToNode( type: "download", data: { label: block.label, - editable: false, + editable: true, url: block.url, }, - position: { x: 0, y: 0 }, }; } @@ -188,10 +201,9 @@ function convertToNode( type: "upload", data: { label: block.label, - editable: false, + editable: true, path: block.path, }, - position: { x: 0, y: 0 }, }; } } @@ -210,22 +222,274 @@ function getElements( 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) { edges.push({ id: `edge-${id}-${nextId}`, + type: "edgeWithAddButton", source: id, target: nextId, style: { strokeWidth: 2, }, + zIndex: REACT_FLOW_EDGE_Z_INDEX, }); } }); + if (nodes.length > 0) { + edges.push({ + id: "edge-nodeAdder", + type: "default", + source: nodes[nodes.length - 1]!.id, + target: "nodeAdder", + style: { + strokeWidth: 2, + }, + }); + nodes.push({ + id: "nodeAdder", + type: "nodeAdder", + position: { x: 0, y: 0 }, + data: {}, + draggable: false, + connectable: false, + }); + } + return { nodes, edges }; } -export { getElements, layout }; +function createNode( + identifiers: { id: string; parentId?: string }, + nodeType: Exclude, + labelPostfix: string, // unique label requirement +): AppNode { + const label = "Block " + labelPostfix; + const common = { + draggable: false, + position: { x: 0, y: 0 }, + }; + switch (nodeType) { + case "task": { + return { + ...identifiers, + ...common, + type: "task", + data: { + ...taskNodeDefaultData, + label, + }, + }; + } + case "loop": { + return { + ...identifiers, + ...common, + type: "loop", + data: { + ...loopNodeDefaultData, + label, + }, + }; + } + case "codeBlock": { + return { + ...identifiers, + ...common, + type: "codeBlock", + data: { + ...codeBlockNodeDefaultData, + label, + }, + }; + } + case "download": { + return { + ...identifiers, + ...common, + type: "download", + data: { + ...downloadNodeDefaultData, + label, + }, + }; + } + case "upload": { + return { + ...identifiers, + ...common, + type: "upload", + data: { + ...uploadNodeDefaultData, + label, + }, + }; + } + case "sendEmail": { + return { + ...identifiers, + ...common, + type: "sendEmail", + data: { + ...sendEmailNodeDefaultData, + label, + }, + }; + } + case "textPrompt": { + return { + ...identifiers, + ...common, + type: "textPrompt", + data: { + ...textPromptNodeDefaultData, + label, + }, + }; + } + case "fileParser": { + return { + ...identifiers, + ...common, + type: "fileParser", + data: { + ...fileParserNodeDefaultData, + label, + }, + }; + } + } +} + +function JSONParseSafe(json: string): Record | null { + try { + return JSON.parse(json); + } catch { + return null; + } +} + +function getWorkflowBlock( + node: Exclude, +): BlockYAML { + switch (node.type) { + case "task": { + return { + block_type: "task", + label: node.data.label, + url: node.data.url, + navigation_goal: node.data.navigationGoal, + data_extraction_goal: node.data.dataExtractionGoal, + data_schema: JSONParseSafe(node.data.dataSchema), + error_code_mapping: JSONParseSafe(node.data.errorCodeMapping) as Record< + string, + string + > | null, + max_retries: node.data.maxRetries ?? undefined, + max_steps_per_run: node.data.maxStepsOverride, + complete_on_download: node.data.allowDownloads, + parameter_keys: node.data.parameterKeys, + }; + } + case "sendEmail": { + return { + block_type: "send_email", + label: node.data.label, + body: node.data.body, + file_attachments: node.data.fileAttachments.split(","), + recipients: node.data.recipients.split(","), + subject: node.data.subject, + sender: node.data.sender, + }; + } + case "codeBlock": { + return { + block_type: "code", + label: node.data.label, + code: node.data.code, + }; + } + case "download": { + return { + block_type: "download_to_s3", + label: node.data.label, + url: node.data.url, + }; + } + case "upload": { + return { + block_type: "upload_to_s3", + label: node.data.label, + path: node.data.path, + }; + } + case "fileParser": { + return { + block_type: "file_url_parser", + label: node.data.label, + file_url: node.data.fileUrl, + file_type: "csv", + }; + } + case "textPrompt": { + return { + block_type: "text_prompt", + label: node.data.label, + llm_key: "", + prompt: node.data.prompt, + json_schema: JSONParseSafe(node.data.jsonSchema), + }; + } + default: { + throw new Error("Invalid node type for getWorkflowBlock"); + } + } +} + +function getWorkflowBlocksUtil(nodes: Array): Array { + return nodes.flatMap((node) => { + if (node.parentId) { + return []; + } + if (node.type === "loop") { + return [ + { + block_type: "for_loop", + label: node.data.label, + loop_over_parameter_key: node.data.loopValue, + loop_blocks: nodes + .filter((n) => n.parentId === node.id) + .map((n) => { + return getWorkflowBlock( + n as Exclude, + ); + }), + }, + ]; + } + return [ + getWorkflowBlock(node as Exclude), + ]; + }); +} + +function getWorkflowBlocks(nodes: Array): Array { + return getWorkflowBlocksUtil( + nodes.filter((node) => node.type !== "nodeAdder"), + ); +} + +export { getElements, layout, createNode, getWorkflowBlocks }; diff --git a/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts b/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts index a43065f9cc..1afb803f9c 100644 --- a/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts +++ b/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts @@ -196,7 +196,7 @@ export type WorkflowBlock = | FileURLParserBlock; export type WorkflowDefinition = { - parameters: Array; + parameters: Array; blocks: Array; }; @@ -211,6 +211,7 @@ export type WorkflowApiResponse = { workflow_definition: WorkflowDefinition; proxy_location: string; webhook_callback_url: string; + totp_verification_url: string; created_at: string; modified_at: string; deleted_at: string | null; diff --git a/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts b/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts new file mode 100644 index 0000000000..b93c0f5fc3 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts @@ -0,0 +1,133 @@ +export type WorkflowCreateYAMLRequest = { + title: string; + description?: string | null; + proxy_location?: string | null; + webhook_callback_url?: string | null; + totp_verification_url?: string | null; + workflow_definition: WorkflowDefinitionYAML; + is_saved_task?: boolean; +}; + +export type WorkflowDefinitionYAML = { + parameters: Array; + blocks: Array; +}; + +export type ParameterYAML = + | WorkflowParameterYAML + | BitwardenLoginCredentialParameterYAML; + +export type ParameterYAMLBase = { + parameter_type: string; + key: string; + description?: string | null; +}; + +export type WorkflowParameterYAML = ParameterYAMLBase & { + parameter_type: "workflow"; + workflow_parameter_type: string; + default_value: string | null; +}; + +export type BitwardenLoginCredentialParameterYAML = ParameterYAMLBase & { + parameter_type: "bitwarden_login_credential"; + bitwarden_collection_id: string; + url_parameter_key: string; + bitwarden_client_id_aws_secret_key: "SKYVERN_BITWARDEN_CLIENT_ID"; + bitwarden_client_secret_aws_secret_key: "SKYVERN_BITWARDEN_CLIENT_SECRET"; + bitwarden_master_password_aws_secret_key: "SKYVERN_BITWARDEN_MASTER_PASSWORD"; +}; + +const BlockTypes = { + TASK: "task", + FOR_LOOP: "for_loop", + CODE: "code", + TEXT_PROMPT: "text_prompt", + DOWNLOAD_TO_S3: "download_to_s3", + UPLOAD_TO_S3: "upload_to_s3", + SEND_EMAIL: "send_email", + FILE_URL_PARSER: "file_url_parser", +} as const; + +export type BlockType = (typeof BlockTypes)[keyof typeof BlockTypes]; + +export type BlockYAML = + | TaskBlockYAML + | CodeBlockYAML + | TextPromptBlockYAML + | DownloadToS3BlockYAML + | UploadToS3BlockYAML + | SendEmailBlockYAML + | FileUrlParserBlockYAML + | ForLoopBlockYAML; + +export type BlockYAMLBase = { + block_type: BlockType; + label: string; + continue_on_failure?: boolean; +}; + +export type TaskBlockYAML = BlockYAMLBase & { + block_type: "task"; + url: string | null; + title?: string; + navigation_goal: string | null; + data_extraction_goal: string | null; + data_schema: Record | null; + error_code_mapping: Record | null; + max_retries?: number; + max_steps_per_run?: number | null; + parameter_keys?: Array | null; + complete_on_download?: boolean; +}; + +export type CodeBlockYAML = BlockYAMLBase & { + block_type: "code"; + code: string; + parameter_keys?: Array | null; +}; + +export type TextPromptBlockYAML = BlockYAMLBase & { + block_type: "text_prompt"; + llm_key: string; + prompt: string; + json_schema?: Record | null; + parameter_keys?: Array | null; +}; + +export type DownloadToS3BlockYAML = BlockYAMLBase & { + block_type: "download_to_s3"; + url: string; +}; + +export type UploadToS3BlockYAML = BlockYAMLBase & { + block_type: "upload_to_s3"; + path?: string | null; +}; + +export type SendEmailBlockYAML = BlockYAMLBase & { + block_type: "send_email"; + + smtp_host_secret_parameter_key?: string; + smtp_port_secret_parameter_key?: string; + smtp_username_secret_parameter_key?: string; + smtp_password_secret_parameter_key?: string; + + sender: string; + recipients: Array; + subject: string; + body: string; + file_attachments?: Array | null; +}; + +export type FileUrlParserBlockYAML = BlockYAMLBase & { + block_type: "file_url_parser"; + file_url: string; + file_type: "csv"; +}; + +export type ForLoopBlockYAML = BlockYAMLBase & { + block_type: "for_loop"; + loop_over_parameter_key: string; + loop_blocks: Array; +}; diff --git a/skyvern-frontend/src/store/WorkflowPanelStore.ts b/skyvern-frontend/src/store/WorkflowPanelStore.ts new file mode 100644 index 0000000000..554e27c906 --- /dev/null +++ b/skyvern-frontend/src/store/WorkflowPanelStore.ts @@ -0,0 +1,49 @@ +import { create } from "zustand"; + +type WorkflowPanelState = { + active: boolean; + content: "parameters" | "nodeLibrary"; + data?: { + previous?: string | null; + next?: string | null; + parent?: string; + connectingEdgeType?: string; + }; +}; + +type WorkflowPanelStore = { + workflowPanelState: WorkflowPanelState; + closeWorkflowPanel: () => void; + setWorkflowPanelState: (state: WorkflowPanelState) => void; + toggleWorkflowPanel: () => void; +}; + +const useWorkflowPanelStore = create((set, get) => { + return { + workflowPanelState: { + active: false, + content: "parameters", + }, + setWorkflowPanelState: (workflowPanelState: WorkflowPanelState) => { + set({ workflowPanelState }); + }, + closeWorkflowPanel: () => { + set({ + workflowPanelState: { + ...get().workflowPanelState, + active: false, + }, + }); + }, + toggleWorkflowPanel: () => { + set({ + workflowPanelState: { + ...get().workflowPanelState, + active: !get().workflowPanelState.active, + }, + }); + }, + }; +}); + +export { useWorkflowPanelStore };