mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-12 19:37:35 +03:00
refactor with shared utilities
This commit is contained in:
parent
90d2ebfe19
commit
f6f8c82f3c
136
web/src/components/config-form/theme/components/index.tsx
Normal file
136
web/src/components/config-form/theme/components/index.tsx
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* Shared UI components for config form templates and fields.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { canExpand } from "@rjsf/utils";
|
||||||
|
import type { RJSFSchema, UiSchema } from "@rjsf/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { LuPlus, LuChevronDown, LuChevronRight } from "react-icons/lu";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface AddPropertyButtonProps {
|
||||||
|
/** Callback fired when the add button is clicked */
|
||||||
|
onAddProperty?: () => void;
|
||||||
|
/** JSON Schema to determine expandability */
|
||||||
|
schema: RJSFSchema;
|
||||||
|
/** UI Schema for expansion checks */
|
||||||
|
uiSchema?: UiSchema;
|
||||||
|
/** Current form data for expansion checks */
|
||||||
|
formData?: unknown;
|
||||||
|
/** Whether the form is disabled */
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Whether the form is read-only */
|
||||||
|
readonly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add property button for RJSF objects with additionalProperties.
|
||||||
|
* Shows "Add" button that allows adding new key-value pairs to objects.
|
||||||
|
*/
|
||||||
|
export function AddPropertyButton({
|
||||||
|
onAddProperty,
|
||||||
|
schema,
|
||||||
|
uiSchema,
|
||||||
|
formData,
|
||||||
|
disabled,
|
||||||
|
readonly,
|
||||||
|
}: AddPropertyButtonProps) {
|
||||||
|
const { t } = useTranslation(["common"]);
|
||||||
|
|
||||||
|
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("button.add", { ns: "common", defaultValue: "Add" })}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdvancedCollapsibleProps {
|
||||||
|
/** Number of advanced fields */
|
||||||
|
count: number;
|
||||||
|
/** Whether the collapsible is open */
|
||||||
|
open: boolean;
|
||||||
|
/** Callback when open state changes */
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
/** Content to show when expanded */
|
||||||
|
children: ReactNode;
|
||||||
|
/** Use root-level label variant (longer text) */
|
||||||
|
isRoot?: boolean;
|
||||||
|
/** Button size - defaults to undefined (default) for root, "sm" for nested */
|
||||||
|
buttonSize?: "sm" | "default" | "lg" | "icon";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapsible section for advanced form fields.
|
||||||
|
* Provides consistent styling and i18n labels for advanced settings.
|
||||||
|
*/
|
||||||
|
export function AdvancedCollapsible({
|
||||||
|
count,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
children,
|
||||||
|
isRoot = false,
|
||||||
|
buttonSize,
|
||||||
|
}: AdvancedCollapsibleProps) {
|
||||||
|
const { t } = useTranslation(["views/settings", "common"]);
|
||||||
|
|
||||||
|
if (count === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveSize = buttonSize ?? (isRoot ? undefined : "sm");
|
||||||
|
|
||||||
|
const label = isRoot
|
||||||
|
? t("configForm.advancedSettingsCount", {
|
||||||
|
ns: "views/settings",
|
||||||
|
defaultValue: "Advanced Settings ({{count}})",
|
||||||
|
count,
|
||||||
|
})
|
||||||
|
: t("configForm.advancedCount", {
|
||||||
|
ns: "views/settings",
|
||||||
|
defaultValue: "Advanced ({{count}})",
|
||||||
|
count,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible open={open} onOpenChange={onOpenChange}>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size={effectiveSize}
|
||||||
|
className="w-full justify-start gap-2 pl-0"
|
||||||
|
>
|
||||||
|
{open ? (
|
||||||
|
<LuChevronDown className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<LuChevronRight className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="space-y-4 pt-2">
|
||||||
|
{children}
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -77,19 +77,13 @@
|
|||||||
* handling).
|
* handling).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { canExpand } from "@rjsf/utils";
|
|
||||||
import type { FieldProps, ObjectFieldTemplateProps } from "@rjsf/utils";
|
import type { FieldProps, ObjectFieldTemplateProps } from "@rjsf/utils";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
|
||||||
Collapsible,
|
|
||||||
CollapsibleContent,
|
|
||||||
CollapsibleTrigger,
|
|
||||||
} from "@/components/ui/collapsible";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
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 { ConfigFormContext } from "@/types/configForm";
|
import { ConfigFormContext } from "@/types/configForm";
|
||||||
|
import { getDomainFromNamespace, humanizeKey } from "../utils/i18n";
|
||||||
|
import { AddPropertyButton, AdvancedCollapsible } from "../components";
|
||||||
|
|
||||||
type LayoutGridColumnConfig = {
|
type LayoutGridColumnConfig = {
|
||||||
"ui:col"?: number | string;
|
"ui:col"?: number | string;
|
||||||
@ -171,29 +165,20 @@ function GridLayoutObjectFieldTemplate(
|
|||||||
(p) => p.content.props.uiSchema?.["ui:options"]?.advanced !== true,
|
(p) => p.content.props.uiSchema?.["ui:options"]?.advanced !== true,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Extract domain from i18nNamespace (e.g., "config/audio" -> "audio")
|
|
||||||
const getDomainFromNamespace = (ns?: string): string => {
|
|
||||||
if (!ns || !ns.startsWith("config/")) return "";
|
|
||||||
return ns.replace("config/", "");
|
|
||||||
};
|
|
||||||
|
|
||||||
const domain = getDomainFromNamespace(formContext?.i18nNamespace);
|
const domain = getDomainFromNamespace(formContext?.i18nNamespace);
|
||||||
const sectionI18nPrefix = formContext?.sectionI18nPrefix;
|
const sectionI18nPrefix = formContext?.sectionI18nPrefix;
|
||||||
|
|
||||||
const toTitle = (value: string) =>
|
|
||||||
value.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
|
|
||||||
|
|
||||||
const getGroupLabel = (groupKey: string) => {
|
const getGroupLabel = (groupKey: string) => {
|
||||||
if (domain && sectionI18nPrefix) {
|
if (domain && sectionI18nPrefix) {
|
||||||
return t(`${sectionI18nPrefix}.${domain}.${groupKey}`, {
|
return t(`${sectionI18nPrefix}.${domain}.${groupKey}`, {
|
||||||
ns: "config/groups",
|
ns: "config/groups",
|
||||||
defaultValue: toTitle(groupKey),
|
defaultValue: humanizeKey(groupKey),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return t(`groups.${groupKey}`, {
|
return t(`groups.${groupKey}`, {
|
||||||
ns: "config/groups",
|
ns: "config/groups",
|
||||||
defaultValue: toTitle(groupKey),
|
defaultValue: humanizeKey(groupKey),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -460,29 +445,6 @@ function GridLayoutObjectFieldTemplate(
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
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("button.add", { ns: "common", defaultValue: "Add" })}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const regularLayout = renderGroupedGridLayout(regularProps, baseRowClassName);
|
const regularLayout = renderGroupedGridLayout(regularProps, baseRowClassName);
|
||||||
const advancedLayout = useGridForAdvanced
|
const advancedLayout = useGridForAdvanced
|
||||||
? renderGroupedGridLayout(advancedProps, advancedRowClassName)
|
? renderGroupedGridLayout(advancedProps, advancedRowClassName)
|
||||||
@ -496,32 +458,23 @@ function GridLayoutObjectFieldTemplate(
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{regularLayout}
|
{regularLayout}
|
||||||
{renderAddButton()}
|
<AddPropertyButton
|
||||||
|
onAddProperty={onAddProperty}
|
||||||
|
schema={schema}
|
||||||
|
uiSchema={uiSchema}
|
||||||
|
formData={formData}
|
||||||
|
disabled={disabled}
|
||||||
|
readonly={readonly}
|
||||||
|
/>
|
||||||
|
|
||||||
{advancedProps.length > 0 && (
|
<AdvancedCollapsible
|
||||||
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
|
count={advancedProps.length}
|
||||||
<CollapsibleTrigger asChild>
|
open={showAdvanced}
|
||||||
<Button
|
onOpenChange={setShowAdvanced}
|
||||||
variant="ghost"
|
isRoot
|
||||||
className="w-full justify-start gap-2 pl-0"
|
>
|
||||||
>
|
{advancedLayout}
|
||||||
{showAdvanced ? (
|
</AdvancedCollapsible>
|
||||||
<LuChevronDown className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<LuChevronRight className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
{t("configForm.advancedSettingsCount", {
|
|
||||||
ns: "views/settings",
|
|
||||||
defaultValue: "Advanced Settings ({{count}})",
|
|
||||||
count: advancedProps.length,
|
|
||||||
})}
|
|
||||||
</Button>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
<CollapsibleContent className="space-y-4 pt-2">
|
|
||||||
{advancedLayout}
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -533,33 +486,22 @@ function GridLayoutObjectFieldTemplate(
|
|||||||
<ObjectFieldTemplate {...props}>
|
<ObjectFieldTemplate {...props}>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{regularLayout}
|
{regularLayout}
|
||||||
{renderAddButton()}
|
<AddPropertyButton
|
||||||
|
onAddProperty={onAddProperty}
|
||||||
|
schema={schema}
|
||||||
|
uiSchema={uiSchema}
|
||||||
|
formData={formData}
|
||||||
|
disabled={disabled}
|
||||||
|
readonly={readonly}
|
||||||
|
/>
|
||||||
|
|
||||||
{advancedProps.length > 0 && (
|
<AdvancedCollapsible
|
||||||
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
|
count={advancedProps.length}
|
||||||
<CollapsibleTrigger asChild>
|
open={showAdvanced}
|
||||||
<Button
|
onOpenChange={setShowAdvanced}
|
||||||
variant="ghost"
|
>
|
||||||
size="sm"
|
{advancedLayout}
|
||||||
className="w-full justify-start gap-2 pl-0"
|
</AdvancedCollapsible>
|
||||||
>
|
|
||||||
{showAdvanced ? (
|
|
||||||
<LuChevronDown className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<LuChevronRight className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
{t("label.advancedCount", {
|
|
||||||
ns: "common",
|
|
||||||
defaultValue: "Advanced ({{count}})",
|
|
||||||
count: advancedProps.length,
|
|
||||||
})}
|
|
||||||
</Button>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
<CollapsibleContent className="space-y-4 pt-2">
|
|
||||||
{advancedLayout}
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</ObjectFieldTemplate>
|
</ObjectFieldTemplate>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -16,59 +16,11 @@ import { ConfigFormContext } from "@/types/configForm";
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { LuExternalLink } from "react-icons/lu";
|
import { LuExternalLink } from "react-icons/lu";
|
||||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||||
|
import {
|
||||||
/**
|
buildTranslationPath,
|
||||||
* Build the i18n translation key path for nested fields using the field path
|
getFilterObjectLabel,
|
||||||
* provided by RJSF. This avoids ambiguity with underscores in field names and
|
humanizeKey,
|
||||||
* skips dynamic filter labels for per-object filter fields.
|
} from "../utils/i18n";
|
||||||
*/
|
|
||||||
function buildTranslationPath(segments: string[], sectionI18nPrefix?: string) {
|
|
||||||
// Example: filters.person.threshold -> filters.threshold or ov1.model -> model
|
|
||||||
const filtersIndex = segments.indexOf("filters");
|
|
||||||
if (filtersIndex !== -1 && segments.length > filtersIndex + 2) {
|
|
||||||
const normalized = [
|
|
||||||
...segments.slice(0, filtersIndex + 1),
|
|
||||||
...segments.slice(filtersIndex + 2),
|
|
||||||
];
|
|
||||||
return normalized.join(".");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Example: detectors.ov1.type -> detectors.type
|
|
||||||
const detectorsIndex = segments.indexOf("detectors");
|
|
||||||
if (detectorsIndex !== -1 && segments.length > detectorsIndex + 2) {
|
|
||||||
const normalized = [
|
|
||||||
...segments.slice(0, detectorsIndex + 1),
|
|
||||||
...segments.slice(detectorsIndex + 2),
|
|
||||||
];
|
|
||||||
return normalized.join(".");
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we are in the detectors section but 'detectors' is not in the path (specialized section)
|
|
||||||
// then the first segment is the dynamic detector name.
|
|
||||||
if (sectionI18nPrefix === "detectors" && segments.length > 1) {
|
|
||||||
return segments.slice(1).join(".");
|
|
||||||
}
|
|
||||||
|
|
||||||
return segments.join(".");
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFilterObjectLabel(pathSegments: string[]): string | undefined {
|
|
||||||
const filtersIndex = pathSegments.indexOf("filters");
|
|
||||||
if (filtersIndex === -1 || pathSegments.length <= filtersIndex + 1) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const objectLabel = pathSegments[filtersIndex + 1];
|
|
||||||
return typeof objectLabel === "string" && objectLabel.length > 0
|
|
||||||
? objectLabel
|
|
||||||
: undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function humanizeKey(value: string): string {
|
|
||||||
return value
|
|
||||||
.split("_")
|
|
||||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
|
||||||
.join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function _isArrayItemInAdditionalProperty(
|
function _isArrayItemInAdditionalProperty(
|
||||||
pathSegments: Array<string | number>,
|
pathSegments: Array<string | number>,
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
// 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 {
|
||||||
@ -7,47 +6,20 @@ import {
|
|||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from "@/components/ui/collapsible";
|
} from "@/components/ui/collapsible";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Children, useState } from "react";
|
import { Children, useState } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { LuChevronDown, LuChevronRight, LuPlus } from "react-icons/lu";
|
import { LuChevronDown, LuChevronRight } 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";
|
||||||
import { ConfigFormContext } from "@/types/configForm";
|
import { ConfigFormContext } from "@/types/configForm";
|
||||||
|
import {
|
||||||
/**
|
buildTranslationPath,
|
||||||
* Build the i18n translation key path for nested fields using the field path
|
getFilterObjectLabel,
|
||||||
* provided by RJSF. This avoids ambiguity with underscores in field names and
|
humanizeKey,
|
||||||
* skips dynamic filter labels for per-object filter fields.
|
getDomainFromNamespace,
|
||||||
*/
|
} from "../utils/i18n";
|
||||||
function buildTranslationPath(path: Array<string | number>): string {
|
import { AddPropertyButton, AdvancedCollapsible } from "../components";
|
||||||
const segments = path.filter(
|
|
||||||
(segment): segment is string => typeof segment === "string",
|
|
||||||
);
|
|
||||||
|
|
||||||
const filtersIndex = segments.indexOf("filters");
|
|
||||||
if (filtersIndex !== -1 && segments.length > filtersIndex + 2) {
|
|
||||||
const normalized = [
|
|
||||||
...segments.slice(0, filtersIndex + 1),
|
|
||||||
...segments.slice(filtersIndex + 2),
|
|
||||||
];
|
|
||||||
return normalized.join(".");
|
|
||||||
}
|
|
||||||
|
|
||||||
return segments.join(".");
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFilterObjectLabel(
|
|
||||||
pathSegments: Array<string | number>,
|
|
||||||
): string | undefined {
|
|
||||||
const index = pathSegments.indexOf("filters");
|
|
||||||
if (index === -1 || pathSegments.length <= index + 1) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const label = pathSegments[index + 1];
|
|
||||||
return typeof label === "string" && label.length > 0 ? label : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
||||||
const {
|
const {
|
||||||
@ -80,12 +52,6 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
|||||||
"common",
|
"common",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Extract domain from i18nNamespace (e.g., "config/audio" -> "audio")
|
|
||||||
const getDomainFromNamespace = (ns?: string): string => {
|
|
||||||
if (!ns || !ns.startsWith("config/")) return "";
|
|
||||||
return ns.replace("config/", "");
|
|
||||||
};
|
|
||||||
|
|
||||||
const domain = getDomainFromNamespace(formContext?.i18nNamespace);
|
const domain = getDomainFromNamespace(formContext?.i18nNamespace);
|
||||||
|
|
||||||
const groupDefinitions =
|
const groupDefinitions =
|
||||||
@ -110,9 +76,6 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
|||||||
};
|
};
|
||||||
const hasCustomChildren = Children.count(children) > 0;
|
const hasCustomChildren = Children.count(children) > 0;
|
||||||
|
|
||||||
const toTitle = (value: string) =>
|
|
||||||
value.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
|
|
||||||
|
|
||||||
// Get the full translation path from the field path
|
// Get the full translation path from the field path
|
||||||
const fieldPathId = (
|
const fieldPathId = (
|
||||||
props as { fieldPathId?: { path?: (string | number)[] } }
|
props as { fieldPathId?: { path?: (string | number)[] } }
|
||||||
@ -157,7 +120,9 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
|||||||
}
|
}
|
||||||
const schemaTitle = schema?.title;
|
const schemaTitle = schema?.title;
|
||||||
const fallbackLabel =
|
const fallbackLabel =
|
||||||
title || schemaTitle || (propertyName ? toTitle(propertyName) : undefined);
|
title ||
|
||||||
|
schemaTitle ||
|
||||||
|
(propertyName ? humanizeKey(propertyName) : undefined);
|
||||||
inferredLabel = inferredLabel ?? fallbackLabel;
|
inferredLabel = inferredLabel ?? fallbackLabel;
|
||||||
|
|
||||||
let inferredDescription: string | undefined;
|
let inferredDescription: string | undefined;
|
||||||
@ -203,10 +168,10 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
|||||||
const label = domain
|
const label = domain
|
||||||
? t(`${sectionI18nPrefix}.${domain}.${groupKey}`, {
|
? t(`${sectionI18nPrefix}.${domain}.${groupKey}`, {
|
||||||
ns: "config/groups",
|
ns: "config/groups",
|
||||||
defaultValue: toTitle(groupKey),
|
defaultValue: humanizeKey(groupKey),
|
||||||
})
|
})
|
||||||
: t(`groups.${groupKey}`, {
|
: t(`groups.${groupKey}`, {
|
||||||
defaultValue: toTitle(groupKey),
|
defaultValue: humanizeKey(groupKey),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -249,29 +214,6 @@ 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("button.add", { ns: "common", defaultValue: "Add" })}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Root level renders children directly
|
// Root level renders children directly
|
||||||
if (isRoot) {
|
if (isRoot) {
|
||||||
return (
|
return (
|
||||||
@ -281,32 +223,23 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{renderGroupedFields(regularProps)}
|
{renderGroupedFields(regularProps)}
|
||||||
{renderAddButton()}
|
<AddPropertyButton
|
||||||
|
onAddProperty={onAddProperty}
|
||||||
|
schema={schema}
|
||||||
|
uiSchema={uiSchema}
|
||||||
|
formData={formData}
|
||||||
|
disabled={disabled}
|
||||||
|
readonly={readonly}
|
||||||
|
/>
|
||||||
|
|
||||||
{advancedProps.length > 0 && (
|
<AdvancedCollapsible
|
||||||
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
|
count={advancedProps.length}
|
||||||
<CollapsibleTrigger asChild>
|
open={showAdvanced}
|
||||||
<Button
|
onOpenChange={setShowAdvanced}
|
||||||
variant="ghost"
|
isRoot
|
||||||
className="w-full justify-start gap-2 pl-0"
|
>
|
||||||
>
|
{renderGroupedFields(advancedProps)}
|
||||||
{showAdvanced ? (
|
</AdvancedCollapsible>
|
||||||
<LuChevronDown className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<LuChevronRight className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
{t("configForm.advancedSettingsCount", {
|
|
||||||
ns: "views/settings",
|
|
||||||
defaultValue: "Advanced Settings ({{count}})",
|
|
||||||
count: advancedProps.length,
|
|
||||||
})}
|
|
||||||
</Button>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
<CollapsibleContent className="space-y-4 pt-2">
|
|
||||||
{renderGroupedFields(advancedProps)}
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -343,36 +276,22 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{renderGroupedFields(regularProps)}
|
{renderGroupedFields(regularProps)}
|
||||||
{renderAddButton()}
|
<AddPropertyButton
|
||||||
|
onAddProperty={onAddProperty}
|
||||||
|
schema={schema}
|
||||||
|
uiSchema={uiSchema}
|
||||||
|
formData={formData}
|
||||||
|
disabled={disabled}
|
||||||
|
readonly={readonly}
|
||||||
|
/>
|
||||||
|
|
||||||
{advancedProps.length > 0 && (
|
<AdvancedCollapsible
|
||||||
<Collapsible
|
count={advancedProps.length}
|
||||||
open={showAdvanced}
|
open={showAdvanced}
|
||||||
onOpenChange={setShowAdvanced}
|
onOpenChange={setShowAdvanced}
|
||||||
>
|
>
|
||||||
<CollapsibleTrigger asChild>
|
{renderGroupedFields(advancedProps)}
|
||||||
<Button
|
</AdvancedCollapsible>
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="w-full justify-start gap-2 pl-0"
|
|
||||||
>
|
|
||||||
{showAdvanced ? (
|
|
||||||
<LuChevronDown className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<LuChevronRight className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
{t("configForm.advancedCount", {
|
|
||||||
ns: "views/settings",
|
|
||||||
defaultValue: "Advanced ({{count}})",
|
|
||||||
count: advancedProps.length,
|
|
||||||
})}
|
|
||||||
</Button>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
<CollapsibleContent className="space-y-4 pt-2">
|
|
||||||
{renderGroupedFields(advancedProps)}
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
117
web/src/components/config-form/theme/utils/i18n.ts
Normal file
117
web/src/components/config-form/theme/utils/i18n.ts
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* Shared i18n utilities for config form templates and fields.
|
||||||
|
*
|
||||||
|
* These functions handle translation key path building and label normalization
|
||||||
|
* for RJSF form fields.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the i18n translation key path for nested fields using the field path
|
||||||
|
* provided by RJSF. This avoids ambiguity with underscores in field names and
|
||||||
|
* normalizes dynamic segments like filter object names or detector names.
|
||||||
|
*
|
||||||
|
* @param segments Array of path segments (strings and/or numbers)
|
||||||
|
* @param sectionI18nPrefix Optional section prefix for specialized sections
|
||||||
|
* @returns Normalized translation key path as a dot-separated string
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* buildTranslationPath(["filters", "person", "threshold"]) => "filters.threshold"
|
||||||
|
* buildTranslationPath(["detectors", "ov1", "type"]) => "detectors.type"
|
||||||
|
* buildTranslationPath(["model", "type"], "detectors") => "type"
|
||||||
|
*/
|
||||||
|
export function buildTranslationPath(
|
||||||
|
segments: Array<string | number>,
|
||||||
|
sectionI18nPrefix?: string,
|
||||||
|
): string {
|
||||||
|
// Filter out numeric indices to get string segments only
|
||||||
|
const stringSegments = segments.filter(
|
||||||
|
(segment): segment is string => typeof segment === "string",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle filters section - skip the dynamic filter object name
|
||||||
|
// Example: filters.person.threshold -> filters.threshold
|
||||||
|
const filtersIndex = stringSegments.indexOf("filters");
|
||||||
|
if (filtersIndex !== -1 && stringSegments.length > filtersIndex + 2) {
|
||||||
|
const normalized = [
|
||||||
|
...stringSegments.slice(0, filtersIndex + 1),
|
||||||
|
...stringSegments.slice(filtersIndex + 2),
|
||||||
|
];
|
||||||
|
return normalized.join(".");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle detectors section - skip the dynamic detector name
|
||||||
|
// Example: detectors.ov1.type -> detectors.type
|
||||||
|
const detectorsIndex = stringSegments.indexOf("detectors");
|
||||||
|
if (detectorsIndex !== -1 && stringSegments.length > detectorsIndex + 2) {
|
||||||
|
const normalized = [
|
||||||
|
...stringSegments.slice(0, detectorsIndex + 1),
|
||||||
|
...stringSegments.slice(detectorsIndex + 2),
|
||||||
|
];
|
||||||
|
return normalized.join(".");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle specialized sections like detectors where the first segment is dynamic
|
||||||
|
// Example: (sectionI18nPrefix="detectors") "ov1.type" -> "type"
|
||||||
|
if (sectionI18nPrefix === "detectors" && stringSegments.length > 1) {
|
||||||
|
return stringSegments.slice(1).join(".");
|
||||||
|
}
|
||||||
|
|
||||||
|
return stringSegments.join(".");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the filter object label from a path containing "filters" segment.
|
||||||
|
* Returns the segment immediately after "filters".
|
||||||
|
*
|
||||||
|
* @param pathSegments Array of path segments
|
||||||
|
* @returns The filter object label or undefined if not found
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* getFilterObjectLabel(["filters", "person", "threshold"]) => "person"
|
||||||
|
* getFilterObjectLabel(["detect", "enabled"]) => undefined
|
||||||
|
*/
|
||||||
|
export function getFilterObjectLabel(
|
||||||
|
pathSegments: Array<string | number>,
|
||||||
|
): string | undefined {
|
||||||
|
const filtersIndex = pathSegments.indexOf("filters");
|
||||||
|
if (filtersIndex === -1 || pathSegments.length <= filtersIndex + 1) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const objectLabel = pathSegments[filtersIndex + 1];
|
||||||
|
return typeof objectLabel === "string" && objectLabel.length > 0
|
||||||
|
? objectLabel
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert snake_case string to Title Case with spaces.
|
||||||
|
* Useful for generating human-readable labels from schema property names.
|
||||||
|
*
|
||||||
|
* @param value The snake_case string to convert
|
||||||
|
* @returns Title Case string
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* humanizeKey("detect_fps") => "Detect Fps"
|
||||||
|
* humanizeKey("min_initialized") => "Min Initialized"
|
||||||
|
*/
|
||||||
|
export function humanizeKey(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/_/g, " ")
|
||||||
|
.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract domain name from an i18n namespace string.
|
||||||
|
* Handles config/* namespace format by stripping the prefix.
|
||||||
|
*
|
||||||
|
* @param ns The i18n namespace (e.g., "config/audio", "config/global")
|
||||||
|
* @returns The domain portion (e.g., "audio", "global") or empty string
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* getDomainFromNamespace("config/audio") => "audio"
|
||||||
|
* getDomainFromNamespace("common") => ""
|
||||||
|
*/
|
||||||
|
export function getDomainFromNamespace(ns?: string): string {
|
||||||
|
if (!ns || !ns.startsWith("config/")) return "";
|
||||||
|
return ns.replace("config/", "");
|
||||||
|
}
|
||||||
10
web/src/components/config-form/theme/utils/index.ts
Normal file
10
web/src/components/config-form/theme/utils/index.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Config form theme utilities
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
buildTranslationPath,
|
||||||
|
getFilterObjectLabel,
|
||||||
|
humanizeKey,
|
||||||
|
getDomainFromNamespace,
|
||||||
|
} from "./i18n";
|
||||||
Loading…
Reference in New Issue
Block a user