Skip to content

Commit 8bcaa1b

Browse files
committed
fix(types, console): shared record-mode amount shows its record's currency
R1 rider from #27268's contract review: the share-link resolve answer projected only currencyMode/defaultCurrency, so a shared record-mode amount rendered as a plain number. Project the currencyField pointer when the record carries the code field (never naming a redacted one), and hand the shared record to the currency face on the shared-record page. Claude-Session: https://claude.ai/code/session_01Utp6CXtadYa5QXb2PbXPev Co-authored-by: Claude <noreply@anthropic.com>
1 parent ad38603 commit 8bcaa1b

6 files changed

Lines changed: 187 additions & 7 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/types': patch
3+
'@object-ui/console': patch
4+
---
5+
6+
fix(types, console): a shared `record`-mode currency amount shows its record's own currency (ADR-0144 D2, D9)
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
The share-link resolve answer's field projection (`buildShareLinkResolveAnswer`, `@objectstack/types`) carried a currency field's `currencyMode` and `defaultCurrency` only, so a shared `record`-mode amount, whose currency lives in a sibling code field, rendered as a plain number. A currency field's projected `currencyConfig` now also carries `currencyField`, the amount's code-field pointer, when the answer's `record` carries that code field. A link that redacts the code field gets neither the code nor the pointer, so the answer still describes nothing the record hides, and the amount shows as a plain number.
12+
13+
The shared-record page hands the shared record to the currency face, which reads the code from it, so the amount shows in the currency its own record names. Every other field and every other key of the answer is unchanged.
14+
15+
⛔ Not released alone: this ships in the same release as `currencyMode: 'record'` itself.

‎packages/types/src/share-link-resolve-answer.test.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,30 @@ describe('[#26991] redactSharedRecord keeps the redaction both mounts served', (
9595
expect(redactSharedRecord(null, ['a'])).toBeNull();
9696
});
9797
});
98+
99+
describe('[#27188] a record-mode amount (ADR-0144 D2)', () => {
100+
const RECORD_SCHEMA = {
101+
name: 'deal',
102+
fields: {
103+
amount: { name: 'amount', type: 'currency', currencyConfig: { currencyMode: 'record', currencyField: 'currency_code' } },
104+
currency_code: { name: 'currency_code', label: 'Currency', type: 'text', valueDomain: 'iso_4217_currency' },
105+
},
106+
};
107+
108+
it('projects the currencyField pointer when the record carries the code field', () => {
109+
const fields = projectSharedRecordFields(RECORD_SCHEMA, { amount: 5, currency_code: 'EUR' }, []);
110+
expect(fields.amount.currencyConfig).toEqual({ currencyMode: 'record', currencyField: 'currency_code' });
111+
expect(fields.currency_code).toEqual({ label: 'Currency', type: 'text' });
112+
});
113+
114+
it('never names a redacted code field', () => {
115+
const answer = buildShareLinkResolveAnswer(
116+
{ amount: 5, currency_code: 'EUR' },
117+
{ ...RESOLVED, redactFields: ['currency_code'] } as ResolveShareLinkResult,
118+
RECORD_SCHEMA,
119+
);
120+
expect(answer.record).toEqual({ amount: 5 });
121+
expect(answer.fields.amount.currencyConfig).toEqual({ currencyMode: 'record' });
122+
expect(answer.fields.currency_code).toBeUndefined();
123+
});
124+
});

‎packages/types/src/share-link-resolve-answer.ts‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,10 @@
3232
* Each entry carries only what a read-only renderer needs to draw the value:
3333
* the authored `label` and `type`; a picklist's `options` (value, label and
3434
* colour — never `default` or visibility rules); `multiple`; a currency field's
35-
* `currencyConfig`; and a lookup's `reference` (target object) and authored
35+
* `currencyConfig` (its mode, a fixed code, and — for a `record`-mode amount,
36+
* ADR-0144 D2 — the `currencyField` pointer, only while `record` also carries
37+
* that code field, so a redacted code field's name is never disclosed); and a
38+
* lookup's `reference` (target object) and authored
3639
* `displayField`. The labels are the authored ones the object schema holds:
3740
* the resolve read path has no caller-locale seam, and this module builds none.
3841
* A lookup's value stays what the record read returns — the stored id; nothing
@@ -52,6 +55,12 @@ export interface ShareLinkFieldOption {
5255
export interface ShareLinkFieldCurrencyConfig {
5356
currencyMode?: string;
5457
defaultCurrency?: string;
58+
/**
59+
* A `record`-mode amount's sibling code field (ADR-0144 D2): the page reads
60+
* the record's code from it. Projected only when the answer's `record`
61+
* carries that field.
62+
*/
63+
currencyField?: string;
5564
}
5665

5766
/** The display projection of one field the resolve answer's `record` carries. */
@@ -109,18 +118,22 @@ function projectOptions(options: unknown): ShareLinkFieldOption[] | undefined {
109118
return out;
110119
}
111120

112-
function projectField(field: Record<string, unknown>): ShareLinkFieldDisplay {
121+
function projectField(field: Record<string, unknown>, carried: (key: string) => boolean): ShareLinkFieldDisplay {
113122
const out: ShareLinkFieldDisplay = {};
114123
if (typeof field.label === 'string') out.label = field.label;
115124
if (typeof field.type === 'string') out.type = field.type;
116125
const options = projectOptions(field.options);
117126
if (options) out.options = options;
118127
if (field.multiple === true) out.multiple = true;
119128
if (isPlainObject(field.currencyConfig)) {
120-
const { currencyMode, defaultCurrency } = field.currencyConfig;
129+
const { currencyMode, defaultCurrency, currencyField } = field.currencyConfig;
121130
out.currencyConfig = {
122131
...(typeof currencyMode === 'string' ? { currencyMode } : {}),
123132
...(typeof defaultCurrency === 'string' ? { defaultCurrency } : {}),
133+
// The pointer names another field: kept only when that field is one the
134+
// record shows, so the answer still describes nothing the record hides.
135+
// Without it a record-mode amount renders as a plain number.
136+
...(typeof currencyField === 'string' && carried(currencyField) ? { currencyField } : {}),
124137
};
125138
}
126139
if (typeof field.reference === 'string') out.reference = field.reference;
@@ -142,10 +155,12 @@ export function projectSharedRecordFields(
142155
const fields: Record<string, ShareLinkFieldDisplay> = {};
143156
if (!isPlainObject(record) || !isPlainObject(schema) || !isPlainObject(schema.fields)) return fields;
144157
const declared = schema.fields;
158+
const carried = (key: string) =>
159+
!redactFields.includes(key) && Object.prototype.hasOwnProperty.call(record, key);
145160
for (const key of Object.keys(record)) {
146161
if (redactFields.includes(key) || !Object.prototype.hasOwnProperty.call(declared, key)) continue;
147162
const field = declared[key];
148-
if (isPlainObject(field)) fields[key] = projectField(field);
163+
if (isPlainObject(field)) fields[key] = projectField(field, carried);
149164
}
150165
return fields;
151166
}

‎ui/apps/console/src/pages/SharedRecordFields.tsx‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,8 @@ function formatValue(value: unknown, locale: string, t: Translate, depth = 0): s
102102
* authored label and colour from `field.options`; `CurrencyCellRenderer`
103103
* resolves its code with `resolveFieldCurrency` from the field's
104104
* `currencyConfig` and the tenant default the localization context holds
105-
* (none on this page). Neither fetches. ⛔ Never add `LookupCellRenderer` or
105+
* (none on this page) — and, for a `record`-mode amount, from the record's own
106+
* code field, which it reads off `dependentValues` (the shared record itself). Neither fetches. ⛔ Never add `LookupCellRenderer` or
106107
* `UserCellRenderer`: both read the referenced record through the data source,
107108
* which an anonymous visitor cannot.
108109
*/
@@ -146,7 +147,13 @@ export function SharedRecordFields({ record, fields }: SharedRecordFieldsProps)
146147
<dt className="text-xs font-medium text-muted-foreground">{field?.label || humanizeLabel(key)}</dt>
147148
<dd className="min-w-0 whitespace-pre-wrap break-words text-sm text-foreground">
148149
{Renderer ? (
149-
<Renderer value={record[key]} field={{ name: key, ...field } as unknown as FieldMetadata} />
150+
<Renderer
151+
value={record[key]}
152+
field={{ name: key, ...field } as unknown as FieldMetadata}
153+
// The shared record: a record-mode amount reads its currency
154+
// from the code field beside it (ADR-0144 D2).
155+
dependentValues={record}
156+
/>
150157
) : (
151158
formatValue(record[key], locale, t)
152159
)}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/**
2+
* objectstack#27188 — a shared `record`-mode amount (ADR-0144 D2) shows the
3+
* currency its own record names: the resolve answer projects the amount's
4+
* `currencyField` pointer, and the page hands the shared record to the
5+
* currency face. A code field the link hides leaves a plain number.
6+
*/
7+
import '@testing-library/jest-dom/vitest';
8+
import { afterEach, describe, expect, it, vi } from 'vitest';
9+
import { cleanup, render, screen } from '@testing-library/react';
10+
import { MemoryRouter, Route, Routes } from 'react-router-dom';
11+
import { SchemaRendererProvider } from '@object-ui/react';
12+
import SharedRecordPage from './SharedRecordPage';
13+
14+
const TOKEN = 'tok_27188';
15+
const RESOLVE_PATH = `/api/v1/share-links/${TOKEN}/resolve`;
16+
17+
const RECORD_MODE = { currencyMode: 'record', currencyField: 'currency_code' } as const;
18+
19+
const DEAL = { id: 'deal_1', name: 'Shared deal', amount: 1250, currency_code: 'EUR' };
20+
const FIELDS = {
21+
name: { label: 'Deal Name', type: 'text' },
22+
amount: { label: 'Amount', type: 'currency', currencyConfig: RECORD_MODE },
23+
currency_code: { label: 'Currency', type: 'text' },
24+
};
25+
26+
const json = (status: number, body: unknown) =>
27+
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
28+
29+
/** Stub the resolve route; answer `fields` only when given. Returns every requested path. */
30+
function shareServer(record: Record<string, unknown>, fields?: Record<string, unknown>): string[] {
31+
const requested: string[] = [];
32+
vi.stubGlobal(
33+
'fetch',
34+
vi.fn(async (input: string | URL | Request) => {
35+
const raw = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
36+
const { pathname } = new URL(raw, 'http://localhost');
37+
requested.push(pathname);
38+
if (pathname === RESOLVE_PATH) {
39+
return json(200, {
40+
success: true,
41+
data: {
42+
record,
43+
link: {
44+
id: 'sl_1',
45+
token: TOKEN,
46+
object_name: 'deal',
47+
record_id: String(record.id),
48+
permission: 'view',
49+
audience: 'anyone',
50+
},
51+
redactFields: ['ssn'],
52+
...(fields ? { fields } : {}),
53+
},
54+
});
55+
}
56+
return new Response(null, { status: 404 });
57+
}),
58+
);
59+
return requested;
60+
}
61+
62+
function renderLink(dataSource?: Record<string, unknown>) {
63+
const routed = (
64+
<MemoryRouter initialEntries={[`/s/${TOKEN}`]}>
65+
<Routes>
66+
<Route path="/s/:token" element={<SharedRecordPage />} />
67+
</Routes>
68+
</MemoryRouter>
69+
);
70+
return render(
71+
dataSource ? <SchemaRendererProvider dataSource={dataSource as never}>{routed}</SchemaRendererProvider> : routed,
72+
);
73+
}
74+
75+
/** The value cell beside the row whose label reads `label`. */
76+
function cellOf(label: string): HTMLElement {
77+
const term = screen.getByText(label, { selector: 'dt' });
78+
const value = term.parentElement?.querySelector('dd');
79+
expect(value).not.toBeNull();
80+
return value as HTMLElement;
81+
}
82+
83+
afterEach(() => {
84+
cleanup();
85+
vi.unstubAllGlobals();
86+
});
87+
88+
describe('shared record — a record-mode amount', () => {
89+
it('shows the currency the record\'s code field names', async () => {
90+
shareServer(DEAL, FIELDS);
91+
renderLink();
92+
await screen.findByText('Amount', { selector: 'dt' });
93+
expect(cellOf('Amount').textContent).toContain('€');
94+
});
95+
96+
it('follows each record\'s own code', async () => {
97+
shareServer({ ...DEAL, currency_code: 'JPY', amount: 1250 }, FIELDS);
98+
renderLink();
99+
await screen.findByText('Amount', { selector: 'dt' });
100+
expect(cellOf('Amount').textContent).toMatch(/¥|JP¥/);
101+
expect(cellOf('Amount').textContent).not.toContain('€');
102+
});
103+
104+
it('a hidden code field (no pointer, no code) leaves a plain number', async () => {
105+
const { currency_code: _hidden, ...record } = DEAL;
106+
shareServer(record, { ...FIELDS, amount: { ...FIELDS.amount, currencyConfig: { currencyMode: 'record' } } });
107+
renderLink();
108+
await screen.findByText('Amount', { selector: 'dt' });
109+
expect(cellOf('Amount').textContent).not.toMatch(/€|\$|¥/);
110+
expect(cellOf('Amount').textContent).toMatch(/1,?250/);
111+
});
112+
});

‎ui/apps/console/src/pages/shared-record-shape.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,11 @@ export interface SharedFieldDisplay {
119119
/** A picklist's options: value, authored label and colour. */
120120
options?: Array<{ value: string; label: string; color?: string }>;
121121
multiple?: true;
122-
currencyConfig?: { currencyMode?: string; defaultCurrency?: string };
122+
/**
123+
* A currency field's currency. `currencyField` names a `record`-mode
124+
* amount's code field (ADR-0144 D2), present only when `record` carries it.
125+
*/
126+
currencyConfig?: { currencyMode?: string; defaultCurrency?: string; currencyField?: string };
123127
/** A lookup's target object. The record's value stays the stored id. */
124128
reference?: string;
125129
displayField?: string;

0 commit comments

Comments
 (0)