mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-10 10:33:11 +03:00
add wildcards and fix object filter fields
This commit is contained in:
parent
ba1d5d42c5
commit
06c21bf6f2
@ -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"
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -500,6 +500,7 @@ export function createConfigSection({
|
||||
level === "camera" && cameraName
|
||||
? config?.cameras?.[cameraName]
|
||||
: undefined,
|
||||
fullConfig: config,
|
||||
t,
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user