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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/six-bare-keys-defaults-4396.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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 `<I18nProvider>` 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(<DashboardEditor schema={DASHBOARD} onChange={() => {}} />);

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(<DashboardEditor schema={DASHBOARD} onChange={() => {}} />);
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(<DashboardEditor schema={DASHBOARD} onChange={() => {}} />);
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(<DashboardEditor schema={DASHBOARD} onChange={() => {}} />);
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(<ObjectManager objects={OBJECTS} onObjectsChange={vi.fn()} />);
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();
});
});
13 changes: 13 additions & 0 deletions packages/plugin-designer/src/hooks/useDesignerTranslation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,13 @@ const DESIGNER_DEFAULT_TRANSLATIONS: Record<string, string> = {
'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.',
Expand Down Expand Up @@ -192,6 +199,12 @@ const DESIGNER_DEFAULT_TRANSLATIONS: Record<string, string> = {
'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',
};

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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 `<I18nProvider>` 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 (
<>
<button onClick={() => inline.enter('status')}>edit-enter</button>
<button onClick={() => inline.setSaving(true)}>edit-saving</button>
</>
);
}

function renderSavingBar() {
render(
<InlineEditProvider canEdit>
<Harness />
<InlineEditSaveBar objectName="proj" recordId="p1" data={{ updated_at: 'v1' }} />
</InlineEditProvider>,
);
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]/);
});
});
4 changes: 4 additions & 0 deletions packages/plugin-detail/src/useDetailTranslation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ export const DETAIL_DEFAULT_TRANSLATIONS: Record<string, string> = {
'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',
Expand Down
4 changes: 4 additions & 0 deletions packages/plugin-list/src/ListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,10 @@ const LIST_DEFAULT_TRANSLATIONS: Record<string, string> = {
// `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',
Expand Down
Loading
Loading