add wildcards and fix object filter fields

This commit is contained in:
Josh Hawkins 2026-01-30 08:52:11 -06:00
parent ba1d5d42c5
commit 06c21bf6f2
9 changed files with 419 additions and 70 deletions

View File

@ -1213,6 +1213,9 @@
"summary": "Selected {{count}}",
"empty": "No object labels available"
},
"filters": {
"objectFieldLabel": "{{field}} for {{label}}"
},
"zoneNames": {
"summary": "Selected {{count}}",
"empty": "No zones available"

View File

@ -10,6 +10,126 @@ import { useMemo, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { cn, mergeUiSchema } from "@/lib/utils";
// Runtime guard for object-like schema fragments
const isSchemaObject = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null;
// Detects path-style uiSchema keys (e.g., "filters.*.mask")
const isPathKey = (key: string) => key.includes(".") || key.includes("*");
type UiSchemaPathOverride = {
path: string[];
value: UiSchema;
};
// Split uiSchema into normal keys vs path-based overrides
const splitUiSchemaOverrides = (
uiSchema?: UiSchema,
): { baseUiSchema?: UiSchema; pathOverrides: UiSchemaPathOverride[] } => {
if (!uiSchema) {
return { baseUiSchema: undefined, pathOverrides: [] };
}
const baseUiSchema: UiSchema = {};
const pathOverrides: UiSchemaPathOverride[] = [];
Object.entries(uiSchema).forEach(([key, value]) => {
if (isPathKey(key)) {
pathOverrides.push({
path: key.split("."),
value: value as UiSchema,
});
} else {
baseUiSchema[key] = value as UiSchema;
}
});
return { baseUiSchema, pathOverrides };
};
// Apply wildcard path overrides to uiSchema using the schema structure
const applyUiSchemaPathOverrides = (
uiSchema: UiSchema,
schema: RJSFSchema,
overrides: UiSchemaPathOverride[],
): UiSchema => {
if (overrides.length === 0) {
return uiSchema;
}
// Recursively apply a path override; supports "*" to match any property.
const applyOverride = (
targetUi: UiSchema,
targetSchema: RJSFSchema,
path: string[],
value: UiSchema,
) => {
if (path.length === 0) {
Object.assign(targetUi, mergeUiSchema(targetUi, value));
return;
}
const [segment, ...rest] = path;
const schemaObj = targetSchema as Record<string, unknown>;
if (segment === "*") {
if (isSchemaObject(schemaObj.properties)) {
Object.entries(schemaObj.properties as Record<string, unknown>).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,
rest,
value,
);
},
);
} else if (isSchemaObject(schemaObj.additionalProperties)) {
// 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,
rest,
value,
);
}
return;
}
if (isSchemaObject(schemaObj.properties)) {
const propertySchema = (schemaObj.properties as Record<string, unknown>)[
segment
];
if (isSchemaObject(propertySchema)) {
const existing = (targetUi[segment] as UiSchema | undefined) || {};
targetUi[segment] = { ...existing };
applyOverride(
targetUi[segment] as UiSchema,
propertySchema as RJSFSchema,
rest,
value,
);
}
}
};
const updated = { ...uiSchema };
overrides.forEach(({ path, value }) => {
applyOverride(updated, schema, path, value);
});
return updated;
};
export interface ConfigFormProps {
/** JSON Schema for the form */
schema: RJSFSchema;
@ -89,10 +209,20 @@ export function ConfigForm({
[schema, fieldOrder, effectiveHiddenFields, advancedFields, i18nNamespace],
);
const { baseUiSchema, pathOverrides } = useMemo(
() => splitUiSchemaOverrides(customUiSchema),
[customUiSchema],
);
// Merge generated uiSchema with custom overrides
const finalUiSchema = useMemo(() => {
// Start with generated schema
const merged = mergeUiSchema(generatedUiSchema, customUiSchema);
const expandedUiSchema = applyUiSchemaPathOverrides(
generatedUiSchema,
transformedSchema,
pathOverrides,
);
const merged = mergeUiSchema(expandedUiSchema, baseUiSchema);
// Add field groups
if (fieldGroups) {
@ -105,7 +235,14 @@ export function ConfigForm({
: { norender: true };
return merged;
}, [generatedUiSchema, customUiSchema, showSubmit, fieldGroups]);
}, [
generatedUiSchema,
transformedSchema,
pathOverrides,
baseUiSchema,
showSubmit,
fieldGroups,
]);
// Create error transformer for user-friendly error messages
const errorTransformer = useMemo(() => createErrorTransformer(i18n), [i18n]);

View File

@ -500,6 +500,7 @@ export function createConfigSection({
level === "camera" && cameraName
? config?.cameras?.[cameraName]
: undefined,
fullConfig: config,
t,
}}
/>

View File

@ -17,9 +17,21 @@ export const ObjectsSection = createConfigSection({
"mask",
"raw_mask",
"genai.enabled_in_config",
"filters.*.mask",
"filters.*.raw_mask",
],
advancedFields: ["filters"],
uiSchema: {
"filters.*.min_area": {
"ui:options": {
suppressMultiSchema: true,
},
},
"filters.*.max_area": {
"ui:options": {
suppressMultiSchema: true,
},
},
track: {
"ui:widget": "objectLabels",
"ui:options": {

View File

@ -4,13 +4,46 @@ import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
import { isNullableUnionSchema } from "../fields/nullableUtils";
import { getTranslatedLabel } from "@/utils/i18n";
/**
* Build the i18n translation key path for nested fields using the field path
* provided by RJSF. This avoids ambiguity with underscores in field names.
* provided by RJSF. This avoids ambiguity with underscores in field names and
* skips dynamic filter labels for per-object filter fields.
*/
function buildTranslationPath(path: Array<string | number>): string {
return path.filter((segment) => typeof segment === "string").join(".");
const segments = path.filter(
(segment): segment is string => typeof segment === "string",
);
const filtersIndex = segments.indexOf("filters");
if (filtersIndex !== -1 && segments.length > filtersIndex + 2) {
const normalized = [
...segments.slice(0, filtersIndex + 1),
...segments.slice(filtersIndex + 2),
];
return normalized.join(".");
}
return segments.join(".");
}
function getFilterObjectLabel(pathSegments: string[]): string | undefined {
const filtersIndex = pathSegments.indexOf("filters");
if (filtersIndex === -1 || pathSegments.length <= filtersIndex + 1) {
return undefined;
}
const objectLabel = pathSegments[filtersIndex + 1];
return typeof objectLabel === "string" && objectLabel.length > 0
? objectLabel
: undefined;
}
function humanizeKey(value: string): string {
return value
.split("_")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(" ");
}
export function FieldTemplate(props: FieldTemplateProps) {
@ -35,7 +68,10 @@ export function FieldTemplate(props: FieldTemplateProps) {
| Record<string, unknown>
| undefined;
const i18nNamespace = formContext?.i18nNamespace as string | undefined;
const { t } = useTranslation([i18nNamespace || "common"]);
const { t, i18n } = useTranslation([
i18nNamespace || "common",
"views/settings",
]);
if (hidden) {
return <div className="hidden">{children}</div>;
@ -61,7 +97,14 @@ export function FieldTemplate(props: FieldTemplateProps) {
(schema.anyOf || schema.oneOf) && (suppressMultiSchema || isNullableUnion);
// Get translation path for this field
const translationPath = buildTranslationPath(fieldPathId.path);
const pathSegments = fieldPathId.path.filter(
(segment): segment is string => typeof segment === "string",
);
const translationPath = buildTranslationPath(pathSegments);
const filterObjectLabel = getFilterObjectLabel(pathSegments);
const translatedFilterObjectLabel = filterObjectLabel
? getTranslatedLabel(filterObjectLabel, "object")
: undefined;
// Use schema title/description as primary source (from JSON Schema)
const schemaTitle = (schema as Record<string, unknown>).title as
@ -75,29 +118,80 @@ export function FieldTemplate(props: FieldTemplateProps) {
let finalLabel = label;
if (i18nNamespace && translationPath) {
const translationKey = `${translationPath}.label`;
const translatedLabel = t(translationKey, {
ns: i18nNamespace,
defaultValue: "",
});
// Only use translation if it's not the key itself (which means translation exists)
if (translatedLabel && translatedLabel !== translationKey) {
finalLabel = translatedLabel;
if (i18n.exists(translationKey, { ns: i18nNamespace })) {
finalLabel = t(translationKey, { ns: i18nNamespace });
} else if (schemaTitle) {
finalLabel = schemaTitle;
} else if (translatedFilterObjectLabel) {
const filtersIndex = pathSegments.indexOf("filters");
const isFilterObjectField =
filtersIndex > -1 && pathSegments.length === filtersIndex + 2;
if (isFilterObjectField) {
finalLabel = translatedFilterObjectLabel;
} else {
// Try to get translated field label, fall back to humanized
const fieldName = pathSegments[pathSegments.length - 1] || "";
let fieldLabel = schemaTitle;
if (!fieldLabel) {
const fieldTranslationKey = `${fieldName}.label`;
if (
i18nNamespace &&
i18n.exists(fieldTranslationKey, { ns: i18nNamespace })
) {
fieldLabel = t(fieldTranslationKey, { ns: i18nNamespace });
} else {
fieldLabel = humanizeKey(fieldName);
}
}
if (fieldLabel) {
finalLabel = t("configForm.filters.objectFieldLabel", {
ns: "views/settings",
field: fieldLabel,
label: translatedFilterObjectLabel,
});
}
}
}
} else if (schemaTitle) {
finalLabel = schemaTitle;
} else if (translatedFilterObjectLabel) {
const filtersIndex = pathSegments.indexOf("filters");
const isFilterObjectField =
filtersIndex > -1 && pathSegments.length === filtersIndex + 2;
if (isFilterObjectField) {
finalLabel = translatedFilterObjectLabel;
} else {
// Try to get translated field label, fall back to humanized
const fieldName = pathSegments[pathSegments.length - 1] || "";
let fieldLabel = schemaTitle;
if (!fieldLabel) {
const fieldTranslationKey = `${fieldName}.label`;
if (
i18nNamespace &&
i18n.exists(fieldTranslationKey, { ns: i18nNamespace })
) {
fieldLabel = t(fieldTranslationKey, { ns: i18nNamespace });
} else {
fieldLabel = humanizeKey(fieldName);
}
}
if (fieldLabel) {
finalLabel = t("configForm.filters.objectFieldLabel", {
ns: "views/settings",
field: fieldLabel,
label: translatedFilterObjectLabel,
});
}
}
}
// Try to get translated description, falling back to schema description
let finalDescription = description || "";
if (i18nNamespace && translationPath) {
const translatedDesc = t(`${translationPath}.description`, {
ns: i18nNamespace,
defaultValue: "",
});
if (translatedDesc && translatedDesc !== `${translationPath}.description`) {
finalDescription = translatedDesc;
const descriptionKey = `${translationPath}.description`;
if (i18n.exists(descriptionKey, { ns: i18nNamespace })) {
finalDescription = t(descriptionKey, { ns: i18nNamespace });
} else if (schemaDescription) {
finalDescription = schemaDescription;
}
@ -114,18 +208,22 @@ export function FieldTemplate(props: FieldTemplateProps) {
)}
data-field-id={translationPath}
>
{displayLabel && finalLabel && !isBoolean && !isMultiSchemaWrapper && (
<Label
htmlFor={id}
className={cn(
"text-sm font-medium",
errors && errors.props?.errors?.length > 0 && "text-destructive",
)}
>
{finalLabel}
{required && <span className="ml-1 text-destructive">*</span>}
</Label>
)}
{displayLabel &&
finalLabel &&
!isBoolean &&
!isMultiSchemaWrapper &&
!isObjectField && (
<Label
htmlFor={id}
className={cn(
"text-sm font-medium",
errors && errors.props?.errors?.length > 0 && "text-destructive",
)}
>
{finalLabel}
{required && <span className="ml-1 text-destructive">*</span>}
</Label>
)}
{isBoolean ? (
<div className="flex w-full items-center justify-between gap-4">

View File

@ -11,13 +11,39 @@ import { useState } from "react";
import { LuChevronDown, LuChevronRight } from "react-icons/lu";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { getTranslatedLabel } from "@/utils/i18n";
/**
* Build the i18n translation key path for nested fields using the field path
* provided by RJSF. This avoids ambiguity with underscores in field names.
* provided by RJSF. This avoids ambiguity with underscores in field names and
* skips dynamic filter labels for per-object filter fields.
*/
function buildTranslationPath(path: Array<string | number>): string {
return path.filter((segment) => typeof segment === "string").join(".");
const segments = path.filter(
(segment): segment is string => typeof segment === "string",
);
const filtersIndex = segments.indexOf("filters");
if (filtersIndex !== -1 && segments.length > filtersIndex + 2) {
const normalized = [
...segments.slice(0, filtersIndex + 1),
...segments.slice(filtersIndex + 2),
];
return normalized.join(".");
}
return segments.join(".");
}
function getFilterObjectLabel(
pathSegments: Array<string | number>,
): string | undefined {
const index = pathSegments.indexOf("filters");
if (index === -1 || pathSegments.length <= index + 1) {
return undefined;
}
const label = pathSegments[index + 1];
return typeof label === "string" && label.length > 0 ? label : undefined;
}
export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
@ -30,7 +56,7 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
const [isOpen, setIsOpen] = useState(true);
const { t } = useTranslation([
const { t, i18n } = useTranslation([
formContext?.i18nNamespace || "common",
"config/groups",
]);
@ -71,6 +97,10 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
let propertyName: string | undefined;
let translationPath: string | undefined;
const path = fieldPathId?.path;
const filterObjectLabel = path ? getFilterObjectLabel(path) : undefined;
const translatedFilterLabel = filterObjectLabel
? getTranslatedLabel(filterObjectLabel, "object")
: undefined;
if (path) {
translationPath = buildTranslationPath(path);
// Also get the last property name for fallback label generation
@ -88,11 +118,13 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
let inferredLabel: string | undefined;
if (i18nNs && translationPath) {
const translated = t(`${translationPath}.label`, {
ns: i18nNs,
defaultValue: "",
});
inferredLabel = translated || undefined;
const labelKey = `${translationPath}.label`;
if (i18n.exists(labelKey, { ns: i18nNs })) {
inferredLabel = t(labelKey, { ns: i18nNs });
}
}
if (!inferredLabel && translatedFilterLabel) {
inferredLabel = translatedFilterLabel;
}
const schemaTitle = schema?.title;
const fallbackLabel =
@ -101,11 +133,10 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
let inferredDescription: string | undefined;
if (i18nNs && translationPath) {
const translated = t(`${translationPath}.description`, {
ns: i18nNs,
defaultValue: "",
});
inferredDescription = translated || undefined;
const descriptionKey = `${translationPath}.description`;
if (i18n.exists(descriptionKey, { ns: i18nNs })) {
inferredDescription = t(descriptionKey, { ns: i18nNs });
}
}
const schemaDescription = schema?.description;
const fallbackDescription =

View File

@ -3,8 +3,45 @@ import type { WidgetProps } from "@rjsf/utils";
import { SwitchesWidget } from "./SwitchesWidget";
import type { FormContext } from "./SwitchesWidget";
import { getTranslatedLabel } from "@/utils/i18n";
import type { FrigateConfig } from "@/types/frigateConfig";
// Collect labelmap values (human-readable labels) from a labelmap object.
function collectLabelmapLabels(labelmap: unknown, labels: Set<string>) {
if (!labelmap || typeof labelmap !== "object") {
return;
}
Object.values(labelmap as Record<string, unknown>).forEach((value) => {
if (typeof value === "string" && value.trim().length > 0) {
labels.add(value);
}
});
}
// Read labelmap labels from the global model and detector models.
function getLabelmapLabels(context: FormContext): string[] {
const labels = new Set<string>();
const fullConfig = context.fullConfig as FrigateConfig | undefined;
if (fullConfig?.model) {
collectLabelmapLabels(fullConfig.model.labelmap, labels);
}
if (fullConfig?.detectors) {
// detectors is a map of detector configs; each may include a model labelmap.
Object.values(fullConfig.detectors).forEach((detector) => {
if (detector?.model?.labelmap) {
collectLabelmapLabels(detector.model.labelmap, labels);
}
});
}
return [...labels];
}
// Build the list of labels for switches (labelmap + configured track list).
function getObjectLabels(context: FormContext): string[] {
const labelmapLabels = getLabelmapLabels(context);
let cameraLabels: string[] = [];
let globalLabels: string[] = [];
@ -26,7 +63,8 @@ function getObjectLabels(context: FormContext): string[] {
}
const sourceLabels = cameraLabels.length > 0 ? cameraLabels : globalLabels;
return [...sourceLabels].sort();
const combinedLabels = new Set<string>([...labelmapLabels, ...sourceLabels]);
return [...combinedLabels].sort();
}
function getObjectLabelDisplayName(label: string): string {

View File

@ -9,11 +9,13 @@ import {
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { LuChevronDown, LuChevronRight } from "react-icons/lu";
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
type FormContext = {
cameraValue?: Record<string, unknown>;
globalValue?: Record<string, unknown>;
fullCameraConfig?: Record<string, unknown>;
fullCameraConfig?: CameraConfig;
fullConfig?: FrigateConfig;
t?: (key: string, options?: Record<string, unknown>) => string;
};

View File

@ -408,6 +408,7 @@ function getWidgetForField(
function generateUiSchema(
schema: RJSFSchema,
options: UiSchemaOptions = {},
currentPath: string[] = [],
): UiSchema {
const uiSchema: UiSchema = {};
const {
@ -418,6 +419,24 @@ function generateUiSchema(
includeDescriptions = true,
} = options;
// Pre-split patterns for wildcard matching ("*") on nested paths
const hiddenFieldPatterns = hiddenFields.map((field) => field.split("."));
const advancedFieldPatterns = advancedFields.map((field) => field.split("."));
// Match a concrete path to a wildcard pattern of equal length
const matchesPathPattern = (path: string[], pattern: string[]) => {
if (path.length !== pattern.length) {
return false;
}
return pattern.every((segment, index) => {
if (segment === "*") {
return true;
}
return segment === path[index];
});
};
const schemaObj = schema as Record<string, unknown>;
// Set field ordering
@ -437,8 +456,15 @@ function generateUiSchema(
const fSchema = fieldSchema as Record<string, unknown>;
const fieldUiSchema: UiSchema = {};
// Track full path to support wildcard-based rules
const fieldPath = [...currentPath, fieldName];
// Hidden fields
if (hiddenFields.includes(fieldName)) {
if (
hiddenFieldPatterns.some((pattern) =>
matchesPathPattern(fieldPath, pattern),
)
) {
fieldUiSchema["ui:widget"] = "hidden";
uiSchema[fieldName] = fieldUiSchema;
continue;
@ -460,7 +486,11 @@ function generateUiSchema(
}
// Advanced fields - mark for collapsible
if (advancedFields.includes(fieldName)) {
if (
advancedFieldPatterns.some((pattern) =>
matchesPathPattern(fieldPath, pattern),
)
) {
fieldUiSchema["ui:options"] = {
...((fieldUiSchema["ui:options"] as object) || {}),
advanced: true,
@ -468,28 +498,25 @@ function generateUiSchema(
}
// Handle nested objects recursively
if (
schemaHasType(fSchema, "object") &&
isSchemaObject(fSchema.properties)
) {
const nestedOptions: UiSchemaOptions = {
hiddenFields: hiddenFields
.filter((f) => f.startsWith(`${fieldName}.`))
.map((f) => f.replace(`${fieldName}.`, "")),
advancedFields: advancedFields
.filter((f) => f.startsWith(`${fieldName}.`))
.map((f) => f.replace(`${fieldName}.`, "")),
widgetMappings: Object.fromEntries(
Object.entries(widgetMappings)
.filter(([k]) => k.startsWith(`${fieldName}.`))
.map(([k, v]) => [k.replace(`${fieldName}.`, ""), v]),
),
includeDescriptions,
};
Object.assign(
fieldUiSchema,
generateUiSchema(fieldSchema as RJSFSchema, nestedOptions),
);
if (schemaHasType(fSchema, "object")) {
if (isSchemaObject(fSchema.properties)) {
Object.assign(
fieldUiSchema,
generateUiSchema(fieldSchema as RJSFSchema, options, fieldPath),
);
}
if (isSchemaObject(fSchema.additionalProperties)) {
// For dict-like schemas (additionalProperties), use "*" for path matching
const additionalSchema = generateUiSchema(
fSchema.additionalProperties as RJSFSchema,
options,
[...fieldPath, "*"],
);
if (Object.keys(additionalSchema).length > 0) {
fieldUiSchema.additionalProperties = additionalSchema;
}
}
}
if (Object.keys(fieldUiSchema).length > 0) {