|
| 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 | +}); |
0 commit comments