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
39 changes: 39 additions & 0 deletions .changeset/9574-objectform-step-scale.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
'@object-ui/plugin-form': patch
---

`ObjectForm`'s numeric `step` now follows `scale` (decimal places), not `precision`
(total digit count) — objectui#9574.

`@objectstack/spec` declares the two members apart: `precision` is "Total digits
(non-negative integer)", `scale` is "Decimal places (non-negative integer)". The
auto-generated form field derived its step from `precision`, so a `decimal(10, 0)`
field — ten total digits, ZERO decimal places — was handed `step` `1e-10` instead of
`1`, and a `decimal(10, 2)` got `1e-10` instead of `0.01`. `NumberField` states the
same rule verbatim one layer down and already moved; this is the producer one layer up
catching up with it.

Two further consequences of the same expression:

- **`scale: 0` is honoured.** The test is `typeof field.scale === 'number'`, not
truthiness — a declared zero means "steps by 1", and the spec's own example of a
`scale: 0` field is an ordinal integer.
- **An undeclared `scale` now resolves to `step="any"`, not to no attribute at all.**
An absent `step` is HTML's default of 1, which marks every decimal `:invalid` and
blocks the submit — a granularity the author never declared. This matches
`NumberField`'s tail for the same case.

**Where the change is observable.** The registered `field:*` widgets do not read this
key: their metadata carrier is the raw object-schema field, their DOM whitelist does
not forward `step`, and each derives its own — measured by deleting the assignment
outright and re-rendering, which left every `<input>` on that route byte-identical. The
key IS read on the renderer's unregistered-widget fallback, where a field whose declared
`widget` names a component the app never registered has its leftover props spread onto
the `<input>`; there the producer's step is the granularity the browser enforces. Both
routes are pinned side by side in `objectFormNumericStep-9574.test.tsx`.

`percent` is covered by the same expression it was always covered by, and moves from
`10^-precision` to `10^-scale`. What `scale` means for a percent field that stores a
0–1 fraction — stored decimals, which is what the record validator enforces, or
displayed percentage points, which is the landed display convention — is open at
objectui#9810 and is not decided here.
35 changes: 34 additions & 1 deletion packages/plugin-form/src/ObjectForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -844,7 +844,40 @@ const SimpleObjectForm: React.FC<ObjectFormComponentProps> = ({
formField.inputType = 'number';
formField.min = field.min;
formField.max = field.max;
formField.step = field.precision ? Math.pow(10, -field.precision) : undefined;
// Step is DECIMAL PLACES (`scale`), never the total digit count
// (`precision`). The two are separate members with separate meanings
// in the installed `@objectstack/spec` — "Decimal places
// (non-negative integer)" against "Total digits (non-negative
// integer)" — and `NumberField` already states the rule verbatim one
// layer down: "Step follows `scale` (decimal places), not
// `precision` (total digit count)". Deriving granularity from
// `precision` gave a `decimal(10, 0)` field a step of `1e-10`.
//
// `typeof`, not truthiness: `scale: 0` is a valid declaration (the
// spec's own example is an ordinal integer declared with `scale: 0`)
// and it means "steps by 1".
//
// The undeclared case resolves to `'any'`, matching `NumberField`'s
// tail, and NOT to `undefined`: an absent `step` attribute is HTML's
// default of 1, which refuses every decimal the author never said
// anything about. A field that declares no `scale` claims no
// granularity, so the control must not invent one.
//
// ⚠️ WHERE THIS KEY IS ACTUALLY READ — measured on this branch's
// base, through a real ObjectForm render, because the same file's
// `formField.maxLength` tombstone below shows how easily a key
// written here reaches nothing. The registered `field:*` widgets
// DROP it: their metadata carrier is `formField.field` (the raw
// object-schema field, assigned above) and their `toDomProps`
// whitelist does not forward `step`, so `NumberField`,
// `CurrencyField` and `PercentField` each derive their own. What
// this key does reach is the form renderer's unregistered-widget
// fallback — a field whose declared `widget` names a component the
// app never registered — where the leftover props are spread onto
// the `<input>`. Both halves are pinned by
// `objectFormNumericStep-9574.test.tsx`, which measures the two
// routes side by side.
formField.step = typeof field.scale === 'number' ? Math.pow(10, -field.scale) : 'any';
}

if (field.type === 'date') {
Expand Down
139 changes: 139 additions & 0 deletions packages/plugin-form/src/__tests__/objectFormNumericStep-9574.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/**
* 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.
*/

/**
* `ObjectForm`'s numeric `step` follows `scale`, not `precision` (objectui#9574).
*
* `@objectstack/spec` declares the two members apart — `precision` is "Total
* digits (non-negative integer)", `scale` is "Decimal places (non-negative
* integer)" — and `NumberField` states the consequence verbatim one layer
* down: "Step follows `scale` (decimal places), not `precision` (total digit
* count)". `ObjectForm` derived the same quantity from the other member, so a
* `decimal(10, 0)` field was handed a step of `1e-10` instead of `1`.
*
* ## Two routes, and only one of them can see the producer
*
* Measured on this branch's base, through a real render of this component:
*
* - **Registered widget** (the ordinary route — importing `ObjectForm` pulls
* `@object-ui/fields` in, whose module body registers every `field:*`
* widget): the widget receives a `step` PROP and drops it. Its metadata
* carrier is the `field` key — the raw object-schema field — and its
* `toDomProps` whitelist does not forward `step`, so `NumberField` derives
* its own from `scale`. Deleting the producer's assignment outright left
* every rendered `<input>` on this route byte-identical.
* - **Unregistered widget** (a field declaring a `widget` the app never
* registered): the renderer's fallback branch spreads the leftover field
* props straight onto the `<input>`, so the producer's `step` IS the
* attribute the browser enforces.
*
* ⇒ the first block below is the load-bearing pin — it fails on the
* `precision`-derived expression, where these three inputs all read
* `step="1e-10"`. The second block pins the same user-visible rule through the
* ordinary route and PASSES ON BOTH configurations of the producer: it is a
* pin of the whole chain (metadata carrier → widget → attribute), deliberately
* kept and deliberately labelled, not evidence for this card's change.
*/
import { describe, it, expect, vi } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import React from 'react';

import { ObjectForm } from '../ObjectForm';

/**
* `decimal(10, 0)` and `decimal(10, 2)` — the two cases the card names — plus
* a field that declares neither digit member.
*
* The `widget` key on the first three names a component nothing registers,
* which is what routes them to the renderer's fallback branch (see the header).
*/
const FALLBACK_OBJECT = {
name: 'invoice_line',
fields: {
qty: { type: 'number', label: 'Qty', precision: 10, scale: 0, widget: 'nothing-registers-this' },
amount: { type: 'number', label: 'Amount', precision: 10, scale: 2, widget: 'nothing-registers-this' },
fee: { type: 'currency', label: 'Fee', precision: 10, scale: 2, widget: 'nothing-registers-this' },
rate: { type: 'percent', label: 'Rate', precision: 10, scale: 2, widget: 'nothing-registers-this' },
unscaled: { type: 'number', label: 'Unscaled', precision: 10, widget: 'nothing-registers-this' },
},
};

/** Same two cases, no `widget` override — the ordinary registered-widget route. */
const REGISTERED_OBJECT = {
name: 'invoice_line',
fields: {
qty: { type: 'number', label: 'Qty', precision: 10, scale: 0 },
amount: { type: 'number', label: 'Amount', precision: 10, scale: 2 },
},
};

const makeDataSource = (objectSchema: unknown) => ({
getObjectSchema: vi.fn().mockResolvedValue(objectSchema),
create: vi.fn(async (_o: string, d: Record<string, unknown>) => ({ id: 'r1', ...d })),
update: vi.fn(),
findOne: vi.fn(),
});

async function renderFields(objectSchema: unknown) {
const { container } = render(
<ObjectForm
schema={{ type: 'object-form', objectName: 'invoice_line', mode: 'create' } as any}
dataSource={makeDataSource(objectSchema) as any}
/>,
);
await waitFor(() => {
if (!container.querySelector('input[name="qty"]')) throw new Error('form not ready');
});
return (name: string) => {
const el = container.querySelector(`input[name="${name}"]`) as HTMLInputElement | null;
if (!el) throw new Error(`no input rendered for ${name}`);
return el;
};
}

describe('ObjectForm numeric step follows `scale` (objectui#9574)', () => {
it('hands the fallback input a step of 10^-scale — 1 for scale 0, 0.01 for scale 2', async () => {
const input = await renderFields(FALLBACK_OBJECT);

// `decimal(10, 0)`: ten total digits, ZERO decimal places ⇒ steps by 1.
// The defect read `precision` here and produced `1e-10`.
expect(input('qty').getAttribute('step'), 'decimal(10, 0) steps by 1').toBe('1');
// `decimal(10, 2)` ⇒ hundredths.
expect(input('amount').getAttribute('step'), 'decimal(10, 2) steps by 0.01').toBe('0.01');
// Same derivation for the other two numeric types the branch covers, so
// the producer cannot drift type by type. ⚠️ For `percent` this pins the
// producer's UNIFORMITY only: what `scale` means for a percent field that
// stores a 0–1 fraction (stored decimals, which is what the record
// validator enforces, vs displayed percentage points, which is the landed
// display convention) is open at objectui#9810, and a ruling there
// re-prices this line.
expect(input('fee').getAttribute('step'), 'currency follows scale too').toBe('0.01');
expect(input('rate').getAttribute('step'), 'percent follows scale too').toBe('0.01');
});

it('claims no granularity when no `scale` is declared', async () => {
const input = await renderFields(FALLBACK_OBJECT);

// NOT an absent attribute: absent means HTML's default step of 1, which
// marks every decimal `:invalid` and blocks the submit — a constraint the
// author never declared. `'any'` is the tail `NumberField` already uses.
expect(input('unscaled').getAttribute('step'), 'undeclared scale ⇒ step="any"').toBe('any');
});

it('CHAIN PIN, not a pin of this card: the ordinary route shows the same two steps', async () => {
// ⚠️ This passes on BOTH configurations of the producer — the registered
// widget drops the `step` prop and derives its own from the same `scale`
// (see the header). It is kept because it is the rule as a user meets it:
// the metadata carrier reaching the widget, and the widget's derivation,
// are both live links in that chain and neither is pinned here otherwise.
const input = await renderFields(REGISTERED_OBJECT);

expect(input('qty').getAttribute('step')).toBe('1');
expect(input('amount').getAttribute('step')).toBe('0.01');
});
});
Loading