preserve form data when changing cameras

This commit is contained in:
Josh Hawkins 2026-02-08 18:12:46 -06:00
parent a48304d3a2
commit c90a547ec9
2 changed files with 49 additions and 24 deletions

View File

@ -178,8 +178,7 @@ export function ConfigSection({
const [dirtyOverrides, setDirtyOverrides] = useState<JsonValue | undefined>( const [dirtyOverrides, setDirtyOverrides] = useState<JsonValue | undefined>(
undefined, undefined,
); );
const [baselineFormData, setBaselineFormData] = const baselineByKeyRef = useRef<Record<string, ConfigSectionData>>({});
useState<ConfigSectionData | null>(null);
const pendingData = const pendingData =
pendingDataBySection !== undefined pendingDataBySection !== undefined
@ -202,6 +201,7 @@ export function ConfigSection({
const [restartDialogOpen, setRestartDialogOpen] = useState(false); const [restartDialogOpen, setRestartDialogOpen] = useState(false);
const isResettingRef = useRef(false); const isResettingRef = useRef(false);
const isInitializingRef = useRef(true); const isInitializingRef = useRef(true);
const lastPendingDataKeyRef = useRef<string | null>(null);
const updateTopic = const updateTopic =
level === "camera" && cameraName level === "camera" && cameraName
@ -268,6 +268,23 @@ export function ConfigSection({
return sanitizeSectionData(baseData); return sanitizeSectionData(baseData);
}, [rawFormData, modifiedSchema, sanitizeSectionData]); }, [rawFormData, modifiedSchema, sanitizeSectionData]);
const baselineSnapshot = useMemo(() => {
if (!pendingData) {
const snapshot = cloneDeep(formData as ConfigSectionData);
baselineByKeyRef.current[pendingDataKey] = snapshot;
return snapshot;
}
const cached = baselineByKeyRef.current[pendingDataKey];
if (cached) {
return cached;
}
const snapshot = cloneDeep(formData as ConfigSectionData);
baselineByKeyRef.current[pendingDataKey] = snapshot;
return snapshot;
}, [formData, pendingData, pendingDataKey]);
const schemaDefaults = useMemo(() => { const schemaDefaults = useMemo(() => {
if (!modifiedSchema) { if (!modifiedSchema) {
return {}; return {};
@ -297,21 +314,29 @@ export function ConfigSection({
// This prevents RJSF's initial onChange call from being treated as a user edit // This prevents RJSF's initial onChange call from being treated as a user edit
// Only clear if pendingData is managed locally (not by parent) // Only clear if pendingData is managed locally (not by parent)
useEffect(() => { useEffect(() => {
if (!pendingData) { const pendingKeyChanged = lastPendingDataKeyRef.current !== pendingDataKey;
if (pendingKeyChanged) {
lastPendingDataKeyRef.current = pendingDataKey;
isInitializingRef.current = true;
setPendingOverrides(undefined);
setDirtyOverrides(undefined);
} else if (!pendingData) {
isInitializingRef.current = true; isInitializingRef.current = true;
setPendingOverrides(undefined); setPendingOverrides(undefined);
setDirtyOverrides(undefined); setDirtyOverrides(undefined);
setBaselineFormData(cloneDeep(formData as ConfigSectionData));
} }
if (onPendingDataChange === undefined) { if (onPendingDataChange === undefined) {
setPendingData(null); setPendingData(null);
} }
}, [ }, [
formData,
pendingData,
setPendingData,
setBaselineFormData,
onPendingDataChange, onPendingDataChange,
pendingData,
pendingDataKey,
setPendingData,
setDirtyOverrides,
setPendingOverrides,
]); ]);
useEffect(() => { useEffect(() => {
@ -344,7 +369,7 @@ export function ConfigSection({
return; return;
} }
const sanitizedData = sanitizeSectionData(data as ConfigSectionData); const sanitizedData = sanitizeSectionData(data as ConfigSectionData);
let nextBaselineFormData = baselineFormData ?? formData; const nextBaselineFormData = baselineSnapshot;
const overrides = buildOverrides( const overrides = buildOverrides(
sanitizedData, sanitizedData,
compareBaseData, compareBaseData,
@ -353,16 +378,6 @@ export function ConfigSection({
setPendingOverrides(overrides as JsonValue | undefined); setPendingOverrides(overrides as JsonValue | undefined);
if (isInitializingRef.current && !pendingData) { if (isInitializingRef.current && !pendingData) {
isInitializingRef.current = false; isInitializingRef.current = false;
if (!baselineFormData) {
// Always use formData (server data + schema defaults) for the
// baseline snapshot, NOT sanitizedData from the onChange callback.
// If a custom component (e.g., zone checkboxes) triggers onChange
// before RJSF's initial onChange, sanitizedData would include the
// user's modification, corrupting the baseline.
const baselineSnapshot = cloneDeep(formData as ConfigSectionData);
setBaselineFormData(baselineSnapshot);
nextBaselineFormData = baselineSnapshot;
}
if (overrides === undefined) { if (overrides === undefined) {
setPendingData(null); setPendingData(null);
setPendingOverrides(undefined); setPendingOverrides(undefined);
@ -392,14 +407,12 @@ export function ConfigSection({
setPendingData, setPendingData,
setPendingOverrides, setPendingOverrides,
setDirtyOverrides, setDirtyOverrides,
baselineFormData, baselineSnapshot,
setBaselineFormData,
formData,
], ],
); );
const currentFormData = pendingData || formData; const currentFormData = pendingData || formData;
const effectiveBaselineFormData = baselineFormData ?? formData; const effectiveBaselineFormData = baselineSnapshot;
const currentOverrides = useMemo(() => { const currentOverrides = useMemo(() => {
if (!currentFormData || typeof currentFormData !== "object") { if (!currentFormData || typeof currentFormData !== "object") {

View File

@ -6,7 +6,7 @@ import {
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from "@/components/ui/collapsible"; } from "@/components/ui/collapsible";
import { Children, useState, useEffect } from "react"; import { Children, useState, useEffect, useRef } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { LuChevronDown, LuChevronRight } from "react-icons/lu"; import { LuChevronDown, LuChevronRight } from "react-icons/lu";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -142,6 +142,10 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
const hasModifiedDescendants = checkSubtreeModified(fieldPath); const hasModifiedDescendants = checkSubtreeModified(fieldPath);
const [isOpen, setIsOpen] = useState(hasModifiedDescendants); const [isOpen, setIsOpen] = useState(hasModifiedDescendants);
const resetKey = `${formContext?.level ?? "global"}::${
formContext?.cameraName ?? "global"
}`;
const lastResetKeyRef = useRef<string | null>(null);
// Auto-expand collapsible when modifications are detected // Auto-expand collapsible when modifications are detected
useEffect(() => { useEffect(() => {
@ -191,6 +195,14 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
setShowAdvanced(true); setShowAdvanced(true);
} }
}, [hasModifiedAdvanced]); }, [hasModifiedAdvanced]);
useEffect(() => {
if (lastResetKeyRef.current !== resetKey) {
lastResetKeyRef.current = resetKey;
setIsOpen(hasModifiedDescendants);
setShowAdvanced(hasModifiedAdvanced);
}
}, [resetKey, hasModifiedDescendants, hasModifiedAdvanced]);
const { children } = props as ObjectFieldTemplateProps & { const { children } = props as ObjectFieldTemplateProps & {
children?: ReactNode; children?: ReactNode;
}; };