diff --git a/.changeset/report-preview-measure-display-locale-4575.md b/.changeset/report-preview-measure-display-locale-4575.md new file mode 100644 index 000000000..21347321f --- /dev/null +++ b/.changeset/report-preview-measure-display-locale-4575.md @@ -0,0 +1,61 @@ +--- +'@object-ui/plugin-report': patch +'@object-ui/app-shell': patch +--- + +Report and dataset-preview measures follow the display locale (objectui#4575) + +objectui#4566 gave `formatMeasure` / `formatDimensionValue` in `@object-ui/core` +an optional trailing `locale` and threaded `useDisplayLocale()` through the +dashboard's `DatasetWidget`. The parameter is OPTIONAL by design, so the +producer could land without dragging every consumer with it — which left the +consumers it did not reach still formatting in the MACHINE's locale. A German +session read a report measure as `1,234.5` directly beside a dashboard measure +that, after #4566, rendered `1.234,5`: one number, two spellings, on the same +screen. That is a sharper inconsistency than the one before #4566, when both +surfaces were uniformly wrong. + +The remaining thirteen call sites now thread `useDisplayLocale()`: + +- `plugin-report`'s `DatasetReportRenderer` (ten) — the grouped table's measure, + dimension and grand-total cells, the embedded single-value chart's metric, and + the cross-tab's across-axis header, down-axis cell, measure cell, row total, + column total and grand total; +- `app-shell`'s metadata-admin `DatasetPreview` (two) — the preview table's + measure and dimension cells; +- `app-shell`'s `DatasetDefaultInspector` (one) — the measure format-hint + sample, which is a preview of authored formatting and so has to be rendered + through the channel it previews. + +**English output does not move**, and that is the discriminator against the +sibling fix. These sites already went through `Intl` with default grouping, so +the only thing that changes is WHOSE locale is used — contrast objectui#4553, +where `formatPercent` had never grouped at all and moving en `1235%` to +`1,235%` WAS the fix. Every new case pins the same value in de AND in en, so +at least one half must fail on any runner: before the change both render in the +machine's locale, which is what makes the machine locale stop being a test +input. + +Two details worth recording: + +- **The cross-tab's header labels are built inside a `useMemo`**, so the locale + joins that dependency array. Threading it into the call alone would leave the + headers frozen in whatever locale they were first built with — measured, and + pinned by a case that changes only the locale and asserts the header + re-labels. Removing just the dependency entry turns exactly that one case red + and leaves the other nine green. +- **The metadata designer's `locale` prop is deliberately not used.** It carries + the designer's own chrome language (`useMetadataLocale()`, which resolves to + exactly `en-US` or `zh-CN`), not a number-formatting locale — a German session + gets `en-US` from it. The preview's numbers have to match what the report and + dashboard render for the same dataset, which is `useDisplayLocale()`. + +Both packages are `patch`: their published declarations are unchanged (measured +against the built `.d.ts` with `dist/` cleared between builds). The threading is +module-local, and the one signature that gained a parameter — the file-local +`bucketLabel` helper — is not exported. + +A side effect of the fallback: these surfaces are now DETERMINISTIC where they +previously followed whatever locale the machine happened to run in. +`useDisplayLocale` ends at a concrete `'en'` rather than the `undefined` that +hands `Intl` the machine's locale. diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/DatasetDefaultInspector.measureLocale.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/DatasetDefaultInspector.measureLocale.test.tsx new file mode 100644 index 000000000..6be71c81b --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/inspectors/DatasetDefaultInspector.measureLocale.test.tsx @@ -0,0 +1,162 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#4575 — the dataset inspector's format-hint SAMPLE follows the + * display locale, not the machine's. + * + * The sample under the measure's display-format picker exists so a business + * user can see what their `format` / `currency` choice will look like without + * hand-writing a numeral pattern. It is a preview of authored formatting, so it + * has to arrive through the same channel as the surface it previews: a German + * session picking "Number · 1 decimal" must be shown `1.234,5`, because that is + * what the report and dashboard will render — showing `1,234.5` makes the + * sample lie about the very thing it exists to demonstrate. + * + * ── Why every case pins TWO locales ────────────────────────────────────────── + * A lone de assertion is not falsifiable: on a German runner it would pass + * before the fix too. Each case pins the same sample in de AND in en, so before + * the fix — when both render in the machine's locale — at least one of the two + * must fail on ANY runner. + * + * ── Directions, predicted in writing BEFORE the run ────────────────────────── + * Runner machine locale measured as en-US. + * the de number / currency / percent samples RED pre-fix — render the en form + * the en counterparts GREEN both sides — byte-identity + * pins; en must NOT move here + * the no-provider case GREEN both sides — see its note + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, within } from '@testing-library/react'; + +// Stub the catalog hooks so the inspector renders without a MetadataClient / +// network — the same stubs the sibling `DatasetDefaultInspector.test.tsx` uses. +vi.mock('./useDatasetFields', () => ({ + useObjectOptions: () => ({ options: [], loading: false }), + useDatasetFieldCatalog: () => ({ relationships: [], fieldOptions: [], loading: false }), + useDatasetUsage: () => ({ reports: 0, dashboards: 0, loading: false }), + fieldTypeToDimensionType: (t: string) => (t === 'date' ? 'date' : 'string'), +})); + +import { I18nProvider, LocalizationProvider } from '@object-ui/i18n'; +import { DatasetDefaultInspector } from './DatasetDefaultInspector'; + +afterEach(cleanup); + +/** + * The `locale` prop is the metadata designer's own chrome language + * (`useMetadataLocale()` — exactly 'en-US' or 'zh-CN'), NOT a number-formatting + * locale. It stays en-US in every case below, so a German sample can only have + * come from `useDisplayLocale()`. + */ +const baseProps = { type: 'dataset', name: 'sales', locale: 'en-US' as const }; + +const draftWith = (measure: Record) => ({ + name: 'sales', + label: 'Sales', + object: 'opportunity', + dimensions: [{ name: 'region', field: 'account.region', type: 'string' }], + measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount', ...measure }], +}); + +function renderIn(locale: string | undefined, ui: React.ReactElement, language = 'en') { + return render( + + {ui} + , + ); +} + +/** The sample renders as `Sample: {value}` beneath the picker. */ +const sampleText = () => { + const label = screen.getByText(/^Sample:/); + return within(label).getByText(/\d/).textContent; +}; + +describe('dataset inspector format sample follows the display locale (objectui#4575)', () => { + it('renders the German number sample in a de session', () => { + renderIn( + 'de-DE', + , + ); + expect(sampleText()).toBe('1.234,5'); + }); + + it('leaves the en number sample byte-identical (must-not-change)', () => { + renderIn( + 'en-US', + , + ); + expect(sampleText()).toBe('1,234.5'); + }); + + it('places the currency sign the way the locale does, not the way en does', () => { + renderIn( + 'de-DE', + , + ); + // German writes the sign LAST, separated by a NO-BREAK SPACE (U+00A0). + // Read through `textContent` rather than a text matcher, so no normalizer + // can collapse that byte away (the objectui#4577 lesson). + expect(sampleText()).toBe(`1.234,50\u00a0€`); + }); + + it('keeps the en currency sample byte-identical (must-not-change)', () => { + renderIn( + 'en-US', + , + ); + expect(sampleText()).toBe('€1,234.50'); + }); + + it('renders the German percent sample in a de session', () => { + renderIn( + 'de-DE', + , + ); + // The decimal COMMA is the point: `12.3%` and `12,3%` read as different + // numbers to the two audiences, not as the same one restyled. + expect(sampleText()).toBe('12,3%'); + }); + + it('keeps the en percent sample byte-identical (must-not-change)', () => { + renderIn( + 'en-US', + , + ); + expect(sampleText()).toBe('12.3%'); + }); + + /** + * ⚠️ HONEST LABELLING — GREEN on both sides of the fix, and NOT a defect pin. + * The inspector's own suite mounts it with no i18n providers at all, so this + * records that `useDisplayLocale` is provider-safe there (it documents + * `useLocalization` returning `{}` and `useObjectTranslation` reading an + * optional context, falling back to 'en'). It is what keeps that sibling + * suite green and untouched by this card — and it makes the sample + * DETERMINISTIC where it previously followed whatever locale the machine ran + * in. + */ + it('degrades to en when mounted with no localization providers at all', () => { + render( + , + ); + expect(sampleText()).toBe('1,234.5'); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/DatasetDefaultInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/DatasetDefaultInspector.tsx index be9ac9076..ff872b762 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/DatasetDefaultInspector.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/DatasetDefaultInspector.tsx @@ -33,6 +33,7 @@ import { import { InspectorComboField, type InspectorComboOption } from './InspectorComboField'; import { toFieldName } from '../previews/object-fields-io'; import { formatMeasure } from '@object-ui/core'; +import { useDisplayLocale } from '@object-ui/i18n'; import { conditionToGroup, groupToCondition, type FilterCondition } from './datasetFilterCondition'; import { useObjectOptions, @@ -176,9 +177,16 @@ function MeasureFormatField({ measure, onPatch, disabled }: { measure: Measure; const { kind, decimals } = parseMeasureFormat(measure.format, measure.currency); const currency = measure.currency || 'USD'; const apply = (k: string, d: number, c: string) => onPatch(buildMeasureFormat(k, d, c)); + // The sample is a PREVIEW of authored formatting, so it has to be rendered + // through the same channel as the surfaces it previews (objectui#4575): a + // German session picking "Number · 1 decimal" is shown `1.234,5`, because + // that is what the report and the dashboard will render. Showing the machine + // locale's form here would make the sample lie about the one thing it exists + // to demonstrate. + const displayLocale = useDisplayLocale(); // The percent sample is a hand-picked 0–1 FRACTION, so it says so rather than // leaving the formatter to infer a scale from the sample's magnitude. - const sample = formatMeasure(kind === 'percent' ? 0.1234 : 1234.5, measure.format, measure.currency, kind === 'percent' ? 'fraction' : undefined); + const sample = formatMeasure(kind === 'percent' ? 0.1234 : 1234.5, measure.format, measure.currency, kind === 'percent' ? 'fraction' : undefined, displayLocale); return (
diff --git a/packages/app-shell/src/views/metadata-admin/previews/DatasetPreview.measureLocale.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/DatasetPreview.measureLocale.test.tsx new file mode 100644 index 000000000..6f165e1bc --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/previews/DatasetPreview.measureLocale.test.tsx @@ -0,0 +1,169 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#4575 — the metadata-admin dataset PREVIEW's measures and numeric + * dimension values follow the display locale, not the machine's. + * + * objectui#4566 (PR #4577) gave `formatMeasure` / `formatDimensionValue` in + * `@object-ui/core` an optional trailing `locale` and threaded + * `useDisplayLocale()` from the dashboard's `DatasetWidget`. The parameter is + * optional, so consumers that were not threaded kept formatting in the MACHINE's + * locale — this preview's two call sites among them. + * + * ── Why every case pins TWO locales ────────────────────────────────────────── + * A lone de assertion is not falsifiable: on a German runner it would pass + * before the fix too. Each case pins the same value in de AND in en, so before + * the fix — when both render in the machine's locale — at least one of the two + * must fail on ANY runner. + * + * ── Directions, predicted in writing BEFORE the run ────────────────────────── + * Runner machine locale measured as en-US. + * the de measure + dimension cases RED pre-fix — render the en form + * the en counterparts GREEN both sides — byte-identity pins; + * en must NOT move on this card + * the `locale` PROP case RED pre-fix, and it is the channel + * discriminator — see its own note + * malformed-tag guard GREEN both sides — a guard, labelled as + * such, not a pin of the defect + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +// Same pre-warm as the sibling `DatasetPreview.test.tsx`, for the same reason: +// the preview renders its chart behind `React.lazy(() => import(...))`, and +// importing the package here moves that cost into this file's import phase — +// which no test or hook timeout applies to — instead of letting it race the +// assertions below. Keep the specifier identical to DatasetPreview.tsx's. +import '@object-ui/plugin-charts'; +import { I18nProvider, LocalizationProvider } from '@object-ui/i18n'; +import { formatMeasure } from '@object-ui/core'; +import { DatasetPreview } from './DatasetPreview'; + +// Mock the data adapter the preview pulls from AdapterProvider. +const { queryDataset } = vi.hoisted(() => ({ queryDataset: vi.fn() })); +vi.mock('../../../providers/AdapterProvider', () => ({ + useAdapter: () => ({ queryDataset }), +})); + +afterEach(() => { + cleanup(); + queryDataset.mockReset(); +}); + +/** + * NOTE the `locale` prop is pinned to 'en-US' in EVERY case below. It is a + * different channel from the one this card threads — it carries the metadata + * designer's own chrome language (`useMetadataLocale()`, which resolves to + * exactly 'en-US' or 'zh-CN'), never a number-formatting locale. Holding it at + * en-US while the session is German is what proves the numbers follow + * `useDisplayLocale()` rather than this prop. + */ +const baseProps = { type: 'dataset', name: 'sales', locale: 'en-US' as const }; + +const draft = { + name: 'sales', + label: 'Sales', + object: 'opportunity', + dimensions: [{ name: 'bucket', field: 'account.bucket' }], + measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount' }], +}; + +/** A fractional DIMENSION value alongside the measure, so both sites render. */ +const RESULT = { + rows: [{ bucket: 9876.5, revenue: 1234.5 }], + fields: [ + { name: 'bucket', type: 'number', label: 'Bucket' }, + { name: 'revenue', type: 'number', label: 'Revenue', format: '0.0' }, + ], +}; + +/** + * `locale` on the LocalizationProvider is the tenant's resolved regional + * default — channel 1 of `useDisplayLocale`, which outranks the UI language. + */ +function renderIn(locale: string, ui: React.ReactElement, language = 'en') { + return render( + + {ui} + , + ); +} + +describe('DatasetPreview follows the display locale (objectui#4575)', () => { + it('localizes both the measure cell and the dimension cell in a de session', async () => { + queryDataset.mockResolvedValue(RESULT); + renderIn('de-DE', ); + // site 228 — the measure cell + expect(await screen.findByText('1.234,5')).toBeInTheDocument(); + // site 229 — the numeric dimension cell + expect(screen.getByText('9.876,5')).toBeInTheDocument(); + }); + + it('leaves the en session byte-identical (must-not-change)', async () => { + queryDataset.mockResolvedValue(RESULT); + renderIn('en-US', ); + expect(await screen.findByText('1,234.5')).toBeInTheDocument(); + expect(screen.getByText('9,876.5')).toBeInTheDocument(); + }); + + it('places the currency sign the way the locale does, not the way en does', async () => { + queryDataset.mockResolvedValue({ + rows: [{ bucket: 1, revenue: 1234.5 }], + fields: [ + { name: 'bucket', type: 'number', label: 'Bucket' }, + { name: 'revenue', type: 'number', label: 'Revenue', format: '0.00', currency: 'EUR' }, + ], + }); + renderIn('de-DE', ); + // German writes the sign LAST, separated by a NO-BREAK SPACE (U+00A0). + // Testing Library's default normalizer collapses whitespace runs — U+00A0 + // among them — so a trim-only normalizer is what keeps the byte the locale + // actually produces assertable (the objectui#4577 lesson). + expect( + await screen.findByText(`1.234,50\u00a0€`, { normalizer: (t: string) => t.trim() }), + ).toBeInTheDocument(); + }); + + it('keeps the en currency form byte-identical (must-not-change)', async () => { + queryDataset.mockResolvedValue({ + rows: [{ bucket: 1, revenue: 1234.5 }], + fields: [ + { name: 'bucket', type: 'number', label: 'Bucket' }, + { name: 'revenue', type: 'number', label: 'Revenue', format: '0.00', currency: 'EUR' }, + ], + }); + renderIn('en-US', ); + expect( + await screen.findByText('€1,234.50', { normalizer: (t: string) => t.trim() }), + ).toBeInTheDocument(); + }); + + /** + * The channel discriminator. The designer chrome stays English (`locale` + * prop) while the tenant's display locale is German — so a German number here + * can only have come from `useDisplayLocale()`. Reading the prop instead + * would render `1,234.5` and fail this case, which is what stops a later + * reader from "simplifying" the hook away into the prop already in scope. + */ + it('follows the display locale even though the designer chrome locale stays en-US', async () => { + queryDataset.mockResolvedValue(RESULT); + renderIn('de-DE', ); + expect(await screen.findByText('1.234,5')).toBeInTheDocument(); + }); + + /** + * ⚠️ HONEST LABELLING — GREEN on both sides of the fix, because before it the + * tag was never passed and nothing could throw. It pins that the hazard the + * threading introduces is contained, not the #4575 defect. The containment + * belongs to the producer (#4577's `formatNumberInLocale` retries without the + * locale), so this asserts only that the surface survives and degrades to that + * same no-locale output — computed through the function itself, so the + * assertion does not depend on the runner's machine locale. + */ + it('a malformed tenant locale tag degrades instead of taking the preview down', async () => { + queryDataset.mockResolvedValue(RESULT); + renderIn('en_US', ); + expect(await screen.findByText(formatMeasure(1234.5, '0.0'))).toBeInTheDocument(); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/previews/DatasetPreview.tsx b/packages/app-shell/src/views/metadata-admin/previews/DatasetPreview.tsx index c60aeadc6..eeeadd2cf 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/DatasetPreview.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/DatasetPreview.tsx @@ -27,7 +27,7 @@ import { buildDatasetFieldHelpers, type DatasetResultField, } from '@object-ui/core'; -import { useSafeFieldLabel } from '@object-ui/i18n'; +import { useSafeFieldLabel, useDisplayLocale } from '@object-ui/i18n'; // Lazy-loaded so the (recharts-backed) chart bundle only loads when a dataset // preview actually renders a chart — keeps the metadata-admin bundle small. @@ -48,6 +48,13 @@ type PreviewState = export function DatasetPreview({ draft }: MetadataPreviewProps) { const adapter = useAdapter(); const { fieldLabel } = useSafeFieldLabel(); + // The display locale the measure / dimension cells below format in + // (objectui#4575, completing objectui#4566's channel). Deliberately NOT the + // `locale` PROP in scope: that one is the metadata designer's own chrome + // language (`useMetadataLocale()`, which resolves to exactly 'en-US' or + // 'zh-CN'), while these numbers must match what the report and dashboard + // render for the same dataset — which is `useDisplayLocale()`. + const displayLocale = useDisplayLocale(); const objectName = (draft as Record).object as string | undefined; @@ -225,8 +232,8 @@ export function DatasetPreview({ draft }: MetadataPreviewProps) { {columns.map((c) => ( {measureNames.includes(c) - ? formatMeasure(row[c], measureField(c)?.format, measureField(c)?.currency, measureField(c)?.percentScale) - : formatDimensionValue(row[c])} + ? formatMeasure(row[c], measureField(c)?.format, measureField(c)?.currency, measureField(c)?.percentScale, displayLocale) + : formatDimensionValue(row[c], displayLocale)} ))} diff --git a/packages/plugin-report/src/DatasetReportRenderer.tsx b/packages/plugin-report/src/DatasetReportRenderer.tsx index bc118c288..99607f082 100644 --- a/packages/plugin-report/src/DatasetReportRenderer.tsx +++ b/packages/plugin-report/src/DatasetReportRenderer.tsx @@ -71,7 +71,7 @@ import { type DatasetResultField, type DatasetDrillRange, } from '@object-ui/core'; -import { useSafeFieldLabel, useSafeTranslate } from '@object-ui/i18n'; +import { useSafeFieldLabel, useSafeTranslate, useDisplayLocale } from '@object-ui/i18n'; import { mergeFilters } from './mergeFilters'; import { useDatasetDimensionLabels } from './useDatasetDimensionLabels'; @@ -411,6 +411,14 @@ function DatasetReportTable({ ); const { fieldLabel } = useSafeFieldLabel(); const tt = useSafeTranslate(); + // The display locale every measure and dimension value below is formatted in + // (objectui#4575, completing objectui#4566's channel). `formatMeasure` / + // `formatDimensionValue` are pure functions in `@object-ui/core`, which is + // React-free, so the tag has to arrive as an argument. Nothing in this + // component formats inside a `useMemo` — every site is in the render body — + // so there is no dependency array to add it to here (the matrix below is the + // one place in this file where there is). + const displayLocale = useDisplayLocale(); // objectui#4330 — the option list this report's select dimensions are // localized against. Null (and free) for a report whose dimensions own no // options, or before the read lands. @@ -484,8 +492,8 @@ function DatasetReportTable({ {columns.map((c) => ( {values.includes(c) - ? formatMeasure(row[c], measureField(c)?.format, measureField(c)?.currency, measureField(c)?.percentScale) - : formatDimensionValue(row[c])} + ? formatMeasure(row[c], measureField(c)?.format, measureField(c)?.currency, measureField(c)?.percentScale, displayLocale) + : formatDimensionValue(row[c], displayLocale)} ))} @@ -499,7 +507,7 @@ function DatasetReportTable({ )} {values.map((measure) => ( - {formatMeasure(grandTotal[measure], measureField(measure)?.format, measureField(measure)?.currency, measureField(measure)?.percentScale)} + {formatMeasure(grandTotal[measure], measureField(measure)?.format, measureField(measure)?.currency, measureField(measure)?.percentScale, displayLocale)} ))} @@ -684,6 +692,10 @@ function DatasetReportChart({ ); const ChartComponent = useRegistryComponent('chart'); const { fieldLabel } = useSafeFieldLabel(); + // objectui#4575 — the single-value metric below is a MEASURE like any other + // and follows the display locale. (The series charts render their own labels + // through the chart component, not through `formatMeasure`.) + const displayLocale = useDisplayLocale(); // objectui#4330 — the embedded chart plots the SAME dimension the table // beneath it groups by, so it takes the same label map. Leaving it out would // put the two spellings of one value on one screen, which is the defect this @@ -734,7 +746,7 @@ function DatasetReportChart({ {title ?

{title}

: null}
- {formatMeasure(state.rows[0]?.[yAxis], mf?.format, mf?.currency, mf?.percentScale)} + {formatMeasure(state.rows[0]?.[yAxis], mf?.format, mf?.currency, mf?.percentScale, displayLocale)} {headerLabel(yAxis)}
@@ -788,8 +800,17 @@ function bucketId(dims: string[], row: Row): string { return pivotBucketId(dims.map((d) => pivotDimensionValue(row[d]))); } -function bucketLabel(dims: string[], row: Row): string { - return dims.map((d) => formatDimensionValue(row[d])).join(' / '); +/** + * `locale` is the display-locale tag the bucket's numeric values are formatted + * in (objectui#4575) — this is a plain function, not a component, so it cannot + * read `useDisplayLocale()` itself and the caller threads it. It affects the + * human-readable LABEL only; the matching bucket `id` comes from + * {@link bucketId}, which is built from the RAW values and is deliberately + * untouched by the locale, so a locale change can never re-key a cell (which + * would break the `cells` lookup and drill-through with it). + */ +function bucketLabel(dims: string[], row: Row, locale?: string): string { + return dims.map((d) => formatDimensionValue(row[d], locale)).join(' / '); } /** @@ -838,6 +859,12 @@ function DatasetMatrixTable({ ); const tt = useSafeTranslate(); const { fieldLabel } = useSafeFieldLabel(); + // objectui#4575 — the display locale every measure and dimension value here + // is formatted in. Unlike the flat table above, this component formats inside + // a `useMemo` (the header labels `bucketLabel` builds), so the locale also + // joins that memo's dependency array — threading it into the call alone would + // leave the headers frozen in the locale they were first built with. + const displayLocale = useDisplayLocale(); // objectui#4330 — both axes' dimensions, one read (see the flat table above). const dimensionLabels = useDatasetDimensionLabels(state.object, state.dimensionFields, [ ...rows, @@ -869,14 +896,14 @@ function DatasetMatrixTable({ const key: Row = {}; const display: Row = {}; for (const d of rows) { key[d] = raw[d]; display[d] = r[d]; } - rowHeaders.push({ id: rid, label: bucketLabel(rows, r), key, display }); + rowHeaders.push({ id: rid, label: bucketLabel(rows, r, displayLocale), key, display }); } if (!seenCol.has(cid)) { seenCol.add(cid); const key: Row = {}; const display: Row = {}; for (const d of columnsAcross) { key[d] = raw[d]; display[d] = r[d]; } - colHeaders.push({ id: cid, label: bucketLabel(columnsAcross, r), key, display }); + colHeaders.push({ id: cid, label: bucketLabel(columnsAcross, r, displayLocale), key, display }); } // Keyed by pivotCellKey, not `${rid} ${cid}`: a plain space is a boundary // only while no dimension value contains one, and they do constantly @@ -887,7 +914,13 @@ function DatasetMatrixTable({ cells.set(pivotCellKey(rid, cid), { row: raw, index }); }); return { rowHeaders, colHeaders, cells }; - }, [state, rows, columnsAcross, dimensionLabels]); + // `displayLocale` is load-bearing here, not decorative: the header labels + // above are built inside this memo (objectui#4575). Measured — with the + // locale threaded into `bucketLabel` but absent from these deps, a session + // that switches locale without refetching keeps the previous locale's + // headers, and the pin for that is the "re-labels the across header when + // only the locale changes" case in DatasetReportRenderer.measureLocale.test.tsx. + }, [state, rows, columnsAcross, dimensionLabels, displayLocale]); if (values.length === 0) return ; if (state.status === 'loading' || state.status === 'idle') return ; @@ -977,7 +1010,7 @@ function DatasetMatrixTable({ {rows.map((d) => ( - {formatDimensionValue(rh.display[d])} + {formatDimensionValue(rh.display[d], displayLocale)} ))} {cellCols.map((cc) => { @@ -991,7 +1024,7 @@ function DatasetMatrixTable({ data-testid={clickable ? 'dataset-drill-cell' : undefined} onClick={clickable ? () => drillCell(rh.key, cc.col.key, entry!.index) : undefined} > - {formatMeasure(value, measureField(cc.measure)?.format, measureField(cc.measure)?.currency, measureField(cc.measure)?.percentScale)} + {formatMeasure(value, measureField(cc.measure)?.format, measureField(cc.measure)?.currency, measureField(cc.measure)?.percentScale, displayLocale)} ); })} @@ -1002,7 +1035,7 @@ function DatasetMatrixTable({ className="px-2 py-1 text-right tabular-nums whitespace-nowrap font-medium" data-testid="matrix-row-total" > - {formatMeasure(rowTotalById.get(rh.id)?.[measure], measureField(measure)?.format, measureField(measure)?.currency, measureField(measure)?.percentScale)} + {formatMeasure(rowTotalById.get(rh.id)?.[measure], measureField(measure)?.format, measureField(measure)?.currency, measureField(measure)?.percentScale, displayLocale)} ))} @@ -1016,7 +1049,7 @@ function DatasetMatrixTable({ )} {cellCols.map((cc) => ( - {formatMeasure(colTotalById.get(cc.col.id)?.[cc.measure], measureField(cc.measure)?.format, measureField(cc.measure)?.currency, measureField(cc.measure)?.percentScale)} + {formatMeasure(colTotalById.get(cc.col.id)?.[cc.measure], measureField(cc.measure)?.format, measureField(cc.measure)?.currency, measureField(cc.measure)?.percentScale, displayLocale)} ))} {showTotalCol && @@ -1026,7 +1059,7 @@ function DatasetMatrixTable({ className="px-2 py-1 text-right tabular-nums whitespace-nowrap" data-testid="matrix-grand-total" > - {formatMeasure(grandTotal?.[measure], measureField(measure)?.format, measureField(measure)?.currency, measureField(measure)?.percentScale)} + {formatMeasure(grandTotal?.[measure], measureField(measure)?.format, measureField(measure)?.currency, measureField(measure)?.percentScale, displayLocale)} ))} diff --git a/packages/plugin-report/src/__tests__/DatasetReportRenderer.measureLocale.test.tsx b/packages/plugin-report/src/__tests__/DatasetReportRenderer.measureLocale.test.tsx new file mode 100644 index 000000000..daecf7206 --- /dev/null +++ b/packages/plugin-report/src/__tests__/DatasetReportRenderer.measureLocale.test.tsx @@ -0,0 +1,333 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#4575 — a dataset-bound REPORT's measures and numeric dimension + * values follow the display locale, not the machine's. + * + * objectui#4566 (PR #4577) gave `formatMeasure` / `formatDimensionValue` in + * `@object-ui/core` an OPTIONAL trailing `locale` and threaded + * `useDisplayLocale()` from the dashboard's `DatasetWidget`. The parameter is + * optional precisely so the producer could land without dragging every consumer + * with it — which left this renderer's ten call sites still formatting in the + * MACHINE's locale, beside a dashboard that had stopped doing so. This file + * pins the report half of that channel. + * + * ── Why every case pins TWO locales ────────────────────────────────────────── + * A lone de assertion is not falsifiable: on a German runner it would pass + * before the fix too, because the machine locale would already be German. Every + * case therefore pins the same value in de AND in en. Before the fix the + * threaded tag is ignored, so BOTH render in the machine's locale and at least + * one of the two must fail on ANY runner. That is the un-fakeable signal, and + * it is the property the fix delivers: the machine locale stops being an input. + * + * ── Directions, predicted in writing BEFORE the run ────────────────────────── + * Runner machine locale measured as en-US + * (`new Intl.NumberFormat().resolvedOptions().locale`). + * every de case (tabular cell + dimension + total, currency, + * KPI chart, matrix down-axis / across header / cell / + * row total / column total / grand total) RED pre-fix — renders the en form + * every en counterpart GREEN both sides — the + * byte-identity pins. en must NOT + * move on this card: these sites + * already grouped through `Intl`, + * so the only thing the fix + * changes is WHOSE locale is + * used. (Contrast objectui#4553, + * where `formatPercent` had never + * grouped and moving en WAS the + * fix.) + * the memo dep-isolation case RED pre-fix, and red again if + * the fix threads the locale but + * leaves it out of the `pivot` + * dep array — see its own note + * malformed-tag guard GREEN both sides — a guard, + * labelled as such, not a pin of + * the defect + * + * ⚠️ Malformed-tag SAFETY is the producer's job (#4577's `formatNumberInLocale` + * retry) and is deliberately not re-implemented here. The single case below + * only pins that a bad tag reaching this surface degrades instead of taking the + * report down. + * + * ⚠️ The objectui#4487 flake lives in the sibling `DatasetReportRenderer.test.tsx`. + * This file mounts the same component, so a red here is verified locally and + * re-run before being owned. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, waitFor, within } from '@testing-library/react'; +import { I18nProvider, LocalizationProvider } from '@object-ui/i18n'; +import { formatMeasure } from '@object-ui/core'; +import { DatasetReportRenderer } from '../DatasetReportRenderer'; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +/** German writes a NO-BREAK SPACE between the amount and the currency sign. */ +const NBSP = '\u00a0'; + +/** + * Testing Library's DEFAULT normalizer collapses whitespace runs to a plain + * space, and U+00A0 is whitespace — so the German currency form would be + * compared as if written with an ordinary space and the no-break space could + * never be asserted. Trimming only keeps the byte the locale actually produces, + * which is the whole point of the case (the objectui#4577 lesson). + */ +const KEEP_NBSP = { normalizer: (text: string) => text.trim() }; + +type Row = Record; + +const sourceOf = (result: unknown) => ({ queryDataset: vi.fn(async () => result) }); + +/** + * `locale` on the LocalizationProvider is the tenant's resolved regional + * default — channel 1 of `useDisplayLocale`, which outranks the UI language. + * + * The report element is passed IN and re-used verbatim across a `setLocale` + * rerender: React bails out of re-rendering a child whose element identity is + * unchanged, so only the components that actually consume the locale context + * re-render. That is what makes the dep-isolation case below a real + * measurement rather than a whole-tree repaint (see its note). + */ +function renderIn(locale: string | undefined, element: React.ReactElement, language = 'en') { + const Tree = ({ loc }: { loc: string | undefined }) => ( + + {element} + + ); + const utils = render(); + return { ...utils, setLocale: (l: string) => utils.rerender() }; +} + +// ─── The grouped (summary) table — sites 487 / 488 / 502 ────────────────────── + +const TABLE_FIELDS = [ + { name: 'bucket', type: 'number', label: 'Bucket' }, + { name: 'revenue', type: 'number', label: 'Revenue', format: '0.0' }, +]; +// A fractional DIMENSION value alongside the measure, so `formatDimensionValue` +// is exercised at its own site and not merely inferred from the measure's. +const TABLE_RESULT = { + rows: [{ bucket: 9876.5, revenue: 1234.5 }] as Row[], + fields: TABLE_FIELDS, + totals: [{ dimensions: [] as string[], rows: [{ revenue: 2469 }] }], +}; +const SUMMARY_REPORT = { + name: 'sales_summary', + type: 'summary', + dataset: 'sales', + rows: ['bucket'], + values: ['revenue'], +}; + +describe('report grouped table follows the display locale (objectui#4575)', () => { + it('localizes the measure cell, the dimension cell and the totals row in a de session', async () => { + renderIn( + 'de-DE', + , + ); + // site 487 — the measure cell + expect(await screen.findByText('1.234,5')).toBeInTheDocument(); + // site 488 — the numeric dimension cell + expect(screen.getByText('9.876,5')).toBeInTheDocument(); + // site 502 — the server-computed grand total + expect(within(screen.getByTestId('dataset-report-total-row')).getByText('2.469,0')).toBeInTheDocument(); + }); + + it('leaves the en session byte-identical (must-not-change)', async () => { + renderIn( + 'en-US', + , + ); + expect(await screen.findByText('1,234.5')).toBeInTheDocument(); + expect(screen.getByText('9,876.5')).toBeInTheDocument(); + expect(within(screen.getByTestId('dataset-report-total-row')).getByText('2,469.0')).toBeInTheDocument(); + }); + + it('places the currency sign the way the locale does, not the way en does', async () => { + const result = { + rows: [{ bucket: 1, revenue: 1234.5 }] as Row[], + fields: [ + { name: 'bucket', type: 'number', label: 'Bucket' }, + { name: 'revenue', type: 'number', label: 'Revenue', format: '0.00', currency: 'EUR' }, + ], + }; + renderIn( + 'de-DE', + , + ); + // German writes the sign LAST, separated by a no-break space. + expect(await screen.findByText(`1.234,50${NBSP}€`, KEEP_NBSP)).toBeInTheDocument(); + }); + + it('keeps the en currency form byte-identical (must-not-change)', async () => { + const result = { + rows: [{ bucket: 1, revenue: 1234.5 }] as Row[], + fields: [ + { name: 'bucket', type: 'number', label: 'Bucket' }, + { name: 'revenue', type: 'number', label: 'Revenue', format: '0.00', currency: 'EUR' }, + ], + }; + renderIn( + 'en-US', + , + ); + // English writes it FIRST, with no space at all. + expect(await screen.findByText('€1,234.50', KEEP_NBSP)).toBeInTheDocument(); + }); +}); + +// ─── The embedded single-value chart — site 737 ─────────────────────────────── + +const KPI_REPORT = { + name: 'sales_kpi', + type: 'tabular', + dataset: 'sales', + rows: [] as string[], + // The table beneath selects no measures (it renders the "no measures" state), + // so the only number on screen is the chart's — no ambiguous match. + values: [] as string[], + chart: { type: 'kpi', yAxis: 'revenue' }, +}; +const KPI_RESULT = { + rows: [{ revenue: 1234.5 }] as Row[], + fields: [{ name: 'revenue', type: 'number', label: 'Revenue', format: '0.0' }], +}; + +describe("report chart's single-value metric follows the display locale (objectui#4575)", () => { + it('renders the German form in a de session', async () => { + renderIn('de-DE', ); + const metric = await screen.findByTestId('dataset-report-metric'); + expect(within(metric).getByText('1.234,5')).toBeInTheDocument(); + }); + + it('renders the English form unchanged in an en session (must-not-change)', async () => { + renderIn('en-US', ); + const metric = await screen.findByTestId('dataset-report-metric'); + expect(within(metric).getByText('1,234.5')).toBeInTheDocument(); + }); +}); + +// ─── The cross-tab — sites 792 / 980 / 994 / 1005 / 1019 / 1029 ─────────────── + +/** + * Both axes carry FRACTIONAL dimension values so the across-axis header (built + * by `bucketLabel` inside the `pivot` memo) and the down-axis cell (formatted in + * the render body) are each observable, and every total is a distinct number so + * no assertion can be satisfied by the wrong cell. + */ +const MATRIX_FIELDS = [ + { name: 'bucket', type: 'number', label: 'Bucket' }, + { name: 'quarter', type: 'number', label: 'Quarter' }, + { name: 'revenue', type: 'number', label: 'Revenue', format: '0.0' }, +]; +const MATRIX_RESULT = { + rows: [{ bucket: 9876.5, quarter: 2468.5, revenue: 1234.5 }] as Row[], + fields: MATRIX_FIELDS, + totals: [ + { dimensions: ['bucket'], rows: [{ bucket: 9876.5, revenue: 1111.5 }] }, + { dimensions: ['quarter'], rows: [{ quarter: 2468.5, revenue: 2222.5 }] }, + { dimensions: [] as string[], rows: [{ revenue: 3333.5 }] }, + ], +}; +const MATRIX_REPORT = { + name: 'sales_matrix', + type: 'matrix', + dataset: 'sales', + rows: ['bucket'], + columns: ['quarter'], + values: ['revenue'], +}; + +describe('report cross-tab follows the display locale (objectui#4575)', () => { + it('localizes the across header, the down-axis cell, the cell and all three totals in a de session', async () => { + renderIn('de-DE', ); + // site 792 (via `bucketLabel`, inside the `pivot` memo) — the across header + expect(await screen.findByRole('columnheader', { name: '2.468,5' })).toBeInTheDocument(); + // site 980 — the down-axis dimension cell + expect(screen.getByText('9.876,5')).toBeInTheDocument(); + // site 994 — the measure cell + expect(screen.getByText('1.234,5')).toBeInTheDocument(); + // site 1005 — the row total (the "Total" COLUMN) + expect(within(screen.getByTestId('matrix-row-total')).getByText('1.111,5')).toBeInTheDocument(); + // site 1019 — the column total (inside the "Total" ROW) + expect(within(screen.getByTestId('matrix-total-row')).getByText('2.222,5')).toBeInTheDocument(); + // site 1029 — the grand total + expect(within(screen.getByTestId('matrix-grand-total')).getByText('3.333,5')).toBeInTheDocument(); + }); + + it('leaves the en session byte-identical (must-not-change)', async () => { + renderIn('en-US', ); + expect(await screen.findByRole('columnheader', { name: '2,468.5' })).toBeInTheDocument(); + expect(screen.getByText('9,876.5')).toBeInTheDocument(); + expect(screen.getByText('1,234.5')).toBeInTheDocument(); + expect(within(screen.getByTestId('matrix-row-total')).getByText('1,111.5')).toBeInTheDocument(); + expect(within(screen.getByTestId('matrix-total-row')).getByText('2,222.5')).toBeInTheDocument(); + expect(within(screen.getByTestId('matrix-grand-total')).getByText('3,333.5')).toBeInTheDocument(); + }); + + /** + * ── The dependency-array case, and why it ISOLATES here ──────────────────── + * The across-axis header labels are built by `bucketLabel` INSIDE the `pivot` + * `React.useMemo`, so threading the locale into the call is only half the fix: + * without the locale in that memo's dependency array the header keeps the + * labels it was first built with. + * + * Isolating that needs a locale change that does NOT also invalidate the + * memo's other dependencies. `rows` / `columnsAcross` arrive as fresh arrays + * from `readNames(...)` on every render of the PARENT, so a whole-tree repaint + * would re-run the memo regardless and prove nothing. The harness therefore + * re-uses one report ELEMENT across the rerender: React bails out on a child + * whose element identity is unchanged, so the parent does not re-render, the + * array props keep their identity, `useDatasetRows` does not refetch (its + * effect is keyed on a signature the locale is not part of), and the only + * dependency that moves is the locale itself. + * + * Measured, not assumed — the three states this case separates: + * pre-fix RED (nothing is threaded) + * fix threaded but locale NOT in the deps RED (header stays `2,468.5`) + * fix threaded and locale in the deps GREEN + * The middle state is what makes the dep entry load-bearing rather than + * decorative, and it was run in both directions before this was committed. + */ + it('re-labels the across header when only the locale changes (memo dependency)', async () => { + const element = ( + + ); + const { setLocale } = renderIn('en-US', element); + expect(await screen.findByRole('columnheader', { name: '2,468.5' })).toBeInTheDocument(); + + setLocale('de-DE'); + // The memo-built header — the assertion the dependency array owns. + await waitFor(() => expect(screen.getByRole('columnheader', { name: '2.468,5' })).toBeInTheDocument()); + // The render-body cell, which would follow the locale even with a stale + // memo — asserted so a future reader can tell the two apart. + expect(screen.getByText('1.234,5')).toBeInTheDocument(); + }); +}); + +// ─── Guard, not a defect pin ────────────────────────────────────────────────── + +describe('locale-tag robustness (guard, not a defect pin)', () => { + /** + * ⚠️ HONEST LABELLING — GREEN on both sides of the fix, because before it the + * tag was never passed and nothing could throw. It does not pin the #4575 + * defect; it pins that the hazard the threading introduces is contained. The + * containment itself belongs to the producer (#4577's `formatNumberInLocale` + * retries without the locale), so this asserts the SURFACE survives and + * degrades to the producer's own no-locale output — deliberately not a + * re-implementation of that retry, and machine-locale-independent because the + * expected value is computed through the same function. + */ + it('a malformed tenant locale tag degrades instead of taking the report down', async () => { + renderIn( + 'en_US', // the likeliest tenant-config typo: underscore, not hyphen + , + ); + expect(await screen.findByText(formatMeasure(1234.5, '0.0'))).toBeInTheDocument(); + expect(screen.getByTestId('dataset-report-total-row')).toBeInTheDocument(); + }); +});