improve typing

This commit is contained in:
Josh Hawkins 2026-01-30 11:41:55 -06:00
parent 4dc039072a
commit 8b7156438e
11 changed files with 169 additions and 114 deletions

View File

@ -9,10 +9,32 @@ import { createErrorTransformer } from "@/lib/config-schema/errorMessages";
import { useMemo, useCallback } from "react"; import { useMemo, useCallback } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { cn, mergeUiSchema } from "@/lib/utils"; import { cn, mergeUiSchema } from "@/lib/utils";
import type { ConfigFormContext } from "@/types/configForm";
// Runtime guard for object-like schema fragments type SchemaWithProperties = RJSFSchema & {
const isSchemaObject = (value: unknown): value is Record<string, unknown> => properties: Record<string, RJSFSchema>;
typeof value === "object" && value !== null; };
type SchemaWithAdditionalProperties = RJSFSchema & {
additionalProperties: RJSFSchema;
};
// Runtime guards for schema fragments
const hasSchemaProperties = (
schema: RJSFSchema,
): schema is SchemaWithProperties =>
typeof schema === "object" &&
schema !== null &&
typeof schema.properties === "object" &&
schema.properties !== null;
const hasSchemaAdditionalProperties = (
schema: RJSFSchema,
): schema is SchemaWithAdditionalProperties =>
typeof schema === "object" &&
schema !== null &&
typeof schema.additionalProperties === "object" &&
schema.additionalProperties !== null;
// Detects path-style uiSchema keys (e.g., "filters.*.mask") // Detects path-style uiSchema keys (e.g., "filters.*.mask")
const isPathKey = (key: string) => key.includes(".") || key.includes("*"); const isPathKey = (key: string) => key.includes(".") || key.includes("*");
@ -70,34 +92,31 @@ const applyUiSchemaPathOverrides = (
} }
const [segment, ...rest] = path; const [segment, ...rest] = path;
const schemaObj = targetSchema as Record<string, unknown>; const schemaObj = targetSchema;
if (segment === "*") { if (segment === "*") {
if (isSchemaObject(schemaObj.properties)) { if (hasSchemaProperties(schemaObj)) {
Object.entries(schemaObj.properties as Record<string, unknown>).forEach( Object.entries(schemaObj.properties).forEach(
([propertyName, propertySchema]) => { ([propertyName, propertySchema]) => {
if (!isSchemaObject(propertySchema)) {
return;
}
const existing = const existing =
(targetUi[propertyName] as UiSchema | undefined) || {}; (targetUi[propertyName] as UiSchema | undefined) || {};
targetUi[propertyName] = { ...existing }; targetUi[propertyName] = { ...existing };
applyOverride( applyOverride(
targetUi[propertyName] as UiSchema, targetUi[propertyName] as UiSchema,
propertySchema as RJSFSchema, propertySchema,
rest, rest,
value, value,
); );
}, },
); );
} else if (isSchemaObject(schemaObj.additionalProperties)) { } else if (hasSchemaAdditionalProperties(schemaObj)) {
// For dict schemas, apply override to additionalProperties // For dict schemas, apply override to additionalProperties
const existing = const existing =
(targetUi.additionalProperties as UiSchema | undefined) || {}; (targetUi.additionalProperties as UiSchema | undefined) || {};
targetUi.additionalProperties = { ...existing }; targetUi.additionalProperties = { ...existing };
applyOverride( applyOverride(
targetUi.additionalProperties as UiSchema, targetUi.additionalProperties as UiSchema,
schemaObj.additionalProperties as RJSFSchema, schemaObj.additionalProperties,
rest, rest,
value, value,
); );
@ -105,16 +124,14 @@ const applyUiSchemaPathOverrides = (
return; return;
} }
if (isSchemaObject(schemaObj.properties)) { if (hasSchemaProperties(schemaObj)) {
const propertySchema = (schemaObj.properties as Record<string, unknown>)[ const propertySchema = schemaObj.properties[segment];
segment if (propertySchema) {
];
if (isSchemaObject(propertySchema)) {
const existing = (targetUi[segment] as UiSchema | undefined) || {}; const existing = (targetUi[segment] as UiSchema | undefined) || {};
targetUi[segment] = { ...existing }; targetUi[segment] = { ...existing };
applyOverride( applyOverride(
targetUi[segment] as UiSchema, targetUi[segment] as UiSchema,
propertySchema as RJSFSchema, propertySchema,
rest, rest,
value, value,
); );
@ -162,7 +179,7 @@ export interface ConfigFormProps {
/** Live validation mode */ /** Live validation mode */
liveValidate?: boolean; liveValidate?: boolean;
/** Form context passed to all widgets */ /** Form context passed to all widgets */
formContext?: Record<string, unknown>; formContext?: ConfigFormContext;
/** i18n namespace for field labels */ /** i18n namespace for field labels */
i18nNamespace?: string; i18nNamespace?: string;
} }

View File

@ -33,6 +33,8 @@ import {
CollapsibleTrigger, CollapsibleTrigger,
} from "@/components/ui/collapsible"; } from "@/components/ui/collapsible";
import { applySchemaDefaults } from "@/lib/config-schema"; import { applySchemaDefaults } from "@/lib/config-schema";
import { isJsonObject } from "@/lib/utils";
import { ConfigSectionData, JsonObject, JsonValue } from "@/types/configForm";
export interface SectionConfig { export interface SectionConfig {
/** Field ordering within the section */ /** Field ordering within the section */
@ -130,10 +132,9 @@ export function createConfigSection({
}: BaseSectionProps) { }: BaseSectionProps) {
const { t } = useTranslation([i18nNamespace, "views/settings", "common"]); const { t } = useTranslation([i18nNamespace, "views/settings", "common"]);
const [isOpen, setIsOpen] = useState(!defaultCollapsed); const [isOpen, setIsOpen] = useState(!defaultCollapsed);
const [pendingData, setPendingData] = useState<Record< const [pendingData, setPendingData] = useState<ConfigSectionData | null>(
string, null,
unknown );
> | null>(null);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [formKey, setFormKey] = useState(0); const [formKey, setFormKey] = useState(0);
const isResettingRef = useRef(false); const isResettingRef = useRef(false);
@ -175,11 +176,8 @@ export function createConfigSection({
}, [config, level, cameraName]); }, [config, level, cameraName]);
const sanitizeSectionData = useCallback( const sanitizeSectionData = useCallback(
(data: Record<string, unknown>) => { (data: ConfigSectionData) => {
const normalized = normalizeConfigValue(data) as Record< const normalized = normalizeConfigValue(data) as ConfigSectionData;
string,
unknown
>;
if ( if (
!sectionConfig.hiddenFields || !sectionConfig.hiddenFields ||
sectionConfig.hiddenFields.length === 0 sectionConfig.hiddenFields.length === 0
@ -187,7 +185,7 @@ export function createConfigSection({
return normalized; return normalized;
} }
const cleaned = cloneDeep(normalized); const cleaned = cloneDeep(normalized) as ConfigSectionData;
sectionConfig.hiddenFields.forEach((path) => { sectionConfig.hiddenFields.forEach((path) => {
if (!path) return; if (!path) return;
unset(cleaned, path); unset(cleaned, path);
@ -245,18 +243,12 @@ export function createConfigSection({
return current; return current;
} }
if (typeof current === "object") { if (isJsonObject(current)) {
const currentObj = current as Record<string, unknown>; const currentObj = current;
const baseObj = const baseObj = isJsonObject(base) ? base : undefined;
base && typeof base === "object" const defaultsObj = isJsonObject(defaults) ? defaults : undefined;
? (base as Record<string, unknown>)
: undefined;
const defaultsObj =
defaults && typeof defaults === "object"
? (defaults as Record<string, unknown>)
: undefined;
const result: Record<string, unknown> = {}; const result: JsonObject = {};
for (const [key, value] of Object.entries(currentObj)) { for (const [key, value] of Object.entries(currentObj)) {
const overrideValue = buildOverrides( const overrideValue = buildOverrides(
value, value,
@ -264,7 +256,7 @@ export function createConfigSection({
defaultsObj ? defaultsObj[key] : undefined, defaultsObj ? defaultsObj[key] : undefined,
); );
if (overrideValue !== undefined) { if (overrideValue !== undefined) {
result[key] = overrideValue; result[key] = overrideValue as JsonValue;
} }
} }
@ -305,9 +297,7 @@ export function createConfigSection({
setPendingData(null); setPendingData(null);
return; return;
} }
const sanitizedData = sanitizeSectionData( const sanitizedData = sanitizeSectionData(data as ConfigSectionData);
data as Record<string, unknown>,
);
if (isEqual(formData, sanitizedData)) { if (isEqual(formData, sanitizedData)) {
setPendingData(null); setPendingData(null);
return; return;

View File

@ -1,5 +1,5 @@
// Field Template - wraps each form field with label and description // Field Template - wraps each form field with label and description
import type { FieldTemplateProps, StrictRJSFSchema } from "@rjsf/utils"; import { FieldTemplateProps, StrictRJSFSchema, UiSchema } from "@rjsf/utils";
import { import {
getTemplate, getTemplate,
getUiOptions, getUiOptions,
@ -10,6 +10,7 @@ import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { isNullableUnionSchema } from "../fields/nullableUtils"; import { isNullableUnionSchema } from "../fields/nullableUtils";
import { getTranslatedLabel } from "@/utils/i18n"; import { getTranslatedLabel } from "@/utils/i18n";
import { ConfigFormContext } from "@/types/configForm";
/** /**
* 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
@ -78,9 +79,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
} = props; } = props;
// Get i18n namespace from form context (passed through registry) // Get i18n namespace from form context (passed through registry)
const formContext = registry?.formContext as const formContext = registry?.formContext as ConfigFormContext | undefined;
| Record<string, unknown>
| undefined;
const i18nNamespace = formContext?.i18nNamespace as string | undefined; const i18nNamespace = formContext?.i18nNamespace as string | undefined;
const { t, i18n } = useTranslation([ const { t, i18n } = useTranslation([
i18nNamespace || "common", i18nNamespace || "common",
@ -103,7 +102,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
const isNullableUnion = isNullableUnionSchema(schema as StrictRJSFSchema); const isNullableUnion = isNullableUnionSchema(schema as StrictRJSFSchema);
const isAdditionalProperty = ADDITIONAL_PROPERTY_FLAG in schema; const isAdditionalProperty = ADDITIONAL_PROPERTY_FLAG in schema;
const suppressMultiSchema = const suppressMultiSchema =
(uiSchema?.["ui:options"] as Record<string, unknown> | undefined) (uiSchema?.["ui:options"] as UiSchema["ui:options"] | undefined)
?.suppressMultiSchema === true; ?.suppressMultiSchema === true;
// Only suppress labels/descriptions if this is a multi-schema field (anyOf/oneOf) with suppressMultiSchema flag // Only suppress labels/descriptions if this is a multi-schema field (anyOf/oneOf) with suppressMultiSchema flag
@ -122,12 +121,8 @@ export function FieldTemplate(props: FieldTemplateProps) {
: undefined; : undefined;
// Use schema title/description as primary source (from JSON Schema) // Use schema title/description as primary source (from JSON Schema)
const schemaTitle = (schema as Record<string, unknown>).title as const schemaTitle = schema.title;
| string const schemaDescription = schema.description;
| undefined;
const schemaDescription = (schema as Record<string, unknown>).description as
| string
| undefined;
// Try to get translated label, falling back to schema title, then RJSF label // Try to get translated label, falling back to schema title, then RJSF label
let finalLabel = label; let finalLabel = label;

View File

@ -1,10 +1,11 @@
// Custom MultiSchemaFieldTemplate to handle anyOf [Type, null] fields // Custom MultiSchemaFieldTemplate to handle anyOf [Type, null] fields
// Renders simple nullable types as single inputs instead of dropdowns // Renders simple nullable types as single inputs instead of dropdowns
import type { import {
MultiSchemaFieldTemplateProps, MultiSchemaFieldTemplateProps,
StrictRJSFSchema, StrictRJSFSchema,
FormContextType, FormContextType,
UiSchema,
} from "@rjsf/utils"; } from "@rjsf/utils";
import { isNullableUnionSchema } from "../fields/nullableUtils"; import { isNullableUnionSchema } from "../fields/nullableUtils";
@ -23,7 +24,7 @@ export function MultiSchemaFieldTemplate<
const { schema, selector, optionSchemaField, uiSchema } = props; const { schema, selector, optionSchemaField, uiSchema } = props;
const uiOptions = uiSchema?.["ui:options"] as const uiOptions = uiSchema?.["ui:options"] as
| Record<string, unknown> | UiSchema["ui:options"]
| undefined; | undefined;
const suppressMultiSchema = uiOptions?.suppressMultiSchema === true; const suppressMultiSchema = uiOptions?.suppressMultiSchema === true;

View File

@ -13,6 +13,7 @@ import { LuChevronDown, LuChevronRight, LuPlus } from "react-icons/lu";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { getTranslatedLabel } from "@/utils/i18n"; import { getTranslatedLabel } from "@/utils/i18n";
import { ConfigFormContext } from "@/types/configForm";
/** /**
* 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
@ -60,8 +61,7 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
disabled, disabled,
readonly, readonly,
} = props; } = props;
type FormContext = { i18nNamespace?: string }; const formContext = registry?.formContext as ConfigFormContext | undefined;
const formContext = registry?.formContext as FormContext | undefined;
// Check if this is a root-level object // Check if this is a root-level object
const isRoot = registry?.rootSchema === schema; const isRoot = registry?.rootSchema === schema;

View File

@ -1,9 +1,10 @@
// Object Label Switches Widget - For selecting objects via switches // Object Label Switches Widget - For selecting objects via switches
import type { WidgetProps } from "@rjsf/utils"; import { WidgetProps } from "@rjsf/utils";
import { SwitchesWidget } from "./SwitchesWidget"; import { SwitchesWidget } from "./SwitchesWidget";
import type { FormContext } from "./SwitchesWidget"; import { FormContext } from "./SwitchesWidget";
import { getTranslatedLabel } from "@/utils/i18n"; import { getTranslatedLabel } from "@/utils/i18n";
import type { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { JsonObject } from "@/types/configForm";
// Collect labelmap values (human-readable labels) from a labelmap object. // Collect labelmap values (human-readable labels) from a labelmap object.
function collectLabelmapLabels(labelmap: unknown, labels: Set<string>) { function collectLabelmapLabels(labelmap: unknown, labels: Set<string>) {
@ -11,7 +12,7 @@ function collectLabelmapLabels(labelmap: unknown, labels: Set<string>) {
return; return;
} }
Object.values(labelmap as Record<string, unknown>).forEach((value) => { Object.values(labelmap as JsonObject).forEach((value) => {
if (typeof value === "string" && value.trim().length > 0) { if (typeof value === "string" && value.trim().length > 0) {
labels.add(value); labels.add(value);
} }
@ -47,18 +48,30 @@ function getObjectLabels(context: FormContext): string[] {
if (context) { if (context) {
// context.cameraValue and context.globalValue should be the entire objects section // context.cameraValue and context.globalValue should be the entire objects section
const trackValue = context.cameraValue?.track; if (
if (Array.isArray(trackValue)) { context.cameraValue &&
cameraLabels = trackValue.filter( typeof context.cameraValue === "object" &&
(item): item is string => typeof item === "string", !Array.isArray(context.cameraValue)
); ) {
const trackValue = (context.cameraValue as JsonObject).track;
if (Array.isArray(trackValue)) {
cameraLabels = trackValue.filter(
(item): item is string => typeof item === "string",
);
}
} }
const globalTrackValue = context.globalValue?.track; if (
if (Array.isArray(globalTrackValue)) { context.globalValue &&
globalLabels = globalTrackValue.filter( typeof context.globalValue === "object" &&
(item): item is string => typeof item === "string", !Array.isArray(context.globalValue)
); ) {
const globalTrackValue = (context.globalValue as JsonObject).track;
if (Array.isArray(globalTrackValue)) {
globalLabels = globalTrackValue.filter(
(item): item is string => typeof item === "string",
);
}
} }
} }

View File

@ -1,5 +1,5 @@
// Generic Switches Widget - Reusable component for selecting from any list of entities // Generic Switches Widget - Reusable component for selecting from any list of entities
import type { WidgetProps } from "@rjsf/utils"; import { WidgetProps } from "@rjsf/utils";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -10,13 +10,14 @@ import {
} from "@/components/ui/collapsible"; } from "@/components/ui/collapsible";
import { LuChevronDown, LuChevronRight } from "react-icons/lu"; import { LuChevronDown, LuChevronRight } from "react-icons/lu";
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import { ConfigFormContext } from "@/types/configForm";
type FormContext = { type FormContext = Pick<
cameraValue?: Record<string, unknown>; ConfigFormContext,
globalValue?: Record<string, unknown>; "cameraValue" | "globalValue" | "fullCameraConfig" | "fullConfig" | "t"
> & {
fullCameraConfig?: CameraConfig; fullCameraConfig?: CameraConfig;
fullConfig?: FrigateConfig; fullConfig?: FrigateConfig;
t?: (key: string, options?: Record<string, unknown>) => string;
}; };
export type { FormContext }; export type { FormContext };

View File

@ -3,23 +3,25 @@ import { useMemo } from "react";
import isEqual from "lodash/isEqual"; import isEqual from "lodash/isEqual";
import get from "lodash/get"; import get from "lodash/get";
import set from "lodash/set"; import set from "lodash/set";
import type { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { JsonObject, JsonValue } from "@/types/configForm";
import { isJsonObject } from "@/lib/utils";
const INTERNAL_FIELD_SUFFIXES = ["enabled_in_config", "raw_mask"]; const INTERNAL_FIELD_SUFFIXES = ["enabled_in_config", "raw_mask"];
function stripInternalFields(value: unknown): unknown { function stripInternalFields(value: JsonValue): JsonValue {
if (Array.isArray(value)) { if (Array.isArray(value)) {
return value.map(stripInternalFields); return value.map(stripInternalFields);
} }
if (value && typeof value === "object") { if (isJsonObject(value)) {
const obj = value as Record<string, unknown>; const obj = value;
const cleaned: Record<string, unknown> = {}; const cleaned: JsonObject = {};
for (const [key, val] of Object.entries(obj)) { for (const [key, val] of Object.entries(obj)) {
if (INTERNAL_FIELD_SUFFIXES.some((suffix) => key.endsWith(suffix))) { if (INTERNAL_FIELD_SUFFIXES.some((suffix) => key.endsWith(suffix))) {
continue; continue;
} }
cleaned[key] = stripInternalFields(val); cleaned[key] = stripInternalFields(val as JsonValue);
} }
return cleaned; return cleaned;
} }
@ -27,8 +29,8 @@ function stripInternalFields(value: unknown): unknown {
return value; return value;
} }
export function normalizeConfigValue(value: unknown): unknown { export function normalizeConfigValue(value: unknown): JsonValue {
return stripInternalFields(value); return stripInternalFields(value as JsonValue);
} }
export interface OverrideStatus { export interface OverrideStatus {
@ -51,15 +53,15 @@ export interface UseConfigOverrideOptions {
compareFields?: string[]; compareFields?: string[];
} }
function pickFields(value: unknown, fields: string[]): Record<string, unknown> { function pickFields(value: unknown, fields: string[]): JsonObject {
if (!fields || fields.length === 0) { if (!fields || fields.length === 0) {
return {}; return {};
} }
const result: Record<string, unknown> = {}; const result: JsonObject = {};
fields.forEach((path) => { fields.forEach((path) => {
if (!path) return; if (!path) return;
const fieldValue = get(value as Record<string, unknown>, path); const fieldValue = get(value as JsonObject, path);
if (fieldValue !== undefined) { if (fieldValue !== undefined) {
set(result, path, fieldValue); set(result, path, fieldValue);
} }

View File

@ -3,12 +3,23 @@
import { useMemo } from "react"; import { useMemo } from "react";
import useSWR from "swr"; import useSWR from "swr";
import type { RJSFSchema } from "@rjsf/utils"; import { RJSFSchema } from "@rjsf/utils";
import { resolveAndCleanSchema } from "@/lib/config-schema"; import { resolveAndCleanSchema } from "@/lib/config-schema";
// Cache for resolved section schemas - keyed by schema reference + section key // Cache for resolved section schemas - keyed by schema reference + section key
const sectionSchemaCache = new WeakMap<RJSFSchema, Map<string, RJSFSchema>>(); const sectionSchemaCache = new WeakMap<RJSFSchema, Map<string, RJSFSchema>>();
type SchemaWithDefinitions = RJSFSchema & {
$defs?: Record<string, RJSFSchema>;
definitions?: Record<string, RJSFSchema>;
properties?: Record<string, RJSFSchema>;
};
const getSchemaDefinitions = (schema: RJSFSchema): Record<string, RJSFSchema> =>
(schema as SchemaWithDefinitions).$defs ||
(schema as SchemaWithDefinitions).definitions ||
{};
/** /**
* Extracts and resolves a section schema from the full config schema * Extracts and resolves a section schema from the full config schema
* Uses caching to avoid repeated expensive resolution * Uses caching to avoid repeated expensive resolution
@ -32,50 +43,43 @@ function extractSectionSchema(
return schemaCache.get(cacheKey)!; return schemaCache.get(cacheKey)!;
} }
const schemaObj = schema as Record<string, unknown>; const defs = getSchemaDefinitions(schema);
const defs = (schemaObj.$defs || schemaObj.definitions || {}) as Record< const schemaObj = schema as SchemaWithDefinitions;
string,
unknown
>;
let sectionDef: Record<string, unknown> | null = null; let sectionDef: RJSFSchema | null = null;
// For camera level, get section from CameraConfig in $defs // For camera level, get section from CameraConfig in $defs
if (level === "camera") { if (level === "camera") {
const cameraConfigDef = defs.CameraConfig as const cameraConfigDef = defs.CameraConfig;
| Record<string, unknown>
| undefined;
if (cameraConfigDef?.properties) { if (cameraConfigDef?.properties) {
const props = cameraConfigDef.properties as Record<string, unknown>; const props = cameraConfigDef.properties;
const sectionProp = props[sectionPath]; const sectionProp = props[sectionPath];
if (sectionProp && typeof sectionProp === "object") { if (sectionProp && typeof sectionProp === "object") {
const refProp = sectionProp as Record<string, unknown>; if ("$ref" in sectionProp && typeof sectionProp.$ref === "string") {
if (refProp.$ref && typeof refProp.$ref === "string") { const refPath = sectionProp.$ref
const refPath = (refProp.$ref as string)
.replace(/^#\/\$defs\//, "") .replace(/^#\/\$defs\//, "")
.replace(/^#\/definitions\//, ""); .replace(/^#\/definitions\//, "");
sectionDef = defs[refPath] as Record<string, unknown>; sectionDef = defs[refPath] || null;
} else { } else {
sectionDef = sectionProp as Record<string, unknown>; sectionDef = sectionProp;
} }
} }
} }
} else { } else {
// For global level, get from root properties // For global level, get from root properties
if (schemaObj.properties) { if (schemaObj.properties) {
const props = schemaObj.properties as Record<string, unknown>; const props = schemaObj.properties;
const sectionProp = props[sectionPath]; const sectionProp = props[sectionPath];
if (sectionProp && typeof sectionProp === "object") { if (sectionProp && typeof sectionProp === "object") {
const refProp = sectionProp as Record<string, unknown>; if ("$ref" in sectionProp && typeof sectionProp.$ref === "string") {
if (refProp.$ref && typeof refProp.$ref === "string") { const refPath = sectionProp.$ref
const refPath = (refProp.$ref as string)
.replace(/^#\/\$defs\//, "") .replace(/^#\/\$defs\//, "")
.replace(/^#\/definitions\//, ""); .replace(/^#\/definitions\//, "");
sectionDef = defs[refPath] as Record<string, unknown>; sectionDef = defs[refPath] || null;
} else { } else {
sectionDef = sectionProp as Record<string, unknown>; sectionDef = sectionProp;
} }
} }
} }
@ -84,10 +88,10 @@ function extractSectionSchema(
if (!sectionDef) return null; if (!sectionDef) return null;
// Include $defs for nested references and resolve them // Include $defs for nested references and resolve them
const schemaWithDefs = { const schemaWithDefs: RJSFSchema = {
...sectionDef, ...sectionDef,
$defs: defs, $defs: defs,
} as RJSFSchema; };
// Resolve all references and strip $defs from result // Resolve all references and strip $defs from result
const resolved = resolveAndCleanSchema(schemaWithDefs); const resolved = resolveAndCleanSchema(schemaWithDefs);

View File

@ -1,6 +1,7 @@
import { type ClassValue, clsx } from "clsx"; import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import type { UiSchema } from "@rjsf/utils"; import { UiSchema } from "@rjsf/utils";
import { JsonObject } from "@/types/configForm";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
@ -47,3 +48,10 @@ export function mergeUiSchema(
return result; return result;
} }
/**
* Type guard to check if a value is a JsonObject (non-array object)
*/
export function isJsonObject(value: unknown): value is JsonObject {
return !!value && typeof value === "object" && !Array.isArray(value);
}

View File

@ -0,0 +1,24 @@
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
export type JsonPrimitive = string | number | boolean | null;
export type JsonValue = JsonPrimitive | JsonObject | JsonArray;
export interface JsonObject {
[key: string]: JsonValue;
}
export type JsonArray = JsonValue[];
export type ConfigSectionData = JsonObject;
export type ConfigFormContext = {
level?: "global" | "camera";
cameraName?: string;
globalValue?: JsonValue;
cameraValue?: JsonValue;
fullCameraConfig?: CameraConfig;
fullConfig?: FrigateConfig;
i18nNamespace?: string;
t?: (key: string, options?: Record<string, unknown>) => string;
};