add special case and comments for global motion section

This commit is contained in:
Josh Hawkins 2026-02-02 09:56:54 -06:00
parent 59b0f3a680
commit f886846253

View File

@ -10,7 +10,7 @@ import sectionRenderers, {
RendererComponent, RendererComponent,
} from "@/components/config-form/sectionExtras/registry"; } from "@/components/config-form/sectionExtras/registry";
import { ConfigForm } from "../ConfigForm"; import { ConfigForm } from "../ConfigForm";
import type { FormValidation, UiSchema } from "@rjsf/utils"; import type { FormValidation, RJSFSchema, UiSchema } from "@rjsf/utils";
import { getSectionValidation } from "../section-validations"; import { getSectionValidation } from "../section-validations";
import { import {
useConfigOverride, useConfigOverride,
@ -234,15 +234,25 @@ export function ConfigSection({
}); });
// Get current form data // Get current form data
const rawSectionValue = useMemo(() => {
if (!config) return undefined;
if (level === "camera" && cameraName) {
return get(config.cameras?.[cameraName], sectionPath);
}
return get(config, sectionPath);
}, [config, level, cameraName, sectionPath]);
const rawFormData = useMemo(() => { const rawFormData = useMemo(() => {
if (!config) return {}; if (!config) return {};
if (level === "camera" && cameraName) { if (rawSectionValue === undefined || rawSectionValue === null) {
return get(config.cameras?.[cameraName], sectionPath) || {}; return {};
} }
return get(config, sectionPath) || {}; return rawSectionValue;
}, [config, level, cameraName, sectionPath]); }, [config, rawSectionValue]);
const sanitizeSectionData = useCallback( const sanitizeSectionData = useCallback(
(data: ConfigSectionData) => { (data: ConfigSectionData) => {
@ -278,6 +288,47 @@ export function ConfigSection({
return applySchemaDefaults(sectionSchema, {}); return applySchemaDefaults(sectionSchema, {});
}, [sectionSchema]); }, [sectionSchema]);
const effectiveSchemaDefaults = useMemo(() => {
// Special-case: the server JSON Schema for the top-level `motion` global
// value is expressed as `anyOf` including `null` (default: null). The
// backend intentionally allows `motion` to be omitted/`null` so that an
// absent global motion config does not get merged into every camera's
// config. However, the form renderer materializes schema defaults into
// an object for display. That object-vs-null mismatch would cause our
// override-detection to see a difference on first render and show the
// false "Modified" badge.
//
// To avoid changing backend semantics we derive the effective defaults
// from the non-null anyOf branch for the `motion` global section and
// use those defaults as the comparison baseline in the UI. This ensures
// the displayed form and the comparison baseline match while leaving
// server behavior unchanged.
if (sectionPath !== "motion" || level !== "global" || !sectionSchema) {
return schemaDefaults;
}
const defaultsKeys = Object.keys(schemaDefaults);
if (defaultsKeys.length > 0) {
return schemaDefaults;
}
const anyOfSchemas = (sectionSchema as { anyOf?: unknown[] }).anyOf;
if (!Array.isArray(anyOfSchemas)) {
return schemaDefaults;
}
const motionSchema = anyOfSchemas.find(
(schema) =>
typeof schema === "object" && schema !== null && "properties" in schema,
);
if (!motionSchema || typeof motionSchema !== "object") {
return schemaDefaults;
}
return applySchemaDefaults(motionSchema as RJSFSchema, {});
}, [level, schemaDefaults, sectionPath, sectionSchema]);
// Clear pendingData whenever formData changes (e.g., from server refresh) // Clear pendingData whenever formData changes (e.g., from server refresh)
// 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)
@ -383,8 +434,25 @@ export function ConfigSection({
return; return;
} }
const sanitizedData = sanitizeSectionData(data as ConfigSectionData); const sanitizedData = sanitizeSectionData(data as ConfigSectionData);
const rawData = sanitizeSectionData(rawFormData as ConfigSectionData); // When the server-stored `rawSectionValue` for `motion` global is
const overrides = buildOverrides(sanitizedData, rawData, schemaDefaults); // actually `null` we must preserve that `null` sentinel for base
// comparisons. `isMotionGlobal` signals that the stored value is
// intentionally null (meaning "no global override provided"), so the
// baseline used by `buildOverrides` should be `null` rather than an
// object. This keeps the UI from treating the form-populated default
// object as a user edit on initial render.
const isMotionGlobal =
sectionPath === "motion" &&
level === "global" &&
rawSectionValue === null;
const rawData = isMotionGlobal
? null
: sanitizeSectionData(rawFormData as ConfigSectionData);
const overrides = buildOverrides(
sanitizedData,
rawData,
effectiveSchemaDefaults,
);
if (isInitializingRef.current && !pendingData) { if (isInitializingRef.current && !pendingData) {
isInitializingRef.current = false; isInitializingRef.current = false;
if (overrides === undefined) { if (overrides === undefined) {
@ -400,10 +468,13 @@ export function ConfigSection({
}, },
[ [
pendingData, pendingData,
level,
sectionPath,
rawSectionValue,
rawFormData, rawFormData,
sanitizeSectionData, sanitizeSectionData,
buildOverrides, buildOverrides,
schemaDefaults, effectiveSchemaDefaults,
setPendingData, setPendingData,
], ],
); );
@ -446,7 +517,11 @@ export function ConfigSection({
? `cameras.${cameraName}.${sectionPath}` ? `cameras.${cameraName}.${sectionPath}`
: sectionPath; : sectionPath;
const rawData = sanitizeSectionData(rawFormData); const rawData = sanitizeSectionData(rawFormData);
const overrides = buildOverrides(pendingData, rawData, schemaDefaults); const overrides = buildOverrides(
pendingData,
rawData,
effectiveSchemaDefaults,
);
if (!overrides || Object.keys(overrides).length === 0) { if (!overrides || Object.keys(overrides).length === 0) {
setPendingData(null); setPendingData(null);
@ -531,7 +606,7 @@ export function ConfigSection({
rawFormData, rawFormData,
sanitizeSectionData, sanitizeSectionData,
buildOverrides, buildOverrides,
schemaDefaults, effectiveSchemaDefaults,
updateTopic, updateTopic,
setPendingData, setPendingData,
requiresRestartForOverrides, requiresRestartForOverrides,
@ -547,7 +622,7 @@ export function ConfigSection({
? `cameras.${cameraName}.${sectionPath}` ? `cameras.${cameraName}.${sectionPath}`
: sectionPath; : sectionPath;
const configData = level === "global" ? schemaDefaults : ""; const configData = level === "global" ? effectiveSchemaDefaults : "";
await axios.put("config/sett", { await axios.put("config/sett", {
requires_restart: requiresRestart ? 0 : 1, requires_restart: requiresRestart ? 0 : 1,
@ -590,7 +665,7 @@ export function ConfigSection({
); );
} }
}, [ }, [
schemaDefaults, effectiveSchemaDefaults,
sectionPath, sectionPath,
level, level,
cameraName, cameraName,