improve add dialog

This commit is contained in:
Josh Hawkins 2026-04-11 15:02:24 -05:00
parent 3f5d2ec71b
commit 050ad6cfac
2 changed files with 84 additions and 32 deletions

View File

@ -79,6 +79,9 @@
"addExportDialog": { "addExportDialog": {
"title": "Add Export to {{caseName}}", "title": "Add Export to {{caseName}}",
"searchPlaceholder": "Search uncategorized exports", "searchPlaceholder": "Search uncategorized exports",
"empty": "No uncategorized exports match this search." "empty": "No uncategorized exports match this search.",
"addButton_one": "Add 1 Export",
"addButton_other": "Add {{count}} Exports",
"adding": "Adding..."
} }
} }

View File

@ -14,7 +14,12 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import {
Dialog,
DialogContent,
DialogFooter,
DialogTitle,
} from "@/components/ui/dialog";
import Heading from "@/components/ui/heading"; import Heading from "@/components/ui/heading";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
@ -1024,9 +1029,14 @@ function CaseAddExportDialog({
}: CaseAddExportDialogProps) { }: CaseAddExportDialogProps) {
const { t } = useTranslation(["views/exports", "common"]); const { t } = useTranslation(["views/exports", "common"]);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [isAdding, setIsAdding] = useState(false);
// Reset dialog state whenever the target case changes or the dialog reopens.
useEffect(() => { useEffect(() => {
setSearch(""); setSearch("");
setSelectedIds([]);
setIsAdding(false);
}, [exportCase?.id]); }, [exportCase?.id]);
const filteredExports = useMemo(() => { const filteredExports = useMemo(() => {
@ -1043,51 +1053,73 @@ function CaseAddExportDialog({
); );
}, [availableExports, search]); }, [availableExports, search]);
const toggleSelection = useCallback((exportId: string) => {
setSelectedIds((previous) =>
previous.includes(exportId)
? previous.filter((id) => id !== exportId)
: [...previous, exportId],
);
}, []);
const handleAdd = useCallback(async () => {
if (!exportCase || selectedIds.length === 0 || isAdding) {
return;
}
setIsAdding(true);
try {
await Promise.all(
selectedIds.map((id) => onAssign(id, exportCase.id)),
);
onClose();
} finally {
setIsAdding(false);
}
}, [exportCase, isAdding, onAssign, onClose, selectedIds]);
return ( return (
<Dialog <Dialog
open={exportCase != undefined} open={exportCase != undefined}
onOpenChange={(open) => !open && onClose()} onOpenChange={(open) => !open && onClose()}
> >
<DialogContent className="max-h-[80dvh] overflow-hidden"> <DialogContent className="flex max-h-[80dvh] flex-col overflow-hidden">
<DialogTitle> <DialogTitle>
{t("addExportDialog.title", { caseName: exportCase?.name })} {t("addExportDialog.title", { caseName: exportCase?.name })}
</DialogTitle> </DialogTitle>
<div className="space-y-3 overflow-hidden"> <div className="flex min-h-0 flex-1 flex-col gap-3 overflow-hidden">
<Input <Input
placeholder={t("addExportDialog.searchPlaceholder")} placeholder={t("addExportDialog.searchPlaceholder")}
value={search} value={search}
onChange={(event) => setSearch(event.target.value)} onChange={(event) => setSearch(event.target.value)}
/> />
<div className="scrollbar-container max-h-[50dvh] space-y-2 overflow-y-auto"> <div className="scrollbar-container min-h-0 flex-1 space-y-2 overflow-y-auto py-1 pr-1">
{filteredExports.length > 0 ? ( {filteredExports.length > 0 ? (
filteredExports.map((exportItem) => ( filteredExports.map((exportItem) => {
<div const isSelected = selectedIds.includes(exportItem.id);
key={exportItem.id} return (
className="flex items-center justify-between rounded-lg border border-border p-3" <button
> key={exportItem.id}
<div className="min-w-0 pr-4"> type="button"
<div className="truncate font-medium"> aria-pressed={isSelected}
{exportItem.name} className={cn(
</div> "flex w-full items-center gap-3 rounded-md border px-3 py-2 text-left transition-colors",
<div className="truncate text-xs text-muted-foreground"> isSelected
{exportItem.camera.replaceAll("_", " ")} ? "border-selected bg-selected/10 ring-1 ring-selected"
</div> : "border-transparent bg-secondary/40 hover:bg-secondary/70",
</div> )}
<Button onClick={() => toggleSelection(exportItem.id)}
size="sm"
variant="select"
onClick={() => {
if (!exportCase) {
return;
}
void onAssign(exportItem.id, exportCase.id).then(onClose);
}}
> >
{t("button.add", { ns: "common" })} <div className="min-w-0 flex-1">
</Button> <div className="truncate text-sm font-medium text-primary">
</div> {exportItem.name}
)) </div>
<div className="truncate text-xs text-muted-foreground">
{exportItem.camera.replaceAll("_", " ")}
</div>
</div>
</button>
);
})
) : ( ) : (
<div className="rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground"> <div className="rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
{t("addExportDialog.empty")} {t("addExportDialog.empty")}
@ -1095,6 +1127,23 @@ function CaseAddExportDialog({
)} )}
</div> </div>
</div> </div>
<DialogFooter className="flex-row justify-end gap-2">
<Button variant="outline" size="sm" onClick={onClose}>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
size="sm"
disabled={selectedIds.length === 0 || isAdding}
onClick={() => void handleAdd()}
>
{isAdding
? t("addExportDialog.adding")
: t("addExportDialog.addButton", {
count: selectedIds.length || 1,
})}
</Button>
</DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );