Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .changeset/report-preview-measure-display-locale-4575.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => ({
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(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }} persistLanguage={false}>
<LocalizationProvider value={locale ? { locale } : {}}>{ui}</LocalizationProvider>
</I18nProvider>,
);
}

/** The sample renders as `Sample: <span>{value}</span>` 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',
<DatasetDefaultInspector {...baseProps} draft={draftWith({ format: '0,0.0' })} onPatch={vi.fn()} readOnly={false} />,
);
expect(sampleText()).toBe('1.234,5');
});

it('leaves the en number sample byte-identical (must-not-change)', () => {
renderIn(
'en-US',
<DatasetDefaultInspector {...baseProps} draft={draftWith({ format: '0,0.0' })} onPatch={vi.fn()} readOnly={false} />,
);
expect(sampleText()).toBe('1,234.5');
});

it('places the currency sign the way the locale does, not the way en does', () => {
renderIn(
'de-DE',
<DatasetDefaultInspector
{...baseProps}
draft={draftWith({ format: '0,0.00', currency: 'EUR' })}
onPatch={vi.fn()}
readOnly={false}
/>,
);
// 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',
<DatasetDefaultInspector
{...baseProps}
draft={draftWith({ format: '0,0.00', currency: 'EUR' })}
onPatch={vi.fn()}
readOnly={false}
/>,
);
expect(sampleText()).toBe('€1,234.50');
});

it('renders the German percent sample in a de session', () => {
renderIn(
'de-DE',
<DatasetDefaultInspector {...baseProps} draft={draftWith({ format: '0.0%' })} onPatch={vi.fn()} readOnly={false} />,
);
// 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',
<DatasetDefaultInspector {...baseProps} draft={draftWith({ format: '0.0%' })} onPatch={vi.fn()} readOnly={false} />,
);
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(
<DatasetDefaultInspector
{...baseProps}
draft={draftWith({ format: '0,0.0' })}
onPatch={vi.fn()}
readOnly={false}
/>,
);
expect(sampleText()).toBe('1,234.5');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
<div className="space-y-1.5">
<div className="grid grid-cols-2 gap-1.5">
Expand Down
Loading
Loading