mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-10 10:33:11 +03:00
add field template for additionalproperties schema objects
This commit is contained in:
parent
06c21bf6f2
commit
4dc039072a
@ -1137,6 +1137,12 @@
|
||||
"system": "System",
|
||||
"integrations": "Integrations"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"keyLabel": "Key",
|
||||
"valueLabel": "Value",
|
||||
"keyPlaceholder": "New key",
|
||||
"remove": "Remove"
|
||||
},
|
||||
"sections": {
|
||||
"detect": "Detection",
|
||||
"record": "Recording",
|
||||
|
||||
@ -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: {},
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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;
|
||||
Loading…
Reference in New Issue
Block a user