// 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 { 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) { 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 | 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(); 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 (
{groups.map((group) => (
{group.label}
{group.items.map((element) => (
{element.content}
))}
))} {ungrouped.length > 0 && (
0 && "pt-2")}> {ungrouped.map((element) => (
{element.content}
))}
)}
); }; const renderAddButton = () => { const canAdd = Boolean(onAddProperty) && canExpand(schema, uiSchema, formData); if (!canAdd) { return null; } return ( ); }; // Root level renders children directly if (isRoot) { return (
{renderGroupedFields(regularProps)} {renderAddButton()} {advancedProps.length > 0 && ( {renderGroupedFields(advancedProps)} )}
); } // Nested objects render as collapsible cards return (
{inferredLabel} {inferredDescription && (

{inferredDescription}

)}
{isOpen ? ( ) : ( )}
{renderGroupedFields(regularProps)} {renderAddButton()} {advancedProps.length > 0 && ( {renderGroupedFields(advancedProps)} )}
); }