Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/11070-form-field-scale-currency-config.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 4 additions & 3 deletions content/docs/guide/schema-rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Expand All @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@
"label": "Price (EUR)",
"type": "currency",
"placeholder": "€0.00",
"currency": "EUR"
"currencyConfig": {
"currencyMode": "fixed",
"defaultCurrency": "EUR"
}
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@
"label": "Amount",
"type": "currency",
"placeholder": "$0.00",
"currency": "USD"
"currencyConfig": {
"currencyMode": "fixed",
"defaultCurrency": "USD"
}
}
]
}
Original file line number Diff line number Diff line change
@@ -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(<SchemaRenderer schema={schema as never} />);
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' });
});
});
4 changes: 4 additions & 0 deletions packages/types/src/__tests__/form-field-zod-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading
Loading