diff --git a/.changeset/overlay-caller-titles-i18n-3459.md b/.changeset/overlay-caller-titles-i18n-3459.md new file mode 100644 index 0000000000..40ab0aab67 --- /dev/null +++ b/.changeset/overlay-caller-titles-i18n-3459.md @@ -0,0 +1,65 @@ +--- +'@object-ui/plugin-kanban': patch +'@object-ui/plugin-tree': patch +'@object-ui/plugin-view': patch +--- + +Localize the record-detail headings that `ObjectKanban`, `ObjectTree` and +`ObjectView` build themselves (objectui#3459) + +#3426 / PR #3457 keyed `ListView` and `ObjectGrid`; a repo-wide grep found the +same pattern in three more hosts, each string-building an English heading in +TypeScript so the surrounding drawer/panel was fully localized with one English +phrase on top of it. + +- `packages/plugin-kanban/src/ObjectKanban.tsx` — the object-derived heading of + the card-detail drawer +- `packages/plugin-tree/src/ObjectTree.tsx` — the bare literal + `"Record Details"` handed to `NavigationOverlay` +- `packages/plugin-view/src/ObjectView.tsx` — `` `${objectLabel} Detail` `` on + the `mode: 'split'` panel + +All three are user-reachable, each verified by a test that drives the real +interaction (render the block, click a card/row, read the heading), not by +inspection: + +- `object-kanban` is a public page block whose `navigation` config DEFAULTS to + `{ mode: 'drawer' }`, so a board needs no authoring at all to open this + drawer on card click; +- `object-tree` needs `navigation: { mode: 'drawer' }` authored explicitly, and + every row's click is wired to `navigation.handleClick`; +- `object-view` declares `navigation` as an authorable input and maps + `mode: 'split'` onto the branch that renders this heading. + +## What changed + +Each call site now keys its heading through the existing `detail.*` pair — +`detail.recordDetailWithLabel` (`'{{label}} Detail'`) where an object label is +available, `detail.recordDetail` where none is. No new locale keys: both +already ship in all ten packs from #3457, and reusing them keeps one heading on +one control instead of minting per-plugin twins that drift. + +Each plugin gains its own English defaults map, which is what +`createSafeTranslation` falls back to with no `I18nProvider` mounted; +`@object-ui/plugin-tree` gains a dependency on `@object-ui/i18n` for it. + +## Visible English change + +One, deliberate: the tree overlay's heading goes from the plural +`Record Details` to the singular `Record Detail` — the spelling the whole +`detail.*` family, including `NavigationOverlay`'s own default, already uses. +The maintainer ruled on normalizing the stray plurals rather than minting a +plural key; a repo-wide grep confirmed no `e2e/` spec and no unit test +addressed the old string. + +Every other branch is byte-identical in English (`Contacts Detail`, +`Support cases Detail`, `Contacts 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()`. + +The kanban's other former plural (`'Card Details'`) is NOT a visible change: it +sat on a branch that fires only when the board has no `objectName`, while the +drawer consuming it returns `null` on that very condition. It is keyed anyway +so the literal cannot leak if that guard ever relaxes, and it deliberately has +no test — an assertion there would pass because nothing renders. diff --git a/packages/plugin-kanban/src/ObjectKanban.overlayTitleI18n.test.tsx b/packages/plugin-kanban/src/ObjectKanban.overlayTitleI18n.test.tsx new file mode 100644 index 0000000000..af422034ce --- /dev/null +++ b/packages/plugin-kanban/src/ObjectKanban.overlayTitleI18n.test.tsx @@ -0,0 +1,178 @@ +/** + * 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. + */ + +/** + * `ObjectKanban`'s record-detail drawer heading speaks the session locale — + * objectui#3459 (the #3426 family, third of five hosts). + * + * ── Why this path is user-reachable (the issue left it unverified) ───────── + * `object-kanban` is a PUBLIC page block (`packages/core/src/registry/ + * public-blocks.ts`) and its overlay needs no authoring at all: `navConfig` + * DEFAULTS to `{ mode: 'drawer' }` when the schema declares no `navigation`, + * and every card's `onCardClick` is wired straight to `navigation.handleClick`. + * So a page that simply drops a kanban block gets this drawer on card click. + * The heading falls back to the object-derived title whenever the board + * declares no `cardTitle`/`titleField` (or the record's value is empty). + * + * The hosts that DO suppress it — `ListView`, which passes its own + * `onRowClick` (that takes full priority inside `useNavigationOverlay`) and + * owns a unified overlay — are host overrides of a public block, not proof the + * branch is dead. The test below drives the standalone path. + * + * ── One correction to the issue's premise ───────────────────────────────── + * The issue filed this as a `NavigationOverlay` `title` prop. It is not: + * `ObjectKanban` renders `RecordDetailDrawer` (from `@object-ui/plugin-detail`), + * whose `title` becomes an **`sr-only` `SheetTitle`** — DetailView's own + * HeaderHighlight draws the visible heading. So this string is the drawer's + * ACCESSIBLE NAME, not a visible label. That makes it no less user-facing (it + * is the whole announcement a screen-reader user gets on open, and the same + * file already sources its resize-handle `aria-label` from the locale packs for + * exactly that reason) — but the assertions below check the accessible name, + * not a visible heading, because that is what the code actually renders. + * + * ── Direction of these assertions ───────────────────────────────────────── + * The non-English cases (zh / ja / de) were RED before the change — the drawer + * was named by a TypeScript template literal, so a zh session announced + * "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 byte + * of what an English session gets, including the underscore-to-space + * humanization of the object name. + * + * The second branch of `detailTitle` (`schema.objectName` absent, formerly the + * literal `'Card Details'`) has NO test here on purpose: it is unreachable. + * The drawer that consumes it bails on the very same condition + * (`if (!objectName || recordId == null) return null` in `ObjectKanban.tsx`), + * so no dialog ever opens without an object name — verified by rendering a + * kanban with no `objectName`, clicking a card, and finding no dialog at all. + * A test asserting a heading there would pass because nothing is produced, not + * because the logic is right. + * + * The provider-less fallback is asserted in + * `ObjectKanban.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 here 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 { ObjectKanban } from './ObjectKanban'; + +// Pay the board's lazy chunk at import time, not inside a `findBy` budget +// (AGENTS.md §测试纪律). `KanbanRenderer` renders +// `React.lazy(() => import('./KanbanImpl'))` behind a Suspense boundary, and +// every assertion below sits AFTER that boundary — a card has to be on screen +// before it can be clicked. Under full CI parallelism a first `import()` has +// been measured at ~976ms against RTL's 1000ms default, so without this the +// suite would race the module loader. The specifier must stay byte-identical to +// the one in `./index` — ESM caches by resolved specifier, which is what makes +// the component's own lazy factory resolve immediately. +import './KanbanImpl'; + +registerAllFields(); + +const cards = [ + { id: '1', title: 'On the board', status: 'todo' }, + { id: '2', title: 'Second card', status: 'todo' }, +]; + +function renderKanbanIn(language: string, schemaExtra: Record) { + return render( + + + , + ); +} + +/** Open the detail drawer the way a user does: click a card. */ +async function openDrawer() { + const card = await screen.findByText('On the board'); + fireEvent.click(card); + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); +} + +afterEach(() => cleanup()); + +describe('ObjectKanban record-detail drawer heading (objectui#3459)', () => { + it('names the drawer in English under an en session', async () => { + renderKanbanIn('en', { objectName: 'contacts' }); + await openDrawer(); + + expect(screen.getByRole('dialog')).toHaveAccessibleName('Contacts Detail'); + }); + + it('keeps the underscore-to-space humanization of the object name', async () => { + renderKanbanIn('en', { objectName: 'support_cases' }); + await openDrawer(); + + expect(screen.getByRole('dialog')).toHaveAccessibleName('Support cases Detail'); + }); + + it('names the drawer from the zh bundle under a zh session', async () => { + renderKanbanIn('zh', { objectName: 'contacts' }); + await openDrawer(); + + expect(screen.getByRole('dialog')).toHaveAccessibleName('Contacts详情'); + // The whole point of the issue: no English leaks into a zh drawer. + expect(screen.queryByText('Contacts Detail')).toBeNull(); + }); + + it('names the drawer from the ja bundle under a ja session', async () => { + renderKanbanIn('ja', { objectName: 'contacts' }); + await openDrawer(); + + expect(screen.getByRole('dialog')).toHaveAccessibleName('Contactsの詳細'); + }); + + it('names the drawer from the de bundle under a de session', async () => { + renderKanbanIn('de', { objectName: 'contacts' }); + await openDrawer(); + + expect(screen.getByRole('dialog')).toHaveAccessibleName('Contacts-Details'); + }); + + /** + * The record's own title still wins over the keyed fallback — this fix must + * not start overriding a board that names its cards. + */ + it('still prefers the record title field when the board declares one', async () => { + renderKanbanIn('zh', { objectName: 'contacts', cardTitle: 'title' }); + await openDrawer(); + + expect(screen.getByRole('dialog')).toHaveAccessibleName('On the board'); + }); +}); + +describe('ObjectKanban record-detail drawer — no-objectName branch is dead (objectui#3459)', () => { + /** + * Pins the reachability finding above, so a future reader does not "restore" + * a heading for a branch that cannot render. Without `objectName` the drawer + * IIFE returns `null`, so there is no dialog to carry any heading at all. + */ + it('opens no drawer at all when the board declares no objectName', async () => { + renderKanbanIn('en', {}); + const card = await screen.findByText('On the board'); + fireEvent.click(card); + + await waitFor(() => expect(screen.getByText('Second card')).toBeInTheDocument()); + expect(screen.queryByRole('dialog')).toBeNull(); + }); +}); diff --git a/packages/plugin-kanban/src/ObjectKanban.overlayTitleNoProviderFallback.test.tsx b/packages/plugin-kanban/src/ObjectKanban.overlayTitleNoProviderFallback.test.tsx new file mode 100644 index 0000000000..d0862f9a84 --- /dev/null +++ b/packages/plugin-kanban/src/ObjectKanban.overlayTitleNoProviderFallback.test.tsx @@ -0,0 +1,99 @@ +/** + * 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. + */ + +/** + * `ObjectKanban`'s record-detail drawer heading still resolves to ENGLISH, and + * to the SAME BYTES as before, when no `I18nProvider` is mounted — + * objectui#3459. + * + * 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-kanban` 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 `KANBAN_DEFAULT_TRANSLATIONS` + * (`ObjectKanban.tsx`) — that map is what `createSafeTranslation` falls back to + * when its `detail.recordDetail` probe comes back unresolved. + * + * Direction: this file was GREEN before the change and is GREEN after. It pins + * the FALLBACK, not the fix — the fix is asserted in + * `ObjectKanban.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 { ObjectKanban } from './ObjectKanban'; + +// Same reason as the sibling i18n file: the board's `KanbanImpl` chunk is +// `React.lazy`-loaded behind Suspense and every assertion here is after that +// boundary, so the cost is paid at import time rather than raced against a +// `findBy` timeout (AGENTS.md §测试纪律). Specifier byte-identical to `./index`'s. +import './KanbanImpl'; + +registerAllFields(); + +const cards = [ + { id: '1', title: 'On the board', status: 'todo' }, + { id: '2', title: 'Second card', status: 'todo' }, +]; + +function renderKanban(schemaExtra: Record) { + return render( + , + ); +} + +async function openDrawer() { + const card = await screen.findByText('On the board'); + fireEvent.click(card); + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); +} + +afterEach(() => cleanup()); + +describe('ObjectKanban drawer heading — English fallback with no provider (objectui#3459)', () => { + it('interpolates the capitalized object name in English, never the raw key', async () => { + renderKanban({ objectName: 'contacts' }); + await openDrawer(); + + expect(screen.getByRole('dialog')).toHaveAccessibleName('Contacts Detail'); + expect(screen.queryByText('detail.recordDetailWithLabel')).toBeNull(); + }); + + it('keeps the underscore-to-space humanization in the fallback path', async () => { + renderKanban({ objectName: 'support_cases' }); + await openDrawer(); + + expect(screen.getByRole('dialog')).toHaveAccessibleName('Support cases Detail'); + }); +}); diff --git a/packages/plugin-kanban/src/ObjectKanban.tsx b/packages/plugin-kanban/src/ObjectKanban.tsx index 81da2a6217..d47f2e98c8 100644 --- a/packages/plugin-kanban/src/ObjectKanban.tsx +++ b/packages/plugin-kanban/src/ObjectKanban.tsx @@ -17,12 +17,44 @@ import { isPermissionError, } from '@object-ui/react'; import { toast } from '@object-ui/components'; +import { createSafeTranslation } from '@object-ui/i18n'; import { RecordDetailDrawer, deriveRecordPageHref } from '@object-ui/plugin-detail'; import { extractRecords, buildExpandFields, getRecordDisplayName } from '@object-ui/core'; import { getBadgeColorClasses, getCellRenderer, resolveCellRendererType } from '@object-ui/fields'; import { KanbanRenderer, KANBAN_UNCOLUMNED_ID } from './index'; import { KanbanSchema } from './types'; +/** + * English fallbacks for the record-detail drawer heading this board opens on + * card click (objectui#3459, following #3426's shape). + * + * The two entries are borrowed from the `detail.*` namespace rather than minted + * as `kanban.recordDetail`: `NavigationOverlay` and `ListView`/`ObjectGrid` + * already resolve exactly these, and one heading on one control should not get + * several translations that can drift apart. They must exist HERE too — a + * provider-less host (a standalone board, this package's own tests) never + * reaches the locale packs, and `createSafeTranslation`'s fallback interpolates + * `{{label}}` from this map. + * + * `useSafeTranslate` (the `tt` used elsewhere in this file) cannot serve the + * labelled branch: its `tt(key, fallback)` signature has no options argument, + * so `{{label}}` would reach the DOM un-interpolated. + */ +const KANBAN_DEFAULT_TRANSLATIONS: Record = { + 'detail.recordDetail': 'Record Detail', + 'detail.recordDetailWithLabel': '{{label}} Detail', +}; + +/** + * Safe wrapper for useObjectTranslation that falls back to the English defaults + * above when no `I18nProvider` is mounted (standalone board, tests). + * Delegates to `@object-ui/i18n`'s `createSafeTranslation`. + */ +const useKanbanTranslation = createSafeTranslation( + KANBAN_DEFAULT_TRANSLATIONS, + 'detail.recordDetail', +); + /** * Minimal shape of the object definition this module reads. `objectDef` is * fetched via `dataSource.getObjectSchema` and is otherwise untyped here. @@ -91,6 +123,9 @@ export const ObjectKanban: React.FC = ({ void _props; const { translateOptions, fieldLabel } = useSafeFieldLabel(); const tt = useSafeTranslate(); + // Separate from `tt` because the record-detail heading interpolates a label — + // see KANBAN_DEFAULT_TRANSLATIONS above. + const { t } = useKanbanTranslation(); // When a parent (e.g. ListView) pre-fetches data and passes it via the `data` prop, // we must not trigger a second fetch. Detect external data by checking if externalData // is an array (undefined when not provided by parent). @@ -500,10 +535,31 @@ export const ObjectKanban: React.FC = ({ onRowClick: externalClick, }); - // Pass through to the renderer + // Fallback heading of the record-detail drawer opened on card click, used + // when the board declares no card-title field (or the record's is empty). + // + // Keyed, not string-built (objectui#3459, same shape as #3426). The value is + // handed to `RecordDetailDrawer`'s required `title` prop, which renders it as + // the drawer's `SheetTitle`. That heading is `sr-only` — DetailView's own + // HeaderHighlight draws the visible one — so this string IS the drawer's + // accessible name to a screen reader, and it was the one English phrase left + // in an otherwise fully localized zh/ja/de drawer. + // + // English output of the first branch is byte-identical (`Tasks Detail`), + // including with no `I18nProvider` mounted. + // + // The second branch is currently UNREACHABLE and deliberately has no test: + // it fires only when `schema.objectName` is falsy, but the drawer below bails + // on the very same condition (`if (!objectName || recordId == null) return + // null`), so nothing renders. It is keyed anyway rather than left as a + // literal — `'Card Details'` would be an English leak the day that guard + // relaxes, and reusing `detail.recordDetail` (the key NavigationOverlay + // itself defaults to) costs nothing and normalizes the stray plural. const detailTitle = schema.objectName - ? `${schema.objectName.charAt(0).toUpperCase() + schema.objectName.slice(1).replace(/_/g, ' ')} Detail` - : 'Card Details'; + ? t('detail.recordDetailWithLabel', { + label: schema.objectName.charAt(0).toUpperCase() + schema.objectName.slice(1).replace(/_/g, ' '), + }) + : t('detail.recordDetail'); // Persist cross-column drags by writing the new column id back to the // record's `groupBy` field. Local state is updated optimistically so the diff --git a/packages/plugin-tree/package.json b/packages/plugin-tree/package.json index ff3b4080b9..e67d1c4b90 100644 --- a/packages/plugin-tree/package.json +++ b/packages/plugin-tree/package.json @@ -33,6 +33,7 @@ "dependencies": { "@object-ui/components": "workspace:*", "@object-ui/core": "workspace:*", + "@object-ui/i18n": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", "@objectstack/spec": "^17.0.0-rc.2", diff --git a/packages/plugin-tree/src/ObjectTree.overlayTitleI18n.test.tsx b/packages/plugin-tree/src/ObjectTree.overlayTitleI18n.test.tsx new file mode 100644 index 0000000000..db327a5651 --- /dev/null +++ b/packages/plugin-tree/src/ObjectTree.overlayTitleI18n.test.tsx @@ -0,0 +1,129 @@ +/** + * 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. + */ + +/** + * `ObjectTree`'s record-detail overlay heading speaks the session locale — + * objectui#3459 (the #3426 family). + * + * ── Why this path is user-reachable (the issue left it unverified) ───────── + * `object-tree` / `tree` are registered renderer types + * (`packages/plugin-tree/src/index.tsx`), so any page schema that names one + * resolves through `ComponentRegistry`. `ObjectTree` reads `schema.navigation` + * and hands it to `useNavigationOverlay`, which turns + * `mode: 'drawer' | 'modal' | 'split' | 'popover'` into `isOverlay`; every row's + * `onClick` is wired to `navigation.handleClick`. So authored metadata of the + * form `{ type: 'tree', objectName, navigation: { mode: 'drawer' } }` opens + * exactly this overlay on row click — the path the test below drives. + * + * Unlike `object-grid`/`object-kanban`, `object-tree` is NOT in the curated + * `PUBLIC_BLOCKS` contract, so it is a rendering capability rather than part of + * the AI-authoring vocabulary — and unlike the kanban it has NO default + * navigation config, so the overlay needs `navigation` to be authored + * explicitly. Both narrow how the heading is reached; neither makes it dead. + * The hosts that embed a tree (`ListView`, `plugin-view`'s `ObjectView`) never + * forward `navigation` and `ListView` additionally passes its own `onRowClick`, + * so those paths suppress this overlay entirely — host overrides, not proof of + * a dead branch. + * + * ── Direction of these assertions ───────────────────────────────────────── + * The non-English cases (zh / ja / de) were RED before the change — the overlay + * carried the bare literal `"Record Details"`, so a zh session read one English + * heading on an otherwise localized drawer — and are GREEN after. + * + * The `en` case is the ONE assertion in this PR that was RED before and is + * GREEN after in English too, and that is deliberate, not a regression: the + * literal was the stray plural `Record Details`, and per the maintainer's + * ruling on #3459 it normalizes to the singular `detail.recordDetail` + * (`Record Detail`) that the whole `detail.*` family — including the default + * `NavigationOverlay` itself falls back to — already spells. A repo-wide grep + * for `Record Details` before the change found no `e2e/` spec and no unit test + * addressing the plural; the only other hit is an unrelated designer palette + * label in `plugin-detail`'s registration. + * + * The provider-less fallback is asserted in + * `ObjectTree.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 here 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 { ObjectTree } from './ObjectTree'; + +// A small self-referencing org hierarchy. +const orgUnits = [ + { id: '1', name: 'Acme', parent_id: null, head: 'CEO' }, + { id: '2', name: 'Engineering', parent_id: '1', head: 'VP Eng' }, +]; + +function renderTreeIn(language: string) { + return render( + + + , + ); +} + +/** Open the detail overlay the way a user does: click a row. */ +async function openOverlay() { + const cell = await screen.findByText('Acme'); + fireEvent.click(cell); + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); +} + +afterEach(() => cleanup()); + +describe('ObjectTree record-detail overlay heading (objectui#3459)', () => { + it('renders the singular English heading under an en session', async () => { + renderTreeIn('en'); + await openOverlay(); + + expect(screen.getByText('Record Detail')).toBeInTheDocument(); + // The stray plural is gone — one control, one spelling (maintainer ruling). + expect(screen.queryByText('Record Details')).toBeNull(); + }); + + it('renders the zh bundle value under a zh session', async () => { + renderTreeIn('zh'); + await openOverlay(); + + expect(screen.getByText('记录详情')).toBeInTheDocument(); + // The whole point of the issue: no English leaks into a zh drawer. + expect(screen.queryByText('Record Details')).toBeNull(); + expect(screen.queryByText('Record Detail')).toBeNull(); + }); + + it('renders the ja bundle value under a ja session', async () => { + renderTreeIn('ja'); + await openOverlay(); + + expect(screen.getByText('レコード詳細')).toBeInTheDocument(); + }); + + it('renders the de bundle value under a de session', async () => { + renderTreeIn('de'); + await openOverlay(); + + expect(screen.getByText('Datensatzdetails')).toBeInTheDocument(); + }); +}); diff --git a/packages/plugin-tree/src/ObjectTree.overlayTitleNoProviderFallback.test.tsx b/packages/plugin-tree/src/ObjectTree.overlayTitleNoProviderFallback.test.tsx new file mode 100644 index 0000000000..f3657247b4 --- /dev/null +++ b/packages/plugin-tree/src/ObjectTree.overlayTitleNoProviderFallback.test.tsx @@ -0,0 +1,86 @@ +/** + * 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. + */ + +/** + * `ObjectTree`'s record-detail overlay heading still resolves to ENGLISH when + * no `I18nProvider` is mounted — objectui#3459. + * + * 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: a tree renders wherever its type is registered, + * 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 default lives in `TREE_DEFAULT_TRANSLATIONS` + * (`ObjectTree.tsx`) — that map is what `createSafeTranslation` falls back to + * when its `detail.recordDetail` probe comes back unresolved. + * + * Direction: unlike its kanban / view siblings, this file was RED before the + * change and is GREEN after, in English. That is the deliberate visible change + * the maintainer ruled on for #3459: the old literal was the stray plural + * `Record Details`, normalized here to the singular `detail.recordDetail` + * spelling shared with `NavigationOverlay`'s own default. A missing map entry + * would turn this red the other way, by rendering the raw key + * `detail.recordDetail` — precisely the regression it also 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 { ObjectTree } from './ObjectTree'; + +const orgUnits = [ + { id: '1', name: 'Acme', parent_id: null, head: 'CEO' }, + { id: '2', name: 'Engineering', parent_id: '1', head: 'VP Eng' }, +]; + +function renderTree() { + return render( + , + ); +} + +async function openOverlay() { + const cell = await screen.findByText('Acme'); + fireEvent.click(cell); + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); +} + +afterEach(() => cleanup()); + +describe('ObjectTree overlay heading — English fallback with no provider (objectui#3459)', () => { + it('falls back to the English heading, never the raw key', async () => { + renderTree(); + await openOverlay(); + + expect(screen.getByText('Record Detail')).toBeInTheDocument(); + expect(screen.queryByText('detail.recordDetail')).toBeNull(); + }); +}); diff --git a/packages/plugin-tree/src/ObjectTree.tsx b/packages/plugin-tree/src/ObjectTree.tsx index 333f87e071..29fdf4a297 100644 --- a/packages/plugin-tree/src/ObjectTree.tsx +++ b/packages/plugin-tree/src/ObjectTree.tsx @@ -23,9 +23,34 @@ import React, { useEffect, useMemo, useState } from 'react'; import type { DataSource, ViewData } from '@object-ui/types'; import { useNavigationOverlay, useSafeFieldLabel } from '@object-ui/react'; import { NavigationOverlay, cn } from '@object-ui/components'; +import { createSafeTranslation } from '@object-ui/i18n'; import { extractRecords, buildExpandFields, columnIdentity } from '@object-ui/core'; import { ChevronRight, ChevronDown } from 'lucide-react'; +/** + * English fallback for the record-detail overlay heading this tree opens on row + * click (objectui#3459, following #3426's shape). + * + * Borrowed from the `detail.*` namespace rather than minted as + * `tree.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. The entry must + * exist HERE too — a provider-less host (a standalone tree, this package's own + * tests) never reaches the locale packs. + * + * It doubles as the `createSafeTranslation` probe key: with a provider mounted + * it resolves to a real pack value, without one it comes back as the key and + * the map below supplies the English. + */ +const TREE_DEFAULT_TRANSLATIONS: Record = { + 'detail.recordDetail': 'Record Detail', +}; + +const useTreeTranslation = createSafeTranslation( + TREE_DEFAULT_TRANSLATIONS, + 'detail.recordDetail', +); + export interface ObjectTreeProps { schema: any; dataSource?: DataSource; @@ -356,6 +381,10 @@ export const ObjectTree: React.FC = ({ onRowClick, }); + // Heading of the record-detail overlay rendered at the bottom of this file. + // Must stay above the conditional returns below — rules-of-hooks. + const { t } = useTreeTranslation(); + if (error) { return (
@@ -450,7 +479,15 @@ export const ObjectTree: React.FC = ({ {navigation.isOverlay && ( - + /* Keyed, not a bare literal (objectui#3459). This value is handed to + `NavigationOverlay`'s `title` prop, so the overlay's own + `detail.recordDetail` default never applies here — whatever this + resolves to IS the visible heading of the drawer/modal/split/popover. + Reusing that very key rather than minting a twin keeps one control on + one translation. Visible English changes `Record Details` → + `Record Detail` (the singular the whole `detail.*` family already + spells); nothing in `e2e/` or the unit suites addressed the plural. */ + {(record) => (
{Object.entries(record).map(([key, value]) => ( diff --git a/packages/plugin-tree/vite.config.ts b/packages/plugin-tree/vite.config.ts index 6f060f59f7..3fe43ece19 100644 --- a/packages/plugin-tree/vite.config.ts +++ b/packages/plugin-tree/vite.config.ts @@ -47,6 +47,7 @@ export default defineConfig({ 'react-dom': 'ReactDOM', '@object-ui/components': 'ObjectUIComponents', '@object-ui/core': 'ObjectUICore', + '@object-ui/i18n': 'ObjectUII18n', '@object-ui/react': 'ObjectUIReact', '@object-ui/types': 'ObjectUITypes', 'lucide-react': 'LucideReact', diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index 4b213355da..f4632ba7ac 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -55,7 +55,7 @@ import { useIsMobile, } from '@object-ui/components'; import { Plus } from 'lucide-react'; -import { useObjectTranslation } from '@object-ui/i18n'; +import { useObjectTranslation, createSafeTranslation } from '@object-ui/i18n'; import { buildExpandFields, normalizeListViewSchema, mergeFilterNodes } from '@object-ui/core'; import { SchemaRenderer as ImportedSchemaRenderer } from '@object-ui/react'; import { ViewSwitcher } from './ViewSwitcher'; @@ -84,6 +84,31 @@ function useCreateVerb(): string { return value === 'console.objectView.new' ? 'New' : value; } +/** + * English fallback for the split-mode record-detail heading (objectui#3459, + * following #3426's shape). + * + * Borrowed from the `detail.*` namespace rather than minted as + * `view.recordDetail`: `NavigationOverlay` — the very component this heading is + * handed to — already resolves that namespace, as do `ListView` / `ObjectGrid` + * / `ObjectKanban` / `ObjectTree`, and one heading on one control should not + * get several translations that can drift apart. The entry must exist HERE too + * — a provider-less host never reaches the locale packs, and + * `createSafeTranslation`'s fallback is what interpolates `{{label}}`. + * + * It doubles as the probe key: under a provider `t()` returns the pack's + * template (≠ the key) so the real translator is used; with no provider the key + * comes back unchanged and this map supplies the English. + */ +const VIEW_DEFAULT_TRANSLATIONS: Record = { + 'detail.recordDetailWithLabel': '{{label}} Detail', +}; + +const useObjectViewTranslation = createSafeTranslation( + VIEW_DEFAULT_TRANSLATIONS, + 'detail.recordDetailWithLabel', +); + export interface ObjectViewProps { /** * The schema configuration for the view @@ -246,6 +271,10 @@ export const ObjectView: React.FC = ({ onViewAction, }) => { const createVerb = useCreateVerb(); + // Heading of the split-mode record-detail panel (see the split branch far + // below). Declared with the other top-level hooks so it stays above every + // conditional return — rules-of-hooks. + const { t: tDetail } = useObjectViewTranslation(); const [objectSchema, setObjectSchema] = useState | null>(null); // Assigned in the render body (not in an effect) so the fetchData effect always // reads the latest objectSchema without needing it as a dependency. This matches @@ -1150,7 +1179,16 @@ export const ObjectView: React.FC = ({ setIsOpen={handleOverlayOpenChange} width={navigationConfig?.width} isOverlay={true} - title={`${objectLabel} Detail`} + /* Keyed, not string-built (objectui#3459). This value is handed + to `NavigationOverlay`'s `title` prop, so the overlay's own + `detail.recordDetail` default never applies — whatever this + resolves to IS the visible `h3` heading of the split panel. + Interpolating through `detail.recordDetailWithLabel` instead of + splicing the label into an English template lets each pack + choose its own word order (de hyphenates, ja/zh need a + possessive particle). English output is byte-identical + (`Contacts Detail`), with or without an `I18nProvider`. */ + title={tDetail('detail.recordDetailWithLabel', { label: objectLabel })} mainContent={
{renderContent()}
} > {renderOverlayDetail} diff --git a/packages/plugin-view/src/__tests__/ObjectView.overlayTitleI18n.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.overlayTitleI18n.test.tsx new file mode 100644 index 0000000000..ad3ea2714b --- /dev/null +++ b/packages/plugin-view/src/__tests__/ObjectView.overlayTitleI18n.test.tsx @@ -0,0 +1,128 @@ +/** + * 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. + */ + +/** + * `ObjectView`'s split-mode record-detail heading speaks the session locale — + * objectui#3459 (the #3426 family). + * + * ── Why this path is user-reachable (the issue left it unverified) ───────── + * `object-view` / `view` are registered renderer types + * (`packages/plugin-view/src/index.tsx`) and `navigation` is a DECLARED + * authorable input on that registration (`{ name: 'navigation', type: + * 'object', label: 'Navigation Config' }`), typed as `ViewNavigationConfig` + * whose `mode` union includes `'split'`. `handleRowClick` turns a click under + * `mode: 'split'` into `setSelectedRecord(record); setIsFormOpen(true)`, and + * `formLayout` maps that same mode to the `split` branch that renders + * `NavigationOverlay` with this heading. So authored metadata of the form + * `{ type: 'object-view', objectName, navigation: { mode: 'split' } }` reaches + * it on a row click — the path the test below drives. + * + * `layout` cannot get you here: `ObjectViewSchema.layout` is + * `'drawer' | 'modal' | 'page'` and `deriveRecordSurface` only ever returns + * `'drawer'` or `'page'`, so `navigation.mode` is the only door. The one host + * that suppresses the branch is `app-shell`'s `ObjectView` wrapper, which pins + * `layout: 'page'` and supplies `onNavigate` — a host override of a registered + * block, not proof the branch is dead. + * + * ── Direction of these assertions ───────────────────────────────────────── + * The non-English cases (zh / ja / de) were RED before the change — the panel + * was headed by a TypeScript template literal (`` `${objectLabel} Detail` ``), + * so a zh session read "联系人 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. No plural is + * involved on this site, so nothing visible changes in English here. + * + * The provider-less fallback is asserted in + * `ObjectView.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 here would silently resolve against whichever locale a + * previous test mounted. + */ + +import React from 'react'; +import { describe, it, expect, vi, 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 type { DataSource } from '@object-ui/types'; +import { ObjectView } from '../ObjectView'; + +const rows = [{ id: '1', name: 'Alice' }]; + +/** Minimal DataSource: `ObjectGrid` reads `result.data`, the label comes from the schema. */ +function dataSourceLabelled(label: string): DataSource { + return { + find: vi.fn().mockResolvedValue({ data: rows, total: rows.length }), + findOne: vi.fn().mockResolvedValue(rows[0]), + create: vi.fn().mockResolvedValue({}), + update: vi.fn().mockResolvedValue({}), + delete: vi.fn().mockResolvedValue({}), + getObjectSchema: vi.fn().mockResolvedValue({ + name: 'contacts', + label, + fields: { name: { label: 'Name', type: 'text' } }, + }), + } as unknown as DataSource; +} + +function renderViewIn(language: string, label: string) { + return render( + + + , + ); +} + +/** Open the split detail panel the way a user does: click a row. */ +async function openSplitPanel() { + const cell = await screen.findByText('Alice'); + fireEvent.click(cell); +} + +afterEach(() => cleanup()); + +describe('ObjectView split-mode detail heading (objectui#3459)', () => { + it('renders the object label in English under an en session', async () => { + renderViewIn('en', 'Contacts'); + await openSplitPanel(); + + await waitFor(() => expect(screen.getByText('Contacts Detail')).toBeInTheDocument()); + }); + + it('renders the zh bundle arrangement under a zh session', async () => { + renderViewIn('zh', '联系人'); + await openSplitPanel(); + + await waitFor(() => expect(screen.getByText('联系人详情')).toBeInTheDocument()); + // The whole point of the issue: no English leaks into a zh panel. + expect(screen.queryByText('联系人 Detail')).toBeNull(); + }); + + it('renders the ja bundle arrangement under a ja session', async () => { + renderViewIn('ja', '取引先'); + await openSplitPanel(); + + await waitFor(() => expect(screen.getByText('取引先の詳細')).toBeInTheDocument()); + }); + + it('renders the de bundle arrangement under a de session', async () => { + renderViewIn('de', 'Kontakte'); + await openSplitPanel(); + + await waitFor(() => expect(screen.getByText('Kontakte-Details')).toBeInTheDocument()); + }); +}); diff --git a/packages/plugin-view/src/__tests__/ObjectView.overlayTitleNoProviderFallback.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.overlayTitleNoProviderFallback.test.tsx new file mode 100644 index 0000000000..2d89526f22 --- /dev/null +++ b/packages/plugin-view/src/__tests__/ObjectView.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. + */ + +/** + * `ObjectView`'s split-mode record-detail heading still resolves to ENGLISH, + * and to the SAME BYTES as before, when no `I18nProvider` is mounted — + * objectui#3459. + * + * 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-view` renders wherever its type is + * registered, 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 default lives in + * `VIEW_DEFAULT_TRANSLATIONS` (`ObjectView.tsx`) — that map is what + * `createSafeTranslation` falls back to when its + * `detail.recordDetailWithLabel` probe comes back unresolved, and it is what + * interpolates `{{label}}`. + * + * 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 + * `ObjectView.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, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import type { DataSource } from '@object-ui/types'; +import { ObjectView } from '../ObjectView'; + +const rows = [{ id: '1', name: 'Alice' }]; + +function dataSourceLabelled(label?: string): DataSource { + return { + find: vi.fn().mockResolvedValue({ data: rows, total: rows.length }), + findOne: vi.fn().mockResolvedValue(rows[0]), + create: vi.fn().mockResolvedValue({}), + update: vi.fn().mockResolvedValue({}), + delete: vi.fn().mockResolvedValue({}), + getObjectSchema: vi.fn().mockResolvedValue({ + name: 'contacts', + ...(label ? { label } : {}), + fields: { name: { label: 'Name', type: 'text' } }, + }), + } as unknown as DataSource; +} + +function renderView(label?: string) { + return render( + , + ); +} + +async function openSplitPanel() { + const cell = await screen.findByText('Alice'); + fireEvent.click(cell); +} + +afterEach(() => cleanup()); + +describe('ObjectView split heading — English fallback with no provider (objectui#3459)', () => { + it('interpolates the object label in English, never the raw key', async () => { + renderView('Contacts'); + await openSplitPanel(); + + await waitFor(() => expect(screen.getByText('Contacts Detail')).toBeInTheDocument()); + expect(screen.queryByText('detail.recordDetailWithLabel')).toBeNull(); + }); + + it('falls back to the objectName when the object declares no label', async () => { + renderView(); + await openSplitPanel(); + + await waitFor(() => expect(screen.getByText('contacts Detail')).toBeInTheDocument()); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ae1e4bb5e..da0ad9311b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2427,6 +2427,9 @@ importers: '@object-ui/core': specifier: workspace:* version: link:../core + '@object-ui/i18n': + specifier: workspace:* + version: link:../i18n '@object-ui/react': specifier: workspace:* version: link:../react