add array field item template and fix ffmpeg section

This commit is contained in:
Josh Hawkins 2026-01-28 09:49:32 -06:00
parent 8c65cbce22
commit 40b59c5d0c
9 changed files with 225 additions and 37 deletions

View File

@ -1,5 +1,5 @@
{
"label": "Object tracking",
"label": "Object Detection",
"description": "Settings for the detection/detect role used to run object detection and initialize trackers.",
"groups": {
"resolution": "Resolution",

View File

@ -38,6 +38,100 @@ export const FfmpegSection = createConfigSection({
"apple_compatibility",
"gpu",
],
uiSchema: {
global_args: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
hwaccel_args: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
input_args: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
output_args: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
detect: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
record: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
items: {
detect: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
record: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
},
},
inputs: {
items: {
global_args: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
hwaccel_args: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
input_args: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
output_args: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
items: {
detect: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
record: {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
},
},
},
},
},
},
},
});

View File

@ -8,7 +8,6 @@ import type {
RegistryFieldsType,
TemplatesType,
} from "@rjsf/utils";
import { getDefaultRegistry } from "@rjsf/core";
import { SwitchWidget } from "./widgets/SwitchWidget";
import { SelectWidget } from "./widgets/SelectWidget";
@ -27,6 +26,7 @@ import { ArrayAsTextWidget } from "./widgets/ArrayAsTextWidget";
import { FieldTemplate } from "./templates/FieldTemplate";
import { ObjectFieldTemplate } from "./templates/ObjectFieldTemplate";
import { ArrayFieldTemplate } from "./templates/ArrayFieldTemplate";
import { ArrayFieldItemTemplate } from "./templates/ArrayFieldItemTemplate";
import { BaseInputTemplate } from "./templates/BaseInputTemplate";
import { DescriptionFieldTemplate } from "./templates/DescriptionFieldTemplate";
import { TitleFieldTemplate } from "./templates/TitleFieldTemplate";
@ -39,11 +39,8 @@ export interface FrigateTheme {
fields: RegistryFieldsType;
}
const defaultRegistry = getDefaultRegistry();
export const frigateTheme: FrigateTheme = {
widgets: {
...defaultRegistry.widgets,
// Override default widgets with shadcn/ui styled versions
TextWidget: TextWidget,
PasswordWidget: PasswordWidget,
@ -64,20 +61,15 @@ export const frigateTheme: FrigateTheme = {
zoneNames: ZoneSwitchesWidget,
},
templates: {
...defaultRegistry.templates,
FieldTemplate: FieldTemplate as React.ComponentType<FieldTemplateProps>,
ObjectFieldTemplate: ObjectFieldTemplate,
ArrayFieldTemplate: ArrayFieldTemplate,
ArrayFieldItemTemplate: ArrayFieldItemTemplate,
BaseInputTemplate: BaseInputTemplate as React.ComponentType<WidgetProps>,
DescriptionFieldTemplate: DescriptionFieldTemplate,
TitleFieldTemplate: TitleFieldTemplate,
ErrorListTemplate: ErrorListTemplate,
MultiSchemaFieldTemplate: MultiSchemaFieldTemplate,
ButtonTemplates: {
...defaultRegistry.templates.ButtonTemplates,
},
},
fields: {
...defaultRegistry.fields,
},
fields: {},
};

View File

@ -0,0 +1,58 @@
import type {
ArrayFieldItemTemplateProps,
FormContextType,
RJSFSchema,
StrictRJSFSchema,
} from "@rjsf/utils";
import { getTemplate, getUiOptions } from "@rjsf/utils";
/**
* Custom ArrayFieldItemTemplate to ensure array item content uses full width
* while keeping action buttons aligned to the right.
*/
export function ArrayFieldItemTemplate<
T = unknown,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = FormContextType,
>(props: ArrayFieldItemTemplateProps<T, S, F>) {
const {
children,
buttonsProps,
displayLabel,
hasDescription,
hasToolbar,
uiSchema,
registry,
} = props;
const uiOptions = getUiOptions<T, S, F>(uiSchema);
const ArrayFieldItemButtonsTemplate = getTemplate<
"ArrayFieldItemButtonsTemplate",
T,
S,
F
>("ArrayFieldItemButtonsTemplate", registry, uiOptions);
const margin = hasDescription ? -6 : 22;
return (
<div className="w-full">
<div className="mb-2 flex w-full flex-row items-start gap-2">
<div className="min-w-0 flex-1">{children}</div>
{hasToolbar && (
<div
className="flex shrink-0 items-start justify-end gap-2"
style={{
marginLeft: "5px",
marginTop: displayLabel ? `${margin}px` : undefined,
}}
>
<ArrayFieldItemButtonsTemplate {...buttonsProps} />
</div>
)}
</div>
</div>
);
}
export default ArrayFieldItemTemplate;

View File

@ -33,7 +33,7 @@ export function ArrayFieldTemplate(props: ArrayFieldTemplateProps) {
<div
key={element.key || index}
className={cn(
"flex items-start gap-2",
"flex w-full items-start gap-2",
!isSimpleType && "flex-col",
)}
>

View File

@ -43,11 +43,11 @@ export function FieldTemplate(props: FieldTemplateProps) {
// Get UI options
const uiOptions = uiSchema?.["ui:options"] || {};
// Determine field characteristics
const isAdvanced = uiOptions.advanced === true;
// Boolean fields (switches) render label inline
const isBoolean = schema.type === "boolean";
const isObjectField = schema.type === "object";
const isNullableUnion = isNullableUnionSchema(schema as StrictRJSFSchema);
const suppressMultiSchema =
(uiSchema?.["ui:options"] as Record<string, unknown> | undefined)
@ -144,7 +144,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
</div>
) : (
<>
{finalDescription && !isMultiSchemaWrapper && (
{finalDescription && !isMultiSchemaWrapper && !isObjectField && (
<p className="text-xs text-muted-foreground">{finalDescription}</p>
)}
{children}

View File

@ -1,4 +1,4 @@
// Object Field Template - renders nested object fields
// Object Field Template - renders nested object fields with i18n support
import type { ObjectFieldTemplateProps } from "@rjsf/utils";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
@ -14,21 +14,15 @@ import { cn } from "@/lib/utils";
export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
const { title, description, properties, uiSchema, registry, schema } = props;
const { idSchema } = props as ObjectFieldTemplateProps & {
idSchema?: { $id?: string };
};
const formContext = (props as Record<string, unknown>).formContext as
| Record<string, unknown>
| undefined;
type FormContext = { i18nNamespace?: string };
const formContext = registry?.formContext as FormContext | undefined;
// Check if this is a root-level object
const isRoot = idSchema?.$id === "root" || registry?.rootSchema === schema;
const isRoot = registry?.rootSchema === schema;
const [isOpen, setIsOpen] = useState(true);
const { t } = useTranslation([
(formContext?.i18nNamespace as string | undefined) || "common",
]);
const { t } = useTranslation([formContext?.i18nNamespace || "common"]);
const groupDefinitions =
(uiSchema?.["ui:groups"] as Record<string, string[]> | undefined) || {};
@ -51,6 +45,52 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
const toTitle = (value: string) =>
value.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
// Get the property name from the field path (e.g., "alerts" from path)
const fieldPathId = (
props as { fieldPathId?: { path?: (string | number)[] } }
).fieldPathId;
let propertyName: string | undefined;
const path = fieldPathId?.path;
if (path) {
for (let i = path.length - 1; i >= 0; i -= 1) {
const segment = path[i];
if (typeof segment === "string") {
propertyName = segment;
break;
}
}
}
// Try i18n translation, fall back to schema or original values
const i18nNs = formContext?.i18nNamespace;
let inferredLabel: string | undefined;
if (i18nNs && propertyName) {
const translated = t(`${propertyName}.label`, {
ns: i18nNs,
defaultValue: "",
});
inferredLabel = translated || undefined;
}
const schemaTitle = schema?.title;
const fallbackLabel =
title || schemaTitle || (propertyName ? toTitle(propertyName) : undefined);
inferredLabel = inferredLabel ?? fallbackLabel;
let inferredDescription: string | undefined;
if (i18nNs && propertyName) {
const translated = t(`${propertyName}.description`, {
ns: i18nNs,
defaultValue: "",
});
inferredDescription = translated || undefined;
}
const schemaDescription = schema?.description;
const fallbackDescription =
(typeof description === "string" ? description : undefined) ||
schemaDescription;
inferredDescription = inferredDescription ?? fallbackDescription;
const renderGroupedFields = (items: (typeof properties)[number][]) => {
if (!items.length) {
return null;
@ -142,16 +182,16 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
// Nested objects render as collapsible cards
return (
<Card>
<Card className="w-full">
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer transition-colors hover:bg-muted/50">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">{title}</CardTitle>
{description && (
<p className="mt-1 text-sm text-muted-foreground">
{description}
<CardTitle className="text-sm">{inferredLabel}</CardTitle>
{inferredDescription && (
<p className="mt-1 text-xs text-muted-foreground">
{inferredDescription}
</p>
)}
</div>

View File

@ -6,9 +6,13 @@ import { useCallback } from "react";
export function ArrayAsTextWidget(props: WidgetProps) {
const { value, onChange, disabled, readonly, placeholder } = props;
// Convert array to space-separated string
const textValue =
Array.isArray(value) && value.length > 0 ? value.join(" ") : "";
// Convert array or string to text
let textValue = "";
if (typeof value === "string" && value.length > 0) {
textValue = value;
} else if (Array.isArray(value) && value.length > 0) {
textValue = value.join(" ");
}
const handleChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {

View File

@ -325,7 +325,7 @@ const CameraConfigContent = memo(function CameraConfigContent({
return (
<div className="flex flex-1 gap-6 overflow-hidden">
{/* Section Navigation */}
<nav className="w-48 shrink-0">
<nav className="w-64 shrink-0">
<ul className="space-y-1">
{sections.map((section) => {
const isOverridden = overriddenSections.includes(section.key);