fix validation for schema objects that can be null

This commit is contained in:
Josh Hawkins 2026-01-25 17:44:47 -06:00
parent 55c6c50c97
commit f7cc87e8ce
4 changed files with 78 additions and 13 deletions

View File

@ -16,6 +16,10 @@ export function TextWidget(props: WidgetProps) {
options,
} = props;
const isNullable = Array.isArray(schema.type)
? schema.type.includes("null")
: false;
return (
<Input
id={id}
@ -24,7 +28,13 @@ export function TextWidget(props: WidgetProps) {
disabled={disabled || readonly}
placeholder={placeholder || (options.placeholder as string) || ""}
onChange={(e) =>
onChange(e.target.value === "" ? undefined : e.target.value)
onChange(
e.target.value === ""
? isNullable
? null
: undefined
: e.target.value,
)
}
onBlur={(e) => onBlur(id, e.target.value)}
onFocus={(e) => onFocus(id, e.target.value)}

View File

@ -16,6 +16,10 @@ export function TextareaWidget(props: WidgetProps) {
options,
} = props;
const isNullable = Array.isArray(schema.type)
? schema.type.includes("null")
: false;
return (
<Textarea
id={id}
@ -24,7 +28,13 @@ export function TextareaWidget(props: WidgetProps) {
placeholder={placeholder || (options.placeholder as string) || ""}
rows={(options.rows as number) || 3}
onChange={(e) =>
onChange(e.target.value === "" ? undefined : e.target.value)
onChange(
e.target.value === ""
? isNullable
? null
: undefined
: e.target.value,
)
}
onBlur={(e) => onBlur(id, e.target.value)}
onFocus={(e) => onFocus(id, e.target.value)}

View File

@ -30,6 +30,14 @@ function isSchemaObject(
return typeof schema === "object" && schema !== null;
}
function schemaHasType(schema: Record<string, unknown>, type: string): boolean {
const schemaType = schema.type;
if (Array.isArray(schemaType)) {
return schemaType.includes(type);
}
return schemaType === type;
}
/**
* Normalizes nullable schemas by unwrapping anyOf/oneOf [Type, null] patterns.
*
@ -57,6 +65,15 @@ function normalizeNullableSchema(schema: RJSFSchema): RJSFSchema {
const schemaObj = schema as Record<string, unknown>;
if (
schemaObj.default === null &&
schemaObj.type &&
!Array.isArray(schemaObj.type) &&
schemaObj.type !== "null"
) {
schemaObj.type = [schemaObj.type, "null"];
}
const anyOf = schemaObj.anyOf;
if (Array.isArray(anyOf)) {
const hasNull = anyOf.some(
@ -71,8 +88,20 @@ function normalizeNullableSchema(schema: RJSFSchema): RJSFSchema {
) as RJSFSchema | undefined;
if (hasNull && nonNull && anyOf.length === 2) {
const normalizedNonNull = normalizeNullableSchema(nonNull as RJSFSchema);
const normalizedNonNullObj = normalizedNonNull as Record<string, unknown>;
const nonNullType = normalizedNonNullObj.type;
const mergedType = Array.isArray(nonNullType)
? Array.from(new Set([...nonNullType, "null"]))
: nonNullType
? [nonNullType, "null"]
: ["null"];
const { anyOf: _anyOf, oneOf: _oneOf, ...rest } = schemaObj;
return normalizeNullableSchema({ ...nonNull, ...rest } as RJSFSchema);
return {
...rest,
...normalizedNonNullObj,
type: mergedType,
} as RJSFSchema;
}
return {
@ -97,8 +126,20 @@ function normalizeNullableSchema(schema: RJSFSchema): RJSFSchema {
) as RJSFSchema | undefined;
if (hasNull && nonNull && oneOf.length === 2) {
const normalizedNonNull = normalizeNullableSchema(nonNull as RJSFSchema);
const normalizedNonNullObj = normalizedNonNull as Record<string, unknown>;
const nonNullType = normalizedNonNullObj.type;
const mergedType = Array.isArray(nonNullType)
? Array.from(new Set([...nonNullType, "null"]))
: nonNullType
? [nonNullType, "null"]
: ["null"];
const { anyOf: _anyOf, oneOf: _oneOf, ...rest } = schemaObj;
return normalizeNullableSchema({ ...nonNull, ...rest } as RJSFSchema);
return {
...rest,
...normalizedNonNullObj,
type: mergedType,
} as RJSFSchema;
}
return {
@ -324,7 +365,7 @@ function getWidgetForField(
// Color fields
if (
fieldName.toLowerCase().includes("color") &&
schemaObj.type === "object"
schemaHasType(schemaObj, "object")
) {
return "color";
}
@ -335,13 +376,14 @@ function getWidgetForField(
}
// Boolean fields get switch widget
if (schemaObj.type === "boolean") {
if (schemaHasType(schemaObj, "boolean")) {
return "switch";
}
// Number with range gets slider
if (
(schemaObj.type === "number" || schemaObj.type === "integer") &&
(schemaHasType(schemaObj, "number") ||
schemaHasType(schemaObj, "integer")) &&
schemaObj.minimum !== undefined &&
schemaObj.maximum !== undefined
) {
@ -350,7 +392,7 @@ function getWidgetForField(
// Array of strings gets tags widget
if (
schemaObj.type === "array" &&
schemaHasType(schemaObj, "array") &&
isSchemaObject(schemaObj.items) &&
(schemaObj.items as Record<string, unknown>).type === "string"
) {
@ -426,7 +468,10 @@ function generateUiSchema(
}
// Handle nested objects recursively
if (fSchema.type === "object" && isSchemaObject(fSchema.properties)) {
if (
schemaHasType(fSchema, "object") &&
isSchemaObject(fSchema.properties)
) {
const nestedOptions: UiSchemaOptions = {
hiddenFields: hiddenFields
.filter((f) => f.startsWith(`${fieldName}.`))
@ -561,7 +606,7 @@ export function applySchemaDefaults(
) {
result[key] = propSchema.default;
} else if (
propSchema.type === "object" &&
schemaHasType(propSchema, "object") &&
isSchemaObject(propSchema.properties) &&
result[key] !== undefined
) {

View File

@ -1,7 +1,7 @@
// Global Configuration View
// Main view for configuring global Frigate settings
import { useMemo, useCallback, useState, useEffect, memo, useRef } from "react";
import { useMemo, useCallback, useState, useEffect, useRef } from "react";
import useSWR from "swr";
import axios from "axios";
import { toast } from "sonner";
@ -386,7 +386,7 @@ interface GlobalConfigSectionProps {
title: string;
}
const GlobalConfigSection = memo(function GlobalConfigSection({
function GlobalConfigSection({
sectionKey,
schema,
config,
@ -546,7 +546,7 @@ const GlobalConfigSection = memo(function GlobalConfigSection({
</div>
</div>
);
});
}
export default function GlobalConfigView() {
const { t } = useTranslation([