Skip to content

Commit cc40307

Browse files
committed
feat(ui): record-currency used codes and filter pairing (seat ruling A/A)
The code field's "Available" group now carries the codes the object's records hold: the form renderer reads them once per object with one grouped header query and hands them down on the field metadata (`usedCurrencyCodes`). The ISO 4217 picker moves to `@object-ui/components` as `CurrencyCodePicker`, shared with the filter. A list filter row on a record-mode amount gains a required currency picker defaulting to the organization currency; the builder emits the code condition as a companion row, so the group lowers to `amount op X AND code = C`. A paired group stays on AND. Claude-Session: https://claude.ai/code/session_01Utp6CXtadYa5QXb2PbXPev Co-authored-by: Claude <noreply@anthropic.com>
1 parent d59c3cb commit cc40307

23 files changed

Lines changed: 1009 additions & 147 deletions

‎.changeset/27188-record-currency-display.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
'@object-ui/components': patch
55
'@object-ui/plugin-form': patch
66
'@object-ui/plugin-grid': patch
7+
'@object-ui/plugin-list': patch
78
'@object-ui/plugin-dashboard': patch
89
'@object-ui/plugin-detail': patch
910
'@object-ui/plugin-gantt': patch
@@ -18,7 +19,8 @@ Metadata-protocol: none
1819
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:
1920

2021
- **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.
21-
- **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 code the record already holds) and "All currencies (ISO 4217)". It offers only codes the value domain accepts. The rated group is empty until the exchange-rate table ships.
22+
- **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`.
23+
- **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.
2224
- **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.
2325
- **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.
2426
- **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.
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectstack#27188 — a filter on a `record`-mode amount compares within one
11+
* currency (ADR-0144 D6): the row draws a required currency picker, defaulting
12+
* to the organization currency, and the builder emits `amount op X AND
13+
* code = C` as the amount row plus its companion code row.
14+
*/
15+
import { describe, it, expect, vi, afterEach } from 'vitest';
16+
import React from 'react';
17+
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
18+
import '@testing-library/jest-dom';
19+
import { LocalizationProvider } from '@object-ui/i18n';
20+
import { FilterBuilder, type FilterBuilderRowGroup } from '../custom/filter-builder';
21+
import {
22+
CURRENCY_COMPANION_SUFFIX,
23+
syncRecordCurrencyRows,
24+
} from '../custom/filterRecordCurrency';
25+
26+
afterEach(() => cleanup());
27+
28+
const FIELDS = [
29+
{ value: 'amount', label: 'Amount', type: 'currency', currencyField: 'currency_code' },
30+
{ value: 'budget', label: 'Budget', type: 'currency' },
31+
{ value: 'stage', label: 'Stage', type: 'text' },
32+
];
33+
34+
const comparesValue = (op: string) => !['is_null', 'is_not_null', 'is_empty', 'is_not_empty', 'exists', 'notExists'].includes(op);
35+
36+
function group(conditions: FilterBuilderRowGroup['conditions'], logic: 'and' | 'or' = 'and'): FilterBuilderRowGroup {
37+
return { id: 'root', logic, conditions };
38+
}
39+
40+
describe('syncRecordCurrencyRows', () => {
41+
it('follows a value-comparing amount row with its code row, on the home currency', () => {
42+
const out = syncRecordCurrencyRows(
43+
group([{ id: 'a', field: 'amount', operator: 'greater_than', value: 100 }]),
44+
FIELDS,
45+
'CNY',
46+
comparesValue,
47+
);
48+
expect(out.conditions).toEqual([
49+
{ id: 'a', field: 'amount', operator: 'greater_than', value: 100 },
50+
{ id: `a${CURRENCY_COMPANION_SUFFIX}`, field: 'currency_code', operator: 'equals', value: 'CNY' },
51+
]);
52+
});
53+
54+
it('keeps a code the row already holds', () => {
55+
const out = syncRecordCurrencyRows(
56+
group([
57+
{ id: 'a', field: 'amount', operator: 'greater_than', value: 100 },
58+
{ id: 'a::currency', field: 'currency_code', operator: 'equals', value: 'EUR' },
59+
]),
60+
FIELDS,
61+
'CNY',
62+
comparesValue,
63+
);
64+
expect(out.conditions[1].value).toBe('EUR');
65+
});
66+
67+
it('returns the same group when every pair is settled', () => {
68+
const settled = group([
69+
{ id: 'a', field: 'amount', operator: 'greater_than', value: 100 },
70+
{ id: 'a::currency', field: 'currency_code', operator: 'equals', value: 'EUR' },
71+
]);
72+
expect(syncRecordCurrencyRows(settled, FIELDS, 'CNY', comparesValue)).toBe(settled);
73+
});
74+
75+
it('pairs nothing for a presence check, a fixed/dynamic amount, or another field', () => {
76+
const out = syncRecordCurrencyRows(
77+
group([
78+
{ id: 'a', field: 'amount', operator: 'is_null' },
79+
{ id: 'b', field: 'budget', operator: 'greater_than', value: 5 },
80+
{ id: 'c', field: 'stage', operator: 'equals', value: 'won' },
81+
]),
82+
FIELDS,
83+
'CNY',
84+
comparesValue,
85+
);
86+
expect(out.conditions.map((c) => c.id)).toEqual(['a', 'b', 'c']);
87+
});
88+
89+
it('drops a companion whose amount row is gone or no longer pairs', () => {
90+
const out = syncRecordCurrencyRows(
91+
group([
92+
{ id: 'x::currency', field: 'currency_code', operator: 'equals', value: 'USD' },
93+
{ id: 'a', field: 'budget', operator: 'greater_than', value: 1 },
94+
{ id: 'a::currency', field: 'currency_code', operator: 'equals', value: 'USD' },
95+
]),
96+
FIELDS,
97+
'CNY',
98+
comparesValue,
99+
);
100+
expect(out.conditions.map((c) => c.id)).toEqual(['a']);
101+
});
102+
103+
it('leaves the code blank when the organization currency is unset', () => {
104+
const out = syncRecordCurrencyRows(
105+
group([{ id: 'a', field: 'amount', operator: 'equals', value: 1 }]),
106+
FIELDS,
107+
undefined,
108+
comparesValue,
109+
);
110+
expect(out.conditions[1].value).toBe('');
111+
});
112+
});
113+
114+
function renderBuilder(value: FilterBuilderRowGroup, currency = 'CNY') {
115+
const onChange = vi.fn();
116+
const utils = render(
117+
<LocalizationProvider value={{ currency }}>
118+
<FilterBuilder fields={FIELDS as any} value={value} onChange={onChange} />
119+
</LocalizationProvider>,
120+
);
121+
return { ...utils, onChange };
122+
}
123+
124+
describe('FilterBuilder — a record-mode amount row', () => {
125+
it('draws one currency picker per paired row and no row for the companion', () => {
126+
const value = group([
127+
{ id: 'a', field: 'amount', operator: 'greater_than', value: 100 },
128+
{ id: 'a::currency', field: 'currency_code', operator: 'equals', value: 'EUR' },
129+
]);
130+
renderBuilder(value);
131+
const pickers = screen.getAllByTestId('filter-currency-picker');
132+
expect(pickers).toHaveLength(1);
133+
expect(pickers[0]).toHaveTextContent('EUR');
134+
// One visible row: one remove button.
135+
expect(screen.getAllByText('Remove condition')).toHaveLength(1);
136+
});
137+
138+
it('a new amount row emits its code condition on the organization currency', () => {
139+
const value = group([]);
140+
const { onChange } = renderBuilder(value);
141+
fireEvent.click(screen.getByText('Add filter'));
142+
const emitted: FilterBuilderRowGroup = onChange.mock.calls.at(-1)![0];
143+
expect(emitted.logic).toBe('and');
144+
expect(emitted.conditions).toHaveLength(2);
145+
expect(emitted.conditions[0]).toMatchObject({ field: 'amount', operator: 'equals' });
146+
expect(emitted.conditions[1]).toEqual({
147+
id: `${emitted.conditions[0].id}${CURRENCY_COMPANION_SUFFIX}`,
148+
field: 'currency_code',
149+
operator: 'equals',
150+
value: 'CNY',
151+
});
152+
});
153+
154+
it('removing the amount row removes its code condition', () => {
155+
const value = group([
156+
{ id: 'a', field: 'amount', operator: 'greater_than', value: 100 },
157+
{ id: 'a::currency', field: 'currency_code', operator: 'equals', value: 'EUR' },
158+
{ id: 's', field: 'stage', operator: 'equals', value: 'won' },
159+
]);
160+
const { onChange } = renderBuilder(value);
161+
fireEvent.click(screen.getAllByText('Remove condition')[0].closest('button')!);
162+
const emitted: FilterBuilderRowGroup = onChange.mock.calls.at(-1)![0];
163+
expect(emitted.conditions.map((c) => c.id)).toEqual(['s']);
164+
});
165+
166+
it('a stored amount row without a code shows a blank, invalid picker and emits nothing on read', () => {
167+
const value = group([{ id: 'a', field: 'amount', operator: 'greater_than', value: 100 }]);
168+
const { onChange } = renderBuilder(value);
169+
const picker = screen.getByTestId('filter-currency-picker');
170+
expect(picker).toHaveTextContent('Currency');
171+
expect(picker).toHaveAttribute('aria-invalid', 'true');
172+
expect(onChange).not.toHaveBeenCalled();
173+
});
174+
175+
it('a paired group cannot switch to OR, and a stored OR group says why', () => {
176+
const paired = group([
177+
{ id: 'a', field: 'amount', operator: 'greater_than', value: 100 },
178+
{ id: 'a::currency', field: 'currency_code', operator: 'equals', value: 'EUR' },
179+
{ id: 's', field: 'stage', operator: 'equals', value: 'won' },
180+
]);
181+
const { unmount } = renderBuilder(paired);
182+
expect(screen.getByTestId('filter-logic-toggle')).toBeDisabled();
183+
expect(screen.queryByTestId('filter-currency-needs-and')).toBeNull();
184+
unmount();
185+
186+
renderBuilder({ ...paired, logic: 'or' });
187+
expect(screen.getByTestId('filter-logic-toggle')).not.toBeDisabled();
188+
expect(screen.getByTestId('filter-currency-needs-and')).toBeInTheDocument();
189+
});
190+
191+
it('draws no picker on a dynamic/fixed amount', () => {
192+
renderBuilder(group([{ id: 'b', field: 'budget', operator: 'greater_than', value: 1 }]));
193+
expect(screen.queryByTestId('filter-currency-picker')).toBeNull();
194+
});
195+
});

0 commit comments

Comments
 (0)