mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-23 16:48:23 +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}}",
|
"summary": "Selected {{count}}",
|
||||||
"empty": "No object labels available"
|
"empty": "No object labels available"
|
||||||
},
|
},
|
||||||
|
"filters": {
|
||||||
|
"objectFieldLabel": "{{field}} for {{label}}"
|
||||||
|
},
|
||||||
"zoneNames": {
|
"zoneNames": {
|
||||||
"summary": "Selected {{count}}",
|
"summary": "Selected {{count}}",
|
||||||
"empty": "No zones available"
|
"empty": "No zones available"
|
||||||
|
|||||||
@ -10,6 +10,126 @@ 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";
|
||||||
|
|
||||||
|
// 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 {
|
export interface ConfigFormProps {
|
||||||
/** JSON Schema for the form */
|
/** JSON Schema for the form */
|
||||||
schema: RJSFSchema;
|
schema: RJSFSchema;
|
||||||
@ -89,10 +209,20 @@ export function ConfigForm({
|
|||||||
[schema, fieldOrder, effectiveHiddenFields, advancedFields, i18nNamespace],
|
[schema, fieldOrder, effectiveHiddenFields, advancedFields, i18nNamespace],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { baseUiSchema, pathOverrides } = useMemo(
|
||||||
|
() => splitUiSchemaOverrides(customUiSchema),
|
||||||
|
[customUiSchema],
|
||||||
|
);
|
||||||
|
|
||||||
// Merge generated uiSchema with custom overrides
|
// Merge generated uiSchema with custom overrides
|
||||||
const finalUiSchema = useMemo(() => {
|
const finalUiSchema = useMemo(() => {
|
||||||
// Start with generated schema
|
// Start with generated schema
|
||||||
const merged = mergeUiSchema(generatedUiSchema, customUiSchema);
|
const expandedUiSchema = applyUiSchemaPathOverrides(
|
||||||
|
generatedUiSchema,
|
||||||
|
transformedSchema,
|
||||||
|
pathOverrides,
|
||||||
|
);
|
||||||
|
const merged = mergeUiSchema(expandedUiSchema, baseUiSchema);
|
||||||
|
|
||||||
// Add field groups
|
// Add field groups
|
||||||
if (fieldGroups) {
|
if (fieldGroups) {
|
||||||
@ -105,7 +235,14 @@ export function ConfigForm({
|
|||||||
: { norender: true };
|
: { norender: true };
|
||||||
|
|
||||||
return merged;
|
return merged;
|
||||||
}, [generatedUiSchema, customUiSchema, showSubmit, fieldGroups]);
|
}, [
|
||||||
|
generatedUiSchema,
|
||||||
|
transformedSchema,
|
||||||
|
pathOverrides,
|
||||||
|
baseUiSchema,
|
||||||
|
showSubmit,
|
||||||
|
fieldGroups,
|
||||||
|
]);
|
||||||
|
|
||||||
// Create error transformer for user-friendly error messages
|
// Create error transformer for user-friendly error messages
|
||||||
const errorTransformer = useMemo(() => createErrorTransformer(i18n), [i18n]);
|
const errorTransformer = useMemo(() => createErrorTransformer(i18n), [i18n]);
|
||||||
|
|||||||
@ -500,6 +500,7 @@ export function createConfigSection({
|
|||||||
level === "camera" && cameraName
|
level === "camera" && cameraName
|
||||||
? config?.cameras?.[cameraName]
|
? config?.cameras?.[cameraName]
|
||||||
: undefined,
|
: undefined,
|
||||||
|
fullConfig: config,
|
||||||
t,
|
t,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -17,9 +17,21 @@ export const ObjectsSection = createConfigSection({
|
|||||||
"mask",
|
"mask",
|
||||||
"raw_mask",
|
"raw_mask",
|
||||||
"genai.enabled_in_config",
|
"genai.enabled_in_config",
|
||||||
|
"filters.*.mask",
|
||||||
|
"filters.*.raw_mask",
|
||||||
],
|
],
|
||||||
advancedFields: ["filters"],
|
advancedFields: ["filters"],
|
||||||
uiSchema: {
|
uiSchema: {
|
||||||
|
"filters.*.min_area": {
|
||||||
|
"ui:options": {
|
||||||
|
suppressMultiSchema: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"filters.*.max_area": {
|
||||||
|
"ui:options": {
|
||||||
|
suppressMultiSchema: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
track: {
|
track: {
|
||||||
"ui:widget": "objectLabels",
|
"ui:widget": "objectLabels",
|
||||||
"ui:options": {
|
"ui:options": {
|
||||||
|
|||||||
@ -4,13 +4,46 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { cn } from "@/lib/utils";
|
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";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
* 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 {
|
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) {
|
export function FieldTemplate(props: FieldTemplateProps) {
|
||||||
@ -35,7 +68,10 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
|||||||
| Record<string, unknown>
|
| Record<string, unknown>
|
||||||
| undefined;
|
| undefined;
|
||||||
const i18nNamespace = formContext?.i18nNamespace as string | undefined;
|
const i18nNamespace = formContext?.i18nNamespace as string | undefined;
|
||||||
const { t } = useTranslation([i18nNamespace || "common"]);
|
const { t, i18n } = useTranslation([
|
||||||
|
i18nNamespace || "common",
|
||||||
|
"views/settings",
|
||||||
|
]);
|
||||||
|
|
||||||
if (hidden) {
|
if (hidden) {
|
||||||
return <div className="hidden">{children}</div>;
|
return <div className="hidden">{children}</div>;
|
||||||
@ -61,7 +97,14 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
|||||||
(schema.anyOf || schema.oneOf) && (suppressMultiSchema || isNullableUnion);
|
(schema.anyOf || schema.oneOf) && (suppressMultiSchema || isNullableUnion);
|
||||||
|
|
||||||
// Get translation path for this field
|
// 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)
|
// Use schema title/description as primary source (from JSON Schema)
|
||||||
const schemaTitle = (schema as Record<string, unknown>).title as
|
const schemaTitle = (schema as Record<string, unknown>).title as
|
||||||
@ -75,29 +118,80 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
|||||||
let finalLabel = label;
|
let finalLabel = label;
|
||||||
if (i18nNamespace && translationPath) {
|
if (i18nNamespace && translationPath) {
|
||||||
const translationKey = `${translationPath}.label`;
|
const translationKey = `${translationPath}.label`;
|
||||||
const translatedLabel = t(translationKey, {
|
if (i18n.exists(translationKey, { ns: i18nNamespace })) {
|
||||||
ns: i18nNamespace,
|
finalLabel = 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;
|
|
||||||
} else if (schemaTitle) {
|
} else if (schemaTitle) {
|
||||||
finalLabel = 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) {
|
} else if (schemaTitle) {
|
||||||
finalLabel = 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
|
// Try to get translated description, falling back to schema description
|
||||||
let finalDescription = description || "";
|
let finalDescription = description || "";
|
||||||
if (i18nNamespace && translationPath) {
|
if (i18nNamespace && translationPath) {
|
||||||
const translatedDesc = t(`${translationPath}.description`, {
|
const descriptionKey = `${translationPath}.description`;
|
||||||
ns: i18nNamespace,
|
if (i18n.exists(descriptionKey, { ns: i18nNamespace })) {
|
||||||
defaultValue: "",
|
finalDescription = t(descriptionKey, { ns: i18nNamespace });
|
||||||
});
|
|
||||||
if (translatedDesc && translatedDesc !== `${translationPath}.description`) {
|
|
||||||
finalDescription = translatedDesc;
|
|
||||||
} else if (schemaDescription) {
|
} else if (schemaDescription) {
|
||||||
finalDescription = schemaDescription;
|
finalDescription = schemaDescription;
|
||||||
}
|
}
|
||||||
@ -114,18 +208,22 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
|||||||
)}
|
)}
|
||||||
data-field-id={translationPath}
|
data-field-id={translationPath}
|
||||||
>
|
>
|
||||||
{displayLabel && finalLabel && !isBoolean && !isMultiSchemaWrapper && (
|
{displayLabel &&
|
||||||
<Label
|
finalLabel &&
|
||||||
htmlFor={id}
|
!isBoolean &&
|
||||||
className={cn(
|
!isMultiSchemaWrapper &&
|
||||||
"text-sm font-medium",
|
!isObjectField && (
|
||||||
errors && errors.props?.errors?.length > 0 && "text-destructive",
|
<Label
|
||||||
)}
|
htmlFor={id}
|
||||||
>
|
className={cn(
|
||||||
{finalLabel}
|
"text-sm font-medium",
|
||||||
{required && <span className="ml-1 text-destructive">*</span>}
|
errors && errors.props?.errors?.length > 0 && "text-destructive",
|
||||||
</Label>
|
)}
|
||||||
)}
|
>
|
||||||
|
{finalLabel}
|
||||||
|
{required && <span className="ml-1 text-destructive">*</span>}
|
||||||
|
</Label>
|
||||||
|
)}
|
||||||
|
|
||||||
{isBoolean ? (
|
{isBoolean ? (
|
||||||
<div className="flex w-full items-center justify-between gap-4">
|
<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 { LuChevronDown, LuChevronRight } 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";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
* 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 {
|
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) {
|
export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
||||||
@ -30,7 +56,7 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
|||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(true);
|
const [isOpen, setIsOpen] = useState(true);
|
||||||
|
|
||||||
const { t } = useTranslation([
|
const { t, i18n } = useTranslation([
|
||||||
formContext?.i18nNamespace || "common",
|
formContext?.i18nNamespace || "common",
|
||||||
"config/groups",
|
"config/groups",
|
||||||
]);
|
]);
|
||||||
@ -71,6 +97,10 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
|||||||
let propertyName: string | undefined;
|
let propertyName: string | undefined;
|
||||||
let translationPath: string | undefined;
|
let translationPath: string | undefined;
|
||||||
const path = fieldPathId?.path;
|
const path = fieldPathId?.path;
|
||||||
|
const filterObjectLabel = path ? getFilterObjectLabel(path) : undefined;
|
||||||
|
const translatedFilterLabel = filterObjectLabel
|
||||||
|
? getTranslatedLabel(filterObjectLabel, "object")
|
||||||
|
: undefined;
|
||||||
if (path) {
|
if (path) {
|
||||||
translationPath = buildTranslationPath(path);
|
translationPath = buildTranslationPath(path);
|
||||||
// Also get the last property name for fallback label generation
|
// Also get the last property name for fallback label generation
|
||||||
@ -88,11 +118,13 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
|||||||
|
|
||||||
let inferredLabel: string | undefined;
|
let inferredLabel: string | undefined;
|
||||||
if (i18nNs && translationPath) {
|
if (i18nNs && translationPath) {
|
||||||
const translated = t(`${translationPath}.label`, {
|
const labelKey = `${translationPath}.label`;
|
||||||
ns: i18nNs,
|
if (i18n.exists(labelKey, { ns: i18nNs })) {
|
||||||
defaultValue: "",
|
inferredLabel = t(labelKey, { ns: i18nNs });
|
||||||
});
|
}
|
||||||
inferredLabel = translated || undefined;
|
}
|
||||||
|
if (!inferredLabel && translatedFilterLabel) {
|
||||||
|
inferredLabel = translatedFilterLabel;
|
||||||
}
|
}
|
||||||
const schemaTitle = schema?.title;
|
const schemaTitle = schema?.title;
|
||||||
const fallbackLabel =
|
const fallbackLabel =
|
||||||
@ -101,11 +133,10 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
|||||||
|
|
||||||
let inferredDescription: string | undefined;
|
let inferredDescription: string | undefined;
|
||||||
if (i18nNs && translationPath) {
|
if (i18nNs && translationPath) {
|
||||||
const translated = t(`${translationPath}.description`, {
|
const descriptionKey = `${translationPath}.description`;
|
||||||
ns: i18nNs,
|
if (i18n.exists(descriptionKey, { ns: i18nNs })) {
|
||||||
defaultValue: "",
|
inferredDescription = t(descriptionKey, { ns: i18nNs });
|
||||||
});
|
}
|
||||||
inferredDescription = translated || undefined;
|
|
||||||
}
|
}
|
||||||
const schemaDescription = schema?.description;
|
const schemaDescription = schema?.description;
|
||||||
const fallbackDescription =
|
const fallbackDescription =
|
||||||
|
|||||||
@ -3,8 +3,45 @@ import type { WidgetProps } from "@rjsf/utils";
|
|||||||
import { SwitchesWidget } from "./SwitchesWidget";
|
import { SwitchesWidget } from "./SwitchesWidget";
|
||||||
import type { FormContext } from "./SwitchesWidget";
|
import type { FormContext } from "./SwitchesWidget";
|
||||||
import { getTranslatedLabel } from "@/utils/i18n";
|
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[] {
|
function getObjectLabels(context: FormContext): string[] {
|
||||||
|
const labelmapLabels = getLabelmapLabels(context);
|
||||||
let cameraLabels: string[] = [];
|
let cameraLabels: string[] = [];
|
||||||
let globalLabels: string[] = [];
|
let globalLabels: string[] = [];
|
||||||
|
|
||||||
@ -26,7 +63,8 @@ function getObjectLabels(context: FormContext): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sourceLabels = cameraLabels.length > 0 ? cameraLabels : globalLabels;
|
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 {
|
function getObjectLabelDisplayName(label: string): string {
|
||||||
|
|||||||
@ -9,11 +9,13 @@ import {
|
|||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} 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";
|
||||||
|
|
||||||
type FormContext = {
|
type FormContext = {
|
||||||
cameraValue?: Record<string, unknown>;
|
cameraValue?: Record<string, unknown>;
|
||||||
globalValue?: Record<string, unknown>;
|
globalValue?: Record<string, unknown>;
|
||||||
fullCameraConfig?: Record<string, unknown>;
|
fullCameraConfig?: CameraConfig;
|
||||||
|
fullConfig?: FrigateConfig;
|
||||||
t?: (key: string, options?: Record<string, unknown>) => string;
|
t?: (key: string, options?: Record<string, unknown>) => string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -408,6 +408,7 @@ function getWidgetForField(
|
|||||||
function generateUiSchema(
|
function generateUiSchema(
|
||||||
schema: RJSFSchema,
|
schema: RJSFSchema,
|
||||||
options: UiSchemaOptions = {},
|
options: UiSchemaOptions = {},
|
||||||
|
currentPath: string[] = [],
|
||||||
): UiSchema {
|
): UiSchema {
|
||||||
const uiSchema: UiSchema = {};
|
const uiSchema: UiSchema = {};
|
||||||
const {
|
const {
|
||||||
@ -418,6 +419,24 @@ function generateUiSchema(
|
|||||||
includeDescriptions = true,
|
includeDescriptions = true,
|
||||||
} = options;
|
} = 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>;
|
const schemaObj = schema as Record<string, unknown>;
|
||||||
|
|
||||||
// Set field ordering
|
// Set field ordering
|
||||||
@ -437,8 +456,15 @@ function generateUiSchema(
|
|||||||
const fSchema = fieldSchema as Record<string, unknown>;
|
const fSchema = fieldSchema as Record<string, unknown>;
|
||||||
const fieldUiSchema: UiSchema = {};
|
const fieldUiSchema: UiSchema = {};
|
||||||
|
|
||||||
|
// Track full path to support wildcard-based rules
|
||||||
|
const fieldPath = [...currentPath, fieldName];
|
||||||
|
|
||||||
// Hidden fields
|
// Hidden fields
|
||||||
if (hiddenFields.includes(fieldName)) {
|
if (
|
||||||
|
hiddenFieldPatterns.some((pattern) =>
|
||||||
|
matchesPathPattern(fieldPath, pattern),
|
||||||
|
)
|
||||||
|
) {
|
||||||
fieldUiSchema["ui:widget"] = "hidden";
|
fieldUiSchema["ui:widget"] = "hidden";
|
||||||
uiSchema[fieldName] = fieldUiSchema;
|
uiSchema[fieldName] = fieldUiSchema;
|
||||||
continue;
|
continue;
|
||||||
@ -460,7 +486,11 @@ function generateUiSchema(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Advanced fields - mark for collapsible
|
// Advanced fields - mark for collapsible
|
||||||
if (advancedFields.includes(fieldName)) {
|
if (
|
||||||
|
advancedFieldPatterns.some((pattern) =>
|
||||||
|
matchesPathPattern(fieldPath, pattern),
|
||||||
|
)
|
||||||
|
) {
|
||||||
fieldUiSchema["ui:options"] = {
|
fieldUiSchema["ui:options"] = {
|
||||||
...((fieldUiSchema["ui:options"] as object) || {}),
|
...((fieldUiSchema["ui:options"] as object) || {}),
|
||||||
advanced: true,
|
advanced: true,
|
||||||
@ -468,28 +498,25 @@ function generateUiSchema(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle nested objects recursively
|
// Handle nested objects recursively
|
||||||
if (
|
if (schemaHasType(fSchema, "object")) {
|
||||||
schemaHasType(fSchema, "object") &&
|
if (isSchemaObject(fSchema.properties)) {
|
||||||
isSchemaObject(fSchema.properties)
|
Object.assign(
|
||||||
) {
|
fieldUiSchema,
|
||||||
const nestedOptions: UiSchemaOptions = {
|
generateUiSchema(fieldSchema as RJSFSchema, options, fieldPath),
|
||||||
hiddenFields: hiddenFields
|
);
|
||||||
.filter((f) => f.startsWith(`${fieldName}.`))
|
}
|
||||||
.map((f) => f.replace(`${fieldName}.`, "")),
|
|
||||||
advancedFields: advancedFields
|
if (isSchemaObject(fSchema.additionalProperties)) {
|
||||||
.filter((f) => f.startsWith(`${fieldName}.`))
|
// For dict-like schemas (additionalProperties), use "*" for path matching
|
||||||
.map((f) => f.replace(`${fieldName}.`, "")),
|
const additionalSchema = generateUiSchema(
|
||||||
widgetMappings: Object.fromEntries(
|
fSchema.additionalProperties as RJSFSchema,
|
||||||
Object.entries(widgetMappings)
|
options,
|
||||||
.filter(([k]) => k.startsWith(`${fieldName}.`))
|
[...fieldPath, "*"],
|
||||||
.map(([k, v]) => [k.replace(`${fieldName}.`, ""), v]),
|
);
|
||||||
),
|
if (Object.keys(additionalSchema).length > 0) {
|
||||||
includeDescriptions,
|
fieldUiSchema.additionalProperties = additionalSchema;
|
||||||
};
|
}
|
||||||
Object.assign(
|
}
|
||||||
fieldUiSchema,
|
|
||||||
generateUiSchema(fieldSchema as RJSFSchema, nestedOptions),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(fieldUiSchema).length > 0) {
|
if (Object.keys(fieldUiSchema).length > 0) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user