From 6d97634e1db0b3e78daf8077cb8457b2112cb5b6 Mon Sep 17 00:00:00 2001 From: Kerem Yilmaz Date: Thu, 11 Jul 2024 03:08:52 -0700 Subject: [PATCH] Implement workflows tab, workflow runs view, ability to run workflows from UI (#582) Co-authored-by: Muhammed Salih Altun --- skyvern-frontend/package-lock.json | 201 +++++++++++++++ skyvern-frontend/package.json | 1 + skyvern-frontend/src/api/types.ts | 40 ++- .../src/components/FileUpload.tsx | 203 +++++++++++++++ .../src/components/ui/checkbox.tsx | 28 +++ skyvern-frontend/src/router.tsx | 30 ++- skyvern-frontend/src/routes/root/SideNav.tsx | 16 +- .../tasks/create/CreateNewTaskFormPage.tsx | 2 +- .../src/routes/tasks/create/SavedTasks.tsx | 2 +- .../src/routes/tasks/list/TaskHistory.tsx | 2 +- .../src/routes/tasks/running/QueuedTasks.tsx | 2 +- .../src/routes/workflows/RunWorkflowForm.tsx | 154 ++++++++++++ .../src/routes/workflows/WorkflowPage.tsx | 140 +++++++++++ .../workflows/WorkflowParameterInput.tsx | 65 +++++ .../src/routes/workflows/WorkflowRun.tsx | 157 ++++++++++++ .../workflows/WorkflowRunParameters.tsx | 90 +++++++ .../src/routes/workflows/Workflows.tsx | 236 ++++++++++++++++++ .../workflows/WorkflowsBetaAlertCard.tsx | 37 +++ .../routes/workflows/WorkflowsPageLayout.tsx | 11 + 19 files changed, 1409 insertions(+), 8 deletions(-) create mode 100644 skyvern-frontend/src/components/FileUpload.tsx create mode 100644 skyvern-frontend/src/components/ui/checkbox.tsx create mode 100644 skyvern-frontend/src/routes/workflows/RunWorkflowForm.tsx create mode 100644 skyvern-frontend/src/routes/workflows/WorkflowPage.tsx create mode 100644 skyvern-frontend/src/routes/workflows/WorkflowParameterInput.tsx create mode 100644 skyvern-frontend/src/routes/workflows/WorkflowRun.tsx create mode 100644 skyvern-frontend/src/routes/workflows/WorkflowRunParameters.tsx create mode 100644 skyvern-frontend/src/routes/workflows/Workflows.tsx create mode 100644 skyvern-frontend/src/routes/workflows/WorkflowsBetaAlertCard.tsx create mode 100644 skyvern-frontend/src/routes/workflows/WorkflowsPageLayout.tsx diff --git a/skyvern-frontend/package-lock.json b/skyvern-frontend/package-lock.json index 097c0aab..55e97153 100644 --- a/skyvern-frontend/package-lock.json +++ b/skyvern-frontend/package-lock.json @@ -11,6 +11,7 @@ "@hookform/resolvers": "^3.3.4", "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-aspect-ratio": "^1.0.3", + "@radix-ui/react-checkbox": "^1.1.1", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-icons": "^1.3.0", @@ -918,6 +919,206 @@ } } }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.1.1.tgz", + "integrity": "sha512-0i/EKJ222Afa1FE0C6pNJxDq1itzcl3HChE9DwskA4th4KRse8ojx8a1nVcOjwJdbpDLcz7uol77yYnQNMHdKw==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-presence": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz", + "integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==" + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", + "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-context": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", + "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-presence": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.0.tgz", + "integrity": "sha512-Gq6wuRN/asf9H/E/VzdKoUtT8GC9PQc9z40/vEr0VCJ4u5XvvhWIrSsCB6vD2/cH7ugTdSfYq9fLJCcM00acrQ==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-primitive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", + "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", + "dependencies": { + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", + "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", + "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", + "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", + "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-use-previous": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.0.tgz", + "integrity": "sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-use-size": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz", + "integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collapsible": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.0.3.tgz", diff --git a/skyvern-frontend/package.json b/skyvern-frontend/package.json index 0d55ff47..05da02c0 100644 --- a/skyvern-frontend/package.json +++ b/skyvern-frontend/package.json @@ -19,6 +19,7 @@ "@hookform/resolvers": "^3.3.4", "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-aspect-ratio": "^1.0.3", + "@radix-ui/react-checkbox": "^1.1.1", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-icons": "^1.3.0", diff --git a/skyvern-frontend/src/api/types.ts b/skyvern-frontend/src/api/types.ts index 0b697e63..48e99c17 100644 --- a/skyvern-frontend/src/api/types.ts +++ b/skyvern-frontend/src/api/types.ts @@ -112,9 +112,21 @@ export type ApiKeyApiResponse = { valid: boolean; }; +export const WorkflowParameterType = { + String: "string", + Integer: "integer", + Float: "float", + Boolean: "boolean", + JSON: "json", + FileURL: "file_url", +} as const; + +export type WorkflowParameterType = + (typeof WorkflowParameterType)[keyof typeof WorkflowParameterType]; + export type WorkflowParameter = { workflow_parameter_id: string; - workflow_parameter_type?: string; + workflow_parameter_type: WorkflowParameterType; key: string; description: string | null; workflow_id: string; @@ -144,6 +156,7 @@ export type WorkflowBlock = { export type WorkflowApiResponse = { workflow_id: string; organization_id: string; + is_saved_task: boolean; title: string; workflow_permanent_id: string; version: number; @@ -204,3 +217,28 @@ export type Action = { stepId: string; index: number; }; + +export type WorkflowRunApiResponse = { + workflow_permanent_id: string; + workflow_run_id: string; + workflow_id: string; + status: Status; + proxy_location: string; + webhook_callback_url: string; + created_at: string; + modified_at: string; +}; + +export type WorkflowRunStatusApiResponse = { + workflow_id: string; + workflow_run_id: string; + status: Status; + proxy_location: string; + webhook_callback_url: string | null; + created_at: string; + modified_at: string; + parameters: Record; + screenshot_urls: Array | null; + recording_url: string | null; + outputs: Record | null; +}; diff --git a/skyvern-frontend/src/components/FileUpload.tsx b/skyvern-frontend/src/components/FileUpload.tsx new file mode 100644 index 00000000..36e5a8f6 --- /dev/null +++ b/skyvern-frontend/src/components/FileUpload.tsx @@ -0,0 +1,203 @@ +import { getClient } from "@/api/AxiosClient"; +import { useCredentialGetter } from "@/hooks/useCredentialGetter"; +import { cn } from "@/util/utils"; +import { ReloadIcon } from "@radix-ui/react-icons"; +import { useMutation } from "@tanstack/react-query"; +import { useId, useState } from "react"; +import { Button } from "./ui/button"; +import { Input } from "./ui/input"; +import { Label } from "./ui/label"; +import { toast } from "./ui/use-toast"; + +export type FileInputValue = + | { + s3uri: string; + presignedUrl: string; + } + | string + | null; + +type Props = { + value: FileInputValue; + onChange: (value: FileInputValue) => void; +}; + +const FILE_SIZE_LIMIT_IN_BYTES = 10 * 1024 * 1024; // 10 MB + +function showFileSizeError() { + toast({ + variant: "destructive", + title: "File size limit exceeded", + description: + "The file you are trying to upload exceeds the 10MB limit, please try again with a different file", + }); +} + +function FileUpload({ value, onChange }: Props) { + const credentialGetter = useCredentialGetter(); + const [file, setFile] = useState(null); + const [fileUrl, setFileUrl] = useState(""); + const [highlight, setHighlight] = useState(false); + const inputId = useId(); + + const uploadFileMutation = useMutation({ + mutationFn: async (file: File) => { + const client = await getClient(credentialGetter); + const formData = new FormData(); + formData.append("file", file); + return client.post< + FormData, + { + data: { + s3_uri: string; + presigned_url: string; + }; + } + >("/upload_file", formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }); + }, + onSuccess: (response) => { + onChange({ + s3uri: response.data.s3_uri, + presignedUrl: response.data.presigned_url, + }); + }, + onError: (error) => { + setFile(null); + toast({ + variant: "destructive", + title: "Failed to upload file", + description: `An error occurred while uploading the file: ${error.message}`, + }); + }, + }); + + const handleFileChange = (e: React.ChangeEvent) => { + if (e.target.files && e.target.files.length > 0) { + const file = e.target.files[0] as File; + if (file.size > FILE_SIZE_LIMIT_IN_BYTES) { + showFileSizeError(); + return; + } + setFile(file); + uploadFileMutation.mutate(file); + } + }; + + function reset() { + setFile(null); + onChange(null); + } + + if (value === null) { + return ( +
+
+ +
+
+ OR +
+
+ +
+ setFileUrl(e.target.value)} + /> + +
+
+
+ ); + } + + if (typeof value === "string") { + return ( +
+ {value} + +
+ ); + } + + if (typeof value === "object" && file && "s3uri" in value) { + return ( +
+ + {file.name} + + +
+ ); + } +} + +export { FileUpload }; diff --git a/skyvern-frontend/src/components/ui/checkbox.tsx b/skyvern-frontend/src/components/ui/checkbox.tsx new file mode 100644 index 00000000..663242c8 --- /dev/null +++ b/skyvern-frontend/src/components/ui/checkbox.tsx @@ -0,0 +1,28 @@ +import * as React from "react"; +import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; +import { CheckIcon } from "@radix-ui/react-icons"; + +import { cn } from "@/util/utils"; + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)); +Checkbox.displayName = CheckboxPrimitive.Root.displayName; + +export { Checkbox }; diff --git a/skyvern-frontend/src/router.tsx b/skyvern-frontend/src/router.tsx index 08f8cf8c..c5bb0e63 100644 --- a/skyvern-frontend/src/router.tsx +++ b/skyvern-frontend/src/router.tsx @@ -1,4 +1,4 @@ -import { Navigate, createBrowserRouter } from "react-router-dom"; +import { Navigate, Outlet, createBrowserRouter } from "react-router-dom"; import { RootLayout } from "./routes/root/RootLayout"; import { TasksPageLayout } from "./routes/tasks/TasksPageLayout"; import { TaskTemplates } from "./routes/tasks/create/TaskTemplates"; @@ -13,6 +13,10 @@ import { TaskRecording } from "./routes/tasks/detail/TaskRecording"; import { TaskParameters } from "./routes/tasks/detail/TaskParameters"; import { StepArtifactsLayout } from "./routes/tasks/detail/StepArtifactsLayout"; import { CreateNewTaskFromPrompt } from "./routes/tasks/create/CreateNewTaskFromPrompt"; +import { WorkflowsPageLayout } from "./routes/workflows/WorkflowsPageLayout"; +import { Workflows } from "./routes/workflows/Workflows"; +import { WorkflowPage } from "./routes/workflows/WorkflowPage"; +import { WorkflowRunParameters } from "./routes/workflows/WorkflowRunParameters"; const router = createBrowserRouter([ { @@ -77,6 +81,30 @@ const router = createBrowserRouter([ }, ], }, + { + path: "workflows", + element: , + children: [ + { + index: true, + element: , + }, + { + path: ":workflowPermanentId", + element: , + children: [ + { + index: true, + element: , + }, + { + path: "run", + element: , + }, + ], + }, + ], + }, { path: "settings", element: , diff --git a/skyvern-frontend/src/routes/root/SideNav.tsx b/skyvern-frontend/src/routes/root/SideNav.tsx index 934d8eb2..2ad0e40e 100644 --- a/skyvern-frontend/src/routes/root/SideNav.tsx +++ b/skyvern-frontend/src/routes/root/SideNav.tsx @@ -1,6 +1,7 @@ import { cn } from "@/util/utils"; import { GearIcon, + LightningBoltIcon, ListBulletIcon, PlusCircledIcon, } from "@radix-ui/react-icons"; @@ -18,7 +19,7 @@ function SideNav() { }} > - New Task + Create - Task History + Tasks + + { + return cn("flex items-center px-5 py-3 hover:bg-muted rounded-2xl", { + "bg-muted": isActive, + }); + }} + > + + Workflows (Beta) { const client = await getClient(credentialGetter); return client diff --git a/skyvern-frontend/src/routes/tasks/create/SavedTasks.tsx b/skyvern-frontend/src/routes/tasks/create/SavedTasks.tsx index b2607c86..261da13c 100644 --- a/skyvern-frontend/src/routes/tasks/create/SavedTasks.tsx +++ b/skyvern-frontend/src/routes/tasks/create/SavedTasks.tsx @@ -51,7 +51,7 @@ function SavedTasks() { const navigate = useNavigate(); const { data } = useQuery>({ - queryKey: ["workflows"], + queryKey: ["savedTasks"], queryFn: async () => { const client = await getClient(credentialGetter); return client diff --git a/skyvern-frontend/src/routes/tasks/list/TaskHistory.tsx b/skyvern-frontend/src/routes/tasks/list/TaskHistory.tsx index 50fc376d..74f3909b 100644 --- a/skyvern-frontend/src/routes/tasks/list/TaskHistory.tsx +++ b/skyvern-frontend/src/routes/tasks/list/TaskHistory.tsx @@ -92,7 +92,7 @@ function TaskHistory() { ) : tasks?.length === 0 ? ( - No tasks found + No tasks found ) : ( tasks?.map((task) => { diff --git a/skyvern-frontend/src/routes/tasks/running/QueuedTasks.tsx b/skyvern-frontend/src/routes/tasks/running/QueuedTasks.tsx index dd2b657d..f436a755 100644 --- a/skyvern-frontend/src/routes/tasks/running/QueuedTasks.tsx +++ b/skyvern-frontend/src/routes/tasks/running/QueuedTasks.tsx @@ -58,7 +58,7 @@ function QueuedTasks() { {tasks?.length === 0 ? ( - No queued tasks + No queued tasks ) : ( tasks?.map((task) => { diff --git a/skyvern-frontend/src/routes/workflows/RunWorkflowForm.tsx b/skyvern-frontend/src/routes/workflows/RunWorkflowForm.tsx new file mode 100644 index 00000000..2c9d411f --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/RunWorkflowForm.tsx @@ -0,0 +1,154 @@ +import { getClient } from "@/api/AxiosClient"; +import { WorkflowParameter } from "@/api/types"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, +} from "@/components/ui/form"; +import { useCredentialGetter } from "@/hooks/useCredentialGetter"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useForm } from "react-hook-form"; +import { useParams } from "react-router-dom"; +import { WorkflowParameterInput } from "./WorkflowParameterInput"; +import { Button } from "@/components/ui/button"; +import { toast } from "@/components/ui/use-toast"; +import { ReloadIcon } from "@radix-ui/react-icons"; + +type Props = { + workflowParameters: Array; + initialValues: Record; +}; + +function RunWorkflowForm({ workflowParameters, initialValues }: Props) { + const { workflowPermanentId } = useParams(); + const credentialGetter = useCredentialGetter(); + const queryClient = useQueryClient(); + const form = useForm({ + defaultValues: initialValues, + }); + + const runWorkflowMutation = useMutation({ + mutationFn: async (values: Record) => { + const client = await getClient(credentialGetter); + return client + .post(`/workflows/${workflowPermanentId}/run`, { + data: values, + }) + .then((response) => response.data); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["workflowRuns"], + }); + toast({ + variant: "success", + title: "Workflow run started", + description: "The workflow run has been started successfully", + }); + }, + onError: (error) => { + toast({ + variant: "destructive", + title: "Failed to start workflow run", + description: error.message, + }); + }, + }); + + function onSubmit(values: Record) { + const parsedValues = Object.fromEntries( + Object.entries(values).map(([key, value]) => { + const parameter = workflowParameters?.find( + (parameter) => parameter.key === key, + ); + if (parameter?.workflow_parameter_type === "json") { + try { + return [key, JSON.parse(value as string)]; + } catch { + console.error("Invalid JSON"); // this should never happen, it should fall to form error + return [key, value]; + } + } + // can improve this via the type system maybe + if ( + parameter?.workflow_parameter_type === "file_url" && + value !== null && + typeof value === "object" && + "s3uri" in value + ) { + return [key, value.s3uri]; + } + return [key, value]; + }), + ); + runWorkflowMutation.mutate(parsedValues); + } + + return ( +
+
+ + {workflowParameters?.map((parameter) => { + return ( + { + if ( + parameter.workflow_parameter_type === "json" && + typeof value === "string" + ) { + try { + JSON.parse(value); + return true; + } catch (e) { + return "Invalid JSON"; + } + } + }, + }} + render={({ field }) => { + return ( + + {parameter.key} + + + + {parameter.description && ( + + {parameter.description} + + )} + {form.formState.errors[parameter.key] && ( +
+ {form.formState.errors[parameter.key]?.message} +
+ )} +
+ ); + }} + /> + ); + })} + + + +
+ ); +} + +export { RunWorkflowForm }; diff --git a/skyvern-frontend/src/routes/workflows/WorkflowPage.tsx b/skyvern-frontend/src/routes/workflows/WorkflowPage.tsx new file mode 100644 index 00000000..3ef53070 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/WorkflowPage.tsx @@ -0,0 +1,140 @@ +import { getClient } from "@/api/AxiosClient"; +import { WorkflowRunApiResponse } from "@/api/types"; +import { StatusBadge } from "@/components/StatusBadge"; +import { Button } from "@/components/ui/button"; +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "@/components/ui/pagination"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useCredentialGetter } from "@/hooks/useCredentialGetter"; +import { cn } from "@/util/utils"; +import { useQuery } from "@tanstack/react-query"; +import { + Link, + useNavigate, + useParams, + useSearchParams, +} from "react-router-dom"; + +function WorkflowPage() { + const credentialGetter = useCredentialGetter(); + const { workflowPermanentId } = useParams(); + const [searchParams, setSearchParams] = useSearchParams(); + const page = searchParams.get("page") ? Number(searchParams.get("page")) : 1; + const navigate = useNavigate(); + + const { data: workflowRuns, isLoading } = useQuery< + Array + >({ + queryKey: ["workflowRuns", workflowPermanentId, page], + queryFn: async () => { + const client = await getClient(credentialGetter); + const params = new URLSearchParams(); + params.append("page", String(page)); + return client + .get(`/workflows/${workflowPermanentId}/runs`, { + params, + }) + .then((response) => response.data); + }, + }); + + if (!workflowPermanentId) { + return null; // this should never happen + } + + return ( +
+
+

{workflowPermanentId}

+ +
+
+
+

Past Runs

+
+ + + + ID + Status + + + + {isLoading ? ( + + Loading... + + ) : workflowRuns?.length === 0 ? ( + + No workflow runs found + + ) : ( + workflowRuns?.map((workflowRun) => ( + { + navigate(`${workflowRun.workflow_run_id}`); + }} + className="cursor-pointer" + > + {workflowRun.workflow_run_id} + + + + + )) + )} + +
+ + + + { + if (page === 1) { + return; + } + const params = new URLSearchParams(); + params.set("page", String(Math.max(1, page - 1))); + setSearchParams(params); + }} + /> + + + {page} + + + { + const params = new URLSearchParams(); + params.set("page", String(page + 1)); + setSearchParams(params); + }} + /> + + + +
+
+ ); +} + +export { WorkflowPage }; diff --git a/skyvern-frontend/src/routes/workflows/WorkflowParameterInput.tsx b/skyvern-frontend/src/routes/workflows/WorkflowParameterInput.tsx new file mode 100644 index 00000000..dd3f4f12 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/WorkflowParameterInput.tsx @@ -0,0 +1,65 @@ +import { WorkflowParameterType } from "@/api/types"; +import { FileInputValue, FileUpload } from "@/components/FileUpload"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; + +type Props = { + type: WorkflowParameterType; + value: unknown; + onChange: (value: unknown) => void; +}; + +function WorkflowParameterInput({ type, value, onChange }: Props) { + if (type === "json" || type === "string") { + return ( +