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
36 changes: 36 additions & 0 deletions .changeset/10276-currency-fraction-digits-not-precision.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
'@object-ui/fields': minor
---

`CurrencyField` takes its fraction digits from the currency, never from the
field-level `precision` (objectui#10276).

`@objectstack/spec` declares a field's `precision` as "Total digits
(non-negative integer)" — the `p` of a `decimal(p, s)` column, so a
DECIMAL(18,2) amount is `precision: 18, scale: 2`. The currency edit widget
passed that count to `Intl` as the number of decimal places, so a currency
field declaring `precision: 18` rendered eighteen decimal places, offered a
`step` of `0.000000000000000001` and rounded typed input to eighteen places on
blur. The currency cell renderer does not read `precision` and is unaffected.

The widget's one width is now the resolved currency's own ISO 4217 minor-unit
count — two decimals for USD or CNY, none for JPY, three for KWD — and it drives
the read-only display, the input's `step` and the blur rounding alike. A field
with no resolvable currency keeps two decimals. The widget does not read `scale`
for the width either (it never did), in line with the ruling on
objectstack-ai/objectstack#19629 that takes `scale` off the `currency` type.

**Behaviour change** for currency fields that declare `precision`: the declared
value no longer sets the decimal places shown, the `step`, or the blur rounding.
Where it differed from the currency's own digit count (or from two decimals on a
field with no currency), the output moves: `precision: 2` on a JPY field used to
show `¥1,234.50` and now shows `¥1,235`; `precision: 0` on a USD field used to
show `$1,235` and now shows `$1,234.50`. This reverses the "an explicitly
authored `precision` still wins" rule objectui#4361 published, under the
maintainer ruling recorded on objectstack-ai/objectstack#19910 that a
currency's decimal places are the currency's, not a setting. Fields that declare
no `precision`, or one equal to the currency's own digit count, render exactly
as before.

**Migration.** Nothing to restate: a currency field's decimal places follow its
currency. Keep `precision` only as the total digit count of the stored decimal.
11 changes: 5 additions & 6 deletions .changeset/9568-percent-widget-reads-scale.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,8 @@ to make widget-versus-cell agreement a premise, and closing that gap needs its
own ruling rather than arriving as a side effect of moving the member that is
read.

`CurrencyField`'s own read of `precision` is untouched and is not the same
shape: there the competing source is the currency's ISO 4217 minor-unit count,
and objectui#4361 ruled an authored `precision` wins over that. It ruled nothing
about `scale`. `CurrencyConfigSchema.precision` is a third surface again, with
the opposite convention and its own `scale` alias, and the spec warns against
conflating it with the field face.
`CurrencyField` is not part of this change: a currency's decimal places are the
currency's own ISO 4217 minor-unit count, and since objectui#10276 that widget
reads neither `precision` nor `scale` for them. `CurrencyConfigSchema.precision`
is a third surface again, with the opposite convention and its own `scale`
alias, and the spec warns against conflating it with the field face.
10 changes: 8 additions & 2 deletions content/docs/fields/currency.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ The Currency Field component provides a formatted currency input with proper loc

A currency field is authored as `CurrencyFieldMetadata` (`@object-ui/types`), which is
the source of truth for the key set: it extends `BaseFieldMetadata` with the currency
code, the decimal precision and the two numeric bounds.
code, the total-digit `precision` and the two numeric bounds.

```ts
import type { CurrencyFieldMetadata } from '@object-ui/types';
Expand All @@ -29,12 +29,18 @@ const amount: CurrencyFieldMetadata = {
placeholder: '0.00',
required: true,
currency: 'USD',
precision: 2,
min: 0,
max: 1000000,
};
```

**Decimal places come from the currency, not from a field key.** An amount shows its
currency's own ISO 4217 minor unit — two decimals for USD or CNY, none for JPY, three
for KWD — in the read-only display, in the input's `step` and in the rounding applied
when the input loses focus. `precision` is the total digit count of the stored decimal
(a DECIMAL(18,2) amount declares `precision: 18`), so it never sets how many decimals are
shown. With no currency resolved at all, the amount shows two decimals.

The value being edited, and the `className` / `disabled` a host supplies, are **not**
metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props).

Expand Down
163 changes: 107 additions & 56 deletions packages/fields/src/__tests__/CurrencyField.minorUnits.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,44 +7,35 @@
*/

/**
* objectui#4361, second path — `CurrencyField`'s `precision`.
* `CurrencyField`'s fraction-digit width — two cards, one rule.
*
* `formatAmount` passes the field's `precision` to BOTH `Intl` bounds, and the
* widget defaulted that precision to a literal `2`. So a JPY field that
* declared no precision rendered `¥1,234.50` for the same reason
* `formatCurrency` did, one layer up.
* objectui#4361, second path: `formatAmount` passes the widget's width to
* BOTH `Intl` bounds, and the widget defaulted that width to a literal `2`. So
* a JPY field rendered `¥1,234.50` for the same reason `formatCurrency` did,
* one layer up. That card made the width derive from the currency's own
* ISO 4217 minor-unit count — pinned below, unchanged.
*
* The PM ruling on this card (issue #4361, claim comment) splits it in two,
* and both halves are pinned here:
* objectui#10276: that card ALSO let an authored field-level `precision` win
* over the currency. `@objectstack/spec` declares that key as "Total digits
* (non-negative integer)" — the `p` of a decimal(p, s) column, so a
* DECIMAL(18,2) amount is `precision: 18, scale: 2` — and the widget passed it
* to both `Intl` bounds as decimal places, rendering eighteen of them. The
* maintainer ruling recorded on objectstack-ai/objectstack#19910 (batch #218
* item 2) names that reading as wrong: a currency's decimal places are the
* currency's, not a setting. The pins that used to say "an authored
* `precision` wins" are REWRITTEN below to the ruled rule, not deleted: the
* same inputs, now asserting that `precision` moves nothing.
*
* 1. **An explicitly authored `precision` WINS.** It is authored metadata and
* this repo's convention is that authored metadata keeps priority. A JPY
* field declaring `precision: 2` still renders `¥1,234.50` — whether that
* combination should be REJECTED at publish time is a spec question filed
* upstream (contract-first), not something the renderer decides by
* overriding the author.
* 2. **An ABSENT `precision` derives from the currency** instead of falling
* back to 2.
* `scale` is not a currency width either — the ruling on
* objectstack-ai/objectstack#19629 (letter B) takes it off the `currency` type,
* and `CurrencyFieldMetadata` in `@object-ui/types` does not declare it — so a
* declared `scale` is pinned as inert too.
*
* MEASURED, per the ruling's stop condition — is "absent" distinguishable from
* "authored 2" by the time the renderer sees it? Yes:
*
* - `CurrencyFieldMetadata.precision` is `precision?: number` in
* `packages/types/src/field-types.ts` — optional, no default;
* - the field-level key is `z.ZodOptional<z.ZodNumber>` in
* `@objectstack/spec` (no `.default()`), so a parsed field metadata object
* does NOT carry a materialized 2;
* - the only `.default(2)` in the spec's currency surface is on
* `CurrencyConfigSchema.precision` — a DIFFERENT key on a different object
* (the `currencyConfig` block), which this widget never reads; and
* - the `?? 2` itself lived in the widget, at `CurrencyField.tsx`.
*
* So the renderer holds an `undefined` it can act on, and this half lands.
*
* The derived precision is the widget's ONE precision, so it reaches the edit
* affordances too (`step`, and the blur rounding) — pinned below. Leaving
* those at 2 would have produced a JPY field that displays whole yen but
* offers a 0.01 spinner step and rounds typed input to 1234.56 yen.
* The width is the widget's ONE width, so it reaches the edit affordances too
* (`step`, and the blur rounding) — pinned below. Leaving those at 2 would
* produce a JPY field that displays whole yen but offers a 0.01 spinner step
* and rounds typed input to 1234.56 yen; reading `precision` there would give
* a DECIMAL(18,2) amount a `1e-18` step.
*/

import { describe, it, expect, vi } from 'vitest';
Expand Down Expand Up @@ -79,13 +70,13 @@ const renderField = (
</LocalizationProvider>,
);

describe('CurrencyField — an ABSENT precision derives from the currency (objectui#4361)', () => {
it('a JPY field that declares no precision shows no cents', () => {
describe('CurrencyField — fraction digits derive from the currency (objectui#4361)', () => {
it('a JPY field shows no cents', () => {
const { container } = renderField(1234.5, { currency: 'JPY' }, { readonly: true });
expect(normalizeNbsp(container.textContent)).toBe('¥1,235');
});

it('a KWD field that declares no precision shows all three digits', () => {
it('a KWD field shows all three digits', () => {
const { container } = renderField(1.5, { currency: 'KWD' }, { readonly: true });
expect(normalizeNbsp(container.textContent)).toBe('KWD 1.500');
});
Expand All @@ -103,27 +94,71 @@ describe('CurrencyField — an ABSENT precision derives from the currency (objec
);
expect(normalizeNbsp(container.textContent)).toBe('KWD 1.500');
});

it('a zero-digit currency keeps its grouping separators', () => {
// Zero fraction digits is a WIDTH, not the ordinal reading
// `formatDisplayNumber` gives `scale: 0` (objectui#4033): an amount keeps
// its thousands separators.
const { container } = renderField(1234567, { currency: 'JPY' }, { readonly: true });
expect(normalizeNbsp(container.textContent)).toBe('¥1,234,567');
});
});

describe('CurrencyField — an AUTHORED precision still wins (authored metadata keeps priority)', () => {
it('a JPY field declaring `precision: 2` keeps its two digits', () => {
// The renderer does not overrule the author. Whether publish-time
// validation should reject this combination is the upstream spec question
// filed alongside this card — deliberately NOT decided here.
describe('CurrencyField — the field-level `precision` is a TOTAL-digit count and sets no fraction digits (objectui#10276)', () => {
it('a DECIMAL(18,2) amount — `precision: 18, scale: 2` — shows two decimals, not eighteen', () => {
const { container } = renderField(
1234.5,
{ currency: 'USD', precision: 18, scale: 2 },
{ readonly: true },
);
expect(normalizeNbsp(container.textContent)).toBe('$1,234.50');
});

it('`precision: 18` alone falls to the currency\'s own digits — USD', () => {
const { container } = renderField(1234.5, { currency: 'USD', precision: 18 }, { readonly: true });
expect(normalizeNbsp(container.textContent)).toBe('$1,234.50');
});

it('`precision: 18` alone falls to the currency\'s own digits — CNY', () => {
const { container } = renderField(1234.5, { currency: 'CNY', precision: 18 }, { readonly: true });
expect(normalizeNbsp(container.textContent)).toBe('CN¥1,234.50');
});

// The three pins below are the objectui#4361 "an authored `precision` wins"
// pins, REWRITTEN to the ruled rule on the same inputs: each used to assert
// the authored count and now asserts the currency's.
it('a JPY field declaring `precision: 2` shows whole yen', () => {
// Was `¥1,234.50`.
const { container } = renderField(1234.5, { currency: 'JPY', precision: 2 }, { readonly: true });
expect(normalizeNbsp(container.textContent)).toBe('¥1,234.50');
expect(normalizeNbsp(container.textContent)).toBe('¥1,235');
});

it('an authored `precision: 0` on a 2-digit currency still wins', () => {
// The other direction, so the pin cannot be satisfied by "authored wins
// only when it asks for MORE digits".
it('a `precision: 0` on a 2-digit currency keeps its cents', () => {
// Was `$1,235`. The other direction, so the pin cannot be satisfied by
// "`precision` is ignored only when it asks for MORE digits".
const { container } = renderField(1234.5, { currency: 'USD', precision: 0 }, { readonly: true });
expect(normalizeNbsp(container.textContent)).toBe('$1,235');
expect(normalizeNbsp(container.textContent)).toBe('$1,234.50');
});

it('an authored `precision: 3` on JPY wins', () => {
it('a `precision: 3` on JPY shows whole yen', () => {
// Was `¥1,234.500`.
const { container } = renderField(1234.5, { currency: 'JPY', precision: 3 }, { readonly: true });
expect(normalizeNbsp(container.textContent)).toBe('¥1,234.500');
expect(normalizeNbsp(container.textContent)).toBe('¥1,235');
});
});

describe('CurrencyField — a declared `scale` sets no fraction digits either', () => {
// The ruling on objectstack-ai/objectstack#19629 (letter B) takes `scale`
// off the `currency` type and `CurrencyFieldMetadata` does not declare it,
// so the widget does not read it: the currency's own digits hold.
it('`scale: 0` on a USD field keeps its cents', () => {
const { container } = renderField(1234.5, { currency: 'USD', scale: 0 }, { readonly: true });
expect(normalizeNbsp(container.textContent)).toBe('$1,234.50');
});

it('`scale: 2` on a JPY field shows whole yen', () => {
const { container } = renderField(1234.5, { currency: 'JPY', scale: 2 }, { readonly: true });
expect(normalizeNbsp(container.textContent)).toBe('¥1,235');
});
});

Expand All @@ -145,29 +180,36 @@ describe('CurrencyField — CONTROL: the 2-digit and no-currency cases are uncha
expect(normalizeNbsp(container.textContent)).toBe('1,234.50');
});

it('a field with NO currency ignores `precision` too', () => {
const { container } = renderField(1234.5, { precision: 18 }, { readonly: true });
expect(normalizeNbsp(container.textContent)).toBe('1,234.50');
});

it('a null value is still the empty marker, not a formatted zero', () => {
const { container } = renderField(null, { currency: 'JPY' }, { readonly: true });
expect(container.textContent).not.toContain('¥');
});
});

describe('CurrencyField — the derived precision is the widget\'s ONE precision', () => {
it('a JPY field with no declared precision offers a whole-unit spinner step', () => {
describe('CurrencyField — the derived width is the widget\'s ONE width', () => {
it('a JPY field offers a whole-unit spinner step', () => {
renderField(1234, { currency: 'JPY' });
expect(screen.getByRole('spinbutton')).toHaveAttribute('step', '1');
});

it('a KWD field with no declared precision offers a three-digit step', () => {
it('a KWD field offers a three-digit step', () => {
renderField(1, { currency: 'KWD' });
expect(screen.getByRole('spinbutton')).toHaveAttribute('step', '0.001');
});

it('CONTROL: USD is still 0.01, authored or derived', () => {
const { unmount } = renderField(1, { currency: 'USD' });
it('the step follows the currency whatever `precision` declares', () => {
// Rewritten from "USD is still 0.01, authored or derived", whose second
// half pinned `precision: 2` on JPY to a 0.01 step.
const { unmount } = renderField(1, { currency: 'USD', precision: 18 });
expect(screen.getByRole('spinbutton')).toHaveAttribute('step', '0.01');
unmount();
renderField(1, { currency: 'JPY', precision: 2 });
expect(screen.getByRole('spinbutton')).toHaveAttribute('step', '0.01');
expect(screen.getByRole('spinbutton')).toHaveAttribute('step', '1');
});

it('blur rounds a typed amount to the currency\'s own unit', () => {
Expand All @@ -176,10 +218,19 @@ describe('CurrencyField — the derived precision is the widget\'s ONE precision
const input = screen.getByRole('spinbutton');
fireEvent.change(input, { target: { value: '1234.56' } });
fireEvent.blur(input, { target: { value: '1234.56' } });
// Before: 1234.56 yen — a sub-unit amount the currency cannot express.
// Before objectui#4361: 1234.56 yen — a sub-unit amount the currency cannot express.
expect(onChange).toHaveBeenLastCalledWith(1235);
});

it('blur on a `precision: 18` USD field rounds to cents', () => {
const onChange = vi.fn();
renderField(null, { currency: 'USD', precision: 18 }, { onChange });
const input = screen.getByRole('spinbutton');
fireEvent.change(input, { target: { value: '1234.567' } });
fireEvent.blur(input, { target: { value: '1234.567' } });
expect(onChange).toHaveBeenLastCalledWith(1234.57);
});

it('CONTROL: blur on a USD field still rounds to cents', () => {
const onChange = vi.fn();
renderField(null, { currency: 'USD' }, { onChange });
Expand Down
Loading
Loading