mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-27 18:48:22 +03:00
add layout grid field
This commit is contained in:
parent
1aec1eb87c
commit
90d2ebfe19
@ -1184,6 +1184,8 @@
|
|||||||
"title": "Camera Settings",
|
"title": "Camera Settings",
|
||||||
"description": "These settings apply only to this camera and override the global 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",
|
"showAdvanced": "Show Advanced Settings",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"sharedDefaults": "Shared Defaults",
|
"sharedDefaults": "Shared Defaults",
|
||||||
|
|||||||
@ -4,18 +4,19 @@ const detect: SectionConfigOverrides = {
|
|||||||
base: {
|
base: {
|
||||||
sectionDocs: "/configuration/camera_specific",
|
sectionDocs: "/configuration/camera_specific",
|
||||||
restartRequired: [],
|
restartRequired: [],
|
||||||
fieldOrder: [
|
// use the grid for field order
|
||||||
"enabled",
|
// fieldOrder: [
|
||||||
"fps",
|
// "enabled",
|
||||||
"width",
|
// "width",
|
||||||
"height",
|
// "height",
|
||||||
"min_initialized",
|
// "fps",
|
||||||
"max_disappeared",
|
// "min_initialized",
|
||||||
"annotation_offset",
|
// "max_disappeared",
|
||||||
"stationary",
|
// "annotation_offset",
|
||||||
],
|
// "stationary",
|
||||||
|
// ],
|
||||||
fieldGroups: {
|
fieldGroups: {
|
||||||
resolution: ["enabled", "width", "height"],
|
resolution: ["width", "height", "fps"],
|
||||||
tracking: ["min_initialized", "max_disappeared"],
|
tracking: ["min_initialized", "max_disappeared"],
|
||||||
},
|
},
|
||||||
hiddenFields: ["enabled_in_config"],
|
hiddenFields: ["enabled_in_config"],
|
||||||
@ -25,6 +26,35 @@ const detect: SectionConfigOverrides = {
|
|||||||
"annotation_offset",
|
"annotation_offset",
|
||||||
"stationary",
|
"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" } }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
599
web/src/components/config-form/theme/fields/LayoutGridField.tsx
Normal file
599
web/src/components/config-form/theme/fields/LayoutGridField.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
web/src/components/config-form/theme/fields/index.ts
Normal file
2
web/src/components/config-form/theme/fields/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
// Custom RJSF Fields
|
||||||
|
export { LayoutGridField } from "./LayoutGridField";
|
||||||
@ -35,6 +35,8 @@ import { ErrorListTemplate } from "./templates/ErrorListTemplate";
|
|||||||
import { MultiSchemaFieldTemplate } from "./templates/MultiSchemaFieldTemplate";
|
import { MultiSchemaFieldTemplate } from "./templates/MultiSchemaFieldTemplate";
|
||||||
import { WrapIfAdditionalTemplate } from "./templates/WrapIfAdditionalTemplate";
|
import { WrapIfAdditionalTemplate } from "./templates/WrapIfAdditionalTemplate";
|
||||||
|
|
||||||
|
import { LayoutGridField } from "./fields/LayoutGridField";
|
||||||
|
|
||||||
export interface FrigateTheme {
|
export interface FrigateTheme {
|
||||||
widgets: RegistryWidgetsType;
|
widgets: RegistryWidgetsType;
|
||||||
templates: Partial<TemplatesType>;
|
templates: Partial<TemplatesType>;
|
||||||
@ -75,5 +77,7 @@ export const frigateTheme: FrigateTheme = {
|
|||||||
MultiSchemaFieldTemplate: MultiSchemaFieldTemplate,
|
MultiSchemaFieldTemplate: MultiSchemaFieldTemplate,
|
||||||
WrapIfAdditionalTemplate: WrapIfAdditionalTemplate,
|
WrapIfAdditionalTemplate: WrapIfAdditionalTemplate,
|
||||||
},
|
},
|
||||||
fields: {},
|
fields: {
|
||||||
|
LayoutGridField: LayoutGridField,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -8,7 +8,8 @@ import {
|
|||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from "@/components/ui/collapsible";
|
} from "@/components/ui/collapsible";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { LuChevronDown, LuChevronRight, LuPlus } from "react-icons/lu";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@ -75,6 +76,7 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
|||||||
const { t, i18n } = useTranslation([
|
const { t, i18n } = useTranslation([
|
||||||
effectiveNamespace,
|
effectiveNamespace,
|
||||||
"config/groups",
|
"config/groups",
|
||||||
|
"views/settings",
|
||||||
"common",
|
"common",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -103,6 +105,10 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
|
const { children } = props as ObjectFieldTemplateProps & {
|
||||||
|
children?: ReactNode;
|
||||||
|
};
|
||||||
|
const hasCustomChildren = Children.count(children) > 0;
|
||||||
|
|
||||||
const toTitle = (value: string) =>
|
const toTitle = (value: string) =>
|
||||||
value.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
|
value.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
|
||||||
@ -270,28 +276,38 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
|||||||
if (isRoot) {
|
if (isRoot) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{renderGroupedFields(regularProps)}
|
{hasCustomChildren ? (
|
||||||
{renderAddButton()}
|
children
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{renderGroupedFields(regularProps)}
|
||||||
|
{renderAddButton()}
|
||||||
|
|
||||||
{advancedProps.length > 0 && (
|
{advancedProps.length > 0 && (
|
||||||
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
|
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="w-full justify-start gap-2 pl-0"
|
className="w-full justify-start gap-2 pl-0"
|
||||||
>
|
>
|
||||||
{showAdvanced ? (
|
{showAdvanced ? (
|
||||||
<LuChevronDown className="h-4 w-4" />
|
<LuChevronDown className="h-4 w-4" />
|
||||||
) : (
|
) : (
|
||||||
<LuChevronRight className="h-4 w-4" />
|
<LuChevronRight className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
Advanced Settings ({advancedProps.length})
|
{t("configForm.advancedSettingsCount", {
|
||||||
</Button>
|
ns: "views/settings",
|
||||||
</CollapsibleTrigger>
|
defaultValue: "Advanced Settings ({{count}})",
|
||||||
<CollapsibleContent className="space-y-4 pt-2">
|
count: advancedProps.length,
|
||||||
{renderGroupedFields(advancedProps)}
|
})}
|
||||||
</CollapsibleContent>
|
</Button>
|
||||||
</Collapsible>
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="space-y-4 pt-2">
|
||||||
|
{renderGroupedFields(advancedProps)}
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -322,29 +338,42 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
|||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<CardContent className="space-y-4 pt-0">
|
<CardContent className="space-y-4 pt-0">
|
||||||
{renderGroupedFields(regularProps)}
|
{hasCustomChildren ? (
|
||||||
{renderAddButton()}
|
children
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{renderGroupedFields(regularProps)}
|
||||||
|
{renderAddButton()}
|
||||||
|
|
||||||
{advancedProps.length > 0 && (
|
{advancedProps.length > 0 && (
|
||||||
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
|
<Collapsible
|
||||||
<CollapsibleTrigger asChild>
|
open={showAdvanced}
|
||||||
<Button
|
onOpenChange={setShowAdvanced}
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="w-full justify-start gap-2 pl-0"
|
|
||||||
>
|
>
|
||||||
{showAdvanced ? (
|
<CollapsibleTrigger asChild>
|
||||||
<LuChevronDown className="h-4 w-4" />
|
<Button
|
||||||
) : (
|
variant="ghost"
|
||||||
<LuChevronRight className="h-4 w-4" />
|
size="sm"
|
||||||
)}
|
className="w-full justify-start gap-2 pl-0"
|
||||||
Advanced ({advancedProps.length})
|
>
|
||||||
</Button>
|
{showAdvanced ? (
|
||||||
</CollapsibleTrigger>
|
<LuChevronDown className="h-4 w-4" />
|
||||||
<CollapsibleContent className="space-y-4 pt-2">
|
) : (
|
||||||
{renderGroupedFields(advancedProps)}
|
<LuChevronRight className="h-4 w-4" />
|
||||||
</CollapsibleContent>
|
)}
|
||||||
</Collapsible>
|
{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>
|
</CardContent>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user