diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 06cf9fccf..6e1fe387e 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1137,6 +1137,12 @@ "system": "System", "integrations": "Integrations" }, + "additionalProperties": { + "keyLabel": "Key", + "valueLabel": "Value", + "keyPlaceholder": "New key", + "remove": "Remove" + }, "sections": { "detect": "Detection", "record": "Recording", diff --git a/web/src/components/config-form/theme/frigateTheme.ts b/web/src/components/config-form/theme/frigateTheme.ts index 61bbea061..225ecd4d9 100644 --- a/web/src/components/config-form/theme/frigateTheme.ts +++ b/web/src/components/config-form/theme/frigateTheme.ts @@ -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: {}, }; diff --git a/web/src/components/config-form/theme/templates/FieldTemplate.tsx b/web/src/components/config-form/theme/templates/FieldTemplate.tsx index adad4215b..82f14c352 100644 --- a/web/src/components/config-form/theme/templates/FieldTemplate.tsx +++ b/web/src/components/config-form/theme/templates/FieldTemplate.tsx @@ -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 | undefined) ?.suppressMultiSchema === true; @@ -199,60 +214,92 @@ export function FieldTemplate(props: FieldTemplateProps) { finalDescription = schemaDescription; } - return ( -
- {displayLabel && - finalLabel && - !isBoolean && - !isMultiSchemaWrapper && - !isObjectField && ( - - )} + const uiOptions = getUiOptions(uiSchema); + const WrapIfAdditionalTemplate = getTemplate( + "WrapIfAdditionalTemplate", + registry, + uiOptions, + ); - {isBoolean ? ( -
-
- {displayLabel && finalLabel && ( - - )} - {finalDescription && !isMultiSchemaWrapper && ( + return ( + +
+ {displayLabel && + finalLabel && + !isBoolean && + !isMultiSchemaWrapper && + !isObjectField && + !isAdditionalProperty && ( + + )} + + {isBoolean ? ( +
+
+ {displayLabel && finalLabel && ( + + )} + {finalDescription && !isMultiSchemaWrapper && ( +

+ {finalDescription} +

+ )} +
+ {children} +
+ ) : ( + <> + {children} + {finalDescription && !isMultiSchemaWrapper && !isObjectField && (

{finalDescription}

)} -
- {children} -
- ) : ( - <> - {children} - {finalDescription && !isMultiSchemaWrapper && !isObjectField && ( -

{finalDescription}

- )} - - )} + + )} - {errors} - {help} -
+ {errors} + {help} +
+ ); } diff --git a/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx b/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx index 925a11fc4..f15a1a303 100644 --- a/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx +++ b/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx @@ -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 ( + + ); + }; + // Root level renders children directly if (isRoot) { return (
{renderGroupedFields(regularProps)} + {renderAddButton()} {advancedProps.length > 0 && ( @@ -264,6 +301,7 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) { {renderGroupedFields(regularProps)} + {renderAddButton()} {advancedProps.length > 0 && ( diff --git a/web/src/components/config-form/theme/templates/WrapIfAdditionalTemplate.tsx b/web/src/components/config-form/theme/templates/WrapIfAdditionalTemplate.tsx new file mode 100644 index 000000000..7755643c5 --- /dev/null +++ b/web/src/components/config-form/theme/templates/WrapIfAdditionalTemplate.tsx @@ -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) { + 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 ( +
+ {children} +
+ ); + } + + 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 ( +
+
+ {displayLabel && } + +
+
+ {displayLabel && } +
{children}
+
+
+ +
+
+ ); +} + +export default WrapIfAdditionalTemplate;