mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-28 02:58:22 +03:00
improve typing
This commit is contained in:
parent
4dc039072a
commit
8b7156438e
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 };
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
|||||||
24
web/src/types/configForm.ts
Normal file
24
web/src/types/configForm.ts
Normal 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;
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user