From 06c21bf6f2d24a5d4b38ff58b5821c130a8b0ddc Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Fri, 30 Jan 2026 08:52:11 -0600 Subject: [PATCH] add wildcards and fix object filter fields --- web/public/locales/en/views/settings.json | 3 + web/src/components/config-form/ConfigForm.tsx | 141 +++++++++++++++- .../config-form/sections/BaseSection.tsx | 1 + .../config-form/sections/ObjectsSection.tsx | 12 ++ .../theme/templates/FieldTemplate.tsx | 156 ++++++++++++++---- .../theme/templates/ObjectFieldTemplate.tsx | 57 +++++-- .../widgets/ObjectLabelSwitchesWidget.tsx | 40 ++++- .../theme/widgets/SwitchesWidget.tsx | 4 +- web/src/lib/config-schema/transformer.ts | 75 ++++++--- 9 files changed, 419 insertions(+), 70 deletions(-) diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index d03b5f2b3..06cf9fccf 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -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" diff --git a/web/src/components/config-form/ConfigForm.tsx b/web/src/components/config-form/ConfigForm.tsx index 279e11214..669c7bdff 100644 --- a/web/src/components/config-form/ConfigForm.tsx +++ b/web/src/components/config-form/ConfigForm.tsx @@ -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 => + 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; + + if (segment === "*") { + if (isSchemaObject(schemaObj.properties)) { + Object.entries(schemaObj.properties as Record).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)[ + 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]); diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index 979d9b9f8..1ea3fcb0e 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -500,6 +500,7 @@ export function createConfigSection({ level === "camera" && cameraName ? config?.cameras?.[cameraName] : undefined, + fullConfig: config, t, }} /> diff --git a/web/src/components/config-form/sections/ObjectsSection.tsx b/web/src/components/config-form/sections/ObjectsSection.tsx index e4953958d..09141b8f8 100644 --- a/web/src/components/config-form/sections/ObjectsSection.tsx +++ b/web/src/components/config-form/sections/ObjectsSection.tsx @@ -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": { diff --git a/web/src/components/config-form/theme/templates/FieldTemplate.tsx b/web/src/components/config-form/theme/templates/FieldTemplate.tsx index 8022c7f8f..adad4215b 100644 --- a/web/src/components/config-form/theme/templates/FieldTemplate.tsx +++ b/web/src/components/config-form/theme/templates/FieldTemplate.tsx @@ -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 { - 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 | undefined; const i18nNamespace = formContext?.i18nNamespace as string | undefined; - const { t } = useTranslation([i18nNamespace || "common"]); + const { t, i18n } = useTranslation([ + i18nNamespace || "common", + "views/settings", + ]); if (hidden) { return
{children}
; @@ -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).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 && ( - - )} + {displayLabel && + finalLabel && + !isBoolean && + !isMultiSchemaWrapper && + !isObjectField && ( + + )} {isBoolean ? (
diff --git a/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx b/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx index 4e1dba1fd..925a11fc4 100644 --- a/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx +++ b/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx @@ -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 { - 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 | 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 = diff --git a/web/src/components/config-form/theme/widgets/ObjectLabelSwitchesWidget.tsx b/web/src/components/config-form/theme/widgets/ObjectLabelSwitchesWidget.tsx index db4735011..ad25952fc 100644 --- a/web/src/components/config-form/theme/widgets/ObjectLabelSwitchesWidget.tsx +++ b/web/src/components/config-form/theme/widgets/ObjectLabelSwitchesWidget.tsx @@ -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) { + if (!labelmap || typeof labelmap !== "object") { + return; + } + + Object.values(labelmap as Record).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(); + 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([...labelmapLabels, ...sourceLabels]); + return [...combinedLabels].sort(); } function getObjectLabelDisplayName(label: string): string { diff --git a/web/src/components/config-form/theme/widgets/SwitchesWidget.tsx b/web/src/components/config-form/theme/widgets/SwitchesWidget.tsx index 1b84d440e..6d85a4d7b 100644 --- a/web/src/components/config-form/theme/widgets/SwitchesWidget.tsx +++ b/web/src/components/config-form/theme/widgets/SwitchesWidget.tsx @@ -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; globalValue?: Record; - fullCameraConfig?: Record; + fullCameraConfig?: CameraConfig; + fullConfig?: FrigateConfig; t?: (key: string, options?: Record) => string; }; diff --git a/web/src/lib/config-schema/transformer.ts b/web/src/lib/config-schema/transformer.ts index 9a498b681..8681266fa 100644 --- a/web/src/lib/config-schema/transformer.ts +++ b/web/src/lib/config-schema/transformer.ts @@ -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; // Set field ordering @@ -437,8 +456,15 @@ function generateUiSchema( const fSchema = fieldSchema as Record; 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) {