diff --git a/web/e2e/specs/export.spec.ts b/web/e2e/specs/export.spec.ts index 9418da63cc..f167e16177 100644 --- a/web/e2e/specs/export.spec.ts +++ b/web/e2e/specs/export.spec.ts @@ -988,6 +988,58 @@ test.describe("Multi-Camera Export from History @high", () => { "Front gate follow-up", ); }); + + test("a blank case name shows an error and disables export", async ({ + frigateApp, + }) => { + await frigateApp.installDefaults(); + const form = await openMultiCameraTab(frigateApp); + + const caseName = form.getByPlaceholder(/new case name/i); + await expect(caseName).toHaveValue(/^Multi-camera export - /, { + timeout: 5_000, + }); + await expect(form.getByText("Case name is required")).toBeHidden(); + + await caseName.fill(""); + + await expect(form.getByText("Case name is required")).toBeVisible(); + await expect( + form.getByRole("button", { name: /^export \d+ cameras?$/i }), + ).toBeDisabled(); + }); + + test("clearing a time field keeps the previous valid range", async ({ + frigateApp, + }) => { + await frigateApp.installDefaults(); + + let capturedPayload: { items: { end_time: unknown }[] } | null = null; + await frigateApp.page.route("**/api/exports/batch", async (route) => { + capturedPayload = route.request().postDataJSON(); + await route.fulfill({ + status: 202, + json: { export_case_id: null, export_ids: [], results: [] }, + }); + }); + + const form = await openMultiCameraTab(frigateApp); + + const firstCamera = form.locator("button[aria-pressed]").first(); + await expect(firstCamera).toBeVisible({ timeout: 5_000 }); + if ((await firstCamera.getAttribute("aria-pressed")) === "false") { + await firstCamera.click(); + } + + await form.getByRole("button", { name: /^end time$/i }).click(); + await frigateApp.page.locator("#endTime").fill(""); + await frigateApp.page.keyboard.press("Escape"); + + await form.getByRole("button", { name: /^export \d+ cameras?$/i }).click(); + + await expect.poll(() => capturedPayload).not.toBeNull(); + expect(typeof capturedPayload!.items[0].end_time).toBe("number"); + }); }); test.describe("Export Page - Active Job Progress @medium", () => { diff --git a/web/public/locales/en/components/dialog.json b/web/public/locales/en/components/dialog.json index 1a875b3a1f..d50f08670f 100644 --- a/web/public/locales/en/components/dialog.json +++ b/web/public/locales/en/components/dialog.json @@ -67,7 +67,8 @@ "newCaseDescriptionPlaceholder": "Case description", "label": "Case", "nonAdminHelp": "A new case will be created for these exports.", - "placeholder": "Select a case" + "placeholder": "Select a case", + "nameRequired": "Case name is required" }, "select": "Select", "export": "Export", @@ -91,9 +92,11 @@ "selectedCount": "{{selected}} / {{total}} selected", "checkingActivity": "Checking camera activity...", "noCameras": "No cameras available", + "noCamerasSelected": "Select at least one camera", "detectionCount_one": "1 tracked object", "detectionCount_other": "{{count}} tracked objects", "nameLabel": "Export name", + "caseNameDefault": "Multi-camera export - {{date}}", "namePlaceholder": "Optional base name for these exports", "queueingButton": "Queueing Exports...", "exportButton_one": "Export 1 Camera", diff --git a/web/src/components/overlay/CustomTimeSelector.tsx b/web/src/components/overlay/CustomTimeSelector.tsx index f164471114..eefacca30b 100644 --- a/web/src/components/overlay/CustomTimeSelector.tsx +++ b/web/src/components/overlay/CustomTimeSelector.tsx @@ -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(":"); diff --git a/web/src/components/overlay/ExportDialog.tsx b/web/src/components/overlay/ExportDialog.tsx index dd2071d7c5..e53190b160 100644 --- a/web/src/components/overlay/ExportDialog.tsx +++ b/web/src/components/overlay/ExportDialog.tsx @@ -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("1"); const { data: cases } = useSWR(isAdmin ? "cases" : null); const { data: config } = useSWR("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({ + {batchRangeError && ( +

+ {batchRangeError} +

+ )}
@@ -1315,6 +1347,11 @@ export function ExportContent({ ); })}
+ {batchCameraError && ( +

+ {batchCameraError} +

+ )}
@@ -1367,6 +1404,11 @@ export function ExportContent({ value={newCaseName} onChange={(event) => setNewCaseName(event.target.value)} /> + {batchCaseNameError && ( +

+ {batchCaseNameError} +

+ )}