diff --git a/.changeset/10254-sections-customfields-drawer-modal.md b/.changeset/10254-sections-customfields-drawer-modal.md new file mode 100644 index 0000000000..e896916f1d --- /dev/null +++ b/.changeset/10254-sections-customfields-drawer-modal.md @@ -0,0 +1,29 @@ +--- +'@object-ui/plugin-form': minor +--- + +fix(plugin-form): `customFields` members render inside explicit `sections` on the drawer, modal, tabbed, wizard and split arms + +The registered description of `object-form.customFields` is one sentence for +every `formType`: "Field definitions merged over the set generated from object +metadata." With explicit `sections`, an `object-form` with `formType: 'drawer'`, +`'modal'`, `'tabbed'`, `'wizard'` or `'split'` (and a `DrawerForm`, `ModalForm`, +`TabbedForm`, `WizardForm` or `SplitForm` mounted directly) built every field a +section names from the object schema alone, so a member naming that field was +dropped: its label, its `required` and the rest of its definition never reached +the form. The default arm already drew the member. + +Those five arms now take a section field's definition from the member naming +it, through the same lookup the default arm's merge uses; a field no member +names is built from the object schema as before. This holds for a bare field +name and for a spec `{ field }` entry, whose own overrides still apply on top +of the member. Over a member, the widget changes only when the entry restates +`type`. A section naming a field that only a member supplies now renders that +member, where it used to render a plain text input labelled with the field +name. A member that no section lists is still not drawn when `sections` are +given, as on the default arm. `TabbedFormSchema`, `WizardFormSchema` and +`SplitFormSchema` now declare `customFields`, as `DrawerFormSchema` and +`ModalFormSchema` already did. + +Unchanged: the rules for what a section entry may override, which still differ +between the default arm and the other five. diff --git a/packages/plugin-form/src/DrawerForm.tsx b/packages/plugin-form/src/DrawerForm.tsx index 67b1a2bcca..a50c51fecc 100644 --- a/packages/plugin-form/src/DrawerForm.tsx +++ b/packages/plugin-form/src/DrawerForm.tsx @@ -40,7 +40,6 @@ import { createSafeTranslation } from '@object-ui/i18n'; import { MasterDetailForm } from './MasterDetailForm'; import { buildSectionFields as buildSectionFieldsShared } from './sectionFields'; import { buildFlatFields } from './flatFields'; -import { withCustomFieldMembers } from './customFieldsMerge'; import { useUploadGate, UploadGateProvider, UploadInFlightNotice } from './uploadGate'; import { applyAutoColSpan, @@ -344,8 +343,13 @@ export const DrawerForm: React.FC = ({ // `defaultValue` excuses a field from `required` (#4069). recordId: schema.recordId, fieldLabel, + // A member naming a section's field is that field's definition, as it + // is in ObjectForm's merged pool — for explicit and derived sections + // alike (objectui#10254; the explicit push used to regenerate every + // named field from the object schema and drop the member). + customFields: schema.customFields, }), - [objectSchema, schema.readOnly, schema.mode, schema.recordId, schema.objectName, fieldLabel], + [objectSchema, schema.readOnly, schema.mode, schema.recordId, schema.objectName, schema.customFields, fieldLabel], ); // Build fields from flat field list (when no sections provided) @@ -424,12 +428,10 @@ export const DrawerForm: React.FC = ({ const columns = (schema.columns && schema.columns > 0 ? Math.min(Math.floor(schema.columns), 4) : inferColumns(fs.length)) as 1 | 2 | 3 | 4; - return sections.map((s) => ({ - ...s, - columns, - fields: withCustomFieldMembers(s.fields ?? [], schema.customFields), - })) as DrawerFormSectionConfig[]; - }, [schema.sections, schema.customFields, schema.columns, schema.mode, formFields, objectSchema]); + // A member's definition reaches a group's body through `buildSectionFields`, + // the route an explicit section's takes (objectui#10254). + return sections.map((s) => ({ ...s, columns })) as DrawerFormSectionConfig[]; + }, [schema.sections, schema.columns, schema.mode, formFields, objectSchema]); // Handle form submission const handleSubmit = useCallback(async (data: Record) => { diff --git a/packages/plugin-form/src/ModalForm.tsx b/packages/plugin-form/src/ModalForm.tsx index cefa4717c5..d264f61a8e 100644 --- a/packages/plugin-form/src/ModalForm.tsx +++ b/packages/plugin-form/src/ModalForm.tsx @@ -40,7 +40,6 @@ import { SchemaRenderer, useSafeFieldLabel, usePreviewMode } from '@object-ui/re import { createSafeTranslation } from '@object-ui/i18n'; import { buildSectionFields as buildSectionFieldsShared } from './sectionFields'; import { buildFlatFields } from './flatFields'; -import { withCustomFieldMembers } from './customFieldsMerge'; import { applyAutoColSpan, applyAutoLayout, @@ -296,12 +295,10 @@ export const ModalForm: React.FC = ({ const columns = (schema.columns && schema.columns > 0 ? Math.min(Math.floor(schema.columns), 4) : inferColumns(fs.length)) as 1 | 2 | 3 | 4; - return sections.map((s) => ({ - ...s, - columns, - fields: withCustomFieldMembers(s.fields ?? [], schema.customFields), - })) as ModalFormSectionConfig[]; - }, [schema.sections, schema.customFields, schema.columns, schema.mode, formFields, objectSchema]); + // A member's definition reaches a group's body through `buildSectionFields`, + // the route an explicit section's takes (objectui#10254). + return sections.map((s) => ({ ...s, columns })) as ModalFormSectionConfig[]; + }, [schema.sections, schema.columns, schema.mode, formFields, objectSchema]); const effectiveSections = schema.sections?.length ? schema.sections : (derivedSections ?? undefined); @@ -424,8 +421,13 @@ export const ModalForm: React.FC = ({ // `defaultValue` excuses a field from `required` (#4069). recordId: schema.recordId, fieldLabel, + // A member naming a section's field is that field's definition, as it + // is in ObjectForm's merged pool — for explicit and derived sections + // alike (objectui#10254; the explicit path used to regenerate every + // named field from the object schema and drop the member). + customFields: schema.customFields, }), - [objectSchema, schema.readOnly, schema.mode, schema.recordId, schema.objectName, fieldLabel], + [objectSchema, schema.readOnly, schema.mode, schema.recordId, schema.objectName, schema.customFields, fieldLabel], ); // Build fields from flat field list (when no sections) diff --git a/packages/plugin-form/src/SplitForm.tsx b/packages/plugin-form/src/SplitForm.tsx index 6975d1ff91..f85a43eebb 100644 --- a/packages/plugin-form/src/SplitForm.tsx +++ b/packages/plugin-form/src/SplitForm.tsx @@ -74,6 +74,13 @@ export interface SplitFormSchema { /** Record ID (for edit/view modes). A string, per the one record-id rule on `DataSource` (objectui#9511) — `ObjectForm` builds this schema from the authorable `ObjectFormSchema.recordId`, which is a string, and `findOne` takes a string. */ recordId?: string; sections: SplitFormSectionConfig[]; + /** + * Inline field definitions (`object-form.customFields`). A member naming a + * field a section lists is that field's definition, as on every other + * `formType` (objectui#10254); `ObjectForm` hands the key over in its + * `{...schema}` spread. + */ + customFields?: FormField[]; /** * Split direction. @@ -240,8 +247,11 @@ export const SplitForm: React.FC = ({ // `defaultValue` excuses a field from `required` (#4069). recordId: schema.recordId, fieldLabel, + // A member naming a section's field is that field's definition, as it + // is on every other arm (objectui#10254). + customFields: schema.customFields, }), - [objectSchema, schema.readOnly, schema.mode, schema.recordId, schema.objectName, fieldLabel], + [objectSchema, schema.readOnly, schema.mode, schema.recordId, schema.objectName, schema.customFields, fieldLabel], ); // Handle form submission diff --git a/packages/plugin-form/src/TabbedForm.tsx b/packages/plugin-form/src/TabbedForm.tsx index 7b8a07d423..5e7f9ff2fa 100644 --- a/packages/plugin-form/src/TabbedForm.tsx +++ b/packages/plugin-form/src/TabbedForm.tsx @@ -126,6 +126,14 @@ export interface TabbedFormSchema { * Tab sections configuration */ sections: FormSectionConfig[]; + + /** + * Inline field definitions (`object-form.customFields`). A member naming a + * field a section lists is that field's definition, as on every other + * `formType` (objectui#10254); `ObjectForm` hands the key over in its + * `{...schema}` spread. + */ + customFields?: FormField[]; /** * Grid width for the whole form (1–4). Aligns with @objectstack/spec @@ -343,8 +351,11 @@ export const TabbedForm: React.FC = ({ // `defaultValue` excuses a field from `required` (#4069). recordId: schema.recordId, fieldLabel, + // A member naming a section's field is that field's definition, as it + // is on every other arm (objectui#10254). + customFields: schema.customFields, }), - [objectSchema, schema.readOnly, schema.mode, schema.recordId, schema.objectName, fieldLabel], + [objectSchema, schema.readOnly, schema.mode, schema.recordId, schema.objectName, schema.customFields, fieldLabel], ); // Handle form submission diff --git a/packages/plugin-form/src/WizardForm.tsx b/packages/plugin-form/src/WizardForm.tsx index efe9ffe362..b9dfeef7da 100644 --- a/packages/plugin-form/src/WizardForm.tsx +++ b/packages/plugin-form/src/WizardForm.tsx @@ -225,6 +225,14 @@ export interface WizardFormSchema { * Wizard step sections */ sections: WizardStepConfig[]; + + /** + * Inline field definitions (`object-form.customFields`). A member naming a + * field a section lists is that field's definition, as on every other + * `formType` (objectui#10254); `ObjectForm` hands the key over in its + * `{...schema}` spread. + */ + customFields?: FormField[]; /** * Allow navigation to any step (not just sequential). @@ -531,8 +539,11 @@ export const WizardForm: React.FC = ({ // this produces, so it agrees with the renderer for free. recordId: schema.recordId, fieldLabel, + // A member naming a section's field is that field's definition, as it + // is on every other arm (objectui#10254). + customFields: schema.customFields, }), - [objectSchema, schema.readOnly, schema.mode, schema.recordId, schema.objectName, fieldLabel], + [objectSchema, schema.readOnly, schema.mode, schema.recordId, schema.objectName, schema.customFields, fieldLabel], ); // The same "no persisted record" test the seeding and the create-mode diff --git a/packages/plugin-form/src/__tests__/sectionsCustomFields-10254.test.tsx b/packages/plugin-form/src/__tests__/sectionsCustomFields-10254.test.tsx new file mode 100644 index 0000000000..407078050e --- /dev/null +++ b/packages/plugin-form/src/__tests__/sectionsCustomFields-10254.test.tsx @@ -0,0 +1,181 @@ +/** + * 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. + */ + +/** + * `object-form.customFields` under explicit `sections`, on every arm that + * takes them (objectui#10254). + * + * The registered description of `customFields` is one sentence for every + * `formType`: "Field definitions merged over the set generated from object + * metadata." objectui#10073 brought the drawer and modal to the default arm's + * merge for a form with NO sections (`drawerModalCustomFieldsMerge-10073`). + * With explicit `sections`, the `drawer`, `modal`, `tabbed`, `wizard` and + * `split` arms still rebuilt every section member from the object schema + * alone, so the authored member was dropped: same schema, `INLINE NOTE` on the + * default arm, `Note` on the other five. + * + * What this card unifies is the BASE definition a section member starts from: + * a member naming the field supplies the whole definition, in place of the + * generated one, through the one lookup the default arm's merge uses + * (`findCustomFieldMember`). What it deliberately does NOT unify is each arm's + * section-entry override rules, which differ (the default arm copies only + * `visibleOn` / `colSpan` / `span` from a spec entry; the other five apply + * `normalizeSectionField`'s full set) — no row below authors an override the + * two rule sets answer differently. + * + * Every row mounts the real `ObjectForm` with the arm's `formType`, the path an + * author reaches. The `simple` rows are the default-arm control: that arm + * already resolved section members against its merged pool, and they stay + * green without the fix. Every schema below has ONE section, so the wizard's + * rendered step is the section under test — and a wizard draws only its + * current step's fields, which the `drawnFields()` equalities pin. On a tree + * where an arm's `buildSectionFields` context does not carry `customFields`, + * that arm's LABEL, REQUIRED, SPEC ENTRY and UNDECLARED NAME rows are red; + * UNNAMED MEMBER is green on every arm either way (the arms already agreed + * there, and it pins that the fix did not start drawing members no section + * lists). + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, waitFor, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { registerAllFields } from '@object-ui/fields'; +import { ObjectForm } from '../ObjectForm'; + +registerAllFields(); + +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): HTMLInputElement | HTMLTextAreaElement | null => + document.body.querySelector(`[data-field="${name}"] input, [data-field="${name}"] textarea`); + +const MAIN_SECTION = (fields: Array>) => [ + { name: 'main', label: 'Main', fields }, +]; + +async function mount(arm: Arm, schema: Record) { + const adapter = makeDataSource(); + const utils = render( + , + ); + await waitFor(() => { + if (!document.body.querySelector('form [data-field]')) throw new Error('form not ready'); + }); + return { ...utils, adapter }; +} + +describe.each(ARMS)('`object-form` `formType: %s` — explicit `sections` draw a `customFields` member as the field', (arm) => { + it('LABEL — a section naming a member field renders the member\'s label', async () => { + const { unmount } = await mount(arm, { + sections: MAIN_SECTION(['customer', 'note']), + customFields: [{ name: 'note', label: 'INLINE NOTE', type: 'text' }], + }); + expect(drawnFields()).toEqual(['customer', 'note']); + expect(labelOf('note'), 'the member supplies the whole definition').toBe('INLINE NOTE'); + expect(labelOf('customer'), 'a field no member names keeps the object\'s definition').toBe('Customer'); + unmount(); + }); + + it('REQUIRED — a member\'s `required` refuses an empty submit and lets a filled one through', async () => { + const { adapter, unmount } = await mount(arm, { + sections: MAIN_SECTION(['customer', 'note']), + customFields: [{ name: 'note', label: 'INLINE NOTE', type: 'text', required: true }], + }); + expect(controlOf('note')?.getAttribute('aria-required')).toBe('true'); + expect(controlOf('customer')?.getAttribute('aria-required'), 'the object does not require customer').toBeNull(); + + const form = document.body.querySelector('form') as HTMLFormElement; + fireEvent.submit(form); + await waitFor(() => expect(document.body.textContent).toContain('INLINE NOTE is required')); + expect(adapter.create, 'the member\'s required refused the empty submit').not.toHaveBeenCalled(); + + fireEvent.change(controlOf('note') as HTMLInputElement, { target: { value: 'filled' } }); + fireEvent.submit(form); + await waitFor(() => + expect(adapter.create).toHaveBeenCalledWith('invoice', expect.objectContaining({ note: 'filled' })), + ); + unmount(); + }); + + it('SPEC ENTRY — a spec `{ field }` entry starts from the member, not the generated field', async () => { + const { unmount } = await mount(arm, { + // `colSpan` is a layout key both override rule sets carry; nothing here + // restates `label`, `required` or `type`, so the member is what shows. + sections: MAIN_SECTION(['customer', { field: 'note', colSpan: 1 }]), + customFields: [{ name: 'note', label: 'INLINE NOTE', type: 'textarea', required: true }], + }); + expect(labelOf('note')).toBe('INLINE NOTE*'); + expect(controlOf('note')?.getAttribute('aria-required')).toBe('true'); + expect( + controlOf('note')?.tagName, + 'the member\'s own `type` renders — the object schema\'s `text` is the generated definition\'s', + ).toBe('TEXTAREA'); + unmount(); + }); + + it('UNDECLARED NAME — a section naming a field only a member supplies renders the member', async () => { + const { unmount } = await mount(arm, { + sections: MAIN_SECTION(['customer', 'extra']), + customFields: [{ name: 'extra', label: 'EXTRA', type: 'text' }], + }); + expect(drawnFields()).toEqual(['customer', 'extra']); + expect(labelOf('extra')).toBe('EXTRA'); + unmount(); + }); + + it('UNNAMED MEMBER — a member no section lists is not drawn under explicit sections', async () => { + const { unmount } = await mount(arm, { + sections: MAIN_SECTION(['customer', 'note']), + customFields: [ + { name: 'extra', label: 'EXTRA', type: 'text' }, + { name: 'amount', label: 'INLINE AMOUNT', type: 'text' }, + ], + }); + expect( + drawnFields(), + 'sections draw what they list — neither an appended member nor an overriding one', + ).toEqual(['customer', 'note']); + unmount(); + }); +}); diff --git a/packages/plugin-form/src/customFieldsMerge.ts b/packages/plugin-form/src/customFieldsMerge.ts index fa1cc5f0fa..878303c451 100644 --- a/packages/plugin-form/src/customFieldsMerge.ts +++ b/packages/plugin-form/src/customFieldsMerge.ts @@ -32,11 +32,38 @@ * * An EMPTY `customFields` is unauthored: nothing is overridden or appended. * + * Explicit and derived SECTIONS answer the same question — whose definition is + * the field a section names? — through `findCustomFieldMember` below, which + * `mergeCustomFields` itself resolves every name through (objectui#10254). A + * section body has no append direction: it draws only what its entries name. + * * Module-private: not re-exported from the package barrel. */ import type { FormField } from '@object-ui/types'; +/** + * The member that supplies the definition of the field called `name`, or + * `undefined` when no member names it — the override direction's precedence, + * spelled once. The first member naming the field wins, and an EMPTY + * `customFields` supplies nothing. + * + * Shared by the two producers of a runtime field from a name: the flat list + * (`mergeCustomFields` below, the default arm's pool and the drawer / modal + * no-sections path) and a section body (`normalizeSectionField` in + * `sectionFields.ts`, where the member is a section entry's BASE definition). + * objectui#10254: the drawer and modal section bodies used to regenerate every + * named field from the object schema, so a member the default arm drew was + * dropped on those two arms. + */ +export function findCustomFieldMember( + customFields: readonly FormField[] | null | undefined, + name: string, +): FormField | undefined { + if (!customFields?.length) return undefined; + return customFields.find((m) => m?.name === name); +} + /** * Resolve the runtime field list for `names`, with `customFields` merged over * the fields `generate` produces. @@ -58,7 +85,7 @@ export function mergeCustomFields( const drawn: FormField[] = []; names.forEach((name, index) => { - const member = members?.find((m) => m?.name === name); + const member = findCustomFieldMember(members, name); if (member) { drawn.push(normalize(member)); return; @@ -79,27 +106,3 @@ export function mergeCustomFields( return drawn; } - -/** - * Hand a derived section's member NAMES back as the authored member objects - * where a `customFields` member supplies the field. - * - * The drawer and modal build a derived (`fieldGroups`) section's body from its - * names through `buildSectionFields`, which regenerates a named field from the - * object schema. `ObjectForm` instead resolves a derived section's names - * against its merged pool, so a member's definition is what renders inside a - * group there. Passing the member object keeps the two arms on one answer: - * `buildSectionFields` draws a runtime `FormField` verbatim. Names no member - * supplies are returned unchanged, so a form without `customFields` resolves - * byte-identically to before. - */ -export function withCustomFieldMembers( - fields: ReadonlyArray>, - customFields: readonly FormField[] | null | undefined, -): Array> { - if (!customFields?.length) return [...fields]; - return fields.map((entry) => { - if (typeof entry !== 'string') return entry; - return customFields.find((m) => m?.name === entry) ?? entry; - }); -} diff --git a/packages/plugin-form/src/sectionFields.ts b/packages/plugin-form/src/sectionFields.ts index 3c134bb1ec..b72311714e 100644 --- a/packages/plugin-form/src/sectionFields.ts +++ b/packages/plugin-form/src/sectionFields.ts @@ -41,6 +41,7 @@ import type { FormField } from '@object-ui/types'; import { mapFieldTypeToFormType, buildValidationRules } from '@object-ui/fields'; import { isCreateFormMode, isRequiredInForm } from './schemaDefaults'; +import { findCustomFieldMember } from './customFieldsMerge'; export interface SectionFieldsContext { /** Resolved object schema (`{ fields: { [name]: fieldDef } }`) or null. */ @@ -69,6 +70,16 @@ export interface SectionFieldsContext { * passes it. */ fieldLabel: (objectName: string, fieldName: string, fallback: string) => string; + /** + * The authored inline members (`schema.customFields`). A member naming the + * field a section entry names is that entry's BASE definition, in place of + * the one generated from the object schema — the precedence `ObjectForm`'s + * default arm resolves its section members with (`findCustomFieldMember`, + * objectui#10254). Only the base moves: the entry's own overrides still + * apply on top, by the rules below. Omitted or empty → every base is + * generated, as before. + */ + customFields?: readonly FormField[] | null; } /** @@ -251,8 +262,12 @@ export function normalizeSectionField( fieldDef: string | Record, ctx: SectionFieldsContext, ): FormField { - // (1) string shorthand → build entirely from the object schema. + // (1) string shorthand → build entirely from the object schema, unless a + // `customFields` member names the field: the member is then the whole + // definition, drawn as shape (3) below (objectui#10254). if (typeof fieldDef === 'string') { + const member = findCustomFieldMember(ctx.customFields, fieldDef); + if (member) return normalizeSectionField(member, ctx); const meta = ctx.objectSchema?.fields?.[fieldDef] as any; return attachVisibility(fromObjectSchema(fieldDef, ctx), meta?.visible_on ?? meta?.visibleOn); } @@ -267,10 +282,14 @@ export function normalizeSectionField( return attachVisibility(fd as FormField, fd.visibleOn); } - // (2) spec FormFieldSchema object — merge object-schema base + spec overrides. + // (2) spec FormFieldSchema object — merge base + spec overrides. The base is + // the `customFields` member naming the field when there is one (a COPY: the + // overrides below write onto it), else the object-schema field + // (objectui#10254). warnOnMixedVocabulary(fd, ctx.objectName); const fieldName = fd.field; - const base = fromObjectSchema(fieldName, ctx) as any; + const member = findCustomFieldMember(ctx.customFields, fieldName); + const base = (member ? { ...member } : fromObjectSchema(fieldName, ctx)) as any; if (fd.widget != null) base.widget = fd.widget; if (fd.label != null) base.label = fd.label; @@ -305,7 +324,11 @@ export function normalizeSectionField( // mapped id (`field:…`) and re-deciding from it would need an inverse mapping. // Absent on both sides (a spec field naming nothing in the object schema and // declaring no type) it stays untouched — `fromObjectSchema`'s `input`. - const rawType = fd.type ?? ctx.objectSchema?.fields?.[fieldName]?.type; + // Over a MEMBER base only the entry's own `type` re-decides: the object + // schema's type is the generated definition's, which the member replaces + // whole, and the member's `type` is drawn as authored, as the flat path + // draws it. + const rawType = fd.type ?? (member ? undefined : ctx.objectSchema?.fields?.[fieldName]?.type); if (rawType != null) base.type = mapFieldTypeToFormType(rawType, { multiple: base.multiple }); // Spec canon for the lookup target is `reference_to` (views.zod.ts); accept // both spellings and stamp both keys so dual-key readers see the override. diff --git a/packages/plugin-form/src/submitTarget.ts b/packages/plugin-form/src/submitTarget.ts index 5aba4ae7d6..cc6a8eb765 100644 --- a/packages/plugin-form/src/submitTarget.ts +++ b/packages/plugin-form/src/submitTarget.ts @@ -49,10 +49,9 @@ * becomes the primary field source"*. * * **(b) every section field is an inline runtime `FormField`.** This limb is not - * optional and not an invention: it is how the sectioned variants express the - * same thing, because `TabbedFormSchema` / `SplitFormSchema` / - * `WizardFormSchema` have no `customFields` of their own — they build from - * `sections`. This package's README documents the shape and ships an example of + * optional and not an invention: it is how a sectioned form expresses the same + * thing with no `customFields` at all — its sections carry the definitions. + * This package's README documents the shape and ships an example of * it, `` under the comment *"dataSource omitted: * every step lists inline fields"*, introduced by *"The inline shape is what * lets a wizard run with no data source at all"*. Measured before this module @@ -70,10 +69,10 @@ * and it refuses. Erring that way is the loud direction; the silent one is the * defect. * - * `TabbedForm` / `SplitForm` / `WizardForm` do not declare `customFields` on - * their own schema surface — the key reaches them through `ObjectForm`'s - * `{...schema}` spread, and limb (a) reads it there only as that SIGNAL. - * Reading it is deliberately not the same as claiming they render it. + * `TabbedForm` / `SplitForm` / `WizardForm` receive `customFields` through + * `ObjectForm`'s `{...schema}` spread. Until objectui#10254 they neither + * declared nor rendered it, and limb (a) read it there only as a SIGNAL; they + * now declare it and draw a member a section names, as the drawer and modal do. */ /** The shape this module reads — deliberately narrower than any form schema. */ @@ -121,13 +120,13 @@ function sectionsAreFullyInline(sections: unknown): boolean { * Literally `SimpleObjectForm`'s test, kept in one place so the six renderers * cannot answer it six ways. * - * The parameter is `object`, not `InlineFieldSource`, on purpose: three of the - * five callers (`TabbedFormSchema` / `SplitFormSchema` / `WizardFormSchema`) do - * NOT declare `customFields` on their own surface — the key arrives through - * `ObjectForm`'s `{...schema}` spread — and TypeScript's weak-type check rejects - * an argument sharing no property with a wholly-optional interface. Widening the - * three schema interfaces instead would declare a field source those three do - * not render, which is the declared-≠-enforced shape this card exists to undo. + * The parameter is `object`, not `InlineFieldSource`: TypeScript's weak-type + * check rejects an argument sharing no property with a wholly-optional + * interface, so a caller whose schema type declares neither key could not pass + * it. `TabbedFormSchema` / `SplitFormSchema` / `WizardFormSchema` declared no + * `customFields` while they did not render it, since declaring a field source a + * renderer ignores is the declared-≠-enforced shape objectui#6300 set out to + * undo; they declare it since objectui#10254, which made them render it. */ export function hasInlineFieldSource(schema: object | null | undefined): boolean { const s = schema as InlineFieldSource | null | undefined;