Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
59ac9e8
feat(spec): currencyMode 'record' + currencyField — a record-mode amo…
claude Oct 28, 2026
2537aff
chore(spec): regenerate artifacts and declare the record-mode refinem…
claude Oct 28, 2026
1d9a533
chore(spec): dropped-refinements ledger header totals for the record-…
claude Oct 28, 2026
d1316dd
docs(skills): field-types — the record currency mode and its sibling-…
claude Oct 28, 2026
5232c8b
chore(platform-objects): metadata-form translation bundles for the re…
claude Oct 28, 2026
9416158
Merge origin/main (#27218 landed) into the record-mode branch
claude Oct 28, 2026
2feede7
wip(ui): record-mode currency — resolver reads the record's code fiel…
claude Oct 28, 2026
a9f5abf
chore(spec): regenerate artifacts after bringing main in
claude Oct 28, 2026
044e67c
wip(ui): record-currency code select, form record hand-off, grid foot…
claude Oct 28, 2026
c5baf62
wip(ui): Studio currency section, tests per widget
claude Oct 28, 2026
d59c3cb
chore(changeset): record-mode currency display
claude Oct 28, 2026
da1ebc7
fix(i18n): resolveFieldCurrency accepts the record currency mode — th…
claude Oct 28, 2026
f174d4b
test(platform-objects): move the metadata-form catalog pins for the c…
claude Oct 28, 2026
bf7f2b7
fix(i18n): a record-mode field resolves no currency, never the tenant…
claude Oct 28, 2026
cc40307
feat(ui): record-currency used codes and filter pairing (seat ruling …
claude Oct 28, 2026
7520d3e
test(ui): escape the no-break space in two record-currency test regexes
claude Oct 28, 2026
e527c3b
Merge origin/main (#27233, #27239) into claude/project-thread-0wmk7j
claude Oct 29, 2026
9011464
chore(spec): regenerate artifacts after bringing main in
claude Oct 29, 2026
ad38603
Merge #27268 (claude/project-thread-0wmk7j @ 90114641) into the recor…
claude Oct 29, 2026
8bcaa1b
fix(types, console): shared record-mode amount shows its record's cur…
claude Oct 29, 2026
f3d286c
fix(spec): record currency mode refuses an empty pointer and an inert…
claude Oct 29, 2026
b6f152b
docs(spec): three-mode currency help texts, translated; the skill's c…
claude Oct 29, 2026
66a421c
fix(components): keep removeCondition's pinned match site (#27188)
claude Oct 29, 2026
c36aa49
test(census): ledger valueDomain and the used-codes `field` read (#27…
claude Oct 29, 2026
26913b3
Merge remote-tracking branch 'origin/claude/project-thread-0wmk7j' in…
claude Oct 29, 2026
e128ccc
test(app-shell): a fixed-to-record switch writes no defaultCurrency (…
claude Oct 29, 2026
7d06290
feat(ui): dataset cells format a record-mode amount in its row's curr…
claude Oct 29, 2026
3e2eb1d
feat(plugin-report): the report table formats a record-mode amount in…
claude Oct 29, 2026
24055cf
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 29, 2026
a9b2b97
chore(spec): regenerate the authorable surface on the merged tree (#2…
claude Oct 29, 2026
554bd57
docs(core): keep formatDimensionValue's docblock on its function (#27…
claude Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/27188-record-currency-display.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
'@object-ui/core': patch
'@object-ui/i18n': patch
'@object-ui/fields': patch
'@object-ui/components': patch
'@object-ui/plugin-form': patch
'@object-ui/plugin-grid': patch
'@object-ui/plugin-list': patch
'@object-ui/plugin-dashboard': patch
'@object-ui/plugin-report': patch
'@object-ui/plugin-detail': patch
'@object-ui/plugin-gantt': patch
'@object-ui/app-shell': patch
---

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)

Clause-②: no
Metadata-protocol: none

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:

- **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.
- **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`.
- **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.
- **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.
- **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.
- **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.
- **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.

⛔ Not released alone: this ships in the same release as `currencyMode: 'record'` itself and its aggregate refusals.
15 changes: 15 additions & 0 deletions .changeset/27188-share-link-record-currency.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@objectstack/types': patch
'@object-ui/console': patch
---

fix(types, console): a shared `record`-mode currency amount shows its record's own currency (ADR-0144 D2, D9)

Clause-②: no
Metadata-protocol: none

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.

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.

⛔ Not released alone: this ships in the same release as `currencyMode: 'record'` itself.
27 changes: 27 additions & 0 deletions packages/types/src/share-link-resolve-answer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,30 @@ describe('[#26991] redactSharedRecord keeps the redaction both mounts served', (
expect(redactSharedRecord(null, ['a'])).toBeNull();
});
});

describe('[#27188] a record-mode amount (ADR-0144 D2)', () => {
const RECORD_SCHEMA = {
name: 'deal',
fields: {
amount: { name: 'amount', type: 'currency', currencyConfig: { currencyMode: 'record', currencyField: 'currency_code' } },
currency_code: { name: 'currency_code', label: 'Currency', type: 'text', valueDomain: 'iso_4217_currency' },
},
};

it('projects the currencyField pointer when the record carries the code field', () => {
const fields = projectSharedRecordFields(RECORD_SCHEMA, { amount: 5, currency_code: 'EUR' }, []);
expect(fields.amount.currencyConfig).toEqual({ currencyMode: 'record', currencyField: 'currency_code' });
expect(fields.currency_code).toEqual({ label: 'Currency', type: 'text' });
});

it('never names a redacted code field', () => {
const answer = buildShareLinkResolveAnswer(
{ amount: 5, currency_code: 'EUR' },
{ ...RESOLVED, redactFields: ['currency_code'] } as ResolveShareLinkResult,
RECORD_SCHEMA,
);
expect(answer.record).toEqual({ amount: 5 });
expect(answer.fields.amount.currencyConfig).toEqual({ currencyMode: 'record' });
expect(answer.fields.currency_code).toBeUndefined();
});
});
23 changes: 19 additions & 4 deletions packages/types/src/share-link-resolve-answer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@
* Each entry carries only what a read-only renderer needs to draw the value:
* the authored `label` and `type`; a picklist's `options` (value, label and
* colour — never `default` or visibility rules); `multiple`; a currency field's
* `currencyConfig`; and a lookup's `reference` (target object) and authored
* `currencyConfig` (its mode, a fixed code, and — for a `record`-mode amount,
* ADR-0144 D2 — the `currencyField` pointer, only while `record` also carries
* that code field, so a redacted code field's name is never disclosed); and a
* lookup's `reference` (target object) and authored
* `displayField`. The labels are the authored ones the object schema holds:
* the resolve read path has no caller-locale seam, and this module builds none.
* A lookup's value stays what the record read returns — the stored id; nothing
Expand All @@ -52,6 +55,12 @@ export interface ShareLinkFieldOption {
export interface ShareLinkFieldCurrencyConfig {
currencyMode?: string;
defaultCurrency?: string;
/**
* A `record`-mode amount's sibling code field (ADR-0144 D2): the page reads
* the record's code from it. Projected only when the answer's `record`
* carries that field.
*/
currencyField?: string;
}

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

function projectField(field: Record<string, unknown>): ShareLinkFieldDisplay {
function projectField(field: Record<string, unknown>, carried: (key: string) => boolean): ShareLinkFieldDisplay {
const out: ShareLinkFieldDisplay = {};
if (typeof field.label === 'string') out.label = field.label;
if (typeof field.type === 'string') out.type = field.type;
const options = projectOptions(field.options);
if (options) out.options = options;
if (field.multiple === true) out.multiple = true;
if (isPlainObject(field.currencyConfig)) {
const { currencyMode, defaultCurrency } = field.currencyConfig;
const { currencyMode, defaultCurrency, currencyField } = field.currencyConfig;
out.currencyConfig = {
...(typeof currencyMode === 'string' ? { currencyMode } : {}),
...(typeof defaultCurrency === 'string' ? { defaultCurrency } : {}),
// The pointer names another field: kept only when that field is one the
// record shows, so the answer still describes nothing the record hides.
// Without it a record-mode amount renders as a plain number.
...(typeof currencyField === 'string' && carried(currencyField) ? { currencyField } : {}),
};
}
if (typeof field.reference === 'string') out.reference = field.reference;
Expand All @@ -142,10 +155,12 @@ export function projectSharedRecordFields(
const fields: Record<string, ShareLinkFieldDisplay> = {};
if (!isPlainObject(record) || !isPlainObject(schema) || !isPlainObject(schema.fields)) return fields;
const declared = schema.fields;
const carried = (key: string) =>
!redactFields.includes(key) && Object.prototype.hasOwnProperty.call(record, key);
for (const key of Object.keys(record)) {
if (redactFields.includes(key) || !Object.prototype.hasOwnProperty.call(declared, key)) continue;
const field = declared[key];
if (isPlainObject(field)) fields[key] = projectField(field);
if (isPlainObject(field)) fields[key] = projectField(field, carried);
}
return fields;
}
Expand Down
11 changes: 9 additions & 2 deletions ui/apps/console/src/pages/SharedRecordFields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ function formatValue(value: unknown, locale: string, t: Translate, depth = 0): s
* authored label and colour from `field.options`; `CurrencyCellRenderer`
* resolves its code with `resolveFieldCurrency` from the field's
* `currencyConfig` and the tenant default the localization context holds
* (none on this page). Neither fetches. ⛔ Never add `LookupCellRenderer` or
* (none on this page) — and, for a `record`-mode amount, from the record's own
* code field, which it reads off `dependentValues` (the shared record itself). Neither fetches. ⛔ Never add `LookupCellRenderer` or
* `UserCellRenderer`: both read the referenced record through the data source,
* which an anonymous visitor cannot.
*/
Expand Down Expand Up @@ -146,7 +147,13 @@ export function SharedRecordFields({ record, fields }: SharedRecordFieldsProps)
<dt className="text-xs font-medium text-muted-foreground">{field?.label || humanizeLabel(key)}</dt>
<dd className="min-w-0 whitespace-pre-wrap break-words text-sm text-foreground">
{Renderer ? (
<Renderer value={record[key]} field={{ name: key, ...field } as unknown as FieldMetadata} />
<Renderer
value={record[key]}
field={{ name: key, ...field } as unknown as FieldMetadata}
// The shared record: a record-mode amount reads its currency
// from the code field beside it (ADR-0144 D2).
dependentValues={record}
/>
) : (
formatValue(record[key], locale, t)
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* objectstack#27188 — a shared `record`-mode amount (ADR-0144 D2) shows the
* currency its own record names: the resolve answer projects the amount's
* `currencyField` pointer, and the page hands the shared record to the
* currency face. A code field the link hides leaves a plain number.
*/
import '@testing-library/jest-dom/vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { SchemaRendererProvider } from '@object-ui/react';
import SharedRecordPage from './SharedRecordPage';

const TOKEN = 'tok_27188';
const RESOLVE_PATH = `/api/v1/share-links/${TOKEN}/resolve`;

const RECORD_MODE = { currencyMode: 'record', currencyField: 'currency_code' } as const;

const DEAL = { id: 'deal_1', name: 'Shared deal', amount: 1250, currency_code: 'EUR' };
const FIELDS = {
name: { label: 'Deal Name', type: 'text' },
amount: { label: 'Amount', type: 'currency', currencyConfig: RECORD_MODE },
currency_code: { label: 'Currency', type: 'text' },
};

const json = (status: number, body: unknown) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });

/** Stub the resolve route; answer `fields` only when given. Returns every requested path. */
function shareServer(record: Record<string, unknown>, fields?: Record<string, unknown>): string[] {
const requested: string[] = [];
vi.stubGlobal(
'fetch',
vi.fn(async (input: string | URL | Request) => {
const raw = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
const { pathname } = new URL(raw, 'http://localhost');
requested.push(pathname);
if (pathname === RESOLVE_PATH) {
return json(200, {
success: true,
data: {
record,
link: {
id: 'sl_1',
token: TOKEN,
object_name: 'deal',
record_id: String(record.id),
permission: 'view',
audience: 'anyone',
},
redactFields: ['ssn'],
...(fields ? { fields } : {}),
},
});
}
return new Response(null, { status: 404 });
}),
);
return requested;
}

function renderLink(dataSource?: Record<string, unknown>) {
const routed = (
<MemoryRouter initialEntries={[`/s/${TOKEN}`]}>
<Routes>
<Route path="/s/:token" element={<SharedRecordPage />} />
</Routes>
</MemoryRouter>
);
return render(
dataSource ? <SchemaRendererProvider dataSource={dataSource as never}>{routed}</SchemaRendererProvider> : routed,
);
}

/** The value cell beside the row whose label reads `label`. */
function cellOf(label: string): HTMLElement {
const term = screen.getByText(label, { selector: 'dt' });
const value = term.parentElement?.querySelector('dd');
expect(value).not.toBeNull();
return value as HTMLElement;
}

afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});

describe('shared record — a record-mode amount', () => {
it('shows the currency the record\'s code field names', async () => {
shareServer(DEAL, FIELDS);
renderLink();
await screen.findByText('Amount', { selector: 'dt' });
expect(cellOf('Amount').textContent).toContain('€');
});

it('follows each record\'s own code', async () => {
shareServer({ ...DEAL, currency_code: 'JPY', amount: 1250 }, FIELDS);
renderLink();
await screen.findByText('Amount', { selector: 'dt' });
expect(cellOf('Amount').textContent).toMatch(/¥|JP¥/);
expect(cellOf('Amount').textContent).not.toContain('€');
});

it('a hidden code field (no pointer, no code) leaves a plain number', async () => {
const { currency_code: _hidden, ...record } = DEAL;
shareServer(record, { ...FIELDS, amount: { ...FIELDS.amount, currencyConfig: { currencyMode: 'record' } } });
renderLink();
await screen.findByText('Amount', { selector: 'dt' });
expect(cellOf('Amount').textContent).not.toMatch(/€|\$|¥/);
expect(cellOf('Amount').textContent).toMatch(/1,?250/);
});
});
6 changes: 5 additions & 1 deletion ui/apps/console/src/pages/shared-record-shape.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,11 @@ export interface SharedFieldDisplay {
/** A picklist's options: value, authored label and colour. */
options?: Array<{ value: string; label: string; color?: string }>;
multiple?: true;
currencyConfig?: { currencyMode?: string; defaultCurrency?: string };
/**
* A currency field's currency. `currencyField` names a `record`-mode
* amount's code field (ADR-0144 D2), present only when `record` carries it.
*/
currencyConfig?: { currencyMode?: string; defaultCurrency?: string; currencyField?: string };
/** A lookup's target object. The record's value stays the stored id. */
reference?: string;
displayField?: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@ interface LedgerRow {
const LOOKUP = 'entry@fields/src/widgets/LookupField.tsx';
const USER = 'entry@fields/src/widgets/UserField.tsx';
const CURRENCY = 'entry@i18n/src/currency.ts';
const USED_CODES = 'entry@components/src/renderers/form/usedCurrencyCodes.ts';

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