From 59ac9e812c6c214671085823c95568b13d91e926 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 12:54:55 +0000 Subject: [PATCH 01/26] =?UTF-8?q?feat(spec):=20currencyMode=20'record'=20+?= =?UTF-8?q?=20currencyField=20=E2=80=94=20a=20record-mode=20amount=20point?= =?UTF-8?q?s=20at=20a=20sibling=20ISO=204217=20code=20field?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01Utp6CbfVSc1hXtxLUPHZ2f Co-authored-by: Claude --- .../data/currency-config-record-mode.test.ts | 147 ++++++++++++++++++ packages/spec/src/data/field.form.ts | 5 +- packages/spec/src/data/field.zod.ts | 54 ++++++- packages/spec/src/data/object.zod.ts | 83 ++++++++++ skills/objectstack-data/rules/field-types.md | 8 + 5 files changed, 288 insertions(+), 9 deletions(-) create mode 100644 packages/spec/src/data/currency-config-record-mode.test.ts diff --git a/packages/spec/src/data/currency-config-record-mode.test.ts b/packages/spec/src/data/currency-config-record-mode.test.ts new file mode 100644 index 00000000000..cea2f5453e8 --- /dev/null +++ b/packages/spec/src/data/currency-config-record-mode.test.ts @@ -0,0 +1,147 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0144 D2] `currencyMode: 'record'` + `currencyField` — a record-mode + * amount points at a sibling ISO 4217 code field on the same object. + * + * Two doors, one per half: `CurrencyConfigSchema` refuses the half one config + * can see (`record` without a pointer, a pointer under another mode), and the + * object's `superRefine` refuses what the pointer names (nothing, a non-text + * field, a text field with no ISO 4217 value domain). A `formula` declaring + * `returnType: 'currency'` carries the same config, so it meets both doors. + * `dynamic` and `fixed` parse exactly as they did before `record` existed. + */ + +import { describe, it, expect } from 'vitest'; + +import { CurrencyConfigSchema, FieldSchema } from './field.zod'; +import { ObjectSchema } from './object.zod'; + +const CODE_FIELD = { type: 'text', label: 'Currency', valueDomain: 'iso_4217_currency' } as const; +const RECORD = { currencyMode: 'record', currencyField: 'currency_code' } as const; + +function invoice(fields: Record): Record { + return { name: 'invoice', label: 'Invoice', fields }; +} + +/** The issues of a refused object parse, reduced to what an author is shown. */ +function objectIssues(input: unknown): Array<{ code: string; path: PropertyKey[]; message: string }> { + const r = ObjectSchema.safeParse(input); + if (r.success) return []; + return r.error.issues.map((i) => ({ code: i.code, path: i.path, message: i.message })); +} + +describe('`CurrencyConfigSchema`: `record` ⇔ `currencyField`', () => { + it('a `record` config with a pointer parses, keeping the mode and the pointer', () => { + const r = CurrencyConfigSchema.safeParse(RECORD); + expect(r.success).toBe(true); + if (!r.success) return; + expect(r.data.currencyMode).toBe('record'); + expect(r.data.currencyField).toBe('currency_code'); + }); + + it('refuses `record` without `currencyField`, at the key, with the prescription', () => { + const r = CurrencyConfigSchema.safeParse({ currencyMode: 'record' }); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues).toHaveLength(1); + expect(r.error.issues[0]!.code).toBe('custom'); + expect(r.error.issues[0]!.path).toEqual(['currencyField']); + expect(r.error.issues[0]!.message).toMatch(/^`currencyMode: 'record'` needs `currencyField`/); + }); + + it.each(['dynamic', 'fixed'] as const)('refuses `currencyField` under `%s`, at the key', (mode) => { + const r = CurrencyConfigSchema.safeParse({ currencyMode: mode, defaultCurrency: 'USD', currencyField: 'currency_code' }); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues).toHaveLength(1); + expect(r.error.issues[0]!.path).toEqual(['currencyField']); + expect(r.error.issues[0]!.message).toMatch(/^`currencyField` is read only under `currencyMode: 'record'`/); + }); + + it('refuses `currencyField` under the defaulted mode (no `currencyMode` written ⇒ dynamic)', () => { + expect(CurrencyConfigSchema.safeParse({ currencyField: 'currency_code' }).success).toBe(false); + }); + + it('`dynamic` and `fixed` outputs are byte-identical to before `record` existed', () => { + const cases: Array<[unknown, string]> = [ + [{}, '{"currencyMode":"dynamic","defaultCurrency":"CNY"}'], + [{ currencyMode: 'dynamic' }, '{"currencyMode":"dynamic","defaultCurrency":"CNY"}'], + [{ currencyMode: 'dynamic', defaultCurrency: 'EUR' }, '{"currencyMode":"dynamic","defaultCurrency":"EUR"}'], + [{ currencyMode: 'fixed', defaultCurrency: 'USD' }, '{"currencyMode":"fixed","defaultCurrency":"USD"}'], + [{ currencyMode: 'fixed' }, '{"currencyMode":"fixed","defaultCurrency":"CNY"}'], + ]; + for (const [input, before] of cases) { + expect(JSON.stringify(CurrencyConfigSchema.parse(input))).toBe(before); + } + }); + + it('parse is idempotent: `parse(parse(x))` equals `parse(x)` in every mode', () => { + for (const input of [{}, { currencyMode: 'fixed', defaultCurrency: 'USD' }, RECORD]) { + const once = CurrencyConfigSchema.parse(input); + expect(CurrencyConfigSchema.parse(once)).toEqual(once); + } + const object = invoice({ currency_code: CODE_FIELD, amount: { type: 'currency', label: 'Amount', currencyConfig: RECORD } }); + const once = ObjectSchema.parse(object); + expect(ObjectSchema.parse(once)).toEqual(once); + }); +}); + +describe('the object judges what `currencyField` names', () => { + it('accepts a pointer at a declared `text` field with `valueDomain: iso_4217_currency`', () => { + const object = invoice({ currency_code: CODE_FIELD, amount: { type: 'currency', label: 'Amount', currencyConfig: RECORD } }); + expect(objectIssues(object)).toEqual([]); + }); + + it('refuses a pointer into nothing, naming the candidates', () => { + const issues = objectIssues(invoice({ + doc_currency: CODE_FIELD, + amount: { type: 'currency', label: 'Amount', currencyConfig: RECORD }, + })); + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe('custom'); + expect(issues[0]!.path).toEqual(['fields', 'amount', 'currencyConfig', 'currencyField']); + expect(issues[0]!.message).toMatch(/^`amount\.currencyConfig\.currencyField` names `currency_code`, but object `invoice` declares no other field/); + expect(issues[0]!.message).toContain('candidates: `doc_currency`'); + }); + + it('refuses a pointer at the amount itself', () => { + const issues = objectIssues(invoice({ + currency_code: { type: 'currency', label: 'Amount', currencyConfig: RECORD }, + })); + expect(issues.map((i) => i.path)).toEqual([['fields', 'currency_code', 'currencyConfig', 'currencyField']]); + expect(issues[0]!.message).toContain('the object has none; declare one'); + }); + + it('refuses a pointer at a field that is not `text`', () => { + const issues = objectIssues(invoice({ + currency_code: { type: 'select', label: 'Currency', options: [{ label: 'USD', value: 'USD' }] }, + amount: { type: 'currency', label: 'Amount', currencyConfig: RECORD }, + })); + expect(issues).toHaveLength(1); + expect(issues[0]!.path).toEqual(['fields', 'amount', 'currencyConfig', 'currencyField']); + expect(issues[0]!.message).toMatch(/^`amount\.currencyConfig\.currencyField` names `currency_code`, a `select` field/); + }); + + it('refuses a pointer at a `text` field without the ISO 4217 value domain', () => { + const issues = objectIssues(invoice({ + currency_code: { type: 'text', label: 'Currency' }, + amount: { type: 'currency', label: 'Amount', currencyConfig: RECORD }, + })); + expect(issues).toHaveLength(1); + expect(issues[0]!.path).toEqual(['fields', 'amount', 'currencyConfig', 'currencyField']); + expect(issues[0]!.message).toMatch(/^`amount\.currencyConfig\.currencyField` names `currency_code`, a `text` field .* does not declare `valueDomain: 'iso_4217_currency'`/); + }); + + it('a `formula` with `returnType: currency` meets the same doors in the same shape', () => { + const formula = (currencyConfig: unknown) => ({ + type: 'formula', label: 'Net', expression: 'record.amount * 0.9', returnType: 'currency', currencyConfig, + }); + expect(FieldSchema.safeParse({ name: 'net', ...formula(RECORD) }).success).toBe(true); + expect(FieldSchema.safeParse({ name: 'net', ...formula({ currencyMode: 'record' }) }).success).toBe(false); + const ok = invoice({ currency_code: CODE_FIELD, net: formula(RECORD) }); + expect(objectIssues(ok)).toEqual([]); + const bad = invoice({ currency_code: { type: 'text', label: 'Currency' }, net: formula(RECORD) }); + expect(objectIssues(bad).map((i) => i.path)).toEqual([['fields', 'net', 'currencyConfig', 'currencyField']]); + }); +}); diff --git a/packages/spec/src/data/field.form.ts b/packages/spec/src/data/field.form.ts index f40ee308f2f..233367297b9 100644 --- a/packages/spec/src/data/field.form.ts +++ b/packages/spec/src/data/field.form.ts @@ -108,10 +108,13 @@ export const fieldForm = defineForm({ visibleWhen: "data.type == 'currency' || (data.type == 'formula' && data.returnType == 'currency')", helpText: 'Which currency this field is in — a currency field, or a formula whose return type is Currency. Unset: dynamic mode. The value is a bare number in either mode.', fields: [ - { field: 'currencyMode', type: 'select', helpText: 'dynamic (the default): the field has no currency of its own, and amounts display in the tenant default currency (the localization.currency setting). fixed: the field has one currency, defaultCurrency.', options: [ + { field: 'currencyMode', type: 'select', helpText: 'dynamic (the default): the field has no currency of its own, and amounts display in the tenant default currency (the localization.currency setting). fixed: the field has one currency, defaultCurrency. record: each record has its own currency, stored in the field currencyField names.', options: [ { label: 'Dynamic — the tenant default currency', value: 'dynamic' }, { label: 'Fixed — one currency for this field', value: 'fixed' }, + { label: 'Record — each record names its currency', value: 'record' }, ] }, + // ADR-0144 D2 — the record-mode pointer. + { field: 'currencyField', type: 'text', helpText: 'Record mode only, and required there: the field on this object holding each record\'s ISO 4217 code — a text field with valueDomain iso_4217_currency, conventionally currency_code.' }, { field: 'defaultCurrency', type: 'text', helpText: 'The one currency of a fixed-mode field, as a three-character ISO 4217 code (e.g. USD, EUR). Defaults to CNY. Not read in dynamic mode.' }, ], }, diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 2777cf65b3c..d490e3a3a9d 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -534,6 +534,37 @@ const CURRENCY_CONFIG_DECIMAL_PLACES_GUIDANCE: Readonly> + ' Delete the key.', }; +/** + * [ADR-0144 D2] `currencyMode: 'record'` ⇔ `currencyField`. A `record`-mode + * amount has no currency until a pointer names the sibling code field, and a + * pointer under `dynamic` / `fixed` is read by nothing — the declared-but-inert + * shape ADR-0078 keeps out. One located issue at `['currencyField']` either + * way; what the pointer NAMES is the object's to judge (object.zod.ts). + */ +function refuseCurrencyFieldOutsideRecordMode( + config: { currencyMode: 'dynamic' | 'fixed' | 'record'; currencyField?: string }, + ctx: z.RefinementCtx, +): void { + if (config.currencyMode === 'record' && config.currencyField === undefined) { + ctx.addIssue({ + code: 'custom', + path: ['currencyField'], + message: + '`currencyMode: \'record\'` needs `currencyField`: name the field on this object that holds each ' + + 'record\'s ISO 4217 code — a `text` field with `valueDomain: \'iso_4217_currency\'`, conventionally ' + + '`currency_code` — or use `currencyMode: \'fixed\'` (one currency) or `\'dynamic\'` (the tenant default).', + }); + } else if (config.currencyMode !== 'record' && config.currencyField !== undefined) { + ctx.addIssue({ + code: 'custom', + path: ['currencyField'], + message: + `\`currencyField\` is read only under \`currencyMode: 'record'\`, and this config is \`${config.currencyMode}\`: ` + + 'set `currencyMode: \'record\'` if each record carries its own currency, or delete `currencyField`.', + }); + } +} + /** * Currency Configuration Schema * Configuration for currency field type supporting multi-currency — and, in the @@ -555,14 +586,21 @@ export const CurrencyConfigSchema = lazySchema(() => strictObject({ aliases: { mode: 'currencyMode', currency: 'defaultCurrency', code: 'defaultCurrency', isoCode: 'defaultCurrency' }, guidance: CURRENCY_CONFIG_DECIMAL_PLACES_GUIDANCE, }, { - currencyMode: z.enum(['dynamic', 'fixed']).default('dynamic').describe('Currency mode. `fixed`: the field has one currency, `defaultCurrency`. `dynamic` (the default): the field has no currency of its own — amounts display in the tenant default currency (the `localization.currency` setting; a plain number when none is set) and `defaultCurrency` is not read. Neither mode is a per-record choice: the value is a bare number either way.'), - defaultCurrency: z.string().length(3).default('CNY').describe('The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency. Not read under `dynamic` (the default), where amounts display in the tenant default currency.'), - // #19992 — no `.superRefine()` / `.overwrite()` here any more. The #7918 - // ISO 4217 contradiction check and the #11423 default-materializing - // `.overwrite()` both existed only for the removed `precision` key (see - // CURRENCY_CONFIG_DECIMAL_PLACES_GUIDANCE above), so parse output is now - // exactly `{ currencyMode, defaultCurrency }`. -})); + currencyMode: z.enum(['dynamic', 'fixed', 'record']).default('dynamic').describe('Currency mode. `fixed`: the field has one currency, `defaultCurrency`. `dynamic` (the default): the field has no currency of its own — amounts display in the tenant default currency (the `localization.currency` setting; a plain number when none is set) and `defaultCurrency` is not read. `record`: each record has its own currency, the ISO 4217 code stored in the sibling field `currencyField` names; `defaultCurrency` is not read. The value is a bare number in every mode (ADR-0104 D1).'), + defaultCurrency: z.string().length(3).default('CNY').describe('The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency. Not read under `dynamic` (the default), where amounts display in the tenant default currency, nor under `record`, where each record names its own.'), + // ADR-0144 D2 — the pointer of a `record`-mode amount. Optional at the key + // level; `refuseCurrencyFieldOutsideRecordMode` below makes it required + // under `record` and refuses it under the other two modes, and the object's + // `superRefine` (`refuseDishonestRecordCurrencyField`, object.zod.ts) checks + // what it names, because only the object holds the sibling field map. + currencyField: z.string().optional().describe('`record` mode only, and required there: the name of the field on the same object that holds each record\'s currency — a `text` field declaring `valueDomain: \'iso_4217_currency\'` (conventionally `currency_code`). Refused under `dynamic` and `fixed`.'), + // #19992 — no `.overwrite()` here any more. The #7918 ISO 4217 + // contradiction check and the #11423 default-materializing `.overwrite()` + // both existed only for the removed `precision` key (see + // CURRENCY_CONFIG_DECIMAL_PLACES_GUIDANCE above), so parse output is + // exactly `{ currencyMode, defaultCurrency }` plus the `currencyField` a + // `record`-mode author wrote. +}).superRefine(refuseCurrencyFieldOutsideRecordMode)); /** * Currency Value Schema diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index ccb40733e21..5ed722c6bc9 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -1812,6 +1812,86 @@ function refuseNonPictureImageField( }); } +/** + * [ADR-0144 D2] A `record`-mode amount's `currencyConfig.currencyField` names + * the field THIS object declares that holds each record's currency: a `text` + * field carrying `valueDomain: 'iso_4217_currency'`. The amount stays a bare + * number (ADR-0104 D1); the code field is the only place its currency lives, + * so a pointer into nothing, into a non-text field or into an unconstrained + * text field would parse and give the amount a currency no write ever checks. + * + * `CurrencyConfigSchema` sees one config at a time and refuses the half it + * can see (a `record` config with no pointer, a pointer under another mode); + * what the pointer names is judged here, beside + * {@link refuseNonPictureImageField}, for the same reason: only the object + * holds the field map. Judged for every field whose config is `record`, so a + * `formula` declaring `returnType: 'currency'` meets it in the same shape. + * + * Three refusals, one located issue per offending field at + * `['fields', , 'currencyConfig', 'currencyField']`, each carrying the + * prescription and the object's candidate fields: + * + * - the name is not a declared field (or is the amount itself); + * - the field is not `text`; + * - the field is `text` without `valueDomain: 'iso_4217_currency'`. + * + * Judged against the AUTHORED field map. Not judged on + * `ObjectExtensionSchema`: an extension's fields merge into an object it does + * not hold, so its pointer may name a field only the owner declares. + */ +function refuseDishonestRecordCurrencyField( + ownName: unknown, + fields: unknown, + ctx: z.RefinementCtx, +): void { + if (fields === null || typeof fields !== 'object') return; + const objectName = typeof ownName === 'string' ? ownName : ''; + const map = fields as Record; + const isCodeField = (f: (typeof map)[string]): boolean => + f?.type === 'text' && f.valueDomain === 'iso_4217_currency'; + for (const [name, field] of Object.entries(map)) { + const config = field?.currencyConfig as { currencyMode?: unknown; currencyField?: unknown } | undefined; + if (config?.currencyMode !== 'record' || typeof config.currencyField !== 'string') continue; + const target = config.currencyField; + const candidates = Object.keys(map).filter((k) => k !== name && isCodeField(map[k]!)); + const prescription = + '`currencyField` must name a `text` field of this object declaring `valueDomain: \'iso_4217_currency\'` — ' + + (candidates.length > 0 + ? `candidates: ${candidates.map((c) => `\`${c}\``).join(', ')}.` + : 'the object has none; declare one, conventionally `currency_code: Field.text({ valueDomain: \'iso_4217_currency\' })`.'); + const path = ['fields', name, 'currencyConfig', 'currencyField']; + const declared = target !== name && Object.prototype.hasOwnProperty.call(map, target); + if (!declared) { + ctx.addIssue({ + code: 'custom', + path, + message: `\`${name}.currencyConfig.currencyField\` names \`${target}\`, but object \`${objectName}\` declares no ` + + `other field \`${target}\`. ${prescription}`, + }); + continue; + } + const type = map[target]?.type; + if (type !== 'text') { + ctx.addIssue({ + code: 'custom', + path, + message: `\`${name}.currencyConfig.currencyField\` names \`${target}\`, a \`${String(type)}\` field on object ` + + `\`${objectName}\`; a record currency is a \`text\` ISO 4217 code. ${prescription}`, + }); + continue; + } + if (!isCodeField(map[target]!)) { + ctx.addIssue({ + code: 'custom', + path, + message: `\`${name}.currencyConfig.currencyField\` names \`${target}\`, a \`text\` field on object \`${objectName}\` ` + + `that does not declare \`valueDomain: 'iso_4217_currency'\`, so a write could store any string as the ` + + `currency. Add \`valueDomain: 'iso_4217_currency'\` to \`${target}\`, or point at another field. ${prescription}`, + }); + } + } +} + /** * [objectui#12489 ruling 6203737086, letter A] A field's * `referenceDeletedWhen: { field, value }` names a SIBLING `select` field on @@ -3394,6 +3474,9 @@ const ObjectSchemaBase = strictObject( // object — the same door, for the same reason: only the object holds the // field map the pointer resolves against. refuseNonPictureImageField(object.name, object.imageField, object.fields, ctx); + // [ADR-0144 D2] A `record`-mode amount's `currencyField` names a declared + // ISO 4217 `text` sibling — the same door: only the object holds the map. + refuseDishonestRecordCurrencyField(object.name, object.fields, ctx); // [objectui#12489 ruling 6203737086, letter A] A `referenceDeletedWhen` // predicate names a declared single-value `select` sibling and one of its // options — the same door: only the object holds the sibling map. diff --git a/skills/objectstack-data/rules/field-types.md b/skills/objectstack-data/rules/field-types.md index 4f929d6c349..70ec654f415 100644 --- a/skills/objectstack-data/rules/field-types.md +++ b/skills/objectstack-data/rules/field-types.md @@ -186,6 +186,14 @@ D1), never `{ value, currency }`. The displayed symbol: under `'fixed'`, `localization.currency` setting, not `defaultCurrency`; with neither, a plain grouped number (never a hardcoded `$`). A measure's own `currency` wins. +**Per-record currency** (`currencyMode: 'record'`): `currencyField` names a +`text` field on the same object with `valueDomain: 'iso_4217_currency'` that +holds each record's code; the amount stays a bare number. Name it +`currency_code`; an amount `price`'s home-currency copy and its rate go in +`price_home` and `price_rate` (`AMOUNT_home`, `AMOUNT_rate`) — a convention, +not a contract. A pointer at a missing, non-`text` or domain-less field is +refused at parse. + ### Select with Default ```typescript From 2537aff8fba17066889c6e99fb5c663e291b724c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 13:14:10 +0000 Subject: [PATCH 02/26] chore(spec): regenerate artifacts and declare the record-mode refinement in the dropped-refinements ledger Claude-Session: https://claude.ai/code/session_01Utp6CbfVSc1hXtxLUPHZ2f Co-authored-by: Claude --- .changeset/27184-currency-record-mode.md | 23 +++++++++++++++++++ content/docs/references/data/field.mdx | 12 ++++++---- content/docs/references/data/object.mdx | 4 ++-- content/docs/references/system/migration.mdx | 4 ++-- packages/spec/authorable-surface/data.json | 1 + .../spec/dropped-refinements.baseline.json | 17 ++++++++++++++ .../data/currency-config-record-mode.test.ts | 4 ++-- 7 files changed, 54 insertions(+), 11 deletions(-) create mode 100644 .changeset/27184-currency-record-mode.md diff --git a/.changeset/27184-currency-record-mode.md b/.changeset/27184-currency-record-mode.md new file mode 100644 index 00000000000..43e3071d6ea --- /dev/null +++ b/.changeset/27184-currency-record-mode.md @@ -0,0 +1,23 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec): `currencyMode: 'record'` + `currencyField` — a record-mode amount points at a sibling ISO 4217 code field (ADR-0144 D2) + +Clause-②: yes (widening) +Metadata-protocol: added + +A currency amount (or a `formula` with `returnType: 'currency'`) can now carry a currency per record. `currencyConfig.currencyMode` gains a third value, `record`, and `currencyConfig.currencyField` names the field on the same object that holds each record's ISO 4217 code. The amount itself stays a bare number (ADR-0104 D1). + +```ts +fields: { + currency_code: Field.text({ label: 'Currency', valueDomain: 'iso_4217_currency' }), + amount: Field.currency({ label: 'Amount', currencyConfig: { currencyMode: 'record', currencyField: 'currency_code' } }), +} +``` + +- `currencyField` is required under `record` and refused under `dynamic` and `fixed`. +- The object refuses a `currencyField` that names no other declared field, a field that is not `text`, or a `text` field without `valueDomain: 'iso_4217_currency'`. Each refusal is located at `fields.FIELD.currencyConfig.currencyField` and lists the object's candidate code fields. +- `dynamic` and `fixed` configs parse exactly as before; an app that never writes `record` sees no change. + +⛔ Not released alone: this ships in the same release as the record-mode aggregate refusals and the per-row display. diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index c319e88e320..8a148e37557 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -28,8 +28,9 @@ const result = CurrencyConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **currencyMode** | `Enum<'dynamic' \| 'fixed'>` | optional (default: `"dynamic"`) | Currency mode. `fixed`: the field has one currency, `defaultCurrency`. `dynamic` (the default): the field has no currency of its own — amounts display in the tenant default currency (the `localization.currency` setting; a plain number when none is set) and `defaultCurrency` is not read. Neither mode is a per-record choice: the value is a bare number either way. | -| **defaultCurrency** | `string` | optional (default: `"CNY"`) | The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency. Not read under `dynamic` (the default), where amounts display in the tenant default currency. | +| **currencyMode** | `Enum<'dynamic' \| 'fixed' \| 'record'>` | optional (default: `"dynamic"`) | Currency mode. `fixed`: the field has one currency, `defaultCurrency`. `dynamic` (the default): the field has no currency of its own — amounts display in the tenant default currency (the `localization.currency` setting; a plain number when none is set) and `defaultCurrency` is not read. `record`: each record has its own currency, the ISO 4217 code stored in the sibling field `currencyField` names; `defaultCurrency` is not read. The value is a bare number in every mode (ADR-0104 D1). | +| **defaultCurrency** | `string` | optional (default: `"CNY"`) | The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency. Not read under `dynamic` (the default), where amounts display in the tenant default currency, nor under `record`, where each record names its own. | +| **currencyField** | `string` | optional | `record` mode only, and required there: the name of the field on the same object that holds each record's currency — a `text` field declaring `valueDomain: 'iso_4217_currency'` (conventionally `currency_code`). Refused under `dynamic` and `fixed`. | --- @@ -101,7 +102,7 @@ const result = CurrencyConfigSchema.parse(data); | **summaryOperations** | `{ object: string; field: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | | **step** | `number` | optional | Step increment for slider (default: 1) | -| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | | **dimensions** | `integer` | optional | Vector dimensionality (e.g., 1536 for OpenAI embeddings) | | **trackHistory** | `boolean` | optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. | | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") | @@ -248,8 +249,9 @@ const result = CurrencyConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **currencyMode** | `Enum<'dynamic' \| 'fixed'>` | optional (default: `"dynamic"`) | Currency mode. `fixed`: the field has one currency, `defaultCurrency`. `dynamic` (the default): the field has no currency of its own — amounts display in the tenant default currency (the `localization.currency` setting; a plain number when none is set) and `defaultCurrency` is not read. Neither mode is a per-record choice: the value is a bare number either way. | -| **defaultCurrency** | `string` | optional (default: `"CNY"`) | The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency. Not read under `dynamic` (the default), where amounts display in the tenant default currency. | +| **currencyMode** | `Enum<'dynamic' \| 'fixed' \| 'record'>` | optional (default: `"dynamic"`) | Currency mode. `fixed`: the field has one currency, `defaultCurrency`. `dynamic` (the default): the field has no currency of its own — amounts display in the tenant default currency (the `localization.currency` setting; a plain number when none is set) and `defaultCurrency` is not read. `record`: each record has its own currency, the ISO 4217 code stored in the sibling field `currencyField` names; `defaultCurrency` is not read. The value is a bare number in every mode (ADR-0104 D1). | +| **defaultCurrency** | `string` | optional (default: `"CNY"`) | The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency. Not read under `dynamic` (the default), where amounts display in the tenant default currency, nor under `record`, where each record names its own. | +| **currencyField** | `string` | optional | `record` mode only, and required there: the name of the field on the same object that holds each record's currency — a `text` field declaring `valueDomain: 'iso_4217_currency'` (conventionally `currency_code`). Refused under `dynamic` and `fixed`. | ### Nested Shape: `Field.conditionalFormatting[number]` diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index e0c72980991..592b298cd27 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -267,7 +267,7 @@ const result = ApiMethod.parse(data); | **summaryOperations** | `{ object: string; field: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | | **step** | `number` | optional | Step increment for slider (default: 1) | -| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | | **dimensions** | `integer` | optional | Vector dimensionality (e.g., 1536 for OpenAI embeddings) | | **trackHistory** | `boolean` | optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. | | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") | @@ -629,7 +629,7 @@ const result = ApiMethod.parse(data); | **summaryOperations** | `{ object: string; field: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | | **step** | `number` | optional | Step increment for slider (default: 1) | -| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | | **dimensions** | `integer` | optional | Vector dimensionality (e.g., 1536 for OpenAI embeddings) | | **trackHistory** | `boolean` | optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. | | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 148179f0d46..33757a7c57c 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -102,7 +102,7 @@ Add a new field to an existing object | **summaryOperations** | `{ object: string; field: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | | **step** | `number` | optional | Step increment for slider (default: 1) | -| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | | **dimensions** | `integer` | optional | Vector dimensionality (e.g., 1536 for OpenAI embeddings) | | **trackHistory** | `boolean` | optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. | | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") | @@ -532,7 +532,7 @@ Add a new field to an existing object | **summaryOperations** | `{ object: string; field: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | | **step** | `number` | optional | Step increment for slider (default: 1) | -| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | | **dimensions** | `integer` | optional | Vector dimensionality (e.g., 1536 for OpenAI embeddings) | | **trackHistory** | `boolean` | optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. | | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") | diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index c6278e47f81..2f574c25018 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -101,6 +101,7 @@ "data/Cube:sql", "data/Cube:title", "data/CubeJoin:name", + "data/CurrencyConfig:currencyField", "data/CurrencyConfig:currencyMode", "data/CurrencyConfig:defaultCurrency", "data/CurrencyValue:currency", diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index e0da8fefe65..008e051060a 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -105,6 +105,7 @@ "manifest.objects.element.defaultRowScope", "manifest.objects.element.fieldGroups", "manifest.objects.element.fields.out.valueType", + "manifest.objects.element.fields.out.valueType.currencyConfig", "manifest.objects.element.fields.out.valueType.inlineColumns.element", "manifest.objects.element.lifecycle", "manifest.objects.element.membershipKeys", @@ -234,6 +235,7 @@ "data.options[1].manifest.objects.element.defaultRowScope", "data.options[1].manifest.objects.element.fieldGroups", "data.options[1].manifest.objects.element.fields.out.valueType", + "data.options[1].manifest.objects.element.fields.out.valueType.currencyConfig", "data.options[1].manifest.objects.element.fields.out.valueType.inlineColumns.element", "data.options[1].manifest.objects.element.lifecycle", "data.options[1].manifest.objects.element.membershipKeys", @@ -362,6 +364,7 @@ "options[1].manifest.objects.element.defaultRowScope", "options[1].manifest.objects.element.fieldGroups", "options[1].manifest.objects.element.fields.out.valueType", + "options[1].manifest.objects.element.fields.out.valueType.currencyConfig", "options[1].manifest.objects.element.fields.out.valueType.inlineColumns.element", "options[1].manifest.objects.element.lifecycle", "options[1].manifest.objects.element.membershipKeys", @@ -433,6 +436,7 @@ "data.packages.element.options[1].manifest.objects.element.defaultRowScope", "data.packages.element.options[1].manifest.objects.element.fieldGroups", "data.packages.element.options[1].manifest.objects.element.fields.out.valueType", + "data.packages.element.options[1].manifest.objects.element.fields.out.valueType.currencyConfig", "data.packages.element.options[1].manifest.objects.element.fields.out.valueType.inlineColumns.element", "data.packages.element.options[1].manifest.objects.element.lifecycle", "data.packages.element.options[1].manifest.objects.element.membershipKeys", @@ -485,6 +489,7 @@ "data.defaultRowScope", "data.fieldGroups", "data.fields.out.valueType", + "data.fields.out.valueType.currencyConfig", "data.fields.out.valueType.inlineColumns.element", "data.fields.out.valueType.relatedListFilter.lazy", "data.lifecycle", @@ -756,6 +761,11 @@ "measures.valueType" ] }, + "data/CurrencyConfig": { + "sites": [ + "" + ] + }, "data/DataEngineAggregateOptions": { "sites": [ "filter.options[1].lazy" @@ -875,6 +885,7 @@ "data/Field": { "sites": [ "", + "currencyConfig", "inlineColumns.element", "relatedListFilter.lazy" ] @@ -977,6 +988,7 @@ "defaultRowScope", "fieldGroups", "fields.out.valueType", + "fields.out.valueType.currencyConfig", "fields.out.valueType.inlineColumns.element", "fields.out.valueType.relatedListFilter.lazy", "lifecycle", @@ -996,6 +1008,7 @@ "sites": [ "", "fields.valueType", + "fields.valueType.currencyConfig", "fields.valueType.inlineColumns.element", "fields.valueType.relatedListFilter.lazy" ] @@ -1276,6 +1289,7 @@ "system/AddFieldOperation": { "sites": [ "field", + "field.currencyConfig", "field.inlineColumns.element", "field.relatedListFilter.lazy" ] @@ -1298,6 +1312,7 @@ "system/ChangeSet": { "sites": [ "operations.element.options[0].field", + "operations.element.options[0].field.currencyConfig", "operations.element.options[0].field.inlineColumns.element", "operations.element.options[0].field.relatedListFilter.lazy", "operations.element.options[3].object", @@ -1331,6 +1346,7 @@ "object.defaultRowScope", "object.fieldGroups", "object.fields.out.valueType", + "object.fields.out.valueType.currencyConfig", "object.fields.out.valueType.inlineColumns.element", "object.fields.out.valueType.relatedListFilter.lazy", "object.lifecycle", @@ -1370,6 +1386,7 @@ "system/MigrationOperation": { "sites": [ "options[0].field", + "options[0].field.currencyConfig", "options[0].field.inlineColumns.element", "options[0].field.relatedListFilter.lazy", "options[3].object", diff --git a/packages/spec/src/data/currency-config-record-mode.test.ts b/packages/spec/src/data/currency-config-record-mode.test.ts index cea2f5453e8..92e6613b8e5 100644 --- a/packages/spec/src/data/currency-config-record-mode.test.ts +++ b/packages/spec/src/data/currency-config-record-mode.test.ts @@ -115,12 +115,12 @@ describe('the object judges what `currencyField` names', () => { it('refuses a pointer at a field that is not `text`', () => { const issues = objectIssues(invoice({ - currency_code: { type: 'select', label: 'Currency', options: [{ label: 'USD', value: 'USD' }] }, + currency_code: { type: 'number', label: 'Currency' }, amount: { type: 'currency', label: 'Amount', currencyConfig: RECORD }, })); expect(issues).toHaveLength(1); expect(issues[0]!.path).toEqual(['fields', 'amount', 'currencyConfig', 'currencyField']); - expect(issues[0]!.message).toMatch(/^`amount\.currencyConfig\.currencyField` names `currency_code`, a `select` field/); + expect(issues[0]!.message).toMatch(/^`amount\.currencyConfig\.currencyField` names `currency_code`, a `number` field/); }); it('refuses a pointer at a `text` field without the ISO 4217 value domain', () => { From 1d9a5331614a575393b6bb63b9855d934f5ee8f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 13:20:46 +0000 Subject: [PATCH 03/26] chore(spec): dropped-refinements ledger header totals for the record-mode sites Claude-Session: https://claude.ai/code/session_01Utp6CbfVSc1hXtxLUPHZ2f Co-authored-by: Claude --- packages/spec/dropped-refinements.baseline.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 008e051060a..8770d6cf062 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -2,8 +2,8 @@ "description": "Shrink-only ledger of every PUBLISHED JSON Schema that is STILL WIDER than the Zod type it was generated from, because a rule written as `.refine()` reaches the runtime and not the file (#18670). `z.toJSONSchema()` has no arm for a `custom` check: a plain record, the same record with a `.refine()`, and the same record with an ABORTING `.refine()` all project byte-identically (measured on zod 4.4.3, the version packages/spec resolves). So a document one of these files ACCEPTS can still be refused at parse time, and an author -- or an AI -- validating against packages/spec/json-schema/** finds out a release later. Each `sites` path is a position under that schema at which a refinement is dropped; the same paths are written onto the artifact itself as `x-dropped-refinements`. Item 2 closed the first patterns: a refinement DECLARED through the closed list in src/shared/refinement-projection.ts is emitted into the published file, reads `projected` rather than `dropped`, and its row LEAVES this ledger in the same PR -- which is why the ledger shrinks and never grows on a repair. Every refinement outside that closed list stays here, and adding an arm to the list is a public-contract decision, not a refactor. Hand-edited on purpose and with no `gen:` script: a generator would let a new gap be admitted by running a command instead of by a decision, which is the silence this ledger exists to end. Adding, removing or moving a site fails packages/spec/scripts/build-schemas.ts until the line moves with it, and the failure prints the corrected entry in full. ⛔ Do not delete or weaken a refinement to shorten this file -- the runtime rule is correct; it is the projection that is silent, and the remedy is to teach the closed list a NAMED pattern, never to drop the rule.", "measured": { "zod": "4.4.3", - "publishedSchemasWithDroppedRefinements": 251, - "droppedRefinementSites": 887, + "publishedSchemasWithDroppedRefinements": 252, + "droppedRefinementSites": 900, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, From d1316ddb9c1097ccd97ce5608a7a73241b2432ff Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 13:21:53 +0000 Subject: [PATCH 04/26] =?UTF-8?q?docs(skills):=20field-types=20=E2=80=94?= =?UTF-8?q?=20the=20record=20currency=20mode=20and=20its=20sibling-field?= =?UTF-8?q?=20naming=20convention,=20paid=20by=20tightening=20the=20curren?= =?UTF-8?q?cy=20block?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01Utp6CbfVSc1hXtxLUPHZ2f Co-authored-by: Claude --- skills/objectstack-data/rules/field-types.md | 26 ++++++-------------- 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/skills/objectstack-data/rules/field-types.md b/skills/objectstack-data/rules/field-types.md index 70ec654f415..bbdd811bb92 100644 --- a/skills/objectstack-data/rules/field-types.md +++ b/skills/objectstack-data/rules/field-types.md @@ -172,27 +172,17 @@ Stored as JSON on the parent row — no separate table / FK: ```typescript { type: 'currency', - currencyConfig: { - currencyMode: 'fixed', // 'fixed' = the column is pinned to - // defaultCurrency; 'dynamic' = tenant default - defaultCurrency: 'USD', // ISO 4217 - }, + currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' }, // ISO 4217 } ``` -**Currency resolution.** The value is a bare number in both modes (ADR-0104 -D1), never `{ value, currency }`. The displayed symbol: under `'fixed'`, -`currencyConfig.defaultCurrency`; under `'dynamic'` (default), the tenant -`localization.currency` setting, not `defaultCurrency`; with neither, a plain -grouped number (never a hardcoded `$`). A measure's own `currency` wins. - -**Per-record currency** (`currencyMode: 'record'`): `currencyField` names a -`text` field on the same object with `valueDomain: 'iso_4217_currency'` that -holds each record's code; the amount stays a bare number. Name it -`currency_code`; an amount `price`'s home-currency copy and its rate go in -`price_home` and `price_rate` (`AMOUNT_home`, `AMOUNT_rate`) — a convention, -not a contract. A pointer at a missing, non-`text` or domain-less field is -refused at parse. +**Currency resolution.** The value is a bare number in every mode (ADR-0104 +D1), never `{ value, currency }`. Shown: `'fixed'`, `defaultCurrency`; +`'dynamic'` (default), the tenant `localization.currency`, not +`defaultCurrency`, else a plain number (never `$`); `'record'`, each record's +code in the `text` field `currencyField` names, which declares `valueDomain: +'iso_4217_currency'` (names by convention, not contract: `currency_code`, +`AMOUNT_home`, `AMOUNT_rate`). A measure's own `currency` wins. ### Select with Default From 5232c8b14bd74f8ab8f496ef06cdf0e3ee189a78 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 13:50:12 +0000 Subject: [PATCH 05/26] chore(platform-objects): metadata-form translation bundles for the record-mode currencyField Claude-Session: https://claude.ai/code/session_01Utp6CbfVSc1hXtxLUPHZ2f Co-authored-by: Claude --- .../src/apps/translations/en.metadata-forms.generated.ts | 6 +++++- .../src/apps/translations/es-ES.metadata-forms.generated.ts | 4 ++++ .../src/apps/translations/ja-JP.metadata-forms.generated.ts | 4 ++++ .../src/apps/translations/zh-CN.metadata-forms.generated.ts | 4 ++++ 4 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index 1bf6bb40b84..5b449157cde 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -614,7 +614,11 @@ export const enMetadataForms: NonNullable = { }, "currencyConfig.currencyMode": { label: "Currency Mode", - helpText: "dynamic (the default): the field has no currency of its own, and amounts display in the tenant default currency (the localization.currency setting). fixed: the field has one currency, defaultCurrency." + helpText: "dynamic (the default): the field has no currency of its own, and amounts display in the tenant default currency (the localization.currency setting). fixed: the field has one currency, defaultCurrency. record: each record has its own currency, stored in the field currencyField names." + }, + "currencyConfig.currencyField": { + label: "Currency Field", + helpText: "Record mode only, and required there: the field on this object holding each record's ISO 4217 code — a text field with valueDomain iso_4217_currency, conventionally currency_code." }, "currencyConfig.defaultCurrency": { label: "Default Currency", diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 97f7a5ac903..c05a81b4425 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -616,6 +616,10 @@ export const esESMetadataForms: NonNullable = label: "Modo de moneda", helpText: "dynamic (predeterminado): el campo no tiene moneda propia y los importes se muestran en la moneda predeterminada del tenant (el ajuste localization.currency). fixed: el campo tiene una única moneda, defaultCurrency." }, + "currencyConfig.currencyField": { + label: "Campo de moneda", + helpText: "Solo en modo record, y obligatorio en él: el campo de este objeto que guarda el código ISO 4217 de cada registro — un campo de texto con valueDomain iso_4217_currency, por convención currency_code." + }, "currencyConfig.defaultCurrency": { label: "Moneda predeterminada", helpText: "La única moneda de un campo en modo fixed, como código ISO 4217 de tres caracteres (p. ej., USD, EUR). Por defecto, CNY. No se lee en modo dynamic." diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 0c1da605a44..c3d0815b7d9 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -616,6 +616,10 @@ export const jaJPMetadataForms: NonNullable = label: "通貨モード", helpText: "dynamic(既定):フィールド自体は通貨を持たず、金額はテナントの既定通貨(localization.currency 設定)で表示されます。fixed:フィールドは 1 つの通貨 defaultCurrency を持ちます。" }, + "currencyConfig.currencyField": { + label: "通貨フィールド", + helpText: "record モード専用で、同モードでは必須:各レコードの ISO 4217 通貨コードを保持する、このオブジェクト上のフィールド。valueDomain iso_4217_currency を宣言したテキストフィールドで、慣例名は currency_code。" + }, "currencyConfig.defaultCurrency": { label: "既定の通貨", helpText: "fixed モードのフィールドの通貨。3 文字の ISO 4217 コード(例:USD、EUR)で指定します。既定は CNY です。dynamic モードでは参照されません。" diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index eae72e313c1..417bc714a17 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -616,6 +616,10 @@ export const zhCNMetadataForms: NonNullable = label: "货币模式", helpText: "dynamic(默认):字段本身没有货币,金额以租户默认货币显示(localization.currency 设置)。fixed:字段只有一种货币,即 defaultCurrency。" }, + "currencyConfig.currencyField": { + label: "币种字段", + helpText: "仅用于 record 模式,且在该模式下必填:本对象上保存每条记录 ISO 4217 币种代码的字段——一个声明了 valueDomain iso_4217_currency 的文本字段,惯例命名为 currency_code。" + }, "currencyConfig.defaultCurrency": { label: "默认货币", helpText: "fixed 模式字段的唯一货币,使用三个字符的 ISO 4217 代码(例如 USD、EUR)。默认为 CNY。dynamic 模式下不读取。" From 2feede75db57661c45876bc1d4b3d26feee8f9f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 14:15:12 +0000 Subject: [PATCH 06/26] =?UTF-8?q?wip(ui):=20record-mode=20currency=20?= =?UTF-8?q?=E2=80=94=20resolver=20reads=20the=20record's=20code=20field;?= =?UTF-8?q?=20cells,=20currency=20widget=20and=20create-form=20seeding=20f?= =?UTF-8?q?ollow=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ui/packages/fields/src/currency.ts | 2 +- ui/packages/fields/src/index.tsx | 16 +++-- .../fields/src/widgets/CurrencyField.tsx | 7 +- .../fields/src/widgets/FormulaField.tsx | 2 +- ui/packages/i18n/src/currency.ts | 66 ++++++++++++++++--- ui/packages/i18n/src/index.ts | 2 +- ui/packages/plugin-form/src/DrawerForm.tsx | 7 +- ui/packages/plugin-form/src/ModalForm.tsx | 7 +- ui/packages/plugin-form/src/ObjectForm.tsx | 9 ++- ui/packages/plugin-form/src/SplitForm.tsx | 6 +- ui/packages/plugin-form/src/TabbedForm.tsx | 6 +- ui/packages/plugin-form/src/WizardForm.tsx | 11 ++-- ui/packages/plugin-form/src/schemaDefaults.ts | 32 +++++++++ 13 files changed, 139 insertions(+), 34 deletions(-) diff --git a/ui/packages/fields/src/currency.ts b/ui/packages/fields/src/currency.ts index 6238aec7750..6c6680cb0b3 100644 --- a/ui/packages/fields/src/currency.ts +++ b/ui/packages/fields/src/currency.ts @@ -11,7 +11,7 @@ * it so the long-standing `@object-ui/fields` import path keeps working for * every field/cell renderer that already depends on it. */ -export { resolveFieldCurrency } from '@object-ui/i18n'; +export { resolveFieldCurrency, recordCurrencyField } from '@object-ui/i18n'; /** * Fallback width for a currency whose digit count cannot be resolved: the diff --git a/ui/packages/fields/src/index.tsx b/ui/packages/fields/src/index.tsx index 04cd12d255d..e0153626855 100644 --- a/ui/packages/fields/src/index.tsx +++ b/ui/packages/fields/src/index.tsx @@ -821,7 +821,7 @@ function RatingCellRenderer({ value, field }: CellRendererProps): React.ReactEle /** * Currency field cell renderer */ -export function CurrencyCellRenderer({ value, field }: CellRendererProps): React.ReactElement { +export function CurrencyCellRenderer({ value, field, dependentValues }: CellRendererProps): React.ReactElement { // Hooks before the empty-value early return — a value flipping between // null and set must not change the hook count between renders. const { currency: tenantCurrency } = useLocalization(); @@ -834,8 +834,11 @@ export function CurrencyCellRenderer({ value, field }: CellRendererProps): React // Resolve the display currency via the shared precedence: field `currency` → // `currencyConfig.defaultCurrency` → the tenant default (ADR-0053). When none // is known, render a plain number — never a guessed symbol (silently assuming - // USD mis-displays non-USD orgs, e.g. RMB amounts shown as $). - const currency = resolveFieldCurrency(field as any, tenantCurrency); + // USD mis-displays non-USD orgs, e.g. RMB amounts shown as $). A + // `record`-mode amount (ADR-0144 D2) reads its row's code field instead, so + // three invoices in USD / EUR / JPY show three symbols; a row with no code + // shows a plain number. + const currency = resolveFieldCurrency(field as any, tenantCurrency, dependentValues); const num = Number(safe); // `CurrencyField`'s read-only branch makes this same call, so a table cell // 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 * not import each other; `__tests__/formulaFaces.returnType-11748.test.tsx` * compares the two faces' text for every declared type. */ -export function FormulaCellRenderer({ value, field }: CellRendererProps): React.ReactElement { +export function FormulaCellRenderer({ value, field, dependentValues }: CellRendererProps): React.ReactElement { // Hooks before the empty-value early return: the hook count must not change // when a value flips between null and set. The date face reads the display // locale; the boolean face reads the locale's word. @@ -2910,9 +2913,10 @@ export function FormulaCellRenderer({ value, field }: CellRendererProps): React. // renderer reads `currencyConfig` through `resolveFieldCurrency` exactly as // it reads a currency field's, so a `fixed` config keeps its own code and an // absent one is `dynamic`, the tenant default currency. ⛔ No second money - // formatter here. + // formatter here. The row rides along for a `record`-mode formula, whose + // currency is its row's code field (ADR-0144 D2). if (valueType === 'currency') { - return ; + return ; } // A declared BOOLEAN reads the locale's Yes / No word (objectui#11748), the diff --git a/ui/packages/fields/src/widgets/CurrencyField.tsx b/ui/packages/fields/src/widgets/CurrencyField.tsx index a2d3258e3a7..93d429a066c 100644 --- a/ui/packages/fields/src/widgets/CurrencyField.tsx +++ b/ui/packages/fields/src/widgets/CurrencyField.tsx @@ -7,12 +7,15 @@ import { resolveFieldCurrency, currencySymbol } from '../currency.js'; import { useBadInputRefusal, BadInputMessage, BAD_INPUT_BORDER } from './numberBadInput.js'; import { formatCurrency, currencyDisplayWidth } from './number-format.js'; -export function CurrencyField({ value, onChange, field, readonly, error, className, ...props }: FieldWidgetComponentProps) { +export function CurrencyField({ value, onChange, field, readonly, error, className, dependentValues, ...props }: FieldWidgetComponentProps) { const currencyField = field as any; // Shared precedence: field currency → currencyConfig → tenant default (ADR-0053). + // A `record`-mode amount (ADR-0144 D2) reads the code its record holds — + // `dependentValues` is the live form record, so picking a code in the + // sibling select moves this box's symbol and decimals with it. const { currency: tenantCurrency } = useLocalization(); const locale = useDisplayLocale(); - const currency: string | undefined = resolveFieldCurrency(currencyField, tenantCurrency); + const currency: string | undefined = resolveFieldCurrency(currencyField, tenantCurrency, dependentValues); // A currency's decimal places are the CURRENCY's, not a field setting — the // maintainer ruling recorded on objectstack-ai/objectstack#19910 (batch #218 // item 2), executed here by objectui#10276. The width is the ISO 4217 diff --git a/ui/packages/fields/src/widgets/FormulaField.tsx b/ui/packages/fields/src/widgets/FormulaField.tsx index 79c6a8468e7..2be3e73af4f 100644 --- a/ui/packages/fields/src/widgets/FormulaField.tsx +++ b/ui/packages/fields/src/widgets/FormulaField.tsx @@ -116,7 +116,7 @@ export function FormulaField({ value, field, ...props }: FieldWidgetComponentPro } const amount = Number(safe); if (isNaN(amount)) return {String(safe)}; - return ; + return ; } if (valueType === 'boolean') { diff --git a/ui/packages/i18n/src/currency.ts b/ui/packages/i18n/src/currency.ts index f192e57d0f8..fcefef92957 100644 --- a/ui/packages/i18n/src/currency.ts +++ b/ui/packages/i18n/src/currency.ts @@ -6,10 +6,44 @@ * LICENSE file in the root directory of this source tree. */ +/** + * The currency config a field declares, in the three modes of + * `@objectstack/spec`'s `CurrencyConfigSchema` (ADR-0144 D2 adds `record`). + */ +export interface FieldCurrencyConfig { + currencyMode?: 'dynamic' | 'fixed' | 'record'; + defaultCurrency?: string; + /** `record` mode only: the sibling field holding each record's ISO 4217 code. */ + currencyField?: string; +} + +/** + * The sibling code field a `record`-mode amount points at (ADR-0144 D2), or + * `undefined` for a `dynamic` / `fixed` field and for anything that is not a + * currency config at all. The one reading every surface that needs the + * pointer itself — the form's code select, the grid footer's grouping test, + * the filter builder — shares, so none of them re-derives the mode test. + */ +export function recordCurrencyField( + field: { currencyConfig?: FieldCurrencyConfig } | null | undefined, +): string | undefined { + const config = field?.currencyConfig; + if (config?.currencyMode !== 'record') return undefined; + return typeof config.currencyField === 'string' && config.currencyField !== '' + ? config.currencyField + : undefined; +} + /** * Resolve the display currency code for a currency-typed field, in precedence * order: * + * 0. a `record`-mode field (ADR-0144 D2) reads ONLY the record in scope: the + * value of the sibling field `currencyConfig.currencyField` names. No + * record, or a record whose code is blank, resolves to `undefined` — never + * to steps 1–4. The record's code field is the only place that amount's + * currency lives, so the tenant default would name a currency the amount + * is not in: an amount of 100 EUR shown as `¥100` is worse than `100`; * 1. the field's explicit `currency` — a per-node display code. The spec * declares it on presentation surfaces (the metric widget's `currency`, a * dataset measure's `currency`) and looser grid/column configs carry it; @@ -27,34 +61,46 @@ * * `currencyMode: 'dynamic'` — and a `currencyConfig` with no `currencyMode` at * all, which `@objectstack/spec` defaults to `'dynamic'` — skips step 2 - * (objectui#10422). The spec describes that mode as "user selectable", and its - * `FieldSchema` guidance says a field without a fixed currency "uses the tenant - * default at runtime". No value carries a user-selected code today (a currency - * value is a bare number), so a dynamic field reads the tenant default, never - * its `defaultCurrency`. That matters beyond authored dynamic configs: the - * spec parses an EMPTY `currencyConfig` to - * `{ currencyMode: 'dynamic', defaultCurrency: 'CNY' }`, which must not render - * as yuan. If a value ever carries its own selected code, that code is the one - * to win, ahead of the tenant default. + * (objectui#10422): a dynamic field reads the tenant default, never its + * `defaultCurrency`. That matters beyond authored dynamic configs: until + * @objectstack/spec dropped the `'CNY'` default (#27157), the spec parsed an + * EMPTY `currencyConfig` to `{ currencyMode: 'dynamic', defaultCurrency: 'CNY' }`, + * and rows stored or built by that parse still carry it until a rehydration + * strips it; it must not render as yuan. The spec now refuses a `fixed` + * config without a code at authoring and save, but a stored row saved that + * way before is kept as authored and still loads with no code; step 2 skips + * it and the tenant default shows, as it always did. A per-record currency is + * step 0's, and only a `record`-mode field has one. * * This is the single resolution every field / measure / cell renderer shares - * ending the per-renderer drift where some read only `currency`, others only * `defaultCurrency`, and others `currencyConfig`. It lives in `@object-ui/i18n` * (alongside `useLocalization`) because the tenant default is a localization * concern; `@object-ui/fields` re-exports it for backward compatibility. + * + * `record` is the row / form values the amount sits in. A caller that holds no + * record (a column-level header, a footer over many rows) passes none, and a + * `record`-mode field then resolves to no currency, which is the honest answer + * for a set of amounts in different currencies. */ export function resolveFieldCurrency( field: | { currency?: string; defaultCurrency?: string; - currencyConfig?: { currencyMode?: 'dynamic' | 'fixed'; defaultCurrency?: string }; + currencyConfig?: FieldCurrencyConfig; } | null | undefined, tenantDefault?: string, + record?: Readonly> | null, ): string | undefined { const config = field?.currencyConfig; + if (config?.currencyMode === 'record') { + const codeField = recordCurrencyField(field); + const code = codeField !== undefined ? record?.[codeField] : undefined; + return typeof code === 'string' && code !== '' ? code : undefined; + } return ( field?.currency || (config?.currencyMode === 'fixed' ? config.defaultCurrency : undefined) || diff --git a/ui/packages/i18n/src/index.ts b/ui/packages/i18n/src/index.ts index 6101cf79db5..86b199af164 100644 --- a/ui/packages/i18n/src/index.ts +++ b/ui/packages/i18n/src/index.ts @@ -154,7 +154,7 @@ export { export { pickLocalized, setLocalized, clearLocalized } from './pickLocalized.js'; export { LocalizationProvider, useLocalization, type LocalizationValue } from './LocalizationContext.js'; -export { resolveFieldCurrency } from './currency.js'; +export { resolveFieldCurrency, recordCurrencyField, type FieldCurrencyConfig } from './currency.js'; // The one number-display formatter (objectui#4033) and the locale it formats // in. Every field cell / widget / metric renderer goes through this pair — diff --git a/ui/packages/plugin-form/src/DrawerForm.tsx b/ui/packages/plugin-form/src/DrawerForm.tsx index ed6bd2ea98e..52e3e050d61 100644 --- a/ui/packages/plugin-form/src/DrawerForm.tsx +++ b/ui/packages/plugin-form/src/DrawerForm.tsx @@ -37,7 +37,7 @@ import { import { Loader2 } from 'lucide-react'; import { SchemaRenderer, useSafeFieldLabel, usePreviewMode } from '@object-ui/react'; -import { createSafeTranslation } from '@object-ui/i18n'; +import { createSafeTranslation, useLocalization } from '@object-ui/i18n'; import { MasterDetailForm } from './MasterDetailForm'; import { buildSectionFields as buildSectionFieldsShared } from './sectionFields'; import { buildFlatFields } from './flatFields'; @@ -253,6 +253,9 @@ const DrawerFormBody: React.FC = ({ const { fieldLabel, sectionLabel } = useSafeFieldLabel(); const perms = usePermissions(); const { userId: currentUserId } = perms; + // ADR-0144 D8 rule 4 — the organization currency a record-currency code + // field opens on in create mode. + const { currency: homeCurrency } = useLocalization(); const { t } = useDiscardTranslation(); // Upload-in-flight gate (objectui#10166): Save is refused, disabled and // EXPLAINED while a file/image widget below is still uploading. @@ -405,7 +408,7 @@ const DrawerFormBody: React.FC = ({ // Declared static defaults are this form's opening values (#4047) — // see `schemaDefaults` for the create-only boundary and for why // runtime defaults are left to the server. - setFormData(seedCreateValues(objectSchema, resolveInitialRecord(schema), { currentUserId })); + setFormData(seedCreateValues(objectSchema, resolveInitialRecord(schema), { currentUserId, homeCurrency })); // Not a read, so no earlier record read's failure describes the form. run.commit(); setLoading(false); diff --git a/ui/packages/plugin-form/src/ModalForm.tsx b/ui/packages/plugin-form/src/ModalForm.tsx index 0600ce512fc..f046c1ec9d2 100644 --- a/ui/packages/plugin-form/src/ModalForm.tsx +++ b/ui/packages/plugin-form/src/ModalForm.tsx @@ -38,7 +38,7 @@ import { import { Loader2 } from 'lucide-react'; import { MasterDetailForm } from './MasterDetailForm'; import { SchemaRenderer, useSafeFieldLabel, usePreviewMode } from '@object-ui/react'; -import { createSafeTranslation } from '@object-ui/i18n'; +import { createSafeTranslation, useLocalization } from '@object-ui/i18n'; import { buildSectionFields as buildSectionFieldsShared } from './sectionFields'; import { buildFlatFields } from './flatFields'; import { isRecordReadOutstanding } from './recordReadGate'; @@ -342,6 +342,9 @@ const ModalFormBody: React.FC = ({ const uploadGate = useUploadGate(); const previewMode = usePreviewMode(); const perms = usePermissions(); + // ADR-0144 D8 rule 4 — the organization currency a record-currency code + // field opens on in create mode. + const { currency: homeCurrency } = useLocalization(); const [objectSchema, setObjectSchema] = useState(null); const [formData, setFormData] = useState>({}); // The ONE field-gate step every layout draws through (`gateFormFields`, @@ -565,7 +568,7 @@ const ModalFormBody: React.FC = ({ // supplied initial values still win. See `schemaDefaults` for why // runtime defaults (`NOW()`, `current_user`, CEL envelopes) are left // to the server and why option-level `default` is not read here. - setFormData(seedCreateValues(objectSchema, resolveInitialRecord(schema), { currentUserId: perms.userId })); + setFormData(seedCreateValues(objectSchema, resolveInitialRecord(schema), { currentUserId: perms.userId, homeCurrency })); // Not a read, so no earlier record read's failure describes the form. run.commit(); setLoading(false); diff --git a/ui/packages/plugin-form/src/ObjectForm.tsx b/ui/packages/plugin-form/src/ObjectForm.tsx index 275fce164d7..c9c5bc99e6e 100644 --- a/ui/packages/plugin-form/src/ObjectForm.tsx +++ b/ui/packages/plugin-form/src/ObjectForm.tsx @@ -16,7 +16,7 @@ import React, { useEffect, useState, useCallback, useMemo } from 'react'; import type { ObjectFormSchema, FormField, FormSchema, DataSource } from '@object-ui/types'; import { SchemaRenderer, useSafeFieldLabel } from '@object-ui/react'; -import { pickLocalized, useObjectTranslation } from '@object-ui/i18n'; +import { pickLocalized, useObjectTranslation, useLocalization } from '@object-ui/i18n'; import { useRecordInvalidation } from './recordInvalidation'; import { mapFieldTypeToFormType, buildValidationRules, formatFileSize, fieldHelpText } from '@object-ui/fields'; import { useIsMobile, toast } from '@object-ui/components'; @@ -1270,13 +1270,16 @@ const SimpleObjectForm: React.FC<{ schema: LocalizedObjectFormSchema; dataSource // the create-mode `required` suppression (#4069), so seeding and validation // cannot disagree about which mode this form is in. const isCreateForm = isCreateFormMode(schema); + // ADR-0144 D8 rule 4 — the organization currency a record-currency code + // field opens on in create mode. + const { currency: homeCurrency } = useLocalization(); // [#5683] `currentUserId` lets the one client-resolvable token // (`current_user`) pre-fill with the id the server would stamp anyway; // null/unloaded seeds nothing and keeps the omit-and-let-the-engine-resolve // contract above. const schemaDefaults = React.useMemo( - () => (isCreateForm ? schemaDefaultValues(objectSchema, { currentUserId: perms.userId }) : {}), - [objectSchema, isCreateForm, perms.userId], + () => (isCreateForm ? schemaDefaultValues(objectSchema, { currentUserId: perms.userId, homeCurrency }) : {}), + [objectSchema, isCreateForm, perms.userId, homeCurrency], ); // What the form OPENS with: the seeded defaults, overlaid with the caller's diff --git a/ui/packages/plugin-form/src/SplitForm.tsx b/ui/packages/plugin-form/src/SplitForm.tsx index 2154cf5f163..96c88dbad62 100644 --- a/ui/packages/plugin-form/src/SplitForm.tsx +++ b/ui/packages/plugin-form/src/SplitForm.tsx @@ -26,6 +26,7 @@ import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react'; import type { FormField, FormSchema, DataSource, ObjectFormSchema, ObjectFormSection } from '@object-ui/types'; import { cn, toast } from '@object-ui/components'; +import { useLocalization } from '@object-ui/i18n'; import { SchemaRenderer, useSafeFieldLabel } from '@object-ui/react'; import { buildSectionFields as buildSectionFieldsShared } from './sectionFields'; import { seedCreateValues } from './schemaDefaults'; @@ -182,6 +183,9 @@ export const SplitForm: React.FC = ({ const { t } = useFormChromeTranslation(); const perms = usePermissions(); const { userId: currentUserId } = perms; + // ADR-0144 D8 rule 4 — the organization currency a record-currency code + // field opens on in create mode. + const { currency: homeCurrency } = useLocalization(); // Upload-in-flight gate (objectui#10166): a `file`/`image` value is only its // fileId once the presigned upload settles, so a save during that window // stored the record WITHOUT the attachment and reported success. @@ -270,7 +274,7 @@ export const SplitForm: React.FC = ({ // Declared static defaults are this form's opening values (#4047) — // see `schemaDefaults` for the create-only boundary and for why // runtime defaults are left to the server. - setFormData(seedCreateValues(objectSchema, resolveInitialRecord(schema), { currentUserId })); + setFormData(seedCreateValues(objectSchema, resolveInitialRecord(schema), { currentUserId, homeCurrency })); // Not a read, so no earlier record read's failure describes the form. run.commit(); setLoading(false); diff --git a/ui/packages/plugin-form/src/TabbedForm.tsx b/ui/packages/plugin-form/src/TabbedForm.tsx index 37f614368f1..8cb83d0e140 100644 --- a/ui/packages/plugin-form/src/TabbedForm.tsx +++ b/ui/packages/plugin-form/src/TabbedForm.tsx @@ -16,6 +16,7 @@ import React, { useState, useCallback, useRef } from 'react'; import type { FormField, FormSchema, DataSource, ObjectFormSchema, ObjectFormSection } from '@object-ui/types'; import { cn, toast } from '@object-ui/components'; +import { useLocalization } from '@object-ui/i18n'; import { SchemaRenderer, useSafeFieldLabel } from '@object-ui/react'; import { buildSectionFields as buildSectionFieldsShared } from './sectionFields'; import { seedCreateValues } from './schemaDefaults'; @@ -292,6 +293,9 @@ export const TabbedForm: React.FC = ({ const { t } = useFormChromeTranslation(); const perms = usePermissions(); const { userId: currentUserId } = perms; + // ADR-0144 D8 rule 4 — the organization currency a record-currency code + // field opens on in create mode. + const { currency: homeCurrency } = useLocalization(); const [objectSchema, setObjectSchema] = useState(null); // Upload-in-flight gate (objectui#10166): a `file`/`image` value is only its // fileId once the presigned upload settles, so a save during that window @@ -386,7 +390,7 @@ export const TabbedForm: React.FC = ({ // Declared static defaults are this form's opening values (#4047) — // see `schemaDefaults` for the create-only boundary and for why // runtime defaults are left to the server. - setFormData(seedCreateValues(objectSchema, resolveInitialRecord(schema), { currentUserId })); + setFormData(seedCreateValues(objectSchema, resolveInitialRecord(schema), { currentUserId, homeCurrency })); // Not a read, so no earlier record read's failure describes the form. run.commit(); setLoading(false); diff --git a/ui/packages/plugin-form/src/WizardForm.tsx b/ui/packages/plugin-form/src/WizardForm.tsx index 4d685ce93f2..769b8d332fc 100644 --- a/ui/packages/plugin-form/src/WizardForm.tsx +++ b/ui/packages/plugin-form/src/WizardForm.tsx @@ -18,7 +18,7 @@ import type { FormField, FormSchema, DataSource, ObjectFormSchema, ObjectFormSec import { Button, cn, toast } from '@object-ui/components'; import { AlertCircle, Check, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'; import { resolveFieldRuleState, evalFieldPredicate, isMissingForRequired, isServerOwnedValue } from '@object-ui/core'; -import { createSafeTranslation } from '@object-ui/i18n'; +import { createSafeTranslation, useLocalization } from '@object-ui/i18n'; import { FormSectionContainer } from './FormSection'; import { SchemaRenderer, useSafeFieldLabel, usePredicateScope } from '@object-ui/react'; import { buildSectionFields as buildSectionFieldsShared } from './sectionFields'; @@ -465,6 +465,9 @@ export const WizardForm: React.FC = ({ const { fieldLabel } = useSafeFieldLabel(); const perms = usePermissions(); const { userId: currentUserId } = perms; + // ADR-0144 D8 rule 4 — the organization currency a record-currency code + // field opens on in create mode. + const { currency: homeCurrency } = useLocalization(); const { t } = useWizardTranslation(); // Upload-in-flight gate (objectui#10166). Scoped to the FINAL commit, not to // step navigation: moving between steps writes nothing, and blocking Next @@ -640,7 +643,7 @@ export const WizardForm: React.FC = ({ // Declared static defaults are this wizard's opening values (#4047) // — see `schemaDefaults` for the create-only boundary and for why // runtime defaults are left to the server. - setFormData(holdCreateSeed(createSeedRef, seedCreateValues(objectSchema, resolveInitialRecord(schema), { currentUserId }))); + setFormData(holdCreateSeed(createSeedRef, seedCreateValues(objectSchema, resolveInitialRecord(schema), { currentUserId, homeCurrency }))); seededRef.current = true; } // Not a read, so no earlier record read's failure describes the form. @@ -1069,7 +1072,7 @@ export const WizardForm: React.FC = ({ // Back to a fresh step 1 for the next entry — "fresh" means the // same opening values the wizard had, defaults included (#4047), // not a blank object the first entry never started from. - setFormData(holdCreateSeed(createSeedRef, seedCreateValues(objectSchema, resolveInitialRecord(schema), { currentUserId }))); + setFormData(holdCreateSeed(createSeedRef, seedCreateValues(objectSchema, resolveInitialRecord(schema), { currentUserId, homeCurrency }))); setCompletedSteps(new Set()); setCurrentStep(0); setResetNonce((n) => n + 1); @@ -1126,7 +1129,7 @@ export const WizardForm: React.FC = ({ if (schema.resetOnSuccess && schema.mode === 'create') { // Back to a fresh step 1 for the next entry — same opening values // as the first entry, defaults included (#4047). - setFormData(holdCreateSeed(createSeedRef, seedCreateValues(objectSchema, resolveInitialRecord(schema), { currentUserId }))); + setFormData(holdCreateSeed(createSeedRef, seedCreateValues(objectSchema, resolveInitialRecord(schema), { currentUserId, homeCurrency }))); setCompletedSteps(new Set()); setCurrentStep(0); setResetNonce((n) => n + 1); diff --git a/ui/packages/plugin-form/src/schemaDefaults.ts b/ui/packages/plugin-form/src/schemaDefaults.ts index 18af10aeb29..8e2bc85d0ab 100644 --- a/ui/packages/plugin-form/src/schemaDefaults.ts +++ b/ui/packages/plugin-form/src/schemaDefaults.ts @@ -149,6 +149,7 @@ export interface FieldDefaultsSchemaLike { type SeedFieldLike = NonNullable[string]> & { options?: unknown; multiple?: unknown; + currencyConfig?: unknown; }; /** @@ -185,6 +186,31 @@ export interface SeedContext { * engine's own `current_user` resolution handles at insert. */ currentUserId?: string | null; + /** + * The organization's currency (`localization.currency`, via + * `useLocalization().currency`), or null/undefined when the setting is + * unset. A record-currency code field — the field a `record`-mode amount's + * `currencyConfig.currencyField` names (ADR-0144 D2) — opens pre-filled with + * it on a create form (ADR-0144 D8 rule 4): the user submits the code as an + * explicit value, and the server never fills one in. Unset seeds nothing. + */ + homeCurrency?: string | null; +} + +/** + * The fields of `fields` that some `record`-mode amount names as its + * `currencyConfig.currencyField` (ADR-0144 D2) — the record-currency code + * fields a create form pre-fills with the organization's currency. + */ +function recordCurrencyCodeFields(fields: Record): Set { + const out = new Set(); + for (const f of Object.values(fields)) { + const config = f?.currencyConfig as { currencyMode?: unknown; currencyField?: unknown } | undefined; + if (config?.currencyMode === 'record' && typeof config.currencyField === 'string' && config.currencyField !== '') { + out.add(config.currencyField); + } + } + return out; } /** @@ -286,6 +312,7 @@ export function schemaDefaultValues( const fields = objectSchema?.fields; if (!fields || typeof fields !== 'object') return {}; const defaults: Record = {}; + const codeFields = ctx?.homeCurrency ? recordCurrencyCodeFields(fields) : undefined; for (const name of Object.keys(fields)) { const f = fields[name]; const dv = f?.defaultValue; @@ -313,6 +340,11 @@ export function schemaDefaultValues( // server never applies to that field. const fromOptions = optionListDefault(f); if (fromOptions !== undefined) defaults[name] = fromOptions; + // ADR-0144 D8 rule 4 — a record-currency code field with no declared + // default opens on the organization's currency. It is a pre-filled + // VALUE the user submits (or changes), so the stored code is always + // explicit; a declared `defaultValue` is the author's and wins. + else if (codeFields?.has(name) && ctx?.homeCurrency) defaults[name] = ctx.homeCurrency; } } return defaults; From a9f5abfd1b18735bf378a5965ad4a558e8a0b6d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 14:17:49 +0000 Subject: [PATCH 07/26] chore(spec): regenerate artifacts after bringing main in Claude-Session: https://claude.ai/code/session_01Utp6CbfVSc1hXtxLUPHZ2f Co-authored-by: Claude --- content/docs/references/data/field.mdx | 6 +++--- content/docs/references/data/object.mdx | 9 ++++++--- content/docs/references/system/migration.mdx | 6 ++++-- packages/spec/authorable-surface/data.json | 2 ++ 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 8a148e37557..bffa4ef1874 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -29,7 +29,7 @@ const result = CurrencyConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **currencyMode** | `Enum<'dynamic' \| 'fixed' \| 'record'>` | optional (default: `"dynamic"`) | Currency mode. `fixed`: the field has one currency, `defaultCurrency`. `dynamic` (the default): the field has no currency of its own — amounts display in the tenant default currency (the `localization.currency` setting; a plain number when none is set) and `defaultCurrency` is not read. `record`: each record has its own currency, the ISO 4217 code stored in the sibling field `currencyField` names; `defaultCurrency` is not read. The value is a bare number in every mode (ADR-0104 D1). | -| **defaultCurrency** | `string` | optional (default: `"CNY"`) | The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency. Not read under `dynamic` (the default), where amounts display in the tenant default currency, nor under `record`, where each record names its own. | +| **defaultCurrency** | `string` | optional | The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency, required under `fixed`. Not read under `dynamic` (the default), where amounts display in the tenant default currency, nor under `record`, where each record names its own. | | **currencyField** | `string` | optional | `record` mode only, and required there: the name of the field on the same object that holds each record's currency — a `text` field declaring `valueDomain: 'iso_4217_currency'` (conventionally `currency_code`). Refused under `dynamic` and `fixed`. | @@ -86,7 +86,7 @@ const result = CurrencyConfigSchema.parse(data); | **inlineTitle** | `string` | optional | Title for the inline master-detail grid | | **inlineColumns** | `{ name: string; label?: string; type?: Enum<'text' \| 'number' \| 'currency' \| 'date' \| 'datetime' \| 'time' \| 'select' \| 'lookup' \| 'file'>; width?: number; … }[]` | optional | Explicit columns for the inline grid (derived from the child object when omitted). Each entry is a strict, name-keyed column (`{ name, label?, type?, … }` — objectui GridColumn); identity-only entries (`{ name }`) hydrate everything else from the child object's fields. Unknown keys and the retired `field` spelling are refused at parse. | | **inlineAmountField** | `string` | optional | Numeric child field summed for the inline grid total | -| **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown (stacked under the shared "Related" tab); 'primary' = core relationship, promoted to its own tab. Prominence intent, not a layout switch (ADR-0085). A derived related list (relatedList: 'primary') inherits its row order from the child object's DEFAULT list view sort — the isDefault view, or the first declared list item when none is marked default; wire spelling is sort=`` / sort=-``, never $orderby. A child object with no list-view sort emits no ordering parameter, and rows fall back to record-id order. | +| **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown as a supporting list, a side card beside the record (the shared "Related" tab on the tabbed page); 'primary' = part of the record's main content, drawn in the record's body (the details column, after the field sections) as a full-width table (its own tab on the tabbed page). Absent on a master_detail field that sets inlineEdit, the list is 'primary' by default; write true to keep it a side card. 'primary' places a list and does not order it: the parent object's relatedLists orders the body tables and the side cards (ADR-0085). A derived related list (relatedList: 'primary') inherits its row order from the child object's DEFAULT list view sort — the isDefault view, or the first declared list item when none is marked default; wire spelling is sort=`` / sort=-``, never $orderby. A child object with no list-view sort emits no ordering parameter, and rows fall back to record-id order. | | **relatedListTitle** | `string` | optional | Title for the detail-page related list | | **relatedListColumns** | `string[]` | optional | Explicit columns for the detail-page related list, as child field names (e.g. ['name', 'status']); derived from the child object (highlightFields → field walk) when omitted. Strings only — labels, cell types and formatting always derive from the child object's field definitions; column objects are refused at parse. | | **relatedListFilter** | `any` | optional | Declarative default filter for the detail-page related list: AND-composed with the parent-relationship condition `{ [referenceField]: parentId }` — an authored constraint, never a user-editable suggestion. The related-list tab badge count honors the same composed filter, so counts match the visible rows. Canonical Query-DSL FilterCondition (the same dialect as a query `where`), e.g. `{ status: { $ne: 'deleted' } }` to hide soft-deleted children. | @@ -250,7 +250,7 @@ const result = CurrencyConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **currencyMode** | `Enum<'dynamic' \| 'fixed' \| 'record'>` | optional (default: `"dynamic"`) | Currency mode. `fixed`: the field has one currency, `defaultCurrency`. `dynamic` (the default): the field has no currency of its own — amounts display in the tenant default currency (the `localization.currency` setting; a plain number when none is set) and `defaultCurrency` is not read. `record`: each record has its own currency, the ISO 4217 code stored in the sibling field `currencyField` names; `defaultCurrency` is not read. The value is a bare number in every mode (ADR-0104 D1). | -| **defaultCurrency** | `string` | optional (default: `"CNY"`) | The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency. Not read under `dynamic` (the default), where amounts display in the tenant default currency, nor under `record`, where each record names its own. | +| **defaultCurrency** | `string` | optional | The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency, required under `fixed`. Not read under `dynamic` (the default), where amounts display in the tenant default currency, nor under `record`, where each record names its own. | | **currencyField** | `string` | optional | `record` mode only, and required there: the name of the field on the same object that holds each record's currency — a `text` field declaring `valueDomain: 'iso_4217_currency'` (conventionally `currency_code`). Refused under `dynamic` and `fixed`. | ### Nested Shape: `Field.conditionalFormatting[number]` diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 592b298cd27..c0e83af4ed0 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -165,6 +165,7 @@ const result = ApiMethod.parse(data); | **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField; a composite title that import, sort or search must match to a stored computed field (a non-formula field with an expression, computed and stored on every write) designated as nameField — a text formula designated as nameField stays legal for a title shown for display only. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. | | **imageField** | `string` | optional | The record's picture: names the field the record page header (record chrome) draws beside the title — one object-level declaration every record detail page reads, not a per-page header prop. Must name a field of this object whose type is `image` or `avatar`; any other name is refused. A record whose field is empty shows no picture (no initials or placeholder). | | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | +| **relatedLists** | `string[]` | optional | Business order of this object's related lists on its record page, most important first. Each entry is a child object name (e.g. "task"), or object.field when that child has more than one relationship to this object (e.g. "event.related_to"). Listed relationships come first in this order; unlisted ones follow in the derived order (owned master_detail children, then lookup children). An entry that matches no inbound lookup / master_detail field of this object is refused. It orders only: relatedList: false on the relationship still hides a list, and relatedList: 'primary' still places a list in the record's body, so the order applies among the body tables and among the side cards. | | **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. When the named field carries options, it must be a single-value `select` / `radio` with inline `options`, each declaring its `kind` ('open' / 'won' / 'lost'); a picklist-backed or multi-value stage field is refused. A name that resolves to no declared field, or a field with no options, is not judged at parse. | | **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). | | **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| object \| object; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) | @@ -251,7 +252,7 @@ const result = ApiMethod.parse(data); | **inlineTitle** | `string` | optional | Title for the inline master-detail grid | | **inlineColumns** | `{ name: string; label?: string; type?: Enum<'text' \| 'number' \| 'currency' \| 'date' \| 'datetime' \| 'time' \| 'select' \| 'lookup' \| 'file'>; width?: number; … }[]` | optional | Explicit columns for the inline grid (derived from the child object when omitted). Each entry is a strict, name-keyed column (`{ name, label?, type?, … }` — objectui GridColumn); identity-only entries (`{ name }`) hydrate everything else from the child object's fields. Unknown keys and the retired `field` spelling are refused at parse. | | **inlineAmountField** | `string` | optional | Numeric child field summed for the inline grid total | -| **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown (stacked under the shared "Related" tab); 'primary' = core relationship, promoted to its own tab. Prominence intent, not a layout switch (ADR-0085). A derived related list (relatedList: 'primary') inherits its row order from the child object's DEFAULT list view sort — the isDefault view, or the first declared list item when none is marked default; wire spelling is sort=`` / sort=-``, never $orderby. A child object with no list-view sort emits no ordering parameter, and rows fall back to record-id order. | +| **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown as a supporting list, a side card beside the record (the shared "Related" tab on the tabbed page); 'primary' = part of the record's main content, drawn in the record's body (the details column, after the field sections) as a full-width table (its own tab on the tabbed page). Absent on a master_detail field that sets inlineEdit, the list is 'primary' by default; write true to keep it a side card. 'primary' places a list and does not order it: the parent object's relatedLists orders the body tables and the side cards (ADR-0085). A derived related list (relatedList: 'primary') inherits its row order from the child object's DEFAULT list view sort — the isDefault view, or the first declared list item when none is marked default; wire spelling is sort=`` / sort=-``, never $orderby. A child object with no list-view sort emits no ordering parameter, and rows fall back to record-id order. | | **relatedListTitle** | `string` | optional | Title for the detail-page related list | | **relatedListColumns** | `string[]` | optional | Explicit columns for the detail-page related list, as child field names (e.g. ['name', 'status']); derived from the child object (highlightFields → field walk) when omitted. Strings only — labels, cell types and formatting always derive from the child object's field definitions; column objects are refused at parse. | | **relatedListFilter** | `any` | optional | Declarative default filter for the detail-page related list: AND-composed with the parent-relationship condition `{ [referenceField]: parentId }` — an authored constraint, never a user-editable suggestion. The related-list tab badge count honors the same composed filter, so counts match the visible rows. Canonical Query-DSL FilterCondition (the same dialect as a query `where`), e.g. `{ status: { $ne: 'deleted' } }` to hide soft-deleted children. | @@ -314,6 +315,7 @@ const result = ApiMethod.parse(data); | **label** | `string` | ✅ | Group display label | | **icon** | `string` | optional | Icon name (Lucide/Material) for the group header | | **description** | `string` | optional | Optional description shown under the group header | +| **relatedList** | `string` | optional | Places a related list's body table at this position in the record's details column instead of a section of fields: names one inbound relationship, as a relatedLists entry does (child object, or object.field when that child has more than one relationship to this object). A group with relatedList holds no fields: a field that names it as its group is refused, and so is a name that matches no inbound lookup / master_detail field. relatedList: false on the relationship still hides the table. | | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Section visibility predicate (CEL) — the whole group (header included) is shown only when TRUE, else hidden (fail-closed). e.g. P`record.type == 'invoice'` | | **collapse** | `Enum<'none' \| 'expanded' \| 'collapsed'>` | optional (default: `"none"`) | [ADR-0085] Section collapse behaviour: 'none' (always open, no toggle), 'expanded' (collapsible, starts open), 'collapsed' (collapsible, starts closed). | | **defaultExpanded** | `boolean` | optional | [DEPRECATED → collapse] true → 'expanded', false → 'collapsed'. | @@ -509,7 +511,7 @@ const result = ApiMethod.parse(data); | **bodyExtra** | `Record` | optional | Static request-body fields for a type:"api" action, merged last (overrides user params). `{{page.}}` tokens are resolved by the runtime. This — not `params` — is where a payload goes. | | **mode** | `Enum<'create' \| 'edit' \| 'delete' \| 'custom'>` | optional | Semantic mode of the action. | | **opensInNewTab** | `boolean` | optional | Open the action result in a new tab. The renderer pre-opens the tab synchronously on click (popup-blocker-safe) and navigates it to the handler's redirectUrl. | -| **newTabUrl** | `string` | optional | Direct new-tab URL template (`{recordId}` placeholder). When set with opensInNewTab, the renderer navigates the pre-opened tab here immediately — no action POST. The endpoint must enforce auth itself. | +| **newTabUrl** | `string` | optional | New-tab path template on this backend, starting with / (`{recordId}` placeholder). When set with opensInNewTab, the renderer navigates the pre-opened tab to the backend origin + this path immediately — no action POST. The endpoint must enforce auth itself. An absolute URL is refused; an external page is a type:'url' action with openIn: 'new-tab'. | | **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | Post-success navigation for type:'api', type:'script' and type:'flow' actions. `navigate` is a route/URL template interpolating $`{param.*}`, $`{ctx.*}` and $`{result.*}` (the server response; for a flow, the completed run's answer); `openIn` defaults 'self'. A flow run that pauses at a screen or ends refused performs no navigation. The handler-return convention (`{ redirectUrl }` without openIn) keeps its 17.0.0 new-tab behavior. | | **aria** | `never` | optional | [REMOVED] `action.aria` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no action surface ever applied it: the button, icon, menu, group and bar renderers, the row and bulk action menus and the record quick-actions toolbar all take the accessible name from the action's `label` and never read this block, so ARIA attributes declared here parsed and then silently did not reach the DOM. Delete the key. The accessible name that IS applied is the action's required `label` — the visible button or menu-item text, and the `aria-label` of an icon-only action — so write the name you meant there. To name the region that PLACES the actions, author `ariaLabel` / `ariaDescribedBy` / `role` in the `aria` block of the placing node: `page.components[].aria` (the component that renders the actions) or the list view `aria`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | @@ -613,7 +615,7 @@ const result = ApiMethod.parse(data); | **inlineTitle** | `string` | optional | Title for the inline master-detail grid | | **inlineColumns** | `{ name: string; label?: string; type?: Enum<'text' \| 'number' \| 'currency' \| 'date' \| 'datetime' \| 'time' \| 'select' \| 'lookup' \| 'file'>; width?: number; … }[]` | optional | Explicit columns for the inline grid (derived from the child object when omitted). Each entry is a strict, name-keyed column (`{ name, label?, type?, … }` — objectui GridColumn); identity-only entries (`{ name }`) hydrate everything else from the child object's fields. Unknown keys and the retired `field` spelling are refused at parse. | | **inlineAmountField** | `string` | optional | Numeric child field summed for the inline grid total | -| **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown (stacked under the shared "Related" tab); 'primary' = core relationship, promoted to its own tab. Prominence intent, not a layout switch (ADR-0085). A derived related list (relatedList: 'primary') inherits its row order from the child object's DEFAULT list view sort — the isDefault view, or the first declared list item when none is marked default; wire spelling is sort=`` / sort=-``, never $orderby. A child object with no list-view sort emits no ordering parameter, and rows fall back to record-id order. | +| **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown as a supporting list, a side card beside the record (the shared "Related" tab on the tabbed page); 'primary' = part of the record's main content, drawn in the record's body (the details column, after the field sections) as a full-width table (its own tab on the tabbed page). Absent on a master_detail field that sets inlineEdit, the list is 'primary' by default; write true to keep it a side card. 'primary' places a list and does not order it: the parent object's relatedLists orders the body tables and the side cards (ADR-0085). A derived related list (relatedList: 'primary') inherits its row order from the child object's DEFAULT list view sort — the isDefault view, or the first declared list item when none is marked default; wire spelling is sort=`` / sort=-``, never $orderby. A child object with no list-view sort emits no ordering parameter, and rows fall back to record-id order. | | **relatedListTitle** | `string` | optional | Title for the detail-page related list | | **relatedListColumns** | `string[]` | optional | Explicit columns for the detail-page related list, as child field names (e.g. ['name', 'status']); derived from the child object (highlightFields → field walk) when omitted. Strings only — labels, cell types and formatting always derive from the child object's field definitions; column objects are refused at parse. | | **relatedListFilter** | `any` | optional | Declarative default filter for the detail-page related list: AND-composed with the parent-relationship condition `{ [referenceField]: parentId }` — an authored constraint, never a user-editable suggestion. The related-list tab badge count honors the same composed filter, so counts match the visible rows. Canonical Query-DSL FilterCondition (the same dialect as a query `where`), e.g. `{ status: { $ne: 'deleted' } }` to hide soft-deleted children. | @@ -699,6 +701,7 @@ External datasource binding (ADR-0015) | **label** | `string` | ✅ | Group display label | | **icon** | `string` | optional | Icon name (Lucide/Material) for the group header | | **description** | `string` | optional | Optional description shown under the group header | +| **relatedList** | `string` | optional | Places a related list's body table at this position in the record's details column instead of a section of fields: names one inbound relationship, as a relatedLists entry does (child object, or object.field when that child has more than one relationship to this object). A group with relatedList holds no fields: a field that names it as its group is refused, and so is a name that matches no inbound lookup / master_detail field. relatedList: false on the relationship still hides the table. | | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Section visibility predicate (CEL) — the whole group (header included) is shown only when TRUE, else hidden (fail-closed). e.g. P`record.type == 'invoice'` | | **collapse** | `Enum<'none' \| 'expanded' \| 'collapsed'>` | optional (default: `"none"`) | [ADR-0085] Section collapse behaviour: 'none' (always open, no toggle), 'expanded' (collapsible, starts open), 'collapsed' (collapsible, starts closed). | | **defaultExpanded** | `boolean` | optional | [DEPRECATED → collapse] true → 'expanded', false → 'collapsed'. | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 33757a7c57c..c034e66a503 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -86,7 +86,7 @@ Add a new field to an existing object | **inlineTitle** | `string` | optional | Title for the inline master-detail grid | | **inlineColumns** | `{ name: string; label?: string; type?: Enum<'text' \| 'number' \| 'currency' \| 'date' \| 'datetime' \| 'time' \| 'select' \| 'lookup' \| 'file'>; width?: number; … }[]` | optional | Explicit columns for the inline grid (derived from the child object when omitted). Each entry is a strict, name-keyed column (`{ name, label?, type?, … }` — objectui GridColumn); identity-only entries (`{ name }`) hydrate everything else from the child object's fields. Unknown keys and the retired `field` spelling are refused at parse. | | **inlineAmountField** | `string` | optional | Numeric child field summed for the inline grid total | -| **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown (stacked under the shared "Related" tab); 'primary' = core relationship, promoted to its own tab. Prominence intent, not a layout switch (ADR-0085). A derived related list (relatedList: 'primary') inherits its row order from the child object's DEFAULT list view sort — the isDefault view, or the first declared list item when none is marked default; wire spelling is sort=`` / sort=-``, never $orderby. A child object with no list-view sort emits no ordering parameter, and rows fall back to record-id order. | +| **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown as a supporting list, a side card beside the record (the shared "Related" tab on the tabbed page); 'primary' = part of the record's main content, drawn in the record's body (the details column, after the field sections) as a full-width table (its own tab on the tabbed page). Absent on a master_detail field that sets inlineEdit, the list is 'primary' by default; write true to keep it a side card. 'primary' places a list and does not order it: the parent object's relatedLists orders the body tables and the side cards (ADR-0085). A derived related list (relatedList: 'primary') inherits its row order from the child object's DEFAULT list view sort — the isDefault view, or the first declared list item when none is marked default; wire spelling is sort=`` / sort=-``, never $orderby. A child object with no list-view sort emits no ordering parameter, and rows fall back to record-id order. | | **relatedListTitle** | `string` | optional | Title for the detail-page related list | | **relatedListColumns** | `string[]` | optional | Explicit columns for the detail-page related list, as child field names (e.g. ['name', 'status']); derived from the child object (highlightFields → field walk) when omitted. Strings only — labels, cell types and formatting always derive from the child object's field definitions; column objects are refused at parse. | | **relatedListFilter** | `any` | optional | Declarative default filter for the detail-page related list: AND-composed with the parent-relationship condition `{ [referenceField]: parentId }` — an authored constraint, never a user-editable suggestion. The related-list tab badge count honors the same composed filter, so counts match the visible rows. Canonical Query-DSL FilterCondition (the same dialect as a query `where`), e.g. `{ status: { $ne: 'deleted' } }` to hide soft-deleted children. | @@ -348,6 +348,7 @@ Create a new object | **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField; a composite title that import, sort or search must match to a stored computed field (a non-formula field with an expression, computed and stored on every write) designated as nameField — a text formula designated as nameField stays legal for a title shown for display only. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. | | **imageField** | `string` | optional | The record's picture: names the field the record page header (record chrome) draws beside the title — one object-level declaration every record detail page reads, not a per-page header prop. Must name a field of this object whose type is `image` or `avatar`; any other name is refused. A record whose field is empty shows no picture (no initials or placeholder). | | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | +| **relatedLists** | `string[]` | optional | Business order of this object's related lists on its record page, most important first. Each entry is a child object name (e.g. "task"), or object.field when that child has more than one relationship to this object (e.g. "event.related_to"). Listed relationships come first in this order; unlisted ones follow in the derived order (owned master_detail children, then lookup children). An entry that matches no inbound lookup / master_detail field of this object is refused. It orders only: relatedList: false on the relationship still hides a list, and relatedList: 'primary' still places a list in the record's body, so the order applies among the body tables and among the side cards. | | **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. When the named field carries options, it must be a single-value `select` / `radio` with inline `options`, each declaring its `kind` ('open' / 'won' / 'lost'); a picklist-backed or multi-value stage field is refused. A name that resolves to no declared field, or a field with no options, is not judged at parse. | | **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). | | **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| object \| object; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) | @@ -516,7 +517,7 @@ Add a new field to an existing object | **inlineTitle** | `string` | optional | Title for the inline master-detail grid | | **inlineColumns** | `{ name: string; label?: string; type?: Enum<'text' \| 'number' \| 'currency' \| 'date' \| 'datetime' \| 'time' \| 'select' \| 'lookup' \| 'file'>; width?: number; … }[]` | optional | Explicit columns for the inline grid (derived from the child object when omitted). Each entry is a strict, name-keyed column (`{ name, label?, type?, … }` — objectui GridColumn); identity-only entries (`{ name }`) hydrate everything else from the child object's fields. Unknown keys and the retired `field` spelling are refused at parse. | | **inlineAmountField** | `string` | optional | Numeric child field summed for the inline grid total | -| **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown (stacked under the shared "Related" tab); 'primary' = core relationship, promoted to its own tab. Prominence intent, not a layout switch (ADR-0085). A derived related list (relatedList: 'primary') inherits its row order from the child object's DEFAULT list view sort — the isDefault view, or the first declared list item when none is marked default; wire spelling is sort=`` / sort=-``, never $orderby. A child object with no list-view sort emits no ordering parameter, and rows fall back to record-id order. | +| **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown as a supporting list, a side card beside the record (the shared "Related" tab on the tabbed page); 'primary' = part of the record's main content, drawn in the record's body (the details column, after the field sections) as a full-width table (its own tab on the tabbed page). Absent on a master_detail field that sets inlineEdit, the list is 'primary' by default; write true to keep it a side card. 'primary' places a list and does not order it: the parent object's relatedLists orders the body tables and the side cards (ADR-0085). A derived related list (relatedList: 'primary') inherits its row order from the child object's DEFAULT list view sort — the isDefault view, or the first declared list item when none is marked default; wire spelling is sort=`` / sort=-``, never $orderby. A child object with no list-view sort emits no ordering parameter, and rows fall back to record-id order. | | **relatedListTitle** | `string` | optional | Title for the detail-page related list | | **relatedListColumns** | `string[]` | optional | Explicit columns for the detail-page related list, as child field names (e.g. ['name', 'status']); derived from the child object (highlightFields → field walk) when omitted. Strings only — labels, cell types and formatting always derive from the child object's field definitions; column objects are refused at parse. | | **relatedListFilter** | `any` | optional | Declarative default filter for the detail-page related list: AND-composed with the parent-relationship condition `{ [referenceField]: parentId }` — an authored constraint, never a user-editable suggestion. The related-list tab badge count honors the same composed filter, so counts match the visible rows. Canonical Query-DSL FilterCondition (the same dialect as a query `where`), e.g. `{ status: { $ne: 'deleted' } }` to hide soft-deleted children. | @@ -643,6 +644,7 @@ Create a new object | **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField; a composite title that import, sort or search must match to a stored computed field (a non-formula field with an expression, computed and stored on every write) designated as nameField — a text formula designated as nameField stays legal for a title shown for display only. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. | | **imageField** | `string` | optional | The record's picture: names the field the record page header (record chrome) draws beside the title — one object-level declaration every record detail page reads, not a per-page header prop. Must name a field of this object whose type is `image` or `avatar`; any other name is refused. A record whose field is empty shows no picture (no initials or placeholder). | | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | +| **relatedLists** | `string[]` | optional | Business order of this object's related lists on its record page, most important first. Each entry is a child object name (e.g. "task"), or object.field when that child has more than one relationship to this object (e.g. "event.related_to"). Listed relationships come first in this order; unlisted ones follow in the derived order (owned master_detail children, then lookup children). An entry that matches no inbound lookup / master_detail field of this object is refused. It orders only: relatedList: false on the relationship still hides a list, and relatedList: 'primary' still places a list in the record's body, so the order applies among the body tables and among the side cards. | | **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. When the named field carries options, it must be a single-value `select` / `radio` with inline `options`, each declaring its `kind` ('open' / 'won' / 'lost'); a picklist-backed or multi-value stage field is refused. A name that resolves to no declared field, or a field with no options, is not judged at parse. | | **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). | | **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| object \| object; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) | diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index 2f574c25018..a433eac4846 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -691,6 +691,7 @@ "data/Object:pluralLabel", "data/Object:protection", "data/Object:publicSharing", + "data/Object:relatedLists", "data/Object:requiredPermissions", "data/Object:searchableFields", "data/Object:sharing", @@ -739,6 +740,7 @@ "data/ObjectFieldGroup:icon", "data/ObjectFieldGroup:key", "data/ObjectFieldGroup:label", + "data/ObjectFieldGroup:relatedList", "data/ObjectFieldGroup:visibleWhen", "data/PerOperationRequiredPermissions:create", "data/PerOperationRequiredPermissions:delete", From 044e67c0bba4d052d105f0f882a346833e749552 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 20:28:08 +0000 Subject: [PATCH 08/26] 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 --- .../components/src/renderers/form/form.tsx | 12 ++ .../fields/src/widgets/CurrencyCodeField.tsx | 181 ++++++++++++++++++ ui/packages/fields/src/widgets/TextField.tsx | 9 + .../fields/src/widgets/useFieldTranslation.ts | 6 + ui/packages/i18n/src/locales/ar.ts | 8 + ui/packages/i18n/src/locales/de.ts | 8 + ui/packages/i18n/src/locales/en.ts | 8 + ui/packages/i18n/src/locales/es.ts | 8 + ui/packages/i18n/src/locales/fr.ts | 8 + ui/packages/i18n/src/locales/ja.ts | 8 + ui/packages/i18n/src/locales/ko.ts | 8 + ui/packages/i18n/src/locales/pt.ts | 8 + ui/packages/i18n/src/locales/ru.ts | 8 + ui/packages/i18n/src/locales/zh.ts | 8 + .../plugin-dashboard/src/ObjectDataTable.tsx | 2 +- .../src/RecordDetailDrawer.tsx | 2 +- .../plugin-dashboard/src/recordFields.tsx | 18 +- ui/packages/plugin-detail/src/DetailView.tsx | 3 +- ui/packages/plugin-gantt/src/ObjectGantt.tsx | 7 +- ui/packages/plugin-grid/src/ObjectGrid.tsx | 2 +- .../plugin-grid/src/objectGridGrouping.ts | 21 +- .../src/recordCurrencyAggregates.ts | 62 ++++++ .../plugin-grid/src/useColumnSummary.ts | 9 +- 23 files changed, 403 insertions(+), 11 deletions(-) create mode 100644 ui/packages/fields/src/widgets/CurrencyCodeField.tsx create mode 100644 ui/packages/plugin-grid/src/recordCurrencyAggregates.ts diff --git a/ui/packages/components/src/renderers/form/form.tsx b/ui/packages/components/src/renderers/form/form.tsx index 88b12b82310..dca5bf28acf 100644 --- a/ui/packages/components/src/renderers/form/form.tsx +++ b/ui/packages/components/src/renderers/form/form.tsx @@ -1208,6 +1208,15 @@ function renderFieldDescription( ); } +/** + * Widget keys whose DISPLAY reads a sibling field's value: a `record`-mode + * amount (ADR-0144 D2) shows the currency its record's code field holds, and a + * `formula` declaring `returnType: 'currency'` is drawn by the same widget. + * They get the live record (`dependentValues`) and nothing else from the + * data-source family — they query nothing. + */ +const RECORD_CURRENCY_WIDGET_TYPES = new Set(['currency', 'formula']); + function stripRegisteredFieldProps(type: string, props: RenderFieldProps): RenderFieldProps { const { dataSource, @@ -1253,6 +1262,9 @@ function stripRegisteredFieldProps(type: string, props: RenderFieldProps): Rende // leftover props onto a DOM node, where an unknown `emptyHint` attribute is // a React warning — hence an allow-list, not an unconditional pass-through. ...(CASCADE_OPTION_WIDGET_TYPES.has(normalizedType) ? { dependentValues, emptyHint } : {}), + // A record-mode amount's symbol and decimals follow the code the user picks + // in its sibling select, so the amount box needs the live record too. + ...(RECORD_CURRENCY_WIDGET_TYPES.has(normalizedType) ? { dependentValues } : {}), }; } diff --git a/ui/packages/fields/src/widgets/CurrencyCodeField.tsx b/ui/packages/fields/src/widgets/CurrencyCodeField.tsx new file mode 100644 index 00000000000..c97d3f5cf37 --- /dev/null +++ b/ui/packages/fields/src/widgets/CurrencyCodeField.tsx @@ -0,0 +1,181 @@ +import React from 'react'; +import { Check, ChevronsUpDown } from 'lucide-react'; +import { + Button, + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + Popover, + PopoverContent, + PopoverTrigger, + EmptyValue, + cn, +} from '@object-ui/components'; +import { isValueDomainMember } from '@objectstack/spec/shared'; +import { useLocalization, useDisplayLocale } from '@object-ui/i18n'; +import { FieldWidgetComponentProps } from './types.js'; +import { toDomProps } from './toDomProps.js'; +import { useFieldTranslation } from './useFieldTranslation.js'; + +/** + * 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'; + +/** Whether `field` is a text field the `iso_4217_currency` domain governs. */ +export function isCurrencyCodeField(field: unknown): boolean { + return (field as { valueDomain?: unknown } | null | undefined)?.valueDomain === CURRENCY_CODE_VALUE_DOMAIN; +} + +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 field 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; +} + +/** + * The organization's rated currencies: every code with a row in the + * organization's exchange-rate table. ⛔ Not served yet — the rate table + * (`sys_exchange_rate`, ADR-0144 D3) ships in the next multi-currency release, + * and this is the seam its reader plugs into. Until then the "available" + * group is the home currency plus the used codes. + */ +export function ratedCurrencyCodes(): readonly string[] { + return []; +} + +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 the display locale, or the bare code when ICU has no name. */ +function currencyLabel(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; + } +} + +/** + * CurrencyCodeField — the control a record-currency code field renders + * (ADR-0144 D2): a searchable select of ISO 4217 codes, grouped "available" + * (home + rated + used, D8 rule 3) and "all". + * + * The value it emits is always the explicit code the user sees selected + * (D8 rule 4): picking the selected code again keeps it — there is no + * "click again to clear" here, because a blank code is what the server + * refuses. A create form opens it on the organization's currency through the + * form's seeding (`schemaDefaultValues`), never through this widget, so an + * edit form never rewrites a stored code just by being opened. + */ +export function CurrencyCodeField({ value, onChange, field, readonly, error, className, ...props }: FieldWidgetComponentProps) { + const { t } = useFieldTranslation(); + const { currency: home } = useLocalization(); + const locale = useDisplayLocale(); + const [open, setOpen] = React.useState(false); + const current = typeof value === 'string' && value !== '' ? value : undefined; + + if (readonly) { + return current ? {currencyLabel(current, locale)} : ; + } + + const groups = currencyCodeGroups({ home, rated: ratedCurrencyCodes(), current }); + const domProps = toDomProps(props); + const item = (code: string) => ( + { + onChange(code); + setOpen(false); + }} + > + + {currencyLabel(code, locale)} + + ); + + return ( + + + + + + + + + {t('fields.currencyCode.noResults')} + {groups.available.length > 0 && ( + {groups.available.map(item)} + )} + {groups.all.length > 0 && ( + {groups.all.map(item)} + )} + + + + + ); +} diff --git a/ui/packages/fields/src/widgets/TextField.tsx b/ui/packages/fields/src/widgets/TextField.tsx index af8c49fbd90..66ed9a186c8 100644 --- a/ui/packages/fields/src/widgets/TextField.tsx +++ b/ui/packages/fields/src/widgets/TextField.tsx @@ -3,6 +3,7 @@ import { Input, Textarea, EmptyValue } from '@object-ui/components'; import type { TextFieldMetadata, TextareaFieldMetadata } from '@object-ui/types'; import { FieldWidgetComponentProps } from './types.js'; import { toDomProps } from './toDomProps.js'; +import { CurrencyCodeField, isCurrencyCodeField } from './CurrencyCodeField.js'; /** * TextField - Standard single-line or multi-line text input @@ -11,6 +12,14 @@ import { toDomProps } from './toDomProps.js'; export function TextField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps) { const fieldData = field; + // A record-currency code field (ADR-0144 D2) — `valueDomain: + // 'iso_4217_currency'` declared on the field — is chosen from the ISO 4217 + // list, never typed: keyed off the DECLARATION, so every host that renders a + // text field (form, inline edit, action dialog) gets the select. + if (isCurrencyCodeField(fieldData)) { + return ; + } + if (readonly) { return {value || }; } diff --git a/ui/packages/fields/src/widgets/useFieldTranslation.ts b/ui/packages/fields/src/widgets/useFieldTranslation.ts index 985121f8330..306c0d18f16 100644 --- a/ui/packages/fields/src/widgets/useFieldTranslation.ts +++ b/ui/packages/fields/src/widgets/useFieldTranslation.ts @@ -20,6 +20,12 @@ const FIELD_DEFAULTS: Record = { // "LABEL — Off" badge (`{{label}}` is the column label the face is handed) // and the completion indicator's two accessible names. Same values as the // `en` pack, so a provider-less render reads what the literals did. + // ADR-0144 D2 — `CurrencyCodeField`, the record-currency code select. + 'fields.currencyCode.placeholder': 'Select currency', + 'fields.currencyCode.search': 'Search currency…', + 'fields.currencyCode.noResults': 'No currency found', + 'fields.currencyCode.available': 'Available', + 'fields.currencyCode.all': 'All currencies (ISO 4217)', 'fields.boolean.offBadge': '{{label}} — Off', 'fields.boolean.completed': 'Completed', 'fields.boolean.notCompleted': 'Not completed', diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 2eb0e69081d..7faa8bb578e 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -395,6 +395,14 @@ const ar = { postalCode: "الرمز البريدي", country: "الدولة", }, + // ADR-0144 D2 — the record-currency code select (`CurrencyCodeField`). + currencyCode: { + placeholder: 'اختر عملة', + search: 'ابحث عن عملة…', + noResults: 'لم يتم العثور على عملة', + available: 'المتاحة', + all: 'كل العملات (ISO 4217)', + }, boolean: { offBadge: "{{label}} — إيقاف", completed: "مكتمل", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index cb5078cde0e..92de6da509e 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -377,6 +377,14 @@ const de = { postalCode: "Postleitzahl", country: "Land", }, + // ADR-0144 D2 — the record-currency code select (`CurrencyCodeField`). + currencyCode: { + placeholder: 'Währung auswählen', + search: 'Währung suchen…', + noResults: 'Keine Währung gefunden', + available: 'Verfügbar', + all: 'Alle Währungen (ISO 4217)', + }, boolean: { offBadge: "{{label}} — Aus", completed: "Abgeschlossen", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 7a17fd6658f..dbab7029c8e 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -357,6 +357,14 @@ const en = { }, }, fields: { + // ADR-0144 D2 — the record-currency code select (`CurrencyCodeField`). + currencyCode: { + placeholder: 'Select currency', + search: 'Search currency…', + noResults: 'No currency found', + available: 'Available', + all: 'All currencies (ISO 4217)', + }, // objectui#13461 — names for icon-only buttons in the field widgets. masterDetail: { view: 'Open {{name}}', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index b0573d7e0bd..785dfbce371 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -385,6 +385,14 @@ const es = { postalCode: "Código postal", country: "País", }, + // ADR-0144 D2 — the record-currency code select (`CurrencyCodeField`). + currencyCode: { + placeholder: 'Seleccionar moneda', + search: 'Buscar moneda…', + noResults: 'No se encontró ninguna moneda', + available: 'Disponibles', + all: 'Todas las monedas (ISO 4217)', + }, boolean: { offBadge: "{{label}} — Desactivado", completed: "Completado", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index aebc60e97bf..c2530bfb301 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -380,6 +380,14 @@ const fr = { postalCode: "Code postal", country: "Pays", }, + // ADR-0144 D2 — the record-currency code select (`CurrencyCodeField`). + currencyCode: { + placeholder: 'Sélectionner une devise', + search: 'Rechercher une devise…', + noResults: 'Aucune devise trouvée', + available: 'Disponibles', + all: 'Toutes les devises (ISO 4217)', + }, boolean: { offBadge: "{{label}} — Désactivé", completed: "Terminé", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 89004ee9a4f..51cfd0576bb 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -377,6 +377,14 @@ const ja = { postalCode: "郵便番号", country: "国", }, + // ADR-0144 D2 — the record-currency code select (`CurrencyCodeField`). + currencyCode: { + placeholder: '通貨を選択', + search: '通貨を検索…', + noResults: '通貨が見つかりません', + available: '利用可能', + all: 'すべての通貨(ISO 4217)', + }, boolean: { offBadge: "{{label}} — オフ", completed: "完了", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index d811b24e25c..b05bab87239 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -377,6 +377,14 @@ const ko = { postalCode: "우편번호", country: "국가", }, + // ADR-0144 D2 — the record-currency code select (`CurrencyCodeField`). + currencyCode: { + placeholder: '통화 선택', + search: '통화 검색…', + noResults: '통화를 찾을 수 없음', + available: '사용 가능', + all: '모든 통화 (ISO 4217)', + }, boolean: { offBadge: "{{label}} — 꺼짐", completed: "완료됨", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index ddae724e3cd..be10fc5265f 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -380,6 +380,14 @@ const pt = { postalCode: "Código postal", country: "País", }, + // ADR-0144 D2 — the record-currency code select (`CurrencyCodeField`). + currencyCode: { + placeholder: 'Selecionar moeda', + search: 'Pesquisar moeda…', + noResults: 'Nenhuma moeda encontrada', + available: 'Disponíveis', + all: 'Todas as moedas (ISO 4217)', + }, boolean: { offBadge: "{{label}} — Desativado", completed: "Concluído", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index 407084baf6d..3ff25fc2d8d 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -386,6 +386,14 @@ const ru = { postalCode: "Почтовый индекс", country: "Страна", }, + // ADR-0144 D2 — the record-currency code select (`CurrencyCodeField`). + currencyCode: { + placeholder: 'Выберите валюту', + search: 'Поиск валюты…', + noResults: 'Валюта не найдена', + available: 'Доступные', + all: 'Все валюты (ISO 4217)', + }, boolean: { offBadge: "{{label}} — Выкл.", completed: "Выполнено", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index 05c1b3dd427..1342ad6339a 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -386,6 +386,14 @@ const zh = { postalCode: '邮政编码', country: '国家 / 地区', }, + // ADR-0144 D2 — the record-currency code select (`CurrencyCodeField`). + currencyCode: { + placeholder: '选择币种', + search: '搜索币种…', + noResults: '未找到币种', + available: '可用', + all: '全部币种(ISO 4217)', + }, boolean: { offBadge: '{{label}} — 已关闭', completed: '已完成', diff --git a/ui/packages/plugin-dashboard/src/ObjectDataTable.tsx b/ui/packages/plugin-dashboard/src/ObjectDataTable.tsx index 1586517bd02..04ac2543a37 100644 --- a/ui/packages/plugin-dashboard/src/ObjectDataTable.tsx +++ b/ui/packages/plugin-dashboard/src/ObjectDataTable.tsx @@ -1038,7 +1038,7 @@ export const ObjectDataTable: React.FC = ({ schema, dataSo } // Tenant-default currency backstops a currency column with no explicit code. - const cell = (value: any): React.ReactNode => renderFieldValue(value, fieldMeta, tenantCurrency, displayLocale); + const cell = (value: any, row?: any): React.ReactNode => renderFieldValue(value, fieldMeta, tenantCurrency, displayLocale, row); return { ...col, name: fieldMeta.name, type: columnType, align: inferredAlign, cell, ...maskedStamp }; }; diff --git a/ui/packages/plugin-dashboard/src/RecordDetailDrawer.tsx b/ui/packages/plugin-dashboard/src/RecordDetailDrawer.tsx index af13bcde330..910ade1033f 100644 --- a/ui/packages/plugin-dashboard/src/RecordDetailDrawer.tsx +++ b/ui/packages/plugin-dashboard/src/RecordDetailDrawer.tsx @@ -148,7 +148,7 @@ export const RecordDetailDrawer: React.FC = ({ // mask, never the value, and so for good after a failed read. node: objectTypesPending ? - : renderFieldValue(record[key], fieldMeta, tenantCurrency, displayLocale), + : renderFieldValue(record[key], fieldMeta, tenantCurrency, displayLocale, record), numeric: !objectTypesPending && isNumericFieldMeta(fieldMeta), }; }); diff --git a/ui/packages/plugin-dashboard/src/recordFields.tsx b/ui/packages/plugin-dashboard/src/recordFields.tsx index 8537bf3058e..1b2c4834685 100644 --- a/ui/packages/plugin-dashboard/src/recordFields.tsx +++ b/ui/packages/plugin-dashboard/src/recordFields.tsx @@ -44,7 +44,7 @@ import { EXPANDABLE_FIELD_TYPES } from '@object-ui/core'; // The one currency precedence every field / measure / cell face shares // (objectui#10463), read from its home package as `ObjectGrid` and // `ObjectMetricWidget` read it. -import { resolveFieldCurrency } from '@object-ui/i18n'; +import { resolveFieldCurrency, recordCurrencyField, type FieldCurrencyConfig } from '@object-ui/i18n'; /** * Framework / system audit fields hidden from auto-derived columns and the @@ -280,6 +280,14 @@ export interface FieldMeta { options?: Array<{ value: any; label: string; color?: string }>; format?: string; currency?: string; + /** + * A `record`-mode amount's currency config (ADR-0144 D2), copied from the + * SCHEMA field def by {@link buildFieldMeta} and only for that mode: such an + * amount has no field-level `currency`, and its cell reads the code from the + * row's sibling field through it. Absent for `dynamic` / `fixed`, whose code + * `currency` already carries. Never an authored column override. + */ + currencyConfig?: FieldCurrencyConfig; /** * The field def's declared upper bound, which on a `percent` field is its * STORAGE statement: the spec's `percentScaleOf` reads a fraction unless @@ -395,6 +403,7 @@ export function buildFieldMeta(params: BuildFieldMetaParams): FieldMeta { options, format: overrides.format ?? meta?.format, currency: overrides.currency ?? resolveFieldCurrency(meta), + ...(recordCurrencyField(meta) !== undefined && { currencyConfig: meta.currencyConfig }), // The storage statement of a `percent` field (objectui#11475), from the // schema def only — see `FieldMeta.max`. ...(typeof meta?.max === 'number' && { max: meta.max }), @@ -443,9 +452,16 @@ export function renderFieldValue( // had. This is a plain function rather than a component, so the locale has to // arrive as an argument — there is no hook to read it from here. displayLocale?: string, + // The record the value sits in. A `record`-mode amount (ADR-0144 D2) reads + // its currency from the record's code field; without the record it shows a + // plain number, never the tenant default. + record?: Readonly> | null, ): React.ReactNode { if (value == null || value === '') return ''; const fmt = fieldMeta.format; + if (recordCurrencyField(fieldMeta) !== undefined && typeof value === 'number') { + return formatCurrency(value, resolveFieldCurrency(fieldMeta, tenantCurrency, record), displayLocale); + } if (typeof fmt === 'string' && /^\$|¥|€|£/.test(fmt) && typeof value === 'number') { // Honor explicit `currency`; else infer from the leading symbol so we never // silently fall back to USD when the author wrote `¥`/`€`; finally fall back diff --git a/ui/packages/plugin-detail/src/DetailView.tsx b/ui/packages/plugin-detail/src/DetailView.tsx index 494199c4267..8d94c16b950 100644 --- a/ui/packages/plugin-detail/src/DetailView.tsx +++ b/ui/packages/plugin-detail/src/DetailView.tsx @@ -1272,7 +1272,8 @@ export const DetailView: React.FC = ({ if (ftype === 'currency') { const num = Number(val); if (!Number.isNaN(num)) { - const cur = resolveFieldCurrency({ ...(objField as any), ...(sectionField as any) }, tenantCurrency); + // A `record`-mode amount (ADR-0144 D2) reads this record's code field. + const cur = resolveFieldCurrency({ ...(objField as any), ...(sectionField as any) }, tenantCurrency, data); display = cur ? new Intl.NumberFormat(displayLocale, { style: 'currency', diff --git a/ui/packages/plugin-gantt/src/ObjectGantt.tsx b/ui/packages/plugin-gantt/src/ObjectGantt.tsx index 8759f6cb5ab..1d8a851292c 100644 --- a/ui/packages/plugin-gantt/src/ObjectGantt.tsx +++ b/ui/packages/plugin-gantt/src/ObjectGantt.tsx @@ -1314,7 +1314,7 @@ export const ObjectGantt: React.FC = ({ // render the same data: select options resolve to their label, lookups to // the embedded record's name, dates/numbers/currency/percent through the // shared @object-ui/fields formatters. - const formatFieldValue = (value: unknown, fieldName: string): string => { + const formatFieldValue = (value: unknown, fieldName: string, record?: Record): string => { if (value == null || value === '') return '—'; const def = fieldDefs[fieldName] ?? fieldDefs[fieldName.split('.')[0]]; const type: string | undefined = def?.type; @@ -1385,7 +1385,8 @@ export const ObjectGantt: React.FC = ({ // watch it); the locale that renders that code is what was missing. return formatCurrency( Number(value), - resolveFieldCurrency(def as any, tenantCurrency), + // A `record`-mode amount (ADR-0144 D2) reads its row's code field. + resolveFieldCurrency(def as any, tenantCurrency, record), displayLocale, ); // `percent` completes the switch (objectui#4553 phase 2). It could not @@ -1461,7 +1462,7 @@ export const ObjectGantt: React.FC = ({ if (raw == null || raw === '' || (Array.isArray(raw) && raw.length === 0)) continue; rows.push({ label: resolveFieldLabel(fieldName, explicitLabel), - value: formatFieldValue(raw, fieldName), + value: formatFieldValue(raw, fieldName, record), }); } return rows.length ? rows : undefined; diff --git a/ui/packages/plugin-grid/src/ObjectGrid.tsx b/ui/packages/plugin-grid/src/ObjectGrid.tsx index c29ef76865a..ee76872ac4e 100644 --- a/ui/packages/plugin-grid/src/ObjectGrid.tsx +++ b/ui/packages/plugin-grid/src/ObjectGrid.tsx @@ -3193,7 +3193,7 @@ export const ObjectGrid: React.FC = ({ `amountCol` is a column draft and carries neither `currency` nor `currencyConfig`. */} {typeof row[amountCol.accessorKey] === 'number' - ? formatCompactCurrency(row[amountCol.accessorKey], resolveFieldCurrency(objectSchema?.fields?.[amountCol.accessorKey], tenantCurrency), displayLocale) + ? formatCompactCurrency(row[amountCol.accessorKey], resolveFieldCurrency(objectSchema?.fields?.[amountCol.accessorKey], tenantCurrency, row), displayLocale) : (coerceToSafeValue(row[amountCol.accessorKey]) ?? '—')} )} diff --git a/ui/packages/plugin-grid/src/objectGridGrouping.ts b/ui/packages/plugin-grid/src/objectGridGrouping.ts index 54c14621972..2e09523fce4 100644 --- a/ui/packages/plugin-grid/src/objectGridGrouping.ts +++ b/ui/packages/plugin-grid/src/objectGridGrouping.ts @@ -16,6 +16,7 @@ import { resolveGridCellRendering, type GridCellFieldLike } from './cellRenderer import { useGroupedDataInOptionOrder, usableGroupingFields, type GroupOptionRanks, type ServerGroupSource } from './useGroupedData'; import { groupSearchOf, useServerGroupHeaders, useServerGroupRows, type ServerGroupLeaf } from './useServerGrouping'; import { normalizeColumns } from './objectGridColumns'; +import { honestGroupAggregations } from './recordCurrencyAggregates'; import type { useGridTranslation } from './objectGridTranslations'; import type { useObjectGridQueryInputs } from './objectGridQueryInputs'; import type { useObjectGridGroupingDecisions } from './objectGridGroupingDecisions'; @@ -276,6 +277,22 @@ export function useObjectGridGrouping({ } }, [groupRowQueryKey]); const groupReloadKey = `${refreshKey}:${invalidationNonce}`; + // [ADR-0144 D6] A money aggregate over a `record`-mode amount stands only in + // groups bucketed by that amount's code field; elsewhere it would add + // amounts in different currencies, so it is dropped before either the + // server or the client bucketing computes it. Keyed on what it reads, so a + // host that rebuilds `schema` every render does not re-key the header query. + const groupAggregationKey = JSON.stringify({ + aggregations: schema.aggregations ?? null, + grouping: usableGroupingFields(schema.grouping?.fields).map((gf) => gf.field), + }); + const groupAggregations = React.useMemo(() => { + const { aggregations, grouping } = JSON.parse(groupAggregationKey) as { + aggregations: ObjectGridSchema['aggregations'] | null; + grouping: string[]; + }; + return honestGroupAggregations(aggregations ?? undefined, objectFields, grouping); + }, [groupAggregationKey, objectFields]); const groupHeaders = useServerGroupHeaders({ enabled: serverGroupedFetch && !objectTypesPending && groupRowQuery !== null && !groupWhere.error, dataSource, @@ -285,7 +302,7 @@ export function useObjectGridGrouping({ // [objectui#11021] The search term, off the same resolved query every // group's row page is asked with: one pair on both queries. ...groupSearchOf(groupRowQuery), - aggregations: schema.aggregations, + aggregations: groupAggregations, objectFields, reloadKey: groupReloadKey, }); @@ -326,7 +343,7 @@ export function useObjectGridGrouping({ ? (schema.grouping ? { ...schema.grouping, fields: serverGroupingFields } : undefined) : groupingForRender, data, - schema.aggregations, + groupAggregations, groupValueFormatter, serverGroupSource, groupOptionRanks, diff --git a/ui/packages/plugin-grid/src/recordCurrencyAggregates.ts b/ui/packages/plugin-grid/src/recordCurrencyAggregates.ts new file mode 100644 index 00000000000..41932bded01 --- /dev/null +++ b/ui/packages/plugin-grid/src/recordCurrencyAggregates.ts @@ -0,0 +1,62 @@ +/** + * 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. + */ + +/** + * ADR-0144 D6 on the grid: a raw money aggregate over a `record`-mode amount + * is honest only inside one currency. A group bucketed by the amount's code + * field holds one currency, so its `sum` / `avg` / `min` / `max` stand; any + * other set of rows mixes currencies, and adding 100 USD to 100 EUR answers a + * number that is no amount at all. Consolidated totals belong to the home + * field, an ordinary `dynamic` amount that totals unchanged. + * + * The counts are currency-free and always stand. + */ +import { recordCurrencyField } from '@object-ui/i18n'; +import type { GroupAggregationConfig } from './useGroupedData'; + +const MONEY_AGGREGATES = new Set(['sum', 'avg', 'min', 'max']); + +/** Whether `type` aggregates the amounts themselves (not a count of rows). */ +export function isMoneyAggregate(type: string): boolean { + return MONEY_AGGREGATES.has(type); +} + +type CurrencyFieldLike = { currencyConfig?: unknown } | null | undefined; + +/** + * The code field a money aggregate over `field` needs to be grouped by, or + * `undefined` when `field` is not a `record`-mode amount (any aggregate over + * it is honest as it stands). + */ +export function requiredCurrencyGrouping( + field: string, + type: string, + objectFields: Readonly> | undefined, +): string | undefined { + if (!isMoneyAggregate(type)) return undefined; + return recordCurrencyField(objectFields?.[field] as Parameters[0]); +} + +/** + * The group-header aggregations the grid may draw: every configured one, + * minus each money aggregate over a `record`-mode amount whose code field is + * not among the grouping fields. Dropped before the rows are bucketed and + * before the server is asked, so neither path computes a cross-currency sum. + */ +export function honestGroupAggregations( + aggregations: GroupAggregationConfig[] | undefined, + objectFields: Readonly> | undefined, + groupingFields: readonly string[], +): GroupAggregationConfig[] | undefined { + if (!aggregations || aggregations.length === 0) return aggregations; + const kept = aggregations.filter(({ field, type }) => { + const code = requiredCurrencyGrouping(field, type, objectFields); + return code === undefined || groupingFields.includes(code); + }); + return kept.length === aggregations.length ? aggregations : kept; +} diff --git a/ui/packages/plugin-grid/src/useColumnSummary.ts b/ui/packages/plugin-grid/src/useColumnSummary.ts index 45edd7ae53b..84552a1a103 100644 --- a/ui/packages/plugin-grid/src/useColumnSummary.ts +++ b/ui/packages/plugin-grid/src/useColumnSummary.ts @@ -12,7 +12,7 @@ import type { ColumnSummary, ListViewGroupHeaderRow } from '@objectstack/spec/ui import { deriveColumnSummary } from '@objectstack/spec/ui'; import type { CurrencyConfig } from '@objectstack/spec/data'; import { resolveFieldScale } from '@objectstack/spec/data'; -import { useLocalization, useDisplayLocale, resolveFieldCurrency, createSafeTranslation } from '@object-ui/i18n'; +import { useLocalization, useDisplayLocale, resolveFieldCurrency, recordCurrencyField, createSafeTranslation } from '@object-ui/i18n'; import { formatCurrency, formatPercent, percentCellScale } from '@object-ui/fields'; /** @@ -622,6 +622,13 @@ export function useScopedColumnSummary( const targetField = config.field || col.field; + // [ADR-0144 D6] The footer spans every row, so it is never inside one + // currency: a sum / avg / min / max over a `record`-mode amount would add + // amounts in different currencies. It draws nothing for that column (the + // counts stand); the consolidated figure is the home field's, and a + // per-currency figure is the header of a group bucketed by the code. + if (!NON_NUMERIC_TYPES.has(config.type) && recordCurrencyField(fieldMetadata?.[targetField])) continue; + // objectui#12081 item 3 — the whole matched set's figure, read off the // server's answer through the spec's own read-back (the four derived // members included). The two percents come back as a ratio in `0..1`; From c5baf621174c72d473a27e401a67e70e00c86f52 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 20:43:27 +0000 Subject: [PATCH 09/26] wip(ui): Studio currency section, tests per widget Claude-Session: https://claude.ai/code/session_01Utp6CXtadYa5QXb2PbXPev Co-authored-by: Claude --- ...rencyConfigField.recordMode-27188.test.tsx | 99 ++++++++++ .../inspectors/CurrencyConfigField.tsx | 170 ++++++++++++++++++ .../inspectors/ObjectFieldInspector.tsx | 37 ++++ .../recordCurrency.faces-27188.test.tsx | 116 ++++++++++++ ...olveFieldCurrency.recordMode-27188.test.ts | 59 ++++++ ui/packages/i18n/src/namespaces/studio/en.ts | 11 ++ ui/packages/i18n/src/namespaces/studio/zh.ts | 11 ++ ...recordFields.recordCurrency-27188.test.tsx | 52 ++++++ .../recordCurrencySeed-27188.test.ts | 48 +++++ .../recordCurrencyAggregates-27188.test.tsx | 83 +++++++++ .../src/recordCurrencyAggregates.ts | 5 +- 11 files changed, 689 insertions(+), 2 deletions(-) create mode 100644 ui/packages/app-shell/src/views/metadata-admin/inspectors/CurrencyConfigField.recordMode-27188.test.tsx create mode 100644 ui/packages/app-shell/src/views/metadata-admin/inspectors/CurrencyConfigField.tsx create mode 100644 ui/packages/fields/src/__tests__/recordCurrency.faces-27188.test.tsx create mode 100644 ui/packages/i18n/src/__tests__/resolveFieldCurrency.recordMode-27188.test.ts create mode 100644 ui/packages/plugin-dashboard/src/__tests__/recordFields.recordCurrency-27188.test.tsx create mode 100644 ui/packages/plugin-form/src/__tests__/recordCurrencySeed-27188.test.ts create mode 100644 ui/packages/plugin-grid/src/__tests__/recordCurrencyAggregates-27188.test.tsx 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..07c9cf76b9e --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/CurrencyConfigField.recordMode-27188.test.tsx @@ -0,0 +1,99 @@ +// 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 } 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 }; +} + +const CODE = { type: 'text', label: 'Currency', valueDomain: 'iso_4217_currency' }; + +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('"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 2e162f7d955..b166587f00a 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 @@ -102,6 +102,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 { CelPredicateField } from '../CelPredicateField.js'; import type { CelLintIssue } from '../celAuthoring.js'; import { t, tFormat, type SupportedLocale } from '../i18n.js'; @@ -794,6 +801,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 @@ -1298,6 +1325,16 @@ export function ObjectFieldInspector({ /> )} + {(type === 'currency' || (type === 'formula' && def.returnType === 'currency')) && ( + patchDef({ currencyConfig: next })} + onCreateCodeField={createCurrencyCodeField} + disabled={readOnly} + tr={tr} + /> + )} {isTexty(type) && (
(s ?? '').replace(/ /g, ' '); + +function cell(row: Record | undefined): string { + const { container, unmount } = render( + + + , + ); + const out = text(container.textContent); + unmount(); + return out; +} + +describe('CurrencyCellRenderer — record mode', () => { + it('three rows in three currencies show three symbols', () => { + expect(cell({ amount: 1234.5, currency_code: 'USD' })).toBe('$1,234.50'); + expect(cell({ amount: 1234.5, currency_code: 'EUR' })).toBe('€1,234.50'); + expect(cell({ amount: 1234, currency_code: 'JPY' })).toBe('¥1,234'); + }); + + it('a row with no code shows a plain number, never the tenant CN¥', () => { + expect(cell({ amount: 1234.5 })).not.toContain('CN¥'); + expect(cell({ amount: 1234.5, currency_code: '' })).toBe('1,234.50'); + expect(cell(undefined)).toBe('1,234.50'); + }); +}); + +describe('CurrencyField — the amount box follows the selected code', () => { + function box(code: string | undefined) { + const { container, unmount } = render( + + {}} field={AMOUNT} dependentValues={{ currency_code: code }} /> + , + ); + const input = container.querySelector('input')!; + const out = { step: input.getAttribute('step'), adornment: text(container.querySelector('span')?.textContent ?? '') }; + unmount(); + return out; + } + + it('symbol and minor-unit step follow the record code', () => { + expect(box('USD')).toEqual({ step: '0.01', adornment: '$' }); + expect(box('JPY')).toEqual({ step: '1', adornment: '¥' }); + expect(box('KWD').step).toBe('0.001'); + }); + + it('no code: no symbol, and not the tenant one', () => { + expect(box(undefined).adornment).toBe(''); + }); +}); + +describe('the record-currency code select (CurrencyCodeField)', () => { + it('groups available (home, rated, used, current) ahead of all ISO 4217, without duplicates', () => { + const groups = currencyCodeGroups({ home: 'CNY', rated: ['USD'], used: ['EUR', 'USD'], current: 'JPY', iso: ['CNY', 'EUR', 'GBP', 'JPY', 'USD'] }); + expect(groups.available).toEqual(['CNY', 'USD', 'EUR', 'JPY']); + expect(groups.all).toEqual(['GBP']); + }); + + it('never offers a code the iso_4217_currency domain refuses', () => { + const groups = currencyCodeGroups({ home: 'XXX1', current: 'BTC', iso: ['USD'] }); + expect(groups.available).toEqual([]); + expect(isoCurrencyCodes()).toContain('USD'); + expect(isoCurrencyCodes()).not.toContain('BTC'); + }); + + it('a text field declaring the ISO 4217 domain renders the select, not a free-text box', () => { + render( + + {}} field={{ name: 'currency_code', type: 'text', valueDomain: 'iso_4217_currency' } as any} /> + , + ); + expect(screen.getByRole('combobox')).toHaveTextContent('EUR'); + expect(document.querySelector('input[type="text"]')).toBeNull(); + }); + + it('read-only shows the code with its name', () => { + render( + + {}} readonly field={{ name: 'currency_code', type: 'text', valueDomain: 'iso_4217_currency' } as any} /> + , + ); + expect(screen.getByText(/^EUR — /)).toBeInTheDocument(); + }); +}); diff --git a/ui/packages/i18n/src/__tests__/resolveFieldCurrency.recordMode-27188.test.ts b/ui/packages/i18n/src/__tests__/resolveFieldCurrency.recordMode-27188.test.ts new file mode 100644 index 00000000000..5b4cf8ed665 --- /dev/null +++ b/ui/packages/i18n/src/__tests__/resolveFieldCurrency.recordMode-27188.test.ts @@ -0,0 +1,59 @@ +/** + * 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. + */ + +/** + * ADR-0144 D2 — a `record`-mode amount's currency is the value of the sibling + * code field its `currencyConfig.currencyField` names, read off the record in + * scope. No value ⇒ no currency: never the tenant default, never a stray + * `defaultCurrency`, never a node-level `currency`. + */ + +import { describe, it, expect } from 'vitest'; +import { resolveFieldCurrency, recordCurrencyField } from '../currency'; + +const RECORD_MODE = { currencyConfig: { currencyMode: 'record' as const, currencyField: 'currency_code' } }; +const TENANT = 'CNY'; + +describe('resolveFieldCurrency — record mode (ADR-0144 D2)', () => { + it('reads the record\'s code field', () => { + expect(resolveFieldCurrency(RECORD_MODE, TENANT, { amount: 1, currency_code: 'EUR' })).toBe('EUR'); + expect(resolveFieldCurrency(RECORD_MODE, TENANT, { amount: 1, currency_code: 'JPY' })).toBe('JPY'); + }); + + it('no record, or a blank code, resolves to no currency — not the tenant default', () => { + expect(resolveFieldCurrency(RECORD_MODE, TENANT)).toBeUndefined(); + expect(resolveFieldCurrency(RECORD_MODE, TENANT, null)).toBeUndefined(); + expect(resolveFieldCurrency(RECORD_MODE, TENANT, { currency_code: '' })).toBeUndefined(); + expect(resolveFieldCurrency(RECORD_MODE, TENANT, { currency_code: null })).toBeUndefined(); + expect(resolveFieldCurrency(RECORD_MODE, TENANT, { other: 'USD' })).toBeUndefined(); + }); + + it('a node-level currency or a stray defaultCurrency does not outrank the record', () => { + const field = { + currency: 'USD', + defaultCurrency: 'GBP', + currencyConfig: { currencyMode: 'record' as const, currencyField: 'currency_code', defaultCurrency: 'CNY' }, + }; + expect(resolveFieldCurrency(field, TENANT, { currency_code: 'EUR' })).toBe('EUR'); + expect(resolveFieldCurrency(field, TENANT, {})).toBeUndefined(); + }); + + it('a record is ignored outside record mode', () => { + expect(resolveFieldCurrency({ currencyConfig: { currencyMode: 'dynamic' } }, TENANT, { currency_code: 'EUR' })).toBe('CNY'); + expect( + resolveFieldCurrency({ currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'JPY' } }, TENANT, { currency_code: 'EUR' }), + ).toBe('JPY'); + }); + + it('recordCurrencyField names the pointer only under record mode', () => { + expect(recordCurrencyField(RECORD_MODE)).toBe('currency_code'); + expect(recordCurrencyField({ currencyConfig: { currencyMode: 'record' } })).toBeUndefined(); + expect(recordCurrencyField({ currencyConfig: { currencyMode: 'fixed', currencyField: 'x' } })).toBeUndefined(); + expect(recordCurrencyField(undefined)).toBeUndefined(); + }); +}); diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts index 1dcf095a254..2efb18f19f7 100644 --- a/ui/packages/i18n/src/namespaces/studio/en.ts +++ b/ui/packages/i18n/src/namespaces/studio/en.ts @@ -433,6 +433,17 @@ const STUDIO_EN: Record = { // labels are NOT here: they are read from the spec's own describe() prose. 'designer.field.valueDomain': 'Value domain', 'designer.field.valueDomainNone': '— None —', + // ADR-0144 D2 — the currency section (`CurrencyConfigField`). + 'designer.field.currency.mode': 'Currency mode', + 'designer.field.currency.modeDynamic': 'Dynamic — the organization currency', + 'designer.field.currency.modeFixed': 'Fixed — one currency for this field', + 'designer.field.currency.modeRecord': 'Record — each record names its currency', + 'designer.field.currency.defaultCurrency': 'Currency (ISO 4217 code)', + 'designer.field.currency.currencyField': 'Currency field', + 'designer.field.currency.currencyFieldNone': '— Pick a currency field —', + 'designer.field.currency.createCodeField': 'Create currency field', + 'designer.field.currency.codeFieldLabel': 'Currency', + 'designer.field.currency.recordHint': 'Lists the text fields of this object whose value domain is ISO 4217 currency. Each record stores its own code there; the amount shows in that currency.', // Object basics (no-selection default inspector) 'designer.object.kind': 'Object', 'designer.object.section.basic': 'Basic info', diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index d2143b639a9..8d08d28c92f 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -368,6 +368,17 @@ const STUDIO_ZH: Record = { 'designer.field.maxLength': '最大长度', 'designer.field.valueDomain': '值域', 'designer.field.valueDomainNone': '— 无 —', + // ADR-0144 D2 — the currency section (`CurrencyConfigField`). + 'designer.field.currency.mode': '币种模式', + 'designer.field.currency.modeDynamic': '动态 — 组织币种', + 'designer.field.currency.modeFixed': '固定 — 本字段一种币种', + 'designer.field.currency.modeRecord': '按记录 — 每条记录自带币种', + 'designer.field.currency.defaultCurrency': '币种(ISO 4217 代码)', + 'designer.field.currency.currencyField': '币种字段', + 'designer.field.currency.currencyFieldNone': '— 选择币种字段 —', + 'designer.field.currency.createCodeField': '创建币种字段', + 'designer.field.currency.codeFieldLabel': '币种', + 'designer.field.currency.recordHint': '列出本对象中值域为 ISO 4217 币种的文本字段。每条记录在其中保存自己的币种代码,金额按该币种显示。', // Object basics (no-selection default inspector) 'designer.object.kind': '对象', 'designer.object.section.basic': '基础信息', diff --git a/ui/packages/plugin-dashboard/src/__tests__/recordFields.recordCurrency-27188.test.tsx b/ui/packages/plugin-dashboard/src/__tests__/recordFields.recordCurrency-27188.test.tsx new file mode 100644 index 00000000000..61e1a3f976a --- /dev/null +++ b/ui/packages/plugin-dashboard/src/__tests__/recordFields.recordCurrency-27188.test.tsx @@ -0,0 +1,52 @@ +/** + * 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. + */ + +/** + * ADR-0144 D2 on the dashboard's table cells and record drawer: a `record`-mode + * amount is drawn in its own row's currency, and with no row code as a plain + * number — never the tenant default. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import * as React from 'react'; +import { render, cleanup } from '@testing-library/react'; +import { buildFieldMeta, renderFieldValue } from '../recordFields'; + +afterEach(cleanup); + +const DEF = { type: 'currency', currencyConfig: { currencyMode: 'record', currencyField: 'currency_code' } }; +const text = (node: React.ReactNode) => { + const { container, unmount } = render(<>{node}); + const out = (container.textContent ?? '').replace(/ /g, ' '); + unmount(); + return out; +}; + +describe('dashboard record cells — record mode', () => { + const meta = buildFieldMeta({ accessorKey: 'amount', label: 'Amount', def: DEF }); + + it('carries the record-mode config and no field-level currency', () => { + expect(meta.currency).toBeUndefined(); + expect(meta.currencyConfig).toEqual(DEF.currencyConfig); + }); + + it('draws each row in its own currency', () => { + expect(text(renderFieldValue(1234.5, meta, 'CNY', 'en-US', { currency_code: 'EUR' }))).toBe('€1,234.50'); + expect(text(renderFieldValue(1234, meta, 'CNY', 'en-US', { currency_code: 'JPY' }))).toBe('¥1,234'); + }); + + it('no row code: a plain number, not CN¥', () => { + expect(text(renderFieldValue(1234.5, meta, 'CNY', 'en-US', {}))).toBe('1,234.50'); + expect(text(renderFieldValue(1234.5, meta, 'CNY', 'en-US'))).toBe('1,234.50'); + }); + + it('a dynamic amount is untouched: the tenant currency', () => { + const home = buildFieldMeta({ accessorKey: 'amount_home', label: 'Home', def: { type: 'currency', currencyConfig: { currencyMode: 'dynamic' } } }); + expect(home.currencyConfig).toBeUndefined(); + }); +}); diff --git a/ui/packages/plugin-form/src/__tests__/recordCurrencySeed-27188.test.ts b/ui/packages/plugin-form/src/__tests__/recordCurrencySeed-27188.test.ts new file mode 100644 index 00000000000..0f08c7da713 --- /dev/null +++ b/ui/packages/plugin-form/src/__tests__/recordCurrencySeed-27188.test.ts @@ -0,0 +1,48 @@ +/** + * 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. + */ + +/** + * ADR-0144 D8 rule 4 — a create form pre-fills a record-currency code field + * (the field a `record`-mode amount's `currencyField` names) with the + * organization currency, as a VALUE the user submits; the server never fills + * one in. Only that field, only with a known organization currency, and never + * over the author's own `defaultValue` or the caller's record. + */ + +import { describe, it, expect } from 'vitest'; +import { schemaDefaultValues, seedCreateValues } from '../schemaDefaults'; + +const INVOICE = { + fields: { + currency_code: { type: 'text', valueDomain: 'iso_4217_currency' }, + amount: { type: 'currency', currencyConfig: { currencyMode: 'record', currencyField: 'currency_code' } }, + // An ISO-domain text field no amount points at is not the record currency. + billing_currency: { type: 'text', valueDomain: 'iso_4217_currency' }, + total: { type: 'currency', currencyConfig: { currencyMode: 'dynamic' } }, + }, +}; + +describe('create-form seeding of the record currency (ADR-0144 D8 rule 4)', () => { + it('pre-fills the pointed-at code field with the organization currency', () => { + expect(schemaDefaultValues(INVOICE, { homeCurrency: 'CNY' })).toEqual({ currency_code: 'CNY' }); + }); + + it('seeds nothing while the organization currency is unset', () => { + expect(schemaDefaultValues(INVOICE, { homeCurrency: undefined })).toEqual({}); + expect(schemaDefaultValues(INVOICE)).toEqual({}); + }); + + it('the author\'s defaultValue wins', () => { + const schema = { fields: { ...INVOICE.fields, currency_code: { ...INVOICE.fields.currency_code, defaultValue: 'EUR' } } }; + expect(schemaDefaultValues(schema, { homeCurrency: 'CNY' }).currency_code).toBe('EUR'); + }); + + it('the caller\'s record wins', () => { + expect(seedCreateValues(INVOICE, { currency_code: 'JPY' }, { homeCurrency: 'CNY' }).currency_code).toBe('JPY'); + }); +}); diff --git a/ui/packages/plugin-grid/src/__tests__/recordCurrencyAggregates-27188.test.tsx b/ui/packages/plugin-grid/src/__tests__/recordCurrencyAggregates-27188.test.tsx new file mode 100644 index 00000000000..12087680490 --- /dev/null +++ b/ui/packages/plugin-grid/src/__tests__/recordCurrencyAggregates-27188.test.tsx @@ -0,0 +1,83 @@ +/** + * 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. + */ + +/** + * ADR-0144 D6 on the grid: a money aggregate over a `record`-mode amount + * stands only inside one currency — a group bucketed by the amount's code + * field. The footer spans every row, so it draws no sum for such a column; the + * counts stand, and a `dynamic` (home) amount sums as it always did. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import * as React from 'react'; +import { renderHook, cleanup } from '@testing-library/react'; +import { I18nProvider, LocalizationProvider } from '@object-ui/i18n'; +import { useColumnSummary } from '../useColumnSummary'; +import { honestGroupAggregations, requiredCurrencyGrouping } from '../recordCurrencyAggregates'; + +afterEach(cleanup); + +function Providers({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +const META: NonNullable[2]> = { + amount: { type: 'currency', currencyConfig: { currencyMode: 'record', currencyField: 'currency_code' } }, + amount_home: { type: 'currency', currencyConfig: { currencyMode: 'dynamic' } }, +}; +const ROWS = [ + { amount: 100, amount_home: 720, currency_code: 'USD' }, + { amount: 100, amount_home: 780, currency_code: 'EUR' }, +]; + +function footer(field: string, summary: string): string | undefined { + const columns: any[] = [{ field, summary }]; + const { result } = renderHook(() => useColumnSummary(columns, ROWS, META), { wrapper: Providers }); + return result.current.summaries.get(field)?.label; +} + +describe('grid footer over a record-mode amount', () => { + it('draws no sum / avg / min / max — they would add USD to EUR', () => { + for (const s of ['sum', 'avg', 'min', 'max']) expect(footer('amount', s)).toBeUndefined(); + }); + + it('a count still stands', () => { + expect(footer('amount', 'count')).toBe('Count: 2'); + }); + + it('the home field sums as before', () => { + expect(footer('amount_home', 'sum')).toBe('Sum: CN¥1,500.00'); + }); +}); + +describe('group-header aggregations over a record-mode amount', () => { + const AGGS = [ + { field: 'amount', type: 'sum' as const }, + { field: 'amount', type: 'count' as const }, + { field: 'amount_home', type: 'sum' as const }, + ]; + + it('stand when the grid is grouped by the amount\'s code field', () => { + expect(honestGroupAggregations(AGGS, META, ['currency_code'])).toBe(AGGS); + expect(honestGroupAggregations(AGGS, META, ['region', 'currency_code'])).toBe(AGGS); + }); + + it('drop the money aggregate when grouped by anything else', () => { + expect(honestGroupAggregations(AGGS, META, ['region'])).toEqual([AGGS[1], AGGS[2]]); + }); + + it('only a money aggregate over a record-mode amount needs the code grouping', () => { + expect(requiredCurrencyGrouping('amount', 'sum', META)).toBe('currency_code'); + expect(requiredCurrencyGrouping('amount', 'count_distinct', META)).toBeUndefined(); + expect(requiredCurrencyGrouping('amount_home', 'sum', META)).toBeUndefined(); + }); +}); diff --git a/ui/packages/plugin-grid/src/recordCurrencyAggregates.ts b/ui/packages/plugin-grid/src/recordCurrencyAggregates.ts index 41932bded01..eaeb7d7ab27 100644 --- a/ui/packages/plugin-grid/src/recordCurrencyAggregates.ts +++ b/ui/packages/plugin-grid/src/recordCurrencyAggregates.ts @@ -26,7 +26,8 @@ export function isMoneyAggregate(type: string): boolean { return MONEY_AGGREGATES.has(type); } -type CurrencyFieldLike = { currencyConfig?: unknown } | null | undefined; +/** Any object field def; only its `currencyConfig` is read. */ +type CurrencyFieldLike = object | null | undefined; /** * The code field a money aggregate over `field` needs to be grouped by, or @@ -39,7 +40,7 @@ export function requiredCurrencyGrouping( objectFields: Readonly> | undefined, ): string | undefined { if (!isMoneyAggregate(type)) return undefined; - return recordCurrencyField(objectFields?.[field] as Parameters[0]); + return recordCurrencyField(objectFields?.[field] as Parameters[0] | undefined); } /** From d59c3cbe901ba26509cb572596af21729d456b2b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 20:44:26 +0000 Subject: [PATCH 10/26] chore(changeset): record-mode currency display Claude-Session: https://claude.ai/code/session_01Utp6CXtadYa5QXb2PbXPev Co-authored-by: Claude --- .changeset/27188-record-currency-display.md | 26 +++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .changeset/27188-record-currency-display.md diff --git a/.changeset/27188-record-currency-display.md b/.changeset/27188-record-currency-display.md new file mode 100644 index 00000000000..2803019ee86 --- /dev/null +++ b/.changeset/27188-record-currency-display.md @@ -0,0 +1,26 @@ +--- +'@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-dashboard': 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 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. +- **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. +- **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. From da1ebc7b4ee160abfb179d77e003149d9b8751c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 21:09:08 +0000 Subject: [PATCH 11/26] =?UTF-8?q?fix(i18n):=20resolveFieldCurrency=20accep?= =?UTF-8?q?ts=20the=20record=20currency=20mode=20=E2=80=94=20the=20field-l?= =?UTF-8?q?evel=20answer=20never=20reads=20a=20code=20from=20a=20record-mo?= =?UTF-8?q?de=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01Utp6CbfVSc1hXtxLUPHZ2f Co-authored-by: Claude --- .changeset/27184-currency-record-mode.md | 1 + .../i18n/src/__tests__/resolveFieldCurrency.test.ts | 7 +++++++ ui/packages/i18n/src/currency.ts | 8 +++++++- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/.changeset/27184-currency-record-mode.md b/.changeset/27184-currency-record-mode.md index 43e3071d6ea..79a5b99b31a 100644 --- a/.changeset/27184-currency-record-mode.md +++ b/.changeset/27184-currency-record-mode.md @@ -1,5 +1,6 @@ --- '@objectstack/spec': minor +'@object-ui/i18n': minor --- feat(spec): `currencyMode: 'record'` + `currencyField` — a record-mode amount points at a sibling ISO 4217 code field (ADR-0144 D2) diff --git a/ui/packages/i18n/src/__tests__/resolveFieldCurrency.test.ts b/ui/packages/i18n/src/__tests__/resolveFieldCurrency.test.ts index ca5ff20e760..fb4a5bc2337 100644 --- a/ui/packages/i18n/src/__tests__/resolveFieldCurrency.test.ts +++ b/ui/packages/i18n/src/__tests__/resolveFieldCurrency.test.ts @@ -78,6 +78,13 @@ describe('currencyMode decides whether currencyConfig.defaultCurrency is read (o expect(resolveFieldCurrency({ currencyConfig: parsed }, TENANT)).toBe('USD'); }); + it('record: the field names no currency of its own, so the config never supplies one', () => { + const parsed = CurrencyConfigSchema.parse({ currencyMode: 'record', currencyField: 'currency_code' }); + expect(resolveFieldCurrency({ currencyConfig: parsed }, TENANT)).toBe('USD'); + expect(resolveFieldCurrency({ currencyConfig: { ...parsed, defaultCurrency: 'EUR' } }, TENANT)).toBe('USD'); + expect(resolveFieldCurrency({ currencyConfig: parsed })).toBeUndefined(); + }); + it('parsed-empty: the materialized shape as a renderer receives it', () => { expect( resolveFieldCurrency({ currencyConfig: { currencyMode: 'dynamic', defaultCurrency: 'CNY' } }, TENANT), diff --git a/ui/packages/i18n/src/currency.ts b/ui/packages/i18n/src/currency.ts index 075de0ccad9..1cf4656e6d2 100644 --- a/ui/packages/i18n/src/currency.ts +++ b/ui/packages/i18n/src/currency.ts @@ -42,6 +42,12 @@ * its own selected code, that code is the one to win, ahead of the tenant * default. * + * `currencyMode: 'record'` (ADR-0144 D2) also skips step 2: the field has no + * currency of its own — each record's code lives in the sibling field + * `currencyConfig.currencyField` names, which only a renderer holding the row + * can read. This field-level answer therefore never claims a code from the + * config; reading the row's code is the per-row display's job. + * * This is the single resolution every field / measure / cell renderer shares - * ending the per-renderer drift where some read only `currency`, others only * `defaultCurrency`, and others `currencyConfig`. It lives in `@object-ui/i18n` @@ -53,7 +59,7 @@ export function resolveFieldCurrency( | { currency?: string; defaultCurrency?: string; - currencyConfig?: { currencyMode?: 'dynamic' | 'fixed'; defaultCurrency?: string }; + currencyConfig?: { currencyMode?: 'dynamic' | 'fixed' | 'record'; defaultCurrency?: string; currencyField?: string }; } | null | undefined, From f174d4b0c07b858058fc3720ea38147646e254bc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 21:18:39 +0000 Subject: [PATCH 12/26] test(platform-objects): move the metadata-form catalog pins for the currencyField row The field form's new `currencyConfig.currencyField` row is one more unwalked composite child (10 -> 11) and one more translated row label in each locale's positive control (695 -> 696). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Utp6CbfVSc1hXtxLUPHZ2f --- .../apps/translations/field-panel-echo-decisions.test.ts | 6 ++++-- .../object-lifecycle-panel-echo-decisions.test.ts | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/platform-objects/src/apps/translations/field-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/field-panel-echo-decisions.test.ts index 7d4d617c6da..280c7c7ccbe 100644 --- a/packages/platform-objects/src/apps/translations/field-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/field-panel-echo-decisions.test.ts @@ -708,9 +708,11 @@ describe('#19403 round 4 — the blind spot, FOURTH shape: a type-level coverage // locales, so the echo rule above reads them translated, not undecided. // 10 since #23735: the `referenceDeletedWhen` composite (`field`, `value`), // whose four string leaves that card authored in all three locales. - expect(UNWALKED_CHILDREN.length).toBe(10); + // 11 since #27184: `currencyConfig.currencyField` (the record mode's code + // field), its two string leaves authored in all three locales. + expect(UNWALKED_CHILDREN.length).toBe(11); for (const child of [ - 'currencyConfig.currencyMode', 'currencyConfig.defaultCurrency', 'storage.notNull', + 'currencyConfig.currencyMode', 'currencyConfig.defaultCurrency', 'currencyConfig.currencyField', 'storage.notNull', 'referenceDeletedWhen.field', 'referenceDeletedWhen.value', ]) { expect(UNWALKED_CHILDREN).toContain(child); diff --git a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts index 7036b1ba7e5..ce35b19f2d9 100644 --- a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts @@ -1238,7 +1238,9 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => { // 695 since #27069: the object form's `relatedLists` row and the // `fieldGroups` repeater's `relatedList` sub-row — two row labels, // authored in all three locales. - expect(translated.length, `${locale} positive control`).toBe(695); + // 696 since #27184: the field form's `currencyConfig.currencyField` row + // (record currency mode) — one row label, authored in all three locales. + expect(translated.length, `${locale} positive control`).toBe(696); } // ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the // all-three predicate returns 0 while the per-locale one returns 1, so the From bf7f2b74eb28b6da52d9222ece2f28f4438d91be Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 21:37:05 +0000 Subject: [PATCH 13/26] fix(i18n): a record-mode field resolves no currency, never the tenant default Under `currencyMode: 'record'` each record names its own code in the sibling field, which only a per-row renderer can read. The field-level answer is now `undefined` outright, so a record with a blank code shows no symbol instead of a currency it never named. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Utp6CbfVSc1hXtxLUPHZ2f --- .../i18n/src/__tests__/resolveFieldCurrency.test.ts | 7 ++++--- ui/packages/i18n/src/currency.ts | 12 +++++++----- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/ui/packages/i18n/src/__tests__/resolveFieldCurrency.test.ts b/ui/packages/i18n/src/__tests__/resolveFieldCurrency.test.ts index fb4a5bc2337..6360c005f30 100644 --- a/ui/packages/i18n/src/__tests__/resolveFieldCurrency.test.ts +++ b/ui/packages/i18n/src/__tests__/resolveFieldCurrency.test.ts @@ -78,10 +78,11 @@ describe('currencyMode decides whether currencyConfig.defaultCurrency is read (o expect(resolveFieldCurrency({ currencyConfig: parsed }, TENANT)).toBe('USD'); }); - it('record: the field names no currency of its own, so the config never supplies one', () => { + it('record: the field names no currency of its own, so nothing supplies one, the tenant default included', () => { const parsed = CurrencyConfigSchema.parse({ currencyMode: 'record', currencyField: 'currency_code' }); - expect(resolveFieldCurrency({ currencyConfig: parsed }, TENANT)).toBe('USD'); - expect(resolveFieldCurrency({ currencyConfig: { ...parsed, defaultCurrency: 'EUR' } }, TENANT)).toBe('USD'); + expect(resolveFieldCurrency({ currencyConfig: parsed }, TENANT)).toBeUndefined(); + expect(resolveFieldCurrency({ currencyConfig: { ...parsed, defaultCurrency: 'EUR' } }, TENANT)).toBeUndefined(); + expect(resolveFieldCurrency({ defaultCurrency: 'EUR', currencyConfig: parsed }, TENANT)).toBeUndefined(); expect(resolveFieldCurrency({ currencyConfig: parsed })).toBeUndefined(); }); diff --git a/ui/packages/i18n/src/currency.ts b/ui/packages/i18n/src/currency.ts index 1cf4656e6d2..50f9d0d03e7 100644 --- a/ui/packages/i18n/src/currency.ts +++ b/ui/packages/i18n/src/currency.ts @@ -42,11 +42,12 @@ * its own selected code, that code is the one to win, ahead of the tenant * default. * - * `currencyMode: 'record'` (ADR-0144 D2) also skips step 2: the field has no - * currency of its own — each record's code lives in the sibling field - * `currencyConfig.currencyField` names, which only a renderer holding the row - * can read. This field-level answer therefore never claims a code from the - * config; reading the row's code is the per-row display's job. + * `currencyMode: 'record'` (ADR-0144 D2) returns `undefined` outright: the + * field has no currency of its own — each record's code lives in the sibling + * field `currencyConfig.currencyField` names, which only a renderer holding the + * row can read. No step applies, the tenant default included: a record whose + * code is blank shows no symbol rather than a currency it never named. Reading + * the row's code is the per-row display's job. * * This is the single resolution every field / measure / cell renderer shares - * ending the per-renderer drift where some read only `currency`, others only @@ -66,6 +67,7 @@ export function resolveFieldCurrency( tenantDefault?: string, ): string | undefined { const config = field?.currencyConfig; + if (config?.currencyMode === 'record') return undefined; return ( field?.currency || (config?.currencyMode === 'fixed' ? config.defaultCurrency : undefined) || From cc4030750dbc5e9cf18b94d0a84fae46f71c13e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 21:51:16 +0000 Subject: [PATCH 14/26] 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 --- .changeset/27188-record-currency-display.md | 4 +- ...ter-builder-record-currency-27188.test.tsx | 195 ++++++++++++++++++ .../src/custom/currency-code-picker.tsx | 193 +++++++++++++++++ .../components/src/custom/filter-builder.tsx | 125 +++++++++-- .../src/custom/filterRecordCurrency.ts | 117 +++++++++++ ui/packages/components/src/custom/index.ts | 2 + .../form-used-currency-codes-27188.test.tsx | 150 ++++++++++++++ .../components/src/renderers/form/form.tsx | 11 +- .../src/renderers/form/usedCurrencyCodes.ts | 106 ++++++++++ .../recordCurrency.faces-27188.test.tsx | 26 ++- .../fields/src/widgets/CurrencyCodeField.tsx | 164 ++++----------- ui/packages/i18n/src/locales/ar.ts | 2 + ui/packages/i18n/src/locales/de.ts | 2 + ui/packages/i18n/src/locales/en.ts | 2 + ui/packages/i18n/src/locales/es.ts | 2 + ui/packages/i18n/src/locales/fr.ts | 2 + ui/packages/i18n/src/locales/ja.ts | 2 + ui/packages/i18n/src/locales/ko.ts | 2 + ui/packages/i18n/src/locales/pt.ts | 2 + ui/packages/i18n/src/locales/ru.ts | 2 + ui/packages/i18n/src/locales/zh.ts | 2 + ui/packages/plugin-list/src/ListView.tsx | 14 +- ...terGroupToAST.recordCurrency-27188.test.ts | 29 +++ 23 files changed, 1009 insertions(+), 147 deletions(-) create mode 100644 ui/packages/components/src/__tests__/filter-builder-record-currency-27188.test.tsx create mode 100644 ui/packages/components/src/custom/currency-code-picker.tsx create mode 100644 ui/packages/components/src/custom/filterRecordCurrency.ts create mode 100644 ui/packages/components/src/renderers/form/__tests__/form-used-currency-codes-27188.test.tsx create mode 100644 ui/packages/components/src/renderers/form/usedCurrencyCodes.ts create mode 100644 ui/packages/plugin-list/src/__tests__/convertFilterGroupToAST.recordCurrency-27188.test.ts diff --git a/.changeset/27188-record-currency-display.md b/.changeset/27188-record-currency-display.md index 2803019ee86..2a771d1835b 100644 --- a/.changeset/27188-record-currency-display.md +++ b/.changeset/27188-record-currency-display.md @@ -4,6 +4,7 @@ '@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-detail': patch '@object-ui/plugin-gantt': patch @@ -18,7 +19,8 @@ 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 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. +- **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. - **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. 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 7be93bedf86..cdd95dd8acd 100644 --- a/ui/packages/components/src/custom/filter-builder.tsx +++ b/ui/packages/components/src/custom/filter-builder.tsx @@ -35,9 +35,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" @@ -135,6 +143,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 @@ -1149,6 +1164,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', @@ -1455,11 +1476,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(), @@ -1474,9 +1512,30 @@ function FilterBuilder({ } const removeCondition = (conditionId: string) => { + const companionId = currencyCompanionId(conditionId) + handleChange({ + ...filterGroup, + conditions: filterGroup.conditions.filter((c) => c.id !== conditionId && c.id !== companionId), + }) + } + + /** 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: filterGroup.conditions.filter((c) => c.id !== conditionId), + conditions: held + ? filterGroup.conditions.map((c) => (c.id === companionId ? companion : c)) + : filterGroup.conditions.flatMap((c) => (c.id === conditionId ? [c, companion] : [c])), }) } @@ -1518,6 +1577,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", @@ -1641,9 +1703,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 @@ -2066,13 +2127,16 @@ function FilterBuilder({
{t('filterBuilder.where')} - {filterGroup.conditions.length > 1 && ( + {visibleConditions.length > 1 && ( @@ -2092,11 +2156,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 (
-
+
-
+