Skip to content

Commit d63b014

Browse files
feat(spec)!: narrow ObjectMasterDetailFormPropsSchema.formType to the measured simple | tabbed (#11963)
* feat(spec)!: narrow object-master-detail-form formType to the measured simple | tabbed Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K93P8PbH7kVjsAXkqzH1zY * docs(spec): regenerate component reference for the formType narrowing Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K93P8PbH7kVjsAXkqzH1zY * chore: adr-0087 disposition marker on the formType narrowing changeset Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K93P8PbH7kVjsAXkqzH1zY --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e1d773e commit d63b014

4 files changed

Lines changed: 123 additions & 2 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: `ObjectMasterDetailFormPropsSchema.formType` narrows from bare `string` to the measured `simple | tabbed` (#11873 — the spec half of objectui#5939).
6+
7+
**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.
8+
9+
**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.
10+
11+
Breaking ships as minor per the launch-window convention (`scripts/check-changeset-no-major.mjs`).
12+
13+
<!-- adr-0087: not-required (no-migration-prescription) the four dropped names were never this block's declared vocabulary — the key was a bare `z.string()`, so unlike the #8762 / #8010 precedents there is no spec-promised old value to rewrite — and the authored-value census on both repos (this repo, #11873; objectui, objectui#5939) found zero out-of-vocabulary occurrences. Nothing exists to migrate, so no migration is prescribed; live authors are taught at parse by the enum's per-value error-map prescriptions. -->
14+

content/docs/references/ui/component.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,7 @@ const result = AIChatWindowProps.parse(data);
340340
| **objectName** | `string` | optional | PARENT object. Optional because the component-level `dataSource` binding can supply the object instead (#7121) |
341341
| **recordId** | `string \| number` | optional | Parent record to load (edit mode) |
342342
| **mode** | `Enum<'create' \| 'edit'>` | optional | Form mode |
343-
| **formType** | `string` | optional | Parent form presentation |
343+
| **formType** | `Enum<'simple' \| 'tabbed'>` | optional | Parent form presentation — the two variants the renderer honours for the parent half (#11873, objectui#5939) |
344344
| **sections** | `any[]` | optional | Parent form sections |
345345
| **fields** | `any[]` | optional | Parent fields shown |
346346
| **details** | `any[]` | optional | Detail collections (`{ title, childObject, addLabel?, columns?, relationshipField? }` — FK and columns auto-derive from child metadata) |

packages/spec/src/ui/component.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2175,6 +2175,56 @@ describe('#7751 — object-* block props schemas', () => {
21752175
expect(r.success, JSON.stringify((r as any).error?.issues)).toBe(true);
21762176
});
21772177

2178+
describe('`object-master-detail-form` `formType` speaks the measured vocabulary (#11873)', () => {
2179+
// Spec half of objectui#5939: the renderer honours exactly `simple` and
2180+
// `tabbed` for the parent half; the old bare `z.string()` let any value
2181+
// parse clean, match no branch, and render a silently sectionless parent
2182+
// form (the objectui#3840 probe read GREEN through a real crash this way).
2183+
const schema = ComponentPropsMap['object-master-detail-form'];
2184+
2185+
for (const value of ['simple', 'tabbed'] as const) {
2186+
it(`'${value}' is accepted`, () => {
2187+
const r = schema.safeParse({ objectName: 'po', details: [], formType: value });
2188+
expect(r.success, JSON.stringify((r as any).error?.issues)).toBe(true);
2189+
});
2190+
}
2191+
2192+
it("a never-vocabulary value ('wizzard' — the issue's own repro) refuses with the plain enum refusal", () => {
2193+
const result = schema.safeParse({ objectName: 'po', details: [], formType: 'wizzard' });
2194+
expect(result.success).toBe(false);
2195+
if (!result.success) {
2196+
const issue = result.error.issues[0]!;
2197+
expect(issue.code).toBe('invalid_value');
2198+
expect(issue.path).toEqual(['formType']);
2199+
// Never a legal spelling anywhere, so it gets zod's own enum message,
2200+
// not a retirement prescription.
2201+
expect(issue.message).not.toContain('is not part of');
2202+
}
2203+
});
2204+
2205+
describe('the four `object-form` spellings refuse with a per-value prescription', () => {
2206+
// Each names the measured way it breaks the atomic parent+details
2207+
// contract and prescribes the two honoured values — the `record:chatter`
2208+
// `position` precedent (#8762): an enum-VALUE narrowing has no
2209+
// `retiredKey()` tombstone, so the enum's own error map carries the
2210+
// prescription, keyed on `issue.input`.
2211+
for (const from of ['wizard', 'split', 'drawer', 'modal'] as const) {
2212+
it(`'${from}' → refused, prescribing 'simple' or 'tabbed'`, () => {
2213+
const result = schema.safeParse({ objectName: 'po', details: [], formType: from });
2214+
expect(result.success).toBe(false);
2215+
if (!result.success) {
2216+
const issue = result.error.issues[0]!;
2217+
expect(issue.code).toBe('invalid_value');
2218+
expect(issue.path).toEqual(['formType']);
2219+
expect(issue.message).toContain(`'${from}' is not part of`);
2220+
expect(issue.message).toContain("Write 'simple'");
2221+
expect(issue.message).toContain('object-form');
2222+
}
2223+
});
2224+
}
2225+
});
2226+
});
2227+
21782228
it("the designer's dead `groupField` spelling is answered with the `groupBy` the board reads", () => {
21792229
// Producer: objectui previews/block-config.ts publishes `groupField` for
21802230
// object-kanban; ObjectKanban.tsx reads only `groupBy` (#7973 class).

packages/spec/src/ui/component.zod.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2502,12 +2502,66 @@ export const ObjectFormPropsSchema = lazySchema(() => strictObject({
25022502
/** Author state (ADR-0122: the bare name is the author state). */
25032503
export type ObjectFormProps = z.input<typeof ObjectFormPropsSchema>;
25042504

2505+
// `formType` old-vocabulary prescriptions (#11873; the objectui#5939
2506+
// measurement). Declared with `//` on purpose — the `LIST_VIEW_EXPORT_PDF_RETIRED`
2507+
// placement note applies here too: build-docs takes a file's first JSDoc per
2508+
// exported symbol, and these need no doc page. This is an enum-VALUE
2509+
// narrowing, so there is no `retiredKey()` tombstone to hang the prescription
2510+
// on — the enum's own error map carries it, keyed on `issue.input` so only
2511+
// the four sibling-block spellings an author would plausibly carry over from
2512+
// `object-form` get a prescription (the `record:chatter` `position`
2513+
// precedent, #8762). A never-vocabulary string (`'wizzard'`) gets zod's own
2514+
// enum refusal — which is the fix's whole point: under the old `z.string()`
2515+
// it parsed clean, matched no renderer branch, and rendered a silently
2516+
// sectionless parent form. No ADR-0087 conversion is registered here: unlike
2517+
// #8762 (whose old set was the schema's own declared vocabulary and default),
2518+
// these four names were never this block's declared vocabulary — the key was
2519+
// a bare `z.string()` — and the authored-value census on both repos (this
2520+
// repo + objectui#5939's) found zero occurrences to rewrite.
2521+
const MASTER_DETAIL_FORM_TYPE_RETIRED: ReadonlyMap<string, string> = new Map([
2522+
['wizard', "'wizard' is not part of `object-master-detail-form` `formType` (#11873 — objectui#5939 "
2523+
+ 'measured the renderer): only the current wizard step\'s fields mount and the block\'s single '
2524+
+ "Save bar acts as the wizard's Next, so parent + details never save through the atomic batch "
2525+
+ "(ADR-0001, the block's whole contract). Write 'simple' (sections render stacked) or 'tabbed'; "
2526+
+ "for a wizard without inline details author an `object-form`, where 'wizard' is honoured."],
2527+
['split', "'split' is not part of `object-master-detail-form` `formType` (#11873 — objectui#5939 "
2528+
+ 'measured the renderer): the parent half renders inline but persists via `dataSource.create`, '
2529+
+ "bypassing the atomic parent+details batch (ADR-0001, the block's whole contract). Write "
2530+
+ "'simple' or 'tabbed'; for a split presentation without inline details author an "
2531+
+ "`object-form`, where 'split' is honoured."],
2532+
['drawer', "'drawer' is not part of `object-master-detail-form` `formType` (#11873 — objectui#5939 "
2533+
+ 'measured the renderer): the parent half renders in a portal dialog outside the master-detail '
2534+
+ "container, so the block's Save bar has no form to submit. Write 'simple' or 'tabbed'; for a "
2535+
+ "drawer overlay without inline details author an `object-form`, where 'drawer' is honoured."],
2536+
['modal', "'modal' is not part of `object-master-detail-form` `formType` (#11873 — objectui#5939 "
2537+
+ 'measured the renderer): the parent half renders in a portal dialog outside the master-detail '
2538+
+ "container (the same portal shape as 'drawer'), so the block's Save bar has no form to "
2539+
+ "submit. Write 'simple' or 'tabbed'; for a modal overlay without inline details author an "
2540+
+ "`object-form`, where 'modal' is honoured."],
2541+
]);
2542+
25052543
/**
25062544
* `object-master-detail-form` (objectui `plugin-form/src/MasterDetailForm.tsx`
25072545
* @ `eb7f586b`). Parent + child line items entered together (ADR-0001). The
25082546
* child collections come from `details` — the FK and editable-grid columns
25092547
* are auto-derived from the child object's metadata (`deriveMasterDetail.ts`),
25102548
* so `details[].columns` is an override, not a requirement.
2549+
*
2550+
* `formType` speaks the MEASURED vocabulary — `simple` / `tabbed` — since
2551+
* #11873 (the spec half of objectui#5939, which tightened the objectui
2552+
* registry declaration to the same pair on the same measurement, corroborated
2553+
* by objectui's own two declarations: `MasterDetailFormSchema.formType?:
2554+
* 'simple' | 'tabbed'` and the `formType === 'tabbed' ? 'tabbed' : 'simple'`
2555+
* coercion). The key was a bare `z.string()`, so a value outside the
2556+
* renderer's vocabulary (`'wizzard'`) parsed clean, matched no branch, and
2557+
* the parent half fell through to a flat field list — authored sections
2558+
* silently disappeared with no diagnostic (the objectui#3840 probe read GREEN
2559+
* through a real crash this way). The four `object-form` spellings that do
2560+
* name renderer branches (`wizard`/`split`/`drawer`/`modal`) each break the
2561+
* block's atomic parent+details contract and refuse with a per-value
2562+
* prescription ({@link MASTER_DETAIL_FORM_TYPE_RETIRED}). objectui#6176
2563+
* (`tabbed` presentationally honoured but escaping the atomic batch) is a
2564+
* renderer defect tracked there — it does not change this vocabulary.
25112565
*/
25122566
export const ObjectMasterDetailFormPropsSchema = lazySchema(() => strictObject({
25132567
surface: 'this `object-master-detail-form`',
@@ -2518,7 +2572,10 @@ export const ObjectMasterDetailFormPropsSchema = lazySchema(() => strictObject({
25182572
.describe('PARENT object. Optional because the component-level `dataSource` binding can supply the object instead (#7121)'),
25192573
recordId: z.union([z.string(), z.number()]).optional().describe('Parent record to load (edit mode)'),
25202574
mode: z.enum(['create', 'edit']).optional().describe('Form mode'),
2521-
formType: z.string().optional().describe('Parent form presentation'),
2575+
formType: z.enum(['simple', 'tabbed'], {
2576+
error: (issue) =>
2577+
typeof issue.input === 'string' ? MASTER_DETAIL_FORM_TYPE_RETIRED.get(issue.input) : undefined,
2578+
}).optional().describe("Parent form presentation — the two variants the renderer honours for the parent half (#11873, objectui#5939)"),
25222579
sections: z.array(z.unknown()).optional().describe('Parent form sections'),
25232580
fields: z.array(z.unknown()).optional().describe('Parent fields shown'),
25242581
details: z.array(z.unknown()).optional()

0 commit comments

Comments
 (0)