add layout grid field

This commit is contained in:
Josh Hawkins 2026-02-05 08:53:08 -06:00
parent 1aec1eb87c
commit 90d2ebfe19
6 changed files with 721 additions and 55 deletions

View File

@ -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",

View File

@ -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" } }],
},
],
},
},
};

View File

@ -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<string | Record<string, LayoutGridColumnConfig>>;
"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<ObjectFieldTemplateProps>,
) {
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<string, string[]> | undefined) || {};
// If no layout grid is defined, use the default template
if (layoutGrid.length === 0) {
return <ObjectFieldTemplate {...props} />;
}
// 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<string>();
return (
<div className="space-y-4">
{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(
<div key={`${rowIndex}-${colIndex}`} className={colClass}>
{item.content}
</div>,
);
});
if (cols.length === 0) return null;
const rowClass = cn(
"grid gap-4",
rowClassName,
rowDef["ui:className"],
rowDef.className,
);
return (
<div key={rowIndex} className={rowClass}>
{cols}
</div>
);
})}
{Array.from(itemMap.keys())
.filter((name) => !renderedFields.has(name))
.map((name) => {
const item = itemMap.get(name);
return item ? (
<div key={name} className="space-y-6">
{item.content}
</div>
) : null;
})}
</div>
);
};
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<string>();
const renderedGroups = new Set<string>();
const groupMap = new Map<string, string>();
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(
<div key={`${rowIndex}-${colIndex}`} className={colClass}>
{item.content}
</div>,
);
});
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 (
<div key={rowIndex} className="space-y-4">
{showGroupLabel && (
<div className="text-md font-medium text-primary">
{getGroupLabel(rowGroupKey)}
</div>
)}
<div className={rowClass}>{cols}</div>
</div>
);
})
.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<string, PropertyElement[]>();
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(
<div key={groupKey} className="space-y-6">
{showGroupLabel && (
<div className="text-md font-medium text-primary">
{getGroupLabel(groupKey)}
</div>
)}
<div className="space-y-6">
{groupItems.map((item) => (
<div key={item.name}>{item.content}</div>
))}
</div>
</div>,
);
});
if (ungroupedLeftovers.length > 0) {
leftoverSections.push(
<div
key="ungrouped-leftovers"
className={cn(
"space-y-6",
(rows.length > 0 || groupedLeftovers.size > 0) && "pt-2",
)}
>
{ungroupedLeftovers.map((item) => (
<div key={item.name}>{item.content}</div>
))}
</div>,
);
}
return (
<div className="space-y-6">
{rows}
{leftoverSections}
</div>
);
};
const renderStackedLayout = (items: PropertyElement[]) => {
if (!items.length) {
return null;
}
return (
<div className="space-y-6">
{items.map((item) => (
<div key={item.name}>{item.content}</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>
);
};
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 (
<div className="space-y-6">
{regularLayout}
{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" />
)}
{t("configForm.advancedSettingsCount", {
ns: "views/settings",
defaultValue: "Advanced Settings ({{count}})",
count: advancedProps.length,
})}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-2">
{advancedLayout}
</CollapsibleContent>
</Collapsible>
)}
</div>
);
}
// 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 (
<ObjectFieldTemplate {...props}>
<div className="space-y-4">
{regularLayout}
{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" />
)}
{t("label.advancedCount", {
ns: "common",
defaultValue: "Advanced ({{count}})",
count: advancedProps.length,
})}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-2">
{advancedLayout}
</CollapsibleContent>
</Collapsible>
)}
</div>
</ObjectFieldTemplate>
);
}
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 (
<ObjectField
{...props}
registry={modifiedRegistry}
schema={schema}
uiSchema={uiSchema}
idSchema={idSchema}
formData={formData}
/>
);
}

View File

@ -0,0 +1,2 @@
// Custom RJSF Fields
export { LayoutGridField } from "./LayoutGridField";

View File

@ -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<TemplatesType>;
@ -75,5 +77,7 @@ export const frigateTheme: FrigateTheme = {
MultiSchemaFieldTemplate: MultiSchemaFieldTemplate,
WrapIfAdditionalTemplate: WrapIfAdditionalTemplate,
},
fields: {},
fields: {
LayoutGridField: LayoutGridField,
},
};

View File

@ -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 (
<div className="space-y-6">
{renderGroupedFields(regularProps)}
{renderAddButton()}
{hasCustomChildren ? (
children
) : (
<>
{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>
{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" />
)}
{t("configForm.advancedSettingsCount", {
ns: "views/settings",
defaultValue: "Advanced Settings ({{count}})",
count: advancedProps.length,
})}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-2">
{renderGroupedFields(advancedProps)}
</CollapsibleContent>
</Collapsible>
)}
</>
)}
</div>
);
@ -322,29 +338,42 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-4 pt-0">
{renderGroupedFields(regularProps)}
{renderAddButton()}
{hasCustomChildren ? (
children
) : (
<>
{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"
{advancedProps.length > 0 && (
<Collapsible
open={showAdvanced}
onOpenChange={setShowAdvanced}
>
{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>
<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" />
)}
{t("configForm.advancedCount", {
ns: "views/settings",
defaultValue: "Advanced ({{count}})",
count: advancedProps.length,
})}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-2">
{renderGroupedFields(advancedProps)}
</CollapsibleContent>
</Collapsible>
)}
</>
)}
</CardContent>
</CollapsibleContent>