frigate/web/src/components/config-form/theme/fields/LayoutGridField.tsx
Josh Hawkins e7250f24cb
Full UI configuration (#22151)
* use react-jsonschema-form for UI config

* don't use properties wrapper when generating config i18n json

* configure for full i18n support

* section fields

* add descriptions to all fields for i18n

* motion i18n

* fix nullable fields

* sanitize internal fields

* add switches widgets and use friendly names

* fix nullable schema entries

* ensure update_topic is added to api calls

this needs further backend implementation to work correctly

* add global sections, camera config overrides, and reset button

* i18n

* add reset logic to global config view

* tweaks

* fix sections and live validation

* fix validation for schema objects that can be null

* generic and custom per-field validation

* improve generic error validation messages

* remove show advanced fields switch

* tweaks

* use shadcn theme

* fix array field template

* i18n tweaks

* remove collapsible around root section

* deep merge schema for advanced fields

* add array field item template and fix ffmpeg section

* add missing i18n keys

* tweaks

* comment out api call for testing

* add config groups as a separate i18n namespace

* add descriptions to all pydantic fields

* make titles more concise

* new titles as i18n

* update i18n config generation script to use json schema

* tweaks

* tweaks

* rebase

* clean up

* form tweaks

* add wildcards and fix object filter fields

* add field template for additionalproperties schema objects

* improve typing

* add section description from schema and clarify global vs camera level descriptions

* separate and consolidate global and camera i18n namespaces

* clean up now obsolete namespaces

* tweaks

* refactor sections and overrides

* add ability to render components before and after fields

* fix titles

* chore(sections): remove legacy single-section components replaced by template

* refactor configs to use individual files with a template

* fix review description

* apply hidden fields after ui schema

* move util

* remove unused i18n

* clean up error messages

* fix fast refresh

* add custom validation and use it for ffmpeg input roles

* update nav tree

* remove unused

* re-add override and modified indicators

* mark pending changes and add confirmation dialog for resets

* fix red unsaved dot

* tweaks

* add docs links, readonly keys, and restart required per field

* add special case and comments for global motion section

* add section form special cases

* combine review sections

* tweaks

* add audio labels endpoint

* add audio label switches and input to filter list

* fix type

* remove key from config when resetting to default/global

* don't show description for new key/val fields

* tweaks

* spacing tweaks

* add activity indicator and scrollbar tweaks

* add docs to filter fields

* wording changes

* fix global ffmpeg section

* add review classification zones to review form

* add backend endpoint and frontend widget for ffmpeg presets and manual args

* improve wording

* hide descriptions for additional properties arrays

* add warning log about incorrectly nested model config

* spacing and language tweaks

* fix i18n keys

* networking section docs and description

* small wording tweaks

* add layout grid field

* refactor with shared utilities

* field order

* add individual detectors to schema

add detector titles and descriptions (docstrings in pydantic are used for descriptions) and add i18n keys to globals

* clean up detectors section and i18n

* don't save model config back to yaml when saving detectors

* add full detectors config to api model dump

works around the way we use detector plugins so we can have the full detector config for the frontend

* add restart button to toast when restart is required

* add ui option to remove inner cards

* fix buttons

* section tweaks

* don't zoom into text on mobile

* make buttons sticky at bottom of sections

* small tweaks

* highlight label of changed fields

* add null to enum list when unwrapping

* refactor to shared utils and add save all button

* add undo all button

* add RJSF to dictionary

* consolidate utils

* preserve form data when changing cameras

* add mono fonts

* add popover to show what fields will be saved

* fix mobile menu not re-rendering with unsaved dots

* tweaks

* fix logger and env vars config section saving

use escaped periods in keys to retain them in the config file (eg "frigate.embeddings")

* add timezone widget

* role map field with validation

* fix validation for model section

* add another hidden field

* add footer message for required restart

* use rjsf for notifications view

* fix config saving

* add replace rules field

* default column layout and add field sizing

* clean up field template

* refactor profile settings to match rjsf forms

* tweaks

* refactor frigate+ view and make tweaks to sections

* show frigate+ model info in detection model settings when using a frigate+ model

* update restartRequired for all fields

* fix restart fields

* tweaks and add ability enable disabled cameras

more backend changes required

* require restart when enabling camera that is disabled in config

* disable save when form is invalid

* refactor ffmpeg section for readability

* change label

* clean up camera inputs fields

* misc tweaks to ffmpeg section

- add raw paths endpoint to ensure credentials get saved
- restart required tooltip

* maintenance settings tweaks

* don't mutate with lodash

* fix description re-rendering for nullable object fields

* hide reindex field

* update rjsf

* add frigate+ description to settings pane

* disable save all when any section is invalid

* show translated field name in validation error pane

* clean up

* remove unused

* fix genai merge

* fix genai
2026-02-27 08:55:36 -07:00

588 lines
18 KiB
TypeScript

/**
* 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<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;
}
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<ObjectFieldTemplateProps>,
) {
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<string, string[]> | undefined) || {};
const overrides = formContext?.overrides;
const fieldPath = props.fieldPathId.path;
const isPathModified = (path: Array<string | number>) =>
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 <ObjectFieldTemplate {...props} />;
}
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<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={cn(
"space-y-4",
rowGroupKey &&
"rounded-lg border border-border/70 bg-card/30 p-4",
)}
>
{showGroupLabel && (
<div className="border-b border-border/60 pb-2 text-sm font-semibold text-primary-variant">
{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-4 rounded-lg border border-border/70 bg-card/30 p-4"
>
{showGroupLabel && (
<div className="border-b border-border/60 pb-2 text-sm font-semibold text-primary-variant">
{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}
className={cn(
groupedLeftovers.size > 0 &&
!isObjectLikeElement(item) &&
"px-4",
)}
>
{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 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}
<AddPropertyButton
onAddProperty={onAddProperty}
schema={schema}
uiSchema={uiSchema}
formData={formData}
disabled={disabled}
readonly={readonly}
/>
<AdvancedCollapsible
count={advancedProps.length}
open={showAdvanced}
onOpenChange={setShowAdvanced}
isRoot
>
{advancedLayout}
</AdvancedCollapsible>
</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}
<AddPropertyButton
onAddProperty={onAddProperty}
schema={schema}
uiSchema={uiSchema}
formData={formData}
disabled={disabled}
readonly={readonly}
/>
<AdvancedCollapsible
count={advancedProps.length}
open={showAdvanced}
onOpenChange={setShowAdvanced}
>
{advancedLayout}
</AdvancedCollapsible>
</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 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 (
<ObjectField
{...props}
registry={modifiedRegistry}
schema={schema}
uiSchema={uiSchema}
idSchema={idSchema}
formData={formData}
/>
);
}