From 1a276c23f1c1fbd45cd0ed2bc34941aaeadb8fe4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 05:05:26 +0000 Subject: [PATCH 1/6] wip(spec): type the list family's z.unknown() members on object-grid, object-kanban and object-calendar (#21464 stage 2) Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude --- ...grid-kanban-calendar-list-members-typed.ts | 55 ++++ packages/spec/src/migrations/registry.ts | 66 +++++ ...nent-list-family-typed-members.pin.test.ts | 272 ++++++++++++++++++ ...omponent-props-unknown-members.pin.test.ts | 20 +- .../spec/src/ui/component-type-vocabulary.ts | 5 +- packages/spec/src/ui/component.zod.ts | 186 ++++++++++-- 6 files changed, 573 insertions(+), 31 deletions(-) create mode 100644 packages/spec/src/migrations/entries/semantic/18.ui-object-grid-kanban-calendar-list-members-typed.ts create mode 100644 packages/spec/src/ui/component-list-family-typed-members.pin.test.ts diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-object-grid-kanban-calendar-list-members-typed.ts b/packages/spec/src/migrations/entries/semantic/18.ui-object-grid-kanban-calendar-list-members-typed.ts new file mode 100644 index 00000000000..d173ea11de2 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-object-grid-kanban-calendar-list-members-typed.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #21464 — the list members of the `object-grid`, `object-kanban` and +// `object-calendar` page blocks were `z.unknown()` (an array of it for the +// lists) although each renderer reads them with a fixed shape, so an off-shape +// value passed the component-props gate and the block dropped or substituted it +// in silence. The rows now take the list view's own members by reference where +// a list view declares one, and the measured shape otherwise. D3 only: +// page-component `properties` is not parsed on the metadata save or load path, +// so a stored page is never refused; an off-shape value has no rewrite that +// says what the author meant; and the authored census found no authored value +// to respell — the refused values are fixtures probing that the renderer drops +// them. +export const entry: SemanticMigration = { + id: 'ui-object-grid-kanban-calendar-list-members-typed', + surface: 'page `object-grid` components — `properties.columns`, `.fields`, `.selection`, `.selectable`, ' + + '`.rowActions`, `.bulkActions` and `.batchActions`; page `object-kanban` components — ' + + '`properties.columns`; page `object-calendar` components — `properties.calendar` (which used to ' + + 'accept any value)', + replacement: 'the shape each block reads, the list view\'s own where it has one: `object-grid` `columns` ' + + 'all field-name strings or all column entries `{ field, label?, width?, … }` (never mixed); `fields` ' + + 'field-name strings; `selection` `{ type }` with `none` / `single` / `multiple`; `selectable` `true`, ' + + '`false`, `\'single\'` or `\'multiple\'`; `rowActions`, `bulkActions` and `batchActions` action-name ' + + 'strings. `object-kanban` `columns` all lanes `{ id, title, cards?, limit?, className?, collapsed? }` ' + + 'or all bare value strings (never mixed), a lane `id` a string. `object-calendar` `calendar` ' + + '`{ startDateField, endDateField?, titleField?, colorField?, allDayField? }`. Rewrite a grid column ' + + 'written `{ accessorKey, header }` or `{ name }` as `{ field, label }`; move an object entry of ' + + '`fields` to `columns`; move a `{ name }` entry of `bulkActions` to `bulkActionDefs` or write the ' + + 'bare name; style a lane with `className` instead of `color`; rename `dateField` / `endField` to ' + + '`startDateField` / `endDateField`.', + reason: 'Each renderer reads these members with one shape, and the page-component rows declared them ' + + '`z.unknown()`, so any value passed the component-props gate and the block answered an off-shape one ' + + 'with a silent default: a grid column keyed `accessorKey` or `name`, or an array mixing strings and ' + + 'column objects, drew no column; an object entry of `fields` named no field; a `{ name }` entry of ' + + '`bulkActions` was skipped; a kanban lane list mixing objects and strings drew a blank lane and swept ' + + 'its records into the trailing lane; and a calendar block without `startDateField` placed no event. The rows ' + + 'now take the list view\'s own `columns`, `selection`, `rowActions`, `bulkActions` (for `batchActions` ' + + 'too, the spelling the grid reads first) and `calendar` members by reference, and the measured shape ' + + 'for the grid\'s `fields` and `selectable` and the kanban lane, so one value is judged the same way ' + + 'on every door that carries it. It is read where every page component\'s props are: the ' + + 'component-props gate reports a refused value as an advisory `component-props-invalid` / ' + + '`component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and ' + + '`objectstack lint`, and a stored page still saves and loads, because a page component\'s ' + + '`properties` is not parsed on the metadata save or load path. No conversion is registered: nothing ' + + 'on the load path refuses the shape, and an off-shape value has no rewrite that both keeps what the ' + + 'block shows today and honours what the author wrote — which is the judgment this entry leaves to ' + + 'the upgrader. Deployed metadata NOT MEASURED.', + acceptanceCriteria: 'Every `object-grid`, `object-kanban` and `object-calendar` node validates: ' + + '`objectstack validate` reports no `component-props-invalid` / `component-props-unknown-key` ' + + 'finding under the nine members\' paths. Each block that set one of them now shows it: the declared ' + + 'grid columns, the selection mode, the row and bulk actions, the kanban lanes with their records, and ' + + 'the calendar events placed by `startDateField`.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 3233239ec55..f61ecad0b98 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -6131,6 +6131,21 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'would change the menu a deployed grid shows. The authored census found nothing to respell. ' + 'Its D3 record is the semantic entry `ui-object-grid-export-options-closed`.', }, + { + id: 'ui-object-grid-kanban-calendar-list-members-typed', + order: 66, + text: + 'It also types the list members of the `object-grid`, `object-kanban` and `object-calendar` page ' + + 'blocks (#21464, the second stage of the `ComponentPropsMap` `z.unknown()` close-out): the grid\'s ' + + '`columns`, `fields`, `selection`, `selectable`, `rowActions`, `bulkActions` and `batchActions`, the ' + + 'kanban\'s `columns` and the calendar\'s `calendar` were `z.unknown()` (an array of it for the lists), ' + + 'although each renderer reads them with one shape, so a grid column keyed `accessorKey` passed every ' + + 'door and drew no column. The members a list view declares take the list view\'s own by reference ' + + '(`batchActions`, the spelling the grid reads first, takes `bulkActions`\'s); the grid\'s `fields` ' + + 'and `selectable` and the kanban lane take the measured shape. Read by the component-props gate ' + + '(advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is ' + + 'the semantic entry `ui-object-grid-kanban-calendar-list-members-typed`.', + }, { id: 'ui-object-grid-row-members-typed', order: 63, @@ -19440,6 +19455,57 @@ const step18: MigrationStep = { + 'and the grid\'s export menu offers the declared formats the active export path delivers ' + '(`xlsx` on the server stream only).', }, + // #21464 — the list members of the `object-grid`, `object-kanban` and + // `object-calendar` page blocks were `z.unknown()` (an array of it for the + // lists) although each renderer reads them with a fixed shape, so an off-shape + // value passed the component-props gate and the block dropped or substituted it + // in silence. The rows now take the list view's own members by reference where + // a list view declares one, and the measured shape otherwise. D3 only: + // page-component `properties` is not parsed on the metadata save or load path, + // so a stored page is never refused; an off-shape value has no rewrite that + // says what the author meant; and the authored census found no authored value + // to respell — the refused values are fixtures probing that the renderer drops + // them. + { + id: 'ui-object-grid-kanban-calendar-list-members-typed', + surface: 'page `object-grid` components — `properties.columns`, `.fields`, `.selection`, `.selectable`, ' + + '`.rowActions`, `.bulkActions` and `.batchActions`; page `object-kanban` components — ' + + '`properties.columns`; page `object-calendar` components — `properties.calendar` (which used to ' + + 'accept any value)', + replacement: 'the shape each block reads, the list view\'s own where it has one: `object-grid` `columns` ' + + 'all field-name strings or all column entries `{ field, label?, width?, … }` (never mixed); `fields` ' + + 'field-name strings; `selection` `{ type }` with `none` / `single` / `multiple`; `selectable` `true`, ' + + '`false`, `\'single\'` or `\'multiple\'`; `rowActions`, `bulkActions` and `batchActions` action-name ' + + 'strings. `object-kanban` `columns` all lanes `{ id, title, cards?, limit?, className?, collapsed? }` ' + + 'or all bare value strings (never mixed), a lane `id` a string. `object-calendar` `calendar` ' + + '`{ startDateField, endDateField?, titleField?, colorField?, allDayField? }`. Rewrite a grid column ' + + 'written `{ accessorKey, header }` or `{ name }` as `{ field, label }`; move an object entry of ' + + '`fields` to `columns`; move a `{ name }` entry of `bulkActions` to `bulkActionDefs` or write the ' + + 'bare name; style a lane with `className` instead of `color`; rename `dateField` / `endField` to ' + + '`startDateField` / `endDateField`.', + reason: 'Each renderer reads these members with one shape, and the page-component rows declared them ' + + '`z.unknown()`, so any value passed the component-props gate and the block answered an off-shape one ' + + 'with a silent default: a grid column keyed `accessorKey` or `name`, or an array mixing strings and ' + + 'column objects, drew no column; an object entry of `fields` named no field; a `{ name }` entry of ' + + '`bulkActions` was skipped; a kanban lane list mixing objects and strings drew a blank lane and swept ' + + 'its records into the trailing lane; and a calendar block without `startDateField` placed no event. The rows ' + + 'now take the list view\'s own `columns`, `selection`, `rowActions`, `bulkActions` (for `batchActions` ' + + 'too, the spelling the grid reads first) and `calendar` members by reference, and the measured shape ' + + 'for the grid\'s `fields` and `selectable` and the kanban lane, so one value is judged the same way ' + + 'on every door that carries it. It is read where every page component\'s props are: the ' + + 'component-props gate reports a refused value as an advisory `component-props-invalid` / ' + + '`component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and ' + + '`objectstack lint`, and a stored page still saves and loads, because a page component\'s ' + + '`properties` is not parsed on the metadata save or load path. No conversion is registered: nothing ' + + 'on the load path refuses the shape, and an off-shape value has no rewrite that both keeps what the ' + + 'block shows today and honours what the author wrote — which is the judgment this entry leaves to ' + + 'the upgrader. Deployed metadata NOT MEASURED.', + acceptanceCriteria: 'Every `object-grid`, `object-kanban` and `object-calendar` node validates: ' + + '`objectstack validate` reports no `component-props-invalid` / `component-props-unknown-key` ' + + 'finding under the nine members\' paths. Each block that set one of them now shows it: the declared ' + + 'grid columns, the selection mode, the row and bulk actions, the kanban lanes with their records, and ' + + 'the calendar events placed by `startDateField`.', + }, { id: 'ui-object-grid-page-size-positive-integer-refused', surface: '`object-grid` page-component page sizes ' diff --git a/packages/spec/src/ui/component-list-family-typed-members.pin.test.ts b/packages/spec/src/ui/component-list-family-typed-members.pin.test.ts new file mode 100644 index 00000000000..9f0dee24e08 --- /dev/null +++ b/packages/spec/src/ui/component-list-family-typed-members.pin.test.ts @@ -0,0 +1,272 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21464, stage 2] The list family's nine `z.unknown()` members are typed: + * `object-grid` `columns`, `fields`, `selection`, `selectable`, `rowActions`, + * `bulkActions` and `batchActions`, `object-kanban` `columns`, and + * `object-calendar` `calendar`. + * + * ## The defect this file closes + * + * Each renderer reads these members with one shape (measured at the + * `.objectui-sha` pin `89cad75d55`; the read points are in the members' + * docblocks), and each row declared them `z.unknown()`. So a grid column keyed + * `accessorKey`, a `{ name }` entry in `bulkActions`, a kanban lane list mixing + * objects and strings and a calendar block with no `startDateField` all passed + * the component-props gate, and the block drew no column, skipped the action, + * drew a blank lane or placed no event, with no report. + * + * ## What is pinned, and why each half + * + * - §1 THE DECLARED SHAPES PARSE: a value of each member's shape parses, and + * parses to what the shared schema itself answers. A refusal pin with no lit + * control passes just as well when the door refuses everything. + * - §2 THE REFUSALS: an off-shape value of each member is refused with the + * code AND the path — and, for the two-array unions, the issue inside the + * arm that should have taken it — so a refusal for the wrong reason reds. + * - §3 ONE SCHEMA: the five members a list view also declares hold the list + * view's own defs by identity (`batchActions` holds `bulkActions`'s), and the + * shapes declared here hold exactly the measured vocabulary. + * - §4 THE REGISTRATION: the ADR-0087 D3 entry step 18 carries. + * + * The enumeration pin (`component-props-unknown-members.pin.test.ts`) holds the + * other half: these nine left its ledger, so a member reverted to + * `z.unknown()` reds there. + */ + +import { describe, it, expect } from 'vitest'; +import type { z } from 'zod'; + +import { + ComponentPropsMap, + ObjectCalendarPropsSchema, + ObjectGridPropsSchema, + ObjectKanbanPropsSchema, +} from './component.zod'; +import { CalendarConfigSchema, ListViewSchema, SelectionConfigSchema } from './view.zod'; +import { MIGRATIONS_BY_MAJOR } from '../migrations/registry'; + +const BASE = { objectName: 'account' } as const; +type Row = 'object-grid' | 'object-kanban' | 'object-calendar'; +const parse = (row: Row, props: Record) => ComponentPropsMap[row].safeParse({ ...BASE, ...props }); + +/** The issue codes and paths a refusal carries, so a refusal for the WRONG reason reds. */ +function issues(result: z.ZodSafeParseResult): { code: string; path: string }[] { + if (result.success) return []; + return result.error.issues.map((i) => ({ code: i.code, path: i.path.join('.') })); +} + +/** Inside a two-array union's refusal: each arm's issues, `code@path` relative to the member. */ +function armIssues(result: z.ZodSafeParseResult): string[][] { + if (result.success) return []; + const union = result.error.issues[0] as { errors?: Array> }; + return (union.errors ?? []).map((arm) => arm.map((i) => `${i.code}@${i.path.join('.')}`)); +} + +// ─────────────────────────────────────────────────────────────────────────── +// §1 the declared shapes parse +// ─────────────────────────────────────────────────────────────────────────── + +describe('§1 each member accepts a value of its declared shape', () => { + const BYTE_IDENTICAL: ReadonlyArray]> = [ + ['grid columns as field names', 'object-grid', { columns: ['name', 'amount'] }], + ['grid columns as column entries', 'object-grid', { + columns: [ + { field: 'name', label: 'Name', width: 240, sortable: true, link: true }, + { field: 'amount', align: 'right', summary: 'sum', wrap: true, pinned: 'left' }, + ], + }], + ['grid fields', 'object-grid', { fields: ['name', 'amount'] }], + ['each selection type', 'object-grid', { selection: { type: 'single' } }], + ['grid rowActions', 'object-grid', { rowActions: ['edit', 'delete', 'approve'] }], + ['grid bulkActions', 'object-grid', { bulkActions: ['delete', 'export'] }], + ['grid batchActions', 'object-grid', { batchActions: ['approve'] }], + ['kanban lanes with every member, a static card carrying its row values', 'object-kanban', { + columns: [ + { id: 'todo', title: 'To Do', limit: 5, className: 'border-t-2 border-blue-500', collapsed: false }, + { id: 'done', title: 'Done', cards: [{ id: 'c1', title: 'Ship it', description: 'Release notes', owner: 'ana' }] }, + ], + }], + ['kanban lanes as bare values', 'object-kanban', { columns: ['todo', 'doing'] }], + ['a calendar block with all five bindings', 'object-calendar', { + calendar: { startDateField: 'starts_at', endDateField: 'ends_at', titleField: 'name', colorField: 'status', allDayField: 'is_all_day' }, + }], + ['a calendar block with only startDateField', 'object-calendar', { calendar: { startDateField: 'starts_at' } }], + ]; + + for (const [label, row, props] of BYTE_IDENTICAL) { + it(`parses ${label}, byte-identical`, () => { + const r = parse(row, props); + expect(issues(r)).toEqual([]); + expect(r.success && r.data).toStrictEqual({ ...BASE, ...props }); + }); + } + + for (const type of ['none', 'single', 'multiple'] as const) { + it(`parses selection.type '${type}'`, () => { + expect(issues(parse('object-grid', { selection: { type } }))).toEqual([]); + }); + } + + for (const selectable of [true, false, 'single', 'multiple'] as const) { + it(`parses selectable: ${JSON.stringify(selectable)}`, () => { + const r = parse('object-grid', { selectable }); + expect(issues(r)).toEqual([]); + expect(r.success && r.data).toStrictEqual({ ...BASE, selectable }); + }); + } + + it('parses a column with a prefix to exactly what the list view\'s columns answer (its default included)', () => { + const columns = [{ field: 'name', prefix: { field: 'status' } }]; + const r = parse('object-grid', { columns }); + expect(issues(r)).toEqual([]); + expect(r.success && (r.data as { columns?: unknown }).columns) + .toStrictEqual(ListViewSchema.shape.columns.parse(columns)); + }); + + it('parses an empty selection block to exactly what the list view\'s selection answers (its default included)', () => { + const r = parse('object-grid', { selection: {} }); + expect(issues(r)).toEqual([]); + expect(r.success && (r.data as { selection?: unknown }).selection).toStrictEqual(SelectionConfigSchema.parse({})); + }); + + it('an absent member stays absent', () => { + for (const row of ['object-grid', 'object-kanban', 'object-calendar'] as const) { + const r = parse(row, {}); + expect(issues(r)).toEqual([]); + expect(r.success && Object.keys(r.data)).toEqual(['objectName']); + } + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// §2 the refusals +// ─────────────────────────────────────────────────────────────────────────── + +describe('§2 each member refuses an off-shape value', () => { + const REFUSED: ReadonlyArray, code: string, path: string]> = [ + ['an object entry in grid fields', 'object-grid', { fields: [{ field: 'name' }] }, 'invalid_type', 'fields.0'], + ['a bare-string grid fields', 'object-grid', { fields: 'name' }, 'invalid_type', 'fields'], + ['a bare-string selection', 'object-grid', { selection: 'multiple' }, 'invalid_type', 'selection'], + ['an unknown selection type', 'object-grid', { selection: { type: 'all' } }, 'invalid_value', 'selection.type'], + ['an undeclared selection key', 'object-grid', { selection: { mode: 'single' } }, 'unrecognized_keys', 'selection'], + ['selectable: \'none\'', 'object-grid', { selectable: 'none' }, 'invalid_union', 'selectable'], + ['a numeric selectable', 'object-grid', { selectable: 1 }, 'invalid_union', 'selectable'], + ['a bare-string rowActions', 'object-grid', { rowActions: 'edit' }, 'invalid_type', 'rowActions'], + ['an object entry in rowActions', 'object-grid', { rowActions: [{ name: 'edit' }] }, 'invalid_type', 'rowActions.0'], + ['a def entry in bulkActions', 'object-grid', { bulkActions: [{ name: 'approve' }] }, 'invalid_type', 'bulkActions.0'], + ['a def entry in batchActions', 'object-grid', { batchActions: [{ name: 'approve' }] }, 'invalid_type', 'batchActions.0'], + ['a bare-string batchActions', 'object-grid', { batchActions: 'approve' }, 'invalid_type', 'batchActions'], + ['a lane limit of 0', 'object-kanban', { columns: [{ id: 'todo', title: 'To Do', limit: 0 }] }, 'too_small', 'columns.0.limit'], + ['a calendar block with no startDateField', 'object-calendar', { calendar: { titleField: 'name' } }, 'invalid_type', 'calendar.startDateField'], + ['the retired endField alias', 'object-calendar', { calendar: { startDateField: 'kickoff', endField: 'wrapup' } }, 'unrecognized_keys', 'calendar'], + ['a bare-string calendar', 'object-calendar', { calendar: 'starts_at' }, 'invalid_type', 'calendar'], + ]; + + for (const [label, row, props, code, path] of REFUSED) { + it(`${row}: refuses ${label} — ${code} at ${path}`, () => { + const r = parse(row, props); + expect(r.success).toBe(false); + expect(issues(r)).toEqual([{ code, path }]); + }); + } + + // The two-array unions answer `invalid_union` at the member; the arm that + // should have taken the value says why it did not. + const UNION_REFUSED: ReadonlyArray, arms: string[][]]> = [ + ['a grid column list mixing strings and column objects', 'object-grid', + { columns: ['name', { field: 'amount' }] }, [['invalid_type@1'], ['invalid_type@0']]], + ['a grid column keyed accessorKey / header', 'object-grid', + { columns: [{ accessorKey: 'amount', header: 'Amount' }] }, [['invalid_type@0'], ['invalid_type@0.field', 'unrecognized_keys@0']]], + ['a grid column keyed name', 'object-grid', + { columns: [{ name: 'salary' }] }, [['invalid_type@0'], ['invalid_type@0.field', 'unrecognized_keys@0']]], + ['a grid column key the grid never reads (editable)', 'object-grid', + { columns: [{ field: 'name', editable: false }] }, [['invalid_type@0'], ['unrecognized_keys@0']]], + ['a numeric grid columns', 'object-grid', { columns: 42 }, [['invalid_type@'], ['invalid_type@']]], + ['a kanban lane list mixing objects and strings', 'object-kanban', + { columns: [{ id: 'done', title: 'Done' }, 'todo'] }, [['invalid_type@0'], ['invalid_type@1']]], + ['a numeric lane id', 'object-kanban', { columns: [{ id: 1, title: 'One' }] }, [['invalid_type@0'], ['invalid_type@0.id']]], + ['a lane with no title', 'object-kanban', { columns: [{ id: 'todo' }] }, [['invalid_type@0'], ['invalid_type@0.title']]], + ['a static card with no title', 'object-kanban', + { columns: [{ id: 'todo', title: 'To Do', cards: [{ id: 'c1' }] }] }, [['invalid_type@0'], ['invalid_type@0.cards.0.title']]], + ['a lane color', 'object-kanban', + { columns: [{ id: 'todo', title: 'To Do', color: 'red' }] }, [['invalid_type@0'], ['unrecognized_keys@0']]], + ['a bare-string kanban columns', 'object-kanban', { columns: 'todo' }, [['invalid_type@'], ['invalid_type@']]], + ]; + + for (const [label, row, props, arms] of UNION_REFUSED) { + it(`${row}: refuses ${label} — invalid_union at columns, for the arm's reason`, () => { + const r = parse(row, props); + expect(r.success).toBe(false); + expect(issues(r)).toEqual([{ code: 'invalid_union', path: 'columns' }]); + expect(armIssues(r)).toEqual(arms); + }); + } + + it('object-kanban: says a lane `color` is not read, and names `className`', () => { + const r = parse('object-kanban', { columns: [{ id: 'todo', title: 'To Do', color: 'red' }] }); + const union = (r.success ? undefined : r.error.issues[0]) as { errors?: Array> } | undefined; + expect(union?.errors?.[1]?.[0]?.message).toMatch(/`color` is not a lane member: no board reads it\. Style a lane through its `className`/); + }); + + it('LIT CONTROL — an unknown top-level key is still refused at each row itself', () => { + expect(issues(parse('object-grid', { fields: ['name'], notAGridKey: 1 }))).toEqual([{ code: 'unrecognized_keys', path: '' }]); + expect(issues(parse('object-kanban', { columns: ['todo'], notABoardKey: 1 }))).toEqual([{ code: 'unrecognized_keys', path: '' }]); + expect(issues(parse('object-calendar', { calendar: { startDateField: 'd' }, notACalendarKey: 1 }))) + .toEqual([{ code: 'unrecognized_keys', path: '' }]); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// §3 one schema, not a copy of its shape +// ─────────────────────────────────────────────────────────────────────────── + +describe('§3 the members hold the list view\'s own defs, and the measured vocabulary', () => { + const grid = () => ObjectGridPropsSchema.shape; + const listView = () => ListViewSchema.shape; + + it('object-grid columns is the list view\'s own columns member — the same union def', () => { + expect(grid().columns.unwrap()._zod.def).toBe(listView().columns._zod.def); + }); + + it('object-grid selection, rowActions and bulkActions are the list view\'s own members — the same defs', () => { + expect(grid().selection.unwrap()._zod.def).toBe(SelectionConfigSchema._zod.def); + expect(grid().selection.unwrap()._zod.def).toBe(listView().selection.unwrap()._zod.def); + expect(grid().rowActions.unwrap()._zod.def).toBe(listView().rowActions.unwrap()._zod.def); + expect(grid().bulkActions.unwrap()._zod.def).toBe(listView().bulkActions.unwrap()._zod.def); + }); + + it('object-grid batchActions holds bulkActions\'s def — one capability, one accept set', () => { + expect(grid().batchActions.unwrap()._zod.def).toBe(listView().bulkActions.unwrap()._zod.def); + }); + + it('object-calendar calendar is the list view\'s own calendar member — CalendarConfigSchema', () => { + const calendar = ObjectCalendarPropsSchema.shape.calendar.unwrap(); + expect(calendar._zod.def).toBe(CalendarConfigSchema._zod.def); + expect(calendar._zod.def).toBe(listView().calendar.unwrap()._zod.def); + }); + + it('object-grid selectable declares exactly the read\'s set: a boolean, `single`, `multiple`', () => { + const [bool, modes] = grid().selectable.unwrap().options; + expect(bool._zod.def.type).toBe('boolean'); + expect([...(modes as z.ZodEnum).options].sort()).toEqual(['multiple', 'single']); + }); + + it('an object-kanban lane declares exactly the six members the board reads', () => { + const [strings, lanes] = ObjectKanbanPropsSchema.shape.columns.unwrap().options; + expect(strings.element._zod.def.type).toBe('string'); + expect(Object.keys(lanes.element.shape).sort()).toEqual(['cards', 'className', 'collapsed', 'id', 'limit', 'title']); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// §4 the registration +// ─────────────────────────────────────────────────────────────────────────── + +describe('§4 the ADR-0087 entry', () => { + it('is registered as the D3 entry step 18 carries, beside the stage-1 entry', () => { + const ids = MIGRATIONS_BY_MAJOR[18]!.semantic.map((s) => s.id); + expect(ids).toContain('ui-object-grid-kanban-calendar-list-members-typed'); + expect(ids).toContain('ui-object-map-gantt-tree-navigation-typed'); + }); +}); diff --git a/packages/spec/src/ui/component-props-unknown-members.pin.test.ts b/packages/spec/src/ui/component-props-unknown-members.pin.test.ts index 80e8d85afdf..2a0ab0f9c38 100644 --- a/packages/spec/src/ui/component-props-unknown-members.pin.test.ts +++ b/packages/spec/src/ui/component-props-unknown-members.pin.test.ts @@ -30,6 +30,12 @@ * `NavigationConfigSchema`, by identity, and refuses an off-shape value with * the code AND the path; its ADR-0087 D3 entry is registered. * + * Later stages pin the members they type in their own file, beside this one: + * the list family (`object-grid` `columns` / `fields` / `selection` / + * `selectable` / `rowActions` / `bulkActions` / `batchActions`, + * `object-kanban` `columns`, `object-calendar` `calendar`) in + * `component-list-family-typed-members.pin.test.ts`. + * * ## The STAGED reason is debt, not a verdict * * A `staged` member IS read with a fixed shape at the `.objectui-sha` pin; its @@ -118,7 +124,6 @@ function unknownMembers(schema: unknown): UnknownMember[] { const STAGES = { 'object-metric': 'the metric tile\'s four config blocks; the dashboard widget\'s `compareTo` and the chart\'s `aggregate` / `drillDown` are the by-reference candidates, and `trend` has no spec declaration, so it is typed to the renderer\'s read', 'object-form': 'the form and master-detail form rows; `FormViewSchema` (`sections`, `submitBehavior`) is the by-reference candidate', - 'list-family': 'the grid, kanban and calendar list members; `ListViewSchema` (`columns`, `selection`, `rowActions`, `bulkActions`, `calendar`) is the by-reference candidate', 'objectui-held': 'element contracts whose only declaration is still objectui\'s (`GanttMarker`, `TimelineMappingSchema`, the timeline items, and `UIActionSchema`, an objectui interface that borrows some members from the spec `Action`); the spec declares each first, contract-first, then the row takes it', 'held-for-decision': 'a by-reference shape exists, but measured writers author values it refuses — the narrowing waits for a ruling', } as const; @@ -137,8 +142,6 @@ type Reason = | { readonly kind: 'any-value'; readonly why: string } /** A deliberately open bag: the declared members are typed, the rest pass through. */ | { readonly kind: 'open-bag'; readonly why: string } - /** No reader at the pin, and not visibly forwarded — an open question, not a verdict. */ - | { readonly kind: 'no-reader'; readonly why: string } /** Read with a fixed shape at the pin (`reader`); typing it is a named later stage. */ | { readonly kind: 'staged'; readonly stage: Stage; readonly reader: string }; @@ -203,6 +206,9 @@ on(['object-kanban', 'object-timeline'], ['data[]'], RECORDS); on(['object-calendar'], ['data[]', 'staticData[]'], RECORDS); on(['object-form', 'object-master-detail-form'], ['initialValues{}', 'initialData{}'], RECORDS); on(['object-grid'], ['bulkActionDefs[].patch{}', 'bulkActionDefs[].params[].default'], RECORDS); +// A static board's card is a record row: `id` and `title` are typed, the rest +// is the row's own values (`plugin-kanban/src/index.tsx:155`, kept verbatim). +on(['object-kanban'], ['columns[].cards[].*'], RECORDS); on(['object-grid'], ['bulkActionDefs[].params[].options[].*'], BULK_OPTION_ENTRY); // The rest, one line each. @@ -228,14 +234,6 @@ on(['object-form'], ['submitBehavior'], staged('object-form', 'plugin-form/src/O on(['object-form'], ['navigateOnSuccess'], staged('object-form', 'plugin-form/src/ObjectForm.tsx:1373, :1412-1424')); on(['object-form'], ['mobile'], staged('object-form', 'plugin-form/src/ObjectForm.tsx:1857')); on(['object-master-detail-form'], ['sections[]', 'fields[]'], staged('object-form', 'plugin-form/src/MasterDetailForm.tsx:1692-1693, into the parent form')); -on(['object-grid'], ['columns[]'], staged('list-family', 'plugin-grid/src/ObjectGrid.tsx:2158 (`normalizeColumns`, `string | ListColumn`)')); -on(['object-grid'], ['fields[]'], staged('list-family', 'plugin-grid/src/ObjectGrid.tsx:1946')); -on(['object-grid'], ['selection'], staged('list-family', 'plugin-grid/src/ObjectGrid.tsx:4799-4810 (`.type`)')); -on(['object-grid'], ['selectable'], staged('list-family', 'plugin-grid/src/ObjectGrid.tsx:4813-4815')); -on(['object-grid'], ['rowActions[]'], staged('list-family', 'plugin-grid/src/ObjectGrid.tsx:1834-1835 (`string[]`)')); -on(['object-grid'], ['bulkActions[]', 'batchActions[]'], staged('list-family', 'plugin-grid/src/ObjectGrid.tsx:4763 (`batchActions ?? bulkActions`)')); -on(['object-kanban'], ['columns[]'], staged('list-family', 'plugin-kanban/src/KanbanBoardCore.tsx:95')); -on(['object-calendar'], ['calendar'], staged('list-family', 'plugin-calendar/src/ObjectCalendar.tsx:296-297 (`ObjectCalendarConfig`)')); on(['object-gantt'], ['markers[]'], staged('objectui-held', 'plugin-gantt/src/ObjectGantt.tsx:2497 (`GanttMarker`)')); on(['object-timeline'], ['items[]'], staged('objectui-held', 'plugin-timeline/src/ObjectTimeline.tsx:587')); on(['object-timeline'], ['mapping'], staged('objectui-held', 'plugin-timeline/src/ObjectTimeline.tsx:551, :576-579')); diff --git a/packages/spec/src/ui/component-type-vocabulary.ts b/packages/spec/src/ui/component-type-vocabulary.ts index 4b282b309e5..8e0a9e8e43c 100644 --- a/packages/spec/src/ui/component-type-vocabulary.ts +++ b/packages/spec/src/ui/component-type-vocabulary.ts @@ -81,8 +81,9 @@ export const RESERVED_COMPONENT_TYPE_NAMESPACES: ReadonlySet = new Set( * measured string-arm registrations that DID get a row — `element:metadata_viewer`, * `record:line_items`, the plugin console widgets, the `object-*` blocks — plus * every type the vocabulary RETIRED by name, whose row is kept on purpose so the - * readers that dispatch on it keep recognising the name: `user:profile`, and the - * retired-with-tombstones `element:filter` / `element:form`), and the + * readers that dispatch on it keep recognising the name: `user:profile`, + * `ai:chat_window`, and the retired-with-tombstones `element:filter` / + * `element:form`), and the * string-arm ledger above. * * KNOWN is not the same as WRITABLE. A retired type stays known here — that is diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 891d947a323..e60c46e7760 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -3879,6 +3879,21 @@ const GridOperationsSchema = lazySchema(() => strictObject({ * the measured shape here. In the same change `resizableColumns` — read only * as `schema.resizable ?? schema.resizableColumns` (:5361) — retires to a * tombstone naming `resizable`. + * + * [#21464] The list members, re-measured at the same pin `89cad75d55` and + * typed the same way — each was `z.unknown()` (an array of it for the four + * lists), so a value of the wrong shape passed the component-props gate and + * the grid dropped or substituted it in silence: `columns` (`normalizeColumns`, + * :819, dispatching on the FIRST entry — all strings or all `ListColumn` + * objects; read at :2158 and projected at :2590), `fields` (:1946 — field + * NAMES on the draw path, `objectSchema.fields[fieldName]` at :3969 / :4012), + * `selection` (`.type`, :4799-4812), `selectable` (:4813-4815, handed to the + * table's `selectable` at :5333), `rowActions` (`string[]`, :1834-1835) and + * `bulkActions` / `batchActions` (`batchActions ?? bulkActions`, :4763, each + * entry a NAME `resolveBulkActions` folds; a non-string entry is skipped). The + * four a list view also declares take the list view's own members by + * reference; `fields` and `selectable` have no list-view counterpart and + * declare the measured shape here; `batchActions` takes `bulkActions`'s def. */ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ surface: 'this `object-grid`', @@ -3922,10 +3937,31 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ */ emptyState: EmptyStateSchema.optional() .describe('What the grid draws instead of an empty table: `{ title, message, icon }` — the list view\'s own empty-state shape'), - columns: z.array(z.unknown()).optional() - .describe('Columns: field names or column definition objects'), - fields: z.array(z.unknown()).optional() - .describe('Field list fallback used when `columns` is absent'), + /** + * [#21464] The list view's own `columns` member, by reference + * (`ListViewSchema.shape.columns` — the union of two array shapes the + * column entry is declared in): all field-name strings, or all + * `ListColumn` entries. `normalizeColumns` (`ObjectGrid.tsx:819` at the pin + * `89cad75d55`) decides which by the FIRST entry, and the draw path keeps + * only an entry whose `field` is a non-empty string, so a mixed array, a + * column keyed `accessorKey` / `header` / `name`, or a key the grid never + * reads (`editable`, `options`) drew no column or was ignored. Optional + * here, where the list view requires it: a grid with no `columns` derives + * them from `fields` or the object. + */ + columns: ListViewSchema.shape.columns.optional() + .describe('Columns — all field-name strings, or all column entries `{ field, label?, width?, align?, hidden?, sortable?, … }`, the same union a list view\'s `columns` declares. One spelling per list: an array mixing strings and column objects is refused'), + /** + * [#21464] Field NAMES. No list-view schema declares this member, so the + * shape is the one the grid reads (`ObjectGrid.tsx:1946` at the pin + * `89cad75d55`): when `columns` is absent the draw path looks each entry up + * as `objectSchema.fields[fieldName]` (`:3969`, `:4012`), so an object + * entry named no field and drew no column. The projection reads object + * entries too (`:2587`), but only for the host hand-off `ListView` makes, + * which is not authored here. Column decoration belongs on `columns`. + */ + fields: z.array(z.string()).optional() + .describe('Field-name fallback the grid reads when `columns` is absent — bare field names (`[\'name\', \'amount\']`); write column decoration such as `label` or `width` on `columns`'), /** * Base query filter — the `ViewFilterRule` ARRAY form, * `[{ field, operator, value }, ...]`, the one filter orthography every @@ -4167,11 +4203,56 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ */ rowColor: RowColorConfigSchema.optional() .describe('Row colour by field value — `{ field, colors }`, the same block a list view\'s `rowColor` declares'), - selection: z.unknown().optional().describe('Selection config ({ type: none | single | multiple })'), - selectable: z.unknown().optional().describe('Legacy selection shorthand, read only when `selection` is absent. Prefer `selection`'), - rowActions: z.array(z.unknown()).optional().describe('Per-row action names'), - bulkActions: z.array(z.unknown()).optional().describe('Bulk action names shown on selection'), - batchActions: z.array(z.unknown()).optional().describe('Alternate spelling the renderer reads FIRST (`batchActions ?? bulkActions`)'), + /** + * [#21464] The list view's own `selection` member, by reference + * (`SelectionConfigSchema`): the grid reads `selection.type` + * (`ObjectGrid.tsx:4799-4812` at the pin `89cad75d55`) — `none` is off, + * `single` and `multiple` are the two modes. ⚠️ An object with no `type` + * turns selection ON at that read (objectui#9837, ruling A-prime: presence + * enables), while the shared schema's own `type` default is `none`; that + * default reaches only a PARSED document, never the bag the grid reads, and + * it is the list view's declaration either way — objectui#9837 holds the + * question. + */ + selection: ListViewSchema.shape.selection + .describe('Selection config `{ type }` — `none`, `single` or `multiple`, the same block a list view\'s `selection` declares'), + /** + * [#21464] The legacy shorthand, read only when `selection` is absent + * (`ObjectGrid.tsx:4813-4815` at the pin `89cad75d55`) and handed on as the + * table's `selectable` (`:5333`), which `resolveSelectionMode` + * (`components/src/renderers/complex/data-table.tsx:644`) reads as + * `single`, off for a falsy value, and multiple for anything else — so the + * shape is the read's own declaration, `boolean | 'single' | 'multiple'`. + * Typed, not retired: retiring the second spelling is its own decision. + */ + selectable: z.union([z.boolean(), z.enum(['single', 'multiple'])]).optional() + .describe('Legacy selection shorthand, read only when `selection` is absent — `true` (multiple), `false` (off), `\'single\'` or `\'multiple\'`. Prefer `selection`'), + /** + * [#21464] The list view's own `rowActions` member, by reference: action + * NAMES (`ObjectGrid.tsx:1834-1835` at the pin `89cad75d55` reads a + * `string[]`; `edit` and `delete` select the row menu's generic entries, + * any other name resolves against the object's actions). + */ + rowActions: ListViewSchema.shape.rowActions + .describe('Per-row action names — `edit` / `delete` select the generic entries, any other name resolves against the object\'s actions; the same list a list view\'s `rowActions` declares'), + /** + * [#21464] The list view's own `bulkActions` member, by reference: action + * NAMES, which `resolveBulkActions` folds against the object's actions + * (`ObjectGrid.tsx:4763` at the pin `89cad75d55`). An object entry such as + * `{ name }` is the def vocabulary, which belongs on `bulkActionDefs`; the + * fold skipped it in silence. + */ + bulkActions: ListViewSchema.shape.bulkActions + .describe('Bulk action names shown on selection — the same list a list view\'s `bulkActions` declares; a full def goes on `bulkActionDefs`'), + /** + * [#21464] The alternate spelling of `bulkActions`, read FIRST + * (`batchActions ?? bulkActions`, `ObjectGrid.tsx:4763` at the pin + * `89cad75d55`), so it takes `bulkActions`'s def: one capability, one + * accept set. The list view declares only `bulkActions`; this second + * spelling is typed here, not retired — retiring it is its own decision. + */ + batchActions: ListViewSchema.shape.bulkActions + .describe('Alternate spelling of `bulkActions` that the renderer reads FIRST (`batchActions ?? bulkActions`) — the same action-name list. Prefer `bulkActions`'), /** * [#21445] The list view's own bulk-action def, {@link BulkActionDefSchema}, * by identity — the element `ListViewSchema.bulkActionDefs` declares. The @@ -4286,11 +4367,14 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ /** Author state (ADR-0122: the bare name is the author state). */ export type ObjectGridProps = z.input; /** - * ADR-0122: the parsed state differs from the authored state on exactly one - * key — `data` carries `ViewDataSchema` (the ui#6207 convergence), whose own - * input ≠ infer. So `object-grid` leaves the type-alias convention pin's - * default-free family (the Iso839 line deleted with this alias), taking the - * `RecordAlertPropsParsed` route its comment prescribes. + * ADR-0122: the parsed state differs from the authored state. It first did on + * one key — `data` carries `ViewDataSchema` (the ui#6207 convergence), whose + * own input ≠ infer — and that is what took `object-grid` out of the + * type-alias convention pin's default-free family (the Iso839 line deleted + * with this alias), on the `RecordAlertPropsParsed` route its comment + * prescribes. The list view's own members taken by reference since carry + * their defaults too (#21445, #21464: `navigation`'s four, `selection.type`, + * a column's `prefix.type`). */ export type ObjectGridPropsParsed = z.infer; @@ -4444,6 +4528,51 @@ export type ObjectMetricProps = z.input; */ export type ObjectMetricPropsParsed = z.infer; +/** + * [#21464] One `object-kanban` swimlane — the members the board reads off a + * lane at the `.objectui-sha` pin `89cad75d55`, and nothing else. No list-view + * schema declares a lane, so the shape is the read's own: + * + * - `id` (string) — the bucketing key: a record whose `groupBy` value equals + * it lands in this lane (`plugin-kanban/src/index.tsx:129-149`, + * `bucketCardsIntoColumns`); `title` (string) — the heading, localized + * against the `groupBy` picklist (`ObjectKanban.tsx:1168-1188`); + * - `cards` — a STATIC board's own cards, kept ahead of the bucketed records + * (`index.tsx:152-158`); `limit` — the WIP count at which the lane warns + * (`KanbanImpl.tsx:540`, `:591`); `className` — the lane's styling channel + * (`:568`); `collapsed` — the lane's initial collapsed state (`:742`). + * + * A card is a record row: `id` and `title` are typed (the two members the + * board identifies and heads a card by), and the rest of the card is that + * row's own values, passed on as the board's `data` rows are. Module-private, + * as {@link GridAggregationSchema} is. + */ +const ObjectKanbanLaneSchema = lazySchema(() => strictObject({ + surface: 'this `object-kanban` lane', + history: + 'Until this shape was declared, `columns` was `z.array(z.unknown())`: a lane with no `title`, ' + + 'a card with no `title` or a mis-spelled lane key passed, and the board drew a lane with no ' + + 'heading, a card with no title, or ignored the key.', + guidance: { + color: + '`color` is not a lane member: no board reads it. Style a lane through its `className` ' + + '(for example `className: \'border-t-2 border-blue-500\'`).', + }, +}, { + id: z.string().describe('Lane id — the `groupBy` value whose records land in this lane (matched as a string)'), + title: z.string().describe('Lane heading, localized against the `groupBy` picklist\'s option labels'), + cards: z.array(z.looseObject({ + id: z.string().describe('Card id'), + title: z.string().describe('Card title'), + })).optional() + .describe('A static board\'s own cards, drawn ahead of the records bucketed into this lane — each `{ id, title, … }`, the rest of the card being the record row\'s own values'), + limit: z.number().int().positive().optional() + .describe('WIP limit — the card count at which the lane warns; never reaches the query'), + className: z.string().optional().describe('CSS class names applied to the lane'), + collapsed: z.boolean().optional() + .describe('Whether the lane first renders collapsed — a title spine with its cards withheld, which the viewer can reopen'), +})); + /** * `object-kanban` (objectui `plugin-kanban/src/ObjectKanban.tsx` + * `KanbanRenderer` in `plugin-kanban/src/index.tsx` @ `eb7f586b` — the board @@ -4511,8 +4640,20 @@ export const ObjectKanbanPropsSchema = lazySchema(() => strictObject({ objectName: z.string().optional() .describe('Object this board binds to. Optional because the component-level `dataSource` binding can supply the object instead'), groupBy: z.string().optional().describe('Field whose values become the board columns'), - columns: z.array(z.unknown()).optional() - .describe('Swimlane definitions ({ id, title } per `groupBy` value, or bare value strings) — NOT a field projection'), + /** + * [#21464] Swimlanes: all bare value strings, or all lane objects + * ({@link ObjectKanbanLaneSchema}) — two array shapes, not one array of + * either, because the board dispatches on the FIRST entry + * (`ObjectKanban.tsx:1177-1188` at the pin `89cad75d55`): an object-first + * mix sends each string down the object branch, a string-first mix is + * ignored whole. The string arm draws only on a board with no `groupBy` + * (`:1179-1186`), each value titling its own lane. + */ + columns: z.union([ + z.array(z.string()), + z.array(ObjectKanbanLaneSchema), + ]).optional() + .describe('Swimlane definitions — all lane objects `{ id, title, cards?, limit?, className?, collapsed? }` (one per `groupBy` value), or all bare value strings (drawn only on a board with no `groupBy`). NOT a field projection; one spelling per list'), /** * Base query filter — the `ViewFilterRule` ARRAY form, the one filter * orthography every `filter` door in this map shares (#15449; the family @@ -4961,8 +5102,17 @@ export const ObjectCalendarPropsSchema = lazySchema(() => strictObject({ }, { objectName: z.string().optional() .describe('Object this calendar binds to. Optional because the component-level `dataSource` binding can supply the object instead'), - calendar: z.unknown().optional() - .describe('Calendar field config: { startDateField, endDateField?, titleField?, colorField?, allDayField? }'), + /** + * [#21464] The list view's own `calendar` member, by reference + * (`CalendarConfigSchema`): `getCalendarConfig` + * (`plugin-calendar/src/ObjectCalendar.tsx:294-297` at the pin + * `89cad75d55`) returns this block as the config and reads exactly its five + * bindings (`:857`, `:1056`, `:1141`), so a block without `startDateField` + * placed no event, and a key outside the five — the retired `dateField` / + * `endField` aliases among them — was never read. + */ + calendar: ListViewSchema.shape.calendar + .describe('Calendar field config `{ startDateField, endDateField?, titleField?, colorField?, allDayField? }` — the same block a list view\'s `calendar` declares; `startDateField` is required'), defaultView: z.enum(['month', 'week', 'day']).optional().describe('Initial view mode'), /** * Base query filter — the `ViewFilterRule` ARRAY form, the one filter From 96b9ed3cfff8f4b40ecce5eef3ff17c6050b0b3d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 05:06:19 +0000 Subject: [PATCH 2/6] wip(spec): changeset for the list-family typing (#21464 stage 2) Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude --- ...21464-component-props-list-family-typed.md | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .changeset/21464-component-props-list-family-typed.md diff --git a/.changeset/21464-component-props-list-family-typed.md b/.changeset/21464-component-props-list-family-typed.md new file mode 100644 index 00000000000..f66371e407c --- /dev/null +++ b/.changeset/21464-component-props-list-family-typed.md @@ -0,0 +1,46 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: the list members of an `object-grid`, `object-kanban` or `object-calendar` page block take the shape the block reads instead of any value — the grid's `columns`, `fields`, `selection`, `selectable`, `rowActions`, `bulkActions` and `batchActions`, the kanban's `columns` and the calendar's `calendar` (#21464) + +Clause-②: yes (narrowing) + + + +**BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. What reads the rows: the component-props gate on `objectstack validate`, `objectstack build` and `objectstack lint`, which reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding. A stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. + +**`@objectstack/spec`** + +- **Nine members are typed.** `ComponentPropsMap['object-grid']`, `['object-kanban']` and `['object-calendar']` declared these members as `z.unknown()` (an array of it for the lists), although each renderer reads them with one shape. Any value passed, and an off-shape one was dropped or substituted with no report: a grid column keyed `accessorKey` or `name`, or a column list mixing strings and objects, drew no column; an object entry in `fields` named no field; a `{ name }` entry in `bulkActions` was skipped; a kanban lane list mixing objects and strings drew a blank lane; a calendar block with no `startDateField` placed no event. +- **The list view's own members, by reference**, where a list view declares one: the grid's `columns` (all field-name strings, or all column entries `{ field, label?, width?, … }`), `selection` (`{ type }`, with `none`, `single` or `multiple`), `rowActions` and `bulkActions` (action-name strings), and the calendar's `calendar` (`{ startDateField, endDateField?, titleField?, colorField?, allDayField? }`). `batchActions`, the second spelling of `bulkActions` that the grid reads first, takes `bulkActions`'s def. Neither spelling is retired here. +- **The measured shape**, where no list view declares the member: the grid's `fields` (field-name strings), the grid's `selectable` (`true`, `false`, `'single'` or `'multiple'`), and the kanban's `columns` (all lanes `{ id, title, cards?, limit?, className?, collapsed? }`, or all bare value strings). A lane `id` and `title` are strings, a static card carries a string `id` and `title` beside its row's own values, and `limit` is a positive integer. +- **`ObjectGridProps`, `ObjectKanbanProps` and `ObjectCalendarProps`** (and their `…Parsed` twins) carry these types on the nine members instead of `unknown`. +- **The enumeration pin** loses the nine lines, and the list-family stage is done. One `z.unknown()` member is added and recorded: the rest of a static kanban card (its row's own values, beside the typed `id` and `title`). + +## FROM → TO + +| you wrote | write instead | +|:--|:--| +| `object-grid` `columns: [{ accessorKey: 'amount', header: 'Amount' }]` | `columns: [{ field: 'amount', label: 'Amount' }]` | +| `object-grid` `columns: [{ name: 'amount' }]` | `columns: [{ field: 'amount' }]` | +| `object-grid` `columns: ['name', { field: 'amount', width: 120 }]` | one spelling per list: `columns: [{ field: 'name' }, { field: 'amount', width: 120 }]` | +| `object-grid` a column key the grid never reads, such as `editable` or `options` | delete the key (inline editing is the grid's `editable`) | +| `object-grid` `fields: [{ field: 'name', width: 240 }]` | `fields: ['name']`, or the entry on `columns` | +| `object-grid` `selection: 'multiple'` | `selection: { type: 'multiple' }` | +| `object-grid` `selectable: 'none'` | `selectable: false`, or `selection: { type: 'none' }` | +| `object-grid` `bulkActions: [{ name: 'approve' }]` (also `batchActions`, `rowActions`) | `bulkActions: ['approve']`, or the full def on `bulkActionDefs` | +| `object-kanban` `columns: [{ id: 'done', title: 'Done' }, 'todo']` | one spelling per list: `columns: [{ id: 'done', title: 'Done' }, { id: 'todo', title: 'To Do' }]` | +| `object-kanban` a lane `color: 'red'` | `className: 'border-t-2 border-red-500'` | +| `object-kanban` a lane `{ id: 1, title: 'One' }` | `{ id: '1', title: 'One' }` | +| `object-calendar` `calendar: { dateField: 'kickoff', endField: 'wrapup' }` | `calendar: { startDateField: 'kickoff', endDateField: 'wrapup' }` | + +The one-line fix: write each member as the list view declares it, or as the table above shows. No conversion is registered, because an off-shape value has no rewrite that both keeps what the block shows today and honours what the author wrote; the D3 entry `ui-object-grid-kanban-calendar-list-members-typed` carries that judgment. + +## Who is affected, measured + +A writer is a page-component node (an object literal naming the type, a literal annotated with the block's type, a `schema={{…}}` on the block's React component, or a call into a local helper that builds the node), with each member's value read through same-file constants; the control is `objectName` on the same nodes. + +- **objectstack** at `49161683fb`, over `examples/`, `packages/` (with `packages/apps/`), `content/`, `skills/` and `apps/`: 44 `object-grid`, 29 `object-kanban` and 2 `object-calendar` nodes (the control on 37 / 26 / 1 of them). The authored values are 4 grid `columns` (field-name strings, the showcase's two grids among them) and 1 kanban `columns` (lanes, in the protocol docs); every one parses. No node authors another of the nine members. +- **objectui** at the `.objectui-sha` pin `89cad75d55`: 672 `object-grid`, 240 `object-kanban` and 155 `object-calendar` nodes (the control on 277 / 108 / 93). 409 member values are static; 360 parse. Each of the 49 that do not is a fixture or a probe of a value the renderer drops, skips or refuses: grid columns keyed `accessorKey` / `header` or `name` (the console's own column diagnostic names both), column keys the grid never reads (`editable`, `options`), a numeric `columns`, object entries in `bulkActions` (the renderer skips them, and the test asserts the skip), object entries in `fields` that copy the hand-off the list view makes to the grid at run time (not an authored page), a kanban lane `color` (retired in the console, and the test marks it an undeclared member), and the calendar's retired `dateField` / `endField` aliases (the test asserts their refusal). No refused value is one the renderer honours. 44 values are not static (helper parameters, `.map` results and the run-time hand-offs); none of them is an authored page. +- **Deployed metadata** was not measured. From 8da174f3e7f96a53b25af01f4075a91827d6cf46 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 05:13:22 +0000 Subject: [PATCH 3/6] chore(spec): regenerate the component reference page and the strictness-ledger counts (#21464 stage 2) Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude --- content/docs/references/ui/component.mdx | 64 ++++++++++++++++--- .../ui.md | 10 +-- 2 files changed, 60 insertions(+), 14 deletions(-) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 7d034275fca..26adb99539e 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -458,7 +458,7 @@ Sort field and direction pair | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **objectName** | `string` | optional | Object this calendar binds to. Optional because the component-level `dataSource` binding can supply the object instead | -| **calendar** | `any` | optional | Calendar field config: `{ startDateField, endDateField?, titleField?, colorField?, allDayField? }` | +| **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar field config `{ startDateField, endDateField?, titleField?, colorField?, allDayField? }` — the same block a list view's `calendar` declares; `startDateField` is required | | **defaultView** | `Enum<'month' \| 'week' \| 'day'>` | optional | Initial view mode | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Row order for the fetched events — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` | @@ -468,6 +468,16 @@ Sort field and direction pair | **loading** | `boolean` | optional | External loading state (honoured only alongside `data`) | | **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Event-click navigation config — the same block `ListViewSchema.navigation` declares (`{ mode, size, openNewTab, preventNavigation }`). The renderer's own default is `{ mode: 'drawer' }` when the key is absent; it is documented rather than declared, so a parsed calendar carries the key only when the author wrote it | +### Nested Shape: `ObjectCalendarProps.calendar` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **startDateField** | `string` | ✅ | Field providing the event start date/time | +| **endDateField** | `string` | optional | Field providing the event end date/time (defaults to a single-day event) | +| **titleField** | `string` | optional | Field displayed as the event title. Omit to fall back to the record display name (ADR-0079 resolver chain) | +| **colorField** | `string` | optional | Field to derive each event color from (it names a field, not a color): the option color declared on that field for the record value, else the value itself when it already is a color literal (hex, rgb() or hsl()), else the calendar theme-aware palette color hashed from the value | +| **allDayField** | `string` | optional | Field carrying the all-day flag for each event (names a boolean field, not a value): a record whose flag is true is drawn as an all-day band rather than at a clock time, and one whose flag is absent or false is not all-day. Omit to leave the renderer inference in place — an event with no end date draws as all-day | + ### Nested Shape: `ObjectCalendarProps.filter[number]` View filter rule @@ -682,8 +692,8 @@ Sort field and direction pair | **title** | `string \| Record` | optional | Fallback for `label` (the renderer reads `label \|\| title`) | | **description** | `string \| Record` | optional | One line of help text drawn above the grid's rows — a string, or an inline locale map resolved against the display locale | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string }` | optional | What the grid draws instead of an empty table: `{ title, message, icon }` — the list view's own empty-state shape | -| **columns** | `any[]` | optional | Columns: field names or column definition objects | -| **fields** | `any[]` | optional | Field list fallback used when `columns` is absent | +| **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | optional | Columns — all field-name strings, or all column entries `{ field, label?, width?, align?, hidden?, sortable?, … }`, the same union a list view's `columns` declares. One spelling per list: an array mixing strings and column objects is refused | +| **fields** | `string[]` | optional | Field-name fallback the grid reads when `columns` is absent — bare field names (`['name', 'amount']`); write column decoration such as `label` or `width` on `columns` | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. THE key, singular — not the plural misspelling. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **defaultFilters** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Legacy base-filter fallback, read only when `filter` is absent — the SAME ViewFilterRule array form `[{ field, operator, value }, ...]` as `filter`, lowered through the same sink. Prefer `filter`. The MongoDB-style record form, a bare string and an ObjectQL AST tuple array are refused — see migration `object-grid-default-filters-rule-array` | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Initial row order — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` | @@ -698,11 +708,11 @@ Sort field and direction pair | **aggregations** | `{ field: string; type: Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'> }[]` | optional | Per-group aggregations drawn in a grouped grid's group headers — `[{ field, type }]`, `type` one of `count`, `sum`, `avg`, `min`, `max`, `count_distinct` (`count` is the group's row count, whatever `field` names) | | **conditionalFormatting** | `{ condition: string \| object; style: Record }[]` | optional | Conditional formatting rules — `[{ condition, style }]`, the same rules a list view declares: the first rule whose CEL `condition` holds applies its CSS `style` map to the row | | **rowColor** | `{ field: string; colors?: Record }` | optional | Row colour by field value — `{ field, colors }`, the same block a list view's `rowColor` declares | -| **selection** | `any` | optional | Selection config (`{ type: none \| single \| multiple }`) | -| **selectable** | `any` | optional | Legacy selection shorthand, read only when `selection` is absent. Prefer `selection` | -| **rowActions** | `any[]` | optional | Per-row action names | -| **bulkActions** | `any[]` | optional | Bulk action names shown on selection | -| **batchActions** | `any[]` | optional | Alternate spelling the renderer reads FIRST (`batchActions ?? bulkActions`) | +| **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Selection config `{ type }` — `none`, `single` or `multiple`, the same block a list view's `selection` declares | +| **selectable** | `boolean \| Enum<'single' \| 'multiple'>` | optional | Legacy selection shorthand, read only when `selection` is absent — `true` (multiple), `false` (off), `'single'` or `'multiple'`. Prefer `selection` | +| **rowActions** | `string[]` | optional | Per-row action names — `edit` / `delete` select the generic entries, any other name resolves against the object's actions; the same list a list view's `rowActions` declares | +| **bulkActions** | `string[]` | optional | Bulk action names shown on selection — the same list a list view's `bulkActions` declares; a full def goes on `bulkActionDefs` | +| **batchActions** | `string[]` | optional | Alternate spelling of `bulkActions` that the renderer reads FIRST (`batchActions ?? bulkActions`) — the same action-name list. Prefer `bulkActions` | | **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Inline bulk-action definitions (full defs, not names) — the same `BulkActionDef` entries a list view's `bulkActionDefs` declares | | **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Row-click navigation config — the same block `ListViewSchema.navigation` declares (`{ mode, size, openNewTab, preventNavigation }`) | | **editable** | `boolean` | optional | Enable inline cell editing | @@ -726,6 +736,25 @@ Sort field and direction pair | **message** | `string \| Record` | optional | Line of text below the heading | | **icon** | `string` | optional | Icon name drawn above the heading; a name that resolves to no icon draws the default empty-state glyph | +### Nested Shape: `ObjectGridProps.columns[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **field** | `string` | ✅ | Field name (snake_case) | +| **label** | `string \| Record` | optional | Display label override | +| **width** | `number` | optional | Column width in pixels | +| **align** | `Enum<'left' \| 'center' \| 'right'>` | optional | Text alignment | +| **hidden** | `boolean` | optional | Hide column by default | +| **sortable** | `boolean` | optional | Allow sorting by this column | +| **resizable** | `boolean` | optional | Allow resizing this column | +| **wrap** | `boolean` | optional | Allow text wrapping | +| **type** | `string` | optional | Renderer type override (e.g., "currency", "date") | +| **pinned** | `Enum<'left' \| 'right'>` | optional | Pin/freeze column to left or right side | +| **summary** | `Enum<'none' \| 'count' \| 'count_empty' \| 'count_filled' \| 'count_unique' \| …> \| { type: Enum<'none' \| 'count' \| 'count_empty' \| 'count_filled' \| 'count_unique' \| …>; field?: string }` | optional | Footer aggregation for this column — the function alone, or `{ type, field }` to aggregate another field | +| **prefix** | `{ field: string; type?: Enum<'badge' \| 'text'> }` | optional | Field rendered inline before this cell value | +| **link** | `boolean` | optional | Functions as the primary navigation link (triggers View navigation) | +| **action** | `string` | optional | Registered Action ID to execute when clicked | + ### Nested Shape: `ObjectGridProps.filter[number]` View filter rule @@ -782,6 +811,12 @@ Sort field and direction pair | **field** | `string` | ✅ | Field whose value is looked up in the `colors` map below to pick a row colour (typically a select/status field). The map is what does the colouring — with no `colors`, no row is ever coloured, whatever this field holds. Author-time diagnostic `view/row-color-without-colors` reports that combination. | | **colors** | `Record` | optional | Map of field value to row colour. The spellings that actually paint a row are not free-form: objectui `plugin-grid`'s `useRowColor` hands a value already written as a complete Tailwind background class (`bg-red-200`) straight through, otherwise lower-cases and trims it and resolves it through its own closed vocabulary of colour NAMES (`red`, `blue`, `slate`, … each mapping to `bg-NAME-100`), and returns undefined for anything else. A hex, an `rgb()` or a CSS variable parses here, publishes, and colours no row — Tailwind v4 has no runtime, so no class can be fabricated from one. Author-time diagnostic `view/row-color-unresolvable-value` reports a value that cannot resolve. | +### Nested Shape: `ObjectGridProps.selection` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `Enum<'none' \| 'single' \| 'multiple'>` | optional (default: `"none"`) | Selection mode | + ### Nested Shape: `ObjectGridProps.bulkActionDefs[number]` | Property | Type | Required | Description | @@ -872,7 +907,7 @@ Sort field and direction pair | :--- | :--- | :--- | :--- | | **objectName** | `string` | optional | Object this board binds to. Optional because the component-level `dataSource` binding can supply the object instead | | **groupBy** | `string` | optional | Field whose values become the board columns | -| **columns** | `any[]` | optional | Swimlane definitions (`{ id, title }` per `groupBy` value, or bare value strings) — NOT a field projection | +| **columns** | `string[] \| { id: string; title: string; cards?: object[]; limit?: integer; … }[]` | optional | Swimlane definitions — all lane objects `{ id, title, cards?, limit?, className?, collapsed? }` (one per `groupBy` value), or all bare value strings (drawn only on a board with no `groupBy`). NOT a field projection; one spelling per list | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter, handed to the wire `$filter` — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **limit** | `integer` | optional | Maximum number of records loaded onto the board (row cap); lowered to the query's top-level `$top` (renderer default 100). The component-level `dataSource.limit` wins when both are set; a bound view's `pagination.pageSize` fills this key only when it is unset — and on this face unset is the whole rule, because every cap this key accepts is one the binding gate already treats as authored | | **data** | `any[]` | optional | Static inline cards — bypasses the object query | @@ -886,6 +921,17 @@ Sort field and direction pair | **conditionalFormatting** | `any` | optional | Card conditional formatting rules | | **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Card-click navigation config — the same block `ListViewSchema.navigation` declares (`{ mode, size, openNewTab, preventNavigation }`). The renderer's own default is `{ mode: 'drawer' }` when the key is absent; it is documented rather than declared, so a parsed board carries the key only when the author wrote it | +### Nested Shape: `ObjectKanbanProps.columns[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **id** | `string` | ✅ | Lane id — the `groupBy` value whose records land in this lane (matched as a string) | +| **title** | `string` | ✅ | Lane heading, localized against the `groupBy` picklist's option labels | +| **cards** | `({ id: string; title: string } & Record)[]` | optional | A static board's own cards, drawn ahead of the records bucketed into this lane — each `{ id, title, … }`, the rest of the card being the record row's own values | +| **limit** | `integer` | optional | WIP limit — the card count at which the lane warns; never reaches the query | +| **className** | `string` | optional | CSS class names applied to the lane | +| **collapsed** | `boolean` | optional | Whether the lane first renders collapsed — a title spine with its cards withheld, which the viewer can reopen | + ### Nested Shape: `ObjectKanbanProps.filter[number]` View filter rule diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md index 71224ae481c..b7dba0b3b2f 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `ui/` | 189 | 179 | 3 | 0 | 7 | +| `ui/` | 191 | 180 | 4 | 0 | 7 | ## `ui/` — sites @@ -36,7 +36,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `app.zod.ts` | 19 | | `bulk-action.zod.ts` | 4 | | `chart.zod.ts` | 8 | -| `component.zod.ts` | 59 | +| `component.zod.ts` | 61 | | `dashboard.zod.ts` | 11 | | `dataset.zod.ts` | 4 | | `i18n.zod.ts` | 1 | @@ -46,7 +46,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `sharing.zod.ts` | 1 | | `view.zod.ts` | 60 | | `widget.zod.ts` | 1 | -| **total** | **189** | +| **total** | **191** | ## `ui/` — open @@ -54,7 +54,7 @@ Per file, how many of its sites still silently discard unknown keys. The `Class` column that decides the bucket split is hand-written in the ledger; the arithmetic over it is here. -**7 strip of 189**, in 4 file(s). +**7 strip of 191**, in 4 file(s). | File | Strip | Sites | |---|---|---| @@ -62,7 +62,7 @@ over it is here. | `app.zod.ts` | 1 | 19 | | `view.zod.ts` | 4 | 60 | | `widget.zod.ts` | 1 | 1 | -| **total** | **7** | **189** | +| **total** | **7** | **191** | | Bucket | Sites | |---|---| From 870e7327eccca99a4cdc88c3dfe9d63726b919fc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 05:22:44 +0000 Subject: [PATCH 4/6] wip(spec): changeset census numbers measured on the built rows (#21464 stage 2) Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude --- .changeset/21464-component-props-list-family-typed.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/21464-component-props-list-family-typed.md b/.changeset/21464-component-props-list-family-typed.md index f66371e407c..721aa33d565 100644 --- a/.changeset/21464-component-props-list-family-typed.md +++ b/.changeset/21464-component-props-list-family-typed.md @@ -39,8 +39,8 @@ The one-line fix: write each member as the list view declares it, or as the tabl ## Who is affected, measured -A writer is a page-component node (an object literal naming the type, a literal annotated with the block's type, a `schema={{…}}` on the block's React component, or a call into a local helper that builds the node), with each member's value read through same-file constants; the control is `objectName` on the same nodes. +A writer is a page-component node: an object literal naming the type, a literal annotated with the block's type, a `schema={{…}}` on the block's React component, a call into a local helper that builds the node, or a direct parse through the row. Each member's value is read through same-file constants, and the control is `objectName` on the same nodes. -- **objectstack** at `49161683fb`, over `examples/`, `packages/` (with `packages/apps/`), `content/`, `skills/` and `apps/`: 44 `object-grid`, 29 `object-kanban` and 2 `object-calendar` nodes (the control on 37 / 26 / 1 of them). The authored values are 4 grid `columns` (field-name strings, the showcase's two grids among them) and 1 kanban `columns` (lanes, in the protocol docs); every one parses. No node authors another of the nine members. -- **objectui** at the `.objectui-sha` pin `89cad75d55`: 672 `object-grid`, 240 `object-kanban` and 155 `object-calendar` nodes (the control on 277 / 108 / 93). 409 member values are static; 360 parse. Each of the 49 that do not is a fixture or a probe of a value the renderer drops, skips or refuses: grid columns keyed `accessorKey` / `header` or `name` (the console's own column diagnostic names both), column keys the grid never reads (`editable`, `options`), a numeric `columns`, object entries in `bulkActions` (the renderer skips them, and the test asserts the skip), object entries in `fields` that copy the hand-off the list view makes to the grid at run time (not an authored page), a kanban lane `color` (retired in the console, and the test marks it an undeclared member), and the calendar's retired `dateField` / `endField` aliases (the test asserts their refusal). No refused value is one the renderer honours. 44 values are not static (helper parameters, `.map` results and the run-time hand-offs); none of them is an authored page. +- **objectstack** at `49161683fb`, over `examples/`, `packages/` (with `packages/apps/`), `content/`, `skills/` and `apps/`: 57 `object-grid`, 30 `object-kanban` and 5 `object-calendar` nodes (the control on 47 / 27 / 4 of them). The authored values are 5 grid `columns` (field-name strings, the showcase's two grids among them) and 1 kanban `columns` (lanes, in the protocol docs); every one parses. No node authors another of the nine members. +- **objectui** at the `.objectui-sha` pin `89cad75d55`: 689 `object-grid`, 240 `object-kanban` and 160 `object-calendar` nodes (the control on 293 / 108 / 98). 551 member values are static, and 503 of them parse. Each of the 48 that do not is a test fixture whose value the renderer drops, skips or refuses: 17 grid columns keyed `accessorKey` / `header` and 5 keyed `name` (the grid draws neither, and its own column diagnostic names both), 16 column keys the grid never reads (`editable` 14, `options` 2), a numeric `columns` and a column with no `field`, 2 object entries in `bulkActions` (the renderer skips them, and the tests assert the skip), 3 object entries in `fields` that copy the hand-off the list view makes to the grid at run time (not an authored page), a lane `color` (retired in the console; the test marks it an undeclared member), and 2 uses of the calendar's retired `dateField` / `endField` aliases (the test asserts their refusal). No refused value is one the renderer draws. 44 values are not static (helper parameters, `.map` results and the run-time hand-offs); none of them is an authored page. - **Deployed metadata** was not measured. From d3a5b3ab34afb2f3c74432e8232ef76dc4025529 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 07:32:54 +0000 Subject: [PATCH 5/6] fix(spec): hold object-grid columns at z.unknown(): the grid draws an authored column's options, which ListColumn does not declare (#21464 stage 2) Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude --- ...21464-component-props-list-family-typed.md | 19 +++---- ...grid-kanban-calendar-list-members-typed.ts | 29 +++++----- packages/spec/src/migrations/registry.ts | 47 ++++++++-------- ...nent-list-family-typed-members.pin.test.ts | 56 +++++-------------- ...omponent-props-unknown-members.pin.test.ts | 17 ++++-- packages/spec/src/ui/component.zod.ts | 45 ++++++++------- 6 files changed, 101 insertions(+), 112 deletions(-) diff --git a/.changeset/21464-component-props-list-family-typed.md b/.changeset/21464-component-props-list-family-typed.md index 721aa33d565..8edeb47bdc3 100644 --- a/.changeset/21464-component-props-list-family-typed.md +++ b/.changeset/21464-component-props-list-family-typed.md @@ -2,7 +2,7 @@ '@objectstack/spec': minor --- -feat(spec)!: the list members of an `object-grid`, `object-kanban` or `object-calendar` page block take the shape the block reads instead of any value — the grid's `columns`, `fields`, `selection`, `selectable`, `rowActions`, `bulkActions` and `batchActions`, the kanban's `columns` and the calendar's `calendar` (#21464) +feat(spec)!: eight list members of an `object-grid`, `object-kanban` or `object-calendar` page block take the shape the block reads instead of any value — the grid's `fields`, `selection`, `selectable`, `rowActions`, `bulkActions` and `batchActions`, the kanban's `columns` and the calendar's `calendar` (#21464) Clause-②: yes (narrowing) @@ -12,20 +12,17 @@ Clause-②: yes (narrowing) **`@objectstack/spec`** -- **Nine members are typed.** `ComponentPropsMap['object-grid']`, `['object-kanban']` and `['object-calendar']` declared these members as `z.unknown()` (an array of it for the lists), although each renderer reads them with one shape. Any value passed, and an off-shape one was dropped or substituted with no report: a grid column keyed `accessorKey` or `name`, or a column list mixing strings and objects, drew no column; an object entry in `fields` named no field; a `{ name }` entry in `bulkActions` was skipped; a kanban lane list mixing objects and strings drew a blank lane; a calendar block with no `startDateField` placed no event. -- **The list view's own members, by reference**, where a list view declares one: the grid's `columns` (all field-name strings, or all column entries `{ field, label?, width?, … }`), `selection` (`{ type }`, with `none`, `single` or `multiple`), `rowActions` and `bulkActions` (action-name strings), and the calendar's `calendar` (`{ startDateField, endDateField?, titleField?, colorField?, allDayField? }`). `batchActions`, the second spelling of `bulkActions` that the grid reads first, takes `bulkActions`'s def. Neither spelling is retired here. +- **Eight members are typed.** `ComponentPropsMap['object-grid']`, `['object-kanban']` and `['object-calendar']` declared these members as `z.unknown()` (an array of it for the lists), although each renderer reads them with one shape. Any value passed, and an off-shape one was dropped or substituted with no report: an object entry in `fields` named no field; a `{ name }` entry in `bulkActions` was skipped; a kanban lane list mixing objects and strings drew a blank lane; a calendar block with no `startDateField` placed no event. +- **The list view's own members, by reference**, where a list view declares one: the grid's `selection` (`{ type }`, with `none`, `single` or `multiple`), `rowActions` and `bulkActions` (action-name strings), and the calendar's `calendar` (`{ startDateField, endDateField?, titleField?, colorField?, allDayField? }`). `batchActions`, the second spelling of `bulkActions` that the grid reads first, takes `bulkActions`'s def. Neither spelling is retired here. - **The measured shape**, where no list view declares the member: the grid's `fields` (field-name strings), the grid's `selectable` (`true`, `false`, `'single'` or `'multiple'`), and the kanban's `columns` (all lanes `{ id, title, cards?, limit?, className?, collapsed? }`, or all bare value strings). A lane `id` and `title` are strings, a static card carries a string `id` and `title` beside its row's own values, and `limit` is a positive integer. -- **`ObjectGridProps`, `ObjectKanbanProps` and `ObjectCalendarProps`** (and their `…Parsed` twins) carry these types on the nine members instead of `unknown`. -- **The enumeration pin** loses the nine lines, and the list-family stage is done. One `z.unknown()` member is added and recorded: the rest of a static kanban card (its row's own values, beside the typed `id` and `title`). +- **`ObjectGridProps`, `ObjectKanbanProps` and `ObjectCalendarProps`** (and their `…Parsed` twins) carry these types on the eight members instead of `unknown`. +- **The grid's `columns` is not narrowed** and still accepts any value. The list view's column entry is its by-reference shape, and the grid's draw path reads exactly that, but the grid's group headers also draw the labels from an authored column's `options` (the column whose `field` is the grouping field, ahead of the field's own options). The list view's column entry declares no `options`, so typing `columns` now would refuse a value the grid draws. It is held until that read is ruled. +- **The enumeration pin** loses eight lines and keeps the grid's `columns` as held for that ruling. One `z.unknown()` member is added and recorded: the rest of a static kanban card (its row's own values, beside the typed `id` and `title`). ## FROM → TO | you wrote | write instead | |:--|:--| -| `object-grid` `columns: [{ accessorKey: 'amount', header: 'Amount' }]` | `columns: [{ field: 'amount', label: 'Amount' }]` | -| `object-grid` `columns: [{ name: 'amount' }]` | `columns: [{ field: 'amount' }]` | -| `object-grid` `columns: ['name', { field: 'amount', width: 120 }]` | one spelling per list: `columns: [{ field: 'name' }, { field: 'amount', width: 120 }]` | -| `object-grid` a column key the grid never reads, such as `editable` or `options` | delete the key (inline editing is the grid's `editable`) | | `object-grid` `fields: [{ field: 'name', width: 240 }]` | `fields: ['name']`, or the entry on `columns` | | `object-grid` `selection: 'multiple'` | `selection: { type: 'multiple' }` | | `object-grid` `selectable: 'none'` | `selectable: false`, or `selection: { type: 'none' }` | @@ -41,6 +38,6 @@ The one-line fix: write each member as the list view declares it, or as the tabl A writer is a page-component node: an object literal naming the type, a literal annotated with the block's type, a `schema={{…}}` on the block's React component, a call into a local helper that builds the node, or a direct parse through the row. Each member's value is read through same-file constants, and the control is `objectName` on the same nodes. -- **objectstack** at `49161683fb`, over `examples/`, `packages/` (with `packages/apps/`), `content/`, `skills/` and `apps/`: 57 `object-grid`, 30 `object-kanban` and 5 `object-calendar` nodes (the control on 47 / 27 / 4 of them). The authored values are 5 grid `columns` (field-name strings, the showcase's two grids among them) and 1 kanban `columns` (lanes, in the protocol docs); every one parses. No node authors another of the nine members. -- **objectui** at the `.objectui-sha` pin `89cad75d55`: 689 `object-grid`, 240 `object-kanban` and 160 `object-calendar` nodes (the control on 293 / 108 / 98). 551 member values are static, and 503 of them parse. Each of the 48 that do not is a test fixture whose value the renderer drops, skips or refuses: 17 grid columns keyed `accessorKey` / `header` and 5 keyed `name` (the grid draws neither, and its own column diagnostic names both), 16 column keys the grid never reads (`editable` 14, `options` 2), a numeric `columns` and a column with no `field`, 2 object entries in `bulkActions` (the renderer skips them, and the tests assert the skip), 3 object entries in `fields` that copy the hand-off the list view makes to the grid at run time (not an authored page), a lane `color` (retired in the console; the test marks it an undeclared member), and 2 uses of the calendar's retired `dateField` / `endField` aliases (the test asserts their refusal). No refused value is one the renderer draws. 44 values are not static (helper parameters, `.map` results and the run-time hand-offs); none of them is an authored page. +- **objectstack** at `49161683fb`, over `examples/`, `packages/` (with `packages/apps/`), `content/`, `skills/` and `apps/`: 57 `object-grid`, 30 `object-kanban` and 5 `object-calendar` nodes (the control on 47 / 27 / 4 of them). The one authored value among the eight members is a kanban `columns` (lanes, in the protocol docs), and it parses. No node authors another of the eight. +- **objectui** at the `.objectui-sha` pin `89cad75d55`: 689 `object-grid`, 240 `object-kanban` and 160 `object-calendar` nodes (the control on 293 / 108 / 98). Across the eight members, 241 values are static, and 233 of them parse. Each of the 8 that do not is a test fixture whose value the renderer drops, skips or refuses: 2 object entries in `bulkActions` (the renderer skips them, and the tests assert the skip), 3 object entries in `fields` that copy the hand-off the list view makes to the grid at run time (not an authored page), a lane `color` (retired in the console; the test marks it an undeclared member), and 2 uses of the calendar's retired `dateField` / `endField` aliases (the test asserts their refusal). No refused value is one the renderer draws. 24 values are not static (helper parameters, `.map` results and the run-time hand-offs); none of them is an authored page. The grid's `columns` (310 static values) is held because 2 of them author a column `options` the grid draws in its group headers, a fixture written to pin that behaviour. - **Deployed metadata** was not measured. diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-object-grid-kanban-calendar-list-members-typed.ts b/packages/spec/src/migrations/entries/semantic/18.ui-object-grid-kanban-calendar-list-members-typed.ts index d173ea11de2..2b90ca91d30 100644 --- a/packages/spec/src/migrations/entries/semantic/18.ui-object-grid-kanban-calendar-list-members-typed.ts +++ b/packages/spec/src/migrations/entries/semantic/18.ui-object-grid-kanban-calendar-list-members-typed.ts @@ -2,12 +2,14 @@ import type { SemanticMigration } from '../../types.js'; -// #21464 — the list members of the `object-grid`, `object-kanban` and +// #21464 — eight list members of the `object-grid`, `object-kanban` and // `object-calendar` page blocks were `z.unknown()` (an array of it for the // lists) although each renderer reads them with a fixed shape, so an off-shape // value passed the component-props gate and the block dropped or substituted it // in silence. The rows now take the list view's own members by reference where -// a list view declares one, and the measured shape otherwise. D3 only: +// a list view declares one, and the measured shape otherwise. The grid's +// `columns` is held at `z.unknown()`: the grid draws a column's `options`, +// which the list view's column entry does not declare. D3 only: // page-component `properties` is not parsed on the metadata save or load path, // so a stored page is never refused; an off-shape value has no rewrite that // says what the author meant; and the authored census found no authored value @@ -15,31 +17,30 @@ import type { SemanticMigration } from '../../types.js'; // them. export const entry: SemanticMigration = { id: 'ui-object-grid-kanban-calendar-list-members-typed', - surface: 'page `object-grid` components — `properties.columns`, `.fields`, `.selection`, `.selectable`, ' + surface: 'page `object-grid` components — `properties.fields`, `.selection`, `.selectable`, ' + '`.rowActions`, `.bulkActions` and `.batchActions`; page `object-kanban` components — ' + '`properties.columns`; page `object-calendar` components — `properties.calendar` (which used to ' + 'accept any value)', - replacement: 'the shape each block reads, the list view\'s own where it has one: `object-grid` `columns` ' - + 'all field-name strings or all column entries `{ field, label?, width?, … }` (never mixed); `fields` ' + replacement: 'the shape each block reads, the list view\'s own where it has one: `object-grid` `fields` ' + 'field-name strings; `selection` `{ type }` with `none` / `single` / `multiple`; `selectable` `true`, ' + '`false`, `\'single\'` or `\'multiple\'`; `rowActions`, `bulkActions` and `batchActions` action-name ' + 'strings. `object-kanban` `columns` all lanes `{ id, title, cards?, limit?, className?, collapsed? }` ' + 'or all bare value strings (never mixed), a lane `id` a string. `object-calendar` `calendar` ' - + '`{ startDateField, endDateField?, titleField?, colorField?, allDayField? }`. Rewrite a grid column ' - + 'written `{ accessorKey, header }` or `{ name }` as `{ field, label }`; move an object entry of ' - + '`fields` to `columns`; move a `{ name }` entry of `bulkActions` to `bulkActionDefs` or write the ' + + '`{ startDateField, endDateField?, titleField?, colorField?, allDayField? }`. Move an object entry ' + + 'of `fields` to `columns`; move a `{ name }` entry of `bulkActions` to `bulkActionDefs` or write the ' + 'bare name; style a lane with `className` instead of `color`; rename `dateField` / `endField` to ' + '`startDateField` / `endDateField`.', reason: 'Each renderer reads these members with one shape, and the page-component rows declared them ' + '`z.unknown()`, so any value passed the component-props gate and the block answered an off-shape one ' - + 'with a silent default: a grid column keyed `accessorKey` or `name`, or an array mixing strings and ' - + 'column objects, drew no column; an object entry of `fields` named no field; a `{ name }` entry of ' + + 'with a silent default: an object entry of `fields` named no field; a `{ name }` entry of ' + '`bulkActions` was skipped; a kanban lane list mixing objects and strings drew a blank lane and swept ' + 'its records into the trailing lane; and a calendar block without `startDateField` placed no event. The rows ' - + 'now take the list view\'s own `columns`, `selection`, `rowActions`, `bulkActions` (for `batchActions` ' + + 'now take the list view\'s own `selection`, `rowActions`, `bulkActions` (for `batchActions` ' + 'too, the spelling the grid reads first) and `calendar` members by reference, and the measured shape ' + 'for the grid\'s `fields` and `selectable` and the kanban lane, so one value is judged the same way ' - + 'on every door that carries it. It is read where every page component\'s props are: the ' + + 'on every door that carries it. The grid\'s `columns` is not narrowed: its group-header labels read ' + + 'an authored column\'s `options`, which the list view\'s column entry does not declare, so it stays ' + + 'open until that read is ruled. It is read where every page component\'s props are: the ' + 'component-props gate reports a refused value as an advisory `component-props-invalid` / ' + '`component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and ' + '`objectstack lint`, and a stored page still saves and loads, because a page component\'s ' @@ -49,7 +50,7 @@ export const entry: SemanticMigration = { + 'the upgrader. Deployed metadata NOT MEASURED.', acceptanceCriteria: 'Every `object-grid`, `object-kanban` and `object-calendar` node validates: ' + '`objectstack validate` reports no `component-props-invalid` / `component-props-unknown-key` ' - + 'finding under the nine members\' paths. Each block that set one of them now shows it: the declared ' - + 'grid columns, the selection mode, the row and bulk actions, the kanban lanes with their records, and ' + + 'finding under the eight members\' paths. Each block that set one of them now shows it: the grid\'s ' + + 'field fallback, the selection mode, the row and bulk actions, the kanban lanes with their records, and ' + 'the calendar events placed by `startDateField`.', }; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index a56201a462c..f0f2613e59f 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -6151,16 +6151,18 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ id: 'ui-object-grid-kanban-calendar-list-members-typed', order: 66, text: - 'It also types the list members of the `object-grid`, `object-kanban` and `object-calendar` page ' + 'It also types eight list members of the `object-grid`, `object-kanban` and `object-calendar` page ' + 'blocks (#21464, the second stage of the `ComponentPropsMap` `z.unknown()` close-out): the grid\'s ' - + '`columns`, `fields`, `selection`, `selectable`, `rowActions`, `bulkActions` and `batchActions`, the ' - + 'kanban\'s `columns` and the calendar\'s `calendar` were `z.unknown()` (an array of it for the lists), ' - + 'although each renderer reads them with one shape, so a grid column keyed `accessorKey` passed every ' - + 'door and drew no column. The members a list view declares take the list view\'s own by reference ' + + '`fields`, `selection`, `selectable`, `rowActions`, `bulkActions` and `batchActions`, the kanban\'s ' + + '`columns` and the calendar\'s `calendar` were `z.unknown()` (an array of it for the lists), although ' + + 'each renderer reads them with one shape, so a `{ name }` entry in `bulkActions` passed every door ' + + 'and was skipped. The members a list view declares take the list view\'s own by reference ' + '(`batchActions`, the spelling the grid reads first, takes `bulkActions`\'s); the grid\'s `fields` ' - + 'and `selectable` and the kanban lane take the measured shape. Read by the component-props gate ' - + '(advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is ' - + 'the semantic entry `ui-object-grid-kanban-calendar-list-members-typed`.', + + 'and `selectable` and the kanban lane take the measured shape. The grid\'s `columns` stays open: its ' + + 'group headers draw an authored column\'s `options`, which the list view\'s column entry does not ' + + 'declare. Read by the component-props gate (advisory); a stored page still saves and loads, so no ' + + 'conversion is registered. Its D3 record is the semantic entry ' + + '`ui-object-grid-kanban-calendar-list-members-typed`.', }, { id: 'ui-object-grid-row-members-typed', @@ -19518,12 +19520,14 @@ const step18: MigrationStep = { + 'and the grid\'s export menu offers the declared formats the active export path delivers ' + '(`xlsx` on the server stream only).', }, - // #21464 — the list members of the `object-grid`, `object-kanban` and + // #21464 — eight list members of the `object-grid`, `object-kanban` and // `object-calendar` page blocks were `z.unknown()` (an array of it for the // lists) although each renderer reads them with a fixed shape, so an off-shape // value passed the component-props gate and the block dropped or substituted it // in silence. The rows now take the list view's own members by reference where - // a list view declares one, and the measured shape otherwise. D3 only: + // a list view declares one, and the measured shape otherwise. The grid's + // `columns` is held at `z.unknown()`: the grid draws a column's `options`, + // which the list view's column entry does not declare. D3 only: // page-component `properties` is not parsed on the metadata save or load path, // so a stored page is never refused; an off-shape value has no rewrite that // says what the author meant; and the authored census found no authored value @@ -19531,31 +19535,30 @@ const step18: MigrationStep = { // them. { id: 'ui-object-grid-kanban-calendar-list-members-typed', - surface: 'page `object-grid` components — `properties.columns`, `.fields`, `.selection`, `.selectable`, ' + surface: 'page `object-grid` components — `properties.fields`, `.selection`, `.selectable`, ' + '`.rowActions`, `.bulkActions` and `.batchActions`; page `object-kanban` components — ' + '`properties.columns`; page `object-calendar` components — `properties.calendar` (which used to ' + 'accept any value)', - replacement: 'the shape each block reads, the list view\'s own where it has one: `object-grid` `columns` ' - + 'all field-name strings or all column entries `{ field, label?, width?, … }` (never mixed); `fields` ' + replacement: 'the shape each block reads, the list view\'s own where it has one: `object-grid` `fields` ' + 'field-name strings; `selection` `{ type }` with `none` / `single` / `multiple`; `selectable` `true`, ' + '`false`, `\'single\'` or `\'multiple\'`; `rowActions`, `bulkActions` and `batchActions` action-name ' + 'strings. `object-kanban` `columns` all lanes `{ id, title, cards?, limit?, className?, collapsed? }` ' + 'or all bare value strings (never mixed), a lane `id` a string. `object-calendar` `calendar` ' - + '`{ startDateField, endDateField?, titleField?, colorField?, allDayField? }`. Rewrite a grid column ' - + 'written `{ accessorKey, header }` or `{ name }` as `{ field, label }`; move an object entry of ' - + '`fields` to `columns`; move a `{ name }` entry of `bulkActions` to `bulkActionDefs` or write the ' + + '`{ startDateField, endDateField?, titleField?, colorField?, allDayField? }`. Move an object entry ' + + 'of `fields` to `columns`; move a `{ name }` entry of `bulkActions` to `bulkActionDefs` or write the ' + 'bare name; style a lane with `className` instead of `color`; rename `dateField` / `endField` to ' + '`startDateField` / `endDateField`.', reason: 'Each renderer reads these members with one shape, and the page-component rows declared them ' + '`z.unknown()`, so any value passed the component-props gate and the block answered an off-shape one ' - + 'with a silent default: a grid column keyed `accessorKey` or `name`, or an array mixing strings and ' - + 'column objects, drew no column; an object entry of `fields` named no field; a `{ name }` entry of ' + + 'with a silent default: an object entry of `fields` named no field; a `{ name }` entry of ' + '`bulkActions` was skipped; a kanban lane list mixing objects and strings drew a blank lane and swept ' + 'its records into the trailing lane; and a calendar block without `startDateField` placed no event. The rows ' - + 'now take the list view\'s own `columns`, `selection`, `rowActions`, `bulkActions` (for `batchActions` ' + + 'now take the list view\'s own `selection`, `rowActions`, `bulkActions` (for `batchActions` ' + 'too, the spelling the grid reads first) and `calendar` members by reference, and the measured shape ' + 'for the grid\'s `fields` and `selectable` and the kanban lane, so one value is judged the same way ' - + 'on every door that carries it. It is read where every page component\'s props are: the ' + + 'on every door that carries it. The grid\'s `columns` is not narrowed: its group-header labels read ' + + 'an authored column\'s `options`, which the list view\'s column entry does not declare, so it stays ' + + 'open until that read is ruled. It is read where every page component\'s props are: the ' + 'component-props gate reports a refused value as an advisory `component-props-invalid` / ' + '`component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and ' + '`objectstack lint`, and a stored page still saves and loads, because a page component\'s ' @@ -19565,8 +19568,8 @@ const step18: MigrationStep = { + 'the upgrader. Deployed metadata NOT MEASURED.', acceptanceCriteria: 'Every `object-grid`, `object-kanban` and `object-calendar` node validates: ' + '`objectstack validate` reports no `component-props-invalid` / `component-props-unknown-key` ' - + 'finding under the nine members\' paths. Each block that set one of them now shows it: the declared ' - + 'grid columns, the selection mode, the row and bulk actions, the kanban lanes with their records, and ' + + 'finding under the eight members\' paths. Each block that set one of them now shows it: the grid\'s ' + + 'field fallback, the selection mode, the row and bulk actions, the kanban lanes with their records, and ' + 'the calendar events placed by `startDateField`.', }, { diff --git a/packages/spec/src/ui/component-list-family-typed-members.pin.test.ts b/packages/spec/src/ui/component-list-family-typed-members.pin.test.ts index 9f0dee24e08..9d8fa6aef06 100644 --- a/packages/spec/src/ui/component-list-family-typed-members.pin.test.ts +++ b/packages/spec/src/ui/component-list-family-typed-members.pin.test.ts @@ -1,20 +1,22 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#21464, stage 2] The list family's nine `z.unknown()` members are typed: - * `object-grid` `columns`, `fields`, `selection`, `selectable`, `rowActions`, + * [#21464, stage 2] Eight of the list family's nine `z.unknown()` members are + * typed: `object-grid` `fields`, `selection`, `selectable`, `rowActions`, * `bulkActions` and `batchActions`, `object-kanban` `columns`, and - * `object-calendar` `calendar`. + * `object-calendar` `calendar`. The ninth, `object-grid` `columns`, is held in + * the enumeration pin's ledger: the grid draws a column's `options`, which the + * list view's column entry does not declare (objectstack-ai/objectui#11544). * * ## The defect this file closes * * Each renderer reads these members with one shape (measured at the * `.objectui-sha` pin `89cad75d55`; the read points are in the members' - * docblocks), and each row declared them `z.unknown()`. So a grid column keyed - * `accessorKey`, a `{ name }` entry in `bulkActions`, a kanban lane list mixing - * objects and strings and a calendar block with no `startDateField` all passed - * the component-props gate, and the block drew no column, skipped the action, - * drew a blank lane or placed no event, with no report. + * docblocks), and each row declared them `z.unknown()`. So an object entry in + * the grid's `fields`, a `{ name }` entry in `bulkActions`, a kanban lane list + * mixing objects and strings and a calendar block with no `startDateField` all + * passed the component-props gate, and the block drew no column, skipped the + * action, drew a blank lane or placed no event, with no report. * * ## What is pinned, and why each half * @@ -22,15 +24,15 @@ * parses to what the shared schema itself answers. A refusal pin with no lit * control passes just as well when the door refuses everything. * - §2 THE REFUSALS: an off-shape value of each member is refused with the - * code AND the path — and, for the two-array unions, the issue inside the - * arm that should have taken it — so a refusal for the wrong reason reds. - * - §3 ONE SCHEMA: the five members a list view also declares hold the list + * code AND the path — and, for the kanban's two-array union, the issue + * inside the arm that should have taken it — so a refusal for the wrong reason reds. + * - §3 ONE SCHEMA: the four members a list view also declares hold the list * view's own defs by identity (`batchActions` holds `bulkActions`'s), and the * shapes declared here hold exactly the measured vocabulary. * - §4 THE REGISTRATION: the ADR-0087 D3 entry step 18 carries. * * The enumeration pin (`component-props-unknown-members.pin.test.ts`) holds the - * other half: these nine left its ledger, so a member reverted to + * other half: these eight left its ledger, so a member reverted to * `z.unknown()` reds there. */ @@ -69,13 +71,6 @@ function armIssues(result: z.ZodSafeParseResult): string[][] { describe('§1 each member accepts a value of its declared shape', () => { const BYTE_IDENTICAL: ReadonlyArray]> = [ - ['grid columns as field names', 'object-grid', { columns: ['name', 'amount'] }], - ['grid columns as column entries', 'object-grid', { - columns: [ - { field: 'name', label: 'Name', width: 240, sortable: true, link: true }, - { field: 'amount', align: 'right', summary: 'sum', wrap: true, pinned: 'left' }, - ], - }], ['grid fields', 'object-grid', { fields: ['name', 'amount'] }], ['each selection type', 'object-grid', { selection: { type: 'single' } }], ['grid rowActions', 'object-grid', { rowActions: ['edit', 'delete', 'approve'] }], @@ -116,14 +111,6 @@ describe('§1 each member accepts a value of its declared shape', () => { }); } - it('parses a column with a prefix to exactly what the list view\'s columns answer (its default included)', () => { - const columns = [{ field: 'name', prefix: { field: 'status' } }]; - const r = parse('object-grid', { columns }); - expect(issues(r)).toEqual([]); - expect(r.success && (r.data as { columns?: unknown }).columns) - .toStrictEqual(ListViewSchema.shape.columns.parse(columns)); - }); - it('parses an empty selection block to exactly what the list view\'s selection answers (its default included)', () => { const r = parse('object-grid', { selection: {} }); expect(issues(r)).toEqual([]); @@ -171,18 +158,9 @@ describe('§2 each member refuses an off-shape value', () => { }); } - // The two-array unions answer `invalid_union` at the member; the arm that + // The kanban's two-array union answers `invalid_union` at the member; the arm that // should have taken the value says why it did not. const UNION_REFUSED: ReadonlyArray, arms: string[][]]> = [ - ['a grid column list mixing strings and column objects', 'object-grid', - { columns: ['name', { field: 'amount' }] }, [['invalid_type@1'], ['invalid_type@0']]], - ['a grid column keyed accessorKey / header', 'object-grid', - { columns: [{ accessorKey: 'amount', header: 'Amount' }] }, [['invalid_type@0'], ['invalid_type@0.field', 'unrecognized_keys@0']]], - ['a grid column keyed name', 'object-grid', - { columns: [{ name: 'salary' }] }, [['invalid_type@0'], ['invalid_type@0.field', 'unrecognized_keys@0']]], - ['a grid column key the grid never reads (editable)', 'object-grid', - { columns: [{ field: 'name', editable: false }] }, [['invalid_type@0'], ['unrecognized_keys@0']]], - ['a numeric grid columns', 'object-grid', { columns: 42 }, [['invalid_type@'], ['invalid_type@']]], ['a kanban lane list mixing objects and strings', 'object-kanban', { columns: [{ id: 'done', title: 'Done' }, 'todo'] }, [['invalid_type@0'], ['invalid_type@1']]], ['a numeric lane id', 'object-kanban', { columns: [{ id: 1, title: 'One' }] }, [['invalid_type@0'], ['invalid_type@0.id']]], @@ -225,10 +203,6 @@ describe('§3 the members hold the list view\'s own defs, and the measured vocab const grid = () => ObjectGridPropsSchema.shape; const listView = () => ListViewSchema.shape; - it('object-grid columns is the list view\'s own columns member — the same union def', () => { - expect(grid().columns.unwrap()._zod.def).toBe(listView().columns._zod.def); - }); - it('object-grid selection, rowActions and bulkActions are the list view\'s own members — the same defs', () => { expect(grid().selection.unwrap()._zod.def).toBe(SelectionConfigSchema._zod.def); expect(grid().selection.unwrap()._zod.def).toBe(listView().selection.unwrap()._zod.def); diff --git a/packages/spec/src/ui/component-props-unknown-members.pin.test.ts b/packages/spec/src/ui/component-props-unknown-members.pin.test.ts index 2a0ab0f9c38..3938c5e1d3a 100644 --- a/packages/spec/src/ui/component-props-unknown-members.pin.test.ts +++ b/packages/spec/src/ui/component-props-unknown-members.pin.test.ts @@ -31,10 +31,11 @@ * the code AND the path; its ADR-0087 D3 entry is registered. * * Later stages pin the members they type in their own file, beside this one: - * the list family (`object-grid` `columns` / `fields` / `selection` / - * `selectable` / `rowActions` / `bulkActions` / `batchActions`, - * `object-kanban` `columns`, `object-calendar` `calendar`) in - * `component-list-family-typed-members.pin.test.ts`. + * the list family (`object-grid` `fields` / `selection` / `selectable` / + * `rowActions` / `bulkActions` / `batchActions`, `object-kanban` `columns`, + * `object-calendar` `calendar`) in + * `component-list-family-typed-members.pin.test.ts`. The family's ninth, + * `object-grid` `columns`, is held below. * * ## The STAGED reason is debt, not a verdict * @@ -246,6 +247,14 @@ on(['action:menu'], ['actions[]{}'], staged('objectui-held', 'components/src/ren // `types/src/__tests__/kanban-conditional-formatting.test.ts:29-52`), so the // narrowing is reported for a ruling instead of shipped. on(['object-kanban'], ['conditionalFormatting'], staged('held-for-decision', 'plugin-kanban/src/KanbanBoardCore.tsx:114, evaluated at KanbanImpl.tsx:179 (`resolveConditionalFormatting`)')); +// The list view's own `columns` is the by-reference shape, and the draw path +// matches it — but the grid's group-header formatter also reads `options` off +// an authored column (`colOverride?.options || objectDefField?.options`, the +// column winning) and draws the group labels from it, which objectui pins as +// behaviour (`plugin-grid/src/__tests__/gridGroupingMembers-8071.test.tsx:260-301`). +// `ListColumn` declares no `options`, so the narrowing would refuse a value the +// grid draws: held until objectstack-ai/objectui#11544 is ruled. +on(['object-grid'], ['columns[]'], staged('held-for-decision', 'plugin-grid/src/ObjectGrid.tsx:2158 (`normalizeColumns`), `columns[].options` drawn by the group-header formatter at :2997-3001')); /** Every `z.unknown()` member of every row, keyed as the ledger keys it. */ function census(): Map { diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index e60c46e7760..285edfb2057 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -3880,20 +3880,19 @@ const GridOperationsSchema = lazySchema(() => strictObject({ * as `schema.resizable ?? schema.resizableColumns` (:5361) — retires to a * tombstone naming `resizable`. * - * [#21464] The list members, re-measured at the same pin `89cad75d55` and - * typed the same way — each was `z.unknown()` (an array of it for the four + * [#21464] Six list members, re-measured at the same pin `89cad75d55` and + * typed the same way — each was `z.unknown()` (an array of it for the three * lists), so a value of the wrong shape passed the component-props gate and - * the grid dropped or substituted it in silence: `columns` (`normalizeColumns`, - * :819, dispatching on the FIRST entry — all strings or all `ListColumn` - * objects; read at :2158 and projected at :2590), `fields` (:1946 — field + * the grid dropped or substituted it in silence: `fields` (:1946 — field * NAMES on the draw path, `objectSchema.fields[fieldName]` at :3969 / :4012), * `selection` (`.type`, :4799-4812), `selectable` (:4813-4815, handed to the * table's `selectable` at :5333), `rowActions` (`string[]`, :1834-1835) and * `bulkActions` / `batchActions` (`batchActions ?? bulkActions`, :4763, each * entry a NAME `resolveBulkActions` folds; a non-string entry is skipped). The - * four a list view also declares take the list view's own members by + * three a list view also declares take the list view's own members by * reference; `fields` and `selectable` have no list-view counterpart and * declare the measured shape here; `batchActions` takes `bulkActions`'s def. + * `columns` stays `z.unknown()`, held for a ruling — see the member. */ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ surface: 'this `object-grid`', @@ -3938,19 +3937,25 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ emptyState: EmptyStateSchema.optional() .describe('What the grid draws instead of an empty table: `{ title, message, icon }` — the list view\'s own empty-state shape'), /** - * [#21464] The list view's own `columns` member, by reference - * (`ListViewSchema.shape.columns` — the union of two array shapes the - * column entry is declared in): all field-name strings, or all - * `ListColumn` entries. `normalizeColumns` (`ObjectGrid.tsx:819` at the pin - * `89cad75d55`) decides which by the FIRST entry, and the draw path keeps - * only an entry whose `field` is a non-empty string, so a mixed array, a - * column keyed `accessorKey` / `header` / `name`, or a key the grid never - * reads (`editable`, `options`) drew no column or was ignored. Optional - * here, where the list view requires it: a grid with no `columns` derives - * them from `fields` or the object. + * [#21464] HELD at `z.unknown()` for a ruling, not typed. The by-reference + * candidate is the list view's own `columns` member + * (`ListViewSchema.shape.columns`: all field-name strings, or all strict + * `ListColumn` entries), and the draw path matches it: `normalizeColumns` + * (`ObjectGrid.tsx:819` at the pin `89cad75d55`, read at `:2158`) decides + * by the FIRST entry, and only an entry with a non-empty string `field` + * draws a column. But the grid also reads `options` off an authored column: + * the group-header formatter (`:2997-3001`) takes the column whose `field` + * is the grouping field and draws the group-header labels from + * `colOverride?.options || objectDefField?.options`, the column's list + * winning — and objectui's own `gridGroupingMembers-8071` test pins that as + * behaviour. `ListColumn` declares no `options`, so the by-reference shape + * would refuse a value the grid draws. The renderer-side read is carded as + * objectstack-ai/objectui#11544; the member is typed once that is ruled. + * (A column `editable` key, by contrast, is read nowhere off an authored + * column.) */ - columns: ListViewSchema.shape.columns.optional() - .describe('Columns — all field-name strings, or all column entries `{ field, label?, width?, align?, hidden?, sortable?, … }`, the same union a list view\'s `columns` declares. One spelling per list: an array mixing strings and column objects is refused'), + columns: z.array(z.unknown()).optional() + .describe('Columns: field names or column definition objects'), /** * [#21464] Field NAMES. No list-view schema declares this member, so the * shape is the one the grid reads (`ObjectGrid.tsx:1946` at the pin @@ -4373,8 +4378,8 @@ export type ObjectGridProps = z.input; * type-alias convention pin's default-free family (the Iso839 line deleted * with this alias), on the `RecordAlertPropsParsed` route its comment * prescribes. The list view's own members taken by reference since carry - * their defaults too (#21445, #21464: `navigation`'s four, `selection.type`, - * a column's `prefix.type`). + * their defaults too (#21445, #21464: `navigation`'s four and + * `selection.type`). */ export type ObjectGridPropsParsed = z.infer; From 8b276755c216f2e2a122ea25c63fad49347a94a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 07:38:22 +0000 Subject: [PATCH 6/6] chore(spec): regenerate the component reference page for the columns hold (#21464 stage 2) Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude --- content/docs/references/ui/component.mdx | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 26adb99539e..ba4022cdce3 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -692,7 +692,7 @@ Sort field and direction pair | **title** | `string \| Record` | optional | Fallback for `label` (the renderer reads `label \|\| title`) | | **description** | `string \| Record` | optional | One line of help text drawn above the grid's rows — a string, or an inline locale map resolved against the display locale | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string }` | optional | What the grid draws instead of an empty table: `{ title, message, icon }` — the list view's own empty-state shape | -| **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | optional | Columns — all field-name strings, or all column entries `{ field, label?, width?, align?, hidden?, sortable?, … }`, the same union a list view's `columns` declares. One spelling per list: an array mixing strings and column objects is refused | +| **columns** | `any[]` | optional | Columns: field names or column definition objects | | **fields** | `string[]` | optional | Field-name fallback the grid reads when `columns` is absent — bare field names (`['name', 'amount']`); write column decoration such as `label` or `width` on `columns` | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. THE key, singular — not the plural misspelling. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **defaultFilters** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Legacy base-filter fallback, read only when `filter` is absent — the SAME ViewFilterRule array form `[{ field, operator, value }, ...]` as `filter`, lowered through the same sink. Prefer `filter`. The MongoDB-style record form, a bare string and an ObjectQL AST tuple array are refused — see migration `object-grid-default-filters-rule-array` | @@ -736,25 +736,6 @@ Sort field and direction pair | **message** | `string \| Record` | optional | Line of text below the heading | | **icon** | `string` | optional | Icon name drawn above the heading; a name that resolves to no icon draws the default empty-state glyph | -### Nested Shape: `ObjectGridProps.columns[number]` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **field** | `string` | ✅ | Field name (snake_case) | -| **label** | `string \| Record` | optional | Display label override | -| **width** | `number` | optional | Column width in pixels | -| **align** | `Enum<'left' \| 'center' \| 'right'>` | optional | Text alignment | -| **hidden** | `boolean` | optional | Hide column by default | -| **sortable** | `boolean` | optional | Allow sorting by this column | -| **resizable** | `boolean` | optional | Allow resizing this column | -| **wrap** | `boolean` | optional | Allow text wrapping | -| **type** | `string` | optional | Renderer type override (e.g., "currency", "date") | -| **pinned** | `Enum<'left' \| 'right'>` | optional | Pin/freeze column to left or right side | -| **summary** | `Enum<'none' \| 'count' \| 'count_empty' \| 'count_filled' \| 'count_unique' \| …> \| { type: Enum<'none' \| 'count' \| 'count_empty' \| 'count_filled' \| 'count_unique' \| …>; field?: string }` | optional | Footer aggregation for this column — the function alone, or `{ type, field }` to aggregate another field | -| **prefix** | `{ field: string; type?: Enum<'badge' \| 'text'> }` | optional | Field rendered inline before this cell value | -| **link** | `boolean` | optional | Functions as the primary navigation link (triggers View navigation) | -| **action** | `string` | optional | Registered Action ID to execute when clicked | - ### Nested Shape: `ObjectGridProps.filter[number]` View filter rule