Skip to content

Commit 59ef3db

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/project-thread-nx5qkr
2 parents 2659dd6 + 1ed3360 commit 59ef3db

70 files changed

Lines changed: 2548 additions & 73 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
'@object-ui/core': patch
3+
'@object-ui/i18n': patch
4+
'@object-ui/fields': patch
5+
'@object-ui/components': patch
6+
'@object-ui/plugin-form': patch
7+
'@object-ui/plugin-grid': patch
8+
'@object-ui/plugin-list': patch
9+
'@object-ui/plugin-dashboard': patch
10+
'@object-ui/plugin-report': patch
11+
'@object-ui/plugin-detail': patch
12+
'@object-ui/plugin-gantt': patch
13+
'@object-ui/app-shell': patch
14+
---
15+
16+
feat(ui): a `record`-mode currency amount shows each record's own currency; the code field is an ISO 4217 select pre-filled with the organization currency; Studio edits the mode and the pointer (ADR-0144 D2, D6, D8 rule 4)
17+
18+
Clause-②: no
19+
Metadata-protocol: none
20+
21+
An amount declaring `currencyConfig: { currencyMode: 'record', currencyField: 'currency_code' }` now renders in the currency its record's `currency_code` holds, on every face that holds the record:
22+
23+
- **Cells and widgets.** `resolveFieldCurrency(field, tenantDefault, record)` takes the record in scope as an optional third argument. Under `record` mode it reads only that record's code field; no record, or a blank code, resolves to no currency, so the amount shows as a plain number and never in the tenant default. `dynamic` and `fixed` fields resolve exactly as before. The list cell, the form and inline amount box (symbol, decimals and spinner step follow the selected code), formula cells returning `currency`, the record page's summary chips, the dashboard table and record drawer, and Gantt tooltips all pass the record. `recordCurrencyField(field)` names the pointer.
24+
- **The code field.** A `text` field declaring `valueDomain: 'iso_4217_currency'` renders a searchable select instead of a free-text box, grouped "Available" (the organization currency, then rated currencies, then the codes the object's records already hold) and "All currencies (ISO 4217)". It offers only codes the value domain accepts. The form reads the used codes once per object, with one grouped query, and hands them to the field on its own metadata (`usedCurrencyCodes`); a data source without grouped queries offers home + the record's own code. The rated group is empty until the exchange-rate table ships. The picker itself is `CurrencyCodePicker` in `@object-ui/components`.
25+
- **Filters.** In the list's filter builder, a condition on a `record`-mode amount gains a required currency picker, defaulting to the organization currency, and the filter reads `amount op X AND code = C`: the code condition is a second row of the same group, so stored filters keep their shape. A group holding such a pair stays on AND (under OR the pair would split), and a stored OR group says so. Presence checks (`is null`, `is empty`, …) need no currency.
26+
- **Create forms.** A code field that a `record`-mode amount points at opens pre-filled with `localization.currency`, as a value the user submits; a declared `defaultValue` and the caller's record win, and nothing is seeded while the setting is unset.
27+
- **Totals.** The grid footer draws no sum, average, minimum or maximum for a `record`-mode amount (its rows are in different currencies); counts stand. A group header keeps those aggregates only when the grid is grouped by the amount's code field. Consolidated totals belong to the home field, a `dynamic` amount that totals as before.
28+
- **Dataset answers.** A grouped analytics answer over a `record`-mode amount marks its measure column with `currencyField`, the result column holding each row's code. The dashboard `table` widget, the report table (and its export) and the Studio dataset preview format each row in that row's currency; a row without a code, and the totals row, show a plain number. `measureCellCurrency(field, row)` in `@object-ui/core` is the one read. Columns without `currencyField` format exactly as before.
29+
- **Studio.** A currency field (or a formula returning `currency`) gains a currency section: the mode (dynamic, fixed, record), the fixed code, and under `record` a `currencyField` picker listing the object's ISO 4217 code fields, with "Create currency field", which adds `currency_code` (`text`, `valueDomain: 'iso_4217_currency'`, required) and points the amount at it.
30+
31+
⛔ Not released alone: this ships in the same release as `currencyMode: 'record'` itself and its aggregate refusals.
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;

‎ui/examples/schema-catalog/test/form-field-channel-census-12130.test.tsx‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,7 @@ interface LedgerRow {
281281
const LOOKUP = 'entry@fields/src/widgets/LookupField.tsx';
282282
const USER = 'entry@fields/src/widgets/UserField.tsx';
283283
const CURRENCY = 'entry@i18n/src/currency.ts';
284+
const USED_CODES = 'entry@components/src/renderers/form/usedCurrencyCodes.ts';
284285

285286
/**
286287
* Every key the channel reads that `FormFieldSchema` does not declare, with its
@@ -310,14 +311,21 @@ const LEDGER: Readonly<Record<string, LedgerRow>> = {
310311
},
311312
// ── runtime-only, by an existing ruling ─────────────────────────────────
312313
field: {
313-
readers: ['entry@components/src/renderers/form/form.tsx', 'entry@core/src/validation/server-owned-value.ts', LOOKUP, USER],
314+
readers: [
315+
'entry@components/src/renderers/form/form.tsx', USED_CODES, 'entry@core/src/validation/server-owned-value.ts', LOOKUP, USER,
316+
],
314317
why: 'RUNTIME-ONLY (objectui#6609) — the resolved object-field metadata stash; in the authored vocabulary the same '
315318
+ 'key names an object field (objectui#3090).',
316319
},
317320
mobile_fullscreen: {
318321
readers: ['entry@fields/src/widgets/RichTextField.tsx'],
319322
why: 'RUNTIME-ONLY (objectui#3245) — stamped by `ObjectForm` from `ObjectFormSchema.mobile.fullscreenLongText`.',
320323
},
324+
valueDomain: {
325+
readers: [USED_CODES, 'entry@fields/src/widgets/CurrencyCodeField.tsx'],
326+
why: 'RUNTIME-ONLY (objectstack#27188) — object-field metadata (the spec\'s `FieldSchema.valueDomain`, ADR-0144 D2) read '
327+
+ 'off the resolved `field` stash: a record-currency code field takes its domain from the object, not from the form.',
328+
},
321329
currency: {
322330
readers: [CURRENCY],
323331
why: 'RUNTIME-ONLY (objectui#11070 round 13) — the spec\'s `FieldSchema` refuses it; `currencyConfig` is the declared spelling.',

0 commit comments

Comments
 (0)