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
23 changes: 23 additions & 0 deletions .changeset/11615-simple-inline-section-entry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@object-ui/plugin-form': minor
'@object-ui/types': minor
---

The default (`simple`) `object-form` draws a self-describing inline section entry, as the `tabbed`, `wizard`, `split`, `drawer` and `modal` forms already did (objectui#11615). Before, the default form resolved every section entry against its parent field pool and skipped an inline `{ name, … }` entry whose name the pool did not hold. The same section drew that entry on every other form type and drew nothing for it on `simple`, apart from a console warning when the object declared the name.

**Clause-②: yes (widening, with one break for TypeScript readers).** Authored input is only widened: nothing either package accepted before is refused now. Code that READS `ObjectFormSection.fields` can break at compile time, described under "Breaking for TypeScript readers" below.

- `@object-ui/types`: `ObjectFormSection.fields` is `(string | SpecFormFieldInput | FormField)[]`. The new arm is the form view's `{ field, … }` entry, and it is `@objectstack/spec`'s `FormFieldInput` by reference, not a copy. The form already drew that entry. Before, a TypeScript author could not annotate it, because `FormField` requires `name` and types `field` as an object. The zod mirror is unchanged: a section's `fields` entry is still `z.any()` there.
- `@object-ui/plugin-form`, layout types: the section `fields` of the five layout configs is `NonNullable<ObjectFormSection['fields']>`, by reference. Those configs are `FormSectionConfig` (tabbed), `WizardStepConfig`, `SplitFormSectionConfig`, `DrawerFormSectionConfig` and `ModalFormSectionConfig`, reached through the exported `TabbedFormSchema`, `WizardFormSchema`, `SplitFormSchema`, `DrawerFormSchema` and `ModalFormSchema`. Each of these layouts already drew the `{ field }` entry. Before, their types refused it, and `ObjectForm` passing an authored section to them would no longer compile once the section type named the entry.
- `@object-ui/plugin-form`, section drawing: on `simple`, an entry that names itself is drawn as it stands, whatever the field pool holds. Such an entry is an object whose `field` is not a string and whose `name` is a string. This is the existing `isInlineFieldDef` predicate that the submit-target rule already reads. It does not require `type`: the spec's inline arm makes `type` optional, and the other five forms draw a typeless entry as the default input.
- `@object-ui/plugin-form`, inline collector: a `simple` form with no data source and no `submitHandler`, whose sections list only inline entries, is now a self-contained collector, as on the other five forms. It opens on `initialValues` / `initialData`, and its `onSuccess` receives the collected values. Before, that form drew no fields and refused the submit. Its submit carve-out now reads the shared `hasInlineFieldSource`.

**Breaking for TypeScript readers of `ObjectFormSection.fields`** (still `minor`: objectui's major follows the `@objectstack` family major, so its own breaks ship as `minor` and are stated here). A consumer that narrowed an entry with `typeof entry === 'string' ? entry : entry.name` compiled while every object entry was typed as an inline `FormField`. It no longer compiles: `Property 'name' does not exist on type 'FormFieldInput | FormField'`. The read was already wrong at runtime for a `{ field }` entry, where it gave `undefined`. Remedy: name an entry by its arm. The string is the name itself, the `{ field }` entry names its field by `field`, and the inline entry names it by `name`. `@object-ui/plugin-form` now exports `sectionEntryName(entry)`, which applies exactly that rule and returns `undefined` for an entry that names nothing. It sits beside `resolveSectionGroupReferences`, whose result it reads. The repo's own reader, an app-shell test over that resolver's result, is respelled this way.

**What stays refused or warned.**

- A field name and a `{ field }` entry still resolve against the pool on `simple`. A name the pool does not hold is still dropped, and still warned about once when the object declares it, because top-level `fields` and `sections` still intersect (objectui#9884).
- An inline entry with no `name` is malformed and is still not drawn on `simple`.
- A form with no data source and no `submitHandler` still refuses its submit with `DataSource is required for form submission (inline mode not configured)` unless every section entry is inline. One name or `{ field }` entry among inline ones is enough to refuse, on all six forms.

**Behaviour change for an existing schema.** On `simple`, an inline entry whose name the object declares but top-level `fields` leaves out used to be dropped with the intersection warning. It is now drawn as its own definition, with no warning, as on the other five forms. With a data source, its value is still written only if the object declares the field. As on every form, a key the object does not declare is stripped from the write.
24 changes: 24 additions & 0 deletions content/docs/plugins/plugin-form.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,30 @@ into an object literal produces numeric keys (`{ '0': …, '1': … }`), and
react-hook-form recognises none of them: every rule is dropped, nothing throws,
and the form looks validated while validating nothing.

### What a section's `fields` entries draw

A section's `fields` takes three entry shapes, and every `formType` draws them
the same way: a field **name** and the form view's `{ field, … }` entry (which
overrides that object field) are resolved against the object schema, while an
inline runtime `FormField` keyed by `name` carries its own definition and is
drawn as it stands.

On the default (`simple`) form the two named shapes resolve against the form's
parent field pool — top-level `fields` when given, else the object's fields,
plus `customFields` — so a named member the pool does not hold is dropped
(reported once when the object declares it: `fields` and `sections`
intersect). An inline entry names nothing to resolve, so it is drawn whatever
the pool holds, exactly as the other five layouts draw it (objectui#11615). A
`simple` form whose sections list only inline entries is therefore a
self-contained collector: with no data source and no `submitHandler`, its
`onSuccess` receives the collected values, as an inline wizard's does.

Code that reads a section's entries names each one with `sectionEntryName(entry)`
from `@object-ui/plugin-form`: the string itself, the `{ field }` entry's
`field`, the inline entry's `name` (`undefined` when the entry names nothing).
Reading `.name` off every object entry reads `undefined` off a `{ field }`
entry, and `ObjectFormSection.fields` types that read as an error.

### Column width of a sectioned form

A sectioned form renders as ONE grid. The form view's `columns` (spec
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@

import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import { resolveSectionGroupReferences } from '@object-ui/plugin-form';
import { resolveSectionGroupReferences, sectionEntryName } from '@object-ui/plugin-form';
import { SchemaForm } from './SchemaForm';
import type { FormSectionSpec, FormViewSpec } from './form-spec';
import type { RichMetadataTypeEntry } from './useMetadata';
Expand Down Expand Up @@ -136,13 +136,20 @@ function renderForm(form: FormViewSpec): { error: Error | undefined; ids: string
* The field names the shared resolver assigns to each section, given exactly
* the inputs `SchemaForm` hands it — the answer `FormPage` gives for the same
* section over an object that does not declare the group.
*
* Each entry is named by its OWN arm through `sectionEntryName`, the form
* package's one spelling of the identity rule (objectui#11615): a bare string
* is the name, the form view's `{ field }` entry names its field by `field`,
* and an inline `FormField` by `name`. Reading `.name` off every object entry
* typed only while the section type claimed every object entry was an inline
* `FormField`; on a `{ field }` entry it read `undefined`.
*/
function resolverFieldNames(form: FormViewSpec): string[][] {
function resolverFieldNames(form: FormViewSpec): Array<Array<string | undefined>> {
const resolved = resolveSectionGroupReferences(
form.sections as unknown as Parameters<typeof resolveSectionGroupReferences>[0],
{ objectName: SCHEMA_ID, formType: form.type, objectDef: null, resolvable: false },
) ?? [];
return resolved.map((s) => (s.fields ?? []).map((f) => (typeof f === 'string' ? f : f.name)));
return resolved.map((s) => (s.fields ?? []).map(sectionEntryName));
}

let consoleError: ReturnType<typeof vi.spyOn>;
Expand Down
31 changes: 30 additions & 1 deletion packages/plugin-form/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@ The package entry exports these — components, their prop/schema types, the
layout helpers, the two create-payload rules a second form renderer needs
(see [`required` + a runtime default](#required-or-requiredwhen--a-runtime-default))
and the section-group resolver a third one needs
(see [Resolving `sections[].group` outside this package](#resolving-sectionsgroup-outside-this-package)).
(see [Resolving `sections[].group` outside this package](#resolving-sectionsgroup-outside-this-package)),
with `sectionEntryName` to read the entries it returns
(see [What a section's `fields` entries draw](#what-a-sections-fields-entries-draw)).
There is no aggregate map among them:

```typescript
Expand Down Expand Up @@ -100,6 +102,7 @@ import {
omitServerResolvedDefaults,
isRequiredInForm,
resolveSectionGroupReferences,
sectionEntryName,
} from '@object-ui/plugin-form';

import type {
Expand Down Expand Up @@ -433,6 +436,32 @@ directly would have to re-spell the `collapse` enum onto its own boolean pair an
pass `visibleWhen` through by hand, which is the duplication this export exists to
prevent.

### What a section's `fields` entries draw

A section's `fields` takes three entry shapes, and every `formType` draws them
the same way: a field **name** and the form view's `{ field, … }` entry (which
overrides that object field) are resolved against the object schema, while an
inline runtime `FormField` keyed by `name` carries its own definition and is
drawn as it stands.

On the default (`simple`) form the two named shapes resolve against the form's
parent field pool — top-level `fields` when given, else the object's fields,
plus `customFields` — so a named member the pool does not hold is dropped
(reported once when the object declares it: `fields` and `sections`
intersect). An inline entry names nothing to resolve, so it is drawn whatever
the pool holds, exactly as the other five layouts draw it (objectui#11615). A
`simple` form whose sections list only inline entries is therefore a
self-contained collector, like the inline wizard below — see
[What a form submits to](#what-a-form-submits-to).

Code that reads a section's entries — a host holding the sections
`resolveSectionGroupReferences` returns, say — names each one with
`sectionEntryName(entry)`: the string itself, the `{ field }` entry's `field`,
the inline entry's `name` (`undefined` when the entry names nothing). Narrowing
by hand and reading `.name` off every object entry reads `undefined` off a
`{ field }` entry, and `ObjectFormSection.fields` now types that read as an
error.

### Column width of a sectioned form

A sectioned form renders as ONE grid, and two keys decide its shape:
Expand Down
9 changes: 7 additions & 2 deletions packages/plugin-form/src/DrawerForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
*/

import React, { useState, useCallback, useEffect, useMemo, useRef, useId } from 'react';
import type { FormField, FormSchema, DataSource, ObjectFormSchema } from '@object-ui/types';
import type { FormField, FormSchema, DataSource, ObjectFormSchema, ObjectFormSection } from '@object-ui/types';
import {
Sheet,
SheetContent,
Expand Down Expand Up @@ -105,7 +105,12 @@ export interface DrawerFormSectionConfig {
label?: string;
description?: string;
columns?: 1 | 2 | 3 | 4;
fields: (string | FormField)[];
/**
* The same three entry shapes as `ObjectFormSection.fields`, by reference:
* a field name, the form view's `{ field, … }` entry, or an inline
* `FormField` (objectui#11615).
*/
fields: NonNullable<ObjectFormSection['fields']>;
collapsible?: boolean;
collapsed?: boolean;
/**
Expand Down
7 changes: 6 additions & 1 deletion packages/plugin-form/src/ModalForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,12 @@ export interface ModalFormSectionConfig {
label?: string;
description?: string;
columns?: 1 | 2 | 3 | 4;
fields: (string | FormField)[];
/**
* The same three entry shapes as `ObjectFormSection.fields`, by reference:
* a field name, the form view's `{ field, … }` entry, or an inline
* `FormField` (objectui#11615).
*/
fields: NonNullable<ObjectFormSection['fields']>;
/**
* Whether the section can be collapsed — spec `FormSection.collapsible`.
* `collapsed: true` implies it (objectui#9780). The control lives on the
Expand Down
Loading
Loading