/** * LayoutGridField - RJSF field for responsive, semantic grid layouts * * Overview: * - Apply a responsive grid to object properties using `ui:layoutGrid` while * preserving the default `ObjectFieldTemplate` behavior (cards, nested * collapsibles, add button, and i18n). * - Falls back to the original template when `ui:layoutGrid` is not present. * * Capabilities: * - 12-column grid logic. `ui:col` accepts a number (1-12) or a Tailwind class * string (e.g. "col-span-12 md:col-span-4") for responsive column widths. * - Per-row and global class overrides: * - `ui:options.layoutGrid.rowClassName` (default: "grid-cols-12") is merged * with the base `grid gap-4` classes. * - `ui:options.layoutGrid.advancedRowClassName` (default: "grid-cols-12") * controls advanced-section rows. * - Per-row `ui:className` and per-column `ui:className`/`className` are * supported for fine-grained layout control. * - Optional `useGridForAdvanced` (via `ui:options.layoutGrid`) to toggle * whether advanced fields use the grid or fall back to stacked layout. * - Integrates with `ui:groups` to show semantic group labels (resolved via * `config/groups` i18n). If a layout row contains fields from the same group, * that row shows the group label above it; leftover or ungrouped fields are * rendered after the configured rows. * - Hidden fields (`ui:widget: "hidden"`) are ignored. * * Internationalization * - Advanced collapsible labels use `label.advancedSettingsCount` and * `label.advancedCount` in the `common` namespace. * - Group labels are looked up in `config/groups` (uses `sectionI18nPrefix` * when available). * * Usage examples: * Basic: * { * "ui:field": "LayoutGridField", * "ui:layoutGrid": [ * { "ui:row": ["field1", "field2"] }, * { "ui:row": ["field3"] } * ] * } * * Custom columns and responsive classes: * { * "ui:field": "LayoutGridField", * "ui:options": { * "layoutGrid": { "rowClassName": "grid-cols-12 md:grid-cols-6", "useGridForAdvanced": true } * }, * "ui:layoutGrid": [ * { * "ui:row": [ * { "field1": { "ui:col": "col-span-12 md:col-span-4", "ui:className": "md:col-start-2" } }, * { "field2": { "ui:col": 4 } } * ], * "ui:className": "gap-6" * } * ] * } * * Groups and rows: * { * "ui:field": "LayoutGridField", * "ui:groups": { "resolution": ["fps","width","height"], "tracking": ["min_initialized","max_disappeared"] }, * "ui:layoutGrid": [ * { "ui:row": ["enabled"] }, * { "ui:row": ["fps","width","height"] } * ] * } * * Notes: * - `ui:layoutGrid` must be an array; non-array values are ignored and the * default ObjectFieldTemplate is used instead. * - This implementation adheres to RJSF patterns (use `ui:options`, * `ui:className`, and `ui:layoutGrid` as documented) while adding a few * Frigate-specific conveniences (defaults and Tailwind-friendly class * handling). */ import type { FieldProps, ObjectFieldTemplateProps } from "@rjsf/utils"; import { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { cn } from "@/lib/utils"; import { ConfigFormContext } from "@/types/configForm"; import { getDomainFromNamespace, hasOverrideAtPath, humanizeKey, } from "../utils"; import { AddPropertyButton, AdvancedCollapsible } from "../components"; type LayoutGridColumnConfig = { "ui:col"?: number | string; "ui:className"?: string; className?: string; }; type LayoutRow = { "ui:row": Array>; "ui:className"?: string; className?: string; }; type LayoutGrid = LayoutRow[]; type LayoutGridOptions = { rowClassName?: string; advancedRowClassName?: string; useGridForAdvanced?: boolean; }; interface PropertyElement { name: string; content: React.ReactElement; } function isObjectLikeElement(item: PropertyElement) { const fieldSchema = item.content.props?.schema as | { type?: string | string[] } | undefined; return fieldSchema?.type === "object"; } // Custom ObjectFieldTemplate wrapper that applies grid layout function GridLayoutObjectFieldTemplate( props: ObjectFieldTemplateProps, originalObjectFieldTemplate: React.ComponentType, ) { const { uiSchema, properties, registry, schema, onAddProperty, formData, disabled, readonly, } = props; const formContext = registry?.formContext as ConfigFormContext | undefined; const { t } = useTranslation(["common", "config/groups"]); // Use the original ObjectFieldTemplate passed as parameter, not from registry const ObjectFieldTemplate = originalObjectFieldTemplate; // Get layout configuration const layoutGrid = Array.isArray(uiSchema?.["ui:layoutGrid"]) ? (uiSchema?.["ui:layoutGrid"] as LayoutGrid) : []; const layoutGridOptions = (uiSchema?.["ui:options"] as { layoutGrid?: LayoutGridOptions } | undefined) ?.layoutGrid ?? {}; const baseRowClassName = layoutGridOptions.rowClassName ?? "grid-cols-12"; const advancedRowClassName = layoutGridOptions.advancedRowClassName ?? "grid-cols-12"; const useGridForAdvanced = layoutGridOptions.useGridForAdvanced ?? true; const groupDefinitions = (uiSchema?.["ui:groups"] as Record | undefined) || {}; const overrides = formContext?.overrides; const fieldPath = props.fieldPathId.path; const isPathModified = (path: Array) => hasOverrideAtPath(overrides, path, formContext?.formData); // Override the properties rendering with grid layout const isHiddenProp = (prop: (typeof properties)[number]) => prop.content.props.uiSchema?.["ui:widget"] === "hidden"; const visibleProps = properties.filter((prop) => !isHiddenProp(prop)); // Separate regular and advanced properties 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 hasModifiedAdvanced = advancedProps.some((prop) => isPathModified([...fieldPath, prop.name]), ); const [showAdvanced, setShowAdvanced] = useState(hasModifiedAdvanced); // If no layout grid is defined, use the default template if (layoutGrid.length === 0) { return ; } const domain = getDomainFromNamespace(formContext?.i18nNamespace); const sectionI18nPrefix = formContext?.sectionI18nPrefix; const getGroupLabel = (groupKey: string) => { if (domain && sectionI18nPrefix) { return t(`${sectionI18nPrefix}.${domain}.${groupKey}`, { ns: "config/groups", defaultValue: humanizeKey(groupKey), }); } return t(`groups.${groupKey}`, { ns: "config/groups", defaultValue: humanizeKey(groupKey), }); }; // Render fields using the layout grid structure const renderGridLayout = (items: PropertyElement[], rowClassName: string) => { if (!items.length) { return null; } // Create a map for quick lookup const itemMap = new Map(items.map((item) => [item.name, item])); const renderedFields = new Set(); return (
{layoutGrid.map((rowDef, rowIndex) => { const rowItems = rowDef["ui:row"]; const cols: React.ReactNode[] = []; rowItems.forEach((colDef, colIndex) => { let fieldName: string; let colSpan: number | string = 12; // Default to full width let colClassName: string | undefined; if (typeof colDef === "string") { fieldName = colDef; } else { // Object with field name as key and ui:col as value const entries = Object.entries(colDef); if (entries.length === 0) return; const [name, config] = entries[0]; fieldName = name; colSpan = config["ui:col"] ?? 12; colClassName = config["ui:className"] ?? config.className; } const item = itemMap.get(fieldName); if (!item) return; renderedFields.add(fieldName); // Calculate column width class (using 12-column grid) const colSpanClass = typeof colSpan === "string" ? colSpan : `col-span-${colSpan}`; const colClass = cn(colSpanClass, colClassName); cols.push(
{item.content}
, ); }); if (cols.length === 0) return null; const rowClass = cn( "grid gap-4", rowClassName, rowDef["ui:className"], rowDef.className, ); return (
{cols}
); })} {Array.from(itemMap.keys()) .filter((name) => !renderedFields.has(name)) .map((name) => { const item = itemMap.get(name); return item ? (
{item.content}
) : null; })}
); }; const renderGroupedGridLayout = ( items: PropertyElement[], rowClassName: string, ) => { if (!items.length) { return null; } if (Object.keys(groupDefinitions).length === 0) { return renderGridLayout(items, rowClassName); } const itemMap = new Map(items.map((item) => [item.name, item])); const renderedFields = new Set(); const renderedGroups = new Set(); const groupMap = new Map(); Object.entries(groupDefinitions).forEach(([groupKey, fields]) => { fields.forEach((field) => { groupMap.set(field, groupKey); }); }); const rows = layoutGrid .map((rowDef, rowIndex) => { const rowItems = rowDef["ui:row"]; const cols: React.ReactNode[] = []; const rowFieldNames: string[] = []; rowItems.forEach((colDef, colIndex) => { let fieldName: string; let colSpan: number | string = 12; let colClassName: string | undefined; if (typeof colDef === "string") { fieldName = colDef; } else { const entries = Object.entries(colDef); if (entries.length === 0) return; const [name, config] = entries[0]; fieldName = name; colSpan = config["ui:col"] ?? 12; colClassName = config["ui:className"] ?? config.className; } const item = itemMap.get(fieldName); if (!item) return; renderedFields.add(fieldName); rowFieldNames.push(fieldName); const colSpanClass = typeof colSpan === "string" ? colSpan : `col-span-${colSpan}`; const colClass = cn(colSpanClass, colClassName); cols.push(
{item.content}
, ); }); if (cols.length === 0) return null; const rowClass = cn( "grid gap-4", rowClassName, rowDef["ui:className"], rowDef.className, ); const rowGroupKeys = rowFieldNames .map((name) => groupMap.get(name)) .filter(Boolean) as string[]; const rowGroupKey = rowGroupKeys.length > 0 && rowGroupKeys.length === rowFieldNames.length && new Set(rowGroupKeys).size === 1 ? rowGroupKeys[0] : undefined; const showGroupLabel = rowGroupKey && !renderedGroups.has(rowGroupKey); if (showGroupLabel) { renderedGroups.add(rowGroupKey); } return (
{showGroupLabel && (
{getGroupLabel(rowGroupKey)}
)}
{cols}
); }) .filter(Boolean); const remainingItems = Array.from(itemMap.keys()) .filter((name) => !renderedFields.has(name)) .map((name) => itemMap.get(name)) .filter(Boolean) as PropertyElement[]; const groupedLeftovers = new Map(); const ungroupedLeftovers: PropertyElement[] = []; remainingItems.forEach((item) => { const groupKey = groupMap.get(item.name); if (groupKey) { const existing = groupedLeftovers.get(groupKey); if (existing) { existing.push(item); } else { groupedLeftovers.set(groupKey, [item]); } } else { ungroupedLeftovers.push(item); } }); const leftoverSections: React.ReactNode[] = []; groupedLeftovers.forEach((groupItems, groupKey) => { const showGroupLabel = !renderedGroups.has(groupKey); if (showGroupLabel) { renderedGroups.add(groupKey); } leftoverSections.push(
{showGroupLabel && (
{getGroupLabel(groupKey)}
)}
{groupItems.map((item) => (
{item.content}
))}
, ); }); if (ungroupedLeftovers.length > 0) { leftoverSections.push(
0 || groupedLeftovers.size > 0) && "pt-2", )} > {ungroupedLeftovers.map((item) => (
0 && !isObjectLikeElement(item) && "px-4", )} > {item.content}
))}
, ); } return (
{rows} {leftoverSections}
); }; const renderStackedLayout = (items: PropertyElement[]) => { if (!items.length) { return null; } return (
{items.map((item) => (
{item.content}
))}
); }; const regularLayout = renderGroupedGridLayout(regularProps, baseRowClassName); const advancedLayout = useGridForAdvanced ? renderGroupedGridLayout(advancedProps, advancedRowClassName) : renderStackedLayout(advancedProps); // Create modified props with custom property rendering // Render using the original template but with our custom content const isRoot = registry?.rootSchema === props.schema; if (isRoot) { return (
{regularLayout} {advancedLayout}
); } // We need to inject our custom rendering into the template // Since we can't directly modify the template's internal rendering, // we'll render the full structure ourselves return (
{regularLayout} {advancedLayout}
); } export function LayoutGridField(props: FieldProps) { const { registry, schema, uiSchema, idSchema, formData } = props; // Store the original ObjectFieldTemplate before any modifications const originalObjectFieldTemplate = registry.templates.ObjectFieldTemplate; // Get the ObjectField component from the registry const ObjectField = registry.fields.ObjectField; // Create a modified registry with our custom template // But we'll pass the original template to it to prevent circular reference const gridObjectFieldTemplate = useCallback( (tProps: ObjectFieldTemplateProps) => GridLayoutObjectFieldTemplate(tProps, originalObjectFieldTemplate), [originalObjectFieldTemplate], ); const modifiedRegistry = useMemo( () => ({ ...registry, templates: { ...registry.templates, ObjectFieldTemplate: gridObjectFieldTemplate, }, }), [registry, gridObjectFieldTemplate], ); // Delegate to ObjectField with the modified registry return ( ); }