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