From 8b7156438e6d880d4b61a62348dbe83a40d9090c Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Fri, 30 Jan 2026 11:41:55 -0600 Subject: [PATCH] improve typing --- web/src/components/config-form/ConfigForm.tsx | 55 ++++++++++++------- .../config-form/sections/BaseSection.tsx | 40 +++++--------- .../theme/templates/FieldTemplate.tsx | 17 ++---- .../templates/MultiSchemaFieldTemplate.tsx | 5 +- .../theme/templates/ObjectFieldTemplate.tsx | 4 +- .../widgets/ObjectLabelSwitchesWidget.tsx | 41 +++++++++----- .../theme/widgets/SwitchesWidget.tsx | 11 ++-- web/src/hooks/use-config-override.ts | 24 ++++---- web/src/hooks/use-config-schema.ts | 52 ++++++++++-------- web/src/lib/utils.ts | 10 +++- web/src/types/configForm.ts | 24 ++++++++ 11 files changed, 169 insertions(+), 114 deletions(-) create mode 100644 web/src/types/configForm.ts diff --git a/web/src/components/config-form/ConfigForm.tsx b/web/src/components/config-form/ConfigForm.tsx index 669c7bdff..c909dbefc 100644 --- a/web/src/components/config-form/ConfigForm.tsx +++ b/web/src/components/config-form/ConfigForm.tsx @@ -9,10 +9,32 @@ import { createErrorTransformer } from "@/lib/config-schema/errorMessages"; import { useMemo, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { cn, mergeUiSchema } from "@/lib/utils"; +import type { ConfigFormContext } from "@/types/configForm"; -// Runtime guard for object-like schema fragments -const isSchemaObject = (value: unknown): value is Record => - typeof value === "object" && value !== null; +type SchemaWithProperties = RJSFSchema & { + properties: Record; +}; + +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") const isPathKey = (key: string) => key.includes(".") || key.includes("*"); @@ -70,34 +92,31 @@ const applyUiSchemaPathOverrides = ( } const [segment, ...rest] = path; - const schemaObj = targetSchema as Record; + const schemaObj = targetSchema; if (segment === "*") { - if (isSchemaObject(schemaObj.properties)) { - Object.entries(schemaObj.properties as Record).forEach( + if (hasSchemaProperties(schemaObj)) { + Object.entries(schemaObj.properties).forEach( ([propertyName, propertySchema]) => { - if (!isSchemaObject(propertySchema)) { - return; - } const existing = (targetUi[propertyName] as UiSchema | undefined) || {}; targetUi[propertyName] = { ...existing }; applyOverride( targetUi[propertyName] as UiSchema, - propertySchema as RJSFSchema, + propertySchema, rest, value, ); }, ); - } else if (isSchemaObject(schemaObj.additionalProperties)) { + } else if (hasSchemaAdditionalProperties(schemaObj)) { // For dict schemas, apply override to additionalProperties const existing = (targetUi.additionalProperties as UiSchema | undefined) || {}; targetUi.additionalProperties = { ...existing }; applyOverride( targetUi.additionalProperties as UiSchema, - schemaObj.additionalProperties as RJSFSchema, + schemaObj.additionalProperties, rest, value, ); @@ -105,16 +124,14 @@ const applyUiSchemaPathOverrides = ( return; } - if (isSchemaObject(schemaObj.properties)) { - const propertySchema = (schemaObj.properties as Record)[ - segment - ]; - if (isSchemaObject(propertySchema)) { + if (hasSchemaProperties(schemaObj)) { + const propertySchema = schemaObj.properties[segment]; + if (propertySchema) { const existing = (targetUi[segment] as UiSchema | undefined) || {}; targetUi[segment] = { ...existing }; applyOverride( targetUi[segment] as UiSchema, - propertySchema as RJSFSchema, + propertySchema, rest, value, ); @@ -162,7 +179,7 @@ export interface ConfigFormProps { /** Live validation mode */ liveValidate?: boolean; /** Form context passed to all widgets */ - formContext?: Record; + formContext?: ConfigFormContext; /** i18n namespace for field labels */ i18nNamespace?: string; } diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index 1ea3fcb0e..e96e8ef9b 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -33,6 +33,8 @@ import { CollapsibleTrigger, } from "@/components/ui/collapsible"; import { applySchemaDefaults } from "@/lib/config-schema"; +import { isJsonObject } from "@/lib/utils"; +import { ConfigSectionData, JsonObject, JsonValue } from "@/types/configForm"; export interface SectionConfig { /** Field ordering within the section */ @@ -130,10 +132,9 @@ export function createConfigSection({ }: BaseSectionProps) { const { t } = useTranslation([i18nNamespace, "views/settings", "common"]); const [isOpen, setIsOpen] = useState(!defaultCollapsed); - const [pendingData, setPendingData] = useState | null>(null); + const [pendingData, setPendingData] = useState( + null, + ); const [isSaving, setIsSaving] = useState(false); const [formKey, setFormKey] = useState(0); const isResettingRef = useRef(false); @@ -175,11 +176,8 @@ export function createConfigSection({ }, [config, level, cameraName]); const sanitizeSectionData = useCallback( - (data: Record) => { - const normalized = normalizeConfigValue(data) as Record< - string, - unknown - >; + (data: ConfigSectionData) => { + const normalized = normalizeConfigValue(data) as ConfigSectionData; if ( !sectionConfig.hiddenFields || sectionConfig.hiddenFields.length === 0 @@ -187,7 +185,7 @@ export function createConfigSection({ return normalized; } - const cleaned = cloneDeep(normalized); + const cleaned = cloneDeep(normalized) as ConfigSectionData; sectionConfig.hiddenFields.forEach((path) => { if (!path) return; unset(cleaned, path); @@ -245,18 +243,12 @@ export function createConfigSection({ return current; } - if (typeof current === "object") { - const currentObj = current as Record; - const baseObj = - base && typeof base === "object" - ? (base as Record) - : undefined; - const defaultsObj = - defaults && typeof defaults === "object" - ? (defaults as Record) - : undefined; + if (isJsonObject(current)) { + const currentObj = current; + const baseObj = isJsonObject(base) ? base : undefined; + const defaultsObj = isJsonObject(defaults) ? defaults : undefined; - const result: Record = {}; + const result: JsonObject = {}; for (const [key, value] of Object.entries(currentObj)) { const overrideValue = buildOverrides( value, @@ -264,7 +256,7 @@ export function createConfigSection({ defaultsObj ? defaultsObj[key] : undefined, ); if (overrideValue !== undefined) { - result[key] = overrideValue; + result[key] = overrideValue as JsonValue; } } @@ -305,9 +297,7 @@ export function createConfigSection({ setPendingData(null); return; } - const sanitizedData = sanitizeSectionData( - data as Record, - ); + const sanitizedData = sanitizeSectionData(data as ConfigSectionData); if (isEqual(formData, sanitizedData)) { setPendingData(null); return; diff --git a/web/src/components/config-form/theme/templates/FieldTemplate.tsx b/web/src/components/config-form/theme/templates/FieldTemplate.tsx index 82f14c352..79091cda5 100644 --- a/web/src/components/config-form/theme/templates/FieldTemplate.tsx +++ b/web/src/components/config-form/theme/templates/FieldTemplate.tsx @@ -1,5 +1,5 @@ // 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 { getTemplate, getUiOptions, @@ -10,6 +10,7 @@ import { cn } from "@/lib/utils"; import { useTranslation } from "react-i18next"; import { isNullableUnionSchema } from "../fields/nullableUtils"; import { getTranslatedLabel } from "@/utils/i18n"; +import { ConfigFormContext } from "@/types/configForm"; /** * Build the i18n translation key path for nested fields using the field path @@ -78,9 +79,7 @@ export function FieldTemplate(props: FieldTemplateProps) { } = props; // Get i18n namespace from form context (passed through registry) - const formContext = registry?.formContext as - | Record - | undefined; + const formContext = registry?.formContext as ConfigFormContext | undefined; const i18nNamespace = formContext?.i18nNamespace as string | undefined; const { t, i18n } = useTranslation([ i18nNamespace || "common", @@ -103,7 +102,7 @@ export function FieldTemplate(props: FieldTemplateProps) { const isNullableUnion = isNullableUnionSchema(schema as StrictRJSFSchema); const isAdditionalProperty = ADDITIONAL_PROPERTY_FLAG in schema; const suppressMultiSchema = - (uiSchema?.["ui:options"] as Record | undefined) + (uiSchema?.["ui:options"] as UiSchema["ui:options"] | undefined) ?.suppressMultiSchema === true; // 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; // Use schema title/description as primary source (from JSON Schema) - const schemaTitle = (schema as Record).title as - | string - | undefined; - const schemaDescription = (schema as Record).description as - | string - | undefined; + const schemaTitle = schema.title; + const schemaDescription = schema.description; // Try to get translated label, falling back to schema title, then RJSF label let finalLabel = label; diff --git a/web/src/components/config-form/theme/templates/MultiSchemaFieldTemplate.tsx b/web/src/components/config-form/theme/templates/MultiSchemaFieldTemplate.tsx index e020beb99..d845e2c61 100644 --- a/web/src/components/config-form/theme/templates/MultiSchemaFieldTemplate.tsx +++ b/web/src/components/config-form/theme/templates/MultiSchemaFieldTemplate.tsx @@ -1,10 +1,11 @@ // Custom MultiSchemaFieldTemplate to handle anyOf [Type, null] fields // Renders simple nullable types as single inputs instead of dropdowns -import type { +import { MultiSchemaFieldTemplateProps, StrictRJSFSchema, FormContextType, + UiSchema, } from "@rjsf/utils"; import { isNullableUnionSchema } from "../fields/nullableUtils"; @@ -23,7 +24,7 @@ export function MultiSchemaFieldTemplate< const { schema, selector, optionSchemaField, uiSchema } = props; const uiOptions = uiSchema?.["ui:options"] as - | Record + | UiSchema["ui:options"] | undefined; const suppressMultiSchema = uiOptions?.suppressMultiSchema === true; diff --git a/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx b/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx index f15a1a303..0503978db 100644 --- a/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx +++ b/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx @@ -13,6 +13,7 @@ import { LuChevronDown, LuChevronRight, LuPlus } from "react-icons/lu"; import { useTranslation } from "react-i18next"; import { cn } from "@/lib/utils"; import { getTranslatedLabel } from "@/utils/i18n"; +import { ConfigFormContext } from "@/types/configForm"; /** * Build the i18n translation key path for nested fields using the field path @@ -60,8 +61,7 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) { disabled, readonly, } = props; - type FormContext = { i18nNamespace?: string }; - const formContext = registry?.formContext as FormContext | undefined; + const formContext = registry?.formContext as ConfigFormContext | undefined; // Check if this is a root-level object const isRoot = registry?.rootSchema === schema; diff --git a/web/src/components/config-form/theme/widgets/ObjectLabelSwitchesWidget.tsx b/web/src/components/config-form/theme/widgets/ObjectLabelSwitchesWidget.tsx index ad25952fc..24bec259d 100644 --- a/web/src/components/config-form/theme/widgets/ObjectLabelSwitchesWidget.tsx +++ b/web/src/components/config-form/theme/widgets/ObjectLabelSwitchesWidget.tsx @@ -1,9 +1,10 @@ // 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 type { FormContext } from "./SwitchesWidget"; +import { FormContext } from "./SwitchesWidget"; 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. function collectLabelmapLabels(labelmap: unknown, labels: Set) { @@ -11,7 +12,7 @@ function collectLabelmapLabels(labelmap: unknown, labels: Set) { return; } - Object.values(labelmap as Record).forEach((value) => { + Object.values(labelmap as JsonObject).forEach((value) => { if (typeof value === "string" && value.trim().length > 0) { labels.add(value); } @@ -47,18 +48,30 @@ function getObjectLabels(context: FormContext): string[] { if (context) { // context.cameraValue and context.globalValue should be the entire objects section - const trackValue = context.cameraValue?.track; - if (Array.isArray(trackValue)) { - cameraLabels = trackValue.filter( - (item): item is string => typeof item === "string", - ); + if ( + context.cameraValue && + typeof context.cameraValue === "object" && + !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 (Array.isArray(globalTrackValue)) { - globalLabels = globalTrackValue.filter( - (item): item is string => typeof item === "string", - ); + if ( + context.globalValue && + typeof context.globalValue === "object" && + !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", + ); + } } } diff --git a/web/src/components/config-form/theme/widgets/SwitchesWidget.tsx b/web/src/components/config-form/theme/widgets/SwitchesWidget.tsx index 6d85a4d7b..15e5accbc 100644 --- a/web/src/components/config-form/theme/widgets/SwitchesWidget.tsx +++ b/web/src/components/config-form/theme/widgets/SwitchesWidget.tsx @@ -1,5 +1,5 @@ // 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 { Switch } from "@/components/ui/switch"; import { Button } from "@/components/ui/button"; @@ -10,13 +10,14 @@ import { } from "@/components/ui/collapsible"; import { LuChevronDown, LuChevronRight } from "react-icons/lu"; import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; +import { ConfigFormContext } from "@/types/configForm"; -type FormContext = { - cameraValue?: Record; - globalValue?: Record; +type FormContext = Pick< + ConfigFormContext, + "cameraValue" | "globalValue" | "fullCameraConfig" | "fullConfig" | "t" +> & { fullCameraConfig?: CameraConfig; fullConfig?: FrigateConfig; - t?: (key: string, options?: Record) => string; }; export type { FormContext }; diff --git a/web/src/hooks/use-config-override.ts b/web/src/hooks/use-config-override.ts index 0cfeb6d49..d0577a6c6 100644 --- a/web/src/hooks/use-config-override.ts +++ b/web/src/hooks/use-config-override.ts @@ -3,23 +3,25 @@ import { useMemo } from "react"; import isEqual from "lodash/isEqual"; import get from "lodash/get"; 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"]; -function stripInternalFields(value: unknown): unknown { +function stripInternalFields(value: JsonValue): JsonValue { if (Array.isArray(value)) { return value.map(stripInternalFields); } - if (value && typeof value === "object") { - const obj = value as Record; - const cleaned: Record = {}; + if (isJsonObject(value)) { + const obj = value; + const cleaned: JsonObject = {}; for (const [key, val] of Object.entries(obj)) { if (INTERNAL_FIELD_SUFFIXES.some((suffix) => key.endsWith(suffix))) { continue; } - cleaned[key] = stripInternalFields(val); + cleaned[key] = stripInternalFields(val as JsonValue); } return cleaned; } @@ -27,8 +29,8 @@ function stripInternalFields(value: unknown): unknown { return value; } -export function normalizeConfigValue(value: unknown): unknown { - return stripInternalFields(value); +export function normalizeConfigValue(value: unknown): JsonValue { + return stripInternalFields(value as JsonValue); } export interface OverrideStatus { @@ -51,15 +53,15 @@ export interface UseConfigOverrideOptions { compareFields?: string[]; } -function pickFields(value: unknown, fields: string[]): Record { +function pickFields(value: unknown, fields: string[]): JsonObject { if (!fields || fields.length === 0) { return {}; } - const result: Record = {}; + const result: JsonObject = {}; fields.forEach((path) => { if (!path) return; - const fieldValue = get(value as Record, path); + const fieldValue = get(value as JsonObject, path); if (fieldValue !== undefined) { set(result, path, fieldValue); } diff --git a/web/src/hooks/use-config-schema.ts b/web/src/hooks/use-config-schema.ts index f73f4faff..969e0fdc5 100644 --- a/web/src/hooks/use-config-schema.ts +++ b/web/src/hooks/use-config-schema.ts @@ -3,12 +3,23 @@ import { useMemo } from "react"; import useSWR from "swr"; -import type { RJSFSchema } from "@rjsf/utils"; +import { RJSFSchema } from "@rjsf/utils"; import { resolveAndCleanSchema } from "@/lib/config-schema"; // Cache for resolved section schemas - keyed by schema reference + section key const sectionSchemaCache = new WeakMap>(); +type SchemaWithDefinitions = RJSFSchema & { + $defs?: Record; + definitions?: Record; + properties?: Record; +}; + +const getSchemaDefinitions = (schema: RJSFSchema): Record => + (schema as SchemaWithDefinitions).$defs || + (schema as SchemaWithDefinitions).definitions || + {}; + /** * Extracts and resolves a section schema from the full config schema * Uses caching to avoid repeated expensive resolution @@ -32,50 +43,43 @@ function extractSectionSchema( return schemaCache.get(cacheKey)!; } - const schemaObj = schema as Record; - const defs = (schemaObj.$defs || schemaObj.definitions || {}) as Record< - string, - unknown - >; + const defs = getSchemaDefinitions(schema); + const schemaObj = schema as SchemaWithDefinitions; - let sectionDef: Record | null = null; + let sectionDef: RJSFSchema | null = null; // For camera level, get section from CameraConfig in $defs if (level === "camera") { - const cameraConfigDef = defs.CameraConfig as - | Record - | undefined; + const cameraConfigDef = defs.CameraConfig; if (cameraConfigDef?.properties) { - const props = cameraConfigDef.properties as Record; + const props = cameraConfigDef.properties; const sectionProp = props[sectionPath]; if (sectionProp && typeof sectionProp === "object") { - const refProp = sectionProp as Record; - if (refProp.$ref && typeof refProp.$ref === "string") { - const refPath = (refProp.$ref as string) + if ("$ref" in sectionProp && typeof sectionProp.$ref === "string") { + const refPath = sectionProp.$ref .replace(/^#\/\$defs\//, "") .replace(/^#\/definitions\//, ""); - sectionDef = defs[refPath] as Record; + sectionDef = defs[refPath] || null; } else { - sectionDef = sectionProp as Record; + sectionDef = sectionProp; } } } } else { // For global level, get from root properties if (schemaObj.properties) { - const props = schemaObj.properties as Record; + const props = schemaObj.properties; const sectionProp = props[sectionPath]; if (sectionProp && typeof sectionProp === "object") { - const refProp = sectionProp as Record; - if (refProp.$ref && typeof refProp.$ref === "string") { - const refPath = (refProp.$ref as string) + if ("$ref" in sectionProp && typeof sectionProp.$ref === "string") { + const refPath = sectionProp.$ref .replace(/^#\/\$defs\//, "") .replace(/^#\/definitions\//, ""); - sectionDef = defs[refPath] as Record; + sectionDef = defs[refPath] || null; } else { - sectionDef = sectionProp as Record; + sectionDef = sectionProp; } } } @@ -84,10 +88,10 @@ function extractSectionSchema( if (!sectionDef) return null; // Include $defs for nested references and resolve them - const schemaWithDefs = { + const schemaWithDefs: RJSFSchema = { ...sectionDef, $defs: defs, - } as RJSFSchema; + }; // Resolve all references and strip $defs from result const resolved = resolveAndCleanSchema(schemaWithDefs); diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 665bc9e37..dc10ccc3a 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -1,6 +1,7 @@ import { type ClassValue, clsx } from "clsx"; 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[]) { return twMerge(clsx(inputs)); @@ -47,3 +48,10 @@ export function mergeUiSchema( 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); +} diff --git a/web/src/types/configForm.ts b/web/src/types/configForm.ts new file mode 100644 index 000000000..3a83da47d --- /dev/null +++ b/web/src/types/configForm.ts @@ -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; +};