From 33868feff6b4d33435da4c565a520bddb12f9b07 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 02:10:38 +0000 Subject: [PATCH] fix(i18n): six bare pack keys get their consumers' defaults rows (#4396) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `detail.saving`, `list.resetSortToDefault`, `appDesigner.widgetProperties`, `appDesigner.addWidget`, `appDesigner.modeEdit` and `common.delete` were read through `createSafeTranslation` with neither a row in their hook's defaults table nor an inline `defaultValue` at the call site — the only two fallbacks that path has — so a provider-less host rendered the raw key string. Each key now has a row in its consumer hook's defaults table, byte-identical to the `en` pack value. No pack edits, no new keys, no call-site changes. `common.delete` was the one key PR #4372's census left unattributed; both of its call sites (`FieldDesigner`, `ObjectManager`) resolve through `useDesignerTranslation`, so the row lands in plugin-designer. --- .changeset/six-bare-keys-defaults-4396.md | 11 ++ .../bareKeysNoProviderFallback.test.tsx | 136 +++++++++++++++++ .../src/hooks/useDesignerTranslation.ts | 13 ++ ...tSaveBar.savingNoProviderFallback.test.tsx | 90 +++++++++++ .../plugin-detail/src/useDetailTranslation.ts | 4 + packages/plugin-list/src/ListView.tsx | 4 + ...etSortToDefaultNoProviderFallback.test.tsx | 140 ++++++++++++++++++ 7 files changed, 398 insertions(+) create mode 100644 .changeset/six-bare-keys-defaults-4396.md create mode 100644 packages/plugin-designer/src/__tests__/bareKeysNoProviderFallback.test.tsx create mode 100644 packages/plugin-detail/src/__tests__/InlineEditSaveBar.savingNoProviderFallback.test.tsx create mode 100644 packages/plugin-list/src/__tests__/ListView.resetSortToDefaultNoProviderFallback.test.tsx diff --git a/.changeset/six-bare-keys-defaults-4396.md b/.changeset/six-bare-keys-defaults-4396.md new file mode 100644 index 0000000000..f5f4b855bd --- /dev/null +++ b/.changeset/six-bare-keys-defaults-4396.md @@ -0,0 +1,11 @@ +--- +'@object-ui/plugin-detail': patch +'@object-ui/plugin-list': patch +'@object-ui/plugin-designer': patch +--- + +Six i18n keys no longer render as raw key strings on hosts with no `I18nProvider` (objectui#4396) + +`detail.saving`, `list.resetSortToDefault`, `appDesigner.widgetProperties`, `appDesigner.addWidget`, `appDesigner.modeEdit` and `common.delete` were read through `createSafeTranslation` without a row in their hook's defaults table and without an inline `defaultValue` at the call site — the only two fallbacks that path has. On a provider-less host (standalone embedding, the preview gallery, host apps that never mount a provider) `fallbackT` therefore returned the key itself, so users saw `detail.saving` in the inline-edit save button, `list.resetSortToDefault` on the sort popover's reset control, `appDesigner.widgetProperties` as the dashboard inspector heading, `appDesigner.addWidget` as its toolbar label, `appDesigner.modeEdit` as a button's accessible name, and `common.delete` on the designer's destructive confirm. + +Each key now has a row in its consumer hook's defaults table, byte-identical to the `en` pack value. No pack was edited, no key added, no call site changed. diff --git a/packages/plugin-designer/src/__tests__/bareKeysNoProviderFallback.test.tsx b/packages/plugin-designer/src/__tests__/bareKeysNoProviderFallback.test.tsx new file mode 100644 index 0000000000..3c385009ca --- /dev/null +++ b/packages/plugin-designer/src/__tests__/bareKeysNoProviderFallback.test.tsx @@ -0,0 +1,136 @@ +/** + * 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. + */ + +/** + * The four `useDesignerTranslation` keys objectui#4396 backfills resolve to + * ENGLISH when no `I18nProvider` is mounted. + * + * All four — `appDesigner.widgetProperties`, `appDesigner.addWidget`, + * `appDesigner.modeEdit`, `common.delete` — are among the six keys PR #4372's + * census measured as reading **outside** their hook's defaults table with **no + * inline `defaultValue`** at the call site. #4372 taught + * `createSafeTranslation`'s fallback to honour an inline default, which fixed + * the other 20 outside-table keys; these carry neither, so until this card they + * fell through to `fallbackT`'s last resort — the raw key. A provider-less + * designer showed `appDesigner.widgetProperties` as an inspector heading, + * `appDesigner.addWidget` as a toolbar label, `appDesigner.modeEdit` as a + * button's accessible name, and `common.delete` on a destructive confirm. + * + * `common.delete` is the key the census left unattributed ("outside table + * without default", no consumer named). Both of its call sites — + * `FieldDesigner.tsx` and `ObjectManager.tsx` — resolve through + * `useDesignerTranslation`, so the row belongs to THIS package's table, not to + * `packages/components`. `ObjectManager` is the cheaper of the two to mount, so + * it carries the pin. + * + * The call sites are bare on purpose and stay bare; the fix is four rows in + * `DESIGNER_DEFAULT_TRANSLATIONS`, each byte-identical to its `en` pack value. + * + * Direction: this file was RED before the change (four raw keys) and is GREEN + * after. + * + * ── 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 one `` mount + * anywhere in a file silently resolves every later "no provider" render in that + * same file against it — 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, fireEvent, waitFor, cleanup, within } from '@testing-library/react'; +import type { DashboardComponentSchema, ObjectDefinition } from '@object-ui/types'; +import { DashboardEditor } from '../DashboardEditor'; +import { ObjectManager } from '../ObjectManager'; + +vi.mock('@object-ui/plugin-grid', () => import('./__mocks__/plugin-grid')); +vi.mock('@object-ui/plugin-form', () => import('./__mocks__/plugin-form')); + +const DASHBOARD = { + type: 'dashboard', + name: 'sales', + title: 'Sales dashboard', + widgets: [{ id: 'w1', type: 'metric', title: 'Revenue' }], +} as DashboardComponentSchema; + +const OBJECTS: ObjectDefinition[] = [ + { + id: 'obj-1', + name: 'accounts', + label: 'Accounts', + group: 'Custom Objects', + isSystem: false, + fieldCount: 12, + }, +]; + +afterEach(() => cleanup()); + +describe('DashboardEditor — English fallback with no provider (objectui#4396)', () => { + it('labels the add-widget toolbar in English, never the raw key', () => { + render( {}} />); + + expect(screen.getByText('Add Widget:')).toBeTruthy(); + expect(screen.queryByText(/appDesigner\.addWidget/)).toBeNull(); + }); + + it('heads the widget property panel in English, never the raw key', () => { + render( {}} />); + fireEvent.click(screen.getByTestId('dashboard-widget-w1')); + + // Non-vacuity: the panel really did mount. + expect(screen.getByTestId('widget-property-panel')).toBeTruthy(); + expect(screen.getByText('Widget Properties')).toBeTruthy(); + expect(screen.queryByText(/appDesigner\.widgetProperties/)).toBeNull(); + }); + + it('names the preview toggle’s edit state in English, never the raw key', () => { + render( {}} />); + const toggle = screen.getByTestId('dashboard-preview-toggle'); + + // In edit mode the toggle offers "Preview" (already in the table); the + // `modeEdit` half only appears once preview mode is ON, so the click is + // what puts this key on screen at all. + expect(toggle.getAttribute('aria-label')).toBe('Preview'); + fireEvent.click(toggle); + expect(toggle.getAttribute('aria-label')).toBe('Edit'); + }); + + it('leaves no `appDesigner.` raw key anywhere in the editor', () => { + render( {}} />); + fireEvent.click(screen.getByTestId('dashboard-widget-w1')); + + expect(document.body.textContent).not.toMatch(/appDesigner\.[a-zA-Z]/); + }); +}); + +describe('ObjectManager delete confirm — English fallback with no provider (objectui#4396)', () => { + it('labels the destructive confirm in English, never the raw key', async () => { + render(); + await waitFor(() => expect(screen.getByTestId('grid-delete-obj-1')).toBeDefined()); + fireEvent.click(screen.getByTestId('grid-delete-obj-1')); + + // Scoped to the dialog: the stub grid renders its own "Delete" control, so + // an unscoped `getByText('Delete')` matches two nodes and throws. + const dialog = await waitFor(() => { + const el = document.querySelector('dialog'); + expect(el).not.toBeNull(); + return within(el as HTMLElement); + }); + + // `Cancel` is already in the table and pins that the dialog is mounted, so + // the assertions below are about the key rather than about an empty DOM. + expect(dialog.getByText('Cancel')).toBeTruthy(); + expect(dialog.getByText('Delete')).toBeTruthy(); + expect(dialog.queryByText('common.delete')).toBeNull(); + }); +}); diff --git a/packages/plugin-designer/src/hooks/useDesignerTranslation.ts b/packages/plugin-designer/src/hooks/useDesignerTranslation.ts index 69fe07b6c7..79a7f8c0e0 100644 --- a/packages/plugin-designer/src/hooks/useDesignerTranslation.ts +++ b/packages/plugin-designer/src/hooks/useDesignerTranslation.ts @@ -107,6 +107,13 @@ const DESIGNER_DEFAULT_TRANSLATIONS: Record = { 'appDesigner.widgetHeight': 'Height', 'appDesigner.dashboardPreview': 'Dashboard Preview', 'appDesigner.noWidgetsPreview': 'No widgets to preview', + // objectui#4396 — DashboardEditor's inspector heading, its add-widget picker + // label, and the edit half of its preview/edit toggle. All three are read + // bare (no inline `defaultValue`), so before these rows a provider-less host + // rendered the raw keys into a heading, a label and an `aria-label`. + 'appDesigner.widgetProperties': 'Widget Properties', + 'appDesigner.addWidget': 'Add Widget', + 'appDesigner.modeEdit': 'Edit', // Page Canvas Editor 'appDesigner.pageCanvasEditor': 'Page Canvas Editor', 'appDesigner.emptyPage': 'Empty page. Click a button above to add a component.', @@ -192,6 +199,12 @@ const DESIGNER_DEFAULT_TRANSLATIONS: Record = { 'appDesigner.fieldDesigner.typeCategory.advanced': 'Advanced', // Common 'common.edit': 'Edit', + // objectui#4396 — the confirm label on FieldDesigner's and ObjectManager's + // delete dialogs (`confirmLabel={t('common.delete')}`, read bare). The census + // in PR #4372 recorded this key as "outside table, no default" without naming + // its consumer; both call sites resolve through THIS hook, so the row belongs + // here beside the other borrowed `common.*` entries — not in `packages/components`. + 'common.delete': 'Delete', }; /** diff --git a/packages/plugin-detail/src/__tests__/InlineEditSaveBar.savingNoProviderFallback.test.tsx b/packages/plugin-detail/src/__tests__/InlineEditSaveBar.savingNoProviderFallback.test.tsx new file mode 100644 index 0000000000..a43e51a956 --- /dev/null +++ b/packages/plugin-detail/src/__tests__/InlineEditSaveBar.savingNoProviderFallback.test.tsx @@ -0,0 +1,90 @@ +/** + * 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. + */ + +/** + * `InlineEditSaveBar`'s in-flight label resolves to ENGLISH when no + * `I18nProvider` is mounted — objectui#4396. + * + * `detail.saving` is one of the six keys PR #4372's census measured as reading + * **outside** its hook's defaults table with **no inline `defaultValue`** at + * the call site. #4372 taught `createSafeTranslation`'s fallback to honour an + * inline default, which fixed the other 20 outside-table keys; these six carry + * neither, so until this card they fell through to `fallbackT`'s last resort — + * the raw key. The button announced `detail.saving` to the user mid-save. + * + * The call site is bare on purpose and stays bare (`t('detail.saving')`, + * `InlineEditSaveBar.tsx`); the fix is the row in + * `DETAIL_DEFAULT_TRANSLATIONS`, byte-identical to `en.detail.saving`. + * + * Direction: this file was RED before the change (it rendered the raw key) and + * is GREEN after. The negative assertion is the load-bearing half — asserting + * only `'Saving…'` would also pass if the bar rendered both. + * + * ── 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 one `` mount + * anywhere in a file silently resolves every later "no provider" render in that + * same file against it — 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, fireEvent, cleanup } from '@testing-library/react'; +import { InlineEditProvider, useInlineEdit } from '@object-ui/react'; +import { InlineEditSaveBar } from '../InlineEditSaveBar'; + +/** + * Drives the shared inline-edit context into the one state that renders the + * label: `editing` (the bar returns `null` otherwise) with `saving` true. + */ +function Harness() { + const inline = useInlineEdit()!; + return ( + <> + + + + ); +} + +function renderSavingBar() { + render( + + + + , + ); + fireEvent.click(screen.getByText('edit-enter')); + fireEvent.click(screen.getByText('edit-saving')); +} + +afterEach(() => cleanup()); + +describe('InlineEditSaveBar saving label — English fallback with no provider (objectui#4396)', () => { + it('renders the en value, never the raw key', () => { + renderSavingBar(); + + expect(screen.getByText('Saving…')).toBeTruthy(); + expect(screen.queryByText('detail.saving')).toBeNull(); + }); + + it('leaves no `detail.` raw key anywhere in the bar', () => { + // Non-vacuity for the case above: a bar that rendered nothing at all would + // satisfy `queryByText(...) === null` for the empty reason. This asserts + // the bar IS mounted (its Cancel control resolves) while carrying no raw + // key of any kind. + renderSavingBar(); + + expect(screen.getByText('Cancel')).toBeTruthy(); + expect(document.body.textContent).not.toMatch(/detail\.[a-zA-Z]/); + }); +}); diff --git a/packages/plugin-detail/src/useDetailTranslation.ts b/packages/plugin-detail/src/useDetailTranslation.ts index f24831c922..39c47ca530 100644 --- a/packages/plugin-detail/src/useDetailTranslation.ts +++ b/packages/plugin-detail/src/useDetailTranslation.ts @@ -39,6 +39,10 @@ export const DETAIL_DEFAULT_TRANSLATIONS: Record = { 'detail.editInline': 'Edit', 'detail.save': 'Save', 'detail.saveChanges': 'Save changes', + // objectui#4396 — InlineEditSaveBar's in-flight label. Read bare + // (`t('detail.saving')`, no inline `defaultValue`), so before this row a + // provider-less host rendered the raw key `detail.saving` into the button. + 'detail.saving': 'Saving…', 'detail.editFieldsInline': 'Edit fields inline', 'detail.editInlineHint': 'Double-click to edit', 'detail.cancel': 'Cancel', diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index d78809600a..c82a631483 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -484,6 +484,10 @@ const LIST_DEFAULT_TRANSLATIONS: Record = { // `t(key, { defaultValue })` options, never a `createSafeTranslation` table. 'list.sortRelationalHint': 'Columns that link to another record are not listed: they can only be sorted by the stored ID, not by the name shown in the cell. To sort by that name, denormalize it onto this object as a stored field, written when the source changes, and sort by that. Not a formula field: it is virtual, so no column is stored for it and the server refuses to sort by one.', + // objectui#4396 — the sort popover's reset action. Read bare + // (`t('list.resetSortToDefault')`, no inline `defaultValue`), so before this + // row a provider-less host rendered the raw key as the menu item's label. + 'list.resetSortToDefault': 'Reset to view default', 'list.group': 'Group', 'list.groupBy': 'Group By', 'list.export': 'Export', diff --git a/packages/plugin-list/src/__tests__/ListView.resetSortToDefaultNoProviderFallback.test.tsx b/packages/plugin-list/src/__tests__/ListView.resetSortToDefaultNoProviderFallback.test.tsx new file mode 100644 index 0000000000..9bba8f3d51 --- /dev/null +++ b/packages/plugin-list/src/__tests__/ListView.resetSortToDefaultNoProviderFallback.test.tsx @@ -0,0 +1,140 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `ListView`'s reset-to-declared-sort control resolves to ENGLISH when no + * `I18nProvider` is mounted — objectui#4396. + * + * `list.resetSortToDefault` is one of the six keys PR #4372's census measured + * as reading **outside** its hook's defaults table with **no inline + * `defaultValue`** at the call site. #4372 taught `createSafeTranslation`'s + * fallback to honour an inline default, which fixed the other 20 outside-table + * keys; these six carry neither, so until this card they fell through to + * `fallbackT`'s last resort — the raw key. The control in the sort popover was + * labelled `list.resetSortToDefault`. + * + * The call site is bare on purpose and stays bare + * (`t('list.resetSortToDefault')`, `ListView.tsx`); the fix is the row in + * `LIST_DEFAULT_TRANSLATIONS`, byte-identical to `en.list.resetSortToDefault`. + * That table is the same one objectui#4294 pinned byte-for-byte one row above, + * for the same reason: on a provider-less host it is this copy that renders. + * + * Direction: this file was RED before the change (the button's accessible name + * was the raw key) and is GREEN after. + * + * ── 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 one `` mount + * anywhere in a file silently resolves every later "no provider" render in that + * same file against it — a green-looking file that asserts nothing about the + * fallback. Vitest's `dom` project runs with `isolate: true`, so a file that + * never mounts a provider gets a genuinely clean global. Keep it that way: + * **do not import or mount `I18nProvider` here.** + */ + +import React from 'react'; +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { ComponentRegistry } from '@object-ui/core'; +import { SchemaRendererProvider } from '@object-ui/react'; +import { ListView } from '../ListView'; +import type { ListViewSchema } from '@object-ui/types'; + +const objectDef = { + name: 'projects', + label: 'Projects', + fields: { + name: { type: 'text', label: 'Name' }, + plan_start_date: { type: 'date', label: 'Plan Start Date' }, + status: { type: 'select', label: 'Status' }, + }, +}; + +// Rows matter: with an empty result set ListView renders `DataEmptyState` +// instead of the grid, and the toolbar the sort popover hangs off never mounts. +const ROWS = [ + { id: 'p-1', name: 'Alpha', plan_start_date: '2026-01-01', status: 'open' }, + { id: 'p-2', name: 'Beta', plan_start_date: '2026-02-01', status: 'done' }, +]; + +/** + * The control renders only when the view DECLARES a sort — with nothing + * declared there is no default to return to, so the fixture must declare one. + */ +const DECLARED_SORT = [ + { field: 'plan_start_date', order: 'asc' }, + { field: 'name', order: 'asc' }, +]; + +let prevObjectGrid: ReturnType; + +beforeAll(() => { + // plugin-grid is not a dependency of plugin-list (that would be a cycle), so + // the grid is stubbed — the same device the neighbouring sort tests use. + prevObjectGrid = ComponentRegistry.get('object-grid'); + ComponentRegistry.register('object-grid', () =>
); +}); + +afterAll(() => { + if (prevObjectGrid) ComponentRegistry.register('object-grid', prevObjectGrid); + else ComponentRegistry.unregister('object-grid'); +}); + +afterEach(() => cleanup()); + +async function openSortPanel() { + const dataSource = { + find: vi.fn().mockResolvedValue({ data: ROWS, total: ROWS.length }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue(objectDef), + }; + render( + + + , + ); + await waitFor(() => expect(dataSource.getObjectSchema).toHaveBeenCalled()); + fireEvent.click(screen.getByRole('button', { name: /^sort/i })); + await screen.findByText('Sort Records'); +} + +describe('ListView reset-sort control — English fallback with no provider (objectui#4396)', () => { + it('names the reset control in English, never the raw key', async () => { + await openSortPanel(); + + const reset = screen.getByTestId('sort-reset-default'); + expect(reset.textContent).toBe('Reset to view default'); + expect(reset.textContent).not.toBe('list.resetSortToDefault'); + }); + + it('leaves no `list.` raw key anywhere in the sort popover', async () => { + // Non-vacuity: `Sort Records` (already in the table) proves the popover is + // genuinely mounted, so the absence below is a fact about the keys rather + // than about an empty DOM. + await openSortPanel(); + + expect(screen.getByText('Sort Records')).toBeTruthy(); + expect(document.body.textContent).not.toMatch(/list\.[a-zA-Z]/); + }); +});