diff --git a/.changeset/masterdetail-formtype-vocabulary.md b/.changeset/masterdetail-formtype-vocabulary.md new file mode 100644 index 0000000000..632bbf5e57 --- /dev/null +++ b/.changeset/masterdetail-formtype-vocabulary.md @@ -0,0 +1,14 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: `ObjectMasterDetailFormPropsSchema.formType` narrows from bare `string` to the measured `simple | tabbed` (#11873 — the spec half of objectui#5939). + +**Newly rejected:** `wizard`, `split`, `drawer` and `modal` — each names an `object-form` renderer branch that breaks `object-master-detail-form`'s atomic parent+details contract (wizard mounts only the current step and turns the Save bar into Next; split persists via `dataSource.create` around the batch; drawer/modal move the parent half into a portal dialog the Save bar cannot submit). Each refuses with a per-value prescription; any other string (e.g. `wizzard`) now gets the plain enum refusal instead of parsing clean and rendering a silently sectionless parent form. + +**Write instead:** `simple` or `tabbed` — the two variants the renderer honours end-to-end for the parent half. For a wizard/split/drawer/modal presentation without inline details, author an `object-form`, whose `formType` keeps all six values. + +Breaking ships as minor per the launch-window convention (`scripts/check-changeset-no-major.mjs`). + + + diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 1229d2ee99..af792121d9 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -340,7 +340,7 @@ const result = AIChatWindowProps.parse(data); | **objectName** | `string` | optional | PARENT object. Optional because the component-level `dataSource` binding can supply the object instead (#7121) | | **recordId** | `string \| number` | optional | Parent record to load (edit mode) | | **mode** | `Enum<'create' \| 'edit'>` | optional | Form mode | -| **formType** | `string` | optional | Parent form presentation | +| **formType** | `Enum<'simple' \| 'tabbed'>` | optional | Parent form presentation — the two variants the renderer honours for the parent half (#11873, objectui#5939) | | **sections** | `any[]` | optional | Parent form sections | | **fields** | `any[]` | optional | Parent fields shown | | **details** | `any[]` | optional | Detail collections (`{ title, childObject, addLabel?, columns?, relationshipField? }` — FK and columns auto-derive from child metadata) | diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index 3358b71e9f..00c7e777c7 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -2175,6 +2175,56 @@ describe('#7751 — object-* block props schemas', () => { expect(r.success, JSON.stringify((r as any).error?.issues)).toBe(true); }); + describe('`object-master-detail-form` `formType` speaks the measured vocabulary (#11873)', () => { + // Spec half of objectui#5939: the renderer honours exactly `simple` and + // `tabbed` for the parent half; the old bare `z.string()` let any value + // parse clean, match no branch, and render a silently sectionless parent + // form (the objectui#3840 probe read GREEN through a real crash this way). + const schema = ComponentPropsMap['object-master-detail-form']; + + for (const value of ['simple', 'tabbed'] as const) { + it(`'${value}' is accepted`, () => { + const r = schema.safeParse({ objectName: 'po', details: [], formType: value }); + expect(r.success, JSON.stringify((r as any).error?.issues)).toBe(true); + }); + } + + it("a never-vocabulary value ('wizzard' — the issue's own repro) refuses with the plain enum refusal", () => { + const result = schema.safeParse({ objectName: 'po', details: [], formType: 'wizzard' }); + expect(result.success).toBe(false); + if (!result.success) { + const issue = result.error.issues[0]!; + expect(issue.code).toBe('invalid_value'); + expect(issue.path).toEqual(['formType']); + // Never a legal spelling anywhere, so it gets zod's own enum message, + // not a retirement prescription. + expect(issue.message).not.toContain('is not part of'); + } + }); + + describe('the four `object-form` spellings refuse with a per-value prescription', () => { + // Each names the measured way it breaks the atomic parent+details + // contract and prescribes the two honoured values — the `record:chatter` + // `position` precedent (#8762): an enum-VALUE narrowing has no + // `retiredKey()` tombstone, so the enum's own error map carries the + // prescription, keyed on `issue.input`. + for (const from of ['wizard', 'split', 'drawer', 'modal'] as const) { + it(`'${from}' → refused, prescribing 'simple' or 'tabbed'`, () => { + const result = schema.safeParse({ objectName: 'po', details: [], formType: from }); + expect(result.success).toBe(false); + if (!result.success) { + const issue = result.error.issues[0]!; + expect(issue.code).toBe('invalid_value'); + expect(issue.path).toEqual(['formType']); + expect(issue.message).toContain(`'${from}' is not part of`); + expect(issue.message).toContain("Write 'simple'"); + expect(issue.message).toContain('object-form'); + } + }); + } + }); + }); + it("the designer's dead `groupField` spelling is answered with the `groupBy` the board reads", () => { // Producer: objectui previews/block-config.ts publishes `groupField` for // object-kanban; ObjectKanban.tsx reads only `groupBy` (#7973 class). diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index ff9c4b1ab4..2383799958 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -2502,12 +2502,66 @@ export const ObjectFormPropsSchema = lazySchema(() => strictObject({ /** Author state (ADR-0122: the bare name is the author state). */ export type ObjectFormProps = z.input; +// `formType` old-vocabulary prescriptions (#11873; the objectui#5939 +// measurement). Declared with `//` on purpose — the `LIST_VIEW_EXPORT_PDF_RETIRED` +// placement note applies here too: build-docs takes a file's first JSDoc per +// exported symbol, and these need no doc page. This is an enum-VALUE +// narrowing, so there is no `retiredKey()` tombstone to hang the prescription +// on — the enum's own error map carries it, keyed on `issue.input` so only +// the four sibling-block spellings an author would plausibly carry over from +// `object-form` get a prescription (the `record:chatter` `position` +// precedent, #8762). A never-vocabulary string (`'wizzard'`) gets zod's own +// enum refusal — which is the fix's whole point: under the old `z.string()` +// it parsed clean, matched no renderer branch, and rendered a silently +// sectionless parent form. No ADR-0087 conversion is registered here: unlike +// #8762 (whose old set was the schema's own declared vocabulary and default), +// these four names were never this block's declared vocabulary — the key was +// a bare `z.string()` — and the authored-value census on both repos (this +// repo + objectui#5939's) found zero occurrences to rewrite. +const MASTER_DETAIL_FORM_TYPE_RETIRED: ReadonlyMap = new Map([ + ['wizard', "'wizard' is not part of `object-master-detail-form` `formType` (#11873 — objectui#5939 " + + 'measured the renderer): only the current wizard step\'s fields mount and the block\'s single ' + + "Save bar acts as the wizard's Next, so parent + details never save through the atomic batch " + + "(ADR-0001, the block's whole contract). Write 'simple' (sections render stacked) or 'tabbed'; " + + "for a wizard without inline details author an `object-form`, where 'wizard' is honoured."], + ['split', "'split' is not part of `object-master-detail-form` `formType` (#11873 — objectui#5939 " + + 'measured the renderer): the parent half renders inline but persists via `dataSource.create`, ' + + "bypassing the atomic parent+details batch (ADR-0001, the block's whole contract). Write " + + "'simple' or 'tabbed'; for a split presentation without inline details author an " + + "`object-form`, where 'split' is honoured."], + ['drawer', "'drawer' is not part of `object-master-detail-form` `formType` (#11873 — objectui#5939 " + + 'measured the renderer): the parent half renders in a portal dialog outside the master-detail ' + + "container, so the block's Save bar has no form to submit. Write 'simple' or 'tabbed'; for a " + + "drawer overlay without inline details author an `object-form`, where 'drawer' is honoured."], + ['modal', "'modal' is not part of `object-master-detail-form` `formType` (#11873 — objectui#5939 " + + 'measured the renderer): the parent half renders in a portal dialog outside the master-detail ' + + "container (the same portal shape as 'drawer'), so the block's Save bar has no form to " + + "submit. Write 'simple' or 'tabbed'; for a modal overlay without inline details author an " + + "`object-form`, where 'modal' is honoured."], +]); + /** * `object-master-detail-form` (objectui `plugin-form/src/MasterDetailForm.tsx` * @ `eb7f586b`). Parent + child line items entered together (ADR-0001). The * child collections come from `details` — the FK and editable-grid columns * are auto-derived from the child object's metadata (`deriveMasterDetail.ts`), * so `details[].columns` is an override, not a requirement. + * + * `formType` speaks the MEASURED vocabulary — `simple` / `tabbed` — since + * #11873 (the spec half of objectui#5939, which tightened the objectui + * registry declaration to the same pair on the same measurement, corroborated + * by objectui's own two declarations: `MasterDetailFormSchema.formType?: + * 'simple' | 'tabbed'` and the `formType === 'tabbed' ? 'tabbed' : 'simple'` + * coercion). The key was a bare `z.string()`, so a value outside the + * renderer's vocabulary (`'wizzard'`) parsed clean, matched no branch, and + * the parent half fell through to a flat field list — authored sections + * silently disappeared with no diagnostic (the objectui#3840 probe read GREEN + * through a real crash this way). The four `object-form` spellings that do + * name renderer branches (`wizard`/`split`/`drawer`/`modal`) each break the + * block's atomic parent+details contract and refuse with a per-value + * prescription ({@link MASTER_DETAIL_FORM_TYPE_RETIRED}). objectui#6176 + * (`tabbed` presentationally honoured but escaping the atomic batch) is a + * renderer defect tracked there — it does not change this vocabulary. */ export const ObjectMasterDetailFormPropsSchema = lazySchema(() => strictObject({ surface: 'this `object-master-detail-form`', @@ -2518,7 +2572,10 @@ export const ObjectMasterDetailFormPropsSchema = lazySchema(() => strictObject({ .describe('PARENT object. Optional because the component-level `dataSource` binding can supply the object instead (#7121)'), recordId: z.union([z.string(), z.number()]).optional().describe('Parent record to load (edit mode)'), mode: z.enum(['create', 'edit']).optional().describe('Form mode'), - formType: z.string().optional().describe('Parent form presentation'), + formType: z.enum(['simple', 'tabbed'], { + error: (issue) => + typeof issue.input === 'string' ? MASTER_DETAIL_FORM_TYPE_RETIRED.get(issue.input) : undefined, + }).optional().describe("Parent form presentation — the two variants the renderer honours for the parent half (#11873, objectui#5939)"), sections: z.array(z.unknown()).optional().describe('Parent form sections'), fields: z.array(z.unknown()).optional().describe('Parent fields shown'), details: z.array(z.unknown()).optional()