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
46 changes: 46 additions & 0 deletions .changeset/block-config-placeholder-i18n-3979.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
"@object-ui/app-shell": patch
---

Page block inspector: the input hints inside the properties panel follow the session's language

objectui#3913 turned `block-config.ts`'s `label` / `addLabel` / option labels into
translation keys and stopped at the column next to them. The 8 `placeholder`
values that are prose stayed display text, so a zh-CN admin opening `page:header`
read 「图标」 over a box hinting `lucide icon name`, and `record:details`'
「名称(i18n 键)」 over `snake_case, e.g. contact_info` — in the panel #3913 and
objectui#3963 had just finished translating. Those 8 are now keys under
`engine.inspector.pageBlock.placeholder.<blockType>.<field path>`, resolved by
`PageBlockInspector` at render, with en-US and zh-CN both defined. The en-US text
is byte-identical to the literals it replaces, ellipsis included, so an English
admin sees no change.

The column could not simply follow `label`, and that is the design half of this
change: it is a MIXED surface. Of the 18 placeholders, 10 are example VALUES — a
row count `20`, `https://…`, the inline-action sample
`{ "type": "url", "target": "/environments" }` — and translating those would be a
defect, not a courtesy. A localized JSON sample is metadata `InlineActionSchema`
rejects, and a "translated" default row count means nothing at all.

So which kind a placeholder is, is declared in the type rather than left to a
convention plus a list of exceptions:

placeholder?: { key: string } | { literal: string }

A bare `placeholder: 'lucide icon name'` — the shape this file used until now,
and the first thing anything generating a new field reaches for — no longer
compiles. That matters more than the eight strings: `type-check` runs in every
lane and in the editor, so the mistake is caught where it is made instead of
becoming an English box in a Chinese panel that waits for a reviewer to notice.
`addLabel` was made required for the same reason in #3913; `placeholder` stays
optional, because a field with no hint is legitimate.

The keyed half joins the derivation pin
(`previews/__tests__/block-config-i18n.test.ts`) as a fifth key family, so a new
prose placeholder without a translation is red rather than shipping. The literal
half is pinned as an inventory with a reason per entry, because "finish
translating the other ten" is the plausible next edit and it needs to argue with
a test first. `block-config.test.ts`'s snake_case assertion moved from the raw
placeholder to the RESOLVED hint in both locales, which is now the stronger
statement: the `snake_case` token has to survive translation, not merely exist in
English.
25 changes: 24 additions & 1 deletion packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,12 +489,19 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.inspector.pageBlock.list.removeItem': 'Remove item',
'engine.inspector.pageBlock.objectPlaceholder': 'snake_case object',
'engine.inspector.pageBlock.fieldPlaceholder': 'field name',
// Page block inspector — curated property labels (#3913).
// Page block inspector — curated property labels (#3913) and the prose
// placeholders (#3979).
// These are the `label` / `addLabel` / option-label values of
// `previews/block-config.ts`; that file stores the KEY and the inspector
// resolves it through `t()` at render. Key shape is derived from the label's
// position in BLOCK_CONFIG and pinned by
// `previews/__tests__/block-config-i18n.test.ts` — see that file's header.
//
// `placeholder.*` is the same derivation for a field's input hint, and it is
// deliberately only PART of that column: a placeholder holding an example
// VALUE (`20`, `https://…`, a JSON sample) stays a literal in block-config and
// has no key here, because translating it would corrupt what the author is
// being told to type. `block-config.ts` declares which kind each one is.
'engine.inspector.pageBlock.field.object-grid.objectName': 'Object',
'engine.inspector.pageBlock.field.object-grid.columns': 'Columns',
'engine.inspector.pageBlock.field.object-grid.pageSize': 'Page size',
Expand Down Expand Up @@ -525,6 +532,7 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.inspector.pageBlock.field.object-metric.label': 'Label',
'engine.inspector.pageBlock.field.object-metric.description': 'Description',
'engine.inspector.pageBlock.field.object-metric.icon': 'Icon',
'engine.inspector.pageBlock.placeholder.object-metric.icon': 'lucide icon name',
'engine.inspector.pageBlock.field.object-metric.colorVariant': 'Color',
'engine.inspector.pageBlock.option.colorVariant.default': 'Default',
'engine.inspector.pageBlock.option.colorVariant.blue': 'Blue',
Expand All @@ -547,6 +555,7 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.inspector.pageBlock.field.grid.columns': 'Columns',
'engine.inspector.pageBlock.field.grid.gap': 'Gap',
'engine.inspector.pageBlock.field.element:text.content': 'Content',
'engine.inspector.pageBlock.placeholder.element:text.content': 'Text…',
'engine.inspector.pageBlock.field.element:text.variant': 'Variant',
'engine.inspector.pageBlock.option.variant.heading': 'Heading',
'engine.inspector.pageBlock.option.variant.subheading': 'Subheading',
Expand Down Expand Up @@ -597,10 +606,12 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.inspector.pageBlock.option.size.medium': 'Medium',
'engine.inspector.pageBlock.option.size.large': 'Large',
'engine.inspector.pageBlock.field.element:button.icon': 'Icon',
'engine.inspector.pageBlock.placeholder.element:button.icon': 'lucide icon name',
'engine.inspector.pageBlock.field.element:button.action': 'Action',
'engine.inspector.pageBlock.field.page:header.title': 'Title',
'engine.inspector.pageBlock.field.page:header.subtitle': 'Subtitle',
'engine.inspector.pageBlock.field.page:header.icon': 'Icon',
'engine.inspector.pageBlock.placeholder.page:header.icon': 'lucide icon name',
'engine.inspector.pageBlock.field.page:header.breadcrumb': 'Show breadcrumb',
'engine.inspector.pageBlock.field.page:card.title': 'Title',
'engine.inspector.pageBlock.field.page:card.bordered': 'Bordered',
Expand All @@ -621,6 +632,7 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.inspector.pageBlock.field.record:details.sections': 'Sections',
'engine.inspector.pageBlock.add.record:details.sections': 'Add section',
'engine.inspector.pageBlock.field.record:details.sections.name': 'Name (i18n key)',
'engine.inspector.pageBlock.placeholder.record:details.sections.name': 'snake_case, e.g. contact_info',
'engine.inspector.pageBlock.field.record:details.sections.label': 'Label',
'engine.inspector.pageBlock.field.record:details.sections.columns': 'Columns',
'engine.inspector.pageBlock.field.record:details.sections.fields': 'Fields',
Expand All @@ -632,13 +644,15 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.inspector.pageBlock.field.record:alert.title': 'Title',
'engine.inspector.pageBlock.field.record:alert.body': 'Body',
'engine.inspector.pageBlock.field.record:alert.icon': 'Icon',
'engine.inspector.pageBlock.placeholder.record:alert.icon': 'lucide icon name',
'engine.inspector.pageBlock.field.record:alert.dismissible': 'Dismissible',
'engine.inspector.pageBlock.field.record:path.statusField': 'Status field',
'engine.inspector.pageBlock.field.record:path.stages': 'Stages',
'engine.inspector.pageBlock.add.record:path.stages': 'Add stage',
'engine.inspector.pageBlock.field.record:path.stages.value': 'Value',
'engine.inspector.pageBlock.field.record:path.stages.label': 'Label',
'engine.inspector.pageBlock.field.record:quick_actions.actionNames': 'Action names',
'engine.inspector.pageBlock.placeholder.record:quick_actions.actionNames': 'action name',
'engine.inspector.pageBlock.field.record:quick_actions.location': 'Location',
'engine.inspector.pageBlock.option.location.record_header': 'Record header',
'engine.inspector.pageBlock.option.location.record_more': 'Record more menu',
Expand All @@ -648,6 +662,7 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.inspector.pageBlock.option.location.list_item': 'List item',
'engine.inspector.pageBlock.option.location.global_nav': 'Global nav',
'engine.inspector.pageBlock.field.ai:input.agentName': 'Agent',
'engine.inspector.pageBlock.placeholder.ai:input.agentName': 'agent name',
'engine.inspector.pageBlock.field.ai:input.placeholder': 'Input placeholder',
// Report default ("home") inspector
'engine.inspector.report.kind': 'Report',
Expand Down Expand Up @@ -2247,6 +2262,7 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.inspector.pageBlock.field.object-metric.label': '标签',
'engine.inspector.pageBlock.field.object-metric.description': '描述',
'engine.inspector.pageBlock.field.object-metric.icon': '图标',
'engine.inspector.pageBlock.placeholder.object-metric.icon': 'lucide 图标名',
'engine.inspector.pageBlock.field.object-metric.colorVariant': '颜色',
'engine.inspector.pageBlock.option.colorVariant.default': '默认',
'engine.inspector.pageBlock.option.colorVariant.blue': '蓝色',
Expand All @@ -2269,6 +2285,7 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.inspector.pageBlock.field.grid.columns': '列数',
'engine.inspector.pageBlock.field.grid.gap': '间距',
'engine.inspector.pageBlock.field.element:text.content': '内容',
'engine.inspector.pageBlock.placeholder.element:text.content': '文本…',
'engine.inspector.pageBlock.field.element:text.variant': '样式',
'engine.inspector.pageBlock.option.variant.heading': '标题',
'engine.inspector.pageBlock.option.variant.subheading': '副标题',
Expand Down Expand Up @@ -2319,10 +2336,12 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.inspector.pageBlock.option.size.medium': '中',
'engine.inspector.pageBlock.option.size.large': '大',
'engine.inspector.pageBlock.field.element:button.icon': '图标',
'engine.inspector.pageBlock.placeholder.element:button.icon': 'lucide 图标名',
'engine.inspector.pageBlock.field.element:button.action': '动作',
'engine.inspector.pageBlock.field.page:header.title': '标题',
'engine.inspector.pageBlock.field.page:header.subtitle': '副标题',
'engine.inspector.pageBlock.field.page:header.icon': '图标',
'engine.inspector.pageBlock.placeholder.page:header.icon': 'lucide 图标名',
'engine.inspector.pageBlock.field.page:header.breadcrumb': '显示面包屑',
'engine.inspector.pageBlock.field.page:card.title': '标题',
'engine.inspector.pageBlock.field.page:card.bordered': '显示边框',
Expand All @@ -2343,6 +2362,7 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.inspector.pageBlock.field.record:details.sections': '分区',
'engine.inspector.pageBlock.add.record:details.sections': '添加分区',
'engine.inspector.pageBlock.field.record:details.sections.name': '名称(i18n 键)',
'engine.inspector.pageBlock.placeholder.record:details.sections.name': 'snake_case,例如:contact_info',
'engine.inspector.pageBlock.field.record:details.sections.label': '标签',
'engine.inspector.pageBlock.field.record:details.sections.columns': '列数',
'engine.inspector.pageBlock.field.record:details.sections.fields': '字段',
Expand All @@ -2354,13 +2374,15 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.inspector.pageBlock.field.record:alert.title': '标题',
'engine.inspector.pageBlock.field.record:alert.body': '正文',
'engine.inspector.pageBlock.field.record:alert.icon': '图标',
'engine.inspector.pageBlock.placeholder.record:alert.icon': 'lucide 图标名',
'engine.inspector.pageBlock.field.record:alert.dismissible': '可关闭',
'engine.inspector.pageBlock.field.record:path.statusField': '状态字段',
'engine.inspector.pageBlock.field.record:path.stages': '阶段',
'engine.inspector.pageBlock.add.record:path.stages': '添加阶段',
'engine.inspector.pageBlock.field.record:path.stages.value': '值',
'engine.inspector.pageBlock.field.record:path.stages.label': '标签',
'engine.inspector.pageBlock.field.record:quick_actions.actionNames': '动作名称',
'engine.inspector.pageBlock.placeholder.record:quick_actions.actionNames': '动作名称',
'engine.inspector.pageBlock.field.record:quick_actions.location': '位置',
'engine.inspector.pageBlock.option.location.record_header': '记录头部',
'engine.inspector.pageBlock.option.location.record_more': '记录更多菜单',
Expand All @@ -2370,6 +2392,7 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.inspector.pageBlock.option.location.list_item': '列表行',
'engine.inspector.pageBlock.option.location.global_nav': '全局导航',
'engine.inspector.pageBlock.field.ai:input.agentName': '智能体',
'engine.inspector.pageBlock.placeholder.ai:input.agentName': '智能体名称',
'engine.inspector.pageBlock.field.ai:input.placeholder': '输入框占位提示',
// Report default ("home") inspector
'engine.inspector.report.kind': '报表',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
/**
* objectui#3913 — the PROPERTIES panel renders in the session's language.
* objectui#3963 — and so does the panel's OWN chrome (see the second describe).
* objectui#3979 — and so do the input HINTS inside it, the prose ones (third).
*
* 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
Expand Down Expand Up @@ -63,6 +64,19 @@ function expectNoRawKeys() {
expect(document.body.textContent ?? '').not.toContain('engine.inspector.pageBlock.');
}

/**
* The same guard for `placeholder` attributes (#3979) — and it has to be its own
* function, because `textContent` cannot see an attribute. A key forwarded raw
* into a placeholder is invisible to `expectNoRawKeys` above, which is the exact
* blind spot that let #3963's two placeholder literals through #3913's review.
*/
function expectNoRawKeyPlaceholders() {
const raw = [...document.querySelectorAll('[placeholder]')]
.map((el) => el.getAttribute('placeholder') ?? '')
.filter((v) => v.startsWith('engine.inspector.pageBlock.'));
expect(raw).toEqual([]);
}

describe('PageBlockInspector PROPERTIES labels follow the locale (#3913)', () => {
it('renders curated field labels in Chinese under zh-CN', () => {
renderInspector(pageDraft('object-grid'), 'zh-CN');
Expand Down Expand Up @@ -264,6 +278,105 @@ describe("PageBlockInspector's own chrome follows the locale (#3963)", () => {
});
});

/**
* objectui#3979 — the block-config PLACEHOLDER column, on screen.
*
* Third mechanism in this file, and the third time the same defect shape came
* back one column over: #3913 keyed the labels, #3963 keyed the panel's own
* chrome, and the hints inside block-config's own boxes stayed English until
* here. `renderField` reaches `f.placeholder` from four branches — number, json,
* string-list and the default text field — and each was handing the table's value
* straight to the input.
*
* What makes this column different from the two before it is that translating
* ALL of it would be wrong. Ten of the eighteen placeholders are example VALUES,
* and the assertions come in matching pairs: the prose ones must change with the
* locale, the value ones must NOT. Both halves are pinned here because both are
* regressions a reader could introduce while believing they were finishing the
* job — and neither is visible to `expectNoRawKeys`, since a placeholder is an
* attribute.
*/
describe('PageBlockInspector placeholders follow the locale — but only the prose ones (#3979)', () => {
it('translates the text-field hint under an already-Chinese label', () => {
// The issue's headline symptom: 「图标」 over a box hinting `lucide icon name`.
renderInspector(pageDraft('page:header'), 'zh-CN');

expect(screen.getByText('图标')).toBeTruthy(); // #3913's half, still working
expect(screen.getByPlaceholderText('lucide 图标名')).toBeTruthy();
expect(screen.queryByPlaceholderText('lucide icon name')).toBeNull();
expectNoRawKeyPlaceholders();
expectNoRawKeys();
});

it('renders the same panel in English under en-US, unchanged', () => {
// en-US is the baseline: the new keys carry the exact literals the table
// used to hold, so nothing an English admin sees may have moved.
renderInspector(pageDraft('page:header'), 'en-US');

expect(screen.getByPlaceholderText('lucide icon name')).toBeTruthy();
expectNoRawKeyPlaceholders();
});

it('translates the nested array-item hint, and leaves the count beside it alone', () => {
// One fixture, both halves: `sections.name` is prose (a key) while
// `sections.columns` is a column count (a literal). Nested items recurse
// through `renderField` with a different read/write pair, so a fix applied
// only at the top level would leave these English.
renderInspector(pageDraft('record:details', { sections: [{ label: 'Contact info' }] }), 'zh-CN');

expect(screen.getByPlaceholderText('snake_case,例如:contact_info')).toBeTruthy();
expect(screen.queryByPlaceholderText('snake_case, e.g. contact_info')).toBeNull();
// The `2` box is untouched — a "translated" default count is meaningless.
expect(screen.getByPlaceholderText('2')).toBeTruthy();
expectNoRawKeyPlaceholders();
});

it('translates the string-list branch too — its own copy of the placeholder site', () => {
// `string-list` builds its rows inline in `renderField` rather than through a
// field component, so it is a separate forwarding of `f.placeholder` and no
// other fixture in this file renders one with a hint.
renderInspector(pageDraft('record:quick_actions', { actionNames: ['send_email'] }), 'zh-CN');

expect(screen.getAllByPlaceholderText('动作名称').length).toBeGreaterThan(0);
expect(screen.queryByPlaceholderText('action name')).toBeNull();
expectNoRawKeyPlaceholders();
});

it('translates element:text and ai:input, the remaining prose sites', () => {
renderInspector(pageDraft('element:text'), 'zh-CN');
expect(screen.getByPlaceholderText('文本…')).toBeTruthy();
expect(screen.queryByPlaceholderText('Text…')).toBeNull();

cleanup();
renderInspector(pageDraft('ai:input'), 'zh-CN');
expect(screen.getByPlaceholderText('智能体名称')).toBeTruthy();
expect(screen.queryByPlaceholderText('agent name')).toBeNull();
expectNoRawKeyPlaceholders();
});

it('passes example VALUES through untranslated in zh-CN', () => {
// The half that must NOT move. A number, a URL scheme and a JSON sample:
// these are the characters the author is being shown to type, and the JSON
// one would fail `InlineActionSchema` if its keys were localized.
renderInspector(pageDraft('object-grid'), 'zh-CN');
expect(screen.getByPlaceholderText('20')).toBeTruthy();

cleanup();
renderInspector(pageDraft('element:image'), 'zh-CN');
expect(screen.getByPlaceholderText('https://…')).toBeTruthy();

cleanup();
renderInspector(pageDraft('element:button'), 'zh-CN');
const sample = '{ "type": "url", "target": "/environments" }';
expect(screen.getByPlaceholderText(sample)).toBeTruthy();

// …and identical in en-US: a locale-invariant placeholder is invariant.
cleanup();
renderInspector(pageDraft('element:button'), 'en-US');
expect(screen.getByPlaceholderText(sample)).toBeTruthy();
});
});

/**
* Key completeness for the panel's own chrome — the same shape as the
* structural pin `previews/__tests__/block-config-i18n.test.ts` uses for the
Expand Down
Loading
Loading