Skip to content

Commit 1ed3360

Browse files
feat(ui): record-mode currency — each record shows its own currency; ISO 4217 code select pre-filled with the organization currency; no cross-currency totals; Studio currency section (#27340)
epic:#8345#1 · os-bill · session_01Utp6CbTZrFqRYNgjSaSbN5 <!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg9oTskgBVZmtxXLAEh251Cb)_ Fixes #27188 ⛔ not released alone — R1: pairs with #27184, #27186, #27187, #27188 (one Version Packages release) Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么:** record 模式的金额按每条记录自己的币种显示(列表、表单、详情、仪表盘、甘特提示);币种码字段变成 ISO 4217 分组搜索下拉,「可用」组含组织币种和该对象已用过的币种,新建时预填组织币种;列表筛选里 record 金额条件带必填币种选择,生成「金额条件 且 币种 = C」;列表页脚与非按币种分组的合计不再跨币种相加;Studio 字段编辑器新增币种模式、币种字段选择和「创建币种字段」;分享链接页的 record 金额也按记录自己的币种显示;按币种分组的分析结果(仪表盘表格、报表表格及其导出、Studio 数据集预览)每行按该行币种格式化。 - **为什么改:** 没有这一步,record 字段会显示租户币种符号,比今天更错(ADR-0144 D9:R1 必含)。 - **风险与回滚:** 只影响声明 `currencyMode: 'record'` 的字段和声明 ISO 4217 值域的文本字段;dynamic / fixed 字段行为不变(原有测试全过)。回滚即 revert 本 PR。 - **先看:** `ui/packages/i18n/src/currency.ts`(解析器,record 分支)、`ui/packages/components/src/custom/filterRecordCurrency.ts`(筛选配对)、`ui/packages/components/src/renderers/form/usedCurrencyCodes.ts`(已用币种查询)、`ui/packages/plugin-grid/src/recordCurrencyAggregates.ts`(合计规则)。 - **两分钟验证:** `cd ui && pnpm exec vitest run packages/fields/src/__tests__/recordCurrency.faces-27188.test.tsx`,应见三行 $ / € / ¥,无币种的行显示纯数字。 - **需要你做的:** 无,席位落地(#27268 已合并)。 ## What changes ADR-0144 D2 (the record currency lives in a sibling code field), D6 (raw aggregates over a record-mode amount stand only inside one currency) and D8 rule 4 (the form pre-fills the organization currency; the stored value is always explicit), on the renderer side. - **Resolver** (`@object-ui/i18n`). `resolveFieldCurrency(field, tenantDefault, record?)` gains an optional record. Under `currencyMode: 'record'` it reads only `record[currencyConfig.currencyField]`; no record or a blank code resolves to `undefined`, so the face shows a plain number. It never falls back to the tenant default, a node-level `currency` or a stray `defaultCurrency`. `dynamic` / `fixed` are unchanged. New `recordCurrencyField(field)` names the pointer; `FieldCurrencyConfig` types the three modes. - **Faces that hold the record now pass it.** `CurrencyCellRenderer` and the formula cell (the grid already hands `dependentValues={row}`), `CurrencyField` (symbol, minor-unit decimals and spinner step follow the selected code), the form renderer (hands the live record to the `currency` and `formula` widgets, `RECORD_CURRENCY_WIDGET_TYPES`), the grid's mobile card, the record page's summary chips, the dashboard table cell and record drawer (`FieldMeta.currencyConfig`, copied only for record mode), and Gantt tooltips. - **Code field.** A `text` field declaring `valueDomain: 'iso_4217_currency'` renders `CurrencyCodeField`: a searchable select grouped "Available" (home currency, then rated, then the codes in use) and "All currencies (ISO 4217)", offering only codes `isValueDomainMember('iso_4217_currency', code)` accepts. Re-selecting the selected code keeps it (no click-to-clear). The picker is `CurrencyCodePicker` in `@object-ui/components`, shared with the filter row below. - **Used codes** (`@object-ui/components` form renderer, `usedCurrencyCodes.ts`). For every ISO 4217 code field on a form with an `objectName`, the renderer asks `dataSource.queryGroupHeaders(object, compileListViewGroupQuery(grouped by the code field))` once, keyed on content, and hands the distinct stored codes down on the field's own metadata as `usedCurrencyCodes`. No widget-contract key. All five plugin-form hosts already put `objectName` on the form schema, so this one seam covers them. A data source without `queryGroupHeaders` is not asked (`aggregate` may degrade to a full fetch); a failed read hands nothing down. Either way the picker offers home + rated + the record's own code. - **Filters** (`@object-ui/components` FilterBuilder, `@object-ui/plugin-list`). A builder field entry may carry `currencyField` (the list sets it from `currencyConfig` for record-mode amounts). A value-comparing row on such an amount draws a required currency picker, defaulting to `localization.currency`. The builder emits the code condition as a companion row of the same flat group, `{ id: '<row id>::currency', field: <code field>, operator: 'equals', value: C }`, so the group lowers to `amount op X AND code = C` everywhere it is lowered (`convertFilterGroupToAST` gives `['and', ['amount','>',100], ['currency_code','=','USD']]`) and stored filters keep the `ViewFilterRule` shape. The companion is drawn as the picker, never as a row, and goes when its amount row goes, switches to a non-pairing field, or switches to a presence operator. A paired group cannot be toggled to OR, because under OR the pair would split; a stored OR group shows why. Opening a stored filter emits nothing (a stored amount row without a code shows a blank, invalid picker until the next edit). The toolbar's filter count does not count companions. - **Create forms** (`@object-ui/plugin-form`). `SeedContext.homeCurrency`: a code field that some record-mode amount points at, with no declared default, opens on `localization.currency`. Wired into ObjectForm, DrawerForm, ModalForm, TabbedForm and WizardForm (`SplitForm` is retired on `main`, #27159). Edit forms are not seeded, so opening a record never rewrites its stored code. - **Totals** (`@object-ui/plugin-grid`). The footer draws no sum / avg / min / max for a record-mode amount; counts stand. Group-header aggregations keep those only when the grid is grouped by the amount's code field (`honestGroupAggregations`, applied before both the server header query and client bucketing). The home field is a `dynamic` amount and totals as before. - **Studio** (`@object-ui/app-shell`). A currency field, or a formula returning `currency`, gets a currency section (`CurrencyConfigField`): mode, the fixed code, and under `record` a `currencyField` picker listing only the object's `text` + ISO 4217 fields, with "Create currency field", which appends `currency_code` (`type: 'text'`, `valueDomain: 'iso_4217_currency'`, `required: true`) and sets the pointer in one write. A mode switch keeps only the keys that mode reads. - **Share links** (`@objectstack/types`, `@object-ui/console`; R1 rider from #27268's contract review). The resolve answer's field projection (`buildShareLinkResolveAnswer`) carried only `currencyMode` and `defaultCurrency`, so a shared record-mode amount rendered as a plain number. A currency field's projected `currencyConfig` now also carries `currencyField`, only when the answer's `record` carries that code field, so a link that redacts the code field discloses neither the code nor the field's name and the amount shows as a plain number. The shared-record page passes the shared record to the currency face as `dependentValues`, so the amount shows in its own record's currency. - **Dataset answers** (`@object-ui/core`, `@object-ui/plugin-dashboard`, `@object-ui/plugin-report`, `@object-ui/app-shell`; R1 rider from #27338's contract review). A grouped analytics answer over a `record`-mode amount marks its measure column with `AnalyticsResult.fields[].currencyField`, naming the result column that holds each row's code (#27338). The readers passed only `fields[].currency`, so those columns rendered as plain numbers. New `measureCellCurrency(field, row)` returns that row's code when the column carries `currencyField`, nothing for a row without one, and the column's declared `currency` otherwise, so every other column formats byte for byte as before. It reads `currencyField` structurally (`(field as { currencyField?: unknown }).currencyField`): #27338's contract is not on `main` yet, so the spec type does not carry the key. That passes type-check, and it can switch to the spec type once #27338 lands. The dashboard `table` widget's body cells, the report table's body cells and its export snapshot, and the Studio dataset preview read it off the RAW row (`state.rows[i]`, before `relabelDimensions`). Totals and cross-tab cells pass no row and so draw no symbol, since they may add several currencies. `chart-series.ts` reads no currency at all (only the numeral `format`), so it has nothing to switch, and a chart axis has one unit. - **i18n.** `fields.currencyCode.*` in all eleven locale packs; `designer.field.currency.*` in the studio namespace (en, zh). File surface beyond the Claim's list, accepted by the epic PM: `ui/packages/plugin-grid`, `ui/packages/components`; also `ui/packages/plugin-form`, `ui/packages/plugin-detail`, `ui/packages/plugin-gantt` (one resolver call each, plus the form seeding), and `ui/packages/plugin-list` (the list filter's `currencyField` and filter count). ## Tests - `i18n/src/__tests__/resolveFieldCurrency.recordMode-27188.test.ts` — the record-mode truth table. - `fields/src/__tests__/recordCurrency.faces-27188.test.tsx` — cells in USD / EUR / JPY, no-code rows plain, the amount box's symbol and step (USD 0.01, JPY 1, KWD 0.001), the code select's groups and domain filter, `TextField` routing. - `plugin-form/src/__tests__/recordCurrencySeed-27188.test.ts` — seeding, unset setting, author default and caller record win. - `plugin-grid/src/__tests__/recordCurrencyAggregates-27188.test.tsx` — footer refuses money aggregates over record mode, counts stand, home field sums; group aggregations by grouping field. - `plugin-dashboard/src/__tests__/recordFields.recordCurrency-27188.test.tsx` — dashboard cells per row. - `app-shell/.../CurrencyConfigField.recordMode-27188.test.tsx` — mode switch, candidates, and the create shortcut's output parses under `ObjectSchema`. - `components/src/renderers/form/__tests__/form-used-currency-codes-27188.test.tsx` — the query shape, the distinct domain-valid read, one ask per object across re-renders, no ask without an object or the capability, a failed read. - `components/src/__tests__/filter-builder-record-currency-27188.test.tsx` — the pairing rules, one picker per paired row, a new row on the home currency, removal, the stored blank picker emitting nothing, the AND lock and the OR notice. - `plugin-list/src/__tests__/convertFilterGroupToAST.recordCurrency-27188.test.ts` — the pair lowers to `amount op X AND code = C`. - `packages/types/src/share-link-resolve-answer.test.ts` gains: the pointer is projected when the record carries the code field, and a redacted code field is never named. - `console/src/pages/SharedRecordPage.recordCurrency-27188.test.tsx` — a shared record-mode amount shows its record's currency (EUR, then JPY), and a hidden code field leaves a plain number. - `core/src/utils/__tests__/dataset-format.recordCurrency-27188.test.ts` — `measureCellCurrency`: per-row code, no code / no row gives no symbol, a column without `currencyField` keeps `currency`; two rows of one column format as `$1,234.50` and `€1,234.50`. - `app-shell/.../previews/DatasetPreview.measureLocale.test.tsx` gains: a record-mode column renders `$1,234.50` and `€99.00` in one table. - `plugin-dashboard/src/__tests__/DatasetWidget.recordCurrency-27188.test.tsx` — the table widget formats USD, EUR and a code-less row (plain), and the grand total has no symbol. - `plugin-report/src/__tests__/DatasetReportRenderer.measureLocale.test.tsx` gains: the grouped report table formats USD, EUR and a code-less row (plain), and the grand total has no symbol. - `fields/.../recordCurrency.faces-27188.test.tsx` gains: the used codes off `field.usedCurrencyCodes` appear in "Available" after the home currency. ## Acceptance notes - **Seat ruling: A/A** (epic PM, on the two open questions of the first report). (1) The form reads the used codes once per object and passes them on the field metadata, with no widget-contract key. (2) The filter row gets a required currency picker defaulting to the organization currency, and the builder emits `amount op X AND code = C`. Both are built here. - **Rated group is a seam.** `ratedCurrencyCodes()` in `CurrencyCodeField.tsx` returns nothing until `sys_exchange_rate` ships (R2, #27185). - **Used codes, where asked.** The read rides the form renderer, the seam every form host already reaches with `objectName`, rather than five host-level copies. It is a per-mount read: a code first used after the form opened shows up on the next open. - **Filter scope.** The pairing is the list's filter builder (`FilterBuilder` + `ListView`). The sharing-rule criteria editor (`FilterConditionField`, stored as a Mongo-style object) is not paired: it re-ids rows on every read, so a code condition there stays a row the author writes. - **No organization currency.** With `localization.currency` unset a new pair's code is blank and the picker is marked invalid; the list's lowering drops a blank row, so that filter compares the amount alone until a code is picked. - **Minor units.** The amount box reads the existing `currencyDisplayWidth` / `currencyFractionDigits` reader (CLDR via `Intl`), which `CURRENCY_FRACTION_DIGITS` mirrors by construction (its header); no second table. - **Chart tooltips, follow-on.** A chart over a grouped record-mode answer still shows plain numbers in its tooltips. `chart-series.ts` reads no currency at all (only the numeral `format`), and a value axis has one unit, so per-row currency there is a follow-on, not part of R1's table surfaces. - **Home field display** (`homeAmount`) is #27196's. - **Metric tiles** already show a plain number for a record-mode aggregate (the resolver has no record); the build-time refusal is #27187's. ## Verification Head `554bd578` on `a9b2b97d`, which regenerates `packages/spec/authorable-surface/data.json` on the merged tree (the result is byte-identical to `main`'s), on `24055cf0`, a merge commit bringing in `main` at `f7188b56` after #27268 landed as `41452103` (no rebase). The diff against `main` is this card's own: 70 files, none under `packages/spec`. Two conflicts: - `ui/packages/i18n/src/currency.ts`. `main`'s side is #27268's final file, byte-identical to the one this branch had already reconciled, so this branch's side is kept. Under `record` mode the resolver reads the row's code when the caller passes the row; a call with no record still resolves to `undefined`, which #27268's own `resolveFieldCurrency.test.ts` cases pin. - `ui/packages/plugin-form/src/SplitForm.tsx`. `main` retired it (#27159), so the deletion stands, along with this card's seeding call in it. `554bd578` moves `measureCellCurrency` above `formatDimensionValue`'s docblock, which it had orphaned. It is a doc-only move. `check:generated` (spec) passes at this head. ADR-0144 citations (D2, D3, D6, D8 rules 3 and 4, D9) were re-read against the landed `docs/adr/0144-…md` and hold. <details><summary>Landing verification at <code>554bd578</code></summary> - Builds: `@objectstack/spec`, `@objectstack/types`, and the ui closure of app-shell, console and plugin-report, exit 0. `@objectstack/spec check:generated` exit 0. - Tests, all exit 0 under `os-verify-lock`: - the targeted ui set plus the Studio, census, #8415, dataset-format, dataset preview, dashboard table and report table files: 55 files, 855 passed; - every `plugin-form/src` suite: 210 files, 2343 passed, 1 skipped; - every `core/src` suite after the docblock move: 218 files, 4133 passed, 27 skipped; - share-link runtime, types and console: 12, 9 and 22 passed. - Type-check exit 0: `@object-ui/` i18n, fields, components, plugin-form, plugin-grid, plugin-list, plugin-dashboard, plugin-detail, plugin-gantt, plugin-report, core, app-shell, console, and `@objectstack/types`. - Gates: `dispatch-gates --commands` on this card's 70 paths derives no family the earlier runs had not. The 34 local re-runs exit 0; the rest are CI's. </details> <details><summary>Targeted tests — 49 files, 786 tests, exit 0</summary> ``` cd ui && pnpm exec vitest run --maxWorkers=2 (every *27188* test above, #27268's resolveFieldCurrency suites, every components filter-builder suite, form-data-source-wiring, form-dependent-values, the plugin-list convertFilterGroupToAST suites, filter-operator-ast-parity and filter-count suites, and the currency neighbours: currencyMode.faces-10422, CurrencyField.minorUnits, TextField.maxLength-10179, optionDefaultSeed-11914, useColumnSummary.currencyMode-10422, recordFields.currencyConfig-10463, ObjectFieldInspector.valueDomain-7597) Test Files 49 passed (49) Tests 786 passed (786) os-verify-lock: VERDICT command-exit 0 ``` Before the merge, the neighbouring-package run (i18n, components form renderer and custom, fields widgets, plugin-list filter suites) was 328 files, 3544 passed, 30 skipped. </details> <details><summary>Type-check and lint</summary> `pnpm --filter @object-ui/PKG type-check` exit 0 at `ad386034` for i18n, fields, components, plugin-form, plugin-grid, plugin-list, plugin-dashboard, plugin-detail, plugin-gantt, app-shell, after building their dependency closure. `pnpm exec eslint` over every ui file this card touches: 0 errors. </details> <details><summary>Ablations at <code>ad386034</code> — three, each red then restored</summary> - Resolver record branch, `ui/packages/i18n/src/currency.ts`: `currencyMode === 'record'` → `'ABLATED'` (blob `82ae7ac6` → `c78a363b`): 7 failed / 8 passed across the i18n record-mode suite and the fields faces suite. - Used codes hand-off, `ui/packages/components/src/renderers/form/form.tsx`: `withUsedCurrencyCodes(field.field || field, …)` → `field.field || field` (blob `12d5c6b5` → `18cbb6d6`): 1 failed / 6 passed. - Filter pairing, `ui/packages/components/src/custom/filterRecordCurrency.ts`: `pairedCurrencyField` answers `undefined` (blob `025b1560` → `d78defb9`): 9 failed / 4 passed across the builder suite and the AST lowering. Each restored by `ablation-replace.mjs`: blob == HEAD, `git diff HEAD` empty. </details> <details><summary>Share-link rider at <code>8bcaa1bc</code></summary> - Tests: `@objectstack/runtime` `share-links-field-projection.test.ts` 12 passed; `@objectstack/types` `share-link-resolve-answer.test.ts` 9 passed; console `SharedRecordPage.recordCurrency-27188`, `SharedRecordPage-27121`, `shared-record-shape` 22 passed. All exit 0 under `os-verify-lock`. - Type-check: `@objectstack/types` and `@object-ui/console` exit 0. eslint over the five files: 0 errors. - Ablation, console page: `dependentValues={record}` → `dependentValues={undefined}` in `SharedRecordFields.tsx` (blob `e4ce54ff` → `88b99e73`): 2 failed / 1 passed. Restored: blob == HEAD. - Ablation, projection: the `currencyField` spread → `false ? … : {}` in `share-link-resolve-answer.ts` (blob `8c7a3bd5` → `57379712`): 1 failed / 8 passed. Restored: blob == HEAD. </details> <details><summary>CI fixes at <code>66a421cc</code> and <code>c36aa49e</code></summary> CI's Test UI at `8bcaa1bc` failed one test: `ui/packages/types/src/__tests__/filter-builder-condition-id-8415.test.ts` reads `removeCondition`'s id filter verbatim, and this card had widened it to also drop the companion. The line is back to the pinned form. The companion still goes with its amount row, because `handleChange` runs `syncRecordCurrencyRows`, which drops a companion whose amount row is gone. The "removing the amount row removes its code condition" case covers this. - Tests: the #8415 pin plus every components `__tests__` and `custom/__tests__` suite, 255 files, 2431 passed, 7 skipped, exit 0 under `os-verify-lock`. - Type-check: `@object-ui/components` exit 0. eslint: 0 errors. CI's Test UI at `8bcaa1bc` also failed `ui/examples/schema-catalog/test/form-field-channel-census-12130.test.tsx`, which measures every key the form-field channel reads by name. This card adds two reads: the form's used-codes hand-off (`usedCurrencyCodes.ts`) reads the entry's `field` stash and `valueDomain`, and the currency-code widget reads `valueDomain` off its carrier. `usedCurrencyCodes.ts` joins the `field` row's readers. `valueDomain` gets a RUNTIME-ONLY ledger row: it is object-field metadata (ADR-0144 D2) the code field takes from the object, so it is ledgered rather than declared on `FormFieldSchema`. - Tests: the census and its effects file, 2 files, 120 passed, exit 0 under `os-verify-lock`. </details> <details><summary>Studio and #27268's record-mode refusals</summary> #27268 refuses `defaultCurrency` under `currencyMode: 'record'`, and `currencyField` has `.min(1)`. The Studio currency section never writes either refused shape. `switchCurrencyMode` drops `defaultCurrency` on the switch to `record`, the pointer picker deletes the key rather than writing `''`, and "Create currency field" writes a fresh `{ currencyMode: 'record', currencyField }`. A new test drives the whole path through the inspector: a fixed EUR amount, a switch to Record, then a picked pointer. It asserts the saved config and parses the saved object with `ObjectSchema`. - Ablation: `switchCurrencyMode` carries `prev.defaultCurrency` into `record` (blob `ad4d29d0` → `8cd4f3de`): 2 failed / 3 passed. Restored: blob == HEAD. - Tests: the targeted ui set plus the Studio, census and #8415 files, 51 files, 831 passed. Spec `currency-config-record-mode.test.ts`: 15 passed. Share-link runtime, types and console: 12, 9 and 22 passed. All exit 0 under `os-verify-lock`. - Type-check: i18n, fields, components, plugin-list, app-shell and console exit 0. - Gates: the 27 local re-runs, plus the 8 families `dispatch-gates --commands` newly derives at this head (`check:adr-anchors`, `bash32-floor`, `cli-command-ids`, `entry-guard`, `parse-guard`, `pnpm-filter-targets`, `pnpm-flag-spellings`, `scripts-symbol-anchors`), exit 0 each. </details> <details><summary>Dataset-answer rider at <code>7d062908</code></summary> - Ablation: `measureCellCurrency` always answers the column's `currency` (blob `488643d0` → `da6fa0c1`): 4 failed / 9 passed, one red case in each of the three files (core, preview, dashboard table). Restored: blob == HEAD. - Tests: every suite under `core/src/utils/__tests__/`, `app-shell/.../metadata-admin/previews/` and `plugin-dashboard/src/__tests__/`: 433 files, 5275 passed, 6 skipped, exit 0 under `os-verify-lock`. - Type-check: `@object-ui/core`, `@object-ui/plugin-dashboard`, `@object-ui/app-shell` exit 0. eslint over the six files: 0 errors. - Gates: `dispatch-gates --commands` derives no new family at this head. The 34 local re-runs exit 0. Report table at `3e2eb1d0`: - Ablation: the report's `fmt` reads `measureField(measure)?.currency` instead of `measureCellCurrency(measureField(measure), row)` (blob `8f61bf2d` → `631d266c`): 1 failed / 10 passed. Restored: blob == HEAD. - Tests: every `plugin-report/src` suite, 30 files, 278 passed, 7 skipped, exit 0 under `os-verify-lock`. - Type-check `@object-ui/plugin-report` exit 0. eslint: 0 errors. No new gate family. The 34 local re-runs exit 0. </details> <details><summary>Derived gates — 34 local runs, exit 0 each</summary> Re-run locally at each head: the families `dispatch-gates --commands` matches by this card's paths, plus `check:nul-bytes`, `check:cross-package-test-inputs`, `check:doc-authoring`, `check:issue-citations`, `check:type-check-coverage`, `check:keyed-text-bounds`, `check:role-word`, `check:adr-anchors`, `check:bash32-floor`, `check:cli-command-ids`, `check:entry-guard`, `check:parse-guard`, `check:pnpm-filter-targets`, `check:pnpm-flag-spellings`, `check:scripts-symbol-anchors`, and `@objectstack/spec check:generated`. The rest are CI's. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Utp6CXtadYa5QXb2PbXPev --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CXtadYa5QXb2PbXPev)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 556f323 commit 1ed3360

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)