Skip to content

Commit 561b764

Browse files
fix(app-shell): a dynamic currency change in record History reads in the tenant currency (#27779)
domain:ui#2 · os-bill · session_01Utp6CY2n1JmZZKrHgtp9jC <!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgP6XkfpjCfyzzG2KwQPUZfy)_ Fixes #27640 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:记录页 History 的格式化上下文现在带上租户默认币种(`useLocalization().currency`),`dynamic` 币种字段的变更显示为 `€1,500.00 → €2,500.00`,不再是裸数字。 - 为什么改:同一字段在记录页显示 `$4,321,000.00`,History 里却是 `4,321,000.00`,两处读数不一致;原因是 History 从没拿到租户币种。 - 风险与回滚:风险低,只是给已有的可选成员传值,`record` 模式不受影响;回滚即还原本 PR 一个提交。 - 先看:`ui/packages/app-shell/src/views/RecordDetailView.tsx`(`fmtCtx` 一行、hook 读取、effect 依赖),再看新测试 `RecordDetailView.auditTenantCurrency-27640.test.tsx`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/RecordDetailView.auditTenantCurrency-27640.test.tsx`,预期 3 个测试全绿。 - 需要你做的:无,席位落地。 ## What changes `RecordDetailView`'s History effect built its format context as `{ t, locale: displayLocale, lookupLabels }`, so `formatAuditValue` → `resolveFieldCurrency` never reached its last step, the tenant default currency (`AuditValueFormatContext.tenantCurrency`, ADR-0053). A `dynamic`-mode currency change therefore read as a plain grouped number next to the record's own currency face. - The view reads `const { currency: tenantCurrency } = useLocalization();` (from `@object-ui/i18n`), the same value the record page's cells read: `DetailView` in `@object-ui/plugin-detail` takes its `tenantCurrency` from `useLocalization()` the same way. The view held no such value before, so this is the one hook read the triage allowed. - `fmtCtx` becomes `{ t, locale: displayLocale, lookupLabels, tenantCurrency }`. - `tenantCurrency` joins the History effect's dependency list beside `displayLocale`, so the stream re-formats when the tenant's localization arrives or changes, the way it already does for a display-locale switch. - `record`-mode fields are unchanged (out of scope per triage): `resolveFieldCurrency` answers no code for them without the row, and the tenant default is never their fallback. Changeset: `.changeset/27640-history-dynamic-currency.md`, `@object-ui/app-shell` patch. ## Tests New: `ui/packages/app-shell/src/views/RecordDetailView.auditTenantCurrency-27640.test.tsx` renders the record page on its History stream (`?tab=timeline`) under a real `I18nProvider` and a `LocalizationProvider`, with one audit entry that moves a `dynamic` field and a `record`-mode field from 1500 to 2500: - EUR tenant: the `dynamic` change reads `Amount €1,500.00 → €2,500.00` (the pin); - EUR tenant: the `record`-mode change stays `List price 1,500.00 → 2,500.00`; - control, no tenant currency: the `dynamic` change reads `Amount 1,500.00 → 2,500.00`. Ablation (fix committed first, mutation through `scripts/ablation-replace.mjs`): replacing `lookupLabels, tenantCurrency };` with `lookupLabels };` landed on disk (anchor 1 → 0, blob `ab5f20a6e2c8` → `cb4dc3752561`); the pin went red (`got: Amount 1,500.00 → 2,500.00`), the record-mode case and the control stayed green, and the restore matched the HEAD blob `ab5f20a6e2c8`. ## Acceptance notes - The spec seat's draft PR #27713 also edits `RecordDetailView.tsx`; its patch does not touch the hunks here (the import line, the `displayLocale` hook area, `fmtCtx` and the History effect's dependency list). Whichever lands second rebases. - Local verification was narrowed: the `@object-ui/app-shell` package holds about 1,900 test files, too many to run on the shared box under the verification lock, so the local run covers every test file that imports `RecordDetailView` plus the `auditHistoryDisplay` unit tests. `Test UI` in CI runs the whole package. ## Verification Local readings, on head `7be9046e` (after the one merge of `origin/main`) unless named otherwise. <details> <summary>Targeted tests</summary> - New test file: `Test Files 1 passed (1)`, `Tests 3 passed (3)`. - History siblings (`RecordDetailView.auditDisplayLocale-10442`, `auditHistoryDisplay.numericAmounts-27486`): green. - Every test file importing `RecordDetailView` plus the `auditHistoryDisplay` unit tests (157 files, the new one included): `Test Files 156 passed | 1 skipped (157)`, `Tests 1079 passed | 8 skipped (1087)`, lock `VERDICT command-exit 0`. </details> <details> <summary>Typecheck and lint</summary> - `pnpm --filter @object-ui/app-shell run type-check` (both `tsc --noEmit` and `tsconfig.test.json`), on `f804616f` (the merge brought no change to this diff's files): lock `VERDICT command-exit 0`. - `eslint` on the two touched source files: exit 0, 0 errors (warnings only, the existing `no-explicit-any` ones and two `as any` test doubles that mirror the sibling History test). - `pnpm check:new-line-citations` (run by hand per `ui/AGENTS.md`): 0 new citations. </details> <details> <summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 34 commands over the 3 changed paths; all 34 exited 0 (`node scripts/check-sdui-manifest.mjs` among them), and `--ran` reconciled 34 derived / 34 run / 0 NOT-MEASURED. Re-run in full on `7be9046e`: 34/34 exit 0, `--ran` reconciled 34/34/0. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CY2n1JmZZKrHgtp9jC)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2a0b37b commit 561b764

3 files changed

Lines changed: 238 additions & 3 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(app-shell): a `dynamic`-mode currency change in a record's History reads in the tenant's default currency
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The record page's Activity › History stream formatted a `currency` field with `currencyMode: 'dynamic'` (or no mode) as a plain grouped number (`4,321,000.00`) while the record itself showed `$4,321,000.00`, because the History format context was never handed the tenant's default currency. The record page now passes `useLocalization().currency` (the `localization.currency` setting the page's cells already read, ADR-0053) into that context, so a dynamic change reads with the tenant's code. A `record`-mode field is unchanged: its code lives in a sibling field of the row, so its change still reads as a plain number.
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
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+
* `RecordDetailView`'s audit history formats a `dynamic`-mode currency diff in
11+
* the tenant's default currency (ADR-0053), the code the record page's cells
12+
* show for the same field (objectstack#27640).
13+
*
14+
* `formatAuditValue` resolves a currency code through `resolveFieldCurrency`,
15+
* whose last step is `AuditValueFormatContext.tenantCurrency`. The History
16+
* effect built `fmtCtx = { t, locale: displayLocale, lookupLabels }` with no
17+
* tenant currency, so a `dynamic` change read as a plain grouped number beside
18+
* the record's own currency face. The view now reads
19+
* `useLocalization().currency` into `fmtCtx.tenantCurrency`.
20+
*
21+
* The tenant currency is declared through `LocalizationProvider`, the channel
22+
* the record page's cells read. A `record`-mode field keeps its code in a
23+
* sibling field of the row, which the per-value call is not handed, so it
24+
* stays a plain number with the tenant currency set: the tenant default is
25+
* never its fallback. The page is the synthesized one, opened on its History
26+
* stream through the `tab` URL param; the diff line is read off the rendered
27+
* timeline, the way a user sees it.
28+
*/
29+
30+
import * as React from 'react';
31+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
32+
import { cleanup, render, waitFor } from '@testing-library/react';
33+
import { MemoryRouter } from 'react-router-dom';
34+
import { MetadataCtx } from '@object-ui/react';
35+
import { I18nProvider, LocalizationProvider } from '@object-ui/i18n';
36+
37+
vi.mock('@object-ui/auth', async (importOriginal) => ({
38+
...(await importOriginal<Record<string, unknown>>()),
39+
useAuth: () => ({ user: { id: 'u1', name: 'Ada', image: null }, activeOrganization: null }),
40+
createAuthenticatedFetch: () => vi.fn(),
41+
}));
42+
43+
vi.mock('@object-ui/collaboration', async (importOriginal) => ({
44+
...(await importOriginal<Record<string, unknown>>()),
45+
useRecordPresence: () => [],
46+
PresenceAvatars: () => null,
47+
}));
48+
49+
vi.mock('sonner', () => ({
50+
toast: Object.assign(vi.fn(), {
51+
success: vi.fn(),
52+
error: vi.fn(),
53+
info: vi.fn(),
54+
warning: vi.fn(),
55+
loading: vi.fn(),
56+
dismiss: vi.fn(),
57+
}),
58+
}));
59+
60+
// The dialogs / flow runner are orthogonal chrome; stubbing them keeps this
61+
// file about the history diff (same posture as the display-locale history pin).
62+
vi.mock('./ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null }));
63+
vi.mock('./ActionParamDialog', () => ({ ActionParamDialog: () => null }));
64+
vi.mock('./ActionResultDialog', () => ({ ActionResultDialog: () => null }));
65+
vi.mock('./FlowRunner', () => ({ FlowRunner: () => null }));
66+
vi.mock('./MetadataInspector', () => ({
67+
MetadataPanel: () => null,
68+
useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }),
69+
}));
70+
71+
import { RecordDetailView } from './RecordDetailView';
72+
73+
const OBJECT_NAME = 'crm_deal';
74+
const RECORD_ID = 'rec-deal';
75+
76+
const OBJECTS = [
77+
{
78+
name: OBJECT_NAME,
79+
label: 'Deal',
80+
managedBy: 'platform',
81+
// The History tab is opt-in per object; this and the registered audit
82+
// object below are what switch it on.
83+
enable: { trackHistory: true },
84+
fields: {
85+
id: { type: 'text', label: 'Id' },
86+
name: { type: 'text', label: 'Name' },
87+
amount: { type: 'currency', label: 'Amount', currencyConfig: { currencyMode: 'dynamic' } },
88+
list_price: {
89+
type: 'currency',
90+
label: 'List price',
91+
currencyConfig: { currencyMode: 'record', currencyField: 'currency_code' },
92+
},
93+
currency_code: { type: 'text', label: 'Currency code' },
94+
},
95+
},
96+
{ name: 'sys_audit_log', label: 'Audit log', fields: { id: { type: 'text' } } },
97+
];
98+
99+
function makeDataSource() {
100+
const find = vi.fn((objectName: string) => {
101+
if (objectName === 'sys_audit_log') {
102+
return Promise.resolve({
103+
data: [
104+
{
105+
id: 'a-1',
106+
created_at: '2020-03-05T12:00:00.000Z',
107+
action: 'update',
108+
old_value: JSON.stringify({ amount: 1500, list_price: 1500 }),
109+
new_value: JSON.stringify({ amount: 2500, list_price: 2500 }),
110+
},
111+
],
112+
});
113+
}
114+
return Promise.resolve({ data: [] });
115+
});
116+
return {
117+
find,
118+
findOne: vi.fn(async () => ({
119+
id: RECORD_ID,
120+
name: 'Alpha',
121+
amount: 2500,
122+
list_price: 2500,
123+
currency_code: 'JPY',
124+
})),
125+
create: vi.fn(async () => ({})),
126+
update: vi.fn(async () => ({})),
127+
delete: vi.fn(async () => ({})),
128+
} as any;
129+
}
130+
131+
function makeMetadata() {
132+
return {
133+
objects: OBJECTS,
134+
pages: [],
135+
loading: false,
136+
error: null,
137+
refresh: async () => {},
138+
invalidate: () => {},
139+
ensureType: async () => [],
140+
getItem: async () => null,
141+
getItemsByType: () => [],
142+
} as any;
143+
}
144+
145+
/**
146+
* The rendered diff parts, keyed by field label, with `currency` as the
147+
* tenant default (none when undefined) and `en-US` as the display locale.
148+
*/
149+
async function diffLines(currency: string | undefined): Promise<Record<string, string>> {
150+
render(
151+
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }} persistLanguage={false}>
152+
<LocalizationProvider value={{ locale: 'en-US', currency }}>
153+
<MemoryRouter initialEntries={[`/app/demo/${OBJECT_NAME}/${RECORD_ID}?tab=timeline`]}>
154+
<MetadataCtx.Provider value={makeMetadata()}>
155+
<RecordDetailView
156+
dataSource={makeDataSource()}
157+
objects={OBJECTS}
158+
onEdit={() => {}}
159+
objectNameOverride={OBJECT_NAME}
160+
recordIdOverride={RECORD_ID}
161+
embedded
162+
/>
163+
</MetadataCtx.Provider>
164+
</MemoryRouter>
165+
</LocalizationProvider>
166+
</I18nProvider>,
167+
);
168+
// One audit entry draws its changes as one compact line (`data-stream-change`),
169+
// joined with `; `, so each field's part is split out and keyed by label.
170+
const lines: Record<string, string> = {};
171+
await waitFor(() => {
172+
const line = Array.from(document.querySelectorAll('[data-stream-change]')).find((el) =>
173+
(el.textContent ?? '').includes('Amount'),
174+
);
175+
expect(line, 'no History diff line').toBeTruthy();
176+
const parts = (line!.textContent ?? '').replace(/\s+/g, ' ').trim().split('; ');
177+
for (const label of ['Amount', 'List price']) {
178+
const part = parts.find((p) => p.startsWith(`${label} `));
179+
expect(part, `no change for ${label} in: ${parts.join(' | ')}`).toBeTruthy();
180+
lines[label] = part!;
181+
}
182+
});
183+
cleanup();
184+
return lines;
185+
}
186+
187+
beforeEach(() => {
188+
// Unrelated chrome on this view reaches for the platform API; answering it
189+
// locally keeps the only asynchrony here the reads this file stubs.
190+
vi.stubGlobal(
191+
'fetch',
192+
vi.fn(async () =>
193+
new Response(JSON.stringify({ data: [] }), {
194+
status: 200,
195+
headers: { 'content-type': 'application/json' },
196+
}),
197+
),
198+
);
199+
});
200+
201+
afterEach(() => {
202+
cleanup();
203+
vi.unstubAllGlobals();
204+
vi.clearAllMocks();
205+
});
206+
207+
describe('RecordDetailView — a dynamic currency diff reads in the tenant currency (objectstack#27640)', () => {
208+
it('formats a dynamic-mode change from 1500 to 2500 with the EUR face under an EUR tenant', async () => {
209+
const lines = await diffLines('EUR');
210+
expect(lines['Amount'], `got: ${lines['Amount']}`).toBe('Amount €1,500.00 → €2,500.00');
211+
});
212+
213+
it('keeps a record-mode change a plain number: the tenant currency is not its fallback', async () => {
214+
const lines = await diffLines('EUR');
215+
expect(lines['List price'], `got: ${lines['List price']}`).toBe('List price 1,500.00 → 2,500.00');
216+
});
217+
218+
it('control: with no tenant currency, the dynamic change reads as a plain number', async () => {
219+
const lines = await diffLines(undefined);
220+
expect(lines['Amount'], `got: ${lines['Amount']}`).toBe('Amount 1,500.00 → 2,500.00');
221+
});
222+
});

‎ui/packages/app-shell/src/views/RecordDetailView.tsx‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { activityRowToFeedItem, ArrangedRelatedTab, auditEntriesToFeedItems, bod
1414
import { Empty, EmptyTitle, EmptyDescription, Button, PageHeaderActions, PageHeaderTrailingContext, PanelToggles, type PanelToggleItem } from '@object-ui/components';
1515
import { useAuth, createAuthenticatedFetch } from '@object-ui/auth';
1616
import { usePermissions } from '@object-ui/permissions';
17-
import { useDisplayLocale } from '@object-ui/i18n';
17+
import { useDisplayLocale, useLocalization } from '@object-ui/i18n';
1818
import { ActionProvider, useObjectTranslation, useObjectLabel, useActionTextLocalizer, usePageAssignment, isRecordPageOf, useMetadata, RecordContextProvider, SchemaRenderer, DiscussionContextProvider, HighlightFieldsProvider, InlineEditProvider, resolveApprovalLock, useGlobalUndo, useDataInvalidation, notifyDataChanged, subscribeDataChanges, dataChangeMatches, useRowPredicate, classifyLoadError, extractWriteErrorMessage } from '@object-ui/react';
1919
import { buildExpandFields, captureUpdateUndoData, recordDelete, resolveAffordance, resolveRecordIdParamSeed, userActionPredicates, withoutDeniedFields, type AffordanceGrantPrincipal, type SchemaLike } from '@object-ui/core';
2020
import { toast } from 'sonner';
@@ -762,6 +762,9 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
762762
// The DISPLAY locale the audit-history dates format with (objectui#10442).
763763
// `language` above stays for what it is: the key into per-locale LABEL maps.
764764
const displayLocale = useDisplayLocale();
765+
// The tenant default currency (ADR-0053) a `dynamic` currency diff reads,
766+
// the same `useLocalization().currency` the record page's cells read.
767+
const { currency: tenantCurrency } = useLocalization();
765768
const { objectLabel, objectPluralLabel, viewLabel: _vLabel, sectionLabel, actionParamText, actionParamOptionLabel, actionDescription, actionResultDialog, fieldLabel, fieldOptionLabel } = useObjectLabel();
766769
// label + confirmText + successMessage through ONE call (objectui#4265) —
767770
// the three keys of an `_actions.<name>` bundle entry can no longer be
@@ -2440,7 +2443,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
24402443

24412444
// `locale` is spent on the date faces only, so it is the display
24422445
// locale, never the UI language (objectui#10442).
2443-
const fmtCtx = { t, locale: displayLocale, lookupLabels };
2446+
const fmtCtx = { t, locale: displayLocale, lookupLabels, tenantCurrency };
24442447
const enriched = items.map((it, idx) => {
24452448
const u = it?.user_id ? userMap.get(it.user_id) : undefined;
24462449
// Attribution fallback chain: resolved user name → service/automation
@@ -2485,7 +2488,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
24852488
// language switch so the translated values re-localize, and
24862489
// `displayLocale` on a display-locale switch so the dates do.
24872490
// eslint-disable-next-line react-hooks/exhaustive-deps
2488-
}, [dataSource, pureRecordId, objectDef, historyEnabled, historyWanted, objects, language, displayLocale]);
2491+
}, [dataSource, pureRecordId, objectDef, historyEnabled, historyWanted, objects, language, displayLocale, tenantCurrency]);
24892492

24902493
// Fetch a directory of active users once per dataSource mount and expose
24912494
// them as @-mention suggestions to the DiscussionContext. Capped at 50 to

0 commit comments

Comments
 (0)