diff --git a/web/src/components/config-form/theme/components/index.tsx b/web/src/components/config-form/theme/components/index.tsx new file mode 100644 index 000000000..4c9446c87 --- /dev/null +++ b/web/src/components/config-form/theme/components/index.tsx @@ -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 ( + + ); +} + +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 ( + + + + + + {children} + + + ); +} diff --git a/web/src/components/config-form/theme/fields/LayoutGridField.tsx b/web/src/components/config-form/theme/fields/LayoutGridField.tsx index 793720b8c..52c1288ee 100644 --- a/web/src/components/config-form/theme/fields/LayoutGridField.tsx +++ b/web/src/components/config-form/theme/fields/LayoutGridField.tsx @@ -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 ( - - ); - }; - const regularLayout = renderGroupedGridLayout(regularProps, baseRowClassName); const advancedLayout = useGridForAdvanced ? renderGroupedGridLayout(advancedProps, advancedRowClassName) @@ -496,32 +458,23 @@ function GridLayoutObjectFieldTemplate( return (
{regularLayout} - {renderAddButton()} + - {advancedProps.length > 0 && ( - - - - - - {advancedLayout} - - - )} + + {advancedLayout} +
); } @@ -533,33 +486,22 @@ function GridLayoutObjectFieldTemplate(
{regularLayout} - {renderAddButton()} + - {advancedProps.length > 0 && ( - - - - - - {advancedLayout} - - - )} + + {advancedLayout} +
); diff --git a/web/src/components/config-form/theme/templates/FieldTemplate.tsx b/web/src/components/config-form/theme/templates/FieldTemplate.tsx index c577c13c0..15bad53a3 100644 --- a/web/src/components/config-form/theme/templates/FieldTemplate.tsx +++ b/web/src/components/config-form/theme/templates/FieldTemplate.tsx @@ -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, diff --git a/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx b/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx index 7baac1c2a..82547dba2 100644 --- a/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx +++ b/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx @@ -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 { - 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 | 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 ( - - ); - }; - // Root level renders children directly if (isRoot) { return ( @@ -281,32 +223,23 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) { ) : ( <> {renderGroupedFields(regularProps)} - {renderAddButton()} + - {advancedProps.length > 0 && ( - - - - - - {renderGroupedFields(advancedProps)} - - - )} + + {renderGroupedFields(advancedProps)} + )} @@ -343,36 +276,22 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) { ) : ( <> {renderGroupedFields(regularProps)} - {renderAddButton()} + - {advancedProps.length > 0 && ( - - - - - - {renderGroupedFields(advancedProps)} - - - )} + + {renderGroupedFields(advancedProps)} + )} diff --git a/web/src/components/config-form/theme/utils/i18n.ts b/web/src/components/config-form/theme/utils/i18n.ts new file mode 100644 index 000000000..a104ccb41 --- /dev/null +++ b/web/src/components/config-form/theme/utils/i18n.ts @@ -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, + 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 | 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/", ""); +} diff --git a/web/src/components/config-form/theme/utils/index.ts b/web/src/components/config-form/theme/utils/index.ts new file mode 100644 index 000000000..45ede5eaf --- /dev/null +++ b/web/src/components/config-form/theme/utils/index.ts @@ -0,0 +1,10 @@ +/** + * Config form theme utilities + */ + +export { + buildTranslationPath, + getFilterObjectLabel, + humanizeKey, + getDomainFromNamespace, +} from "./i18n";