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
2 changes: 1 addition & 1 deletion .changeset/12130-form-field-channel-keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ A hand-authored `form` hands each field widget the `fields[]` entry itself as it
- **TypeScript.** `FormField` gains each member, typed as above. Each used to fall to the index signature's `any`, so a typed literal with a value of the wrong type is now a compile error.
- **zod (`@object-ui/types/zod`).** WIDENS on the strict authoring face (`StrictAnyComponentSchema`): each key used to be refused with `unrecognized_keys`, and now parses. NARROWS on the tolerant face (`safeValidateSchema`), where each key used to be stripped unjudged: a value of the wrong type (a `maxSize` that is not a positive whole number, a `lookupFilters` operator the spec does not list, a `picker` other than `search` / `default`) is now refused, and the values are now kept. The face is flat: each key is accepted on every field type, and only the widgets named above read it.
- **Not declared: `defaultValue` and `defaultChecked` on a form field.** No reader asks for either, and neither becomes the field's value on any type the effects pins measure, with or without `@object-ui/fields` registered: a checkbox stays unchecked and submits `false`, and the submitted payload carries no value. The strict face keeps refusing both by name. A form's initial values are `FormSchema.defaultValues`, keyed by field name. `content/docs/components/form/form.mdx` no longer lists a field-level `defaultValue`, and the `login-form` example in this package drops its `defaultChecked: false`.
- **Still refused, by name:** the snake_case second spellings the `lookup` widget still reads before the spec's keys above (`allow_create`, `lookup_columns`, `lookup_page_size`, and `reference_field` for `displayField`). Still undeclared, and listed as open in the census: `step`, which the `number` and `slider` widgets read but `ObjectForm` writes as `'any'`, a value the spec's numeric `step` refuses; and `className`.
- **Still refused, by name:** the snake_case second spellings the `lookup` widget still reads before the spec's keys above (`allow_create`, `lookup_columns`, `lookup_page_size`, and `reference_field` for `displayField`).

BREAKING (`@object-ui/types`), for a document whose form field carries one of the declared keys with a value of the wrong type: it no longer validates on the tolerant face. Fix the value. (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.)

Expand Down
21 changes: 21 additions & 0 deletions .changeset/12130-step-classname.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@object-ui/types': minor
'@object-ui/components': minor
'@object-ui/plugin-form': patch
---

A form field declares `step` and `className` on both faces, so the strict authoring face stops telling authors to remove two keys that work. A built-in form branch now joins an entry's `className` with its own classes, and the form renderer's own number input derives its step, so `ObjectForm` no longer writes `step: 'any'` (objectui#12130).

These were the last two keys the form-field channel census (`form-field-channel-census-12130.test.tsx` in `examples/schema-catalog/test/`) listed as read and undeclared with the question open. Each is measured through the real `SchemaRenderer`, with and without the key, in `form-field-channel-effects-12130.test.tsx` beside it.

- **`step`, declared by reference to `@objectstack/spec`'s `FieldSchema.step`:** a number. The `number` widget and the form renderer's own number input put it on the `<input>`; the `slider` widget steps by it.
- **The form renderer's own number input (`@object-ui/components`).** The built-in `input` branch, and the default branch for an unregistered widget, draw a native number input under `inputType: 'number'`. Its step now follows the `number` widget's rule, read off the field's metadata: an explicit `step` wins, else `10^-scale`, else `any`. Before, it took only a `step` written on the entry, and with none the input kept HTML's step of 1, which refuses decimals. An entry with `inputType: 'number'` and neither `step` nor `scale` now accepts decimals. On an `ObjectForm` field drawn by that input (`widget: 'input'`), a `step` the object field declares now wins over `scale`, as it always did in the `number` widget.
- **`ObjectForm` (`@object-ui/plugin-form`) no longer writes `step`** onto the number, currency and percent fields it builds. It wrote `10^-scale`, or `'any'` with no `scale`, and the spec's numeric `step` refuses `'any'`. Both routes `objectFormNumericStep-9574.test.tsx` measures render the same steps as before, and that pin passes unchanged.
- **`className`, declared as a string:** the classes of the element the field's type renders. That is the control on a built-in branch, the element a registered field widget renders, or a `section-divider` row's divider. It is never the field row; a field's width is `colSpan` / `span`. Some widgets draw no class from it, and most read-only displays draw none.
- **Built-in branches merge it (`@object-ui/components`).** The `input`, `textarea`, `checkbox` and `switch` branches and the unregistered-widget default used to let an entry's `className` replace their own classes, which dropped the touch-target height and the read-only tint. They now join the two with `cn`, as the built-in `select` already did, so a conflicting utility in `className` still wins.
- **TypeScript.** `FormField` gains both members. `step` is `number`, so assigning `'any'` is now a compile error.
- **zod (`@object-ui/types/zod`).** WIDENS on the strict authoring face (`StrictAnyComponentSchema`): both keys used to be refused with `unrecognized_keys`, and now parse, `step` as a number. NARROWS on the tolerant face (`safeValidateSchema`), where both keys used to be stripped unjudged. `step: 'any'` was accepted and dropped there, and is now refused at `fields[].step`. A non-string `className` is now refused too. The face is flat: both keys are accepted on every field type.

BREAKING (`@object-ui/types`), for a document whose form field carries `step: 'any'` or a non-number `step`, or a non-string `className`: it no longer validates on the tolerant face. Delete `step: 'any'` (a number input with no `step` and no `scale` already steps by `any`), or write a number. (The bumps are `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 (narrowing)**: the two declared keys widen the strict face's accept set. Typing `step` as the spec's number narrows the tolerant face, from accepting `step: 'any'` (stripped) to refusing it at `fields[].step`.
4 changes: 4 additions & 0 deletions content/docs/components/form/form.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,14 @@ interface FormField {
maxLength?: number;
min?: number;
max?: number;
step?: number; // a number, never 'any'; without one, a number input steps by 10^-scale, or 'any' with no scale
pattern?: string;

// Options (for select, radio-group)
options?: Array<{ value: string; label: string }>;

// Styling: the classes of the control this field draws, joined with its own; never the field row
className?: string;
}

interface FormSchema {
Expand Down
19 changes: 8 additions & 11 deletions content/docs/plugins/plugin-form.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,8 @@ examples used to be, and they now live in the catalog under
name (`json-schema/ui/FormField.json`) with a different key set, so read this
table against the `@object-ui/types` copy rather than the same-named schema in
the spec. This page does not redeclare it, because a locally written
`interface` compiles no matter how far it has drifted from the real one. It
has **23 declared keys**, and `name` is the only **required** one: `type` and
`label` are both optional.
`interface` compiles no matter how far it has drifted from the real one.
`name` is its only **required** key: `type` and `label` are both optional.

| Key | Type | What it does |
| --- | --- | --- |
Expand All @@ -98,23 +97,21 @@ has **23 declared keys**, and `name` is the only **required** one: `type` and
| `dependsOn` | `DependsOnInput` | cascading parent(s): a bare name, a list of names, or `{ field, param }` entries |
| `span` | `'auto' \| 'full'` | relative field width, independent of the column count (preferred) |
| `colSpan` | `number` | legacy column span (1–4), clamped to the current column count |
| `className` | `string` | the classes of the element the field's type renders (its control, or a `section-divider` row's divider), joined with that element's own classes; never the field row: `span` / `colSpan` for width, `FormSchema.fieldContainerClass` for the grid |
| `field` | `Record<string, any>` | the resolved object-field **metadata object**, stashed by the object-bound paths so widgets can read `precision`, `currency`, `reference`, … |

`FormField` also declares `[key: string]: any`, so a misspelled or invented key
is **not** a compile error — it is simply outside the contract. Two that a
reader might expect here, and that are **not** declared members:
is **not** a compile error — it is simply outside the contract. One that a
reader might expect here, and that is **not** a declared member:

| Not a `FormField` key | Write this instead |
| --- | --- |
| `defaultValue` | `FormSchema.defaultValues`, at form level. An object-bound form seeds from the object field's own declared `defaultValue` instead |
| `className` | `span` / `colSpan` for width, `FormSchema.fieldContainerClass` for the field grid |

An undeclared key still rides the props spread down to whichever component the
field resolves to, so a field-level `className` can visibly land on a built-in
control — but nothing in the contract promises that, and a registered widget
honours it only if it happens to spread its leftover props. A field-level
`defaultValue` reaches the widget the same way and changes nothing: the control
is bound to react-hook-form, whose initial values come from the form.
field resolves to. A field-level `defaultValue` reaches the widget the same way
and changes nothing: the control is bound to react-hook-form, whose initial
values come from the form.

#### `validation` is an object keyed by rule name

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@
* readers; so does a ledger row whose read is gone. Each row says why the
* strict face's refusal of its key stands, or that the question is still open.
* {@link DECLARED_BY_12130} names, for each key this card declared, the field
* types whose widget reads it; on every other type the key is accepted and
* read by nothing.
* types whose widget reads it by name; on every other type the key is
* accepted and read by nothing, except where that map's note says otherwise.
*
* ## What it does not see (bounds, said per AGENTS.md #9)
*
Expand Down Expand Up @@ -288,26 +288,6 @@ const CURRENCY = 'entry@i18n/src/currency.ts';
* refusal is right.
*/
const LEDGER: Readonly<Record<string, LedgerRow>> = {
// ── read, and still open: the refusal may be false ──────────────────────
step: {
readers: ['entry@fields/src/widgets/NumberField.tsx', 'entry@fields/src/widgets/SliderField.tsx'],
why: 'OPEN — read and effective (the effects pins), but `ObjectForm` writes `step: \'any\'` onto the entries it '
+ 'builds, which the spec\'s numeric `FieldSchema.step` refuses. Declaring it by reference breaks that producer; '
+ 'declaring `number | \'any\'` departs from the spec. A decision on objectui#12130.',
},
className: {
readers: [
'props@fields/src/widgets/CheckboxesField.tsx', 'props@fields/src/widgets/CurrencyField.tsx',
'props@fields/src/widgets/FilterConditionField.tsx', 'props@fields/src/widgets/MultiSelectField.tsx',
'props@fields/src/widgets/ObjectRefField.tsx', 'props@fields/src/widgets/PasswordField.tsx',
'props@fields/src/widgets/PercentField.tsx', 'props@fields/src/widgets/RadioField.tsx',
'props@fields/src/widgets/RatingField.tsx', 'props@fields/src/widgets/RecipientPickerField.tsx',
'props@fields/src/widgets/TagsField.tsx',
],
why: 'OPEN — read off props by these widgets and forwarded to the control by the rest; on a built-in branch it '
+ 'replaces the branch\'s own classes. Whether a field entry declares it, and which element it styles, is a '
+ 'decision on objectui#12130.',
},
// ── second spellings of a declared key: refused (AGENTS.md #0.1) ────────
allow_create: { readers: [LOOKUP], why: 'ALIAS of the spec\'s `allowCreate`, which this face declares; the widget reads it first.' },
lookup_columns: { readers: [LOOKUP], why: 'ALIAS of the spec\'s `lookupColumns`, which this face declares; the widget reads it first.' },
Expand Down Expand Up @@ -366,6 +346,17 @@ const DECLARED_BY_12130: Readonly<Record<string, readonly string[]>> = {
picker: ['lookup', 'master_detail', 'user'],
subtitle: ['lookup', 'master_detail', 'user'],
avatarField: ['lookup', 'master_detail', 'user'],
// Round 3. The spec's number: the form renderer's own number input reads it
// too, under `inputType: 'number'`, which no entry here carries (the
// effects file measures that input).
step: ['number', 'slider'],
// Round 3. These widgets read it by name; the built-in branches and every
// widget that spreads its props onto its control apply it too, which no
// recorder sees. The effects file measures where it lands, type by type.
className: [
'checkboxes', 'currency', 'filter-condition', 'multiselect', 'object-ref', 'password',
'percent', 'radio', 'rating', 'recipient-picker', 'tags',
],
};

/* ── the census ──────────────────────────────────────────────────────────── */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@
* `FormSchema.defaultValues`, the form's initial-values channel, and reads it
* back from the same places.
*
* It also measures the default branch's own control (objectui#12130 round
* 3): with no `number` widget registered, an entry that says
* `inputType: 'number'` draws a native number input there, and its step
* follows the registered widget's rule; an entry's `className` joins the
* control's own classes there instead of replacing them.
*
* This file imports no field widgets on purpose. That absence IS the registry
* set it measures, so a module-scope `@object-ui/fields` import added here
* would silently turn it into a copy of the file beside it.
Expand Down Expand Up @@ -104,3 +110,32 @@ describe('`defaultValue` on a form field never becomes the submitted value witho
expect((await withKey.submit())?.f).toBeUndefined();
});
});

describe('the default branch\'s number input takes the `number` widget\'s step without `@object-ui/fields` (objectui#12130)', () => {
it('an explicit `step` wins, else `10^-scale`, else `any`', async () => {
const stepOf = async (field: Row) => {
const m = await mount({ type: 'number', inputType: 'number', ...field });
const input = m.row().querySelector('input');
const read = { type: input?.getAttribute('type'), step: input?.getAttribute('step') };
m.unmount();
return read;
};
expect(await stepOf({})).toEqual({ type: 'number', step: 'any' });
expect(await stepOf({ scale: 2 })).toEqual({ type: 'number', step: '0.01' });
expect(await stepOf({ step: 5, scale: 2 })).toEqual({ type: 'number', step: '5' });
});
});

describe('the default branch merges an entry\'s `className` into its control\'s classes (objectui#12130)', () => {
it('a read-only control keeps its read-only tint beside the authored class', async () => {
const classesOf = (m: Awaited<ReturnType<typeof mount>>) =>
(m.row().querySelector('input')?.getAttribute('class') ?? '').split(/\s+/);
const without = await mount({ type: 'text', readonly: true });
expect(classesOf(without)).toContain('bg-muted/40');
without.unmount();

const withKey = await mount({ type: 'text', readonly: true, className: 'authored-cls-12130' });
expect(classesOf(withKey)).toEqual(expect.arrayContaining(['bg-muted/40', 'authored-cls-12130']));
expect(withKey.row().getAttribute('class') ?? '').not.toContain('authored-cls-12130');
});
});
Loading
Loading