fix nullable schema entries

This commit is contained in:
Josh Hawkins 2026-01-24 11:59:55 -06:00
parent 72ab1f93b5
commit 73ae2db1a5
4 changed files with 223 additions and 7 deletions

View File

@ -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<string, unknown>;
const baseObj =
base && typeof base === "object"
? (base as Record<string, unknown>)
: undefined;
const defaultsObj =
defaults && typeof defaults === "object"
? (defaults as Record<string, unknown>)
: undefined;
const result: Record<string, unknown> = {};
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]: "",
},

View File

@ -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" });

View File

@ -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<string, unknown>;
const anyOf = schemaObj.anyOf;
if (Array.isArray(anyOf)) {
const hasNull = anyOf.some(
(item) =>
isSchemaObject(item) &&
(item as Record<string, unknown>).type === "null",
);
const nonNull = anyOf.find(
(item) =>
isSchemaObject(item) &&
(item as Record<string, unknown>).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<string, unknown>).type === "null",
);
const nonNull = oneOf.find(
(item) =>
isSchemaObject(item) &&
(item as Record<string, unknown>).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<string, RJSFSchema> = {};
for (const [key, prop] of Object.entries(
schemaObj.properties as Record<string, unknown>,
)) {
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<string, unknown>;
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" &&

View File

@ -215,7 +215,6 @@ const GlobalConfigSection = memo(function GlobalConfigSection({
setIsSaving(true);
try {
await axios.put("config/set", {
requires_restart: 1,
config_data: {
[sectionKey]: pendingData,
},