Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .changeset/overlay-caller-titles-i18n-3426.md
Original file line number Diff line number Diff line change
@@ -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()`.
1 change: 1 addition & 0 deletions packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,7 @@ const ar = {
reactionCount: "{{emoji}} {{count}} تفاعلات",
reactionCountOne: "{{emoji}} {{count}} تفاعل",
recordDetail: "تفاصيل السجل",
recordDetailWithLabel: "تفاصيل {{label}}",
openAsFullPage: "فتح كصفحة كاملة",
recordDetailOverlay: "طبقة تفاصيل السجل الخاصة بـ {{title}}.",
addToFavorites: "إضافة إلى المفضلة",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -755,6 +755,7 @@ const ja = {
reactionCount: "{{emoji}} {{count}} 件のリアクション",
reactionCountOne: "{{emoji}} {{count}} 件のリアクション",
recordDetail: "レコード詳細",
recordDetailWithLabel: "{{label}}の詳細",
openAsFullPage: "フルページで開く",
recordDetailOverlay: "{{title}} のレコード詳細オーバーレイ。",
addToFavorites: "お気に入りに追加",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -755,6 +755,7 @@ const ko = {
reactionCount: "{{emoji}} 반응 {{count}}개",
reactionCountOne: "{{emoji}} 반응 {{count}}개",
recordDetail: "레코드 세부 정보",
recordDetailWithLabel: "{{label}} 세부 정보",
openAsFullPage: "전체 페이지로 열기",
recordDetailOverlay: "{{title}}의 레코드 세부 정보 오버레이입니다.",
addToFavorites: "즐겨찾기에 추가",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,7 @@ const ru = {
reactionCount: "{{emoji}} {{count}} реакций",
reactionCountOne: "{{emoji}} {{count}} реакция",
recordDetail: "Сведения о записи",
recordDetailWithLabel: "Сведения: {{label}}",
openAsFullPage: "Открыть на всю страницу",
recordDetailOverlay: "Наложение со сведениями о записи для {{title}}.",
addToFavorites: "Добавить в избранное",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -805,6 +805,7 @@ const zh = {
reactionCount: '{{emoji}} {{count}} 个回应',
reactionCountOne: '{{emoji}} {{count}} 个回应',
recordDetail: '记录详情',
recordDetailWithLabel: '{{label}}详情',
openAsFullPage: '以完整页面打开',
recordDetailOverlay: '{{title}} 的记录详情浮层。',
addToFavorites: '添加到收藏',
Expand Down
32 changes: 28 additions & 4 deletions packages/plugin-grid/src/ObjectGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,15 @@ const GRID_DEFAULT_TRANSLATIONS: Record<string, string> = {
// 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',
};

/**
Expand Down Expand Up @@ -2277,12 +2286,27 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
})),
});

// 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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
return render(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
<ActionProvider>
<ObjectGrid
schema={{
type: 'object-grid',
objectName: 'contacts',
columns: [{ field: 'name', label: 'Name' }],
data: { provider: 'value', items: rows },
navigation: { mode: 'drawer' },
...schemaExtra,
} as never}
/>
</ActionProvider>
</I18nProvider>,
);
}

/** 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(
<I18nProvider config={{ defaultLanguage: 'zh', detectBrowserLanguage: false }}>
<ActionProvider>
<ObjectGrid
schema={{
type: 'object-grid',
columns: [{ field: 'name', label: 'Name' }],
data: { provider: 'value', items: rows },
navigation: { mode: 'drawer' },
} as never}
/>
</ActionProvider>
</I18nProvider>,
);
await openOverlay();

expect(screen.getByText('记录详情')).toBeInTheDocument();
});
});
Loading
Loading