From 9d2080878635d655521e6fe4431ebbe754331c19 Mon Sep 17 00:00:00 2001 From: Shuchang Zheng Date: Fri, 11 Oct 2024 06:48:26 -0700 Subject: [PATCH] Clone workflow (#959) --- .../src/routes/workflows/WorkflowActions.tsx | 144 +++++++++++++ .../src/routes/workflows/Workflows.tsx | 6 +- .../workflows/editor/DeleteWorkflowButton.tsx | 94 -------- .../workflows/editor/workflowEditorUtils.ts | 203 +++++++++++++++++- .../routes/workflows/types/workflowTypes.ts | 6 + 5 files changed, 353 insertions(+), 100 deletions(-) create mode 100644 skyvern-frontend/src/routes/workflows/WorkflowActions.tsx delete mode 100644 skyvern-frontend/src/routes/workflows/editor/DeleteWorkflowButton.tsx diff --git a/skyvern-frontend/src/routes/workflows/WorkflowActions.tsx b/skyvern-frontend/src/routes/workflows/WorkflowActions.tsx new file mode 100644 index 00000000..7e7dd24a --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/WorkflowActions.tsx @@ -0,0 +1,144 @@ +import { getClient } from "@/api/AxiosClient"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { toast } from "@/components/ui/use-toast"; +import { useCredentialGetter } from "@/hooks/useCredentialGetter"; +import { + CopyIcon, + DotsHorizontalIcon, + ReloadIcon, +} from "@radix-ui/react-icons"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { AxiosError } from "axios"; +import { useWorkflowQuery } from "./hooks/useWorkflowQuery"; +import { stringify as convertToYAML } from "yaml"; +import { WorkflowApiResponse } from "./types/workflowTypes"; +import { useNavigate } from "react-router-dom"; +import { WorkflowCreateYAMLRequest } from "./types/workflowYamlTypes"; +import { convert } from "./editor/workflowEditorUtils"; +import { GarbageIcon } from "@/components/icons/GarbageIcon"; + +type Props = { + id: string; +}; + +function WorkflowActions({ id }: Props) { + const credentialGetter = useCredentialGetter(); + const queryClient = useQueryClient(); + const { data: workflow } = useWorkflowQuery({ workflowPermanentId: id }); + const navigate = useNavigate(); + + const createWorkflowMutation = useMutation({ + mutationFn: async (workflow: WorkflowCreateYAMLRequest) => { + const client = await getClient(credentialGetter); + const yaml = convertToYAML(workflow); + return client.post( + "/workflows", + yaml, + { + headers: { + "Content-Type": "text/plain", + }, + }, + ); + }, + onSuccess: (response) => { + queryClient.invalidateQueries({ + queryKey: ["workflows"], + }); + navigate(`/workflows/${response.data.workflow_permanent_id}/edit`); + }, + }); + + const deleteWorkflowMutation = useMutation({ + mutationFn: async (id: string) => { + const client = await getClient(credentialGetter); + return client.delete(`/workflows/${id}`); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["workflows"], + }); + }, + onError: (error: AxiosError) => { + toast({ + variant: "destructive", + title: "Failed to delete workflow", + description: error.message, + }); + }, + }); + + return ( + + + + + + + { + if (!workflow) { + return; + } + const clonedWorkflow = convert(workflow); + createWorkflowMutation.mutate(clonedWorkflow); + }} + className="p-2" + > + + Clone Workflow + + + + + Delete Workflow + + + + + e.preventDefault()}> + + Are you sure? + This workflow will be deleted. + + + + + + + + + + ); +} + +export { WorkflowActions }; diff --git a/skyvern-frontend/src/routes/workflows/Workflows.tsx b/skyvern-frontend/src/routes/workflows/Workflows.tsx index 3653aa0d..dee1212e 100644 --- a/skyvern-frontend/src/routes/workflows/Workflows.tsx +++ b/skyvern-frontend/src/routes/workflows/Workflows.tsx @@ -37,10 +37,10 @@ import { import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate, useSearchParams } from "react-router-dom"; import { stringify as convertToYAML } from "yaml"; -import { DeleteWorkflowButton } from "./editor/DeleteWorkflowButton"; import { WorkflowCreateYAMLRequest } from "./types/workflowYamlTypes"; import { WorkflowTitle } from "./WorkflowTitle"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { WorkflowActions } from "./WorkflowActions"; const emptyWorkflowRequest: WorkflowCreateYAMLRequest = { title: "New Workflow", @@ -279,9 +279,7 @@ function Workflows() { Create New Run - + diff --git a/skyvern-frontend/src/routes/workflows/editor/DeleteWorkflowButton.tsx b/skyvern-frontend/src/routes/workflows/editor/DeleteWorkflowButton.tsx deleted file mode 100644 index 5bd7bdbd..00000000 --- a/skyvern-frontend/src/routes/workflows/editor/DeleteWorkflowButton.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { getClient } from "@/api/AxiosClient"; -import { GarbageIcon } from "@/components/icons/GarbageIcon"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { toast } from "@/components/ui/use-toast"; -import { useCredentialGetter } from "@/hooks/useCredentialGetter"; -import { ReloadIcon } from "@radix-ui/react-icons"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { AxiosError } from "axios"; - -type Props = { - id: string; -}; - -function DeleteWorkflowButton({ id }: Props) { - const credentialGetter = useCredentialGetter(); - const queryClient = useQueryClient(); - - const deleteWorkflowMutation = useMutation({ - mutationFn: async (id: string) => { - const client = await getClient(credentialGetter); - return client.delete(`/workflows/${id}`); - }, - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ["workflows"], - }); - }, - onError: (error: AxiosError) => { - toast({ - variant: "destructive", - title: "Failed to delete workflow", - description: error.message, - }); - }, - }); - - return ( - - - - - - - - - Delete Workflow - - - e.preventDefault()}> - - Are you sure? - This workflow will be deleted. - - - - - - - - - - ); -} - -export { DeleteWorkflowButton }; diff --git a/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts b/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts index db355a62..de64ae96 100644 --- a/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts +++ b/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts @@ -2,10 +2,25 @@ import Dagre from "@dagrejs/dagre"; import { Edge } from "@xyflow/react"; import { nanoid } from "nanoid"; import type { + OutputParameter, + Parameter, + WorkflowApiResponse, WorkflowBlock, WorkflowParameterValueType, } from "../types/workflowTypes"; -import { BlockYAML, ParameterYAML } from "../types/workflowYamlTypes"; +import { + BlockYAML, + CodeBlockYAML, + DownloadToS3BlockYAML, + FileUrlParserBlockYAML, + ForLoopBlockYAML, + ParameterYAML, + SendEmailBlockYAML, + TaskBlockYAML, + TextPromptBlockYAML, + UploadToS3BlockYAML, + WorkflowCreateYAMLRequest, +} from "../types/workflowYamlTypes"; import { EMAIL_BLOCK_SENDER, REACT_FLOW_EDGE_Z_INDEX, @@ -494,7 +509,9 @@ function getWorkflowBlock( string, string > | null, - max_retries: node.data.maxRetries ?? undefined, + ...(node.data.maxRetries !== null && { + max_retries: node.data.maxRetries, + }), max_steps_per_run: node.data.maxStepsOverride, complete_on_download: node.data.allowDownloads, download_suffix: node.data.downloadSuffix, @@ -875,6 +892,187 @@ function getAvailableOutputParameterKeys( return outputParameterKeys; } +function convertParameters( + parameters: Array>, +): Array { + return parameters.map((parameter) => { + const base = { + key: parameter.key, + description: parameter.description, + }; + switch (parameter.parameter_type) { + case "aws_secret": { + return { + ...base, + parameter_type: "aws_secret", + aws_key: parameter.aws_key, + }; + } + case "bitwarden_login_credential": { + return { + ...base, + parameter_type: "bitwarden_login_credential", + bitwarden_collection_id: parameter.bitwarden_collection_id, + url_parameter_key: parameter.url_parameter_key, + 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", + }; + } + case "bitwarden_sensitive_information": { + return { + ...base, + parameter_type: "bitwarden_sensitive_information", + bitwarden_collection_id: parameter.bitwarden_collection_id, + bitwarden_identity_key: parameter.bitwarden_identity_key, + bitwarden_identity_fields: parameter.bitwarden_identity_fields, + bitwarden_client_id_aws_secret_key: + parameter.bitwarden_client_id_aws_secret_key, + bitwarden_client_secret_aws_secret_key: + parameter.bitwarden_client_secret_aws_secret_key, + bitwarden_master_password_aws_secret_key: + parameter.bitwarden_master_password_aws_secret_key, + }; + } + case "context": { + return { + ...base, + parameter_type: "context", + source_parameter_key: parameter.source.key, + }; + } + case "workflow": { + return { + ...base, + parameter_type: "workflow", + workflow_parameter_type: parameter.workflow_parameter_type, + default_value: parameter.default_value, + }; + } + } + }); +} + +function convertBlocks(blocks: Array): Array { + return blocks.map((block) => { + const base = { + label: block.label, + continue_on_failure: block.continue_on_failure, + }; + switch (block.block_type) { + case "task": { + const blockYaml: TaskBlockYAML = { + ...base, + block_type: "task", + url: block.url, + navigation_goal: block.navigation_goal, + data_extraction_goal: block.data_extraction_goal, + data_schema: block.data_schema, + error_code_mapping: block.error_code_mapping, + max_retries: block.max_retries, + max_steps_per_run: block.max_steps_per_run, + complete_on_download: block.complete_on_download, + download_suffix: block.download_suffix, + parameter_keys: block.parameters.map((p) => p.key), + totp_identifier: block.totp_identifier, + totp_verification_url: block.totp_verification_url, + }; + return blockYaml; + } + case "for_loop": { + const blockYaml: ForLoopBlockYAML = { + ...base, + block_type: "for_loop", + loop_over_parameter_key: block.loop_over.key, + loop_blocks: convertBlocks(block.loop_blocks), + }; + return blockYaml; + } + case "code": { + const blockYaml: CodeBlockYAML = { + ...base, + block_type: "code", + code: block.code, + }; + return blockYaml; + } + case "text_prompt": { + const blockYaml: TextPromptBlockYAML = { + ...base, + block_type: "text_prompt", + llm_key: block.llm_key, + prompt: block.prompt, + json_schema: block.json_schema, + parameter_keys: block.parameters.map((p) => p.key), + }; + return blockYaml; + } + case "download_to_s3": { + const blockYaml: DownloadToS3BlockYAML = { + ...base, + block_type: "download_to_s3", + url: block.url, + }; + return blockYaml; + } + case "upload_to_s3": { + const blockYaml: UploadToS3BlockYAML = { + ...base, + block_type: "upload_to_s3", + path: block.path, + }; + return blockYaml; + } + case "file_url_parser": { + const blockYaml: FileUrlParserBlockYAML = { + ...base, + block_type: "file_url_parser", + file_url: block.file_url, + file_type: block.file_type, + }; + return blockYaml; + } + case "send_email": { + const blockYaml: SendEmailBlockYAML = { + ...base, + block_type: "send_email", + smtp_host_secret_parameter_key: block.smtp_host?.key, + smtp_port_secret_parameter_key: block.smtp_port?.key, + smtp_username_secret_parameter_key: block.smtp_username?.key, + smtp_password_secret_parameter_key: block.smtp_password?.key, + sender: block.sender, + recipients: block.recipients, + subject: block.subject, + body: block.body, + file_attachments: block.file_attachments, + }; + return blockYaml; + } + } + }); +} + +function convert(workflow: WorkflowApiResponse): WorkflowCreateYAMLRequest { + const title = `Copy of ${workflow.title}`; + const userParameters = workflow.workflow_definition.parameters.filter( + (parameter) => parameter.parameter_type !== "output", + ); + return { + title: 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: convertParameters(userParameters), + blocks: convertBlocks(workflow.workflow_definition.blocks), + }, + is_saved_task: workflow.is_saved_task, + }; +} + export { createNode, generateNodeData, @@ -893,4 +1091,5 @@ export { getUpdatedParametersAfterLabelUpdateForSourceParameterKey, getPreviousNodeIds, getAvailableOutputParameterKeys, + convert, }; diff --git a/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts b/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts index 88b98f70..6904838d 100644 --- a/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts +++ b/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts @@ -219,3 +219,9 @@ export type WorkflowApiResponse = { modified_at: string; deleted_at: string | null; }; + +export function isOutputParameter( + parameter: Parameter, +): parameter is OutputParameter { + return parameter.parameter_type === "output"; +}