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
14 changes: 14 additions & 0 deletions .changeset/27486-history-numeric-amounts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@object-ui/app-shell': patch
'@object-ui/fields': patch
---

fix(app-shell): the record history "Changes" entry formats a currency, number or percent value the way the record page formats that field, instead of printing the raw stored number

Clause-②: yes (widening)
Metadata-protocol: none

- **Before.** Editing a currency field to `4321000` showed `$4,321,000.00` on the record but `— → 4321000` in Activity › Timeline. Number and percent fields printed their raw stored value the same way (`1234.5`, `0.25`).
- **Now.** The History tab formats these three types through the record page's own formatters in `@object-ui/fields`: `formatCurrency` with the code `resolveFieldCurrency` resolves, `formatNumberFieldValue` (declared `scale`, grouping hint) and `formatPercent` at the field's declared storage and width, in the viewer's display locale. A fixed-currency field reads `$4,321,000.00`.
- **Currency modes.** A `fixed` field shows its declared code. A `dynamic` field shows the tenant default when the History tab's caller passes one (`tenantCurrency` on the format context), and a plain grouped number when it does not. A `record`-mode field shows a plain grouped number: its code lives in a sibling field of the row, which a per-value format call is not handed, and the tenant default is never substituted for it.
- **`@object-ui/fields`.** `formatNumberFieldValue` is now exported from the package entry (it was module-internal), and its `locale` parameter is optional like its siblings `formatCurrency` and `formatPercent`. No rendering changes.
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* auditHistoryDisplay — the History tab's Changes entry formats a currency,
* number or percent value the way the record page formats that field.
*
* The record page's cells (`CurrencyCellRenderer`, `NumberCellRenderer`,
* `PercentCellRenderer`) format through `formatCurrency`,
* `formatNumberFieldValue` and `formatPercent` in `@object-ui/fields`; the
* History tab printed the raw stored number instead (`4321000` where the
* record header read `$4,321,000.00`). Each case below states the record
* page's text as a literal and checks the shared formatter agrees with it, so
* the literal is the cell's answer and not this helper's own.
*/
import { describe, it, expect } from 'vitest';
import {
formatCurrency,
formatNumberFieldValue,
formatPercent,
} from '@object-ui/fields';
import { formatAuditValue, type AuditFieldDef } from '../auditHistoryDisplay';

const annualRevenue: AuditFieldDef = {
type: 'currency',
label: 'Annual Revenue',
currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' },
};

describe('formatAuditValue — currency', () => {
it('formats a fixed-currency amount as the record page does', () => {
const out = formatAuditValue(annualRevenue, 4321000, { locale: 'en-US' });
expect(out).toBe('$4,321,000.00');
expect(out).toBe(formatCurrency(4321000, 'USD', 'en-US'));
// Both sides of the change, the old value included.
expect(formatAuditValue(annualRevenue, 8000000, { locale: 'en-US' })).toBe('$8,000,000.00');
});

it("reads the currency's own minor-unit width and the viewer's locale", () => {
const yen: AuditFieldDef = { type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'JPY' } };
expect(formatAuditValue(yen, 4321000, { locale: 'en-US' })).toBe(formatCurrency(4321000, 'JPY', 'en-US'));
expect(formatAuditValue(yen, 4321000, { locale: 'en-US' })).not.toContain('.');
const de = formatAuditValue(annualRevenue, 1234.5, { locale: 'de-DE' });
expect(de).toBe(formatCurrency(1234.5, 'USD', 'de-DE'));
expect(de).toContain('1.234,50');
});

it('reads a decimal column stored as its numeric string', () => {
expect(formatAuditValue(annualRevenue, '4321000.5', { locale: 'en-US' })).toBe('$4,321,000.50');
});

it('shows a dynamic-mode amount in the tenant default, and a plain number without one', () => {
const dynamic: AuditFieldDef = { type: 'currency', currencyConfig: { currencyMode: 'dynamic' } };
expect(formatAuditValue(dynamic, 1500, { locale: 'en-US', tenantCurrency: 'EUR' })).toBe(
formatCurrency(1500, 'EUR', 'en-US'),
);
// No code known: the formatter's own no-code face, never a guessed symbol.
expect(formatAuditValue(dynamic, 1500, { locale: 'en-US' })).toBe('1,500.00');
});

it('shows a record-mode amount as a plain number: its code lives on the row, not in this call', () => {
const recordMode: AuditFieldDef = {
type: 'currency',
currencyConfig: { currencyMode: 'record', currencyField: 'currency_code' },
};
// The tenant default is NOT a fallback for a record-mode amount.
expect(formatAuditValue(recordMode, 1500, { locale: 'en-US', tenantCurrency: 'EUR' })).toBe(
formatCurrency(1500, undefined, 'en-US'),
);
expect(formatAuditValue(recordMode, 1500, { locale: 'en-US', tenantCurrency: 'EUR' })).toBe('1,500.00');
});
});

describe('formatAuditValue — number', () => {
it('pads to the declared scale and groups', () => {
const amount: AuditFieldDef = { type: 'number', scale: 2 };
const out = formatAuditValue(amount, 1234.5, { locale: 'en-US' });
expect(out).toBe('1,234.50');
expect(out).toBe(formatNumberFieldValue(1234.5, { name: 'amount', type: 'number', scale: 2 }, 'en-US'));
});

it('keeps a scale-0 number ungrouped, as the cell does for a year', () => {
const year: AuditFieldDef = { type: 'number', scale: 0 };
expect(formatAuditValue(year, 2026, { locale: 'en-US' })).toBe('2026');
});

it("follows the viewer's locale", () => {
const amount: AuditFieldDef = { type: 'number', scale: 2 };
expect(formatAuditValue(amount, 1234.5, { locale: 'de-DE' })).toBe('1.234,50');
});

it('keeps a value that is not a number as it was stored', () => {
expect(formatAuditValue({ type: 'number' }, 'n/a', { locale: 'en-US' })).toBe('n/a');
});
});

describe('formatAuditValue — percent', () => {
it('reads a fraction-stored percent at its declared scale', () => {
const rate: AuditFieldDef = { type: 'percent', scale: 1 };
const out = formatAuditValue(rate, 0.25, { locale: 'en-US' });
expect(out).toBe('25.0%');
expect(out).toBe(formatPercent(0.25, 'fraction', 1, 'en-US'));
});

it('reads a whole-stored percent when the field declares max above 1', () => {
const points: AuditFieldDef = { type: 'percent', scale: 0, max: 100 };
expect(formatAuditValue(points, 25, { locale: 'en-US' })).toBe('25%');
});

it("follows the viewer's locale percent convention", () => {
const rate: AuditFieldDef = { type: 'percent', scale: 1 };
expect(formatAuditValue(rate, 0.25, { locale: 'de-DE' })).toBe(formatPercent(0.25, 'fraction', 1, 'de-DE'));
});
});
85 changes: 84 additions & 1 deletion ui/packages/app-shell/src/utils/auditHistoryDisplay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,21 @@
* 2. drop no-op changes where both sides are empty (null vs '' vs []),
* 3. format values by field type (dates localized, booleans as Yes/No,
* select values mapped to option labels, lookup ids mapped to record
* names via a caller-resolved id → label map).
* names via a caller-resolved id → label map, currency / number / percent
* amounts through the field renderer's own formatters).
*/

import { toDisplayDate } from '@object-ui/core';
import type { NumberFieldMetadata } from '@object-ui/types';
import type { FieldCurrencyConfig } from '@object-ui/i18n';
import {
formatCurrency,
formatNumberFieldValue,
formatPercent,
percentCellScale,
resolveFieldCurrency,
} from '@object-ui/fields';
import { resolveFieldScale } from '@objectstack/spec/data';

/** Minimal structural view of an object field definition. */
export interface AuditFieldDef {
Expand All @@ -39,6 +50,17 @@ export interface AuditFieldDef {
* only ever describes an object metadata document.
*/
reference?: string;
/**
* The members the field renderer's numeric formatters read
* ({@link formatScalar}'s currency / number / percent arm): `scale` the
* declared decimal places (`number` / `percent`), `max` the percent storage
* hint, `useGrouping` the author's grouping hint, `currencyConfig` the
* currency mode and code.
*/
scale?: number;
max?: number;
useGrouping?: boolean;
currencyConfig?: FieldCurrencyConfig;
[k: string]: unknown;
}

Expand All @@ -57,6 +79,14 @@ export interface AuditValueFormatContext {
* runtime default answers.
*/
locale?: string;
/**
* The tenant's default currency code (`useLocalization().currency`,
* ADR-0053): the last step of `resolveFieldCurrency`'s precedence, which a
* `dynamic`-mode currency field with no code of its own displays in.
* Absent, such an amount reads as a plain number, the formatter's no-code
* face, never a guessed symbol.
*/
tenantCurrency?: string;
/** target object name → (record id → display label), for lookup fields. */
lookupLabels?: Map<string, Map<string, string>>;
}
Expand Down Expand Up @@ -213,9 +243,62 @@ export function collectLookupIds(
return out;
}

/**
* A stored numeric value as a number, or `null` when it is not one. The audit
* payload carries the raw stored value: a JS number, or a decimal column read
* back as its numeric string.
*/
const asFiniteNumber = (v: unknown): number | null => {
const n = typeof v === 'number' ? v : typeof v === 'string' && v.trim() !== '' ? Number(v) : NaN;
return Number.isFinite(n) ? n : null;
};

/**
* A `currency` / `number` / `percent` amount, formatted by the calls the
* record page's cells make for that field (`CurrencyCellRenderer`,
* `NumberCellRenderer`, `PercentCellRenderer` in `@object-ui/fields`), so the
* History tab reads one stored value the way the record does. ⛔ No formatter
* of this helper's own: these are the shared ones, by reference.
*
* Currency resolves its code through `resolveFieldCurrency`, the cells' one
* precedence: a `fixed` field reads its declared code and a `dynamic` one the
* tenant default in {@link AuditValueFormatContext.tenantCurrency}. A
* `record`-mode field (ADR-0144 D2) keeps its code in a sibling field of the
* row, which this per-value call is not handed, so it resolves to no code and
* reads as the formatter's plain number: the resolver's own answer for a call
* with no record, never a code the amount may not be in.
*/
function formatNumericAmount(type: string, def: AuditFieldDef, value: number, ctx: AuditValueFormatContext): string {
if (type === 'currency') {
return formatCurrency(value, resolveFieldCurrency(def, ctx.tenantCurrency), ctx.locale);
}
if (type === 'percent') {
// The pair `PercentCellRenderer` asks: the declared width through the
// protocol's resolver, and the declared storage (fraction unless `max` > 1).
const scale = resolveFieldScale({ type, scale: def.scale });
return formatPercent(value, percentCellScale(def), scale, ctx.locale);
}
// The members `formatNumberFieldValue` reads, handed over by name: an object
// metadata document's field carries no `name` of its own (it is the key).
const numberField: NumberFieldMetadata = {
name: '',
type: 'number',
scale: def.scale,
useGrouping: def.useGrouping,
};
return formatNumberFieldValue(value, numberField, ctx.locale);
}

const NUMERIC_AMOUNT_TYPES = new Set(['currency', 'number', 'percent']);

function formatScalar(def: AuditFieldDef | undefined, value: unknown, ctx: AuditValueFormatContext): string {
const type = def?.type;

if (def && typeof type === 'string' && NUMERIC_AMOUNT_TYPES.has(type)) {
const num = asFiniteNumber(value);
if (num !== null) return formatNumericAmount(type, def, num, ctx);
}

if (type === 'boolean' || typeof value === 'boolean') {
const truthy = value === true || value === 'true' || value === 1;
const key = truthy ? 'common.yes' : 'common.no';
Expand Down
9 changes: 9 additions & 0 deletions ui/packages/fields/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,15 @@ export { resolveFieldCurrency };
*/
export { formatCurrency };

/**
* `formatNumberFieldValue` lives in `./widgets/number-format.ts` and is
* re-exported here so a face outside this package that renders a `number`
* field's stored value (the record History tab's change list) makes the same
* call `NumberCellRenderer` and `NumberField`'s read-only branch make, rather
* than a formatter of its own.
*/
export { formatNumberFieldValue };

/**
* Format currency value in compact form for mobile display.
* E.g., $150,000 → $150K, $1,200,000 → $1.2M
Expand Down
5 changes: 3 additions & 2 deletions ui/packages/fields/src/widgets/number-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,10 @@ import { currencyFractionDigits } from '../currency.js';
* not judge one.
* @param field the field metadata; its `type`, `scale` and `useGrouping` are
* the inputs read.
* @param locale the display locale tag, from `useDisplayLocale()`.
* @param locale the display locale tag, from `useDisplayLocale()`. Absent,
* the runtime default answers, as for {@link formatCurrency}.
*/
export function formatNumberFieldValue(value: number, field: FieldMetadata, locale: string): string {
export function formatNumberFieldValue(value: number, field: FieldMetadata, locale?: string): string {
// Decimal places come from `scale` (the `s` in a `decimal(p, s)` column),
// NOT `precision`: `precision` is the TOTAL digit count (`p`), and reading
// it padded every value out to that width (e.g. `1` from a decimal(10, 0)
Expand Down
3 changes: 3 additions & 0 deletions ui/scripts/__tests__/percent-storage-callers-11475.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ interface Call {

/** Every caller, by file, in source order within each file. */
const CALLERS: Record<string, Call[]> = {
'packages/app-shell/src/utils/auditHistoryDisplay.ts': [
{ fn: 'formatPercent', storage: 'percentCellScale(def)', source: 'field in hand', why: 'record History tab: a percent change formatted as the percent cell formats it' },
],
'packages/core/src/utils/dataset-format.ts': [
{ fn: 'percentDisplayValue', storage: "percentScale ?? 'fraction'", source: 'stated: no field', why: 'formatMeasure: the server annotation, else the `%` pattern (numeral: a fraction)' },
],
Expand Down
Loading