From a542ac62cc8657087ad3002c4d736cd9cbab8fd4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 14:38:57 +0000 Subject: [PATCH 1/4] fix(app-shell): format currency, number and percent values in the record history Changes entry Route numeric field types in auditHistoryDisplay formatScalar through the field renderer's own formatters (formatCurrency + resolveFieldCurrency, formatNumberFieldValue, formatPercent + percentCellScale), so the History tab reads a stored amount the way the record page does. Claude-Session: https://claude.ai/code/session_01Utp6CXcn7RbCYknff87Ke1 Co-authored-by: Claude --- ...istoryDisplay.numericAmounts-27486.test.ts | 115 ++++++++++++++++++ .../src/utils/auditHistoryDisplay.ts | 65 +++++++++- ui/packages/fields/src/index.tsx | 9 ++ .../fields/src/widgets/number-format.ts | 5 +- 4 files changed, 191 insertions(+), 3 deletions(-) create mode 100644 ui/packages/app-shell/src/utils/__tests__/auditHistoryDisplay.numericAmounts-27486.test.ts diff --git a/ui/packages/app-shell/src/utils/__tests__/auditHistoryDisplay.numericAmounts-27486.test.ts b/ui/packages/app-shell/src/utils/__tests__/auditHistoryDisplay.numericAmounts-27486.test.ts new file mode 100644 index 00000000000..edc6fde9e68 --- /dev/null +++ b/ui/packages/app-shell/src/utils/__tests__/auditHistoryDisplay.numericAmounts-27486.test.ts @@ -0,0 +1,115 @@ +/** + * 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 type { FieldMetadata } from '@object-ui/types'; +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, amount as FieldMetadata, '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')); + }); +}); diff --git a/ui/packages/app-shell/src/utils/auditHistoryDisplay.ts b/ui/packages/app-shell/src/utils/auditHistoryDisplay.ts index 0fa58c96460..4f61b54242c 100644 --- a/ui/packages/app-shell/src/utils/auditHistoryDisplay.ts +++ b/ui/packages/app-shell/src/utils/auditHistoryDisplay.ts @@ -19,10 +19,20 @@ * 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 { FieldMetadata } from '@object-ui/types'; +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 { @@ -57,6 +67,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>; } @@ -213,9 +231,54 @@ 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 as number | undefined }); + return formatPercent(value, percentCellScale(def), scale, ctx.locale); + } + return formatNumberFieldValue(value, def as FieldMetadata, 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'; diff --git a/ui/packages/fields/src/index.tsx b/ui/packages/fields/src/index.tsx index 2af51d1dfc3..6459144d27b 100644 --- a/ui/packages/fields/src/index.tsx +++ b/ui/packages/fields/src/index.tsx @@ -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 diff --git a/ui/packages/fields/src/widgets/number-format.ts b/ui/packages/fields/src/widgets/number-format.ts index 43f03cd4a40..48dfd4d2db5 100644 --- a/ui/packages/fields/src/widgets/number-format.ts +++ b/ui/packages/fields/src/widgets/number-format.ts @@ -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) From c00377b3e7c42e95407e94ef21be30338c81388b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 14:54:42 +0000 Subject: [PATCH 2/4] fix(app-shell): type the numeric arm's field members and add the changeset Claude-Session: https://claude.ai/code/session_01Utp6CXcn7RbCYknff87Ke1 Co-authored-by: Claude --- .changeset/27486-history-numeric-amounts.md | 14 ++++++++++ ...istoryDisplay.numericAmounts-27486.test.ts | 3 +-- .../src/utils/auditHistoryDisplay.ts | 26 ++++++++++++++++--- 3 files changed, 38 insertions(+), 5 deletions(-) create mode 100644 .changeset/27486-history-numeric-amounts.md diff --git a/.changeset/27486-history-numeric-amounts.md b/.changeset/27486-history-numeric-amounts.md new file mode 100644 index 00000000000..84cee60eaad --- /dev/null +++ b/.changeset/27486-history-numeric-amounts.md @@ -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-②: no +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. diff --git a/ui/packages/app-shell/src/utils/__tests__/auditHistoryDisplay.numericAmounts-27486.test.ts b/ui/packages/app-shell/src/utils/__tests__/auditHistoryDisplay.numericAmounts-27486.test.ts index edc6fde9e68..cb12e86e6b1 100644 --- a/ui/packages/app-shell/src/utils/__tests__/auditHistoryDisplay.numericAmounts-27486.test.ts +++ b/ui/packages/app-shell/src/utils/__tests__/auditHistoryDisplay.numericAmounts-27486.test.ts @@ -19,7 +19,6 @@ import { formatNumberFieldValue, formatPercent, } from '@object-ui/fields'; -import type { FieldMetadata } from '@object-ui/types'; import { formatAuditValue, type AuditFieldDef } from '../auditHistoryDisplay'; const annualRevenue: AuditFieldDef = { @@ -77,7 +76,7 @@ describe('formatAuditValue — number', () => { 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, amount as FieldMetadata, 'en-US')); + 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', () => { diff --git a/ui/packages/app-shell/src/utils/auditHistoryDisplay.ts b/ui/packages/app-shell/src/utils/auditHistoryDisplay.ts index 4f61b54242c..1f5e566314e 100644 --- a/ui/packages/app-shell/src/utils/auditHistoryDisplay.ts +++ b/ui/packages/app-shell/src/utils/auditHistoryDisplay.ts @@ -24,7 +24,8 @@ */ import { toDisplayDate } from '@object-ui/core'; -import type { FieldMetadata } from '@object-ui/types'; +import type { NumberFieldMetadata } from '@object-ui/types'; +import type { FieldCurrencyConfig } from '@object-ui/i18n'; import { formatCurrency, formatNumberFieldValue, @@ -49,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; } @@ -263,10 +275,18 @@ function formatNumericAmount(type: string, def: AuditFieldDef, value: number, ct 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 as number | undefined }); + const scale = resolveFieldScale({ type, scale: def.scale }); return formatPercent(value, percentCellScale(def), scale, ctx.locale); } - return formatNumberFieldValue(value, def as FieldMetadata, 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']); From fded0c5f7b9c8d859ddd2c619d23f50341bf08ee Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 15:26:10 +0000 Subject: [PATCH 3/4] test(ui): list the record history helper among the percent storage callers Claude-Session: https://claude.ai/code/session_01Utp6CXcn7RbCYknff87Ke1 Co-authored-by: Claude --- ui/scripts/__tests__/percent-storage-callers-11475.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ui/scripts/__tests__/percent-storage-callers-11475.test.ts b/ui/scripts/__tests__/percent-storage-callers-11475.test.ts index 20dc1d11a23..4d0a21510af 100644 --- a/ui/scripts/__tests__/percent-storage-callers-11475.test.ts +++ b/ui/scripts/__tests__/percent-storage-callers-11475.test.ts @@ -46,6 +46,9 @@ interface Call { /** Every caller, by file, in source order within each file. */ const CALLERS: Record = { + '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)' }, ], From 62d62ff21758229180d8b08d6b1224b3c1d3b5f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 15:49:41 +0000 Subject: [PATCH 4/4] chore(changeset): declare the published-surface widening of @object-ui/fields Claude-Session: https://claude.ai/code/session_01Utp6CXcn7RbCYknff87Ke1 Co-authored-by: Claude --- .changeset/27486-history-numeric-amounts.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/27486-history-numeric-amounts.md b/.changeset/27486-history-numeric-amounts.md index 84cee60eaad..0f72ea8f9da 100644 --- a/.changeset/27486-history-numeric-amounts.md +++ b/.changeset/27486-history-numeric-amounts.md @@ -5,7 +5,7 @@ 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-②: no +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`).