Folders, Persistent Import Progress Tracking & UX Enhancements (#3841)

Co-authored-by: Jonathan Dobson <jon.m.dobson@gmail.com>
This commit is contained in:
Celal Zamanoglu
2025-11-05 18:37:18 +03:00
committed by GitHub
parent fcc3f30ba4
commit 75948053b9
32 changed files with 2886 additions and 538 deletions

View File

@@ -1,13 +1,11 @@
import { getClient } from "@/api/AxiosClient";
import { Label } from "@/components/ui/label";
import { ReloadIcon, UploadIcon } from "@radix-ui/react-icons";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { UploadIcon } from "@radix-ui/react-icons";
import { useQueryClient } from "@tanstack/react-query";
import { useId } from "react";
import { stringify as convertToYAML } from "yaml";
import { WorkflowApiResponse } from "./types/workflowTypes";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { useNavigate } from "react-router-dom";
import { AxiosError } from "axios";
import { toast } from "@/components/ui/use-toast";
import {
Tooltip,
@@ -15,6 +13,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { AxiosError } from "axios";
function isJsonString(str: string): boolean {
try {
@@ -25,16 +24,28 @@ function isJsonString(str: string): boolean {
return true;
}
function ImportWorkflowButton() {
function getErrorMessage(error: unknown, fallback: string): string {
if (error instanceof AxiosError) {
return error.response?.data?.detail || error.message || fallback;
} else if (error instanceof Error) {
return error.message;
}
return fallback;
}
interface ImportWorkflowButtonProps {
onImportStart?: () => void;
}
function ImportWorkflowButton({ onImportStart }: ImportWorkflowButtonProps) {
const inputId = useId();
const credentialGetter = useCredentialGetter();
const queryClient = useQueryClient();
const navigate = useNavigate();
const createWorkflowFromYamlMutation = useMutation({
mutationFn: async (yaml: string) => {
const createWorkflowFromYamlMutation = async (yaml: string) => {
try {
const client = await getClient(credentialGetter);
return client.post<string, { data: WorkflowApiResponse }>(
await client.post<string, { data: WorkflowApiResponse }>(
"/workflows",
yaml,
{
@@ -43,21 +54,51 @@ function ImportWorkflowButton() {
},
},
);
},
onSuccess: (response) => {
queryClient.invalidateQueries({
queryKey: ["workflows"],
});
navigate(`/workflows/${response.data.workflow_permanent_id}/debug`);
},
onError: (error: AxiosError) => {
toast({
variant: "success",
title: "Workflow imported",
description: "Successfully imported workflow",
});
} catch (error) {
toast({
variant: "destructive",
title: "Error importing workflow",
description: error.message || "An error occurred",
description: getErrorMessage(error, "Failed to import workflow"),
});
},
});
}
};
const createWorkflowFromPdfMutation = async (file: File) => {
try {
const formData = new FormData();
formData.append("file", file);
const client = await getClient(credentialGetter);
await client.post("/workflows/import-pdf", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
// Notify parent to start polling
onImportStart?.();
toast({
title: "Import started",
description: `Importing ${file.name}...`,
});
} catch (error) {
toast({
title: "Import Failed",
description: getErrorMessage(error, "Failed to import PDF"),
variant: "destructive",
});
}
};
return (
<TooltipProvider>
@@ -75,38 +116,8 @@ function ImportWorkflowButton() {
const fileName = file.name.toLowerCase();
if (fileName.endsWith(".pdf")) {
// Handle PDF file - send as FormData to new endpoint
const formData = new FormData();
formData.append("file", file);
const client = await getClient(credentialGetter);
try {
const response = await client.post<WorkflowApiResponse>(
"/workflows/import-pdf",
formData,
{
headers: {
"Content-Type": "multipart/form-data",
},
},
);
queryClient.invalidateQueries({
queryKey: ["workflows"],
});
navigate(
`/workflows/${response.data.workflow_permanent_id}/debug`,
);
} catch (error) {
toast({
title: "Import Failed",
description:
error instanceof Error
? error.message
: "Failed to import PDF",
variant: "destructive",
});
}
// Handle PDF file
await createWorkflowFromPdfMutation(file);
} else {
// Non-pdf files like yaml, json
const fileTextContent = await file.text();
@@ -114,17 +125,14 @@ function ImportWorkflowButton() {
const content = isJson
? convertToYAML(JSON.parse(fileTextContent))
: fileTextContent;
createWorkflowFromYamlMutation.mutate(content);
await createWorkflowFromYamlMutation(content);
}
}
}}
/>
<div className="flex h-full cursor-pointer items-center gap-2 rounded-md bg-secondary px-4 py-2 font-bold text-secondary-foreground hover:bg-secondary/90">
{createWorkflowFromYamlMutation.isPending ? (
<ReloadIcon className="h-4 w-4 animate-spin" />
) : (
<UploadIcon className="h-4 w-4" />
)}
<UploadIcon className="h-4 w-4" />
Import
</div>
</Label>

View File

@@ -84,6 +84,9 @@ function WorkflowActions({ workflow, onSuccessfullyDeleted }: Props) {
queryClient.invalidateQueries({
queryKey: ["workflows"],
});
queryClient.invalidateQueries({
queryKey: ["folders"],
});
onSuccessfullyDeleted?.();
},
onError: (error: AxiosError) => {

View File

@@ -27,26 +27,34 @@ import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { basicLocalTimeFormat, basicTimeFormat } from "@/util/timeFormat";
import { cn } from "@/util/utils";
import {
DotsHorizontalIcon,
FileIcon,
LightningBoltIcon,
MagnifyingGlassIcon,
MixerHorizontalIcon,
Pencil2Icon,
PlayIcon,
PlusIcon,
ReloadIcon,
} from "@radix-ui/react-icons";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import React, { useCallback, useMemo, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useDebounce } from "use-debounce";
import { NarrativeCard } from "./components/header/NarrativeCard";
import { WorkflowParametersDialog } from "./components/WorkflowParametersDialog";
import { FolderCard } from "./components/FolderCard";
import { CreateFolderDialog } from "./components/CreateFolderDialog";
import { ViewAllFoldersDialog } from "./components/ViewAllFoldersDialog";
import { WorkflowFolderSelector } from "./components/WorkflowFolderSelector";
import { HighlightText } from "./components/HighlightText";
import { useCreateWorkflowMutation } from "./hooks/useCreateWorkflowMutation";
import { useFoldersQuery } from "./hooks/useFoldersQuery";
import { useActiveImportsPolling } from "./hooks/useActiveImportsPolling";
import { ImportWorkflowButton } from "./ImportWorkflowButton";
import { WorkflowApiResponse } from "./types/workflowTypes";
import { Parameter, WorkflowApiResponse } from "./types/workflowTypes";
import { WorkflowCreateYAMLRequest } from "./types/workflowYamlTypes";
import { WorkflowActions } from "./WorkflowActions";
import { WorkflowTemplates } from "../discover/WorkflowTemplates";
import { Skeleton } from "@/components/ui/skeleton";
const emptyWorkflowRequest: WorkflowCreateYAMLRequest = {
title: "New Workflow",
@@ -65,17 +73,55 @@ function Workflows() {
const createWorkflowMutation = useCreateWorkflowMutation();
const [searchParams, setSearchParams] = useSearchParams();
const [search, setSearch] = useState("");
const [openWorkflowId, setOpenWorkflowId] = useState<string | null>(null);
const [debouncedSearch] = useDebounce(search, 500);
const [debouncedSearch] = useDebounce(search, 250);
const page = searchParams.get("page") ? Number(searchParams.get("page")) : 1;
const itemsPerPage = searchParams.get("page_size")
? Number(searchParams.get("page_size"))
: 10;
const { data: workflows = [], isLoading } = useQuery<
Array<WorkflowApiResponse>
>({
queryKey: ["workflows", debouncedSearch, page, itemsPerPage],
// Folder state
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
const [isViewAllFoldersOpen, setIsViewAllFoldersOpen] = useState(false);
// Parameter expansion state
const [manuallyExpandedRows, setManuallyExpandedRows] = useState<Set<string>>(
new Set(),
);
// Poll for active imports
const { activeImports, startPolling } = useActiveImportsPolling();
// Fetch folders
const { data: allFolders = [] } = useFoldersQuery({ page_size: 10 });
// Create folders map for O(1) lookup
const foldersMap = useMemo(() => {
return new Map(allFolders.map((f) => [f.folder_id, f]));
}, [allFolders]);
// Sort folders by modified date (most recent first) and get top 5
const recentFolders = useMemo(() => {
return [...allFolders]
.sort(
(a, b) =>
new Date(b.modified_at).getTime() - new Date(a.modified_at).getTime(),
)
.slice(0, 5);
}, [allFolders]);
const {
data: workflows = [],
isFetching,
isPlaceholderData,
} = useQuery<Array<WorkflowApiResponse>>({
queryKey: [
"workflows",
debouncedSearch,
page,
itemsPerPage,
selectedFolderId,
],
queryFn: async () => {
const client = await getClient(credentialGetter);
const params = new URLSearchParams();
@@ -85,16 +131,26 @@ function Workflows() {
if (debouncedSearch) {
params.append("search_key", debouncedSearch);
}
if (selectedFolderId) {
params.append("folder_id", selectedFolderId);
}
return client
.get(`/workflows`, {
params,
})
.then((response) => response.data);
},
placeholderData: (previousData) => previousData,
});
const { data: nextPageWorkflows } = useQuery<Array<WorkflowApiResponse>>({
queryKey: ["workflows", debouncedSearch, page + 1, itemsPerPage],
queryKey: [
"workflows",
debouncedSearch,
page + 1,
itemsPerPage,
selectedFolderId,
],
queryFn: async () => {
const client = await getClient(credentialGetter);
const params = new URLSearchParams();
@@ -104,6 +160,9 @@ function Workflows() {
if (debouncedSearch) {
params.append("search_key", debouncedSearch);
}
if (selectedFolderId) {
params.append("folder_id", selectedFolderId);
}
return client
.get(`/workflows`, {
params,
@@ -114,7 +173,61 @@ function Workflows() {
});
const isNextDisabled =
isLoading || !nextPageWorkflows || nextPageWorkflows.length === 0;
isFetching || !nextPageWorkflows || nextPageWorkflows.length === 0;
// Check if a specific parameter matches the search
const parameterMatchesSearch = useCallback(
(param: Parameter): boolean => {
if (!debouncedSearch.trim()) return false;
const lowerQuery = debouncedSearch.toLowerCase();
const keyMatch = param.key?.toLowerCase().includes(lowerQuery) ?? false;
const descMatch =
param.description?.toLowerCase().includes(lowerQuery) ?? false;
const valueMatch = Boolean(
param.parameter_type === "workflow" &&
param.default_value &&
String(param.default_value).toLowerCase().includes(lowerQuery),
);
return keyMatch || descMatch || valueMatch;
},
[debouncedSearch],
);
// Auto-expand rows when parameters match search
const autoExpandedRows = useMemo(() => {
if (!debouncedSearch.trim()) return new Set<string>();
const expanded = new Set<string>();
workflows.forEach((workflow) => {
const hasParameterMatch = workflow.workflow_definition.parameters?.some(
(param) => parameterMatchesSearch(param),
);
if (hasParameterMatch) {
expanded.add(workflow.workflow_permanent_id);
}
});
return expanded;
}, [workflows, debouncedSearch, parameterMatchesSearch]);
// Combine manual and auto-expanded rows
const expandedRows = useMemo(() => {
return new Set([...manuallyExpandedRows, ...autoExpandedRows]);
}, [manuallyExpandedRows, autoExpandedRows]);
const toggleParametersExpanded = (workflowId: string) => {
const newExpanded = new Set(manuallyExpandedRows);
if (newExpanded.has(workflowId)) {
newExpanded.delete(workflowId);
} else {
newExpanded.add(workflowId);
}
setManuallyExpandedRows(newExpanded);
};
function handleRowClick(
event: React.MouseEvent<HTMLTableCellElement>,
@@ -162,6 +275,18 @@ function Workflows() {
setParamPatch({ page: String(page + 1) });
}
// Show importing workflows from polling hook (only on page 1)
const displayWorkflows = useMemo(() => {
const importingOnly = activeImports.filter(
(imp) => imp.status === "importing",
);
if (page === 1 && importingOnly.length > 0) {
return [...importingOnly, ...workflows];
}
return workflows;
}, [activeImports, workflows, page]);
return (
<div className="space-y-10">
<div className="flex h-32 justify-between gap-6">
@@ -191,8 +316,88 @@ function Workflows() {
</div>
</div>
<div className="space-y-4">
<header>
{/* Folders Section */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold">Folders</h2>
<Button
variant="link"
size="sm"
className="h-auto p-0 text-blue-600 dark:text-blue-400"
onClick={() => setIsCreateFolderOpen(true)}
>
+ New folder
</Button>
</div>
{allFolders.length > 5 && (
<Button
variant="link"
size="sm"
className="text-blue-600 dark:text-blue-400"
onClick={() => setIsViewAllFoldersOpen(true)}
>
View all
</Button>
)}
</div>
{recentFolders.length > 0 ? (
<div className="grid grid-cols-5 gap-4">
{recentFolders.map((folder) => (
<FolderCard
key={folder.folder_id}
folder={folder}
isSelected={selectedFolderId === folder.folder_id}
onClick={() =>
setSelectedFolderId(
selectedFolderId === folder.folder_id
? null
: folder.folder_id,
)
}
/>
))}
</div>
) : (
<div className="rounded-lg border border-slate-200 bg-slate-elevation1 py-6 text-center dark:border-slate-700">
<div className="mx-auto max-w-md">
<FileIcon className="mx-auto mb-3 h-10 w-10 text-blue-400 opacity-50" />
<h3 className="mb-2 text-slate-900 dark:text-slate-100">
Organize Your Workflows with Folders
</h3>
<p className="mb-4 text-sm text-slate-500 dark:text-slate-400">
Keep your workflows organized by creating folders. Group
related workflows together by project, team, or workflow type
for easier management.
</p>
<Button
variant="link"
size="sm"
className="h-auto p-0 text-blue-600 dark:text-blue-400"
onClick={() => setIsCreateFolderOpen(true)}
>
<PlusIcon className="mr-2 h-4 w-4" />
Create Your First Folder
</Button>
</div>
</div>
)}
</div>
{/* Workflows Section */}
<header className="flex items-center justify-between">
<h1 className="text-xl">My Flows</h1>
{selectedFolderId && (
<Button
variant="link"
size="sm"
className="h-auto p-0 text-blue-600 dark:text-blue-400"
onClick={() => setSelectedFolderId(null)}
>
View all workflows
</Button>
)}
</header>
<div className="flex justify-between">
<div className="relative">
@@ -210,7 +415,7 @@ function Workflows() {
/>
</div>
<div className="flex gap-4">
<ImportWorkflowButton />
<ImportWorkflowButton onImportStart={startPolling} />
<Button
disabled={createWorkflowMutation.isPending}
onClick={() => {
@@ -230,134 +435,298 @@ function Workflows() {
<Table>
<TableHeader className="rounded-t-lg bg-slate-elevation2">
<TableRow>
<TableHead className="w-1/3 rounded-tl-lg text-slate-400">
<TableHead className="w-1/4 rounded-tl-lg text-slate-400">
ID
</TableHead>
<TableHead className="w-1/3 text-slate-400">Title</TableHead>
<TableHead className="w-1/3 text-slate-400">
<TableHead className="w-1/4 text-slate-400">Title</TableHead>
<TableHead className="w-1/6 text-slate-400">Folder</TableHead>
<TableHead className="w-1/6 text-slate-400">
Created At
</TableHead>
<TableHead className="rounded-tr-lg"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
{isFetching &&
!isPlaceholderData &&
displayWorkflows.length === 0 ? (
// Show skeleton rows only on initial load (not during search refinement)
Array.from({ length: 10 }).map((_, index) => (
<TableRow key={`skeleton-${index}`}>
<TableCell>
<Skeleton className="h-5 w-full" />
</TableCell>
<TableCell>
<Skeleton className="h-5 w-full" />
</TableCell>
<TableCell>
<Skeleton className="h-5 w-20" />
</TableCell>
<TableCell>
<Skeleton className="h-5 w-32" />
</TableCell>
<TableCell>
<div className="flex justify-end gap-2">
<Skeleton className="h-8 w-8 rounded" />
<Skeleton className="h-8 w-8 rounded" />
<Skeleton className="h-8 w-8 rounded" />
<Skeleton className="h-8 w-8 rounded" />
</div>
</TableCell>
</TableRow>
))
) : displayWorkflows?.length === 0 ? (
<TableRow>
<TableCell colSpan={4}>Loading...</TableCell>
</TableRow>
) : workflows?.length === 0 ? (
<TableRow>
<TableCell colSpan={4}>No workflows found</TableCell>
<TableCell colSpan={5}>No workflows found</TableCell>
</TableRow>
) : (
workflows?.map((workflow) => {
displayWorkflows?.map((workflow) => {
const hasParameters =
workflow.workflow_definition.parameters.filter(
(p) => p.parameter_type !== "output",
).length > 0;
const isExpanded = expandedRows.has(
workflow.workflow_permanent_id,
);
// Check if this is an importing workflow
const isUploading = workflow.status === "importing";
return (
<TableRow
key={workflow.workflow_permanent_id}
className="cursor-pointer"
>
<TableCell
onClick={(event) => {
handleRowClick(event, workflow.workflow_permanent_id);
}}
>
{workflow.workflow_permanent_id}
</TableCell>
<TableCell
onClick={(event) => {
handleRowClick(event, workflow.workflow_permanent_id);
}}
>
{workflow.title}
</TableCell>
<TableCell
onClick={(event) => {
handleRowClick(event, workflow.workflow_permanent_id);
}}
title={basicTimeFormat(workflow.created_at)}
>
{basicLocalTimeFormat(workflow.created_at)}
</TableCell>
<TableCell>
<div className="flex justify-end gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="outline"
onClick={(event) => {
handleIconClick(
event,
`/workflows/${workflow.workflow_permanent_id}/debug`,
<React.Fragment key={workflow.workflow_permanent_id}>
{/* Main workflow row */}
{isUploading ? (
<TableRow className="opacity-70">
<TableCell colSpan={2}>
<div className="flex items-center gap-2">
<ReloadIcon className="h-4 w-4 animate-spin text-blue-400" />
<span>{workflow.title}</span>
</div>
</TableCell>
<TableCell>
<span className="text-slate-400">-</span>
</TableCell>
<TableCell>
{basicLocalTimeFormat(workflow.created_at)}
</TableCell>
<TableCell>
<div className="flex justify-end gap-2">
<Button size="icon" variant="ghost" disabled>
<FileIcon className="h-4 w-4" />
</Button>
<Button size="icon" variant="ghost" disabled>
<MixerHorizontalIcon className="h-4 w-4" />
</Button>
<Button size="icon" variant="ghost" disabled>
<PlayIcon className="h-4 w-4" />
</Button>
<Button size="icon" variant="ghost" disabled>
<DotsHorizontalIcon className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
) : (
<TableRow className="cursor-pointer">
<TableCell
onClick={(event) => {
handleRowClick(
event,
workflow.workflow_permanent_id,
);
}}
>
<HighlightText
text={workflow.workflow_permanent_id}
query={debouncedSearch}
/>
</TableCell>
<TableCell
onClick={(event) => {
handleRowClick(
event,
workflow.workflow_permanent_id,
);
}}
>
<HighlightText
text={workflow.title}
query={debouncedSearch}
/>
</TableCell>
<TableCell
onClick={(event) => {
handleRowClick(
event,
workflow.workflow_permanent_id,
);
}}
>
{workflow.folder_id ? (
<div className="flex items-center gap-1.5">
<FileIcon className="h-3.5 w-3.5 text-blue-400" />
<span className="text-sm">
<HighlightText
text={
foldersMap.get(workflow.folder_id)
?.title || workflow.folder_id
}
query={debouncedSearch}
/>
</span>
</div>
) : (
<span className="text-slate-400">-</span>
)}
</TableCell>
<TableCell
onClick={(event) => {
handleRowClick(
event,
workflow.workflow_permanent_id,
);
}}
title={basicTimeFormat(workflow.created_at)}
>
{basicLocalTimeFormat(workflow.created_at)}
</TableCell>
<TableCell>
<div className="flex justify-end gap-2">
<WorkflowFolderSelector
workflowPermanentId={
workflow.workflow_permanent_id
}
currentFolderId={workflow.folder_id}
/>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="outline"
onClick={() =>
toggleParametersExpanded(
workflow.workflow_permanent_id,
)
}
disabled={!hasParameters}
className={cn(
isExpanded && "text-blue-400",
)}
>
<MixerHorizontalIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{hasParameters
? isExpanded
? "Hide Parameters"
: "Show Parameters"
: "No Parameters"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="outline"
onClick={(event) => {
handleIconClick(
event,
`/workflows/${workflow.workflow_permanent_id}/run`,
);
}}
>
<PlayIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
Create New Run
</TooltipContent>
</Tooltip>
</TooltipProvider>
<WorkflowActions workflow={workflow} />
</div>
</TableCell>
</TableRow>
)}
{/* Expanded parameters section */}
{isExpanded && hasParameters && (
<TableRow
key={`${workflow.workflow_permanent_id}-params`}
>
<TableCell
colSpan={5}
className="bg-slate-50 dark:bg-slate-900/50"
>
<div className="ml-8 space-y-2 py-4">
<div className="mb-3 text-sm font-medium">
Parameters
</div>
<div className="space-y-2">
{workflow.workflow_definition.parameters
.filter((p) => p.parameter_type !== "output")
.map((param, idx) => {
const matchesParam =
parameterMatchesSearch(param);
return (
<div
key={idx}
className={cn(
"grid grid-cols-[140px_1fr_2fr] gap-4 rounded border bg-white p-3 text-sm dark:border-slate-800 dark:bg-slate-900",
matchesParam &&
"shadow-[0_0_15px_rgba(59,130,246,0.3)] ring-2 ring-blue-500/50",
)}
>
<div className="font-medium text-blue-600 dark:text-blue-400">
<HighlightText
text={param.key}
query={debouncedSearch}
/>
</div>
<div className="truncate">
{param.parameter_type ===
"workflow" &&
param.default_value ? (
<HighlightText
text={String(param.default_value)}
query={debouncedSearch}
/>
) : (
<span className="text-slate-400">
-
</span>
)}
</div>
<div className="text-slate-500">
{param.description ? (
<HighlightText
text={param.description}
query={debouncedSearch}
/>
) : (
<span className="text-slate-400">
No description
</span>
)}
</div>
</div>
);
}}
>
<Pencil2Icon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Open in Editor</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="outline"
onClick={(event) => {
handleIconClick(
event,
`/workflows/${workflow.workflow_permanent_id}/run`,
);
}}
>
<PlayIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Create New Run</TooltipContent>
</Tooltip>
</TooltipProvider>
<WorkflowActions workflow={workflow} />
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="outline"
onClick={() =>
setOpenWorkflowId(
workflow.workflow_permanent_id,
)
}
disabled={
!workflow.workflow_definition.parameters.some(
(p) => p.parameter_type !== "output",
)
}
>
<MixerHorizontalIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>View Parameters</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</TableCell>
</TableRow>
})}
</div>
</div>
</TableCell>
</TableRow>
)}
</React.Fragment>
);
})
)}
</TableBody>
</Table>
<WorkflowParametersDialog
open={openWorkflowId !== null}
onOpenChange={(open) => {
if (!open) setOpenWorkflowId(null);
}}
workflowId={openWorkflowId}
workflows={workflows}
/>
<div className="relative px-3 py-3">
<div className="absolute left-3 top-1/2 flex -translate-y-1/2 items-center gap-2 text-sm">
<span className="text-slate-400">Items per page</span>
@@ -403,6 +772,19 @@ function Workflows() {
</Pagination>
</div>
</div>
{/* Folder Dialogs */}
<CreateFolderDialog
open={isCreateFolderOpen}
onOpenChange={setIsCreateFolderOpen}
/>
<ViewAllFoldersDialog
open={isViewAllFoldersOpen}
onOpenChange={setIsViewAllFoldersOpen}
selectedFolderId={selectedFolderId}
onFolderSelect={setSelectedFolderId}
/>
<WorkflowTemplates />
</div>
</div>

View File

@@ -0,0 +1,101 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useCreateFolderMutation } from "../hooks/useFolderMutations";
interface CreateFolderDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
function CreateFolderDialog({ open, onOpenChange }: CreateFolderDialogProps) {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const createFolderMutation = useCreateFolderMutation();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) return;
await createFolderMutation.mutateAsync({
title: title.trim(),
description: description.trim() || null,
});
setTitle("");
setDescription("");
onOpenChange(false);
};
const handleOpenChange = (open: boolean) => {
onOpenChange(open);
if (!open) {
setTitle("");
setDescription("");
}
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Folder</DialogTitle>
<DialogDescription>
Create a folder to organize your workflows.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="folder-title">Title</Label>
<Input
id="folder-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g., Production Workflows"
autoFocus
/>
</div>
<div className="grid gap-2">
<Label htmlFor="folder-description">Description (optional)</Label>
<Textarea
id="folder-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Add a description..."
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
>
Cancel
</Button>
<Button
type="submit"
disabled={!title.trim() || createFolderMutation.isPending}
>
Create Folder
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
export { CreateFolderDialog };

View File

@@ -0,0 +1,118 @@
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 { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { ReloadIcon } from "@radix-ui/react-icons";
import { useEffect, useState } from "react";
import { useDeleteFolderMutation } from "../hooks/useFolderMutations";
type Props = {
folderId: string;
folderTitle: string;
};
function DeleteFolderButton({ folderId, folderTitle }: Props) {
const [deleteOption, setDeleteOption] = useState<
"folder_only" | "folder_and_workflows"
>("folder_only");
const [isDialogOpen, setIsDialogOpen] = useState(false);
const {
mutate: deleteFolder,
isPending: isDeleteFolderPending,
isSuccess: isDeleteFolderSuccess,
} = useDeleteFolderMutation();
// Close dialog when deletion succeeds
useEffect(() => {
if (isDeleteFolderSuccess) setIsDialogOpen(false);
}, [isDeleteFolderSuccess]);
const handleDelete = () => {
const deleteWorkflows = deleteOption === "folder_and_workflows";
deleteFolder({ folderId, folderTitle, deleteWorkflows });
};
return (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<button
onClick={(e) => e.stopPropagation()}
className="rounded p-1.5 text-red-400 transition-colors hover:bg-red-500/20 hover:text-red-300"
aria-label="Delete folder"
>
<GarbageIcon className="h-4 w-4" />
</button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent>Delete Folder</TooltipContent>
</Tooltip>
</TooltipProvider>
<DialogContent onCloseAutoFocus={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle>Delete Folder: {folderTitle}</DialogTitle>
<DialogDescription>
Choose how you want to delete this folder.
</DialogDescription>
</DialogHeader>
<RadioGroup
value={deleteOption}
onValueChange={(value) =>
setDeleteOption(value as typeof deleteOption)
}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="folder_only" id="folder_only" />
<Label htmlFor="folder_only" className="font-normal">
Delete folder only (workflows will be unassigned)
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="folder_and_workflows"
id="folder_and_workflows"
/>
<Label htmlFor="folder_and_workflows" className="font-normal">
Delete folder and all workflows inside it
</Label>
</div>
</RadioGroup>
<DialogFooter>
<DialogClose asChild>
<Button variant="secondary">Cancel</Button>
</DialogClose>
<Button
variant="destructive"
onClick={handleDelete}
disabled={isDeleteFolderPending}
>
{isDeleteFolderPending && (
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
)}
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { DeleteFolderButton };

View File

@@ -0,0 +1,110 @@
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useUpdateFolderMutation } from "../hooks/useFolderMutations";
import type { Folder } from "../types/folderTypes";
interface EditFolderDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
folder: Folder;
}
function EditFolderDialog({
open,
onOpenChange,
folder,
}: EditFolderDialogProps) {
const [title, setTitle] = useState(folder.title);
const [description, setDescription] = useState(folder.description || "");
const updateFolderMutation = useUpdateFolderMutation();
// Reset form when folder changes or dialog opens
useEffect(() => {
if (open) {
setTitle(folder.title);
setDescription(folder.description || "");
}
}, [open, folder]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) return;
await updateFolderMutation.mutateAsync({
folderId: folder.folder_id,
data: {
title: title.trim(),
description: description.trim() || null,
},
});
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Folder</DialogTitle>
<DialogDescription>
Update the folder's title and description.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="edit-folder-title">Title</Label>
<Input
id="edit-folder-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g., Production Workflows"
autoFocus
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-folder-description">
Description (optional)
</Label>
<Textarea
id="edit-folder-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Add a description..."
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
type="submit"
disabled={!title.trim() || updateFolderMutation.isPending}
>
Save Changes
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
export { EditFolderDialog };

View File

@@ -0,0 +1,89 @@
import { FileIcon, Pencil1Icon } from "@radix-ui/react-icons";
import { cn } from "@/util/utils";
import type { Folder } from "../types/folderTypes";
import { DeleteFolderButton } from "./DeleteFolderButton";
import { EditFolderDialog } from "./EditFolderDialog";
import { useState } from "react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface FolderCardProps {
folder: Folder;
isSelected: boolean;
onClick: () => void;
}
function FolderCard({ folder, isSelected, onClick }: FolderCardProps) {
const [isHovered, setIsHovered] = useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
return (
<>
<button
onClick={onClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className={cn(
"relative flex h-24 flex-col gap-3 rounded-lg border p-4 text-left transition-colors hover:border-blue-400",
isSelected
? "border-blue-400 bg-blue-50 ring-2 ring-blue-400/20 dark:bg-blue-950/20"
: "border-slate-200 bg-slate-elevation1 dark:border-slate-700",
)}
>
<div className="flex items-start gap-3">
<div className="mt-0.5">
<FileIcon className="h-5 w-5 text-blue-400" />
</div>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex items-start justify-between gap-2">
<h3 className="truncate text-sm font-medium text-slate-900 dark:text-slate-100">
{folder.title}
</h3>
<div
onClick={(e) => e.stopPropagation()}
className={cn(
"flex gap-1 transition-opacity",
isHovered ? "opacity-100" : "opacity-0",
)}
>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setIsEditDialogOpen(true)}
className="rounded p-1.5 text-slate-400 transition-colors hover:bg-slate-500/20 hover:text-slate-300"
aria-label="Edit folder"
>
<Pencil1Icon className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent>Edit Folder</TooltipContent>
</Tooltip>
</TooltipProvider>
<DeleteFolderButton
folderId={folder.folder_id}
folderTitle={folder.title}
/>
</div>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400">
{folder.workflow_count}{" "}
{folder.workflow_count === 1 ? "workflow" : "workflows"}
</p>
</div>
</div>
</button>
<EditFolderDialog
open={isEditDialogOpen}
onOpenChange={setIsEditDialogOpen}
folder={folder}
/>
</>
);
}
export { FolderCard };

View File

@@ -0,0 +1,32 @@
interface HighlightTextProps {
text: string;
query?: string;
}
function HighlightText({ text, query }: HighlightTextProps) {
if (!query || !query.trim()) {
return <>{text}</>;
}
const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const q = query.trim();
const regex = new RegExp(`(${escapeRegExp(q)})`, "gi");
const parts = text.split(regex);
const lowerQ = q.toLowerCase();
return (
<>
{parts.map((part, i) =>
part.toLowerCase() === lowerQ ? (
<span key={i} className="rounded bg-blue-500/30 px-0.5 text-blue-400">
{part}
</span>
) : (
<span key={i}>{part}</span>
),
)}
</>
);
}
export { HighlightText };

View File

@@ -0,0 +1,147 @@
import { useState, useMemo } from "react";
import { MagnifyingGlassIcon, ReloadIcon } from "@radix-ui/react-icons";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { FolderCard } from "./FolderCard";
import { useInfiniteFoldersQuery } from "../hooks/useInfiniteFoldersQuery";
import { handleInfiniteScroll } from "@/util/utils";
import { useDebounce } from "use-debounce";
import { Skeleton } from "@/components/ui/skeleton";
interface ViewAllFoldersDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
selectedFolderId: string | null;
onFolderSelect: (folderId: string | null) => void;
}
function ViewAllFoldersDialog({
open,
onOpenChange,
selectedFolderId,
onFolderSelect,
}: ViewAllFoldersDialogProps) {
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebounce(search, 500);
const isTyping = search !== debouncedSearch;
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isFetching } =
useInfiniteFoldersQuery({
search: debouncedSearch,
page_size: 20, // Load 20 to ensure scrollbar appears
});
// Flatten pages into a single array
const folders = useMemo(() => {
return data?.pages.flatMap((page) => page) ?? [];
}, [data]);
const handleFolderClick = (folderId: string) => {
onFolderSelect(selectedFolderId === folderId ? null : folderId);
onOpenChange(false);
setSearch("");
};
const handleOpenChange = (open: boolean) => {
onOpenChange(open);
if (!open) {
setSearch("");
}
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-h-[90vh] max-w-4xl">
<DialogHeader>
<DialogTitle>All Folders</DialogTitle>
<DialogDescription>
Browse and select from all folders. Scroll to load more.
</DialogDescription>
</DialogHeader>
<div className="relative mb-4">
<MagnifyingGlassIcon className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
type="text"
placeholder="Search folders..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
<div
className="max-h-[500px] overflow-y-auto pr-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:border-2 [&::-webkit-scrollbar-thumb]:border-slate-100 [&::-webkit-scrollbar-thumb]:bg-slate-300 dark:[&::-webkit-scrollbar-thumb]:border-slate-800 dark:[&::-webkit-scrollbar-thumb]:bg-slate-600 [&::-webkit-scrollbar-track]:bg-slate-100 dark:[&::-webkit-scrollbar-track]:bg-slate-800 [&::-webkit-scrollbar]:w-2"
onScroll={(e) =>
handleInfiniteScroll(
e,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
)
}
>
{(isFetching || isTyping) && folders.length === 0 ? (
// Show 12 skeleton cards while typing or fetching
<div className="grid grid-cols-3 gap-4">
{Array.from({ length: 12 }).map((_, index) => (
<div
key={`skeleton-${index}`}
className="flex h-24 flex-col gap-3 rounded-lg border border-slate-200 bg-slate-elevation1 p-4 dark:border-slate-700"
>
<div className="flex items-start gap-3">
<Skeleton className="mt-0.5 h-5 w-5" />
<div className="flex min-w-0 flex-1 flex-col gap-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
</div>
))}
</div>
) : folders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-slate-400">
<p>No folders found</p>
</div>
) : (
<>
<div className="grid grid-cols-3 gap-4">
{folders.map((folder) => (
<FolderCard
key={folder.folder_id}
folder={folder}
isSelected={selectedFolderId === folder.folder_id}
onClick={() => handleFolderClick(folder.folder_id)}
/>
))}
</div>
{isFetchingNextPage && (
<div className="mt-4 flex items-center justify-center py-4">
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
<span className="text-sm text-slate-400">
Loading more folders...
</span>
</div>
)}
{!hasNextPage && folders.length > 20 && (
<div className="mt-4 flex items-center justify-center py-4">
<span className="text-sm text-slate-400">
All folders loaded
</span>
</div>
)}
</>
)}
</div>
</DialogContent>
</Dialog>
);
}
export { ViewAllFoldersDialog };

View File

@@ -0,0 +1,172 @@
import { useState, useMemo } from "react";
import {
CheckIcon,
Cross2Icon,
FileIcon,
MagnifyingGlassIcon,
ReloadIcon,
} from "@radix-ui/react-icons";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Input } from "@/components/ui/input";
import { cn } from "@/util/utils";
import { useInfiniteFoldersQuery } from "../hooks/useInfiniteFoldersQuery";
import { useUpdateWorkflowFolderMutation } from "../hooks/useFolderMutations";
import { handleInfiniteScroll } from "@/util/utils";
import { useDebounce } from "use-debounce";
import { Skeleton } from "@/components/ui/skeleton";
interface WorkflowFolderSelectorProps {
workflowPermanentId: string;
currentFolderId: string | null;
}
function WorkflowFolderSelector({
workflowPermanentId,
currentFolderId,
}: WorkflowFolderSelectorProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebounce(search, 500);
const isTyping = search !== debouncedSearch;
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isFetching } =
useInfiniteFoldersQuery({
search: debouncedSearch,
page_size: 20,
});
// Flatten pages into a single array
const folders = useMemo(() => {
return data?.pages.flatMap((page) => page) ?? [];
}, [data]);
const updateFolderMutation = useUpdateWorkflowFolderMutation();
const handleFolderSelect = async (folderId: string | null) => {
await updateFolderMutation.mutateAsync({
workflowPermanentId,
data: { folder_id: folderId },
});
setOpen(false);
setSearch("");
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"h-8 w-8",
currentFolderId ? "text-blue-400" : "text-slate-400",
)}
title="Move to folder"
>
<FileIcon className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="end">
<div className="border-b p-3">
<h4 className="mb-2 text-sm font-medium">Move to folder</h4>
<div className="relative">
<MagnifyingGlassIcon className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
placeholder="Search folders..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-8 pl-8"
autoFocus
/>
</div>
</div>
<div
className="max-h-[300px] overflow-y-auto [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:border-2 [&::-webkit-scrollbar-thumb]:border-slate-100 [&::-webkit-scrollbar-thumb]:bg-slate-300 dark:[&::-webkit-scrollbar-thumb]:border-slate-800 dark:[&::-webkit-scrollbar-thumb]:bg-slate-600 [&::-webkit-scrollbar-track]:bg-slate-100 dark:[&::-webkit-scrollbar-track]:bg-slate-800 [&::-webkit-scrollbar]:w-2"
onScroll={(e) =>
handleInfiniteScroll(
e,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
)
}
>
{currentFolderId && (
<button
onClick={() => handleFolderSelect(null)}
className="flex w-full items-center justify-between border-b px-3 py-2 text-left text-sm transition-colors hover:bg-slate-50 dark:hover:bg-slate-800"
>
<div className="flex items-center gap-2">
<Cross2Icon className="h-4 w-4 text-red-400" />
<span>Remove from folder</span>
</div>
</button>
)}
{(isFetching || isTyping) && folders.length === 0 ? (
// Show 8 skeleton rows while typing or fetching
<>
{Array.from({ length: 8 }).map((_, index) => (
<div
key={`skeleton-${index}`}
className="flex w-full items-center gap-2 px-3 py-2"
>
<Skeleton className="h-4 w-4" />
<div className="flex flex-1 flex-col gap-1">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
))}
</>
) : folders.length === 0 ? (
<div className="px-3 py-8 text-center text-sm text-slate-400">
No folders found
</div>
) : (
<>
{folders.map((folder) => {
const isCurrentFolder = currentFolderId === folder.folder_id;
return (
<button
key={folder.folder_id}
onClick={() => handleFolderSelect(folder.folder_id)}
disabled={isCurrentFolder}
className="flex w-full items-center justify-between px-3 py-2 text-left text-sm transition-colors hover:bg-slate-50 disabled:opacity-50 dark:hover:bg-slate-800"
>
<div className="flex items-center gap-2">
<FileIcon className="h-4 w-4 text-blue-400" />
<div className="flex flex-col">
<span>{folder.title}</span>
{folder.description && (
<span className="text-xs text-slate-400">
{folder.description}
</span>
)}
</div>
</div>
{isCurrentFolder && (
<CheckIcon className="h-4 w-4 text-blue-400" />
)}
</button>
);
})}
{isFetchingNextPage && (
<div className="flex items-center justify-center py-2">
<ReloadIcon className="h-3 w-3 animate-spin text-slate-400" />
</div>
)}
</>
)}
</div>
</PopoverContent>
</Popover>
);
}
export { WorkflowFolderSelector };

View File

@@ -1,179 +0,0 @@
import { useMemo } from "react";
import { ParametersDialogBase } from "./ParametersDialogBase";
import {
WorkflowApiResponse,
WorkflowParameter,
WorkflowParameterTypes,
Parameter,
CredentialParameter,
AWSSecretParameter,
OnePasswordCredentialParameter,
AzureVaultCredentialParameter,
BitwardenLoginCredentialParameter,
BitwardenSensitiveInformationParameter,
BitwardenCreditCardDataParameter,
ContextParameter,
} from "../types/workflowTypes";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
workflowId: string | null;
workflows: Array<WorkflowApiResponse>;
};
function getParameterId(param: Parameter): string {
if ("workflow_parameter_id" in param && param.workflow_parameter_id)
return param.workflow_parameter_id;
if ("credential_parameter_id" in param && param.credential_parameter_id)
return param.credential_parameter_id;
if ("aws_secret_parameter_id" in param && param.aws_secret_parameter_id)
return param.aws_secret_parameter_id;
if (
"onepassword_credential_parameter_id" in param &&
param.onepassword_credential_parameter_id
)
return param.onepassword_credential_parameter_id;
if (
"azure_vault_credential_parameter_id" in param &&
param.azure_vault_credential_parameter_id
)
return param.azure_vault_credential_parameter_id;
if (
"bitwarden_login_credential_parameter_id" in param &&
param.bitwarden_login_credential_parameter_id
)
return param.bitwarden_login_credential_parameter_id;
if (
"bitwarden_sensitive_information_parameter_id" in param &&
param.bitwarden_sensitive_information_parameter_id
)
return param.bitwarden_sensitive_information_parameter_id;
if (
"bitwarden_credit_card_data_parameter_id" in param &&
param.bitwarden_credit_card_data_parameter_id
)
return param.bitwarden_credit_card_data_parameter_id;
if ("output_parameter_id" in param && param.output_parameter_id)
return param.output_parameter_id;
return param.key;
}
function getParameterDisplayType(param: Parameter): string {
return param.parameter_type;
}
function getParameterDisplayValue(param: Parameter): string | null {
switch (param.parameter_type) {
case "workflow": {
const p = param as WorkflowParameter;
const value = p.default_value;
try {
return value === null || value === undefined
? ""
: typeof value === "string"
? value
: JSON.stringify(value);
} catch {
return String(value);
}
}
case "credential": {
// Show referenced credential id; do not reveal secrets
return "credential_id" in param
? String((param as CredentialParameter).credential_id)
: null;
}
case "aws_secret": {
// Show the AWS secret key reference only
return "aws_key" in param
? String((param as AWSSecretParameter).aws_key)
: null;
}
case "onepassword": {
const p = param as OnePasswordCredentialParameter;
if (p.vault_id && p.item_id) return `${p.vault_id} / ${p.item_id}`;
return null;
}
case "azure_vault_credential": {
const p = param as AzureVaultCredentialParameter;
return p.vault_name ? `${p.vault_name}` : null;
}
case "bitwarden_login_credential": {
const p = param as BitwardenLoginCredentialParameter;
return p.bitwarden_item_id ?? p.bitwarden_collection_id ?? null;
}
case "bitwarden_sensitive_information": {
const p = param as BitwardenSensitiveInformationParameter;
return p.bitwarden_identity_key ?? null;
}
case "bitwarden_credit_card_data": {
const p = param as BitwardenCreditCardDataParameter;
return p.bitwarden_item_id ?? null;
}
case "context": {
const p = param as ContextParameter;
if ("value" in p && p.value !== undefined) {
try {
return typeof p.value === "string"
? p.value
: JSON.stringify(p.value);
} catch {
return String(p.value);
}
}
return null;
}
default:
return null;
}
}
// Row rendering moved inside component to access local reveal state
export function WorkflowParametersDialog({
open,
onOpenChange,
workflowId,
workflows,
}: Props) {
const workflow = useMemo(
() => workflows?.find((w) => w.workflow_permanent_id === workflowId),
[workflows, workflowId],
);
const items = useMemo(() => {
const params = workflow
? (workflow.workflow_definition.parameters.filter(
(p) =>
p.parameter_type === WorkflowParameterTypes.Workflow ||
p.parameter_type === "credential" ||
p.parameter_type === "aws_secret" ||
p.parameter_type === "onepassword" ||
p.parameter_type === "azure_vault_credential" ||
p.parameter_type === "bitwarden_login_credential" ||
p.parameter_type === "bitwarden_sensitive_information" ||
p.parameter_type === "bitwarden_credit_card_data" ||
p.parameter_type === "context",
) as Parameter[])
: ([] as Parameter[]);
return params.map((param) => ({
id: getParameterId(param),
key: param.key,
description:
"description" in param ? param.description ?? undefined : undefined,
type: getParameterDisplayType(param),
value: getParameterDisplayValue(param),
}));
}, [workflow]);
return (
<ParametersDialogBase
open={open}
onOpenChange={onOpenChange}
title="Parameters"
sectionLabel="Workflow-level parameters"
items={items}
/>
);
}

View File

@@ -0,0 +1,128 @@
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import { toast } from "@/components/ui/use-toast";
import { WorkflowApiResponse } from "../types/workflowTypes";
import { useActiveImportsQuery } from "./useActiveImportsQuery";
export function useActiveImportsPolling() {
const queryClient = useQueryClient();
const previousCountRef = useRef<number>(0);
const [shouldPoll, setShouldPoll] = useState(true); // Start with true to check on mount
const justStartedPollingRef = useRef(false); // Prevent immediate stop after starting
const seenFailuresRef = useRef<Set<string>>(new Set()); // Track failures we've already shown
const seenCompletionsRef = useRef<Set<string>>(new Set()); // Track completions we've already shown
const previousImportsRef = useRef<Map<string, WorkflowApiResponse>>(
new Map(),
); // Track previous state
const { data: activeImports = [] } = useActiveImportsQuery({
enabled: shouldPoll,
refetchInterval: shouldPoll ? 3000 : false,
});
// Monitor for completed/failed imports and invalidate workflows when count decreases
useEffect(() => {
const currentCount = activeImports.length;
const previousCount = previousCountRef.current;
const importingCount = activeImports.filter(
(imp) => imp.status === "importing",
).length;
// Reset completion/failure tracking when an import restarts
activeImports.forEach((imp) => {
if (imp.status === "importing") {
seenCompletionsRef.current.delete(imp.workflow_permanent_id);
seenFailuresRef.current.delete(imp.workflow_permanent_id);
}
});
// Check for status changes and disappeared workflows
const currentPermanentIds = new Set(
activeImports.map((imp) => imp.workflow_permanent_id),
);
// Check for workflows that disappeared (importing -> completed successfully)
previousImportsRef.current.forEach((prevImport, permanentId) => {
// If it was importing and now it's gone, it completed successfully!
if (
prevImport.status === "importing" &&
!currentPermanentIds.has(permanentId) &&
!seenCompletionsRef.current.has(permanentId)
) {
seenCompletionsRef.current.add(permanentId);
toast({
variant: "success",
title: "Workflow imported",
description: `Successfully imported ${prevImport.title || "workflow"}`,
});
// Refresh workflows to show new workflow
queryClient.invalidateQueries({ queryKey: ["workflows"] });
}
});
// Check for failed imports (status changed from importing → import_failed)
activeImports.forEach((imp) => {
const previousImport = previousImportsRef.current.get(
imp.workflow_permanent_id,
);
// Only show toast if we SAW the transition from importing → import_failed
if (
imp.status === "import_failed" &&
previousImport?.status === "importing" &&
!seenFailuresRef.current.has(imp.workflow_permanent_id)
) {
seenFailuresRef.current.add(imp.workflow_permanent_id);
toast({
variant: "destructive",
title: "Import failed",
description:
imp.import_error || `Failed to import ${imp.title || "workflow"}`,
});
// Refresh workflows to update UI
queryClient.invalidateQueries({ queryKey: ["workflows"] });
}
});
// Update previous imports map for next comparison
previousImportsRef.current = new Map(
activeImports.map((imp) => [imp.workflow_permanent_id, imp]),
);
// If we have active IMPORTING imports, make sure polling is enabled
if (importingCount > 0 && !shouldPoll) {
setShouldPoll(true);
}
// Stop polling if there are no IMPORTING imports
// BUT don't stop if we just started polling (to avoid race condition)
if (importingCount === 0 && shouldPoll && !justStartedPollingRef.current) {
setShouldPoll(false);
}
// Clear the "just started" flag once we have imports or if count changed
if (
justStartedPollingRef.current &&
(currentCount > 0 || currentCount !== previousCount)
) {
justStartedPollingRef.current = false;
}
previousCountRef.current = currentCount;
}, [activeImports, queryClient, shouldPoll]);
// Function to start polling (called when a new import starts)
const startPolling = () => {
justStartedPollingRef.current = true; // Set flag to prevent immediate stop
setShouldPoll(true);
// Force refetch immediately to get the latest state
queryClient.refetchQueries({ queryKey: ["active-imports"] });
queryClient.refetchQueries({ queryKey: ["workflows"] });
};
return { activeImports, startPolling };
}

View File

@@ -0,0 +1,38 @@
import { getClient } from "@/api/AxiosClient";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { useQuery } from "@tanstack/react-query";
import { WorkflowApiResponse } from "../types/workflowTypes";
type UseActiveImportsQueryParams = {
enabled?: boolean;
refetchInterval?: number | false;
};
export function useActiveImportsQuery({
enabled = true,
refetchInterval = false,
}: UseActiveImportsQueryParams = {}) {
const credentialGetter = useCredentialGetter();
return useQuery({
queryKey: ["active-imports"],
queryFn: async () => {
const client = await getClient(credentialGetter);
const response = await client.get<WorkflowApiResponse[]>("/workflows", {
params: {
status: ["importing", "import_failed"],
page: 1,
page_size: 20,
},
paramsSerializer: {
indexes: null, // Remove brackets from array params: status=a&status=b instead of status[]=a&status[]=b
},
});
return response.data;
},
enabled,
refetchInterval,
refetchIntervalInBackground: true,
staleTime: 0, // Always consider data stale so it refetches immediately
});
}

View File

@@ -0,0 +1,127 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { getClient } from "@/api/AxiosClient";
import { toast } from "@/components/ui/use-toast";
import type {
Folder,
FolderCreate,
FolderUpdate,
UpdateWorkflowFolderRequest,
} from "../types/folderTypes";
function useCreateFolderMutation() {
const credentialGetter = useCredentialGetter();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: FolderCreate) => {
const client = await getClient(credentialGetter);
return client
.post<Folder>("/folders", data)
.then((response) => response.data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["folders"] });
},
onError: (error: Error) => {
toast({
variant: "destructive",
title: "Failed to create folder",
description: error.message,
});
},
});
}
function useUpdateFolderMutation() {
const credentialGetter = useCredentialGetter();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
folderId,
data,
}: {
folderId: string;
data: FolderUpdate;
}) => {
const client = await getClient(credentialGetter);
return client
.put<Folder>(`/folders/${folderId}`, data)
.then((response) => response.data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["folders"] });
},
onError: (error: Error) => {
toast({
variant: "destructive",
title: "Failed to update folder",
description: error.message,
});
},
});
}
function useDeleteFolderMutation() {
const credentialGetter = useCredentialGetter();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
folderId,
deleteWorkflows,
}: {
folderId: string;
folderTitle: string;
deleteWorkflows: boolean;
}) => {
const client = await getClient(credentialGetter);
return client.delete(`/folders/${folderId}`, {
params: {
delete_workflows: deleteWorkflows,
},
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["folders"] });
queryClient.invalidateQueries({ queryKey: ["workflows"] });
},
onError: (error: Error) => {
toast({
variant: "destructive",
title: "Failed to delete folder",
description: error.message,
});
},
});
}
function useUpdateWorkflowFolderMutation() {
const credentialGetter = useCredentialGetter();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
workflowPermanentId,
data,
}: {
workflowPermanentId: string;
data: UpdateWorkflowFolderRequest;
}) => {
const client = await getClient(credentialGetter);
return client.put(`/workflows/${workflowPermanentId}/folder`, data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["workflows"] });
queryClient.invalidateQueries({ queryKey: ["folders"] });
},
});
}
export {
useCreateFolderMutation,
useUpdateFolderMutation,
useDeleteFolderMutation,
useUpdateWorkflowFolderMutation,
};

View File

@@ -0,0 +1,38 @@
import { getClient } from "@/api/AxiosClient";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { useQuery } from "@tanstack/react-query";
import type { Folder } from "../types/folderTypes";
interface UseFoldersQueryParams {
page?: number;
page_size?: number;
search?: string;
}
function useFoldersQuery(params?: UseFoldersQueryParams) {
const credentialGetter = useCredentialGetter();
return useQuery({
queryKey: ["folders", params],
queryFn: async () => {
const client = await getClient(credentialGetter);
const searchParams = new URLSearchParams();
if (params?.page) {
searchParams.append("page", String(params.page));
}
if (params?.page_size) {
searchParams.append("page_size", String(params.page_size));
}
if (params?.search) {
searchParams.append("search", params.search);
}
return client
.get<Array<Folder>>("/folders", { params: searchParams })
.then((response) => response.data);
},
});
}
export { useFoldersQuery };

View File

@@ -0,0 +1,45 @@
import { getClient } from "@/api/AxiosClient";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { useInfiniteQuery } from "@tanstack/react-query";
import type { Folder } from "../types/folderTypes";
interface UseInfiniteFoldersQueryParams {
page_size?: number;
search?: string;
}
function useInfiniteFoldersQuery(params?: UseInfiniteFoldersQueryParams) {
const credentialGetter = useCredentialGetter();
return useInfiniteQuery({
queryKey: ["folders", "infinite", params],
queryFn: async ({ pageParam = 1 }) => {
const client = await getClient(credentialGetter);
const searchParams = new URLSearchParams();
searchParams.append("page", String(pageParam));
if (params?.page_size) {
searchParams.append("page_size", String(params.page_size));
}
if (params?.search) {
searchParams.append("search", params.search);
}
return client
.get<Array<Folder>>("/folders", { params: searchParams })
.then((response) => response.data);
},
getNextPageParam: (lastPage, allPages) => {
// If the last page has items equal to page_size, there might be more
const pageSize = params?.page_size || 10;
if (lastPage.length === pageSize) {
return allPages.length + 1;
}
return undefined;
},
initialPageParam: 1,
});
}
export { useInfiniteFoldersQuery };

View File

@@ -0,0 +1,23 @@
export interface Folder {
folder_id: string;
organization_id: string;
title: string;
description: string | null;
workflow_count: number;
created_at: string;
modified_at: string;
}
export interface FolderCreate {
title: string;
description?: string | null;
}
export interface FolderUpdate {
title?: string;
description?: string | null;
}
export interface UpdateWorkflowFolderRequest {
folder_id: string | null;
}

View File

@@ -559,6 +559,8 @@ export type WorkflowApiResponse = {
ai_fallback: boolean | null;
run_sequentially: boolean | null;
sequential_key: string | null;
folder_id: string | null;
import_error: string | null;
};
export type WorkflowSettings = {

View File

@@ -67,3 +67,31 @@ export function formatDate(date: Date): string {
};
return date.toLocaleString("en-US", options);
}
/**
* Handle infinite scroll logic for loading more items
* @param event - React scroll event
* @param fetchNextPage - Function to fetch next page
* @param hasNextPage - Whether there are more pages to fetch
* @param isFetchingNextPage - Whether currently fetching
* @param scrollThreshold - Percentage threshold to trigger fetch (default 0.8)
*/
export const handleInfiniteScroll = (
event: React.UIEvent<HTMLDivElement>,
fetchNextPage: () => void,
hasNextPage: boolean,
isFetchingNextPage: boolean,
scrollThreshold: number = 0.8,
) => {
const target = event.currentTarget;
const scrollPercentage =
(target.scrollTop + target.clientHeight) / target.scrollHeight;
if (
scrollPercentage >= scrollThreshold &&
hasNextPage &&
!isFetchingNextPage
) {
fetchNextPage();
}
};