Files
frigate/web/src/components/config-form/theme/fields/LayoutGridField.tsx
T
Josh HawkinsandGitHub 2782931c72 Update frontend to React 19 (#22275)
* remove unused RecoilRoot and fix implicit ref callback

Remove the vestigial recoil dependency (zero consumers) and convert
the implicit-return ref callback in SearchView to block form to
prevent React 19 interpreting it as a cleanup function.

* replace react-transition-group with framer-motion in Chip

Replace CSSTransition with framer-motion AnimatePresence + motion.div
for React 19 compatibility (react-transition-group uses findDOMNode).
framer-motion is already a project dependency.

* migrate react-grid-layout v1 to v2

- Replace WidthProvider(Responsive) HOC with useContainerWidth hook
- Update types: Layout (single item) → LayoutItem, Layout[] → Layout
- Replace isDraggable/isResizable/resizeHandles with dragConfig/resizeConfig
- Update EventCallback signature for v2 API
- Remove @types/react-grid-layout (v2 includes its own types)

* upgrade vaul, next-themes, framer-motion, react-zoom-pan-pinch

- vaul: ^0.9.1 → ^1.1.2
- next-themes: ^0.3.0 → ^0.4.6
- framer-motion: ^11.5.4 → ^12.35.0 (React 19 native support)
- react-zoom-pan-pinch: 3.4.4 → latest

* upgrade to React 19, react-konva v19, eslint-plugin-react-hooks v5

Core React 19 upgrade with all necessary type fixes:
- Update RefObject types to accept T | null (React 19 refs always nullable)
- Add JSX namespace imports (no longer global in React 19)
- Add initial values to useRef calls (required in React 19)
- Fix ReactElement.props unknown type in config-form components
- Fix IconWrapper interface to use HTMLAttributes instead of index signature
- Add monaco-editor as dev dependency for type declarations
- Upgrade react-konva to v19, eslint-plugin-react-hooks to v5

* upgrade typescript to 5.9.3

* modernize Context.Provider to React 19 shorthand

Replace <Context.Provider value={...}> with <Context value={...}>
across all project-owned context providers. External library contexts
(react-icons IconContext, radix TooltipPrimitive) left unchanged.

* add runtime patches for React 19 compatibility

- Patch @radix-ui/react-compose-refs@1.1.2: stabilize useComposedRefs
  to prevent infinite render loops from unstable ref callbacks
  https://github.com/radix-ui/primitives/issues/3799
- Patch @radix-ui/react-slot@1.2.4: use useComposedRefs hook in
  SlotClone instead of inline composeRefs to prevent re-render cycles
  https://github.com/radix-ui/primitives/pull/3804
- Patch react-use-websocket@4.8.1: remove flushSync wrappers that
  cause "Maximum update depth exceeded" with React 19 auto-batching
  https://github.com/facebook/react/issues/27613
- Add npm overrides to ensure single hoisted copies of compose-refs
  and react-slot across all Radix packages
- Add postinstall script for patch-package
- Remove leftover react-transition-group dependency

* formatting

* use availableWidth instead of useContainerWidth for grid layout

The useContainerWidth hook from react-grid-layout v2 returns raw
container width without accounting for scrollbar width, causing the
grid to not fill the full available space. Use the existing
availableWidth value from useResizeObserver which already compensates
for scrollbar width, matching the working implementation.

* remove unused carousel component and fix React 19 peer deps

Remove embla-carousel-react and its unused Carousel UI component.
Upgrade sonner v1 → v2 for native React 19 support. Remove
@types/react-icons stub (react-icons bundles its own types).
These changes eliminate all peer dependency conflicts, so
npm install works without --legacy-peer-deps.

* fix React 19 infinite re-render loop on live dashboard

The "Maximum update depth exceeded" error was caused by two issues:

1. useDeferredStreamMetadata returned a new `{}` default on every render
   when SWR data was undefined, creating an unstable reference that
   triggered the useEffect in useCameraLiveMode on every render cycle.
   Fixed by using a stable module-level EMPTY_METADATA constant.

2. useResizeObserver's rest parameter `...refs` created a new array on
   every render, causing its useEffect to re-run and re-observe elements
   continuously. Fixed by stabilizing refs with useRef and only
   reconnecting the observer when actual DOM elements change.
2026-03-05 07:42:38 -07:00

600 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;
}
/** Shape of the props that RJSF injects into each property element. */
interface RjsfElementProps {
schema?: { type?: string | string[] };
uiSchema?: Record<string, unknown> & {
"ui:widget"?: string;
"ui:options"?: Record<string, unknown>;
};
}
function isObjectLikeElement(item: PropertyElement) {
const fieldSchema = (item.content.props as RjsfElementProps)?.schema;
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 as RjsfElementProps).uiSchema?.["ui:widget"] ===
"hidden";
const visibleProps = properties.filter((prop) => !isHiddenProp(prop));
// Separate regular and advanced properties
const advancedProps = visibleProps.filter(
(p) =>
(p.content.props as RjsfElementProps).uiSchema?.["ui:options"]
?.advanced === true,
);
const regularProps = visibleProps.filter(
(p) =>
(p.content.props as RjsfElementProps).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}
/>
);
}