Export dialog improvements (#24787)

* show validation errors in multi-camera export

Clearing part of a time field in the export time picker set the range to NaN, while the picker kept showing a valid fallback time. The multi-camera Export button then stayed disabled with no explanation, and non-admins could never start an export because a case name was required even though they can't see the case field. The picker now ignores incomplete time values. The multi-camera tab shows inline errors for an invalid range, no selected cameras, and a blank case name, only requires a case name from admins, and prefills a default case name.

* add viewer export test and remove stale non-admin case text

* fix test
This commit is contained in:
Josh Hawkins
2026-10-26 06:24:34 -06:00
committed by GitHub
parent 9fec1c76b5
commit 975f346618
5 changed files with 153 additions and 12 deletions
@@ -151,6 +151,11 @@ export function CustomTimeSelector({
step={isIOS ? "60" : "1"}
onChange={(e) => {
const clock = e.target.value;
if (!clock) {
return;
}
const [hour, minute, second] = isIOS
? [...clock.split(":"), "00"]
: clock.split(":");
@@ -217,6 +222,11 @@ export function CustomTimeSelector({
step={isIOS ? "60" : "1"}
onChange={(e) => {
const clock = e.target.value;
if (!clock) {
return;
}
const [hour, minute, second] = isIOS
? [...clock.split(":"), "00"]
: clock.split(":");
+46 -4
View File
@@ -65,6 +65,8 @@ import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import { Textarea } from "../ui/textarea";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { useDateLocale } from "@/hooks/use-date-locale";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { isReplayCamera } from "@/utils/cameraUtil";
import { isValidIconName } from "@/utils/iconUtil";
@@ -441,6 +443,7 @@ export function ExportContent({
}: ExportContentProps) {
const { t } = useTranslation(["components/dialog"]);
const isAdmin = useIsAdmin();
const locale = useDateLocale();
const [selectedOption, setSelectedOption] = useState<ExportOption>("1");
const { data: cases } = useSWR<ExportCase[]>(isAdmin ? "cases" : null);
const { data: config } = useSWR<FrigateConfig>("config");
@@ -663,12 +666,25 @@ export function ExportContent({
]);
const selectedCameraCount = selectedCameraIds.length;
const batchRangeError = !range
? t("export.toast.error.noValidTimeSelected")
: range.before <= range.after
? t("export.toast.error.endTimeMustAfterStartTime")
: undefined;
const batchCameraError =
!isEventsLoading && selectedCameraCount === 0
? t("export.multiCamera.noCamerasSelected")
: undefined;
const batchCaseNameError =
isAdmin && batchCaseSelection === "new" && newCaseName.trim().length === 0
? t("export.case.nameRequired")
: undefined;
const canStartBatchExport =
Boolean(range && range.before > range.after) &&
!batchRangeError &&
!batchCaseNameError &&
selectedCameraCount > 0 &&
!isStartingBatchExport &&
(batchCaseSelection !== "new" || newCaseName.trim().length > 0) &&
batchCaseSelection.length > 0;
!isStartingBatchExport;
const onSelectTime = useCallback(
(option: ExportOption) => {
@@ -965,6 +981,17 @@ export function ExportContent({
if (tab === "multi") {
setRange(buildRangeAroundCurrentTime(MULTI_CAMERA_RANGE_SECONDS));
setBatchCaseSelection(selectedCaseId ?? "new");
if (newCaseName.trim().length === 0) {
setNewCaseName(
t("export.multiCamera.caseNameDefault", {
date: formatUnixTimestampToDateTime(Date.now() / 1000, {
date_style: "medium",
time_style: "short",
locale,
}),
}),
);
}
} else {
onSelectTime(selectedOption);
}
@@ -1132,6 +1159,11 @@ export function ExportContent({
</TooltipPortal>
</Tooltip>
</div>
{batchRangeError && (
<p className="text-sm font-medium text-destructive">
{batchRangeError}
</p>
)}
</div>
<div className="space-y-2">
@@ -1315,6 +1347,11 @@ export function ExportContent({
);
})}
</div>
{batchCameraError && (
<p className="text-sm font-medium text-destructive">
{batchCameraError}
</p>
)}
</div>
<div className="space-y-2">
@@ -1367,6 +1404,11 @@ export function ExportContent({
value={newCaseName}
onChange={(event) => setNewCaseName(event.target.value)}
/>
{batchCaseNameError && (
<p className="text-sm font-medium text-destructive">
{batchCaseNameError}
</p>
)}
<Textarea
placeholder={t("export.case.newCaseDescriptionPlaceholder")}
value={newCaseDescription}
@@ -68,9 +68,7 @@ export default function MultiExportDialog({
const isAdmin = useIsAdmin();
const { data: config } = useSWR<FrigateConfig>("config");
// Only admins can attach exports to an existing case (enforced server-side
// by POST /exports/batch). Skip fetching the case list entirely for
// non-admins — they can only ever use the "Create new case" branch.
// Only admins can assign exports to a case, so non-admins skip the case list
const { data: cases } = useSWR<ExportCase[]>(isAdmin ? "cases" : null);
const [open, setOpen] = useState(false);
@@ -284,9 +282,6 @@ export default function MultiExportDialog({
t,
]);
// New-case inputs: rendered below the Select when caseSelection === "new",
// or rendered standalone for non-admins (who never see the Select since
// they cannot attach to an existing case).
const newCaseInputs = (
<div className="space-y-2 pt-1">
<Input