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
41 changes: 41 additions & 0 deletions .changeset/page-block-inspector-curated-label-i18n-3913.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
"@object-ui/app-shell": patch
---

Page block inspector: the PROPERTIES panel's curated field labels now follow the session locale instead of always rendering English

`PageBlockInspector`'s chrome went through the translation table from the start —
`t('engine.inspector.pageBlock.properties')` and its siblings — while the panel's
CONTENTS did not. Every `label` in `previews/block-config.ts` was an English
literal handed straight to the field components, so a zh-CN admin opening any
page block read 「属性」 over a stack of English field names: two languages in one
panel, reproducible on any block with no special data.

All 157 label literals (152 field/option labels + 5 `addLabel`s) are now
translation keys resolved through `t(key, locale)` at render, with 154 distinct
keys added to both the `en-US` and `zh-CN` sides of
`views/metadata-admin/i18n.ts`. The English text is unchanged: every key's `en`
value is the literal it replaced, verified by substituting all 157 keys back and
diffing the result byte-for-byte against the pre-change file.

The key is a function of the label's POSITION in `BLOCK_CONFIG`
(`engine.inspector.pageBlock.field.<blockType>.<name>`, `.add.<blockType>.<name>`,
`.option.<fieldName>.<value>`), and a test re-derives all three shapes from the
table's own structure. That is what makes the realistic mistake visible: these
keys differ by one segment, so adding a block by copy-pasting a neighbour's key
yields a key that EXISTS in both locales and renders a plausible label belonging
to a different property — an existence-only check is green for it. Positional
keys also let one English word take different Chinese per block, which the panel
needs: `element:button.label` is a button caption 「按钮文字」 while
`page:tabs.items.label` is a tab title 「标签」, and `Add section` is 「添加分区」
here but stays 「添加分组」 in the form-layout canvas.

`addLabel` is now REQUIRED on the `array` field variant. It was optional and the
inspector fell back to a bare English `'Add'` — an untranslatable literal no
locale table could reach. Requiring it deletes the fallback instead of
translating it, so a new array field cannot compile without naming its
add-button key.

Option labels are translated too, including the ones `ColorVariantPicker` renders
only as `aria-label`/`title`, where an untranslated string is invisible to a text
query.
317 changes: 317 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#3913 — the PROPERTIES panel renders in the session's language.
*
* The sibling `previews/__tests__/block-config-i18n.test.ts` pins the TABLE
* (every label is a key its position implies, and both locales define it). That
* is a fact about data. This file pins the fact about the screen, and they are
* different: `renderField` reaches `f.label` from eleven places — eight as a
* `label=` prop, three as `<Label>` children — plus option lists handed to
* `InspectorSelectField` / `ColorVariantPicker`, and each is a separate chance
* to forward a raw key. A table full of correct keys still renders
* `engine.inspector.pageBlock.field.…` on screen if one branch skips `t()`.
*
* So the assertions here are deliberately about visible strings, and they run
* the same block twice — once per locale — because the defect was never "no
* translation exists" but "this panel does not ask for one".
*/

import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import { PageSchema } from '@objectstack/spec/ui';
import { PageBlockInspector } from './PageBlockInspector';

afterEach(cleanup);

const BLOCK_PATH = 'regions[0].components[0]';

/** A record page carrying one block of `type`, with the given properties. */
function pageDraft(type: string, properties: Record<string, unknown> = {}): Record<string, unknown> {
return PageSchema.parse({
name: 'contact_record',
label: 'Contact',
type: 'record',
object: 'contact',
template: 'default',
regions: [{ name: 'main', components: [{ type, id: 'b1', properties }] }],
}) as unknown as Record<string, unknown>;
}

function renderInspector(draft: Record<string, unknown>, locale: 'en-US' | 'zh-CN') {
render(
<PageBlockInspector
type="page"
name="contact_record"
draft={draft}
selection={{ kind: 'block', id: BLOCK_PATH }}
onPatch={() => {}}
onClearSelection={() => {}}
readOnly={false}
locale={locale as never}
/>,
);
}

/** No raw translation key may reach the DOM in any locale. */
function expectNoRawKeys() {
expect(document.body.textContent ?? '').not.toContain('engine.inspector.pageBlock.');
}

describe('PageBlockInspector PROPERTIES labels follow the locale (#3913)', () => {
it('renders curated field labels in Chinese under zh-CN', () => {
renderInspector(pageDraft('object-grid'), 'zh-CN');

// `object-grid`: object-picker + field-list + number + two booleans, i.e.
// four different `renderField` branches in one panel.
expect(screen.getByText('对象')).toBeTruthy();
expect(screen.getByText('列')).toBeTruthy();
expect(screen.getByText('每页条数')).toBeTruthy();
expect(screen.getByText('斑马纹行')).toBeTruthy();
expect(screen.getByText('显示边框')).toBeTruthy();
// The English literals must be gone, not merely joined by Chinese ones.
expect(screen.queryByText('Striped rows')).toBeNull();
expect(screen.queryByText('Page size')).toBeNull();
expectNoRawKeys();
});

it('renders the same panel in English under en-US, unchanged', () => {
renderInspector(pageDraft('object-grid'), 'en-US');

expect(screen.getByText('Object')).toBeTruthy();
expect(screen.getByText('Columns')).toBeTruthy();
expect(screen.getByText('Page size')).toBeTruthy();
expect(screen.getByText('Striped rows')).toBeTruthy();
expect(screen.getByText('Bordered')).toBeTruthy();
expectNoRawKeys();
});

it('translates the array add button — the branch that had no key at all', () => {
// `addLabel` was optional and the array branch fell back to a bare English
// `'Add'`. It is now required, so that fallback is deleted rather than
// translated — a new array field cannot compile without its key.
renderInspector(pageDraft('record:details', { sections: [{ label: 'Contact info' }] }), 'zh-CN');

expect(screen.getByText('分区')).toBeTruthy();
expect(screen.getByText('添加分区')).toBeTruthy();
expect(screen.queryByText('Add section')).toBeNull();
expectNoRawKeys();
});

/**
* NOT asserted anywhere above, deliberately: that this panel contains no
* English at all. It still does, and the boundary is worth pinning.
*
* `FieldListField` and the `string-list` branch render their row-adder as a
* hardcoded `<Plus /> Add`, and `aria-label`s ("Remove", "Remove item"), the
* JSON parse error ("Invalid JSON") and two placeholders ("field name",
* "snake_case object") are literals in `PageBlockInspector.tsx` itself. None
* of them was ever a `block-config` label — different mechanism, different
* surface — so they are outside objectui#3913 and filed as objectui#3963.
*
* Asserting their PRESENCE (rather than quietly not mentioning them) means the
* day objectui#3963 lands, this case fails and has to be tightened, instead of
* the two fixes silently overlapping and leaving nobody sure what is covered.
*/
it('leaves only the known non-block-config chrome in English (objectui#3963)', () => {
renderInspector(pageDraft('record:details', { sections: [{ label: 'Contact info' }] }), 'zh-CN');

// The section's `fields` item editor is a `field-list`; its adder is chrome.
const adders = screen.getAllByRole('button').filter((b) => b.textContent?.trim() === 'Add');
expect(adders.length).toBeGreaterThan(0);
expect(screen.getAllByLabelText('Remove item').length).toBeGreaterThan(0);
});

it('translates nested array-item labels, not just the container', () => {
// Item fields recurse through `renderField` with a different read/write
// pair; a fix applied only to the top level would leave these English.
renderInspector(pageDraft('record:details', { sections: [{ label: 'Contact info' }] }), 'zh-CN');

expect(screen.getByText('名称(i18n 键)')).toBeTruthy();
expect(screen.getByText('标签')).toBeTruthy();
expect(screen.getByText('列数')).toBeTruthy();
expect(screen.getByText('字段')).toBeTruthy();
expect(screen.queryByText('Name (i18n key)')).toBeNull();
});

it('translates select OPTION labels, not only the field label', () => {
// Options are a separate hop: they are passed as data to
// `InspectorSelectField`, so translating `f.label` alone leaves the
// dropdown's own text English. `severity` renders its current value.
renderInspector(pageDraft('record:alert', { severity: 'warning' }), 'zh-CN');

expect(screen.getByText('严重度')).toBeTruthy();
expect(screen.getByText('警告')).toBeTruthy();
expect(screen.queryByText('Warning')).toBeNull();
expect(screen.getByText('可关闭')).toBeTruthy();
expectNoRawKeys();
});

it('translates color-swatch option labels (the accessible name)', () => {
// `ColorVariantPicker` renders option labels as `aria-label`/`title` only —
// invisible to a text query, and exactly where an untranslated key hides.
renderInspector(pageDraft('object-metric', { colorVariant: 'blue' }), 'zh-CN');

expect(screen.getByLabelText('蓝色')).toBeTruthy();
expect(screen.getByLabelText('成功')).toBeTruthy();
expect(screen.queryByLabelText('Blue')).toBeNull();
});

it('keeps the panel chrome and its contents in ONE language', () => {
// The bug in one assertion: the section heading was already translated
// while everything under it was not.
renderInspector(pageDraft('page:header'), 'zh-CN');

expect(screen.getByText('属性')).toBeTruthy(); // chrome, already worked
expect(screen.getByText('标题')).toBeTruthy(); // contents, the fix
expect(screen.getByText('副标题')).toBeTruthy();
expect(screen.getByText('显示面包屑')).toBeTruthy();
expect(screen.queryByText('Subtitle')).toBeNull();
expect(screen.queryByText('Show breadcrumb')).toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,16 @@ export function PageBlockInspector({ selection, draft, onPatch, onClearSelection
(key) => !curatedNames.has(key) && !STRUCTURAL_PROP_KEYS.has(key),
);

// Curated labels are translation KEYS, not display text (#3913). The panel's
// chrome went through `t()` from the start while its contents did not, so a
// zh-CN admin read 「属性」 over a stack of English field names. `t()` returns
// the key unchanged when it is missing, so an untranslated field is loud in
// every locale rather than silently English in one.
const fieldLabel = (key: string) => t(key, locale);
/** Option labels are keys too — translate before handing them to a picker. */
const optionLabels = <T extends { value: string; label: string }>(options: T[]) =>
options.map((o) => ({ ...o, label: t(o.label, locale) }));

// Generic, recursive field renderer. `read`/`write` abstract the value source
// (the block's `properties` at the top level, or an item object inside an
// `array` field), so the same code drives nested array-item editors.
Expand All @@ -391,47 +401,47 @@ export function PageBlockInspector({ selection, draft, onPatch, onClearSelection
switch (f.kind) {
case 'number':
return (
<InspectorNumberField key={k} label={f.label}
<InspectorNumberField key={k} label={fieldLabel(f.label)}
value={typeof read(f.name) === 'number' ? (read(f.name) as number) : undefined}
placeholder={f.placeholder} onCommit={(v) => write(f.name, v)} disabled={readOnly} />
);
case 'boolean':
return (
<InspectorCheckboxField key={k} label={f.label} value={!!read(f.name)}
<InspectorCheckboxField key={k} label={fieldLabel(f.label)} value={!!read(f.name)}
onCommit={(v) => write(f.name, v)} disabled={readOnly} />
);
case 'color':
return (
<div key={k} className="space-y-1">
<Label className="text-xs text-muted-foreground">{f.label}</Label>
<Label className="text-xs text-muted-foreground">{fieldLabel(f.label)}</Label>
<ColorVariantPicker
value={read(f.name) != null ? String(read(f.name)) : undefined}
onChange={(v) => write(f.name, v)}
disabled={readOnly}
options={f.options}
options={f.options ? optionLabels(f.options) : undefined}
/>
</div>
);
case 'select':
return (
<InspectorSelectField key={k} label={f.label}
<InspectorSelectField key={k} label={fieldLabel(f.label)}
value={read(f.name) != null ? String(read(f.name)) : undefined}
options={f.options} onCommit={(v) => write(f.name, v)} disabled={readOnly} />
options={optionLabels(f.options)} onCommit={(v) => write(f.name, v)} disabled={readOnly} />
);
case 'json':
// Same editor the "Advanced" section uses, but reachable for a prop the
// block does not have yet — Advanced enumerates existing keys only, so
// without this a curated JSON prop could be edited and never added.
return (
<InspectorJsonField key={k} label={f.label} value={read(f.name)}
<InspectorJsonField key={k} label={fieldLabel(f.label)} value={read(f.name)}
placeholder={f.placeholder}
onCommit={(v) => write(f.name, v)} disabled={readOnly} />
);
case 'string-list': {
const arr = Array.isArray(read(f.name)) ? (read(f.name) as unknown[]) : [];
return (
<div key={k} className="space-y-1.5">
<Label className="text-xs text-muted-foreground">{f.label}</Label>
<Label className="text-xs text-muted-foreground">{fieldLabel(f.label)}</Label>
{arr.map((s, i) => (
<div key={i} className="flex items-center gap-1.5">
<Input className="h-8 text-sm" value={String(s ?? '')} placeholder={f.placeholder} disabled={readOnly}
Expand All @@ -454,7 +464,7 @@ export function PageBlockInspector({ selection, draft, onPatch, onClearSelection
const arr = Array.isArray(read(f.name)) ? (read(f.name) as unknown[]) : [];
return (
<div key={k} className="space-y-2">
<Label className="text-xs text-muted-foreground">{f.label}</Label>
<Label className="text-xs text-muted-foreground">{fieldLabel(f.label)}</Label>
{arr.map((item, i) => {
const itemObj = item && typeof item === 'object' ? (item as Record<string, unknown>) : {};
return (
Expand All @@ -479,32 +489,32 @@ export function PageBlockInspector({ selection, draft, onPatch, onClearSelection
})}
{!readOnly && (
<Button type="button" variant="outline" size="sm" onClick={() => write(f.name, [...arr, {}])}>
<Plus className="mr-1 h-3.5 w-3.5" /> {f.addLabel || 'Add'}
<Plus className="mr-1 h-3.5 w-3.5" /> {fieldLabel(f.addLabel)}
</Button>
)}
</div>
);
}
case 'object-picker':
return (
<ObjectPickerField key={k} label={f.label}
<ObjectPickerField key={k} label={fieldLabel(f.label)}
value={read(f.name) != null ? String(read(f.name)) : undefined}
onCommit={(v) => write(f.name, v)} disabled={readOnly} />
);
case 'field-picker':
return (
<FieldPickerField key={k} label={f.label} objectName={resolveObject(f)}
<FieldPickerField key={k} label={fieldLabel(f.label)} objectName={resolveObject(f)}
value={read(f.name) != null ? String(read(f.name)) : undefined}
onCommit={(v) => write(f.name, v)} disabled={readOnly} />
);
case 'field-list':
return (
<FieldListField key={k} label={f.label} objectName={resolveObject(f)}
<FieldListField key={k} label={fieldLabel(f.label)} objectName={resolveObject(f)}
value={read(f.name)} onChange={(v) => write(f.name, v)} disabled={readOnly} />
);
default:
return (
<InspectorTextField key={k} label={f.label}
<InspectorTextField key={k} label={fieldLabel(f.label)}
value={read(f.name) != null ? String(read(f.name)) : ''}
placeholder={(f as any).placeholder} onCommit={(v) => write(f.name, v)} disabled={readOnly} />
);
Expand Down
Loading
Loading