fix nullable fields

This commit is contained in:
Josh Hawkins 2026-01-24 09:42:59 -06:00
parent 6a664f4624
commit 223eb89dc4
4 changed files with 69 additions and 45 deletions

View File

@ -2,24 +2,19 @@
import type { StrictRJSFSchema } from "@rjsf/utils"; import type { StrictRJSFSchema } from "@rjsf/utils";
/** /**
* Checks if a schema is anyOf with exactly [PrimitiveType, null] * Checks if a schema is anyOf/oneOf with exactly [Type, null].
* where the primitive has no additional constraints * This indicates a nullable field in Pydantic schemas.
*/ */
export function isSimpleNullableField(schema: StrictRJSFSchema): boolean { export function isNullableUnionSchema(schema: StrictRJSFSchema): boolean {
if ( const union = schema.anyOf ?? schema.oneOf;
!schema.anyOf || if (!union || !Array.isArray(union) || union.length !== 2) {
!Array.isArray(schema.anyOf) ||
schema.anyOf.length !== 2
) {
return false; return false;
} }
const items = schema.anyOf;
let hasNull = false; let hasNull = false;
let simpleType: StrictRJSFSchema | null = null; let nonNullCount = 0;
// eslint-disable-next-line no-restricted-syntax for (const item of union) {
for (const item of items) {
if (typeof item !== "object" || item === null) { if (typeof item !== "object" || item === null) {
return false; return false;
} }
@ -28,22 +23,19 @@ export function isSimpleNullableField(schema: StrictRJSFSchema): boolean {
if (itemSchema.type === "null") { if (itemSchema.type === "null") {
hasNull = true; hasNull = true;
} else if ( } else {
itemSchema.type && nonNullCount += 1;
!("$ref" in itemSchema) &&
!("additionalProperties" in itemSchema) &&
!("items" in itemSchema) &&
!("pattern" in itemSchema) &&
!("minimum" in itemSchema) &&
!("maximum" in itemSchema) &&
!("exclusiveMinimum" in itemSchema) &&
!("exclusiveMaximum" in itemSchema)
) {
simpleType = itemSchema;
} }
} }
return hasNull && simpleType !== null; return hasNull && nonNullCount === 1;
}
/**
* Backwards-compatible alias for nullable fields
*/
export function isSimpleNullableField(schema: StrictRJSFSchema): boolean {
return isNullableUnionSchema(schema);
} }
/** /**
@ -52,12 +44,13 @@ export function isSimpleNullableField(schema: StrictRJSFSchema): boolean {
export function getNonNullSchema( export function getNonNullSchema(
schema: StrictRJSFSchema, schema: StrictRJSFSchema,
): StrictRJSFSchema | null { ): StrictRJSFSchema | null {
if (!schema.anyOf || !Array.isArray(schema.anyOf)) { const union = schema.anyOf ?? schema.oneOf;
if (!union || !Array.isArray(union)) {
return null; return null;
} }
return ( return (
(schema.anyOf.find( (union.find(
(item) => (item) =>
typeof item === "object" && typeof item === "object" &&
item !== null && item !== null &&

View File

@ -1,8 +1,9 @@
// Field Template - wraps each form field with label and description // Field Template - wraps each form field with label and description
import type { FieldTemplateProps } from "@rjsf/utils"; import type { FieldTemplateProps, StrictRJSFSchema } from "@rjsf/utils";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { isNullableUnionSchema } from "../fields/nullableUtils";
/** /**
* Build the i18n translation key path for nested fields using the field path * Build the i18n translation key path for nested fields using the field path
@ -47,6 +48,8 @@ export function FieldTemplate(props: FieldTemplateProps) {
// Boolean fields (switches) render label inline // Boolean fields (switches) render label inline
const isBoolean = schema.type === "boolean"; const isBoolean = schema.type === "boolean";
const isNullableUnion = isNullableUnionSchema(schema as StrictRJSFSchema);
// Get translation path for this field // Get translation path for this field
const translationPath = buildTranslationPath(fieldPathId.path); const translationPath = buildTranslationPath(fieldPathId.path);
@ -99,8 +102,9 @@ export function FieldTemplate(props: FieldTemplateProps) {
isAdvanced && "border-l-2 border-muted pl-4", isAdvanced && "border-l-2 border-muted pl-4",
isBoolean && "flex items-center justify-between gap-4", isBoolean && "flex items-center justify-between gap-4",
)} )}
data-field-id={translationPath}
> >
{displayLabel && finalLabel && !isBoolean && ( {displayLabel && finalLabel && !isBoolean && !isNullableUnion && (
<Label <Label
htmlFor={id} htmlFor={id}
className={cn( className={cn(
@ -122,7 +126,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
{required && <span className="ml-1 text-destructive">*</span>} {required && <span className="ml-1 text-destructive">*</span>}
</Label> </Label>
)} )}
{finalDescription && ( {finalDescription && !isNullableUnion && (
<p className="max-w-md text-sm text-muted-foreground"> <p className="max-w-md text-sm text-muted-foreground">
{String(finalDescription)} {String(finalDescription)}
</p> </p>
@ -132,10 +136,8 @@ export function FieldTemplate(props: FieldTemplateProps) {
</div> </div>
) : ( ) : (
<> <>
{finalDescription && ( {finalDescription && !isNullableUnion && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">{finalDescription}</p>
{String(finalDescription)}
</p>
)} )}
{children} {children}
</> </>

View File

@ -6,7 +6,7 @@ import type {
StrictRJSFSchema, StrictRJSFSchema,
FormContextType, FormContextType,
} from "@rjsf/utils"; } from "@rjsf/utils";
import { isSimpleNullableField } from "../fields/nullableUtils"; import { isNullableUnionSchema } from "../fields/nullableUtils";
/** /**
* Custom MultiSchemaFieldTemplate that: * Custom MultiSchemaFieldTemplate that:
@ -23,7 +23,7 @@ export function MultiSchemaFieldTemplate<
const { schema, selector, optionSchemaField } = props; const { schema, selector, optionSchemaField } = props;
// Check if this is a simple nullable field that should be handled specially // Check if this is a simple nullable field that should be handled specially
if (isSimpleNullableField(schema)) { if (isNullableUnionSchema(schema)) {
// For simple nullable fields, just render the field directly without the dropdown selector // For simple nullable fields, just render the field directly without the dropdown selector
// This handles the case where empty input = null // This handles the case where empty input = null
return <>{optionSchemaField}</>; return <>{optionSchemaField}</>;

View File

@ -4,6 +4,32 @@ import isEqual from "lodash/isEqual";
import get from "lodash/get"; import get from "lodash/get";
import type { FrigateConfig } from "@/types/frigateConfig"; import type { FrigateConfig } from "@/types/frigateConfig";
const INTERNAL_FIELD_SUFFIXES = ["enabled_in_config", "raw_mask"];
function stripInternalFields(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(stripInternalFields);
}
if (value && typeof value === "object") {
const obj = value as Record<string, unknown>;
const cleaned: Record<string, unknown> = {};
for (const [key, val] of Object.entries(obj)) {
if (INTERNAL_FIELD_SUFFIXES.some((suffix) => key.endsWith(suffix))) {
continue;
}
cleaned[key] = stripInternalFields(val);
}
return cleaned;
}
return value;
}
export function normalizeConfigValue(value: unknown): unknown {
return stripInternalFields(value);
}
export interface OverrideStatus { export interface OverrideStatus {
/** Whether the field is overridden from global */ /** Whether the field is overridden from global */
isOverridden: boolean; isOverridden: boolean;
@ -98,15 +124,18 @@ export function useConfigOverride({
const cameraValue = get(cameraConfig, sectionPath); const cameraValue = get(cameraConfig, sectionPath);
const normalizedGlobalValue = normalizeConfigValue(globalValue);
const normalizedCameraValue = normalizeConfigValue(cameraValue);
// Check if the entire section is overridden // Check if the entire section is overridden
const isOverridden = !isEqual(globalValue, cameraValue); const isOverridden = !isEqual(normalizedGlobalValue, normalizedCameraValue);
/** /**
* Get override status for a specific field within the section * Get override status for a specific field within the section
*/ */
const getFieldOverride = (fieldPath: string): OverrideStatus => { const getFieldOverride = (fieldPath: string): OverrideStatus => {
const globalFieldValue = get(globalValue, fieldPath); const globalFieldValue = get(normalizedGlobalValue, fieldPath);
const cameraFieldValue = get(cameraValue, fieldPath); const cameraFieldValue = get(normalizedCameraValue, fieldPath);
return { return {
isOverridden: !isEqual(globalFieldValue, cameraFieldValue), isOverridden: !isEqual(globalFieldValue, cameraFieldValue),
@ -120,15 +149,15 @@ export function useConfigOverride({
*/ */
const resetToGlobal = (fieldPath?: string) => { const resetToGlobal = (fieldPath?: string) => {
if (fieldPath) { if (fieldPath) {
return get(globalValue, fieldPath); return get(normalizedGlobalValue, fieldPath);
} }
return globalValue; return normalizedGlobalValue;
}; };
return { return {
isOverridden, isOverridden,
globalValue, globalValue: normalizedGlobalValue,
cameraValue, cameraValue: normalizedCameraValue,
getFieldOverride, getFieldOverride,
resetToGlobal, resetToGlobal,
}; };
@ -169,8 +198,8 @@ export function useAllCameraOverrides(
]; ];
for (const section of sectionsToCheck) { for (const section of sectionsToCheck) {
const globalValue = get(config, section); const globalValue = normalizeConfigValue(get(config, section));
const cameraValue = get(cameraConfig, section); const cameraValue = normalizeConfigValue(get(cameraConfig, section));
if (!isEqual(globalValue, cameraValue)) { if (!isEqual(globalValue, cameraValue)) {
overriddenSections.push(section); overriddenSections.push(section);