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, RendererComponent,
} from "@/components/config-form/sectionExtras/registry"; } from "@/components/config-form/sectionExtras/registry";
import { ConfigForm } from "../ConfigForm"; 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 { getSectionValidation } from "../section-validations";
import { import {
useConfigOverride, useConfigOverride,
@ -225,6 +229,13 @@ export function ConfigSection({
// Get section schema using cached hook // Get section schema using cached hook
const sectionSchema = useSectionSchema(sectionPath, level); 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 // Get override status
const { isOverridden, globalValue, cameraValue } = useConfigOverride({ const { isOverridden, globalValue, cameraValue } = useConfigOverride({
config, config,
@ -275,59 +286,31 @@ export function ConfigSection({
); );
const formData = useMemo(() => { const formData = useMemo(() => {
const baseData = sectionSchema const baseData = modifiedSchema
? applySchemaDefaults(sectionSchema, rawFormData) ? applySchemaDefaults(modifiedSchema, rawFormData)
: rawFormData; : rawFormData;
return sanitizeSectionData(baseData); return sanitizeSectionData(baseData);
}, [rawFormData, sectionSchema, sanitizeSectionData]); }, [rawFormData, modifiedSchema, sanitizeSectionData]);
const schemaDefaults = useMemo(() => { const schemaDefaults = useMemo(() => {
if (!sectionSchema) { if (!modifiedSchema) {
return {}; return {};
} }
return applySchemaDefaults(sectionSchema, {}); return applySchemaDefaults(modifiedSchema, {});
}, [sectionSchema]); }, [modifiedSchema]);
const effectiveSchemaDefaults = useMemo(() => { // Get effective defaults, handling special cases where schema defaults
// Special-case: the server JSON Schema for the top-level `motion` global // don't match semantic intent
// value is expressed as `anyOf` including `null` (default: null). The const effectiveSchemaDefaults = useMemo(
// backend intentionally allows `motion` to be omitted/`null` so that an () =>
// absent global motion config does not get merged into every camera's getEffectiveDefaultsForSection(
// config. However, the form renderer materializes schema defaults into sectionPath,
// an object for display. That object-vs-null mismatch would cause our level,
// override-detection to see a difference on first render and show the modifiedSchema,
// false "Modified" badge. schemaDefaults,
// ),
// To avoid changing backend semantics we derive the effective defaults [level, schemaDefaults, sectionPath, modifiedSchema],
// 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
@ -705,7 +688,7 @@ export function ConfigSection({
); );
}, [sectionConfig.customValidate, sectionValidation]); }, [sectionConfig.customValidate, sectionValidation]);
if (!sectionSchema) { if (!modifiedSchema) {
return null; return null;
} }
@ -734,7 +717,7 @@ export function ConfigSection({
<div className="space-y-6"> <div className="space-y-6">
<ConfigForm <ConfigForm
key={formKey} key={formKey}
schema={sectionSchema} schema={modifiedSchema}
formData={pendingData || formData} formData={pendingData || formData}
onChange={handleChange} onChange={handleChange}
fieldOrder={sectionConfig.fieldOrder} 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;
}