From cd5832979697d07fcacd75291214f0ec67dfa264 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 11 Mar 2026 10:35:19 -0500 Subject: [PATCH] fix hidden field validation errors caused by lodash wildcard and schema gaps lodash unset does not support wildcard (*) segments, so hidden fields like filters.*.mask were never stripped from form data, leaving null raw_coordinates that fail RJSF anyOf validation. Add unsetWithWildcard helper and also strip hidden fields from the JSON schema itself as defense-in-depth. --- web/src/lib/config-schema/transformer.ts | 71 ++++++++++++++++++++++++ web/src/utils/configUtil.ts | 30 +++++++++- 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/web/src/lib/config-schema/transformer.ts b/web/src/lib/config-schema/transformer.ts index cafdf4e0b..34dd7e45d 100644 --- a/web/src/lib/config-schema/transformer.ts +++ b/web/src/lib/config-schema/transformer.ts @@ -538,6 +538,72 @@ function generateUiSchema( return uiSchema; } +/** + * Removes hidden field properties from the JSON schema itself so RJSF won't + * validate them. The existing ui:widget=hidden approach only hides rendering + * but still validates — fields with server-only values (e.g. raw_coordinates + * serialized as null) cause spurious validation errors. + * + * Supports dotted paths ("mask"), nested paths ("genai.enabled_in_config"), + * and wildcard segments ("filters.*.mask") where `*` matches + * additionalProperties. + */ +function stripHiddenFieldsFromSchema( + schema: RJSFSchema, + hiddenFields: string[], +): void { + for (const pattern of hiddenFields) { + if (!pattern) continue; + const segments = pattern.split("."); + removePropertyBySegments(schema, segments); + } +} + +function removePropertyBySegments( + schema: RJSFSchema, + segments: string[], +): void { + if (segments.length === 0 || !isSchemaObject(schema)) return; + + const [head, ...rest] = segments; + const props = schema.properties as + | Record + | undefined; + + if (rest.length === 0) { + // Terminal segment — delete the property + if (head === "*") { + // Wildcard at leaf: strip from additionalProperties + if (isSchemaObject(schema.additionalProperties)) { + // Nothing to delete — "*" as the last segment means "every dynamic key". + // The parent's additionalProperties schema IS the dynamic value, not a + // container. In practice hidden-field patterns always have a named leaf + // after the wildcard (e.g. "filters.*.mask"), so this branch is a no-op. + } + } else if (props && head in props) { + delete props[head]; + if (Array.isArray(schema.required)) { + schema.required = (schema.required as string[]).filter( + (r) => r !== head, + ); + } + } + return; + } + + if (head === "*") { + // Wildcard segment — descend into additionalProperties + if (isSchemaObject(schema.additionalProperties)) { + removePropertyBySegments( + schema.additionalProperties as RJSFSchema, + rest, + ); + } + } else if (props && head in props && isSchemaObject(props[head])) { + removePropertyBySegments(props[head], rest); + } +} + /** * Transforms a Pydantic JSON Schema to RJSF format * Resolves references and generates appropriate uiSchema @@ -550,6 +616,11 @@ export function transformSchema( const cleanSchema = resolveAndCleanSchema(rawSchema); const normalizedSchema = normalizeNullableSchema(cleanSchema); + // Remove hidden fields from schema so RJSF won't validate them + if (options.hiddenFields && options.hiddenFields.length > 0) { + stripHiddenFieldsFromSchema(normalizedSchema, options.hiddenFields); + } + // Generate uiSchema const uiSchema = generateUiSchema(normalizedSchema, options); diff --git a/web/src/utils/configUtil.ts b/web/src/utils/configUtil.ts index 5320fca69..0932733e1 100644 --- a/web/src/utils/configUtil.ts +++ b/web/src/utils/configUtil.ts @@ -77,6 +77,8 @@ export const PROFILE_ELIGIBLE_SECTIONS = new Set([ "audio", "birdseye", "detect", + "face_recognition", + "lpr", "motion", "notifications", "objects", @@ -204,6 +206,32 @@ export function buildOverrides( // Normalize raw config data (strip internal fields) and remove any paths // listed in `hiddenFields` so they are not included in override computation. +// lodash `unset` treats `*` as a literal key. This helper expands wildcard +// segments so that e.g. `"filters.*.mask"` unsets `filters..mask`. +function unsetWithWildcard( + obj: Record, + path: string, +): void { + if (!path.includes("*")) { + unset(obj, path); + return; + } + const segments = path.split("."); + const starIndex = segments.indexOf("*"); + const prefix = segments.slice(0, starIndex).join("."); + const suffix = segments.slice(starIndex + 1).join("."); + const parent = prefix ? get(obj, prefix) : obj; + if (parent && typeof parent === "object") { + for (const key of Object.keys(parent as Record)) { + const fullPath = suffix ? `${key}.${suffix}` : key; + unsetWithWildcard( + parent as Record, + fullPath, + ); + } + } +} + export function sanitizeSectionData( data: ConfigSectionData, hiddenFields?: string[], @@ -215,7 +243,7 @@ export function sanitizeSectionData( const cleaned = cloneDeep(normalized) as ConfigSectionData; hiddenFields.forEach((path) => { if (!path) return; - unset(cleaned, path); + unsetWithWildcard(cleaned as Record, path); }); return cleaned; }