From 3acc8ae05f461d07bd0096deda18435b0388c040 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 06:10:09 +0000 Subject: [PATCH] fix(i18n): key ListView / ObjectGrid record-detail overlay headings (#3426) PR #3423 gave NavigationOverlay's `resolvedTitle` an i18n default (`detail.recordDetail`), but two hosts never let it run: they string-built an English heading in TypeScript and passed it as the `title` prop, so a zh/ja/de session got a fully localized drawer with one English heading. Both paths are user-reachable: `list-view` / `object-grid` are public page blocks and `navigation` is an authorable key on their schema, so authoring `navigation: { mode: 'drawer' }` opens exactly this overlay on row click. app-shell's ObjectView suppresses it (own `onRowClick` + own overlay), but that is one host overriding a public block, not a dead branch. - new `detail.recordDetailWithLabel` (`{{label}} Detail`) in all ten packs, so packs whose qualifier trails the noun (de) or that need a possessive particle (ja/zh) write their own arrangement instead of English word order - the no-label branch reuses `detail.recordDetail`, the key the overlay itself defaults to, rather than minting a twin - both keys added to LIST_DEFAULT_TRANSLATIONS / GRID_DEFAULT_TRANSLATIONS so a provider-less host still reads English, byte-identical to before Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt --- .changeset/overlay-caller-titles-i18n-3426.md | 47 +++++ packages/i18n/src/locales/ar.ts | 1 + packages/i18n/src/locales/de.ts | 1 + packages/i18n/src/locales/en.ts | 9 + packages/i18n/src/locales/es.ts | 1 + packages/i18n/src/locales/fr.ts | 1 + packages/i18n/src/locales/ja.ts | 1 + packages/i18n/src/locales/ko.ts | 1 + packages/i18n/src/locales/pt.ts | 1 + packages/i18n/src/locales/ru.ts | 1 + packages/i18n/src/locales/zh.ts | 1 + packages/plugin-grid/src/ObjectGrid.tsx | 32 ++- .../ObjectGrid.overlayTitleI18n.test.tsx | 155 ++++++++++++++ ...id.overlayTitleNoProviderFallback.test.tsx | 108 ++++++++++ packages/plugin-list/src/ListView.tsx | 39 +++- .../ListView.overlayTitleI18n.test.tsx | 189 ++++++++++++++++++ ...ew.overlayTitleNoProviderFallback.test.tsx | 131 ++++++++++++ 17 files changed, 708 insertions(+), 11 deletions(-) create mode 100644 .changeset/overlay-caller-titles-i18n-3426.md create mode 100644 packages/plugin-grid/src/__tests__/ObjectGrid.overlayTitleI18n.test.tsx create mode 100644 packages/plugin-grid/src/__tests__/ObjectGrid.overlayTitleNoProviderFallback.test.tsx create mode 100644 packages/plugin-list/src/__tests__/ListView.overlayTitleI18n.test.tsx create mode 100644 packages/plugin-list/src/__tests__/ListView.overlayTitleNoProviderFallback.test.tsx diff --git a/.changeset/overlay-caller-titles-i18n-3426.md b/.changeset/overlay-caller-titles-i18n-3426.md new file mode 100644 index 0000000000..95cee788b0 --- /dev/null +++ b/.changeset/overlay-caller-titles-i18n-3426.md @@ -0,0 +1,47 @@ +--- +'@object-ui/plugin-list': patch +'@object-ui/plugin-grid': patch +'@object-ui/i18n': patch +--- + +Localize the record-detail overlay heading that `ListView` and `ObjectGrid` +build themselves (objectui#3426) + +#3423 gave `NavigationOverlay`'s `resolvedTitle` an i18n default +(`detail.recordDetail`), but two hosts never let that default run: they +string-built an English heading in TypeScript and passed it as the `title` +prop, so a zh/ja/de session got a fully localized drawer with one English +heading on it. + +- `packages/plugin-list/src/ListView.tsx` — `` `${schema.label} Detail` `` +- `packages/plugin-grid/src/ObjectGrid.tsx` — the same template, plus a bare + `'Record Detail'` literal for the no-label case + +Both are user-reachable, not dead defaults. `list-view` / `object-grid` are +public page blocks and `navigation` is an authorable key on their schema, so a +page that authors `navigation: { mode: 'drawer' }` opens exactly this overlay +on row click. (`app-shell`'s `ObjectView` does suppress it — it passes its own +`onRowClick`, which takes priority inside `useNavigationOverlay`, and renders +its own overlay — but that is one host overriding a public block, not proof the +branch is unreachable.) + +## What changed + +Both call sites now key their heading instead of concatenating it: + +- a new `detail.recordDetailWithLabel` (`'{{label}} Detail'`) carries the + object label through interpolation, so a pack whose qualifier trails the noun + (`de`) or that needs a possessive particle (`ja`/`zh`) can write its own + arrangement rather than inherit English word order; +- the no-label branch reuses `detail.recordDetail` — the very key the overlay + itself defaults to — so one heading on one control cannot drift into two + translations. + +The new key is added to all ten locale packs and to each plugin's English +defaults map (`LIST_DEFAULT_TRANSLATIONS` / `GRID_DEFAULT_TRANSLATIONS`), which +is what `createSafeTranslation` falls back to with no `I18nProvider` mounted. + +English output is byte-identical in every branch (`Contacts Detail` / +`Contacts Detail` / `Record Detail`), with and without a provider — pinned by a +provider-less test file per plugin, kept separate because `initReactI18next` +registers its instance as a module global that outlives `cleanup()`. diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index d729deedd0..74de18a442 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -757,6 +757,7 @@ const ar = { reactionCount: "{{emoji}} {{count}} تفاعلات", reactionCountOne: "{{emoji}} {{count}} تفاعل", recordDetail: "تفاصيل السجل", + recordDetailWithLabel: "تفاصيل {{label}}", openAsFullPage: "فتح كصفحة كاملة", recordDetailOverlay: "طبقة تفاصيل السجل الخاصة بـ {{title}}.", addToFavorites: "إضافة إلى المفضلة", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index d68b6799ab..ec42345e12 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -755,6 +755,7 @@ const de = { reactionCount: "{{emoji}} {{count}} Reaktionen", reactionCountOne: "{{emoji}} {{count}} Reaktion", recordDetail: "Datensatzdetails", + recordDetailWithLabel: "{{label}}-Details", openAsFullPage: "Als ganze Seite öffnen", recordDetailOverlay: "Detail-Overlay des Datensatzes für {{title}}.", addToFavorites: "Zu Favoriten hinzufügen", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 6b93dee9db..f980b5bae5 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -831,6 +831,15 @@ const en = { // name; `recordDetailOverlay` is the sr-only description on the // Sheet/Dialog when the host supplies none. recordDetail: 'Record Detail', + // Same heading, but for the hosts that DO know which object they are + // showing (objectui#3426): `ListView` / `ObjectGrid` used to string-build + // `` `${label} Detail` `` in TypeScript and hand it to the overlay's + // `title` prop, so `recordDetail` above never got a chance to apply and a + // non-English session read one English heading. Interpolating keeps the + // object label in the heading without freezing English word order — a pack + // whose qualifier trails the noun (de: a hyphenated compound) or that needs + // a possessive particle (ja/zh) writes its own arrangement here. + recordDetailWithLabel: '{{label}} Detail', openAsFullPage: 'Open as full page', recordDetailOverlay: 'Record detail overlay for {{title}}.', addToFavorites: 'Add to favorites', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index ca6154ac01..19cf4ebb5b 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -760,6 +760,7 @@ const es = { reactionCount: "{{emoji}} {{count}} reacciones", reactionCountOne: "{{emoji}} {{count}} reacción", recordDetail: "Detalle del registro", + recordDetailWithLabel: "Detalle de {{label}}", openAsFullPage: "Abrir como página completa", recordDetailOverlay: "Capa de detalle del registro para {{title}}.", addToFavorites: "Añadir a favoritos", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index b0142252b9..3382a58ac8 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -757,6 +757,7 @@ const fr = { reactionCount: "{{emoji}} {{count}} réactions", reactionCountOne: "{{emoji}} {{count}} réaction", recordDetail: "Détail de l'enregistrement", + recordDetailWithLabel: "Détail — {{label}}", openAsFullPage: "Ouvrir en pleine page", recordDetailOverlay: "Panneau de détail de l'enregistrement pour {{title}}.", addToFavorites: "Ajouter aux favoris", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index d153cab1ae..52e6036070 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -755,6 +755,7 @@ const ja = { reactionCount: "{{emoji}} {{count}} 件のリアクション", reactionCountOne: "{{emoji}} {{count}} 件のリアクション", recordDetail: "レコード詳細", + recordDetailWithLabel: "{{label}}の詳細", openAsFullPage: "フルページで開く", recordDetailOverlay: "{{title}} のレコード詳細オーバーレイ。", addToFavorites: "お気に入りに追加", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 535a80445d..4de3e20ae0 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -755,6 +755,7 @@ const ko = { reactionCount: "{{emoji}} 반응 {{count}}개", reactionCountOne: "{{emoji}} 반응 {{count}}개", recordDetail: "레코드 세부 정보", + recordDetailWithLabel: "{{label}} 세부 정보", openAsFullPage: "전체 페이지로 열기", recordDetailOverlay: "{{title}}의 레코드 세부 정보 오버레이입니다.", addToFavorites: "즐겨찾기에 추가", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 479bf55ab1..9cbea81ed4 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -757,6 +757,7 @@ const pt = { reactionCount: "{{emoji}} {{count}} reações", reactionCountOne: "{{emoji}} {{count}} reação", recordDetail: "Detalhe do registro", + recordDetailWithLabel: "Detalhe de {{label}}", openAsFullPage: "Abrir como página inteira", recordDetailOverlay: "Sobreposição de detalhe do registro para {{title}}.", addToFavorites: "Adicionar aos favoritos", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 6741737c13..b531ad2351 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -757,6 +757,7 @@ const ru = { reactionCount: "{{emoji}} {{count}} реакций", reactionCountOne: "{{emoji}} {{count}} реакция", recordDetail: "Сведения о записи", + recordDetailWithLabel: "Сведения: {{label}}", openAsFullPage: "Открыть на всю страницу", recordDetailOverlay: "Наложение со сведениями о записи для {{title}}.", addToFavorites: "Добавить в избранное", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 8f82d7c7f4..ab1636fd43 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -805,6 +805,7 @@ const zh = { reactionCount: '{{emoji}} {{count}} 个回应', reactionCountOne: '{{emoji}} {{count}} 个回应', recordDetail: '记录详情', + recordDetailWithLabel: '{{label}}详情', openAsFullPage: '以完整页面打开', recordDetailOverlay: '{{title}} 的记录详情浮层。', addToFavorites: '添加到收藏', diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 83b8ee5aa5..43d9c910d5 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -131,6 +131,15 @@ const GRID_DEFAULT_TRANSLATIONS: Record = { // Reused by the grouped-view pager (falls back here when no I18nProvider). 'table.rowsPerPage': 'Rows per page', 'table.pageInfo': 'Page {{current}} of {{total}}', + // Heading of the record-detail overlay this grid opens on row click + // (objectui#3426). Borrowed from the `detail.*` namespace rather than minted + // as `grid.recordDetail`: `NavigationOverlay` already resolves + // `detail.recordDetail` for hosts that pass no title, and one heading on one + // control should not get two translations that can drift apart. Both entries + // must exist HERE too — a provider-less host (a standalone grid, this + // package's own tests) never reaches the locale packs. + 'detail.recordDetail': 'Record Detail', + 'detail.recordDetailWithLabel': '{{label}} Detail', }; /** @@ -2277,12 +2286,27 @@ export const ObjectGrid: React.FC = ({ })), }); - // Build record detail title + // Build record detail title. + // + // Keyed, not string-built (objectui#3426). This value is handed to + // `NavigationOverlay`'s `title` prop, which means the overlay's own + // `detail.recordDetail` default never applies here — whatever this computes + // IS the visible heading of the drawer/modal/split/popover. Interpolating + // the label through `detail.recordDetailWithLabel` instead of splicing it + // into an English template lets each pack choose its own word order; the + // no-label branch reuses the overlay's own key rather than a twin. + // + // English output is unchanged in all three branches (`Contacts Detail` / + // `Contacts Detail` / `Record Detail`), including with no `I18nProvider` + // mounted — `createSafeTranslation`'s fallback interpolates `{{label}}` from + // `GRID_DEFAULT_TRANSLATIONS`. const detailTitle = schema.label - ? `${schema.label} Detail` + ? t('detail.recordDetailWithLabel', { label: schema.label }) : schema.objectName - ? `${schema.objectName.charAt(0).toUpperCase() + schema.objectName.slice(1)} Detail` - : 'Record Detail'; + ? t('detail.recordDetailWithLabel', { + label: schema.objectName.charAt(0).toUpperCase() + schema.objectName.slice(1), + }) + : t('detail.recordDetail'); // Form-based record detail renderer (replaces simple key-value dump). // Hoisted above the mobile card-view's early return (below) so both the diff --git a/packages/plugin-grid/src/__tests__/ObjectGrid.overlayTitleI18n.test.tsx b/packages/plugin-grid/src/__tests__/ObjectGrid.overlayTitleI18n.test.tsx new file mode 100644 index 0000000000..0755a0984d --- /dev/null +++ b/packages/plugin-grid/src/__tests__/ObjectGrid.overlayTitleI18n.test.tsx @@ -0,0 +1,155 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `ObjectGrid`'s record-detail overlay heading speaks the session locale — + * objectui#3426. + * + * ── Why this path is user-reachable (the issue left it unverified) ───────── + * `object-grid` / `grid` are PUBLIC page blocks (`packages/core/src/registry/ + * public-blocks.ts`) and `navigation` is an authorable key on the grid schema + * (`ViewNavigationConfig`, aligned with `@objectstack/spec ListView.navigation`). + * `useNavigationOverlay` turns `mode: 'drawer' | 'modal' | 'split' | 'popover'` + * into `isOverlay`, and `ObjectGrid` wires `navigation.handleClick` onto every + * row. So any page metadata that drops a grid block with an overlay navigation + * mode gets this drawer — no console/app-shell involvement. + * + * The one host that DOES suppress it is `app-shell`'s `ObjectView`, which + * passes its own `onRowClick` (that takes full priority inside + * `useNavigationOverlay`) and renders its own overlay. That is a host override, + * not proof the branch is dead: `ObjectView` is one consumer of a public block. + * + * ── Direction of these assertions ───────────────────────────────────────── + * The non-English cases (zh / ja / de) were RED before the change — the drawer + * was titled by a TypeScript template literal (`` `${schema.label} Detail` ``), + * so a zh session read "Contacts Detail" — and are GREEN after. The `en` cases + * were GREEN before AND after: they pin that routing the heading through `t()` + * did not change a single byte of what an English session sees, in all three + * branches (label / objectName / neither). + * + * The provider-less fallback is asserted in + * `ObjectGrid.overlayTitleNoProviderFallback.test.tsx` — it cannot live in this + * file, because `createI18n` registers its instance as react-i18next's + * module-global default and that registration survives `cleanup()`; a + * "no provider" render in this file would silently resolve against whichever + * locale a previous test mounted. + */ + +import React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { I18nProvider } from '@object-ui/i18n'; +import { registerAllFields } from '@object-ui/fields'; +import { ActionProvider } from '@object-ui/react'; +import { ObjectGrid } from '../ObjectGrid'; + +registerAllFields(); + +const rows = [ + { id: '1', name: 'Alice' }, + { id: '2', name: 'Bob' }, +]; + +function renderGridIn(language: string, schemaExtra: Record) { + return render( + + + + + , + ); +} + +/** Open the detail overlay the way a user does: click a row. */ +async function openOverlay() { + const cell = await screen.findByText('Alice'); + fireEvent.click(cell); + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); +} + +afterEach(() => cleanup()); + +describe('ObjectGrid record-detail overlay heading (objectui#3426)', () => { + it('renders the authored label in English under an en session', async () => { + renderGridIn('en', { label: 'Contacts' }); + await openOverlay(); + + expect(screen.getByText('Contacts Detail')).toBeInTheDocument(); + }); + + it('renders the zh bundle value under a zh session', async () => { + renderGridIn('zh', { label: '联系人' }); + await openOverlay(); + + expect(screen.getByText('联系人详情')).toBeInTheDocument(); + // The whole point of the issue: no English leaks into a zh drawer. + expect(screen.queryByText('联系人 Detail')).toBeNull(); + }); + + it('renders the ja bundle value under a ja session', async () => { + renderGridIn('ja', { label: '取引先' }); + await openOverlay(); + + expect(screen.getByText('取引先の詳細')).toBeInTheDocument(); + }); + + it('renders the de bundle value under a de session', async () => { + renderGridIn('de', { label: 'Kontakte' }); + await openOverlay(); + + expect(screen.getByText('Kontakte-Details')).toBeInTheDocument(); + }); + + /** + * Second branch: no `label`, so the heading is built from the capitalized + * `objectName`. Same key, same interpolation — this exists because the + * branch is separately spelled in the source and a partial fix would leave + * it English. + */ + it('falls back to the capitalized objectName through the same key', async () => { + renderGridIn('zh', {}); + await openOverlay(); + + expect(screen.getByText('Contacts详情')).toBeInTheDocument(); + }); + + /** + * Third branch: neither label nor objectName — reuses `detail.recordDetail`, + * the very key `NavigationOverlay` defaults to. Pins that the two headings + * cannot drift into two different translations of one control. + */ + it('reuses detail.recordDetail when the schema names nothing', async () => { + render( + + + + + , + ); + await openOverlay(); + + expect(screen.getByText('记录详情')).toBeInTheDocument(); + }); +}); diff --git a/packages/plugin-grid/src/__tests__/ObjectGrid.overlayTitleNoProviderFallback.test.tsx b/packages/plugin-grid/src/__tests__/ObjectGrid.overlayTitleNoProviderFallback.test.tsx new file mode 100644 index 0000000000..5efaf356ab --- /dev/null +++ b/packages/plugin-grid/src/__tests__/ObjectGrid.overlayTitleNoProviderFallback.test.tsx @@ -0,0 +1,108 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `ObjectGrid`'s record-detail overlay heading still resolves to ENGLISH, and + * to the SAME BYTES as before, when no `I18nProvider` is mounted — + * objectui#3426. + * + * This is not a nice-to-have. Routing a literal through `t()` without a working + * default is exactly how a provider-less consumer breaks, and it breaks in a + * suite that is not this one: `object-grid` is a public page block, so any host + * that renders schema without mounting a provider (this package's own tests, + * the preview gallery, an embedding app) reads whatever the defaults map says. + * The English defaults live in `GRID_DEFAULT_TRANSLATIONS` (`ObjectGrid.tsx`) — + * that map is what `createSafeTranslation` falls back to when its `grid.actions` + * probe comes back unresolved. + * + * The byte-identity matters beyond aesthetics: the heading is `Contacts Detail` + * before the change and `Contacts Detail` after, so e2e specs and host tests + * that address this chrome by its English name keep addressing it. + * + * Direction: this file was GREEN before the change and is GREEN after. It pins + * the FALLBACK, not the fix — the fix is asserted in + * `ObjectGrid.overlayTitleI18n.test.tsx`. A missing map entry would have turned + * it red by rendering the raw key `detail.recordDetailWithLabel`, which is + * precisely the regression it exists to catch. + * + * ── Why this is its own FILE, not a describe block ──────────────────────── + * `createI18n` calls `instance.use(initReactI18next)`, and `initReactI18next` + * registers that instance as **react-i18next's module-global default**. The + * registration survives unmount and `cleanup()`. So the moment any test in a + * file mounts ``, every later + * "no provider" render in that same file silently resolves against the Chinese + * instance — a green-looking file that asserts nothing about the fallback. + * + * Vitest's `dom` project runs with `isolate: true`, so a file that never mounts + * a provider gets a genuinely clean global. Keep it that way: **do not import + * or mount `I18nProvider` here.** + */ + +import React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { registerAllFields } from '@object-ui/fields'; +import { ActionProvider } from '@object-ui/react'; +import { ObjectGrid } from '../ObjectGrid'; + +registerAllFields(); + +const rows = [ + { id: '1', name: 'Alice' }, + { id: '2', name: 'Bob' }, +]; + +function renderGrid(schemaExtra: Record) { + return render( + + + , + ); +} + +async function openOverlay() { + const cell = await screen.findByText('Alice'); + fireEvent.click(cell); + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); +} + +afterEach(() => cleanup()); + +describe('ObjectGrid overlay heading — English fallback with no provider (objectui#3426)', () => { + it('interpolates the authored label in English, never the raw key', async () => { + renderGrid({ objectName: 'contacts', label: 'Contacts' }); + await openOverlay(); + + expect(screen.getByText('Contacts Detail')).toBeInTheDocument(); + expect(screen.queryByText('detail.recordDetailWithLabel')).toBeNull(); + }); + + it('capitalizes objectName in English when no label is authored', async () => { + renderGrid({ objectName: 'contacts' }); + await openOverlay(); + + expect(screen.getByText('Contacts Detail')).toBeInTheDocument(); + }); + + it('falls back to the bare English heading when the schema names nothing', async () => { + renderGrid({}); + await openOverlay(); + + expect(screen.getByText('Record Detail')).toBeInTheDocument(); + expect(screen.queryByText('detail.recordDetail')).toBeNull(); + }); +}); diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 4f26df6144..04a0964495 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -409,6 +409,15 @@ const LIST_DEFAULT_TRANSLATIONS: Record = { 'grid.toolbar.densityCycleShortHint': 'Click to cycle', 'list.viewSettings': 'View settings', 'list.viewSettingsHint': 'Grouping, color, density, and visible fields.', + // Heading of the record-detail overlay this view opens when a child view's + // row is clicked (objectui#3426). Borrowed from the `detail.*` namespace + // rather than minted as `list.recordDetail`: `NavigationOverlay` already + // resolves `detail.recordDetail` for hosts that pass no title, and one + // heading on one control should not get two translations that can drift + // apart. Both entries must exist HERE too — a provider-less host (a + // standalone list, this package's own tests) never reaches the locale packs. + 'detail.recordDetail': 'Record Detail', + 'detail.recordDetailWithLabel': '{{label}} Detail', }; /** @@ -1408,6 +1417,28 @@ export const ListView = React.forwardRef(({ onRowClick, }); + // Heading of the record-detail overlay rendered at the bottom of this file. + // + // Keyed, not string-built (objectui#3426). This value is handed to + // `NavigationOverlay`'s `title` prop, which means the overlay's own + // `detail.recordDetail` default never applies here — whatever this computes + // IS the visible heading of the drawer/modal/split/popover. Interpolating + // the label through `detail.recordDetailWithLabel` instead of splicing it + // into an English template lets each pack choose its own word order; the + // no-label branch reuses the overlay's own key rather than a twin. + // + // English output is unchanged in all three branches (`Contacts Detail` / + // `Contacts Detail` / `Record Detail`), including with no `I18nProvider` + // mounted — `createSafeTranslation`'s fallback interpolates `{{label}}` from + // `LIST_DEFAULT_TRANSLATIONS`. + const detailTitle = schema.label + ? t('detail.recordDetailWithLabel', { label: schema.label }) + : schema.objectName + ? t('detail.recordDetailWithLabel', { + label: schema.objectName.charAt(0).toUpperCase() + schema.objectName.slice(1), + }) + : t('detail.recordDetail'); + // Field-level permission gate. Filter unreadable columns from the // field list BEFORE any downstream column construction so they also // disappear from the hide-fields popover, filter/sort builders, and @@ -2847,13 +2878,7 @@ export const ListView = React.forwardRef(({ {navigation.isOverlay && ( {(record) => (
diff --git a/packages/plugin-list/src/__tests__/ListView.overlayTitleI18n.test.tsx b/packages/plugin-list/src/__tests__/ListView.overlayTitleI18n.test.tsx new file mode 100644 index 0000000000..7f0d02fbcd --- /dev/null +++ b/packages/plugin-list/src/__tests__/ListView.overlayTitleI18n.test.tsx @@ -0,0 +1,189 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `ListView`'s record-detail overlay heading speaks the session locale — + * objectui#3426. + * + * ── Why this path is user-reachable (the issue left it unverified) ───────── + * `list-view` / `list` are PUBLIC page blocks (`packages/core/src/registry/ + * public-blocks.ts`) and `navigation` is an authorable key on the list schema + * (`ViewNavigationConfig`, aligned with `@objectstack/spec ListView.navigation`). + * `useNavigationOverlay` turns `mode: 'drawer' | 'modal' | 'split' | 'popover'` + * into `isOverlay`; `ListView` forwards `navigation.handleClick` to whichever + * child view it renders (`viewComponentSchema.onRowClick`) and owns the overlay + * itself. So any page metadata that drops a list block with an overlay + * navigation mode gets this drawer — no console/app-shell involvement. + * + * The one host that DOES suppress it is `app-shell`'s `ObjectView`, which + * passes its own `onRowClick` (that takes full priority inside + * `useNavigationOverlay`) and renders its own overlay. That is a host override, + * not proof the branch is dead: `ObjectView` is one consumer of a public block. + * + * The child view is stubbed (same pattern as `ListView.test.tsx`'s inline-edit + * spy) so the row click under test is ListView's OWN `onRowClick` contract with + * its child, not a re-test of `ObjectGrid`'s row rendering — `plugin-list` does + * not depend on `plugin-grid`. + * + * ── Direction of these assertions ───────────────────────────────────────── + * The non-English cases (zh / ja / de) were RED before the change — the drawer + * was titled by a TypeScript template literal (`` `${schema.label} Detail` ``), + * so a zh session read "Contacts Detail" — and are GREEN after. The `en` case + * was GREEN before AND after: it pins that routing the heading through `t()` + * did not change a single byte of what an English session sees. + * + * The provider-less fallback is asserted in + * `ListView.overlayTitleNoProviderFallback.test.tsx` — it cannot live in this + * file, because `createI18n` registers its instance as react-i18next's + * module-global default and that registration survives `cleanup()`. + */ + +import React from 'react'; +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { ComponentRegistry } from '@object-ui/core'; +import { I18nProvider } from '@object-ui/i18n'; +import { SchemaRendererProvider } from '@object-ui/react'; +import { ListView } from '../ListView'; +import type { ListViewSchema } from '@object-ui/types'; + +const rows = [{ id: '1', name: 'Alice' }]; + +const mockDataSource = { + find: vi.fn().mockResolvedValue(rows), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), +}; + +let prevObjectGrid: ReturnType; + +beforeAll(() => { + prevObjectGrid = ComponentRegistry.get('object-grid'); + // Minimal stand-in for the real grid: exposes the row-click affordance + // ListView hands every child view, and nothing else. + ComponentRegistry.register('object-grid', (props: never) => { + const onRowClick = (props as { onRowClick?: (r: unknown) => void }).onRowClick; + return ( + + ); + }); +}); + +afterAll(() => { + if (prevObjectGrid) ComponentRegistry.register('object-grid', prevObjectGrid); + else ComponentRegistry.unregister('object-grid'); +}); + +afterEach(() => cleanup()); + +function renderListIn(language: string, schemaExtra: Partial) { + return render( + + + + + , + ); +} + +/** Open the detail overlay the way a user does: click a row in the child view. */ +async function openOverlay() { + fireEvent.click(await screen.findByTestId('stub-row')); + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); +} + +describe('ListView record-detail overlay heading (objectui#3426)', () => { + it('renders the authored label in English under an en session', async () => { + renderListIn('en', { label: 'Contacts' }); + await openOverlay(); + + expect(screen.getByText('Contacts Detail')).toBeInTheDocument(); + }); + + it('renders the zh bundle value under a zh session', async () => { + renderListIn('zh', { label: '联系人' }); + await openOverlay(); + + expect(screen.getByText('联系人详情')).toBeInTheDocument(); + // The whole point of the issue: no English leaks into a zh drawer. + expect(screen.queryByText('联系人 Detail')).toBeNull(); + }); + + it('renders the ja bundle value under a ja session', async () => { + renderListIn('ja', { label: '取引先' }); + await openOverlay(); + + expect(screen.getByText('取引先の詳細')).toBeInTheDocument(); + }); + + it('renders the de bundle value under a de session', async () => { + renderListIn('de', { label: 'Kontakte' }); + await openOverlay(); + + expect(screen.getByText('Kontakte-Details')).toBeInTheDocument(); + }); + + /** + * Second branch: no `label`, so the heading is built from the capitalized + * `objectName`. Same key, same interpolation — this exists because the + * branch is separately spelled in the source and a partial fix would leave + * it English. + */ + it('falls back to the capitalized objectName through the same key', async () => { + renderListIn('zh', {}); + await openOverlay(); + + expect(screen.getByText('Contacts详情')).toBeInTheDocument(); + }); + + /** + * Third branch: neither label nor objectName — reuses `detail.recordDetail`, + * the very key `NavigationOverlay` defaults to. Pins that the two headings + * cannot drift into two different translations of one control. + * + * Rows come from inline `data` here: with no `objectName` there is nothing + * for the data-source fetch to query, so the view would paint its empty + * state and render no child view at all. + */ + it('reuses detail.recordDetail when the schema names nothing', async () => { + render( + + + + + , + ); + await openOverlay(); + + expect(screen.getByText('记录详情')).toBeInTheDocument(); + }); +}); diff --git a/packages/plugin-list/src/__tests__/ListView.overlayTitleNoProviderFallback.test.tsx b/packages/plugin-list/src/__tests__/ListView.overlayTitleNoProviderFallback.test.tsx new file mode 100644 index 0000000000..408c7460fe --- /dev/null +++ b/packages/plugin-list/src/__tests__/ListView.overlayTitleNoProviderFallback.test.tsx @@ -0,0 +1,131 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `ListView`'s record-detail overlay heading still resolves to ENGLISH, and to + * the SAME BYTES as before, when no `I18nProvider` is mounted — objectui#3426. + * + * This is not a nice-to-have. Routing a literal through `t()` without a working + * default is exactly how a provider-less consumer breaks, and it breaks in a + * suite that is not this one: `list-view` is a public page block, so any host + * that renders schema without mounting a provider (this package's own tests, + * the preview gallery, an embedding app) reads whatever the defaults map says. + * The English defaults live in `LIST_DEFAULT_TRANSLATIONS` (`ListView.tsx`) — + * that map is what `createSafeTranslation` falls back to when its + * `list.recordCount` probe comes back unresolved. + * + * The byte-identity matters beyond aesthetics: the heading is `Contacts Detail` + * before the change and `Contacts Detail` after, so e2e specs and host tests + * that address this chrome by its English name keep addressing it. + * + * Direction: this file was GREEN before the change and is GREEN after. It pins + * the FALLBACK, not the fix — the fix is asserted in + * `ListView.overlayTitleI18n.test.tsx`. A missing map entry would have turned + * it red by rendering the raw key `detail.recordDetailWithLabel`, which is + * precisely the regression it exists to catch. + * + * ── Why this is its own FILE, not a describe block ──────────────────────── + * `createI18n` calls `instance.use(initReactI18next)`, and `initReactI18next` + * registers that instance as **react-i18next's module-global default**. The + * registration survives unmount and `cleanup()`. So the moment any test in a + * file mounts ``, every later + * "no provider" render in that same file silently resolves against the Chinese + * instance — a green-looking file that asserts nothing about the fallback. + * + * Vitest's `dom` project runs with `isolate: true`, so a file that never mounts + * a provider gets a genuinely clean global. Keep it that way: **do not import + * or mount `I18nProvider` here.** + */ + +import React from 'react'; +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { ComponentRegistry } from '@object-ui/core'; +import { SchemaRendererProvider } from '@object-ui/react'; +import { ListView } from '../ListView'; +import type { ListViewSchema } from '@object-ui/types'; + +const rows = [{ id: '1', name: 'Alice' }]; + +const mockDataSource = { + find: vi.fn().mockResolvedValue(rows), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), +}; + +let prevObjectGrid: ReturnType; + +beforeAll(() => { + prevObjectGrid = ComponentRegistry.get('object-grid'); + ComponentRegistry.register('object-grid', (props: never) => { + const onRowClick = (props as { onRowClick?: (r: unknown) => void }).onRowClick; + return ( + + ); + }); +}); + +afterAll(() => { + if (prevObjectGrid) ComponentRegistry.register('object-grid', prevObjectGrid); + else ComponentRegistry.unregister('object-grid'); +}); + +afterEach(() => cleanup()); + +function renderList(schemaExtra: Record) { + return render( + + + , + ); +} + +async function openOverlay() { + fireEvent.click(await screen.findByTestId('stub-row')); + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); +} + +describe('ListView overlay heading — English fallback with no provider (objectui#3426)', () => { + it('interpolates the authored label in English, never the raw key', async () => { + renderList({ objectName: 'contacts', label: 'Contacts' }); + await openOverlay(); + + expect(screen.getByText('Contacts Detail')).toBeInTheDocument(); + expect(screen.queryByText('detail.recordDetailWithLabel')).toBeNull(); + }); + + it('capitalizes objectName in English when no label is authored', async () => { + renderList({ objectName: 'contacts' }); + await openOverlay(); + + expect(screen.getByText('Contacts Detail')).toBeInTheDocument(); + }); + + it('falls back to the bare English heading when the schema names nothing', async () => { + renderList({}); + await openOverlay(); + + expect(screen.getByText('Record Detail')).toBeInTheDocument(); + expect(screen.queryByText('detail.recordDetail')).toBeNull(); + }); +});