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
49 changes: 49 additions & 0 deletions .changeset/8990-object-kanban-groupby-optional.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
'@object-ui/types': minor
---

`ObjectKanbanSchema.groupBy` is now OPTIONAL on both published faces (objectui#8990).

`@objectstack/spec` declares the key optional — `groupBy: z.string().optional()` on
`ObjectKanbanPropsSchema` — while this package required it on the TypeScript
declaration (`packages/types/src/objectql.ts`) and on the Zod mirror
(`packages/types/src/zod/objectql.zod.ts`). objectui was therefore **narrower than the
protocol** on a published key: `ObjectKanbanSchema.safeParse` and `safeValidateSchema`
refused an `object-kanban` node the protocol accepts, and such a node could not be
annotated with its own type.

**This is a widening: nothing that validated before stops validating.** An authored
`groupBy` is still typed and still enforced — a non-string lane key is refused exactly
as it was. Only its absence is newly admitted.

The requiredness was refuted by this repository's own corpus, not only by the protocol.
objectui#7322 justified it as "every documented and tested `object-kanban` node authors
this key", and objectui#7780 recorded two producers excluded from that count; both are
still live:

- `packages/plugin-list/src/ListView.tsx` **generates** the node as
`groupBy: laneField`. `objectDef` loads asynchronously, so `laneField` is `undefined`
on every load until it lands, and stays `undefined` whenever the object offers no
`stageField` hint and none of `status` / `stage` / `state` / `phase`. The renderer
serves that node; both published faces refused it.
- `content/docs/utilities/data-objectstack.mdx` documents an `object-kanban` node that
is exactly `{ type, dataSource }`, with no `groupBy`. ⚠️ This one is weaker and is
cited for what it is: that fragment is **still** refused after this change, at
`RECORD_SOURCE_REQUIRED`, because `dataSource` is not a rung of the record-source
ladder. It shows a lane-less board is a documented authoring; it is not a document
this change admits.

**What a lane-less board does, measured rather than assumed.** Every `schema.groupBy`
read in `ObjectKanban.tsx` is a guarded early-return, so the board degrades instead of
breaking: with no lane key and no `columns` it renders an empty board; with bare-string
`columns` it draws those lanes, titled by the raw strings; card moves are inert
(`persistCardMove` and the move callback both open `if (!groupBy) return`). ⚠️ Every
lane-less board holds **zero cards** — `bucketCardsIntoColumns` returns before
distributing records when there is no lane key — so omitting `groupBy` is not a way to
configure a board, it is a board that groups by nothing.

**Side effect worth knowing.** The protocol's bare-string `columns` arm, admitted by
objectui#8913 and recorded there as unreachable, is now reachable: it fires only under
`if (!schema.groupBy)`, which no schema-valid document could satisfy while the key was
required. Pinned with a firing control in
`packages/plugin-kanban/src/__tests__/laneLessBoard-8990.test.tsx`.
6 changes: 3 additions & 3 deletions content/docs/api/schema-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -932,7 +932,7 @@ A drag-and-drop Kanban board. The `object-kanban` type key validates the shape t
| Property | Type | Description |
|----------|------|-------------|
| `objectName` | `string` | Object to fetch records from. |
| `groupBy` | `string` | **Required.** Field whose values become the lanes (maps to column ids). |
| `groupBy` | `string` | Field whose values become the lanes (maps to column ids). **Optional** since objectui#8990, matching `@objectstack/spec`. A board that omits it draws whatever lanes `columns` declares — and holds **no cards**, because records are only distributed once a lane key exists. |
| `columns` | `string[] \| KanbanLane[]` | Swimlane definitions — an array of `{ id, title }` lanes (one per `groupBy` value), **or** an array of bare value strings; never a mix. **Not** a field projection (that is `cardFields`). A lane's `cards` is optional: an object-bound board buckets records into the lane by `groupBy`, and only a static board writes a lane's cards itself. |
| `titleField` | `string` | Field used as the card title. |
| `cardFields` | `string[]` | Fields rendered on each card. |
Expand All @@ -947,11 +947,11 @@ A drag-and-drop Kanban board. The `object-kanban` type key validates the shape t

> `columns` is declared on this face since objectui#8913, as the pair of array shapes `@objectstack/spec` declares — an array of `{ id, title }` lanes, **or** an array of bare value strings. A **mixed** array is refused: the renderer decides which shape it has from the first element alone, so a mix yields a blank lane and mis-bucketed cards. A lane accepts `id`, `title`, `cards`, `limit`, `className` and `collapsed`, which are the members the board implementations read; `id` is a **string** — a non-string lane id makes the board render every card twice, once in its lane and once in "Uncategorized" (objectui#8993). When a lane carries `cards`, each card is judged — a card with no `title` is refused. An undeclared lane key is accepted and dropped, not refused, which is this tolerant face's posture; the strict authoring face refuses it by name.
>
> ⚠️ The **bare-string array is accepted but inert on this block.** 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`, and `groupBy` is required here — so on `object-kanban` the strings are always ignored and the lanes come from the group field's picklist options or from the data. Write the `{ id, title }` array to control the lanes. The requiredness of `groupBy` is tracked as objectui#8990.
> ⚠️ 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.
>
> The other keys the retired `kanban` arm alone declared — `cardTitle`, `swimlaneField`, `grouping` and `navigation` — are still undeclared on this face. The renderer reads them, so a board may carry them; they are simply not judged. The board's React host supplies `onCardMove` / `onCardClick` / `onQuickAdd` as props; none of the three is authorable in JSON.

> `data` and `bind` are [`BaseSchema`](#baseschema) members, not narrowed here, but this face requires **one of** `bind`, `data`, `objectName` — the renderer's own record-source ladder (an external `data` prop → `bind` via `useDataScope` → this schema's own `data` → a fetch keyed by `objectName`). A purely static board (lanes carrying their own cards, no record source) authors `"groupBy"` and `"data": []`.
> `data` and `bind` are [`BaseSchema`](#baseschema) members, not narrowed here, but this face requires **one of** `bind`, `data`, `objectName` — the renderer's own record-source ladder (an external `data` prop → `bind` via `useDataScope` → this schema's own `data` → a fetch keyed by `objectName`). A purely static board (lanes carrying their own cards, no record source) authors `"groupBy"` and `"data": []`. ⚠️ The record-source rule is **separate** from the lane key and is unaffected by objectui#8990: omitting `groupBy` is fine, omitting all of `bind` / `data` / `objectName` is still refused, at the refinement rather than at `groupBy`.

**Related:** [ObjectViewSchema](#objectviewschema), [ObjectGridSchema](#objectgridschema)

Expand Down
18 changes: 12 additions & 6 deletions content/docs/plugins/plugin-kanban.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -94,13 +94,19 @@ and only a static board writes a lane's cards itself. Both faces of
validated: a card with no `title` is refused.

<Callout type="warn">
**The bare-string array is accepted but has no effect on `object-kanban`.** It is
**The bare-string array applies only to a board with no `groupBy`.** It is
declared so this package does not refuse an authoring `@objectstack/spec` allows.
The renderer only reads a bare-string lane list when a board has **no** `groupBy`
— and `groupBy` is required on `object-kanban`, so on this block the strings are
always ignored and the lanes come from the group field's picklist options or from
the data. Write the `{ id, title }` array to control the lanes. The requiredness of
`groupBy` is tracked as objectui#8990.
The renderer only reads a bare-string lane list when a board has **no** `groupBy`,
so on a board that declares 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** (matching `@objectstack/spec`), a
lane-less board is a valid authoring and this arm is live on it: the lanes are
drawn, titled by the **raw strings** — where a grouped board titles its lanes with
the picklist **labels**. ⚠️ A lane-less board holds **no cards**: with no lane key
the records are never distributed into lanes, 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.
</Callout>

```plaintext
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ import {
shapeMemberTypeName,
} from '@object-ui/test-support';
// The NODE face of the block vocabulary — `object-kanban`'s own schema, where
// objectui#7322 declared `groupBy` REQUIRED and tombstoned `groupField`. The
// objectui#7322 declared `groupBy` and tombstoned `groupField` (that card also
// made `groupBy` REQUIRED; objectui#8990 made it OPTIONAL again, to match the
// protocol — it is still DECLARED and still typed). The
// spec imports above cover the PAGE face; the two are different contracts and
// this file now reads both.
import { ObjectKanbanSchema } from '@object-ui/types/zod';
Expand Down Expand Up @@ -570,19 +572,23 @@ describe('page:accordion `title` / items `value` — dead designer inputs (#5212
* the same query, so that zero is a reading) and which objectui#7322
* retired BY NAME — `retirementTombstone()` on the zod face, `?: never` on
* the TS one;
* - `groupBy`, which the same card declared REQUIRED, had no control at all.
* - `groupBy`, which the same card declared (REQUIRED then; OPTIONAL since
* objectui#8990, which aligned it with `@objectstack/spec`), had no control
* at all.
*
* So the panel stably emitted a node that was missing a required key AND
* carrying a name-retired one, and rendered a board that grouped nothing with
* no diagnostic anywhere. `limit` is the third declared key it never offered:
* So the panel stably emitted a node that carried a name-retired key and no
* lane key at all, and rendered a board that grouped nothing with no diagnostic
* anywhere. ⚠️ Under today's contract the missing lane key is no longer itself a
* refusal — which is precisely why the CONTROL that the panel offers a `groupBy`
* box matters more than it did: the schema no longer backstops its absence. `limit` is the third declared key it never offered:
* `ObjectKanban.tsx` sends it as a real `$top`, so a board over
* `DEFAULT_KANBAN_LIMIT` records was silently truncated with no way to widen it.
*
* The parse probes below are the instrument this surface otherwise lacks: they
* read the CONTRACT rather than a spelling, so the next control added here is
* measured against the schema instead of against a reviewer's memory.
*/
describe('object-kanban — the required `groupBy` control, and the retired `groupField` (objectui#7772)', () => {
describe('object-kanban — the `groupBy` control, and the retired `groupField` (objectui#7772)', () => {
const fieldNames = () => BLOCK_CONFIG['object-kanban'].map((f) => f.name);

/** A block node as the canvas hoists it: `properties.*` at the top level. */
Expand Down Expand Up @@ -659,20 +665,29 @@ describe('object-kanban — the required `groupBy` control, and the retired `gro

// FALSIFICATION for the probe above — the pre-fix control set, verbatim. A
// green "it parses" means nothing unless the node this panel used to emit
// goes red, and it must go red TWICE: once for the key that is missing and
// once for the key that is refused by name. Either issue alone would be a
// different, smaller defect.
it('the node the pre-fix panel emitted is refused twice — missing `groupBy`, named `groupField`', () => {
// goes red.
//
// ⚠️ It used to go red TWICE — once for the MISSING `groupBy` and once for the
// name-retired `groupField`. objectui#8990 made `groupBy` OPTIONAL on both
// faces (`@objectstack/spec` declares it optional, and requiring it made this
// repository narrower than the protocol), so the first half is gone: an
// absent lane key is no longer a refusal anywhere. The falsification still
// holds on the half that was always the sharper one — the retired key refused
// BY NAME — and it is now asserted as the SOLE issue, which is a strictly
// tighter statement than the old two-key set: it fails if a future change
// either stops refusing `groupField` or starts refusing something else here.
it('the node the pre-fix panel emitted is still refused — at the name-retired `groupField`', () => {
const result = ObjectKanbanSchema.safeParse(
nodeFrom({ objectName: 'opportunity', groupField: 'stage', titleField: 'name' }),
);
expect(result.success).toBe(false);
const byPath = Object.fromEntries(
(result.error?.issues ?? []).map((i) => [i.path.join('.'), i]),
);
expect(Object.keys(byPath).sort()).toEqual(['groupBy', 'groupField']);
// The required key, absent.
expect(byPath.groupBy.code).toBe('invalid_type');
// objectui#8990 — was `['groupBy', 'groupField']`. The absent lane key is no
// longer among the reasons, and asserting the WHOLE key set is what records
// that rather than letting it pass unnoticed.
expect(Object.keys(byPath).sort()).toEqual(['groupField']);
// The retired key, refused BY NAME — the tombstone's guidance reaches the
// author verbatim, which is the whole point of `retirementTombstone()` over
// a silent strip. Asserted on the message because `invalid_type` alone
Expand All @@ -681,6 +696,18 @@ describe('object-kanban — the required `groupBy` control, and the retired `gro
expect(byPath.groupField.message).toContain('author `groupBy`');
});

// CONTROL for the pin above — `groupBy` being optional must not be mistaken
// for `groupBy` being unjudged. The panel's own control writes a string, and
// a non-string still fails.
it('CONTROL — an optional `groupBy` is still TYPED when the panel writes one', () => {
expect(
ObjectKanbanSchema.safeParse(nodeFrom({ objectName: 'opportunity', groupBy: 'stage' })).success,
).toBe(true);
const bad = ObjectKanbanSchema.safeParse(nodeFrom({ objectName: 'opportunity', groupBy: 42 }));
expect(bad.success).toBe(false);
expect((bad.error?.issues ?? []).map((i) => i.path.join('.'))).toContain('groupBy');
});

/* ── the placeholder states the real default ──────────────────────────── */

it("`limit`'s placeholder is DEFAULT_KANBAN_LIMIT, read from the renderer", () => {
Expand Down
25 changes: 16 additions & 9 deletions packages/plugin-kanban/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,10 @@ import '@object-ui/plugin-kanban';
// `object-kanban`. (The STORED `NamedListView.type` value `"kanban"` is a
// different layer and is unaffected — do not rewrite saved views.)
//
// `groupBy` and ONE record source (`data`, `bind` or `objectName`) are what the
// surviving face requires of every board; the shape below is the one the
// catalog fixture `plugin-kanban/basic-kanban-board.json` carries.
// ONE record source (`data`, `bind` or `objectName`) is what the surviving face
// requires of every board; `groupBy` is OPTIONAL since objectui#8990 but is what
// makes the lanes hold cards, so every working board authors it. The shape below
// is the one the catalog fixture `plugin-kanban/basic-kanban-board.json` carries.
const schema = {
type: 'object-kanban',
groupBy: 'status',
Expand Down Expand Up @@ -106,8 +107,9 @@ const column: KanbanColumn = {
};

// ⚠️ `object-kanban`: the bare `kanban` node type key and its `KanbanSchema`
// arm RETIRED in objectui#8802. `groupBy` and one of `bind` / `data` /
// `objectName` are what the surviving face requires of every board.
// arm RETIRED in objectui#8802. One of `bind` / `data` / `objectName` is what the
// surviving face requires of every board; `groupBy` is optional since
// objectui#8990, and authored here because a board without it holds no cards.
const schema: ObjectKanbanSchema = {
type: 'object-kanban',
groupBy: 'status',
Expand All @@ -123,9 +125,14 @@ import type { ObjectKanbanSchema } from '@object-ui/types';

declare const columns: KanbanColumn[];

// The board document. `type` and `groupBy` are required, and so is ONE record
// source — `bind`, `data` or `objectName`. `columns` and `className` are
// optional.
// The board document. `type` is required, and so is ONE record source — `bind`,
// `data` or `objectName`. `groupBy`, `columns` and `className` are optional.
//
// `groupBy` is OPTIONAL since objectui#8990, matching `@objectstack/spec`. It is
// still the key that makes the board work: with no lane key the records are never
// distributed, so a lane-less board draws whatever lanes `columns` declares and
// holds NO cards, and dragging a card writes nothing back. Omitting it is valid,
// not useful — author it on any board meant to group records.
//
// ⚠️ `onCardMove` is NOT a document key: it is a React prop the host supplies
// (JSON has no function value), which is why it is spelled with explicit
Expand All @@ -136,7 +143,7 @@ declare const columns: KanbanColumn[];
// not what catches a misspelt board key.
const board: ObjectKanbanSchema = {
type: 'object-kanban',
groupBy: 'status', // required — the field that makes the lanes
groupBy: 'status', // optional, but the field that makes the lanes
data: [], // one record source is required
columns, // Array of columns
className: 'h-full', // Tailwind classes
Expand Down
Loading
Loading