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
22 changes: 22 additions & 0 deletions .changeset/11131-line-items-chrome.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@object-ui/fields': patch
'@object-ui/i18n': patch
'@object-ui/plugin-form': patch
---

fix(plugin-form,fields,i18n): the record page's line-items panel and the line-items grid speak the user's language

Under a Chinese session the record page's `record:line_items` panel still
rendered some of its chrome in English. Its default title read `Line Items` and
its button read `Save` / `Saving…`. The line-items grid (`GridField`) read
`Add line` on its Add button, `No items yet — click “Add” to begin.` in list
mode, and `No items` when read-only.

They now read the locale packs. The button reuses `common.save` and
`detail.saving`, and the empty text names `detail.add`. Four new keys are added
to all ten packs: `form.lineItems.title`, `fields.grid.addLine`,
`fields.grid.noItems` and `fields.grid.noItemsAddHint`.

Only the defaults move. An authored `title` or `add_label` still wins, and an
authored `add_label` is also the label the empty text names. English output is
byte-identical to the literals these replace, with or without an i18n provider.
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* 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.
*/

/**
* With no `I18nProvider` mounted, the line-items grid's chrome is the English
* it rendered before objectui#11131 moved it onto pack keys, byte for byte.
*
* This is the provider-less path — a standalone embed, this package's own
* tests — served by each call site's inline `defaultValue` through
* `createSafeTranslation`'s fallback, including the `{{label}}` hole of the
* list-mode empty text. The zh half is `GridField.i18nChrome-11131.test.tsx`.
*
* Its own FILE on purpose: `createI18n` registers its instance as
* react-i18next's module-global default, and that registration outlives
* `cleanup()`, so one provider mount earlier in a file would answer every
* later "no provider" render in it. Do not import or mount `I18nProvider`
* here.
*/

import { describe, it, expect, afterEach } from 'vitest';
import * as React from 'react';
import { render, screen, cleanup } from '@testing-library/react';
import { GridField } from './GridField';

afterEach(() => cleanup());

const columns = [{ name: 'description', label: 'Description', type: 'text' as const }];

/** The cell's whole text, so a split into several text nodes still compares. */
const cellText = (text: RegExp) => screen.getByText(text, { selector: 'td' }).textContent;

describe('GridField chrome with no i18n provider (objectui#11131)', () => {
it('the Add button reads `Add line`', () => {
render(<GridField value={[]} onChange={() => {}} field={{ columns } as never} />);
expect(screen.getByRole('button', { name: 'Add line' })).toBeInTheDocument();
});

it('the list-mode empty text is the old sentence, naming `Add`', () => {
render(
<GridField value={[]} onChange={() => {}} field={{ columns } as never} displayMode="list" onAdd={() => {}} />,
);
expect(cellText(/^No items yet/)).toBe('No items yet — click “Add” to begin.');
});

it('an authored `add_label` fills the hole on this path too', () => {
render(
<GridField
value={[]}
onChange={() => {}}
field={{ columns, add_label: 'New row' } as never}
displayMode="list"
onAdd={() => {}}
/>,
);
expect(cellText(/^No items yet/)).toBe('No items yet — click “New row” to begin.');
expect(screen.getByRole('button', { name: 'New row' })).toBeInTheDocument();
});

it('the read-only grid over no rows reads `No items`', () => {
render(<GridField value={[]} onChange={() => {}} field={{ columns } as never} readonly />);
expect(cellText(/^No items$/)).toBe('No items');
});
});
81 changes: 81 additions & 0 deletions packages/fields/src/widgets/GridField.i18nChrome-11131.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* 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 line-items grid's own chrome speaks the session locale (objectui#11131).
*
* Three English literals rendered beside a Chinese page under a `zh` session:
* the Add button's `Add line`, the list-mode empty text `No items yet — click
* “Add” to begin.` and the read-only grid's `No items`. They now read the
* catalogue through the fields package's `useFieldTranslation`:
* `fields.grid.addLine`, `fields.grid.noItemsAddHint` (whose `{{label}}` is the
* authored `add_label`, or `detail.add`) and `fields.grid.noItems`.
*
* Only the DEFAULTS move. The `CONTROL` case pins the other half: an authored
* `add_label` is the button's label exactly as written, and it is the label
* the empty text names, under the same zh session.
*
* The provider-less English is the `.no-provider` companion of this file.
*/

import { describe, it, expect, afterEach } from 'vitest';
import * as React from 'react';
import { render, screen, cleanup } from '@testing-library/react';
import { I18nProvider } from '@object-ui/i18n';
import { GridField } from './GridField';

afterEach(() => cleanup());

const columns = [{ name: 'description', label: 'Description', type: 'text' as const }];

function inZh(node: React.ReactNode) {
return render(
<I18nProvider config={{ defaultLanguage: 'zh', detectBrowserLanguage: false }}>{node}</I18nProvider>,
);
}

describe('GridField chrome resolves through the i18n catalogue (objectui#11131)', () => {
it('zh: the Add button reads 添加行', async () => {
inZh(<GridField value={[]} onChange={() => {}} field={{ columns } as never} />);

expect(await screen.findByRole('button', { name: '添加行' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Add line/ })).toBeNull();
});

it('zh: the list-mode empty text is the zh sentence, naming the zh `detail.add`', async () => {
inZh(
<GridField value={[]} onChange={() => {}} field={{ columns } as never} displayMode="list" onAdd={() => {}} />,
);

expect(await screen.findByText('暂无条目,点击“添加”开始添加。')).toBeInTheDocument();
expect(screen.queryByText(/No items yet/)).toBeNull();
});

it('zh: the read-only grid over no rows reads 暂无条目', async () => {
inZh(<GridField value={[]} onChange={() => {}} field={{ columns } as never} readonly />);

expect(await screen.findByText('暂无条目')).toBeInTheDocument();
expect(screen.queryByText('No items')).toBeNull();
});

it('CONTROL — an authored `add_label` is the button label and the label the empty text names', async () => {
inZh(
<GridField
value={[]}
onChange={() => {}}
field={{ columns, add_label: 'Add invoice line' } as never}
displayMode="list"
onAdd={() => {}}
/>,
);

expect(await screen.findByRole('button', { name: 'Add invoice line' })).toBeInTheDocument();
expect(screen.getByText('暂无条目,点击“Add invoice line”开始添加。')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: '添加行' })).toBeNull();
});
});
14 changes: 10 additions & 4 deletions packages/fields/src/widgets/GridField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -645,7 +645,10 @@ export function GridField({
const displayLocale = useDisplayLocale();
// The editable `date` / `datetime` cell's notice for a stored nonexistent
// day (objectui#10474, objectui#10567) — the sentences `DateField` and
// `DateTimeField` show for it too.
// `DateTimeField` show for it too. Since objectui#11131 also the grid's own
// default chrome: the Add button and the two empty states, English literals
// until then. Each `defaultValue` is the `en` pack's value (held to it by
// `pnpm check:i18n-keys`), which is what a provider-less host renders.
const { t } = useFieldTranslation();
const cellIdBase = React.useId();
// The tenant default currency (ADR-0053) — the resolver's last step, and in
Expand Down Expand Up @@ -939,7 +942,7 @@ export function GridField({
colSpan={Math.max(columns.length + (showLineNumbers ? 1 : 0), 1)}
className="px-3 py-6 text-center text-muted-foreground"
>
No items
{t('fields.grid.noItems', { defaultValue: 'No items' })}
</td>
</tr>
) : (
Expand Down Expand Up @@ -1253,7 +1256,10 @@ export function GridField({
colSpan={columns.length + (hasRowActions ? 1 : 0) + (showLineNumbers ? 1 : 0)}
className="px-3 py-6 text-center text-muted-foreground"
>
No items yet — click “{cfg.add_label || 'Add'}” to begin.
{t('fields.grid.noItemsAddHint', {
label: cfg.add_label || t('detail.add', { defaultValue: 'Add' }),
defaultValue: 'No items yet — click “{{label}}” to begin.',
})}
</td>
</tr>
) : (
Expand Down Expand Up @@ -1422,7 +1428,7 @@ export function GridField({
data-testid="line-items-add"
>
<Plus className="mr-1.5 h-4 w-4" />
{cfg.add_label || 'Add line'}
{cfg.add_label || t('fields.grid.addLine', { defaultValue: 'Add line' })}
</Button>
)}
</div>
Expand Down
10 changes: 7 additions & 3 deletions packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,10 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight
// `form.noPermissionToEdit`, the notice on a form locked for a closed
// affordance, which each quote the object's label („{{object}}“-Datensätze)
// — two interpolated spans, runtime data.
expect(okSpans, 'correctly paired spans').toBe(68);
// 69 once objectui#11131 added `fields.grid.noItemsAddHint`, the list-mode
// line-items grid's empty text, which quotes the Add button's label
// („{{label}}“) — one interpolated span, runtime data.
expect(okSpans, 'correctly paired spans').toBe(69);
});

it('keeps the count identity that replaces the card’s count(„) === count(“)', () => {
Expand Down Expand Up @@ -334,8 +337,9 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight
// 66 / 66 / 0 after objectui#7699 added `fields.file.uploadIncomplete`, one
// more matched pair. 68 / 68 / 0 after objectui#11000 added
// `form.noPermissionToCreate` and `form.noPermissionToEdit`, one matched
// pair each.
expect({ open, close, rdq }).toEqual({ open: 68, close: 68, rdq: 0 });
// pair each. 69 / 69 / 0 after objectui#11131 added
// `fields.grid.noItemsAddHint`, one more matched pair.
expect({ open, close, rdq }).toEqual({ open: 69, close: 69, rdq: 0 });
// The durable shape: every „ closed by a “, every surplus “ an English
// opener answered by a ”. Survived translating the two English values.
expect(close).toBe(open + rdq);
Expand Down
8 changes: 8 additions & 0 deletions packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,9 @@ const ar = {
applyRow: "تطبيق",
editorDescription: "أدخل السجل وبنوده ثم احفظ.",
},
lineItems: {
title: "البنود",
},
keepEditing: "متابعة التحرير",
discard: "تجاهل",
conflictTitle: "تعارض في الحفظ",
Expand Down Expand Up @@ -353,6 +356,11 @@ const ar = {
characterCount: "عدد الأحرف: {{count}} من {{max}}",
charactersRemaining: "الأحرف المتبقية: {{count}}",
},
grid: {
addLine: "إضافة سطر",
noItems: "لا توجد عناصر",
noItemsAddHint: "لا توجد عناصر بعد — انقر على «{{label}}» للبدء.",
},
},
table: {
rowsPerPage: "صفوف في الصفحة",
Expand Down
8 changes: 8 additions & 0 deletions packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,9 @@ const de = {
applyRow: "Übernehmen",
editorDescription: "Erfassen Sie den Datensatz und seine Positionen und speichern Sie anschließend.",
},
lineItems: {
title: "Positionen",
},
keepEditing: "Weiter bearbeiten",
discard: "Verwerfen",
conflictTitle: "Speicherkonflikt",
Expand Down Expand Up @@ -342,6 +345,11 @@ const de = {
characterCount: "Zeichenanzahl: {{count}} von {{max}}",
charactersRemaining: "Verbleibende Zeichen: {{count}}",
},
grid: {
addLine: "Zeile hinzufügen",
noItems: "Keine Einträge",
noItemsAddHint: "Noch keine Einträge – klicken Sie auf „{{label}}“, um zu beginnen.",
},
},
table: {
rowsPerPage: "Zeilen pro Seite",
Expand Down
15 changes: 15 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,12 @@ const en = {
applyRow: 'Apply',
editorDescription: 'Enter the record and its line items, then save.',
},
// The record page's `record:line_items` panel (objectui#11131): its
// title when the author declared none. Its Save / Saving… button reads
// `common.save` / `detail.saving`.
lineItems: {
title: 'Line Items',
},
keepEditing: 'Keep editing',
discard: 'Discard',
conflictTitle: 'Save conflict',
Expand Down Expand Up @@ -514,6 +520,15 @@ const en = {
characterCount: 'Character count: {{count}} of {{max}}',
charactersRemaining: 'Characters remaining: {{count}}',
},
// objectui#11131 — the line-items grid's default chrome (`GridField`):
// its Add button, the read-only grid's empty state, and the list-mode
// grid's empty state. An authored `add_label` still wins over `addLine`,
// and it fills the `{{label}}` hole (`detail.add` when none is authored).
grid: {
addLine: 'Add line',
noItems: 'No items',
noItemsAddHint: 'No items yet — click “{{label}}” to begin.',
},
},
table: {
rowsPerPage: 'Rows per page',
Expand Down
8 changes: 8 additions & 0 deletions packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,9 @@ const es = {
applyRow: "Aplicar",
editorDescription: "Introduzca el registro y sus líneas de detalle y luego guarde.",
},
lineItems: {
title: "Líneas de detalle",
},
keepEditing: "Seguir editando",
discard: "Descartar",
conflictTitle: "Conflicto al guardar",
Expand Down Expand Up @@ -346,6 +349,11 @@ const es = {
characterCount: "Recuento de caracteres: {{count}} de {{max}}",
charactersRemaining: "Caracteres restantes: {{count}}",
},
grid: {
addLine: "Agregar línea",
noItems: "Sin elementos",
noItemsAddHint: "Aún no hay elementos: haga clic en «{{label}}» para empezar.",
},
},
table: {
rowsPerPage: "Filas por página",
Expand Down
8 changes: 8 additions & 0 deletions packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,9 @@ const fr = {
applyRow: "Appliquer",
editorDescription: "Saisissez l'enregistrement et ses lignes, puis enregistrez.",
},
lineItems: {
title: "Lignes",
},
keepEditing: "Continuer l'édition",
discard: "Abandonner",
conflictTitle: "Conflit d'enregistrement",
Expand Down Expand Up @@ -342,6 +345,11 @@ const fr = {
characterCount: "Nombre de caractères : {{count}} sur {{max}}",
charactersRemaining: "Caractères restants : {{count}}",
},
grid: {
addLine: "Ajouter une ligne",
noItems: "Aucun élément",
noItemsAddHint: "Aucun élément pour l'instant — cliquez sur « {{label}} » pour commencer.",
},
},
table: {
rowsPerPage: "Lignes par page",
Expand Down
8 changes: 8 additions & 0 deletions packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,9 @@ const ja = {
applyRow: "適用",
editorDescription: "レコードとその明細行を入力してから、保存してください。",
},
lineItems: {
title: "明細行",
},
keepEditing: "編集を続ける",
discard: "破棄",
conflictTitle: "保存の競合",
Expand Down Expand Up @@ -342,6 +345,11 @@ const ja = {
characterCount: "文字数: {{max}} 文字中 {{count}} 文字",
charactersRemaining: "残り {{count}} 文字",
},
grid: {
addLine: "行を追加",
noItems: "項目なし",
noItemsAddHint: "まだ項目がありません。「{{label}}」をクリックして開始してください。",
},
},
table: {
rowsPerPage: "1ページの行数",
Expand Down
Loading
Loading