From 73ae2db1a581c2fa9c318cb82485dc78408d4987 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sat, 24 Jan 2026 11:59:55 -0600 Subject: [PATCH] fix nullable schema entries --- .../config-form/sections/BaseSection.tsx | 90 +++++++++++- .../theme/templates/SubmitButton.tsx | 6 + web/src/lib/config-schema/transformer.ts | 133 +++++++++++++++++- web/src/views/settings/GlobalConfigView.tsx | 1 - 4 files changed, 223 insertions(+), 7 deletions(-) diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index 78791e4c1..ff688d4c3 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -168,6 +168,78 @@ export function createConfigSection({ return sanitizeSectionData(baseData); }, [rawFormData, sectionSchema, sanitizeSectionData]); + const schemaDefaults = useMemo(() => { + if (!sectionSchema) { + return {}; + } + return applySchemaDefaults(sectionSchema, {}); + }, [sectionSchema]); + + const buildOverrides = useCallback( + ( + current: unknown, + base: unknown, + defaults: unknown, + ): unknown | undefined => { + if (current === null || current === undefined || current === "") { + return undefined; + } + + if (Array.isArray(current)) { + if ( + (base === undefined && + defaults !== undefined && + isEqual(current, defaults)) || + isEqual(current, base) + ) { + return undefined; + } + return current; + } + + if (typeof current === "object") { + const currentObj = current as Record; + const baseObj = + base && typeof base === "object" + ? (base as Record) + : undefined; + const defaultsObj = + defaults && typeof defaults === "object" + ? (defaults as Record) + : undefined; + + const result: Record = {}; + for (const [key, value] of Object.entries(currentObj)) { + const overrideValue = buildOverrides( + value, + baseObj ? baseObj[key] : undefined, + defaultsObj ? defaultsObj[key] : undefined, + ); + if (overrideValue !== undefined) { + result[key] = overrideValue; + } + } + + return Object.keys(result).length > 0 ? result : undefined; + } + + if ( + base === undefined && + defaults !== undefined && + isEqual(current, defaults) + ) { + return undefined; + } + + if (isEqual(current, base)) { + return undefined; + } + + return current; + }, + [], + ); + // Track if there are unsaved changes const hasChanges = useMemo(() => { if (!pendingData) return false; @@ -198,10 +270,18 @@ export function createConfigSection({ ? `cameras.${cameraName}.${sectionPath}` : sectionPath; + const rawData = sanitizeSectionData(rawFormData); + const overrides = buildOverrides(pendingData, rawData, schemaDefaults); + + if (!overrides || Object.keys(overrides).length === 0) { + setPendingData(null); + return; + } + await axios.put("config/set", { - requires_restart: requiresRestart ? 1 : 0, + requires_restart: requiresRestart ? 0 : 1, config_data: { - [basePath]: pendingData, + [basePath]: overrides, }, }); @@ -261,6 +341,10 @@ export function createConfigSection({ t, refreshConfig, onSave, + rawFormData, + sanitizeSectionData, + buildOverrides, + schemaDefaults, ]); // Handle reset to global - removes camera-level override by deleting the section @@ -272,7 +356,7 @@ export function createConfigSection({ // Send empty string to delete the key from config (see update_yaml in backend) await axios.put("config/set", { - requires_restart: requiresRestart ? 1 : 0, + requires_restart: requiresRestart ? 0 : 1, config_data: { [basePath]: "", }, diff --git a/web/src/components/config-form/theme/templates/SubmitButton.tsx b/web/src/components/config-form/theme/templates/SubmitButton.tsx index d3812a97f..cc098e098 100644 --- a/web/src/components/config-form/theme/templates/SubmitButton.tsx +++ b/web/src/components/config-form/theme/templates/SubmitButton.tsx @@ -8,6 +8,12 @@ export function SubmitButton(props: SubmitButtonProps) { const { uiSchema } = props; const { t } = useTranslation(["common"]); + const shouldHide = uiSchema?.["ui:submitButtonOptions"]?.norender === true; + + if (shouldHide) { + return null; + } + const submitText = (uiSchema?.["ui:options"]?.submitText as string) || t("save", { ns: "common" }); diff --git a/web/src/lib/config-schema/transformer.ts b/web/src/lib/config-schema/transformer.ts index 00b7b1140..80fba2d5e 100644 --- a/web/src/lib/config-schema/transformer.ts +++ b/web/src/lib/config-schema/transformer.ts @@ -30,6 +30,128 @@ function isSchemaObject( return typeof schema === "object" && schema !== null; } +/** + * Normalizes nullable schemas by unwrapping anyOf/oneOf [Type, null] patterns. + * + * When Pydantic generates JSON Schema for optional fields (e.g., Optional[int]), + * it creates anyOf/oneOf unions like: [{ type: "integer", ... }, { type: "null" }] + * + * This causes RJSF to treat the field as a multi-schema field with a dropdown selector, + * which leads to the field disappearing when the value is cleared (becomes undefined/null). + * + * This function unwraps these simple nullable patterns to a single non-null schema, + * allowing fields to remain visible and functional even when empty. + * + * @example + * // Input: { anyOf: [{ type: "integer" }, { type: "null" }] } + * // Output: { type: "integer" } + * + * @example + * // Input: { oneOf: [{ type: "string" }, { type: "null" }] } + * // Output: { type: "string" } + */ +function normalizeNullableSchema(schema: RJSFSchema): RJSFSchema { + if (!isSchemaObject(schema)) { + return schema; + } + + const schemaObj = schema as Record; + + const anyOf = schemaObj.anyOf; + if (Array.isArray(anyOf)) { + const hasNull = anyOf.some( + (item) => + isSchemaObject(item) && + (item as Record).type === "null", + ); + const nonNull = anyOf.find( + (item) => + isSchemaObject(item) && + (item as Record).type !== "null", + ) as RJSFSchema | undefined; + + if (hasNull && nonNull && anyOf.length === 2) { + const { anyOf: _anyOf, oneOf: _oneOf, ...rest } = schemaObj; + return normalizeNullableSchema({ ...nonNull, ...rest } as RJSFSchema); + } + + return { + ...schemaObj, + anyOf: anyOf + .filter(isSchemaObject) + .map((item) => normalizeNullableSchema(item as RJSFSchema)), + } as RJSFSchema; + } + + const oneOf = schemaObj.oneOf; + if (Array.isArray(oneOf)) { + const hasNull = oneOf.some( + (item) => + isSchemaObject(item) && + (item as Record).type === "null", + ); + const nonNull = oneOf.find( + (item) => + isSchemaObject(item) && + (item as Record).type !== "null", + ) as RJSFSchema | undefined; + + if (hasNull && nonNull && oneOf.length === 2) { + const { anyOf: _anyOf, oneOf: _oneOf, ...rest } = schemaObj; + return normalizeNullableSchema({ ...nonNull, ...rest } as RJSFSchema); + } + + return { + ...schemaObj, + oneOf: oneOf + .filter(isSchemaObject) + .map((item) => normalizeNullableSchema(item as RJSFSchema)), + } as RJSFSchema; + } + + if (isSchemaObject(schemaObj.properties)) { + const normalizedProps: Record = {}; + for (const [key, prop] of Object.entries( + schemaObj.properties as Record, + )) { + if (isSchemaObject(prop)) { + normalizedProps[key] = normalizeNullableSchema(prop as RJSFSchema); + } + } + return { ...schemaObj, properties: normalizedProps } as RJSFSchema; + } + + if (schemaObj.items) { + if (Array.isArray(schemaObj.items)) { + return { + ...schemaObj, + items: schemaObj.items + .filter(isSchemaObject) + .map((item) => normalizeNullableSchema(item as RJSFSchema)), + } as RJSFSchema; + } else if (isSchemaObject(schemaObj.items)) { + return { + ...schemaObj, + items: normalizeNullableSchema(schemaObj.items as RJSFSchema), + } as RJSFSchema; + } + } + + if ( + schemaObj.additionalProperties && + isSchemaObject(schemaObj.additionalProperties) + ) { + return { + ...schemaObj, + additionalProperties: normalizeNullableSchema( + schemaObj.additionalProperties as RJSFSchema, + ), + } as RJSFSchema; + } + + return schema; +} + /** * Resolves $ref references in a JSON Schema * This converts Pydantic's $defs-based schema to inline schemas @@ -343,12 +465,13 @@ export function transformSchema( ): TransformedSchema { // Resolve all $ref references and clean the result const cleanSchema = resolveAndCleanSchema(rawSchema); + const normalizedSchema = normalizeNullableSchema(cleanSchema); // Generate uiSchema - const uiSchema = generateUiSchema(cleanSchema, options); + const uiSchema = generateUiSchema(normalizedSchema, options); return { - schema: cleanSchema, + schema: normalizedSchema, uiSchema, }; } @@ -431,7 +554,11 @@ export function applySchemaDefaults( const propSchema = prop as Record; - if (result[key] === undefined && propSchema.default !== undefined) { + if ( + result[key] === undefined && + propSchema.default !== undefined && + propSchema.default !== null + ) { result[key] = propSchema.default; } else if ( propSchema.type === "object" && diff --git a/web/src/views/settings/GlobalConfigView.tsx b/web/src/views/settings/GlobalConfigView.tsx index aa16a270a..1b61db40e 100644 --- a/web/src/views/settings/GlobalConfigView.tsx +++ b/web/src/views/settings/GlobalConfigView.tsx @@ -215,7 +215,6 @@ const GlobalConfigSection = memo(function GlobalConfigSection({ setIsSaving(true); try { await axios.put("config/set", { - requires_restart: 1, config_data: { [sectionKey]: pendingData, },