From e1de22e4a78080185658d878912163d353b5b63e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 07:56:25 +0000 Subject: [PATCH] =?UTF-8?q?fix(i18n):=20key=20ObjectView=20=E7=9A=84=20cre?= =?UTF-8?q?ate/edit/view=20=E8=A1=A8=E5=8D=95=E6=A0=87=E9=A2=98=20(#3462)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ObjectView.getFormTitle()` 用模板字符串自拼三个英文动词 (`Create/Edit/View ${objectLabel}`),zh 会话打开抽屉读到的是 "View 联系人" —— 英文动词贴在本地化标签前。三个消费点全是可见标题: `renderDrawerForm` 的 DrawerTitle、`renderModalForm` 的 DialogTitle, 以及 popover 分支传给 `NavigationOverlay` 的 title prop。 三个动词分支改走 `form.createTitle` / `form.editTitle` / `form.viewTitle`。没有新造 key 家族:前两个十包已有,且 app-shell 的 page 模式记录表单(RecordFormPage / AppContent)用的正是它们 —— 抽屉、 弹窗、浮层只是同一个标题的另一个承载面,复用同一组 key 才不会漂。 只有第三个动词没有兄弟,`form.viewTitle` 按各包既有排布补进十个语言包。 `schema.form?.title` 覆盖分支与 `default` 分支保持原样。英文输出逐字节 不变,无 provider 时由 `VIEW_DEFAULT_TRANSLATIONS` 兜底。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt --- .../object-view-form-title-i18n-3462.md | 66 +++++ packages/i18n/src/locales/ar.ts | 1 + packages/i18n/src/locales/de.ts | 1 + packages/i18n/src/locales/en.ts | 1 + 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-view/src/ObjectView.tsx | 79 +++-- .../ObjectView.formTitleI18n.test.tsx | 274 ++++++++++++++++++ ...tView.formTitleNoProviderFallback.test.tsx | 154 ++++++++++ 14 files changed, 562 insertions(+), 21 deletions(-) create mode 100644 .changeset/object-view-form-title-i18n-3462.md create mode 100644 packages/plugin-view/src/__tests__/ObjectView.formTitleI18n.test.tsx create mode 100644 packages/plugin-view/src/__tests__/ObjectView.formTitleNoProviderFallback.test.tsx diff --git a/.changeset/object-view-form-title-i18n-3462.md b/.changeset/object-view-form-title-i18n-3462.md new file mode 100644 index 000000000..8244c68e4 --- /dev/null +++ b/.changeset/object-view-form-title-i18n-3462.md @@ -0,0 +1,66 @@ +--- +'@object-ui/plugin-view': patch +'@object-ui/i18n': patch +--- + +Localize the create / edit / view form title `ObjectView` builds itself +(objectui#3462) + +The same family as #3426 / PR #3457 and #3459 / PR #3464, one call site further +in. `ObjectView.getFormTitle()` string-built its three verbs in TypeScript: + + case 'create': return `Create ${objectLabel}`; + case 'edit': return `Edit ${objectLabel}`; + case 'view': return `View ${objectLabel}`; + +so a Chinese session whose object is labelled 联系人 read a drawer headed +**"View 联系人"** — an English verb glued onto a localized label. All three +consumers are visible chrome: `renderDrawerForm`'s `DrawerTitle`, +`renderModalForm`'s `DialogTitle`, and the `title` prop handed to +`NavigationOverlay` in the `popover` branch (a host-supplied `title` displaces +the overlay's own `resolvedTitle` default, so it is what the user sees). + +The bar to reach it is lower than #3459's split panel: `ObjectViewSchema.layout` +already defaults to `'drawer'`, and `navigation` is a declared authorable input +on the registered `object-view` block whose `mode` union carries `drawer`, +`modal` and `popover`. A row click under any of them sets `formMode: 'view'` and +opens the container. `app-shell`'s wrapper pinning `layout: 'page'` is one host +overriding a registered block, not proof the branch is dead. + +## What changed + +The three verb branches resolve `form.createTitle` / `form.editTitle` / +`form.viewTitle`. + +**No new key family was minted.** `form.createTitle` (`'Create {{object}}'`) and +`form.editTitle` (`'Edit {{object}}'`) already ship in all ten packs and are +already how `app-shell` heads the PAGE-mode record form +(`RecordFormPage.tsx`, `AppContent.tsx`). The drawer / modal / popover titles are +the same heading on a different surface, so they resolve the same keys — a +parallel per-plugin family would have guaranteed the two spellings drift, which +is what the sibling issues were about. Only the third verb had no sibling: +`form.viewTitle` is added to all ten packs, following each pack's existing +arrangement for its create/edit twins rather than a translated-verb-plus-label +concatenation (de puts the verb last, ja/zh use particles and no space). + +`VIEW_DEFAULT_TRANSLATIONS` in `ObjectView.tsx` gains the three English entries, +which is what `createSafeTranslation` falls back to with no `I18nProvider` +mounted. + +Two branches stay literal on purpose and are pinned by tests: `schema.form.title` +(the author wrote a title, so the author's title wins, in every locale) and the +`default` branch (bare object label, no verb to translate). + +## Visible English change + +None. Every branch is byte-identical in English — `Create Contacts`, +`Edit Contacts`, `View Contacts` — with and without a provider, so e2e specs and +host tests that address this chrome by its English name keep addressing it. The +provider-less path has its own test file, kept separate because +`initReactI18next` registers its instance as a module global that outlives +`cleanup()`. + +The toolbar's create BUTTON keeps resolving `console.objectView.new` +("New" / 新建) and was deliberately not reused for the heading: a button verb and +a title are different contexts, and folding them together is how the next drift +of this shape would start. diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 74de18a44..7c67217cc 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -132,6 +132,7 @@ const ar = { stepOf: "الخطوة {{current}} من {{total}}", createTitle: "إنشاء {{object}}", editTitle: "تعديل {{object}}", + viewTitle: "عرض {{object}}", saveRecord: "حفظ السجل", create: "إنشاء", update: "تحديث", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index ec42345e1..f4964ab7c 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -132,6 +132,7 @@ const de = { stepOf: "Schritt {{current}} von {{total}}", createTitle: "{{object}} erstellen", editTitle: "{{object}} bearbeiten", + viewTitle: "{{object}} anzeigen", saveRecord: "Datensatz speichern", create: "Erstellen", update: "Aktualisieren", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index f980b5bae..7aaebe2f3 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -145,6 +145,7 @@ const en = { stepOf: 'Step {{current}} of {{total}}', createTitle: 'Create {{object}}', editTitle: 'Edit {{object}}', + viewTitle: 'View {{object}}', saveRecord: 'Save', create: 'Create', update: 'Update', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 19cf4ebb5..d313942b3 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -137,6 +137,7 @@ const es = { stepOf: "Paso {{current}} de {{total}}", createTitle: "Crear {{object}}", editTitle: "Editar {{object}}", + viewTitle: "Ver {{object}}", saveRecord: "Guardar registro", create: "Crear", update: "Actualizar", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 3382a58ac..19c2426b0 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -132,6 +132,7 @@ const fr = { stepOf: "Étape {{current}} sur {{total}}", createTitle: "Créer {{object}}", editTitle: "Modifier {{object}}", + viewTitle: "Afficher {{object}}", saveRecord: "Enregistrer", create: "Créer", update: "Mettre à jour", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 52e603607..ffe85e71e 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -132,6 +132,7 @@ const ja = { stepOf: "ステップ {{current}} / {{total}}", createTitle: "{{object}}を作成", editTitle: "{{object}}を編集", + viewTitle: "{{object}}を表示", saveRecord: "レコードを保存", create: "作成", update: "更新", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 4de3e20ae..391444a9a 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -132,6 +132,7 @@ const ko = { stepOf: "{{total}}단계 중 {{current}}단계", createTitle: "{{object}} 생성", editTitle: "{{object}} 편집", + viewTitle: "{{object}} 보기", saveRecord: "레코드 저장", create: "생성", update: "업데이트", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 9cbea81ed..5bfe4e51c 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -132,6 +132,7 @@ const pt = { stepOf: "Etapa {{current}} de {{total}}", createTitle: "Criar {{object}}", editTitle: "Editar {{object}}", + viewTitle: "Ver {{object}}", saveRecord: "Salvar registro", create: "Criar", update: "Atualizar", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index b531ad235..be5555069 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -132,6 +132,7 @@ const ru = { stepOf: "Шаг {{current}} из {{total}}", createTitle: "Создать {{object}}", editTitle: "Редактировать {{object}}", + viewTitle: "Просмотреть {{object}}", saveRecord: "Сохранить запись", create: "Создать", update: "Обновить", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index ab1636fd4..0a1055b64 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -137,6 +137,7 @@ const zh = { stepOf: '第{{current}}步,共{{total}}步', createTitle: '新建{{object}}', editTitle: '编辑{{object}}', + viewTitle: '查看{{object}}', saveRecord: '保存', create: '创建', update: '更新', diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index f4632ba7a..fd2bd5a46 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -85,23 +85,47 @@ function useCreateVerb(): string { } /** - * English fallback for the split-mode record-detail heading (objectui#3459, - * following #3426's shape). + * English fallbacks for the headings this view resolves through `t()` + * (objectui#3459 for the split-mode record-detail heading, objectui#3462 for + * the create/edit/view form titles — both 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}}`. + * Every entry must exist HERE as well as in the locale packs: a provider-less + * host never reaches the packs, and `createSafeTranslation`'s fallback is what + * interpolates the placeholder. * - * 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. + * ── Why these keys, and not new ones ────────────────────────────────────── + * `detail.recordDetailWithLabel` is borrowed from the `detail.*` namespace + * rather than minted as `view.recordDetail`: `NavigationOverlay` — the very + * component that 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. + * + * `form.createTitle` / `form.editTitle` are reused for the same reason, and are + * not new: all ten packs already carry them, and `app-shell` already heads the + * PAGE-mode record form with exactly these (`RecordFormPage.tsx`, + * `AppContent.tsx`). The drawer / modal / popover titles below are the same + * heading on a different surface, so they resolve the same keys — minting a + * parallel `console.objectView.*` family would have guaranteed the two spellings + * drift (zh already distinguishes 新建 from 创建). Only the third verb, + * `form.viewTitle`, had no sibling; it was added to all ten packs. + * + * Note the placeholder is `{{object}}`, not `{{label}}` — that is the variable + * the existing `form.*Title` family declares, and the pack-vs-en placeholder + * parity guard compares placeholder sets per key. + * + * `detail.recordDetailWithLabel` 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', + // Byte-for-byte the strings `getFormTitle` used to build with a template + // literal, so an English session and every e2e spec that addresses this + // chrome by name see no change at all. + 'form.createTitle': 'Create {{object}}', + 'form.editTitle': 'Edit {{object}}', + 'form.viewTitle': 'View {{object}}', }; const useObjectViewTranslation = createSafeTranslation( @@ -271,10 +295,11 @@ 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(); + // Headings this view owns: the split-mode record-detail panel (see the split + // branch far below) and the create/edit/view form titles (`getFormTitle`). + // Declared with the other top-level hooks so it stays above every conditional + // return — rules-of-hooks. + const { t: tView } = 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 @@ -872,14 +897,26 @@ export const ObjectView: React.FC = ({ }; }; - // Get form title based on mode + // Get form title based on mode. + // + // objectui#3462: the three verbs used to be string-built (`` `View ${label}` ``), + // so a zh session reading a drawer opened by a row click was headed + // "View 联系人" — an English verb glued onto a localized label. They resolve + // `form.{create,edit,view}Title` now, which is the SAME key family `app-shell` + // already uses for the page-mode record form, so the four surfaces cannot + // drift. German compounds and ja/zh particle order all sit inside the + // template, which is why this is a key and not a verb lookup + concatenation. + // + // Two branches stay literal on purpose: + // - `schema.form?.title` — the author wrote a title, so use the author's. + // - `default` — returns the object label alone, no verb to translate. const getFormTitle = (): string => { if (schema.form?.title) return schema.form.title; const objectLabel = (objectSchema?.label as string) || schema.objectName; switch (formMode) { - case 'create': return `Create ${objectLabel}`; - case 'edit': return `Edit ${objectLabel}`; - case 'view': return `View ${objectLabel}`; + case 'create': return tView('form.createTitle', { object: objectLabel }); + case 'edit': return tView('form.editTitle', { object: objectLabel }); + case 'view': return tView('form.viewTitle', { object: objectLabel }); default: return objectLabel; } }; @@ -1188,7 +1225,7 @@ export const ObjectView: React.FC = ({ 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 })} + title={tView('detail.recordDetailWithLabel', { label: objectLabel })} mainContent={
{renderContent()}
} > {renderOverlayDetail} diff --git a/packages/plugin-view/src/__tests__/ObjectView.formTitleI18n.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.formTitleI18n.test.tsx new file mode 100644 index 000000000..38cf74954 --- /dev/null +++ b/packages/plugin-view/src/__tests__/ObjectView.formTitleI18n.test.tsx @@ -0,0 +1,274 @@ +/** + * 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 create / edit / view form title speaks the session locale — + * objectui#3462 (the #3426 / #3459 family). + * + * ── What was wrong ──────────────────────────────────────────────────────── + * `getFormTitle()` string-built the three verbs: + * + * case 'view': return `View ${objectLabel}`; + * + * so a Chinese session whose object is labelled 联系人 read a drawer headed + * **"View 联系人"** — an English verb glued onto a localized label. The filer + * reproduced exactly that. All three consumers of `getFormTitle()` are visible + * chrome: `renderDrawerForm`'s `DrawerTitle`, `renderModalForm`'s + * `DialogTitle`, and the `title` prop handed to `NavigationOverlay` in the + * `popover` branch (a host-supplied `title` displaces the overlay's own + * `resolvedTitle` default, so this is the string the user sees). + * + * ── Why this path is user-reachable ─────────────────────────────────────── + * `object-view` / `view` are registered renderer types + * (`packages/plugin-view/src/index.tsx`) and `navigation` is a DECLARED + * authorable input on that registration, typed as `ViewNavigationConfig` whose + * `mode` union includes `'drawer'`, `'modal'` and `'popover'`. `handleRowClick` + * turns a click under any of those into `setFormMode('view')` + + * `setIsFormOpen(true)`, and `formLayout` maps the same mode to the container + * that renders the heading. The toolbar's create button (`handleCreate`) and the + * grid's row-edit action (`handleEdit`) reach the other two modes. The bar is + * even lower than #3459's split branch: `ObjectViewSchema.layout` already + * defaults to `'drawer'`. `app-shell`'s wrapper pinning `layout: 'page'` is one + * host overriding a registered block, not proof the branch is dead. + * + * ── Direction of these assertions (decided before running them) ─────────── + * - zh / de / ja cases, all three verbs: **RED before, GREEN after.** Before + * the change they rendered "Create 联系人" / "Create Kontakte" etc. + * - **de is the load-bearing case**: German puts the verb AFTER the object + * ("Kontakte anzeigen"), which a `` `Verb ${label}` `` concatenation cannot + * produce at any value of the verb. It is the assertion that a future + * "just translate the verb and concatenate" regression cannot satisfy. + * - en cases: **GREEN before AND after.** They pin that routing the title + * through `t()` did not move a single byte of what an English session sees, + * so e2e specs and host tests addressing this chrome by its English name + * keep addressing it. + * - the `schema.form.title` override: **GREEN before AND after.** The author + * wrote a title, so the author's title is used — that branch is deliberately + * untouched and this pins it. + * + * The provider-less fallback is asserted in + * `ObjectView.formTitleNoProviderFallback.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, ObjectViewSchema } from '@object-ui/types'; +import { ObjectView } from '../ObjectView'; + +// Mock @object-ui/react to avoid circular dependency issues (same shape as +// ObjectView.test.tsx — the bus exports are imported at module-eval time by the +// real @object-ui/components, so a strict mock must expose them). +vi.mock('@object-ui/react', async () => { + const React = await import('react'); + return { + SchemaRenderer: ({ schema }: any) => ( +
+ {schema?.type} +
+ ), + SchemaRendererContext: React.createContext(null), + subscribeDataChanges: () => () => {}, + notifyDataChanged: () => {}, + }; +}); + +// Grid stub exposing the two row affordances this test drives: a row click +// (-> formMode 'view') and the row edit action (-> formMode 'edit'). +vi.mock('@object-ui/plugin-grid', () => ({ + ObjectGrid: ({ schema, onRowClick, onEdit }: any) => ( +
+ + +
+ ), +})); + +vi.mock('@object-ui/plugin-form', () => ({ + ObjectForm: ({ schema }: any) => ( +
+ Form ({schema?.mode}) +
+ ), +})); + +function dataSourceLabelled(label: string): DataSource { + return { + find: vi.fn().mockResolvedValue({ data: [{ id: '1', name: 'Test' }], total: 1 }), + findOne: vi.fn().mockResolvedValue({ id: '1', name: 'Test' }), + 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, + schemaOverrides: Partial = {}, +) { + return render( + + + , + ); +} + +/** Row click under drawer/modal/popover navigation -> formMode 'view'. */ +const openView = () => fireEvent.click(screen.getByTestId('grid-row')); +/** Row edit action -> formMode 'edit'. */ +const openEdit = () => fireEvent.click(screen.getByTestId('grid-edit')); +/** + * Toolbar create button -> formMode 'create'. Addressed by its own localized + * verb (`console.objectView.new`), which is deliberately NOT the title key: + * the button says "New" / 新建 while the title says "Create" / 新建... — reusing + * the button verb for the heading is the drift this issue's sibling warned + * about, and naming it here keeps the two visibly separate. + */ +const openCreate = (createVerb: string) => + fireEvent.click(screen.getByRole('button', { name: createVerb })); + +/** The object label the fixtures use, per locale. */ +const LABEL = { en: 'Contacts', zh: '联系人', de: 'Kontakte', ja: '取引先' } as const; + +afterEach(() => cleanup()); + +describe('ObjectView form title — view mode, drawer (objectui#3462)', () => { + it('renders the English title unchanged under an en session', async () => { + renderViewIn('en', LABEL.en); + openView(); + + await waitFor(() => expect(screen.getByText('View Contacts')).toBeInTheDocument()); + }); + + it('renders the zh bundle arrangement under a zh session', async () => { + renderViewIn('zh', LABEL.zh); + openView(); + + // The filer's exact repro: a zh drawer used to be headed "View 联系人". + await waitFor(() => expect(screen.getByText('查看联系人')).toBeInTheDocument()); + expect(screen.queryByText('View 联系人')).toBeNull(); + }); + + it('renders the de bundle arrangement — verb AFTER the label', async () => { + renderViewIn('de', LABEL.de); + openView(); + + await waitFor(() => expect(screen.getByText('Kontakte anzeigen')).toBeInTheDocument()); + expect(screen.queryByText('View Kontakte')).toBeNull(); + }); + + it('renders the ja bundle arrangement under a ja session', async () => { + renderViewIn('ja', LABEL.ja); + openView(); + + await waitFor(() => expect(screen.getByText('取引先を表示')).toBeInTheDocument()); + }); +}); + +describe('ObjectView form title — edit mode, drawer (objectui#3462)', () => { + it('renders the English title unchanged under an en session', async () => { + renderViewIn('en', LABEL.en); + openEdit(); + + await waitFor(() => expect(screen.getByText('Edit Contacts')).toBeInTheDocument()); + }); + + it('renders the zh bundle arrangement under a zh session', async () => { + renderViewIn('zh', LABEL.zh); + openEdit(); + + await waitFor(() => expect(screen.getByText('编辑联系人')).toBeInTheDocument()); + expect(screen.queryByText('Edit 联系人')).toBeNull(); + }); + + it('renders the de bundle arrangement — verb AFTER the label', async () => { + renderViewIn('de', LABEL.de); + openEdit(); + + await waitFor(() => expect(screen.getByText('Kontakte bearbeiten')).toBeInTheDocument()); + expect(screen.queryByText('Edit Kontakte')).toBeNull(); + }); +}); + +describe('ObjectView form title — create mode, drawer (objectui#3462)', () => { + it('renders the English title unchanged under an en session', async () => { + renderViewIn('en', LABEL.en); + openCreate('New'); + + await waitFor(() => expect(screen.getByText('Create Contacts')).toBeInTheDocument()); + }); + + it('renders the zh bundle arrangement under a zh session', async () => { + renderViewIn('zh', LABEL.zh); + openCreate('新建'); + + await waitFor(() => expect(screen.getByText('新建联系人')).toBeInTheDocument()); + expect(screen.queryByText('Create 联系人')).toBeNull(); + }); + + it('renders the de bundle arrangement — verb AFTER the label', async () => { + renderViewIn('de', LABEL.de); + openCreate('Neu'); + + await waitFor(() => expect(screen.getByText('Kontakte erstellen')).toBeInTheDocument()); + expect(screen.queryByText('Create Kontakte')).toBeNull(); + }); +}); + +describe('ObjectView form title — the other two consumption sites (objectui#3462)', () => { + it('localizes the modal DialogTitle', async () => { + renderViewIn('zh', LABEL.zh, { navigation: { mode: 'modal' } } as never); + openView(); + + await waitFor(() => expect(screen.getByText('查看联系人')).toBeInTheDocument()); + expect(screen.queryByText('View 联系人')).toBeNull(); + }); + + it('localizes the title handed to NavigationOverlay in popover mode', async () => { + renderViewIn('zh', LABEL.zh, { navigation: { mode: 'popover' } } as never); + openView(); + + await waitFor(() => expect(screen.getByText('查看联系人')).toBeInTheDocument()); + expect(screen.queryByText('View 联系人')).toBeNull(); + }); +}); + +describe('ObjectView form title — branches that stay literal (objectui#3462)', () => { + it('uses the authored schema.form.title verbatim, untranslated', async () => { + renderViewIn('zh', LABEL.zh, { form: { title: 'My Own Heading' } } as never); + openView(); + + // The author wrote a title, so the author's title wins — in every locale. + await waitFor(() => expect(screen.getByText('My Own Heading')).toBeInTheDocument()); + expect(screen.queryByText('查看联系人')).toBeNull(); + }); +}); diff --git a/packages/plugin-view/src/__tests__/ObjectView.formTitleNoProviderFallback.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.formTitleNoProviderFallback.test.tsx new file mode 100644 index 000000000..e13b1720f --- /dev/null +++ b/packages/plugin-view/src/__tests__/ObjectView.formTitleNoProviderFallback.test.tsx @@ -0,0 +1,154 @@ +/** + * 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 create / edit / view form title still resolves to ENGLISH, and + * to the SAME BYTES as before, when no `I18nProvider` is mounted — + * objectui#3462. + * + * 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 defaults live 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 `{{object}}`. + * + * The byte-identity matters beyond aesthetics: the heading is `View Contacts` + * before the change and `View Contacts` 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.formTitleI18n.test.tsx`. A missing map entry would turn it red by + * rendering the raw key `form.viewTitle`, 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'; + +vi.mock('@object-ui/react', async () => { + const React = await import('react'); + return { + SchemaRenderer: ({ schema }: any) => ( +
+ {schema?.type} +
+ ), + SchemaRendererContext: React.createContext(null), + subscribeDataChanges: () => () => {}, + notifyDataChanged: () => {}, + }; +}); + +vi.mock('@object-ui/plugin-grid', () => ({ + ObjectGrid: ({ schema, onRowClick, onEdit }: any) => ( +
+ + +
+ ), +})); + +vi.mock('@object-ui/plugin-form', () => ({ + ObjectForm: ({ schema }: any) => ( +
+ Form ({schema?.mode}) +
+ ), +})); + +function dataSourceLabelled(label?: string): DataSource { + return { + find: vi.fn().mockResolvedValue({ data: [{ id: '1', name: 'Test' }], total: 1 }), + findOne: vi.fn().mockResolvedValue({ id: '1', name: 'Test' }), + 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( + , + ); +} + +const openView = () => fireEvent.click(screen.getByTestId('grid-row')); +const openEdit = () => fireEvent.click(screen.getByTestId('grid-edit')); +const openCreate = () => fireEvent.click(screen.getByRole('button', { name: 'New' })); + +afterEach(() => cleanup()); + +describe('ObjectView form title — English fallback with no provider (objectui#3462)', () => { + it('heads a view-mode drawer "View {label}", never the raw key', async () => { + renderView('Contacts'); + openView(); + + await waitFor(() => expect(screen.getByText('View Contacts')).toBeInTheDocument()); + expect(screen.queryByText('form.viewTitle')).toBeNull(); + }); + + it('heads an edit-mode drawer "Edit {label}", never the raw key', async () => { + renderView('Contacts'); + openEdit(); + + await waitFor(() => expect(screen.getByText('Edit Contacts')).toBeInTheDocument()); + expect(screen.queryByText('form.editTitle')).toBeNull(); + }); + + it('heads a create-mode drawer "Create {label}", never the raw key', async () => { + renderView('Contacts'); + openCreate(); + + await waitFor(() => expect(screen.getByText('Create Contacts')).toBeInTheDocument()); + expect(screen.queryByText('form.createTitle')).toBeNull(); + }); + + it('falls back to the objectName when the object declares no label', async () => { + renderView(); + openView(); + + await waitFor(() => expect(screen.getByText('View contacts')).toBeInTheDocument()); + }); +});