add unsaved changes icon/popover to individual settings section

This commit is contained in:
Josh Hawkins 2026-04-26 11:31:35 -05:00
parent 4953975f5e
commit 61eb365712
3 changed files with 66 additions and 24 deletions

View File

@ -65,10 +65,14 @@ import {
globalCameraDefaultSections,
buildOverrides,
buildConfigDataForPath,
flattenOverrides,
getBaseCameraSectionValue,
sanitizeSectionData as sharedSanitizeSectionData,
requiresRestartForOverrides as sharedRequiresRestartForOverrides,
} from "@/utils/configUtil";
import SaveAllPreviewPopover, {
type SaveAllPreviewItem,
} from "@/components/overlay/detail/SaveAllPreviewPopover";
import RestartDialog from "@/components/overlay/dialog/RestartDialog";
import { useRestart } from "@/api/ws";
import type {
@ -913,6 +917,34 @@ export function ConfigSection({
);
}, [sectionConfig?.renderers, sectionPath, cameraName, setPendingData]);
// Build a flat list of pending field changes for this section only.
// Mirrors the global Save All preview but scoped to the current section so
// users can inspect what will be saved without leaving the section.
const sectionPreviewItems = useMemo<SaveAllPreviewItem[]>(() => {
if (!hasChanges) return [];
if (!effectiveOverrides || typeof effectiveOverrides !== "object") {
return [];
}
const flattened = flattenOverrides(effectiveOverrides as JsonValue);
return flattened.map(({ path, value }) => ({
scope: effectiveLevel,
cameraName,
profileName: profileName
? (profileFriendlyName ?? profileName)
: undefined,
fieldPath: path ? `${sectionPath}.${path}` : sectionPath,
value,
}));
}, [
hasChanges,
effectiveOverrides,
effectiveLevel,
cameraName,
profileName,
profileFriendlyName,
sectionPath,
]);
if (!modifiedSchema) {
return null;
}
@ -1018,6 +1050,12 @@ export function ConfigSection({
defaultValue: "You have unsaved changes",
})}
</span>
<SaveAllPreviewPopover
items={sectionPreviewItems}
className="h-7 w-7"
align="start"
side="top"
/>
</div>
)}
<div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center md:w-auto">

View File

@ -28,11 +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,
JsonObject,
JsonValue,
} from "@/types/configForm";
import type { ConfigSectionData, JsonObject } from "@/types/configForm";
import useSWR from "swr";
import FilterSwitch from "@/components/filter/FilterSwitch";
import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter";
@ -93,6 +89,7 @@ import { mutate } from "swr";
import { RJSFSchema } from "@rjsf/utils";
import {
buildConfigDataForPath,
flattenOverrides,
parseProfileFromSectionPath,
prepareSectionSavePayload,
PROFILE_ELIGIBLE_SECTIONS,
@ -190,25 +187,6 @@ const parsePendingDataKey = (pendingDataKey: string) => {
};
};
const flattenOverrides = (
value: JsonValue | undefined,
path: string[] = [],
): Array<{ path: string; value: JsonValue }> => {
if (value === undefined) return [];
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return [{ path: path.join("."), value }];
}
const entries = Object.entries(value);
if (entries.length === 0) {
return [{ path: path.join("."), value: {} }];
}
return entries.flatMap(([key, entryValue]) =>
flattenOverrides(entryValue, [...path, key]),
);
};
const createSectionPage = (
sectionKey: string,
level: "global" | "camera",

View File

@ -219,6 +219,32 @@ export function buildOverrides(
return current;
}
// ---------------------------------------------------------------------------
// flattenOverrides — turn an overrides object into a list of leaf paths
// ---------------------------------------------------------------------------
// Walks a nested overrides value and produces a flat list of `{ path, value }`
// entries, one per leaf. Used by save/preview UIs to enumerate the individual
// fields that will be changed.
export function flattenOverrides(
value: JsonValue | undefined,
path: string[] = [],
): Array<{ path: string; value: JsonValue }> {
if (value === undefined) return [];
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return [{ path: path.join("."), value }];
}
const entries = Object.entries(value);
if (entries.length === 0) {
return [{ path: path.join("."), value: {} }];
}
return entries.flatMap(([key, entryValue]) =>
flattenOverrides(entryValue, [...path, key]),
);
}
// ---------------------------------------------------------------------------
// sanitizeSectionData — normalize config values and strip hidden fields
// ---------------------------------------------------------------------------