frigate/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx
2026-02-27 09:40:21 -06:00

355 lines
11 KiB
TypeScript

// Object Field Template - renders nested object fields with i18n support
import { canExpand } from "@rjsf/utils";
import type { ObjectFieldTemplateProps } from "@rjsf/utils";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import { LuChevronDown, LuChevronRight, LuPlus } from "react-icons/lu";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { getTranslatedLabel } from "@/utils/i18n";
import { ConfigFormContext } from "@/types/configForm";
/**
* Build the i18n translation key path for nested fields using the field path
* 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 {
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) {
const {
title,
description,
properties,
uiSchema,
registry,
schema,
onAddProperty,
formData,
disabled,
readonly,
} = props;
const formContext = registry?.formContext as ConfigFormContext | undefined;
// Check if this is a root-level object
const isRoot = registry?.rootSchema === schema;
const [isOpen, setIsOpen] = useState(false);
const isCameraLevel = formContext?.level === "camera";
const effectiveNamespace = isCameraLevel ? "config/cameras" : "config/global";
const sectionI18nPrefix = formContext?.sectionI18nPrefix;
const { t, i18n } = useTranslation([
effectiveNamespace,
"config/groups",
"common",
]);
// Extract domain from i18nNamespace (e.g., "config/audio" -> "audio")
const getDomainFromNamespace = (ns?: string): string => {
if (!ns || !ns.startsWith("config/")) return "";
return ns.replace("config/", "");
};
const domain = getDomainFromNamespace(formContext?.i18nNamespace);
const groupDefinitions =
(uiSchema?.["ui:groups"] as Record<string, string[]> | undefined) || {};
const isHiddenProp = (prop: (typeof properties)[number]) =>
prop.content.props.uiSchema?.["ui:widget"] === "hidden";
const visibleProps = properties.filter((prop) => !isHiddenProp(prop));
// Check for advanced section grouping
const advancedProps = visibleProps.filter(
(p) => p.content.props.uiSchema?.["ui:options"]?.advanced === true,
);
const regularProps = visibleProps.filter(
(p) => p.content.props.uiSchema?.["ui:options"]?.advanced !== true,
);
const [showAdvanced, setShowAdvanced] = useState(false);
const toTitle = (value: string) =>
value.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
// Get the full translation path from the field path
const fieldPathId = (
props as { fieldPathId?: { path?: (string | number)[] } }
).fieldPathId;
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
for (let i = path.length - 1; i >= 0; i -= 1) {
const segment = path[i];
if (typeof segment === "string") {
propertyName = segment;
break;
}
}
}
// Try i18n translation, fall back to schema or original values
const i18nNs = effectiveNamespace;
let inferredLabel: string | undefined;
if (i18nNs && translationPath) {
const prefixedLabelKey =
sectionI18nPrefix && !translationPath.startsWith(`${sectionI18nPrefix}.`)
? `${sectionI18nPrefix}.${translationPath}.label`
: undefined;
const labelKey = `${translationPath}.label`;
if (prefixedLabelKey && i18n.exists(prefixedLabelKey, { ns: i18nNs })) {
inferredLabel = t(prefixedLabelKey, { ns: i18nNs });
} else if (i18n.exists(labelKey, { ns: i18nNs })) {
inferredLabel = t(labelKey, { ns: i18nNs });
}
}
if (!inferredLabel && translatedFilterLabel) {
inferredLabel = translatedFilterLabel;
}
const schemaTitle = schema?.title;
const fallbackLabel =
title || schemaTitle || (propertyName ? toTitle(propertyName) : undefined);
inferredLabel = inferredLabel ?? fallbackLabel;
let inferredDescription: string | undefined;
if (i18nNs && translationPath) {
const prefixedDescriptionKey =
sectionI18nPrefix && !translationPath.startsWith(`${sectionI18nPrefix}.`)
? `${sectionI18nPrefix}.${translationPath}.description`
: undefined;
const descriptionKey = `${translationPath}.description`;
if (
prefixedDescriptionKey &&
i18n.exists(prefixedDescriptionKey, { ns: i18nNs })
) {
inferredDescription = t(prefixedDescriptionKey, { ns: i18nNs });
} else if (i18n.exists(descriptionKey, { ns: i18nNs })) {
inferredDescription = t(descriptionKey, { ns: i18nNs });
}
}
const schemaDescription = schema?.description;
const fallbackDescription =
(typeof description === "string" ? description : undefined) ||
schemaDescription;
inferredDescription = inferredDescription ?? fallbackDescription;
const renderGroupedFields = (items: (typeof properties)[number][]) => {
if (!items.length) {
return null;
}
const grouped = new Set<string>();
const groups = Object.entries(groupDefinitions)
.map(([groupKey, fields]) => {
const ordered = fields
.map((field) => items.find((item) => item.name === field))
.filter(Boolean) as (typeof properties)[number][];
if (ordered.length === 0) {
return null;
}
ordered.forEach((item) => grouped.add(item.name));
const label = domain
? t(`${sectionI18nPrefix}.${domain}.${groupKey}`, {
ns: "config/groups",
defaultValue: toTitle(groupKey),
})
: t(`groups.${groupKey}`, {
defaultValue: toTitle(groupKey),
});
return {
key: groupKey,
label,
items: ordered,
};
})
.filter(Boolean) as Array<{
key: string;
label: string;
items: (typeof properties)[number][];
}>;
const ungrouped = items.filter((item) => !grouped.has(item.name));
return (
<div className="space-y-6">
{groups.map((group) => (
<div key={group.key} className="space-y-6">
<div className="text-md font-medium text-primary">
{group.label}
</div>
<div className="space-y-6">
{group.items.map((element) => (
<div key={element.name}>{element.content}</div>
))}
</div>
</div>
))}
{ungrouped.length > 0 && (
<div className={cn("space-y-6", groups.length > 0 && "pt-2")}>
{ungrouped.map((element) => (
<div key={element.name}>{element.content}</div>
))}
</div>
)}
</div>
);
};
const renderAddButton = () => {
const canAdd =
Boolean(onAddProperty) && canExpand(schema, uiSchema, formData);
if (!canAdd) {
return null;
}
return (
<Button
type="button"
variant="outline"
size="sm"
onClick={onAddProperty}
disabled={disabled || readonly}
className="gap-2"
>
<LuPlus className="h-4 w-4" />
{t("button.add", { ns: "common", defaultValue: "Add" })}
</Button>
);
};
// Root level renders children directly
if (isRoot) {
return (
<div className="space-y-6">
{renderGroupedFields(regularProps)}
{renderAddButton()}
{advancedProps.length > 0 && (
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="w-full justify-start gap-2 pl-0"
>
{showAdvanced ? (
<LuChevronDown className="h-4 w-4" />
) : (
<LuChevronRight className="h-4 w-4" />
)}
Advanced Settings ({advancedProps.length})
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-2">
{renderGroupedFields(advancedProps)}
</CollapsibleContent>
</Collapsible>
)}
</div>
);
}
// Nested objects render as collapsible cards
return (
<Card className="w-full">
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer transition-colors hover:bg-muted/50">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-sm">{inferredLabel}</CardTitle>
{inferredDescription && (
<p className="mt-1 text-xs text-muted-foreground">
{inferredDescription}
</p>
)}
</div>
{isOpen ? (
<LuChevronDown className="h-4 w-4" />
) : (
<LuChevronRight className="h-4 w-4" />
)}
</div>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-4 pt-0">
{renderGroupedFields(regularProps)}
{renderAddButton()}
{advancedProps.length > 0 && (
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-2 pl-0"
>
{showAdvanced ? (
<LuChevronDown className="h-4 w-4" />
) : (
<LuChevronRight className="h-4 w-4" />
)}
Advanced ({advancedProps.length})
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-2">
{renderGroupedFields(advancedProps)}
</CollapsibleContent>
</Collapsible>
)}
</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
);
}