mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-21 23:58:22 +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);
|
return sanitizeSectionData(baseData);
|
||||||
}, [rawFormData, sectionSchema, sanitizeSectionData]);
|
}, [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
|
// Track if there are unsaved changes
|
||||||
const hasChanges = useMemo(() => {
|
const hasChanges = useMemo(() => {
|
||||||
if (!pendingData) return false;
|
if (!pendingData) return false;
|
||||||
@ -198,10 +270,18 @@ export function createConfigSection({
|
|||||||
? `cameras.${cameraName}.${sectionPath}`
|
? `cameras.${cameraName}.${sectionPath}`
|
||||||
: 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", {
|
await axios.put("config/set", {
|
||||||
requires_restart: requiresRestart ? 1 : 0,
|
requires_restart: requiresRestart ? 0 : 1,
|
||||||
config_data: {
|
config_data: {
|
||||||
[basePath]: pendingData,
|
[basePath]: overrides,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -261,6 +341,10 @@ export function createConfigSection({
|
|||||||
t,
|
t,
|
||||||
refreshConfig,
|
refreshConfig,
|
||||||
onSave,
|
onSave,
|
||||||
|
rawFormData,
|
||||||
|
sanitizeSectionData,
|
||||||
|
buildOverrides,
|
||||||
|
schemaDefaults,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Handle reset to global - removes camera-level override by deleting the section
|
// 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)
|
// Send empty string to delete the key from config (see update_yaml in backend)
|
||||||
await axios.put("config/set", {
|
await axios.put("config/set", {
|
||||||
requires_restart: requiresRestart ? 1 : 0,
|
requires_restart: requiresRestart ? 0 : 1,
|
||||||
config_data: {
|
config_data: {
|
||||||
[basePath]: "",
|
[basePath]: "",
|
||||||
},
|
},
|
||||||
|
|||||||
@ -8,6 +8,12 @@ export function SubmitButton(props: SubmitButtonProps) {
|
|||||||
const { uiSchema } = props;
|
const { uiSchema } = props;
|
||||||
const { t } = useTranslation(["common"]);
|
const { t } = useTranslation(["common"]);
|
||||||
|
|
||||||
|
const shouldHide = uiSchema?.["ui:submitButtonOptions"]?.norender === true;
|
||||||
|
|
||||||
|
if (shouldHide) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const submitText =
|
const submitText =
|
||||||
(uiSchema?.["ui:options"]?.submitText as string) ||
|
(uiSchema?.["ui:options"]?.submitText as string) ||
|
||||||
t("save", { ns: "common" });
|
t("save", { ns: "common" });
|
||||||
|
|||||||
@ -30,6 +30,128 @@ function isSchemaObject(
|
|||||||
return typeof schema === "object" && schema !== null;
|
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
|
* Resolves $ref references in a JSON Schema
|
||||||
* This converts Pydantic's $defs-based schema to inline schemas
|
* This converts Pydantic's $defs-based schema to inline schemas
|
||||||
@ -343,12 +465,13 @@ export function transformSchema(
|
|||||||
): TransformedSchema {
|
): TransformedSchema {
|
||||||
// Resolve all $ref references and clean the result
|
// Resolve all $ref references and clean the result
|
||||||
const cleanSchema = resolveAndCleanSchema(rawSchema);
|
const cleanSchema = resolveAndCleanSchema(rawSchema);
|
||||||
|
const normalizedSchema = normalizeNullableSchema(cleanSchema);
|
||||||
|
|
||||||
// Generate uiSchema
|
// Generate uiSchema
|
||||||
const uiSchema = generateUiSchema(cleanSchema, options);
|
const uiSchema = generateUiSchema(normalizedSchema, options);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
schema: cleanSchema,
|
schema: normalizedSchema,
|
||||||
uiSchema,
|
uiSchema,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -431,7 +554,11 @@ export function applySchemaDefaults(
|
|||||||
|
|
||||||
const propSchema = prop as Record<string, unknown>;
|
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;
|
result[key] = propSchema.default;
|
||||||
} else if (
|
} else if (
|
||||||
propSchema.type === "object" &&
|
propSchema.type === "object" &&
|
||||||
|
|||||||
@ -215,7 +215,6 @@ const GlobalConfigSection = memo(function GlobalConfigSection({
|
|||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
await axios.put("config/set", {
|
await axios.put("config/set", {
|
||||||
requires_restart: 1,
|
|
||||||
config_data: {
|
config_data: {
|
||||||
[sectionKey]: pendingData,
|
[sectionKey]: pendingData,
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user