From 11e510a6708053058028462f6f932f05feaee433 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 20:13:35 +0000 Subject: [PATCH] fix(metadata-admin): page block inspector chrome follows the locale (#3963) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel's own JSX carried English that no translation table could reach — the row-adder of every field list, the remove aria-labels, the JSON parse error and two fallback placeholders. All resolve through the catalog now: five new `engine.inspector.pageBlock.*` keys in both locales, plus the existing `engine.form.invalidJson` reused rather than duplicated. `InspectorJsonField` now stores the parse failure as a boolean and resolves the message at render, so it follows a later locale switch instead of freezing the language that was active when the parse failed. #3913's boundary pin (which asserted these literals were STILL English) is tightened into a describe that asserts the same sites resolve through the catalog in both locales, plus a completeness pin that reads the key list back out of the component source. --- .../page-block-inspector-chrome-i18n.md | 15 ++ .../src/views/metadata-admin/i18n.ts | 17 ++ .../PageBlockInspector.i18n.test.tsx | 196 ++++++++++++++++-- .../inspectors/PageBlockInspector.tsx | 58 ++++-- 4 files changed, 243 insertions(+), 43 deletions(-) create mode 100644 .changeset/page-block-inspector-chrome-i18n.md diff --git a/.changeset/page-block-inspector-chrome-i18n.md b/.changeset/page-block-inspector-chrome-i18n.md new file mode 100644 index 0000000000..3d864b2797 --- /dev/null +++ b/.changeset/page-block-inspector-chrome-i18n.md @@ -0,0 +1,15 @@ +--- +'@object-ui/app-shell': patch +--- + +fix(metadata-admin): page block inspector chrome follows the locale + +`PageBlockInspector`'s own JSX carried hardcoded English that no translation +table could reach: the row-adder of every field list (`Add`), the per-row and +per-item remove `aria-label`s (`Remove` / `Remove item`), and the free-text +fallback placeholders of the object picker and the field list. All now resolve +through `engine.inspector.pageBlock.*` keys defined in both en-US and zh-CN. +The JSON editor's parse error reuses the catalog's existing +`engine.form.invalidJson` instead of its own literal, and holds the parse +failure as state so the message follows a later locale switch. English is +unchanged. diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index 257e0566dd..eb04506c5f 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -478,6 +478,17 @@ const ENGINE_STRINGS_EN: Record = { 'engine.inspector.pageBlock.advanced': 'Advanced', 'engine.inspector.pageBlock.remove': 'Remove block', 'engine.inspector.pageBlock.outlineLabel': 'Blocks', + // Chrome of the panel's own list/JSON editors (#3963) — NOT block-config + // data. These live beside the keys above (not under `.field.`, which is + // reserved for keys derived from a BLOCK_CONFIG position) because they are + // one shared control repeated across every list field, with no per-field + // wording. `list.*` mirrors `engine.inspector.flowNode.list.*`, the same + // editor shape in a sibling panel. + 'engine.inspector.pageBlock.list.add': 'Add', + 'engine.inspector.pageBlock.list.remove': 'Remove', + '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). // These are the `label` / `addLabel` / option-label values of // `previews/block-config.ts`; that file stores the KEY and the inspector @@ -2197,6 +2208,12 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.inspector.pageBlock.advanced': '高级属性', 'engine.inspector.pageBlock.remove': '删除区块', 'engine.inspector.pageBlock.outlineLabel': '区块', + // 面板自身列表/JSON 编辑器的 chrome(#3963)—— 不是 block-config 表数据。 + 'engine.inspector.pageBlock.list.add': '添加', + 'engine.inspector.pageBlock.list.remove': '删除', + 'engine.inspector.pageBlock.list.removeItem': '删除项', + 'engine.inspector.pageBlock.objectPlaceholder': 'snake_case 对象名', + 'engine.inspector.pageBlock.fieldPlaceholder': '字段名', // 页面区块检查器 —— curated 属性 label(#3913)。 // 键形状与 en 侧一一对应,由 `previews/__tests__/block-config-i18n.test.ts` // 按 BLOCK_CONFIG 的结构重新推导并断言两语齐备。 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 3d1b48acae..7871c2c1a3 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 @@ -2,6 +2,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). * * 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 @@ -18,9 +19,13 @@ */ import { describe, it, expect, afterEach } from 'vitest'; -import { render, screen, cleanup } from '@testing-library/react'; +import { render, screen, cleanup, fireEvent } from '@testing-library/react'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { PageSchema } from '@objectstack/spec/ui'; import { PageBlockInspector } from './PageBlockInspector'; +import { t } from '../i18n'; afterEach(cleanup); @@ -99,28 +104,18 @@ describe('PageBlockInspector PROPERTIES labels follow the locale (#3913)', () => }); /** - * NOT asserted anywhere above, deliberately: that this panel contains no - * English at all. It still does, and the boundary is worth pinning. + * The boundary this describe deliberately did NOT cover used to be pinned + * here, as an assertion that the panel's own chrome was still English — + * ` Add`, the "Remove" / "Remove item" aria-labels, "Invalid JSON", + * and two placeholders were literals in `PageBlockInspector.tsx` itself, + * never `block-config` labels, so they were outside objectui#3913 and filed + * as objectui#3963. * - * `FieldListField` and the `string-list` branch render their row-adder as a - * hardcoded ` 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. + * That case did its job: objectui#3963 landing turned it red instead of the + * two fixes silently overlapping. It is now the second describe below, which + * asserts the same sites resolve THROUGH the catalog in both locales — the + * boundary moved rather than disappeared. */ - 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 @@ -170,3 +165,162 @@ describe('PageBlockInspector PROPERTIES labels follow the locale (#3913)', () => expect(screen.queryByText('Show breadcrumb')).toBeNull(); }); }); + +/** + * objectui#3963 — the panel's OWN chrome follows the locale too. + * + * A different mechanism from the describe above: these strings were never + * `block-config` data, they were literals inside `PageBlockInspector.tsx`'s + * JSX. The visible symptom was one panel in two languages the other way round + * — after #3913 a zh-CN admin read 「分区」/「字段」 with an English `Add` + * underneath each list. + * + * Two of the sites are `aria-label`s and two are placeholders, i.e. invisible + * to a text query and to a screenshot; that is why they survived #3913's + * review and why the assertions below use `getByLabelText` / + * `getByPlaceholderText` rather than reading the rendered text. + */ +describe("PageBlockInspector's own chrome follows the locale (#3963)", () => { + /** `record:details` with one populated section: array card + field-list rows. */ + const detailsDraft = () => + pageDraft('record:details', { sections: [{ label: 'Contact info', fields: ['name'] }] }); + + it('renders every chrome site in Chinese under zh-CN', () => { + renderInspector(detailsDraft(), 'zh-CN'); + + // Row adder of the `field-list` editor (was a bare ` Add`). + const adders = screen.getAllByRole('button').filter((b) => b.textContent?.trim() === '添加'); + expect(adders.length).toBeGreaterThan(0); + // Per-row remove (aria-label only) and the array item card's remove. + expect(screen.getAllByLabelText('删除').length).toBeGreaterThan(0); + expect(screen.getAllByLabelText('删除项').length).toBeGreaterThan(0); + // The free-text fallback row of a `field-list` — a placeholder, invisible + // to `getByText`. + expect(screen.getAllByPlaceholderText('字段名').length).toBeGreaterThan(0); + + // The English literals are gone, not merely joined by Chinese ones. + expect(screen.queryAllByRole('button').filter((b) => b.textContent?.trim() === 'Add')).toEqual([]); + expect(screen.queryByLabelText('Remove')).toBeNull(); + expect(screen.queryByLabelText('Remove item')).toBeNull(); + expect(screen.queryByPlaceholderText('field name')).toBeNull(); + expectNoRawKeys(); + }); + + it('renders the same chrome in English under en-US, unchanged', () => { + // en-US is this repo's baseline language: the new keys carry exactly the + // literals that used to be hardcoded, so this panel must not have moved. + renderInspector(detailsDraft(), 'en-US'); + + const adders = screen.getAllByRole('button').filter((b) => b.textContent?.trim() === 'Add'); + expect(adders.length).toBeGreaterThan(0); + expect(screen.getAllByLabelText('Remove').length).toBeGreaterThan(0); + expect(screen.getAllByLabelText('Remove item').length).toBeGreaterThan(0); + expect(screen.getAllByPlaceholderText('field name').length).toBeGreaterThan(0); + expectNoRawKeys(); + }); + + it('translates the string-list branch too, not only FieldListField', () => { + // Two independent copies of the same adder/remove chrome: `FieldListField` + // (a component) and `renderField`'s `string-list` case (inline JSX). A fix + // applied to one only would leave the other English, and no fixture in the + // describe above renders `string-list` at all. + renderInspector(pageDraft('record:quick_actions', { actionNames: ['send_email'] }), 'zh-CN'); + + const adders = screen.getAllByRole('button').filter((b) => b.textContent?.trim() === '添加'); + expect(adders.length).toBeGreaterThan(0); + expect(screen.getAllByLabelText('删除').length).toBeGreaterThan(0); + expect(screen.queryByLabelText('Remove')).toBeNull(); + expectNoRawKeys(); + }); + + it("translates the object picker's free-text placeholder", () => { + // `ObjectPickerField` degrades to a free-text input when the object list is + // unavailable — which is the state every offline session starts in. + renderInspector(pageDraft('object-grid'), 'zh-CN'); + + expect(screen.getByPlaceholderText('snake_case 对象名')).toBeTruthy(); + expect(screen.queryByPlaceholderText('snake_case object')).toBeNull(); + }); + + it('reuses the catalog key for the JSON parse error instead of a literal', () => { + // `engine.form.invalidJson` already existed and `translateValidationMessage()` + // already mapped `'invalid json'` onto it — this site had bypassed it. Asserting + // the RESOLVED value in both locales is what distinguishes "reused the key" + // from "hardcoded the same English words". + renderInspector(pageDraft('element:button'), 'zh-CN'); + const zhArea = document.querySelector('textarea'); + expect(zhArea).toBeTruthy(); + fireEvent.change(zhArea!, { target: { value: '{ not json' } }); + fireEvent.blur(zhArea!); + expect(screen.getByText(t('engine.form.invalidJson', 'zh-CN'))).toBeTruthy(); + expect(screen.queryByText('Invalid JSON')).toBeNull(); + + cleanup(); + renderInspector(pageDraft('element:button'), 'en-US'); + const enArea = document.querySelector('textarea'); + fireEvent.change(enArea!, { target: { value: '{ not json' } }); + fireEvent.blur(enArea!); + expect(screen.getByText('Invalid JSON')).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 + * table, adapted to the one thing that differs: these keys have no position to + * be derived from, they are `t('…')` call sites in one file. So the list is + * read back OUT of that file instead of hand-copied here, and the day someone + * adds a chrome key with only an en translation this is red without anybody + * remembering to extend a list. + * + * Completeness is measured through `t()` — it returns the key unchanged on a + * miss, so `t(key, locale) === key` is exactly "this locale has no entry". + */ +describe("PageBlockInspector's chrome keys resolve in both locales (#3963)", () => { + const here = path.dirname(fileURLToPath(import.meta.url)); + const source = readFileSync(path.join(here, 'PageBlockInspector.tsx'), 'utf8'); + /** Literal keys passed to `t()`. Dynamic `t(f.label)` sites are block-config's. */ + const KEYS = [...new Set([...source.matchAll(/\bt\(\s*'([^']+)'/g)].map((m) => m[1]))]; + + /** + * Keys whose two locales are deliberately identical, with the reason. A + * ledger, not a rule — everything else staying red is the point. + */ + const LOCALE_INVARIANT = new Set([ + 'engine.inspector.pageBlock.id', // 'ID' — an acronym, not translated in zh-CN + ]); + + it('finds the call sites at all — the scan is not vacuous', () => { + expect(KEYS.length).toBeGreaterThan(12); + // The five keys #3963 added, plus the existing one it reuses rather than + // duplicating. Named explicitly so a regex that silently stops matching + // (e.g. after a reformat) fails here instead of passing over nothing. + for (const key of [ + 'engine.inspector.pageBlock.list.add', + 'engine.inspector.pageBlock.list.remove', + 'engine.inspector.pageBlock.list.removeItem', + 'engine.inspector.pageBlock.objectPlaceholder', + 'engine.inspector.pageBlock.fieldPlaceholder', + 'engine.form.invalidJson', + ]) { + expect(KEYS).toContain(key); + } + }); + + it('every key resolves in en-US', () => { + expect(KEYS.filter((k) => t(k, 'en-US') === k)).toEqual([]); + }); + + it('every key resolves in zh-CN', () => { + expect(KEYS.filter((k) => t(k, 'zh-CN') === k)).toEqual([]); + }); + + it('zh-CN is actually translated, not a copy of en-US', () => { + const untranslated = KEYS.filter( + (k) => !LOCALE_INVARIANT.has(k) && t(k, 'zh-CN') === t(k, 'en-US'), + ).map((k) => `${k} = '${t(k, 'en-US')}'`); + // If a chrome string really is locale-invariant, add it to + // LOCALE_INVARIANT above with its reason — do not weaken this assertion. + expect(untranslated).toEqual([]); + }); +}); 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 8c7fba7e87..4f6a7ee60d 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.tsx @@ -14,7 +14,7 @@ import * as React from 'react'; import type { MetadataInspectorProps } from '../inspector-registry'; import type { ExpressionInput } from '@objectstack/spec/shared'; -import { t } from '../i18n'; +import { t, type SupportedLocale } from '../i18n'; import { InspectorShell, InspectorReorderButtons, @@ -53,12 +53,13 @@ function useFieldOptions(objectName: string | undefined): Array<{ value: string; } /** Object dropdown; falls back to a free-text input when the list is empty. */ -function ObjectPickerField({ label, value, onCommit, disabled }: { +function ObjectPickerField({ label, value, onCommit, disabled, locale }: { label: string; value: string | undefined; onCommit: (v: string) => void; disabled?: boolean; + locale: SupportedLocale; }) { const { options } = useObjectOptions(); if (options.length === 0) { - return ; + return ; } return ; } @@ -75,8 +76,9 @@ function FieldPickerField({ label, objectName, value, onCommit, disabled }: { } /** Editable list of field names — each row a field dropdown (or text fallback). */ -function FieldListField({ label, objectName, value, onChange, disabled }: { +function FieldListField({ label, objectName, value, onChange, disabled, locale }: { label: string; objectName: string | undefined; value: unknown; onChange: (v: string[]) => void; disabled?: boolean; + locale: SupportedLocale; }) { const options = useFieldOptions(objectName); const arr: string[] = Array.isArray(value) ? (value as string[]) : []; @@ -95,10 +97,11 @@ function FieldListField({ label, objectName, value, onChange, disabled }: { ) : ( - { const n = [...arr]; n[i] = e.target.value; onChange(n); }} /> )} - @@ -106,7 +109,7 @@ function FieldListField({ label, objectName, value, onChange, disabled }: { ))} {!disabled && ( )} @@ -125,26 +128,30 @@ function safeStringify(value: unknown): string { /** Editable JSON field for object/array properties — commits on blur so a * half-typed value never trips the parser. Empty clears the property. */ -function InspectorJsonField({ label, value, onCommit, disabled, placeholder }: { +function InspectorJsonField({ label, value, onCommit, disabled, placeholder, locale }: { label: string; value: unknown; onCommit: (v: unknown) => void; disabled?: boolean; /** Shown while the property is unset — the expected shape, since an empty * JSON textarea tells an author nothing about what to type. */ placeholder?: string; + locale: SupportedLocale; }) { const initial = React.useMemo(() => safeStringify(value), [value]); const [text, setText] = React.useState(initial); - const [error, setError] = React.useState(null); - React.useEffect(() => { setText(initial); setError(null); }, [initial]); + // State holds the FACT (the text does not parse), not the wording — so the + // message is resolved at render and follows a locale switch instead of + // freezing whatever language was active when the parse failed. + const [invalid, setInvalid] = React.useState(false); + React.useEffect(() => { setText(initial); setInvalid(false); }, [initial]); const commit = () => { if (disabled) return; const trimmed = text.trim(); - if (trimmed === '') { setError(null); onCommit(undefined); return; } + if (trimmed === '') { setInvalid(false); onCommit(undefined); return; } try { const parsed = JSON.parse(trimmed); - setError(null); + setInvalid(false); onCommit(parsed); } catch { - setError('Invalid JSON'); + setInvalid(true); } }; return ( @@ -160,7 +167,10 @@ function InspectorJsonField({ label, value, onCommit, disabled, placeholder }: { rows={Math.min(12, Math.max(2, text.split('\n').length))} className="w-full rounded border border-input bg-background px-2 py-1.5 text-xs font-mono outline-none focus:ring-1 focus:ring-primary resize-y disabled:opacity-60" /> - {error &&
{error}
} + {/* Reuses the catalog's existing `engine.form.invalidJson` — the same key + the shared `translateValidationMessage()` already maps `'invalid + json'` onto — rather than minting a second wording for one fact. */} + {invalid &&
{t('engine.form.invalidJson', locale)}
} ); } @@ -168,8 +178,9 @@ function InspectorJsonField({ label, value, onCommit, disabled, placeholder }: { /** Renders one arbitrary block property by inferring an editor from its * runtime type. Guarantees the inspector can edit anything visible in the * source, even block types with no curated BLOCK_CONFIG entry. */ -function GenericPropField({ name, value, onCommit, disabled }: { +function GenericPropField({ name, value, onCommit, disabled, locale }: { name: string; value: unknown; onCommit: (v: unknown) => void; disabled?: boolean; + locale: SupportedLocale; }) { if (typeof value === 'boolean') { return ; @@ -180,7 +191,7 @@ function GenericPropField({ name, value, onCommit, disabled }: { if (value === null || typeof value === 'string') { return ; } - return ; + return ; } /** Block `properties` keys whose values are nested block trees — these are @@ -434,7 +445,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': { @@ -447,14 +458,15 @@ export function PageBlockInspector({ selection, draft, onPatch, onClearSelection { const next = [...arr]; next[i] = e.target.value; write(f.name, next); }} /> ))} {!readOnly && ( )} @@ -472,7 +484,8 @@ export function PageBlockInspector({ selection, draft, onPatch, onClearSelection
#{i + 1}
@@ -497,7 +510,7 @@ export function PageBlockInspector({ selection, draft, onPatch, onClearSelection } case 'object-picker': return ( - write(f.name, v)} disabled={readOnly} /> ); @@ -509,7 +522,7 @@ export function PageBlockInspector({ selection, draft, onPatch, onClearSelection ); case 'field-list': return ( - write(f.name, v)} disabled={readOnly} /> ); default: @@ -617,6 +630,7 @@ export function PageBlockInspector({ selection, draft, onPatch, onClearSelection value={blockProps[key]} onCommit={(v) => patchProp(key, v)} disabled={readOnly} + locale={locale} /> ))}