Skip to content

Commit c5baf62

Browse files
committed
wip(ui): Studio currency section, tests per widget
Claude-Session: https://claude.ai/code/session_01Utp6CXtadYa5QXb2PbXPev Co-authored-by: Claude <noreply@anthropic.com>
1 parent 044e67c commit c5baf62

11 files changed

Lines changed: 689 additions & 2 deletions

File tree

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* ADR-0144 D2 in the Studio field editor: a currency amount's mode, the
5+
* `record`-mode `currencyField` picker over the object's own ISO 4217 code
6+
* fields, and "Create currency field", which writes explicit metadata.
7+
*/
8+
9+
import * as React from 'react';
10+
import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest';
11+
import { render, screen, cleanup } from '@testing-library/react';
12+
import userEvent from '@testing-library/user-event';
13+
import { ObjectSchema } from '@objectstack/spec/data';
14+
import {
15+
currencyCodeFieldCandidates,
16+
nextCurrencyCodeFieldName,
17+
switchCurrencyMode,
18+
} from './CurrencyConfigField';
19+
20+
vi.mock('../useMetadata', () => ({
21+
useMetadataClient: () => ({
22+
list: vi.fn().mockResolvedValue([]),
23+
listDrafts: vi.fn().mockResolvedValue([]),
24+
}),
25+
}));
26+
27+
vi.mock('../previews/useObjectFields', () => ({
28+
useObjectFields: () => ({ fields: [], loading: false, error: null }),
29+
}));
30+
31+
import { ObjectFieldInspector } from './ObjectFieldInspector';
32+
33+
afterEach(cleanup);
34+
35+
beforeAll(() => {
36+
for (const m of ['hasPointerCapture', 'setPointerCapture', 'releasePointerCapture'] as const) {
37+
if (!Element.prototype[m]) {
38+
// @ts-expect-error test shim
39+
Element.prototype[m] = m === 'hasPointerCapture' ? () => false : () => {};
40+
}
41+
}
42+
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = () => {};
43+
});
44+
45+
function renderAmount(fields: Record<string, Record<string, unknown>>) {
46+
const onPatch = vi.fn();
47+
render(
48+
<ObjectFieldInspector
49+
type="object"
50+
name="invoice"
51+
draft={{ name: 'invoice', fields }}
52+
selection={{ kind: 'field', id: 'amount' }}
53+
onPatch={onPatch}
54+
onClearSelection={vi.fn()}
55+
onSelectionChange={vi.fn()}
56+
readOnly={false}
57+
locale="en-US"
58+
/>,
59+
);
60+
const saved = () => onPatch.mock.calls.at(-1)![0].fields as Record<string, Record<string, unknown>>;
61+
return { onPatch, saved };
62+
}
63+
64+
const CODE = { type: 'text', label: 'Currency', valueDomain: 'iso_4217_currency' };
65+
66+
describe('Studio currency section (ADR-0144 D2)', () => {
67+
it('a mode switch keeps only the keys that mode reads', () => {
68+
expect(switchCurrencyMode({ currencyMode: 'record', currencyField: 'currency_code' }, 'dynamic')).toEqual({ currencyMode: 'dynamic' });
69+
expect(switchCurrencyMode({ currencyMode: 'fixed', defaultCurrency: 'EUR' }, 'record')).toEqual({ currencyMode: 'record' });
70+
expect(switchCurrencyMode({ currencyMode: 'fixed', defaultCurrency: 'EUR' }, 'fixed')).toEqual({ currencyMode: 'fixed', defaultCurrency: 'EUR' });
71+
});
72+
73+
it('the picker lists only text fields in the ISO 4217 domain, never the amount itself', () => {
74+
const entries = [
75+
{ name: 'amount', def: { type: 'currency' } },
76+
{ name: 'currency_code', def: CODE },
77+
{ name: 'note', def: { type: 'text' } },
78+
{ name: 'country', def: { type: 'text', valueDomain: 'iso_3166_alpha2' } },
79+
];
80+
expect(currencyCodeFieldCandidates(entries, 'amount')).toEqual(['currency_code']);
81+
expect(nextCurrencyCodeFieldName(entries)).toBe('currency_code_2');
82+
expect(nextCurrencyCodeFieldName([])).toBe('currency_code');
83+
});
84+
85+
it('offers the record mode and the currencyField picker on a currency field', () => {
86+
renderAmount({ amount: { type: 'currency', label: 'Amount', currencyConfig: { currencyMode: 'record', currencyField: 'currency_code' } }, currency_code: CODE });
87+
expect(screen.getByRole('combobox', { name: 'Currency mode' })).toHaveTextContent('Record');
88+
expect(screen.getByRole('combobox', { name: /Currency field/ })).toHaveTextContent('currency_code');
89+
});
90+
91+
it('"Create currency field" writes an explicit code field and the pointer, and the object parses', async () => {
92+
const { saved } = renderAmount({ amount: { type: 'currency', label: 'Amount', currencyConfig: { currencyMode: 'record' } } });
93+
await userEvent.click(screen.getByTestId('currency-config-create-code-field'));
94+
const fields = saved();
95+
expect(fields.amount.currencyConfig).toEqual({ currencyMode: 'record', currencyField: 'currency_code' });
96+
expect(fields.currency_code).toEqual({ type: 'text', label: 'Currency', valueDomain: 'iso_4217_currency', required: true });
97+
expect(ObjectSchema.safeParse({ name: 'invoice', label: 'Invoice', fields }).success).toBe(true);
98+
});
99+
});
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
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+
* The Studio field editor's currency section: a currency amount's
11+
* `currencyConfig` (ADR-0144 D2), in its three modes —
12+
*
13+
* - `dynamic` (the default): no currency of its own; the organization
14+
* currency displays;
15+
* - `fixed`: one currency, `defaultCurrency`;
16+
* - `record`: each record names its own, in the sibling code field
17+
* `currencyField` points at.
18+
*
19+
* The pointer is picked from the object's own record-currency code fields —
20+
* `text` fields declaring `valueDomain: 'iso_4217_currency'`, the only fields
21+
* the spec accepts there — and "Create currency field" writes one such field
22+
* with explicit metadata and points at it, so the author never has to know the
23+
* convention to get a valid declaration. Nothing here guesses a code or a
24+
* pointer: an unpicked pointer stays absent and the spec's own refusal says so
25+
* on save.
26+
*/
27+
28+
import * as React from 'react';
29+
import { Button } from '@object-ui/components';
30+
import { Plus } from 'lucide-react';
31+
import { InspectorSelectField, InspectorTextField } from './_shared.js';
32+
import type { FieldEntry } from '../previews/object-fields-io.js';
33+
34+
/** The record-currency code field's value domain (ADR-0144 D2). */
35+
export const CURRENCY_CODE_VALUE_DOMAIN = 'iso_4217_currency';
36+
37+
/** The naming convention for a record-currency code field (guidance, never a contract — ADR-0144 D8 rule 2). */
38+
export const CURRENCY_CODE_FIELD_NAME = 'currency_code';
39+
40+
export type CurrencyMode = 'dynamic' | 'fixed' | 'record';
41+
42+
export interface CurrencyConfigDraft {
43+
currencyMode?: CurrencyMode;
44+
defaultCurrency?: string;
45+
currencyField?: string;
46+
}
47+
48+
/** Whether a field def is a record-currency code field: `text` + the ISO 4217 domain. */
49+
export function isCurrencyCodeFieldDef(def: Record<string, unknown> | undefined): boolean {
50+
return def?.type === 'text' && def.valueDomain === CURRENCY_CODE_VALUE_DOMAIN;
51+
}
52+
53+
/** The fields of `entries` (other than `self`) a `record`-mode pointer may name. */
54+
export function currencyCodeFieldCandidates(entries: readonly FieldEntry[], self: string): string[] {
55+
return entries.filter((e) => e.name !== self && isCurrencyCodeFieldDef(e.def)).map((e) => e.name);
56+
}
57+
58+
/** `currency_code`, or `currency_code_2`, `_3`, … when the name is taken. */
59+
export function nextCurrencyCodeFieldName(entries: readonly FieldEntry[]): string {
60+
const taken = new Set(entries.map((e) => e.name));
61+
if (!taken.has(CURRENCY_CODE_FIELD_NAME)) return CURRENCY_CODE_FIELD_NAME;
62+
let n = 2;
63+
while (taken.has(`${CURRENCY_CODE_FIELD_NAME}_${n}`)) n += 1;
64+
return `${CURRENCY_CODE_FIELD_NAME}_${n}`;
65+
}
66+
67+
/**
68+
* The config a mode switch writes. Each mode keeps only the keys it reads, so
69+
* the spec's own pairing rule holds by construction: `currencyField` exists
70+
* only under `record`, and `defaultCurrency` is carried only under `fixed`. A
71+
* key the author already wrote for the target mode is kept; none is invented.
72+
*/
73+
export function switchCurrencyMode(prev: CurrencyConfigDraft | undefined, mode: CurrencyMode): CurrencyConfigDraft {
74+
if (mode === 'fixed') {
75+
return { currencyMode: 'fixed', ...(prev?.defaultCurrency ? { defaultCurrency: prev.defaultCurrency } : {}) };
76+
}
77+
if (mode === 'record') {
78+
return { currencyMode: 'record', ...(prev?.currencyField ? { currencyField: prev.currencyField } : {}) };
79+
}
80+
return { currencyMode: 'dynamic' };
81+
}
82+
83+
/**
84+
* The explicit metadata "Create currency field" writes: a required `text`
85+
* field in the ISO 4217 domain. Required because a record-mode amount without
86+
* a code has no currency, and the server refuses a blank code at write.
87+
*/
88+
export function newCurrencyCodeFieldDef(label: string): Record<string, unknown> {
89+
return { type: 'text', label, valueDomain: CURRENCY_CODE_VALUE_DOMAIN, required: true };
90+
}
91+
92+
export function CurrencyConfigField({
93+
config,
94+
candidates,
95+
onCommit,
96+
onCreateCodeField,
97+
disabled,
98+
tr,
99+
}: {
100+
config: CurrencyConfigDraft | undefined;
101+
/** {@link currencyCodeFieldCandidates} of the object this field belongs to. */
102+
candidates: readonly string[];
103+
/** Write the whole config. */
104+
onCommit: (next: CurrencyConfigDraft) => void;
105+
/** Append a record-currency code field and point this amount at it, in one write. */
106+
onCreateCodeField: () => void;
107+
disabled?: boolean;
108+
tr: (key: string) => string;
109+
}) {
110+
const mode: CurrencyMode = config?.currencyMode ?? 'dynamic';
111+
const pointer = config?.currencyField;
112+
return (
113+
<div className="space-y-2" data-testid="currency-config-field">
114+
<InspectorSelectField
115+
label={tr('designer.field.currency.mode')}
116+
value={mode}
117+
options={[
118+
{ value: 'dynamic', label: tr('designer.field.currency.modeDynamic') },
119+
{ value: 'fixed', label: tr('designer.field.currency.modeFixed') },
120+
{ value: 'record', label: tr('designer.field.currency.modeRecord') },
121+
]}
122+
onCommit={(v) => onCommit(switchCurrencyMode(config, v as CurrencyMode))}
123+
disabled={disabled}
124+
/>
125+
{mode === 'fixed' && (
126+
<InspectorTextField
127+
label={tr('designer.field.currency.defaultCurrency')}
128+
value={config?.defaultCurrency ?? ''}
129+
// A three-letter code, written upper-case as ISO 4217 spells it.
130+
onCommit={(v) => {
131+
const code = v.trim().toUpperCase();
132+
onCommit(code ? { currencyMode: 'fixed', defaultCurrency: code } : { currencyMode: 'fixed' });
133+
}}
134+
placeholder="USD"
135+
disabled={disabled}
136+
/>
137+
)}
138+
{mode === 'record' && (
139+
<>
140+
<InspectorSelectField
141+
label={tr('designer.field.currency.currencyField')}
142+
value={pointer ?? ''}
143+
options={[
144+
{ value: '', label: tr('designer.field.currency.currencyFieldNone') },
145+
...candidates.map((name) => ({ value: name, label: name })),
146+
]}
147+
// Unpicking DELETES the pointer: the spec refuses a `record` config
148+
// without one on save, which is the honest answer, rather than a
149+
// pointer at a field the author never chose.
150+
onCommit={(v) => onCommit(v ? { currencyMode: 'record', currencyField: v } : { currencyMode: 'record' })}
151+
required
152+
disabled={disabled}
153+
/>
154+
<Button
155+
type="button"
156+
variant="outline"
157+
size="sm"
158+
onClick={onCreateCodeField}
159+
disabled={disabled}
160+
data-testid="currency-config-create-code-field"
161+
>
162+
<Plus className="mr-1 h-3.5 w-3.5" />
163+
{tr('designer.field.currency.createCodeField')}
164+
</Button>
165+
<p className="text-xs text-muted-foreground">{tr('designer.field.currency.recordHint')}</p>
166+
</>
167+
)}
168+
</div>
169+
);
170+
}

‎ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,13 @@ import {
102102
type FieldTypeId,
103103
} from '../previews/field-types.js';
104104
import { FieldTypePicker } from './FieldTypePicker.js';
105+
import {
106+
CurrencyConfigField,
107+
currencyCodeFieldCandidates,
108+
nextCurrencyCodeFieldName,
109+
newCurrencyCodeFieldDef,
110+
type CurrencyConfigDraft,
111+
} from './CurrencyConfigField.js';
105112
import { CelPredicateField } from '../CelPredicateField.js';
106113
import type { CelLintIssue } from '../celAuthoring.js';
107114
import { t, tFormat, type SupportedLocale } from '../i18n.js';
@@ -794,6 +801,26 @@ export function ObjectFieldInspector({
794801
writeView({ shape: view.shape, entries: nextEntries });
795802
};
796803

804+
/**
805+
* ADR-0144 D2 — "Create currency field": append a record-currency code field
806+
* (explicit metadata: `text`, the ISO 4217 domain, required) right after this
807+
* amount and point the amount at it, as ONE write, so the draft never holds
808+
* a `record` config pointing at a field that does not exist yet.
809+
*/
810+
const createCurrencyCodeField = () => {
811+
const codeName = nextCurrencyCodeFieldName(view.entries);
812+
const nextEntries = [...view.entries];
813+
nextEntries[idx] = {
814+
...entry,
815+
def: { ...def, currencyConfig: { currencyMode: 'record', currencyField: codeName } },
816+
};
817+
nextEntries.splice(idx + 1, 0, {
818+
name: codeName,
819+
def: newCurrencyCodeFieldDef(tr('designer.field.currency.codeFieldLabel')),
820+
});
821+
writeView({ shape: view.shape, entries: nextEntries });
822+
};
823+
797824
/**
798825
* objectui#12326 — on a `controlled_by_parent` object a `master_detail` field
799826
* is `required: true` and its Required box is locked on: the object derives
@@ -1298,6 +1325,16 @@ export function ObjectFieldInspector({
12981325
/>
12991326
</div>
13001327
)}
1328+
{(type === 'currency' || (type === 'formula' && def.returnType === 'currency')) && (
1329+
<CurrencyConfigField
1330+
config={def.currencyConfig as CurrencyConfigDraft | undefined}
1331+
candidates={currencyCodeFieldCandidates(view.entries, name)}
1332+
onCommit={(next) => patchDef({ currencyConfig: next })}
1333+
onCreateCodeField={createCurrencyCodeField}
1334+
disabled={readOnly}
1335+
tr={tr}
1336+
/>
1337+
)}
13011338
{isTexty(type) && (
13021339
<div className="grid grid-cols-2 gap-2">
13031340
<InspectorNumberField

0 commit comments

Comments
 (0)