From 5f049697c0270aef7b4c1a4cb6f73b08f32d6b82 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 13:21:48 +0000 Subject: [PATCH] feat(types): form fields declare scale and currencyConfig by spec reference; catalog and guide move to the spec spellings (objectui#11070 round 13) Round 13 of objectui#11070, the three class (ii) pairs round 12's M3 re-run left, on the seat's answers A, B and C. A. FormField / FormFieldSchema declare `scale` by reference to @objectstack/spec FieldSchema.scale, and carry the spec's one cross-key rule on it: `scale` on a `currency` entry is refused at `scale` with the spec's own text (pinned equal to the spec's issue). B. The two currency catalog documents move from `currency` to `currencyConfig { currencyMode: 'fixed', defaultCurrency }`, and the form-field faces declare `currencyConfig` by reference to the spec, which the moved documents need to parse. `currency` stays undeclared. C. The schema-rendering guide's action:button example and its sentence name the endpoint `target`, per the spec's action:button row. Claude-Session: https://claude.ai/code/session_01DBZ9bntPZ7VKyQNtJeNsgw Co-authored-by: Claude --- .../11070-form-field-scale-currency-config.md | 16 +++ content/docs/guide/schema-rendering.md | 7 +- .../fields-currency/euro-currency.json | 5 +- .../schemas/fields-currency/usd-currency.json | 5 +- ...ields-scale-currency-config-11070.test.tsx | 134 ++++++++++++++++++ .../__tests__/form-field-zod-coverage.test.ts | 4 + .../strict-face-read-keys-11070.test.ts | 92 +++++++++++- packages/types/src/form.ts | 29 ++++ packages/types/src/zod/form.zod.ts | 37 +++++ 9 files changed, 322 insertions(+), 7 deletions(-) create mode 100644 .changeset/11070-form-field-scale-currency-config.md create mode 100644 examples/schema-catalog/test/fields-scale-currency-config-11070.test.tsx diff --git a/.changeset/11070-form-field-scale-currency-config.md b/.changeset/11070-form-field-scale-currency-config.md new file mode 100644 index 0000000000..21f17cc111 --- /dev/null +++ b/.changeset/11070-form-field-scale-currency-config.md @@ -0,0 +1,16 @@ +--- +'@object-ui/types': minor +--- + +A form field declares `scale` and `currencyConfig` on both faces, by reference to `@objectstack/spec`'s `FieldSchema`, so the strict authoring face accepts the decimal places and the fixed currency the field widgets read (objectui#11070). + +A hand-authored `form` hands each field widget the `fields[]` entry itself as its metadata carrier. The `number`, `percent`, `formula` and `summary` widgets read `scale` off it, and the `currency` widget reads `currencyConfig` through `resolveFieldCurrency`. Both keys always drew; only the declarations were missing. Each is now the spec member itself, value checks included. + +- **TypeScript.** `FormField` gains `scale?: SpecField['scale']` and `currencyConfig?: SpecField['currencyConfig']`. Both used to fall to the index signature's `any`. A typed literal with a value of the wrong type (a string `scale`, a `currencyMode` outside `fixed` / `dynamic`) is now a compile error. +- **zod (`@object-ui/types/zod`).** WIDENS on the strict authoring face (`StrictAnyComponentSchema`): `fields[].scale` and `fields[].currencyConfig` used to be refused with `unrecognized_keys`, and now parse. NARROWS on the tolerant face (`safeValidateSchema`), where both keys used to be stripped unjudged: a `scale` that is not a whole number from 0 to 100 is now refused, and so is a `currencyConfig` the spec refuses (a `currencyMode` outside `fixed` / `dynamic`, a `defaultCurrency` that is not three characters, or a member the spec's config does not declare, such as the removed `precision`). The values are now kept. No spec default (`dynamic`, `CNY`) is written into the document. +- **`scale` on a `currency` field is refused, on both faces.** The spec's `FieldSchema` refuses it there (objectstack-ai/objectstack#19629): an amount's decimal places are its currency's ISO 4217 minor unit. The `currency` widget never read it. The rule is keyed on `type`, as the spec keys it, and the refusal is the spec's own text. This is the only one of the spec's cross-key rules this face carries. The others (`rows`, `minLength` / `maxLength`, `multiple`) key on the spec's field-type sets, and a form field's `type` is a widget id (`input`, `select`, …) those sets do not hold. +- **Not declared: `currency` on a form field.** The spec's `FieldSchema` refuses it as a field key, so the strict face still refuses it. `resolveFieldCurrency` still reads it at runtime. Declare a fixed currency as `currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'EUR' }`. + +BREAKING (`@object-ui/types`), for a document whose form field carries a `scale` or a `currencyConfig` the spec refuses, or a `scale` on a `currency` field: it no longer validates on the tolerant face. Fix the value, or delete `scale` from the `currency` field. (The bump is `minor` by this repo's release model: objectui's major follows the `@objectstack` family major, and its own breaking changes ship as `minor` with the breaking semantics stated here.) + +**Clause-②: yes (widening)**: both keys widen the strict face, and their spec value checks, plus the spec's `currency` rule on `scale`, narrow the tolerant face. diff --git a/content/docs/guide/schema-rendering.md b/content/docs/guide/schema-rendering.md index 33b2860037..4fa66442f9 100644 --- a/content/docs/guide/schema-rendering.md +++ b/content/docs/guide/schema-rendering.md @@ -320,7 +320,7 @@ written flat on the node: "name": "call_api", "label": "Click Me", "actionType": "api", - "endpoint": "/api/action", + "target": "/api/action", "method": "POST" } } @@ -334,8 +334,9 @@ throws on the first click: "Expected `onClick` listener to be a function, instea value of `object` type." The execution type is `actionType`, and the built-in vocabulary is `script` | `url` | `modal` | `flow` | `api` | `form` (plus objectui's `navigation` alias) — anything else must be a handler your host registered on `ActionProvider`. `ajax` -is neither. And the endpoint key is `endpoint`, with `method`; `api` is not a key any -action renderer forwards. +is neither. And an `api` action's endpoint is its `target`, with the verb in `method`: the +spec's `action:button` row refuses `endpoint` and names `target` in its place, and `api` is +not a key any action renderer forwards. ## Performance Optimization diff --git a/examples/schema-catalog/src/schemas/fields-currency/euro-currency.json b/examples/schema-catalog/src/schemas/fields-currency/euro-currency.json index f3b4026124..d1763805f0 100644 --- a/examples/schema-catalog/src/schemas/fields-currency/euro-currency.json +++ b/examples/schema-catalog/src/schemas/fields-currency/euro-currency.json @@ -8,7 +8,10 @@ "label": "Price (EUR)", "type": "currency", "placeholder": "€0.00", - "currency": "EUR" + "currencyConfig": { + "currencyMode": "fixed", + "defaultCurrency": "EUR" + } } ] } diff --git a/examples/schema-catalog/src/schemas/fields-currency/usd-currency.json b/examples/schema-catalog/src/schemas/fields-currency/usd-currency.json index f3289d644c..20b4f1c3dc 100644 --- a/examples/schema-catalog/src/schemas/fields-currency/usd-currency.json +++ b/examples/schema-catalog/src/schemas/fields-currency/usd-currency.json @@ -8,7 +8,10 @@ "label": "Amount", "type": "currency", "placeholder": "$0.00", - "currency": "USD" + "currencyConfig": { + "currencyMode": "fixed", + "defaultCurrency": "USD" + } } ] } diff --git a/examples/schema-catalog/test/fields-scale-currency-config-11070.test.tsx b/examples/schema-catalog/test/fields-scale-currency-config-11070.test.tsx new file mode 100644 index 0000000000..4c4c30c1eb --- /dev/null +++ b/examples/schema-catalog/test/fields-scale-currency-config-11070.test.tsx @@ -0,0 +1,134 @@ +/** + * 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 `scale` and currency catalog examples parse on the strict authoring + * face, and the currency examples declare their currency the spec's way — + * objectui#11070 round 13. + * + * At the round's base `StrictAnyComponentSchema` refused all five documents + * below by name, although the widget each one renders reads the key: + * + * - the three `scale` documents, because the form-field face did not declare + * `scale`. It now carries `@objectstack/spec`'s `FieldSchema.scale` by + * reference (the faces are pinned in + * `packages/types/src/__tests__/strict-face-read-keys-11070.test.ts`; what + * each document renders is pinned by `fields-number-scale-11413` and + * `fields-percent-scale-11255` beside this file); + * - the two currency documents, because they wrote `currency: 'EUR'` / + * `'USD'` on the field, which the spec's `FieldSchema` refuses as a field + * key. The seat's ruling keeps that read runtime-only and moves the + * documents to the spec's declaration, `currencyConfig: { currencyMode: + * 'fixed', defaultCurrency }`, which `resolveFieldCurrency` reads. + * + * The currency block is driven through the real `SchemaRenderer`, the real + * form renderer and the real `@object-ui/fields` registration: the symbol the + * widget draws, and the input step the currency's minor unit sets. Its two + * controls show the declaration is what decides both. In `dynamic` mode the + * same entry draws no symbol, because that mode reads the tenant default and + * none is set here. With `JPY` it draws ¥ and steps by whole yen. + * + * Module-scope import of `@object-ui/fields`, not `beforeAll` (AGENTS.md + * §测试纪律): the widgets sit behind `React.lazy`, so paying the import here + * keeps the lazy factories out of every test timeout budget. + */ +import { describe, it, expect } from 'vitest'; +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import '@object-ui/components'; +import '@object-ui/fields'; +import { SchemaRenderer } from '@object-ui/react'; +import { StrictAnyComponentSchema } from '@object-ui/types/zod'; +import { getExample } from '../src/index.js'; + +interface CurrencyConfig { + currencyMode?: string; + defaultCurrency?: string; +} +interface FieldEntry { + name: string; + type: string; + scale?: number; + currency?: string; + currencyConfig?: CurrencyConfig; +} +interface FormNode { + type: 'form'; + fields: FieldEntry[]; + [key: string]: unknown; +} + +const ROUND_13_DOCUMENTS = [ + 'fields-number/with-decimal-places', + 'fields-percent/required-percent', + 'fields-percent/with-decimal-places', + 'fields-currency/euro-currency', + 'fields-currency/usd-currency', +] as const; + +/** Every strict-face issue as `code path: message`, so a red run says what broke. */ +function strictIssues(schema: unknown): string[] { + const result = StrictAnyComponentSchema.safeParse(schema); + return result.success + ? [] + : result.error.issues.map((i) => `${i.code} ${i.path.map(String).join('.')}: ${i.message}`); +} + +/** A deep copy of a catalog form: the control cases below edit their own. */ +function example(id: string): FormNode { + return JSON.parse(JSON.stringify(getExample(id).schema)) as FormNode; +} + +/** The symbol drawn beside the input ('' when none) and the input's `step`. */ +async function currencyInput(schema: FormNode): Promise<{ symbol: string; step: string | null }> { + const { container } = render(); + const selector = `[data-field="${schema.fields[0].name}"] input[type="number"]`; + // `waitFor`: the widget is a `React.lazy` boundary, so the FormItem's input + // is absent until the chunk resolves. + await waitFor(() => expect(container.querySelector(selector)).toBeTruthy()); + const input = container.querySelector(selector) as HTMLInputElement; + return { + symbol: input.parentElement?.querySelector('span')?.textContent ?? '', + step: input.getAttribute('step'), + }; +} + +describe('the round-13 catalog documents parse on the strict authoring face (objectui#11070)', () => { + it.each(ROUND_13_DOCUMENTS)('%s parses on the strict face', (id) => { + expect(strictIssues(getExample(id).schema)).toEqual([]); + }); +}); + +describe('the currency catalog examples declare their currency as `currencyConfig` (objectui#11070)', () => { + const CASES = [ + ['fields-currency/euro-currency', 'EUR', '€'], + ['fields-currency/usd-currency', 'USD', '$'], + ] as const; + + it.each(CASES)('%s declares a fixed %s and draws %s', async (id, code, symbol) => { + const schema = example(id); + const [field] = schema.fields; + expect(field.currencyConfig).toEqual({ currencyMode: 'fixed', defaultCurrency: code }); + expect(field).not.toHaveProperty('currency'); + + // Two decimals: the minor unit of both EUR and USD. + expect(await currencyInput(schema)).toEqual({ symbol, step: '0.01' }); + }); + + it('CONTROL: in `dynamic` mode the same entry draws no symbol, because that mode does not read `defaultCurrency`', async () => { + const schema = example('fields-currency/euro-currency'); + schema.fields[0].currencyConfig = { currencyMode: 'dynamic', defaultCurrency: 'EUR' }; + expect((await currencyInput(schema)).symbol).toBe(''); + }); + + it('CONTROL: the declared code is read, not a constant: `JPY` draws ¥ and steps by whole yen', async () => { + const schema = example('fields-currency/euro-currency'); + schema.fields[0].currencyConfig = { currencyMode: 'fixed', defaultCurrency: 'JPY' }; + expect(await currencyInput(schema)).toEqual({ symbol: '¥', step: '1' }); + }); +}); diff --git a/packages/types/src/__tests__/form-field-zod-coverage.test.ts b/packages/types/src/__tests__/form-field-zod-coverage.test.ts index 217cbef040..1d041c943d 100644 --- a/packages/types/src/__tests__/form-field-zod-coverage.test.ts +++ b/packages/types/src/__tests__/form-field-zod-coverage.test.ts @@ -87,6 +87,10 @@ const DECLARED_KEYS = [ 'reference', 'min', 'max', + // objectui#11070 round 13 — the decimal places the number-family widgets + // read, and the currency widget's fixed-currency declaration, by reference. + 'scale', + 'currencyConfig', 'minLength', 'maxLength', 'pattern', diff --git a/packages/types/src/__tests__/strict-face-read-keys-11070.test.ts b/packages/types/src/__tests__/strict-face-read-keys-11070.test.ts index 365bb127d9..b3b5b4e88a 100644 --- a/packages/types/src/__tests__/strict-face-read-keys-11070.test.ts +++ b/packages/types/src/__tests__/strict-face-read-keys-11070.test.ts @@ -26,8 +26,10 @@ * `returnType` and `summaryOperations`, since round 7 the `grid` * widget's `columns`, the spec's `inlineColumns` list, and since round 10 * the `grid` widget's eight field-level keys, `GridFieldMetadata`'s own - * members), which the renderer hands each field widget as its metadata - * carrier; + * members, and since round 13 `scale` and `currencyConfig`), which the + * renderer hands each field widget as its metadata carrier. `scale` + * carries the spec's one cross-key rule with it: it is refused on a + * `currency` entry, with the spec's own refusal; * - `dataSource` on `object-grid`, `object-form`, `object-kanban`, * `list-view`, `object-gantt`, `object-map`, `object-calendar` and, since * round 7, `object-chart` — the spec's per-element binding, which each @@ -93,6 +95,7 @@ import { DetailViewFieldSchema } from '../zod/views.zod.js'; import { SidebarSchema } from '../zod/navigation.zod.js'; import type { ElementDataSource as SpecElementDataSource } from '@objectstack/spec/ui'; import type { Field as SpecField } from '@objectstack/spec/data'; +import { FieldSchema as SpecFieldSchema } from '@objectstack/spec/data'; import { AnyComponentSchema, StrictAnyComponentSchema } from '../zod/index.zod.js'; type Issue = { code: string; path: PropertyKey[]; keys?: string[]; errors?: Issue[][] }; @@ -169,6 +172,12 @@ describe('objectui#11070 — the declared read keys parse on the strict face', ( ['summaryOperations', { type: 'summary', summaryOperations: { object: 'orders', field: 'amount', function: 'sum' } }], // Round 7: the `grid` widget's columns, the spec's `inlineColumns` list. ['columns', { type: 'grid', columns: [{ name: 'qty', type: 'number' }, { name: 'sku' }] }], + // Round 13: the spec's `scale`, read by the `number` and `percent` + // widgets, and its fixed-currency declaration, read by the `currency` + // widget through `resolveFieldCurrency`. Each was refused by name at the + // round's base (`fields.0.KEY`). + ['scale', { type: 'number', scale: 2 }], + ['currencyConfig', { type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'EUR' } }], // Round 10: the `grid` widget's field-level keys, `GridFieldMetadata`'s // members, each on a grid entry. Every row was refused by name at the // round's base (`fields.0.KEY`), so each one is a refusal that flipped; @@ -192,6 +201,21 @@ describe('objectui#11070 — the declared read keys parse on the strict face', ( .toMatchObject(GRID_FIELD_KEYS); }); + it('`scale` and `currencyConfig` keep their values on the tolerant face, and no spec default is written in (round 13)', () => { + // At the round's base the tolerant face STRIPPED both keys. + const fieldsOf = (doc: unknown) => { + const parsed = AnyComponentSchema.safeParse(doc); + expect(parsed.success).toBe(true); + return (parsed.data as { fields: Record[] }).fields; + }; + expect(fieldsOf(form({ type: 'percent', scale: 1 }))[0]).toMatchObject({ scale: 1 }); + const fixed = { currencyMode: 'fixed', defaultCurrency: 'USD' }; + expect(fieldsOf(form({ type: 'currency', currencyConfig: fixed }))[0].currencyConfig).toEqual(fixed); + // The spec's `CurrencyConfigSchema` defaults the two members (`dynamic`, + // `CNY`); the imported-defaults boundary keeps them out of the document. + expect(fieldsOf(form({ type: 'currency', currencyConfig: {} }))[0].currencyConfig).toEqual({}); + }); + const BINDING = { object: 'task', filter: [{ field: 'project', operator: 'equals', value: 'acme' }] }; const BOUND_NODES: ReadonlyArray]> = [ // objectui#11276 (the `object-grid` batch): an authored `object-grid` takes @@ -274,6 +298,16 @@ describe('objectui#11070 — a declared key is judged by its declared type on bo // tolerant face kept any value unjudged, so each row is red there. ['a `sidebar.side` that is not an edge shadcn draws (`top`)', { type: 'sidebar', side: 'top' }], ['a boolean `sidebar.side`', { type: 'sidebar', side: true }], + // Round 13: `scale` and `currencyConfig` are judged by the spec's own + // members. At the round's base the tolerant face STRIPPED both keys and + // accepted the document, so each row is red there. + ['a non-integer `scale`', form({ type: 'number', scale: 2.5 })], + ['a negative `scale`', form({ type: 'percent', scale: -1 })], + ['a `scale` past the platform ceiling of 100', form({ type: 'number', scale: 101 })], + ['a string `scale`', form({ type: 'number', scale: '2' })], + ['a `currencyMode` the spec does not list', form({ type: 'currency', currencyConfig: { currencyMode: 'auto', defaultCurrency: 'EUR' } })], + ['a `defaultCurrency` that is not a three-letter code', form({ type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'EURO' } })], + ['the removed `currencyConfig.precision` (the spec closes the config)', form({ type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'EUR', precision: 2 } })], ]; it.each(WRONG)('refuses %s', (_label, doc) => { @@ -281,6 +315,27 @@ describe('objectui#11070 — a declared key is judged by its declared type on bo expect(issuesOf(StrictAnyComponentSchema, doc)).not.toBeNull(); }); + // Round 13: `FieldSchema` refuses `scale` on a `currency` field + // (objectstack-ai/objectstack#19629), and the `currency` widget does not read + // it, so the flat face carries that one rule rather than accept a key that + // does nothing. The text is the spec's: the first two assertions go red if + // the spec drops the rule or rewords it, and either is this face's cue. + it('`scale` on a `currency` entry is refused AT `scale` on both faces, with the spec\'s own refusal (round 13)', () => { + const spec = SpecFieldSchema.safeParse({ name: 'amount', type: 'currency', scale: 2 }); + const specIssue = (spec.error?.issues ?? []).find((i) => i.path.length === 1 && i.path[0] === 'scale'); + expect(specIssue?.code).toBe('custom'); + const fixed = { currencyMode: 'fixed', defaultCurrency: 'EUR' }; + for (const schema of [AnyComponentSchema, StrictAnyComponentSchema]) { + const issues = (issuesOf(schema, form({ type: 'currency', currencyConfig: fixed, scale: 2 })) ?? []) as unknown as { code: string; path: PropertyKey[]; message: string }[]; + expect(issues.map((i) => [i.path.map(String).join('.'), i.code])).toEqual([['fields.0.scale', 'custom']]); + expect(issues[0]?.message).toBe(specIssue?.message); + } + // CONTROLS: the same `scale` on a `number` entry, and the same `currency` + // entry without it, parse on the strict face. + expect(issuesOf(StrictAnyComponentSchema, form({ type: 'number', scale: 2 }))).toBeNull(); + expect(issuesOf(StrictAnyComponentSchema, form({ type: 'currency', currencyConfig: fixed }))).toBeNull(); + }); + it('a wrong `sidebar.side` is refused AT `side`, by the value check (round 12)', () => { for (const schema of [AnyComponentSchema, StrictAnyComponentSchema]) { const issues = issuesOf(schema, { type: 'sidebar', side: 'top' }) ?? []; @@ -332,6 +387,28 @@ describe('objectui#11070 — the retired spellings stay refused on the strict fa expect(undeclared(issuesOf(StrictAnyComponentSchema, form(field)))).toEqual([`fields.0.${key}`]); }); + // Round 13 (the seat's answer B): `currency` on a form field is RUNTIME-ONLY. + // `resolveFieldCurrency` still reads it first, but the spec's `FieldSchema` + // refuses it as a field key, so it stays undeclared and the catalog writes + // the spec's `currencyConfig` instead (block 1). + it('`fields[].currency` stays refused by name on the strict face (round 13)', () => { + expect(undeclared(issuesOf(StrictAnyComponentSchema, form({ type: 'currency', currency: 'EUR' })))) + .toEqual(['fields.0.currency']); + }); + + // Round 13 (the seat's answer C): the guide's `action:button` example names + // its endpoint `target`, the spec's `action:button` row's spelling; the + // retired `endpoint` stays refused in the bag on both faces. + it('the `action:button` the schema-rendering guide teaches parses with `target`; `endpoint` stays refused (round 13)', () => { + const properties = { name: 'call_api', label: 'Click Me', actionType: 'api', method: 'POST' }; + const taught = { type: 'action:button', properties: { ...properties, target: '/api/action' } }; + expect(issuesOf(StrictAnyComponentSchema, taught)).toBeNull(); + expect(issuesOf(AnyComponentSchema, taught)).toBeNull(); + const retired = { type: 'action:button', properties: { ...properties, endpoint: '/api/action' } }; + expect(issuesOf(StrictAnyComponentSchema, retired)).not.toBeNull(); + expect(issuesOf(AnyComponentSchema, retired)).not.toBeNull(); + }); + it('the authored detail-view field declares `reference`, and `reference_to` is not in its mirror (round 4)', () => { const keys = Object.keys(DetailViewFieldSchema.shape); expect(keys).toContain('reference'); @@ -516,6 +593,17 @@ export type assertionReferenceIsTheOnlyTargetSpelling = [ Expect, never>>, ]; export type assertionShowSubmitIsBoolean = Expect>; +/** + * Round 13: `scale` and `currencyConfig` are the spec's `FieldSchema` members + * BY REFERENCE on the form-field face — an exact match, so a restated type (or + * a drift after a spec release) fails here, and a deleted member falls to the + * index signature's `any`, which the first row refuses. + */ +export type assertionScaleAndCurrencyConfigBySpecReference = [ + Expect, false>>, + Expect>, + Expect>, +]; /** * Round 12: `sidebar.side` is the two edges on BOTH faces — an exact match, so * a widened or narrowed value on either face fails here, and a member deleted diff --git a/packages/types/src/form.ts b/packages/types/src/form.ts index 504dbcb208..9c210d7374 100644 --- a/packages/types/src/form.ts +++ b/packages/types/src/form.ts @@ -2013,6 +2013,35 @@ export interface FormField { * Maximum value. Read like {@link FormField.min}, as the native `max`. */ max?: SpecField['max']; + /** + * Decimal places, a non-negative integer of at most 100. The `number` + * widget reads it as its `step` and its read-only width, the `percent` + * widget as the decimals of the percentage-point value (`scale: 2` reads + * 12.34%), and the `formula` and `summary` widgets when they draw a number; + * each resolves an absent `scale` through the spec's `resolveFieldScale`. + * + * ⛔ Not on a `currency` field. The `currency` widget does not read it: an + * amount's decimal places are its currency's ISO 4217 minor unit. So the + * zod face refuses `scale` on an entry of `type: 'currency'`, as the + * spec's `FieldSchema` refuses it (objectstack-ai/objectstack#19629), + * keyed on `type` as the spec keys it. A typed literal cannot refuse it, + * because this interface keeps an index signature (objectui#11070). + */ + scale?: SpecField['scale']; + /** + * The currency of a `currency` field: `{ currencyMode: 'fixed', + * defaultCurrency: 'EUR' }` gives the field one currency. The `currency` + * widget resolves its code through `resolveFieldCurrency` + * (`@object-ui/i18n`), which reads `defaultCurrency` only under + * `currencyMode: 'fixed'`. Under `dynamic`, the spec's mode when none is + * written, the amount shows in the tenant default currency. + * + * A `currency` code written on the entry itself is still read first by + * `resolveFieldCurrency`, but the spec's `FieldSchema` refuses it as a + * field key, so it is ⛔ NOT declared here and the strict face refuses it + * (objectui#11070). + */ + currencyConfig?: SpecField['currencyConfig']; /** * Minimum character length. The built-in `input` and `textarea` branches * put it on the native control as `minlength`, which the browser enforces diff --git a/packages/types/src/zod/form.zod.ts b/packages/types/src/zod/form.zod.ts index 4fa730a5e8..8056f03b13 100644 --- a/packages/types/src/zod/form.zod.ts +++ b/packages/types/src/zod/form.zod.ts @@ -1026,6 +1026,31 @@ function retiredGridFieldKey(alias: GridFieldRetiredKey) { ); } +/** + * The refusal of `scale` on a `currency` form field (objectui#11070 round 13). + * + * `FormFieldSchema.scale` is the spec's `FieldSchema.scale` by reference, but a + * member taken off `.shape` does not bring the spec's cross-key rules with it, + * and the spec refuses this key on exactly one field type: `currency` + * (objectstack-ai/objectstack#19629, ruling B — a currency amount's decimal + * places are its currency's). The `currency` widget does not read `scale`, so + * the key would be accepted here and do nothing. So this one rule is carried, + * keyed on `type` as the spec keys it, with the spec's own wording: the spec + * exports neither the rule nor its text, so the text is held identical here and + * `strict-face-read-keys-11070.test.ts` pins it equal to the spec's issue for the + * same entry. The spec's other cross-key rules (`rows`, `minLength` / + * `maxLength`, `multiple`) are not carried: they key on the spec's field-type + * sets, and this face's `type` is a widget id (`input`, `select`, …) the spec + * does not know. + */ +const CURRENCY_FIELD_SCALE_REFUSAL = + '`scale` is not valid on a `currency` field — delete the key. A currency amount\'s decimal ' + + 'places are its currency\'s, not a field setting: the currency\'s ISO 4217 minor unit (2 for ' + + 'USD, 0 for JPY, 3 for KWD) decides how the amount displays, and the field\'s write allowance ' + + 'stays unconstrained — a currency write is accepted with the decimals it carries, as it always ' + + 'was on a currency field that declared no `scale`. The key\'s one enforced effect was refusing ' + + 'writes with more decimals, which this field type no longer does.'; + export const FormFieldSchema = z.object({ id: z.string().optional().describe('Field ID'), name: z.string().describe('Field name (form data path)'), @@ -1080,6 +1105,13 @@ export const FormFieldSchema = z.object({ reference: stripImportedDefaults(SpecFieldSchema).shape.reference, min: stripImportedDefaults(SpecFieldSchema).shape.min, max: stripImportedDefaults(SpecFieldSchema).shape.max, + // objectui#11070 round 13 — the decimal places the `number`, `percent`, + // `formula` and `summary` widgets read, and the fixed-currency declaration + // the `currency` widget reads through `resolveFieldCurrency`. The spec's + // `FieldSchema` refuses `scale` on a `currency` field; the superRefine + // below carries that one rule (`CURRENCY_FIELD_SCALE_REFUSAL`). + scale: stripImportedDefaults(SpecFieldSchema).shape.scale, + currencyConfig: stripImportedDefaults(SpecFieldSchema).shape.currencyConfig, minLength: stripImportedDefaults(SpecFieldSchema).shape.minLength, maxLength: stripImportedDefaults(SpecFieldSchema).shape.maxLength, pattern: z.string().optional() @@ -1128,6 +1160,11 @@ export const FormFieldSchema = z.object({ add_label: retiredGridFieldKey('add_label'), sort_field: retiredGridFieldKey('sort_field'), }).superRefine((field, ctx) => { + // objectui#11070 round 13 — the spec's one cross-key rule on `scale`; see + // CURRENCY_FIELD_SCALE_REFUSAL. + if (field.type === 'currency' && field.scale !== undefined) { + ctx.addIssue({ code: 'custom', path: ['scale'], message: CURRENCY_FIELD_SCALE_REFUSAL }); + } // objectui#5449 — the namespace rule `@object-ui/core` has enforced since // objectui#5375, stated here so `objectui validate` (which reaches this // schema via `safeValidateSchema`) stops green-lighting a document the