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/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"} 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..6e469ab65f8 --- /dev/null +++ b/ui/packages/plugin-grid/src/__tests__/bulkConfirmLocale-26307.test.tsx @@ -0,0 +1,130 @@ +/** + * 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, 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'; + +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={() => {}} + /> + + , + ); +} + +/** + * 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'); + 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); + expect(await confirmSentence()).toBe(`Set the amount to ${money('en')} on 2 records?`); + }); +}); 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