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

View File

@ -16,6 +16,10 @@ export function TextareaWidget(props: WidgetProps) {
options, options,
} = props; } = props;
const isNullable = Array.isArray(schema.type)
? schema.type.includes("null")
: false;
return ( return (
<Textarea <Textarea
id={id} id={id}
@ -24,7 +28,13 @@ export function TextareaWidget(props: WidgetProps) {
placeholder={placeholder || (options.placeholder as string) || ""} placeholder={placeholder || (options.placeholder as string) || ""}
rows={(options.rows as number) || 3} rows={(options.rows as number) || 3}
onChange={(e) => 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)} onBlur={(e) => onBlur(id, e.target.value)}
onFocus={(e) => onFocus(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; 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. * 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>; 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; const anyOf = schemaObj.anyOf;
if (Array.isArray(anyOf)) { if (Array.isArray(anyOf)) {
const hasNull = anyOf.some( const hasNull = anyOf.some(
@ -71,8 +88,20 @@ function normalizeNullableSchema(schema: RJSFSchema): RJSFSchema {
) as RJSFSchema | undefined; ) as RJSFSchema | undefined;
if (hasNull && nonNull && anyOf.length === 2) { 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; const { anyOf: _anyOf, oneOf: _oneOf, ...rest } = schemaObj;
return normalizeNullableSchema({ ...nonNull, ...rest } as RJSFSchema); return {
...rest,
...normalizedNonNullObj,
type: mergedType,
} as RJSFSchema;
} }
return { return {
@ -97,8 +126,20 @@ function normalizeNullableSchema(schema: RJSFSchema): RJSFSchema {
) as RJSFSchema | undefined; ) as RJSFSchema | undefined;
if (hasNull && nonNull && oneOf.length === 2) { 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; const { anyOf: _anyOf, oneOf: _oneOf, ...rest } = schemaObj;
return normalizeNullableSchema({ ...nonNull, ...rest } as RJSFSchema); return {
...rest,
...normalizedNonNullObj,
type: mergedType,
} as RJSFSchema;
} }
return { return {
@ -324,7 +365,7 @@ function getWidgetForField(
// Color fields // Color fields
if ( if (
fieldName.toLowerCase().includes("color") && fieldName.toLowerCase().includes("color") &&
schemaObj.type === "object" schemaHasType(schemaObj, "object")
) { ) {
return "color"; return "color";
} }
@ -335,13 +376,14 @@ function getWidgetForField(
} }
// Boolean fields get switch widget // Boolean fields get switch widget
if (schemaObj.type === "boolean") { if (schemaHasType(schemaObj, "boolean")) {
return "switch"; return "switch";
} }
// Number with range gets slider // Number with range gets slider
if ( if (
(schemaObj.type === "number" || schemaObj.type === "integer") && (schemaHasType(schemaObj, "number") ||
schemaHasType(schemaObj, "integer")) &&
schemaObj.minimum !== undefined && schemaObj.minimum !== undefined &&
schemaObj.maximum !== undefined schemaObj.maximum !== undefined
) { ) {
@ -350,7 +392,7 @@ function getWidgetForField(
// Array of strings gets tags widget // Array of strings gets tags widget
if ( if (
schemaObj.type === "array" && schemaHasType(schemaObj, "array") &&
isSchemaObject(schemaObj.items) && isSchemaObject(schemaObj.items) &&
(schemaObj.items as Record<string, unknown>).type === "string" (schemaObj.items as Record<string, unknown>).type === "string"
) { ) {
@ -426,7 +468,10 @@ function generateUiSchema(
} }
// Handle nested objects recursively // Handle nested objects recursively
if (fSchema.type === "object" && isSchemaObject(fSchema.properties)) { if (
schemaHasType(fSchema, "object") &&
isSchemaObject(fSchema.properties)
) {
const nestedOptions: UiSchemaOptions = { const nestedOptions: UiSchemaOptions = {
hiddenFields: hiddenFields hiddenFields: hiddenFields
.filter((f) => f.startsWith(`${fieldName}.`)) .filter((f) => f.startsWith(`${fieldName}.`))
@ -561,7 +606,7 @@ export function applySchemaDefaults(
) { ) {
result[key] = propSchema.default; result[key] = propSchema.default;
} else if ( } else if (
propSchema.type === "object" && schemaHasType(propSchema, "object") &&
isSchemaObject(propSchema.properties) && isSchemaObject(propSchema.properties) &&
result[key] !== undefined result[key] !== undefined
) { ) {

View File

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