mark pending changes and add confirmation dialog for resets

This commit is contained in:
Josh Hawkins 2026-02-01 17:13:32 -06:00
parent 95a0530ce6
commit b9149b6366
5 changed files with 220 additions and 108 deletions

View File

@ -117,6 +117,7 @@
"button": {
"apply": "Apply",
"reset": "Reset",
"undo": "Undo",
"done": "Done",
"enabled": "Enabled",
"enable": "Enable",

View File

@ -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."
}

View File

@ -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>
);

View File

@ -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}
/>
);
})()}

View File

@ -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}
/>