From bb83602af09460423d05edfcc194d85b0b3f92ee Mon Sep 17 00:00:00 2001 From: Marc Kelechava Date: Fri, 12 Dec 2025 09:23:17 -0800 Subject: [PATCH] [Frontend] Saving Workflows as Templates (#4279) --- .../src/routes/workflows/WorkflowActions.tsx | 63 ++++++++ .../src/routes/workflows/Workflows.tsx | 97 +++++++++--- .../components/CreateFromTemplateDialog.tsx | 138 ++++++++++++++++++ .../workflows/editor/WorkflowHeader.tsx | 77 ++++++++++ .../src/routes/workflows/editor/Workspace.tsx | 1 + .../workflows/hooks/useOrgTemplatesQuery.ts | 23 +++ .../routes/workflows/types/workflowTypes.ts | 1 + 7 files changed, 380 insertions(+), 20 deletions(-) create mode 100644 skyvern-frontend/src/routes/workflows/components/CreateFromTemplateDialog.tsx create mode 100644 skyvern-frontend/src/routes/workflows/hooks/useOrgTemplatesQuery.ts diff --git a/skyvern-frontend/src/routes/workflows/WorkflowActions.tsx b/skyvern-frontend/src/routes/workflows/WorkflowActions.tsx index 51dab535..ab9d1a7f 100644 --- a/skyvern-frontend/src/routes/workflows/WorkflowActions.tsx +++ b/skyvern-frontend/src/routes/workflows/WorkflowActions.tsx @@ -16,6 +16,7 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, + DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, @@ -24,6 +25,8 @@ import { import { toast } from "@/components/ui/use-toast"; import { useCredentialGetter } from "@/hooks/useCredentialGetter"; import { + BookmarkFilledIcon, + BookmarkIcon, CopyIcon, DotsHorizontalIcon, DownloadIcon, @@ -98,6 +101,46 @@ function WorkflowActions({ workflow, onSuccessfullyDeleted }: Props) { }, }); + const templateMutation = useMutation({ + mutationFn: async ({ + workflowPermanentId, + isTemplate, + }: { + workflowPermanentId: string; + isTemplate: boolean; + }) => { + // Template endpoint only exists on /v1 (no /api prefix) + const client = await getClient(credentialGetter, "sans-api-v1"); + return client.put( + `/workflows/${workflowPermanentId}/template?is_template=${isTemplate}`, + ); + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: ["workflows"], + }); + queryClient.invalidateQueries({ + queryKey: ["orgTemplates"], + }); + queryClient.invalidateQueries({ + queryKey: ["workflow", variables.workflowPermanentId], + }); + toast({ + title: variables.isTemplate + ? "Saved as template" + : "Removed from templates", + variant: "success", + }); + }, + onError: (error: AxiosError) => { + toast({ + variant: "destructive", + title: "Failed to update template status", + description: error.message, + }); + }, + }); + return ( @@ -123,6 +166,26 @@ function WorkflowActions({ workflow, onSuccessfullyDeleted }: Props) { Clone Workflow + { + templateMutation.mutate({ + workflowPermanentId: workflow.workflow_permanent_id, + isTemplate: !workflow.is_template, + }); + }} + className="p-2" + disabled={templateMutation.isPending} + > + {workflow.is_template ? ( + + ) : ( + + )} + {workflow.is_template + ? "Remove from Templates" + : "Save as Template"} + + diff --git a/skyvern-frontend/src/routes/workflows/Workflows.tsx b/skyvern-frontend/src/routes/workflows/Workflows.tsx index 1d6b0e8f..49f0e559 100644 --- a/skyvern-frontend/src/routes/workflows/Workflows.tsx +++ b/skyvern-frontend/src/routes/workflows/Workflows.tsx @@ -26,6 +26,8 @@ import { useCredentialGetter } from "@/hooks/useCredentialGetter"; import { basicLocalTimeFormat, basicTimeFormat } from "@/util/timeFormat"; import { cn } from "@/util/utils"; import { + BookmarkFilledIcon, + ChevronDownIcon, DotsHorizontalIcon, FileIcon, LightningBoltIcon, @@ -39,9 +41,16 @@ import { useQuery } from "@tanstack/react-query"; import React, { useEffect, useMemo, useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { useDebounce } from "use-debounce"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { NarrativeCard } from "./components/header/NarrativeCard"; import { FolderCard } from "./components/FolderCard"; import { CreateFolderDialog } from "./components/CreateFolderDialog"; +import { CreateFromTemplateDialog } from "./components/CreateFromTemplateDialog"; import { ViewAllFoldersDialog } from "./components/ViewAllFoldersDialog"; import { WorkflowFolderSelector } from "./components/WorkflowFolderSelector"; import { HighlightText } from "./components/HighlightText"; @@ -49,6 +58,7 @@ import { useCreateWorkflowMutation } from "./hooks/useCreateWorkflowMutation"; import { useFoldersQuery } from "./hooks/useFoldersQuery"; import { useActiveImportsPolling } from "./hooks/useActiveImportsPolling"; import { ImportWorkflowButton } from "./ImportWorkflowButton"; +import { convert } from "./editor/workflowEditorUtils"; import { WorkflowApiResponse } from "./types/workflowTypes"; import { WorkflowCreateYAMLRequest } from "./types/workflowYamlTypes"; import { WorkflowActions } from "./WorkflowActions"; @@ -88,6 +98,9 @@ function Workflows() { const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false); const [isViewAllFoldersOpen, setIsViewAllFoldersOpen] = useState(false); + // Template dialog state + const [isTemplateDialogOpen, setIsTemplateDialogOpen] = useState(false); + // Poll for active imports const { activeImports, startPolling } = useActiveImportsPolling(); @@ -390,22 +403,38 @@ function Workflows() { onImportStart={startPolling} selectedFolderId={selectedFolderId} /> - + + + + + + { + createWorkflowMutation.mutate({ + ...emptyWorkflowRequest, + folder_id: selectedFolderId, + }); + }} + > + + Blank Workflow + + setIsTemplateDialogOpen(true)} + > + + From Template + + +
@@ -532,10 +561,22 @@ function Workflows() { ); }} > - +
+ + {workflow.is_template && ( + + + + + + Template + + + )} +
{ @@ -749,6 +790,22 @@ function Workflows() { onFolderSelect={setSelectedFolderId} /> + {/* Template Dialog */} + { + const clonedWorkflow = convert({ + ...template, + title: `${template.title} (copy)`, + }); + createWorkflowMutation.mutate({ + ...clonedWorkflow, + folder_id: selectedFolderId, + }); + }} + /> +
diff --git a/skyvern-frontend/src/routes/workflows/components/CreateFromTemplateDialog.tsx b/skyvern-frontend/src/routes/workflows/components/CreateFromTemplateDialog.tsx new file mode 100644 index 00000000..ceb330cc --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/components/CreateFromTemplateDialog.tsx @@ -0,0 +1,138 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Skeleton } from "@/components/ui/skeleton"; +import { BookmarkFilledIcon, MagnifyingGlassIcon } from "@radix-ui/react-icons"; +import { useOrgTemplatesQuery } from "../hooks/useOrgTemplatesQuery"; +import { WorkflowApiResponse } from "../types/workflowTypes"; +import { cn } from "@/util/utils"; + +interface CreateFromTemplateDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSelectTemplate: (template: WorkflowApiResponse) => void; +} + +function CreateFromTemplateDialog({ + open, + onOpenChange, + onSelectTemplate, +}: CreateFromTemplateDialogProps) { + const [search, setSearch] = useState(""); + const { data: templates = [], isLoading } = useOrgTemplatesQuery(); + + const filteredTemplates = templates.filter((template) => + template.title.toLowerCase().includes(search.toLowerCase()), + ); + + const handleSelect = (template: WorkflowApiResponse) => { + onSelectTemplate(template); + onOpenChange(false); + setSearch(""); + }; + + const handleOpenChange = (open: boolean) => { + onOpenChange(open); + if (!open) { + setSearch(""); + } + }; + + return ( + + + + Create from Template + + Select a template to create a new workflow with pre-filled blocks. + + +
+
+ + setSearch(e.target.value)} + placeholder="Search templates..." + className="pl-9" + autoFocus + /> +
+
+ {isLoading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ) : filteredTemplates.length === 0 ? ( +
+ + {templates.length === 0 ? ( + <> +

+ No templates yet +

+

+ Save a workflow as a template to see it here. +

+ + ) : ( + <> +

+ No templates match your search +

+

+ Try a different search term. +

+ + )} +
+ ) : ( +
+ {filteredTemplates.map((template) => ( + + ))} +
+ )} +
+
+
+
+ ); +} + +export { CreateFromTemplateDialog }; diff --git a/skyvern-frontend/src/routes/workflows/editor/WorkflowHeader.tsx b/skyvern-frontend/src/routes/workflows/editor/WorkflowHeader.tsx index 3b61fff5..ff063eaa 100644 --- a/skyvern-frontend/src/routes/workflows/editor/WorkflowHeader.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/WorkflowHeader.tsx @@ -1,4 +1,6 @@ import { + BookmarkFilledIcon, + BookmarkIcon, ChevronDownIcon, ChevronUpIcon, ClockIcon, @@ -21,6 +23,11 @@ import { import { Input } from "@/components/ui/input"; import { statusIsRunningOrQueued } from "@/routes/tasks/types"; import { useGlobalWorkflowsQuery } from "../hooks/useGlobalWorkflowsQuery"; +import { getClient } from "@/api/AxiosClient"; +import { useCredentialGetter } from "@/hooks/useCredentialGetter"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { AxiosError } from "axios"; +import { toast } from "@/components/ui/use-toast"; import { EditableNodeTitle } from "./nodes/components/EditableNodeTitle"; import { useCreateWorkflowMutation } from "../hooks/useCreateWorkflowMutation"; import { convert } from "./workflowEditorUtils"; @@ -41,6 +48,7 @@ type Props = { cacheKeyValues: CacheKeyValuesResponse | undefined; cacheKeyValuesPanelOpen: boolean; isGeneratingCode?: boolean; + isTemplate?: boolean; parametersPanelOpen: boolean; saving: boolean; showAllCode: boolean; @@ -61,6 +69,7 @@ function WorkflowHeader({ cacheKeyValues, cacheKeyValuesPanelOpen, isGeneratingCode, + isTemplate, parametersPanelOpen, saving, showAllCode, @@ -90,6 +99,41 @@ function WorkflowHeader({ cacheKeyValue ?? null, ); + const credentialGetter = useCredentialGetter(); + const queryClient = useQueryClient(); + + const templateMutation = useMutation({ + mutationFn: async (newIsTemplate: boolean) => { + // Template endpoint only exists on /v1 (no /api prefix) + const client = await getClient(credentialGetter, "sans-api-v1"); + return client.put( + `/workflows/${workflowPermanentId}/template?is_template=${newIsTemplate}`, + ); + }, + onSuccess: (_, newIsTemplate) => { + queryClient.invalidateQueries({ + queryKey: ["workflows"], + }); + queryClient.invalidateQueries({ + queryKey: ["orgTemplates"], + }); + queryClient.invalidateQueries({ + queryKey: ["workflow", workflowPermanentId], + }); + toast({ + title: newIsTemplate ? "Saved as template" : "Removed from templates", + variant: "success", + }); + }, + onError: (error: AxiosError) => { + toast({ + variant: "destructive", + title: "Failed to update template status", + description: error.message, + }); + }, + }); + const dom: Dom = { input: useRef(null), }; @@ -296,6 +340,39 @@ function WorkflowHeader({ Save + + + + + + + {isTemplate ? "Remove from Templates" : "Save as Template"} + + + {!workflowRunIsRunningOrQueued && ( diff --git a/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx b/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx index f586524e..a166e6da 100644 --- a/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx @@ -1123,6 +1123,7 @@ function Workspace({ cacheKeyValue={cacheKeyValue} cacheKeyValues={cacheKeyValues} isGeneratingCode={isGeneratingCode} + isTemplate={workflow?.is_template} saving={workflowChangesStore.saveIsPending} cacheKeyValuesPanelOpen={ workflowPanelState.active && diff --git a/skyvern-frontend/src/routes/workflows/hooks/useOrgTemplatesQuery.ts b/skyvern-frontend/src/routes/workflows/hooks/useOrgTemplatesQuery.ts new file mode 100644 index 00000000..d09c982c --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/hooks/useOrgTemplatesQuery.ts @@ -0,0 +1,23 @@ +import { useQuery } from "@tanstack/react-query"; +import { getClient } from "@/api/AxiosClient"; +import { useCredentialGetter } from "@/hooks/useCredentialGetter"; +import { WorkflowApiResponse } from "../types/workflowTypes"; + +function useOrgTemplatesQuery() { + const credentialGetter = useCredentialGetter(); + + return useQuery({ + queryKey: ["orgTemplates"], + queryFn: async () => { + const client = await getClient(credentialGetter); + const params = new URLSearchParams(); + params.append("only_templates", "true"); + params.append("page_size", "100"); + return client + .get("/workflows", { params }) + .then((response) => response.data); + }, + }); +} + +export { useOrgTemplatesQuery }; diff --git a/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts b/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts index e9de2775..e2a5decb 100644 --- a/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts +++ b/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts @@ -562,6 +562,7 @@ export type WorkflowApiResponse = { workflow_id: string; organization_id: string; is_saved_task: boolean; + is_template: boolean; title: string; workflow_permanent_id: string; version: number;