From 90d2ebfe193e31f00e31c1831b23cf681c37cc3a Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 5 Feb 2026 08:53:08 -0600 Subject: [PATCH] add layout grid field --- web/public/locales/en/views/settings.json | 2 + .../config-form/section-configs/detect.ts | 52 +- .../theme/fields/LayoutGridField.tsx | 599 ++++++++++++++++++ .../config-form/theme/fields/index.ts | 2 + .../config-form/theme/frigateTheme.ts | 6 +- .../theme/templates/ObjectFieldTemplate.tsx | 115 ++-- 6 files changed, 721 insertions(+), 55 deletions(-) create mode 100644 web/src/components/config-form/theme/fields/LayoutGridField.tsx create mode 100644 web/src/components/config-form/theme/fields/index.ts diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 5ed2a576d..3025f1c44 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1184,6 +1184,8 @@ "title": "Camera Settings", "description": "These settings apply only to this camera and override the global settings." }, + "advancedSettingsCount": "Advanced Settings ({{count}})", + "advancedCount": "Advanced ({{count}})", "showAdvanced": "Show Advanced Settings", "tabs": { "sharedDefaults": "Shared Defaults", diff --git a/web/src/components/config-form/section-configs/detect.ts b/web/src/components/config-form/section-configs/detect.ts index 722d81ef3..9639216aa 100644 --- a/web/src/components/config-form/section-configs/detect.ts +++ b/web/src/components/config-form/section-configs/detect.ts @@ -4,18 +4,19 @@ const detect: SectionConfigOverrides = { base: { sectionDocs: "/configuration/camera_specific", restartRequired: [], - fieldOrder: [ - "enabled", - "fps", - "width", - "height", - "min_initialized", - "max_disappeared", - "annotation_offset", - "stationary", - ], + // use the grid for field order + // fieldOrder: [ + // "enabled", + // "width", + // "height", + // "fps", + // "min_initialized", + // "max_disappeared", + // "annotation_offset", + // "stationary", + // ], fieldGroups: { - resolution: ["enabled", "width", "height"], + resolution: ["width", "height", "fps"], tracking: ["min_initialized", "max_disappeared"], }, hiddenFields: ["enabled_in_config"], @@ -25,6 +26,35 @@ const detect: SectionConfigOverrides = { "annotation_offset", "stationary", ], + uiSchema: { + "ui:field": "LayoutGridField", + "ui:layoutGrid": [ + { + "ui:row": [{ enabled: { "ui:col": "col-span-12 lg:col-span-4" } }], + }, + { + "ui:row": [ + { width: { "ui:col": "col-span-12 md:col-span-4" } }, + { height: { "ui:col": "col-span-12 md:col-span-4" } }, + { fps: { "ui:col": "col-span-12 md:col-span-4" } }, + ], + }, + { + "ui:row": [ + { min_initialized: { "ui:col": "col-span-12 md:col-span-6" } }, + { max_disappeared: { "ui:col": "col-span-12 md:col-span-6" } }, + ], + }, + { + "ui:row": [ + { annotation_offset: { "ui:col": "col-span-12 md:col-span-3" } }, + ], + }, + { + "ui:row": [{ stationary: { "ui:col": "col-span-12 md:col-span-6" } }], + }, + ], + }, }, }; diff --git a/web/src/components/config-form/theme/fields/LayoutGridField.tsx b/web/src/components/config-form/theme/fields/LayoutGridField.tsx new file mode 100644 index 000000000..793720b8c --- /dev/null +++ b/web/src/components/config-form/theme/fields/LayoutGridField.tsx @@ -0,0 +1,599 @@ +/** + * 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 { canExpand } from "@rjsf/utils"; +import type { FieldProps, ObjectFieldTemplateProps } from "@rjsf/utils"; +import { useState } from "react"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { Button } from "@/components/ui/button"; +import { LuChevronDown, LuChevronRight, LuPlus } from "react-icons/lu"; +import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/utils"; +import { ConfigFormContext } from "@/types/configForm"; + +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; +} + +// 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 [showAdvanced, setShowAdvanced] = useState(false); + 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) || {}; + + // If no layout grid is defined, use the default template + if (layoutGrid.length === 0) { + return ; + } + + // 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, + ); + + // 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 sectionI18nPrefix = formContext?.sectionI18nPrefix; + + const toTitle = (value: string) => + value.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase()); + + const getGroupLabel = (groupKey: string) => { + if (domain && sectionI18nPrefix) { + return t(`${sectionI18nPrefix}.${domain}.${groupKey}`, { + ns: "config/groups", + defaultValue: toTitle(groupKey), + }); + } + + return t(`groups.${groupKey}`, { + ns: "config/groups", + defaultValue: toTitle(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) => ( +
{item.content}
+ ))} +
, + ); + } + + return ( +
+ {rows} + {leftoverSections} +
+ ); + }; + + const renderStackedLayout = (items: PropertyElement[]) => { + if (!items.length) { + return null; + } + + return ( +
+ {items.map((item) => ( +
{item.content}
+ ))} +
+ ); + }; + + const renderAddButton = () => { + const canAdd = + Boolean(onAddProperty) && canExpand(schema, uiSchema, formData); + + if (!canAdd) { + return null; + } + + return ( + + ); + }; + + 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} + {renderAddButton()} + + {advancedProps.length > 0 && ( + + + + + + {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} + {renderAddButton()} + + {advancedProps.length > 0 && ( + + + + + + {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 modifiedRegistry = { + ...registry, + templates: { + ...registry.templates, + ObjectFieldTemplate: (tProps: ObjectFieldTemplateProps) => + GridLayoutObjectFieldTemplate(tProps, originalObjectFieldTemplate), + }, + }; + + // Delegate to ObjectField with the modified registry + return ( + + ); +} diff --git a/web/src/components/config-form/theme/fields/index.ts b/web/src/components/config-form/theme/fields/index.ts new file mode 100644 index 000000000..1533764f6 --- /dev/null +++ b/web/src/components/config-form/theme/fields/index.ts @@ -0,0 +1,2 @@ +// Custom RJSF Fields +export { LayoutGridField } from "./LayoutGridField"; diff --git a/web/src/components/config-form/theme/frigateTheme.ts b/web/src/components/config-form/theme/frigateTheme.ts index 224854221..ebe1f144d 100644 --- a/web/src/components/config-form/theme/frigateTheme.ts +++ b/web/src/components/config-form/theme/frigateTheme.ts @@ -35,6 +35,8 @@ import { ErrorListTemplate } from "./templates/ErrorListTemplate"; import { MultiSchemaFieldTemplate } from "./templates/MultiSchemaFieldTemplate"; import { WrapIfAdditionalTemplate } from "./templates/WrapIfAdditionalTemplate"; +import { LayoutGridField } from "./fields/LayoutGridField"; + export interface FrigateTheme { widgets: RegistryWidgetsType; templates: Partial; @@ -75,5 +77,7 @@ export const frigateTheme: FrigateTheme = { MultiSchemaFieldTemplate: MultiSchemaFieldTemplate, WrapIfAdditionalTemplate: WrapIfAdditionalTemplate, }, - fields: {}, + fields: { + LayoutGridField: LayoutGridField, + }, }; diff --git a/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx b/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx index c8193b425..7baac1c2a 100644 --- a/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx +++ b/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx @@ -8,7 +8,8 @@ import { CollapsibleTrigger, } from "@/components/ui/collapsible"; import { Button } from "@/components/ui/button"; -import { useState } from "react"; +import { Children, useState } from "react"; +import type { ReactNode } from "react"; import { LuChevronDown, LuChevronRight, LuPlus } from "react-icons/lu"; import { useTranslation } from "react-i18next"; import { cn } from "@/lib/utils"; @@ -75,6 +76,7 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) { const { t, i18n } = useTranslation([ effectiveNamespace, "config/groups", + "views/settings", "common", ]); @@ -103,6 +105,10 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) { ); const [showAdvanced, setShowAdvanced] = useState(false); + const { children } = props as ObjectFieldTemplateProps & { + children?: ReactNode; + }; + const hasCustomChildren = Children.count(children) > 0; const toTitle = (value: string) => value.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase()); @@ -270,28 +276,38 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) { if (isRoot) { return (
- {renderGroupedFields(regularProps)} - {renderAddButton()} + {hasCustomChildren ? ( + children + ) : ( + <> + {renderGroupedFields(regularProps)} + {renderAddButton()} - {advancedProps.length > 0 && ( - - - - - - {renderGroupedFields(advancedProps)} - - + {advancedProps.length > 0 && ( + + + + + + {renderGroupedFields(advancedProps)} + + + )} + )}
); @@ -322,29 +338,42 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) { - {renderGroupedFields(regularProps)} - {renderAddButton()} + {hasCustomChildren ? ( + children + ) : ( + <> + {renderGroupedFields(regularProps)} + {renderAddButton()} - {advancedProps.length > 0 && ( - - - - - - {renderGroupedFields(advancedProps)} - - + + + + + {renderGroupedFields(advancedProps)} + + + )} + )}