Skip to content

Commit 6390fe5

Browse files
fix(plugin-grid): the bulk confirm sentence formats numbers and dates in the display locale (#26358)
Fixes #26307 Clause-②: no Metadata-protocol: none ## What changed A bulk action's confirm sentence 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 since PR #26282. `BulkActionDialog` called `bulkConfirmText(def.confirmText, { count, params, values, lookupLabels })` with no locale, so `fillTemplateHoles` fell back to `en-US`. - `ui/packages/plugin-grid/src/components/BulkActionDialog.tsx`: reads `useDisplayLocale()` from `@object-ui/i18n`, the same hook the console's action runners install (`useActionRunnerDisplayLocale` in `ui/packages/react/src/context/actionRunnerTranslator.ts`), and passes it to `bulkConfirmText`. - `ui/packages/plugin-grid/src/components/bulkConfirmText.ts`: `bulkConfirmText(confirmText, scope, locale?)` hands `locale` to `fillTemplateHoles`'s third parameter, the locale channel apart from the template roots, so a param named `locale` stays a value the sentence prints. No second locale resolution: with no locale handed, the engine keeps `en-US`. - `scripts/sdui-manifest.sources/plugin-grid.json`: re-recorded with `node scripts/gen-sdui-manifest-node.mjs` under the verify lock, on the tree after merging `origin/main` (PR #26323 had moved the same record; main's record was taken at the merge, then re-recorded). Only plugin-grid's sources digest moved; the artefact did not (`manifestSha256` unchanged). `node scripts/check-sdui-manifest.mjs`: exit 0. - `.changeset/26307-bulk-confirm-locale.md`: `@object-ui/plugin-grid` patch. ## Red first New pin `ui/packages/plugin-grid/src/__tests__/bulkConfirmLocale-26307.test.tsx`: the helper over `de-DE` and `zh-CN`, and the real `BulkActionDialog` under a real `I18nProvider` + `LocalizationProvider`. Expected strings are computed with `Intl` in the test, so a newer ICU cannot drift them. Red leg: the two source files restored to `a70aa00b` (the unfixed base versions) in the working tree only, at head `64e9dbc5`, under the verify lock: ```text × formats a currency, a number and a date hole in a German display locale Expected: "Set 1.234.567,50 € on 2 records?" Received: "Set €1,234,567.50 on 2 records?" × formats a date hole in a Chinese display locale Expected: "Due 2026年10月26日?" Received: "Due October 26, 2026?" × formats a currency hole in the tenant display locale (BulkActionDialog) Expected: "Set the amount to 1.234.567,50 € on 2 records?" Received: "Set the amount to €1,234,567.50 on 2 records?" Tests 3 failed | 2 passed (5) ``` The two passing cases are the `en-US` ones (no locale handed; no tenant locale under an English UI), which hold before and after. Restored from `HEAD` (blob hashes compared, `git diff HEAD` empty), green leg: `Tests 5 passed (5)`. ## Tests and gates - Pin, at head `64e9dbc5`: red 3 failed / 2 passed, then green 5 / 5 (above). - Full plugin-grid suite, `pnpm exec vitest run packages/plugin-grid --maxWorkers=2` from `ui/`, under the lock at `e42274e0` (the merge; same sources as `64e9dbc5`, which changes only the pin's matcher): 251 of 252 files, 2251 of 2252 tests passed. The one failure was this pin's dialog case matching with `findByText`, whose normaliser turns the no-break space `Intl` writes before a de-DE `€` into a plain space in the node text but not in the matcher string; `64e9dbc5` reads the dialog description's `textContent` instead, and the pin is 5 / 5 at that head. - `@object-ui/plugin-grid` `type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`): exit 0 at `e42274e0`. - `node scripts/check-sdui-manifest.mjs`: exit 0 at `e42274e0` after the re-record. - Derived gates (`node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, head `64e9dbc5`): 45 derived; 44 run, each exit 0; `pnpm check:type-check-debt` exit 3 (PREREQUISITE NOT MET: it was queued on the shared verify lock for its closure build), so NOT MEASURED locally, and CI's `Lint & Repo Gates` runs it. `--ran` reconciliation: "45 derived famil(ies) accounted for — 44 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)", 0 UNRUN. - The derivation warned that the tree is 5 commits behind `origin/main` (3 gate scripts changed across that range). Main was merged once before opening, as required; it is not merged again unless a conflict, a main-caused red or a queue removal calls for it. ## Acceptance notes - `useDisplayLocale()` with no providers mounted resolves `en`, so every existing bulk dialog suite (rendered without providers) keeps its `en-US` sentences; the full plugin-grid run above confirms it. --- _Generated by [Claude Code](https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6a2866a commit 6390fe5

5 files changed

Lines changed: 158 additions & 3 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/plugin-grid': patch
3+
---
4+
5+
fix(plugin-grid): a bulk action's confirm sentence formats its numbers and dates in the viewer's display locale
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
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.
11+
12+
What to do: nothing.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"package":"ui/packages/plugin-grid","sources":46,"sha256":"2c2577a854216b798b363a8751d8924e3e9205404c0eb592b5f3d53197cd3bc0","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
1+
{"package":"ui/packages/plugin-grid","sources":46,"sha256":"c0a562a633d4901dcde8bad54819f468c8ed4d7ca4f002cf7defa764ebfcd7d8","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectstack#26307 — a bulk `confirmText` formats its numbers and dates in
11+
* the viewer's display locale, the one `useDisplayLocale()` resolves, the way
12+
* the same action's single-record confirm does since objectstack#26232. It
13+
* used to format them in `en-US` for every viewer.
14+
*
15+
* Two halves:
16+
* - the helper `bulkConfirmText`: its `locale` argument reaches the
17+
* formatters, and an absent one keeps `en-US`;
18+
* - the real `BulkActionDialog` under a real `I18nProvider` and
19+
* `LocalizationProvider`: the confirm sentence formats in the tenant's
20+
* display locale.
21+
*
22+
* Expected strings come from `Intl` itself, so a newer ICU cannot drift them.
23+
*/
24+
import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest';
25+
import React from 'react';
26+
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
27+
import '@testing-library/jest-dom';
28+
import { I18nProvider, LocalizationProvider } from '@object-ui/i18n';
29+
import type { BulkActionDef, BulkActionParam } from '@object-ui/types';
30+
31+
import { BulkActionDialog } from '../components/BulkActionDialog';
32+
import { bulkConfirmText } from '../components/bulkConfirmText';
33+
34+
beforeAll(() => {
35+
if (!Element.prototype.scrollIntoView) {
36+
Element.prototype.scrollIntoView = vi.fn() as any;
37+
}
38+
});
39+
40+
afterEach(() => cleanup());
41+
42+
const AMOUNT_VALUE = 1234567.5;
43+
const DUE_VALUE = '2026-10-26';
44+
const money = (tag: string) => new Intl.NumberFormat(tag, { style: 'currency', currency: 'EUR' }).format(AMOUNT_VALUE);
45+
const fixed2 = (tag: string) =>
46+
new Intl.NumberFormat(tag, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(AMOUNT_VALUE);
47+
const longDate = (tag: string) => new Intl.DateTimeFormat(tag, { dateStyle: 'long' }).format(new Date(DUE_VALUE));
48+
49+
const AMOUNT: BulkActionParam = { name: 'amount', label: 'Amount', type: 'number' };
50+
const DUE: BulkActionParam = { name: 'due', label: 'Due', type: 'date' };
51+
52+
function fill(source: string, locale?: string) {
53+
return bulkConfirmText(source, { count: 2, params: [AMOUNT, DUE], values: { amount: AMOUNT_VALUE, due: DUE_VALUE } }, locale);
54+
}
55+
56+
describe('bulkConfirmText formats in the locale it is handed (objectstack#26307)', () => {
57+
it('formats a currency, a number and a date hole in a German display locale', () => {
58+
expect(money('de-DE')).not.toBe(money('en-US'));
59+
expect(fill('Set {{ amount | currency:EUR }} on {{ count }} records?', 'de-DE')).toBe(`Set ${money('de-DE')} on 2 records?`);
60+
expect(fill('Set {{ amount | number:2 }}?', 'de-DE')).toBe(`Set ${fixed2('de-DE')}?`);
61+
expect(fill('Due {{ due | date:long }}?', 'de-DE')).toBe(`Due ${longDate('de-DE')}?`);
62+
});
63+
64+
it('formats a date hole in a Chinese display locale', () => {
65+
expect(longDate('zh-CN')).not.toBe(longDate('en-US'));
66+
expect(fill('Due {{ due | date:long }}?', 'zh-CN')).toBe(`Due ${longDate('zh-CN')}?`);
67+
});
68+
69+
it('keeps en-US when no locale is handed', () => {
70+
expect(fill('Set {{ amount | currency:EUR }}, due {{ due | date:long }}?')).toBe(
71+
`Set ${money('en-US')}, due ${longDate('en-US')}?`,
72+
);
73+
});
74+
});
75+
76+
const ROWS = [{ id: 'r1', name: 'Acme' }, { id: 'r2', name: 'Globex' }];
77+
78+
const REPRICE: BulkActionDef = {
79+
name: 'reprice',
80+
label: 'Reprice',
81+
operation: 'update',
82+
params: [{ ...AMOUNT, default: AMOUNT_VALUE }],
83+
confirmText: { dialect: 'template', source: 'Set the amount to {{ amount | currency:EUR }} on {{ count }} records?' },
84+
};
85+
86+
function openDialog(tenantLocale: string | undefined) {
87+
render(
88+
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }} persistLanguage={false}>
89+
<LocalizationProvider value={{ locale: tenantLocale }}>
90+
<BulkActionDialog
91+
def={REPRICE}
92+
rows={ROWS}
93+
resource="account"
94+
dataSource={{ update: vi.fn(async () => ({})), delete: vi.fn(async () => ({})) } as any}
95+
open
96+
onClose={() => {}}
97+
/>
98+
</LocalizationProvider>
99+
</I18nProvider>,
100+
);
101+
}
102+
103+
/**
104+
* The confirm step's sentence, read as the dialog's description `textContent`.
105+
* Not `findByText`: its default normalizer turns the no-break space `Intl`
106+
* writes before a de-DE `€` into a plain space in the node text, while the
107+
* matcher string keeps it, so the right sentence would not match.
108+
*/
109+
async function confirmSentence(): Promise<string | null> {
110+
fireEvent.click(await screen.findByRole('button', { name: 'Next' }));
111+
const dialog = await screen.findByRole('dialog');
112+
const description = await waitFor(() => {
113+
const el = document.getElementById(dialog.getAttribute('aria-describedby') ?? '');
114+
expect(el?.textContent).toMatch(/^Set the amount to /);
115+
return el;
116+
});
117+
return description?.textContent ?? null;
118+
}
119+
120+
describe('BulkActionDialog formats its confirm sentence in the display locale (objectstack#26307)', () => {
121+
it('formats a currency hole in the tenant display locale', async () => {
122+
openDialog('de-DE');
123+
expect(await confirmSentence()).toBe(`Set the amount to ${money('de-DE')} on 2 records?`);
124+
});
125+
126+
it('formats in the UI language when no tenant locale is set', async () => {
127+
openDialog(undefined);
128+
expect(await confirmSentence()).toBe(`Set the amount to ${money('en')} on 2 records?`);
129+
});
130+
});

‎ui/packages/plugin-grid/src/components/BulkActionDialog.tsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
} from '@object-ui/components';
2222
import { AlertTriangle, CheckCircle2, Loader2, XCircle } from 'lucide-react';
2323
import { useObjectTranslation } from '@object-ui/react';
24+
import { useDisplayLocale } from '@object-ui/i18n';
2425
import { getLazyFieldWidget } from '@object-ui/fields';
2526
// The two shared allow-tables of widgets that are fed the dialog's own
2627
// in-progress values as their record — one definition each for this dialog, the
@@ -160,6 +161,10 @@ export const BulkActionDialog: React.FC<BulkActionDialogProps> = ({
160161
skippedCount = 0,
161162
}) => {
162163
const { t } = useObjectTranslation();
164+
// The viewer's display locale, the one every date, number and currency
165+
// renderer and the console's action runners read: the confirm sentence's
166+
// formatters render in it (objectstack#26307). ⛔ No second resolution.
167+
const displayLocale = useDisplayLocale();
163168
const params = def?.params ?? [];
164169

165170
// Effective multi-value semantics for an `update` param: an explicit
@@ -410,7 +415,7 @@ export const BulkActionDialog: React.FC<BulkActionDialogProps> = ({
410415
</DialogTitle>
411416
{step === 'confirm' && (
412417
<DialogDescription>
413-
{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).` })}
418+
{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).` })}
414419
</DialogDescription>
415420
)}
416421
</DialogHeader>

‎ui/packages/plugin-grid/src/components/bulkConfirmText.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,12 +125,20 @@ export function bulkConfirmTemplateScope(scope: BulkConfirmScope): Record<string
125125
* notifications and the single-record `confirmText` render with, so the
126126
* engine's whitelisted formatters apply (`{{ count | number }}`) and no hole
127127
* holds logic. A sentence without a `{{ }}` hole is shown as written.
128+
*
129+
* `locale` is the viewer's display locale, the BCP-47 tag the dialog reads
130+
* from `useDisplayLocale()`; the formatters (`currency`, `number`, `date`)
131+
* render in it (objectstack#26307). It goes to `fillTemplateHoles`'s own
132+
* locale channel, apart from the roots, so a param named `locale` stays a
133+
* value the sentence prints. ⛔ No second resolution of it here: absent, the
134+
* engine formats in `en-US`.
128135
*/
129136
export function bulkConfirmText(
130137
confirmText: BulkActionDef['confirmText'] | null | undefined,
131138
scope: BulkConfirmScope,
139+
locale?: string,
132140
): string | undefined {
133141
const source = bulkConfirmTextSource(confirmText);
134142
if (source === undefined || !hasTemplateHoles(source)) return source;
135-
return fillTemplateHoles(source, bulkConfirmTemplateScope(scope));
143+
return fillTemplateHoles(source, bulkConfirmTemplateScope(scope), locale);
136144
}

0 commit comments

Comments
 (0)