Skip to content

Commit 7d06290

Browse files
committed
feat(ui): dataset cells format a record-mode amount in its row's currency (#27188)
A grouped analytics answer over a `record`-mode amount marks the measure column with `currencyField` (#27338), naming the result column that holds each row's ISO 4217 code. `measureCellCurrency(field, row)` reads it structurally (the spec type does not carry it yet) and returns that row's code, nothing for a row without one, and the column's declared `currency` for every other column. The dashboard table's body cells and the Studio dataset preview read it off the RAW row, so a relabelled dimension cannot move a code. Totals and cross-tab cells pass no row and draw no symbol. Claude-Session: https://claude.ai/code/session_01Utp6CXtadYa5QXb2PbXPev Co-authored-by: Claude <noreply@anthropic.com>
1 parent e128ccc commit 7d06290

7 files changed

Lines changed: 145 additions & 2 deletions

File tree

‎.changeset/27188-record-currency-display.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
---
2+
'@object-ui/core': patch
23
'@object-ui/i18n': patch
34
'@object-ui/fields': patch
45
'@object-ui/components': patch
@@ -23,6 +24,7 @@ An amount declaring `currencyConfig: { currencyMode: 'record', currencyField: 'c
2324
- **Filters.** In the list's filter builder, a condition on a `record`-mode amount gains a required currency picker, defaulting to the organization currency, and the filter reads `amount op X AND code = C`: the code condition is a second row of the same group, so stored filters keep their shape. A group holding such a pair stays on AND (under OR the pair would split), and a stored OR group says so. Presence checks (`is null`, `is empty`, …) need no currency.
2425
- **Create forms.** A code field that a `record`-mode amount points at opens pre-filled with `localization.currency`, as a value the user submits; a declared `defaultValue` and the caller's record win, and nothing is seeded while the setting is unset.
2526
- **Totals.** The grid footer draws no sum, average, minimum or maximum for a `record`-mode amount (its rows are in different currencies); counts stand. A group header keeps those aggregates only when the grid is grouped by the amount's code field. Consolidated totals belong to the home field, a `dynamic` amount that totals as before.
27+
- **Dataset answers.** A grouped analytics answer over a `record`-mode amount marks its measure column with `currencyField`, the result column holding each row's code. The dashboard `table` widget and the Studio dataset preview format each row in that row's currency; a row without a code, and the totals row, show a plain number. `measureCellCurrency(field, row)` in `@object-ui/core` is the one read. Columns without `currencyField` format exactly as before.
2628
- **Studio.** A currency field (or a formula returning `currency`) gains a currency section: the mode (dynamic, fixed, record), the fixed code, and under `record` a `currencyField` picker listing the object's ISO 4217 code fields, with "Create currency field", which adds `currency_code` (`text`, `valueDomain: 'iso_4217_currency'`, required) and points the amount at it.
2729

2830
⛔ Not released alone: this ships in the same release as `currencyMode: 'record'` itself and its aggregate refusals.

‎ui/packages/app-shell/src/views/metadata-admin/previews/DatasetPreview.measureLocale.test.tsx‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,25 @@ describe('DatasetPreview follows the display locale (objectui#4575)', () => {
147147
).toBeInTheDocument();
148148
});
149149

150+
it('formats a record-mode column in each row\'s own currency (objectstack#27188, ADR-0144 D9)', async () => {
151+
// A grouped answer over a `record`-mode amount: the measure column names
152+
// the result column holding each row's code (objectstack#27338).
153+
queryDataset.mockResolvedValue({
154+
rows: [
155+
{ bucket: 1, currency_code: 'USD', revenue: 1234.5 },
156+
{ bucket: 2, currency_code: 'EUR', revenue: 99 },
157+
],
158+
fields: [
159+
{ name: 'bucket', type: 'number', label: 'Bucket' },
160+
{ name: 'currency_code', type: 'text', label: 'Currency' },
161+
{ name: 'revenue', type: 'number', label: 'Revenue', format: '0.00', currencyField: 'currency_code' },
162+
],
163+
});
164+
renderIn('en-US', <DatasetPreview {...baseProps} draft={draft} />);
165+
expect(await screen.findByText('$1,234.50', { normalizer: (t: string) => t.trim() })).toBeInTheDocument();
166+
expect(screen.getByText('€99.00', { normalizer: (t: string) => t.trim() })).toBeInTheDocument();
167+
});
168+
150169
it('keeps the en currency form byte-identical (must-not-change)', async () => {
151170
queryDataset.mockResolvedValue({
152171
rows: [{ bucket: 1, revenue: 1234.5 }],

‎ui/packages/app-shell/src/views/metadata-admin/previews/DatasetPreview.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import type { MetadataPreviewProps } from '../preview-registry.js';
2323
import { PreviewShell, PreviewEmptyState, PreviewErrorBoundary } from './PreviewShell.js';
2424
import {
2525
formatMeasure,
26+
measureCellCurrency,
2627
formatDimensionValue,
2728
buildDatasetFieldHelpers,
2829
relabelDimensions,
@@ -308,7 +309,7 @@ export function DatasetPreview({ draft, locale }: MetadataPreviewProps) {
308309
{columns.map((c) => (
309310
<td key={c} className="px-2 py-1 tabular-nums whitespace-nowrap">
310311
{measureNames.includes(c)
311-
? formatMeasure(row[c], measureField(c)?.format, measureField(c)?.currency, measureField(c)?.percentScale, displayLocale)
312+
? formatMeasure(row[c], measureField(c)?.format, measureCellCurrency(measureField(c), state.rows[i]), measureField(c)?.percentScale, displayLocale)
312313
: formatDimensionValue(row[c], displayLocale)}
313314
</td>
314315
))}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* objectstack#27188 — a grouped answer over a `record`-mode amount marks its
3+
* measure column with `currencyField` (objectstack#27338), and each CELL takes
4+
* the currency of its own row (ADR-0144 D9). A column without the marker keeps
5+
* its declared `currency`, unchanged.
6+
*/
7+
import { describe, it, expect } from 'vitest';
8+
import { formatMeasure, measureCellCurrency, type DatasetResultField } from '../dataset-format';
9+
10+
const RECORD_COLUMN = { name: 'amount_sum', type: 'number', currencyField: 'currency_code' } as unknown as DatasetResultField;
11+
const FIXED_COLUMN = { name: 'amount_sum', type: 'number', currency: 'EUR' } as unknown as DatasetResultField;
12+
13+
describe('measureCellCurrency', () => {
14+
it('reads each row\'s code from the column `currencyField` names', () => {
15+
expect(measureCellCurrency(RECORD_COLUMN, { currency_code: 'USD', amount_sum: 10 })).toBe('USD');
16+
expect(measureCellCurrency(RECORD_COLUMN, { currency_code: 'JPY', amount_sum: 10 })).toBe('JPY');
17+
});
18+
19+
it('gives no symbol to a row without a code, nor to a cell with no row of its own', () => {
20+
expect(measureCellCurrency(RECORD_COLUMN, { currency_code: null })).toBeUndefined();
21+
expect(measureCellCurrency(RECORD_COLUMN, { currency_code: '' })).toBeUndefined();
22+
expect(measureCellCurrency(RECORD_COLUMN)).toBeUndefined();
23+
});
24+
25+
it('keeps a column\'s declared currency when it carries no currencyField', () => {
26+
expect(measureCellCurrency(FIXED_COLUMN, { currency_code: 'USD' })).toBe('EUR');
27+
expect(measureCellCurrency(undefined, { currency_code: 'USD' })).toBeUndefined();
28+
});
29+
30+
it('formats two rows of one column in their two currencies', () => {
31+
const cell = (row: Record<string, unknown>) =>
32+
formatMeasure(row.amount_sum, '0.00', measureCellCurrency(RECORD_COLUMN, row), undefined, 'en-US');
33+
expect(cell({ currency_code: 'USD', amount_sum: 1234.5 })).toBe('$1,234.50');
34+
expect(cell({ currency_code: 'EUR', amount_sum: 1234.5 })).toBe('€1,234.50');
35+
expect(cell({ currency_code: null, amount_sum: 1234.5 })).toBe('1,234.50');
36+
});
37+
});

‎ui/packages/core/src/utils/dataset-format.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,32 @@ export function formatMeasure(
487487
* reproduces the previous machine-locale output byte for byte. Integers stay
488488
* verbatim here for the same measured reason as there.
489489
*/
490+
/**
491+
* The currency one measure CELL displays in.
492+
*
493+
* A column the answer marks with `currencyField` — a grouped answer over a
494+
* `record`-mode amount (ADR-0144 D9) — has no currency of its own: each row's
495+
* is the code in the result column `currencyField` names. So `row` is the RAW
496+
* row (before `relabelDimensions`), and a row with no code there renders a plain
497+
* number, never another row's symbol nor a column-wide one. Every other column
498+
* keeps its declared `currency`, byte for byte.
499+
*
500+
* A cell with no row of its own (a total, a cross-tab cell) passes none and so
501+
* gets no symbol on such a column: it may add amounts in several currencies.
502+
*
503+
* `currencyField` is read structurally: it is objectstack#27338's addition to
504+
* `AnalyticsResult.fields[]`, which the spec type here does not carry yet.
505+
*/
506+
export function measureCellCurrency(
507+
field: DatasetResultField | undefined,
508+
row?: Readonly<Record<string, unknown>>,
509+
): string | undefined {
510+
const codeColumn = (field as { currencyField?: unknown } | undefined)?.currencyField;
511+
if (typeof codeColumn !== 'string' || codeColumn === '') return field?.currency;
512+
const code = row?.[codeColumn];
513+
return typeof code === 'string' && code !== '' ? code : undefined;
514+
}
515+
490516
export function formatDimensionValue(v: unknown, locale?: string): string {
491517
if (v == null) return '—';
492518
if (typeof v === 'number') {

‎ui/packages/plugin-dashboard/src/DatasetWidget.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import {
5252
dimensionOptionTranslator,
5353
findChartSeriesRow,
5454
formatMeasure,
55+
measureCellCurrency,
5556
formatDimensionValue,
5657
buildDatasetFieldHelpers,
5758
buildDatasetDrillFilter,
@@ -1902,7 +1903,7 @@ export function DatasetWidget({ widget, dataSource, heading, onForbidden }: {
19021903
return (
19031904
<td key={c} className={cn('px-2 py-1 whitespace-nowrap tabular-nums', numericColumns.has(c) && 'text-right')}>
19041905
{measure
1905-
? formatMeasure(row[c], measureField(measure)?.format, measureField(measure)?.currency, measureField(measure)?.percentScale, displayLocale)
1906+
? formatMeasure(row[c], measureField(measure)?.format, measureCellCurrency(measureField(measure), state.rows[index]), measureField(measure)?.percentScale, displayLocale)
19061907
: formatDimensionValue(row[c], displayLocale)}
19071908
</td>
19081909
);
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* objectstack#27188 — a dashboard `table` over a grouped `record`-mode answer
3+
* shows each row's amount in that row's own currency (ADR-0144 D9). The
4+
* measure column names the result column holding the code (`currencyField`,
5+
* objectstack#27338); the grand-total cell, which may add several currencies,
6+
* gets no symbol.
7+
*/
8+
import { describe, it, expect, vi, afterEach } from 'vitest';
9+
import { render, screen, cleanup, waitFor } from '@testing-library/react';
10+
import { DatasetWidget } from '../DatasetWidget';
11+
12+
vi.mock('../DrillDownDrawer', () => ({ DrillDownDrawer: () => null }));
13+
14+
/** Plain TEXT dimension field, no `options` — nothing here relabels. */
15+
const OPPORTUNITY = { name: 'opportunity', fields: { currency_code: { type: 'text' } } };
16+
17+
afterEach(() => {
18+
cleanup();
19+
vi.unstubAllGlobals();
20+
vi.restoreAllMocks();
21+
});
22+
23+
const source = () => ({
24+
queryDataset: vi.fn(async () => ({
25+
rows: [
26+
{ currency_code: 'USD', amount_sum: 1234.5 },
27+
{ currency_code: 'EUR', amount_sum: 99 },
28+
{ currency_code: null, amount_sum: 7 },
29+
],
30+
fields: [
31+
{ name: 'currency_code', type: 'text', label: 'Currency' },
32+
{ name: 'amount_sum', type: 'number', label: 'Amount', format: '0.00', currencyField: 'currency_code' },
33+
],
34+
totals: [{ dimensions: [] as string[], rows: [{ amount_sum: 1340.5 }] }],
35+
})),
36+
});
37+
38+
const TABLE_WIDGET = { type: 'table', dataset: 'amount_by_currency', dimensions: ['currency_code'], values: ['amount_sum'] };
39+
40+
const amountColumn = () =>
41+
Array.from(document.querySelectorAll('tbody tr')).map((r) => r.querySelectorAll('td')[1]?.textContent?.trim() ?? '');
42+
43+
describe('DatasetWidget table — record-mode amounts (ADR-0144 D9)', () => {
44+
it('formats each row in its own currency, and a row without a code as a plain number', async () => {
45+
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ item: OPPORTUNITY }) })));
46+
render(<DatasetWidget widget={TABLE_WIDGET} dataSource={source()} />);
47+
await screen.findByText('Amount');
48+
await waitFor(() => expect(amountColumn()).toEqual(['$1,234.50', '€99.00', '7.00']));
49+
});
50+
51+
it('gives the grand total no symbol', async () => {
52+
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ item: OPPORTUNITY }) })));
53+
render(<DatasetWidget widget={TABLE_WIDGET} dataSource={source()} />);
54+
const foot = await screen.findByTestId('dataset-table-total-row');
55+
expect(foot.querySelectorAll('td')[1]?.textContent?.trim()).toBe('1,340.50');
56+
});
57+
});

0 commit comments

Comments
 (0)