From cf8c86054acb83cbe5618f73d2ff0b22b64cb7d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 17:48:07 +0000 Subject: [PATCH 1/2] feat(types): object-kanban declares grouping as the spec's GroupingConfig, by reference (objectui#11216) The spec's object-kanban row types `grouping` as the list view's own GroupingConfigSchema, and ObjectKanban reads grouping.fields[0].field as the swimlaneField fallback. The arm did not declare the key, so the strict authoring face refused a well-formed config by name while the tolerant face kept any value unjudged. - zod mirror: grouping: stripImportedDefaults(GroupingConfigSchema).optional(), spelled as ObjectGridSchema.grouping spells it. - TS twin: grouping?: GroupingConfig. - New pin object-kanban-grouping-typed-11216: accept on every face, the spec row's refusals at the key, and verdict parity with the spec row. - The grouping census in grouping-field-padded-refused-7347 gains the arm. - The descriptions that called the spec row z.unknown() (two 8313 test headers, the console MEMBER_PINS entry) describe the typed row. - Docs: grouping rows in plugin-kanban.mdx and schema-reference.md. Claude-Session: https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2 Co-authored-by: Claude --- .changeset/11216-kanban-grouping-typed.md | 24 +++ .../registry-inputs-spec-parity.test.ts | 2 +- content/docs/api/schema-reference.md | 3 +- content/docs/plugins/plugin-kanban.mdx | 1 + ...cturedMembersReachTheirSinks-8313.test.tsx | 21 ++- ...redKeysAreDeclaredAndHonoured-8313.test.ts | 19 +- ...grouping-field-padded-refused-7347.test.ts | 10 + ...object-kanban-grouping-typed-11216.test.ts | 172 ++++++++++++++++++ packages/types/src/objectql.ts | 19 +- packages/types/src/zod/objectql.zod.ts | 14 +- 10 files changed, 264 insertions(+), 21 deletions(-) create mode 100644 .changeset/11216-kanban-grouping-typed.md create mode 100644 packages/types/src/__tests__/object-kanban-grouping-typed-11216.test.ts diff --git a/.changeset/11216-kanban-grouping-typed.md b/.changeset/11216-kanban-grouping-typed.md new file mode 100644 index 0000000000..5930077e66 --- /dev/null +++ b/.changeset/11216-kanban-grouping-typed.md @@ -0,0 +1,24 @@ +--- +'@object-ui/types': minor +--- + +`ObjectKanbanSchema.grouping` is declared on both faces, as `@objectstack/spec`'s `GroupingConfig`, by reference (objectui#11216). + +The spec's `object-kanban` row types `grouping` as the list view's own `GroupingConfigSchema`. `ObjectKanban` reads `grouping.fields[0].field` as the fallback for `swimlaneField`. Until this change the key was undeclared on the `object-kanban` arm, so this package and the spec gave three different answers for one document: + +| `grouping` value | spec row | strict authoring face (before → now) | tolerant face (before → now) | +|---|---|---|---| +| `{ fields: [{ field: 'owner' }] }` | accepted | refused by name → **accepted** | kept → accepted, kept as authored | +| a padded field name, `{ fields: [{ field: ' x ' }] }` | refused | refused by name → refused at `fields.0.field` | kept → **refused** | +| a bare string, `'owner'` | refused | refused by name → refused at `grouping` | kept → **refused** | +| an empty list, `{ fields: [] }` | refused | refused by name → refused at `fields` | kept → **refused** | +| an undeclared key on the block or on an entry | refused | refused by name → refused by name inside the block | kept → **refused** | + +- **Zod mirror.** `grouping: stripImportedDefaults(GroupingConfigSchema).optional()`, which is how `ObjectGridSchema.grouping` spells it. The spec's `order` and `collapsed` defaults are not added to a parsed document. +- **TypeScript.** `grouping?: GroupingConfig`, the spec's authored type. It is the same type as the `object-kanban` row's `grouping`. + +**Not changed: the board.** `ObjectKanban` reads `fields[0].field` and nothing else in the block. The registration's input description says so, and it is unchanged. + +**Migration.** Write `grouping` as `{ fields: [{ field }] }` with an unpadded field name, or delete it and author `swimlaneField`, which the board reads first. + +**minor, not patch.** The member is new in the shipped `.d.ts` and in the zod mirror's `.shape`. The tolerant face now refuses the shapes in the table that it used to keep unjudged. diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index 957bf85d76..205e5f9ca4 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -3018,7 +3018,7 @@ const MEMBER_PINS: Record = { }, 'object-kanban.grouping': { file: 'packages/plugin-kanban/src/__tests__/ObjectKanban.structuredMembersReachTheirSinks-8313.test.tsx', - pins: 'ONE nested position and no more: `schema.grouping?.fields?.[0]?.field` is the FALLBACK source of `swimlaneField`, and that is the entire member contract this board carries for the key. Three rows make it a reading rather than a claim — the swimlane layout appears keyed by `fields[0].field` where without the key there is none; an explicit `swimlaneField` WINS over it; and a second `fields` entry changes nothing, which is what pins the read at `[0]` rather than at "the fields list". The declared description says the rest is inert precisely so the declaration does not recommend a write the renderer cannot honour — this file is what keeps that sentence true. The spec row is `z.unknown()`, so the read site is the whole member contract (objectui#8313).', + pins: 'ONE nested position and no more: `schema.grouping?.fields?.[0]?.field` is the FALLBACK source of `swimlaneField`, and that is the entire member contract this board carries for the key. Three rows make it a reading rather than a claim — the swimlane layout appears keyed by `fields[0].field` where without the key there is none; an explicit `swimlaneField` WINS over it; and a second `fields` entry changes nothing, which is what pins the read at `[0]` rather than at "the fields list". The declared description says the rest is inert precisely so the declaration does not recommend a write the renderer cannot honour — this file is what keeps that sentence true. The spec row types the block as the list view\'s `GroupingConfigSchema`, which both `@object-ui/types` faces take by reference (objectui#11216): that fixes its SHAPE, not which position this board reads, so the read site is still the whole of what the board does with a member (objectui#8313).', }, 'object-kanban.navigation': { file: 'packages/plugin-kanban/src/__tests__/kanbanNavigationMembers-8652.test.tsx', diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index 8a47e5a890..be7577d2c0 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -1010,6 +1010,7 @@ A drag-and-drop Kanban board. The `object-kanban` type key validates the shape t | `titleField` | `string` | Field used as the card title. | | `cardFields` | `string[]` | Fields rendered on each card. | | `swimlaneField` | `string` | Record field that splits the board into horizontal swimlanes, across the `groupBy` columns. Declared on both faces since objectui#11355, with the type `@objectstack/spec`'s `object-kanban` row gives it. With the key absent the board falls back to `grouping.fields[0].field`. | +| `grouping` | `GroupingConfig` | The fallback for `swimlaneField`: with that key absent, the board splits into swimlanes by `grouping.fields[0].field`, and reads nothing else in the block. The type is `@objectstack/spec`'s `GroupingConfig` (`{ fields: [{ field, order?, collapsed? }] }`), the one the `object-kanban` row and `object-grid` take, by reference on both faces since objectui#11216: at least one entry, no undeclared key, and a `field` written without leading or trailing spaces. | | `filter` | `any[]` | Query filter, forwarded verbatim as `$filter`. | | `limit` | `number` | Fetch window for the board (default 100). | | `coverImageField` | `string` | Field whose URL renders as the card cover image. | @@ -1024,7 +1025,7 @@ A drag-and-drop Kanban board. The `object-kanban` type key validates the shape t > > ⚠️ The **bare-string array applies only to a board with no `groupBy`.** It is declared so this package does not refuse an authoring the protocol allows. The renderer reads a bare-string lane list only when a board has no `groupBy` — so on a board that *does* declare one the strings are ignored and the lanes come from the group field's picklist options or from the data. Since objectui#8990 made `groupBy` optional, a lane-less board is a valid authoring and this arm is live on it: the lanes are drawn, titled by the **raw strings** (a grouped board titles its lanes with the picklist *labels* instead). ⚠️ Such a board holds **no cards** — with no lane key the records are never distributed — and dragging a card writes nothing back. It is lane headings, not a populated board; to control the lanes of a working board, declare `groupBy` and write the `{ id, title }` array. > -> Of the other keys the retired `kanban` arm alone declared, `cardTitle` (objectui#9606), `navigation` (objectui#8652) and `swimlaneField` (objectui#11355) are declared on this face; `grouping` is still undeclared. The renderer reads it as the fallback for `swimlaneField`, so a board may carry it; it is simply not judged. Aligning it with the spec's typed row is objectui#11216. +> Of the other keys the retired `kanban` arm alone declared, `cardTitle` (objectui#9606), `navigation` (objectui#8652), `swimlaneField` (objectui#11355) and `grouping` (objectui#11216) are declared on this face. `grouping` was the last of them: until objectui#11216 it was kept unjudged here and refused by name on the strict authoring face. > **Handler keys are not authorable in JSON, and all three now say so by name.** Since objectui#7804 this face declares `onCardClick` as an objectui#6124 **runtime slot**: a React host supplies the function through the TypeScript interface or as a React prop, and this validator **refuses the key by name** with a message pointing at the node-type spelling (`{ "type": "toast", … }`, an `action:button` node). Until then an authored `onCardClick: { "action": "toast" }` parsed **green** — `BaseSchema` is `.passthrough()`, so a key no arm declares is not refused, it stops being judged and the value is kept, then reaches a call site expecting a function. ⭐ The other two keys are **tombstones**, not runtime slots, so their TypeScript twins are `?: never` rather than callable. `onCardMove` has been one since objectui#9342: an authored one reached **nothing** even as a function, because an object-bound board substitutes its own mover, and the mover lives on `KanbanRenderer`'s React prop of the same name, a sibling of its `schema`. `onQuickAdd` has been one since objectui#11234: a supplied one reached the board and was never called, because its partner `quickAdd` is retired here. `ObjectKanban` renders an internal board that takes the Quick Add pair only as explicit props and supplies neither half; the pair lives on `KanbanRenderer`'s `schema`, for a React host. diff --git a/content/docs/plugins/plugin-kanban.mdx b/content/docs/plugins/plugin-kanban.mdx index a73101381a..2a7f43ab48 100644 --- a/content/docs/plugins/plugin-kanban.mdx +++ b/content/docs/plugins/plugin-kanban.mdx @@ -174,6 +174,7 @@ interface KanbanCard { | `limit` | number | Rows fetched by an object-driven board (default 100) | | `navigation` | `ViewNavigationConfig` | What a card click opens — the spec's `NavigationConfig` by reference, the type `ObjectGridSchema.navigation` uses: `mode` (`page`, `drawer`, `modal`, `split`, `popover`, `new_window` or `none`) with `size`, `openNewTab` and `preventNavigation`. With the key absent a click opens the record in a drawer, and a click handler from a parent view outranks the whole key. `page` — and a block written without `mode`, which takes the spec's `page` default — opens the record page through the record navigator the host publishes (objectui#11293); the console publishes one on its custom pages, record pages and list views, and under a host that publishes none the click opens nothing. | | `swimlaneField` | string | Record field that splits an object-driven board into horizontal swimlanes, across the `groupBy` columns. `@objectstack/spec` declares it on `object-kanban`, and both faces of `@object-ui/types` declare it since objectui#11355. With the key absent the board falls back to `grouping.fields[0].field` | +| `grouping` | `GroupingConfig` | The fallback for `swimlaneField`: with that key absent, the board splits into swimlanes by `grouping.fields[0].field`. Nothing else in the block is read on this board. The type is `@objectstack/spec`'s `GroupingConfig`, `{ fields: [{ field, order?, collapsed? }] }`, which both faces of `@object-ui/types` take by reference since objectui#11216: at least one entry, no undeclared key, and a `field` without leading or trailing spaces | | `className` | string | Additional Tailwind CSS classes | ### Lane Properties diff --git a/packages/plugin-kanban/src/__tests__/ObjectKanban.structuredMembersReachTheirSinks-8313.test.tsx b/packages/plugin-kanban/src/__tests__/ObjectKanban.structuredMembersReachTheirSinks-8313.test.tsx index 5542c1c73a..df81f90401 100644 --- a/packages/plugin-kanban/src/__tests__/ObjectKanban.structuredMembersReachTheirSinks-8313.test.tsx +++ b/packages/plugin-kanban/src/__tests__/ObjectKanban.structuredMembersReachTheirSinks-8313.test.tsx @@ -49,15 +49,20 @@ * against what. One dialect since objectui#11522, `{ condition, style }`, * per card, on the card's own record. * - * ## The spec supplies none of it + * ## What the spec supplies, and what it cannot * - * On the installed spec, `grouping` and `conditionalFormatting` are - * `z.unknown().optional()` — exactly like `filter` and `sort` — so the contract - * constrains the value not at all and cannot be the thing a member pin compares - * against. `data` is `z.array(z.unknown())` and `cardFields` is - * `z.array(z.string())`: those fix the CONTAINER kind and, for `cardFields`, - * the member kind, but neither says anything about what the board does with a - * member. For all four the read site is the whole of the member contract. + * On the installed spec, `conditionalFormatting` is `z.unknown().optional()`, + * so the contract constrains its value not at all. `grouping` is TYPED: the + * row holds the list view's own `GroupingConfigSchema` by reference, which + * fixes the SHAPE of the block (`{ fields: [{ field, order?, collapsed? }] }`, + * closed, at least one entry) and which both faces of `@object-ui/types` judge + * the key by since objectui#11216. It does not say WHICH position this board + * reads — the schema is the list view's, whose grid reads every entry — so the + * `[0]` rows below are still the only statement of that. `data` is + * `z.array(z.unknown())` and `cardFields` is `z.array(z.string())`: those fix + * the CONTAINER kind and, for `cardFields`, the member kind, but neither says + * anything about what the board does with a member. For all four, what the + * board does with a member is the read site's alone. * * ## Non-vacuity * diff --git a/packages/plugin-kanban/src/__tests__/structuredKeysAreDeclaredAndHonoured-8313.test.ts b/packages/plugin-kanban/src/__tests__/structuredKeysAreDeclaredAndHonoured-8313.test.ts index bcf38c41b3..f86c2e6238 100644 --- a/packages/plugin-kanban/src/__tests__/structuredKeysAreDeclaredAndHonoured-8313.test.ts +++ b/packages/plugin-kanban/src/__tests__/structuredKeysAreDeclaredAndHonoured-8313.test.ts @@ -59,12 +59,19 @@ * * Read off `ComponentPropsMap['object-kanban']` on the installed spec: `data` * is `z.array(z.unknown()).optional()`, `cardFields` is - * `z.array(z.string()).optional()`, and `grouping` and `conditionalFormatting` - * are BOTH `z.unknown().optional()` — exactly like `filter` and `sort`. So for - * two of the four the contract fixes the container kind and nothing about a - * member, and for the other two it constrains nothing at all. That is why row 4 - * is a key verdict and why the member file exists: on these keys the read site - * is the whole of the member contract. + * `z.array(z.string()).optional()` and `conditionalFormatting` is + * `z.unknown().optional()`. `grouping` is TYPED: the row holds the list view's + * own `GroupingConfigSchema` by reference (`{ fields: [{ field, order?, + * collapsed? }] }`, closed, at least one entry), and both faces of + * `@object-ui/types` judge the key by that same schema since objectui#11216 — + * re-derived per run by `object-kanban-grouping-typed-11216.test.ts` in that + * package rather than by this sentence. So the contract fixes the container + * kind of `data` and `cardFields`, constrains `conditionalFormatting` not at + * all, and fixes the SHAPE of `grouping` — but for none of the four does it say + * which member this board reads (`grouping`'s schema is the list view's, whose + * grid reads every entry; this board reads `fields[0].field` only). That is why + * row 4 is a key verdict and why the member file exists: what the board does + * with a member is the read site's alone. */ import { describe, it, expect } from 'vitest'; diff --git a/packages/types/src/__tests__/grouping-field-padded-refused-7347.test.ts b/packages/types/src/__tests__/grouping-field-padded-refused-7347.test.ts index 94d9aa4357..02f236cdc6 100644 --- a/packages/types/src/__tests__/grouping-field-padded-refused-7347.test.ts +++ b/packages/types/src/__tests__/grouping-field-padded-refused-7347.test.ts @@ -68,6 +68,7 @@ import { ListViewSchema, ObjectGridBlockSchema, ObjectGallerySchema, + ObjectKanbanSchema, ObjectViewSchema, StrictAnyComponentSchema, safeValidateSchema, @@ -148,6 +149,15 @@ const DECLARING: Readonly> = { doc: (fields) => ({ type: 'object-grid', properties: { objectName: 'account', grouping: { fields } } }), fieldPath: (i) => ['properties', 'grouping', 'fields', i, 'field'], }, + // objectui#11216: the `object-kanban` arm declares `grouping` (the fallback + // `ObjectKanban` reads for `swimlaneField`) as the spec's + // `GroupingConfigSchema` by reference, the type its spec row gives the key. + 'object-kanban': { + declaredAt: 'grouping', + arm: ObjectKanbanSchema, + doc: (fields) => ({ type: 'object-kanban', objectName: 'account', groupBy: 'status', grouping: { fields } }), + fieldPath: (i) => ['grouping', 'fields', i, 'field'], + }, }; /** diff --git a/packages/types/src/__tests__/object-kanban-grouping-typed-11216.test.ts b/packages/types/src/__tests__/object-kanban-grouping-typed-11216.test.ts new file mode 100644 index 0000000000..9c8f5865cf --- /dev/null +++ b/packages/types/src/__tests__/object-kanban-grouping-typed-11216.test.ts @@ -0,0 +1,172 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#11216 — `object-kanban` declares `grouping` as `@objectstack/spec`'s + * `GroupingConfigSchema`, by reference, on both faces of this package. + * + * ## The defect + * + * `ObjectKanban` reads `grouping.fields[0].field` as the fallback for + * `swimlaneField`, and the spec's `ComponentPropsMap['object-kanban']` row has + * typed `grouping` as the list view's own `GroupingConfigSchema` since the + * release this repository installs. This package's `object-kanban` arm did not + * declare the key, so its two runtime faces and the spec gave three answers for + * one document: + * + * - the STRICT authoring face refused a well-formed `grouping` by name + * (`unrecognized_keys`), which is narrower than the protocol; + * - the tolerant face kept ANY value unjudged through `BaseSchema`'s + * `.passthrough()`, including the shapes the spec row refuses; + * - the spec row accepted the well-formed config and refused the rest. + * + * ## What each block below holds + * + * 1. a well-formed config parses on both faces and is kept as authored — + * the spec's `order` and `collapsed` defaults are not materialised into + * the document (`stripImportedDefaults`, as `object-grid` spells it); + * 2. each shape the spec row refuses is refused on both faces, AT `grouping`, + * with the code the spec row gives it, and a control that differs only in + * the key name parses on the tolerant face — so the refusal is the + * declaration and not the document; + * 3. the verdicts are the spec row's own: over one corpus, both faces accept + * exactly what `ComponentPropsMap['object-kanban'].shape.grouping` accepts; + * 4. the TypeScript twin types the member as the spec row does (type-level, + * read by `tsc -p tsconfig.test.json` only; vitest does not typecheck). + */ + +import { describe, it, expect } from 'vitest'; +import type { z } from 'zod'; +import { ComponentPropsMap } from '@objectstack/spec/ui'; +import type { GroupingConfig, ObjectKanbanProps as SpecObjectKanbanProps } from '@objectstack/spec/ui'; +import { ObjectKanbanSchema } from '../zod/objectql.zod'; +import { AnyComponentSchema, StrictAnyComponentSchema } from '../zod/index.zod.js'; +import type { ObjectKanbanSchema as TsObjectKanbanSchema } from '../objectql'; + +/** Compile-time truth assertion, erased at runtime — only `tsc` checks these. */ +type Expect = T; +/** Compile-time equality, exact in both directions; `any` equals nothing but `any`. */ +type Equal = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? true : false; + +/* ── 4. The TypeScript face: the member is the spec row's type ─────────────── */ + +export type _GroupingIsTheKanbanRows = Expect< + Equal +>; +/** Spelled out as well, so both faces drifting to one wrong type cannot pass. */ +export type _GroupingIsTheSpecGroupingConfig = Expect>; + +/* ── The runtime faces ────────────────────────────────────────────────────── */ + +type Issue = { code: string; path: PropertyKey[]; keys?: string[]; errors?: Issue[][] }; + +/** + * Every issue in the tree, union arms included, as `path` + `code`. An + * `unrecognized_keys` issue is reported once per key, at the key itself, so a + * refusal by name says WHICH name. + */ +const flatIssues = (issues: readonly Issue[] | undefined, prefix: PropertyKey[] = []): Array<{ path: string; code: string }> => { + const out: Array<{ path: string; code: string }> = []; + for (const issue of issues ?? []) { + const at = [...prefix, ...issue.path]; + if (issue.code === 'unrecognized_keys') { + for (const k of issue.keys ?? []) out.push({ path: [...at, k].map(String).join('.'), code: issue.code }); + } else { + out.push({ path: at.map(String).join('.'), code: issue.code }); + } + for (const arm of issue.errors ?? []) out.push(...flatIssues(arm, at)); + } + return out; +}; + +const issuesOf = (schema: z.ZodType, doc: unknown) => { + const r = schema.safeParse(doc); + return r.success ? [] : flatIssues(r.error.issues as unknown as Issue[]); +}; + +const BOARD = { type: 'object-kanban', objectName: 'tasks', groupBy: 'status' } as const; + +/** The fixture `structuredKeysAreDeclaredAndHonoured-8313` row 4 authors. */ +const WELL_FORMED = { fields: [{ field: 'owner' }] }; + +const FACES = [ + ['the tolerant arm (ObjectKanbanSchema)', ObjectKanbanSchema], + ['the tolerant union (AnyComponentSchema)', AnyComponentSchema], + ['the strict authoring face (StrictAnyComponentSchema)', StrictAnyComponentSchema], +] as const; + +/** + * The shapes the card names as the spec row's refusals, each with the path and + * code the spec row gives it (measured on the installed release, below in + * block 3, rather than copied from its source). + */ +const REFUSED = [ + ['a padded field name', { fields: [{ field: ' x ' }] }, 'grouping.fields.0.field', 'custom'], + ['a bare string', 'owner', 'grouping', 'invalid_type'], + ['an empty `fields` list', { fields: [] }, 'grouping.fields', 'too_small'], + ['an undeclared key on the config', { fields: [{ field: 'owner' }], bogus11216: 1 }, 'grouping.bogus11216', 'unrecognized_keys'], + ['an undeclared key on a field entry', { fields: [{ field: 'owner', bogus11216: 1 }] }, 'grouping.fields.0.bogus11216', 'unrecognized_keys'], +] as const; + +describe('object-kanban grouping — a well-formed config parses (objectui#11216)', () => { + it.each(FACES)('%s accepts { fields: [{ field }] }', (_label, schema) => { + expect(issuesOf(schema, { ...BOARD, grouping: WELL_FORMED })).toEqual([]); + }); + + it.each(FACES)('%s accepts every member the spec declares (`field`, `order`, `collapsed`) on two entries', (_label, schema) => { + const grouping = { fields: [{ field: 'owner', order: 'desc', collapsed: true }, { field: 'region' }] }; + expect(issuesOf(schema, { ...BOARD, grouping })).toEqual([]); + }); + + it('the parsed document keeps the config AS AUTHORED: the spec defaults are not materialised', () => { + const r = ObjectKanbanSchema.safeParse({ ...BOARD, grouping: WELL_FORMED }); + expect(r.success && (r.data as Record).grouping).toEqual(WELL_FORMED); + }); + + it('CONTROL — the strict face still refuses a misspelled sibling by name, so it did not open up', () => { + expect(issuesOf(StrictAnyComponentSchema, { ...BOARD, grouping: WELL_FORMED, groupingx: WELL_FORMED })) + .toEqual([{ path: 'groupingx', code: 'unrecognized_keys' }]); + }); +}); + +describe('object-kanban grouping — the spec row refusals hold on both faces (objectui#11216)', () => { + const ROWS = FACES.flatMap(([label, schema]) => REFUSED.map(([what, value, path, code]) => [label, what, schema, value, path, code] as const)); + + it.each(ROWS)('%s refuses %s, at the key', (_label, _what, schema, value, path, code) => { + expect(issuesOf(schema, { ...BOARD, grouping: value })).toContainEqual({ path, code }); + }); + + it.each(REFUSED)('CONTROL — %s under an undeclared key is kept by the tolerant arm, so the refusal is the declaration', (_what, value) => { + expect(issuesOf(ObjectKanbanSchema, { ...BOARD, groupingUndeclared11216: value })).toEqual([]); + }); +}); + +describe('object-kanban grouping — the verdicts are the spec row\'s own (objectui#11216)', () => { + const specRow = (ComponentPropsMap['object-kanban'] as unknown as z.ZodObject>).shape.grouping; + + const CORPUS: ReadonlyArray = [ + ['well-formed', WELL_FORMED], + ['all members, two entries', { fields: [{ field: 'owner', order: 'asc', collapsed: false }, { field: 'region', order: 'desc' }] }], + ...REFUSED.map(([what, value]) => [what, value] as const), + ['a bad `order`', { fields: [{ field: 'owner', order: 'up' }] }], + ['a non-boolean `collapsed`', { fields: [{ field: 'owner', collapsed: 'yes' }] }], + ['a field entry with no `field`', { fields: [{ order: 'asc' }] }], + ['an array instead of the config', [{ field: 'owner' }]], + ]; + + it('the spec row is typed (non-vacuity: it refuses a bare string)', () => { + expect(specRow).toBeDefined(); + expect(specRow.safeParse('owner').success).toBe(false); + }); + + it.each(CORPUS)('%s: both faces return the spec row\'s verdict', (_what, value) => { + const spec = specRow.safeParse(value).success; + expect(ObjectKanbanSchema.safeParse({ ...BOARD, grouping: value }).success).toBe(spec); + expect(StrictAnyComponentSchema.safeParse({ ...BOARD, grouping: value }).success).toBe(spec); + }); +}); diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index e08318d8b6..9d326515ad 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -4798,11 +4798,24 @@ export interface ObjectKanbanSchema extends BaseSchema { * * Undeclared on both faces of this package until objectui#11355, so the read * reached it only through {@link BaseSchema}'s index signature. The same read - * falls back to `grouping.fields[0].field` when this key is absent. `grouping` - * is still undeclared here: aligning it with the spec's typed row is - * objectui#11216's. + * falls back to `grouping.fields[0].field` when this key is absent; see + * {@link ObjectKanbanSchema.grouping}. */ swimlaneField?: SpecObjectKanbanProps['swimlaneField']; + /** + * The FALLBACK source of {@link ObjectKanbanSchema.swimlaneField}: + * `ObjectKanban` reads `grouping.fields[0].field` when no `swimlaneField` is + * authored, and reads nothing else inside the block. + * + * `@objectstack/spec` types this key on the `object-kanban` row as the list + * view's own `GroupingConfigSchema`, by reference, so the type is the spec's + * `GroupingConfig` — the one `ObjectGridSchema.grouping` takes — and the zod + * mirror takes the same schema by reference (objectui#11216). Undeclared on + * both faces until then, so the read reached it only through + * {@link BaseSchema}'s index signature, and the strict authoring face refused + * a well-formed config by name. + */ + grouping?: GroupingConfig; /** * RETIRED (objectui#8285, ruling B of the director seat's decision batch #91, diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 3990a99631..4abab7e182 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -3292,9 +3292,19 @@ export const ObjectKanbanSchema = BaseSchema.extend({ cardFields: z.array(z.string()).optional().describe('Card fields'), // objectui#11355 — declared on both faces, as `@objectstack/spec`'s // `ComponentPropsMap['object-kanban']` row declares it (an optional string). - // Until then `.passthrough()` kept an authored value unjudged. `grouping`, the - // read's fallback, stays undeclared here: that is objectui#11216's. + // Until then `.passthrough()` kept an authored value unjudged. swimlaneField: z.string().optional().describe('Record field that splits the board into horizontal swimlanes, across the groupBy columns; when absent the board falls back to grouping.fields[0].field'), + // objectui#11216 — `swimlaneField`'s fallback, declared as the spec's + // `ComponentPropsMap['object-kanban']` row declares it: the list view's own + // `GroupingConfigSchema`, BY REFERENCE, spelled as `ObjectGridSchema.grouping` + // spells it. Until this card the key was undeclared here, so the strict face + // refused a well-formed config by name while the tolerant face kept any value + // unjudged — including the padded field name, bare string, empty `fields` + // list and undeclared inner key the spec row refuses. The import-boundary + // strip keeps the spec's `order` / `collapsed` defaults out of a parsed + // document. `ObjectKanban` reads `fields[0].field` and nothing else, which the + // registration's input description states. + grouping: stripImportedDefaults(SpecGroupingConfigSchema).optional().describe('Swimlane fallback: the spec GroupingConfig, by reference. The board reads grouping.fields[0].field as the swimlane field when swimlaneField is absent; every other position is inert on this board'), // objectui#8285 — RETIRED on this arm (ruling B, director seat decision batch // #91, 2026-09-08), aligned with `@objectstack/spec` 17.5.0, whose // `ComponentPropsMap['object-kanban']` tombstones the same key. A tombstone From 4b63113a546c327297faf5e01633a00f8eea64c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 18:23:57 +0000 Subject: [PATCH 2/2] =?UTF-8?q?docs(changeset):=20dated=20note=20on=201135?= =?UTF-8?q?5=20=E2=80=94=20grouping=20is=20now=20declared=20by=20objectui#?= =?UTF-8?q?11216?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pending 11355 changeset says `grouping` is "still undeclared"; it publishes in the same release as this branch's change, which declares it on both faces. Append a dated note in the 7664 form; the existing text and frontmatter are unchanged (the HEAD blob is a byte prefix of the new file). Claude-Session: https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2 Co-authored-by: Claude --- .changeset/11355-small-p1-sites.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.changeset/11355-small-p1-sites.md b/.changeset/11355-small-p1-sites.md index f8bcbe8442..3f3c4d77c4 100644 --- a/.changeset/11355-small-p1-sites.md +++ b/.changeset/11355-small-p1-sites.md @@ -17,3 +17,12 @@ Small reader sites stop riding `BaseSchema`'s index signature (objectui#11355, p - `@object-ui/plugin-map`: the dev warning about flat map keys that a `map` block shadows reads those keys through their own type, not through a `Record` conversion. **minor, not patch, for `@object-ui/types`.** Both members are new in the shipped `.d.ts` and in the zod mirror's `.shape`, and the mirror now refuses a wrongly typed value for each, where it used to keep one unjudged. + +⚠️ **Dated note, 2026-10-03 — `ObjectKanbanSchema.grouping` is now declared — objectui#11216.** +At this change `grouping`, the fallback `ObjectKanban` reads for `swimlaneField`, +was undeclared on both faces, as the first bullet above says. objectui#11216 +declares it on both faces in this release, by reference: the TypeScript member is +the spec's `GroupingConfig`, and the zod mirror takes the spec's +`GroupingConfigSchema`, the type the `object-kanban` row gives the key. So the +sentence above that calls `grouping` "still undeclared" does not hold in this +release. The rest of this entry is kept as the reading of this change.