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