From 658f3918ef133eb64c070c6e3e5ff8d7ce5c9d5b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 16:58:28 +0000 Subject: [PATCH 1/5] fix(plugin-form,types): the default form draws a self-describing inline section entry, and `ObjectFormSection.fields` gains the form view's `{ field }` arm The pooled branch of `buildSectionFields` dropped every section entry its parent field pool did not hold, the self-describing inline `FormField` too, so a section drew that entry on the five pool-less arms and skipped it on the default (`simple`) one. It now draws an entry `isInlineFieldDef` accepts as the pool-less branch does; name-only entries keep the objectui#9884 intersection and its warning, which no longer names a drawn inline member. `SimpleObjectForm` reads the shared `hasInlineFieldSource` for its submit carve-out, and with no adapter treats fully-inline sections as a members-only field source, so that collector opens on `initialValues` and its `onSuccess` is the write, as on the five other layouts. `ObjectFormSection.fields` declares the spec's `FormFieldInput`, by reference, beside a name and an inline `FormField`. Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude --- .../11615-simple-inline-section-entry.md | 20 ++ content/docs/plugins/plugin-form.mdx | 18 ++ packages/plugin-form/README.md | 18 ++ packages/plugin-form/src/ObjectForm.tsx | 66 ++++-- .../inlineSectionEntry-11615.test.tsx | 188 ++++++++++++++++++ packages/plugin-form/src/index.tsx | 2 +- .../plugin-form/src/sectionFields.test.ts | 41 +++- packages/plugin-form/src/sectionFields.ts | 30 ++- packages/plugin-form/src/submitTarget.ts | 8 +- .../src/submitTargetRefusal.test.tsx | 97 +++++---- ...ect-form-section-field-entry-11615.test.ts | 56 ++++++ packages/types/src/objectql.ts | 25 ++- packages/types/src/zod/objectql.zod.ts | 4 +- 13 files changed, 503 insertions(+), 70 deletions(-) create mode 100644 .changeset/11615-simple-inline-section-entry.md create mode 100644 packages/plugin-form/src/__tests__/inlineSectionEntry-11615.test.tsx create mode 100644 packages/types/src/__tests__/object-form-section-field-entry-11615.test.ts diff --git a/.changeset/11615-simple-inline-section-entry.md b/.changeset/11615-simple-inline-section-entry.md new file mode 100644 index 0000000000..c68d84b3ba --- /dev/null +++ b/.changeset/11615-simple-inline-section-entry.md @@ -0,0 +1,20 @@ +--- +'@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).** Both packages accept more, and nothing they accepted before is refused now. + +- `@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`, 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`. + +**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. diff --git a/content/docs/plugins/plugin-form.mdx b/content/docs/plugins/plugin-form.mdx index 99b8479bf1..de2b229d99 100644 --- a/content/docs/plugins/plugin-form.mdx +++ b/content/docs/plugins/plugin-form.mdx @@ -144,6 +144,24 @@ 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. + ### Column width of a sectioned form A sectioned form renders as ONE grid. The form view's `columns` (spec diff --git a/packages/plugin-form/README.md b/packages/plugin-form/README.md index 1a48269c3f..3441bb4c5d 100644 --- a/packages/plugin-form/README.md +++ b/packages/plugin-form/README.md @@ -433,6 +433,24 @@ 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). + ### Column width of a sectioned form A sectioned form renders as ONE grid, and two keys decide its shape: diff --git a/packages/plugin-form/src/ObjectForm.tsx b/packages/plugin-form/src/ObjectForm.tsx index 10053fabef..f879f7e85b 100644 --- a/packages/plugin-form/src/ObjectForm.tsx +++ b/packages/plugin-form/src/ObjectForm.tsx @@ -62,7 +62,7 @@ import { formWritePayload } from './writePayload'; import { applyFieldPermissions, closedFormAffordance, fieldWriteGate, gateFormFields } from './fieldWriteGate'; import { ClosedAffordanceNotice } from './closedAffordanceNotice'; import { resolveInitialRecord } from './initialRecord'; -import { noSubmitTargetError } from './submitTarget'; +import { hasInlineFieldSource, isInlineFieldDef, noSubmitTargetError } from './submitTarget'; import { useUploadGate, UploadGateProvider, UploadInFlightNotice } from './uploadGate'; import { schemaDefaultValues, @@ -754,6 +754,13 @@ const SimpleObjectForm: React.FC<{ schema: LocalizedObjectFormSchema; dataSource // Check if using inline fields (fields defined as objects, not just names) const hasInlineFields = schema.customFields && schema.customFields.length > 0; + // objectui#11615 — the members-only field source as the five other layouts + // answer it (`hasInlineFieldSource`): `customFields` (limb a, which is + // `hasInlineFields` above), or sections whose EVERY entry is self-describing + // (limb b), which this arm now draws whatever its field pool holds. With no + // adapter such a form has nothing to read: it opens on the caller's record, + // and its `onSuccess` is the write (the carve-out in `handleSubmit`). + const hasInlineSource = hasInlineFieldSource(schema); // Initialize with inline data if provided useEffect(() => { @@ -825,9 +832,13 @@ const SimpleObjectForm: React.FC<{ schema: LocalizedObjectFormSchema; dataSource // what the registration's second sentence describes ("with inline // definitions and no data source, this becomes the only field source"), so // it is now the FALLBACK rather than the inline path's fixed answer. + // + // objectui#11615: sections of self-describing entries are the other + // members-only source (`hasInlineSource`), so they take the same fallback — + // which is what lets the record effect below seed such a form. if (schema.objectName && dataSource) { fetchObjectSchema(); - } else if (hasInlineFields) { + } else if (hasInlineSource) { setObjectSchema(inlineOnlySchema); run.commit(); } else { @@ -835,7 +846,7 @@ const SimpleObjectForm: React.FC<{ schema: LocalizedObjectFormSchema; dataSource setLoading(false); } return () => { cancelled = true; }; - }, [schema.objectName, dataSource, hasInlineFields]); + }, [schema.objectName, dataSource, hasInlineFields, hasInlineSource]); // objectui#10572 — the data-invalidation bus (`notifyDataChanged` from // `@object-ui/react`), read for the record this form READS (edit/view mode, @@ -882,6 +893,17 @@ const SimpleObjectForm: React.FC<{ schema: LocalizedObjectFormSchema; dataSource } if (!dataSource) { + if (hasInlineSource) { + // objectui#11615 — sections of self-describing entries with no + // adapter: a self-contained collector with nothing to read, so it + // opens on the caller's record, as the five other layouts open the + // same form. Not a read, so no baseline. + loadedRecordRef.current = null; + setInitialData(resolveInitialRecord(schema)); + run.commit(); + setLoading(false); + return; + } run.fail(new Error('DataSource is required for fetching record data (inline data not provided)')); setLoading(false); return; @@ -918,7 +940,7 @@ const SimpleObjectForm: React.FC<{ schema: LocalizedObjectFormSchema; dataSource fetchInitialData(); } return () => { cancelled = true; }; - }, [schema.objectName, schema.recordId, schema.mode, schema.initialValues, schema.initialData, dataSource, objectSchema, hasInlineFields, recordRefetch]); + }, [schema.objectName, schema.recordId, schema.mode, schema.initialValues, schema.initialData, dataSource, objectSchema, hasInlineFields, hasInlineSource, recordRefetch]); // FormField `visibleOn` (spec FormFieldSchema CEL expression) is consumed // directly by the form renderer via the canonical engine — it accepts both @@ -1228,17 +1250,21 @@ const SimpleObjectForm: React.FC<{ schema: LocalizedObjectFormSchema; dataSource // was never asked to perform (objectui#6388). Same rule and same precedence // as the five variant renderers — see `submitTarget.ts` for the whole rule. // - // The predicate stays this component's own `hasInlineFields` (non-empty - // `customFields`) rather than the shared `hasInlineFieldSource`. That - // helper's second limb — sections whose every field is an inline runtime - // `FormField` — is how the SECTIONED variants express an inline field - // source, and this renderer does not read it: here `sections[].fields` only - // SELECT (and override) fields already resolved from `customFields` or the - // object schema, so a sections-only form with no adapter resolves zero - // fields. Treating that as inline would widen the carve-out into a success - // signal for a form that collected nothing — this card's own defect class. - // Limb (a) is identical, and the refusal below is the shared one, verbatim. - if (!dataSource && !schema.submitHandler && hasInlineFields) { + // The predicate is the shared `hasInlineFieldSource`, as on the five other + // renderers (objectui#11615). Its limb (a) is this component's own + // `hasInlineFields` (non-empty `customFields`); its limb (b) — sections + // whose EVERY field is a self-describing inline `FormField` — is now one + // this renderer draws, because a section here draws such an entry whatever + // the parent field pool holds (`buildSectionFields`). So a sections-only + // form with no adapter collects exactly the fields its sections declare, + // and `onSuccess` is that collector's write, as it is everywhere else. + // Until objectui#11615 this renderer kept `hasInlineFields` alone, and + // rightly: a section only SELECTED pooled fields then, so the same form + // resolved zero fields, and counting it inline would have confirmed an + // empty submit. Limb (b) is all-or-nothing, so one name-only entry in any + // section still refuses below: a form that needed metadata it could not + // get never reaches the carve-out. The refusal is the shared one, verbatim. + if (!dataSource && !schema.submitHandler && hasInlineFieldSource(schema)) { if (schema.onSuccess) { await schema.onSuccess(formData); } @@ -1444,7 +1470,7 @@ const SimpleObjectForm: React.FC<{ schema: LocalizedObjectFormSchema; dataSource throw err; } - }, [schema, dataSource, hasInlineFields, perms, objectSchema, saveWithOcc, initialData, uploadGate.uploading, uploadGate.reason, recordSaved, t]); + }, [schema, dataSource, perms, objectSchema, saveWithOcc, initialData, uploadGate.uploading, uploadGate.reason, recordSaved, t]); // Handle form cancellation const handleCancel = useCallback(() => { @@ -1636,7 +1662,13 @@ const SimpleObjectForm: React.FC<{ schema: LocalizedObjectFormSchema; dataSource // exactly how a spec-legal section blanked the entire form — the // well-formed siblings with it — before objectui#7051. `buildSectionFields` // spells the same read `section.fields ?? []` for the members below. - const sectionFieldNames = (section.fields ?? []).map(sectionEntryName); + // NAME-ONLY entries only: a self-describing inline entry is drawn + // whatever the pool holds (objectui#11615, `isInlineFieldDef`), so it is + // never one the intersection below drops, and naming it there would + // report a loss that did not happen. + const sectionFieldNames = (section.fields ?? []) + .filter(entry => !isInlineFieldDef(entry)) + .map(sectionEntryName); // objectui#9884 — make the INTERSECTION audible. // diff --git a/packages/plugin-form/src/__tests__/inlineSectionEntry-11615.test.tsx b/packages/plugin-form/src/__tests__/inlineSectionEntry-11615.test.tsx new file mode 100644 index 0000000000..d2126680ab --- /dev/null +++ b/packages/plugin-form/src/__tests__/inlineSectionEntry-11615.test.tsx @@ -0,0 +1,188 @@ +/** + * 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. + */ + +/** + * A self-describing inline section entry is drawn on EVERY `object-form` arm, + * the default one included (objectui#11615). + * + * A section's `fields` takes three entry shapes: a field name, the form view's + * `{ field, … }` entry, and an inline runtime `FormField` keyed by `name`. The + * `tabbed`, `wizard`, `split`, `drawer` and `modal` arms build a section with no + * field pool, so they draw an inline entry as it stands. The default arm + * (`SimpleObjectForm`) hands `buildSectionFields` its parent field POOL + * (objectui#10475), and the pooled branch dropped every entry the pool did not + * hold — the inline one too. So the same section drew its inline member on five + * arms and skipped it on the sixth, with the objectui#9884 intersection warning + * the only trace when the object happened to declare the name. + * + * Triage's ruling (comment `5980751840`): the intersection catches a NAME-ONLY + * entry the pool does not hold — a typo, a stale name, a field top-level + * `fields` leaves out — and an entry that declares itself names nothing to + * resolve, so skipping it guarded nothing. The pooled branch now draws such an + * entry; the named shapes keep the intersection and its warning. + * + * "Self-describing" is `isInlineFieldDef` (`submitTarget.ts`): an object whose + * `field` is not a string and whose `name` is — the one predicate the + * submit-target rule already read. It does NOT require `type`: the spec's + * inline arm keys by `name` and leaves `type` optional, and the pool-less + * arms draw a typeless entry as the default input. The typeless row below pins + * that the default arm agrees. + * + * Every row mounts the real `ObjectForm` against an adapter, which is the page + * block's route. The data-source-free half lives in `submitTargetRefusal.test.tsx` + * block 3. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, waitFor, cleanup } from '@testing-library/react'; +import React from 'react'; +import { registerAllFields } from '@object-ui/fields'; +import { ObjectForm } from '../ObjectForm'; + +registerAllFields(); +afterEach(cleanup); + +const OBJECT_SCHEMA = { + name: 'invoice', + fields: { + customer: { type: 'text', label: 'Customer' }, + note: { type: 'text', label: 'Note' }, + amount: { type: 'text', label: 'Amount' }, + }, +}; + +const makeDataSource = () => + ({ + getObjectSchema: vi.fn().mockResolvedValue(OBJECT_SCHEMA), + findOne: vi.fn(), + create: vi.fn(async (_object: string, data: Record) => ({ id: 'r1', ...data })), + update: vi.fn(), + }) as any; + +const ARMS = ['simple', 'drawer', 'modal', 'tabbed', 'wizard', 'split'] as const; +type Arm = (typeof ARMS)[number]; + +/** The drawer and modal portal their content, so every read is off `document.body`. */ +const drawnFields = (): string[] => + [...document.body.querySelectorAll('[data-field]')].map((el) => el.getAttribute('data-field') as string); + +const labelOf = (name: string): string | null => + document.body.querySelector(`[data-field="${name}"] label`)?.textContent ?? null; + +const controlOf = (name: string): Element | null => + document.body.querySelector(`[data-field="${name}"] input, [data-field="${name}"] textarea`); + +/** An inline field no object declares, and one with no `type` at all. */ +const MEMO = { name: 'memo', type: 'text', label: 'Memo' }; +const TYPELESS = { name: 'typeless', label: 'Typeless' }; + +async function mount(arm: Arm, schema: Record) { + const adapter = makeDataSource(); + render( + , + ); + await waitFor(() => { + if (!document.body.querySelector('form [data-field]')) throw new Error('form not ready'); + }); + return adapter; +} + +describe.each(ARMS)('`object-form` `formType: %s` — a self-describing inline section entry (objectui#11615)', (arm) => { + it('is drawn in the section’s order, typed or not, though nothing declares its name', async () => { + await mount(arm, { + sections: [{ name: 'main', label: 'Main', fields: ['customer', MEMO, TYPELESS] }], + }); + // On the tree before objectui#11615 the `simple` row read `['customer']`: + // the pooled branch dropped both inline entries. The other five were green. + expect(drawnFields()).toEqual(['customer', 'memo', 'typeless']); + expect(labelOf('memo')).toBe('Memo'); + expect(controlOf('memo'), 'drawn as a working control, not a bare heading').not.toBeNull(); + expect(controlOf('typeless'), 'a typeless entry draws the default input').not.toBeNull(); + }); +}); + +describe('`object-form` default arm — the intersection stays, for NAMED entries only (objectui#9884, objectui#11615)', () => { + it('top-level `fields` still drops a named member it does not list, and warns; the inline member beside them is drawn and not warned about', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + await mount('simple', { + fields: ['customer'], + sections: [ + { + name: 'main', + label: 'Main', + fields: ['customer', 'note', { field: 'amount', label: 'AMOUNT OVERRIDE' }, MEMO], + }, + ], + }); + expect( + drawnFields(), + '`note` (a name) and `amount` (a `{ field }` entry) are declared and left out of `fields`; `memo` names nothing to resolve', + ).toEqual(['customer', 'memo']); + + const said = warn.mock.calls.map((call) => String(call[0])); + expect(said.some((m) => m.includes("names 'note'")), 'the named loss is still reported').toBe(true); + expect(said.some((m) => m.includes("names 'amount'")), 'so is the `{ field }` loss').toBe(true); + expect( + said.some((m) => m.includes("names 'memo'")), + 'the drawn inline member is not reported as dropped', + ).toBe(false); + } finally { + warn.mockRestore(); + } + }); + + it('an inline entry naming a declared field that `fields` leaves out is drawn as ITS OWN definition, with no warning', async () => { + // The case the old pooled branch treated as a NAME: the object declares + // `amount`, top-level `fields` does not list it, and the section supplies a + // whole definition under that name. It is drawn as authored — the label it + // carries, not the object's — exactly as the five pool-less arms draw it. + // Its own section name: the warning is deduped per (object, section, + // member), and the row above already reported `amount` under `main`. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + await mount('simple', { + fields: ['customer'], + sections: [ + { + name: 'second', + label: 'Second', + fields: ['customer', { name: 'amount', type: 'text', label: 'INLINE AMOUNT' }], + }, + ], + }); + expect(drawnFields()).toEqual(['customer', 'amount']); + expect(labelOf('amount')).toBe('INLINE AMOUNT'); + expect(warn.mock.calls.map((call) => String(call[0])).some((m) => m.includes("names 'amount'"))).toBe(false); + + // The control: the same arm, the same object, `amount` named by NAME — + // dropped and warned, so the drawing above is the entry's shape at work. + cleanup(); + warn.mockClear(); + await mount('simple', { + fields: ['customer'], + sections: [{ name: 'second', label: 'Second', fields: ['customer', 'amount'] }], + }); + expect(drawnFields()).toEqual(['customer']); + expect(warn.mock.calls.map((call) => String(call[0])).some((m) => m.includes("names 'amount'"))).toBe(true); + } finally { + warn.mockRestore(); + } + }); +}); diff --git a/packages/plugin-form/src/index.tsx b/packages/plugin-form/src/index.tsx index b730e588d5..1be14d1c5a 100644 --- a/packages/plugin-form/src/index.tsx +++ b/packages/plugin-form/src/index.tsx @@ -546,7 +546,7 @@ ComponentRegistry.register('object-master-detail-form', MasterDetailFormRenderer // Declaring them would mint choices an authoring UI offers and this block // cannot honour. { name: 'formType', type: 'enum', enum: ['simple', 'tabbed'], description: 'How the PARENT half of the form is presented. The detail grids below it are unaffected.' }, - { name: 'fields', type: 'array', description: 'Which parent fields to show, in order — and it is NOT ignored when `sections` is given: the two INTERSECT. The parent field pool is built from this key first and every section then resolves its own members against that pool, so a section member this key does not list is dropped from the rendered form, and a section that loses EVERY member that way disappears with its heading. Each such drop is reported once via `console.warn` (objectui#9884); it is not repaired, because this key bounds what the form DRAWS and edits, not what Save writes: on a create, the submitted set is the drawn fields plus any parent value seeded through `initialValues` (or its alternate spelling `initialData`), drawn or not. A seed for an undeclared, server-owned, computed or read-only field is still stripped, as on any save. Author one or the other, or list every section member here too. Members are bare field names; NOT the spec `FormFieldSchema` object `sections[].fields` accepts (identity key `field`) — that shape resolves to no name here and is silently skipped (the parent form renders through the same `ObjectForm` / `SimpleObjectForm` as `object-form` — see its `fields` description).' }, + { name: 'fields', type: 'array', description: 'Which parent fields to show, in order — and it is NOT ignored when `sections` is given: the two INTERSECT. The parent field pool is built from this key first and every section then resolves its NAMED members (a bare name, or the `{ field }` entry) against that pool, so such a member this key does not list is dropped from the rendered form, and a section that loses EVERY member that way disappears with its heading. An inline field entry (`{ name, type, … }`) names nothing to resolve and is drawn whatever this key lists, as on every form type (objectui#11615). Each such drop is reported once via `console.warn` (objectui#9884); it is not repaired, because this key bounds what the form DRAWS and edits, not what Save writes: on a create, the submitted set is the drawn fields plus any parent value seeded through `initialValues` (or its alternate spelling `initialData`), drawn or not. A seed for an undeclared, server-owned, computed or read-only field is still stripped, as on any save. Author one or the other, or list every section member here too. Members are bare field names; NOT the spec `FormFieldSchema` object `sections[].fields` accepts (identity key `field`) — that shape resolves to no name here and is silently skipped (the parent form renders through the same `ObjectForm` / `SimpleObjectForm` as `object-form` — see its `fields` description).' }, // The three labels are the spec's `I18nLabel` (`ComponentPropsMap // ['object-master-detail-form']`), and `MasterDetailForm` resolves a map // with `pickLocalized` against the active UI language (objectui#10935). So diff --git a/packages/plugin-form/src/sectionFields.test.ts b/packages/plugin-form/src/sectionFields.test.ts index 2e08439f14..b289b69fdf 100644 --- a/packages/plugin-form/src/sectionFields.test.ts +++ b/packages/plugin-form/src/sectionFields.test.ts @@ -259,13 +259,12 @@ describe('buildSectionFields — with a pool (objectui#10475)', () => { expect(fields.map((f) => f.name)).toEqual(['industry', 'name']); }); - it('drops an entry the pool does not hold, in every shape — the objectui#9884 intersection', () => { + it('drops a NAME-ONLY entry the pool does not hold, in both named shapes — the objectui#9884 intersection', () => { const fields = buildSectionFields( { fields: [ 'billing_address', { field: 'billing_address', label: 'X' }, - { name: 'billing_address', type: 'text' }, 'name', ], }, @@ -281,6 +280,42 @@ describe('buildSectionFields — with a pool (objectui#10475)', () => { ).toEqual(['billing_address', 'name']); }); + it('draws a SELF-DESCRIBING inline entry whatever the pool holds, as the pool-less branch does (objectui#11615)', () => { + // Three self-describing entries the pool does not hold: one naming a field + // nothing declares, one naming a field the object declares (absent from the + // pool — what top-level `fields` leaving it out produces), and one carrying + // no `type` at all: the spec's inline arm keys by `name` and leaves `type` + // optional, so `type` is not what makes an entry self-describing. + const memo = { name: 'memo', label: 'Memo', type: 'text' }; + const billing = { name: 'billing_address', label: 'INLINE BILLING', type: 'textarea' }; + const typeless = { name: 'typeless', label: 'Typeless' }; + const section = { fields: ['industry', memo, billing, typeless, 'name'] as any[] }; + + const pooled = buildSectionFields(section, pooledCtx); + expect(pooled.map((f) => f.name), 'section order, the pooled names around the inline ones').toEqual([ + 'industry', + 'memo', + 'billing_address', + 'typeless', + 'name', + ]); + // Each is drawn AS IT STANDS — the same answer the pool-less branch gives. + const poolless = buildSectionFields(section, ctx); + expect(pooled[1]).toEqual(poolless[1]); + expect(pooled[2]).toEqual(poolless[2]); + expect(pooled[3]).toEqual(poolless[3]); + expect((pooled[2] as any).label, 'its own definition, not the object’s field').toBe('INLINE BILLING'); + expect((pooled[3] as any).type, 'no `type` is invented for it').toBeUndefined(); + }); + + it('still drops a malformed shape-3 entry with no `name` — it names nothing and describes nothing', () => { + // Not self-describing (`isInlineFieldDef` wants a `name`), and no pooled + // field answers to it: the one entry the pooled branch drops for having no + // identity at all, as it did before objectui#11615. + const fields = buildSectionFields({ fields: [{ label: 'nameless', type: 'text' } as any, 'name'] }, pooledCtx); + expect(fields.map((f) => f.name)).toEqual(['name']); + }); + it('a name string draws the POOLED field as it is', () => { const [f] = buildSectionFields({ fields: ['industry'] }, pooledCtx); expect(f).toBe(pool[1]); @@ -319,7 +354,7 @@ describe('buildSectionFields — with a pool (objectui#10475)', () => { expect(f.disabled).toBe(true); }); - it('an already-built runtime FormField entry is its own definition, drawn only when pooled', () => { + it('an already-built runtime FormField entry is its own definition, even when the pool holds its name', () => { const runtime = { name: 'industry', label: 'RUNTIME', type: 'field:text' }; const [f] = buildSectionFields({ fields: [runtime as any] }, pooledCtx); expect(f.label).toBe('RUNTIME'); diff --git a/packages/plugin-form/src/sectionFields.ts b/packages/plugin-form/src/sectionFields.ts index 1202250513..6b949a5f1a 100644 --- a/packages/plugin-form/src/sectionFields.ts +++ b/packages/plugin-form/src/sectionFields.ts @@ -45,6 +45,7 @@ import { evalFieldPredicate, isBlankPredicateText, type FieldRulePredicate } fro import { mapFieldTypeToFormType, buildValidationRules } from '@object-ui/fields'; import { isCreateFormMode, isRequiredInForm } from './schemaDefaults'; import { findCustomFieldMember } from './customFieldsMerge'; +import { isInlineFieldDef } from './submitTarget'; export interface SectionFieldsContext { /** Resolved object schema (`{ fields: { [name]: fieldDef } }`) or null. */ @@ -91,7 +92,9 @@ export interface SectionFieldsContext { * * When set, {@link buildSectionFields} still walks the section's entries in * AUTHORED order and applies each entry's overrides by the same rules as - * every other arm; two things change, both the pool's to decide: + * every other arm; for a NAME-ONLY entry — shape (1) or (2), which names a + * field and leaves its definition to be resolved — two things change, both + * the pool's to decide: * * - membership — an entry naming a field the pool does not hold is * dropped. That is objectui#9884's INTERSECTION of `fields` and @@ -107,8 +110,15 @@ export interface SectionFieldsContext { * not one of them: every arm applies it after this builder, in * `gateFormFields` (objectui#10612). * - * An already-built runtime FormField entry (shape 3) is its own definition - * with or without a pool; the pool decides only whether it is drawn. + * An already-built runtime FormField entry (shape 3) carrying its own + * `name` is its own definition, and the pool decides NOTHING about it: it is + * drawn whatever the pool holds, exactly as the pool-less arms draw it + * (objectui#11615). The intersection catches a NAME the object does not + * declare or `fields` does not list — a typo, a stale name — and an entry + * that declares itself names nothing to resolve, so dropping it guarded + * nothing and made this arm the one that skipped what the other five drew. + * "Self-describing" is `isInlineFieldDef` (`submitTarget.ts`), the one + * predicate the submit-target rule already reads. * Omitted → no pool: every entry is drawn, from the bases above. */ pool?: readonly FormField[] | null; @@ -458,9 +468,11 @@ function resolveSectionEntry( /** * Normalize every field def in a section, in the section's AUTHORED order. * - * With a {@link SectionFieldsContext.pool}, an entry naming a field the pool - * does not hold is dropped, and a pooled field is the base the entry starts - * from; the order stays the section's own either way (objectui#10475). + * With a {@link SectionFieldsContext.pool}, a name-only entry naming a field + * the pool does not hold is dropped, and a pooled field is the base the entry + * starts from; a self-describing inline entry is drawn as it stands, pooled + * or not (objectui#11615); the order stays the section's own either way + * (objectui#10475). */ export function buildSectionFields( section: { fields?: Array> }, @@ -478,6 +490,12 @@ export function buildSectionFields( } const drawn: FormField[] = []; for (const fieldDef of entries) { + // A self-describing inline entry needs nothing the pool holds: drawn the + // way the pool-less branch above draws it (objectui#11615). + if (isInlineFieldDef(fieldDef)) { + drawn.push(normalizeSectionField(fieldDef, ctx)); + continue; + } const name = sectionEntryName(fieldDef); const pooled = name === undefined ? undefined : pooledByName.get(name); if (!pooled) continue; diff --git a/packages/plugin-form/src/submitTarget.ts b/packages/plugin-form/src/submitTarget.ts index cc6a8eb765..a36fb85c83 100644 --- a/packages/plugin-form/src/submitTarget.ts +++ b/packages/plugin-form/src/submitTarget.ts @@ -90,8 +90,14 @@ export interface InlineFieldSource { * the `field` slot holds the metadata OBJECT. A missing `name` is not treated as * inline either: shape 3 without one is what used to crash the form renderer on * `name.split('.')`, so it is malformed rather than self-describing. + * + * Exported because it is also what `buildSectionFields` (`sectionFields.ts`) + * asks of a section entry before its parent field pool may drop it + * (objectui#11615): a self-describing entry is drawn whatever the pool holds, + * as the pool-less arms draw it. One predicate, so "inline enough to need no + * adapter" and "inline enough to need no pool" cannot drift apart. */ -function isInlineFieldDef(def: unknown): boolean { +export function isInlineFieldDef(def: unknown): boolean { if (def === null || typeof def !== 'object' || Array.isArray(def)) return false; const fd = def as { field?: unknown; name?: unknown }; return typeof fd.field !== 'string' && typeof fd.name === 'string'; diff --git a/packages/plugin-form/src/submitTargetRefusal.test.tsx b/packages/plugin-form/src/submitTargetRefusal.test.tsx index b1bf7cf7bd..4ad7032cbb 100644 --- a/packages/plugin-form/src/submitTargetRefusal.test.tsx +++ b/packages/plugin-form/src/submitTargetRefusal.test.tsx @@ -52,12 +52,22 @@ * field source — and it too opened `handleSubmit`, ahead of the persistence * chain. Re-derived on the merged tree (faa863dce) with `customFields`, a * `submitHandler` and NO `dataSource`: `onSuccess 1 / submitHandler 0`. Its - * cases live in blocks 1 and 3 beside the family's, on a `customFields` - * fixture: under `simple`, `sections[].fields` only SELECT fields already - * resolved from `customFields` or the object schema, so the sectioned fixture - * above renders no fields at all there — which is also why `simple` keeps its - * own predicate rather than `hasInlineFieldSource` (block 3's `simple` - * BOUNDARY case pins that). + * cases in blocks 1 and 3 sit beside the family's on a `customFields` + * fixture, because `baseSchema`'s section lists a bare NAME, which under + * `simple` resolves against a field pool that only `customFields` or an + * object schema can fill — so that fixture renders no fields there. + * + * ## The inline-sections shape on all six (objectui#11615) + * + * A section of self-describing inline `FormField`s is a different matter. + * Until objectui#11615 `simple` resolved even those against its pool, so the + * README's inline-sections shape rendered ZERO fields under `simple` and its + * submit refused, and `simple` kept its own `customFields`-only predicate for + * that reason (a case here pinned the refusal as its BOUNDARY). `simple` now + * draws such an entry whatever its pool holds, as the five variants do, and + * reads the shared `hasInlineFieldSource` — so block 3's two sections cases + * run all six renderers, its boundary included: one bare name among inline + * entries still refuses on every one of them. * * ## The blocks below * @@ -116,6 +126,12 @@ registerAllFields(); const VARIANTS = ['tabbed', 'wizard', 'split', 'drawer', 'modal'] as const; /** The two of them that implement an inline-`customFields` field source. */ const INLINE_RENDERERS = ['drawer', 'modal'] as const; +/** + * All six renderers, for the inline-SECTIONS shape: `simple` draws a section of + * self-describing entries whatever its field pool holds (objectui#11615), so + * that shape means the same thing under every `formType`. + */ +const SECTION_RENDERERS = [...VARIANTS, 'simple'] as const; const parentObject = { name: 'po', @@ -328,7 +344,7 @@ describe('3. CARVE-OUT: a legitimate inline-fields form still works', () => { }, ); - it.each(VARIANTS)( + it.each(SECTION_RENDERERS)( 'formType `%s`: sections of inline runtime fields — the README\u2019s own shape — still work', async (formType) => { const onSuccess = vi.fn(); @@ -357,7 +373,7 @@ describe('3. CARVE-OUT: a legitimate inline-fields form still works', () => { }, ); - it.each(VARIANTS)( + it.each(SECTION_RENDERERS)( 'formType `%s`: BOUNDARY — one bare field name among inline ones still refuses', async (formType) => { const onSuccess = vi.fn(); @@ -405,39 +421,42 @@ describe('3. CARVE-OUT: a legitimate inline-fields form still works', () => { expect(onError).not.toHaveBeenCalled(); }); - it('formType `simple`: BOUNDARY — sections of inline fields are NOT its field source', async () => { - const onSuccess = vi.fn(); - const onError = vi.fn(); + it.each(SECTION_RENDERERS)( + 'formType `%s`: the inline-sections collector opens on `initialValues` and submits what it holds', + async (formType) => { + const onSuccess = vi.fn(); + const onError = vi.fn(); - // `hasInlineFieldSource`'s second limb (all-inline `sections`) is how the - // SECTIONED variants declare an inline field source — block 3's case above - // pins it for them. `SimpleObjectForm` does not read it: a section field - // here only SELECTS a field already resolved from `customFields` or the - // object schema, so this form resolves ZERO fields and collected nothing. - // Adopting the shared predicate for `simple` while "aligning" it would - // therefore turn this into a success signal for an empty submit — the very - // defect class of objectui#6300. It refuses instead. - render( - , - ); - const submit = await waitFor(() => screen.getByRole('button', { name: /save now/i })); - fireEvent.click(submit); + // The collector half the refusal cases above never reach: with no adapter + // there is nothing to read, so the form opens on the caller's record. This + // case replaces `simple`'s old BOUNDARY row, which pinned the REFUSAL of + // this very form while `simple` drew none of its fields (objectui#11615). + // Now that it draws them, it must also open on `initialValues` as the five + // variants do — the `simple` row is the one that goes red without the + // members-only seeding in `SimpleObjectForm`'s schema and record effects. + render( + , + ); + const ref = await waitFor(() => { + const el = document.querySelector('input[name="ref"]') as HTMLInputElement | null; + if (!el) throw new Error('form never rendered — the assertions below would pass vacuously'); + return el; + }); + expect(ref.value, 'the collector opens on the caller\'s record').toBe('SEEDED'); + fireEvent.click(screen.getByRole('button', { name: /save now/i })); - await waitFor(() => expect(onError).toHaveBeenCalledTimes(1)); - expect((onError.mock.calls[0][0] as Error).message).toBe(NO_SUBMIT_TARGET_MESSAGE); - expect(onSuccess).not.toHaveBeenCalled(); - }); + await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1)); + expect(onSuccess).toHaveBeenCalledWith(expect.objectContaining({ ref: 'SEEDED' })); + expect(onError).not.toHaveBeenCalled(); + }, + ); }); describe('4. DEGENERATE CONTROL: with a dataSource the write really happens', () => { diff --git a/packages/types/src/__tests__/object-form-section-field-entry-11615.test.ts b/packages/types/src/__tests__/object-form-section-field-entry-11615.test.ts new file mode 100644 index 0000000000..723d69d1ae --- /dev/null +++ b/packages/types/src/__tests__/object-form-section-field-entry-11615.test.ts @@ -0,0 +1,56 @@ +/** + * 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. + */ + +/** + * `ObjectFormSection.fields` declares all three entry shapes the form draws + * (objectui#11615). + * + * `buildSectionFields` (`@object-ui/plugin-form`) draws a field NAME, the form + * view's `{ field, … }` entry, and an inline runtime `FormField`. The type + * declared `(string | FormField)[]`, so the middle shape — the one + * `@objectstack/spec`'s own `FormSectionSchema.fields` takes beside a bare + * name — did not compile for a TypeScript author: `FormField` requires `name` + * and declares its `field` slot as the resolved metadata OBJECT, never a + * string. The arm is now the spec's `FormFieldInput`, by reference. + * + * ⚠️ COMPILE-time first: the annotations do the work, so `type-check` (this + * package's `tsconfig.test.json`) is the instrument that can fail these rows, + * and vitest — which strips types — cannot. The runtime expectations only keep + * each row from passing on an emptied literal. + */ + +import { describe, it, expect } from 'vitest'; +import type { ObjectFormSection } from '../objectql'; + +describe('`ObjectFormSection.fields` — the three entry shapes (objectui#11615)', () => { + it('annotates a name, the form view `{ field }` entry, and an inline `FormField` side by side', () => { + const section: ObjectFormSection = { + name: 'main', + label: 'Main', + fields: [ + 'customer', + { field: 'note', label: 'Note override', required: true, colSpan: 2, helpText: 'Shown under it' }, + { name: 'memo', type: 'text', label: 'Memo' }, + ], + }; + expect(section.fields).toHaveLength(3); + expect(section.fields?.[1]).toEqual( + expect.objectContaining({ field: 'note', required: true }), + ); + }); + + it('the `{ field }` arm is the spec’s own entry type, so a mistyped override is a compile error', () => { + const section: ObjectFormSection = { + fields: [ + // @ts-expect-error — `colSpan` is a number on the spec's form-view entry + { field: 'note', colSpan: 'two' }, + ], + }; + expect(section.fields).toHaveLength(1); + }); +}); diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index dad56421b8..c315f48a76 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -40,6 +40,10 @@ import type { ResponsiveStyles as SpecResponsiveStyles } from '@objectstack/spec // the spec's `InlineGridColumn` (its `z.input`, the authoring face), which the zod // twin judges with the spec's own `InlineGridColumnSchema`. Type-only. import type { InlineGridColumn as SpecInlineGridColumn } from '@objectstack/spec/data'; +// objectui#11615 — the form view's `{ field }` section entry, by reference: the +// spec's `FormFieldInput` (the authoring face of `FormFieldSchema`, the arm the +// spec's `FormSectionSchema.fields` takes beside a bare name). Type-only. +import type { FormFieldInput as SpecFormFieldInput } from '@objectstack/spec/ui'; // objectui#7928 — `ObjectViewSchema.listViews` is the protocol's record of this // schema, BY REFERENCE. The spec publishes a TS type for `ListView` and none for // the object-scoped `ObjectListView`, so the member reads it as `z.input` (the @@ -1555,7 +1559,24 @@ export interface ObjectFormSection { pane?: 'primary' | 'secondary'; /** - * Field names or inline field configurations for this section. + * The section's fields, in order. Each entry is one of three shapes, the + * three `buildSectionFields` (`@object-ui/plugin-form`) draws on every + * `formType`: + * + * - a field NAME, resolved against the object schema; + * - the form view's `{ field, … }` entry — `@objectstack/spec`'s + * `FormFieldInput`, by reference (objectui#11615): `field` names an object + * field and every other key overrides that field's generated definition; + * - an inline runtime {@link FormField} keyed by `name`, drawn as it stands + * with no object schema needed. + * + * On the default (`simple`) form the first two shapes resolve against the + * form's parent field pool — top-level `fields` when it is given, else the + * object's fields, plus `customFields` — and a name the pool does not hold is + * dropped (with a warning when the object declares it: `fields` and + * `sections` intersect, objectui#9884). An inline `FormField` names nothing + * to resolve, so it is drawn whatever the pool holds, as on every other + * `formType` (objectui#11615). * * OPTIONAL since objectui#7051, and optional ONLY in the sense that * {@link group} is the other way to declare the same fact — @@ -1565,7 +1586,7 @@ export interface ObjectFormSection { * read `group`, so this type refused the exact shape the spec declares and a * TypeScript author could not write the group-reference form at all. */ - fields?: (string | FormField)[]; + fields?: (string | SpecFormFieldInput | FormField)[]; /** * Reference a declared field GROUP instead of enumerating members diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 27ae77d708..fead84b19b 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -814,7 +814,9 @@ const OBJECT_FORM_NEITHER_CHANNEL = neitherContentChannelGuidance( * `ObjectFormSchema` pair rather than as a pair of its own. * * ⚠️ A `fields` entry is `z.any()`, the precedent this mirror already set for - * `customFields`: the declared entry is `string | FormField`, and + * `customFields`: the declared entry is `string | SpecFormFieldInput | + * FormField` (the spec's form-view `{ field }` entry joined it in + * objectui#11615), and * `FormFieldSchema` (`./form.zod.ts`) carries its own `KnownDrift` (`validation`) * and `UnmirroredDeclared` (`field`) rows, so binding it here would import that * drift into this pair. So every SECTION-level member is judged, and a field From c4c5ac14413b81ab1ffb3146407a7388e9eb50d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 17:02:05 +0000 Subject: [PATCH 2/5] chore(scripts): `ObjectFormSection` leaves the spec-alignment claim debt ledger Its `fields` member now references `@objectstack/spec`'s `FormFieldInput` (objectui#11615), so its "Aligns with @objectstack/spec FormSection" claim has a compile-time tie and `check:spec-symbols` reports the ledger entry as stale. Removed as the gate prescribes; `--claim-ledger` prints the same block. Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude --- scripts/check-spec-symbol-derivation.mjs | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/check-spec-symbol-derivation.mjs b/scripts/check-spec-symbol-derivation.mjs index 1e7223641b..2d3cbb275e 100644 --- a/scripts/check-spec-symbol-derivation.mjs +++ b/scripts/check-spec-symbol-derivation.mjs @@ -1245,7 +1245,6 @@ const CLAIM_DEBT = { "@object-ui/types": [ "ListViewExportOptions", "ManagedByBucket", - "ObjectFormSection", "PageRegionWidth", "RecordActivityComponentProps", "RecordChatterComponentProps", From ce2373ec0fda44ca90692c3251b363e319e1bfcd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 17:33:05 +0000 Subject: [PATCH 3/5] fix(plugin-form): the five layout section configs take the same three entry shapes as `ObjectFormSection.fields` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ObjectForm` hands an authored section's `fields` to the tabbed, wizard, split, drawer and modal configs verbatim, and each of those layouts draws a `{ field, … }` entry through `buildSectionFields`. Their config types declared `(string | FormField)[]`, so once `ObjectFormSection.fields` names the form view's entry (objectui#11615) the handover no longer compiled. Each now reads `NonNullable`, by reference. Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude --- packages/plugin-form/src/DrawerForm.tsx | 9 +++++++-- packages/plugin-form/src/ModalForm.tsx | 7 ++++++- packages/plugin-form/src/SplitForm.tsx | 9 +++++++-- packages/plugin-form/src/TabbedForm.tsx | 8 +++++--- packages/plugin-form/src/WizardForm.tsx | 8 +++++--- 5 files changed, 30 insertions(+), 11 deletions(-) diff --git a/packages/plugin-form/src/DrawerForm.tsx b/packages/plugin-form/src/DrawerForm.tsx index d9292a66e7..0a99730198 100644 --- a/packages/plugin-form/src/DrawerForm.tsx +++ b/packages/plugin-form/src/DrawerForm.tsx @@ -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, @@ -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; collapsible?: boolean; collapsed?: boolean; /** diff --git a/packages/plugin-form/src/ModalForm.tsx b/packages/plugin-form/src/ModalForm.tsx index 470e6b4270..78db783957 100644 --- a/packages/plugin-form/src/ModalForm.tsx +++ b/packages/plugin-form/src/ModalForm.tsx @@ -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; /** * Whether the section can be collapsed — spec `FormSection.collapsible`. * `collapsed: true` implies it (objectui#9780). The control lives on the diff --git a/packages/plugin-form/src/SplitForm.tsx b/packages/plugin-form/src/SplitForm.tsx index f0e62effe9..b15d023603 100644 --- a/packages/plugin-form/src/SplitForm.tsx +++ b/packages/plugin-form/src/SplitForm.tsx @@ -24,7 +24,7 @@ */ import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react'; -import type { FormField, FormSchema, DataSource, ObjectFormSchema } from '@object-ui/types'; +import type { FormField, FormSchema, DataSource, ObjectFormSchema, ObjectFormSection } from '@object-ui/types'; import { cn, toast } from '@object-ui/components'; import { SchemaRenderer, useSafeFieldLabel } from '@object-ui/react'; import { buildSectionFields as buildSectionFieldsShared } from './sectionFields'; @@ -61,7 +61,12 @@ export interface SplitFormSectionConfig { * positional rule (first section 'primary', every other 'secondary'). */ pane?: 'primary' | 'secondary'; - 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; /** * ADR-0089 `FormSection.visibleWhen` — conditional visibility for the * section's divider HEADER, evaluated by the form renderer with the canonical diff --git a/packages/plugin-form/src/TabbedForm.tsx b/packages/plugin-form/src/TabbedForm.tsx index 1996fbfe45..407edd9fa1 100644 --- a/packages/plugin-form/src/TabbedForm.tsx +++ b/packages/plugin-form/src/TabbedForm.tsx @@ -14,7 +14,7 @@ */ import React, { useState, useCallback, useRef } from 'react'; -import type { FormField, FormSchema, DataSource, ObjectFormSchema } from '@object-ui/types'; +import type { FormField, FormSchema, DataSource, ObjectFormSchema, ObjectFormSection } from '@object-ui/types'; import { cn, toast } from '@object-ui/components'; import { SchemaRenderer, useSafeFieldLabel } from '@object-ui/react'; import { buildSectionFields as buildSectionFieldsShared } from './sectionFields'; @@ -62,9 +62,11 @@ export interface FormSectionConfig { columns?: 1 | 2 | 3 | 4; /** - * Field names or configurations in this section + * 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: (string | FormField)[]; + fields: NonNullable; /** * ADR-0089 `FormSection.visibleWhen` — the TABBED arm of the one grouping diff --git a/packages/plugin-form/src/WizardForm.tsx b/packages/plugin-form/src/WizardForm.tsx index 82fb025ead..084f5bb88b 100644 --- a/packages/plugin-form/src/WizardForm.tsx +++ b/packages/plugin-form/src/WizardForm.tsx @@ -14,7 +14,7 @@ */ import React, { useState, useCallback, useMemo } from 'react'; -import type { FormField, FormSchema, DataSource, ObjectFormSchema } from '@object-ui/types'; +import type { FormField, FormSchema, DataSource, ObjectFormSchema, ObjectFormSection } from '@object-ui/types'; import { Button, cn, toast } from '@object-ui/components'; import { AlertCircle, Check, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'; import { resolveFieldRuleState, evalFieldPredicate, isMissingForRequired, isServerOwnedValue } from '@object-ui/core'; @@ -141,9 +141,11 @@ export interface WizardStepConfig { columns?: 1 | 2 | 3 | 4; /** - * Field names or configurations in this step. + * 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: (string | FormField)[]; + fields: NonNullable; /** * Custom CSS class for this step's container. From b87783c89623fb72dc02f06756ca9916ce4091f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 17:33:19 +0000 Subject: [PATCH 4/5] docs(changeset): objectui#11615 names the five layout section types it widens Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude --- .changeset/11615-simple-inline-section-entry.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/11615-simple-inline-section-entry.md b/.changeset/11615-simple-inline-section-entry.md index c68d84b3ba..3c4a75db86 100644 --- a/.changeset/11615-simple-inline-section-entry.md +++ b/.changeset/11615-simple-inline-section-entry.md @@ -8,6 +8,7 @@ The default (`simple`) `object-form` draws a self-describing inline section entr **Clause-②: yes (widening).** Both packages accept more, and nothing they accepted before is refused now. - `@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`, 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`. From 31a9d9a784fb8820d595b4efcc8a3a8ad04cf5f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 18:16:08 +0000 Subject: [PATCH 5/5] =?UTF-8?q?fix(plugin-form,app-shell):=20read=20a=20se?= =?UTF-8?q?ction=20entry=20by=20its=20arm=20=E2=80=94=20publish=20`section?= =?UTF-8?q?EntryName`,=20respell=20the=20app-shell=20reader?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ObjectFormSection.fields` gained the form view's `{ field }` arm (objectui#11615), so an app-shell test that named every object entry the section-group resolver returns by `.name` stopped compiling (TS2339; the read gave `undefined` on a `{ field }` entry all along). The form package already spells the identity rule once, `sectionEntryName`; it is now published beside `resolveSectionGroupReferences`, whose result it reads, and the test names each entry through it. No cast, no second predicate. Also: the changeset names that reader-side break and its remedy, the README and docs page say how to read an entry, and the `object-form` element gate's docblock no longer claims its `customFields`-only exemption is in step with the components (comment only; the gate is unchanged). Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude --- .../11615-simple-inline-section-entry.md | 4 ++- content/docs/plugins/plugin-form.mdx | 6 ++++ ...orm.groupSectionReachability-8725.test.tsx | 13 +++++++-- packages/plugin-form/README.md | 13 ++++++++- packages/plugin-form/src/index.tsx | 29 ++++++++++++++++--- 5 files changed, 56 insertions(+), 9 deletions(-) diff --git a/.changeset/11615-simple-inline-section-entry.md b/.changeset/11615-simple-inline-section-entry.md index 3c4a75db86..e9a4be6164 100644 --- a/.changeset/11615-simple-inline-section-entry.md +++ b/.changeset/11615-simple-inline-section-entry.md @@ -5,13 +5,15 @@ 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).** Both packages accept more, and nothing they accepted before is refused now. +**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`, 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). diff --git a/content/docs/plugins/plugin-form.mdx b/content/docs/plugins/plugin-form.mdx index de2b229d99..b9427319e3 100644 --- a/content/docs/plugins/plugin-form.mdx +++ b/content/docs/plugins/plugin-form.mdx @@ -162,6 +162,12 @@ the pool holds, exactly as the other five layouts draw it (objectui#11615). 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 diff --git a/packages/app-shell/src/views/metadata-admin/SchemaForm.groupSectionReachability-8725.test.tsx b/packages/app-shell/src/views/metadata-admin/SchemaForm.groupSectionReachability-8725.test.tsx index cbeb5c985e..52cb67bfb3 100644 --- a/packages/app-shell/src/views/metadata-admin/SchemaForm.groupSectionReachability-8725.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/SchemaForm.groupSectionReachability-8725.test.tsx @@ -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'; @@ -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> { const resolved = resolveSectionGroupReferences( form.sections as unknown as Parameters[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; diff --git a/packages/plugin-form/README.md b/packages/plugin-form/README.md index 3441bb4c5d..417dfd05df 100644 --- a/packages/plugin-form/README.md +++ b/packages/plugin-form/README.md @@ -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 @@ -100,6 +102,7 @@ import { omitServerResolvedDefaults, isRequiredInForm, resolveSectionGroupReferences, + sectionEntryName, } from '@object-ui/plugin-form'; import type { @@ -451,6 +454,14 @@ the pool holds, exactly as the other five layouts draw it (objectui#11615). 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: diff --git a/packages/plugin-form/src/index.tsx b/packages/plugin-form/src/index.tsx index 1be14d1c5a..6b3f6736df 100644 --- a/packages/plugin-form/src/index.tsx +++ b/packages/plugin-form/src/index.tsx @@ -144,6 +144,21 @@ export { omitServerResolvedDefaults, isRequiredInForm } from './schemaDefaults'; export { resolveSectionGroupReferences } from './sectionGroups'; export type { ResolveSectionGroupsOptions } from './sectionGroups'; +/** + * The field name a section entry resolves to — the identity rule of the three + * entry shapes `ObjectFormSection.fields` declares, spelled once + * (objectui#11615): a bare string is the name, the form view's `{ field }` + * entry names its field by `field`, an inline `FormField` by `name`; + * `undefined` when the entry names nothing. + * + * Published beside the resolver because the resolver RETURNS that union, and + * objectui#7324's rule applies to reading what a published function hands + * back as much as to naming what it takes: a consumer narrowing an entry by + * hand reads `.name` off a `{ field }` entry — the second dialect this one + * spelling exists to prevent. A pure function over plain data. + */ +export { sectionEntryName } from './sectionFields'; + /** * The parameter types those published signatures require, so a consumer can * NAME what it must pass (objectui#7324). @@ -208,10 +223,16 @@ const ObjectFormRenderer: React.FC<{ schema: any; dataSource?: unknown }> = elem // `ObjectForm` builds its fields from the object's metadata, which only an // adapter can serve — its own effect calls the branch it comments as // "cannot proceed" and then renders a field-less card in silence - // (objectui#5378 item 2). The one escape hatch is inline `customFields`, - // which is exactly what `hasInlineFields` gates on inside the component, - // so the two stay in step. A form with no `objectName` is a different - // defect, answered by `requiresObject` below. + // (objectui#5378 item 2). The one escape hatch HERE is inline + // `customFields`. ⚠️ This gate is narrower than the components: every + // layout, `simple` included since objectui#11615, treats the shared + // `hasInlineFieldSource` as its members-only field source — non-empty + // `customFields`, OR sections whose every entry is an inline `FormField` + // — so a form with an `objectName`, no adapter and all-inline sections is + // refused here although any layout would draw it as a collector. Not + // aligned in that card on purpose (it would move this door's behaviour); + // the two are known to be out of step for that shape. A form with no + // `objectName` is a different defect, answered by `requiresObject` below. requiresDataSource={ !(schema?.customFields?.length > 0) && typeof schema?.objectName === 'string'