Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/masterdetail-formtype-vocabulary.md
Original file line numberDiff line numberDiff line change
@@ -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`).

<!-- 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. -->

2 changes: 1 addition & 1 deletion content/docs/references/ui/component.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) |
Expand Down
50 changes: 50 additions & 0 deletions packages/spec/src/ui/component.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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).
Expand Down
59 changes: 58 additions & 1 deletion packages/spec/src/ui/component.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<typeof ObjectFormPropsSchema>;

// `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<string, string> = 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`',
Expand All@@ -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()
Expand Down
Loading