[Frontend] Saving Workflows as Templates (#4279)

This commit is contained in:
Marc Kelechava
2025-12-12 09:23:17 -08:00
committed by GitHub
parent 30bb5a8c71
commit bb83602af0
7 changed files with 380 additions and 20 deletions

View File

@@ -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 (
<Dialog>
<DropdownMenu>
@@ -123,6 +166,26 @@ function WorkflowActions({ workflow, onSuccessfullyDeleted }: Props) {
<CopyIcon className="mr-2 h-4 w-4" />
Clone Workflow
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
templateMutation.mutate({
workflowPermanentId: workflow.workflow_permanent_id,
isTemplate: !workflow.is_template,
});
}}
className="p-2"
disabled={templateMutation.isPending}
>
{workflow.is_template ? (
<BookmarkFilledIcon className="mr-2 h-4 w-4" />
) : (
<BookmarkIcon className="mr-2 h-4 w-4" />
)}
{workflow.is_template
? "Remove from Templates"
: "Save as Template"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<DownloadIcon className="mr-2 h-4 w-4" />

View File

@@ -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}
/>
<Button
disabled={createWorkflowMutation.isPending}
onClick={() => {
createWorkflowMutation.mutate({
...emptyWorkflowRequest,
folder_id: selectedFolderId,
});
}}
>
{createWorkflowMutation.isPending ? (
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
) : (
<PlusIcon className="mr-2 h-4 w-4" />
)}
Create
</Button>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button disabled={createWorkflowMutation.isPending}>
{createWorkflowMutation.isPending ? (
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
) : (
<PlusIcon className="mr-2 h-4 w-4" />
)}
Create
<ChevronDownIcon className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() => {
createWorkflowMutation.mutate({
...emptyWorkflowRequest,
folder_id: selectedFolderId,
});
}}
>
<PlusIcon className="mr-2 h-4 w-4" />
Blank Workflow
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => setIsTemplateDialogOpen(true)}
>
<BookmarkFilledIcon className="mr-2 h-4 w-4" />
From Template
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="rounded-lg border">
@@ -532,10 +561,22 @@ function Workflows() {
);
}}
>
<HighlightText
text={workflow.title}
query={debouncedSearch}
/>
<div className="flex items-center gap-2">
<HighlightText
text={workflow.title}
query={debouncedSearch}
/>
{workflow.is_template && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<BookmarkFilledIcon className="h-3.5 w-3.5 shrink-0 text-blue-500" />
</TooltipTrigger>
<TooltipContent>Template</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</TableCell>
<TableCell
onClick={(event) => {
@@ -749,6 +790,22 @@ function Workflows() {
onFolderSelect={setSelectedFolderId}
/>
{/* Template Dialog */}
<CreateFromTemplateDialog
open={isTemplateDialogOpen}
onOpenChange={setIsTemplateDialogOpen}
onSelectTemplate={(template) => {
const clonedWorkflow = convert({
...template,
title: `${template.title} (copy)`,
});
createWorkflowMutation.mutate({
...clonedWorkflow,
folder_id: selectedFolderId,
});
}}
/>
<WorkflowTemplates />
</div>
</div>

View File

@@ -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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Create from Template</DialogTitle>
<DialogDescription>
Select a template to create a new workflow with pre-filled blocks.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="relative">
<MagnifyingGlassIcon className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search templates..."
className="pl-9"
autoFocus
/>
</div>
<div className="max-h-[400px] overflow-y-auto">
{isLoading ? (
<div className="grid grid-cols-2 gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-24 rounded-lg" />
))}
</div>
) : filteredTemplates.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<BookmarkFilledIcon className="mb-3 h-10 w-10 text-slate-400" />
{templates.length === 0 ? (
<>
<p className="text-slate-600 dark:text-slate-300">
No templates yet
</p>
<p className="text-sm text-slate-400">
Save a workflow as a template to see it here.
</p>
</>
) : (
<>
<p className="text-slate-600 dark:text-slate-300">
No templates match your search
</p>
<p className="text-sm text-slate-400">
Try a different search term.
</p>
</>
)}
</div>
) : (
<div className="grid grid-cols-2 gap-3">
{filteredTemplates.map((template) => (
<Button
key={template.workflow_permanent_id}
variant="outline"
className={cn(
"flex h-auto flex-col items-start gap-1 p-4 text-left",
"hover:border-blue-400 hover:bg-blue-50 dark:hover:bg-blue-950/30",
)}
onClick={() => handleSelect(template)}
>
<div className="flex w-full items-center gap-2">
<BookmarkFilledIcon className="h-4 w-4 shrink-0 text-blue-500" />
<span className="truncate font-medium">
{template.title}
</span>
</div>
{template.description && (
<p className="line-clamp-2 text-xs text-slate-500">
{template.description}
</p>
)}
<p className="text-xs text-slate-400">
{template.workflow_definition.blocks.length} block
{template.workflow_definition.blocks.length !== 1
? "s"
: ""}
</p>
</Button>
))}
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
export { CreateFromTemplateDialog };

View File

@@ -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<HTMLInputElement>(null),
};
@@ -296,6 +340,39 @@ function WorkflowHeader({
<TooltipContent>Save</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
disabled={
isRecording || templateMutation.isPending || saving
}
size="icon"
variant={isTemplate ? "default" : "tertiary"}
className="size-10 min-w-[2.5rem]"
onClick={() => {
const newIsTemplate = !isTemplate;
if (newIsTemplate) {
// When saving AS template, save the workflow first
onSave();
}
templateMutation.mutate(newIsTemplate);
}}
>
{templateMutation.isPending ? (
<ReloadIcon className="size-6 animate-spin" />
) : isTemplate ? (
<BookmarkFilledIcon className="size-6" />
) : (
<BookmarkIcon className="size-6" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{isTemplate ? "Remove from Templates" : "Save as Template"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
{!workflowRunIsRunningOrQueued && (
<TooltipProvider>
<Tooltip>

View File

@@ -1123,6 +1123,7 @@ function Workspace({
cacheKeyValue={cacheKeyValue}
cacheKeyValues={cacheKeyValues}
isGeneratingCode={isGeneratingCode}
isTemplate={workflow?.is_template}
saving={workflowChangesStore.saveIsPending}
cacheKeyValuesPanelOpen={
workflowPanelState.active &&

View File

@@ -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<WorkflowApiResponse[]>({
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<WorkflowApiResponse[]>("/workflows", { params })
.then((response) => response.data);
},
});
}
export { useOrgTemplatesQuery };

View File

@@ -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;