Skip to content
Merged
12 changes: 12 additions & 0 deletions .changeset/26307-bulk-confirm-locale.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion scripts/sdui-manifest.sources/plugin-grid.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"package":"ui/packages/plugin-grid","sources":46,"sha256":"2c2577a854216b798b363a8751d8924e3e9205404c0eb592b5f3d53197cd3bc0","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
{"package":"ui/packages/plugin-grid","sources":46,"sha256":"c0a562a633d4901dcde8bad54819f468c8ed4d7ca4f002cf7defa764ebfcd7d8","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
130 changes: 130 additions & 0 deletions ui/packages/plugin-grid/src/__tests__/bulkConfirmLocale-26307.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }} persistLanguage={false}>
<LocalizationProvider value={{ locale: tenantLocale }}>
<BulkActionDialog
def={REPRICE}
rows={ROWS}
resource="account"
dataSource={{ update: vi.fn(async () => ({})), delete: vi.fn(async () => ({})) } as any}
open
onClose={() => {}}
/>
</LocalizationProvider>
</I18nProvider>,
);
}

/**
* 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<string | null> {
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?`);
});
});
7 changes: 6 additions & 1 deletion ui/packages/plugin-grid/src/components/BulkActionDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -160,6 +161,10 @@ export const BulkActionDialog: React.FC<BulkActionDialogProps> = ({
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
Expand Down Expand Up @@ -410,7 +415,7 @@ export const BulkActionDialog: React.FC<BulkActionDialogProps> = ({
</DialogTitle>
{step === 'confirm' && (
<DialogDescription>
{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).` })}
</DialogDescription>
)}
</DialogHeader>
Expand Down
10 changes: 9 additions & 1 deletion ui/packages/plugin-grid/src/components/bulkConfirmText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,12 +125,20 @@ export function bulkConfirmTemplateScope(scope: BulkConfirmScope): Record<string
* notifications and the single-record `confirmText` render with, so the
* engine's whitelisted formatters apply (`{{ count | number }}`) and no hole
* holds logic. A sentence without a `{{ }}` hole is shown as written.
*
* `locale` is the viewer's display locale, the BCP-47 tag the dialog reads
* from `useDisplayLocale()`; the formatters (`currency`, `number`, `date`)
* render in it (objectstack#26307). It goes to `fillTemplateHoles`'s own
* locale channel, apart from the roots, so a param named `locale` stays a
* value the sentence prints. ⛔ No second resolution of it here: absent, the
* engine formats in `en-US`.
*/
export function bulkConfirmText(
confirmText: BulkActionDef['confirmText'] | null | undefined,
scope: BulkConfirmScope,
locale?: string,
): string | undefined {
const source = bulkConfirmTextSource(confirmText);
if (source === undefined || !hasTemplateHoles(source)) return source;
return fillTemplateHoles(source, bulkConfirmTemplateScope(scope));
return fillTemplateHoles(source, bulkConfirmTemplateScope(scope), locale);
}
Loading