add field template for additionalproperties schema objects

This commit is contained in:
Josh Hawkins 2026-01-30 09:23:16 -06:00
parent 06c21bf6f2
commit 4dc039072a
5 changed files with 245 additions and 53 deletions

View File

@ -1137,6 +1137,12 @@
"system": "System",
"integrations": "Integrations"
},
"additionalProperties": {
"keyLabel": "Key",
"valueLabel": "Value",
"keyPlaceholder": "New key",
"remove": "Remove"
},
"sections": {
"detect": "Detection",
"record": "Recording",

View File

@ -32,6 +32,7 @@ import { DescriptionFieldTemplate } from "./templates/DescriptionFieldTemplate";
import { TitleFieldTemplate } from "./templates/TitleFieldTemplate";
import { ErrorListTemplate } from "./templates/ErrorListTemplate";
import { MultiSchemaFieldTemplate } from "./templates/MultiSchemaFieldTemplate";
import { WrapIfAdditionalTemplate } from "./templates/WrapIfAdditionalTemplate";
export interface FrigateTheme {
widgets: RegistryWidgetsType;
@ -70,6 +71,7 @@ export const frigateTheme: FrigateTheme = {
TitleFieldTemplate: TitleFieldTemplate,
ErrorListTemplate: ErrorListTemplate,
MultiSchemaFieldTemplate: MultiSchemaFieldTemplate,
WrapIfAdditionalTemplate: WrapIfAdditionalTemplate,
},
fields: {},
};

View File

@ -1,5 +1,10 @@
// Field Template - wraps each form field with label and description
import type { FieldTemplateProps, StrictRJSFSchema } from "@rjsf/utils";
import {
getTemplate,
getUiOptions,
ADDITIONAL_PROPERTY_FLAG,
} from "@rjsf/utils";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
@ -51,6 +56,8 @@ export function FieldTemplate(props: FieldTemplateProps) {
id,
label,
children,
classNames,
style,
errors,
help,
description,
@ -61,6 +68,13 @@ export function FieldTemplate(props: FieldTemplateProps) {
uiSchema,
registry,
fieldPathId,
onKeyRename,
onKeyRenameBlur,
onRemoveProperty,
rawDescription,
rawErrors,
disabled,
readonly,
} = props;
// Get i18n namespace from form context (passed through registry)
@ -78,15 +92,16 @@ export function FieldTemplate(props: FieldTemplateProps) {
}
// Get UI options
const uiOptions = uiSchema?.["ui:options"] || {};
const uiOptionsFromSchema = uiSchema?.["ui:options"] || {};
// Determine field characteristics
const isAdvanced = uiOptions.advanced === true;
const isAdvanced = uiOptionsFromSchema.advanced === true;
const isBoolean =
schema.type === "boolean" ||
(Array.isArray(schema.type) && schema.type.includes("boolean"));
const isObjectField = schema.type === "object";
const isNullableUnion = isNullableUnionSchema(schema as StrictRJSFSchema);
const isAdditionalProperty = ADDITIONAL_PROPERTY_FLAG in schema;
const suppressMultiSchema =
(uiSchema?.["ui:options"] as Record<string, unknown> | undefined)
?.suppressMultiSchema === true;
@ -199,60 +214,92 @@ export function FieldTemplate(props: FieldTemplateProps) {
finalDescription = schemaDescription;
}
return (
<div
className={cn(
"space-y-1",
isAdvanced && "border-l-2 border-muted pl-4",
isBoolean && "flex items-center justify-between gap-4",
)}
data-field-id={translationPath}
>
{displayLabel &&
finalLabel &&
!isBoolean &&
!isMultiSchemaWrapper &&
!isObjectField && (
<Label
htmlFor={id}
className={cn(
"text-sm font-medium",
errors && errors.props?.errors?.length > 0 && "text-destructive",
)}
>
{finalLabel}
{required && <span className="ml-1 text-destructive">*</span>}
</Label>
)}
const uiOptions = getUiOptions(uiSchema);
const WrapIfAdditionalTemplate = getTemplate(
"WrapIfAdditionalTemplate",
registry,
uiOptions,
);
{isBoolean ? (
<div className="flex w-full items-center justify-between gap-4">
<div className="space-y-0.5">
{displayLabel && finalLabel && (
<Label htmlFor={id} className="text-sm font-medium">
{finalLabel}
{required && <span className="ml-1 text-destructive">*</span>}
</Label>
)}
{finalDescription && !isMultiSchemaWrapper && (
return (
<WrapIfAdditionalTemplate
classNames={classNames}
style={style}
disabled={disabled}
id={id}
label={label}
displayLabel={displayLabel}
onKeyRename={onKeyRename}
onKeyRenameBlur={onKeyRenameBlur}
onRemoveProperty={onRemoveProperty}
rawDescription={rawDescription}
readonly={readonly}
required={required}
schema={schema}
uiSchema={uiSchema}
registry={registry}
rawErrors={rawErrors}
hideError={false}
>
<div
className={cn(
"space-y-1",
isAdvanced && "border-l-2 border-muted pl-4",
isBoolean && "flex items-center justify-between gap-4",
)}
data-field-id={translationPath}
>
{displayLabel &&
finalLabel &&
!isBoolean &&
!isMultiSchemaWrapper &&
!isObjectField &&
!isAdditionalProperty && (
<Label
htmlFor={id}
className={cn(
"text-sm font-medium",
errors &&
errors.props?.errors?.length > 0 &&
"text-destructive",
)}
>
{finalLabel}
{required && <span className="ml-1 text-destructive">*</span>}
</Label>
)}
{isBoolean ? (
<div className="flex w-full items-center justify-between gap-4">
<div className="space-y-0.5">
{displayLabel && finalLabel && (
<Label htmlFor={id} className="text-sm font-medium">
{finalLabel}
{required && <span className="ml-1 text-destructive">*</span>}
</Label>
)}
{finalDescription && !isMultiSchemaWrapper && (
<p className="text-xs text-muted-foreground">
{finalDescription}
</p>
)}
</div>
{children}
</div>
) : (
<>
{children}
{finalDescription && !isMultiSchemaWrapper && !isObjectField && (
<p className="text-xs text-muted-foreground">
{finalDescription}
</p>
)}
</div>
{children}
</div>
) : (
<>
{children}
{finalDescription && !isMultiSchemaWrapper && !isObjectField && (
<p className="text-xs text-muted-foreground">{finalDescription}</p>
)}
</>
)}
</>
)}
{errors}
{help}
</div>
{errors}
{help}
</div>
</WrapIfAdditionalTemplate>
);
}

View File

@ -1,4 +1,5 @@
// Object Field Template - renders nested object fields with i18n support
import { canExpand } from "@rjsf/utils";
import type { ObjectFieldTemplateProps } from "@rjsf/utils";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
@ -8,7 +9,7 @@ import {
} from "@/components/ui/collapsible";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import { LuChevronDown, LuChevronRight } from "react-icons/lu";
import { LuChevronDown, LuChevronRight, LuPlus } from "react-icons/lu";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { getTranslatedLabel } from "@/utils/i18n";
@ -47,7 +48,18 @@ function getFilterObjectLabel(
}
export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
const { title, description, properties, uiSchema, registry, schema } = props;
const {
title,
description,
properties,
uiSchema,
registry,
schema,
onAddProperty,
formData,
disabled,
readonly,
} = props;
type FormContext = { i18nNamespace?: string };
const formContext = registry?.formContext as FormContext | undefined;
@ -59,6 +71,7 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
const { t, i18n } = useTranslation([
formContext?.i18nNamespace || "common",
"config/groups",
"common",
]);
// Extract domain from i18nNamespace (e.g., "config/audio" -> "audio")
@ -211,11 +224,35 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
);
};
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("add", { ns: "common", defaultValue: "Add" })}
</Button>
);
};
// Root level renders children directly
if (isRoot) {
return (
<div className="space-y-6">
{renderGroupedFields(regularProps)}
{renderAddButton()}
{advancedProps.length > 0 && (
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
@ -264,6 +301,7 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
<CollapsibleContent>
<CardContent className="space-y-4 pt-0">
{renderGroupedFields(regularProps)}
{renderAddButton()}
{advancedProps.length > 0 && (
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>

View File

@ -0,0 +1,99 @@
import {
ADDITIONAL_PROPERTY_FLAG,
FormContextType,
RJSFSchema,
StrictRJSFSchema,
WrapIfAdditionalTemplateProps,
} from "@rjsf/utils";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
import { LuTrash2 } from "react-icons/lu";
export function WrapIfAdditionalTemplate<
T = unknown,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = FormContextType,
>(props: WrapIfAdditionalTemplateProps<T, S, F>) {
const {
classNames,
style,
children,
disabled,
id,
label,
displayLabel,
onRemoveProperty,
onKeyRenameBlur,
readonly,
required,
schema,
} = props;
const { t } = useTranslation(["views/settings"]);
const additional = ADDITIONAL_PROPERTY_FLAG in schema;
if (!additional) {
return (
<div className={classNames} style={style}>
{children}
</div>
);
}
const keyId = `${id}-key`;
const keyLabel = t("configForm.additionalProperties.keyLabel", {
ns: "views/settings",
});
const valueLabel = t("configForm.additionalProperties.valueLabel", {
ns: "views/settings",
});
const keyPlaceholder = t("configForm.additionalProperties.keyPlaceholder", {
ns: "views/settings",
});
const removeLabel = t("configForm.additionalProperties.remove", {
ns: "views/settings",
});
return (
<div
className={cn("grid grid-cols-12 items-start gap-2", classNames)}
style={style}
>
<div className="col-span-12 space-y-2 md:col-span-5">
{displayLabel && <Label htmlFor={keyId}>{keyLabel}</Label>}
<Input
id={keyId}
name={keyId}
required={required}
defaultValue={label}
placeholder={keyPlaceholder}
disabled={disabled || readonly}
onBlur={!readonly ? onKeyRenameBlur : undefined}
/>
</div>
<div className="col-span-12 space-y-2 md:col-span-6">
{displayLabel && <Label htmlFor={id}>{valueLabel}</Label>}
<div className="min-w-0">{children}</div>
</div>
<div className="col-span-12 flex items-center md:col-span-1 md:justify-end">
<Button
type="button"
variant="ghost"
size="icon"
onClick={onRemoveProperty}
disabled={disabled || readonly}
aria-label={removeLabel}
title={removeLabel}
>
<LuTrash2 className="h-4 w-4" />
</Button>
</div>
</div>
);
}
export default WrapIfAdditionalTemplate;