mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-10 18:43:09 +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).
|
||||
*/
|
||||
|
||||
import { canExpand } from "@rjsf/utils";
|
||||
import type { FieldProps, ObjectFieldTemplateProps } from "@rjsf/utils";
|
||||
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 { cn } from "@/lib/utils";
|
||||
import { ConfigFormContext } from "@/types/configForm";
|
||||
import { getDomainFromNamespace, humanizeKey } from "../utils/i18n";
|
||||
import { AddPropertyButton, AdvancedCollapsible } from "../components";
|
||||
|
||||
type LayoutGridColumnConfig = {
|
||||
"ui:col"?: number | string;
|
||||
@ -171,29 +165,20 @@ function GridLayoutObjectFieldTemplate(
|
||||
(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 sectionI18nPrefix = formContext?.sectionI18nPrefix;
|
||||
|
||||
const toTitle = (value: string) =>
|
||||
value.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
|
||||
const getGroupLabel = (groupKey: string) => {
|
||||
if (domain && sectionI18nPrefix) {
|
||||
return t(`${sectionI18nPrefix}.${domain}.${groupKey}`, {
|
||||
ns: "config/groups",
|
||||
defaultValue: toTitle(groupKey),
|
||||
defaultValue: humanizeKey(groupKey),
|
||||
});
|
||||
}
|
||||
|
||||
return t(`groups.${groupKey}`, {
|
||||
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 advancedLayout = useGridForAdvanced
|
||||
? renderGroupedGridLayout(advancedProps, advancedRowClassName)
|
||||
@ -496,32 +458,23 @@ function GridLayoutObjectFieldTemplate(
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{regularLayout}
|
||||
{renderAddButton()}
|
||||
<AddPropertyButton
|
||||
onAddProperty={onAddProperty}
|
||||
schema={schema}
|
||||
uiSchema={uiSchema}
|
||||
formData={formData}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
/>
|
||||
|
||||
{advancedProps.length > 0 && (
|
||||
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2 pl-0"
|
||||
>
|
||||
{showAdvanced ? (
|
||||
<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>
|
||||
)}
|
||||
<AdvancedCollapsible
|
||||
count={advancedProps.length}
|
||||
open={showAdvanced}
|
||||
onOpenChange={setShowAdvanced}
|
||||
isRoot
|
||||
>
|
||||
{advancedLayout}
|
||||
</AdvancedCollapsible>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -533,33 +486,22 @@ function GridLayoutObjectFieldTemplate(
|
||||
<ObjectFieldTemplate {...props}>
|
||||
<div className="space-y-4">
|
||||
{regularLayout}
|
||||
{renderAddButton()}
|
||||
<AddPropertyButton
|
||||
onAddProperty={onAddProperty}
|
||||
schema={schema}
|
||||
uiSchema={uiSchema}
|
||||
formData={formData}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
/>
|
||||
|
||||
{advancedProps.length > 0 && (
|
||||
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
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("label.advancedCount", {
|
||||
ns: "common",
|
||||
defaultValue: "Advanced ({{count}})",
|
||||
count: advancedProps.length,
|
||||
})}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-4 pt-2">
|
||||
{advancedLayout}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
<AdvancedCollapsible
|
||||
count={advancedProps.length}
|
||||
open={showAdvanced}
|
||||
onOpenChange={setShowAdvanced}
|
||||
>
|
||||
{advancedLayout}
|
||||
</AdvancedCollapsible>
|
||||
</div>
|
||||
</ObjectFieldTemplate>
|
||||
);
|
||||
|
||||
@ -16,59 +16,11 @@ import { ConfigFormContext } from "@/types/configForm";
|
||||
import { Link } from "react-router-dom";
|
||||
import { LuExternalLink } from "react-icons/lu";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
|
||||
/**
|
||||
* 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
|
||||
* skips dynamic filter labels for per-object filter fields.
|
||||
*/
|
||||
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(" ");
|
||||
}
|
||||
import {
|
||||
buildTranslationPath,
|
||||
getFilterObjectLabel,
|
||||
humanizeKey,
|
||||
} from "../utils/i18n";
|
||||
|
||||
function _isArrayItemInAdditionalProperty(
|
||||
pathSegments: Array<string | number>,
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
// 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 {
|
||||
@ -7,47 +6,20 @@ import {
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Children, useState } 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 { cn } from "@/lib/utils";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import { ConfigFormContext } from "@/types/configForm";
|
||||
|
||||
/**
|
||||
* 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
|
||||
* skips dynamic filter labels for per-object filter fields.
|
||||
*/
|
||||
function buildTranslationPath(path: Array<string | number>): string {
|
||||
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;
|
||||
}
|
||||
import {
|
||||
buildTranslationPath,
|
||||
getFilterObjectLabel,
|
||||
humanizeKey,
|
||||
getDomainFromNamespace,
|
||||
} from "../utils/i18n";
|
||||
import { AddPropertyButton, AdvancedCollapsible } from "../components";
|
||||
|
||||
export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
||||
const {
|
||||
@ -80,12 +52,6 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
||||
"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 groupDefinitions =
|
||||
@ -110,9 +76,6 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
||||
};
|
||||
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
|
||||
const fieldPathId = (
|
||||
props as { fieldPathId?: { path?: (string | number)[] } }
|
||||
@ -157,7 +120,9 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
||||
}
|
||||
const schemaTitle = schema?.title;
|
||||
const fallbackLabel =
|
||||
title || schemaTitle || (propertyName ? toTitle(propertyName) : undefined);
|
||||
title ||
|
||||
schemaTitle ||
|
||||
(propertyName ? humanizeKey(propertyName) : undefined);
|
||||
inferredLabel = inferredLabel ?? fallbackLabel;
|
||||
|
||||
let inferredDescription: string | undefined;
|
||||
@ -203,10 +168,10 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
||||
const label = domain
|
||||
? t(`${sectionI18nPrefix}.${domain}.${groupKey}`, {
|
||||
ns: "config/groups",
|
||||
defaultValue: toTitle(groupKey),
|
||||
defaultValue: humanizeKey(groupKey),
|
||||
})
|
||||
: t(`groups.${groupKey}`, {
|
||||
defaultValue: toTitle(groupKey),
|
||||
defaultValue: humanizeKey(groupKey),
|
||||
});
|
||||
|
||||
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
|
||||
if (isRoot) {
|
||||
return (
|
||||
@ -281,32 +223,23 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
||||
) : (
|
||||
<>
|
||||
{renderGroupedFields(regularProps)}
|
||||
{renderAddButton()}
|
||||
<AddPropertyButton
|
||||
onAddProperty={onAddProperty}
|
||||
schema={schema}
|
||||
uiSchema={uiSchema}
|
||||
formData={formData}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
/>
|
||||
|
||||
{advancedProps.length > 0 && (
|
||||
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2 pl-0"
|
||||
>
|
||||
{showAdvanced ? (
|
||||
<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>
|
||||
)}
|
||||
<AdvancedCollapsible
|
||||
count={advancedProps.length}
|
||||
open={showAdvanced}
|
||||
onOpenChange={setShowAdvanced}
|
||||
isRoot
|
||||
>
|
||||
{renderGroupedFields(advancedProps)}
|
||||
</AdvancedCollapsible>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@ -343,36 +276,22 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
||||
) : (
|
||||
<>
|
||||
{renderGroupedFields(regularProps)}
|
||||
{renderAddButton()}
|
||||
<AddPropertyButton
|
||||
onAddProperty={onAddProperty}
|
||||
schema={schema}
|
||||
uiSchema={uiSchema}
|
||||
formData={formData}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
/>
|
||||
|
||||
{advancedProps.length > 0 && (
|
||||
<Collapsible
|
||||
open={showAdvanced}
|
||||
onOpenChange={setShowAdvanced}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
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>
|
||||
)}
|
||||
<AdvancedCollapsible
|
||||
count={advancedProps.length}
|
||||
open={showAdvanced}
|
||||
onOpenChange={setShowAdvanced}
|
||||
>
|
||||
{renderGroupedFields(advancedProps)}
|
||||
</AdvancedCollapsible>
|
||||
</>
|
||||
)}
|
||||
</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