diff --git a/.changeset/i18n-stragglers-4375-4376.md b/.changeset/i18n-stragglers-4375-4376.md new file mode 100644 index 000000000..1ecfd887f --- /dev/null +++ b/.changeset/i18n-stragglers-4375-4376.md @@ -0,0 +1,23 @@ +--- +'@object-ui/fields': patch +'@object-ui/i18n': patch +'@object-ui/plugin-list': patch +--- + +i18n: the two search placeholders become pack values, and four values the packs served in English get translated + +**objectui#4375** — `ListView` and `LookupField` built their search placeholder as +`t(key) + '...'`, so the ellipsis was a literal concatenated in code: it stayed ASCII +in all ten locales on screens where objectui#3878 had converged everything else on +U+2026, and no pack could opt out of it (sharpest in `ar`, where a left-to-right run +was appended to right-to-left text). Both now read `table.search`, which is already +the repo's search-input placeholder key — `data-table`, `RecordPickerDialog` and +`PeoplePicker` render it too — and is translated with the right ellipsis in all ten +packs. No new keys. + +**objectui#4376** — `list.loading` served the English `Loading records…` in eight of +the nine translation packs (`zh` alone had translated it); `designer.undo` and +`designer.redo` were English in all nine; `appDesigner.snakeCaseHint` in `ko`, `pt`, +`ru` and `ar`. All translated, reusing each pack's own established vocabulary. A new +pin (`untranslated-identity-4376.test.ts`) fails on any value byte-identical to `en` +inside a non-Latin pack unless the key is on an explicit 22-entry allowlist. diff --git a/packages/fields/src/complex-widgets.test.tsx b/packages/fields/src/complex-widgets.test.tsx index 1b24371d4..7dc766c61 100644 --- a/packages/fields/src/complex-widgets.test.tsx +++ b/packages/fields/src/complex-widgets.test.tsx @@ -191,13 +191,14 @@ describe('Complex & Relationship Widgets', () => { expect(mockDataSource.find).toHaveBeenCalledTimes(1); }); - // Type in search. ASCII "..." and NOT U+2026, deliberately: - // `LookupField.tsx:1085` builds this placeholder as - // `t('common.search') + '...'`, so the ellipsis is a literal - // concatenated in code and objectui#3878's pack convergence does not - // reach it. Filed as objectui#4375 — when that lands, this moves. + // Type in search. U+2026 now, and it comes from a PACK VALUE: + // objectui#4375 retired the `t('common.search') + '...'` + // concatenation this used to pin, so the placeholder is `table.search` + // — read through `useFieldTranslation`, whose no-provider defaults map + // serves the `en` byte `Search…` in this test. It therefore moves with + // the pack from here on, and is covered by objectui#3878's glyph pin. await act(async () => { - fireEvent.change(screen.getByPlaceholderText('Search...'), { + fireEvent.change(screen.getByPlaceholderText('Search…'), { target: { value: 'acme' }, }); }); @@ -466,7 +467,9 @@ describe('Complex & Relationship Widgets', () => { expect(screen.getByText('Alpha')).toBeInTheDocument(); }); - const searchInput = screen.getByPlaceholderText('Search...'); + // U+2026 from the `table.search` pack value — see the note on the + // `$search` test above (objectui#4375). + const searchInput = screen.getByPlaceholderText('Search…'); // Arrow down twice: -1 → 0 (Alpha) → 1 (Beta) await act(async () => { diff --git a/packages/fields/src/widgets/LookupField.tsx b/packages/fields/src/widgets/LookupField.tsx index da7c98627..7d09864dd 100644 --- a/packages/fields/src/widgets/LookupField.tsx +++ b/packages/fields/src/widgets/LookupField.tsx @@ -1082,7 +1082,7 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro
handleSearchChange(e.target.value)} onKeyDown={handleSearchKeyDown} diff --git a/packages/i18n/src/__tests__/untranslated-identity-4376.test.ts b/packages/i18n/src/__tests__/untranslated-identity-4376.test.ts new file mode 100644 index 000000000..d765e6407 --- /dev/null +++ b/packages/i18n/src/__tests__/untranslated-identity-4376.test.ts @@ -0,0 +1,226 @@ +/** + * objectui#4376 — a value that is byte-identical to `en` inside a NON-LATIN pack + * is untranslated, and nothing in the i18n gate family could say so. + * + * `list.loading` was the specimen: `en` says `Loading records…` and so did + * `ja`, `ko`, `de`, `fr`, `es`, `pt`, `ru` and `ar` — eight of the nine packs + * served the raw English sentence, `zh` alone had translated it. A Japanese, + * Korean, Russian or Arabic user reading a list view saw a Latin-script English + * sentence in the middle of an otherwise translated screen, and in `ar` it was + * left-to-right text inside a right-to-left layout. Three more keys were in the + * same state: `designer.undo`, `designer.redo` and `appDesigner.snakeCaseHint`. + * + * ## Why no existing gate sees it — the value-domain blind spot, fourth instance + * + * This is the same hole as objectui#3582 / #3625 / #3810, one more shape: + * + * - `all-locales-key-parity` compares key SETS and placeholder shapes. All + * four keys were present in all ten packs with matching holes: full parity, + * green, and correctly so — asking a key-set test to judge meaning is how + * you get a test that owns nothing clearly. + * - `scripts/check-i18n-call-site-keys.mjs` asks only whether a key RESOLVES. + * All four resolved. + * - `scripts/check-i18n-en-drift.mjs` fires on an `en` VALUE CHANGE. These + * values were identical from the day the packs landed, so no drift event + * ever existed for it to fire on. It is blind to a value that was never + * right rather than one that stopped being right. + * - `ellipsis-glyph-3878.test.ts` scans the GLYPH, not the sentence. It is why + * `list.loading` surfaced at all — converging its ellipsis made the + * untranslated body of the value obvious — but its green says nothing here. + * + * ## The probe, and why it is scoped to the five non-Latin packs + * + * `value === en[key] && /[A-Za-z]{4,}/.test(value)`, over `zh`/`ja`/`ko`/`ru`/`ar` + * only. In a pack whose script is not Latin, a real translation of a run of four + * or more Latin letters essentially cannot be byte-equal to the English, so + * identity is decidable evidence of omission rather than a hint. + * + * It is deliberately NOT extended to `de`/`fr`/`es`/`pt`, where identity can be + * genuine — `Standard`, `Import`, `Text` and friends are simply the same word, + * and a probe that flagged them would be a false-positive generator whose + * allowlist grew without bound. That scoping has a real cost and it is stated + * rather than hidden: `list.loading` was ALSO untranslated in `de`/`fr`/`es`/`pt`, + * and `appDesigner.snakeCaseHint` in `pt`, and this file cannot see either. Both + * were fixed by hand in the same change; a Latin-pack omission still needs a + * human to notice it. objectui#3582 measured the inverse probe ("no ASCII English + * in a non-Latin pack") and found it produces false NEGATIVES; this is the + * narrower, decidable form of the same idea. + * + * The `{4,}` floor is what keeps acronyms and units out of the report without an + * allowlist entry each: `URL`, `API`, `ID`, `CSV`, `KB`, `v1` and the bare + * `{{count}}`-style format strings never reach the identity check at all. + * + * ## The allowlist is the cost, and it is keyed by KEY, not by key+lang + * + * 22 keys below are byte-identical to `en` in at least one non-Latin pack and + * are RIGHT that way — proper nouns, protocol and format names, example + * addresses, generated filenames, and pure interpolation. Each carries a + * one-line reason so the next reader can re-judge it instead of trusting it. + * + * Key-level rather than key+lang is a deliberate trade: it is looser (an entry + * excuses the identity in all five packs, not just the one that has it today) + * but it does not churn every time another pack legitimately adopts the same + * proper noun. `assert the allowlist has no dead entries` below is what pays for + * the looseness — an entry that stops being an identity anywhere has to be + * deleted, so the list cannot quietly accumulate cover for keys it no longer + * describes. + */ +import { describe, expect, it } from 'vitest'; + +import { builtInLocales } from '../locales/index'; + +/** + * The packs whose script is not Latin. Identity with `en` is decidable evidence + * of omission here, and only here — see the header on why de/fr/es/pt are out. + */ +const NON_LATIN = ['zh', 'ja', 'ko', 'ru', 'ar'] as const; +type NonLatin = (typeof NON_LATIN)[number]; + +/** A run of Latin letters long enough that a non-Latin pack cannot mean it. */ +const LATIN_RUN = /[A-Za-z]{4,}/; + +/** + * Keys whose value is legitimately identical to `en` in a non-Latin pack. + * Every entry is a fact about the VALUE, not a suppression of a translation + * someone did not get to — if a key here ever acquires a real translation, its + * entry must be deleted, and the dead-entry assertion below enforces that. + */ +const LEGITIMATE_IDENTITIES: Record = { + // Proper nouns and product/protocol/format names — the same token in every language. + 'layout.metadata.jsonBadge': 'JSON — the format name, not a word.', + 'workflow.webhook': 'Webhook — the protocol term these packs use untranslated.', + 'workflow.webhookEvent': 'Webhook — same term, event-typed.', + 'publicForm.poweredBy': 'Powered by ObjectStack — product attribution; ja keeps the English wordmark line.', + 'connectAgent.apiKey.badge': 'headless — the literal mode name the CLI and API use.', + 'marketplace.pricing.freemium': 'Freemium — the pricing-tier term of art; ru keeps the loanword.', + 'console.settingsHub.beta': 'Beta — the release-stage badge, kept Latin in zh.', + 'console.settingsHub.categories.Beta': 'Beta — the same badge, reached by category name.', + + // Loanwords these packs have adopted as their OWN vocabulary (checked against + // their neighbours: zh writes `Logo 链接` / `Logo 已上传…`, ru writes `Email подтверждён`). + 'appDesigner.logoUrl': 'Logo URL — zh/ko/ru/ar use `Logo` and the `URL` acronym as-is.', + 'appDesigner.faviconUrl': 'Favicon URL — same, and `Favicon` has no settled translation in these packs.', + 'organization.settings.logoLabel': 'Logo — zh vocabulary for this control; its whole block says `Logo`.', + 'auth.setup.emailLabel': 'Email — ru loanword; the rest of that block is localized (`ООО Пример`, `name@example.ru`).', + + // Example values and identifiers — changing them would change what they mean. + 'auth.login.emailPlaceholder': 'name@example.com — RFC 2606 example address, an identifier.', + 'auth.register.emailPlaceholder': 'name@example.com — same.', + 'auth.forgotPassword.emailPlaceholder': 'name@example.com — same.', + 'auth.setup.emailPlaceholder': 'name@example.com — same (ru localizes it to name@example.ru).', + 'auth.setup.orgNamePlaceholder': 'Acme Inc. — the placeholder company name; ru localizes it, zh/ja keep it.', + 'grid.import.templateFileName': + '{{object}}-import-template — a generated FILENAME; it must stay ASCII and portable.', + + // Pure interpolation and punctuation: there is no prose in these to translate. + 'fields.image.counter': '{{current}} / {{total}} — digits and a slash.', + 'marketplace.versionBadge': 'v{{version}} — version prefix plus the hole.', + 'appManagement.toast.bulkFailureEntry': '{{name}} ({{reason}}) — holes and parentheses only.', + 'collaboration.userStatusTitle': '{{name}} ({{status}}) — holes and parentheses only.', +}; + +/** Every string leaf of a pack, as `[dotted.path, value]`. */ +function flatten(pack: unknown, prefix = ''): Array<[string, string]> { + const out: Array<[string, string]> = []; + for (const [k, v] of Object.entries(pack as Record)) { + const path = prefix ? `${prefix}.${k}` : k; + if (typeof v === 'string') out.push([path, v]); + else if (v && typeof v === 'object') out.push(...flatten(v, path)); + } + return out; +} + +const EN = new Map(flatten(builtInLocales.en)); +const PACKS = Object.fromEntries( + NON_LATIN.map((lang) => [lang, new Map(flatten(builtInLocales[lang]))]), +) as Record>; + +/** `[key, lang]` pairs where the pack serves the English value verbatim. */ +function findIdentities(): Array<{ key: string; lang: NonLatin; value: string }> { + const out: Array<{ key: string; lang: NonLatin; value: string }> = []; + for (const [key, enValue] of EN) { + if (!LATIN_RUN.test(enValue)) continue; + for (const lang of NON_LATIN) { + if (PACKS[lang].get(key) === enValue) out.push({ key, lang, value: enValue }); + } + } + return out; +} + +describe('objectui#4376 — a non-Latin pack does not serve the English value verbatim', () => { + it('holds no untranslated English value in zh/ja/ko/ru/ar outside the allowlist', () => { + // Non-vacuity first: a collapsed or empty pack satisfies every assertion + // below while checking nothing, which is the failure mode this whole gate + // family exists to avoid. + expect(EN.size, 'en pack collapsed').toBeGreaterThan(2000); + for (const lang of NON_LATIN) { + expect(PACKS[lang].size, `${lang} pack collapsed`).toBeGreaterThan(2000); + } + + const offenders = findIdentities() + .filter(({ key }) => !(key in LEGITIMATE_IDENTITIES)) + .map(({ key, lang, value }) => `${lang} ${key}: ${JSON.stringify(value)}`); + + // Named, not counted: a regression has to say which key and which pack, and + // the message has to tell the next author both remedies — translate it, or + // justify the identity in the allowlist above. + expect( + offenders, + 'These values are byte-identical to `en` inside a pack whose script is not Latin, ' + + 'which means they were never translated — objectui#4376. ' + + `${offenders.length} value(s). Translate them, or, if the value is genuinely the same ` + + 'in that language (a proper noun, a format string, an identifier), add the key to ' + + 'LEGITIMATE_IDENTITIES above with a one-line reason.', + ).toEqual([]); + }); + + it('keeps the allowlist honest — no dead entries', () => { + // The complement. A key-level allowlist is only safe while every entry still + // describes a live identity; once a key is genuinely translated everywhere, + // its entry stops documenting anything and starts pre-authorising a future + // regression on that key. So a stale entry is a failure, not tidy-up debt. + const live = new Set(findIdentities().map(({ key }) => key)); + const dead = Object.keys(LEGITIMATE_IDENTITIES).filter((key) => !live.has(key)); + expect( + dead, + 'these allowlist entries no longer match any identity — delete them, ' + + 'or the allowlist is covering keys it no longer describes', + ).toEqual([]); + + // And every entry must name a key that actually exists, so a rename cannot + // leave a silent hole behind. + const unknown = Object.keys(LEGITIMATE_IDENTITIES).filter((key) => !EN.has(key)); + expect(unknown, 'these allowlist entries name keys the en pack does not define').toEqual([]); + + for (const [key, reason] of Object.entries(LEGITIMATE_IDENTITIES)) { + expect(reason.length, `${key} has no reason recorded`).toBeGreaterThan(10); + } + }); + + it('pins the four keys this card repaired, so the fix cannot be reverted quietly', () => { + // The scan above goes green either by a value being translated OR by it + // being deleted/emptied — green because nothing is produced is not the same + // fact as green because the copy is right. Same complement as the one + // `ellipsis-glyph-3878.test.ts` carries for the same reason. + const REPAIRED = ['list.loading', 'designer.undo', 'designer.redo', 'appDesigner.snakeCaseHint'] as const; + for (const key of REPAIRED) { + const enValue = EN.get(key); + expect(typeof enValue, `en ${key} missing`).toBe('string'); + for (const lang of NON_LATIN) { + const value = PACKS[lang].get(key); + expect(typeof value, `${lang} ${key} missing`).toBe('string'); + expect((value as string).trim().length, `${lang} ${key} is empty`).toBeGreaterThan(0); + expect(value, `${lang} ${key} is back to the English value`).not.toBe(enValue); + } + } + + // `appDesigner.snakeCaseHint` keeps the two IDENTIFIERS it is about. They are + // code, not prose — a pack that "translated" them would document a rule the + // validator does not enforce. + for (const lang of NON_LATIN) { + const hint = PACKS[lang].get('appDesigner.snakeCaseHint') as string; + expect(hint, `${lang} lost the snake_case identifier`).toContain('snake_case'); + expect(hint, `${lang} lost the my_app example`).toContain('my_app'); + } + }); +}); diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 8dd3a14c1..e7518cdd2 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -535,7 +535,7 @@ const ar = { }, }, refresh: "تحديث", - loading: 'Loading records…', + loading: "جارٍ تحميل السجلات…", recordCount: "{{count}} سجلات", recordCountOne: "{{count}} سجل", addRecord: "إضافة سجل", @@ -775,8 +775,8 @@ const ar = { readonlyTooltip: "عرض النظام — معرَّف في الكود، للقراءة فقط.", }, designer: { - undo: 'Undo', - redo: 'Redo', + undo: "تراجع", + redo: "إعادة", }, detail: { editInlineHint: "انقر نقرًا مزدوجًا للتحرير", @@ -1133,7 +1133,7 @@ const ar = { faviconUrl: "Favicon URL", preview: "معاينة", complete: "إكمال", - snakeCaseHint: "Must be snake_case (e.g. my_app)", + snakeCaseHint: "يجب أن يكون بصيغة snake_case (مثل my_app)", modeEdit: "تعديل", modePreview: "معاينة", modeCode: "كود", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index a9b3b0cd9..caf58e6f4 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -531,7 +531,7 @@ const de = { }, }, refresh: "Aktualisieren", - loading: 'Loading records…', + loading: "Datensätze werden geladen…", recordCount: "{{count}} Datensätze", recordCountOne: "{{count}} Datensatz", addRecord: "Datensatz hinzufügen", @@ -771,8 +771,8 @@ const de = { readonlyTooltip: "Systemansicht — im Code definiert, schreibgeschützt.", }, designer: { - undo: 'Undo', - redo: 'Redo', + undo: "Rückgängig", + redo: "Wiederholen", }, detail: { editInlineHint: "Zum Bearbeiten doppelklicken", diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 674eb91f1..c918fc656 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -535,7 +535,7 @@ const es = { }, }, refresh: "Actualizar", - loading: 'Loading records…', + loading: "Cargando registros…", recordCount: "{{count}} registros", recordCountOne: "{{count}} registro", addRecord: "Agregar registro", @@ -775,8 +775,8 @@ const es = { readonlyTooltip: "Vista del sistema — definida en el código, solo lectura.", }, designer: { - undo: 'Undo', - redo: 'Redo', + undo: "Deshacer", + redo: "Rehacer", }, detail: { editInlineHint: "Haga doble clic para editar", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index d1ed6c85d..d40488b25 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -531,7 +531,7 @@ const fr = { }, }, refresh: "Actualiser", - loading: 'Loading records…', + loading: "Chargement des enregistrements…", recordCount: "{{count}} enregistrements", recordCountOne: "{{count}} enregistrement", addRecord: "Ajouter un enregistrement", @@ -771,8 +771,8 @@ const fr = { readonlyTooltip: "Vue système — définie dans le code, en lecture seule.", }, designer: { - undo: 'Undo', - redo: 'Redo', + undo: "Annuler", + redo: "Rétablir", }, detail: { editInlineHint: "Double-cliquez pour modifier", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 3fe19bed9..0b5fb1293 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -531,7 +531,7 @@ const ja = { }, }, refresh: "更新", - loading: 'Loading records…', + loading: "レコードを読み込み中…", recordCount: "{{count}} 件のレコード", recordCountOne: "{{count}} 件のレコード", addRecord: "レコードを追加", @@ -771,8 +771,8 @@ const ja = { readonlyTooltip: "システムビュー — コードで定義され、読み取り専用です。", }, designer: { - undo: 'Undo', - redo: 'Redo', + undo: "元に戻す", + redo: "やり直す", }, detail: { editInlineHint: "ダブルクリックで編集", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 75d2a8bc7..02a1a953c 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -531,7 +531,7 @@ const ko = { }, }, refresh: "새로고침", - loading: 'Loading records…', + loading: "레코드 로딩 중…", recordCount: "{{count}}개 레코드", recordCountOne: "{{count}}개 레코드", addRecord: "레코드 추가", @@ -771,8 +771,8 @@ const ko = { readonlyTooltip: "시스템 보기 — 코드에 정의되어 있으며 읽기 전용입니다.", }, designer: { - undo: 'Undo', - redo: 'Redo', + undo: "실행 취소", + redo: "다시 실행", }, detail: { editInlineHint: "더블클릭하여 편집", @@ -1126,7 +1126,7 @@ const ko = { faviconUrl: "Favicon URL", preview: "미리보기", complete: "완료", - snakeCaseHint: "Must be snake_case (e.g. my_app)", + snakeCaseHint: "snake_case 형식이어야 합니다 (예: my_app)", modeEdit: "편집", modePreview: "미리보기", modeCode: "코드", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 91467202d..5752b61c0 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -530,7 +530,7 @@ const pt = { }, }, refresh: "Atualizar", - loading: 'Loading records…', + loading: "Carregando registros…", recordCount: "{{count}} registros", recordCountOne: "{{count}} registro", addRecord: "Adicionar registro", @@ -770,8 +770,8 @@ const pt = { readonlyTooltip: "Exibição do sistema — definida no código, somente leitura.", }, designer: { - undo: 'Undo', - redo: 'Redo', + undo: "Desfazer", + redo: "Refazer", }, detail: { editInlineHint: "Clique duas vezes para editar", @@ -1125,7 +1125,7 @@ const pt = { faviconUrl: "Favicon URL", preview: "Pré-visualização", complete: "Concluir", - snakeCaseHint: "Must be snake_case (e.g. my_app)", + snakeCaseHint: "Deve ser snake_case (ex. my_app)", modeEdit: "Editar", modePreview: "Pré-visualizar", modeCode: "Código", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 54b93a960..f8b8c5296 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -537,7 +537,7 @@ const ru = { }, }, refresh: "Обновить", - loading: 'Loading records…', + loading: "Загрузка записей…", recordCount: "{{count}} записей", recordCountOne: "{{count}} запись", addRecord: "Добавить запись", @@ -777,8 +777,8 @@ const ru = { readonlyTooltip: "Системное представление — определено в коде, только для чтения.", }, designer: { - undo: 'Undo', - redo: 'Redo', + undo: "Отменить", + redo: "Повторить", }, detail: { editInlineHint: "Дважды щёлкните для редактирования", @@ -1136,7 +1136,7 @@ const ru = { faviconUrl: "Favicon URL", preview: "Предпросмотр", complete: "Завершить", - snakeCaseHint: "Must be snake_case (e.g. my_app)", + snakeCaseHint: "Должно быть в формате snake_case (например, my_app)", modeEdit: "Редактировать", modePreview: "Предпросмотр", modeCode: "Код", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 02bfff8b0..acd976195 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -792,8 +792,8 @@ const zh = { readonlyTooltip: '系统视图 — 由代码定义,只读。', }, designer: { - undo: 'Undo', - redo: 'Redo', + undo: '撤销', + redo: '重做', }, detail: { back: '返回', diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index c5839d16f..d78809600 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -451,7 +451,26 @@ const LIST_DEFAULT_TRANSLATIONS: Record = { 'list.loadErrorRejectedTitle': 'This view’s query was rejected', 'list.loadErrorRejectedMessage': 'The server could not process this view’s filter or query options. Clearing the filters usually fixes it; if the view is saved this way, an administrator needs to correct it.', 'list.retry': 'Retry', + // The bare NOUN, for the search button's tooltip. It is deliberately NOT the + // input placeholder: that is `table.search` below (objectui#4375). 'list.search': 'Search', + // Placeholder of the search popover's input. Borrowed from the `table.*` + // namespace rather than minted as `list.searchPlaceholder`, on the same + // reasoning as `detail.recordDetail` below: `table.search` is already THE + // search-input placeholder key in this repo — `data-table`, `RecordPickerDialog` + // and `PeoplePicker` all render it — and one control should not get two + // translations that can drift apart in a locale. + // + // Until objectui#4375 this read `t('list.search') + '...'`, so the ellipsis was + // a literal concatenated in code: it stayed ASCII in all ten locales (on a + // screen where objectui#3878 had converged everything else on U+2026), and no + // pack could opt out of it — sharpest in `ar`, which got a left-to-right run + // appended to right-to-left text. As a pack value the ellipsis is the ar + // pack's own (`بحث…`) and the bidi algorithm places it at the logical end. + // + // Byte-identical to `en`, like every entry here — a provider-less host renders + // THIS copy, so a divergence would make the two paths disagree on one control. + 'table.search': 'Search…', 'list.filter': 'Filter', 'list.filterRecords': 'Filter Records', 'list.sort': 'Sort', @@ -2826,7 +2845,7 @@ export const ListView = React.forwardRef(({
handleSearchChange(e.target.value)} className="pl-7 h-8 text-xs" diff --git a/packages/plugin-list/src/__tests__/ListView.test.tsx b/packages/plugin-list/src/__tests__/ListView.test.tsx index 1c906d87f..bae8e3be5 100644 --- a/packages/plugin-list/src/__tests__/ListView.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.test.tsx @@ -962,6 +962,38 @@ describe('ListView', () => { expect(screen.getByPlaceholderText(/search/i)).toBeInTheDocument(); }); + it('takes the search placeholder from a pack value, not a code concatenation', () => { + // objectui#4375. This used to be `t('list.search') + '...'` — the ellipsis + // was a literal appended in code, so it stayed ASCII in all ten locales + // (while objectui#3878 had converged every other placeholder on U+2026) + // and no pack could opt out of it. The regex the sibling tests above use + // (`/search/i`) matches BOTH spellings, so nothing here could see the + // difference; this is the byte-exact pin that can. + // + // There is no LocalizationProvider in these tests, so the string below is + // served by `LIST_DEFAULT_TRANSLATIONS`, whose contract is to stay + // byte-identical to the `en` pack — which makes this an assertion about + // that contract as much as about the call site. + const schema: ListViewSchema = { + type: 'list-view', + objectName: 'contacts', + viewType: 'grid', + fields: ['name', 'email'], + }; + + renderWithProvider(); + fireEvent.click(screen.getByTestId('search-icon-button')); + + // U+2026, and it comes from `table.search`. + expect(screen.getByPlaceholderText('Search…')).toBeInTheDocument(); + expect(screen.queryByPlaceholderText('Search...')).not.toBeInTheDocument(); + + // …while the trigger's tooltip keeps the bare NOUN `list.search`. The two + // are deliberately different keys: giving `list.search` the ellipsis would + // have put one on this tooltip too. + expect(screen.getByTestId('search-icon-button')).toHaveAttribute('title', 'Search'); + }); + it('should highlight search icon when search term is active', () => { const schema: ListViewSchema = { type: 'list-view',