diff --git a/web/public/locales/en/common.json b/web/public/locales/en/common.json index 48e46f5e5..67dc057ef 100644 --- a/web/public/locales/en/common.json +++ b/web/public/locales/en/common.json @@ -117,6 +117,7 @@ "button": { "apply": "Apply", "reset": "Reset", + "undo": "Undo", "done": "Done", "enabled": "Enabled", "enable": "Enable", diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 554e66cf1..4cf54d2b9 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -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." } diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index 118f9a2a2..23575082f 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -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; + /** 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( - 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(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({
{hasChanges && ( - + {t("unsavedChanges", { ns: "views/settings", defaultValue: "You have unsaved changes", @@ -606,6 +678,26 @@ export function ConfigSection({ )}
+ {((level === "camera" && isOverridden) || level === "global") && + !hasChanges && ( + + )} {hasChanges && ( )}
+ + + + + + {t("confirmReset", { ns: "views/settings" })} + + + {level === "global" + ? t("resetToDefaultDescription", { ns: "views/settings" }) + : t("resetToGlobalDescription", { ns: "views/settings" })} + + + + + {t("cancel", { ns: "common" })} + + { + await handleResetToGlobal(); + setIsResetDialogOpen(false); + }} + > + {level === "global" + ? t("button.resetToDefault", { ns: "common" }) + : t("button.resetToGlobal", { ns: "common" })} + + + + ); @@ -664,28 +787,6 @@ export function ConfigSection({ )} - {((level === "camera" && isOverridden) || level === "global") && ( - - )} @@ -724,52 +825,9 @@ export function ConfigSection({

)} - {((level === "camera" && isOverridden) || level === "global") && ( - - )} )} - {/* Reset button when title is hidden but we're at camera level with override */} - {!shouldShowTitle && - ((level === "camera" && isOverridden) || level === "global") && ( -
- -
- )} - {sectionContent} ); diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 684ee7e0c..ef75e31c5 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -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 + >({}); + 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 (
@@ -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} /> ); })()} diff --git a/web/src/views/settings/SingleSectionPage.tsx b/web/src/views/settings/SingleSectionPage.tsx index 97184e634..40be4f3cb 100644 --- a/web/src/views/settings/SingleSectionPage.tsx +++ b/web/src/views/settings/SingleSectionPage.tsx @@ -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; + 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 (
@@ -68,32 +71,44 @@ export function SingleSectionPage({ } return ( -
-
- - {t(`${sectionKey}.label`, { ns: sectionNamespace })} - - - {i18n.exists(`${sectionKey}.description`, { - ns: sectionNamespace, - }) && ( -

- {t(`${sectionKey}.description`, { ns: sectionNamespace })} -

- )} -
- {level === "camera" && - showOverrideIndicator && - sectionStatus.isOverridden && ( - - {t("overridden", { ns: "common", defaultValue: "Overridden" })} +
+
+
+
+ {t(`${sectionKey}.label`, { ns: sectionNamespace })} +
+ {i18n.exists(`${sectionKey}.description`, { + ns: sectionNamespace, + }) && ( +
+ {t(`${sectionKey}.description`, { ns: sectionNamespace })} +
+ )} +
+
+
+ {level === "camera" && + showOverrideIndicator && + sectionStatus.isOverridden && ( + + {t("overridden", { + ns: "common", + defaultValue: "Overridden", + })} + + )} + {sectionStatus.hasChanges && ( + + {t("modified", { ns: "common", defaultValue: "Modified" })} )} - {sectionStatus.hasChanges && ( - - {t("modified", { ns: "common", defaultValue: "Modified" })} - - )} +
setUnsavedChanges?.(false)} showTitle={false} sectionConfig={sectionConfig} + pendingDataBySection={pendingDataBySection} + onPendingDataChange={onPendingDataChange} requiresRestart={requiresRestart} onStatusChange={setSectionStatus} />