From a70aa00bc7af7d8b6dead446bf26690029e200d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 21:17:05 +0000 Subject: [PATCH 1/6] test(plugin-grid): pin the bulk confirm sentence to the display locale The bulk confirm sentence formats its numbers and dates in en-US for every viewer. This pin is red until BulkActionDialog passes the display locale through bulkConfirmText to fillTemplateHoles. Claude-Session: https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup Co-authored-by: Claude --- .changeset/26307-bulk-confirm-locale.md | 12 ++ .../bulkConfirmLocale-26307.test.tsx | 115 ++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 .changeset/26307-bulk-confirm-locale.md create mode 100644 ui/packages/plugin-grid/src/__tests__/bulkConfirmLocale-26307.test.tsx diff --git a/.changeset/26307-bulk-confirm-locale.md b/.changeset/26307-bulk-confirm-locale.md new file mode 100644 index 00000000000..3c892dcc282 --- /dev/null +++ b/.changeset/26307-bulk-confirm-locale.md @@ -0,0 +1,12 @@ +--- +'@object-ui/plugin-grid': patch +--- + +fix(plugin-grid): a bulk action's confirm sentence formats its numbers and dates in the viewer's display locale + +Clause-②: no +Metadata-protocol: none + +A bulk `confirmText` such as `Set the amount to {{ amount | currency:EUR }} on {{ count }} records?` formatted its numbers and dates in `en-US` for every viewer, while the same action's single-record confirm formats them in the viewer's language. `BulkActionDialog` now reads `useDisplayLocale()`, the locale every date, number and currency renderer and the console's action runners read, and `bulkConfirmText` takes it as an optional third argument, handed to `fillTemplateHoles`'s locale channel apart from the template roots. A German viewer reads `1.234.567,50 €`; with no locale handed, the sentence formats in `en-US` as before. + +What to do: nothing. diff --git a/ui/packages/plugin-grid/src/__tests__/bulkConfirmLocale-26307.test.tsx b/ui/packages/plugin-grid/src/__tests__/bulkConfirmLocale-26307.test.tsx new file mode 100644 index 00000000000..b13675568ad --- /dev/null +++ b/ui/packages/plugin-grid/src/__tests__/bulkConfirmLocale-26307.test.tsx @@ -0,0 +1,115 @@ +/** + * 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. + */ + +/** + * objectstack#26307 — a bulk `confirmText` formats its numbers and dates in + * the viewer's display locale, the one `useDisplayLocale()` resolves, the way + * the same action's single-record confirm does since objectstack#26232. It + * used to format them in `en-US` for every viewer. + * + * Two halves: + * - the helper `bulkConfirmText`: its `locale` argument reaches the + * formatters, and an absent one keeps `en-US`; + * - the real `BulkActionDialog` under a real `I18nProvider` and + * `LocalizationProvider`: the confirm sentence formats in the tenant's + * display locale. + * + * Expected strings come from `Intl` itself, so a newer ICU cannot drift them. + */ +import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest'; +import React from 'react'; +import { render, screen, cleanup, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { I18nProvider, LocalizationProvider } from '@object-ui/i18n'; +import type { BulkActionDef, BulkActionParam } from '@object-ui/types'; + +import { BulkActionDialog } from '../components/BulkActionDialog'; +import { bulkConfirmText } from '../components/bulkConfirmText'; + +beforeAll(() => { + if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = vi.fn() as any; + } +}); + +afterEach(() => cleanup()); + +const AMOUNT_VALUE = 1234567.5; +const DUE_VALUE = '2026-10-26'; +const money = (tag: string) => new Intl.NumberFormat(tag, { style: 'currency', currency: 'EUR' }).format(AMOUNT_VALUE); +const fixed2 = (tag: string) => + new Intl.NumberFormat(tag, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(AMOUNT_VALUE); +const longDate = (tag: string) => new Intl.DateTimeFormat(tag, { dateStyle: 'long' }).format(new Date(DUE_VALUE)); + +const AMOUNT: BulkActionParam = { name: 'amount', label: 'Amount', type: 'number' }; +const DUE: BulkActionParam = { name: 'due', label: 'Due', type: 'date' }; + +function fill(source: string, locale?: string) { + return bulkConfirmText(source, { count: 2, params: [AMOUNT, DUE], values: { amount: AMOUNT_VALUE, due: DUE_VALUE } }, locale); +} + +describe('bulkConfirmText formats in the locale it is handed (objectstack#26307)', () => { + it('formats a currency, a number and a date hole in a German display locale', () => { + expect(money('de-DE')).not.toBe(money('en-US')); + expect(fill('Set {{ amount | currency:EUR }} on {{ count }} records?', 'de-DE')).toBe(`Set ${money('de-DE')} on 2 records?`); + expect(fill('Set {{ amount | number:2 }}?', 'de-DE')).toBe(`Set ${fixed2('de-DE')}?`); + expect(fill('Due {{ due | date:long }}?', 'de-DE')).toBe(`Due ${longDate('de-DE')}?`); + }); + + it('formats a date hole in a Chinese display locale', () => { + expect(longDate('zh-CN')).not.toBe(longDate('en-US')); + expect(fill('Due {{ due | date:long }}?', 'zh-CN')).toBe(`Due ${longDate('zh-CN')}?`); + }); + + it('keeps en-US when no locale is handed', () => { + expect(fill('Set {{ amount | currency:EUR }}, due {{ due | date:long }}?')).toBe( + `Set ${money('en-US')}, due ${longDate('en-US')}?`, + ); + }); +}); + +const ROWS = [{ id: 'r1', name: 'Acme' }, { id: 'r2', name: 'Globex' }]; + +const REPRICE: BulkActionDef = { + name: 'reprice', + label: 'Reprice', + operation: 'update', + params: [{ ...AMOUNT, default: AMOUNT_VALUE }], + confirmText: { dialect: 'template', source: 'Set the amount to {{ amount | currency:EUR }} on {{ count }} records?' }, +}; + +function openDialog(tenantLocale: string | undefined) { + render( + + + ({})), delete: vi.fn(async () => ({})) } as any} + open + onClose={() => {}} + /> + + , + ); +} + +describe('BulkActionDialog formats its confirm sentence in the display locale (objectstack#26307)', () => { + it('formats a currency hole in the tenant display locale', async () => { + openDialog('de-DE'); + fireEvent.click(await screen.findByRole('button', { name: 'Next' })); + expect(await screen.findByText(`Set the amount to ${money('de-DE')} on 2 records?`)).toBeInTheDocument(); + }); + + it('formats in the UI language when no tenant locale is set', async () => { + openDialog(undefined); + fireEvent.click(await screen.findByRole('button', { name: 'Next' })); + expect(await screen.findByText(`Set the amount to ${money('en')} on 2 records?`)).toBeInTheDocument(); + }); +}); From 8f120fa7e88eb9e52f86945e2f04223d8c961b9f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 21:27:03 +0000 Subject: [PATCH 2/6] fix(plugin-grid): the bulk confirm sentence formats in the display locale BulkActionDialog reads useDisplayLocale(), the locale the console's action runners already install, and passes it through bulkConfirmText to fillTemplateHoles's locale parameter. The bulk confirm sentence's currency, number and date holes render in the viewer's locale instead of en-US. No second locale resolution: absent, the engine keeps en-US. Claude-Session: https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup Co-authored-by: Claude --- .../plugin-grid/src/components/BulkActionDialog.tsx | 7 ++++++- .../plugin-grid/src/components/bulkConfirmText.ts | 10 +++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/ui/packages/plugin-grid/src/components/BulkActionDialog.tsx b/ui/packages/plugin-grid/src/components/BulkActionDialog.tsx index 76a683a69c7..6c9fb47d733 100644 --- a/ui/packages/plugin-grid/src/components/BulkActionDialog.tsx +++ b/ui/packages/plugin-grid/src/components/BulkActionDialog.tsx @@ -21,6 +21,7 @@ import { } from '@object-ui/components'; import { AlertTriangle, CheckCircle2, Loader2, XCircle } from 'lucide-react'; import { useObjectTranslation } from '@object-ui/react'; +import { useDisplayLocale } from '@object-ui/i18n'; import { getLazyFieldWidget } from '@object-ui/fields'; // The two shared allow-tables of widgets that are fed the dialog's own // in-progress values as their record — one definition each for this dialog, the @@ -160,6 +161,10 @@ export const BulkActionDialog: React.FC = ({ skippedCount = 0, }) => { const { t } = useObjectTranslation(); + // The viewer's display locale, the one every date, number and currency + // renderer and the console's action runners read: the confirm sentence's + // formatters render in it (objectstack#26307). ⛔ No second resolution. + const displayLocale = useDisplayLocale(); const params = def?.params ?? []; // Effective multi-value semantics for an `update` param: an explicit @@ -410,7 +415,7 @@ export const BulkActionDialog: React.FC = ({ {step === 'confirm' && ( - {bulkConfirmText(def.confirmText, { count: rows.length, params, values, lookupLabels }) ?? t('grid.bulk.confirmDefault', { count: rows.length, defaultValue: `This will apply to ${rows.length} record(s).` })} + {bulkConfirmText(def.confirmText, { count: rows.length, params, values, lookupLabels }, displayLocale) ?? t('grid.bulk.confirmDefault', { count: rows.length, defaultValue: `This will apply to ${rows.length} record(s).` })} )} diff --git a/ui/packages/plugin-grid/src/components/bulkConfirmText.ts b/ui/packages/plugin-grid/src/components/bulkConfirmText.ts index 393b2f5ce8b..6754286aecf 100644 --- a/ui/packages/plugin-grid/src/components/bulkConfirmText.ts +++ b/ui/packages/plugin-grid/src/components/bulkConfirmText.ts @@ -125,12 +125,20 @@ export function bulkConfirmTemplateScope(scope: BulkConfirmScope): Record Date: Mon, 26 Oct 2026 22:15:13 +0000 Subject: [PATCH 3/6] chore(sdui): re-record plugin-grid's sources digest node scripts/gen-sdui-manifest-node.mjs on the fixed tree; the artefact did not move (manifestSha256 unchanged), only plugin-grid's sources digest. node scripts/check-sdui-manifest.mjs exit 0. Claude-Session: https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup Co-authored-by: Claude --- scripts/sdui-manifest.sources/plugin-grid.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.sources/plugin-grid.json b/scripts/sdui-manifest.sources/plugin-grid.json index 8e53b237242..148263ad266 100644 --- a/scripts/sdui-manifest.sources/plugin-grid.json +++ b/scripts/sdui-manifest.sources/plugin-grid.json @@ -1 +1 @@ -{"package":"ui/packages/plugin-grid","sources":38,"sha256":"f6f7b1cbd89a1536a6e33febf4db131b8d526bd7281be460c401dccb8e97876a","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/plugin-grid","sources":38,"sha256":"19011afad35291ac1b00e3930ae1e4c02cf6ec968990f481b798c8b5ed80ea7a","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} From 920714235c6fe03f5c16d93b8a5c21659d8b5cf7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 22:49:57 +0000 Subject: [PATCH 4/6] chore(sdui): re-record plugin-grid's sources digest on the merged tree After merging origin/main (main's record taken at the merge), node scripts/gen-sdui-manifest-node.mjs re-recorded plugin-grid alone; the artefact did not move. node scripts/check-sdui-manifest.mjs exit 0. Claude-Session: https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup Co-authored-by: Claude --- scripts/sdui-manifest.sources/plugin-grid.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.sources/plugin-grid.json b/scripts/sdui-manifest.sources/plugin-grid.json index be3fa2493dc..5c72953c788 100644 --- a/scripts/sdui-manifest.sources/plugin-grid.json +++ b/scripts/sdui-manifest.sources/plugin-grid.json @@ -1 +1 @@ -{"package":"ui/packages/plugin-grid","sources":44,"sha256":"afbc05964d76c725b0d533f1bd28b1515591d0748eca468158f64fc3bb7f7a80","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/plugin-grid","sources":44,"sha256":"bab483277a19b53c61a6104bd9465e62f0767bee2c6e28cf0c6d4f080eed1099","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} From 64e9dbc596cdf965a5573be10bb8542fdceaf507 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 22:49:57 +0000 Subject: [PATCH 5/6] test(plugin-grid): read the bulk confirm sentence's exact bytes findByText normalises the no-break space Intl writes before a de-DE currency sign in the node text but not in the matcher string, so the right German sentence did not match. Read the dialog description's textContent instead. Claude-Session: https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup Co-authored-by: Claude --- .../bulkConfirmLocale-26307.test.tsx | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/ui/packages/plugin-grid/src/__tests__/bulkConfirmLocale-26307.test.tsx b/ui/packages/plugin-grid/src/__tests__/bulkConfirmLocale-26307.test.tsx index b13675568ad..6e469ab65f8 100644 --- a/ui/packages/plugin-grid/src/__tests__/bulkConfirmLocale-26307.test.tsx +++ b/ui/packages/plugin-grid/src/__tests__/bulkConfirmLocale-26307.test.tsx @@ -23,7 +23,7 @@ */ import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest'; import React from 'react'; -import { render, screen, cleanup, fireEvent } from '@testing-library/react'; +import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; import { I18nProvider, LocalizationProvider } from '@object-ui/i18n'; import type { BulkActionDef, BulkActionParam } from '@object-ui/types'; @@ -100,16 +100,31 @@ function openDialog(tenantLocale: string | undefined) { ); } +/** + * The confirm step's sentence, read as the dialog's description `textContent`. + * Not `findByText`: its default normalizer turns the no-break space `Intl` + * writes before a de-DE `€` into a plain space in the node text, while the + * matcher string keeps it, so the right sentence would not match. + */ +async function confirmSentence(): Promise { + fireEvent.click(await screen.findByRole('button', { name: 'Next' })); + const dialog = await screen.findByRole('dialog'); + const description = await waitFor(() => { + const el = document.getElementById(dialog.getAttribute('aria-describedby') ?? ''); + expect(el?.textContent).toMatch(/^Set the amount to /); + return el; + }); + return description?.textContent ?? null; +} + describe('BulkActionDialog formats its confirm sentence in the display locale (objectstack#26307)', () => { it('formats a currency hole in the tenant display locale', async () => { openDialog('de-DE'); - fireEvent.click(await screen.findByRole('button', { name: 'Next' })); - expect(await screen.findByText(`Set the amount to ${money('de-DE')} on 2 records?`)).toBeInTheDocument(); + expect(await confirmSentence()).toBe(`Set the amount to ${money('de-DE')} on 2 records?`); }); it('formats in the UI language when no tenant locale is set', async () => { openDialog(undefined); - fireEvent.click(await screen.findByRole('button', { name: 'Next' })); - expect(await screen.findByText(`Set the amount to ${money('en')} on 2 records?`)).toBeInTheDocument(); + expect(await confirmSentence()).toBe(`Set the amount to ${money('en')} on 2 records?`); }); }); From 661393e6b2add11199010c2abec2205883c28601 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 23:48:57 +0000 Subject: [PATCH 6/6] chore(sdui): re-record plugin-grid's sources digest after #26354 Merged origin/main (main's record taken at the merge); node scripts/gen-sdui-manifest-node.mjs re-recorded plugin-grid alone, the artefact did not move. node scripts/check-sdui-manifest.mjs exit 0. Claude-Session: https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup Co-authored-by: Claude --- scripts/sdui-manifest.sources/plugin-grid.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.sources/plugin-grid.json b/scripts/sdui-manifest.sources/plugin-grid.json index 9e85d6c9ec0..0e5567fe367 100644 --- a/scripts/sdui-manifest.sources/plugin-grid.json +++ b/scripts/sdui-manifest.sources/plugin-grid.json @@ -1 +1 @@ -{"package":"ui/packages/plugin-grid","sources":46,"sha256":"2c2577a854216b798b363a8751d8924e3e9205404c0eb592b5f3d53197cd3bc0","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/plugin-grid","sources":46,"sha256":"c0a562a633d4901dcde8bad54819f468c8ed4d7ca4f002cf7defa764ebfcd7d8","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}