mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-10 18:43:09 +03:00
fix nullable schema entries
This commit is contained in:
parent
72ab1f93b5
commit
73ae2db1a5
@ -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]: "",
|
||||
},
|
||||
|
||||
@ -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" });
|
||||
|
||||
@ -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" &&
|
||||
|
||||
@ -215,7 +215,6 @@ const GlobalConfigSection = memo(function GlobalConfigSection({
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await axios.put("config/set", {
|
||||
requires_restart: 1,
|
||||
config_data: {
|
||||
[sectionKey]: pendingData,
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user