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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/11216-kanban-grouping-typed.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions .changeset/11355-small-p1-sites.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -3018,7 +3018,7 @@ const MEMBER_PINS: Record<string, MemberPin> = {
},
'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',
Expand Down
3 changes: 2 additions & 1 deletion content/docs/api/schema-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand All @@ -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.

Expand Down
1 change: 1 addition & 0 deletions content/docs/plugins/plugin-kanban.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import {
ListViewSchema,
ObjectGridBlockSchema,
ObjectGallerySchema,
ObjectKanbanSchema,
ObjectViewSchema,
StrictAnyComponentSchema,
safeValidateSchema,
Expand Down Expand Up @@ -148,6 +149,15 @@ const DECLARING: Readonly<Record<string, DeclaringArm>> = {
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'],
},
};

/**
Expand Down
Loading
Loading