Skip to content

Commit d220b9b

Browse files
fix(app-shell): format currency, number and percent values in the record history Changes entry (#27638)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg54Jh9FAXMnvRAdp2wREPH5)_ ## 维护者速读 - 改了什么:记录详情 Activity › Timeline 的 "Changes" 条目里,货币、数字、百分比字段不再显示原始存储值(如 `4321000`),改为与记录页同样的格式(如 `$4,321,000.00`)。 - 为什么改:历史条目与记录头部对同一个值显示两种样子,用户会以为数据有问题;格式化统一走字段渲染器已有的格式化函数,不另写一套。 - 风险与回滚:只影响历史面板的显示文本,不改存储、不改记录页;回滚即 revert 本 PR。record 模式货币因单次格式化拿不到行上的币种字段,显示为不带符号的数字。 - 先看:`ui/packages/app-shell/src/utils/auditHistoryDisplay.ts` 的 `formatNumericAmount`;`ui/packages/fields/src/index.tsx` 新增的 `formatNumberFieldValue` 导出。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/utils/__tests__/auditHistoryDisplay.numericAmounts-27486.test.ts`,预期 12 passed。 - 需要你做的:无,席位落地。 Fixes #27486 Clause-②: no Metadata-protocol: none ## What changes - `auditHistoryDisplay.ts` `formatScalar()` gains an arm for `currency`, `number` and `percent` fields. A finite stored value (a JS number, or a decimal column's numeric string) is formatted by the calls the record page's cells make, by reference: - `currency`: `formatCurrency(value, resolveFieldCurrency(def, ctx.tenantCurrency), ctx.locale)`, the pair `CurrencyCellRenderer` uses. - `number`: `formatNumberFieldValue(value, field, ctx.locale)`, the call `NumberCellRenderer` and `NumberField`'s read-only branch make (declared `scale`, `useGrouping`). - `percent`: `formatPercent(value, percentCellScale(def), resolveFieldScale(...), ctx.locale)`, the pair `PercentCellRenderer` uses (declared storage and width). A value that is not a number keeps the existing `String(value)` face. - `AuditFieldDef` declares the members those formatters read (`scale`, `max`, `useGrouping`, `currencyConfig`), so the arm needs no cast. - `AuditValueFormatContext` gains an optional `tenantCurrency`, the last step of `resolveFieldCurrency`'s precedence. - `@object-ui/fields`: `formatNumberFieldValue` is exported from the package entry (it was module-internal), and its `locale` parameter is optional like `formatCurrency`'s and `formatPercent`'s. No rendering change there. - `ui/scripts/__tests__/percent-storage-callers-11475.test.ts`: the repo-wide pin over every `formatPercent` caller gains the new call's row (storage `percentCellScale(def)`, field in hand). The pin went red on the first gate run because the population on disk grew by this file, which is its job. - Changeset: `patch` for `@object-ui/app-shell` and `@object-ui/fields`. ### Currency modes, measured against `resolveFieldCurrency` - `fixed`: the declared code. The card's `showcase_account.annual_revenue` is `fixed` USD, so its repro now reads `$4,321,000.00`. - `dynamic`: the tenant default when the caller passes `tenantCurrency`; without it, the formatter's no-code face (a plain grouped number at width 2), never a guessed symbol. - `record` (ADR-0144 D2): the code lives in a sibling field of the row. `formatAuditValue` is called once per side with no row, so the resolver answers no code, and the amount reads as a plain grouped number. The tenant default is never substituted (the resolver's own rule). No hard-coded code, no second formatter. ## Tests `ui/packages/app-shell/src/utils/__tests__/auditHistoryDisplay.numericAmounts-27486.test.ts`, 12 cases: currency (fixed USD both sides, JPY width, de-DE locale, numeric string, dynamic with and without a tenant default, record mode), number (declared scale, scale-0 year ungrouped, de-DE, non-numeric value), percent (fraction storage, whole storage via `max`, de-DE). Each states the record page's text as a literal and checks the shared formatter returns the same. ## Acceptance notes - **Caller wiring, out of this card's file surface.** `RecordDetailView`'s History effect builds the format context as `{ t, locale: displayLocale, lookupLabels }`. It does not pass `tenantCurrency`, so a `dynamic`-mode currency field still reads as a plain number in history while the record page shows the tenant symbol. The one-line fix (`tenantCurrency` from `useLocalization().currency`) is in `RecordDetailView.tsx`, which this card's order puts out of bounds (the record-page chain). Listed for the seat in the report. - **Record-mode code.** A per-side code would need each side's payload (`old_value` / `new_value`) handed to the formatter; that is a caller change too. Not attempted. - **File surface.** The percent-caller pin row above is outside the order's named surface (`auditHistoryDisplay.ts` and its tests, the `@object-ui/fields` export, the changeset); it is the registration that pin requires of any new `formatPercent` caller, and is declared as a deviation in the report. - The scale-0 year case and the non-numeric case also pass with the new arm removed (both equal `String(value)`); they pin that the arm keeps those faces, not that it fires. ## Verification <details><summary>Local runs, head fded0c5</summary> - `pnpm --dir ui exec vitest run packages/app-shell/src/utils/__tests__/`: Test Files 28 passed, 1 skipped; Tests 433 passed, 8 skipped. - `pnpm --filter @object-ui/app-shell run type-check` (both tsconfigs) and `pnpm --filter @object-ui/fields run type-check`: exit 0. - Ablation (`scripts/ablation-replace.mjs`): the arm's `return formatNumericAmount(...)` disabled, the new file reads 10 failed, 2 passed; restored, blob equals HEAD and `git diff HEAD` is empty. - Derived gate list (`dispatch-gates --commands`, 34 commands, `check-sdui-manifest` included) run on fded0c5, each exit 0, including `pnpm --dir ui exec vitest run scripts/` (Test Files 169 passed, 2 skipped; Tests 4902 passed, 2 skipped). `dispatch-gates --ran`: 34 derived, 34 run, 0 NOT-MEASURED. - `pnpm --dir ui run check:new-line-citations`: 0 new citations. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CXcn7RbCYknff87Ke1)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9d5ebfd commit d220b9b

6 files changed

Lines changed: 227 additions & 3 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/fields': patch
4+
---
5+
6+
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
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
- **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`).
12+
- **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`.
13+
- **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.
14+
- **`@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.
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* auditHistoryDisplay — the History tab's Changes entry formats a currency,
6+
* number or percent value the way the record page formats that field.
7+
*
8+
* The record page's cells (`CurrencyCellRenderer`, `NumberCellRenderer`,
9+
* `PercentCellRenderer`) format through `formatCurrency`,
10+
* `formatNumberFieldValue` and `formatPercent` in `@object-ui/fields`; the
11+
* History tab printed the raw stored number instead (`4321000` where the
12+
* record header read `$4,321,000.00`). Each case below states the record
13+
* page's text as a literal and checks the shared formatter agrees with it, so
14+
* the literal is the cell's answer and not this helper's own.
15+
*/
16+
import { describe, it, expect } from 'vitest';
17+
import {
18+
formatCurrency,
19+
formatNumberFieldValue,
20+
formatPercent,
21+
} from '@object-ui/fields';
22+
import { formatAuditValue, type AuditFieldDef } from '../auditHistoryDisplay';
23+
24+
const annualRevenue: AuditFieldDef = {
25+
type: 'currency',
26+
label: 'Annual Revenue',
27+
currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' },
28+
};
29+
30+
describe('formatAuditValue — currency', () => {
31+
it('formats a fixed-currency amount as the record page does', () => {
32+
const out = formatAuditValue(annualRevenue, 4321000, { locale: 'en-US' });
33+
expect(out).toBe('$4,321,000.00');
34+
expect(out).toBe(formatCurrency(4321000, 'USD', 'en-US'));
35+
// Both sides of the change, the old value included.
36+
expect(formatAuditValue(annualRevenue, 8000000, { locale: 'en-US' })).toBe('$8,000,000.00');
37+
});
38+
39+
it("reads the currency's own minor-unit width and the viewer's locale", () => {
40+
const yen: AuditFieldDef = { type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'JPY' } };
41+
expect(formatAuditValue(yen, 4321000, { locale: 'en-US' })).toBe(formatCurrency(4321000, 'JPY', 'en-US'));
42+
expect(formatAuditValue(yen, 4321000, { locale: 'en-US' })).not.toContain('.');
43+
const de = formatAuditValue(annualRevenue, 1234.5, { locale: 'de-DE' });
44+
expect(de).toBe(formatCurrency(1234.5, 'USD', 'de-DE'));
45+
expect(de).toContain('1.234,50');
46+
});
47+
48+
it('reads a decimal column stored as its numeric string', () => {
49+
expect(formatAuditValue(annualRevenue, '4321000.5', { locale: 'en-US' })).toBe('$4,321,000.50');
50+
});
51+
52+
it('shows a dynamic-mode amount in the tenant default, and a plain number without one', () => {
53+
const dynamic: AuditFieldDef = { type: 'currency', currencyConfig: { currencyMode: 'dynamic' } };
54+
expect(formatAuditValue(dynamic, 1500, { locale: 'en-US', tenantCurrency: 'EUR' })).toBe(
55+
formatCurrency(1500, 'EUR', 'en-US'),
56+
);
57+
// No code known: the formatter's own no-code face, never a guessed symbol.
58+
expect(formatAuditValue(dynamic, 1500, { locale: 'en-US' })).toBe('1,500.00');
59+
});
60+
61+
it('shows a record-mode amount as a plain number: its code lives on the row, not in this call', () => {
62+
const recordMode: AuditFieldDef = {
63+
type: 'currency',
64+
currencyConfig: { currencyMode: 'record', currencyField: 'currency_code' },
65+
};
66+
// The tenant default is NOT a fallback for a record-mode amount.
67+
expect(formatAuditValue(recordMode, 1500, { locale: 'en-US', tenantCurrency: 'EUR' })).toBe(
68+
formatCurrency(1500, undefined, 'en-US'),
69+
);
70+
expect(formatAuditValue(recordMode, 1500, { locale: 'en-US', tenantCurrency: 'EUR' })).toBe('1,500.00');
71+
});
72+
});
73+
74+
describe('formatAuditValue — number', () => {
75+
it('pads to the declared scale and groups', () => {
76+
const amount: AuditFieldDef = { type: 'number', scale: 2 };
77+
const out = formatAuditValue(amount, 1234.5, { locale: 'en-US' });
78+
expect(out).toBe('1,234.50');
79+
expect(out).toBe(formatNumberFieldValue(1234.5, { name: 'amount', type: 'number', scale: 2 }, 'en-US'));
80+
});
81+
82+
it('keeps a scale-0 number ungrouped, as the cell does for a year', () => {
83+
const year: AuditFieldDef = { type: 'number', scale: 0 };
84+
expect(formatAuditValue(year, 2026, { locale: 'en-US' })).toBe('2026');
85+
});
86+
87+
it("follows the viewer's locale", () => {
88+
const amount: AuditFieldDef = { type: 'number', scale: 2 };
89+
expect(formatAuditValue(amount, 1234.5, { locale: 'de-DE' })).toBe('1.234,50');
90+
});
91+
92+
it('keeps a value that is not a number as it was stored', () => {
93+
expect(formatAuditValue({ type: 'number' }, 'n/a', { locale: 'en-US' })).toBe('n/a');
94+
});
95+
});
96+
97+
describe('formatAuditValue — percent', () => {
98+
it('reads a fraction-stored percent at its declared scale', () => {
99+
const rate: AuditFieldDef = { type: 'percent', scale: 1 };
100+
const out = formatAuditValue(rate, 0.25, { locale: 'en-US' });
101+
expect(out).toBe('25.0%');
102+
expect(out).toBe(formatPercent(0.25, 'fraction', 1, 'en-US'));
103+
});
104+
105+
it('reads a whole-stored percent when the field declares max above 1', () => {
106+
const points: AuditFieldDef = { type: 'percent', scale: 0, max: 100 };
107+
expect(formatAuditValue(points, 25, { locale: 'en-US' })).toBe('25%');
108+
});
109+
110+
it("follows the viewer's locale percent convention", () => {
111+
const rate: AuditFieldDef = { type: 'percent', scale: 1 };
112+
expect(formatAuditValue(rate, 0.25, { locale: 'de-DE' })).toBe(formatPercent(0.25, 'fraction', 1, 'de-DE'));
113+
});
114+
});

‎ui/packages/app-shell/src/utils/auditHistoryDisplay.ts‎

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,21 @@
1919
* 2. drop no-op changes where both sides are empty (null vs '' vs []),
2020
* 3. format values by field type (dates localized, booleans as Yes/No,
2121
* select values mapped to option labels, lookup ids mapped to record
22-
* names via a caller-resolved id → label map).
22+
* names via a caller-resolved id → label map, currency / number / percent
23+
* amounts through the field renderer's own formatters).
2324
*/
2425

2526
import { toDisplayDate } from '@object-ui/core';
27+
import type { NumberFieldMetadata } from '@object-ui/types';
28+
import type { FieldCurrencyConfig } from '@object-ui/i18n';
29+
import {
30+
formatCurrency,
31+
formatNumberFieldValue,
32+
formatPercent,
33+
percentCellScale,
34+
resolveFieldCurrency,
35+
} from '@object-ui/fields';
36+
import { resolveFieldScale } from '@objectstack/spec/data';
2637

2738
/** Minimal structural view of an object field definition. */
2839
export interface AuditFieldDef {
@@ -39,6 +50,17 @@ export interface AuditFieldDef {
3950
* only ever describes an object metadata document.
4051
*/
4152
reference?: string;
53+
/**
54+
* The members the field renderer's numeric formatters read
55+
* ({@link formatScalar}'s currency / number / percent arm): `scale` the
56+
* declared decimal places (`number` / `percent`), `max` the percent storage
57+
* hint, `useGrouping` the author's grouping hint, `currencyConfig` the
58+
* currency mode and code.
59+
*/
60+
scale?: number;
61+
max?: number;
62+
useGrouping?: boolean;
63+
currencyConfig?: FieldCurrencyConfig;
4264
[k: string]: unknown;
4365
}
4466

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

246+
/**
247+
* A stored numeric value as a number, or `null` when it is not one. The audit
248+
* payload carries the raw stored value: a JS number, or a decimal column read
249+
* back as its numeric string.
250+
*/
251+
const asFiniteNumber = (v: unknown): number | null => {
252+
const n = typeof v === 'number' ? v : typeof v === 'string' && v.trim() !== '' ? Number(v) : NaN;
253+
return Number.isFinite(n) ? n : null;
254+
};
255+
256+
/**
257+
* A `currency` / `number` / `percent` amount, formatted by the calls the
258+
* record page's cells make for that field (`CurrencyCellRenderer`,
259+
* `NumberCellRenderer`, `PercentCellRenderer` in `@object-ui/fields`), so the
260+
* History tab reads one stored value the way the record does. ⛔ No formatter
261+
* of this helper's own: these are the shared ones, by reference.
262+
*
263+
* Currency resolves its code through `resolveFieldCurrency`, the cells' one
264+
* precedence: a `fixed` field reads its declared code and a `dynamic` one the
265+
* tenant default in {@link AuditValueFormatContext.tenantCurrency}. A
266+
* `record`-mode field (ADR-0144 D2) keeps its code in a sibling field of the
267+
* row, which this per-value call is not handed, so it resolves to no code and
268+
* reads as the formatter's plain number: the resolver's own answer for a call
269+
* with no record, never a code the amount may not be in.
270+
*/
271+
function formatNumericAmount(type: string, def: AuditFieldDef, value: number, ctx: AuditValueFormatContext): string {
272+
if (type === 'currency') {
273+
return formatCurrency(value, resolveFieldCurrency(def, ctx.tenantCurrency), ctx.locale);
274+
}
275+
if (type === 'percent') {
276+
// The pair `PercentCellRenderer` asks: the declared width through the
277+
// protocol's resolver, and the declared storage (fraction unless `max` > 1).
278+
const scale = resolveFieldScale({ type, scale: def.scale });
279+
return formatPercent(value, percentCellScale(def), scale, ctx.locale);
280+
}
281+
// The members `formatNumberFieldValue` reads, handed over by name: an object
282+
// metadata document's field carries no `name` of its own (it is the key).
283+
const numberField: NumberFieldMetadata = {
284+
name: '',
285+
type: 'number',
286+
scale: def.scale,
287+
useGrouping: def.useGrouping,
288+
};
289+
return formatNumberFieldValue(value, numberField, ctx.locale);
290+
}
291+
292+
const NUMERIC_AMOUNT_TYPES = new Set(['currency', 'number', 'percent']);
293+
216294
function formatScalar(def: AuditFieldDef | undefined, value: unknown, ctx: AuditValueFormatContext): string {
217295
const type = def?.type;
218296

297+
if (def && typeof type === 'string' && NUMERIC_AMOUNT_TYPES.has(type)) {
298+
const num = asFiniteNumber(value);
299+
if (num !== null) return formatNumericAmount(type, def, num, ctx);
300+
}
301+
219302
if (type === 'boolean' || typeof value === 'boolean') {
220303
const truthy = value === true || value === 'true' || value === 1;
221304
const key = truthy ? 'common.yes' : 'common.no';

‎ui/packages/fields/src/index.tsx‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,15 @@ export { resolveFieldCurrency };
568568
*/
569569
export { formatCurrency };
570570

571+
/**
572+
* `formatNumberFieldValue` lives in `./widgets/number-format.ts` and is
573+
* re-exported here so a face outside this package that renders a `number`
574+
* field's stored value (the record History tab's change list) makes the same
575+
* call `NumberCellRenderer` and `NumberField`'s read-only branch make, rather
576+
* than a formatter of its own.
577+
*/
578+
export { formatNumberFieldValue };
579+
571580
/**
572581
* Format currency value in compact form for mobile display.
573582
* E.g., $150,000 → $150K, $1,200,000 → $1.2M

‎ui/packages/fields/src/widgets/number-format.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,10 @@ import { currencyFractionDigits } from '../currency.js';
4242
* not judge one.
4343
* @param field the field metadata; its `type`, `scale` and `useGrouping` are
4444
* the inputs read.
45-
* @param locale the display locale tag, from `useDisplayLocale()`.
45+
* @param locale the display locale tag, from `useDisplayLocale()`. Absent,
46+
* the runtime default answers, as for {@link formatCurrency}.
4647
*/
47-
export function formatNumberFieldValue(value: number, field: FieldMetadata, locale: string): string {
48+
export function formatNumberFieldValue(value: number, field: FieldMetadata, locale?: string): string {
4849
// Decimal places come from `scale` (the `s` in a `decimal(p, s)` column),
4950
// NOT `precision`: `precision` is the TOTAL digit count (`p`), and reading
5051
// it padded every value out to that width (e.g. `1` from a decimal(10, 0)

‎ui/scripts/__tests__/percent-storage-callers-11475.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ interface Call {
4646

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

0 commit comments

Comments
 (0)