Settings i18n improvements (#22571)

* i18n improvements for settings UI

- deduplicate shared detector translation keys and centralize config translation resolution
- add missing i18n keys

* formatting
This commit is contained in:
Josh Hawkins
2026-03-22 13:03:24 -05:00
committed by GitHub
parent 74c89beaf9
commit b6c03c99de
12 changed files with 217 additions and 809 deletions

View File

@@ -75,7 +75,9 @@ export default function CameraReviewStatusToggles({
/>
<div className="space-y-0.5">
<Label htmlFor="detections-enabled">
<Trans ns="views/settings">camera.review.detections</Trans>
<Trans ns="views/settings">
cameraReview.review.detections
</Trans>
</Label>
</div>
</div>

View File

@@ -1136,7 +1136,7 @@ export function ConfigSection({
)}
{hasChanges && (
<Badge variant="outline" className="text-xs">
{t("modified", {
{t("button.modified", {
ns: "common",
defaultValue: "Modified",
})}
@@ -1210,7 +1210,10 @@ export function ConfigSection({
variant="secondary"
className="cursor-default bg-danger text-xs text-white hover:bg-danger"
>
{t("modified", { ns: "common", defaultValue: "Modified" })}
{t("button.modified", {
ns: "common",
defaultValue: "Modified",
})}
</Badge>
)}
</div>

View File

@@ -7,7 +7,11 @@ import type {
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { LuCircleAlert } from "react-icons/lu";
import { useTranslation } from "react-i18next";
import { buildTranslationPath, humanizeKey } from "../utils";
import {
buildTranslationPath,
resolveConfigTranslation,
humanizeKey,
} from "../utils";
import type { ConfigFormContext } from "@/types/configForm";
type ErrorSchemaNode = RJSFSchema & {
@@ -114,22 +118,15 @@ const resolveErrorFieldLabel = ({
);
if (effectiveNamespace && translationPath) {
const prefixedTranslationKey =
sectionI18nPrefix && !translationPath.startsWith(`${sectionI18nPrefix}.`)
? `${sectionI18nPrefix}.${translationPath}.label`
: undefined;
const translationKey = `${translationPath}.label`;
if (
prefixedTranslationKey &&
i18n.exists(prefixedTranslationKey, { ns: effectiveNamespace })
) {
return t(prefixedTranslationKey, { ns: effectiveNamespace });
}
if (i18n.exists(translationKey, { ns: effectiveNamespace })) {
return t(translationKey, { ns: effectiveNamespace });
}
const translated = resolveConfigTranslation(
i18n,
t,
translationPath,
"label",
sectionI18nPrefix,
effectiveNamespace,
);
if (translated) return translated;
}
const schemaNode = resolveSchemaNodeForPath(schema, segments);

View File

@@ -20,6 +20,7 @@ import { requiresRestartForFieldPath } from "@/utils/configUtil";
import RestartRequiredIndicator from "@/components/indicators/RestartRequiredIndicator";
import {
buildTranslationPath,
resolveConfigTranslation,
getFilterObjectLabel,
hasOverrideAtPath,
humanizeKey,
@@ -219,20 +220,16 @@ export function FieldTemplate(props: FieldTemplateProps) {
// Try to get translated label, falling back to schema title, then RJSF label
let finalLabel = label;
if (effectiveNamespace && translationPath) {
// Prefer camera-scoped translations when a section prefix is provided
const prefixedTranslationKey =
sectionI18nPrefix && !translationPath.startsWith(`${sectionI18nPrefix}.`)
? `${sectionI18nPrefix}.${translationPath}.label`
: undefined;
const translationKey = `${translationPath}.label`;
if (
prefixedTranslationKey &&
i18n.exists(prefixedTranslationKey, { ns: effectiveNamespace })
) {
finalLabel = t(prefixedTranslationKey, { ns: effectiveNamespace });
} else if (i18n.exists(translationKey, { ns: effectiveNamespace })) {
finalLabel = t(translationKey, { ns: effectiveNamespace });
const translatedLabel = resolveConfigTranslation(
i18n,
t,
translationPath,
"label",
sectionI18nPrefix,
effectiveNamespace,
);
if (translatedLabel) {
finalLabel = translatedLabel;
} else if (schemaTitle) {
finalLabel = schemaTitle;
} else if (translatedFilterObjectLabel) {
@@ -330,18 +327,16 @@ export function FieldTemplate(props: FieldTemplateProps) {
// Try to get translated description, falling back to schema description
let finalDescription = description || "";
if (effectiveNamespace && translationPath) {
const prefixedDescriptionKey =
sectionI18nPrefix && !translationPath.startsWith(`${sectionI18nPrefix}.`)
? `${sectionI18nPrefix}.${translationPath}.description`
: undefined;
const descriptionKey = `${translationPath}.description`;
if (
prefixedDescriptionKey &&
i18n.exists(prefixedDescriptionKey, { ns: effectiveNamespace })
) {
finalDescription = t(prefixedDescriptionKey, { ns: effectiveNamespace });
} else if (i18n.exists(descriptionKey, { ns: effectiveNamespace })) {
finalDescription = t(descriptionKey, { ns: effectiveNamespace });
const translatedDescription = resolveConfigTranslation(
i18n,
t,
translationPath,
"description",
sectionI18nPrefix,
effectiveNamespace,
);
if (translatedDescription) {
finalDescription = translatedDescription;
} else if (schemaDescription) {
finalDescription = schemaDescription;
}

View File

@@ -17,6 +17,7 @@ import { requiresRestartForFieldPath } from "@/utils/configUtil";
import { ConfigFormContext } from "@/types/configForm";
import {
buildTranslationPath,
resolveConfigTranslation,
getDomainFromNamespace,
getFilterObjectLabel,
humanizeKey,
@@ -263,16 +264,14 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
let inferredLabel: string | undefined;
if (i18nNs && translationPath) {
const prefixedLabelKey =
sectionI18nPrefix && !translationPath.startsWith(`${sectionI18nPrefix}.`)
? `${sectionI18nPrefix}.${translationPath}.label`
: undefined;
const labelKey = `${translationPath}.label`;
if (prefixedLabelKey && i18n.exists(prefixedLabelKey, { ns: i18nNs })) {
inferredLabel = t(prefixedLabelKey, { ns: i18nNs });
} else if (i18n.exists(labelKey, { ns: i18nNs })) {
inferredLabel = t(labelKey, { ns: i18nNs });
}
inferredLabel = resolveConfigTranslation(
i18n,
t,
translationPath,
"label",
sectionI18nPrefix,
i18nNs,
);
}
if (!inferredLabel && translatedFilterLabel) {
inferredLabel = translatedFilterLabel;
@@ -286,19 +285,14 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
let inferredDescription: string | undefined;
if (i18nNs && translationPath) {
const prefixedDescriptionKey =
sectionI18nPrefix && !translationPath.startsWith(`${sectionI18nPrefix}.`)
? `${sectionI18nPrefix}.${translationPath}.description`
: undefined;
const descriptionKey = `${translationPath}.description`;
if (
prefixedDescriptionKey &&
i18n.exists(prefixedDescriptionKey, { ns: i18nNs })
) {
inferredDescription = t(prefixedDescriptionKey, { ns: i18nNs });
} else if (i18n.exists(descriptionKey, { ns: i18nNs })) {
inferredDescription = t(descriptionKey, { ns: i18nNs });
}
inferredDescription = resolveConfigTranslation(
i18n,
t,
translationPath,
"description",
sectionI18nPrefix,
i18nNs,
);
}
const schemaDescription = schema?.description;
const fallbackDescription =

View File

@@ -124,6 +124,50 @@ export function buildTranslationPath(
return stringSegments.join(".");
}
/**
* Resolve a translated label or description for a config form field.
*
* Tries keys in priority order:
* 1. Type-specific prefixed key (e.g. "detectors.edgetpu.device.label")
* 2. Shared prefixed key with type stripped (e.g. "detectors.device.label")
* 3. Unprefixed key (e.g. "device.label")
*
* @returns The translated string, or undefined if no key matched.
*/
export function resolveConfigTranslation(
i18n: { exists: (key: string, opts?: Record<string, unknown>) => boolean },
t: (key: string, opts?: Record<string, unknown>) => string,
translationPath: string,
suffix: "label" | "description",
sectionI18nPrefix?: string,
ns?: string,
): string | undefined {
const opts = ns ? { ns } : undefined;
if (
sectionI18nPrefix &&
!translationPath.startsWith(`${sectionI18nPrefix}.`)
) {
// 1. Type-specific prefixed key (e.g. detectors.edgetpu.device.label)
const prefixed = `${sectionI18nPrefix}.${translationPath}.${suffix}`;
if (i18n.exists(prefixed, opts)) return t(prefixed, opts);
// 2. Shared prefixed key — strip leading type segment
// e.g. detectors.edgetpu.model.path → detectors.model.path
const dot = translationPath.indexOf(".");
if (dot !== -1) {
const shared = `${sectionI18nPrefix}.${translationPath.substring(dot + 1)}.${suffix}`;
if (i18n.exists(shared, opts)) return t(shared, opts);
}
}
// 3. Unprefixed key
const base = `${translationPath}.${suffix}`;
if (i18n.exists(base, opts)) return t(base, opts);
return undefined;
}
/**
* Extract the filter object label from a path containing "filters" segment.
* Returns the segment immediately after "filters".

View File

@@ -4,6 +4,7 @@
export {
buildTranslationPath,
resolveConfigTranslation,
getFilterObjectLabel,
humanizeKey,
getDomainFromNamespace,

View File

@@ -207,7 +207,10 @@ export function SingleSectionPage({
variant="secondary"
className="cursor-default bg-danger text-xs text-white hover:bg-danger"
>
{t("modified", { ns: "common", defaultValue: "Modified" })}
{t("button.modified", {
ns: "common",
defaultValue: "Modified",
})}
</Badge>
)}
</div>
@@ -242,7 +245,7 @@ export function SingleSectionPage({
variant="secondary"
className="cursor-default bg-danger text-xs text-white hover:bg-danger"
>
{t("modified", { ns: "common", defaultValue: "Modified" })}
{t("button.modified", { ns: "common", defaultValue: "Modified" })}
</Badge>
)}
</div>