mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-10 10:33:11 +03:00
mark pending changes and add confirmation dialog for resets
This commit is contained in:
parent
95a0530ce6
commit
b9149b6366
@ -117,6 +117,7 @@
|
||||
"button": {
|
||||
"apply": "Apply",
|
||||
"reset": "Reset",
|
||||
"undo": "Undo",
|
||||
"done": "Done",
|
||||
"enabled": "Enabled",
|
||||
"enable": "Enable",
|
||||
|
||||
@ -1290,5 +1290,8 @@
|
||||
"resetSuccess": "Reset to global defaults",
|
||||
"resetError": "Failed to reset settings"
|
||||
},
|
||||
"unsavedChanges": "You have unsaved changes"
|
||||
"unsavedChanges": "You have unsaved changes",
|
||||
"confirmReset": "Confirm Reset",
|
||||
"resetToDefaultDescription": "This will reset all settings in this section to their default values. This action cannot be undone.",
|
||||
"resetToGlobalDescription": "This will reset the settings in this section to the global defaults. This action cannot be undone."
|
||||
}
|
||||
|
||||
@ -36,6 +36,16 @@ import {
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { applySchemaDefaults } from "@/lib/config-schema";
|
||||
import { isJsonObject } from "@/lib/utils";
|
||||
import { ConfigSectionData, JsonObject, JsonValue } from "@/types/configForm";
|
||||
@ -92,6 +102,14 @@ export interface BaseSectionProps {
|
||||
hasChanges: boolean;
|
||||
isOverridden: boolean;
|
||||
}) => void;
|
||||
/** Pending form data keyed by "sectionKey" or "cameraName::sectionKey" */
|
||||
pendingDataBySection?: Record<string, unknown>;
|
||||
/** Callback to update pending data for a section */
|
||||
onPendingDataChange?: (
|
||||
sectionKey: string,
|
||||
cameraName: string | undefined,
|
||||
data: ConfigSectionData | null,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export interface CreateSectionOptions {
|
||||
@ -140,6 +158,8 @@ export function ConfigSection({
|
||||
defaultCollapsed = false,
|
||||
showTitle,
|
||||
onStatusChange,
|
||||
pendingDataBySection,
|
||||
onPendingDataChange,
|
||||
}: ConfigSectionProps) {
|
||||
const { t, i18n } = useTranslation([
|
||||
level === "camera" ? "config/cameras" : "config/global",
|
||||
@ -148,12 +168,40 @@ export function ConfigSection({
|
||||
"common",
|
||||
]);
|
||||
const [isOpen, setIsOpen] = useState(!defaultCollapsed);
|
||||
const [pendingData, setPendingData] = useState<ConfigSectionData | null>(
|
||||
null,
|
||||
|
||||
// Create a key for this section's pending data
|
||||
const pendingDataKey = useMemo(
|
||||
() =>
|
||||
level === "camera" && cameraName
|
||||
? `${cameraName}::${sectionPath}`
|
||||
: sectionPath,
|
||||
[level, cameraName, sectionPath],
|
||||
);
|
||||
|
||||
// Use pending data from parent if available, otherwise use local state
|
||||
const [localPendingData, setLocalPendingData] =
|
||||
useState<ConfigSectionData | null>(null);
|
||||
|
||||
const pendingData =
|
||||
pendingDataBySection !== undefined
|
||||
? (pendingDataBySection[pendingDataKey] as ConfigSectionData | null)
|
||||
: localPendingData;
|
||||
|
||||
const setPendingData = useCallback(
|
||||
(data: ConfigSectionData | null) => {
|
||||
if (onPendingDataChange) {
|
||||
onPendingDataChange(sectionPath, cameraName, data);
|
||||
} else {
|
||||
setLocalPendingData(data);
|
||||
}
|
||||
},
|
||||
[onPendingDataChange, sectionPath, cameraName],
|
||||
);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [formKey, setFormKey] = useState(0);
|
||||
const [isResetDialogOpen, setIsResetDialogOpen] = useState(false);
|
||||
const isResettingRef = useRef(false);
|
||||
const isInitializingRef = useRef(true);
|
||||
|
||||
const updateTopic =
|
||||
level === "camera" && cameraName
|
||||
@ -226,9 +274,15 @@ export function ConfigSection({
|
||||
|
||||
// Clear pendingData whenever formData changes (e.g., from server refresh)
|
||||
// This prevents RJSF's initial onChange call from being treated as a user edit
|
||||
// Only clear if pendingData is managed locally (not by parent)
|
||||
useEffect(() => {
|
||||
setPendingData(null);
|
||||
}, [formData]);
|
||||
if (!pendingData) {
|
||||
isInitializingRef.current = true;
|
||||
}
|
||||
if (onPendingDataChange === undefined) {
|
||||
setPendingData(null);
|
||||
}
|
||||
}, [formData, pendingData, setPendingData, onPendingDataChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isResettingRef.current) {
|
||||
@ -323,20 +377,36 @@ export function ConfigSection({
|
||||
return;
|
||||
}
|
||||
const sanitizedData = sanitizeSectionData(data as ConfigSectionData);
|
||||
if (isEqual(formData, sanitizedData)) {
|
||||
const rawData = sanitizeSectionData(rawFormData as ConfigSectionData);
|
||||
const overrides = buildOverrides(sanitizedData, rawData, schemaDefaults);
|
||||
if (isInitializingRef.current && !pendingData) {
|
||||
isInitializingRef.current = false;
|
||||
if (overrides === undefined) {
|
||||
setPendingData(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (overrides === undefined) {
|
||||
setPendingData(null);
|
||||
return;
|
||||
}
|
||||
setPendingData(sanitizedData);
|
||||
},
|
||||
[formData, sanitizeSectionData],
|
||||
[
|
||||
pendingData,
|
||||
rawFormData,
|
||||
sanitizeSectionData,
|
||||
buildOverrides,
|
||||
schemaDefaults,
|
||||
setPendingData,
|
||||
],
|
||||
);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
isResettingRef.current = true;
|
||||
setPendingData(null);
|
||||
setFormKey((prev) => prev + 1);
|
||||
}, []);
|
||||
}, [setPendingData]);
|
||||
|
||||
// Handle save button click
|
||||
const handleSave = useCallback(async () => {
|
||||
@ -433,6 +503,7 @@ export function ConfigSection({
|
||||
buildOverrides,
|
||||
schemaDefaults,
|
||||
updateTopic,
|
||||
setPendingData,
|
||||
]);
|
||||
|
||||
// Handle reset to global/defaults - removes camera-level override or resets global to defaults
|
||||
@ -496,6 +567,7 @@ export function ConfigSection({
|
||||
t,
|
||||
refreshConfig,
|
||||
updateTopic,
|
||||
setPendingData,
|
||||
]);
|
||||
|
||||
const sectionValidation = useMemo(
|
||||
@ -597,7 +669,7 @@ export function ConfigSection({
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{hasChanges && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="text-sm text-danger">
|
||||
{t("unsavedChanges", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "You have unsaved changes",
|
||||
@ -606,6 +678,26 @@ export function ConfigSection({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{((level === "camera" && isOverridden) || level === "global") &&
|
||||
!hasChanges && (
|
||||
<Button
|
||||
onClick={() => setIsResetDialogOpen(true)}
|
||||
variant="outline"
|
||||
disabled={isSaving || disabled}
|
||||
className="gap-2"
|
||||
>
|
||||
<LuRotateCcw className="h-4 w-4" />
|
||||
{level === "global"
|
||||
? t("button.resetToDefault", {
|
||||
ns: "common",
|
||||
defaultValue: "Reset to Default",
|
||||
})
|
||||
: t("button.resetToGlobal", {
|
||||
ns: "common",
|
||||
defaultValue: "Reset to Global",
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
{hasChanges && (
|
||||
<Button
|
||||
onClick={handleReset}
|
||||
@ -613,7 +705,7 @@ export function ConfigSection({
|
||||
disabled={isSaving || disabled}
|
||||
className="gap-2"
|
||||
>
|
||||
{t("reset", { ns: "common", defaultValue: "Reset" })}
|
||||
{t("undo", { ns: "common", defaultValue: "Undo" })}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
@ -629,6 +721,37 @@ export function ConfigSection({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={isResetDialogOpen} onOpenChange={setIsResetDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("confirmReset", { ns: "views/settings" })}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{level === "global"
|
||||
? t("resetToDefaultDescription", { ns: "views/settings" })
|
||||
: t("resetToGlobalDescription", { ns: "views/settings" })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("cancel", { ns: "common" })}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-selected text-white hover:bg-selected/90"
|
||||
onClick={async () => {
|
||||
await handleResetToGlobal();
|
||||
setIsResetDialogOpen(false);
|
||||
}}
|
||||
>
|
||||
{level === "global"
|
||||
? t("button.resetToDefault", { ns: "common" })
|
||||
: t("button.resetToGlobal", { ns: "common" })}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -664,28 +787,6 @@ export function ConfigSection({
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{((level === "camera" && isOverridden) || level === "global") && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleResetToGlobal();
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
<LuRotateCcw className="h-4 w-4" />
|
||||
{level === "global"
|
||||
? t("button.resetToDefault", {
|
||||
ns: "common",
|
||||
defaultValue: "Reset to Default",
|
||||
})
|
||||
: t("button.resetToGlobal", {
|
||||
ns: "common",
|
||||
defaultValue: "Reset to Global",
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
@ -724,52 +825,9 @@ export function ConfigSection({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{((level === "camera" && isOverridden) || level === "global") && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleResetToGlobal}
|
||||
className="gap-2"
|
||||
>
|
||||
<LuRotateCcw className="h-4 w-4" />
|
||||
{level === "global"
|
||||
? t("button.resetToDefault", {
|
||||
ns: "common",
|
||||
defaultValue: "Reset to Default",
|
||||
})
|
||||
: t("button.resetToGlobal", {
|
||||
ns: "common",
|
||||
defaultValue: "Reset to Global",
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reset button when title is hidden but we're at camera level with override */}
|
||||
{!shouldShowTitle &&
|
||||
((level === "camera" && isOverridden) || level === "global") && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleResetToGlobal}
|
||||
className="gap-2"
|
||||
>
|
||||
<LuRotateCcw className="h-4 w-4" />
|
||||
{level === "global"
|
||||
? t("button.resetToDefault", {
|
||||
ns: "common",
|
||||
defaultValue: "Reset to Default",
|
||||
})
|
||||
: t("button.resetToGlobal", {
|
||||
ns: "common",
|
||||
defaultValue: "Reset to Global",
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sectionContent}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -28,6 +28,7 @@ import useOptimisticState from "@/hooks/use-optimistic-state";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { FaVideo } from "react-icons/fa";
|
||||
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
||||
import type { ConfigSectionData } from "@/types/configForm";
|
||||
import useSWR from "swr";
|
||||
import FilterSwitch from "@/components/filter/FilterSwitch";
|
||||
import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter";
|
||||
@ -539,6 +540,11 @@ export default function Settings() {
|
||||
const [unsavedChanges, setUnsavedChanges] = useState(false);
|
||||
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
|
||||
|
||||
// Store pending form data keyed by "sectionKey" or "cameraName::sectionKey"
|
||||
const [pendingDataBySection, setPendingDataBySection] = useState<
|
||||
Record<string, unknown>
|
||||
>({});
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const cameras = useMemo(() => {
|
||||
@ -666,6 +672,30 @@ export default function Settings() {
|
||||
[],
|
||||
);
|
||||
|
||||
const handlePendingDataChange = useCallback(
|
||||
(
|
||||
sectionKey: string,
|
||||
cameraName: string | undefined,
|
||||
data: ConfigSectionData | null,
|
||||
) => {
|
||||
const pendingDataKey = cameraName
|
||||
? `${cameraName}::${sectionKey}`
|
||||
: sectionKey;
|
||||
|
||||
setPendingDataBySection((prev) => {
|
||||
if (data === null) {
|
||||
const { [pendingDataKey]: _, ...rest } = prev;
|
||||
return rest;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
[pendingDataKey]: data,
|
||||
};
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Initialize override status for all camera sections
|
||||
useEffect(() => {
|
||||
if (!selectedCamera || !cameraOverrides) return;
|
||||
@ -701,8 +731,7 @@ export default function Settings() {
|
||||
const status = sectionStatusByKey[key];
|
||||
const showOverrideDot =
|
||||
CAMERA_SECTION_KEYS.has(key) && status?.isOverridden;
|
||||
// const showUnsavedDot = status?.hasChanges;
|
||||
const showUnsavedDot = false; // Disable unsaved changes indicator for now
|
||||
const showUnsavedDot = status?.hasChanges;
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between pr-4 md:pr-0">
|
||||
@ -824,6 +853,8 @@ export default function Settings() {
|
||||
setUnsavedChanges={setUnsavedChanges}
|
||||
selectedZoneMask={filterZoneMask}
|
||||
onSectionStatusChange={handleSectionStatusChange}
|
||||
pendingDataBySection={pendingDataBySection}
|
||||
onPendingDataChange={handlePendingDataChange}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
@ -983,6 +1014,8 @@ export default function Settings() {
|
||||
setUnsavedChanges={setUnsavedChanges}
|
||||
selectedZoneMask={filterZoneMask}
|
||||
onSectionStatusChange={handleSectionStatusChange}
|
||||
pendingDataBySection={pendingDataBySection}
|
||||
onPendingDataChange={handlePendingDataChange}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Heading from "@/components/ui/heading";
|
||||
import type { SectionConfig } from "@/components/config-form/sections";
|
||||
import { ConfigSectionTemplate } from "@/components/config-form/sections";
|
||||
import type { PolygonType } from "@/types/canvas";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { ConfigSectionData } from "@/types/configForm";
|
||||
|
||||
export type SettingsPageProps = {
|
||||
selectedCamera?: string;
|
||||
@ -15,6 +15,12 @@ export type SettingsPageProps = {
|
||||
level: "global" | "camera",
|
||||
status: SectionStatus,
|
||||
) => void;
|
||||
pendingDataBySection?: Record<string, unknown>;
|
||||
onPendingDataChange?: (
|
||||
sectionKey: string,
|
||||
cameraName: string | undefined,
|
||||
data: ConfigSectionData | null,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export type SectionStatus = {
|
||||
@ -41,7 +47,8 @@ export function SingleSectionPage({
|
||||
showOverrideIndicator = true,
|
||||
selectedCamera,
|
||||
setUnsavedChanges,
|
||||
onSectionStatusChange,
|
||||
pendingDataBySection,
|
||||
onPendingDataChange,
|
||||
}: SingleSectionPageProps) {
|
||||
const sectionNamespace =
|
||||
level === "camera" ? "config/cameras" : "config/global";
|
||||
@ -55,10 +62,6 @@ export function SingleSectionPage({
|
||||
isOverridden: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
onSectionStatusChange?.(sectionKey, level, sectionStatus);
|
||||
}, [onSectionStatusChange, sectionKey, level, sectionStatus]);
|
||||
|
||||
if (level === "camera" && !selectedCamera) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
@ -68,32 +71,44 @@ export function SingleSectionPage({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col pr-2">
|
||||
<div className="space-y-4">
|
||||
<Heading as="h3">
|
||||
{t(`${sectionKey}.label`, { ns: sectionNamespace })}
|
||||
</Heading>
|
||||
|
||||
{i18n.exists(`${sectionKey}.description`, {
|
||||
ns: sectionNamespace,
|
||||
}) && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(`${sectionKey}.description`, { ns: sectionNamespace })}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{level === "camera" &&
|
||||
showOverrideIndicator &&
|
||||
sectionStatus.isOverridden && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("overridden", { ns: "common", defaultValue: "Overridden" })}
|
||||
<div className="flex size-full flex-col lg:pr-2">
|
||||
<div className="mb-5 flex items-center justify-between gap-4">
|
||||
<div className="flex flex-col">
|
||||
<div className="text-xl">
|
||||
{t(`${sectionKey}.label`, { ns: sectionNamespace })}
|
||||
</div>
|
||||
{i18n.exists(`${sectionKey}.description`, {
|
||||
ns: sectionNamespace,
|
||||
}) && (
|
||||
<div className="my-1 text-sm text-muted-foreground">
|
||||
{t(`${sectionKey}.description`, { ns: sectionNamespace })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2 md:flex-row md:items-center">
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{level === "camera" &&
|
||||
showOverrideIndicator &&
|
||||
sectionStatus.isOverridden && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="border-2 border-selected text-xs text-primary-variant"
|
||||
>
|
||||
{t("overridden", {
|
||||
ns: "common",
|
||||
defaultValue: "Overridden",
|
||||
})}
|
||||
</Badge>
|
||||
)}
|
||||
{sectionStatus.hasChanges && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-danger text-xs text-white"
|
||||
>
|
||||
{t("modified", { ns: "common", defaultValue: "Modified" })}
|
||||
</Badge>
|
||||
)}
|
||||
{sectionStatus.hasChanges && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t("modified", { ns: "common", defaultValue: "Modified" })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ConfigSectionTemplate
|
||||
@ -104,6 +119,8 @@ export function SingleSectionPage({
|
||||
onSave={() => setUnsavedChanges?.(false)}
|
||||
showTitle={false}
|
||||
sectionConfig={sectionConfig}
|
||||
pendingDataBySection={pendingDataBySection}
|
||||
onPendingDataChange={onPendingDataChange}
|
||||
requiresRestart={requiresRestart}
|
||||
onStatusChange={setSectionStatus}
|
||||
/>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user