Export improvements (#22867)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions

* backend

* frontend + i18n

* tests + api spec

* tweak backend to use Job infrastructure for exports

* frontend tweaks and Job infrastructure

* tests

* tweaks

- add ability to remove from case
- change location of counts in case card

* add stale export reaper on startup

* fix toaster close button color

* improve add dialog

* formatting

* hide max_concurrent from camera config export settings

* remove border

* refactor batch endpoint for multiple review items

* frontend

* tests and fastapi spec

* fix deletion of in-progress exports in a case

* tweaks

- hide cases when filtering cameras that have no exports from those cameras
- remove description from case card
- use textarea instead of input for case description in add new case dialog

* add auth exceptions for exports

* add e2e test for deleting cases with exports

* refactor delete and case endpoints

allow bulk deleting and reassigning

* frontend

- bulk selection like Review
- gate admin-only actions
- consolidate dialogs
- spacing/padding tweaks

* i18n and tests

* update openapi spec

* tweaks

- add None to case selection list
- allow new case creation from single cam export dialog

* fix codeql

* fix i18n

* remove unused

* fix frontend tests
This commit is contained in:
Josh Hawkins
2026-04-14 08:19:50 -06:00
committed by GitHub
parent 18c068a3f9
commit e7e6f87682
31 changed files with 6789 additions and 733 deletions
@@ -8,6 +8,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
@@ -15,9 +16,10 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { cn } from "@/lib/utils";
import { isMobile } from "react-device-detect";
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
type Option = {
@@ -35,8 +37,8 @@ type OptionAndInputDialogProps = {
nameLabel: string;
descriptionLabel: string;
setOpen: (open: boolean) => void;
onSave: (value: string) => void;
onCreateNew: (name: string, description: string) => void;
onSave: (value: string) => Promise<void>;
onCreateNew: (name: string, description: string) => Promise<void>;
};
export default function OptionAndInputDialog({
@@ -69,10 +71,12 @@ export default function OptionAndInputDialog({
}
}, [open, initialValue, firstOption]);
const [isLoading, setIsLoading] = useState(false);
const isNew = selectedValue === newValueKey;
const disableSave = !selectedValue || (isNew && name.trim().length === 0);
const disableSave =
!selectedValue || (isNew && name.trim().length === 0) || isLoading;
const handleSave = () => {
const handleSave = useCallback(async () => {
if (!selectedValue) {
return;
}
@@ -80,13 +84,26 @@ export default function OptionAndInputDialog({
const trimmedName = name.trim();
const trimmedDescription = descriptionValue.trim();
if (isNew) {
onCreateNew(trimmedName, trimmedDescription);
} else {
onSave(selectedValue);
setIsLoading(true);
try {
if (isNew) {
await onCreateNew(trimmedName, trimmedDescription);
} else {
await onSave(selectedValue);
}
setOpen(false);
} finally {
setIsLoading(false);
}
setOpen(false);
};
}, [
selectedValue,
name,
descriptionValue,
isNew,
onCreateNew,
onSave,
setOpen,
]);
return (
<Dialog open={open} defaultOpen={false} onOpenChange={setOpen}>
@@ -127,15 +144,21 @@ export default function OptionAndInputDialog({
<label className="text-sm font-medium text-secondary-foreground">
{nameLabel}
</label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
<Input
className="text-md"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium text-secondary-foreground">
{descriptionLabel}
</label>
<Input
<Textarea
className="text-md"
value={descriptionValue}
onChange={(e) => setDescriptionValue(e.target.value)}
rows={2}
/>
</div>
</div>
@@ -145,6 +168,7 @@ export default function OptionAndInputDialog({
<Button
type="button"
variant="outline"
disabled={isLoading}
onClick={() => {
setOpen(false);
}}
@@ -155,9 +179,13 @@ export default function OptionAndInputDialog({
type="button"
variant="select"
disabled={disableSave}
onClick={handleSave}
onClick={() => void handleSave()}
>
{t("button.save")}
{isLoading ? (
<ActivityIndicator className="size-4" />
) : (
t("button.save")
)}
</Button>
</DialogFooter>
</DialogContent>