Skip to content

Commit 044e67c

Browse files
committed
wip(ui): record-currency code select, form record hand-off, grid footer/group aggregates, dashboard/detail/gantt record reads
Claude-Session: https://claude.ai/code/session_01Utp6CXtadYa5QXb2PbXPev Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2feede7 commit 044e67c

23 files changed

Lines changed: 403 additions & 11 deletions

File tree

‎ui/packages/components/src/renderers/form/form.tsx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1208,6 +1208,15 @@ function renderFieldDescription(
12081208
);
12091209
}
12101210

1211+
/**
1212+
* Widget keys whose DISPLAY reads a sibling field's value: a `record`-mode
1213+
* amount (ADR-0144 D2) shows the currency its record's code field holds, and a
1214+
* `formula` declaring `returnType: 'currency'` is drawn by the same widget.
1215+
* They get the live record (`dependentValues`) and nothing else from the
1216+
* data-source family — they query nothing.
1217+
*/
1218+
const RECORD_CURRENCY_WIDGET_TYPES = new Set(['currency', 'formula']);
1219+
12111220
function stripRegisteredFieldProps(type: string, props: RenderFieldProps): RenderFieldProps {
12121221
const {
12131222
dataSource,
@@ -1253,6 +1262,9 @@ function stripRegisteredFieldProps(type: string, props: RenderFieldProps): Rende
12531262
// leftover props onto a DOM node, where an unknown `emptyHint` attribute is
12541263
// a React warning — hence an allow-list, not an unconditional pass-through.
12551264
...(CASCADE_OPTION_WIDGET_TYPES.has(normalizedType) ? { dependentValues, emptyHint } : {}),
1265+
// A record-mode amount's symbol and decimals follow the code the user picks
1266+
// in its sibling select, so the amount box needs the live record too.
1267+
...(RECORD_CURRENCY_WIDGET_TYPES.has(normalizedType) ? { dependentValues } : {}),
12561268
};
12571269
}
12581270

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import React from 'react';
2+
import { Check, ChevronsUpDown } from 'lucide-react';
3+
import {
4+
Button,
5+
Command,
6+
CommandEmpty,
7+
CommandGroup,
8+
CommandInput,
9+
CommandItem,
10+
CommandList,
11+
Popover,
12+
PopoverContent,
13+
PopoverTrigger,
14+
EmptyValue,
15+
cn,
16+
} from '@object-ui/components';
17+
import { isValueDomainMember } from '@objectstack/spec/shared';
18+
import { useLocalization, useDisplayLocale } from '@object-ui/i18n';
19+
import { FieldWidgetComponentProps } from './types.js';
20+
import { toDomProps } from './toDomProps.js';
21+
import { useFieldTranslation } from './useFieldTranslation.js';
22+
23+
/**
24+
* The `valueDomain` a record-currency code field declares (ADR-0144 D2): a
25+
* `text` field whose value is an ISO 4217 code, checked on write against the
26+
* spec's `iso_4217_currency` membership table.
27+
*/
28+
export const CURRENCY_CODE_VALUE_DOMAIN = 'iso_4217_currency';
29+
30+
/** Whether `field` is a text field the `iso_4217_currency` domain governs. */
31+
export function isCurrencyCodeField(field: unknown): boolean {
32+
return (field as { valueDomain?: unknown } | null | undefined)?.valueDomain === CURRENCY_CODE_VALUE_DOMAIN;
33+
}
34+
35+
let isoCodesCache: readonly string[] | undefined;
36+
37+
/**
38+
* Every code the `iso_4217_currency` value domain accepts, sorted. The
39+
* candidates are ICU's (`Intl.supportedValuesOf('currency')`), kept only where
40+
* the spec's own membership test agrees, so the list offers exactly what the
41+
* write path accepts and never a code it would refuse. A runtime without
42+
* `Intl.supportedValuesOf` offers no "all" group; the field keeps its
43+
* "available" group and its current value.
44+
*/
45+
export function isoCurrencyCodes(): readonly string[] {
46+
if (isoCodesCache) return isoCodesCache;
47+
const supported = (Intl as { supportedValuesOf?: (key: string) => string[] }).supportedValuesOf;
48+
const candidates = typeof supported === 'function' ? supported('currency') : [];
49+
isoCodesCache = candidates.filter((c) => isValueDomainMember(CURRENCY_CODE_VALUE_DOMAIN, c)).sort();
50+
return isoCodesCache;
51+
}
52+
53+
/**
54+
* The organization's rated currencies: every code with a row in the
55+
* organization's exchange-rate table. ⛔ Not served yet — the rate table
56+
* (`sys_exchange_rate`, ADR-0144 D3) ships in the next multi-currency release,
57+
* and this is the seam its reader plugs into. Until then the "available"
58+
* group is the home currency plus the used codes.
59+
*/
60+
export function ratedCurrencyCodes(): readonly string[] {
61+
return [];
62+
}
63+
64+
export interface CurrencyCodeGroups {
65+
/** ADR-0144 D8 rule 3 — home + rated + used, in that order, deduplicated. */
66+
available: string[];
67+
/** Every other ISO 4217 code. */
68+
all: string[];
69+
}
70+
71+
/**
72+
* Split the ISO 4217 codes into the "available" group — the derived enabled
73+
* currencies of ADR-0144 D8 rule 3: the home currency, then every rated
74+
* currency, then every code already used — and "all" the rest. The current
75+
* value counts as used: a record that holds a code has used it. A code the
76+
* domain does not accept is never offered.
77+
*/
78+
export function currencyCodeGroups(input: {
79+
home?: string | null;
80+
rated?: readonly string[];
81+
used?: readonly string[];
82+
current?: string | null;
83+
iso?: readonly string[];
84+
}): CurrencyCodeGroups {
85+
const iso = input.iso ?? isoCurrencyCodes();
86+
const available: string[] = [];
87+
const seen = new Set<string>();
88+
for (const code of [input.home, ...(input.rated ?? []), ...(input.used ?? []), input.current]) {
89+
if (typeof code !== 'string' || seen.has(code) || !isValueDomainMember(CURRENCY_CODE_VALUE_DOMAIN, code)) continue;
90+
seen.add(code);
91+
available.push(code);
92+
}
93+
return { available, all: iso.filter((c) => !seen.has(c)) };
94+
}
95+
96+
/** `USD — US Dollar` in the display locale, or the bare code when ICU has no name. */
97+
function currencyLabel(code: string, locale: string): string {
98+
try {
99+
const name = new Intl.DisplayNames([locale], { type: 'currency' }).of(code);
100+
return name && name !== code ? `${code} — ${name}` : code;
101+
} catch {
102+
return code;
103+
}
104+
}
105+
106+
/**
107+
* CurrencyCodeField — the control a record-currency code field renders
108+
* (ADR-0144 D2): a searchable select of ISO 4217 codes, grouped "available"
109+
* (home + rated + used, D8 rule 3) and "all".
110+
*
111+
* The value it emits is always the explicit code the user sees selected
112+
* (D8 rule 4): picking the selected code again keeps it — there is no
113+
* "click again to clear" here, because a blank code is what the server
114+
* refuses. A create form opens it on the organization's currency through the
115+
* form's seeding (`schemaDefaultValues`), never through this widget, so an
116+
* edit form never rewrites a stored code just by being opened.
117+
*/
118+
export function CurrencyCodeField({ value, onChange, field, readonly, error, className, ...props }: FieldWidgetComponentProps<string>) {
119+
const { t } = useFieldTranslation();
120+
const { currency: home } = useLocalization();
121+
const locale = useDisplayLocale();
122+
const [open, setOpen] = React.useState(false);
123+
const current = typeof value === 'string' && value !== '' ? value : undefined;
124+
125+
if (readonly) {
126+
return current ? <span className="text-sm">{currencyLabel(current, locale)}</span> : <EmptyValue />;
127+
}
128+
129+
const groups = currencyCodeGroups({ home, rated: ratedCurrencyCodes(), current });
130+
const domProps = toDomProps(props);
131+
const item = (code: string) => (
132+
<CommandItem
133+
key={code}
134+
value={code}
135+
keywords={[currencyLabel(code, locale)]}
136+
onSelect={() => {
137+
onChange(code);
138+
setOpen(false);
139+
}}
140+
>
141+
<Check className={cn('mr-2 h-4 w-4', current === code ? 'opacity-100' : 'opacity-0')} />
142+
{currencyLabel(code, locale)}
143+
</CommandItem>
144+
);
145+
146+
return (
147+
<Popover open={open} onOpenChange={setOpen}>
148+
<PopoverTrigger asChild>
149+
<Button
150+
type="button"
151+
{...domProps}
152+
variant="outline"
153+
role="combobox"
154+
aria-expanded={open}
155+
aria-invalid={!!error}
156+
className={cn('w-full min-w-0 justify-between font-normal', className)}
157+
disabled={readonly || domProps.disabled}
158+
>
159+
<span className="truncate">
160+
{current ? currencyLabel(current, locale) : (field?.placeholder || t('fields.currencyCode.placeholder'))}
161+
</span>
162+
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
163+
</Button>
164+
</PopoverTrigger>
165+
<PopoverContent className="w-[var(--radix-popover-trigger-width)] min-w-[240px] p-0">
166+
<Command>
167+
<CommandInput placeholder={t('fields.currencyCode.search')} />
168+
<CommandList>
169+
<CommandEmpty>{t('fields.currencyCode.noResults')}</CommandEmpty>
170+
{groups.available.length > 0 && (
171+
<CommandGroup heading={t('fields.currencyCode.available')}>{groups.available.map(item)}</CommandGroup>
172+
)}
173+
{groups.all.length > 0 && (
174+
<CommandGroup heading={t('fields.currencyCode.all')}>{groups.all.map(item)}</CommandGroup>
175+
)}
176+
</CommandList>
177+
</Command>
178+
</PopoverContent>
179+
</Popover>
180+
);
181+
}

‎ui/packages/fields/src/widgets/TextField.tsx‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Input, Textarea, EmptyValue } from '@object-ui/components';
33
import type { TextFieldMetadata, TextareaFieldMetadata } from '@object-ui/types';
44
import { FieldWidgetComponentProps } from './types.js';
55
import { toDomProps } from './toDomProps.js';
6+
import { CurrencyCodeField, isCurrencyCodeField } from './CurrencyCodeField.js';
67

78
/**
89
* TextField - Standard single-line or multi-line text input
@@ -11,6 +12,14 @@ import { toDomProps } from './toDomProps.js';
1112
export function TextField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
1213
const fieldData = field;
1314

15+
// A record-currency code field (ADR-0144 D2) — `valueDomain:
16+
// 'iso_4217_currency'` declared on the field — is chosen from the ISO 4217
17+
// list, never typed: keyed off the DECLARATION, so every host that renders a
18+
// text field (form, inline edit, action dialog) gets the select.
19+
if (isCurrencyCodeField(fieldData)) {
20+
return <CurrencyCodeField value={value} onChange={onChange} field={field} readonly={readonly} error={error} {...props} />;
21+
}
22+
1423
if (readonly) {
1524
return <span className="text-sm">{value || <EmptyValue />}</span>;
1625
}

‎ui/packages/fields/src/widgets/useFieldTranslation.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ const FIELD_DEFAULTS: Record<string, string> = {
2020
// "LABEL — Off" badge (`{{label}}` is the column label the face is handed)
2121
// and the completion indicator's two accessible names. Same values as the
2222
// `en` pack, so a provider-less render reads what the literals did.
23+
// ADR-0144 D2 — `CurrencyCodeField`, the record-currency code select.
24+
'fields.currencyCode.placeholder': 'Select currency',
25+
'fields.currencyCode.search': 'Search currency…',
26+
'fields.currencyCode.noResults': 'No currency found',
27+
'fields.currencyCode.available': 'Available',
28+
'fields.currencyCode.all': 'All currencies (ISO 4217)',
2329
'fields.boolean.offBadge': '{{label}} — Off',
2430
'fields.boolean.completed': 'Completed',
2531
'fields.boolean.notCompleted': 'Not completed',

‎ui/packages/i18n/src/locales/ar.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,14 @@ const ar = {
395395
postalCode: "الرمز البريدي",
396396
country: "الدولة",
397397
},
398+
// ADR-0144 D2 — the record-currency code select (`CurrencyCodeField`).
399+
currencyCode: {
400+
placeholder: 'اختر عملة',
401+
search: 'ابحث عن عملة…',
402+
noResults: 'لم يتم العثور على عملة',
403+
available: 'المتاحة',
404+
all: 'كل العملات (ISO 4217)',
405+
},
398406
boolean: {
399407
offBadge: "{{label}} — إيقاف",
400408
completed: "مكتمل",

‎ui/packages/i18n/src/locales/de.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,14 @@ const de = {
377377
postalCode: "Postleitzahl",
378378
country: "Land",
379379
},
380+
// ADR-0144 D2 — the record-currency code select (`CurrencyCodeField`).
381+
currencyCode: {
382+
placeholder: 'Währung auswählen',
383+
search: 'Währung suchen…',
384+
noResults: 'Keine Währung gefunden',
385+
available: 'Verfügbar',
386+
all: 'Alle Währungen (ISO 4217)',
387+
},
380388
boolean: {
381389
offBadge: "{{label}} — Aus",
382390
completed: "Abgeschlossen",

‎ui/packages/i18n/src/locales/en.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,14 @@ const en = {
357357
},
358358
},
359359
fields: {
360+
// ADR-0144 D2 — the record-currency code select (`CurrencyCodeField`).
361+
currencyCode: {
362+
placeholder: 'Select currency',
363+
search: 'Search currency…',
364+
noResults: 'No currency found',
365+
available: 'Available',
366+
all: 'All currencies (ISO 4217)',
367+
},
360368
// objectui#13461 — names for icon-only buttons in the field widgets.
361369
masterDetail: {
362370
view: 'Open {{name}}',

‎ui/packages/i18n/src/locales/es.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,14 @@ const es = {
385385
postalCode: "Código postal",
386386
country: "País",
387387
},
388+
// ADR-0144 D2 — the record-currency code select (`CurrencyCodeField`).
389+
currencyCode: {
390+
placeholder: 'Seleccionar moneda',
391+
search: 'Buscar moneda…',
392+
noResults: 'No se encontró ninguna moneda',
393+
available: 'Disponibles',
394+
all: 'Todas las monedas (ISO 4217)',
395+
},
388396
boolean: {
389397
offBadge: "{{label}} — Desactivado",
390398
completed: "Completado",

‎ui/packages/i18n/src/locales/fr.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,14 @@ const fr = {
380380
postalCode: "Code postal",
381381
country: "Pays",
382382
},
383+
// ADR-0144 D2 — the record-currency code select (`CurrencyCodeField`).
384+
currencyCode: {
385+
placeholder: 'Sélectionner une devise',
386+
search: 'Rechercher une devise…',
387+
noResults: 'Aucune devise trouvée',
388+
available: 'Disponibles',
389+
all: 'Toutes les devises (ISO 4217)',
390+
},
383391
boolean: {
384392
offBadge: "{{label}} — Désactivé",
385393
completed: "Terminé",

‎ui/packages/i18n/src/locales/ja.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,14 @@ const ja = {
377377
postalCode: "郵便番号",
378378
country: "国",
379379
},
380+
// ADR-0144 D2 — the record-currency code select (`CurrencyCodeField`).
381+
currencyCode: {
382+
placeholder: '通貨を選択',
383+
search: '通貨を検索…',
384+
noResults: '通貨が見つかりません',
385+
available: '利用可能',
386+
all: 'すべての通貨(ISO 4217)',
387+
},
380388
boolean: {
381389
offBadge: "{{label}} — オフ",
382390
completed: "完了",

0 commit comments

Comments
 (0)