diff --git a/.changeset/27188-record-currency-display.md b/.changeset/27188-record-currency-display.md new file mode 100644 index 00000000000..6458a1e3442 --- /dev/null +++ b/.changeset/27188-record-currency-display.md @@ -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. diff --git a/.changeset/27188-share-link-record-currency.md b/.changeset/27188-share-link-record-currency.md new file mode 100644 index 00000000000..abe82bce94d --- /dev/null +++ b/.changeset/27188-share-link-record-currency.md @@ -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. diff --git a/packages/types/src/share-link-resolve-answer.test.ts b/packages/types/src/share-link-resolve-answer.test.ts index a14e2ced83f..5cdd5a7f564 100644 --- a/packages/types/src/share-link-resolve-answer.test.ts +++ b/packages/types/src/share-link-resolve-answer.test.ts @@ -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(); + }); +}); diff --git a/packages/types/src/share-link-resolve-answer.ts b/packages/types/src/share-link-resolve-answer.ts index 7c0a9303886..8c7a3bd5c72 100644 --- a/packages/types/src/share-link-resolve-answer.ts +++ b/packages/types/src/share-link-resolve-answer.ts @@ -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 @@ -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. */ @@ -109,7 +118,7 @@ function projectOptions(options: unknown): ShareLinkFieldOption[] | undefined { return out; } -function projectField(field: Record): ShareLinkFieldDisplay { +function projectField(field: Record, 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; @@ -117,10 +126,14 @@ function projectField(field: Record): ShareLinkFieldDisplay { 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; @@ -142,10 +155,12 @@ export function projectSharedRecordFields( const fields: Record = {}; 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; } diff --git a/ui/apps/console/src/pages/SharedRecordFields.tsx b/ui/apps/console/src/pages/SharedRecordFields.tsx index f1a12011f64..e4ce54ff2b6 100644 --- a/ui/apps/console/src/pages/SharedRecordFields.tsx +++ b/ui/apps/console/src/pages/SharedRecordFields.tsx @@ -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. */ @@ -146,7 +147,13 @@ export function SharedRecordFields({ record, fields }: SharedRecordFieldsProps)
{field?.label || humanizeLabel(key)}
{Renderer ? ( - + ) : ( formatValue(record[key], locale, t) )} diff --git a/ui/apps/console/src/pages/SharedRecordPage.recordCurrency-27188.test.tsx b/ui/apps/console/src/pages/SharedRecordPage.recordCurrency-27188.test.tsx new file mode 100644 index 00000000000..2306a7649ad --- /dev/null +++ b/ui/apps/console/src/pages/SharedRecordPage.recordCurrency-27188.test.tsx @@ -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, fields?: Record): 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) { + const routed = ( + + + } /> + + + ); + return render( + dataSource ? {routed} : 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/); + }); +}); diff --git a/ui/apps/console/src/pages/shared-record-shape.ts b/ui/apps/console/src/pages/shared-record-shape.ts index ae8d76aae8c..ace872ac299 100644 --- a/ui/apps/console/src/pages/shared-record-shape.ts +++ b/ui/apps/console/src/pages/shared-record-shape.ts @@ -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; diff --git a/ui/examples/schema-catalog/test/form-field-channel-census-12130.test.tsx b/ui/examples/schema-catalog/test/form-field-channel-census-12130.test.tsx index 4b0dce28f76..9b65b29d214 100644 --- a/ui/examples/schema-catalog/test/form-field-channel-census-12130.test.tsx +++ b/ui/examples/schema-catalog/test/form-field-channel-census-12130.test.tsx @@ -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 @@ -310,7 +311,9 @@ const LEDGER: Readonly> = { }, // ── 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).', }, @@ -318,6 +321,11 @@ const LEDGER: Readonly> = { 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.', diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/CurrencyConfigField.recordMode-27188.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/CurrencyConfigField.recordMode-27188.test.tsx new file mode 100644 index 00000000000..17daaa41a0f --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/CurrencyConfigField.recordMode-27188.test.tsx @@ -0,0 +1,145 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0144 D2 in the Studio field editor: a currency amount's mode, the + * `record`-mode `currencyField` picker over the object's own ISO 4217 code + * fields, and "Create currency field", which writes explicit metadata. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest'; +import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ObjectSchema } from '@objectstack/spec/data'; +import { + currencyCodeFieldCandidates, + nextCurrencyCodeFieldName, + switchCurrencyMode, +} from './CurrencyConfigField'; + +vi.mock('../useMetadata', () => ({ + useMetadataClient: () => ({ + list: vi.fn().mockResolvedValue([]), + listDrafts: vi.fn().mockResolvedValue([]), + }), +})); + +vi.mock('../previews/useObjectFields', () => ({ + useObjectFields: () => ({ fields: [], loading: false, error: null }), +})); + +import { ObjectFieldInspector } from './ObjectFieldInspector'; + +afterEach(cleanup); + +beforeAll(() => { + for (const m of ['hasPointerCapture', 'setPointerCapture', 'releasePointerCapture'] as const) { + if (!Element.prototype[m]) { + // @ts-expect-error test shim + Element.prototype[m] = m === 'hasPointerCapture' ? () => false : () => {}; + } + } + if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = () => {}; +}); + +function renderAmount(fields: Record>) { + const onPatch = vi.fn(); + render( + , + ); + const saved = () => onPatch.mock.calls.at(-1)![0].fields as Record>; + return { onPatch, saved }; +} + +/** {@link renderAmount}, with each patch applied to the draft, so a second edit sees the first. */ +function renderAmountLive(initial: Record>) { + const onPatch = vi.fn(); + function Live() { + const [fields, setFields] = React.useState(initial); + return ( + { + onPatch(patch); + setFields(patch.fields as Record>); + }} + onClearSelection={vi.fn()} + onSelectionChange={vi.fn()} + readOnly={false} + locale="en-US" + /> + ); + } + render(); + const saved = () => onPatch.mock.calls.at(-1)![0].fields as Record>; + return { onPatch, saved }; +} + +const CODE = { type: 'text', label: 'Currency', valueDomain: 'iso_4217_currency' }; + +async function pick(label: RegExp | string, option: string) { + fireEvent.keyDown(screen.getByRole('combobox', { name: label }), { key: 'ArrowDown' }); + await waitFor(() => expect(screen.queryAllByRole('option').length).toBeGreaterThan(0)); + fireEvent.click(screen.getByRole('option', { name: option })); +} + +describe('Studio currency section (ADR-0144 D2)', () => { + it('a mode switch keeps only the keys that mode reads', () => { + expect(switchCurrencyMode({ currencyMode: 'record', currencyField: 'currency_code' }, 'dynamic')).toEqual({ currencyMode: 'dynamic' }); + expect(switchCurrencyMode({ currencyMode: 'fixed', defaultCurrency: 'EUR' }, 'record')).toEqual({ currencyMode: 'record' }); + expect(switchCurrencyMode({ currencyMode: 'fixed', defaultCurrency: 'EUR' }, 'fixed')).toEqual({ currencyMode: 'fixed', defaultCurrency: 'EUR' }); + }); + + it('the picker lists only text fields in the ISO 4217 domain, never the amount itself', () => { + const entries = [ + { name: 'amount', def: { type: 'currency' } }, + { name: 'currency_code', def: CODE }, + { name: 'note', def: { type: 'text' } }, + { name: 'country', def: { type: 'text', valueDomain: 'iso_3166_alpha2' } }, + ]; + expect(currencyCodeFieldCandidates(entries, 'amount')).toEqual(['currency_code']); + expect(nextCurrencyCodeFieldName(entries)).toBe('currency_code_2'); + expect(nextCurrencyCodeFieldName([])).toBe('currency_code'); + }); + + it('offers the record mode and the currencyField picker on a currency field', () => { + renderAmount({ amount: { type: 'currency', label: 'Amount', currencyConfig: { currencyMode: 'record', currencyField: 'currency_code' } }, currency_code: CODE }); + expect(screen.getByRole('combobox', { name: 'Currency mode' })).toHaveTextContent('Record'); + expect(screen.getByRole('combobox', { name: /Currency field/ })).toHaveTextContent('currency_code'); + }); + + it('switching a fixed amount to record writes no defaultCurrency, and the object parses (#27268 refuses one)', async () => { + const { saved } = renderAmountLive({ + amount: { type: 'currency', label: 'Amount', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'EUR' } }, + currency_code: CODE, + }); + await pick('Currency mode', 'Record — each record names its currency'); + expect(saved().amount.currencyConfig).toEqual({ currencyMode: 'record' }); + await pick(/Currency field/, 'currency_code'); + const fields = saved(); + expect(fields.amount.currencyConfig).toEqual({ currencyMode: 'record', currencyField: 'currency_code' }); + expect(ObjectSchema.safeParse({ name: 'invoice', label: 'Invoice', fields }).success).toBe(true); + }); + + it('"Create currency field" writes an explicit code field and the pointer, and the object parses', async () => { + const { saved } = renderAmount({ amount: { type: 'currency', label: 'Amount', currencyConfig: { currencyMode: 'record' } } }); + await userEvent.click(screen.getByTestId('currency-config-create-code-field')); + const fields = saved(); + expect(fields.amount.currencyConfig).toEqual({ currencyMode: 'record', currencyField: 'currency_code' }); + expect(fields.currency_code).toEqual({ type: 'text', label: 'Currency', valueDomain: 'iso_4217_currency', required: true }); + expect(ObjectSchema.safeParse({ name: 'invoice', label: 'Invoice', fields }).success).toBe(true); + }); +}); diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/CurrencyConfigField.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/CurrencyConfigField.tsx new file mode 100644 index 00000000000..ad4d29d02b2 --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/CurrencyConfigField.tsx @@ -0,0 +1,170 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The Studio field editor's currency section: a currency amount's + * `currencyConfig` (ADR-0144 D2), in its three modes — + * + * - `dynamic` (the default): no currency of its own; the organization + * currency displays; + * - `fixed`: one currency, `defaultCurrency`; + * - `record`: each record names its own, in the sibling code field + * `currencyField` points at. + * + * The pointer is picked from the object's own record-currency code fields — + * `text` fields declaring `valueDomain: 'iso_4217_currency'`, the only fields + * the spec accepts there — and "Create currency field" writes one such field + * with explicit metadata and points at it, so the author never has to know the + * convention to get a valid declaration. Nothing here guesses a code or a + * pointer: an unpicked pointer stays absent and the spec's own refusal says so + * on save. + */ + +import * as React from 'react'; +import { Button } from '@object-ui/components'; +import { Plus } from 'lucide-react'; +import { InspectorSelectField, InspectorTextField } from './_shared.js'; +import type { FieldEntry } from '../previews/object-fields-io.js'; + +/** The record-currency code field's value domain (ADR-0144 D2). */ +export const CURRENCY_CODE_VALUE_DOMAIN = 'iso_4217_currency'; + +/** The naming convention for a record-currency code field (guidance, never a contract — ADR-0144 D8 rule 2). */ +export const CURRENCY_CODE_FIELD_NAME = 'currency_code'; + +export type CurrencyMode = 'dynamic' | 'fixed' | 'record'; + +export interface CurrencyConfigDraft { + currencyMode?: CurrencyMode; + defaultCurrency?: string; + currencyField?: string; +} + +/** Whether a field def is a record-currency code field: `text` + the ISO 4217 domain. */ +export function isCurrencyCodeFieldDef(def: Record | undefined): boolean { + return def?.type === 'text' && def.valueDomain === CURRENCY_CODE_VALUE_DOMAIN; +} + +/** The fields of `entries` (other than `self`) a `record`-mode pointer may name. */ +export function currencyCodeFieldCandidates(entries: readonly FieldEntry[], self: string): string[] { + return entries.filter((e) => e.name !== self && isCurrencyCodeFieldDef(e.def)).map((e) => e.name); +} + +/** `currency_code`, or `currency_code_2`, `_3`, … when the name is taken. */ +export function nextCurrencyCodeFieldName(entries: readonly FieldEntry[]): string { + const taken = new Set(entries.map((e) => e.name)); + if (!taken.has(CURRENCY_CODE_FIELD_NAME)) return CURRENCY_CODE_FIELD_NAME; + let n = 2; + while (taken.has(`${CURRENCY_CODE_FIELD_NAME}_${n}`)) n += 1; + return `${CURRENCY_CODE_FIELD_NAME}_${n}`; +} + +/** + * The config a mode switch writes. Each mode keeps only the keys it reads, so + * the spec's own pairing rule holds by construction: `currencyField` exists + * only under `record`, and `defaultCurrency` is carried only under `fixed`. A + * key the author already wrote for the target mode is kept; none is invented. + */ +export function switchCurrencyMode(prev: CurrencyConfigDraft | undefined, mode: CurrencyMode): CurrencyConfigDraft { + if (mode === 'fixed') { + return { currencyMode: 'fixed', ...(prev?.defaultCurrency ? { defaultCurrency: prev.defaultCurrency } : {}) }; + } + if (mode === 'record') { + return { currencyMode: 'record', ...(prev?.currencyField ? { currencyField: prev.currencyField } : {}) }; + } + return { currencyMode: 'dynamic' }; +} + +/** + * The explicit metadata "Create currency field" writes: a required `text` + * field in the ISO 4217 domain. Required because a record-mode amount without + * a code has no currency, and the server refuses a blank code at write. + */ +export function newCurrencyCodeFieldDef(label: string): Record { + return { type: 'text', label, valueDomain: CURRENCY_CODE_VALUE_DOMAIN, required: true }; +} + +export function CurrencyConfigField({ + config, + candidates, + onCommit, + onCreateCodeField, + disabled, + tr, +}: { + config: CurrencyConfigDraft | undefined; + /** {@link currencyCodeFieldCandidates} of the object this field belongs to. */ + candidates: readonly string[]; + /** Write the whole config. */ + onCommit: (next: CurrencyConfigDraft) => void; + /** Append a record-currency code field and point this amount at it, in one write. */ + onCreateCodeField: () => void; + disabled?: boolean; + tr: (key: string) => string; +}) { + const mode: CurrencyMode = config?.currencyMode ?? 'dynamic'; + const pointer = config?.currencyField; + return ( +
+ onCommit(switchCurrencyMode(config, v as CurrencyMode))} + disabled={disabled} + /> + {mode === 'fixed' && ( + { + const code = v.trim().toUpperCase(); + onCommit(code ? { currencyMode: 'fixed', defaultCurrency: code } : { currencyMode: 'fixed' }); + }} + placeholder="USD" + disabled={disabled} + /> + )} + {mode === 'record' && ( + <> + ({ value: name, label: name })), + ]} + // Unpicking DELETES the pointer: the spec refuses a `record` config + // without one on save, which is the honest answer, rather than a + // pointer at a field the author never chose. + onCommit={(v) => onCommit(v ? { currencyMode: 'record', currencyField: v } : { currencyMode: 'record' })} + required + disabled={disabled} + /> + +

{tr('designer.field.currency.recordHint')}

+ + )} +
+ ); +} diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx index 01ff01d2217..2b4038261e1 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx @@ -107,6 +107,13 @@ import { type FieldTypeId, } from '../previews/field-types.js'; import { FieldTypePicker } from './FieldTypePicker.js'; +import { + CurrencyConfigField, + currencyCodeFieldCandidates, + nextCurrencyCodeFieldName, + newCurrencyCodeFieldDef, + type CurrencyConfigDraft, +} from './CurrencyConfigField.js'; import { ExternalFieldBadge, readExternalBinding, @@ -812,6 +819,26 @@ export function ObjectFieldInspector({ writeView({ shape: view.shape, entries: nextEntries }); }; + /** + * ADR-0144 D2 — "Create currency field": append a record-currency code field + * (explicit metadata: `text`, the ISO 4217 domain, required) right after this + * amount and point the amount at it, as ONE write, so the draft never holds + * a `record` config pointing at a field that does not exist yet. + */ + const createCurrencyCodeField = () => { + const codeName = nextCurrencyCodeFieldName(view.entries); + const nextEntries = [...view.entries]; + nextEntries[idx] = { + ...entry, + def: { ...def, currencyConfig: { currencyMode: 'record', currencyField: codeName } }, + }; + nextEntries.splice(idx + 1, 0, { + name: codeName, + def: newCurrencyCodeFieldDef(tr('designer.field.currency.codeFieldLabel')), + }); + writeView({ shape: view.shape, entries: nextEntries }); + }; + /** * objectui#12326 — on a `controlled_by_parent` object a `master_detail` field * is `required: true` and its Required box is locked on: the object derives @@ -1321,6 +1348,16 @@ export function ObjectFieldInspector({ /> )} + {(type === 'currency' || (type === 'formula' && def.returnType === 'currency')) && ( + patchDef({ currencyConfig: next })} + onCreateCodeField={createCurrencyCodeField} + disabled={readOnly} + tr={tr} + /> + )} {isTexty(type) && (
{ ).toBeInTheDocument(); }); + it('formats a record-mode column in each row\'s own currency (objectstack#27188, ADR-0144 D9)', async () => { + // A grouped answer over a `record`-mode amount: the measure column names + // the result column holding each row's code (objectstack#27338). + queryDataset.mockResolvedValue({ + rows: [ + { bucket: 1, currency_code: 'USD', revenue: 1234.5 }, + { bucket: 2, currency_code: 'EUR', revenue: 99 }, + ], + fields: [ + { name: 'bucket', type: 'number', label: 'Bucket' }, + { name: 'currency_code', type: 'text', label: 'Currency' }, + { name: 'revenue', type: 'number', label: 'Revenue', format: '0.00', currencyField: 'currency_code' }, + ], + }); + renderIn('en-US', ); + expect(await screen.findByText('$1,234.50', { normalizer: (t: string) => t.trim() })).toBeInTheDocument(); + expect(screen.getByText('€99.00', { normalizer: (t: string) => t.trim() })).toBeInTheDocument(); + }); + it('keeps the en currency form byte-identical (must-not-change)', async () => { queryDataset.mockResolvedValue({ rows: [{ bucket: 1, revenue: 1234.5 }], diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/DatasetPreview.tsx b/ui/packages/app-shell/src/views/metadata-admin/previews/DatasetPreview.tsx index ace4740a7a8..9be998a74f7 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/DatasetPreview.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/DatasetPreview.tsx @@ -23,6 +23,7 @@ import type { MetadataPreviewProps } from '../preview-registry.js'; import { PreviewShell, PreviewEmptyState, PreviewErrorBoundary } from './PreviewShell.js'; import { formatMeasure, + measureCellCurrency, formatDimensionValue, buildDatasetFieldHelpers, relabelDimensions, @@ -308,7 +309,7 @@ export function DatasetPreview({ draft, locale }: MetadataPreviewProps) { {columns.map((c) => ( {measureNames.includes(c) - ? formatMeasure(row[c], measureField(c)?.format, measureField(c)?.currency, measureField(c)?.percentScale, displayLocale) + ? formatMeasure(row[c], measureField(c)?.format, measureCellCurrency(measureField(c), state.rows[i]), measureField(c)?.percentScale, displayLocale) : formatDimensionValue(row[c], displayLocale)} ))} diff --git a/ui/packages/components/src/__tests__/filter-builder-record-currency-27188.test.tsx b/ui/packages/components/src/__tests__/filter-builder-record-currency-27188.test.tsx new file mode 100644 index 00000000000..3404135eecf --- /dev/null +++ b/ui/packages/components/src/__tests__/filter-builder-record-currency-27188.test.tsx @@ -0,0 +1,195 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#27188 — a filter on a `record`-mode amount compares within one + * currency (ADR-0144 D6): the row draws a required currency picker, defaulting + * to the organization currency, and the builder emits `amount op X AND + * code = C` as the amount row plus its companion code row. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import React from 'react'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { LocalizationProvider } from '@object-ui/i18n'; +import { FilterBuilder, type FilterBuilderRowGroup } from '../custom/filter-builder'; +import { + CURRENCY_COMPANION_SUFFIX, + syncRecordCurrencyRows, +} from '../custom/filterRecordCurrency'; + +afterEach(() => cleanup()); + +const FIELDS = [ + { value: 'amount', label: 'Amount', type: 'currency', currencyField: 'currency_code' }, + { value: 'budget', label: 'Budget', type: 'currency' }, + { value: 'stage', label: 'Stage', type: 'text' }, +]; + +const comparesValue = (op: string) => !['is_null', 'is_not_null', 'is_empty', 'is_not_empty', 'exists', 'notExists'].includes(op); + +function group(conditions: FilterBuilderRowGroup['conditions'], logic: 'and' | 'or' = 'and'): FilterBuilderRowGroup { + return { id: 'root', logic, conditions }; +} + +describe('syncRecordCurrencyRows', () => { + it('follows a value-comparing amount row with its code row, on the home currency', () => { + const out = syncRecordCurrencyRows( + group([{ id: 'a', field: 'amount', operator: 'greater_than', value: 100 }]), + FIELDS, + 'CNY', + comparesValue, + ); + expect(out.conditions).toEqual([ + { id: 'a', field: 'amount', operator: 'greater_than', value: 100 }, + { id: `a${CURRENCY_COMPANION_SUFFIX}`, field: 'currency_code', operator: 'equals', value: 'CNY' }, + ]); + }); + + it('keeps a code the row already holds', () => { + const out = syncRecordCurrencyRows( + group([ + { id: 'a', field: 'amount', operator: 'greater_than', value: 100 }, + { id: 'a::currency', field: 'currency_code', operator: 'equals', value: 'EUR' }, + ]), + FIELDS, + 'CNY', + comparesValue, + ); + expect(out.conditions[1].value).toBe('EUR'); + }); + + it('returns the same group when every pair is settled', () => { + const settled = group([ + { id: 'a', field: 'amount', operator: 'greater_than', value: 100 }, + { id: 'a::currency', field: 'currency_code', operator: 'equals', value: 'EUR' }, + ]); + expect(syncRecordCurrencyRows(settled, FIELDS, 'CNY', comparesValue)).toBe(settled); + }); + + it('pairs nothing for a presence check, a fixed/dynamic amount, or another field', () => { + const out = syncRecordCurrencyRows( + group([ + { id: 'a', field: 'amount', operator: 'is_null' }, + { id: 'b', field: 'budget', operator: 'greater_than', value: 5 }, + { id: 'c', field: 'stage', operator: 'equals', value: 'won' }, + ]), + FIELDS, + 'CNY', + comparesValue, + ); + expect(out.conditions.map((c) => c.id)).toEqual(['a', 'b', 'c']); + }); + + it('drops a companion whose amount row is gone or no longer pairs', () => { + const out = syncRecordCurrencyRows( + group([ + { id: 'x::currency', field: 'currency_code', operator: 'equals', value: 'USD' }, + { id: 'a', field: 'budget', operator: 'greater_than', value: 1 }, + { id: 'a::currency', field: 'currency_code', operator: 'equals', value: 'USD' }, + ]), + FIELDS, + 'CNY', + comparesValue, + ); + expect(out.conditions.map((c) => c.id)).toEqual(['a']); + }); + + it('leaves the code blank when the organization currency is unset', () => { + const out = syncRecordCurrencyRows( + group([{ id: 'a', field: 'amount', operator: 'equals', value: 1 }]), + FIELDS, + undefined, + comparesValue, + ); + expect(out.conditions[1].value).toBe(''); + }); +}); + +function renderBuilder(value: FilterBuilderRowGroup, currency = 'CNY') { + const onChange = vi.fn(); + const utils = render( + + + , + ); + return { ...utils, onChange }; +} + +describe('FilterBuilder — a record-mode amount row', () => { + it('draws one currency picker per paired row and no row for the companion', () => { + const value = group([ + { id: 'a', field: 'amount', operator: 'greater_than', value: 100 }, + { id: 'a::currency', field: 'currency_code', operator: 'equals', value: 'EUR' }, + ]); + renderBuilder(value); + const pickers = screen.getAllByTestId('filter-currency-picker'); + expect(pickers).toHaveLength(1); + expect(pickers[0]).toHaveTextContent('EUR'); + // One visible row: one remove button. + expect(screen.getAllByText('Remove condition')).toHaveLength(1); + }); + + it('a new amount row emits its code condition on the organization currency', () => { + const value = group([]); + const { onChange } = renderBuilder(value); + fireEvent.click(screen.getByText('Add filter')); + const emitted: FilterBuilderRowGroup = onChange.mock.calls.at(-1)![0]; + expect(emitted.logic).toBe('and'); + expect(emitted.conditions).toHaveLength(2); + expect(emitted.conditions[0]).toMatchObject({ field: 'amount', operator: 'equals' }); + expect(emitted.conditions[1]).toEqual({ + id: `${emitted.conditions[0].id}${CURRENCY_COMPANION_SUFFIX}`, + field: 'currency_code', + operator: 'equals', + value: 'CNY', + }); + }); + + it('removing the amount row removes its code condition', () => { + const value = group([ + { id: 'a', field: 'amount', operator: 'greater_than', value: 100 }, + { id: 'a::currency', field: 'currency_code', operator: 'equals', value: 'EUR' }, + { id: 's', field: 'stage', operator: 'equals', value: 'won' }, + ]); + const { onChange } = renderBuilder(value); + fireEvent.click(screen.getAllByText('Remove condition')[0].closest('button')!); + const emitted: FilterBuilderRowGroup = onChange.mock.calls.at(-1)![0]; + expect(emitted.conditions.map((c) => c.id)).toEqual(['s']); + }); + + it('a stored amount row without a code shows a blank, invalid picker and emits nothing on read', () => { + const value = group([{ id: 'a', field: 'amount', operator: 'greater_than', value: 100 }]); + const { onChange } = renderBuilder(value); + const picker = screen.getByTestId('filter-currency-picker'); + expect(picker).toHaveTextContent('Currency'); + expect(picker).toHaveAttribute('aria-invalid', 'true'); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('a paired group cannot switch to OR, and a stored OR group says why', () => { + const paired = group([ + { id: 'a', field: 'amount', operator: 'greater_than', value: 100 }, + { id: 'a::currency', field: 'currency_code', operator: 'equals', value: 'EUR' }, + { id: 's', field: 'stage', operator: 'equals', value: 'won' }, + ]); + const { unmount } = renderBuilder(paired); + expect(screen.getByTestId('filter-logic-toggle')).toBeDisabled(); + expect(screen.queryByTestId('filter-currency-needs-and')).toBeNull(); + unmount(); + + renderBuilder({ ...paired, logic: 'or' }); + expect(screen.getByTestId('filter-logic-toggle')).not.toBeDisabled(); + expect(screen.getByTestId('filter-currency-needs-and')).toBeInTheDocument(); + }); + + it('draws no picker on a dynamic/fixed amount', () => { + renderBuilder(group([{ id: 'b', field: 'budget', operator: 'greater_than', value: 1 }])); + expect(screen.queryByTestId('filter-currency-picker')).toBeNull(); + }); +}); diff --git a/ui/packages/components/src/custom/currency-code-picker.tsx b/ui/packages/components/src/custom/currency-code-picker.tsx new file mode 100644 index 00000000000..e2a57946d3d --- /dev/null +++ b/ui/packages/components/src/custom/currency-code-picker.tsx @@ -0,0 +1,193 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +"use client" + +/** + * The one ISO 4217 code picker (ADR-0144 D2, D8 rule 3): a searchable select + * grouped "available" (home + rated + used) and "all". + * + * Two faces draw it: the record-currency code field (`CurrencyCodeField` in + * `@object-ui/fields`, which depends on this package) and the filter row of a + * `record`-mode amount (`FilterBuilder` here). One component, so the two + * offer the same codes in the same order under the same labels. + */ + +import * as React from "react" +import { Check, ChevronsUpDown } from "lucide-react" +import { isValueDomainMember } from "@objectstack/spec/shared" + +import { cn } from "../lib/utils" +import { Button } from "../ui/button" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "../ui/command" +import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover" + +/** + * The `valueDomain` a record-currency code field declares (ADR-0144 D2): a + * `text` field whose value is an ISO 4217 code, checked on write against the + * spec's `iso_4217_currency` membership table. + */ +export const CURRENCY_CODE_VALUE_DOMAIN = "iso_4217_currency" + +let isoCodesCache: readonly string[] | undefined + +/** + * Every code the `iso_4217_currency` value domain accepts, sorted. The + * candidates are ICU's (`Intl.supportedValuesOf('currency')`), kept only where + * the spec's own membership test agrees, so the list offers exactly what the + * write path accepts and never a code it would refuse. A runtime without + * `Intl.supportedValuesOf` offers no "all" group; the picker keeps its + * "available" group and its current value. + */ +export function isoCurrencyCodes(): readonly string[] { + if (isoCodesCache) return isoCodesCache + const supported = (Intl as { supportedValuesOf?: (key: string) => string[] }).supportedValuesOf + const candidates = typeof supported === "function" ? supported("currency") : [] + isoCodesCache = candidates.filter((c) => isValueDomainMember(CURRENCY_CODE_VALUE_DOMAIN, c)).sort() + return isoCodesCache +} + +export interface CurrencyCodeGroups { + /** ADR-0144 D8 rule 3 — home + rated + used, in that order, deduplicated. */ + available: string[] + /** Every other ISO 4217 code. */ + all: string[] +} + +/** + * Split the ISO 4217 codes into the "available" group — the derived enabled + * currencies of ADR-0144 D8 rule 3: the home currency, then every rated + * currency, then every code already used — and "all" the rest. The current + * value counts as used: a record that holds a code has used it. A code the + * domain does not accept is never offered. + */ +export function currencyCodeGroups(input: { + home?: string | null + rated?: readonly string[] + used?: readonly string[] + current?: string | null + iso?: readonly string[] +}): CurrencyCodeGroups { + const iso = input.iso ?? isoCurrencyCodes() + const available: string[] = [] + const seen = new Set() + for (const code of [input.home, ...(input.rated ?? []), ...(input.used ?? []), input.current]) { + if (typeof code !== "string" || seen.has(code) || !isValueDomainMember(CURRENCY_CODE_VALUE_DOMAIN, code)) continue + seen.add(code) + available.push(code) + } + return { available, all: iso.filter((c) => !seen.has(c)) } +} + +/** `USD — US Dollar` in `locale`, or the bare code when ICU has no name. */ +export function currencyCodeLabel(code: string, locale: string): string { + try { + const name = new Intl.DisplayNames([locale], { type: "currency" }).of(code) + return name && name !== code ? `${code} — ${name}` : code + } catch { + return code + } +} + +export interface CurrencyCodePickerProps + extends Omit, "value" | "onChange"> { + value?: string + /** Called with the code the user picked — never with a blank. */ + onChange: (code: string) => void + /** The organization currency, first in "available". */ + home?: string | null + /** Rated codes (the exchange-rate table), after home. */ + rated?: readonly string[] + /** Codes stored on the object's records, after rated. */ + used?: readonly string[] + locale: string + labels: { + placeholder: string + search: string + noResults: string + available: string + all: string + } + invalid?: boolean +} + +/** + * The picker. Re-selecting the selected code keeps it — there is no "click + * again to clear", because a blank code is what the server refuses and what + * a record-mode filter cannot compare under. + */ +export function CurrencyCodePicker({ + value, + onChange, + home, + rated, + used, + locale, + labels, + invalid, + className, + disabled, + ...triggerProps +}: CurrencyCodePickerProps) { + const [open, setOpen] = React.useState(false) + const current = typeof value === "string" && value !== "" ? value : undefined + const groups = currencyCodeGroups({ home, rated, used, current }) + const item = (code: string) => ( + { + onChange(code) + setOpen(false) + }} + > + + {currencyCodeLabel(code, locale)} + + ) + + return ( + + + + + + + + + {labels.noResults} + {groups.available.length > 0 && ( + {groups.available.map(item)} + )} + {groups.all.length > 0 && {groups.all.map(item)}} + + + + + ) +} diff --git a/ui/packages/components/src/custom/filter-builder.tsx b/ui/packages/components/src/custom/filter-builder.tsx index 53386a12573..81a37609a06 100644 --- a/ui/packages/components/src/custom/filter-builder.tsx +++ b/ui/packages/components/src/custom/filter-builder.tsx @@ -39,9 +39,17 @@ import { reportRetiredFieldType, toDateTimeInputValue, } from "@object-ui/core" -import { createSafeTranslation } from "@object-ui/i18n" +import { createSafeTranslation, useDisplayLocale, useLocalization } from "@object-ui/i18n" import { cn } from "../lib/utils" +import { CurrencyCodePicker } from "./currency-code-picker" +import { + currencyCompanionId, + currencyCompanionOf, + isCurrencyCompanion, + pairedCurrencyField, + syncRecordCurrencyRows, +} from "./filterRecordCurrency" import { Badge } from "../ui/badge" import { Button } from "../ui/button" import { Checkbox } from "../ui/checkbox" @@ -139,6 +147,13 @@ export interface FilterBuilderProps { displayField?: string /** ID field on the referenced object (defaults to "id") */ idField?: string + /** + * A `record`-mode amount's sibling code field (its + * `currencyConfig.currencyField`, ADR-0144 D2). Set, the row draws a + * required currency picker and the builder emits `amount op X AND + * code = C` — see `filterRecordCurrency.ts`. Absent for every other field. + */ + currencyField?: string }> value?: FilterBuilderRowGroup onChange?: (value: FilterBuilderRowGroup) => void @@ -1153,6 +1168,12 @@ const useSafeFilterTranslation = createSafeTranslation( 'filterBuilder.removeValue': 'Remove {{value}}', 'filterBuilder.rangeStart': 'From', 'filterBuilder.rangeEnd': 'To', + 'filterBuilder.currency': 'Currency', + 'filterBuilder.currencyNeedsAnd': 'A currency amount is compared within the chosen currency only when every condition must match (AND).', + 'fields.currencyCode.search': 'Search currency…', + 'fields.currencyCode.noResults': 'No currency found', + 'fields.currencyCode.available': 'Available', + 'fields.currencyCode.all': 'All currencies (ISO 4217)', 'filterBuilder.operators.equals': 'Equals', 'filterBuilder.operators.not_equals': 'Does not equal', 'filterBuilder.operators.contains': 'Contains', @@ -1453,11 +1474,28 @@ function FilterBuilder({ } }, [value]) + const { currency: homeCurrency } = useLocalization() + const needsValueInput = (operator: string) => { + return !VALUELESS_FILTER_BUILDER_OPERATORS.has(normalizeFilterBuilderOperator(operator)) + } + const locale = useDisplayLocale() + + // Every emit settles the record-currency pairs (ADR-0144 D6): a + // value-comparing row on a `record`-mode amount is followed by its code + // condition, and a companion whose amount row is gone goes with it. On READ + // nothing is settled — opening a stored filter must not dirty its host — + // so a stored amount row without a companion shows a blank picker until + // the author's next edit. const handleChange = (newGroup: FilterBuilderRowGroup) => { - setFilterGroup(newGroup) - onChange?.(newGroup) + const settled = syncRecordCurrencyRows(newGroup, fields, homeCurrency, needsValueInput) + setFilterGroup(settled) + onChange?.(settled) } + // The rows drawn: a companion is drawn as its amount row's picker. + const visibleConditions = filterGroup.conditions.filter((c) => !isCurrencyCompanion(c, filterGroup)) + const pairsCurrency = filterGroup.conditions.some((c) => pairedCurrencyField(c, fields, needsValueInput)) + const addCondition = () => { const newCondition: FilterBuilderRow = { id: crypto.randomUUID(), @@ -1472,12 +1510,34 @@ function FilterBuilder({ } const removeCondition = (conditionId: string) => { + // The amount row's currency companion goes with it: `handleChange` settles + // the pairs, and a companion whose amount row is gone is dropped there. handleChange({ ...filterGroup, conditions: filterGroup.conditions.filter((c) => c.id !== conditionId), }) } + /** Set the code an amount row compares in (its companion's value). */ + const changeCurrency = (conditionId: string, code: string) => { + const companionId = currencyCompanionId(conditionId) + const codeField = pairedCurrencyField( + filterGroup.conditions.find((c) => c.id === conditionId) ?? { field: "", operator: "equals" }, + fields, + needsValueInput, + ) + if (!codeField) return + const companion: FilterBuilderRow = { id: companionId, field: codeField, operator: "equals", value: code } + // A stored amount row may carry no companion yet: the pick writes one. + const held = filterGroup.conditions.some((c) => c.id === companionId) + handleChange({ + ...filterGroup, + conditions: held + ? filterGroup.conditions.map((c) => (c.id === companionId ? companion : c)) + : filterGroup.conditions.flatMap((c) => (c.id === conditionId ? [c, companion] : [c])), + }) + } + const clearAllConditions = () => { handleChange({ ...filterGroup, @@ -1516,6 +1576,9 @@ function FilterBuilder({ } const toggleLogic = () => { + // A paired group stays AND: under OR its pairs would split into + // `amount op X OR code = C`. + if (filterGroup.logic === "and" && pairsCurrency) return handleChange({ ...filterGroup, logic: filterGroup.logic === "and" ? "or" : "and", @@ -1639,9 +1702,8 @@ function FilterBuilder({ // treated as value-taking, drawing a box to type a value into beside a // trigger reading `Is null` (objectui#9302). One operator, two spellings, // two different rows — which the fold rules out in either direction. - const needsValueInput = (operator: string) => { - return !VALUELESS_FILTER_BUILDER_OPERATORS.has(normalizeFilterBuilderOperator(operator)) - } + // `needsValueInput` is declared near the top of the component: the + // record-currency pairing reads it during render. // Derived from the value FAMILY rather than from a second branch ladder over // the same type lists: the input a value is edited in and the type a value is @@ -2064,13 +2126,16 @@ function FilterBuilder({
{t('filterBuilder.where')} - {filterGroup.conditions.length > 1 && ( + {visibleConditions.length > 1 && ( @@ -2090,11 +2155,22 @@ function FilterBuilder({ )}
+ {filterGroup.logic === "or" && pairsCurrency && ( +

+ {t('filterBuilder.currencyNeedsAnd')} +

+ )} +
- {filterGroup.conditions.map((condition) => ( + {visibleConditions.map((condition) => { + const pairsWith = pairedCurrencyField(condition, fields, needsValueInput) + // Four controls on a paired row, three on every other. + const span = pairsWith ? "col-span-3" : "col-span-4" + const code = pairsWith ? currencyCompanionOf(filterGroup, condition.id)?.value : undefined + return (
-
+
-
+