From 519ba78a3b83d4cad2763b77b3ce2567315163ec Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 22:35:20 +0000 Subject: [PATCH 1/5] feat(types): arm `object-form` from its spec row's `properties` bag; the flat spelling is refused by name (objectui#10859, batch 4) New `ObjectFormBlockSchema`: BaseSchema + the `object-form` literal + `properties` = propsBag('object-form', stripImportedDefaults(SpecObjectFormPropsSchema)), the node's `dataSource` binding by reference, one aliasKeyRefusal per member of the row (read off the row) pointing at `properties.KEY`, the five runtime slots and the objectui#9256 content-channel refusals. It joins ObjectQLPublicBlockComponentSchema; the flat ObjectFormSchema mirror leaves ObjectQLComponentSchema and stays published as the post-hoist reading paired with its TypeScript twin. Census rows and fixture respellings the move forces, and the two schema-catalog object-form fixtures rewritten to the bag. Claude-Session: https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ Co-authored-by: Claude --- .../plugin-form/object-form-record.json | 14 +- .../object-form-tabbed-sections.json | 24 +- .../test/catalog-gallery-render.test.tsx | 11 +- .../content-channel-e3-residual-9256.test.ts | 16 +- .../__tests__/imported-defaults-8317.test.ts | 5 + ...bject-form-properties-bag-10859-b4.test.ts | 333 ++++++++++++++++++ ...bject-form-unmirrored-members-6152.test.ts | 54 ++- .../objectql-union-arms-7363.test.ts | 7 +- ...blic-block-responsive-styles-10872.test.ts | 4 + .../registered-type-arms-10859-b2.test.ts | 4 +- .../strict-face-read-keys-11070.test.ts | 4 +- .../src/__tests__/zod-mirror-parity.test.ts | 5 + packages/types/src/zod/index.zod.ts | 5 +- packages/types/src/zod/objectql.zod.ts | 136 ++++++- 14 files changed, 580 insertions(+), 42 deletions(-) create mode 100644 packages/types/src/__tests__/object-form-properties-bag-10859-b4.test.ts diff --git a/examples/schema-catalog/src/schemas/plugin-form/object-form-record.json b/examples/schema-catalog/src/schemas/plugin-form/object-form-record.json index a8c038958e..2ad1fa001d 100644 --- a/examples/schema-catalog/src/schemas/plugin-form/object-form-record.json +++ b/examples/schema-catalog/src/schemas/plugin-form/object-form-record.json @@ -1,9 +1,11 @@ { "type": "object-form", - "objectName": "users", - "mode": "edit", - "recordId": "1", - "columns": 2, - "fields": ["name", "email", "role", "department", "status"], - "submitText": "Save changes" + "properties": { + "objectName": "users", + "mode": "edit", + "recordId": "1", + "columns": 2, + "fields": ["name", "email", "role", "department", "status"], + "submitText": "Save changes" + } } diff --git a/examples/schema-catalog/src/schemas/plugin-form/object-form-tabbed-sections.json b/examples/schema-catalog/src/schemas/plugin-form/object-form-tabbed-sections.json index dfa2ef290a..04aee0ebdb 100644 --- a/examples/schema-catalog/src/schemas/plugin-form/object-form-tabbed-sections.json +++ b/examples/schema-catalog/src/schemas/plugin-form/object-form-tabbed-sections.json @@ -1,14 +1,16 @@ { "type": "object-form", - "objectName": "users", - "mode": "edit", - "recordId": "1", - "formType": "tabbed", - "defaultTab": "identity", - "sections": [ - { "name": "identity", "label": "Identity", "columns": 2, "fields": ["name", "email"] }, - { "name": "organisation", "label": "Organisation", "fields": ["department", "created_at"] }, - { "name": "access", "label": "Access", "fields": ["role", "status"] } - ], - "submitText": "Save changes" + "properties": { + "objectName": "users", + "mode": "edit", + "recordId": "1", + "formType": "tabbed", + "defaultTab": "identity", + "sections": [ + { "name": "identity", "label": "Identity", "columns": 2, "fields": ["name", "email"] }, + { "name": "organisation", "label": "Organisation", "fields": ["department", "created_at"] }, + { "name": "access", "label": "Access", "fields": ["role", "status"] } + ], + "submitText": "Save changes" + } } diff --git a/examples/schema-catalog/test/catalog-gallery-render.test.tsx b/examples/schema-catalog/test/catalog-gallery-render.test.tsx index 2512a7b645..ac19e7b5b5 100644 --- a/examples/schema-catalog/test/catalog-gallery-render.test.tsx +++ b/examples/schema-catalog/test/catalog-gallery-render.test.tsx @@ -1135,12 +1135,15 @@ describe('objectui#6024 — the derivation this pin is built on', () => { * its first paint until the fetch lands, which is why this shape settles.) */ it('the provenance instrument still fails on a surface with no record behind it', async () => { + // The entries' spelling: the props in the `properties` bag (objectui#10859 batch 4). const r = await renderEntry({ type: 'object-form', - objectName: 'users', - mode: 'edit', - recordId: 'no-such-record', - fields: ['name', 'email', 'department'], + properties: { + objectName: 'users', + mode: 'edit', + recordId: 'no-such-record', + fields: ['name', 'email', 'department'], + }, }); try { expect( diff --git a/packages/types/src/__tests__/content-channel-e3-residual-9256.test.ts b/packages/types/src/__tests__/content-channel-e3-residual-9256.test.ts index 4bfd650a07..09cfffd4fe 100644 --- a/packages/types/src/__tests__/content-channel-e3-residual-9256.test.ts +++ b/packages/types/src/__tests__/content-channel-e3-residual-9256.test.ts @@ -114,6 +114,9 @@ type Mirror = { */ const ROWS: ReadonlyArray]> = [ ['object-grid', ObjectGridMirror as unknown as Mirror, { objectName: 'account' }], + // The flat mirror is the node as `ObjectForm` reads it after the `properties` + // hoist; the AUTHORED node takes the same members in the bag + // (`AUTHORED_REQUIRED` below, objectui#10859 batch 4). ['object-form', ObjectFormMirror as unknown as Mirror, { objectName: 'account', mode: 'create' }], ['object-kanban', ObjectKanbanMirror as unknown as Mirror, { objectName: 'account' }], ['object-map', ObjectMapMirror as unknown as Mirror, { objectName: 'account' }], @@ -127,6 +130,16 @@ const ROWS: ReadonlyArray>> = { + 'object-form': { properties: { objectName: 'account', mode: 'create' } }, +}; + const CHANNELS = ['body', 'children'] as const; const CONTENT = [{ type: 'text', content: 'measured' }]; const issues = (m: Mirror, doc: unknown) => { @@ -172,7 +185,8 @@ describe('objectui#9256 E3 residual — both content channels are refused where } }); - it.each(CASES)('%s — the refusal reaches the node through `AnyComponentSchema`, not only its own arm', (_label, type, _mirror, key, required) => { + it.each(CASES)('%s — the refusal reaches the node through `AnyComponentSchema`, not only its own arm', (_label, type, _mirror, key, mirrorRequired) => { + const required = AUTHORED_REQUIRED[type] ?? mirrorRequired; expect(AnyComponentSchema.safeParse({ ...required, type }).success).toBe(true); expect(AnyComponentSchema.safeParse({ ...required, type, [key]: CONTENT }).success).toBe(false); }); diff --git a/packages/types/src/__tests__/imported-defaults-8317.test.ts b/packages/types/src/__tests__/imported-defaults-8317.test.ts index 9ff2e07cff..f730b026e2 100644 --- a/packages/types/src/__tests__/imported-defaults-8317.test.ts +++ b/packages/types/src/__tests__/imported-defaults-8317.test.ts @@ -85,6 +85,7 @@ import { ObjectMetricPropsSchema as SpecObjectMetricPropsSchema, ObjectMasterDetailFormPropsSchema as SpecObjectMasterDetailFormPropsSchema, ObjectTimelinePropsSchema as SpecObjectTimelinePropsSchema, + ObjectFormPropsSchema as SpecObjectFormPropsSchema, // objectui#10872 — the `ComponentPropsMap` rows the public-block arms read. PageHeaderProps as SpecPageHeaderProps, PageTabsProps as SpecPageTabsProps, @@ -261,6 +262,10 @@ const IMPORTED: Array = [ // row, crossed the same way — and unlike the two above it carries spec // defaults (`timeline.scale`, the `navigation` members) for the strip to remove. ['ObjectTimelinePropsSchema', SpecObjectTimelinePropsSchema], + // objectui#10859 batch 4: the `object-form` arm's `properties` is its row, + // crossed the same way; `objectql.zod.ts` also reads the row's key set off the + // crossed copy to refuse each member written flat. + ['ObjectFormPropsSchema', SpecObjectFormPropsSchema], // objectui#10872: each ADR-0080 public-block arm's `properties` is the // block's `ComponentPropsMap` row, crossed through this boundary, so every // row is measured here like every other crossing (`page:section`, diff --git a/packages/types/src/__tests__/object-form-properties-bag-10859-b4.test.ts b/packages/types/src/__tests__/object-form-properties-bag-10859-b4.test.ts new file mode 100644 index 0000000000..27f9c991fc --- /dev/null +++ b/packages/types/src/__tests__/object-form-properties-bag-10859-b4.test.ts @@ -0,0 +1,333 @@ +/** + * 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` takes its props in the spec's `properties` bag, and the flat + * spelling retires from both authoring faces (objectui#10859, batch 4). + * + * ## The defect this pins + * + * `@objectstack/spec`'s `ComponentPropsMap['object-form']` row is the published + * declaration of an authored `object-form` node's props, and the spec's strict + * `PageComponentSchema` refuses a prop written on the node itself as + * mis-layered (ADR-0089 D3a). objectui's arm was the flat mirror of the + * TypeScript twin instead, so the two validators disagreed in both directions: + * + * - the objectstack showcase's wizard page (`NewProjectWizardPage` in + * objectstack's `examples/app-showcase`, the one live spec-shaped producer) + * was refused by `safeValidateSchema` — `objectName` `invalid_type`, `mode` + * `invalid_value` — and by the strict face, which added `properties` as an + * unrecognized key; + * - the flat node `os validate` refuses parsed green on both faces. + * + * The seat's answer at PR objectui#11248's ACCEPT (A, inherited from + * objectui#10872's triage answer A) is the one this arm executes: the + * `properties` bag is the contract, by reference to the row, and the flat + * spelling is refused by name with a prescription that names the bag member. + * + * ## What did NOT move + * + * The TypeScript `ObjectFormSchema` (`../objectql.ts`) and its zod mirror + * (`ObjectFormSchema` in `../zod/objectql.zod.ts`) stay published: they are the + * node as `ObjectForm` reads it AFTER `SchemaRenderer` hoists `properties`, and + * as code composes it. The mirror is no longer an arm of `AnyComponentSchema`; + * `ObjectFormBlockSchema` is. The last describe block holds that split. + * + * ## Two instruments, and which half each one reads + * + * The `Expect` / `Equal` block below is TYPE-level: `tsc -p tsconfig.test.json` + * (the third leg of this package's `type-check` script) reads it, and vitest — + * which strips types — does not. The `describe` blocks are RUNTIME. A green run + * of either one alone says nothing about the other. + */ + +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { + ElementDataSourceSchema as SpecElementDataSourceSchema, + ObjectFormPropsSchema as SpecObjectFormPropsSchema, + PageComponentSchema as SpecPageComponentSchema, + type ObjectFormProps as SpecObjectFormProps, +} from '@objectstack/spec/ui'; + +import type { ObjectFormSchema as TsObjectFormSchema, ObjectQLComponentSchema as TsObjectQLComponentSchema } from '../objectql'; +import { + ObjectFormBlockSchema, + ObjectFormSchema, + ObjectQLComponentSchema, + ObjectQLPublicBlockComponentSchema, + ObjectViewSchema, + StrictAnyComponentSchema, + safeValidateSchema, +} from '../zod/index.zod.js'; +import { stripImportedDefaults } from '../zod/imported-defaults.js'; + +/* ── Type-level parity: the `tsc` channel ────────────────────────────────── */ + +/** Invariant equality — `extends` both ways would accept a narrowing. */ +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Expect = T; + +/** The arm's own shape. */ +type ShapeOf = M extends { shape: infer S } ? S : never; +/** What a shape entry ACCEPTS (input side, so `.optional()` shows). */ +type InputOf = T extends z.ZodType ? z.input : never; +type Arm = ShapeOf; + +/** + * The bag accepts exactly the spec's published props type (absent allowed), + * the node's binding accepts exactly the spec's element binding, a row key + * written flat accepts nothing, and neither content channel accepts anything. + */ +export type assertionObjectFormArmIsTheRow = [ + Expect, SpecObjectFormProps | undefined>>, + Expect, z.input | undefined>>, + Expect, undefined>>, + Expect, undefined>>, + Expect, undefined>>, + Expect, undefined>>, + Expect, undefined>>, +]; + +/** + * Every member of the spec row is refused flat on the arm: the refusal set is + * the row's key set, read by reference, so it cannot fall behind the row. + */ +export type assertionEveryRowKeyIsRefusedFlat = Expect< + Equal, never> +>; + +/** + * The TypeScript twin is RE-DECLARED, not retired: still published, still the + * post-hoist reading with its identity keys required, still a member of the + * TypeScript ObjectQL union. A narrowing or a removal here reddens `tsc`. + */ +export type assertionTwinStaysPublished = [ + Expect>, + Expect>, + Expect>, + Expect, TsObjectFormSchema>>, +]; + +/** Non-vacuity: a bag narrower than the spec's props type is not Equal to it. */ +export type assertionInstrumentFires = [ + Expect, false>>, +]; + +/* ── Runtime fixtures ────────────────────────────────────────────────────── */ + +/** + * The objectstack showcase's wizard node (`NewProjectWizardPage`, objectstack + * `examples/app-showcase/src/ui/pages/new-project-wizard.page.ts`), its props + * copied as it writes them with the comments dropped. Its lit control is the + * spec's own page component, below. + */ +const SHOWCASE_WIZARD = { + type: 'object-form', + properties: { + objectName: 'showcase_project', + mode: 'create', + formType: 'wizard', + showStepIndicator: true, + title: 'Create a Project', + description: 'A three-step wizard — basics, health, then budget & schedule.', + sections: [ + { label: 'Basics', description: 'Name the project and bind its account.', fields: ['name', 'account', 'owner'] }, + { label: 'Health', description: 'New projects start as Planned — how healthy is it today?', fields: ['health'] }, + { label: 'Budget & Schedule', description: 'Money and dates.', fields: ['budget', 'spent', 'start_date', 'end_date'] }, + ], + submitBehavior: { + kind: 'thank-you', + title: 'Project created', + message: 'Your new project is ready — find it in Projects, or reopen this wizard to start another.', + }, + }, +} as const; + +/** The same block bound through the node's `dataSource`, the spec's per-element binding. */ +const BOUND = { + type: 'object-form', + dataSource: { object: 'account' }, + properties: { mode: 'edit', recordId: 'a1', fields: ['name', 'rating'] }, +} as const; + +/** The flat spelling this batch retires. */ +const FLAT = { type: 'object-form', objectName: 'account', mode: 'create' } as const; + +type Issues = z.core.$ZodIssue[]; + +function issuesOf(result: { success: boolean; error?: { issues: Issues } }): Issues { + if (result.success) throw new Error('expected a refusal, the document parsed'); + return result.error!.issues; +} + +describe('object-form validates in the spec\'s `properties` bag (objectui#10859 batch 4)', () => { + it.each([ + ['the objectstack showcase wizard node', SHOWCASE_WIZARD], + ['the bare node', { type: 'object-form' }], + ['a node bound through `dataSource`', BOUND], + ] as const)('%s is accepted by safeValidateSchema and by the strict authoring face', (_label, doc) => { + const tolerant = safeValidateSchema(doc); + expect(tolerant.success, JSON.stringify(tolerant.success ? null : tolerant.error.issues)).toBe(true); + const strict = StrictAnyComponentSchema.safeParse(doc); + expect(strict.success, JSON.stringify(strict.success ? null : strict.error.issues)).toBe(true); + }); + + it('the fixtures are spec-valid by the spec\'s own page component and row (lit control)', () => { + for (const doc of [SHOWCASE_WIZARD, BOUND]) { + const r = SpecPageComponentSchema.safeParse(doc); + expect(r.success, JSON.stringify(r.success ? null : r.error.issues)).toBe(true); + expect(SpecObjectFormPropsSchema.safeParse(doc.properties).success).toBe(true); + } + }); + + it('reaches the arm at a child slot too — a page holding the block', () => { + expect(safeValidateSchema({ type: 'page', children: [SHOWCASE_WIZARD, BOUND] }).success).toBe(true); + }); +}); + +describe('the flat spelling is refused by name, with the bag member as the remedy (objectui#10859 batch 4)', () => { + it('the spec\'s own page component refuses the flat node (lit control: the inherited reason holds)', () => { + const issues = issuesOf(SpecPageComponentSchema.safeParse(FLAT)); + expect(issues.map((i) => i.code)).toEqual(['unrecognized_keys']); + expect((issues[0] as { keys?: string[] }).keys).toEqual(['objectName', 'mode']); + }); + + it.each([ + ['safeValidateSchema', (doc: unknown) => safeValidateSchema(doc)], + ['the strict authoring face', (doc: unknown) => StrictAnyComponentSchema.safeParse(doc)], + ] as const)('%s refuses the flat node at each flat key, naming `properties.KEY`', (_face, parse) => { + const issues = issuesOf(parse(FLAT)); + const byPath = new Map(issues.map((i) => [i.path.join('.'), i.message])); + expect([...byPath.keys()].sort()).toEqual(['mode', 'objectName']); + expect(byPath.get('objectName')).toContain('`objectName` → `properties.objectName`'); + expect(byPath.get('mode')).toContain('`mode` → `properties.mode`'); + // The prescription is the whole document shape, not only the path. + expect(byPath.get('objectName')).toContain('"properties": {'); + }); + + // Every member of the spec's row, read off the installed spec on each run — + // not a transcribed list that could drift from it. + const ROW_KEYS = Object.keys((SpecObjectFormPropsSchema as unknown as { shape: Record }).shape); + + it('the row is non-trivial, so the per-key rows below are not vacuous', () => { + expect(ROW_KEYS.length).toBeGreaterThan(30); + expect(ROW_KEYS).toContain('objectName'); + // `description` is also a `BaseSchema` key; on this node the arm's refusal + // wins, because the form's description is the row's member. + expect(ROW_KEYS).toContain('description'); + }); + + it.each(ROW_KEYS.map((key) => [key] as const))('a flat `%s` is refused on the tolerant face, by name', (key) => { + const issues = issuesOf(safeValidateSchema({ type: 'object-form', [key]: true })); + const issue = issues.find((i) => i.path.join('.') === key); + expect(issue, JSON.stringify(issues)).toBeDefined(); + expect(issue!.message).toContain(`\`${key}\` → \`properties.${key}\``); + }); + + it('a flat key the row does not declare stays unjudged on the tolerant face and is refused on the strict face', () => { + // The strictness control: this arm is `BaseSchema`, whose `.passthrough()` + // every arm keeps, so an INVENTED key is not refused by the tolerant face. + const doc = { type: 'object-form', inventedKey10859b4: true }; + expect(safeValidateSchema(doc).success).toBe(true); + const strict = StrictAnyComponentSchema.safeParse(doc); + expect(strict.success).toBe(false); + if (strict.success) return; + const issue = strict.error.issues.find((i) => i.code === 'unrecognized_keys'); + expect((issue as { keys?: string[] } | undefined)?.keys).toEqual(['inventedKey10859b4']); + }); +}); + +describe('the bag is judged by the spec row (objectui#10859 batch 4)', () => { + it('an undeclared key inside the bag is refused on the TOLERANT face, by name', () => { + const issue = issuesOf(safeValidateSchema({ type: 'object-form', properties: { objectName: 'a', inventedKey10859b4: 1 } }))[0]; + expect(issue.code).toBe('unrecognized_keys'); + expect(issue.path).toEqual(['properties']); + expect((issue as { keys?: string[] }).keys).toEqual(['inventedKey10859b4']); + }); + + it.each([ + // The spec retired both values in 17.5.0; the flat mirror still lists them. + ['layout', { layout: 'grid' }, ['properties', 'layout'], 'invalid_value'], + ['mode', { mode: 'delete' }, ['properties', 'mode'], 'invalid_value'], + ['formType', { formType: 'stepper' }, ['properties', 'formType'], 'invalid_value'], + ['columns', { columns: '2' }, ['properties', 'columns'], 'invalid_type'], + ] as const)('a spec-invalid `%s` is refused at that member', (_key, bag, path, code) => { + const issue = issuesOf(safeValidateSchema({ type: 'object-form', properties: bag }))[0]; + expect(issue.code).toBe(code); + expect(issue.path).toEqual(path); + }); + + it('the bag is the spec row by reference, through the import boundary', () => { + const bag = ObjectFormBlockSchema.shape.properties.unwrap(); + const keysOf = (schema: unknown) => Object.keys((schema as { shape: Record }).shape).sort(); + // The same members, read off the installed spec on every run. + expect(keysOf(bag)).toEqual(keysOf(SpecObjectFormPropsSchema)); + // The row carries no spec default, so the import boundary hands the spec's + // own export back untouched (objectui#8317: a no-op on a clean subtree); + // the bag is what that export resolves to — the spec exports each row + // behind a lazy facade — and it answers every probe exactly as the spec does. + expect(stripImportedDefaults(SpecObjectFormPropsSchema)).toBe(SpecObjectFormPropsSchema); + for (const probe of [ + {}, + { objectName: 'order', mode: 'create' }, + { inventedKey10859b4: 1 }, + { objectName: 7 }, + { recordId: 42 }, + { layout: 'inline' }, + { buttons: { submit: { show: false } } }, + { sections: [{ label: 'a', fields: ['x'] }], formType: 'wizard' }, + ]) { + expect(bag.safeParse(probe).success, JSON.stringify(probe)).toBe(SpecObjectFormPropsSchema.safeParse(probe).success); + } + }); + + it('`dataSource` is judged as the spec binding on both faces', () => { + const adapterShaped = { type: 'object-form', dataSource: 'objectstack' }; + expect(safeValidateSchema(adapterShaped).success).toBe(false); + expect(StrictAnyComponentSchema.safeParse(adapterShaped).success).toBe(false); + expect(SpecElementDataSourceSchema.safeParse(BOUND.dataSource).success).toBe(true); + }); + + it.each(['onSuccess', 'onCancel', 'onError', 'onOpenChange', 'onStepChange'] as const)( + 'the runtime slot `%s` the renderer reads off the node stays refused by name (objectui#6124)', + (key) => { + const issue = issuesOf(safeValidateSchema({ type: 'object-form', [key]: { action: 'toast' } }))[0]; + expect(issue.path).toEqual([key]); + expect(issue.message).toContain('RUNTIME SLOT'); + }, + ); + + it.each(['body', 'children'] as const)('refuses the `%s` content channel by name (objectui#9256)', (key) => { + const issue = issuesOf(safeValidateSchema({ type: 'object-form', [key]: [{ type: 'text', content: 'x' }] }))[0]; + expect(issue.path).toEqual([key]); + expect(issue.message).toContain('objectui#9256'); + expect(issue.message).toContain('`object-form`'); + }); +}); + +describe('the arm moved; the post-hoist mirror stayed (objectui#10859 batch 4)', () => { + const literalsOf = (union: { options: readonly unknown[] }) => + union.options.map((arm) => (arm as { shape: { type: z.ZodLiteral } }).shape.type.value); + + it('`object-form` is armed by the bag arm, not by the flat mirror', () => { + expect(literalsOf(ObjectQLPublicBlockComponentSchema)).toContain('object-form'); + expect(literalsOf(ObjectQLComponentSchema)).not.toContain('object-form'); + expect(ObjectQLPublicBlockComponentSchema.options).toContain(ObjectFormBlockSchema); + }); + + it('the flat mirror is still published and still judges the post-hoist node', () => { + // It is what `ObjectForm` reads after the hoist, and what the object-view + // `form` slot is built from — neither is an authored `object-form` node. + expect(ObjectFormSchema.safeParse(FLAT).success).toBe(true); + expect(ObjectFormSchema.safeParse({ ...FLAT, recordId: 42 }).success).toBe(false); + const slot = ObjectViewSchema.shape.form.unwrap(); + expect(slot.safeParse({ formType: 'tabbed', sections: [{ name: 's', fields: ['a'] }] }).success).toBe(true); + }); +}); diff --git a/packages/types/src/__tests__/object-form-unmirrored-members-6152.test.ts b/packages/types/src/__tests__/object-form-unmirrored-members-6152.test.ts index 7a5701ddad..e0aa8677ef 100644 --- a/packages/types/src/__tests__/object-form-unmirrored-members-6152.test.ts +++ b/packages/types/src/__tests__/object-form-unmirrored-members-6152.test.ts @@ -38,6 +38,21 @@ * `open` and `submitHandler` stay out of the mirror on purpose (their routes are * open on objectui#6152; objectui#6182 rules out a string handler), and a row * holds them out so a later card cannot mirror them without that ruling. + * + * ## Since objectui#10859 batch 4: the faces take these members in the bag + * + * An AUTHORED `object-form` takes its props in the spec's `properties` bag, + * judged by `ComponentPropsMap['object-form']` by reference + * (`ObjectFormBlockSchema`); the flat spelling is refused by name. So the + * face rows below write each member in the bag. The mirror rows still read the + * flat mirror, which stays the node as `ObjectForm` reads it after the hoist. + * + * Three members — `buttons`, `defaults` and `subforms` — are the spec's + * FORM-VIEW members (`FormViewSchema`), not members of the `object-form` row, + * so the row refuses them in the bag. They still reach the renderer the way + * round 1 measured: relayed from an object's form view into the node by + * `ObjectView` / `RecordFormPage`, and through the object-view `form` slot, + * which is built from this mirror. */ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; @@ -50,8 +65,14 @@ import { AnyComponentSchema, StrictAnyComponentSchema } from '../zod/index.zod.j const HERE = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = join(HERE, '..', '..', '..', '..'); -/** A minimal document both faces accept; every row below is a delta on it. */ -const BASE = { type: 'object-form', objectName: 'order', mode: 'create' } as const; +/** A minimal AUTHORED document both faces accept; every face row below is a delta on its bag. */ +const BASE = { type: 'object-form', properties: { objectName: 'order', mode: 'create' } } as const; +/** The same node as the renderer reads it after the hoist — what the mirror rows parse. */ +const FLAT_BASE = { type: 'object-form', objectName: 'order', mode: 'create' } as const; +const withProp = (key: string, value: unknown) => ({ ...BASE, properties: { ...BASE.properties, [key]: value } }); + +/** The spec's form-VIEW members: not members of `ComponentPropsMap['object-form']` (objectui#10859 batch 4). */ +const FORM_VIEW_ONLY = new Set(['buttons', 'defaults', 'subforms']); /** Each mirrored key: one authored value, and one value its declaration refuses. */ const ROWS: ReadonlyArray<{ key: string; valid: unknown; wrong: unknown }> = [ @@ -93,16 +114,27 @@ describe('objectui#6152 round 1 — `object-form` members the mirror now declare expect(key in (ObjectFormMirror.shape as Record)).toBe(true); }); - it.each(ROWS)('an authored `$key` parses on the strict face and the tolerant face', ({ key, valid }) => { - const doc = { ...BASE, [key]: valid }; + it.each(ROWS.filter(({ key }) => !FORM_VIEW_ONLY.has(key)))('an authored `$key` parses on the strict face and the tolerant face, in the bag', ({ key, valid }) => { + const doc = withProp(key, valid); const strict = StrictAnyComponentSchema.safeParse(doc); expect(strict.success, JSON.stringify(strict.error?.issues)).toBe(true); const tolerant = AnyComponentSchema.safeParse(doc); expect(tolerant.success, JSON.stringify(tolerant.error?.issues)).toBe(true); }); + it.each(ROWS.filter(({ key }) => FORM_VIEW_ONLY.has(key)))('`$key` is a form-VIEW member: the row refuses it in the bag, and the object-view `form` slot takes it', ({ key, valid }) => { + for (const face of [StrictAnyComponentSchema, AnyComponentSchema]) { + const parsed = face.safeParse(withProp(key, valid)); + expect(parsed.success).toBe(false); + expect(JSON.stringify(parsed.error?.issues)).toContain(`"${key}"`); + } + const view = { type: 'object-view', objectName: 'order', form: { [key]: valid } }; + const strict = StrictAnyComponentSchema.safeParse(view); + expect(strict.success, JSON.stringify(strict.error?.issues)).toBe(true); + }); + it.each(ROWS)('a wrong-typed `$key` is refused at the key on the tolerant face', ({ key, wrong }) => { - const parsed = ObjectFormMirror.safeParse({ ...BASE, [key]: wrong }); + const parsed = ObjectFormMirror.safeParse({ ...FLAT_BASE, [key]: wrong }); expect(parsed.success).toBe(false); for (const issue of parsed.error?.issues ?? []) expect(issue.path[0]).toBe(key); }); @@ -116,15 +148,21 @@ describe('objectui#6152 round 1 — `object-form` members the mirror now declare it('a section is a closed shape on the strict face: an undeclared section key is named there', () => { // `className` is deliberately undeclared on `ObjectFormSection` (objectui#7200). - const strict = StrictAnyComponentSchema.safeParse({ ...BASE, sections: [{ name: 'a', className: 'p-4', fields: ['a'] }] }); + // Read through the object-view `form` slot, which is built from this mirror. + // (On an authored `object-form` node the bag is the spec row, whose + // `sections` entries the spec types `unknown`, so the row does not judge a + // section's keys there — objectui#10859 batch 4.) + const view = { type: 'object-view', objectName: 'order', form: { sections: [{ name: 'a', className: 'p-4', fields: ['a'] }] } }; + const strict = StrictAnyComponentSchema.safeParse(view); expect(strict.success).toBe(false); expect(JSON.stringify(strict.error?.issues)).toContain('"className"'); }); it('the catalog document objectui#5250 M3 charged with three of these keys now parses on the strict face', () => { const doc = JSON.parse(readFileSync(join(REPO_ROOT, 'examples/schema-catalog/src/schemas/plugin-form/object-form-tabbed-sections.json'), 'utf8')); - // Non-vacuity: the document really carries the keys this row is about. - expect(Object.keys(doc)).toEqual(expect.arrayContaining(['formType', 'defaultTab', 'sections'])); + // Non-vacuity: the document really carries the keys this row is about, in + // its `properties` bag since objectui#10859 batch 4. + expect(Object.keys(doc.properties)).toEqual(expect.arrayContaining(['formType', 'defaultTab', 'sections'])); const strict = StrictAnyComponentSchema.safeParse(doc); expect(strict.success, JSON.stringify(strict.error?.issues)).toBe(true); }); diff --git a/packages/types/src/__tests__/objectql-union-arms-7363.test.ts b/packages/types/src/__tests__/objectql-union-arms-7363.test.ts index 3e3d17ffc5..881472e93e 100644 --- a/packages/types/src/__tests__/objectql-union-arms-7363.test.ts +++ b/packages/types/src/__tests__/objectql-union-arms-7363.test.ts @@ -81,8 +81,11 @@ describe('objectui#7363 — the two objectui#6576 schemas are arms of the Object expect(literals).toContain('object-gallery'); expect(literals).toContain('object-data-table'); // The ten arms PR #7355 left in place are all still there — this is an - // addition, not a reshuffle. - expect(literals).toHaveLength(12); + // addition, not a reshuffle — less `object-form`, whose authored arm left + // this union for `ObjectQLPublicBlockComponentSchema` in objectui#10859 + // batch 4 (`object-form-properties-bag-10859-b4.test.ts`). + expect(literals).toHaveLength(11); + expect(literals).not.toContain('object-form'); }); it.each([ diff --git a/packages/types/src/__tests__/public-block-responsive-styles-10872.test.ts b/packages/types/src/__tests__/public-block-responsive-styles-10872.test.ts index f942e540de..2c63f5852e 100644 --- a/packages/types/src/__tests__/public-block-responsive-styles-10872.test.ts +++ b/packages/types/src/__tests__/public-block-responsive-styles-10872.test.ts @@ -103,6 +103,10 @@ const DECLARES_DATA_SOURCE: ReadonlySet = new Set([ 'object-metric', 'object-master-detail-form', 'object-timeline', + // objectui#10859 batch 4: the `object-form` arm moved here from + // `ObjectQLComponentSchema`, carrying the binding its flat mirror has + // declared since objectui#11070. + 'object-form', ]); /** The five envelope keys the spec declares and this batch leaves undeclared. */ diff --git a/packages/types/src/__tests__/registered-type-arms-10859-b2.test.ts b/packages/types/src/__tests__/registered-type-arms-10859-b2.test.ts index 90ac17a2f7..576c5275a0 100644 --- a/packages/types/src/__tests__/registered-type-arms-10859-b2.test.ts +++ b/packages/types/src/__tests__/registered-type-arms-10859-b2.test.ts @@ -350,11 +350,13 @@ describe('the spec-row arms read the row by reference (objectui#10859)', () => { it('each arm is reachable from AnyComponentSchema through its category union', () => { const literals = (union: { options: readonly { shape: { type: z.ZodLiteral } }[] }) => union.options.map((arm) => arm.shape.type.value); - // `object-timeline` joined the union in batch 3 (`object-timeline-arm-10859-b3.test.ts`). + // `object-timeline` joined the union in batch 3 (`object-timeline-arm-10859-b3.test.ts`), + // `object-form` in batch 4 (`object-form-properties-bag-10859-b4.test.ts`). expect(literals(ObjectQLPublicBlockComponentSchema)).toEqual([ 'object-metric', 'object-master-detail-form', 'object-timeline', + 'object-form', ]); expect(literals(DataDisplaySchema)).toContain('pivot'); }); diff --git a/packages/types/src/__tests__/strict-face-read-keys-11070.test.ts b/packages/types/src/__tests__/strict-face-read-keys-11070.test.ts index 199ec8effd..c24b30cfe5 100644 --- a/packages/types/src/__tests__/strict-face-read-keys-11070.test.ts +++ b/packages/types/src/__tests__/strict-face-read-keys-11070.test.ts @@ -131,7 +131,9 @@ describe('objectui#11070 — the declared read keys parse on the strict face', ( const BINDING = { object: 'task', filter: [{ field: 'project', operator: 'equals', value: 'acme' }] }; const BOUND_NODES: ReadonlyArray]> = [ ['object-grid', { objectName: 'task' }], - ['object-form', { objectName: 'task', mode: 'edit' }], + // objectui#10859 batch 4: an authored `object-form` takes its props in the + // spec's `properties` bag; the flat spelling is refused by name. + ['object-form', { properties: { objectName: 'task', mode: 'edit' } }], // `objectName` on every node, deliberately: whether a binding's `object` // may stand in for the node's own required record source (as the spec's // props gate lets it on `element:number`) is a separate question from diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index d267bc690f..614d592e27 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -4467,6 +4467,11 @@ const EXCLUSIONS: Readonly> = { // objectui#10859 batch 3 — the third, built the same way from its 17.5.0 row. 'objectql.zod.ts#ObjectTimelineBlockSchema': "spec-owned BY REFERENCE — `BaseSchema` + the `object-timeline` literal + `properties`, which IS `ComponentPropsMap['object-timeline']`, + the node's `dataSource` binding, which IS the spec's `ElementDataSourceSchema` (`PageComponentSchema.dataSource`), + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node (`@object-ui/plugin-timeline`'s `ObjectTimelineProps` are the component's React props)", + // objectui#10859 batch 4 — the authored `object-form` arm, from its row. Its + // TS twin is paired with the FLAT mirror above (`ObjectFormSchema`), which + // stays the renderer's post-hoist reading. + 'objectql.zod.ts#ObjectFormBlockSchema': + "spec-owned BY REFERENCE — `BaseSchema` + the `object-form` literal + `properties`, which IS `ComponentPropsMap['object-form']`, + the node's `dataSource` binding, which IS the spec's `ElementDataSourceSchema`, + one `aliasKeyRefusal` per member of the row (the flat spelling, refused by name and pointed at `properties.KEY`; the key set is read off the row), + five `handlerKeyRefusal` runtime slots, + the objectui#9256 `body` / `children` refusals; the TS declaration of the node, `ObjectFormSchema` (`../objectql.ts`), is the renderer's post-hoist reading and is paired with the flat `objectql.zod.ts#ObjectFormSchema` mirror, not with this arm", 'objectql.zod.ts#ObjectQLPublicBlockComponentSchema': "a union OVER the public-block arms above, not an object of its own — its members are checked individually", 'overlay.zod.ts#MenuItemSchema': diff --git a/packages/types/src/zod/index.zod.ts b/packages/types/src/zod/index.zod.ts index 956025aec2..bb1baec51d 100644 --- a/packages/types/src/zod/index.zod.ts +++ b/packages/types/src/zod/index.zod.ts @@ -326,11 +326,14 @@ export { ObjectDataTableSchema, ListViewSchema, ObjectQLComponentSchema, - // objectui#10859 batches 2 and 3 — ADR-0080 public blocks armed from their + // objectui#10859 batches 2 to 4 — ADR-0080 public blocks armed from their // `ComponentPropsMap` rows, and the union they reach `AnyComponentSchema` by. + // Batch 4's `ObjectFormBlockSchema` is the authored `object-form` arm; + // `ObjectFormSchema` above stays the node as the renderer reads it. ObjectMetricBlockSchema, ObjectMasterDetailFormBlockSchema, ObjectTimelineBlockSchema, + ObjectFormBlockSchema, ObjectQLPublicBlockComponentSchema, } from './objectql.zod.js'; diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 87b801a458..03e9d3554c 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -45,12 +45,13 @@ import { // objectui#7928 — the spec's view CONTAINER, read for ONE slot: its // `listViews` record (`ObjectViewSchema.listViews` below, by reference). ViewSchema as SpecViewSchema, - // objectui#10859 (batches 2 and 3) — the `ComponentPropsMap` rows of the + // objectui#10859 (batches 2, 3 and 4) — the `ComponentPropsMap` rows of the // ADR-0080 public blocks this module arms, each read as its arm's // `properties` bag, by reference (`ObjectQLPublicBlockComponentSchema` below). ObjectMetricPropsSchema as SpecObjectMetricPropsSchema, ObjectMasterDetailFormPropsSchema as SpecObjectMasterDetailFormPropsSchema, ObjectTimelinePropsSchema as SpecObjectTimelinePropsSchema, + ObjectFormPropsSchema as SpecObjectFormPropsSchema, // objectui#11070 — the per-element data binding (`PageComponentSchema.dataSource`) // the object-bound arms below declare as `dataSource`, by reference. ElementDataSourceSchema as SpecElementDataSourceSchema, @@ -527,7 +528,19 @@ const ObjectFormButtonToggleSchema = z.object({ }); /** - * ObjectForm Schema + * ObjectForm Schema — the zod mirror of the TypeScript `ObjectFormSchema` + * (`../objectql.ts`): the `object-form` node as `ObjectForm` READS it, after + * `SchemaRenderer` has hoisted the `properties` bag onto the node, and as code + * composes it (`ObjectView`, `RecordFormPage`, `ScreenView`, the plugin-form + * variants). The object-view `form` slot below is built from it. + * + * ⚠️ NOT an arm of `AnyComponentSchema` since objectui#10859 batch 4. An + * AUTHORED `object-form` node takes its props in the spec's `properties` bag, + * judged by `ObjectFormBlockSchema` below, which refuses each prop written + * flat on the node by name and names its bag member. This mirror stays + * published and paired with its TypeScript twin in the parity census, because + * the twin stays the renderer's reading; it no longer answers for authored + * documents. */ export const ObjectFormSchema = BaseSchema.extend({ type: z.literal('object-form'), @@ -3339,7 +3352,8 @@ export const ObjectDataTableSchema = BaseSchema.extend({ /** * ObjectQL Component Schema Union * - * Same twelve members as the TS union in `../objectql.ts`, in the same order. + * The members of the TS union in `../objectql.ts`, in the same order, less + * `ObjectFormSchema` (see the last paragraph). * `ObjectGallerySchema` and `ObjectDataTableSchema` joined in objectui#7363: * PR #7355 (objectui#6576) minted both mirrors and deliberately did not extend * this union, so `AnyComponentSchema` — and `validateSchema` / @@ -3355,10 +3369,15 @@ export const ObjectDataTableSchema = BaseSchema.extend({ * `crud.zod.ts#CRUDComponentSchema` for both reasons. All twelve arms already * declared a distinct `type` literal, so ⛔ no document changes verdict; what * changes is that a refusal now carries ONE arm's diagnosis instead of twelve. + * + * ⚠️ ELEVEN since objectui#10859 batch 4: `ObjectFormSchema` left this union. + * The TypeScript union still carries its twin, which is the node as `ObjectForm` + * reads it after the `properties` hoist; the authored `object-form` node is + * armed by `ObjectFormBlockSchema` in `ObjectQLPublicBlockComponentSchema` + * below, from its spec row. */ export const ObjectQLComponentSchema = z.discriminatedUnion('type', [ ObjectGridSchema, - ObjectFormSchema, ObjectViewSchema, ObjectMapSchema, ObjectTreeSchema, @@ -3590,14 +3609,116 @@ export const ObjectTimelineBlockSchema = BaseSchema.extend({ children: retirementTombstone(OBJECT_TIMELINE_NEITHER_CHANNEL), }); +/** + * The ONE refusal detail every `object-form` prop written flat on the node + * gets (objectui#10859, batch 4). `aliasKeyRefusal` puts the key and its bag + * member in front of it: "Did you mean `mode` → `properties.mode`?". + */ +const OBJECT_FORM_FLAT_PROP = + 'An `object-form` node takes its props in its `properties` bag, where `@objectstack/spec`\'s ' + + '`ComponentPropsMap[\'object-form\']` row declares them: write `{ "type": "object-form", "properties": ' + + '{ "objectName": "…", "mode": "create" } }` (objectui#10859). The spec\'s own page component refuses a prop ' + + 'written on the node as mis-layered (ADR-0089 D3a), so this face and `os validate` agree. Moving it changes ' + + 'nothing at render time: `SchemaRenderer` hoists every `properties` key onto the node before `ObjectForm` ' + + 'reads it.'; + +/** A member of the spec's `object-form` row — a TYPE position, so no boundary crossing. */ +type ObjectFormRowKey = keyof z.input; + +/** + * One by-name refusal per member of the spec's `object-form` row, keyed by the + * row's own key set — read off the row, not transcribed, so a member the spec + * adds is refused flat the day it lands and the list cannot fall behind. + */ +const OBJECT_FORM_FLAT_PROP_REFUSALS = Object.fromEntries( + Object.keys(stripImportedDefaults(SpecObjectFormPropsSchema).shape).map((key) => [ + key, + aliasKeyRefusal(key, `properties.${key}`, 'this `object-form` node', OBJECT_FORM_FLAT_PROP), + ]), +) as { [K in ObjectFormRowKey]-?: ReturnType }; + +/** + * `object-form` — `ComponentPropsMap['object-form']`, plus the node's + * `dataSource` binding and the five handler keys its renderer reads off the + * node (objectui#10859, batch 4). + * + * ## Why the arm moved to the bag + * + * The spec's row is the published declaration of an authored `object-form` + * node's props, and the spec's strict `PageComponentSchema` refuses a prop + * written on the node itself as mis-layered (ADR-0089 D3a). This union used + * to arm the node with the flat `ObjectFormSchema` above, so `objectui + * validate` refused the spec-shaped document — the objectstack showcase's + * wizard page among them — and accepted the flat one `os validate` refuses. + * The seat's answer at PR objectui#11248's ACCEPT, inherited from + * objectui#10872's triage answer A, is this arm: "The `properties` bag is the + * contract". + * + * So it is built exactly as `ObjectMetricBlockSchema` above is: `BaseSchema` + + * the `type` literal + `properties`, which IS the row, by reference through + * the objectui#8317 import boundary. The row carries no spec default, so the + * boundary hands back the export itself. + * + * ## The flat spelling is refused by name + * + * Every member of the row written FLAT on the node is refused on both faces, + * with a message naming its bag member (`OBJECT_FORM_FLAT_PROP_REFUSALS` + * above). Unlike the blocks above, the flat spelling is the one this node was + * taught in, so an author meets the remedy rather than a bare + * `unrecognized_keys`. `description` is also a `BaseSchema` key; here the + * refusal overrides it, because the form's description is the row's member. + * A key the row does not declare (`buttons`, `defaults`, `subforms`, `groups`) + * is left as every arm leaves an undeclared key: unjudged by the tolerant face, + * refused by the strict one. + * + * ## What did not move + * + * The TypeScript `ObjectFormSchema` and its zod mirror above stay published: + * they are the node as `ObjectForm` reads it after the hoist, and as code + * composes it. A runtime composer that builds a flat `object-form` node keeps + * working, because `SchemaRenderer` reads both spellings and no composed node + * passes through `safeValidateSchema` (`SchemaRenderer` runs `@object-ui/core`'s + * structural `validateSchema`). + * + * ## The handler keys, `dataSource` and the content channels + * + * `ObjectForm` reads `onSuccess`, `onCancel`, `onError`, `onOpenChange` and + * `onStepChange` off the node it is handed, so each is declared here exactly as + * the flat mirror declares it: an objectui#6124 RUNTIME SLOT, refused by name. + * The registration is `elementDataSourceBlock`-wrapped, so `dataSource` is the + * spec's `ElementDataSourceSchema` by reference, as on the arms above. Neither + * content channel is read, so both are refused with the objectui#9256 string + * the flat mirror uses. + */ +export const ObjectFormBlockSchema = BaseSchema.extend({ + type: z.literal('object-form'), + ...NODE_ENVELOPE, + properties: propsBag('object-form', stripImportedDefaults(SpecObjectFormPropsSchema)), + dataSource: stripImportedDefaults(SpecElementDataSourceSchema) + .optional() + .describe(ELEMENT_DATA_SOURCE_BINDING_DESCRIPTION), + ...OBJECT_FORM_FLAT_PROP_REFUSALS, + onCancel: handlerKeyRefusal('onCancel', 'runtime-slot', 'Cancel handler'), + onError: handlerKeyRefusal('onError', 'runtime-slot', 'Submit error handler'), + onOpenChange: handlerKeyRefusal('onOpenChange', 'runtime-slot', 'Modal/drawer open-state handler'), + onStepChange: handlerKeyRefusal('onStepChange', 'runtime-slot', 'Wizard step change handler'), + onSuccess: handlerKeyRefusal('onSuccess', 'runtime-slot', 'Submit success handler'), + // objectui#9256: the renderer reads NEITHER content channel, so both are refused by name, each + // kept a MEMBER. + body: retirementTombstone(OBJECT_FORM_NEITHER_CHANNEL), + children: retirementTombstone(OBJECT_FORM_NEITHER_CHANNEL), +}); + /** * The public blocks above, as one arm of `AnyComponentSchema` - * (objectui#10859, batches 2 and 3). + * (objectui#10859, batches 2, 3 and 4). * * A union of its own rather than more members of `ObjectQLComponentSchema`, * deliberately: that union mirrors the TypeScript union in `../objectql.ts` - * member for member, and none of these blocks has a declaration there — their - * declaration is the spec row each `properties` member reads. + * member for member, and the authored node of each block here is declared by + * the spec row its `properties` member reads, not there. `object-form` is the + * one with a member there: its TypeScript twin is the node as `ObjectForm` + * reads it after the hoist, not the authored document (batch 4). * * Each arm also spreads `NODE_ENVELOPE` from `./public-blocks.zod.ts`, * the node-level `responsiveStyles` every public block declares by reference to @@ -3609,4 +3730,5 @@ export const ObjectQLPublicBlockComponentSchema = z.discriminatedUnion('type', [ ObjectMetricBlockSchema, ObjectMasterDetailFormBlockSchema, ObjectTimelineBlockSchema, + ObjectFormBlockSchema, ]); From b93f389b39befc6f24059a67ba801148d9a33c43 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 22:38:27 +0000 Subject: [PATCH 2/5] docs(types): re-declare the TypeScript ObjectFormSchema as the post-hoist reading; point the form `mode` refusal at the bag (objectui#10859, batch 4) The TypeScript twin stays published with its members unchanged: it is the node as ObjectForm reads it after SchemaRenderer hoists `properties`, and as code composes it. The zod README lists ObjectFormBlockSchema, and the `form` node's `mode` refusal teaches the bag spelling of an object-form node. Claude-Session: https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ Co-authored-by: Claude --- packages/types/src/objectql.ts | 23 +++++++++++++++++++++-- packages/types/src/zod/README.md | 9 +++++---- packages/types/src/zod/form.zod.ts | 5 +++-- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 5430131958..40204f0ce1 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -1560,6 +1560,22 @@ export type SubmitBehavior = * #2545; until then they are sanctioned, documented extensions. * - **[runtime-only]** — non-serializable runtime concerns that never belong * in view metadata (`mode`, `recordId`, `open`, callbacks, …). + * + * ## What this type describes (objectui#10859, batch 4) + * + * The `object-form` node as `ObjectForm` READS it: after `SchemaRenderer` has + * hoisted the node's `properties` bag onto it, or as code composes it + * (`ObjectView`, `RecordFormPage`, `ScreenView`, the plugin-form variants, a + * host mounting ``). That is why its identity keys are + * required here and why it carries host callbacks. + * + * It is NOT the shape of an AUTHORED `object-form` document. An authored node + * takes its props in the spec's `properties` bag, whose members are + * `@objectstack/spec`'s `ComponentPropsMap['object-form']` row + * (`ObjectFormProps`): `{ type: 'object-form', properties: { objectName, mode, + * … } }`. `ObjectFormBlockSchema` (`./zod/objectql.zod.ts`) judges it, and + * refuses a prop written flat on the node by name. `SchemaRenderer` reads both + * spellings, so a node built in code keeps its flat keys. */ export interface ObjectFormSchema extends BaseSchema { type: 'object-form'; @@ -5692,8 +5708,11 @@ export interface ObjectDataTableSchema extends BaseSchema { * `ObjectGallerySchema` and `ObjectDataTableSchema` joined in objectui#7363. * PR #7355 (objectui#6576) minted both beside the other members and left this * union alone, so `Extract< ObjectQLComponentSchema, { type: 'object-gallery' } >` - * was `never` and — through the zod twin in `zod/objectql.zod.ts`, which carries - * the same twelve members — `AnyComponentSchema` had no arm for either node. + * was `never` and — through the zod twin in `zod/objectql.zod.ts`, which then + * carried the same twelve members — `AnyComponentSchema` had no arm for either + * node. The zod twin carries eleven since objectui#10859 batch 4: the authored + * `object-form` node is armed from its spec row by `ObjectFormBlockSchema`, + * and `ObjectFormSchema` here is the node as `ObjectForm` reads it. */ export type ObjectQLComponentSchema = | ObjectGridSchema diff --git a/packages/types/src/zod/README.md b/packages/types/src/zod/README.md index 236bf2a164..f687fd6565 100644 --- a/packages/types/src/zod/README.md +++ b/packages/types/src/zod/README.md @@ -231,16 +231,17 @@ function validateComponent(config: unknown) { - `KanbanSchema`, `CalendarViewSchema` - `FilterBuilderSchema`, `CarouselSchema`, `ChatbotSchema` -### ObjectQL Public Blocks (3) -The ADR-0080 public blocks `object-metric`, `object-master-detail-form` and `object-timeline`: each arm's `properties` is the block's `@objectstack/spec` `ComponentPropsMap` row, by reference. Each arm also declares the node's `dataSource` binding, the spec's `ElementDataSourceSchema`, which all three registrations read through `ElementDataSourceGate`. None of the three renderers reads a content channel, so every arm refuses `children` and `body` by name (objectui#9256). -- `ObjectMetricBlockSchema`, `ObjectMasterDetailFormBlockSchema`, `ObjectTimelineBlockSchema` +### ObjectQL Public Blocks (4) +The ADR-0080 public blocks `object-metric`, `object-master-detail-form`, `object-timeline` and `object-form`: each arm's `properties` is the block's `@objectstack/spec` `ComponentPropsMap` row, by reference. Each arm also declares the node's `dataSource` binding, the spec's `ElementDataSourceSchema`, which all four registrations read through `ElementDataSourceGate`. None of the four renderers reads a content channel, so every arm refuses `children` and `body` by name (objectui#9256). +`ObjectFormBlockSchema` is the authored `object-form` node (objectui#10859 batch 4): its props go in the `properties` bag, and each member of the row written flat on the node is refused by name, with a message naming `properties.KEY`. `ObjectFormSchema`, the flat mirror of the TypeScript `ObjectFormSchema`, stays exported as the node as `ObjectForm` reads it after `SchemaRenderer` hoists the bag; it is no longer an arm of `AnyComponentSchema`. +- `ObjectMetricBlockSchema`, `ObjectMasterDetailFormBlockSchema`, `ObjectTimelineBlockSchema`, `ObjectFormBlockSchema` ### AI Components (3) - `AIFormAssistSchema`, `AIRecommendationsSchema`, `NLQuerySchema` ### ADR-0080 Public Blocks (27) Each arm's `properties` is the block's `@objectstack/spec` `ComponentPropsMap` row, by reference. `ElementNumberBlockSchema` also declares the spec's `dataSource` binding, and mirrors the spec gate's one waiver: its bag may omit `object` when `dataSource.object` names the object. -Every arm here, and each of the three ObjectQL public blocks above, declares the node-level `responsiveStyles` (ADR-0065 per-breakpoint style maps, which `SchemaRenderer` compiles to scoped CSS) as the spec's `ResponsiveStylesSchema`, by reference, from one shared fragment, `NODE_ENVELOPE` (objectui#10872 batch 8; named `PUBLIC_BLOCK_ENVELOPE` until batch 9). The other node-level keys the spec's `PageComponentSchema` declares (`events`, `aria`, `visibility`, `responsive`, and `dataSource` outside the arms that read it) stay undeclared, so the strict face refuses them by name. +Every arm here, and each of the four ObjectQL public blocks above, declares the node-level `responsiveStyles` (ADR-0065 per-breakpoint style maps, which `SchemaRenderer` compiles to scoped CSS) as the spec's `ResponsiveStylesSchema`, by reference, from one shared fragment, `NODE_ENVELOPE` (objectui#10872 batch 8; named `PUBLIC_BLOCK_ENVELOPE` until batch 9). The other node-level keys the spec's `PageComponentSchema` declares (`events`, `aria`, `visibility`, `responsive`, and `dataSource` outside the arms that read it) stay undeclared, so the strict face refuses them by name. Outside this set, `FlexSchema`, `ObjectGridSchema` and `ObjectChartSchema` spread the same fragment (objectui#10872 batch 9), and their TypeScript twins declare the key as the spec's `ResponsiveStyles`: they are the arms outside this set that objectui#10872's producer reading found `responsiveStyles` written on (a dated reading, not re-derived here). `BaseSchema` and every other arm leave it undeclared, so the strict face refuses it there by name. Every arm except the four `page:` containers (`page:card`, `page:section`, `page:footer`, `page:sidebar`) refuses `children` and `body` by name, because its renderer reads neither content channel (objectui#9256; the last six below since objectui#10872 batch 5). The four containers do render a child list, and its home is `properties.children`, the member their spec row declares; they refuse the node-level `children` and `body` with a message naming it, as `@objectstack/spec`'s `PageComponentSchema` refuses a node-level `children` (objectui#10872 batch 6). Two carve-outs: `record:alert` refuses `children` with its own message, while its `body` is the message text and belongs in `properties`, so a flat `body` is refused with a pointer to `properties.body` rather than to `children` (objectui#10872); and `page:tabs` / `page:accordion` refuse the node's own `children` while each item's `children` in `items` stays live. - `PageHeaderBlockSchema`, `PageTabsBlockSchema`, `PageCardBlockSchema`, `PageAccordionBlockSchema` diff --git a/packages/types/src/zod/form.zod.ts b/packages/types/src/zod/form.zod.ts index 1301034f97..689d95b331 100644 --- a/packages/types/src/zod/form.zod.ts +++ b/packages/types/src/zod/form.zod.ts @@ -1107,8 +1107,9 @@ export const FormSchema = BaseSchema.extend({ 'REFUSED (objectui#10286, ADR-0049; objectui#7759 ruling D1-(ii)) — the `form` node reads no `mode`: ' + 'the key is not in `@objectstack/spec`, the `form` renderer never reads it, and every spelling ' + 'rendered the same form — no render-time error or warning; only the parser tier\'s `unknown-prop` warning noticed it. The create / edit / view mode belongs to the ' - + '`object-form` node (`ObjectFormSchema.mode`): author `{ "type": "object-form", "objectName": …, ' - + '"mode": "edit", "recordId": … }` for it. To make this form non-editable, set `disabled`.', + + '`object-form` node, in its `properties` bag (`ComponentPropsMap[\'object-form\'].mode`): author ' + + '`{ "type": "object-form", "properties": { "objectName": …, "mode": "edit", "recordId": … } }` for it. ' + + 'To make this form non-editable, set `disabled`.', ), actions: z.array(z.any()).optional().describe('Custom actions'), onSubmit: handlerKeyRefusal('onSubmit', 'runtime-slot', 'Submit handler'), From 8e741e3fe4382b8938f7aec49a87c786fe1d440a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 22:47:04 +0000 Subject: [PATCH 3/5] docs: rewrite objectui's authored object-form producers to the properties bag; render pin and changesets (objectui#10859, batch 4) The root README, the schema reference, the CRUD guide, the ObjectOS migration fragment, the plugin-form page and the plugin-form README author object-form in the bag. The guide's render test reads the snippet's bag. A plugin-form render pin draws the showcase wizard node through the real registry, beside the flat spelling code composers still build. Claude-Session: https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ Co-authored-by: Claude --- .../10859-object-form-properties-bag.md | 26 +++++ ...0859-plugin-form-readme-object-form-bag.md | 7 ++ README.md | 15 ++- content/docs/api/schema-reference.md | 50 ++++---- content/docs/guide/building-crud-app.md | 8 +- content/docs/guide/objectos-integration.mdx | 19 +-- content/docs/plugins/plugin-form.mdx | 37 +++--- packages/plugin-form/README.md | 66 ++++++----- .../__tests__/guideCrudAppRenders.test.tsx | 12 +- .../objectFormPropertiesBag-10859.test.tsx | 110 ++++++++++++++++++ 10 files changed, 260 insertions(+), 90 deletions(-) create mode 100644 .changeset/10859-object-form-properties-bag.md create mode 100644 .changeset/10859-plugin-form-readme-object-form-bag.md create mode 100644 packages/plugin-form/src/__tests__/objectFormPropertiesBag-10859.test.tsx diff --git a/.changeset/10859-object-form-properties-bag.md b/.changeset/10859-object-form-properties-bag.md new file mode 100644 index 0000000000..96b790ce08 --- /dev/null +++ b/.changeset/10859-object-form-properties-bag.md @@ -0,0 +1,26 @@ +--- +'@object-ui/types': minor +--- + +feat(types): an authored `object-form` takes its props in the spec's `properties` bag; the flat spelling is refused by name (objectui#10859, batch 4) + +**BREAKING (authoring):** an `object-form` node that writes its props on the node itself is now refused by `safeValidateSchema`, `validateSchema` and the strict authoring face, and so by `objectui validate`. Move each prop into the node's `properties` bag: + +```json +{ "type": "object-form", "properties": { "objectName": "account", "mode": "create", "fields": ["name", "rating"] } } +``` + +Nothing changes at render time: `SchemaRenderer` hoists every `properties` key onto the node before `ObjectForm` runs, so the bag node draws what the flat one drew, and a node built in code keeps working with its flat keys. + +**Clause-②: yes** — the accept set narrows (a flat `object-form` node that parsed is refused) and widens (a spec-shaped bag node that was refused parses). Released as `minor` under the objectui narrowing rule, with this banner. + +**Why.** `@objectstack/spec`'s `ComponentPropsMap['object-form']` row is the published declaration of an authored `object-form` node's props, and the spec's own strict `PageComponentSchema` refuses a prop written on the node as mis-layered (ADR-0089 D3a). The arm here was the flat mirror of the TypeScript `ObjectFormSchema`, so the two validators disagreed both ways: the objectstack showcase's wizard page, written in the bag, was refused (`objectName` `invalid_type`, `mode` `invalid_value`), and the flat node `os validate` refuses was accepted. + +**What changed, in observable terms.** + +- `@object-ui/types/zod` exports `ObjectFormBlockSchema`, built the way `ObjectMetricBlockSchema` is: `BaseSchema`, the `object-form` literal, and `properties`, which is the spec row by reference, so the row's members, value types and strictness judge the bag. It declares the node's `dataSource` binding (the spec's `ElementDataSourceSchema`), refuses the five handler keys `ObjectForm` reads off the node as runtime slots (objectui#6124), and refuses `body` / `children` (objectui#9256). +- Each member of the row written flat on the node is refused on both faces, with a message that names its bag member (`Did you mean \`mode\` → \`properties.mode\`?`). The refusal set is read off the row, so it follows the spec. +- `ObjectFormBlockSchema` reaches `AnyComponentSchema` through `ObjectQLPublicBlockComponentSchema`. `ObjectFormSchema` leaves `ObjectQLComponentSchema`. +- Inside the bag the spec row decides, and it differs from the flat mirror in a few places. `objectName` and `mode` are optional (a `dataSource` binding can name the object). `layout` takes `vertical` or `horizontal` only (the spec retired `inline` and `grid`; set `columns` for several columns). `recordId` takes a string or a number. `buttons`, `defaults`, `subforms` and `groups` are form-VIEW keys, not members of the row, so the bag refuses them; `buttons`, `defaults` and `subforms` stay authorable in an `object-view`'s `form` slot. The row types `sections` entries as `unknown`, so a section's own keys are not judged inside the bag. + +**What did not move.** The TypeScript `ObjectFormSchema` and its zod mirror `ObjectFormSchema` stay published and unchanged in shape. They are the node as `ObjectForm` reads it after the hoist, and as code composes it (`ObjectView`, `RecordFormPage`, `ScreenView`, a host mounting ``). The object-view `form` slot is still built from the mirror. diff --git a/.changeset/10859-plugin-form-readme-object-form-bag.md b/.changeset/10859-plugin-form-readme-object-form-bag.md new file mode 100644 index 0000000000..1286951a08 --- /dev/null +++ b/.changeset/10859-plugin-form-readme-object-form-bag.md @@ -0,0 +1,7 @@ +--- +'@object-ui/plugin-form': patch +--- + +docs(plugin-form): the README's authored `object-form` examples write their props in the `properties` bag (objectui#10859, batch 4) + +The multi-step and metadata-route examples now author `{ "type": "object-form", "properties": { … } }`, the spelling `@objectstack/spec`'s `ComponentPropsMap['object-form']` row declares and `objectui validate` now requires. The example that mounts `WizardForm` directly keeps the flat `schema` prop: a component mounted without `SchemaRenderer` receives the node as the renderer reads it after the hoist. No runtime change. diff --git a/README.md b/README.md index e35c4a93e4..060d9777ec 100644 --- a/README.md +++ b/README.md @@ -326,19 +326,22 @@ declare class MyCustomDataSource implements DataSource { **Stop writing repetitive UI code.** A form is a schema, not a component: ```tsx -import type { ObjectFormSchema } from '@object-ui/types'; +import type { BaseSchema } from '@object-ui/types'; // Traditional React: useState, validation, handlers, JSX — per form function UserForm() { // ... } -// Object UI: declare it -const schema: ObjectFormSchema = { +// Object UI: declare it — the block's props go in its `properties` bag, +// as `@objectstack/spec` declares them +const schema: BaseSchema = { type: "object-form", - objectName: "user", - mode: "create", - fields: ["name", "email", "role"] + properties: { + objectName: "user", + mode: "create", + fields: ["name", "email", "role"] + } } ``` diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index 681ee67b40..93a27e316f 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -815,30 +815,34 @@ presence enables paging. A smart form that auto-generates fields from an ObjectQL object. Supports simple, tabbed, wizard, split, drawer, and modal layouts. +An authored `object-form` node takes its props in its `properties` bag, whose members are `@objectstack/spec`'s `ComponentPropsMap['object-form']` row. `objectui validate` judges the bag against that row and refuses a prop written flat on the node by name, naming its bag member, as the spec's own page component does (objectui#10859). `SchemaRenderer` hoists the bag onto the node before `ObjectForm` runs, so `ObjectFormSchema` is the node as the renderer reads it, and the table below lists its members. + ```json { "type": "object-form", - "objectName": "Contact", - "mode": "create", - "formType": "tabbed", - "title": "New Contact", - "layout": "vertical", - "columns": 2, - "fields": ["firstName", "lastName", "email", "phone", "company"], - "sections": [ - { - "label": "Basic Info", - "fields": ["firstName", "lastName", "email"] - }, - { - "label": "Details", - "fields": ["phone", "company", "address"] - } - ], - "showSubmit": true, - "submitText": "Create Contact", - "showCancel": true, - "cancelText": "Cancel" + "properties": { + "objectName": "Contact", + "mode": "create", + "formType": "tabbed", + "title": "New Contact", + "layout": "vertical", + "columns": 2, + "fields": ["firstName", "lastName", "email", "phone", "company"], + "sections": [ + { + "label": "Basic Info", + "fields": ["firstName", "lastName", "email"] + }, + { + "label": "Details", + "fields": ["phone", "company", "address"] + } + ], + "showSubmit": true, + "submitText": "Create Contact", + "showCancel": true, + "cancelText": "Cancel" + } } ``` @@ -851,8 +855,8 @@ A smart form that auto-generates fields from an ObjectQL object. Supports simple | `fields` | `string[]` | Field API names to include (auto-resolved from object metadata). | | `customFields` | `FormField[]` | Manually defined fields that override auto-generated ones. | | `sections` | `ObjectFormSection[]` | Field sections (simple groups, tabs, or wizard steps depending on `formType`). Spec-aligned key. | -| `groups` | `array` | **Deprecated.** Legacy alias of `sections` (spec defines `groups` as an alias); normalized into `sections` when `sections` is absent. Legacy shape: `title`→`label`, `defaultCollapsed`→`collapsed`. | -| `layout` | `string` | Label layout: `"vertical"`, `"horizontal"`, `"inline"`, `"grid"`. | +| `groups` | `array` | **Deprecated.** Legacy alias of `sections` (spec defines `groups` as an alias); normalized into `sections` when `sections` is absent. Legacy shape: `title`→`label`, `defaultCollapsed`→`collapsed`. Not a member of the spec's `object-form` row, so an authored bag refuses it; write `sections`. | +| `layout` | `string` | Label layout: `"vertical"`, `"horizontal"`. The spec row retired `"inline"` and `"grid"` (both rendered as `"vertical"`), so an authored bag refuses them; for several columns set `columns`. | | `columns` | `number` | Number of form columns. | | `submitText` / `cancelText` | `string \| I18nLabel` | Button labels. | | `title` / `description` | `string \| I18nLabel` | Heading and subtitle of the drawer and modal presentations. | diff --git a/content/docs/guide/building-crud-app.md b/content/docs/guide/building-crud-app.md index f390aef0ef..a7089ec717 100644 --- a/content/docs/guide/building-crud-app.md +++ b/content/docs/guide/building-crud-app.md @@ -286,9 +286,11 @@ carries a data source of its own: setShowForm(false)} onCancel={() => setShowForm(false)} diff --git a/content/docs/guide/objectos-integration.mdx b/content/docs/guide/objectos-integration.mdx index 6c160dbbc7..f1bc5d485b 100644 --- a/content/docs/guide/objectos-integration.mdx +++ b/content/docs/guide/objectos-integration.mdx @@ -539,19 +539,20 @@ unsubscribe(); ### From Appsmith -{/* doc-snippet: fragment — a SHAPE excerpt showing the ObjectUI half of a side-by-side migration comparison; `dataSource` continues the "Using ObjectQL for Queries" block above, and a bare object literal at statement position parses as a block with labels (measured: TS1005 x4) */} +{/* doc-snippet: fragment — a SHAPE excerpt showing the ObjectUI half of a side-by-side migration comparison; a bare object literal at statement position parses as a block with labels */} ```typescript -// Appsmith form → ObjectUI form +// Appsmith form → ObjectUI form. The props go in the node's `properties` +// bag (the spec's `ComponentPropsMap['object-form']` row). The form saves +// through the adapter `SchemaRendererProvider` supplies, so no submit +// handler is written here. { type: 'object-form', - objectName: 'contact', - dataSource, - mode: 'create', - fieldNames: ['name', 'email', 'phone', 'company'], - onSubmit: async (data) => { - await dataSource.create('contact', data); - } + properties: { + objectName: 'contact', + mode: 'create', + fields: ['name', 'email', 'phone', 'company'], + }, } ``` diff --git a/content/docs/plugins/plugin-form.mdx b/content/docs/plugins/plugin-form.mdx index 98f4697dfe..10cfba44fc 100644 --- a/content/docs/plugins/plugin-form.mdx +++ b/content/docs/plugins/plugin-form.mdx @@ -531,26 +531,31 @@ no `multi-step-form` type. The registered type is `object-form`, and `formType: turns its `sections` into steps; `WizardFormSchema` (exported by `@object-ui/plugin-form`) declares the shape. Because the wizard resolves its fields from the object's own metadata, a section lists field **names** rather than field definitions. +Like every `object-form` prop, they go in the node's `properties` bag — the members of +`@objectstack/spec`'s `ComponentPropsMap['object-form']` row. `objectui validate` refuses a +prop written flat on the node by name, as the spec's own page component does. ```json { "type": "object-form", - "objectName": "contact", - "mode": "create", - "formType": "wizard", - "showStepIndicator": true, - "sections": [ - { - "name": "personal", - "label": "Personal Info", - "fields": ["first_name", "last_name"] - }, - { - "name": "contact_details", - "label": "Contact Info", - "fields": ["email", "phone"] - } - ] + "properties": { + "objectName": "contact", + "mode": "create", + "formType": "wizard", + "showStepIndicator": true, + "sections": [ + { + "name": "personal", + "label": "Personal Info", + "fields": ["first_name", "last_name"] + }, + { + "name": "contact_details", + "label": "Contact Info", + "fields": ["email", "phone"] + } + ] + } } ``` diff --git a/packages/plugin-form/README.md b/packages/plugin-form/README.md index a704982fc4..104d185f6a 100644 --- a/packages/plugin-form/README.md +++ b/packages/plugin-form/README.md @@ -719,27 +719,31 @@ steps are its `sections` — one step per section. There is no unknown-component placeholder and the fields inside `steps` are never read. ```typescript -import type { ObjectFormSchema } from '@object-ui/types'; +import type { BaseSchema } from '@object-ui/types'; -const schema: ObjectFormSchema = { +// The props go in the node's `properties` bag — the members of +// `@objectstack/spec`'s `ComponentPropsMap['object-form']` row. +const schema: BaseSchema = { type: 'object-form', - objectName: 'contacts', // required - mode: 'create', // required - formType: 'wizard', // routes to WizardForm — needs at least one section - sections: [ - { - name: 'personal', - label: 'Personal Info', - fields: ['first_name', 'last_name'] // field NAMES, resolved from the object schema - }, - { - name: 'contact', - label: 'Contact Info', - fields: ['email', 'phone'] - } - ], - allowSkip: false, // see "Wizard steps and allowSkip" above - showStepIndicator: true + properties: { + objectName: 'contacts', // the object whose fields the steps list + mode: 'create', + formType: 'wizard', // routes to WizardForm — needs at least one section + sections: [ + { + name: 'personal', + label: 'Personal Info', + fields: ['first_name', 'last_name'] // field NAMES, resolved from the object schema + }, + { + name: 'contact', + label: 'Contact Info', + fields: ['email', 'phone'] + } + ], + allowSkip: false, // see "Wizard steps and allowSkip" above + showStepIndicator: true + } }; ``` @@ -951,9 +955,13 @@ registrations. ### The metadata route — `object-form` -This is the route that actually reads and writes a record. `ObjectFormSchema` -names its object with **`objectName`** and its intent with **`mode`**, and both -are **required** (`packages/types/src/objectql.ts`); the adapter arrives on the +This is the route that actually reads and writes a record. An `object-form` +node names its object with **`objectName`** and its intent with **`mode`**, both +in its `properties` bag, whose members are `@objectstack/spec`'s +`ComponentPropsMap['object-form']` row; `objectui validate` refuses a prop +written flat on the node by name. `SchemaRenderer` hoists the bag onto the node, +and `ObjectFormSchema` (`packages/types/src/objectql.ts`) is the node as +`ObjectForm` then reads it, with both keys required. The adapter arrives on the context, which `ObjectFormRenderer` reads at `src/index.tsx` before handing `ObjectForm` its `dataSource` prop. @@ -961,19 +969,21 @@ context, which `ObjectFormRenderer` reads at `src/index.tsx` before handing import { SchemaRendererProvider, SchemaRenderer } from '@object-ui/react'; import { createObjectStackAdapter } from '@object-ui/data-objectstack'; import '@object-ui/plugin-form'; -import type { ObjectFormSchema } from '@object-ui/types'; +import type { BaseSchema } from '@object-ui/types'; const dataSource = createObjectStackAdapter({ baseUrl: 'https://api.example.com', token: 'your-auth-token', }); -const schema: ObjectFormSchema = { +const schema: BaseSchema = { type: 'object-form', - objectName: 'users', - mode: 'create', - fields: ['name', 'email'], - submitText: 'Create user', + properties: { + objectName: 'users', + mode: 'create', + fields: ['name', 'email'], + submitText: 'Create user', + }, }; export const App = () => ( diff --git a/packages/plugin-form/src/__tests__/guideCrudAppRenders.test.tsx b/packages/plugin-form/src/__tests__/guideCrudAppRenders.test.tsx index 6b04645286..5878418c00 100644 --- a/packages/plugin-form/src/__tests__/guideCrudAppRenders.test.tsx +++ b/packages/plugin-form/src/__tests__/guideCrudAppRenders.test.tsx @@ -110,8 +110,10 @@ describe('guide/building-crud-app.md — the `object-form` snippet actually rend }); it('names the object with `objectName` — `object` fetches nothing', async () => { - const asItWas = { ...SNIPPET, object: SNIPPET.objectName }; - delete asItWas.objectName; + // The guide writes the props in the node's `properties` bag, the spelling + // the spec declares (objectui#10859 batch 4); `SchemaRenderer` hoists it. + const { objectName, ...rest } = SNIPPET.properties; + const asItWas = { ...SNIPPET, properties: { ...rest, object: objectName } }; const adapter = makeAdapter(); render( @@ -129,10 +131,10 @@ describe('guide/building-crud-app.md — the `object-form` snippet actually rend // The correction, pinned. `detail-view` wants `resourceId`; this block does // not, and a sweep across the guide would have swapped a working key for a // dead one. - expect(SNIPPET.recordId).toBe('42'); + expect(SNIPPET.properties.recordId).toBe('42'); - const swept = { ...SNIPPET, resourceId: SNIPPET.recordId }; - delete swept.recordId; + const { recordId, ...rest } = SNIPPET.properties; + const swept = { ...SNIPPET, properties: { ...rest, resourceId: recordId } }; const adapter = makeAdapter(); render( diff --git a/packages/plugin-form/src/__tests__/objectFormPropertiesBag-10859.test.tsx b/packages/plugin-form/src/__tests__/objectFormPropertiesBag-10859.test.tsx new file mode 100644 index 0000000000..0ee0525f53 --- /dev/null +++ b/packages/plugin-form/src/__tests__/objectFormPropertiesBag-10859.test.tsx @@ -0,0 +1,110 @@ +/** + * 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. + * + * objectui#10859 batch 4 — an `object-form` node authored in the spec's + * `properties` bag RENDERS, through the real registry. + * + * `@object-ui/types` now arms an authored `object-form` from the spec's + * `ComponentPropsMap['object-form']` row (`ObjectFormBlockSchema`) and refuses + * the flat spelling by name. That is only sound if nothing is lost at render + * time: `SchemaRenderer` hoists every `properties` key onto the node before the + * registered renderer runs, so `ObjectForm` reads the bag node as it reads the + * flat one. This file draws the objectstack showcase's wizard node (the one live + * spec-shaped producer) and holds the flat spelling beside it, because code + * still composes flat nodes (`ObjectView`, `RecordFormPage`, `ScreenView`, …) + * and those must keep rendering. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, waitFor, cleanup } from '@testing-library/react'; +import React from 'react'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +// Module-scope side-effect imports: the registry must hold `object-form` and the +// field widgets it renders into before the first render (AGENTS.md §测试纪律). +import '@object-ui/components'; +import '@object-ui/fields'; +import '../index'; + +const PROJECT_SCHEMA = { + name: 'showcase_project', + label: 'Project', + fields: { + name: { name: 'name', type: 'text', label: 'Project Name' }, + health: { name: 'health', type: 'text', label: 'Health' }, + budget: { name: 'budget', type: 'number', label: 'Budget' }, + }, +}; + +/** The showcase wizard's props (objectstack `NewProjectWizardPage`), trimmed to this object's fields. */ +const WIZARD_PROPS = { + objectName: 'showcase_project', + mode: 'create', + formType: 'wizard', + showStepIndicator: true, + title: 'Create a Project', + sections: [ + { label: 'Basics', fields: ['name'] }, + { label: 'Health', fields: ['health'] }, + { label: 'Budget & Schedule', fields: ['budget'] }, + ], + submitBehavior: { kind: 'thank-you', title: 'Project created' }, +}; + +function makeAdapter() { + return { + find: vi.fn().mockResolvedValue({ data: [], total: 0 }), + findOne: vi.fn().mockResolvedValue(null), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue(PROJECT_SCHEMA), + }; +} + +const draw = (schema: Record, adapter: ReturnType) => + render( + + + , + ); + +/** What a wizard shows once its object resolved: the step labels and the first step's field. */ +async function drawnWizard(schema: Record) { + const adapter = makeAdapter(); + const { container } = draw(schema, adapter); + await waitFor(() => expect(adapter.getObjectSchema).toHaveBeenCalledWith('showcase_project')); + await waitFor(() => expect(container.querySelector('input[name="name"]')).not.toBeNull()); + const text = container.textContent ?? ''; + return { + steps: ['Basics', 'Health', 'Budget & Schedule'].filter((label) => text.includes(label)), + firstStepField: text.includes('Project Name'), + laterStepFieldMounted: container.querySelector('input[name="budget"]') !== null, + }; +} + +beforeEach(() => cleanup()); + +describe('object-form in the `properties` bag renders through the registry (objectui#10859 batch 4)', () => { + it('the showcase wizard node draws its steps over the named object', async () => { + const bag = await drawnWizard({ type: 'object-form', properties: WIZARD_PROPS }); + expect(bag).toEqual({ steps: ['Basics', 'Health', 'Budget & Schedule'], firstStepField: true, laterStepFieldMounted: false }); + }); + + it('the flat spelling a code composer builds draws the same wizard (the hoist reads both)', async () => { + const bag = await drawnWizard({ type: 'object-form', properties: WIZARD_PROPS }); + cleanup(); + const flat = await drawnWizard({ type: 'object-form', ...WIZARD_PROPS }); + expect(flat).toEqual(bag); + }); + + it('control: the object the bag names is the one fetched, so the rows above measure the bag', async () => { + const adapter = makeAdapter(); + draw({ type: 'object-form', properties: { ...WIZARD_PROPS, objectName: 'other_object_10859' } }, adapter); + await waitFor(() => expect(adapter.getObjectSchema).toHaveBeenCalledWith('other_object_10859')); + expect(adapter.getObjectSchema).not.toHaveBeenCalledWith('showcase_project'); + }); +}); From 7f58adf65f5594824034156c92f4010872406dc3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 23:18:03 +0000 Subject: [PATCH 4/5] docs(changeset): name how the same release's objectui#6152 members are authored after the bag move (objectui#10859, batch 4) Claude-Session: https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ Co-authored-by: Claude --- .changeset/10859-object-form-properties-bag.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/10859-object-form-properties-bag.md b/.changeset/10859-object-form-properties-bag.md index 96b790ce08..c40251c019 100644 --- a/.changeset/10859-object-form-properties-bag.md +++ b/.changeset/10859-object-form-properties-bag.md @@ -23,4 +23,6 @@ Nothing changes at render time: `SchemaRenderer` hoists every `properties` key o - `ObjectFormBlockSchema` reaches `AnyComponentSchema` through `ObjectQLPublicBlockComponentSchema`. `ObjectFormSchema` leaves `ObjectQLComponentSchema`. - Inside the bag the spec row decides, and it differs from the flat mirror in a few places. `objectName` and `mode` are optional (a `dataSource` binding can name the object). `layout` takes `vertical` or `horizontal` only (the spec retired `inline` and `grid`; set `columns` for several columns). `recordId` takes a string or a number. `buttons`, `defaults`, `subforms` and `groups` are form-VIEW keys, not members of the row, so the bag refuses them; `buttons`, `defaults` and `subforms` stay authorable in an `object-view`'s `form` slot. The row types `sections` entries as `unknown`, so a section's own keys are not judged inside the bag. +**The same release's `.changeset/6152-object-form-unmirrored-members.md`** declared members on the flat mirror so that the strict face stopped refusing them on an authored node. On an authored node those members are now written in the bag, where the spec row judges them: every one of them except `buttons`, `defaults` and `subforms`, which the row does not declare. Those three stay authorable in an `object-view`'s `form` slot, which is built from the mirror, and a section there is still judged member by member. + **What did not move.** The TypeScript `ObjectFormSchema` and its zod mirror `ObjectFormSchema` stay published and unchanged in shape. They are the node as `ObjectForm` reads it after the hoist, and as code composes it (`ObjectView`, `RecordFormPage`, `ScreenView`, a host mounting ``). The object-view `form` slot is still built from the mirror. From a32a4470cd9f702b91413a79b9c231fb096ac20a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 23:28:44 +0000 Subject: [PATCH 5/5] test(plugin-form): type the render pin's casts instead of `any` (objectui#10859, batch 4) Claude-Session: https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ Co-authored-by: Claude --- .../src/__tests__/objectFormPropertiesBag-10859.test.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/plugin-form/src/__tests__/objectFormPropertiesBag-10859.test.tsx b/packages/plugin-form/src/__tests__/objectFormPropertiesBag-10859.test.tsx index 0ee0525f53..92621d0295 100644 --- a/packages/plugin-form/src/__tests__/objectFormPropertiesBag-10859.test.tsx +++ b/packages/plugin-form/src/__tests__/objectFormPropertiesBag-10859.test.tsx @@ -65,10 +65,13 @@ function makeAdapter() { }; } +type ProviderDataSource = React.ComponentProps['dataSource']; +type RenderedSchema = React.ComponentProps['schema']; + const draw = (schema: Record, adapter: ReturnType) => render( - - + + , );