Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/12821-settings-chip-i18n.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@object-ui/i18n': patch
'@object-ui/console': patch
---

A settings row's chips read in the session's language (objectui#12821). The env-lock chip said "Locked by env" in every language, and its title, shown when the server names no reason, was English too; the `Deprecated` chip (with its replacement key) and the row drawn for an unsupported specifier type were English literals as well.

- **`@object-ui/i18n`** adds `console.settingsView.envLockBadge`, `envLockBadgeTitle`, `deprecatedBadge`, `deprecatedBadgeReplacedBy` (with `{{replacedBy}}`) and `unsupportedSpecifierType` (with `{{type}}`) to all ten bundles. The English values are the old wording, so an English session reads exactly as before.
- **The console's settings page** draws those chips from these keys. A reason the server sends (`lockedReason`, such as "Set via env: NAME") is still shown as served.

No export, prop or type is added, removed or renamed.
11 changes: 9 additions & 2 deletions apps/console/src/pages/settings/EnvLockBadge.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
/**
* <EnvLockBadge> — small chip indicating the value is pinned by env.
*
* The chip and its fallback title are the settings page's own copy, so both
* read `console.settingsView.*` in the session's language (objectui#12821).
* A served `reason` is the server's text (`ResolvedSettingValue.lockedReason`)
* and is shown as served.
*/

import { Lock } from 'lucide-react';
import { Badge } from '@object-ui/components';
import { useObjectTranslation } from '@object-ui/i18n';

export interface EnvLockBadgeProps {
/** The reason string carried in ResolvedSettingValue.lockedReason. */
Expand All @@ -12,14 +18,15 @@ export interface EnvLockBadgeProps {
}

export function EnvLockBadge({ reason, className }: EnvLockBadgeProps) {
const { t } = useObjectTranslation();
return (
<Badge
variant="outline"
className={`gap-1 border-amber-300 bg-amber-50 text-amber-900 ${className ?? ''}`}
title={reason ?? 'Pinned by environment variable — cannot be edited from the UI.'}
title={reason ?? t('console.settingsView.envLockBadgeTitle')}
>
<Lock className="h-3 w-3" />
Locked by env
{t('console.settingsView.envLockBadge')}
</Badge>
);
}
9 changes: 7 additions & 2 deletions apps/console/src/pages/settings/SettingsField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ function FieldHeader({
labelId?: string;
htmlFor?: string;
}) {
const { t } = useObjectTranslation();
return (
<div className="flex items-center gap-2">
<Label id={labelId} htmlFor={htmlFor} className="text-sm font-medium">
Expand All @@ -168,7 +169,9 @@ function FieldHeader({
</Label>
{spec.deprecated ? (
<Badge variant="outline" className="text-amber-700 border-amber-300">
Deprecated{spec.replacedBy ? ` → ${spec.replacedBy}` : ''}
{spec.replacedBy
? t('console.settingsView.deprecatedBadgeReplacedBy', { replacedBy: spec.replacedBy })
: t('console.settingsView.deprecatedBadge')}
</Badge>
) : null}
{resolved?.locked ? <EnvLockBadge reason={resolved.lockedReason} /> : null}
Expand Down Expand Up @@ -646,7 +649,9 @@ export function SettingsField(props: SettingsFieldProps) {
);
default:
return wrapper(
<div className="text-sm text-muted-foreground">Unsupported specifier type: {spec.type}</div>,
<div className="text-sm text-muted-foreground">
{t('console.settingsView.unsupportedSpecifierType', { type: spec.type })}
</div>,
undefined,
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* The chips on a settings row's header speak the session locale
* (objectui#12821, the family after objectui#12694).
*
* ## What was broken
*
* `EnvLockBadge` drew its chip text and its fallback title as JSX literals, and
* `FieldHeader` drew the `Deprecated` chip (and its `→ replacedBy` arrow) the
* same way. So an administrator on a zh-CN session read "Locked by env" beside
* a row that otherwise read in zh. All three now read `console.settingsView.*`.
*
* ## What stays as served
*
* A served `lockedReason` (the server's "Set via env: NAME") is the server's
* text and is shown verbatim as the title; only the fallback title, used when
* the server names no reason, is the page's own copy.
*
* ## The `Deprecated` chip has no producer today
*
* No platform settings manifest declares a deprecated specifier, so nothing
* draws this chip in a running console. It is the same class on the same row,
* so a fixture specifier draws it here.
*
* ## Red-first prediction (written BEFORE the first run)
*
* On unchanged `main`: the zh cases and the every-pack case FAIL (the chip
* carries the English literal; no pack defines the keys); the en cases PASS,
* because English is the one language whose output must not change.
*/

import { describe, it, expect, afterEach, beforeEach } from 'vitest';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { I18nProvider } from '@object-ui/i18n';
import { builtInLocales } from '@object-ui/i18n/locales';
import { SettingsField } from '../SettingsField';
import type { ResolvedSettingValue, Specifier } from '../types';

afterEach(cleanup);
// A language a previous case persisted would outrank `defaultLanguage`.
beforeEach(() => {
window.localStorage.clear();
});

const COUNTRY: Specifier = {
type: 'text',
key: 'default_country',
label: 'Default country',
required: false,
};

const DEPRECATED: Specifier = { ...COUNTRY, deprecated: true };
const DEPRECATED_REPLACED: Specifier = { ...COUNTRY, deprecated: true, replacedBy: 'country_code' };

/** Pinned by env, the server naming no reason: the chip shows the fallback title. */
const LOCKED_NO_REASON: ResolvedSettingValue = { value: 'CN', source: 'env', locked: true };

/** Pinned by env with a served reason: the server's text is the title, verbatim. */
const LOCKED_WITH_REASON: ResolvedSettingValue = {
value: 'CN',
source: 'env',
locked: true,
lockedReason: 'Set via env: OS_LOCALIZATION_DEFAULT_COUNTRY',
};

type Lang = 'en' | 'zh-CN';

function renderRow(language: Lang, spec: Specifier, resolved?: ResolvedSettingValue) {
return render(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
<SettingsField spec={spec} resolved={resolved} value="CN" onChange={() => {}} />
</I18nProvider>,
);
}

const view = builtInLocales.zh.console.settingsView as unknown as Record<string, string>;
const viewEn = builtInLocales.en.console.settingsView as unknown as Record<string, string>;
const LATIN = /\p{Script=Latin}/u;

/** The row header's one chip whose text is `text`, once the catalogue has landed. */
async function chip(text: string) {
return waitFor(() => screen.getByText(text, { selector: 'div,span' }));
}

describe('the env-lock chip under zh-CN reads in zh (objectui#12821)', () => {
it('the chip text and its fallback title are the zh strings', async () => {
renderRow('zh-CN', COUNTRY, LOCKED_NO_REASON);
const el = await chip(view.envLockBadge);
expect(el.textContent).not.toMatch(LATIN);
expect(el).toHaveAttribute('title', view.envLockBadgeTitle);
expect(el.getAttribute('title')).not.toMatch(LATIN);
});

it("a served reason is the server's text, shown as served", async () => {
renderRow('zh-CN', COUNTRY, LOCKED_WITH_REASON);
const el = await chip(view.envLockBadge);
expect(el).toHaveAttribute('title', 'Set via env: OS_LOCALIZATION_DEFAULT_COUNTRY');
});
});

describe('the Deprecated chip under zh-CN reads in zh (objectui#12821)', () => {
it('without a replacement', async () => {
renderRow('zh-CN', DEPRECATED);
const el = await chip(view.deprecatedBadge);
expect(el.textContent).not.toMatch(LATIN);
});

it('with a replacement, the key spliced into the zh sentence', async () => {
renderRow('zh-CN', DEPRECATED_REPLACED);
const expected = view.deprecatedBadgeReplacedBy.replace('{{replacedBy}}', 'country_code');
const el = await chip(expected);
// The replacement key is a key, not copy: the rest of the chip is zh.
expect(el.textContent!.replace('country_code', '')).not.toMatch(LATIN);
});
});

describe('the chips under en read exactly as they did (objectui#12821, must-not-change)', () => {
it('Locked by env, with the old fallback title', async () => {
renderRow('en', COUNTRY, LOCKED_NO_REASON);
const el = await chip('Locked by env');
expect(el.textContent).toBe('Locked by env');
expect(el).toHaveAttribute('title', 'Pinned by environment variable — cannot be edited from the UI.');
});

it('Deprecated, and Deprecated → replacedBy', async () => {
renderRow('en', DEPRECATED);
expect((await chip('Deprecated')).textContent).toBe('Deprecated');
cleanup();
renderRow('en', DEPRECATED_REPLACED);
expect((await chip('Deprecated → country_code')).textContent).toBe('Deprecated → country_code');
});

it('the unsupported-type row reads as it did', async () => {
renderRow('en', { ...COUNTRY, type: 'not_a_type' } as unknown as Specifier);
expect((await chip('Unsupported specifier type: not_a_type')).textContent).toBe(
'Unsupported specifier type: not_a_type',
);
});
});

describe('every pack carries the row chips, translated (objectui#12821)', () => {
const PLAIN = ['envLockBadge', 'envLockBadgeTitle', 'deprecatedBadge'] as const;
const HOLED = [
['deprecatedBadgeReplacedBy', '{{replacedBy}}'],
['unsupportedSpecifierType', '{{type}}'],
] as const;

it.each(Object.keys(builtInLocales))('%s: each chip key is a string, not an English copy', (code) => {
const pack = builtInLocales[code as keyof typeof builtInLocales] as unknown as {
console?: { settingsView?: Record<string, unknown> };
};
const at = (key: string) => pack.console?.settingsView?.[key];
for (const key of PLAIN) {
expect(typeof at(key), `${code} console.settingsView.${key}`).toBe('string');
if (code !== 'en') expect(at(key), `${code} ${key}`).not.toBe(viewEn[key]);
}
for (const [key, hole] of HOLED) {
expect(typeof at(key), `${code} console.settingsView.${key}`).toBe('string');
expect((at(key) as string).split(hole), `${code} ${key} holes`).toHaveLength(2);
if (code !== 'en') expect(at(key), `${code} ${key}`).not.toBe(viewEn[key]);
}
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#12821 — no component of the console settings page draws a bare
* English literal: every JSX text node, every string a JSX child expression
* renders, and every `title` / `aria-label` value goes through `t()`.
*
* A row chip drawn as a literal reads English on every other language and
* nothing fails. That happened twice on this page: the source badges'
* "Overrides" / "Inherited from" (objectui#12694) and the env-lock chip
* "Locked by env" (objectui#12821). This pin holds the class for the page
* rather than one chip, so the next chip added cannot ship English-only.
*
* ## How the files are read
*
* Every `*.tsx` directly under `apps/console/src/pages/settings/`, listed from
* disk (a new component is enumerated with no edit here). Comments are blanked
* by `scripts/js-comment-mask.mjs`, the one comment masker this tree uses,
* before the TypeScript parser reads the result, so prose naming a literal is
* never a finding.
*
* ## What counts as a rendered literal
*
* A string with a letter in it (`\p{L}`, so any script, not only Latin) that
* reaches the DOM: a JSX text node, or a string in a RENDERED position of a JSX
* child expression or a `title` / `aria-label` value. A rendered position is
* the expression itself, a branch of `?:`, either side of `??` / `||`, the
* right of `&&`, or a template's text. A string inside a call (`t('key')`,
* `t(key, { defaultValue })`) or a comparison is not rendered, and a
* `className` is not copy.
*/
import { describe, it, expect } from 'vitest';
import { readFileSync, readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import ts from 'typescript';
import { maskComments } from '../../../../../../scripts/js-comment-mask.mjs';

const HERE = dirname(fileURLToPath(import.meta.url));
const PAGE_DIR = join(HERE, '..');
const LETTER = /\p{L}/u;
const COPY_ATTRIBUTES = new Set(['title', 'aria-label']);

function renderedLiterals(file: string, raw: string): string[] {
const source = maskComments(raw);
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const hits: string[] = [];
const hit = (where: string, text: string) => {
if (LETTER.test(text)) hits.push(`${where}: ${JSON.stringify(text.trim())}`);
};

const rendered = (node: ts.Expression, where: string): void => {
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return hit(where, node.text);
if (ts.isTemplateExpression(node)) {
hit(where, node.head.text);
for (const span of node.templateSpans) {
rendered(span.expression, where);
hit(where, span.literal.text);
}
return;
}
if (ts.isParenthesizedExpression(node)) return rendered(node.expression, where);
if (ts.isConditionalExpression(node)) {
rendered(node.whenTrue, where);
rendered(node.whenFalse, where);
return;
}
if (ts.isBinaryExpression(node)) {
const op = node.operatorToken.kind;
if (op === ts.SyntaxKind.QuestionQuestionToken || op === ts.SyntaxKind.BarBarToken) {
rendered(node.left, where);
rendered(node.right, where);
} else if (op === ts.SyntaxKind.AmpersandAmpersandToken) {
rendered(node.right, where);
}
}
};

const walk = (node: ts.Node): void => {
if (ts.isJsxText(node)) hit('text', node.text);
if (
ts.isJsxExpression(node) &&
node.expression &&
(ts.isJsxElement(node.parent) || ts.isJsxFragment(node.parent))
) {
rendered(node.expression, 'child');
}
if (ts.isJsxAttribute(node) && node.initializer) {
const name = node.name.getText(sf);
if (COPY_ATTRIBUTES.has(name)) {
const init = node.initializer;
if (ts.isStringLiteral(init)) hit(name, init.text);
else if (ts.isJsxExpression(init) && init.expression) rendered(init.expression, name);
}
}
ts.forEachChild(node, walk);
};
walk(sf);
return hits;
}

const FILES = readdirSync(PAGE_DIR)
.filter((name) => name.endsWith('.tsx'))
.sort();

describe('the console settings page draws no bare English literal (objectui#12821)', () => {
it('enumerates the page components (a short list would pass vacuously)', () => {
expect(FILES).toContain('EnvLockBadge.tsx');
expect(FILES).toContain('SettingsField.tsx');
});

it('the detector sees the shapes it claims, and not a t() call or a comment (control)', () => {
const sample = [
'export const A = ({ r, n }) => (',
" <B title={r ?? 'Pinned by env'} aria-label=\"Lock\">",
' Locked by env',
" {n ? `Deprecated → ${n}` : ''}",
" {t('console.settingsView.envLockBadge', { defaultValue: 'Locked by env' })}",
" <C title={t('k')} className=\"text-amber-700\" />",
" {/* Locked by env */}",
' </B>',
');',
].join('\n');
expect(renderedLiterals('sample.tsx', sample)).toEqual([
'title: "Pinned by env"',
'aria-label: "Lock"',
'text: "Locked by env"',
'child: "Deprecated →"',
]);
});

it.each(FILES)('%s: no JSX text, rendered child string, title or aria-label is a literal', (file) => {
expect(renderedLiterals(file, readFileSync(join(PAGE_DIR, file), 'utf8'))).toEqual([]);
});
});
Loading
Loading