diff --git a/.changeset/block-config-placeholder-i18n-3979.md b/.changeset/block-config-placeholder-i18n-3979.md new file mode 100644 index 0000000000..62fb6d25d8 --- /dev/null +++ b/.changeset/block-config-placeholder-i18n-3979.md @@ -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..`, 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. diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index eb04506c5f..b5d6384266 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -489,12 +489,19 @@ const ENGINE_STRINGS_EN: Record = { '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', @@ -525,6 +532,7 @@ const ENGINE_STRINGS_EN: Record = { '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', @@ -547,6 +555,7 @@ const ENGINE_STRINGS_EN: Record = { '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', @@ -597,10 +606,12 @@ const ENGINE_STRINGS_EN: Record = { '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', @@ -621,6 +632,7 @@ const ENGINE_STRINGS_EN: Record = { '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', @@ -632,6 +644,7 @@ const ENGINE_STRINGS_EN: Record = { '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', @@ -639,6 +652,7 @@ const ENGINE_STRINGS_EN: Record = { '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', @@ -648,6 +662,7 @@ const ENGINE_STRINGS_EN: Record = { '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', @@ -2247,6 +2262,7 @@ const ENGINE_STRINGS_ZH: Record = { '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': '蓝色', @@ -2269,6 +2285,7 @@ const ENGINE_STRINGS_ZH: Record = { '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': '副标题', @@ -2319,10 +2336,12 @@ const ENGINE_STRINGS_ZH: Record = { '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': '显示边框', @@ -2343,6 +2362,7 @@ const ENGINE_STRINGS_ZH: Record = { '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': '字段', @@ -2354,6 +2374,7 @@ const ENGINE_STRINGS_ZH: Record = { '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': '阶段', @@ -2361,6 +2382,7 @@ const ENGINE_STRINGS_ZH: Record = { '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': '记录更多菜单', @@ -2370,6 +2392,7 @@ const ENGINE_STRINGS_ZH: Record = { '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': '报表', diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.i18n.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.i18n.test.tsx index 7871c2c1a3..27fcc550d7 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.i18n.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.i18n.test.tsx @@ -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 @@ -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'); @@ -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 diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.tsx index 4f6a7ee60d..f67fe58fb2 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.tsx @@ -26,7 +26,7 @@ import { InspectorEmptyState, moveArray, } from './_shared'; -import { BLOCK_CONFIG, blockHasConfig, type BlockPropField } from '../previews/block-config'; +import { BLOCK_CONFIG, blockHasConfig, type BlockPropField, type PlaceholderSpec } from '../previews/block-config'; import { ColorVariantPicker } from '../color-variant-field'; import { ConditionBuilder } from './ConditionBuilder'; import { expressionSource, writeExpressionSource } from './expression-envelope'; @@ -398,6 +398,16 @@ export function PageBlockInspector({ selection, draft, onPatch, onClearSelection /** Option labels are keys too — translate before handing them to a picker. */ const optionLabels = (options: T[]) => options.map((o) => ({ ...o, label: t(o.label, locale) })); + /** + * Placeholders are a MIXED column (#3979) and the table says which kind each + * one is: `{ key }` is prose about the value and goes through `t()`, `{ literal }` + * is the value itself (a number, `https://…`, a JSON sample) and must reach the + * DOM untouched. Resolving in one place is what keeps the four `placeholder=` + * sites below from drifting apart — the panel's contents were English in a + * zh-CN panel precisely because one column skipped the accessor. + */ + const placeholderText = (p: PlaceholderSpec | undefined): string | undefined => + p === undefined ? undefined : p.key !== undefined ? t(p.key, locale) : p.literal; // Generic, recursive field renderer. `read`/`write` abstract the value source // (the block's `properties` at the top level, or an item object inside an @@ -414,7 +424,7 @@ export function PageBlockInspector({ selection, draft, onPatch, onClearSelection return ( write(f.name, v)} disabled={readOnly} /> + placeholder={placeholderText(f.placeholder)} onCommit={(v) => write(f.name, v)} disabled={readOnly} /> ); case 'boolean': return ( @@ -445,7 +455,7 @@ export function PageBlockInspector({ selection, draft, onPatch, onClearSelection // without this a curated JSON prop could be edited and never added. return ( write(f.name, v)} disabled={readOnly} /> ); case 'string-list': { @@ -455,7 +465,7 @@ export function PageBlockInspector({ selection, draft, onPatch, onClearSelection {arr.map((s, i) => (
- { const next = [...arr]; next[i] = e.target.value; write(f.name, next); }} />