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";