Compare commits

..

No commits in common. "e593ac9599bdc440b180e11e9abbf230db7df07d" and "13e4a9406531ada6b4a638dac1d28c1f04e07c10" have entirely different histories.

6 changed files with 18 additions and 108 deletions

View File

@ -596,7 +596,7 @@ def export_recordings_batch(
) )
try: try:
start_export_job(request.app.frigate_config, export_job) start_export_job(request.app.frigate_config, export_job)
except Exception: except Exception as err:
logger.exception("Failed to queue export job %s", export_job.id) logger.exception("Failed to queue export job %s", export_job.id)
results.append( results.append(
{ {
@ -604,7 +604,7 @@ def export_recordings_batch(
"export_id": None, "export_id": None,
"success": False, "success": False,
"status": None, "status": None,
"error": "Failed to queue export job", "error": str(err),
"item_index": index, "item_index": index,
"client_item_id": item.client_item_id, "client_item_id": item.client_item_id,
} }

View File

@ -460,9 +460,10 @@ test.describe("Multi-Review Export @high", () => {
.filter({ hasText: /Export 2 reviews/i }); .filter({ hasText: /Export 2 reviews/i });
await expect(dialog).toBeVisible({ timeout: 5_000 }); await expect(dialog).toBeVisible({ timeout: 5_000 });
// The dialog uses a Select trigger for case selection (admins). The // The dialog uses a Select trigger for case selection (admins). The
// default "None" value is shown on the trigger. // default "Create new case" value is shown on the trigger and the
// New-case inputs render directly below.
await expect(dialog.locator("button[role='combobox']")).toBeVisible(); await expect(dialog.locator("button[role='combobox']")).toBeVisible();
await expect(dialog.getByText(/None/)).toBeVisible(); await expect(dialog.getByText(/Create new case/i)).toBeVisible();
}); });
test("starting an export posts the expected payload and navigates to the case", async ({ test("starting an export posts the expected payload and navigates to the case", async ({
@ -512,12 +513,6 @@ test.describe("Multi-Review Export @high", () => {
.filter({ hasText: /Export 2 reviews/i }); .filter({ hasText: /Export 2 reviews/i });
await expect(dialog).toBeVisible({ timeout: 5_000 }); await expect(dialog).toBeVisible({ timeout: 5_000 });
// Select "Create new case" from the case dropdown (default is "None")
await dialog.locator("button[role='combobox']").click();
await frigateApp.page
.getByRole("option", { name: /Create new case/i })
.click();
const nameInput = dialog.locator("input").first(); const nameInput = dialog.locator("input").first();
await nameInput.fill("E2E Incident"); await nameInput.fill("E2E Incident");

View File

@ -81,13 +81,11 @@
"exportButton_other": "Export {{count}} Cameras" "exportButton_other": "Export {{count}} Cameras"
}, },
"multi": { "multi": {
"title": "Export {{count}} reviews",
"title_one": "Export 1 review", "title_one": "Export 1 review",
"title_other": "Export {{count}} reviews", "title_other": "Export {{count}} reviews",
"description": "Export each selected review. All exports will be grouped under a single case.", "description": "Export each selected review. All exports will be grouped under a single case.",
"descriptionNoCase": "Export each selected review.", "descriptionNoCase": "Export each selected review.",
"caseNamePlaceholder": "Review export - {{date}}", "caseNamePlaceholder": "Review export - {{date}}",
"exportButton": "Export {{count}} reviews",
"exportButton_one": "Export 1 review", "exportButton_one": "Export 1 review",
"exportButton_other": "Export {{count}} reviews", "exportButton_other": "Export {{count}} reviews",
"exportingButton": "Exporting...", "exportingButton": "Exporting...",

View File

@ -93,8 +93,6 @@ export default function ExportDialog({
const { t } = useTranslation(["components/dialog"]); const { t } = useTranslation(["components/dialog"]);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [selectedCaseId, setSelectedCaseId] = useState<string | undefined>(); const [selectedCaseId, setSelectedCaseId] = useState<string | undefined>();
const [singleNewCaseName, setSingleNewCaseName] = useState("");
const [singleNewCaseDescription, setSingleNewCaseDescription] = useState("");
const [activeTab, setActiveTab] = useState<ExportTab>("export"); const [activeTab, setActiveTab] = useState<ExportTab>("export");
const [isStartingExport, setIsStartingExport] = useState(false); const [isStartingExport, setIsStartingExport] = useState(false);
const previousModeRef = useRef<ExportMode>(mode); const previousModeRef = useRef<ExportMode>(mode);
@ -139,24 +137,12 @@ export default function ExportDialog({
setIsStartingExport(true); setIsStartingExport(true);
try { try {
let exportCaseId: string | undefined = selectedCaseId;
if (selectedCaseId === "new" && singleNewCaseName.trim().length > 0) {
const caseResp = await axios.post("cases", {
name: singleNewCaseName.trim(),
description: singleNewCaseDescription.trim() || undefined,
});
exportCaseId = caseResp.data?.id;
} else if (selectedCaseId === "new" || selectedCaseId === "none") {
exportCaseId = undefined;
}
await axios.post<StartExportResponse>( await axios.post<StartExportResponse>(
`export/${camera}/start/${Math.round(range.after)}/end/${Math.round(range.before)}`, `export/${camera}/start/${Math.round(range.after)}/end/${Math.round(range.before)}`,
{ {
source: "recordings", source: "recordings",
name, name,
export_case_id: exportCaseId, export_case_id: selectedCaseId || undefined,
}, },
); );
@ -170,8 +156,6 @@ export default function ExportDialog({
}); });
setName(""); setName("");
setSelectedCaseId(undefined); setSelectedCaseId(undefined);
setSingleNewCaseName("");
setSingleNewCaseDescription("");
setRange(undefined); setRange(undefined);
setMode("none"); setMode("none");
return true; return true;
@ -199,8 +183,6 @@ export default function ExportDialog({
name, name,
range, range,
selectedCaseId, selectedCaseId,
singleNewCaseDescription,
singleNewCaseName,
setMode, setMode,
setRange, setRange,
t, t,
@ -209,8 +191,6 @@ export default function ExportDialog({
const handleCancel = useCallback(() => { const handleCancel = useCallback(() => {
setName(""); setName("");
setSelectedCaseId(undefined); setSelectedCaseId(undefined);
setSingleNewCaseName("");
setSingleNewCaseDescription("");
setMode("none"); setMode("none");
setRange(undefined); setRange(undefined);
setActiveTab("export"); setActiveTab("export");
@ -292,16 +272,12 @@ export default function ExportDialog({
range={range} range={range}
name={name} name={name}
selectedCaseId={selectedCaseId} selectedCaseId={selectedCaseId}
singleNewCaseName={singleNewCaseName}
singleNewCaseDescription={singleNewCaseDescription}
activeTab={activeTab} activeTab={activeTab}
isStartingExport={isStartingExport} isStartingExport={isStartingExport}
onStartExport={onStartExport} onStartExport={onStartExport}
setActiveTab={setActiveTab} setActiveTab={setActiveTab}
setName={setName} setName={setName}
setSelectedCaseId={setSelectedCaseId} setSelectedCaseId={setSelectedCaseId}
setSingleNewCaseName={setSingleNewCaseName}
setSingleNewCaseDescription={setSingleNewCaseDescription}
setRange={setRange} setRange={setRange}
setMode={setMode} setMode={setMode}
onCancel={handleCancel} onCancel={handleCancel}
@ -318,16 +294,12 @@ type ExportContentProps = {
range?: TimeRange; range?: TimeRange;
name: string; name: string;
selectedCaseId?: string; selectedCaseId?: string;
singleNewCaseName: string;
singleNewCaseDescription: string;
activeTab: ExportTab; activeTab: ExportTab;
isStartingExport: boolean; isStartingExport: boolean;
onStartExport: () => Promise<boolean>; onStartExport: () => Promise<boolean>;
setActiveTab: (tab: ExportTab) => void; setActiveTab: (tab: ExportTab) => void;
setName: (name: string) => void; setName: (name: string) => void;
setSelectedCaseId: (caseId: string | undefined) => void; setSelectedCaseId: (caseId: string | undefined) => void;
setSingleNewCaseName: (name: string) => void;
setSingleNewCaseDescription: (description: string) => void;
setRange: (range: TimeRange | undefined) => void; setRange: (range: TimeRange | undefined) => void;
setMode: (mode: ExportMode) => void; setMode: (mode: ExportMode) => void;
onCancel: () => void; onCancel: () => void;
@ -339,16 +311,12 @@ export function ExportContent({
range, range,
name, name,
selectedCaseId, selectedCaseId,
singleNewCaseName,
singleNewCaseDescription,
activeTab, activeTab,
isStartingExport, isStartingExport,
onStartExport, onStartExport,
setActiveTab, setActiveTab,
setName, setName,
setSelectedCaseId, setSelectedCaseId,
setSingleNewCaseName,
setSingleNewCaseDescription,
setRange, setRange,
setMode, setMode,
onCancel, onCancel,
@ -364,7 +332,7 @@ export function ExportContent({
); );
const [selectedCameraIds, setSelectedCameraIds] = useState<string[]>([]); const [selectedCameraIds, setSelectedCameraIds] = useState<string[]>([]);
const [batchCaseSelection, setBatchCaseSelection] = useState<string>( const [batchCaseSelection, setBatchCaseSelection] = useState<string>(
selectedCaseId || "none", selectedCaseId || "new",
); );
const [hasManualCameraSelection, setHasManualCameraSelection] = const [hasManualCameraSelection, setHasManualCameraSelection] =
useState(false); useState(false);
@ -515,8 +483,7 @@ export function ExportContent({
Boolean(range && range.before > range.after) && Boolean(range && range.before > range.after) &&
selectedCameraCount > 0 && selectedCameraCount > 0 &&
!isStartingBatchExport && !isStartingBatchExport &&
(batchCaseSelection !== "new" || newCaseName.trim().length > 0) && (batchCaseSelection !== "new" || newCaseName.trim().length > 0);
batchCaseSelection.length > 0;
const onSelectTime = useCallback( const onSelectTime = useCallback(
(option: ExportOption) => { (option: ExportOption) => {
@ -600,7 +567,7 @@ export function ExportContent({
})), })),
}; };
if (isAdmin && batchCaseSelection !== "none") { if (isAdmin) {
if (batchCaseSelection === "new") { if (batchCaseSelection === "new") {
payload.new_case_name = newCaseName.trim(); payload.new_case_name = newCaseName.trim();
payload.new_case_description = newCaseDescription.trim() || undefined; payload.new_case_description = newCaseDescription.trim() || undefined;
@ -819,30 +786,8 @@ export function ExportContent({
{caseItem.name} {caseItem.name}
</SelectItem> </SelectItem>
))} ))}
<SelectSeparator />
<SelectItem value="new">
{t("export.case.newCaseOption")}
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
{selectedCaseId === "new" && (
<div className="space-y-2 pt-1">
<Input
className="text-md"
placeholder={t("export.case.newCaseNamePlaceholder")}
value={singleNewCaseName}
onChange={(e) => setSingleNewCaseName(e.target.value)}
/>
<Textarea
className="text-md"
placeholder={t("export.case.newCaseDescriptionPlaceholder")}
value={singleNewCaseDescription}
onChange={(e) =>
setSingleNewCaseDescription(e.target.value)
}
/>
</div>
)}
</div> </div>
)} )}
</TabsContent> </TabsContent>
@ -1002,9 +947,6 @@ export function ExportContent({
<SelectValue placeholder={t("export.case.placeholder")} /> <SelectValue placeholder={t("export.case.placeholder")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="none">
{t("label.none", { ns: "common" })}
</SelectItem>
{cases {cases
?.sort((a, b) => a.name.localeCompare(b.name)) ?.sort((a, b) => a.name.localeCompare(b.name))
.map((caseItem) => ( .map((caseItem) => (

View File

@ -115,8 +115,6 @@ export default function MobileReviewSettingsDrawer({
const [selectedCaseId, setSelectedCaseId] = useState<string | undefined>( const [selectedCaseId, setSelectedCaseId] = useState<string | undefined>(
undefined, undefined,
); );
const [singleNewCaseName, setSingleNewCaseName] = useState("");
const [singleNewCaseDescription, setSingleNewCaseDescription] = useState("");
const [isStartingExport, setIsStartingExport] = useState(false); const [isStartingExport, setIsStartingExport] = useState(false);
const onStartExport = useCallback(async () => { const onStartExport = useCallback(async () => {
if (isStartingExport) { if (isStartingExport) {
@ -150,24 +148,12 @@ export default function MobileReviewSettingsDrawer({
setIsStartingExport(true); setIsStartingExport(true);
try { try {
let exportCaseId: string | undefined = selectedCaseId;
if (selectedCaseId === "new" && singleNewCaseName.trim().length > 0) {
const caseResp = await axios.post("cases", {
name: singleNewCaseName.trim(),
description: singleNewCaseDescription.trim() || undefined,
});
exportCaseId = caseResp.data?.id;
} else if (selectedCaseId === "new" || selectedCaseId === "none") {
exportCaseId = undefined;
}
await axios.post<StartExportResponse>( await axios.post<StartExportResponse>(
`export/${camera}/start/${Math.round(range.after)}/end/${Math.round(range.before)}`, `export/${camera}/start/${Math.round(range.after)}/end/${Math.round(range.before)}`,
{ {
source: "recordings", source: "recordings",
name, name,
export_case_id: exportCaseId, export_case_id: selectedCaseId || undefined,
}, },
); );
@ -183,8 +169,6 @@ export default function MobileReviewSettingsDrawer({
}); });
setName(""); setName("");
setSelectedCaseId(undefined); setSelectedCaseId(undefined);
setSingleNewCaseName("");
setSingleNewCaseDescription("");
setRange(undefined); setRange(undefined);
setMode("none"); setMode("none");
return true; return true;
@ -215,8 +199,6 @@ export default function MobileReviewSettingsDrawer({
name, name,
range, range,
selectedCaseId, selectedCaseId,
singleNewCaseDescription,
singleNewCaseName,
setRange, setRange,
setMode, setMode,
t, t,
@ -379,16 +361,12 @@ export default function MobileReviewSettingsDrawer({
range={range} range={range}
name={name} name={name}
selectedCaseId={selectedCaseId} selectedCaseId={selectedCaseId}
singleNewCaseName={singleNewCaseName}
singleNewCaseDescription={singleNewCaseDescription}
activeTab={exportTab} activeTab={exportTab}
isStartingExport={isStartingExport} isStartingExport={isStartingExport}
onStartExport={onStartExport} onStartExport={onStartExport}
setActiveTab={setExportTab} setActiveTab={setExportTab}
setName={setName} setName={setName}
setSelectedCaseId={setSelectedCaseId} setSelectedCaseId={setSelectedCaseId}
setSingleNewCaseName={setSingleNewCaseName}
setSingleNewCaseDescription={setSingleNewCaseDescription}
setRange={setRange} setRange={setRange}
setMode={(mode) => { setMode={(mode) => {
setMode(mode); setMode(mode);
@ -401,8 +379,6 @@ export default function MobileReviewSettingsDrawer({
setMode("none"); setMode("none");
setRange(undefined); setRange(undefined);
setSelectedCaseId(undefined); setSelectedCaseId(undefined);
setSingleNewCaseName("");
setSingleNewCaseDescription("");
setExportTab("export"); setExportTab("export");
setDrawerMode("select"); setDrawerMode("select");
}} }}

View File

@ -55,7 +55,6 @@ type MultiExportDialogProps = {
children: React.ReactNode; children: React.ReactNode;
}; };
const NONE_CASE_OPTION = "none";
const NEW_CASE_OPTION = "new"; const NEW_CASE_OPTION = "new";
export default function MultiExportDialog({ export default function MultiExportDialog({
@ -75,7 +74,10 @@ export default function MultiExportDialog({
const { data: cases } = useSWR<ExportCase[]>(isAdmin ? "cases" : null); const { data: cases } = useSWR<ExportCase[]>(isAdmin ? "cases" : null);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [caseSelection, setCaseSelection] = useState<string>(NONE_CASE_OPTION); // Single unified state: either NEW_CASE_OPTION or an existing case id.
// Defaults to NEW_CASE_OPTION, which is also the only valid value for
// non-admins since they can't attach to existing cases.
const [caseSelection, setCaseSelection] = useState<string>(NEW_CASE_OPTION);
const [newCaseName, setNewCaseName] = useState(""); const [newCaseName, setNewCaseName] = useState("");
const [newCaseDescription, setNewCaseDescription] = useState(""); const [newCaseDescription, setNewCaseDescription] = useState("");
const [isExporting, setIsExporting] = useState(false); const [isExporting, setIsExporting] = useState(false);
@ -132,7 +134,7 @@ export default function MultiExportDialog({
}, [t, locale]); }, [t, locale]);
const resetState = useCallback(() => { const resetState = useCallback(() => {
setCaseSelection(NONE_CASE_OPTION); setCaseSelection(NEW_CASE_OPTION);
setNewCaseName(""); setNewCaseName("");
setNewCaseDescription(""); setNewCaseDescription("");
setIsExporting(false); setIsExporting(false);
@ -144,7 +146,7 @@ export default function MultiExportDialog({
resetState(); resetState();
} else { } else {
// Freshly reset each time so the default name reflects "now" // Freshly reset each time so the default name reflects "now"
setCaseSelection(NONE_CASE_OPTION); setCaseSelection(NEW_CASE_OPTION);
setNewCaseName(defaultCaseName); setNewCaseName(defaultCaseName);
setNewCaseDescription(""); setNewCaseDescription("");
setIsExporting(false); setIsExporting(false);
@ -183,7 +185,7 @@ export default function MultiExportDialog({
const payload: BatchExportBody = { items }; const payload: BatchExportBody = { items };
if (isAdmin && caseSelection !== NONE_CASE_OPTION) { if (isAdmin) {
if (isNewCase) { if (isNewCase) {
payload.new_case_name = newCaseName.trim(); payload.new_case_name = newCaseName.trim();
payload.new_case_description = newCaseDescription.trim() || undefined; payload.new_case_description = newCaseDescription.trim() || undefined;
@ -321,15 +323,12 @@ export default function MultiExportDialog({
<SelectValue placeholder={t("export.case.placeholder")} /> <SelectValue placeholder={t("export.case.placeholder")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value={NONE_CASE_OPTION}>
{t("label.none", { ns: "common" })}
</SelectItem>
{existingCases.map((caseItem) => ( {existingCases.map((caseItem) => (
<SelectItem key={caseItem.id} value={caseItem.id}> <SelectItem key={caseItem.id} value={caseItem.id}>
{caseItem.name} {caseItem.name}
</SelectItem> </SelectItem>
))} ))}
<SelectSeparator /> {existingCases.length > 0 && <SelectSeparator />}
<SelectItem value={NEW_CASE_OPTION}> <SelectItem value={NEW_CASE_OPTION}>
{t("export.case.newCaseOption")} {t("export.case.newCaseOption")}
</SelectItem> </SelectItem>