refactor with shared utilities

This commit is contained in:
Josh Hawkins 2026-02-05 09:25:01 -06:00
parent 90d2ebfe19
commit f6f8c82f3c
6 changed files with 347 additions and 271 deletions

View 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>
);
}

View File

@ -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>
);

View File

@ -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>,

View File

@ -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>

View 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/", "");
}

View File

@ -0,0 +1,10 @@
/**
* Config form theme utilities
*/
export {
buildTranslationPath,
getFilterObjectLabel,
humanizeKey,
getDomainFromNamespace,
} from "./i18n";