Skip to content

Commit ea9d17f

Browse files
fix(i18n): a dynamic-mode currency field shows the tenant currency, not its defaultCurrency (#10422) (#10461)
Fixes #10422 Clause-②: yes A currency field in `dynamic` mode now shows the tenant's currency on every face, not its `currencyConfig.defaultCurrency`. `resolveFieldCurrency` in `@object-ui/i18n` reads `currencyConfig.defaultCurrency` only when `currencyMode` is `'fixed'`. Its parameter type gains `currencyConfig.currencyMode?: 'dynamic' | 'fixed'`, the spec's enum. No other export changes. In a USD tenant, a field declaring `currencyConfig: { currencyMode: 'dynamic', defaultCurrency: 'EUR' }` read `€3,456`; it now reads `$3,456`. An empty `currencyConfig`, which the spec parses to a dynamic CNY config, read `CN¥`; it now reads `$`. A `fixed` field renders as before. ## The contract, quoted from the installed spec `@objectstack/spec` 17.4.0, the version in objectui's lockfile, `CurrencyConfigSchema`: - `currencyMode: z.enum(['dynamic', 'fixed']).default('dynamic').describe('Currency mode: dynamic (user selectable) or fixed (single currency)')` - `defaultCurrency: z.string().length(3).default('CNY').describe('Default or fixed currency code (ISO 4217, e.g., USD, CNY, EUR)')` `FieldSchema` guidance for the key `currency`, which it refuses: "`currency` is not a field key — a fixed currency is declared as `currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'JPY' }`. A field without one uses the tenant default at runtime." Measured by parsing through that installed spec from this worktree: | input | result | |:--|:--| | `CurrencyConfigSchema.parse({})` | `{"precision":2,"currencyMode":"dynamic","defaultCurrency":"CNY"}` | | `CurrencyConfigSchema.parse({ defaultCurrency: 'EUR' })` | `{"precision":2,"currencyMode":"dynamic","defaultCurrency":"EUR"}`, so an absent mode is `dynamic` | | `CurrencyConfigSchema.parse({ currencyMode: 'fixed', defaultCurrency: 'JPY' })` | `{"currencyMode":"fixed","defaultCurrency":"JPY"}` | | `FieldSchema` with a top-level `defaultCurrency: 'GBP'` | refused, `unrecognized_keys` | | `FieldSchema` with a top-level `currency: 'GBP'` | refused, `unrecognized_keys`, with the guidance above | ## The precedence (tenant USD) | step | the field carries | the resolver returns | why | |:--|:--|:--|:--| | 1 | an explicit `currency` | that code | Not a `FieldSchema` key, but the spec declares `currency` on the metric widget and on dataset measures, and looser column configs carry it. Kept first: nothing in the spec says otherwise. | | 2 | `currencyConfig` with `currencyMode: 'fixed'` | its `defaultCurrency` | The spec's one fixed-currency declaration. | | skipped | `currencyConfig` with `currencyMode: 'dynamic'` | falls through | The `FieldSchema` guidance: the tenant default. ⛔ Not `defaultCurrency`. | | skipped | `currencyConfig` with no `currencyMode` | falls through | The spec defaults the mode to `dynamic` (measured above). | | skipped | the spec-parsed empty config (`dynamic`, `CNY`) | falls through | Never `CN¥`. | | 3 | a legacy top-level `defaultCurrency` | that code | A pre-spec spelling: `6c3f018a8` added it as the "canonical top-level" key, which `FieldSchema` now refuses as unrecognized. Kept in its place. | | 4 | none of the above | the tenant default | `localization.currency`, ADR-0053 | | 5 | no tenant default either | `undefined` | The amount shows as a plain number. | Triage point 1 says a value carrying its own user-selected currency should win. Nothing carries one today: the spec deprecates `CurrencyValueSchema`, and a currency value is a bare number. So there is nothing to implement. The docblock says where such a code would rank: ahead of the tenant default. ## Consumers: what each call site passes | face | call | what it passes | does `currencyMode` reach the resolver? | |:--|:--|:--|:--| | list cell, `CurrencyCellRenderer` (`@object-ui/fields`) | `resolveFieldCurrency(field as any, tenantCurrency)` | the cell's field bag as handed | yes. The whole def on auto-generated grid columns and the detail panel. The three configured grid paths copy `currencyConfig` verbatim since objectui#10354. | | field widget, `CurrencyField` | `resolveFieldCurrency(currencyField, tenantCurrency)` | the whole `field` prop | yes | | metric tile, `ObjectMetricWidget` | `resolveFieldCurrency(valueFieldDef, tenantCurrency)` | the object schema's def for `aggregate.field` | yes | | detail summary chip, `DetailView` | `resolveFieldCurrency({ ...objField, ...sectionField }, tenantCurrency)` | the object def and the section field, merged | yes. A section-level `currencyConfig` replaces the object's as a whole. | | gantt tooltip, `ObjectGantt` | `resolveFieldCurrency(def as any, tenantCurrency)` | the object schema's def | yes | | grid summary footer, `useColumnSummary` | `resolveFieldCurrency(column, tenantDefault)` | hints carrying `currencyConfig: meta?.currencyConfig` verbatim | yes | | `GridField` computed currency column | `resolveFieldCurrency(undefined, tenantCurrency)` | nothing, by design (objectui#10355) | not applicable, unchanged | | `ObjectGrid` mobile card amount | `resolveFieldCurrency(amountCol as any, tenantCurrency)` | a table column that carries no currency key | not applicable. See Out of scope. | No consumer needed a change. Every consumer that reads a `currencyConfig` passes it through whole. ## Pins - **At the resolver** (`packages/i18n/src/__tests__/resolveFieldCurrency.test.ts`): the truth table under a USD tenant. It covers fixed, dynamic, parsed-empty (parsed through the installed spec, and as a literal), no mode (the spec's default is read from the spec itself), dynamic with no tenant default, fixed with no code, an explicit `currency` over both modes, the legacy `defaultCurrency` next to each mode, and none. A control row feeds the showcase configs in objectstack at `246314df`, all `fixed` USD (`account`, `field-zoo`, `semantic-zoo`), under a JPY tenant: they read USD. - **One agreement row per face:** list cell and field widget (`packages/fields/src/__tests__/currencyMode.faces-10422.test.tsx`), metric tile (`ObjectMetricWidget.currencyMode-10422.test.tsx`), detail chip (`summaryChip.currencyMode-10422.test.tsx`), gantt tooltip (`ObjectGantt.currencyMode-10422.test.tsx`), and grid summary (`useColumnSummary.currencyMode-10422.test.tsx`). Each row asserts two things. A dynamic field, and a spec-parsed empty config, read exactly what a field with no `currencyConfig` reads on that face. That reference is itself pinned to the tenant `$` as absolute bytes. A fixed JPY field keeps `¥`. The grid's configured cells are held to the whole-def cell by `gridFixedCurrency-10354.test.tsx`, which stays green. Its `dyn` row now reads `$3,456` on both sides. - **Tests the fix made false:** `packages/fields/src/__tests__/resolveFieldCurrency.test.ts` and `CurrencyField.minorUnits.test.tsx` used a mode-less `currencyConfig` to stand for a fixed one. They now say `currencyMode: 'fixed'`. The re-export test also gains a dynamic row. ## Ablation (on committed code, `34795f4b4`) The mutation was the mode read, removed with `ablation-replace.mjs`. The replacement text: `(config?.currencyMode === 'fixed' ? config.defaultCurrency : undefined)` became `config?.defaultCurrency`. - On disk: anchor `x1 -> x0`, replacement `x0 -> x1`, blob `f192e57d0f84 -> af1b29163c2f`. Inside the run, `grep -c` found the mode read 0 times and the ablated line once. - The resolver resolves through the vitest alias to `packages/i18n/src`, so no build or `dist` step applies. - Result over the 8 pin files: `Tests 19 failed | 43 passed (62)`. Every dynamic and parsed-empty row went red, on the resolver and on all six faces, as did the resolver's no-mode, dynamic-with-no-tenant and legacy-with-dynamic rows. Every fixed and control row stayed green. - Predicted versus observed: I predicted the legacy row GREEN and observed it RED. Its second assertion pairs the legacy key with a dynamic config, whose EUR the base read first. The pin's header records this. - Restore: blob after restore `f192e57d0f84` equals the blob at HEAD, and `git diff HEAD` is empty. **Reverse type check** (at `8380484d6`, after the closure build). I wrote `currencyMode: 'fixd'` into the dynamic row of `packages/fields/src/__tests__/resolveFieldCurrency.test.ts`. That file imports the resolver through `@object-ui/fields` from `@object-ui/i18n`'s rebuilt `dist` (`paths: {}`). `tsc -p tsconfig.test.json` then fails with `TS2820: Type '"fixd"' is not assignable to type '"fixed" | "dynamic" | undefined'. Did you mean '"fixed"'?`, and `--listFilesOnly` lists both test files. Restored by blob equality and an empty `git diff HEAD`. ## Local verification Final head: `593f65aa3`. It differs from `8380484d6` by one line: a regex in `currencyMode.faces-10422.test.tsx` now spells U+00A0 as an escape instead of a raw byte. The same regex matches the same text. - **At `593f65aa3`:** - the 8 pin files, plus `gridFixedCurrency-10354`, plus the two tests that read `currency.ts` as text (`machineLocaleCensus-9909`, `CurrencyField.symbol`): all passed; - `pnpm --filter @object-ui/fields type-check`: exit 0; - `pnpm check:control-bytes`: OK; - `pnpm check:new-line-citations`: 0 new; - `node scripts/check-changeset-presence.mjs`: 9 source files of 6 released packages, 1 changeset declared; - `pnpm check:changeset-claims`: no pending changeset names a touched file, and the two bodies this PR adds or edits are self-consistent; - `check-changeset-no-major`: exit 0; - `check-changeset-overwrite` (report-only): it reports the intended objectui#10354 body edit; - `check:phantom-deps`, `check:vi-mock-specifiers`, `check:vi-mock-inherit`, `check:vi-mock-override-shape`, `check:test-path-roots`, `check:pending-changeset-literals`: all exit 0. - **At `8380484d6`,** from the repo root: `vitest run packages/i18n/ packages/fields/` gave 257 files passed and 1 skipped, 4280 tests passed and 7 skipped. `packages/plugin-dashboard/ packages/plugin-detail/ packages/plugin-gantt/` gave 406 passed and 1 skipped, 3857 tests passed and 8 skipped. `packages/plugin-grid/` ran in two shards: 77 + 77 files, 687 + 786 tests passed. - **Type-check at `8380484d6`,** after `turbo run build` over the dependency closure of fields, plugin-dashboard, plugin-detail, plugin-gantt and plugin-grid (14 tasks, i18n included): `type-check` exited 0 for `@object-ui/i18n`, `fields`, `plugin-dashboard`, `plugin-detail`, `plugin-gantt` and `plugin-grid`. - **ESLint** over the 9 touched `.ts`/`.tsx` files: 0 errors. The 4 modified files keep their base counts exactly: `no-explicit-any` warnings 3, 1, 1 and 0. The 5 new test files add 10 `no-explicit-any` warnings, the idiom of the suites they copy (the `GanttView` stub, `as any` data sources). ## The objectui#10354 changeset (bounded in-place fix) Its "Behaviour change" paragraph said a `dynamic` field shows `defaultCurrency` on the grid, "because `resolveFieldCurrency` does not read `currencyMode`". That changeset is still pending, so this PR would publish it false at the next release. The paragraph now says a field whose `currencyConfig` is in `fixed` mode shows its code, and a `dynamic` field still shows the tenant currency. The front matter is byte-identical to the base (`od -c`). The four conditions for an in-place fix: 1. It is the same defect: the sentence describes the resolver this PR replaces. 2. The replacement text follows mechanically from the pinned truth table. 3. No open claim holds the file: objectui#10354 is merged. 4. It falls in the same gate family: changesets. This file is outside the claim's declared file surface. The report says so. ## Acceptance notes - Several comments restate the precedence without the `fixed` condition: the `CurrencyCellRenderer` comment in `packages/fields/src/index.tsx`, `CurrencyField.tsx`, the `columnCurrency` docblock in `GridField.tsx`, and a `buildDefaultPageSchema.ts` comment. They are now incomplete, not wrong about any key. Left alone, because they are outside this claim's file surface. Carrier: none named. - No objectui doc describes currency resolution, so no guide or README page needed an update. ## Out of scope (reported to the seat, not fixed here) - The `ObjectGrid` mobile card amount and the dashboard `ObjectDataTable` cell (`buildFieldMeta` in `recordFields.tsx`) never hand the resolver a `currencyConfig`. Throwaway probes, deleted afterwards, measured both under a USD tenant with a fixed-JPY `1234`: the desktop grid read `¥1,234`, the mobile card read `$1.2K`, and the dashboard table cell read `$1,234`. - objectstack's `skills/objectstack-data/rules/field-types.md` states the resolution chain without the mode, and describes `dynamic` as a per-record `{ value, currency }`. The spec says a currency value is a bare number. --- _Generated by [Claude Code](https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 808f339 commit ea9d17f

11 files changed

Lines changed: 710 additions & 18 deletions

‎.changeset/10354-grid-fixed-currency-cell-footer.md‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,9 @@ type of `useColumnSummary` gains an optional `currencyConfig` member, typed as
2424
the spec's own `CurrencyConfig`. The footer reads `currencyConfig` from the
2525
field only: `ListColumnSchema` declares no such column key.
2626

27-
**Behaviour change.** On those paths, a field that declares `currencyConfig`
28-
now shows `currencyConfig.defaultCurrency` in the cell and the footer where it
29-
used to show the tenant currency. That includes a field in `dynamic` mode,
30-
because `resolveFieldCurrency` does not read `currencyMode`. The grid's
31-
auto-generated columns already showed that currency.
27+
**Behaviour change.** On those paths, a field whose `currencyConfig` is in
28+
`fixed` mode now shows `currencyConfig.defaultCurrency` in the cell and the
29+
footer where it used to show the tenant currency. A field in `dynamic` mode
30+
still shows the tenant currency, because `resolveFieldCurrency` reads
31+
`currencyConfig.defaultCurrency` only in `fixed` mode (objectui#10422). The
32+
grid's auto-generated columns already showed a fixed field's currency.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
'@object-ui/i18n': minor
3+
---
4+
5+
A currency field in `dynamic` mode shows the tenant's currency, not its
6+
`currencyConfig.defaultCurrency` (objectui#10422).
7+
8+
`@objectstack/spec` declares two currency modes: `fixed` (a single currency) and
9+
`dynamic` (user selectable). Its field guidance says a field without a fixed
10+
currency uses the tenant default at runtime. `resolveFieldCurrency` read
11+
`currencyConfig.defaultCurrency` in either mode, so in a USD tenant a field
12+
declaring `currencyConfig: { currencyMode: 'dynamic', defaultCurrency: 'EUR' }`
13+
showed `€3,456` where it should show `$3,456`. An empty `currencyConfig`, which
14+
the spec parses to a dynamic config with `defaultCurrency: 'CNY'`, showed
15+
`CN¥`.
16+
17+
The resolver now reads `currencyConfig.defaultCurrency` only when
18+
`currencyMode` is `'fixed'`. Its precedence is: the explicit `currency`, then a
19+
fixed `currencyConfig.defaultCurrency`, then the legacy top-level
20+
`defaultCurrency`, then the tenant default. With none of these, it returns
21+
`undefined` and the amount shows as a plain number.
22+
23+
**Behaviour change.** Every face that resolves a currency through
24+
`resolveFieldCurrency` changes together: the list cell, the field widget, the
25+
metric tile, the detail summary chip, the gantt tooltip, and the grid's
26+
configured cells and summary footer. On each of them, a field in `dynamic` mode
27+
now shows the tenant currency. So does a `currencyConfig` that names no
28+
`currencyMode`, because the spec's default mode is `dynamic`. To keep a field on
29+
one currency, declare `currencyMode: 'fixed'`. A fixed field renders as before.
30+
31+
The parameter type of `resolveFieldCurrency` gains an optional
32+
`currencyConfig.currencyMode` member (`'dynamic' | 'fixed'`), which is the
33+
spec's enum.

‎packages/fields/src/__tests__/CurrencyField.minorUnits.test.tsx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,10 +86,12 @@ describe('CurrencyField — fraction digits derive from the currency (objectui#4
8686
expect(normalizeNbsp(container.textContent)).toBe('¥1,235');
8787
});
8888

89-
it('`currencyConfig.defaultCurrency` reaches it as well', () => {
89+
it('a fixed `currencyConfig.defaultCurrency` reaches it as well', () => {
90+
// `fixed` mode, the spec's one fixed-currency declaration: a dynamic or
91+
// mode-less config reads the tenant default instead (objectui#10422).
9092
const { container } = renderField(
9193
1.5,
92-
{ currencyConfig: { defaultCurrency: 'KWD' } },
94+
{ currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'KWD' } },
9395
{ readonly: true },
9496
);
9597
expect(normalizeNbsp(container.textContent)).toBe('KWD 1.500');
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
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+
* objectui#10422, the two `@object-ui/fields` faces: the list cell
11+
* (`CurrencyCellRenderer`) and the field widget (`CurrencyField`).
12+
*
13+
* Both hand the WHOLE field def to `resolveFieldCurrency`, whose truth table is
14+
* pinned in `@object-ui/i18n` beside it. These rows hold each face to it:
15+
* under a USD tenant, a `dynamic` field, and an empty config as the spec parses
16+
* it, read exactly what a field with no `currencyConfig` reads (the tenant's
17+
* `$`), while a `fixed` field keeps its own code.
18+
*
19+
* ── Directions on the base tree (predicted before the first run) ─────────
20+
* dynamic reads the tenant currency RED (read `€`)
21+
* parsed-empty reads the tenant currency RED (read `CN¥`)
22+
* control: fixed JPY reads `¥` GREEN (untouched)
23+
*/
24+
25+
import { describe, it, expect, afterEach } from 'vitest';
26+
import React from 'react';
27+
import { render, cleanup } from '@testing-library/react';
28+
import '@testing-library/jest-dom';
29+
import { CurrencyConfigSchema } from '@objectstack/spec/data';
30+
import { LocalizationProvider } from '@object-ui/i18n';
31+
import { CurrencyCellRenderer } from '../index';
32+
import { CurrencyField } from '../widgets/CurrencyField';
33+
34+
afterEach(cleanup);
35+
36+
/** A USD tenant: a field that names no currency of its own reads `$`. */
37+
const TENANT = { locale: 'en-US', currency: 'USD' };
38+
39+
const FIELDS: Record<string, Record<string, unknown>> = {
40+
fixed: { type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'JPY' } },
41+
dynamic: { type: 'currency', currencyConfig: { currencyMode: 'dynamic', defaultCurrency: 'EUR' } },
42+
// What the spec makes of an authored `currencyConfig: {}`: a dynamic CNY config.
43+
parsedEmpty: { type: 'currency', currencyConfig: CurrencyConfigSchema.parse({}) },
44+
// The reference: no `currencyConfig`, so the tenant currency is right.
45+
plain: { type: 'currency' },
46+
};
47+
48+
const VALUE = 3456;
49+
50+
/** ICU puts U+00A0 between a currency CODE and the amount; keep rows readable. */
51+
const text = (s: string | null) => (s ?? '').replace(/\u00a0/g, ' ');
52+
53+
const FACES: Array<[string, (field: Record<string, unknown>, value: number) => string]> = [
54+
[
55+
'CurrencyCellRenderer (the list cell)',
56+
(field, value) => {
57+
const { container, unmount } = render(
58+
<LocalizationProvider value={TENANT}>
59+
<CurrencyCellRenderer value={value} field={{ name: 'amount', ...field } as any} />
60+
</LocalizationProvider>,
61+
);
62+
const out = text(container.textContent);
63+
unmount();
64+
return out;
65+
},
66+
],
67+
[
68+
'CurrencyField (the field widget, read-only)',
69+
(field, value) => {
70+
const { container, unmount } = render(
71+
<LocalizationProvider value={TENANT}>
72+
<CurrencyField value={value} onChange={() => {}} field={{ name: 'amount', ...field } as any} readonly />
73+
</LocalizationProvider>,
74+
);
75+
const out = text(container.textContent);
76+
unmount();
77+
return out;
78+
},
79+
],
80+
];
81+
82+
describe.each(FACES)('%s agrees with the resolver on currencyMode (objectui#10422)', (_face, show) => {
83+
it('dynamic: reads what a field with no currencyConfig reads, the tenant $', () => {
84+
const reference = show(FIELDS.plain, VALUE);
85+
expect(reference).toMatch(/^\$3,456/);
86+
expect(show(FIELDS.dynamic, VALUE)).toBe(reference);
87+
});
88+
89+
it('parsed-empty: an empty config, as the spec parses it, reads the tenant $, never CN¥', () => {
90+
const reference = show(FIELDS.plain, VALUE);
91+
const shown = show(FIELDS.parsedEmpty, VALUE);
92+
expect(shown).not.toContain('CN¥');
93+
expect(shown).toBe(reference);
94+
});
95+
96+
it('control: a fixed JPY field keeps its own ¥', () => {
97+
expect(show(FIELDS.fixed, 1234)).toBe('¥1,234');
98+
});
99+
});

‎packages/fields/src/__tests__/resolveFieldCurrency.test.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,21 @@ import { resolveFieldCurrency } from '../index';
33

44
describe('resolveFieldCurrency', () => {
55
it('prefers the field explicit currency over everything', () => {
6-
expect(resolveFieldCurrency({ currency: 'JPY', currencyConfig: { defaultCurrency: 'EUR' } }, 'USD')).toBe('JPY');
6+
expect(resolveFieldCurrency({ currency: 'JPY', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'EUR' } }, 'USD')).toBe('JPY');
77
});
8-
it('falls back to currencyConfig.defaultCurrency, then legacy defaultCurrency', () => {
9-
expect(resolveFieldCurrency({ currencyConfig: { defaultCurrency: 'EUR' } }, 'USD')).toBe('EUR');
8+
it('falls back to a fixed currencyConfig.defaultCurrency, then legacy defaultCurrency', () => {
9+
expect(resolveFieldCurrency({ currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'EUR' } }, 'USD')).toBe('EUR');
1010
expect(resolveFieldCurrency({ defaultCurrency: 'GBP' } as any, 'USD')).toBe('GBP');
1111
});
1212
it('falls back to the tenant default when the field omits its own', () => {
1313
expect(resolveFieldCurrency({}, 'CNY')).toBe('CNY');
1414
expect(resolveFieldCurrency(null, 'CNY')).toBe('CNY');
1515
});
16+
it('a dynamic currencyConfig reads the tenant default, not its defaultCurrency (objectui#10422)', () => {
17+
// The re-export is the same function: the full truth table is pinned in
18+
// `@object-ui/i18n`, beside the resolver.
19+
expect(resolveFieldCurrency({ currencyConfig: { currencyMode: 'dynamic', defaultCurrency: 'EUR' } }, 'USD')).toBe('USD');
20+
});
1621
it('returns undefined when nothing is known (→ renderer shows a plain number)', () => {
1722
expect(resolveFieldCurrency({})).toBeUndefined();
1823
expect(resolveFieldCurrency(undefined, undefined)).toBeUndefined();

‎packages/i18n/src/__tests__/resolveFieldCurrency.test.ts‎

Lines changed: 114 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, it, expect } from 'vitest';
2+
import { CurrencyConfigSchema } from '@objectstack/spec/data';
23
import { resolveFieldCurrency } from '../index';
34

45
/**
@@ -9,12 +10,12 @@ import { resolveFieldCurrency } from '../index';
910
describe('resolveFieldCurrency (i18n canonical home)', () => {
1011
it('prefers the field explicit currency over everything', () => {
1112
expect(
12-
resolveFieldCurrency({ currency: 'JPY', currencyConfig: { defaultCurrency: 'EUR' } }, 'USD'),
13+
resolveFieldCurrency({ currency: 'JPY', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'EUR' } }, 'USD'),
1314
).toBe('JPY');
1415
});
1516

16-
it('falls back to currencyConfig.defaultCurrency, then legacy defaultCurrency', () => {
17-
expect(resolveFieldCurrency({ currencyConfig: { defaultCurrency: 'EUR' } }, 'USD')).toBe('EUR');
17+
it('falls back to a fixed currencyConfig.defaultCurrency, then legacy defaultCurrency', () => {
18+
expect(resolveFieldCurrency({ currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'EUR' } }, 'USD')).toBe('EUR');
1819
expect(resolveFieldCurrency({ defaultCurrency: 'GBP' } as any, 'USD')).toBe('GBP');
1920
});
2021

@@ -29,3 +30,113 @@ describe('resolveFieldCurrency (i18n canonical home)', () => {
2930
expect(resolveFieldCurrency(undefined, undefined)).toBeUndefined();
3031
});
3132
});
33+
34+
/**
35+
* objectui#10422: `currencyConfig.currencyMode` decides whether
36+
* `currencyConfig.defaultCurrency` is the field's currency.
37+
*
38+
* `@objectstack/spec` names two modes: `fixed` (single currency) and `dynamic`
39+
* (user selectable), and its `FieldSchema` guidance says a field without a
40+
* fixed currency "uses the tenant default at runtime". The resolver used to
41+
* read `defaultCurrency` in either mode, so a dynamic EUR field read `€` in a
42+
* USD tenant, and an EMPTY config, which the spec parses to a dynamic CNY
43+
* config, read `CN¥`.
44+
*
45+
* Every row runs under a USD tenant, so a row that reads anything but `USD`
46+
* took its code from the field.
47+
*
48+
* ── Directions on the base tree (predicted before the first run, then observed) ──
49+
* fixed GREEN (the base read it anyway)
50+
* dynamic RED (read EUR)
51+
* parsed-empty, both spellings RED (read CNY)
52+
* no mode (the spec defaults it dynamic) RED (read EUR)
53+
* dynamic with no tenant default RED (read EUR, not undefined)
54+
* explicit `currency`, none GREEN (untouched legs)
55+
* legacy `defaultCurrency` RED
56+
* Predicted: the legacy row GREEN. Observed: RED. Its second assertion pairs
57+
* the legacy key with a DYNAMIC config, whose EUR the base tree read first.
58+
*/
59+
describe('currencyMode decides whether currencyConfig.defaultCurrency is read (objectui#10422)', () => {
60+
const TENANT = 'USD';
61+
62+
it('fixed: the config code is the field currency', () => {
63+
expect(resolveFieldCurrency({ currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'JPY' } }, TENANT)).toBe('JPY');
64+
});
65+
66+
it('dynamic: the tenant default, not the config code', () => {
67+
expect(resolveFieldCurrency({ currencyConfig: { currencyMode: 'dynamic', defaultCurrency: 'EUR' } }, TENANT)).toBe(
68+
'USD',
69+
);
70+
});
71+
72+
it('parsed-empty: an empty config, as the spec parses it, reads the tenant default, never CNY', () => {
73+
// Parsed through the installed spec rather than restated, so this row keeps
74+
// meaning "whatever the spec materializes for an empty config".
75+
const parsed = CurrencyConfigSchema.parse({});
76+
expect(parsed.currencyMode, 'the spec defaults an empty config to dynamic').toBe('dynamic');
77+
expect(parsed.defaultCurrency, 'the spec fills in a code of its own').not.toBe(TENANT);
78+
expect(resolveFieldCurrency({ currencyConfig: parsed }, TENANT)).toBe('USD');
79+
});
80+
81+
it('parsed-empty: the materialized shape as a renderer receives it', () => {
82+
expect(
83+
resolveFieldCurrency({ currencyConfig: { currencyMode: 'dynamic', defaultCurrency: 'CNY' } }, TENANT),
84+
).toBe('USD');
85+
});
86+
87+
it('no mode: an authored config without currencyMode reads as dynamic, the spec default', () => {
88+
const authored = { defaultCurrency: 'EUR' };
89+
expect(CurrencyConfigSchema.parse(authored).currencyMode, 'the spec defaults an absent mode to dynamic').toBe(
90+
'dynamic',
91+
);
92+
expect(resolveFieldCurrency({ currencyConfig: authored }, TENANT)).toBe('USD');
93+
});
94+
95+
it('dynamic with no tenant default: undefined (a plain number), never the config code', () => {
96+
expect(resolveFieldCurrency({ currencyConfig: { currencyMode: 'dynamic', defaultCurrency: 'EUR' } })).toBeUndefined();
97+
});
98+
99+
it('fixed with no code: falls through to the tenant default', () => {
100+
expect(resolveFieldCurrency({ currencyConfig: { currencyMode: 'fixed' } }, TENANT)).toBe('USD');
101+
});
102+
103+
it('explicit `currency`: wins over a fixed config and over a dynamic one', () => {
104+
expect(
105+
resolveFieldCurrency({ currency: 'KWD', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'JPY' } }, TENANT),
106+
).toBe('KWD');
107+
expect(
108+
resolveFieldCurrency({ currency: 'KWD', currencyConfig: { currencyMode: 'dynamic', defaultCurrency: 'EUR' } }, TENANT),
109+
).toBe('KWD');
110+
});
111+
112+
it('legacy top-level `defaultCurrency`: after a fixed config, ahead of the tenant default', () => {
113+
expect(
114+
resolveFieldCurrency(
115+
{ defaultCurrency: 'GBP', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'JPY' } },
116+
TENANT,
117+
),
118+
).toBe('JPY');
119+
expect(
120+
resolveFieldCurrency(
121+
{ defaultCurrency: 'GBP', currencyConfig: { currencyMode: 'dynamic', defaultCurrency: 'EUR' } },
122+
TENANT,
123+
),
124+
).toBe('GBP');
125+
});
126+
127+
it('none: the tenant default, or undefined when there is none', () => {
128+
expect(resolveFieldCurrency({}, TENANT)).toBe('USD');
129+
expect(resolveFieldCurrency({})).toBeUndefined();
130+
});
131+
132+
it('control: the objectstack showcase configs (all fixed USD) still read USD under a JPY tenant', () => {
133+
// The three `currencyConfig` authors in objectstack's showcase example, as
134+
// written there. Under a JPY tenant, reading USD proves the fixed leg.
135+
for (const currencyConfig of [
136+
{ precision: 2, currencyMode: 'fixed', defaultCurrency: 'USD' },
137+
{ currencyMode: 'fixed', defaultCurrency: 'USD', precision: 2 },
138+
] as const) {
139+
expect(resolveFieldCurrency({ currencyConfig }, 'JPY')).toBe('USD');
140+
}
141+
});
142+
});

‎packages/i18n/src/currency.ts‎

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,35 @@
88

99
/**
1010
* Resolve the display currency code for a currency-typed field, in precedence
11-
* order: the field's explicit `currency` -> its `currencyConfig.defaultCurrency`
12-
* (fixed mode) -> a legacy top-level `defaultCurrency` -> the tenant default
13-
* (`localization.currency`, ADR-0053, surfaced via {@link useLocalization}).
11+
* order:
12+
*
13+
* 1. the field's explicit `currency` — a per-node display code. The spec
14+
* declares it on presentation surfaces (the metric widget's `currency`, a
15+
* dataset measure's `currency`) and looser grid/column configs carry it;
16+
* `FieldSchema` itself refuses it as a field key;
17+
* 2. `currencyConfig.defaultCurrency`, ONLY when `currencyConfig.currencyMode`
18+
* is `'fixed'` — the spec's one fixed-currency declaration;
19+
* 3. a legacy top-level `defaultCurrency` — a pre-spec spelling that
20+
* `FieldSchema` refuses as an unrecognized key, kept in place so configs
21+
* written against it still read their code;
22+
* 4. the tenant default (`localization.currency`, ADR-0053, surfaced via
23+
* {@link useLocalization}).
24+
*
1425
* Returns `undefined` when none is known, so the renderer shows a plain number
1526
* rather than guessing a symbol.
1627
*
28+
* `currencyMode: 'dynamic'` — and a `currencyConfig` with no `currencyMode` at
29+
* all, which `@objectstack/spec` defaults to `'dynamic'` — skips step 2
30+
* (objectui#10422). The spec describes that mode as "user selectable", and its
31+
* `FieldSchema` guidance says a field without a fixed currency "uses the tenant
32+
* default at runtime". No value carries a user-selected code today (a currency
33+
* value is a bare number), so a dynamic field reads the tenant default, never
34+
* its `defaultCurrency`. That matters beyond authored dynamic configs: the
35+
* spec parses an EMPTY `currencyConfig` to
36+
* `{ currencyMode: 'dynamic', defaultCurrency: 'CNY' }`, which must not render
37+
* as yuan. If a value ever carries its own selected code, that code is the one
38+
* to win, ahead of the tenant default.
39+
*
1740
* This is the single resolution every field / measure / cell renderer shares -
1841
* ending the per-renderer drift where some read only `currency`, others only
1942
* `defaultCurrency`, and others `currencyConfig`. It lives in `@object-ui/i18n`
@@ -22,14 +45,19 @@
2245
*/
2346
export function resolveFieldCurrency(
2447
field:
25-
| { currency?: string; defaultCurrency?: string; currencyConfig?: { defaultCurrency?: string } }
48+
| {
49+
currency?: string;
50+
defaultCurrency?: string;
51+
currencyConfig?: { currencyMode?: 'dynamic' | 'fixed'; defaultCurrency?: string };
52+
}
2653
| null
2754
| undefined,
2855
tenantDefault?: string,
2956
): string | undefined {
57+
const config = field?.currencyConfig;
3058
return (
3159
field?.currency ||
32-
field?.currencyConfig?.defaultCurrency ||
60+
(config?.currencyMode === 'fixed' ? config.defaultCurrency : undefined) ||
3361
field?.defaultCurrency ||
3462
tenantDefault ||
3563
undefined

0 commit comments

Comments
 (0)