[Frontend] Saving Workflows as Templates (#4279)
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
|
||||
@@ -1123,6 +1123,7 @@ function Workspace({
|
||||
cacheKeyValue={cacheKeyValue}
|
||||
cacheKeyValues={cacheKeyValues}
|
||||
isGeneratingCode={isGeneratingCode}
|
||||
isTemplate={workflow?.is_template}
|
||||
saving={workflowChangesStore.saveIsPending}
|
||||
cacheKeyValuesPanelOpen={
|
||||
workflowPanelState.active &&
|
||||
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user