Skip to content

Commit 2feede7

Browse files
committed
wip(ui): record-mode currency — resolver reads the record's code field; cells, currency widget and create-form seeding follow it
Work in progress for the record currency display (ADR-0144 D2, D8 rule 4). Claude-Session: https://claude.ai/code/session_01Utp6CXtadYa5QXb2PbXPev Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5232c8b commit 2feede7

13 files changed

Lines changed: 139 additions & 34 deletions

File tree

‎ui/packages/fields/src/currency.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
* it so the long-standing `@object-ui/fields` import path keeps working for
1212
* every field/cell renderer that already depends on it.
1313
*/
14-
export { resolveFieldCurrency } from '@object-ui/i18n';
14+
export { resolveFieldCurrency, recordCurrencyField } from '@object-ui/i18n';
1515

1616
/**
1717
* Fallback width for a currency whose digit count cannot be resolved: the

‎ui/packages/fields/src/index.tsx‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -821,7 +821,7 @@ function RatingCellRenderer({ value, field }: CellRendererProps): React.ReactEle
821821
/**
822822
* Currency field cell renderer
823823
*/
824-
export function CurrencyCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
824+
export function CurrencyCellRenderer({ value, field, dependentValues }: CellRendererProps): React.ReactElement {
825825
// Hooks before the empty-value early return — a value flipping between
826826
// null and set must not change the hook count between renders.
827827
const { currency: tenantCurrency } = useLocalization();
@@ -834,8 +834,11 @@ export function CurrencyCellRenderer({ value, field }: CellRendererProps): React
834834
// Resolve the display currency via the shared precedence: field `currency` →
835835
// `currencyConfig.defaultCurrency` → the tenant default (ADR-0053). When none
836836
// is known, render a plain number — never a guessed symbol (silently assuming
837-
// USD mis-displays non-USD orgs, e.g. RMB amounts shown as $).
838-
const currency = resolveFieldCurrency(field as any, tenantCurrency);
837+
// USD mis-displays non-USD orgs, e.g. RMB amounts shown as $). A
838+
// `record`-mode amount (ADR-0144 D2) reads its row's code field instead, so
839+
// three invoices in USD / EUR / JPY show three symbols; a row with no code
840+
// shows a plain number.
841+
const currency = resolveFieldCurrency(field as any, tenantCurrency, dependentValues);
839842
const num = Number(safe);
840843
// `CurrencyField`'s read-only branch makes this same call, so a table cell
841844
// and a read-only form show one amount identically: the currency's ISO 4217
@@ -2867,7 +2870,7 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
28672870
* not import each other; `__tests__/formulaFaces.returnType-11748.test.tsx`
28682871
* compares the two faces' text for every declared type.
28692872
*/
2870-
export function FormulaCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
2873+
export function FormulaCellRenderer({ value, field, dependentValues }: CellRendererProps): React.ReactElement {
28712874
// Hooks before the empty-value early return: the hook count must not change
28722875
// when a value flips between null and set. The date face reads the display
28732876
// locale; the boolean face reads the locale's word.
@@ -2910,9 +2913,10 @@ export function FormulaCellRenderer({ value, field }: CellRendererProps): React.
29102913
// renderer reads `currencyConfig` through `resolveFieldCurrency` exactly as
29112914
// it reads a currency field's, so a `fixed` config keeps its own code and an
29122915
// absent one is `dynamic`, the tenant default currency. ⛔ No second money
2913-
// formatter here.
2916+
// formatter here. The row rides along for a `record`-mode formula, whose
2917+
// currency is its row's code field (ADR-0144 D2).
29142918
if (valueType === 'currency') {
2915-
return <CurrencyCellRenderer value={value} field={field} />;
2919+
return <CurrencyCellRenderer value={value} field={field} dependentValues={dependentValues} />;
29162920
}
29172921

29182922
// A declared BOOLEAN reads the locale's Yes / No word (objectui#11748), the

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,15 @@ import { resolveFieldCurrency, currencySymbol } from '../currency.js';
77
import { useBadInputRefusal, BadInputMessage, BAD_INPUT_BORDER } from './numberBadInput.js';
88
import { formatCurrency, currencyDisplayWidth } from './number-format.js';
99

10-
export function CurrencyField({ value, onChange, field, readonly, error, className, ...props }: FieldWidgetComponentProps<number>) {
10+
export function CurrencyField({ value, onChange, field, readonly, error, className, dependentValues, ...props }: FieldWidgetComponentProps<number>) {
1111
const currencyField = field as any;
1212
// Shared precedence: field currency → currencyConfig → tenant default (ADR-0053).
13+
// A `record`-mode amount (ADR-0144 D2) reads the code its record holds —
14+
// `dependentValues` is the live form record, so picking a code in the
15+
// sibling select moves this box's symbol and decimals with it.
1316
const { currency: tenantCurrency } = useLocalization();
1417
const locale = useDisplayLocale();
15-
const currency: string | undefined = resolveFieldCurrency(currencyField, tenantCurrency);
18+
const currency: string | undefined = resolveFieldCurrency(currencyField, tenantCurrency, dependentValues);
1619
// A currency's decimal places are the CURRENCY's, not a field setting — the
1720
// maintainer ruling recorded on objectstack-ai/objectstack#19910 (batch #218
1821
// item 2), executed here by objectui#10276. The width is the ISO 4217

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ export function FormulaField({ value, field, ...props }: FieldWidgetComponentPro
116116
}
117117
const amount = Number(safe);
118118
if (isNaN(amount)) return <span className={typedClassName}>{String(safe)}</span>;
119-
return <CurrencyField value={amount} onChange={props.onChange} field={field} readonly />;
119+
return <CurrencyField value={amount} onChange={props.onChange} field={field} dependentValues={props.dependentValues} readonly />;
120120
}
121121

122122
if (valueType === 'boolean') {

‎ui/packages/i18n/src/currency.ts‎

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,44 @@
66
* LICENSE file in the root directory of this source tree.
77
*/
88

9+
/**
10+
* The currency config a field declares, in the three modes of
11+
* `@objectstack/spec`'s `CurrencyConfigSchema` (ADR-0144 D2 adds `record`).
12+
*/
13+
export interface FieldCurrencyConfig {
14+
currencyMode?: 'dynamic' | 'fixed' | 'record';
15+
defaultCurrency?: string;
16+
/** `record` mode only: the sibling field holding each record's ISO 4217 code. */
17+
currencyField?: string;
18+
}
19+
20+
/**
21+
* The sibling code field a `record`-mode amount points at (ADR-0144 D2), or
22+
* `undefined` for a `dynamic` / `fixed` field and for anything that is not a
23+
* currency config at all. The one reading every surface that needs the
24+
* pointer itself — the form's code select, the grid footer's grouping test,
25+
* the filter builder — shares, so none of them re-derives the mode test.
26+
*/
27+
export function recordCurrencyField(
28+
field: { currencyConfig?: FieldCurrencyConfig } | null | undefined,
29+
): string | undefined {
30+
const config = field?.currencyConfig;
31+
if (config?.currencyMode !== 'record') return undefined;
32+
return typeof config.currencyField === 'string' && config.currencyField !== ''
33+
? config.currencyField
34+
: undefined;
35+
}
36+
937
/**
1038
* Resolve the display currency code for a currency-typed field, in precedence
1139
* order:
1240
*
41+
* 0. a `record`-mode field (ADR-0144 D2) reads ONLY the record in scope: the
42+
* value of the sibling field `currencyConfig.currencyField` names. No
43+
* record, or a record whose code is blank, resolves to `undefined` — never
44+
* to steps 1–4. The record's code field is the only place that amount's
45+
* currency lives, so the tenant default would name a currency the amount
46+
* is not in: an amount of 100 EUR shown as `¥100` is worse than `100`;
1347
* 1. the field's explicit `currency` — a per-node display code. The spec
1448
* declares it on presentation surfaces (the metric widget's `currency`, a
1549
* dataset measure's `currency`) and looser grid/column configs carry it;
@@ -27,34 +61,46 @@
2761
*
2862
* `currencyMode: 'dynamic'` — and a `currencyConfig` with no `currencyMode` at
2963
* all, which `@objectstack/spec` defaults to `'dynamic'` — skips step 2
30-
* (objectui#10422). The spec describes that mode as "user selectable", and its
31-
* `FieldSchema` guidance says a field without a fixed currency "uses the tenant
32-
* default at runtime". No value carries a user-selected code today (a currency
33-
* value is a bare number), so a dynamic field reads the tenant default, never
34-
* its `defaultCurrency`. That matters beyond authored dynamic configs: the
35-
* spec parses an EMPTY `currencyConfig` to
36-
* `{ currencyMode: 'dynamic', defaultCurrency: 'CNY' }`, which must not render
37-
* as yuan. If a value ever carries its own selected code, that code is the one
38-
* to win, ahead of the tenant default.
64+
* (objectui#10422): a dynamic field reads the tenant default, never its
65+
* `defaultCurrency`. That matters beyond authored dynamic configs: until
66+
* @objectstack/spec dropped the `'CNY'` default (#27157), the spec parsed an
67+
* EMPTY `currencyConfig` to `{ currencyMode: 'dynamic', defaultCurrency: 'CNY' }`,
68+
* and rows stored or built by that parse still carry it until a rehydration
69+
* strips it; it must not render as yuan. The spec now refuses a `fixed`
70+
* config without a code at authoring and save, but a stored row saved that
71+
* way before is kept as authored and still loads with no code; step 2 skips
72+
* it and the tenant default shows, as it always did. A per-record currency is
73+
* step 0's, and only a `record`-mode field has one.
3974
*
4075
* This is the single resolution every field / measure / cell renderer shares -
4176
* ending the per-renderer drift where some read only `currency`, others only
4277
* `defaultCurrency`, and others `currencyConfig`. It lives in `@object-ui/i18n`
4378
* (alongside `useLocalization`) because the tenant default is a localization
4479
* concern; `@object-ui/fields` re-exports it for backward compatibility.
80+
*
81+
* `record` is the row / form values the amount sits in. A caller that holds no
82+
* record (a column-level header, a footer over many rows) passes none, and a
83+
* `record`-mode field then resolves to no currency, which is the honest answer
84+
* for a set of amounts in different currencies.
4585
*/
4686
export function resolveFieldCurrency(
4787
field:
4888
| {
4989
currency?: string;
5090
defaultCurrency?: string;
51-
currencyConfig?: { currencyMode?: 'dynamic' | 'fixed'; defaultCurrency?: string };
91+
currencyConfig?: FieldCurrencyConfig;
5292
}
5393
| null
5494
| undefined,
5595
tenantDefault?: string,
96+
record?: Readonly<Record<string, unknown>> | null,
5697
): string | undefined {
5798
const config = field?.currencyConfig;
99+
if (config?.currencyMode === 'record') {
100+
const codeField = recordCurrencyField(field);
101+
const code = codeField !== undefined ? record?.[codeField] : undefined;
102+
return typeof code === 'string' && code !== '' ? code : undefined;
103+
}
58104
return (
59105
field?.currency ||
60106
(config?.currencyMode === 'fixed' ? config.defaultCurrency : undefined) ||

‎ui/packages/i18n/src/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ export {
154154

155155
export { pickLocalized, setLocalized, clearLocalized } from './pickLocalized.js';
156156
export { LocalizationProvider, useLocalization, type LocalizationValue } from './LocalizationContext.js';
157-
export { resolveFieldCurrency } from './currency.js';
157+
export { resolveFieldCurrency, recordCurrencyField, type FieldCurrencyConfig } from './currency.js';
158158

159159
// The one number-display formatter (objectui#4033) and the locale it formats
160160
// in. Every field cell / widget / metric renderer goes through this pair —

‎ui/packages/plugin-form/src/DrawerForm.tsx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import {
3737
import { Loader2 } from 'lucide-react';
3838

3939
import { SchemaRenderer, useSafeFieldLabel, usePreviewMode } from '@object-ui/react';
40-
import { createSafeTranslation } from '@object-ui/i18n';
40+
import { createSafeTranslation, useLocalization } from '@object-ui/i18n';
4141
import { MasterDetailForm } from './MasterDetailForm';
4242
import { buildSectionFields as buildSectionFieldsShared } from './sectionFields';
4343
import { buildFlatFields } from './flatFields';
@@ -253,6 +253,9 @@ const DrawerFormBody: React.FC<DrawerFormProps> = ({
253253
const { fieldLabel, sectionLabel } = useSafeFieldLabel();
254254
const perms = usePermissions();
255255
const { userId: currentUserId } = perms;
256+
// ADR-0144 D8 rule 4 — the organization currency a record-currency code
257+
// field opens on in create mode.
258+
const { currency: homeCurrency } = useLocalization();
256259
const { t } = useDiscardTranslation();
257260
// Upload-in-flight gate (objectui#10166): Save is refused, disabled and
258261
// EXPLAINED while a file/image widget below is still uploading.
@@ -405,7 +408,7 @@ const DrawerFormBody: React.FC<DrawerFormProps> = ({
405408
// Declared static defaults are this form's opening values (#4047) —
406409
// see `schemaDefaults` for the create-only boundary and for why
407410
// runtime defaults are left to the server.
408-
setFormData(seedCreateValues(objectSchema, resolveInitialRecord(schema), { currentUserId }));
411+
setFormData(seedCreateValues(objectSchema, resolveInitialRecord(schema), { currentUserId, homeCurrency }));
409412
// Not a read, so no earlier record read's failure describes the form.
410413
run.commit();
411414
setLoading(false);

‎ui/packages/plugin-form/src/ModalForm.tsx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ import {
3838
import { Loader2 } from 'lucide-react';
3939
import { MasterDetailForm } from './MasterDetailForm';
4040
import { SchemaRenderer, useSafeFieldLabel, usePreviewMode } from '@object-ui/react';
41-
import { createSafeTranslation } from '@object-ui/i18n';
41+
import { createSafeTranslation, useLocalization } from '@object-ui/i18n';
4242
import { buildSectionFields as buildSectionFieldsShared } from './sectionFields';
4343
import { buildFlatFields } from './flatFields';
4444
import { isRecordReadOutstanding } from './recordReadGate';
@@ -342,6 +342,9 @@ const ModalFormBody: React.FC<ModalFormProps> = ({
342342
const uploadGate = useUploadGate();
343343
const previewMode = usePreviewMode();
344344
const perms = usePermissions();
345+
// ADR-0144 D8 rule 4 — the organization currency a record-currency code
346+
// field opens on in create mode.
347+
const { currency: homeCurrency } = useLocalization();
345348
const [objectSchema, setObjectSchema] = useState<any>(null);
346349
const [formData, setFormData] = useState<Record<string, any>>({});
347350
// The ONE field-gate step every layout draws through (`gateFormFields`,
@@ -565,7 +568,7 @@ const ModalFormBody: React.FC<ModalFormProps> = ({
565568
// supplied initial values still win. See `schemaDefaults` for why
566569
// runtime defaults (`NOW()`, `current_user`, CEL envelopes) are left
567570
// to the server and why option-level `default` is not read here.
568-
setFormData(seedCreateValues(objectSchema, resolveInitialRecord(schema), { currentUserId: perms.userId }));
571+
setFormData(seedCreateValues(objectSchema, resolveInitialRecord(schema), { currentUserId: perms.userId, homeCurrency }));
569572
// Not a read, so no earlier record read's failure describes the form.
570573
run.commit();
571574
setLoading(false);

‎ui/packages/plugin-form/src/ObjectForm.tsx‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import React, { useEffect, useState, useCallback, useMemo } from 'react';
1717
import type { ObjectFormSchema, FormField, FormSchema, DataSource } from '@object-ui/types';
1818
import { SchemaRenderer, useSafeFieldLabel } from '@object-ui/react';
19-
import { pickLocalized, useObjectTranslation } from '@object-ui/i18n';
19+
import { pickLocalized, useObjectTranslation, useLocalization } from '@object-ui/i18n';
2020
import { useRecordInvalidation } from './recordInvalidation';
2121
import { mapFieldTypeToFormType, buildValidationRules, formatFileSize, fieldHelpText } from '@object-ui/fields';
2222
import { useIsMobile, toast } from '@object-ui/components';
@@ -1270,13 +1270,16 @@ const SimpleObjectForm: React.FC<{ schema: LocalizedObjectFormSchema; dataSource
12701270
// the create-mode `required` suppression (#4069), so seeding and validation
12711271
// cannot disagree about which mode this form is in.
12721272
const isCreateForm = isCreateFormMode(schema);
1273+
// ADR-0144 D8 rule 4 — the organization currency a record-currency code
1274+
// field opens on in create mode.
1275+
const { currency: homeCurrency } = useLocalization();
12731276
// [#5683] `currentUserId` lets the one client-resolvable token
12741277
// (`current_user`) pre-fill with the id the server would stamp anyway;
12751278
// null/unloaded seeds nothing and keeps the omit-and-let-the-engine-resolve
12761279
// contract above.
12771280
const schemaDefaults = React.useMemo(
1278-
() => (isCreateForm ? schemaDefaultValues(objectSchema, { currentUserId: perms.userId }) : {}),
1279-
[objectSchema, isCreateForm, perms.userId],
1281+
() => (isCreateForm ? schemaDefaultValues(objectSchema, { currentUserId: perms.userId, homeCurrency }) : {}),
1282+
[objectSchema, isCreateForm, perms.userId, homeCurrency],
12801283
);
12811284

12821285
// What the form OPENS with: the seeded defaults, overlaid with the caller's

‎ui/packages/plugin-form/src/SplitForm.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
2727
import type { FormField, FormSchema, DataSource, ObjectFormSchema, ObjectFormSection } from '@object-ui/types';
2828
import { cn, toast } from '@object-ui/components';
29+
import { useLocalization } from '@object-ui/i18n';
2930
import { SchemaRenderer, useSafeFieldLabel } from '@object-ui/react';
3031
import { buildSectionFields as buildSectionFieldsShared } from './sectionFields';
3132
import { seedCreateValues } from './schemaDefaults';
@@ -182,6 +183,9 @@ export const SplitForm: React.FC<SplitFormProps> = ({
182183
const { t } = useFormChromeTranslation();
183184
const perms = usePermissions();
184185
const { userId: currentUserId } = perms;
186+
// ADR-0144 D8 rule 4 — the organization currency a record-currency code
187+
// field opens on in create mode.
188+
const { currency: homeCurrency } = useLocalization();
185189
// Upload-in-flight gate (objectui#10166): a `file`/`image` value is only its
186190
// fileId once the presigned upload settles, so a save during that window
187191
// stored the record WITHOUT the attachment and reported success.
@@ -270,7 +274,7 @@ export const SplitForm: React.FC<SplitFormProps> = ({
270274
// Declared static defaults are this form's opening values (#4047) —
271275
// see `schemaDefaults` for the create-only boundary and for why
272276
// runtime defaults are left to the server.
273-
setFormData(seedCreateValues(objectSchema, resolveInitialRecord(schema), { currentUserId }));
277+
setFormData(seedCreateValues(objectSchema, resolveInitialRecord(schema), { currentUserId, homeCurrency }));
274278
// Not a read, so no earlier record read's failure describes the form.
275279
run.commit();
276280
setLoading(false);

0 commit comments

Comments
 (0)