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.
This commit is contained in:
Josh Hawkins 2026-03-11 10:35:19 -05:00
parent 379247dee6
commit cd58329796
2 changed files with 100 additions and 1 deletions

View File

@ -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<string, RJSFSchema>
| 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);

View File

@ -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.<each key>.mask`.
function unsetWithWildcard(
obj: Record<string, unknown>,
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<string, unknown>)) {
const fullPath = suffix ? `${key}.${suffix}` : key;
unsetWithWildcard(
parent as Record<string, unknown>,
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<string, unknown>, path);
});
return cleaned;
}