add section form special cases

This commit is contained in:
Josh Hawkins 2026-02-02 22:03:24 -06:00
parent f886846253
commit f82a721477
2 changed files with 136 additions and 49 deletions

View File

@ -10,7 +10,11 @@ import sectionRenderers, {
RendererComponent,
} from "@/components/config-form/sectionExtras/registry";
import { ConfigForm } from "../ConfigForm";
import type { FormValidation, RJSFSchema, UiSchema } from "@rjsf/utils";
import type { FormValidation, UiSchema } from "@rjsf/utils";
import {
modifySchemaForSection,
getEffectiveDefaultsForSection,
} from "./section-special-cases";
import { getSectionValidation } from "../section-validations";
import {
useConfigOverride,
@ -225,6 +229,13 @@ export function ConfigSection({
// Get section schema using cached hook
const sectionSchema = useSectionSchema(sectionPath, level);
// Apply special case handling for sections with problematic schema defaults
const modifiedSchema = useMemo(
() =>
modifySchemaForSection(sectionPath, level, sectionSchema ?? undefined),
[sectionPath, level, sectionSchema],
);
// Get override status
const { isOverridden, globalValue, cameraValue } = useConfigOverride({
config,
@ -275,59 +286,31 @@ export function ConfigSection({
);
const formData = useMemo(() => {
const baseData = sectionSchema
? applySchemaDefaults(sectionSchema, rawFormData)
const baseData = modifiedSchema
? applySchemaDefaults(modifiedSchema, rawFormData)
: rawFormData;
return sanitizeSectionData(baseData);
}, [rawFormData, sectionSchema, sanitizeSectionData]);
}, [rawFormData, modifiedSchema, sanitizeSectionData]);
const schemaDefaults = useMemo(() => {
if (!sectionSchema) {
if (!modifiedSchema) {
return {};
}
return applySchemaDefaults(sectionSchema, {});
}, [sectionSchema]);
return applySchemaDefaults(modifiedSchema, {});
}, [modifiedSchema]);
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]);
// Get effective defaults, handling special cases where schema defaults
// don't match semantic intent
const effectiveSchemaDefaults = useMemo(
() =>
getEffectiveDefaultsForSection(
sectionPath,
level,
modifiedSchema,
schemaDefaults,
),
[level, schemaDefaults, sectionPath, modifiedSchema],
);
// Clear pendingData whenever formData changes (e.g., from server refresh)
// This prevents RJSF's initial onChange call from being treated as a user edit
@ -705,7 +688,7 @@ export function ConfigSection({
);
}, [sectionConfig.customValidate, sectionValidation]);
if (!sectionSchema) {
if (!modifiedSchema) {
return null;
}
@ -734,7 +717,7 @@ export function ConfigSection({
<div className="space-y-6">
<ConfigForm
key={formKey}
schema={sectionSchema}
schema={modifiedSchema}
formData={pendingData || formData}
onChange={handleChange}
fieldOrder={sectionConfig.fieldOrder}

View File

@ -0,0 +1,104 @@
/**
* Special case handling for config sections with schema/default issues.
*
* Some sections have schema patterns that cause false "Modified" indicators
* when navigating to them due to how defaults are applied. This utility
* centralizes the logic for detecting and handling these cases.
*/
import { RJSFSchema } from "@rjsf/utils";
import { applySchemaDefaults } from "@/lib/config-schema";
/**
* Sections that require special handling at the global level.
* Add new section paths here as needed.
*/
const SPECIAL_CASE_SECTIONS = ["motion", "detectors"] as const;
/**
* Check if a section requires special case handling.
*/
export function isSpecialCaseSection(
sectionPath: string,
level: string,
): boolean {
return (
level === "global" &&
SPECIAL_CASE_SECTIONS.includes(
sectionPath as (typeof SPECIAL_CASE_SECTIONS)[number],
)
);
}
/**
* Modify schema for sections that need defaults stripped or other modifications.
*
* - detectors: Strip the "default" field to prevent RJSF from merging the
* default {"cpu": {"type": "cpu"}} with stored detector keys.
*/
export function modifySchemaForSection(
sectionPath: string,
level: string,
schema: RJSFSchema | undefined,
): RJSFSchema | undefined {
if (!schema || !isSpecialCaseSection(sectionPath, level)) {
return schema;
}
// detectors: Remove default to prevent merging with stored keys
if (sectionPath === "detectors" && "default" in schema) {
const { default: _, ...schemaWithoutDefault } = schema;
return schemaWithoutDefault;
}
return schema;
}
/**
* Get effective defaults for sections with special schema patterns.
*
* - motion: Has anyOf schema with [null, MotionConfig]. When stored value is
* null, derive defaults from the non-null anyOf branch to avoid showing
* changes when navigating to the page.
* - detectors: Return empty object since the schema default would add unwanted
* keys to the stored configuration.
*/
export function getEffectiveDefaultsForSection(
sectionPath: string,
level: string,
schema: RJSFSchema | undefined,
schemaDefaults: unknown,
): unknown {
if (!isSpecialCaseSection(sectionPath, level) || !schema) {
return schemaDefaults;
}
// motion: Derive defaults from non-null anyOf branch
if (sectionPath === "motion") {
const anyOfSchemas = (schema as { anyOf?: unknown[] }).anyOf;
if (!anyOfSchemas || !Array.isArray(anyOfSchemas)) {
return schemaDefaults;
}
// Find the non-null motion config schema
const motionSchema = anyOfSchemas.find(
(s) =>
typeof s === "object" &&
s !== null &&
(s as { type?: string }).type !== "null",
);
if (!motionSchema) {
return schemaDefaults;
}
return applySchemaDefaults(motionSchema as RJSFSchema, {});
}
// detectors: Return empty object to avoid adding default keys
if (sectionPath === "detectors") {
return {};
}
return schemaDefaults;
}