From a63b228d7c52650bb597e7b61b63b804569f9611 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 17:43:24 +0000 Subject: [PATCH 1/9] feat(types)!: an authored object-grid takes its props in the spec's properties bag (objectui#11276) New ObjectGridBlockSchema: BaseSchema + the object-grid literal + NODE_ENVELOPE + properties = the spec's ComponentPropsMap['object-grid'] row by reference, with flatPropRefusals for the flat spelling, the flat mirror's tombstones and runtime slot by reference, and the record-source rule read in the bag. ObjectGridSchema leaves ObjectQLComponentSchema and keeps building the object-view table slot. Part of #11276 Claude-Session: https://claude.ai/code/session_01JG2jy8a9su7ia4Hx7zxv42 Co-authored-by: Claude --- packages/types/src/objectql.ts | 23 +++- packages/types/src/zod/index.zod.ts | 6 +- packages/types/src/zod/objectql.zod.ts | 135 +++++++++++++++++++- packages/types/src/zod/public-blocks.zod.ts | 4 +- 4 files changed, 161 insertions(+), 7 deletions(-) diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 723274b4b1..d7063fc592 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -786,6 +786,25 @@ export interface ListViewExportOptions { * - Column resizing, sorting * - Row selection * - Inline editing support + * + * ## What this type describes (objectui#11276) + * + * The `object-grid` node as `ObjectGrid` READS it: after `SchemaRenderer` has + * hoisted the node's `properties` bag onto it, or as code composes it — + * `ObjectView` and `ListView` building a grid view, the designers' previews, a + * host mounting ``. That is why `objectName` is + * required here and why its members sit flat on the node. The `object-view` + * `table` slot (`ObjectGridSlotKey`) is built from it too. + * + * It is NOT the shape of an AUTHORED `object-grid` document. An authored node + * takes its props in the spec's `properties` bag, whose members are + * `@objectstack/spec`'s `ComponentPropsMap['object-grid']` row + * (`ObjectGridProps`): `{ type: 'object-grid', properties: { objectName, + * columns, … } }`, because the spec's page component refuses a prop written on + * the node itself (ADR-0089 D3a). `ObjectGridBlockSchema` + * (`./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 ObjectGridSchema extends BaseSchema { type: 'object-grid'; @@ -6005,7 +6024,9 @@ export interface ObjectDataTableSchema extends BaseSchema { * `ObjectChartSchema` here is the node as `ObjectChart` reads it. It carries * eight since objectui#10859 batch 6: the authored `object-gantt` node is armed * by `ObjectGanttBlockSchema`, and `ObjectGanttSchema` here is the node as - * `ObjectGantt` reads it. + * `ObjectGantt` reads it. It carries seven since objectui#11276's `object-grid` + * batch: the authored `object-grid` node is armed by `ObjectGridBlockSchema`, + * and `ObjectGridSchema` here is the node as `ObjectGrid` reads it. */ export type ObjectQLComponentSchema = | ObjectGridSchema diff --git a/packages/types/src/zod/index.zod.ts b/packages/types/src/zod/index.zod.ts index 431fc0f7c3..b2dac9d157 100644 --- a/packages/types/src/zod/index.zod.ts +++ b/packages/types/src/zod/index.zod.ts @@ -334,7 +334,10 @@ export { // and `ObjectGanttSchema` above stay the nodes as their renderers read them. // objectui#11276's `ObjectChartBlockSchema` is the authored `object-chart` // arm, its bag the flat mirror's own members (the spec has no row for it); - // `ObjectChartSchema` above stays the node as `ObjectChart` reads it. + // `ObjectChartSchema` above stays the node as `ObjectChart` reads it. Its + // `object-grid` batch's `ObjectGridBlockSchema` is the authored `object-grid` + // arm, from its row; `ObjectGridSchema` above stays the node as `ObjectGrid` + // reads it, and builds the `object-view` `table` slot. ObjectMetricBlockSchema, ObjectMasterDetailFormBlockSchema, ObjectTimelineBlockSchema, @@ -342,6 +345,7 @@ export { ObjectMapBlockSchema, ObjectChartBlockSchema, ObjectGanttBlockSchema, + ObjectGridBlockSchema, ObjectQLPublicBlockComponentSchema, } from './objectql.zod.js'; diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index f318e8a4f6..e39910d51d 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -57,6 +57,9 @@ import { ObjectFormPropsSchema as SpecObjectFormPropsSchema, ObjectMapPropsSchema as SpecObjectMapPropsSchema, ObjectGanttPropsSchema as SpecObjectGanttPropsSchema, + // objectui#11276 — the `object-grid` row, read as the authored arm's + // `properties` bag, by reference (`ObjectGridBlockSchema` below). + ObjectGridPropsSchema as SpecObjectGridPropsSchema, // objectui#11070 — the per-element data binding (`PageComponentSchema.dataSource`) // the object-bound arms below declare as `dataSource`, by reference. ElementDataSourceSchema as SpecElementDataSourceSchema, @@ -311,6 +314,17 @@ const OBJECT_NAME_BINDING_WAIVER_DESCRIPTION = /** * ObjectGrid Schema + * + * ## No longer an authoring arm (objectui#11276) + * + * This mirror is the node as `ObjectGrid` reads it: after `SchemaRenderer` has + * hoisted the node's `properties` bag onto it, or as code composes it + * (`ObjectView`, `ListView`, the designers). It is also what the `object-view` + * `table` slot below is built from. It left `ObjectQLComponentSchema`, and so + * `AnyComponentSchema`: the AUTHORED `object-grid` node is armed by + * `ObjectGridBlockSchema` below, whose `properties` is the spec's + * `ComponentPropsMap['object-grid']` row. It stays exported and paired with + * its TypeScript twin. */ export const ObjectGridSchema = BaseSchema.extend({ type: z.literal('object-grid'), @@ -3800,8 +3814,8 @@ export const ObjectDataTableSchema = BaseSchema.extend({ * ObjectQL Component Schema Union * * The members of the TS union in `../objectql.ts`, in the same order, less - * `ObjectFormSchema`, `ObjectMapSchema`, `ObjectChartSchema` and - * `ObjectGanttSchema` (see the last four paragraphs). + * `ObjectGridSchema`, `ObjectFormSchema`, `ObjectMapSchema`, + * `ObjectChartSchema` and `ObjectGanttSchema` (see the last five paragraphs). * `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` / @@ -3837,9 +3851,14 @@ export const ObjectDataTableSchema = BaseSchema.extend({ * way. Its TypeScript twin is the node as `ObjectGantt` reads it, and the * authored `object-gantt` node is armed by `ObjectGanttBlockSchema` below, from * its spec row. + * + * ⚠️ SEVEN since objectui#11276's `object-grid` batch: `ObjectGridSchema` left + * it the same way. Its TypeScript twin is the node as `ObjectGrid` reads it, + * the mirror still builds the `object-view` `table` slot, and the authored + * `object-grid` node is armed by `ObjectGridBlockSchema` below, from its spec + * row. */ export const ObjectQLComponentSchema = z.discriminatedUnion('type', [ - ObjectGridSchema, ObjectViewSchema, ObjectTreeSchema, ObjectCalendarSchema, @@ -4452,6 +4471,111 @@ export const ObjectGanttBlockSchema = BaseSchema.extend({ children: retirementTombstone(OBJECT_GANTT_NEITHER_CHANNEL), }).superRefine(...requireRecordSource('object-gantt', RECORD_SOURCE_KEYS, 'properties')); +/** + * `object-grid` — `ComponentPropsMap['object-grid']`, plus the node's + * `dataSource` binding (objectui#11276, the `object-grid` batch of triage's + * routing call A). + * + * ## Why the arm moved to the bag + * + * `@objectstack/spec`'s strict `PageComponentSchema` refuses a prop written on + * a page component itself as mis-layered (ADR-0089 D3a), for every component + * type: `properties` is the only home of a component's own props. This union + * used to arm the node with the flat `ObjectGridSchema` above, so `objectui + * validate` refused the spec-shaped document — the objectstack showcase's work + * queues, `{ type: 'object-grid', properties: { objectName, columns, filter } }` + * (its record-source refinement found no source on the node, and the strict + * face named `properties` as an unrecognized key) — and accepted the flat one + * `os validate` refuses. Triage's answer on objectui#11276 is this arm: "A + * block schema takes the `properties` bag, while the flat mirror keeps the + * `object-view` table slot." + * + * It is the construct `ObjectFormBlockSchema`, `ObjectMapBlockSchema` and + * `ObjectGanttBlockSchema` use (objectui#10859, batches 4 to 6): `BaseSchema` + + * the `type` literal + `NODE_ENVELOPE` + `properties`, which IS the spec's row, + * by reference through the objectui#8317 import boundary. The row carries spec + * defaults (inside `data`'s `api` provider), so the boundary hands back a + * rebuilt copy with every default removed and every key still omissible. The + * row is strict and carries the spec's own refusals and prescriptions: an + * undeclared key in the bag is refused there, `properties.operators` with the + * spec's "Did you mean `operators` → `operations`?", and the record form of + * `filter` with the spec's ViewFilterRule prescription. + * + * ## 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 — `flatPropRefusals`, the shared helper + * the `object-metric`, `object-master-detail-form` and `object-timeline` arms + * above spread, read off the row's own key set. It already encodes the two + * exceptions this row needs: `label` is a node-level key of the spec's page + * component too (its display label), so a `label` on the node is not + * mis-layered and keeps `BaseSchema`'s member; and `defaultSort` is a member + * the row itself retires, so written flat it gets the row's own retirement. + * `data` is also a `BaseSchema` key; here the refusal overrides it, because the + * grid's `data` is the row's member. + * + * The flat mirror's own retirements that are not row members keep their + * tombstones when written flat — the mirror's members, by reference: + * `operators` (objectui#9739), and `rowSpecActions`, `bulkSpecActions`, `name`, + * `placeholder`, `showFilters` (objectui#11068). `name` and `placeholder` are + * `BaseSchema` keys, so without the tombstone they would parse again. In the + * bag the row refuses each of them. `onNavigate`, which `ObjectGrid` reads off + * the node, stays the objectui#6124 runtime slot it is on the mirror. A flat + * key neither the row nor those declare — the mirror's `emptyState` and + * `keyboardNavigation` among them — is left as every arm leaves an undeclared + * key: unjudged by the tolerant face, refused by the strict one; the row + * refuses both inside the bag too. + * + * ## The record source, `dataSource` and the content channels + * + * The flat mirror's record-source rule (objectui#11117: `objectName`, or the + * node's binding naming the object) stays on the authored node, read in the + * bag, as on `ObjectMapBlockSchema`: `requireRecordSource(…, 'properties')` + * asks for `properties.objectName`, or the node's `dataSource` binding naming + * its object (a non-empty `dataSource.object`), which `ElementDataSourceGate` + * lands on `objectName` (the registration is `elementDataSourceBlock`-wrapped). + * ⚠️ The spec row is looser here: it keeps `objectName` optional and adds no + * such rule, so a node with no source passes the spec's own page component and + * is refused by this arm, as the flat mirror refused it. The binding itself 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. + * + * ## What did not move + * + * The TypeScript `ObjectGridSchema` and its zod mirror above stay published: + * they are the node as `ObjectGrid` reads it after the hoist, and as code + * composes it (`ObjectView`, `ListView`, the designers). The mirror also keeps + * building the `object-view` `table` slot. A composed flat node keeps working, + * because `SchemaRenderer` reads both spellings and no composed node passes + * through `safeValidateSchema`. + */ +export const ObjectGridBlockSchema = BaseSchema.extend({ + type: z.literal('object-grid'), + ...NODE_ENVELOPE, + // A row member written flat on the node is refused by name, toward `properties.KEY`. + ...flatPropRefusals('object-grid', stripImportedDefaults(SpecObjectGridPropsSchema)), + properties: propsBag('object-grid', stripImportedDefaults(SpecObjectGridPropsSchema)), + dataSource: stripImportedDefaults(SpecElementDataSourceSchema) + .optional() + .describe(ELEMENT_DATA_SOURCE_BINDING_DESCRIPTION), + // The flat mirror's retirements of keys the row does not declare: its own + // tombstones, by reference, so their remedies stay the ones it published. + operators: ObjectGridSchema.shape.operators, + rowSpecActions: ObjectGridSchema.shape.rowSpecActions, + bulkSpecActions: ObjectGridSchema.shape.bulkSpecActions, + name: ObjectGridSchema.shape.name, + placeholder: ObjectGridSchema.shape.placeholder, + showFilters: ObjectGridSchema.shape.showFilters, + // objectui#6124: `ObjectGrid` reads `onNavigate` off the node, so it stays the + // mirror's runtime slot, refused by name. + onNavigate: ObjectGridSchema.shape.onNavigate, + // objectui#9256: the renderer reads NEITHER content channel, so both are + // refused by name — the flat mirror's own members, by reference. + body: ObjectGridSchema.shape.body, + children: ObjectGridSchema.shape.children, +}).superRefine(...requireRecordSource('object-grid', ['objectName'], 'properties')); + /** * The public blocks above, as one arm of `AnyComponentSchema` * (objectui#10859, batches 2 to 6). @@ -4469,6 +4593,10 @@ export const ObjectGanttBlockSchema = BaseSchema.extend({ * `ObjectChartSchema` mirror's own members by reference, and its TypeScript * twin is likewise the node as `ObjectChart` reads it after the hoist. * + * `object-grid` joined in objectui#11276's next batch, a spec-row arm like + * `object-form`: its TypeScript twin is the node as `ObjectGrid` reads it after + * the hoist, and its flat mirror keeps building the `object-view` `table` slot. + * * Each arm also spreads `NODE_ENVELOPE` from `./public-blocks.zod.ts`, * the node-level `responsiveStyles` every public block declares by reference to * the spec's `PageComponentSchema` (objectui#10872 batch 8) — the same one @@ -4483,4 +4611,5 @@ export const ObjectQLPublicBlockComponentSchema = z.discriminatedUnion('type', [ ObjectMapBlockSchema, ObjectChartBlockSchema, ObjectGanttBlockSchema, + ObjectGridBlockSchema, ]); diff --git a/packages/types/src/zod/public-blocks.zod.ts b/packages/types/src/zod/public-blocks.zod.ts index 42a567fcff..d0968d940a 100644 --- a/packages/types/src/zod/public-blocks.zod.ts +++ b/packages/types/src/zod/public-blocks.zod.ts @@ -414,8 +414,8 @@ type FlatPropRefusals = { * tile, a form's master-detail node) never passes through this face. * * The ONE copy: every public-block arm here, and `./objectql.zod.ts`'s - * `object-metric`, `object-master-detail-form` and `object-timeline` arms, - * spread it. Internal to this package's zod modules, like `propsBag`: + * `object-metric`, `object-master-detail-form`, `object-timeline` and + * `object-grid` (objectui#11276) arms, spread it. Internal to this package's zod modules, like `propsBag`: * deliberately NOT re-exported from `index.zod.ts`. * * @param type the registered `type`, spelled into every message From 9906817bc4495863c9ef444a200a75a17e382cb2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 17:49:25 +0000 Subject: [PATCH 2/9] test(types): census rows and fixture triage for the object-grid bag arm (objectui#11276) The census rows the new export forces (parity EXCLUSIONS, the import boundary's IMPORTED row, the union counts and literal lists, the public-block envelope population, flatPropRefusals coverage, the grouping census), and fixtures that a face now judges respelled to the bag; mirror rows keep the post-hoist spelling. Claude-Session: https://claude.ai/code/session_01JG2jy8a9su7ia4Hx7zxv42 Co-authored-by: Claude --- .../any-component-union-fanout.test.ts | 4 +- .../content-channel-e3-residual-9256.test.ts | 16 ++++--- .../flat-arm-responsive-styles-10872.test.ts | 42 ++++++++++++------- .../flat-props-refusal-10872.test.ts | 4 ++ ...grouping-field-padded-refused-7347.test.ts | 35 +++++++++++++++- .../__tests__/imported-defaults-8317.test.ts | 6 +++ ...ct-grid-show-filters-retired-11068.test.ts | 15 +++++-- .../objectql-union-arms-7363.test.ts | 7 +++- ...blic-block-responsive-styles-10872.test.ts | 11 ++++- .../registered-type-arms-10859-b2.test.ts | 4 +- .../strict-face-read-keys-11070.test.ts | 7 +++- .../src/__tests__/zod-mirror-parity.test.ts | 6 +++ 12 files changed, 122 insertions(+), 35 deletions(-) diff --git a/packages/types/src/__tests__/any-component-union-fanout.test.ts b/packages/types/src/__tests__/any-component-union-fanout.test.ts index f5a2120138..92264ac057 100644 --- a/packages/types/src/__tests__/any-component-union-fanout.test.ts +++ b/packages/types/src/__tests__/any-component-union-fanout.test.ts @@ -109,7 +109,9 @@ describe('AnyComponentSchema — the property discriminating rests on', () => { // (measured: `Invalid discriminated union option at index "9"`). These two // were the last flat sites, and `AnyComponentSchema` cannot discriminate // while either of them is one. - expect(literalsOf(ObjectQLComponentSchema)).toContain('object-grid'); + // `object-view`, not `object-grid`: the authored `object-grid` arm left this + // union for the public-block one in objectui#11276's `object-grid` batch. + expect(literalsOf(ObjectQLComponentSchema)).toContain('object-view'); expect(literalsOf(CRUDComponentSchema)).toContain('action'); // `ActionSchema` reaches its literal through a `z.lazy` its maintainer-ruled // `z.ZodType<…>` annotation hides from `tsc` — the fact the cast at that arm 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 2e4cbd4294..1d23f211e1 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 @@ -113,6 +113,9 @@ type Mirror = { * CONTROL block proves every `required` set parses on its own. */ const ROWS: ReadonlyArray]> = [ + // The flat mirror is the node as `ObjectGrid` reads it after the `properties` + // hoist; the AUTHORED node takes the same members in the bag + // (`AUTHORED_REQUIRED` below, objectui#11276's `object-grid` batch). ['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 @@ -138,12 +141,13 @@ const ROWS: ReadonlyArray>> = { + 'object-grid': { properties: { objectName: 'account' } }, 'object-form': { properties: { objectName: 'account', mode: 'create' } }, 'object-map': { properties: { objectName: 'account' } }, 'object-chart': { properties: { objectName: 'account', chartType: 'bar' } }, @@ -216,10 +220,10 @@ describe('objectui#9256 E3 residual — CONTROLS', () => { it('a nested narrowed node is refused inside a container that reads `children`', () => { const tree = (child: Record) => ({ type: 'div', children: [child] }); - expect(AnyComponentSchema.safeParse(tree({ type: 'object-grid', objectName: 'account' })).success).toBe(true); - expect(AnyComponentSchema.safeParse( - tree({ type: 'object-grid', objectName: 'account', children: CONTENT }), - ).success).toBe(false); + // The authored spelling: its props in the bag (objectui#11276's `object-grid` batch). + const grid = { type: 'object-grid', ...AUTHORED_REQUIRED['object-grid'] }; + expect(AnyComponentSchema.safeParse(tree(grid)).success).toBe(true); + expect(AnyComponentSchema.safeParse(tree({ ...grid, children: CONTENT })).success).toBe(false); }); it('family C still reads a content channel — `div` takes `children`', () => { diff --git a/packages/types/src/__tests__/flat-arm-responsive-styles-10872.test.ts b/packages/types/src/__tests__/flat-arm-responsive-styles-10872.test.ts index 749155dd65..90921961cd 100644 --- a/packages/types/src/__tests__/flat-arm-responsive-styles-10872.test.ts +++ b/packages/types/src/__tests__/flat-arm-responsive-styles-10872.test.ts @@ -33,10 +33,12 @@ * `PAGE_COMPONENT_ENVELOPE`, so the member is the same object on all 33 arms. * * ⚠️ objectui#11276 moved `object-chart`'s AUTHORED arm into the public-block - * set (`ObjectChartBlockSchema`, its props in the `properties` bag), so the - * population below is the two arms the node union still selects a flat mirror - * for. The flat `ObjectChartSchema` keeps the shared member as the post-hoist - * reading, and the authored chart arm is pinned with the other public blocks in + * set (`ObjectChartBlockSchema`, its props in the `properties` bag), and its + * `object-grid` batch moved `object-grid`'s the same way + * (`ObjectGridBlockSchema`), so the population below is the one arm the node + * union still selects a flat mirror for, `flex`. The flat `ObjectChartSchema` + * and `ObjectGridSchema` keep the shared member as the post-hoist reading, and + * the authored chart and grid arms are pinned with the other public blocks in * `./public-block-responsive-styles-10872.test.ts`. * The key is NOT declared on `BaseSchema` and NOT on any arm without a measured * producer: `grid`, `stack` and `container` below are the control, and the @@ -133,19 +135,25 @@ function armsByLiteral(): Map { * outside the public-block set that a producer writes `responsiveStyles` on * (objectui#10872's batch-9 pull). NAMED, not derived, because the population * is a measurement of producers. `object-chart` was the third until - * objectui#11276 moved its authored arm into the public-block set; its flat - * mirror keeps the member (the post-hoist row below). + * objectui#11276 moved its authored arm into the public-block set, and + * `object-grid` the second until that card's `object-grid` batch moved its + * authored arm the same way; each flat mirror keeps the member (the post-hoist + * rows below). */ const FLAT_ARMS = [ ['flex', FlexSchema], - ['object-grid', ObjectGridSchema], ] as const; /** Arms with no producer: the strict face still refuses the key on them. */ const CONTROL_TYPES = ['grid', 'stack', 'container'] as const; -/** The arm that already declared `dataSource` before this batch (objectui#11070). */ -const DECLARES_DATA_SOURCE: ReadonlySet = new Set(['object-grid']); +/** + * The arms here that already declared `dataSource` before this batch + * (objectui#11070). `object-grid` was the one until objectui#11276 moved its + * authored arm into the public-block set, where it is named with that set's + * binding arms; `flex` declares none. + */ +const DECLARES_DATA_SOURCE: ReadonlySet = new Set(); /** The five envelope keys the spec declares and this batch leaves undeclared. */ const UNDECLARED_ENVELOPE = ['events', 'dataSource', 'aria', 'visibility', 'responsive'] as const; @@ -163,13 +171,6 @@ const VALID_NODES = { // A nested node carrying the key: judged at depth, through the node recursion. children: [{ type: 'flex', id: 'cc_spacer', responsiveStyles: { large: { flex: '1 1 auto' } } }], }, - 'object-grid': { - type: 'object-grid', - id: 'cc_queue_g', - objectName: 'showcase_task', - columns: ['title', 'status'], - responsiveStyles: { large: { minWidth: '0', display: 'block' } }, - }, } as const; /** Values the spec refuses: a breakpoint it does not have, a non-object, a non-CSS value. */ @@ -234,6 +235,15 @@ describe('`responsiveStyles` is declared on the batch-9 arms, from the public bl expect(armsByLiteral().get('object-chart')).not.toBe(ObjectChartSchema); }); + it('the flat `ObjectGridSchema` mirror keeps the SAME member as the post-hoist reading (objectui#11276)', () => { + // Its authored arm left the node union for the public-block set in the + // `object-grid` batch; the mirror is the node as `ObjectGrid` reads it, and + // it still declares the key and the `dataSource` binding. + expect(ObjectGridSchema.shape.responsiveStyles).toBe(PageSectionBlockSchema.shape.responsiveStyles); + expect('dataSource' in ObjectGridSchema.shape).toBe(true); + expect(armsByLiteral().get('object-grid')).not.toBe(ObjectGridSchema); + }); + it('the member is the spec\'s own map: the same breakpoints, each the spec\'s own value schema', () => { const member = (FlexSchema.shape.responsiveStyles as z.ZodOptional).unwrap() as z.ZodObject; const spec = SpecResponsiveStylesSchema as unknown as z.ZodObject; diff --git a/packages/types/src/__tests__/flat-props-refusal-10872.test.ts b/packages/types/src/__tests__/flat-props-refusal-10872.test.ts index c1f871190e..a75d642a1a 100644 --- a/packages/types/src/__tests__/flat-props-refusal-10872.test.ts +++ b/packages/types/src/__tests__/flat-props-refusal-10872.test.ts @@ -160,6 +160,10 @@ const VALID_BAG: Readonly>> = { 'object-metric': { objectName: 'order', label: 'Orders', description: 'Open orders', aggregate: { function: 'count' } }, 'object-master-detail-form': { objectName: 'order', mode: 'create', title: 'New order', submitText: 'Save' }, 'object-timeline': { objectName: 'task', limit: 5, data: [] }, + // objectui#11276 (the `object-grid` batch): the authored `object-grid` arm + // spreads this helper over its row, so it is covered here like the three + // ObjectQL blocks above; `data` is the base-declared row key in play. + 'object-grid': { objectName: 'task', title: 'Tasks', columns: ['subject'], data: { provider: 'object', object: 'task' } }, }; const COVERED = Object.keys(VALID_BAG); diff --git a/packages/types/src/__tests__/grouping-field-padded-refused-7347.test.ts b/packages/types/src/__tests__/grouping-field-padded-refused-7347.test.ts index fd14be8b25..853684fc76 100644 --- a/packages/types/src/__tests__/grouping-field-padded-refused-7347.test.ts +++ b/packages/types/src/__tests__/grouping-field-padded-refused-7347.test.ts @@ -121,6 +121,23 @@ const DECLARING: Readonly> = { }, }; +/** + * Arms the census finds naming `grouping` that this pin does NOT cover, each + * with the reason and the paths the census records — so an arm cannot slip in + * here silently, and a reason that stops holding turns the rows below red. + * + * `object-grid` (objectui#11276's `object-grid` batch): the authored arm's + * `properties` is `@objectstack/spec`'s `ComponentPropsMap['object-grid']` row + * by reference, and the row types its `grouping` member as `z.unknown()`, so no + * grouping shape is judged in the bag and a padded field name is not refused + * there — the row's own reading, recorded rather than restated here. The + * node-level `grouping` is no declaration at all: it is the flat spelling's + * by-name refusal (`flatPropRefusals`), pointing at `properties.grouping`. + */ +const NOT_COVERED: Readonly> = { + 'object-grid': ['grouping', 'properties.grouping'], +}; + /** The faces an authored document meets. `objectui validate` runs `safeValidateSchema`. */ const facesFor = (arm: z.ZodType): ReadonlyArray Parsed]> => [ ['the tolerant face (`AnyComponentSchema`)', (d) => AnyComponentSchema.safeParse(d)], @@ -238,7 +255,23 @@ describe('objectui#7347 — census: the table above is every arm that declares ` // skipping it would be a silent hole in the census. if (at.length) found[componentTypeOf(arm) ?? '?'] = at; } - const expected = Object.fromEntries(Object.entries(DECLARING).map(([type, spec]) => [type, [spec.declaredAt]])); + const expected = { + ...Object.fromEntries(Object.entries(DECLARING).map(([type, spec]) => [type, [spec.declaredAt]])), + ...NOT_COVERED, + }; expect(found).toEqual(expected); }); + + it('`object-grid` is not covered for the recorded reasons: a flat refusal on the node, the row\'s `unknown` in the bag', () => { + const doc = (grouping: unknown, at: 'node' | 'bag') => (at === 'node' + ? { type: 'object-grid', properties: { objectName: 'account' }, grouping } + : { type: 'object-grid', properties: { objectName: 'account', grouping } }); + const padded = { fields: [{ field: PADDED[0], order: 'asc' }] }; + // On the node, every value is refused by name, toward the bag member. + const flat = issuesOf(safeValidateSchema(doc({ fields: [{ field: CLEAN, order: 'asc' }] }, 'node'))); + expect(flat.map((i) => i.path.join('.'))).toEqual(['grouping']); + // In the bag the row judges nothing about the shape: a padded name parses. + expect(safeValidateSchema(doc(padded, 'bag')).success).toBe(true); + expect(StrictAnyComponentSchema.safeParse(doc(padded, 'bag')).success).toBe(true); + }); }); diff --git a/packages/types/src/__tests__/imported-defaults-8317.test.ts b/packages/types/src/__tests__/imported-defaults-8317.test.ts index 2378a1ea6f..3c7d79a7cd 100644 --- a/packages/types/src/__tests__/imported-defaults-8317.test.ts +++ b/packages/types/src/__tests__/imported-defaults-8317.test.ts @@ -89,6 +89,7 @@ import { ObjectFormPropsSchema as SpecObjectFormPropsSchema, ObjectMapPropsSchema as SpecObjectMapPropsSchema, ObjectGanttPropsSchema as SpecObjectGanttPropsSchema, + ObjectGridPropsSchema as SpecObjectGridPropsSchema, // objectui#10872 — the `ComponentPropsMap` rows the public-block arms read. PageHeaderProps as SpecPageHeaderProps, PageTabsProps as SpecPageTabsProps, @@ -280,6 +281,11 @@ const IMPORTED: Array = [ // off the crossed copy too. This row carries defaults (`data`'s `api` // provider), so the boundary returns a rebuilt copy. ['ObjectGanttPropsSchema', SpecObjectGanttPropsSchema], + // objectui#11276 (the `object-grid` batch): the `object-grid` arm's + // `properties` is its row, crossed the same way, and `flatPropRefusals` reads + // the row's key set off the crossed copy. This row carries defaults too + // (`data`'s `api` provider), so the boundary returns a rebuilt copy. + ['ObjectGridPropsSchema', SpecObjectGridPropsSchema], // 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-grid-show-filters-retired-11068.test.ts b/packages/types/src/__tests__/object-grid-show-filters-retired-11068.test.ts index f12d0aeaa6..31ec2d9fe5 100644 --- a/packages/types/src/__tests__/object-grid-show-filters-retired-11068.test.ts +++ b/packages/types/src/__tests__/object-grid-show-filters-retired-11068.test.ts @@ -49,8 +49,15 @@ import type { /** A control key no surface in this package or the protocol declares. */ const UNKNOWN_KEY = 'zzzNotAKeyAnySurfaceDeclares11068'; -/** The minimum green `object-grid` document. */ -const GRID = { type: 'object-grid' as const, objectName: 'probe' }; +/** + * The minimum green AUTHORED `object-grid` document: its props in the + * `properties` bag, the spelling both faces take since objectui#11276's + * `object-grid` batch (a flat `objectName` is refused by name there). + */ +const GRID = { type: 'object-grid' as const, properties: { objectName: 'probe' } }; + +/** The same grid as the flat mirror reads it, after `SchemaRenderer` hoists the bag. */ +const GRID_POST_HOIST = { type: 'object-grid' as const, objectName: 'probe' }; type Issue = { code: string; path: PropertyKey[]; message: string; errors?: Issue[][] }; @@ -111,7 +118,7 @@ describe('an authored `showFilters` on `object-grid` is refused BY NAME on both it('writes the SAME string into the metadata channel as into the parse message', () => { const described = (ObjectGridSchema.shape.showFilters as { description?: string }).description; - const issue = ObjectGridSchema.safeParse({ ...GRID, showFilters: true }); + const issue = ObjectGridSchema.safeParse({ ...GRID_POST_HOIST, showFilters: true }); expect(issue.success).toBe(false); const message = issue.success ? undefined : issue.error.issues.find((i) => i.path.join('.') === 'showFilters')?.message; expect(described).toBe(message); @@ -165,7 +172,7 @@ describe('the UPSTREAM half, re-derived from the installed pin', () => { * re-typed as a live value. */ export const authoredShowFiltersRefused: ObjectGridSchemaType = { - ...GRID, + ...GRID_POST_HOIST, // @ts-expect-error — `showFilters` is RETIRED on `object-grid` (objectui#11068): a list view's `userActions.filter`. showFilters: 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 3d1f33cd9d..0b616708cd 100644 --- a/packages/types/src/__tests__/objectql-union-arms-7363.test.ts +++ b/packages/types/src/__tests__/objectql-union-arms-7363.test.ts @@ -88,8 +88,11 @@ describe('objectui#7363 — the two objectui#6576 schemas are arms of the Object // authored arm left the same way in objectui#11276 // (`object-chart-properties-bag-11276.test.ts`), and `object-gantt`, whose // authored arm left in objectui#10859 batch 6 - // (`object-gantt-properties-bag-10859-b6.test.ts`). - expect(literals).toHaveLength(8); + // (`object-gantt-properties-bag-10859-b6.test.ts`), and `object-grid`, whose + // authored arm left in objectui#11276's `object-grid` batch + // (`object-grid-properties-bag-11276.test.ts`). + expect(literals).toHaveLength(7); + expect(literals).not.toContain('object-grid'); expect(literals).not.toContain('object-form'); expect(literals).not.toContain('object-map'); expect(literals).not.toContain('object-chart'); 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 941c356ee5..ff8822232d 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 @@ -118,6 +118,10 @@ const DECLARES_DATA_SOURCE: ReadonlySet = new Set([ // registration reads it, and the react-page wrapper no longer writes the // host adapter under that key. 'object-chart', + // objectui#11276 (the `object-grid` batch): `object-grid` moved here from + // `ObjectQLComponentSchema`, carrying the binding its flat mirror has + // declared since objectui#11070. + 'object-grid', ]); /** @@ -126,13 +130,16 @@ const DECLARES_DATA_SOURCE: ReadonlySet = new Set([ * flat mirrors' record-source rule, read in the bag (objectui#10859 batches 5 * and 6, `requireRecordSource`), and `object-chart` keeps the flat mirror's * chart-family floor, read in its bag (objectui#11276, - * `requireObjectChartFamilyInBag`). Each node carries the smallest bag that - * satisfies its rule; every other arm takes the bare node. + * `requireObjectChartFamilyInBag`). `object-grid` keeps its flat mirror's + * record-source rule the same way (objectui#11276's `object-grid` batch). Each + * node carries the smallest bag that satisfies its rule; every other arm takes + * the bare node. */ const RECORD_SOURCE: Readonly>> = { 'object-map': { properties: { objectName: 'store' } }, 'object-gantt': { properties: { objectName: 'task' } }, 'object-chart': { properties: { chartType: 'bar' } }, + 'object-grid': { properties: { objectName: 'task' } }, }; /** 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 fae0cb5241..9957157ac6 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 @@ -354,7 +354,8 @@ describe('the spec-row arms read the row by reference (objectui#10859)', () => { // `object-form` in batch 4 (`object-form-properties-bag-10859-b4.test.ts`), // `object-map` in batch 5 (`object-map-properties-bag-10859-b5.test.ts`), // `object-chart` in objectui#11276 (`object-chart-properties-bag-11276.test.ts`), - // `object-gantt` in batch 6 (`object-gantt-properties-bag-10859-b6.test.ts`). + // `object-gantt` in batch 6 (`object-gantt-properties-bag-10859-b6.test.ts`), + // `object-grid` in objectui#11276 (`object-grid-properties-bag-11276.test.ts`). expect(literals(ObjectQLPublicBlockComponentSchema)).toEqual([ 'object-metric', 'object-master-detail-form', @@ -363,6 +364,7 @@ describe('the spec-row arms read the row by reference (objectui#10859)', () => { 'object-map', 'object-chart', 'object-gantt', + 'object-grid', ]); 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 5228331610..ecf518a3ab 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 @@ -183,7 +183,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' }], + // objectui#11276 (the `object-grid` batch): an authored `object-grid` takes + // its props in the spec's `properties` bag; the binding stays on the node. + ['object-grid', { properties: { objectName: 'task' } }], // 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' } }], @@ -223,7 +225,8 @@ describe('objectui#11070 — a declared key is judged by its declared type on bo ['a `summaryOperations.function` the spec does not list (`first`)', form({ type: 'summary', summaryOperations: { object: 'orders', field: 'amount', function: 'first' } })], ['an unknown member inside `summaryOperations` (the spec closes it)', form({ type: 'summary', summaryOperations: { object: 'orders', field: 'amount', function: 'sum', functon: 'avg' } })], ['a binding that names no `object`', { type: 'object-kanban', dataSource: { filter: { a: 1 } } }], - ['an adapter-shaped `dataSource`', { type: 'object-grid', objectName: 'task', dataSource: 'objectstack' }], + // The grid's own props in the bag (objectui#11276), so `dataSource` is the one key judged here. + ['an adapter-shaped `dataSource`', { type: 'object-grid', properties: { objectName: 'task' }, dataSource: 'objectstack' }], // Round 7: the grid's columns are the spec's strict inline grid column. ['a grid column keyed by the retired `field` spelling', form({ type: 'grid', columns: [{ field: 'qty' }] })], ['a grid column `type` outside the spec\'s nine cell controls (`boolean`)', form({ type: 'grid', columns: [{ name: 'done', type: 'boolean' }] })], diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index ae0ca549b5..791672f8d7 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -4668,6 +4668,12 @@ const EXCLUSIONS: Readonly> = { // (`ObjectGanttSchema`), which stays the renderer's post-hoist reading. 'objectql.zod.ts#ObjectGanttBlockSchema': "spec-owned BY REFERENCE — `BaseSchema` + the `object-gantt` literal + `NODE_ENVELOPE` + `properties`, which IS `ComponentPropsMap['object-gantt']`, + the node's `dataSource` binding, which IS the spec's `ElementDataSourceSchema`, + one `aliasKeyRefusal` per member of the row but the node-level `label` (the flat spelling, refused by name and pointed at `properties.KEY`; the key set is read off the row) plus one per member of the row's `gantt` block and one for the flat mirror's `dependencyField` alias (pointed at `properties.gantt.KEY`), + the objectui#9256 `body` / `children` refusals; the TS declaration of the node, `ObjectGanttSchema` (`../objectql.ts`), is the renderer's post-hoist reading and is paired with the flat `objectql.zod.ts#ObjectGanttSchema` mirror, not with this arm", + // objectui#11276 (the `object-grid` batch) — the authored `object-grid` arm, + // from its row, built as batch 6's above. Its TS twin is paired with the FLAT + // mirror above (`ObjectGridSchema`), which stays the renderer's post-hoist + // reading and still builds the `object-view` `table` slot. + 'objectql.zod.ts#ObjectGridBlockSchema': + "spec-owned BY REFERENCE — `BaseSchema` + the `object-grid` literal + `NODE_ENVELOPE` + `properties`, which IS `ComponentPropsMap['object-grid']`, + the node's `dataSource` binding, which IS the spec's `ElementDataSourceSchema`, + `flatPropRefusals` over the row (one `aliasKeyRefusal` per member but the node-level `label`, pointed at `properties.KEY`, and the row's own `defaultSort` retirement; the key set is read off the row), + the flat mirror's own objectui#9739 / objectui#11068 tombstones, its objectui#6124 `onNavigate` runtime slot and its objectui#9256 `body` / `children` refusals, by reference; the TS declaration of the node, `ObjectGridSchema` (`../objectql.ts`), is the renderer's post-hoist reading and is paired with the flat `objectql.zod.ts#ObjectGridSchema` 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': From 594cc2c10409729363276757afc57da647594a7a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 17:53:28 +0000 Subject: [PATCH 3/9] test(types): pin the authored object-grid bag arm on both faces (objectui#11276) Both showcase queues pass both faces, a flat objectName on a page object-grid is refused with the bag's prescription, the bag is the spec row by reference, the record source is read in the bag, and the mirror keeps the object-view table slot. Type-level block read by tsconfig.test.json. Claude-Session: https://claude.ai/code/session_01JG2jy8a9su7ia4Hx7zxv42 Co-authored-by: Claude --- .../object-grid-properties-bag-11276.test.ts | 547 ++++++++++++++++++ 1 file changed, 547 insertions(+) create mode 100644 packages/types/src/__tests__/object-grid-properties-bag-11276.test.ts diff --git a/packages/types/src/__tests__/object-grid-properties-bag-11276.test.ts b/packages/types/src/__tests__/object-grid-properties-bag-11276.test.ts new file mode 100644 index 0000000000..bb70ece5ea --- /dev/null +++ b/packages/types/src/__tests__/object-grid-properties-bag-11276.test.ts @@ -0,0 +1,547 @@ +/** + * 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-grid` takes its props in the spec's `properties` bag, and the flat + * spelling retires from both authoring faces (objectui#11276, the + * `object-grid` batch of triage's routing call A). + * + * ## The defect this pins + * + * `@objectstack/spec`'s `ComponentPropsMap['object-grid']` row is the published + * declaration of an authored `object-grid` 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 work queues (`command-center.page.ts` and + * `my-work.page.ts` in objectstack's `examples/app-showcase`), written + * `{ type: 'object-grid', properties: { objectName, columns, … } }`, were + * refused by `safeValidateSchema` — the flat mirror's record-source rule + * found no `objectName` on the node — and by the strict face, which added + * `properties` as an unrecognized key; + * - the flat node `os validate` refuses parsed green on both faces. + * + * Triage's answer on objectui#11276 is the one this arm executes: "A block + * schema takes the `properties` bag, while the flat mirror keeps the + * `object-view` table slot", by reference to the row, with the flat spelling + * refused by name and a prescription that names the bag member. + * + * ## What is particular to this node + * + * - The flat spelling is refused by `flatPropRefusals`, the shared helper. + * `label` is a row member AND a node-level key of the spec's page component + * (its display label), so it stays on the node; `defaultSort` is a member + * the row itself retires, so written flat it keeps the row's retirement. + * Both splits are re-derived from the installed spec below, not transcribed. + * - The flat mirror's own retirements of keys the row does not declare + * (`operators`, `rowSpecActions`, `bulkSpecActions`, `name`, `placeholder`, + * `showFilters`), its `onNavigate` runtime slot and its content-channel + * refusals ride onto the arm by reference. + * - The row carries spec defaults (inside `data`'s `api` provider), so the + * import boundary hands back a rebuilt copy: the pins below hold the key + * set, probe equivalence and type-level equality, and that no default is + * authored into a document. + * - The record-source rule (objectui#11117) stays, read in the bag, and the + * node's `dataSource` binding counts. + * + * ## What did NOT move + * + * The TypeScript `ObjectGridSchema` (`../objectql.ts`) and its zod mirror + * (`ObjectGridSchema` in `../zod/objectql.zod.ts`) stay published: they are the + * node as `ObjectGrid` reads it AFTER `SchemaRenderer` hoists `properties`, and + * as code composes it. The mirror also keeps building the `object-view` `table` + * slot. It is no longer an arm of `AnyComponentSchema`; `ObjectGridBlockSchema` + * is. The last describe block holds that split. The renderer half — the bag + * draws what the flat spelling drew — is `plugin-grid`'s + * `ObjectGrid.propertiesBag-11276.test.tsx`. + * + * ## 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, + ObjectGridPropsSchema as SpecObjectGridPropsSchema, + PageComponentSchema as SpecPageComponentSchema, + type ObjectGridProps as SpecObjectGridProps, +} from '@objectstack/spec/ui'; + +import type { ObjectGridSchema as TsObjectGridSchema, ObjectQLComponentSchema as TsObjectQLComponentSchema } from '../objectql'; +import { + ObjectGridBlockSchema, + ObjectGridSchema, + 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 or a + * retired mirror key written flat accepts nothing, and neither content channel + * accepts anything. + */ +export type assertionObjectGridArmIsTheRow = [ + Expect, SpecObjectGridProps | undefined>>, + Expect, z.input | undefined>>, + Expect, undefined>>, + Expect, undefined>>, + Expect, undefined>>, + Expect, undefined>>, + Expect, undefined>>, + Expect, undefined>>, + Expect, undefined>>, + Expect, undefined>>, + Expect, undefined>>, + Expect, undefined>>, + Expect, undefined>>, + Expect, undefined>>, +]; + +/** + * Every member of the spec row is a member of the arm — refused flat, or + * (`label` alone) kept as the node-level key the spec's page component + * declares. The set is read by reference, so it cannot fall behind the row. + */ +export type assertionEveryRowKeyIsOnTheArm = [ + Expect, never>>, + // `label` is NOT refused: it is `BaseSchema`'s member, which accepts a string. + Expect, undefined>, false>>, +]; + +/** + * The TypeScript twin is RE-DECLARED, not retired: still published, still the + * post-hoist reading with `objectName` required, still a member of the + * TypeScript ObjectQL union. A narrowing or a removal here reddens `tsc`. + */ +export type assertionTwinStaysPublished = [ + Expect>, + Expect>, + Expect, TsObjectGridSchema>>, +]; + +/** Non-vacuity: a bag narrower than the spec's props type is not Equal to it. */ +export type assertionInstrumentFires = [ + Expect, false>>, +]; + +/* ── Runtime fixtures ────────────────────────────────────────────────────── */ + +const COLUMNS = ['title', 'project', 'status', 'priority', 'due_date']; + +/** + * The objectstack showcase's command-center work queue + * (`examples/app-showcase/src/ui/pages/command-center.page.ts`, `cc_queue_g`), + * copied as it writes it. Its lit control is the spec's own page component. + */ +const SHOWCASE_COMMAND_CENTER = { + id: 'cc_queue_g', + type: 'object-grid', + responsiveStyles: { large: { minWidth: '0', display: 'block' } }, + properties: { objectName: 'showcase_task', columns: COLUMNS }, +} as const; + +/** + * The objectstack showcase's personal work queue + * (`examples/app-showcase/src/ui/pages/my-work.page.ts`), copied as it writes it. + */ +const SHOWCASE_MY_WORK = { + type: 'object-grid', + properties: { + objectName: 'showcase_task', + columns: COLUMNS, + filter: [{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }], + }, +} as const; + +/** An object-bound grid with its query keys and display options, in the bag. */ +const OBJECT_BOUND = { + type: 'object-grid', + properties: { + objectName: 'task', + label: 'Tasks', + columns: [{ field: 'title', label: 'Title', width: 200, link: true }, { field: 'status' }], + sort: [{ field: 'title', order: 'asc' }], + pagination: { pageSize: 20 }, + searchableFields: ['title'], + selection: { type: 'multiple' }, + rowActions: ['edit', 'delete'], + bulkActions: ['export'], + exportOptions: { formats: ['csv', 'xlsx'] }, + operations: { delete: false }, + editable: true, + }, +} as const; + +/** Inline rows: a `ViewData` provider block in the bag. */ +const INLINE = { + type: 'object-grid', + properties: { objectName: 'task', data: { provider: 'value', items: [{ id: 1, title: 'Design' }] } }, +} as const; + +/** The object supplied by the node's binding, the spec's per-element `dataSource`. */ +const BOUND = { + type: 'object-grid', + dataSource: { object: 'task', view: 'open' }, + properties: { columns: ['title'] }, +} as const; + +/** The node-level display label the spec's page component declares. */ +const LABELLED = { ...SHOWCASE_MY_WORK, label: 'My queue' } as const; + +/** The flat spelling this batch retires. */ +const FLAT = { type: 'object-grid', objectName: 'task', columns: COLUMNS } 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; +} + +/** Every issue, union branches unfolded and paths made absolute — a child slot reports through one. */ +type Unfolded = { code: string; path: PropertyKey[]; message: string }; +const unfold = (issues: readonly unknown[], prefix: PropertyKey[] = []): Unfolded[] => + issues.flatMap((raw) => { + const issue = raw as Unfolded & { errors?: unknown[][] }; + const path = [...prefix, ...issue.path]; + return [{ code: issue.code, path, message: issue.message }, ...(issue.errors ?? []).flatMap((branch) => unfold(branch, path))]; + }); + +const FACES = [ + ['safeValidateSchema', (doc: unknown) => safeValidateSchema(doc)], + ['the strict authoring face', (doc: unknown) => StrictAnyComponentSchema.safeParse(doc)], +] as const; + +/** Every member of the spec's row, read off the installed spec on each run. */ +const ROW_SHAPE = (SpecObjectGridPropsSchema as unknown as { shape: Record }).shape; +const ROW_KEYS = Object.keys(ROW_SHAPE); + +/** Does this member refuse every value — the shape of a `z.never` retirement? */ +const isNeverMember = (member: unknown): boolean => { + const def = (member as { _zod: { def: { type: string; innerType?: { _zod: { def: { type: string } } } } } })._zod.def; + return (def.type === 'optional' ? def.innerType!._zod.def.type : def.type) === 'never'; +}; + +describe('object-grid validates in the spec\'s `properties` bag (objectui#11276)', () => { + it.each([ + ['the objectstack showcase command-center queue', SHOWCASE_COMMAND_CENTER], + ['the objectstack showcase my-work queue', SHOWCASE_MY_WORK], + ['an object-bound grid with its query keys and display options', OBJECT_BOUND], + ['a grid on inline rows', INLINE], + ['a grid bound only through the node\'s `dataSource`', BOUND], + ['a grid with the node-level display `label`', LABELLED], + ] 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_COMMAND_CENTER, SHOWCASE_MY_WORK, OBJECT_BOUND, INLINE, BOUND, LABELLED]) { + const page = SpecPageComponentSchema.safeParse(doc); + expect(page.success, JSON.stringify(page.success ? null : page.error.issues)).toBe(true); + expect(SpecObjectGridPropsSchema.safeParse(doc.properties).success, JSON.stringify(doc)).toBe(true); + } + }); + + it('reaches the arm at a child slot too — a page holding both showcase queues', () => { + const page = { type: 'page', children: [SHOWCASE_COMMAND_CENTER, SHOWCASE_MY_WORK] }; + for (const [face, parse] of FACES) { + const result = parse(page); + expect(result.success, `${face}: ${JSON.stringify(result.success ? null : result.error.issues)}`).toBe(true); + } + }); + + it('the parse authors no default into the document (objectui#8317): an `api` provider keeps no `method`', () => { + const doc = { type: 'object-grid', properties: { objectName: 'task', data: { provider: 'api', read: { url: '/tasks' } } } }; + const parsed = safeValidateSchema(doc); + expect(parsed.success).toBe(true); + const read = (parsed.data as { properties: { data: { read: Record } } }).properties.data.read; + expect('method' in read).toBe(false); + }); +}); + +describe('the flat spelling is refused by name, with the bag member as the remedy (objectui#11276)', () => { + it('the spec\'s own page component refuses the flat node (lit control: the reason holds for this type)', () => { + const issues = issuesOf(SpecPageComponentSchema.safeParse(FLAT)); + expect(issues.map((i) => i.code)).toEqual(['unrecognized_keys']); + expect((issues[0] as { keys?: string[] }).keys).toEqual(['objectName', 'columns']); + }); + + it.each(FACES)('%s refuses a flat `objectName` on a page `object-grid` with the bag\'s prescription', (_face, parse) => { + const issues = unfold(issuesOf(parse({ type: 'page', children: [FLAT] }))); + const byPath = new Map(issues.map((i) => [i.path.join('.'), i.message])); + expect(byPath.get('children.0.objectName')).toContain('`objectName` → `properties.objectName`'); + expect(byPath.get('children.0.columns')).toContain('`columns` → `properties.columns`'); + // The prescription is the whole document shape, not only the path. + expect(byPath.get('children.0.objectName')).toContain('"properties": {'); + }); + + it('the row is non-trivial, and its two exceptions are read off the installed spec', () => { + expect(ROW_KEYS.length).toBeGreaterThan(30); + expect(ROW_KEYS).toEqual(expect.arrayContaining(['objectName', 'columns', 'data', 'label', 'defaultSort'])); + // `label`: the spec's page component declares it at node level too. + const flatLabel = SpecPageComponentSchema.safeParse({ type: 'object-grid', label: 'Tasks' }); + expect(flatLabel.success).toBe(true); + // `defaultSort`: the row's own retirement. + expect(isNeverMember(ROW_SHAPE.defaultSort)).toBe(true); + }); + + const REFUSED_TO_BAG = ROW_KEYS.filter((key) => key !== 'label' && !isNeverMember(ROW_SHAPE[key])); + + it.each(REFUSED_TO_BAG.map((key) => [key] as const))('a flat `%s` is refused on both faces, by name, toward the bag', (key) => { + for (const [face, parse] of FACES) { + const issues = issuesOf(parse({ ...SHOWCASE_MY_WORK, [key]: true })); + const issue = issues.find((i) => i.path.join('.') === key); + expect(issue, `${face}: ${JSON.stringify(issues)}`).toBeDefined(); + expect(issue!.message, face).toContain(`\`${key}\` → \`properties.${key}\``); + } + }); + + it('a node-level `label` is kept on both faces, as the spec\'s page component keeps it', () => { + for (const [face, parse] of FACES) { + expect(parse(LABELLED).success, face).toBe(true); + expect(parse({ ...SHOWCASE_MY_WORK, label: 7 }).success, face).toBe(false); + } + }); + + it('a flat `defaultSort` keeps the row\'s own retirement, by reference', () => { + expect(ObjectGridBlockSchema.shape.defaultSort).toBe(stripImportedDefaults(SpecObjectGridPropsSchema).shape.defaultSort); + for (const [, parse] of FACES) { + const issue = issuesOf(parse({ ...SHOWCASE_MY_WORK, defaultSort: { field: 'title', order: 'asc' } })) + .find((i) => i.path.join('.') === 'defaultSort'); + expect(issue?.message).toContain('`defaultSort`'); + expect(issue?.message).not.toContain('`properties.defaultSort`'); + } + }); + + it.each(['operators', 'rowSpecActions', 'bulkSpecActions', 'name', 'placeholder', 'showFilters'] as const)( + 'the flat mirror\'s retirement of `%s` rides onto the arm by reference, and is refused on both faces', + (key) => { + expect(ObjectGridBlockSchema.shape[key]).toBe(ObjectGridSchema.shape[key]); + for (const [face, parse] of FACES) { + const issue = issuesOf(parse({ ...SHOWCASE_MY_WORK, [key]: 'x' })).find((i) => i.path.join('.') === key); + expect(issue?.code, face).toBe('invalid_type'); + expect(issue?.message, face).toMatch(/objectui#(9739|11068)/); + } + }, + ); + + it('`onNavigate` stays the runtime slot `ObjectGrid` reads off the node (objectui#6124)', () => { + expect(ObjectGridBlockSchema.shape.onNavigate).toBe(ObjectGridSchema.shape.onNavigate); + const issue = issuesOf(safeValidateSchema({ ...SHOWCASE_MY_WORK, onNavigate: { action: 'toast' } }))[0]; + expect(issue.path).toEqual(['onNavigate']); + expect(issue.message).toContain('RUNTIME SLOT'); + }); + + it.each([ + ['an invented key', 'inventedKey11276'], + // Mirror members the row does not declare: not authored keys of this node. + ['the mirror\'s `emptyState`', 'emptyState'], + ['the mirror\'s `keyboardNavigation`', 'keyboardNavigation'], + ] as const)('%s written flat stays unjudged on the tolerant face and is refused on the strict face', (_label, key) => { + // The strictness control: this arm is `BaseSchema`, whose `.passthrough()` + // every arm keeps, so a key the arm does not declare is not refused by the + // tolerant face. + const doc = { ...SHOWCASE_MY_WORK, [key]: { title: 'x' } }; + 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([key]); + }); +}); + +describe('the bag is judged by the spec row (objectui#11276)', () => { + const bag = (props: Record) => ({ type: 'object-grid', properties: { objectName: 'task', ...props } }); + + it('an undeclared key inside the bag is refused on the TOLERANT face, by name', () => { + const issue = issuesOf(safeValidateSchema(bag({ inventedKey11276: 1 })))[0]; + expect(issue.code).toBe('unrecognized_keys'); + expect(issue.path).toEqual(['properties']); + expect((issue as { keys?: string[] }).keys).toEqual(['inventedKey11276']); + }); + + it('`operators` in the bag gets the spec\'s own prescription: `operations`', () => { + const issue = issuesOf(safeValidateSchema(bag({ operators: { create: true } })))[0]; + expect(issue.code).toBe('unrecognized_keys'); + expect(issue.path).toEqual(['properties']); + expect(issue.message).toContain('`operations`'); + }); + + it.each([ + ['the record form of `filter`', { filter: { status: 'open' } }, ['properties', 'filter'], 'invalid_type'], + ['the ObjectQL tuple form of `filter`', { filter: [['status', '=', 'open']] }, ['properties', 'filter', 0], 'invalid_type'], + ['the retired string `sort` clause (objectui#8221)', { sort: 'title asc' }, ['properties', 'sort'], 'invalid_type'], + ['a bare array under `data` (objectui#8348)', { data: [{ id: 1 }] }, ['properties', 'data'], 'invalid_type'], + ['a non-boolean `editable`', { editable: 'yes' }, ['properties', 'editable'], 'invalid_type'], + ['the retired `defaultSort`', { defaultSort: { field: 'title', order: 'asc' } }, ['properties', 'defaultSort'], 'invalid_type'], + ] as const)('%s is refused at that member', (_label, props, path, code) => { + const issue = issuesOf(safeValidateSchema(bag(props)))[0]; + expect(issue.code).toBe(code); + expect(issue.path).toEqual(path); + }); + + it.each(['emptyState', 'keyboardNavigation', 'showFilters', 'name'] as const)( + 'the row does not declare `%s`, so the bag refuses it on both faces (the spec\'s reading, recorded)', + (key) => { + for (const [face, parse] of FACES) { + const issues = issuesOf(parse(bag({ [key]: { title: 'x' } }))); + expect(issues.some((i) => i.code === 'unrecognized_keys' && (i as { keys?: string[] }).keys?.includes(key)), face) + .toBe(true); + } + }, + ); + + it('the bag is the spec row by reference, through the import boundary', () => { + const member = ObjectGridBlockSchema.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(member)).toEqual(keysOf(SpecObjectGridPropsSchema)); + // The row carries spec defaults, so the boundary hands back a rebuilt copy + // rather than the export itself — the boundary's own memoised copy — and + // that copy accepts exactly what the spec's row accepts. + expect(stripImportedDefaults(SpecObjectGridPropsSchema)).toBe(member); + for (const probe of [ + {}, + { objectName: 'task', columns: COLUMNS }, + { inventedKey11276: 1 }, + { objectName: 7 }, + { operators: {} }, + { emptyState: { title: 'x' } }, + { filter: [{ field: 'a', operator: 'equals', value: 1 }] }, + { filter: { a: 1 } }, + { sort: [{ field: 'a', order: 'asc' }] }, + { pagination: { pageSize: 5 } }, + { data: { provider: 'object', object: 'task' } }, + { data: { provider: 'api', read: { url: '/x', method: 'TRACE' } } }, + { exportOptions: { formats: ['csv'] } }, + { label: { en: 'Tasks' } }, + ]) { + expect(member.safeParse(probe).success, JSON.stringify(probe)).toBe(SpecObjectGridPropsSchema.safeParse(probe).success); + } + }); + + it('the bag\'s description names the row it is', () => { + expect(ObjectGridBlockSchema.shape.properties.description).toContain('`ComponentPropsMap[\'object-grid\']`'); + }); + + it('`dataSource` is judged as the spec binding on both faces', () => { + const adapterShaped = { ...SHOWCASE_MY_WORK, 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(['body', 'children'] as const)('refuses the `%s` content channel by name (objectui#9256)', (key) => { + const issue = issuesOf(safeValidateSchema({ ...SHOWCASE_MY_WORK, [key]: [{ type: 'text', content: 'x' }] }))[0]; + expect(issue.path).toEqual([key]); + expect(issue.message).toContain('objectui#9256'); + expect(issue.message).toContain('`object-grid`'); + }); +}); + +describe('the record source is required, read in the bag (objectui#11276)', () => { + const RECORD_SOURCE_REQUIRED = (issues: Issues) => + issues.filter((i) => (i as { params?: { code?: string } }).params?.code === 'RECORD_SOURCE_REQUIRED'); + + it.each([ + ['the bare node', { type: 'object-grid' }], + ['an empty bag', { type: 'object-grid', properties: {} }], + ['a bag with columns and no source', { type: 'object-grid', properties: { columns: COLUMNS } }], + ['a binding that names no object', { type: 'object-grid', dataSource: { object: '' }, properties: { columns: COLUMNS } }], + ] as const)('%s is refused on both faces, at `properties.objectName`, naming the bag rung and the binding', (_label, doc) => { + for (const [, parse] of FACES) { + const found = RECORD_SOURCE_REQUIRED(issuesOf(parse(doc))); + expect(found).toHaveLength(1); + expect(found[0].path).toEqual(['properties', 'objectName']); + expect(found[0].message).toContain('`properties.objectName`'); + expect(found[0].message).toContain('`dataSource`'); + } + }); + + it('`properties.objectName` alone is a source', () => { + for (const [, parse] of FACES) { + expect(parse({ type: 'object-grid', properties: { objectName: 'task' } }).success).toBe(true); + } + }); + + it('the node\'s `dataSource` binding alone is a source, on both faces — its `object` lands on `objectName`', () => { + for (const [, parse] of FACES) { + expect(parse({ type: 'object-grid', dataSource: { object: 'task' } }).success).toBe(true); + expect(parse(BOUND).success).toBe(true); + } + }); + + it('a source written flat is not a source: it is refused by name and pointed at the bag', () => { + const issues = issuesOf(safeValidateSchema({ type: 'object-grid', objectName: 'task' })); + expect(issues.find((i) => i.path.join('.') === 'objectName')?.message).toContain('`objectName` → `properties.objectName`'); + expect(RECORD_SOURCE_REQUIRED(issues)).toHaveLength(1); + }); + + it('the spec row has no such rule: the spec\'s page component accepts the bare node (the recorded divergence)', () => { + expect(SpecPageComponentSchema.safeParse({ type: 'object-grid' }).success).toBe(true); + expect(SpecObjectGridPropsSchema.safeParse({}).success).toBe(true); + }); +}); + +describe('the arm moved; the post-hoist mirror stayed (objectui#11276)', () => { + const literalsOf = (union: { options: readonly unknown[] }) => + union.options.map((arm) => (arm as { shape: { type: z.ZodLiteral } }).shape.type.value); + + it('`object-grid` is armed by the bag arm, not by the flat mirror', () => { + expect(literalsOf(ObjectQLPublicBlockComponentSchema)).toContain('object-grid'); + expect(literalsOf(ObjectQLComponentSchema)).not.toContain('object-grid'); + expect(ObjectQLPublicBlockComponentSchema.options).toContain(ObjectGridBlockSchema); + }); + + it('the flat mirror is still published and still judges the post-hoist node', () => { + // It is what `ObjectGrid` reads after the hoist, and what `ObjectView` / + // `ListView` compose — neither is an authored `object-grid` node. + expect(ObjectGridSchema.safeParse(FLAT).success).toBe(true); + expect(ObjectGridSchema.safeParse({ ...FLAT, emptyState: { title: 'No tasks' } }).success).toBe(true); + expect(ObjectGridSchema.safeParse({ ...FLAT, exportOptions: ['csv'] }).success).toBe(false); + }); + + it('the flat mirror still builds the `object-view` `table` slot', () => { + const slot = ObjectViewSchema.shape.table.unwrap(); + expect(slot.safeParse({ columns: ['title'], pagination: { pageSize: 20 } }).success).toBe(true); + // The slot's own withheld keys stay refused there (objectui#10976). + expect(slot.safeParse({ emptyState: { title: 'x' } }).success).toBe(false); + for (const [face, parse] of FACES) { + const view = { type: 'object-view', objectName: 'task', table: { columns: ['title'], pagination: { pageSize: 20 } } }; + expect(parse(view).success, face).toBe(true); + } + }); +}); From 6273af4229e2c086e9742cc382711fe7f55f2e4f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 18:06:02 +0000 Subject: [PATCH 4/9] docs: objectui's authored object-grid nodes write their props in the properties bag (objectui#11276) The root README, the schema reference, the fields grid page, the CRUD and layout guides, the ObjectOS integration guide, the plugin-grid page and README, the data adapter pages and both schema-catalog object-grid entries move to { type: 'object-grid', properties: { ... } }. Component mounts () keep the flat node they read. A render pin shows the bag and a stored flat node draw the same grid. Claude-Session: https://claude.ai/code/session_01JG2jy8a9su7ia4Hx7zxv42 Co-authored-by: Claude --- README.md | 24 +- content/docs/api/schema-reference.md | 75 ++--- content/docs/fields/grid.mdx | 21 +- content/docs/guide/building-crud-app.md | 12 +- content/docs/guide/layout.md | 2 +- content/docs/guide/objectos-integration.mdx | 98 +++--- content/docs/plugins/plugin-grid.mdx | 231 ++++++++------ content/docs/utilities/data-objectstack.mdx | 5 +- content/docs/utilities/index.md | 11 +- .../plugin-grid/object-grid-columns.json | 26 +- .../object-grid-selection-summaries.json | 28 +- packages/plugin-grid/README.md | 287 +++++++++++------- .../ObjectGrid.propertiesBag-11276.test.tsx | 122 ++++++++ .../__tests__/guideCrudAppRenders.test.tsx | 6 +- packages/types/src/zod/README.md | 9 +- 15 files changed, 601 insertions(+), 356 deletions(-) create mode 100644 packages/plugin-grid/src/__tests__/ObjectGrid.propertiesBag-11276.test.tsx diff --git a/README.md b/README.md index 2f3fe1794c..4175f4340a 100644 --- a/README.md +++ b/README.md @@ -212,17 +212,19 @@ published is not an error: the expression is returned as its own source text. ```json { "type": "object-grid", - "objectName": "user", - "title": "Users", - "columns": [ - { "field": "name", "label": "Name", "sortable": true }, - { "field": "email", "label": "Email" }, - { "field": "role", "label": "Role" }, - { "field": "status", "label": "Status" }, - { "field": "created_at", "label": "Joined" } - ], - "showSearch": true, - "operations": { "create": true, "read": true, "update": true, "delete": true, "export": true } + "properties": { + "objectName": "user", + "title": "Users", + "columns": [ + { "field": "name", "label": "Name", "sortable": true }, + { "field": "email", "label": "Email" }, + { "field": "role", "label": "Role" }, + { "field": "status", "label": "Status" }, + { "field": "created_at", "label": "Joined" } + ], + "showSearch": true, + "operations": { "create": true, "read": true, "update": true, "delete": true, "export": true } + } } ``` diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index d9028dfc49..2a0e37b5e8 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -730,37 +730,36 @@ These schemas integrate with [ObjectStack](https://objectstack.ai) for automatic A data grid that auto-fetches from an ObjectQL object definition. Includes search, filters, pagination, grouping, and inline editing. +An authored `object-grid` node takes its props in its `properties` bag, whose members are `@objectstack/spec`'s `ComponentPropsMap['object-grid']` 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#11276). `SchemaRenderer` hoists the bag onto the node before `ObjectGrid` runs, so `ObjectGridSchema` is the node as the renderer reads it, and the table below lists its members. Base props such as `description` and `className` stay on the node, beside the bag. + ```json { "type": "object-grid", - "objectName": "Contact", - "title": "All Contacts", "description": "Manage your contacts", - "searchableFields": ["name", "email", "company"], - "resizable": true, - "columns": [ - { "field": "name" }, - { "field": "email" }, - { "field": "company" }, - { "field": "phone" }, - { "field": "status", "label": "Status", "sortable": true } - ], - "sort": [{ "field": "name", "order": "asc" }], - "operations": { - "create": true, - "update": true, - "delete": true, - "export": true - }, - "rowActions": ["edit", "delete"], - "pagination": { - "pageSize": 25, - "pageSizeOptions": [10, 25, 50, 100] - }, - "emptyState": { - "title": "No contacts yet", - "message": "Contacts you add appear here.", - "icon": "users" + "properties": { + "objectName": "Contact", + "title": "All Contacts", + "searchableFields": ["name", "email", "company"], + "resizable": true, + "columns": [ + { "field": "name" }, + { "field": "email" }, + { "field": "company" }, + { "field": "phone" }, + { "field": "status", "label": "Status", "sortable": true } + ], + "sort": [{ "field": "name", "order": "asc" }], + "operations": { + "create": true, + "update": true, + "delete": true, + "export": true + }, + "rowActions": ["edit", "delete"], + "pagination": { + "pageSize": 25, + "pageSizeOptions": [10, 25, 50, 100] + } } } ``` @@ -770,7 +769,9 @@ also author `showFilters` (now retired on this node, below), `striped` and `operations.read`, which nothing reads, and `selection: { enabled, mode }` and `pagination.enabled`, which the validator refuses: selection is spelled `selection: { type: 'multiple' }` (`'none'`, `'single'` or `'multiple'`), and `pagination` declares no on switch — its -presence enables paging. +presence enables paging. It also authored `emptyState`, which the grid reads but +the spec's row does not declare, so it is not authorable in a document today (see +its row below). | Property | Type | Description | |----------|------|-------------| @@ -789,7 +790,7 @@ presence enables paging. | `grouping` | `GroupingConfig` | Row grouping configuration. **Server-side**: the set of groups, every group count and every per-group aggregation come from the group header query (`dataSource.queryGroupHeaders`), and each group's rows are paged by the server. Rows handed in whole are grouped in the browser (exact); over a data source with no header query, a grid that fetches its own rows refuses grouping with an error naming `queryGroupHeaders`. | | `frozenColumns` | `number` | Number of columns frozen on scroll. | | `navigation` | `ViewNavigationConfig` | SPA navigation configuration. | -| `emptyState` | `{ title?, message?, icon? }` | Drawn in place of an empty table: a Lucide `icon`, a `title` (default: the table's "No results found") and a `message` (default: none). Not drawn when a term in the grid's own server-side search box emptied it — the table and its search box stay (objectui#11068). | +| `emptyState` | `{ title?, message?, icon? }` | Drawn in place of an empty table: a Lucide `icon`, a `title` (default: the table's "No results found") and a `message` (default: none). Not drawn when a term in the grid's own server-side search box emptied it — the table and its search box stay (objectui#11068). **Not authorable in a document today:** the spec's `object-grid` row does not declare it, so `objectui validate` refuses it in the `properties` bag, and the strict face refuses it on the node; a host mounting `` or composing the node in code can set it. | > **`name`, `placeholder`, `rowSpecActions` and `bulkSpecActions` are retired on > this node (objectui#11068).** Nothing ever read them: `rowSpecActions` / @@ -1282,8 +1283,10 @@ A toggle control that switches between different view types (list, grid, kanban, "icon": "List", "schema": { "type": "object-grid", - "objectName": "Contact", - "columns": ["name", "email", "phone"] + "properties": { + "objectName": "Contact", + "columns": ["name", "email", "phone"] + } } }, { @@ -1366,10 +1369,12 @@ Schemas are designed to compose. Nest any `SchemaNode` inside another to build c }, { "type": "object-grid", - "objectName": "Lead", - "title": "All Leads", - "showSearch": true, - "columns": ["name", "company", "status", "value"] + "properties": { + "objectName": "Lead", + "title": "All Leads", + "showSearch": true, + "columns": ["name", "company", "status", "value"] + } } ] } diff --git a/content/docs/fields/grid.mdx b/content/docs/fields/grid.mdx index 7499ce9403..294cb582b9 100644 --- a/content/docs/fields/grid.mdx +++ b/content/docs/fields/grid.mdx @@ -236,20 +236,23 @@ For advanced grid features, use the grid plugin (`@object-ui/plugin-grid`): { type: 'grid', name: 'items', columns: [...] } // Advanced grid with full features (requires plugin) -// Keys sit on the node — a `props` envelope is never read by the renderer +// The block's props go in its `properties` bag (the spec's +// `ComponentPropsMap['object-grid']` row) — a `props` envelope is never read { type: 'object-grid', // the registered type name — there is no `plugin:grid` - bind: 'items', // resolves the array from the surrounding data scope - columns: [ // spec `ListColumn[]` — each entry is keyed by `field` - { field: 'product', label: 'Product' }, - { field: 'quantity', label: 'Quantity', sortable: false } - ], - editable: true, // Plugin feature: inline cell editing - pagination: { pageSize: 20 } // Plugin feature: pagination + bind: 'items', // node-level: resolves the array from the surrounding data scope + properties: { + columns: [ // spec `ListColumn[]` — each entry is keyed by `field` + { field: 'product', label: 'Product' }, + { field: 'quantity', label: 'Quantity', sortable: false } + ], + editable: true, // Plugin feature: inline cell editing + pagination: { pageSize: 20 } // Plugin feature: pagination + } } ``` -**Note**: `editable` and `pagination` are read off the node by `@object-ui/plugin-grid`, not by the basic grid field. Sorting and search are **not** node keys: column sorting is on by default and is turned off per column (`{ field, sortable: false }`), and there is no node-level `filterable` — a grid that fetches its own rows declares its query with `objectName` plus `filter` / `sort`. +**Note**: `editable` and `pagination` are read by `@object-ui/plugin-grid` (after `SchemaRenderer` hoists the bag onto the node), not by the basic grid field. Sorting and search are **not** node keys: column sorting is on by default and is turned off per column (`{ field, sortable: false }`), and there is no node-level `filterable` — a grid that fetches its own rows declares its query with `objectName` plus `filter` / `sort`. ## Use Cases diff --git a/content/docs/guide/building-crud-app.md b/content/docs/guide/building-crud-app.md index a7089ec717..adbd2802d2 100644 --- a/content/docs/guide/building-crud-app.md +++ b/content/docs/guide/building-crud-app.md @@ -227,7 +227,7 @@ function App() { @@ -253,7 +253,9 @@ a grid that draws its header and nothing else: `dataSource` written on the block itself reaches that one block and never becomes context for anything under it. - **The object is named by `objectName`.** That is the key each of these blocks - declares as required; a differently-spelled key (`object`) is not read, so the + declares as required — inside the `properties` bag on `object-grid` and + `object-form`, as the spec's page component declares their props, and on the + node on `detail-view`; a differently-spelled key (`object`) is not read, so the block has no object to query. Neither mistake used to say anything — the block simply rendered empty. Both now @@ -278,7 +280,7 @@ carries a data source of its own: ```tsx { setEditId(row.id); setShowForm(true); }} /> @@ -354,8 +356,8 @@ so this block does not offer that fallback. in the toolbar — on by default — and typing there drives `DataSource.find()`'s `$search` parameter directly; there is no separate query-param key to author. Add `searchableFields: ['title', 'description']` -to the schema to narrow which fields the server matches; leave it out and the -server decides. +to the grid's `properties` bag to narrow which fields the server matches; leave +it out and the server decides. ## Step 8: Add a Detail View diff --git a/content/docs/guide/layout.md b/content/docs/guide/layout.md index ef9919128d..65a79824db 100644 --- a/content/docs/guide/layout.md +++ b/content/docs/guide/layout.md @@ -173,7 +173,7 @@ A `page` node has no action row of its own. Buttons are NODES, and they go in `c }, { "type": "object-grid", - "objectName": "products" + "properties": { "objectName": "products" } } ] } diff --git a/content/docs/guide/objectos-integration.mdx b/content/docs/guide/objectos-integration.mdx index f1bc5d485b..ca105568da 100644 --- a/content/docs/guide/objectos-integration.mdx +++ b/content/docs/guide/objectos-integration.mdx @@ -259,7 +259,8 @@ const sysUserView: ObjectViewSchema = { ```typescript import { ObjectStackAdapter } from '@object-ui/data-objectstack'; -import type { ObjectGridSchema } from '@object-ui/types'; +import type { BaseSchema } from '@object-ui/types'; +import type { ObjectGridProps } from '@objectstack/spec/ui'; const dataSource = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000/api' @@ -269,21 +270,24 @@ const dataSource = new ObjectStackAdapter({ // `` from `@object-ui/react` — // never as a schema key. A node's `dataSource` is the per-element BINDING // (`{ object, view?, filter?, sort?, limit? }`), not the adapter, and -// `SchemaRenderer` strips it from the props it spreads. -const schema: ObjectGridSchema = { +// `SchemaRenderer` strips it from the props it spreads. The grid's own props go +// in its `properties` bag — the spec's `ComponentPropsMap['object-grid']` row. +const schema: BaseSchema = { type: 'object-grid', - objectName: 'contact', - // Grid filter, JSON-rules form: an array of `{ field, operator, value }` - // entries, AND-ed together. `operator` takes the canonical view-filter - // vocabulary (`equals`, `greater_than_or_equal`, ...). - filter: [ - { field: 'status', operator: 'equals', value: 'active' }, - { field: 'created_date', operator: 'greater_than_or_equal', value: '2024-01-01' } - ], - // ObjectQL sorting - sort: [ - { field: 'created_date', order: 'desc' } - ] + properties: { + objectName: 'contact', + // Grid filter, JSON-rules form: an array of `{ field, operator, value }` + // entries, AND-ed together. `operator` takes the canonical view-filter + // vocabulary (`equals`, `greater_than_or_equal`, ...). + filter: [ + { field: 'status', operator: 'equals', value: 'active' }, + { field: 'created_date', operator: 'greater_than_or_equal', value: '2024-01-01' } + ], + // ObjectQL sorting + sort: [ + { field: 'created_date', order: 'desc' } + ], + } satisfies ObjectGridProps, }; ``` @@ -314,12 +318,16 @@ function ContactList() { if (loading) return
Loading...
; if (error) return
Error: {error.message}
; + // The grid's props go in its `properties` bag. The rows `useViewData` + // fetched are handed over as inline `ViewData` (there is no `rowData` key). return ( ); @@ -471,26 +479,24 @@ ComponentRegistry.register('my-custom-widget', MyCustomWidget, { ### Event Handling & Callbacks -{/* doc-snippet: fragment — a SHAPE excerpt continuing the "Using ObjectQL for Queries" block above, which is where `dataSource` is constructed: a bare object literal at statement position parses as a block with labels (measured: TS1005 x3) */} +A metadata document cannot carry a function, so a callback is never a schema +key: the grid's row callbacks (`onRowClick`, `onCellChange`, …) are **component +props**, passed to the renderer. The grid's own props go in its `properties` +bag, and with `editable` an edited cell saves through the adapter the +`SchemaRendererProvider` above it supplies, with no callback to wire. -```typescript -{ - type: 'object-grid', - objectName: 'contact', - dataSource, - callbacks: { - onRowClicked: (event) => { +```tsx +import { SchemaRenderer } from '@object-ui/react'; + +export const ContactGrid = () => ( + { // Navigate to detail page - window.location.href = `/contact/${event.data.id}`; - }, - onCellValueChanged: async (event) => { - // Auto-save on edit - await dataSource.update('contact', event.data.id, { - [event.column.field]: event.newValue - }); - } - } -} + window.location.href = `/contact/${row.id}`; + }} + /> +); ``` ### Reacting to Data Changes @@ -523,17 +529,21 @@ unsubscribe(); ### From Retool -{/* 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 -// Retool table → ObjectUI Grid +// Retool table → ObjectUI Grid. The props go in the node's `properties` bag +// (the spec's `ComponentPropsMap['object-grid']` row): row selection is +// `selection`, the export menu is `exportOptions`, and the adapter comes from +// `SchemaRendererProvider`, never from the node. { type: 'object-grid', - objectName: 'users', - dataSource, - editable: true, - rowSelection: 'multiple', - exportConfig: { enabled: true } + properties: { + objectName: 'users', + editable: true, + selection: { type: 'multiple' }, + exportOptions: { formats: ['csv', 'xlsx'] }, + }, } ``` @@ -622,9 +632,9 @@ test('renders contact grid', () => { ); expect(getByText('Contact')).toBeInTheDocument(); diff --git a/content/docs/plugins/plugin-grid.mdx b/content/docs/plugins/plugin-grid.mdx index 57db1c2b88..0a120884ed 100644 --- a/content/docs/plugins/plugin-grid.mdx +++ b/content/docs/plugins/plugin-grid.mdx @@ -58,19 +58,28 @@ authored here. ### Grid -A grid node is an `ObjectGridSchema`: one required `objectName`, plus keys drawn -from the list this plugin **declares** as its authoring surface +An authored grid node takes its props in its `properties` bag, whose members are +`@objectstack/spec`'s `ComponentPropsMap['object-grid']` row: one required +`objectName` — or the node's `dataSource` binding naming the object — plus keys +drawn from the list this plugin **declares** as its authoring surface (`GRID_QUERY_INPUTS`, `packages/plugin-grid/src/index.tsx`) — the same list that feeds the designer panel and the generated `sdui-intrinsics.d.ts`, so what is -authorable here is what the renderer reads. +authorable here is what the renderer reads. `objectui validate` judges the bag +against that row and refuses a prop written flat on the node by name, naming +where it goes (`Did you mean objectName → properties.objectName?`), as the spec's +own page component does (objectui#11276). `SchemaRenderer` hoists the bag onto +the node before the grid runs, so `ObjectGridSchema` (`@object-ui/types`) is the +node as the grid reads it. ```json { "type": "object-grid", - "objectName": "users", - "columns": ["name", "email"], - "sort": [{ "field": "created", "order": "desc" }], - "pagination": { "pageSize": 20 } + "properties": { + "objectName": "users", + "columns": ["name", "email"], + "sort": [{ "field": "created", "order": "desc" }], + "pagination": { "pageSize": 20 } + } } ``` @@ -79,7 +88,7 @@ Note the `type`. It is `object-grid` (or `view:grid`), **never `grid`** — bare `columns` is a column **count**. See [Registration](#registration-is-a-side-effect-of-the-import) below for why this plugin deliberately does not claim it. -| Key | Type | Notes | +| Key (in `properties`) | Type | Notes | | --- | --- | --- | | `objectName` | `string` (**required**) | The object queried. There is no `object`. | | `columns` | `string[] \| ListColumn[]` | Field names or column objects — see below. | @@ -94,7 +103,7 @@ below for why this plugin deliberately does not claim it. | `editable` / `singleClickEdit` | `boolean` | Inline editing — see [Inline Editing](#inline-editing). | | `navigation` | `NavigationConfig` | What a row click does: `{ mode: 'page' \| 'drawer' \| 'modal' \| 'split' \| 'none', … }`. | | `operations` | `object` | Toggles the built-in CRUD/export/import affordances, e.g. `{ delete: false }`. | -| `rowHeight`, `frozenColumns`, `resizable`, `reorderableColumns`, `showColumnTypeIcons`, `rowColor`, `conditionalFormatting`, `aggregations`, `exportOptions`, `className` | | The rest of the declared surface. | +| `rowHeight`, `frozenColumns`, `resizable`, `reorderableColumns`, `showColumnTypeIcons`, `rowColor`, `conditionalFormatting`, `aggregations`, `exportOptions` | | The rest of the declared surface. (`className` is a base prop: it stays on the node, beside the bag.) | | `grouping` | `GroupingConfig` | Row grouping — **server-side**: group set, counts and aggregations come from the query, rows are paged per group; see [Grouping is server-side](#grouping-is-server-side). | **There is no `sortable`, `filterable`, `onRowClick`, `onSelectionChange`, @@ -105,7 +114,7 @@ booleans do not exist at all; the five `on*` names are **component props** #### `description` and `emptyState` -Two more keys the grid honours on its own node (objectui#11068): +Two more keys the grid honours on the node it reads (objectui#11068): - `description` — one line of help text drawn above the grid. A string, or an inline locale map resolved against the display locale the way `label` is. @@ -116,19 +125,24 @@ Two more keys the grid honours on its own node (objectui#11068): search box stay, so the term can be cleared. Leave the key out and an empty grid draws the table's own empty row, as before. +`description` is a base prop, so it sits on the node beside the bag: + ```json { "type": "object-grid", - "objectName": "contacts", "description": "Everyone you work with", - "emptyState": { "title": "No contacts yet", "message": "Contacts you add appear here.", "icon": "users" } + "properties": { "objectName": "contacts" } } ``` -Neither is in `GRID_QUERY_INPUTS` yet. That list may only declare keys the -upstream protocol's `object-grid` row accepts, and that row declares neither -today — so the SDUI parser reports an authored `emptyState` as `unknown-prop` (a -warning), while `description` is a base prop it accepts on every node. +`emptyState` is **not authorable in a document today**. The upstream protocol's +`object-grid` row does not declare it, so `objectui validate` refuses it inside +the `properties` bag (the row's own refusal) and, on the node, its strict face +refuses it as an unknown key — as `os validate` does. It stays a key of the node +the grid reads: a host that mounts `` itself, or composes +the node in code, can set it. For the same reason it is not in +`GRID_QUERY_INPUTS` yet: that list may only declare keys the row accepts, so the +SDUI parser reports an authored `emptyState` as `unknown-prop` (a warning). `name`, `placeholder`, `rowSpecActions` and `bulkSpecActions` are **retired** on this node (objectui#11068): nothing ever read them, and both faces of @@ -276,15 +290,17 @@ than this package's component API. ```json { "type": "object-grid", - "objectName": "users", - "columns": [ - { "field": "name", "label": "Name", "width": 200, "sortable": true }, - { "field": "email", "label": "Email" }, - { "field": "role", "label": "Role" }, - { "field": "status", "label": "Status", "type": "select" } - ], - "sort": [{ "field": "name", "order": "asc" }], - "pagination": { "pageSize": 20 } + "properties": { + "objectName": "users", + "columns": [ + { "field": "name", "label": "Name", "width": 200, "sortable": true }, + { "field": "email", "label": "Email" }, + { "field": "role", "label": "Role" }, + { "field": "status", "label": "Status", "type": "select" } + ], + "sort": [{ "field": "name", "order": "asc" }], + "pagination": { "pageSize": 20 } + } } ``` @@ -298,13 +314,15 @@ render alike. ```json { "type": "object-grid", - "objectName": "opportunities", - "columns": [ - { "field": "stage", "type": "select" }, - { "field": "amount", "type": "currency", "align": "right", "summary": "sum" }, - { "field": "name", "link": true, "prefix": { "field": "health", "type": "badge" } }, - { "field": "owner_id", "action": "reassign" } - ] + "properties": { + "objectName": "opportunities", + "columns": [ + { "field": "stage", "type": "select" }, + { "field": "amount", "type": "currency", "align": "right", "summary": "sum" }, + { "field": "name", "link": true, "prefix": { "field": "health", "type": "badge" } }, + { "field": "owner_id", "action": "reassign" } + ] + } } ``` @@ -323,17 +341,19 @@ under `items`. ```json { "type": "object-grid", - "objectName": "users", - "columns": [ - { "field": "name", "label": "Name" }, - { "field": "email", "label": "Email" } - ], - "data": { - "provider": "value", - "items": [ - { "id": 1, "name": "John Doe", "email": "john@example.com", "status": "Active" }, - { "id": 2, "name": "Jane Smith", "email": "jane@example.com", "status": "Active" } - ] + "properties": { + "objectName": "users", + "columns": [ + { "field": "name", "label": "Name" }, + { "field": "email", "label": "Email" } + ], + "data": { + "provider": "value", + "items": [ + { "id": 1, "name": "John Doe", "email": "john@example.com", "status": "Active" }, + { "id": 2, "name": "Jane Smith", "email": "jane@example.com", "status": "Active" } + ] + } } } ``` @@ -343,10 +363,12 @@ under `items`. ```json { "type": "object-grid", - "objectName": "users", - "columns": ["name", "email"], - "selection": { "type": "multiple" }, - "bulkActions": ["delete", "export"] + "properties": { + "objectName": "users", + "columns": ["name", "email"], + "selection": { "type": "multiple" }, + "bulkActions": ["delete", "export"] + } } ``` @@ -362,9 +384,11 @@ see [Row callbacks are component props](#row-callbacks-are-component-props). ```json { "type": "object-grid", - "objectName": "users", - "columns": ["name", "email"], - "pagination": { "pageSize": 10, "pageSizeOptions": [10, 20, 50, 100] } + "properties": { + "objectName": "users", + "columns": ["name", "email"], + "pagination": { "pageSize": 10, "pageSizeOptions": [10, 20, 50, 100] } + } } ``` @@ -447,15 +471,17 @@ metadata `filter` (lowered to `$filter`) and search is `searchableFields` ```json { "type": "object-grid", - "objectName": "users", - "columns": [ - { "field": "name", "label": "Name" }, - { "field": "email", "label": "Email" }, - { "field": "created_at", "label": "Created", "type": "datetime" } - ], - "filter": [{ "field": "status", "operator": "equals", "value": "active" }], - "searchableFields": ["name", "email"], - "pagination": { "pageSize": 20 } + "properties": { + "objectName": "users", + "columns": [ + { "field": "name", "label": "Name" }, + { "field": "email", "label": "Email" }, + { "field": "created_at", "label": "Created", "type": "datetime" } + ], + "filter": [{ "field": "status", "operator": "equals", "value": "active" }], + "searchableFields": ["name", "email"], + "pagination": { "pageSize": 20 } + } } ``` @@ -475,12 +501,14 @@ is no top-level `sortable` switch. ```json { "type": "object-grid", - "objectName": "users", - "sort": [{ "field": "created", "order": "desc" }], - "columns": [ - { "field": "name", "label": "Name" }, - { "field": "email", "label": "Email", "sortable": false } - ] + "properties": { + "objectName": "users", + "sort": [{ "field": "created", "order": "desc" }], + "columns": [ + { "field": "name", "label": "Name" }, + { "field": "email", "label": "Email", "sortable": false } + ] + } } ``` @@ -493,13 +521,15 @@ search over the fields named in `searchableFields`. ```json { "type": "object-grid", - "objectName": "users", - "filter": [ - { "field": "status", "operator": "equals", "value": "active" }, - { "field": "created", "operator": "after", "value": "2026-01-01" } - ], - "searchableFields": ["name", "email"], - "columns": ["name", "email", "status"] + "properties": { + "objectName": "users", + "filter": [ + { "field": "status", "operator": "equals", "value": "active" }, + { "field": "created", "operator": "after", "value": "2026-01-01" } + ], + "searchableFields": ["name", "email"], + "columns": ["name", "email", "status"] + } } ``` @@ -513,11 +543,13 @@ carrying callbacks. ```json { "type": "object-grid", - "objectName": "users", - "columns": ["name", "email"], - "rowActions": ["view", "edit", "delete"], - "selection": { "type": "multiple" }, - "bulkActions": ["delete", "export"] + "properties": { + "objectName": "users", + "columns": ["name", "email"], + "rowActions": ["view", "edit", "delete"], + "selection": { "type": "multiple" }, + "bulkActions": ["delete", "export"] + } } ``` @@ -562,7 +594,8 @@ without any host code. whether the generic Edit / Delete entries exist at all, identically for every row. When the refusal belongs to one RECORD — a system field a designer may not drop — use `rowOperations`, a component prop called with a row record that -answers for that row alone: +answers for that row alone. Mounting `` directly hands it the node as +it reads it, so its `schema` prop is the flat `ObjectGridSchema`, with no bag: ```tsx import { ObjectGrid } from '@object-ui/plugin-grid'; @@ -604,15 +637,17 @@ Enable inline cell editing for quick data updates: ```json { "type": "object-grid", - "objectName": "users", - "columns": [ - { "field": "id", "label": "ID" }, - { "field": "name", "label": "Name" }, - { "field": "email", "label": "Email" }, - { "field": "status", "label": "Status", "type": "select" } - ], - "editable": true, - "singleClickEdit": false + "properties": { + "objectName": "users", + "columns": [ + { "field": "id", "label": "ID" }, + { "field": "name", "label": "Name" }, + { "field": "email", "label": "Email" }, + { "field": "status", "label": "Status", "type": "select" } + ], + "editable": true, + "singleClickEdit": false + } } ``` @@ -643,14 +678,16 @@ their own once a row has pending changes: ```json { "type": "object-grid", - "objectName": "products", - "columns": [ - { "field": "sku", "label": "SKU" }, - { "field": "name", "label": "Name" }, - { "field": "price", "label": "Price", "type": "currency", "align": "right" }, - { "field": "stock", "label": "Stock", "type": "number", "align": "right" } - ], - "editable": true + "properties": { + "objectName": "products", + "columns": [ + { "field": "sku", "label": "SKU" }, + { "field": "name", "label": "Name" }, + { "field": "price", "label": "Price", "type": "currency", "align": "right" }, + { "field": "stock", "label": "Stock", "type": "number", "align": "right" } + ], + "editable": true + } } ``` @@ -695,7 +732,9 @@ package's export surface, and both names are taken elsewhere by different things — `GridSchema` in `@object-ui/types` is the CSS Grid **layout** container, and `GridColumn` in `@object-ui/fields` is a column of the line-items form widget (keyed `name`). The data-grid pair is -`ObjectGridSchema` + `ListColumn`. +`ObjectGridSchema` + `ListColumn`. `ObjectGridSchema` types the component's +`schema` prop — the node as the grid reads it, after `SchemaRenderer` hoists an +authored node's `properties` bag onto it — so its keys sit flat here. ```typescript import type { ObjectGridSchema, ListColumn } from '@object-ui/types'; diff --git a/content/docs/utilities/data-objectstack.mdx b/content/docs/utilities/data-objectstack.mdx index e683d4a429..2e3058e761 100644 --- a/content/docs/utilities/data-objectstack.mdx +++ b/content/docs/utilities/data-objectstack.mdx @@ -64,13 +64,14 @@ React wiring lives in `@object-ui/react`, not here. Wrap your tree in ```tsx import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; import { createObjectStackAdapter } from '@object-ui/data-objectstack'; -import type { ObjectGridSchema } from '@object-ui/types'; +import type { BaseSchema } from '@object-ui/types'; const dataSource = createObjectStackAdapter({ baseUrl: 'https://api.example.com', }); -const mySchema: ObjectGridSchema = { type: 'object-grid', objectName: 'user' }; +// The block's props go in its `properties` bag, as `@objectstack/spec` declares them. +const mySchema: BaseSchema = { type: 'object-grid', properties: { objectName: 'user' } }; function App() { return ( diff --git a/content/docs/utilities/index.md b/content/docs/utilities/index.md index 914e8cfbe2..43ef880d17 100644 --- a/content/docs/utilities/index.md +++ b/content/docs/utilities/index.md @@ -165,17 +165,20 @@ through `@object-ui/react`'s `SchemaRendererProvider`: ```tsx import { createObjectStackAdapter } from '@object-ui/data-objectstack'; import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; -import type { ObjectGridSchema } from '@object-ui/types'; +import type { BaseSchema } from '@object-ui/types'; const dataSource = createObjectStackAdapter({ baseUrl: 'https://api.example.com', token: 'your-api-token', // optional when auth is handled elsewhere }); -const schema: ObjectGridSchema = { +// The block's props go in its `properties` bag, as `@objectstack/spec` declares them. +const schema: BaseSchema = { type: 'object-grid', - objectName: 'account', - columns: ['name', 'owner', 'created'], + properties: { + objectName: 'account', + columns: ['name', 'owner', 'created'], + }, }; export function App() { diff --git a/examples/schema-catalog/src/schemas/plugin-grid/object-grid-columns.json b/examples/schema-catalog/src/schemas/plugin-grid/object-grid-columns.json index 4f90efa6b7..21a1308bc3 100644 --- a/examples/schema-catalog/src/schemas/plugin-grid/object-grid-columns.json +++ b/examples/schema-catalog/src/schemas/plugin-grid/object-grid-columns.json @@ -1,15 +1,17 @@ { "type": "object-grid", - "objectName": "users", - "label": "User Directory", - "columns": [ - { "field": "name", "label": "Full Name", "width": 200, "link": true }, - { "field": "email", "label": "Email" }, - { "field": "role", "type": "select" }, - { "field": "department", "label": "Department" }, - { "field": "status", "type": "select", "align": "right" } - ], - "sort": [{ "field": "name", "order": "asc" }], - "searchableFields": ["name", "email", "department"], - "pagination": { "pageSize": 5 } + "properties": { + "objectName": "users", + "label": "User Directory", + "columns": [ + { "field": "name", "label": "Full Name", "width": 200, "link": true }, + { "field": "email", "label": "Email" }, + { "field": "role", "type": "select" }, + { "field": "department", "label": "Department" }, + { "field": "status", "type": "select", "align": "right" } + ], + "sort": [{ "field": "name", "order": "asc" }], + "searchableFields": ["name", "email", "department"], + "pagination": { "pageSize": 5 } + } } diff --git a/examples/schema-catalog/src/schemas/plugin-grid/object-grid-selection-summaries.json b/examples/schema-catalog/src/schemas/plugin-grid/object-grid-selection-summaries.json index 51f58bd0a6..dd9b52b817 100644 --- a/examples/schema-catalog/src/schemas/plugin-grid/object-grid-selection-summaries.json +++ b/examples/schema-catalog/src/schemas/plugin-grid/object-grid-selection-summaries.json @@ -1,16 +1,18 @@ { "type": "object-grid", - "objectName": "users", - "label": "User Directory", - "columns": [ - { "field": "name", "label": "Full Name", "summary": "count_filled" }, - { "field": "email", "label": "Email" }, - { "field": "department", "label": "Department", "summary": { "type": "count_unique" } }, - { "field": "role", "type": "select" }, - { "field": "status", "type": "select" } - ], - "selection": { "type": "multiple" }, - "rowActions": ["view", "edit"], - "bulkActions": ["export"], - "pagination": { "pageSize": 5 } + "properties": { + "objectName": "users", + "label": "User Directory", + "columns": [ + { "field": "name", "label": "Full Name", "summary": "count_filled" }, + { "field": "email", "label": "Email" }, + { "field": "department", "label": "Department", "summary": { "type": "count_unique" } }, + { "field": "role", "type": "select" }, + { "field": "status", "type": "select" } + ], + "selection": { "type": "multiple" }, + "rowActions": ["view", "edit"], + "bulkActions": ["export"], + "pagination": { "pageSize": 5 } + } } diff --git a/packages/plugin-grid/README.md b/packages/plugin-grid/README.md index e553630147..acad818916 100644 --- a/packages/plugin-grid/README.md +++ b/packages/plugin-grid/README.md @@ -44,11 +44,13 @@ the schema types below resolve: import '@object-ui/plugin-grid'; // The data grid is `object-grid`, and it queries an object — see below for why -// this is not `type: 'grid'`. +// this is not `type: 'grid'`. Its props go in the node's `properties` bag. const schema = { type: 'object-grid', - objectName: 'users', - columns: ['name', 'email'] + properties: { + objectName: 'users', + columns: ['name', 'email'] + } }; ``` @@ -159,26 +161,36 @@ contract rather than this package's component API. ### Grid -A grid node is an `ObjectGridSchema`: one required `objectName`, and keys drawn -from the list this package **declares** as its authoring surface +An authored grid node takes its props in its `properties` bag, whose members are +`@objectstack/spec`'s `ComponentPropsMap['object-grid']` row (`ObjectGridProps`): +one required `objectName` — or the node's `dataSource` binding naming the object — +and keys drawn from the list this package **declares** as its authoring surface (`GRID_QUERY_INPUTS`, `src/index.tsx:166`) — the same list that feeds the designer panel and the generated `sdui-intrinsics.d.ts`, so what is authorable here is what -the renderer reads. +the renderer reads. `objectui validate` judges the bag against that row and +refuses a prop written flat on the node by name, naming where it goes +(`Did you mean objectName → properties.objectName?`), as the spec's own page +component does (objectui#11276). `SchemaRenderer` hoists the bag onto the node +before the grid runs, so `ObjectGridSchema` (`@object-ui/types`) is the node as the +grid reads it — and the type of ``'s `schema` prop. ```typescript -import type { ObjectGridSchema } from '@object-ui/types'; +import type { BaseSchema } from '@object-ui/types'; +import type { ObjectGridProps } from '@objectstack/spec/ui'; -const grid: ObjectGridSchema = { +const grid: BaseSchema = { type: 'object-grid', - objectName: 'users', - columns: ['name', 'email'], - sort: [{ field: 'created', order: 'desc' }], - pagination: { pageSize: 20 }, - selection: { type: 'multiple' }, + properties: { + objectName: 'users', + columns: ['name', 'email'], + sort: [{ field: 'created', order: 'desc' }], + pagination: { pageSize: 20 }, + selection: { type: 'multiple' }, + } satisfies ObjectGridProps, }; ``` -| Key | Type | Notes | +| Key (in `properties`) | Type | Notes | | --- | --- | --- | | `objectName` | `string` (**required**) | The object queried. There is no `object`. | | `columns` | `string[] \| ListColumn[]` | Field names or column objects — see below. | @@ -193,7 +205,7 @@ const grid: ObjectGridSchema = { | `editable` / `singleClickEdit` | `boolean` | Inline editing — see [Inline Editing](#inline-editing). | | `navigation` | `NavigationConfig` | What a row click does, `{ mode: 'page' \| 'drawer' \| 'modal' \| 'split' \| 'none', … }`. | | `operations` | `object` | Toggles the built-in CRUD/export/import affordances, e.g. `{ delete: false }`. | -| `rowHeight`, `frozenColumns`, `resizable`, `reorderableColumns`, `showColumnTypeIcons`, `rowColor`, `conditionalFormatting`, `aggregations`, `exportOptions`, `className` | | The rest of the declared surface. | +| `rowHeight`, `frozenColumns`, `resizable`, `reorderableColumns`, `showColumnTypeIcons`, `rowColor`, `conditionalFormatting`, `aggregations`, `exportOptions` | | The rest of the declared surface. (`className` is a base prop: it stays on the node, beside the bag.) | | `grouping` | `GroupingConfig` | Row grouping — **server-side**: group set, counts and aggregations come from the query, rows are paged per group; see [Grouping is server-side](#grouping-is-server-side). | **There is no `sortable`, `filterable`, `onRowClick`, `onSelectionChange`, @@ -204,7 +216,7 @@ booleans do not exist at all; the five `on*` names are **component props** #### `description` and `emptyState` -Two more keys the grid honours on its own node (objectui#11068): +Two more keys the grid honours on the node it reads (objectui#11068): - `description` — one line of help text drawn above the grid. A string, or an inline locale map resolved against the display locale the way `label` is. @@ -215,19 +227,24 @@ Two more keys the grid honours on its own node (objectui#11068): search box stay, so the term can be cleared. Leave the key out and an empty grid draws the table's own empty row, as before. +`description` is a base prop, so it sits on the node beside the bag: + ```json { "type": "object-grid", - "objectName": "contacts", "description": "Everyone you work with", - "emptyState": { "title": "No contacts yet", "message": "Contacts you add appear here.", "icon": "users" } + "properties": { "objectName": "contacts" } } ``` -Neither is in `GRID_QUERY_INPUTS` yet. That list may only declare keys the -upstream protocol's `object-grid` row accepts, and that row declares neither -today — so the SDUI parser reports an authored `emptyState` as `unknown-prop` (a -warning), while `description` is a base prop it accepts on every node. +`emptyState` is **not authorable in a document today**. The upstream protocol's +`object-grid` row does not declare it, so `objectui validate` refuses it inside +the `properties` bag (the row's own refusal) and, on the node, its strict face +refuses it as an unknown key — as `os validate` does. It stays a key of the node +the grid reads: a host that mounts `` itself, or composes +the node in code, can set it. For the same reason it is not in +`GRID_QUERY_INPUTS` yet: that list may only declare keys the row accepts, so the +SDUI parser reports an authored `emptyState` as `unknown-prop` (a warning). `name`, `placeholder`, `rowSpecActions` and `bulkSpecActions` are **retired** on this node (objectui#11068): nothing ever read them, and both faces of @@ -339,9 +356,10 @@ view whose columns are all `none` (or carry no `summary`) has no footer. ## Examples -Every example below is annotated `ObjectGridSchema`, which is the point: an -un-annotated `const schema = { … }` type-checks no matter what is written in it, -so a snippet that carries no annotation cannot tell you whether its keys are real. +Every example below checks its bag with `satisfies ObjectGridProps` (the spec's +row type), which is the point: an un-annotated `const schema = { … }` type-checks +no matter what is written in it, so a snippet that carries no annotation cannot +tell you whether its keys are real. And note the `type` — `object-grid`, never `grid`. Bare `grid` renders the CSS Grid *layout container* from `@object-ui/components` ([above](#the-schema-types-this-package-claims)), which is how a copied example ends @@ -351,19 +369,22 @@ up leaking `columns="[object Object]"` into the DOM instead of drawing a table ### Basic Grid ```typescript -import type { ObjectGridSchema } from '@object-ui/types'; +import type { BaseSchema } from '@object-ui/types'; +import type { ObjectGridProps } from '@objectstack/spec/ui'; -const schema: ObjectGridSchema = { +const schema: BaseSchema = { type: 'object-grid', - objectName: 'users', - columns: [ - { field: 'name', label: 'Name', width: 200, sortable: true }, - { field: 'email', label: 'Email' }, - { field: 'role', label: 'Role' }, - { field: 'status', label: 'Status', type: 'select' }, - ], - sort: [{ field: 'name', order: 'asc' }], - pagination: { pageSize: 20 }, + properties: { + objectName: 'users', + columns: [ + { field: 'name', label: 'Name', width: 200, sortable: true }, + { field: 'email', label: 'Email' }, + { field: 'role', label: 'Role' }, + { field: 'status', label: 'Status', type: 'select' }, + ], + sort: [{ field: 'name', order: 'asc' }], + pagination: { pageSize: 20 }, + } satisfies ObjectGridProps, }; ``` @@ -378,22 +399,25 @@ fixtures, tests — give it a `ViewData` with the `value` provider. The rows go under `items`; a bare array is the deprecated `staticData` spelling. ```typescript -import type { ObjectGridSchema } from '@object-ui/types'; +import type { BaseSchema } from '@object-ui/types'; +import type { ObjectGridProps } from '@objectstack/spec/ui'; -const schema: ObjectGridSchema = { +const schema: BaseSchema = { type: 'object-grid', - objectName: 'users', - columns: [ - { field: 'name', label: 'Name' }, - { field: 'email', label: 'Email' }, - ], - data: { - provider: 'value', - items: [ - { id: 1, name: 'John Doe', email: 'john@example.com', status: 'Active' }, - { id: 2, name: 'Jane Smith', email: 'jane@example.com', status: 'Active' }, + properties: { + objectName: 'users', + columns: [ + { field: 'name', label: 'Name' }, + { field: 'email', label: 'Email' }, ], - }, + data: { + provider: 'value', + items: [ + { id: 1, name: 'John Doe', email: 'john@example.com', status: 'Active' }, + { id: 2, name: 'Jane Smith', email: 'jane@example.com', status: 'Active' }, + ], + }, + } satisfies ObjectGridProps, }; ``` @@ -459,14 +483,17 @@ Not covered: ### Selectable Grid ```typescript -import type { ObjectGridSchema } from '@object-ui/types'; +import type { BaseSchema } from '@object-ui/types'; +import type { ObjectGridProps } from '@objectstack/spec/ui'; -const schema: ObjectGridSchema = { +const schema: BaseSchema = { type: 'object-grid', - objectName: 'users', - columns: ['name', 'email'], - selection: { type: 'multiple' }, - bulkActions: ['delete', 'export'], + properties: { + objectName: 'users', + columns: ['name', 'email'], + selection: { type: 'multiple' }, + bulkActions: ['delete', 'export'], + } satisfies ObjectGridProps, }; ``` @@ -480,13 +507,16 @@ see [Row callbacks are component props](#row-callbacks-are-component-props). ### Grid with Pagination ```typescript -import type { ObjectGridSchema } from '@object-ui/types'; +import type { BaseSchema } from '@object-ui/types'; +import type { ObjectGridProps } from '@objectstack/spec/ui'; -const schema: ObjectGridSchema = { +const schema: BaseSchema = { type: 'object-grid', - objectName: 'users', - columns: ['name', 'email'], - pagination: { pageSize: 10, pageSizeOptions: [10, 20, 50, 100] }, + properties: { + objectName: 'users', + columns: ['name', 'email'], + pagination: { pageSize: 10, pageSizeOptions: [10, 20, 50, 100] }, + } satisfies ObjectGridProps, }; ``` @@ -571,24 +601,27 @@ host installs once, above the whole tree: import { SchemaRendererProvider, SchemaRenderer } from '@object-ui/react'; import { createObjectStackAdapter } from '@object-ui/data-objectstack'; import '@object-ui/plugin-grid'; -import type { ObjectGridSchema } from '@object-ui/types'; +import type { BaseSchema } from '@object-ui/types'; +import type { ObjectGridProps } from '@objectstack/spec/ui'; const dataSource = createObjectStackAdapter({ baseUrl: 'https://api.example.com', token: 'your-auth-token', }); -const schema: ObjectGridSchema = { +const schema: BaseSchema = { type: 'object-grid', - objectName: 'users', - columns: [ - { field: 'name', label: 'Name' }, - { field: 'email', label: 'Email' }, - { field: 'created', label: 'Created', type: 'datetime' }, - ], - filter: [{ field: 'status', operator: 'equals', value: 'active' }], - searchableFields: ['name', 'email'], - pagination: { pageSize: 20 }, + properties: { + objectName: 'users', + columns: [ + { field: 'name', label: 'Name' }, + { field: 'email', label: 'Email' }, + { field: 'created', label: 'Created', type: 'datetime' }, + ], + filter: [{ field: 'status', operator: 'equals', value: 'active' }], + searchableFields: ['name', 'email'], + pagination: { pageSize: 20 }, + } satisfies ObjectGridProps, }; export const App = () => ( @@ -621,16 +654,19 @@ Columns sort by default. `sortable` is a **per-column** key, used to turn a colu off; the grid-level `sort` declares the order the grid opens with. ```typescript -import type { ObjectGridSchema } from '@object-ui/types'; +import type { BaseSchema } from '@object-ui/types'; +import type { ObjectGridProps } from '@objectstack/spec/ui'; -const schema: ObjectGridSchema = { +const schema: BaseSchema = { type: 'object-grid', - objectName: 'users', - sort: [{ field: 'created', order: 'desc' }], - columns: [ - { field: 'name', label: 'Name' }, - { field: 'email', label: 'Email', sortable: false }, - ], + properties: { + objectName: 'users', + sort: [{ field: 'created', order: 'desc' }], + columns: [ + { field: 'name', label: 'Name' }, + { field: 'email', label: 'Email', sortable: false }, + ], + } satisfies ObjectGridProps, }; ``` @@ -641,17 +677,20 @@ baked into the metadata, and a toolbar search over the fields named in `searchableFields`. ```typescript -import type { ObjectGridSchema } from '@object-ui/types'; +import type { BaseSchema } from '@object-ui/types'; +import type { ObjectGridProps } from '@objectstack/spec/ui'; -const schema: ObjectGridSchema = { +const schema: BaseSchema = { type: 'object-grid', - objectName: 'users', - filter: [ - { field: 'status', operator: 'equals', value: 'active' }, - { field: 'created', operator: 'after', value: '2026-01-01' }, - ], - searchableFields: ['name', 'email'], - columns: ['name', 'email', 'status'], + properties: { + objectName: 'users', + filter: [ + { field: 'status', operator: 'equals', value: 'active' }, + { field: 'created', operator: 'after', value: '2026-01-01' }, + ], + searchableFields: ['name', 'email'], + columns: ['name', 'email', 'status'], + } satisfies ObjectGridProps, }; ``` @@ -662,15 +701,18 @@ themselves live in the object's action set, so the same action behaves identical wherever it is offered. They are `string[]`, not inline definitions with callbacks. ```typescript -import type { ObjectGridSchema } from '@object-ui/types'; +import type { BaseSchema } from '@object-ui/types'; +import type { ObjectGridProps } from '@objectstack/spec/ui'; -const schema: ObjectGridSchema = { +const schema: BaseSchema = { type: 'object-grid', - objectName: 'users', - columns: ['name', 'email'], - rowActions: ['view', 'edit', 'delete'], - selection: { type: 'multiple' }, - bulkActions: ['delete', 'export'], + properties: { + objectName: 'users', + columns: ['name', 'email'], + rowActions: ['view', 'edit', 'delete'], + selection: { type: 'multiple' }, + bulkActions: ['delete', 'export'], + } satisfies ObjectGridProps, }; ``` @@ -714,7 +756,8 @@ decides what a row click does without any host code. whether the generic Edit / Delete entries exist at all, identically for every row. When the refusal belongs to one RECORD — a system field a designer may not drop — use `rowOperations`, a component prop called with a row record that -answers for that row alone: +answers for that row alone. Mounting `` directly hands it the node as +it reads it, so its `schema` prop is the flat `ObjectGridSchema`, with no bag: ```tsx import { ObjectGrid } from '@object-ui/plugin-grid'; @@ -754,19 +797,22 @@ indistinguishable from a broken build (objectui#8674). Enable inline cell editing for quick updates: ```typescript -import type { ObjectGridSchema } from '@object-ui/types'; +import type { BaseSchema } from '@object-ui/types'; +import type { ObjectGridProps } from '@objectstack/spec/ui'; -const schema: ObjectGridSchema = { +const schema: BaseSchema = { type: 'object-grid', - objectName: 'users', - columns: [ - { field: 'id', label: 'ID' }, - { field: 'name', label: 'Name' }, - { field: 'email', label: 'Email' }, - { field: 'status', label: 'Status', type: 'select' }, - ], - editable: true, - singleClickEdit: false, + properties: { + objectName: 'users', + columns: [ + { field: 'id', label: 'ID' }, + { field: 'name', label: 'Name' }, + { field: 'email', label: 'Email' }, + { field: 'status', label: 'Status', type: 'select' }, + ], + editable: true, + singleClickEdit: false, + } satisfies ObjectGridProps, }; ``` @@ -802,18 +848,21 @@ The schema half is just `editable` — the save/cancel affordances appear on the own once a row has pending changes: ```typescript -import type { ObjectGridSchema } from '@object-ui/types'; +import type { BaseSchema } from '@object-ui/types'; +import type { ObjectGridProps } from '@objectstack/spec/ui'; -const schema: ObjectGridSchema = { +const schema: BaseSchema = { type: 'object-grid', - objectName: 'products', - columns: [ - { field: 'sku', label: 'SKU' }, - { field: 'name', label: 'Name' }, - { field: 'price', label: 'Price', type: 'currency', align: 'right' }, - { field: 'stock', label: 'Stock', type: 'number', align: 'right' }, - ], - editable: true, + properties: { + objectName: 'products', + columns: [ + { field: 'sku', label: 'SKU' }, + { field: 'name', label: 'Name' }, + { field: 'price', label: 'Price', type: 'currency', align: 'right' }, + { field: 'stock', label: 'Stock', type: 'number', align: 'right' }, + ], + editable: true, + } satisfies ObjectGridProps, }; ``` @@ -868,7 +917,9 @@ const persistence = ( ## TypeScript Support The schema and column types come from `@object-ui/types`; this package exports the -*component* types. +*component* types. `ObjectGridSchema` types the component's `schema` prop — the +node as the grid reads it, after `SchemaRenderer` hoists an authored node's +`properties` bag onto it — so its keys sit flat here. ```typescript import type { ObjectGridSchema, ListColumn } from '@object-ui/types'; diff --git a/packages/plugin-grid/src/__tests__/ObjectGrid.propertiesBag-11276.test.tsx b/packages/plugin-grid/src/__tests__/ObjectGrid.propertiesBag-11276.test.tsx new file mode 100644 index 0000000000..1163a9e0f0 --- /dev/null +++ b/packages/plugin-grid/src/__tests__/ObjectGrid.propertiesBag-11276.test.tsx @@ -0,0 +1,122 @@ +/** + * 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. + */ + +/** + * An authored `object-grid` writes its props in the spec's `properties` bag, + * and the bag draws what the flat spelling drew (objectui#11276, the + * `object-grid` batch). + * + * `@object-ui/types` now arms the authored node from + * `ComponentPropsMap['object-grid']`: `{ type: 'object-grid', properties: { + * … } }` validates, and the flat spelling is refused by name + * (`object-grid-properties-bag-11276.test.ts` holds both faces). That move is + * only safe if the renderer reads the bag, so this file renders the node + * through the real registry and a recording adapter: + * + * - the bag queries its `objectName` with its own `filter` and `sort`, and + * paints the returned row; + * - the flat spelling — what a stored document, `ObjectView` and `ListView` + * still hand the renderer — makes the identical call and paints the same + * text: `SchemaRenderer` hoists the bag onto the node before `ObjectGrid` + * reads it, and that hoist is unchanged, so a stored flat node keeps + * rendering; + * - a bag bound through the node's `dataSource` queries the bound object; + * - control: a bag naming a different object queries that object and never + * the fixture's, so the first row cannot pass on a fetch the bag did not + * drive. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, waitFor, cleanup } from '@testing-library/react'; +import React from 'react'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +import type { BaseSchema, DataSource } from '@object-ui/types'; +import { safeValidateSchema } from '@object-ui/types/zod'; +// Module scope, not a hook: this import IS the registration (AGENTS.md +// test-discipline section). +import '../index'; + +const FILTER = [{ field: 'status', operator: 'equals', value: 'open' }]; +const SORT = [{ field: 'title', order: 'desc' }]; +const ROW = { id: 't1', title: 'Write the bag pin', status: 'open' }; + +/** The authored spelling, spec-valid. */ +const BAG = { type: 'object-grid', properties: { objectName: 'task', columns: ['title', 'status'], filter: FILTER, sort: SORT } }; +/** The same props written flat — what a stored document or a code composer hands the renderer. */ +const FLAT = { type: 'object-grid', objectName: 'task', columns: ['title', 'status'], filter: FILTER, sort: SORT }; +/** The bag with its object supplied by the node's binding instead. */ +const BOUND_BAG = { type: 'object-grid', dataSource: { object: 'task' }, properties: { columns: ['title', 'status'] } }; + +function makeAdapter() { + return { + find: vi.fn().mockResolvedValue({ data: [ROW], total: 1 }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockImplementation(async (name: string) => ({ + name, + fields: { id: { type: 'text' }, title: { type: 'text' }, status: { type: 'text' } }, + })), + }; +} + +function renderNode(schema: Record) { + const adapter = makeAdapter(); + const view = render( + + + , + ); + return { adapter, ...view }; +} + +describe('object-grid renders from its `properties` bag (objectui#11276)', () => { + it('the fixtures are the spellings the validator now tells apart (lit control)', () => { + expect(safeValidateSchema(BAG).success).toBe(true); + expect(safeValidateSchema(BOUND_BAG).success).toBe(true); + expect(safeValidateSchema(FLAT).success).toBe(false); + }); + + it('the bag queries its object with its own filter and sort, and paints the row', async () => { + const { adapter, container } = renderNode(BAG); + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + const [object, params] = adapter.find.mock.calls[0] as [string, { $filter?: unknown; $orderby?: unknown }]; + expect(object).toBe('task'); + expect(JSON.stringify(params.$filter)).toContain('status'); + expect(params.$orderby).toBeDefined(); + await waitFor(() => expect(container.textContent).toContain(ROW.title)); + }); + + it('a stored flat node keeps rendering: the identical call, the same painted row', async () => { + const bag = renderNode(BAG); + await waitFor(() => expect(bag.adapter.find).toHaveBeenCalled()); + await waitFor(() => expect(bag.container.textContent).toContain(ROW.title)); + const bagCall = bag.adapter.find.mock.calls[0]; + cleanup(); + const flat = renderNode(FLAT); + await waitFor(() => expect(flat.adapter.find).toHaveBeenCalled()); + await waitFor(() => expect(flat.container.textContent).toContain(ROW.title)); + expect(flat.adapter.find.mock.calls[0]).toEqual(bagCall); + }); + + it('a bag bound through the node\'s `dataSource` queries the bound object', async () => { + const { adapter, container } = renderNode(BOUND_BAG); + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + expect(adapter.find.mock.calls[0][0]).toBe('task'); + await waitFor(() => expect(container.textContent).toContain(ROW.title)); + }); + + it('control: a bag naming another object queries that object, never the fixture\'s', async () => { + const { adapter } = renderNode({ ...BAG, properties: { ...BAG.properties, objectName: 'milestone' } }); + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + const objects = adapter.find.mock.calls.map((call) => call[0]); + expect(objects).toContain('milestone'); + expect(objects).not.toContain('task'); + }); +}); diff --git a/packages/plugin-grid/src/__tests__/guideCrudAppRenders.test.tsx b/packages/plugin-grid/src/__tests__/guideCrudAppRenders.test.tsx index 4ca4885065..176e64e98e 100644 --- a/packages/plugin-grid/src/__tests__/guideCrudAppRenders.test.tsx +++ b/packages/plugin-grid/src/__tests__/guideCrudAppRenders.test.tsx @@ -166,9 +166,11 @@ describe('guide/building-crud-app.md — every `object-grid` snippet actually re it('names the object with the key this block declares — `object` fetches nothing', async () => { // The #5377 axis, pinned as a contrast rather than described. Same snippet, // same wiring, one key re-spelled the way the page used to spell it. + // The guide writes the grid's props in its `properties` bag, the spelling + // the spec declares (objectui#11276); `SchemaRenderer` hoists it. const [first] = GRID_SNIPPETS; - const asItWas = { ...first, object: first.objectName }; - delete asItWas.objectName; + const { objectName, ...rest } = first.properties; + const asItWas = { ...first, properties: { ...rest, object: objectName } }; const adapter = makeAdapter(); const { container } = render( diff --git a/packages/types/src/zod/README.md b/packages/types/src/zod/README.md index ec3abeaf33..4231888bac 100644 --- a/packages/types/src/zod/README.md +++ b/packages/types/src/zod/README.md @@ -231,13 +231,14 @@ function validateComponent(config: unknown) { - `KanbanSchema`, `CalendarViewSchema` - `FilterBuilderSchema`, `CarouselSchema`, `ChatbotSchema` -### ObjectQL Public Blocks (7) -The ADR-0080 public blocks `object-metric`, `object-master-detail-form`, `object-timeline`, `object-form`, `object-map`, `object-chart` and `object-gantt`. Every arm's `properties` but `object-chart`'s is the block's `@objectstack/spec` `ComponentPropsMap` row, by reference; `object-chart` has no row (below). Each of those six arms also declares the node's `dataSource` binding, the spec's `ElementDataSourceSchema`, which all six registrations read through `ElementDataSourceGate`. None of the seven renderers reads a content channel, so every arm refuses `children` and `body` by name (objectui#9256). On all seven, a member of the row written flat on the node instead of in `properties` is refused by name, naming `properties.KEY`: `object-metric`, `object-master-detail-form` and `object-timeline` through the public blocks' shared helper since objectui#10872 batch 10 (below), the other four through their own maps. +### ObjectQL Public Blocks (8) +The ADR-0080 public blocks `object-metric`, `object-master-detail-form`, `object-timeline`, `object-form`, `object-map`, `object-chart`, `object-gantt` and `object-grid`. Every arm's `properties` but `object-chart`'s is the block's `@objectstack/spec` `ComponentPropsMap` row, by reference; `object-chart` has no row (below). Each of those seven arms also declares the node's `dataSource` binding, the spec's `ElementDataSourceSchema`, which all seven registrations read through `ElementDataSourceGate`. None of the eight renderers reads a content channel, so every arm refuses `children` and `body` by name (objectui#9256). On all eight, a member of the row written flat on the node instead of in `properties` is refused by name, naming `properties.KEY`: `object-metric`, `object-master-detail-form`, `object-timeline` and `object-grid` through the public blocks' shared helper since objectui#10872 batch 10 (below), the other four through their own maps. `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`. `ObjectMapBlockSchema` is the authored `object-map` node, built the same way (objectui#10859 batch 5). The flat mirror's `locationField` and `titleField` are refused by name too, pointing at `properties.map.KEY`. The flat mirror's record-source rule stays, read in the bag: the node needs one of `properties.data`, `properties.staticData` or `properties.objectName`, or the node's `dataSource` binding. `ObjectMapSchema`, the flat mirror of the TypeScript `ObjectMapSchema`, stays exported as the node as `ObjectMap` reads it; it is no longer an arm of `AnyComponentSchema`. `ObjectChartBlockSchema` is the authored `object-chart` node (objectui#11276), built the same way with one difference: `@objectstack/spec` has no `ComponentPropsMap['object-chart']` row, so the bag's members are the flat `ObjectChartSchema` mirror's own members, by reference, and no row is invented. Each of them written flat on the node is refused by name, naming `properties.KEY`; the three list-view spellings objectui#10608 retired keep their retirement message instead. The flat mirror's chart-family floor stays, read in the bag: the node needs `properties.chartType` (or `properties.specType`). It does not declare `dataSource` (objectui#11070 left that binding undeclared on this node) and refuses both content channels. `ObjectChartSchema` stays exported as the node as `ObjectChart` reads it, and as the composers build it; it is no longer an arm of `AnyComponentSchema`. `ObjectGanttBlockSchema` is the authored `object-gantt` node, built the way `ObjectMapBlockSchema` is (objectui#10859 batch 6). Each member of the row written flat on the node is refused by name, naming `properties.KEY`, except `label`, which `@objectstack/spec`'s page component also declares on the node itself. The flat mirror's `GanttConfig` keys (`startDateField`, `viewMode`, …) and its `dependencyField` alias are refused by name too, pointing at the bag's `gantt` block, `properties.gantt.KEY`. The record-source rule stays, read in the bag, as on `object-map`. `ObjectGanttSchema`, the flat mirror of the TypeScript `ObjectGanttSchema`, stays exported as the node as `ObjectGantt` reads it, and as the list and object views compose it; it is no longer an arm of `AnyComponentSchema`. -- `ObjectMetricBlockSchema`, `ObjectMasterDetailFormBlockSchema`, `ObjectTimelineBlockSchema`, `ObjectFormBlockSchema`, `ObjectMapBlockSchema`, `ObjectChartBlockSchema`, `ObjectGanttBlockSchema` +`ObjectGridBlockSchema` is the authored `object-grid` node (objectui#11276's `object-grid` batch), built the way `ObjectGanttBlockSchema` is from the spec's `ComponentPropsMap['object-grid']` row. `label` stays on the node, as on `object-gantt`, and a flat `defaultSort` keeps the row's own retirement. The flat mirror's retirements of keys the row does not declare (`operators`, `rowSpecActions`, `bulkSpecActions`, `name`, `placeholder`, `showFilters`), its `onNavigate` runtime slot and its content-channel refusals ride onto the arm by reference. The record-source rule stays, read in the bag: the node needs `properties.objectName`, or the node's `dataSource` binding. `ObjectGridSchema`, the flat mirror of the TypeScript `ObjectGridSchema`, stays exported as the node as `ObjectGrid` reads it, and still builds the `object-view` `table` slot; it is no longer an arm of `AnyComponentSchema`. +- `ObjectMetricBlockSchema`, `ObjectMasterDetailFormBlockSchema`, `ObjectTimelineBlockSchema`, `ObjectFormBlockSchema`, `ObjectMapBlockSchema`, `ObjectChartBlockSchema`, `ObjectGanttBlockSchema`, `ObjectGridBlockSchema` ### AI Components (3) - `AIFormAssistSchema`, `AIRecommendationsSchema`, `NLQuerySchema` @@ -246,7 +247,7 @@ The ADR-0080 public blocks `object-metric`, `object-master-detail-form`, `object 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. A member of an arm's row written flat on the node instead of in `properties` is refused by name on both faces, at its own path, with a message naming `properties.KEY`, as `@objectstack/spec`'s `PageComponentSchema` refuses it as mis-layered (objectui#10872 batch 10; one shared helper, `flatPropRefusals`, spread into every arm whose row declares a member). That includes the row members `BaseSchema` also declares (`visible`, `disabled`, `name`, `description`), which the base used to accept flat. Two kinds stay as they were: a member the spec's page component declares on the node itself (`label`, `aria`), and one the row retires, which keeps the row's own retirement message when written flat. An arm's own refusal of a row key (`record:alert`'s `body`, the `action:` controls' `onSuccess`, the content-channel refusals) keeps its message. Every arm here, and each of the seven 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; `ObjectChartSchema` is the post-hoist mirror since objectui#11276, the authored arm being `ObjectChartBlockSchema` above), 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. +Outside this set, `FlexSchema`, `ObjectGridSchema` and `ObjectChartSchema` spread the same fragment (objectui#10872 batch 9; `ObjectChartSchema` and `ObjectGridSchema` are post-hoist mirrors since objectui#11276, the authored arms being `ObjectChartBlockSchema` and `ObjectGridBlockSchema` above), 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` - `PageSectionBlockSchema`, `PageFooterBlockSchema`, `PageSidebarBlockSchema` From 8c53386fa7cd37e07b83ada153071ff217be3130 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 18:07:25 +0000 Subject: [PATCH 5/9] chore(changeset): object-grid properties bag (objectui#11276) @object-ui/types minor with a BREAKING (authoring) banner; @object-ui/plugin-grid patch for the README and the two src/ test files. Claude-Session: https://claude.ai/code/session_01JG2jy8a9su7ia4Hx7zxv42 Co-authored-by: Claude --- .../11276-object-grid-properties-bag.md | 29 +++++++++++++++++++ .../11276-plugin-grid-properties-bag-docs.md | 7 +++++ 2 files changed, 36 insertions(+) create mode 100644 .changeset/11276-object-grid-properties-bag.md create mode 100644 .changeset/11276-plugin-grid-properties-bag-docs.md diff --git a/.changeset/11276-object-grid-properties-bag.md b/.changeset/11276-object-grid-properties-bag.md new file mode 100644 index 0000000000..672daf7c4c --- /dev/null +++ b/.changeset/11276-object-grid-properties-bag.md @@ -0,0 +1,29 @@ +--- +'@object-ui/types': minor +--- + +feat(types): an authored `object-grid` takes its props in the spec's `properties` bag; the flat spelling is refused by name (objectui#11276) + +**BREAKING (authoring):** an `object-grid` 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-grid", "properties": { "objectName": "task", "columns": ["title", "status"], "sort": [{ "field": "title", "order": "asc" }] } } +``` + +Nothing changes at render time: `SchemaRenderer` hoists every `properties` key onto the node before `ObjectGrid` runs, so the bag node draws what the flat one drew, and a stored flat node, or a node built in code (`ObjectView`, `ListView`, the designers, a host mounting ``), keeps working with its flat keys. + +**Clause-②: yes** — the accept set narrows (a flat `object-grid` 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-grid']` row is the published declaration of an authored `object-grid` 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 `ObjectGridSchema`, so the two validators disagreed both ways: the objectstack showcase's work queues, written in the bag, were refused (the flat mirror found no `objectName` on the node), and the flat node `os validate` refuses was accepted. + +**What changed, in observable terms.** + +- `@object-ui/types/zod` exports `ObjectGridBlockSchema`, built the way `ObjectGanttBlockSchema` is: `BaseSchema`, the `object-grid` literal, the node-level `responsiveStyles`, the node's `dataSource` binding (the spec's `ElementDataSourceSchema`), and `properties`, which is the spec row by reference, so the row's members, value types and strictness judge the bag. +- 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 \`objectName\` → \`properties.objectName\`?`). The refusal set is read off the row. `label` stays on the node too, because the spec's page component declares a node-level `label`; a flat `defaultSort` keeps the row's own retirement message. `operators`, `rowSpecActions`, `bulkSpecActions`, `name`, `placeholder` and `showFilters` keep their retirement messages, `onNavigate` stays a refused runtime slot, and `body` / `children` stay refused (objectui#9256). +- The record-source rule stays, read in the bag: the node needs `properties.objectName`, or a `dataSource` binding naming the object. The spec has no such rule. +- `ObjectGridBlockSchema` reaches `AnyComponentSchema` through `ObjectQLPublicBlockComponentSchema`. `ObjectGridSchema` leaves `ObjectQLComponentSchema`. +- Inside the bag the spec row decides, and it differs from the flat mirror. `filter` and `defaultFilters` take the `ViewFilterRule` array only (`[{ field, operator, value }]`), so the tuple form, the record form and a record-form `defaultFilters` are refused there. The row types `columns` entries, `selection`, `exportOptions`, `operations`, `navigation`, `grouping` and a few display keys as unknown, so their shapes are not judged inside the bag: notably a bare-array `exportOptions`, which the flat mirror refuses by name (objectui#7762), is not refused there. The row declares keys the flat mirror does not (`rowHeight`, `grouping`, `aggregations`, `conditionalFormatting`, `rowColor`, `bulkActionDefs`, `navigation`, `singleClickEdit`, `resizableColumns`, `reorderableColumns`, `operations`), so they parse in the bag. It does not declare `emptyState` or `keyboardNavigation`, so the bag refuses them, and on the node the strict face refuses them as unknown keys: `emptyState` is not authorable in a document until the spec's row declares it. + +**The same release's changesets** that describe what the validators say about an `object-grid` written flat (`.changeset/7762-object-grid-export-options-bare-array-refusal.md`, `.changeset/9739-object-grid-operators-tombstone.md`, `.changeset/11068-grid-declared-keys.md`, `.changeset/11068-object-grid-show-filters.md`, `.changeset/11117-datasource-objectname-waiver.md`, `.changeset/10872-flat-arm-responsive-styles.md` and others) describe the flat mirror, which still behaves as they say. On an authored node the props now live in the bag, where the spec row judges them as listed above; the retirements, the record-source rule and `responsiveStyles` hold there as they say. + +**What did not move.** The TypeScript `ObjectGridSchema` and its zod mirror `ObjectGridSchema` stay published and unchanged in shape. They are the node as `ObjectGrid` reads it after the hoist, and as code composes it, and the `object-view` `table` slot is still built from the mirror. diff --git a/.changeset/11276-plugin-grid-properties-bag-docs.md b/.changeset/11276-plugin-grid-properties-bag-docs.md new file mode 100644 index 0000000000..d20d56288d --- /dev/null +++ b/.changeset/11276-plugin-grid-properties-bag-docs.md @@ -0,0 +1,7 @@ +--- +'@object-ui/plugin-grid': patch +--- + +docs(plugin-grid): authored `object-grid` examples write their props in the `properties` bag (objectui#11276) + +The README's authored `object-grid` examples now write `{ "type": "object-grid", "properties": { … } }`, the spelling `@objectstack/spec`'s `ComponentPropsMap['object-grid']` row declares and `objectui validate` now requires, each bag checked with `satisfies ObjectGridProps` (the spec's row type). `emptyState` is described as what it is today: a key `ObjectGrid` reads that the spec's row does not declare, so not authorable in a document. The examples that mount `ObjectGrid` directly keep the flat `schema` prop: a component mounted without `SchemaRenderer` receives the node as it reads it, and nothing hoists a bag there. Under `src/`, a new render pin holds that the bag and a stored flat node draw the same grid, and the CRUD-guide render pin reads the guide's bag. No runtime change. From 0ddbb215dcc5b03b0fd99412eebf942caf54bbc0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 18:12:18 +0000 Subject: [PATCH 6/9] test(cli): the arm-selection fixtures read object-grid's bag (objectui#11276) The selected object-grid arm now reports its record source at properties.objectName, and the provider-keyed union sits at properties.data; both rows keep their subject. Claude-Session: https://claude.ai/code/session_01JG2jy8a9su7ia4Hx7zxv42 Co-authored-by: Claude --- .../validate-union-arm-selection.test.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/__tests__/validate-union-arm-selection.test.ts b/packages/cli/src/__tests__/validate-union-arm-selection.test.ts index d04c527388..fd30bb8691 100644 --- a/packages/cli/src/__tests__/validate-union-arm-selection.test.ts +++ b/packages/cli/src/__tests__/validate-union-arm-selection.test.ts @@ -64,16 +64,20 @@ const UNTYPED_DOCUMENT = { items: [] }; /** * A document whose `type` selects exactly one arm, which then fails on its OWN * required key. The cleanest possible statement of "print that arm and nothing - * else": `objectName` belongs to no other arm. + * else": `objectName` belongs to no other arm. Since objectui#11276's + * `object-grid` batch the authored arm reads it in the `properties` bag, so + * the issue sits at `properties → objectName`. */ const OBJECT_GRID_MISSING_OBJECT_NAME = { type: 'object-grid' }; /** - * `.data` here is `@objectstack/spec`'s `ViewDataSchema`, a - * `z.discriminatedUnion('provider', …)` — the one reachable union in this tree - * keyed on something other than `type`. + * `.data` here is the `data` member of `@objectstack/spec`'s + * `ComponentPropsMap['object-grid']` row, a `z.discriminatedUnion('provider', …)` + * — a reachable union keyed on something other than `type`. It sits in the + * node's `properties` bag, where an authored `object-grid` takes its props + * since objectui#11276's `object-grid` batch. */ -const GRID_WITH_PROVIDERLESS_DATA = { type: 'object-grid', objectName: 'x', data: { type: 'rest' } }; +const GRID_WITH_PROVIDERLESS_DATA = { type: 'object-grid', properties: { objectName: 'x', data: { type: 'rest' } } }; /** A failure that is not a union at all — the control for "nothing changed". */ const FORM_WITH_UNRESOLVABLE_WIDGET = { @@ -160,7 +164,7 @@ describe('objectui validate — the arm the document selected', () => { expect(exitCodes).toEqual([1]); const text = printed(); // The arm `object-grid` selects, failing on its own required key. - expect(text).toContain('Path: objectName'); + expect(text).toContain('Path: properties → objectName'); // ...and nothing from the arms that merely disagree about `type`. // Option A would have printed one of these per arm; this is the assertion // that the ruling's rejection of it is real rather than nominal. @@ -224,7 +228,7 @@ describe('objectui validate — a union keyed on something other than `type`', ( expect(exitCodes).toEqual([1]); const text = printed(); - expect(text).toContain('Path: data → provider'); + expect(text).toContain('Path: properties → data → provider'); expect(text).not.toContain('No arm accepts type'); expect(text).not.toContain('No `type` is declared'); expect(armEntries()).toHaveLength(0); From e4927b7d6781894ca2b1a1576204081514b731fb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 18:25:33 +0000 Subject: [PATCH 7/9] fix(types): declare object-grid's onNavigate runtime slot with the helper on the bag arm (objectui#11276) check:handler-key-reads reads a disposition off the helper call, as on ObjectFormBlockSchema; a reference to the mirror's member read as null. The Retool migration fragment drops the selection literal the doc-types gate reads as a component type. Claude-Session: https://claude.ai/code/session_01JG2jy8a9su7ia4Hx7zxv42 Co-authored-by: Claude --- content/docs/guide/objectos-integration.mdx | 8 ++++---- .../object-grid-properties-bag-11276.test.ts | 7 ++++--- .../types/src/__tests__/zod-mirror-parity.test.ts | 2 +- packages/types/src/zod/README.md | 2 +- packages/types/src/zod/objectql.zod.ts | 11 +++++++---- 5 files changed, 17 insertions(+), 13 deletions(-) diff --git a/content/docs/guide/objectos-integration.mdx b/content/docs/guide/objectos-integration.mdx index ca105568da..de0fbfe534 100644 --- a/content/docs/guide/objectos-integration.mdx +++ b/content/docs/guide/objectos-integration.mdx @@ -533,15 +533,15 @@ unsubscribe(); ```typescript // Retool table → ObjectUI Grid. The props go in the node's `properties` bag -// (the spec's `ComponentPropsMap['object-grid']` row): row selection is -// `selection`, the export menu is `exportOptions`, and the adapter comes from -// `SchemaRendererProvider`, never from the node. +// (the spec's `ComponentPropsMap['object-grid']` row): declaring bulk actions +// turns multi-row selection on, the export menu is `exportOptions`, and the +// adapter comes from `SchemaRendererProvider`, never from the node. { type: 'object-grid', properties: { objectName: 'users', editable: true, - selection: { type: 'multiple' }, + bulkActions: ['delete', 'export'], exportOptions: { formats: ['csv', 'xlsx'] }, }, } diff --git a/packages/types/src/__tests__/object-grid-properties-bag-11276.test.ts b/packages/types/src/__tests__/object-grid-properties-bag-11276.test.ts index bb70ece5ea..610e27e085 100644 --- a/packages/types/src/__tests__/object-grid-properties-bag-11276.test.ts +++ b/packages/types/src/__tests__/object-grid-properties-bag-11276.test.ts @@ -41,8 +41,8 @@ * Both splits are re-derived from the installed spec below, not transcribed. * - The flat mirror's own retirements of keys the row does not declare * (`operators`, `rowSpecActions`, `bulkSpecActions`, `name`, `placeholder`, - * `showFilters`), its `onNavigate` runtime slot and its content-channel - * refusals ride onto the arm by reference. + * `showFilters`) and its content-channel refusals ride onto the arm by + * reference; its `onNavigate` runtime slot is declared as it declares it. * - The row carries spec defaults (inside `data`'s `api` provider), so the * import boundary hands back a rebuilt copy: the pins below hold the key * set, probe equivalence and type-level equality, and that no default is @@ -358,7 +358,8 @@ describe('the flat spelling is refused by name, with the bag member as the remed ); it('`onNavigate` stays the runtime slot `ObjectGrid` reads off the node (objectui#6124)', () => { - expect(ObjectGridBlockSchema.shape.onNavigate).toBe(ObjectGridSchema.shape.onNavigate); + // Declared exactly as the flat mirror declares it: the same helper, the same text. + expect(ObjectGridBlockSchema.shape.onNavigate.description).toBe(ObjectGridSchema.shape.onNavigate.description); const issue = issuesOf(safeValidateSchema({ ...SHOWCASE_MY_WORK, onNavigate: { action: 'toast' } }))[0]; expect(issue.path).toEqual(['onNavigate']); expect(issue.message).toContain('RUNTIME SLOT'); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 791672f8d7..dc30a2ff50 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -4673,7 +4673,7 @@ const EXCLUSIONS: Readonly> = { // mirror above (`ObjectGridSchema`), which stays the renderer's post-hoist // reading and still builds the `object-view` `table` slot. 'objectql.zod.ts#ObjectGridBlockSchema': - "spec-owned BY REFERENCE — `BaseSchema` + the `object-grid` literal + `NODE_ENVELOPE` + `properties`, which IS `ComponentPropsMap['object-grid']`, + the node's `dataSource` binding, which IS the spec's `ElementDataSourceSchema`, + `flatPropRefusals` over the row (one `aliasKeyRefusal` per member but the node-level `label`, pointed at `properties.KEY`, and the row's own `defaultSort` retirement; the key set is read off the row), + the flat mirror's own objectui#9739 / objectui#11068 tombstones, its objectui#6124 `onNavigate` runtime slot and its objectui#9256 `body` / `children` refusals, by reference; the TS declaration of the node, `ObjectGridSchema` (`../objectql.ts`), is the renderer's post-hoist reading and is paired with the flat `objectql.zod.ts#ObjectGridSchema` mirror, not with this arm", + "spec-owned BY REFERENCE — `BaseSchema` + the `object-grid` literal + `NODE_ENVELOPE` + `properties`, which IS `ComponentPropsMap['object-grid']`, + the node's `dataSource` binding, which IS the spec's `ElementDataSourceSchema`, + `flatPropRefusals` over the row (one `aliasKeyRefusal` per member but the node-level `label`, pointed at `properties.KEY`, and the row's own `defaultSort` retirement; the key set is read off the row), + the flat mirror's own objectui#9739 / objectui#11068 tombstones and objectui#9256 `body` / `children` refusals, by reference, + its objectui#6124 `onNavigate` runtime slot, declared as the mirror declares it; the TS declaration of the node, `ObjectGridSchema` (`../objectql.ts`), is the renderer's post-hoist reading and is paired with the flat `objectql.zod.ts#ObjectGridSchema` 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/README.md b/packages/types/src/zod/README.md index 4231888bac..4220c2f286 100644 --- a/packages/types/src/zod/README.md +++ b/packages/types/src/zod/README.md @@ -237,7 +237,7 @@ The ADR-0080 public blocks `object-metric`, `object-master-detail-form`, `object `ObjectMapBlockSchema` is the authored `object-map` node, built the same way (objectui#10859 batch 5). The flat mirror's `locationField` and `titleField` are refused by name too, pointing at `properties.map.KEY`. The flat mirror's record-source rule stays, read in the bag: the node needs one of `properties.data`, `properties.staticData` or `properties.objectName`, or the node's `dataSource` binding. `ObjectMapSchema`, the flat mirror of the TypeScript `ObjectMapSchema`, stays exported as the node as `ObjectMap` reads it; it is no longer an arm of `AnyComponentSchema`. `ObjectChartBlockSchema` is the authored `object-chart` node (objectui#11276), built the same way with one difference: `@objectstack/spec` has no `ComponentPropsMap['object-chart']` row, so the bag's members are the flat `ObjectChartSchema` mirror's own members, by reference, and no row is invented. Each of them written flat on the node is refused by name, naming `properties.KEY`; the three list-view spellings objectui#10608 retired keep their retirement message instead. The flat mirror's chart-family floor stays, read in the bag: the node needs `properties.chartType` (or `properties.specType`). It does not declare `dataSource` (objectui#11070 left that binding undeclared on this node) and refuses both content channels. `ObjectChartSchema` stays exported as the node as `ObjectChart` reads it, and as the composers build it; it is no longer an arm of `AnyComponentSchema`. `ObjectGanttBlockSchema` is the authored `object-gantt` node, built the way `ObjectMapBlockSchema` is (objectui#10859 batch 6). Each member of the row written flat on the node is refused by name, naming `properties.KEY`, except `label`, which `@objectstack/spec`'s page component also declares on the node itself. The flat mirror's `GanttConfig` keys (`startDateField`, `viewMode`, …) and its `dependencyField` alias are refused by name too, pointing at the bag's `gantt` block, `properties.gantt.KEY`. The record-source rule stays, read in the bag, as on `object-map`. `ObjectGanttSchema`, the flat mirror of the TypeScript `ObjectGanttSchema`, stays exported as the node as `ObjectGantt` reads it, and as the list and object views compose it; it is no longer an arm of `AnyComponentSchema`. -`ObjectGridBlockSchema` is the authored `object-grid` node (objectui#11276's `object-grid` batch), built the way `ObjectGanttBlockSchema` is from the spec's `ComponentPropsMap['object-grid']` row. `label` stays on the node, as on `object-gantt`, and a flat `defaultSort` keeps the row's own retirement. The flat mirror's retirements of keys the row does not declare (`operators`, `rowSpecActions`, `bulkSpecActions`, `name`, `placeholder`, `showFilters`), its `onNavigate` runtime slot and its content-channel refusals ride onto the arm by reference. The record-source rule stays, read in the bag: the node needs `properties.objectName`, or the node's `dataSource` binding. `ObjectGridSchema`, the flat mirror of the TypeScript `ObjectGridSchema`, stays exported as the node as `ObjectGrid` reads it, and still builds the `object-view` `table` slot; it is no longer an arm of `AnyComponentSchema`. +`ObjectGridBlockSchema` is the authored `object-grid` node (objectui#11276's `object-grid` batch), built the way `ObjectGanttBlockSchema` is from the spec's `ComponentPropsMap['object-grid']` row. `label` stays on the node, as on `object-gantt`, and a flat `defaultSort` keeps the row's own retirement. The flat mirror's retirements of keys the row does not declare (`operators`, `rowSpecActions`, `bulkSpecActions`, `name`, `placeholder`, `showFilters`) and its content-channel refusals ride onto the arm by reference, and its `onNavigate` runtime slot is declared as the mirror declares it. The record-source rule stays, read in the bag: the node needs `properties.objectName`, or the node's `dataSource` binding. `ObjectGridSchema`, the flat mirror of the TypeScript `ObjectGridSchema`, stays exported as the node as `ObjectGrid` reads it, and still builds the `object-view` `table` slot; it is no longer an arm of `AnyComponentSchema`. - `ObjectMetricBlockSchema`, `ObjectMasterDetailFormBlockSchema`, `ObjectTimelineBlockSchema`, `ObjectFormBlockSchema`, `ObjectMapBlockSchema`, `ObjectChartBlockSchema`, `ObjectGanttBlockSchema`, `ObjectGridBlockSchema` ### AI Components (3) diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index e39910d51d..b3fb5a6522 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -4520,7 +4520,8 @@ export const ObjectGanttBlockSchema = BaseSchema.extend({ * `placeholder`, `showFilters` (objectui#11068). `name` and `placeholder` are * `BaseSchema` keys, so without the tombstone they would parse again. In the * bag the row refuses each of them. `onNavigate`, which `ObjectGrid` reads off - * the node, stays the objectui#6124 runtime slot it is on the mirror. A flat + * the node, is declared exactly as the mirror declares it, an objectui#6124 + * runtime slot. A flat * key neither the row nor those declare — the mirror's `emptyState` and * `keyboardNavigation` among them — is left as every arm leaves an undeclared * key: unjudged by the tolerant face, refused by the strict one; the row @@ -4567,9 +4568,11 @@ export const ObjectGridBlockSchema = BaseSchema.extend({ name: ObjectGridSchema.shape.name, placeholder: ObjectGridSchema.shape.placeholder, showFilters: ObjectGridSchema.shape.showFilters, - // objectui#6124: `ObjectGrid` reads `onNavigate` off the node, so it stays the - // mirror's runtime slot, refused by name. - onNavigate: ObjectGridSchema.shape.onNavigate, + // objectui#6124: `ObjectGrid` reads `onNavigate` off the node, so it is + // declared exactly as the flat mirror declares it: a RUNTIME SLOT, refused by + // name. Spelled as the helper call, as `ObjectFormBlockSchema` spells its five, + // so `check:handler-key-reads` reads the disposition off this arm. + onNavigate: handlerKeyRefusal('onNavigate', 'runtime-slot', 'Record navigation handler'), // objectui#9256: the renderer reads NEITHER content channel, so both are // refused by name — the flat mirror's own members, by reference. body: ObjectGridSchema.shape.body, From 9f45be6d534ab69bfcc945712c4f2df689e95839 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 19:22:15 +0000 Subject: [PATCH 8/9] docs: leave content/docs/guide/layout.md to PR objectui#11069 (objectui#11276) The file is in parked PR objectui#11069's diff, which this batch's order keeps off its surface; its object-grid node is listed as held by that PR, as batch 4 listed validate.ts. The line returns to main's bytes. Claude-Session: https://claude.ai/code/session_01JG2jy8a9su7ia4Hx7zxv42 Co-authored-by: Claude --- content/docs/guide/layout.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/guide/layout.md b/content/docs/guide/layout.md index 65a79824db..ef9919128d 100644 --- a/content/docs/guide/layout.md +++ b/content/docs/guide/layout.md @@ -173,7 +173,7 @@ A `page` node has no action row of its own. Buttons are NODES, and they go in `c }, { "type": "object-grid", - "properties": { "objectName": "products" } + "objectName": "products" } ] } From 9b2789cfbd5576f5a4e8e6a822bf1f1d62cdffa9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 19:34:06 +0000 Subject: [PATCH 9/9] docs: layout.md's object-grid node writes its props in the properties bag (objectui#11276) The seat's call on the PR's first deviation: triage's no-interval rule wins, so the 'With Action Buttons' object-grid node moves to the bag here, the same one-line change that merges cleanly with PR objectui#11069's version of the file. Claude-Session: https://claude.ai/code/session_01JG2jy8a9su7ia4Hx7zxv42 Co-authored-by: Claude --- content/docs/guide/layout.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/guide/layout.md b/content/docs/guide/layout.md index ef9919128d..65a79824db 100644 --- a/content/docs/guide/layout.md +++ b/content/docs/guide/layout.md @@ -173,7 +173,7 @@ A `page` node has no action row of its own. Buttons are NODES, and they go in `c }, { "type": "object-grid", - "objectName": "products" + "properties": { "objectName": "products" } } ] }