From 9e567c5ab8e8bb2133525356720776df3e27a2f5 Mon Sep 17 00:00:00 2001 From: Shuchang Zheng Date: Sat, 25 Apr 2026 23:32:00 -0700 Subject: [PATCH] feat: support multi-file workflow import with duplicate confirmation (#5670) --- .../routes/workflows/ImportWorkflowButton.tsx | 418 +++++++++++++++--- 1 file changed, 351 insertions(+), 67 deletions(-) diff --git a/skyvern-frontend/src/routes/workflows/ImportWorkflowButton.tsx b/skyvern-frontend/src/routes/workflows/ImportWorkflowButton.tsx index d0b44d547..9bc5f8052 100644 --- a/skyvern-frontend/src/routes/workflows/ImportWorkflowButton.tsx +++ b/skyvern-frontend/src/routes/workflows/ImportWorkflowButton.tsx @@ -1,9 +1,18 @@ import { getClient } from "@/api/AxiosClient"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { Label } from "@/components/ui/label"; import { UploadIcon } from "@radix-ui/react-icons"; import { useQueryClient } from "@tanstack/react-query"; -import { useId } from "react"; -import { stringify as convertToYAML } from "yaml"; +import { useId, useRef, useState } from "react"; +import { parse as parseYAML, stringify as convertToYAML } from "yaml"; import { WorkflowApiResponse } from "./types/workflowTypes"; import { useCredentialGetter } from "@/hooks/useCredentialGetter"; import { toast } from "@/components/ui/use-toast"; @@ -18,7 +27,7 @@ import { AxiosError } from "axios"; function isJsonString(str: string): boolean { try { JSON.parse(str); - } catch (e) { + } catch { return false; } return true; @@ -33,6 +42,33 @@ function getErrorMessage(error: unknown, fallback: string): string { return fallback; } +function extractTitleFromYaml(yaml: string): string | null { + try { + const parsed = parseYAML(yaml); + if (parsed && typeof parsed === "object" && "title" in parsed) { + const title = (parsed as { title?: unknown }).title; + if (typeof title === "string" && title.trim().length > 0) { + return title.trim(); + } + } + } catch { + return null; + } + return null; +} + +type DuplicateReason = + | { kind: "existing"; existingTitle: string } + | { kind: "intra-batch" } + | { kind: "check-failed" }; + +type PreparedYamlImport = { + fileName: string; + yaml: string; + title: string | null; + duplicateReason: DuplicateReason | null; +}; + interface ImportWorkflowButtonProps { onImportStart?: () => void; selectedFolderId?: string | null; @@ -45,8 +81,20 @@ function ImportWorkflowButton({ const inputId = useId(); const credentialGetter = useCredentialGetter(); const queryClient = useQueryClient(); + const inputRef = useRef(null); + const [duplicateConfirmOpen, setDuplicateConfirmOpen] = useState(false); + const [pendingDuplicates, setPendingDuplicates] = useState< + PreparedYamlImport[] + >([]); + const [pendingNonDuplicates, setPendingNonDuplicates] = useState< + PreparedYamlImport[] + >([]); + const [isImporting, setIsImporting] = useState(false); - const createWorkflowFromYamlMutation = async (yaml: string) => { + const createWorkflowFromYaml = async ( + yaml: string, + fileName: string, + ): Promise => { try { const client = await getClient(credentialGetter); const params: Record = {}; @@ -63,28 +111,18 @@ function ImportWorkflowButton({ params, }, ); - - queryClient.invalidateQueries({ - queryKey: ["workflows"], - }); - queryClient.invalidateQueries({ - queryKey: ["folders"], - }); - toast({ - variant: "success", - title: "Workflow imported", - description: "Successfully imported workflow", - }); + return true; } catch (error) { toast({ variant: "destructive", - title: "Error importing workflow", + title: `Error importing ${fileName}`, description: getErrorMessage(error, "Failed to import workflow"), }); + return false; } }; - const createWorkflowFromPdfMutation = async (file: File) => { + const createWorkflowFromPdf = async (file: File): Promise => { try { const formData = new FormData(); formData.append("file", file); @@ -100,65 +138,311 @@ function ImportWorkflowButton({ }, params, }); - - // Notify parent to start polling - onImportStart?.(); - - toast({ - title: "Import started", - description: `Importing ${file.name}...`, - }); + return true; } catch (error) { toast({ - title: "Import Failed", + title: `Import Failed: ${file.name}`, description: getErrorMessage(error, "Failed to import PDF"), variant: "destructive", }); + return false; + } + }; + + const findExistingWorkflowTitle = async ( + title: string, + ): Promise => { + const pageSize = 50; + const maxPages = 5; + const normalized = title.trim().toLowerCase(); + const client = await getClient(credentialGetter); + for (let page = 1; page <= maxPages; page++) { + const params = new URLSearchParams(); + params.append("page", String(page)); + params.append("page_size", String(pageSize)); + params.append("only_workflows", "true"); + params.append("search_key", title); + const response = await client.get>( + "/workflows", + { params }, + ); + const match = response.data.find( + (wf) => wf.title.trim().toLowerCase() === normalized, + ); + if (match) { + return match.title; + } + if (response.data.length < pageSize) { + return null; + } + } + return null; + }; + + const importYamlFiles = async (files: PreparedYamlImport[]) => { + if (files.length === 0) { + return; + } + const results = await Promise.all( + files.map((f) => createWorkflowFromYaml(f.yaml, f.fileName)), + ); + const successCount = results.filter(Boolean).length; + if (successCount > 0) { + queryClient.invalidateQueries({ queryKey: ["workflows"] }); + queryClient.invalidateQueries({ queryKey: ["folders"] }); + toast({ + variant: "success", + title: + successCount === 1 + ? "Workflow imported" + : `${successCount} workflows imported`, + description: + successCount === files.length + ? "Successfully imported all workflows" + : `${successCount} of ${files.length} workflows imported successfully`, + }); } }; + const handleFiles = async (fileList: FileList) => { + const files = Array.from(fileList); + const pdfFiles = files.filter((f) => f.name.toLowerCase().endsWith(".pdf")); + const yamlLikeFiles = files.filter( + (f) => !f.name.toLowerCase().endsWith(".pdf"), + ); + + let anyPdfStarted = false; + for (const file of pdfFiles) { + const ok = await createWorkflowFromPdf(file); + if (ok) { + anyPdfStarted = true; + toast({ + title: "Import started", + description: `Importing ${file.name}...`, + }); + } + } + if (anyPdfStarted) { + onImportStart?.(); + } + + const prepared: PreparedYamlImport[] = await Promise.all( + yamlLikeFiles.map(async (file) => { + const text = await file.text(); + const isJson = isJsonString(text); + const yaml = isJson ? convertToYAML(JSON.parse(text)) : text; + const title = extractTitleFromYaml(yaml); + let duplicateReason: DuplicateReason | null = null; + if (title) { + try { + const existing = await findExistingWorkflowTitle(title); + if (existing) { + duplicateReason = { kind: "existing", existingTitle: existing }; + } + } catch { + duplicateReason = { kind: "check-failed" }; + } + } + return { + fileName: file.name, + yaml, + title, + duplicateReason, + }; + }), + ); + + const titleCounts = new Map(); + for (const p of prepared) { + if (!p.title) continue; + const key = p.title.trim().toLowerCase(); + titleCounts.set(key, (titleCounts.get(key) ?? 0) + 1); + } + for (const p of prepared) { + if (p.duplicateReason !== null || !p.title) continue; + const key = p.title.trim().toLowerCase(); + if ((titleCounts.get(key) ?? 0) > 1) { + p.duplicateReason = { kind: "intra-batch" }; + } + } + + const checkFailed = prepared.filter( + (p) => p.duplicateReason?.kind === "check-failed", + ); + if (checkFailed.length > 0) { + toast({ + variant: "destructive", + title: + checkFailed.length === 1 + ? "Could not verify duplicate" + : `Could not verify ${checkFailed.length} duplicates`, + description: + "Network error checking if these workflows already exist. Review before importing.", + }); + } + + const duplicates = prepared.filter((p) => p.duplicateReason !== null); + const nonDuplicates = prepared.filter((p) => p.duplicateReason === null); + + if (duplicates.length === 0) { + await importYamlFiles(nonDuplicates); + return; + } + + setPendingNonDuplicates(nonDuplicates); + setPendingDuplicates(duplicates); + setDuplicateConfirmOpen(true); + }; + + const handleConfirmImportDuplicates = async () => { + const all = [...pendingNonDuplicates, ...pendingDuplicates]; + setDuplicateConfirmOpen(false); + setPendingDuplicates([]); + setPendingNonDuplicates([]); + setIsImporting(true); + try { + await importYamlFiles(all); + } finally { + setIsImporting(false); + } + }; + + const handleSkipDuplicates = async () => { + const nonDupes = pendingNonDuplicates; + setDuplicateConfirmOpen(false); + setPendingDuplicates([]); + setPendingNonDuplicates([]); + setIsImporting(true); + try { + await importYamlFiles(nonDupes); + } finally { + setIsImporting(false); + } + }; + + const handleCancelImport = () => { + setDuplicateConfirmOpen(false); + setPendingDuplicates([]); + setPendingNonDuplicates([]); + }; + + const isBusy = isImporting || duplicateConfirmOpen; + return ( - - - - + + + Import one or more workflows from YAML, JSON, or PDF files + + + + { + if (!open) { + handleCancelImport(); + } + }} + > + + + + {pendingDuplicates.length === 1 + ? "Duplicate workflow detected" + : "Duplicate workflows detected"} + + +
+

+ {pendingDuplicates.length === 1 + ? "The following file may create a duplicate:" + : "The following files may create duplicates:"} +

+
    + {pendingDuplicates.map((dup) => ( +
  • +
    + {dup.fileName} + {dup.title && ( + + {" "} + — “{dup.title}” + + )} +
    +
    + {dup.duplicateReason?.kind === "existing" && + "A workflow with this title already exists"} + {dup.duplicateReason?.kind === "intra-batch" && + "Another selected file has the same title"} + {dup.duplicateReason?.kind === "check-failed" && + "Could not verify if a duplicate exists"} +
    +
  • + ))} +
+

+ Would you like to import{" "} + {pendingDuplicates.length === 1 ? "it" : "them"} anyway? +

+
+
+
+ + + {pendingNonDuplicates.length > 0 && ( + + )} + + +
+
+ ); }