mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-30 03:44:57 +03:00
add field template for additionalproperties schema objects
This commit is contained in:
parent
06c21bf6f2
commit
4dc039072a
@ -1137,6 +1137,12 @@
|
|||||||
"system": "System",
|
"system": "System",
|
||||||
"integrations": "Integrations"
|
"integrations": "Integrations"
|
||||||
},
|
},
|
||||||
|
"additionalProperties": {
|
||||||
|
"keyLabel": "Key",
|
||||||
|
"valueLabel": "Value",
|
||||||
|
"keyPlaceholder": "New key",
|
||||||
|
"remove": "Remove"
|
||||||
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"detect": "Detection",
|
"detect": "Detection",
|
||||||
"record": "Recording",
|
"record": "Recording",
|
||||||
|
|||||||
@ -32,6 +32,7 @@ import { DescriptionFieldTemplate } from "./templates/DescriptionFieldTemplate";
|
|||||||
import { TitleFieldTemplate } from "./templates/TitleFieldTemplate";
|
import { TitleFieldTemplate } from "./templates/TitleFieldTemplate";
|
||||||
import { ErrorListTemplate } from "./templates/ErrorListTemplate";
|
import { ErrorListTemplate } from "./templates/ErrorListTemplate";
|
||||||
import { MultiSchemaFieldTemplate } from "./templates/MultiSchemaFieldTemplate";
|
import { MultiSchemaFieldTemplate } from "./templates/MultiSchemaFieldTemplate";
|
||||||
|
import { WrapIfAdditionalTemplate } from "./templates/WrapIfAdditionalTemplate";
|
||||||
|
|
||||||
export interface FrigateTheme {
|
export interface FrigateTheme {
|
||||||
widgets: RegistryWidgetsType;
|
widgets: RegistryWidgetsType;
|
||||||
@ -70,6 +71,7 @@ export const frigateTheme: FrigateTheme = {
|
|||||||
TitleFieldTemplate: TitleFieldTemplate,
|
TitleFieldTemplate: TitleFieldTemplate,
|
||||||
ErrorListTemplate: ErrorListTemplate,
|
ErrorListTemplate: ErrorListTemplate,
|
||||||
MultiSchemaFieldTemplate: MultiSchemaFieldTemplate,
|
MultiSchemaFieldTemplate: MultiSchemaFieldTemplate,
|
||||||
|
WrapIfAdditionalTemplate: WrapIfAdditionalTemplate,
|
||||||
},
|
},
|
||||||
fields: {},
|
fields: {},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
// Field Template - wraps each form field with label and description
|
// Field Template - wraps each form field with label and description
|
||||||
import type { FieldTemplateProps, StrictRJSFSchema } from "@rjsf/utils";
|
import type { FieldTemplateProps, StrictRJSFSchema } from "@rjsf/utils";
|
||||||
|
import {
|
||||||
|
getTemplate,
|
||||||
|
getUiOptions,
|
||||||
|
ADDITIONAL_PROPERTY_FLAG,
|
||||||
|
} from "@rjsf/utils";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -51,6 +56,8 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
|||||||
id,
|
id,
|
||||||
label,
|
label,
|
||||||
children,
|
children,
|
||||||
|
classNames,
|
||||||
|
style,
|
||||||
errors,
|
errors,
|
||||||
help,
|
help,
|
||||||
description,
|
description,
|
||||||
@ -61,6 +68,13 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
|||||||
uiSchema,
|
uiSchema,
|
||||||
registry,
|
registry,
|
||||||
fieldPathId,
|
fieldPathId,
|
||||||
|
onKeyRename,
|
||||||
|
onKeyRenameBlur,
|
||||||
|
onRemoveProperty,
|
||||||
|
rawDescription,
|
||||||
|
rawErrors,
|
||||||
|
disabled,
|
||||||
|
readonly,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
// Get i18n namespace from form context (passed through registry)
|
// Get i18n namespace from form context (passed through registry)
|
||||||
@ -78,15 +92,16 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get UI options
|
// Get UI options
|
||||||
const uiOptions = uiSchema?.["ui:options"] || {};
|
const uiOptionsFromSchema = uiSchema?.["ui:options"] || {};
|
||||||
|
|
||||||
// Determine field characteristics
|
// Determine field characteristics
|
||||||
const isAdvanced = uiOptions.advanced === true;
|
const isAdvanced = uiOptionsFromSchema.advanced === true;
|
||||||
const isBoolean =
|
const isBoolean =
|
||||||
schema.type === "boolean" ||
|
schema.type === "boolean" ||
|
||||||
(Array.isArray(schema.type) && schema.type.includes("boolean"));
|
(Array.isArray(schema.type) && schema.type.includes("boolean"));
|
||||||
const isObjectField = schema.type === "object";
|
const isObjectField = schema.type === "object";
|
||||||
const isNullableUnion = isNullableUnionSchema(schema as StrictRJSFSchema);
|
const isNullableUnion = isNullableUnionSchema(schema as StrictRJSFSchema);
|
||||||
|
const isAdditionalProperty = ADDITIONAL_PROPERTY_FLAG in schema;
|
||||||
const suppressMultiSchema =
|
const suppressMultiSchema =
|
||||||
(uiSchema?.["ui:options"] as Record<string, unknown> | undefined)
|
(uiSchema?.["ui:options"] as Record<string, unknown> | undefined)
|
||||||
?.suppressMultiSchema === true;
|
?.suppressMultiSchema === true;
|
||||||
@ -199,60 +214,92 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
|||||||
finalDescription = schemaDescription;
|
finalDescription = schemaDescription;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const uiOptions = getUiOptions(uiSchema);
|
||||||
<div
|
const WrapIfAdditionalTemplate = getTemplate(
|
||||||
className={cn(
|
"WrapIfAdditionalTemplate",
|
||||||
"space-y-1",
|
registry,
|
||||||
isAdvanced && "border-l-2 border-muted pl-4",
|
uiOptions,
|
||||||
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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isBoolean ? (
|
return (
|
||||||
<div className="flex w-full items-center justify-between gap-4">
|
<WrapIfAdditionalTemplate
|
||||||
<div className="space-y-0.5">
|
classNames={classNames}
|
||||||
{displayLabel && finalLabel && (
|
style={style}
|
||||||
<Label htmlFor={id} className="text-sm font-medium">
|
disabled={disabled}
|
||||||
{finalLabel}
|
id={id}
|
||||||
{required && <span className="ml-1 text-destructive">*</span>}
|
label={label}
|
||||||
</Label>
|
displayLabel={displayLabel}
|
||||||
)}
|
onKeyRename={onKeyRename}
|
||||||
{finalDescription && !isMultiSchemaWrapper && (
|
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">
|
<p className="text-xs text-muted-foreground">
|
||||||
{finalDescription}
|
{finalDescription}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
{children}
|
)}
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{children}
|
|
||||||
{finalDescription && !isMultiSchemaWrapper && !isObjectField && (
|
|
||||||
<p className="text-xs text-muted-foreground">{finalDescription}</p>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{errors}
|
{errors}
|
||||||
{help}
|
{help}
|
||||||
</div>
|
</div>
|
||||||
|
</WrapIfAdditionalTemplate>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
// Object Field Template - renders nested object fields with i18n support
|
// Object Field Template - renders nested object fields with i18n support
|
||||||
|
import { canExpand } from "@rjsf/utils";
|
||||||
import type { ObjectFieldTemplateProps } from "@rjsf/utils";
|
import type { ObjectFieldTemplateProps } from "@rjsf/utils";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
@ -8,7 +9,7 @@ import {
|
|||||||
} 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 { useState } from "react";
|
||||||
import { LuChevronDown, LuChevronRight } 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";
|
||||||
import { getTranslatedLabel } from "@/utils/i18n";
|
import { getTranslatedLabel } from "@/utils/i18n";
|
||||||
@ -47,7 +48,18 @@ function getFilterObjectLabel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
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 };
|
type FormContext = { i18nNamespace?: string };
|
||||||
const formContext = registry?.formContext as FormContext | undefined;
|
const formContext = registry?.formContext as FormContext | undefined;
|
||||||
|
|
||||||
@ -59,6 +71,7 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
|||||||
const { t, i18n } = useTranslation([
|
const { t, i18n } = useTranslation([
|
||||||
formContext?.i18nNamespace || "common",
|
formContext?.i18nNamespace || "common",
|
||||||
"config/groups",
|
"config/groups",
|
||||||
|
"common",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Extract domain from i18nNamespace (e.g., "config/audio" -> "audio")
|
// 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
|
// Root level renders children directly
|
||||||
if (isRoot) {
|
if (isRoot) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{renderGroupedFields(regularProps)}
|
{renderGroupedFields(regularProps)}
|
||||||
|
{renderAddButton()}
|
||||||
|
|
||||||
{advancedProps.length > 0 && (
|
{advancedProps.length > 0 && (
|
||||||
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
|
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
|
||||||
@ -264,6 +301,7 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
|||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<CardContent className="space-y-4 pt-0">
|
<CardContent className="space-y-4 pt-0">
|
||||||
{renderGroupedFields(regularProps)}
|
{renderGroupedFields(regularProps)}
|
||||||
|
{renderAddButton()}
|
||||||
|
|
||||||
{advancedProps.length > 0 && (
|
{advancedProps.length > 0 && (
|
||||||
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
|
<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