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
+92
View File
@@ -1,4 +1,5 @@
import { test, expect, type FrigateApp } from "../fixtures/frigate-test";
import { viewerProfile } from "../fixtures/mock-data/profile";
import {
expectBodyInteractive,
waitForBodyInteractive,
@@ -988,6 +989,97 @@ 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: number }[] } | 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();
}
// the seeded end can equal the picker's fallback, so move it off that
// value before clearing
await form.getByRole("button", { name: /^end time$/i }).click();
const endInput = frigateApp.page.locator("#endTime");
const [hour, minute, second] = (await endInput.inputValue()).split(":");
const chosen = `${hour}:${minute}:${String((Number(second) + 30) % 60).padStart(2, "0")}`;
await endInput.fill(chosen);
await endInput.fill("");
await expect(endInput).toHaveValue(chosen);
await frigateApp.page.keyboard.press("Escape");
await form.getByRole("button", { name: /^export \d+ cameras?$/i }).click();
await expect.poll(() => capturedPayload).not.toBeNull();
const submittedClock = await frigateApp.page.evaluate(
(endTime) => new Date(endTime * 1000).toTimeString().slice(0, 8),
capturedPayload!.items[0].end_time,
);
expect(submittedClock).toBe(chosen);
});
test("viewers can export without a case", async ({ frigateApp }) => {
await frigateApp.installDefaults({ profile: viewerProfile() });
let capturedPayload: Record<string, 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 expect(form.getByPlaceholder(/new case name/i)).toBeHidden();
await form.getByRole("button", { name: /^export \d+ cameras?$/i }).click();
await expect.poll(() => capturedPayload).not.toBeNull();
expect(capturedPayload).not.toHaveProperty("new_case_name");
expect(capturedPayload).not.toHaveProperty("export_case_id");
});
});
test.describe("Export Page - Active Job Progress @medium", () => {