Folders, Persistent Import Progress Tracking & UX Enhancements (#3841)
Co-authored-by: Jonathan Dobson <jon.m.dobson@gmail.com>
This commit is contained in:
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user