diff --git a/.changeset/8990-object-kanban-groupby-optional.md b/.changeset/8990-object-kanban-groupby-optional.md new file mode 100644 index 0000000000..8468677cb3 --- /dev/null +++ b/.changeset/8990-object-kanban-groupby-optional.md @@ -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`. diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index d41791c631..59d3898fa2 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -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. | @@ -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) diff --git a/content/docs/plugins/plugin-kanban.mdx b/content/docs/plugins/plugin-kanban.mdx index 6a0a6e0529..79d72c9b81 100644 --- a/content/docs/plugins/plugin-kanban.mdx +++ b/content/docs/plugins/plugin-kanban.mdx @@ -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. - **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. ```plaintext diff --git a/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts b/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts index b2f28d71ad..7ac5a4c18b 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts @@ -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'; @@ -570,11 +572,15 @@ 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. * @@ -582,7 +588,7 @@ describe('page:accordion `title` / items `value` — dead designer inputs (#5212 * 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. */ @@ -659,10 +665,18 @@ 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' }), ); @@ -670,9 +684,10 @@ describe('object-kanban — the required `groupBy` control, and the retired `gro 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 @@ -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", () => { diff --git a/packages/plugin-kanban/README.md b/packages/plugin-kanban/README.md index 66d6b4a24e..afa9b8528f 100644 --- a/packages/plugin-kanban/README.md +++ b/packages/plugin-kanban/README.md @@ -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', @@ -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', @@ -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 @@ -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 diff --git a/packages/plugin-kanban/src/__tests__/laneLessBoard-8990.test.tsx b/packages/plugin-kanban/src/__tests__/laneLessBoard-8990.test.tsx new file mode 100644 index 0000000000..b1394965fb --- /dev/null +++ b/packages/plugin-kanban/src/__tests__/laneLessBoard-8990.test.tsx @@ -0,0 +1,351 @@ +/** + * 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#8990 — what a LANE-LESS `object-kanban` board actually does, now + * that `ObjectKanbanSchema.groupBy` is OPTIONAL on both published faces. + * + * ## Why this file exists + * + * The card is a WIDENING: `@objectstack/spec` declares + * `groupBy: z.string().optional()` and this repository required it, so both + * published faces refused a document the protocol accepts. The risk of a + * widening is not a compile break — it is a RUNTIME PATH THAT NEVER RAN. This + * file measures that path instead of asserting the declaration, which + * `packages/types/src/__tests__/object-kanban-group-by-limit-7322.test.ts` + * already does. + * + * ## ⭐ The arm this card unlocks, and its firing control + * + * `@objectstack/spec` declares `object-kanban.columns` as two array shapes — + * `{ id, title }` lanes, or bare value strings. The renderer honours the + * bare-string arm ONLY under `if (!schema.groupBy)` (`ObjectKanban.tsx`, the + * `effectiveColumns` memo). While `groupBy` was REQUIRED, no schema-valid + * document could reach that branch: objectui#8913 admitted the arm anyway + * (refusing it would have been a second narrowing) and recorded it as + * unreachable. Making `groupBy` optional is what makes it reachable BY A + * SCHEMA-VALID DOCUMENT — the qualifier matters, and the section headed "WHICH + * legs guard the widening" below is where it is measured: the RENDERER never + * consulted this package's validator, so the branch was always live for a + * document that reached it unvalidated. ⛔ Not "dead code that came alive". + * + * ⚠️ "The arm fires" is not observable from the lane COUNT alone — a grouped + * board draws two lanes too. It is observable from the lane TITLES, and that is + * this file's discriminator: + * + * - the string branch returns `{ id: val, title: val }` and never calls + * `localizeColumn`, so the lanes are titled by the RAW option VALUES; + * - the picklist branch a grouped board takes returns the option LABELS. + * + * The fixture object's `status` options are deliberately `todo -> 'To Do'` and + * `doing -> 'Doing'`, so the two paths are distinguishable on screen. The + * grouped board is the FIRING CONTROL: it proves the assertion can fail, and it + * proves the `!schema.groupBy` gate is still a gate rather than dead code. + * + * ## ⚠️ Reachable is NOT populated — the honest half of the result + * + * Every lane-less board holds ZERO cards, whatever its `columns`, because + * `bucketCardsIntoColumns` opens with + * `if (!data || !groupBy || !Array.isArray(data)) return columns.map(...)` — + * with no lane key the records are never distributed. So the widening admits + * documents that RENDER (lane headings, no crash), not documents that work + * better. ⛔ It is not an invitation to omit the key. + * + * ## ⚠️ WHICH legs guard the widening — measured by ablation, and not obvious + * + * Reverting `groupBy` to REQUIRED on both faces (source-only mutation, no + * rebuild; the `dist/` marker read both ways and unchanged, so the run was + * reading source through vitest's `@object-ui/types` alias) turns exactly the + * CONTRACT legs of this file red — `safeParse` / `safeValidateSchema` — and + * leaves every RENDER leg green. + * + * ⭐ That is a fact about the system, not a weakness of the pins: `SchemaRenderer` + * runs the structural `validateSchema`, never this package's zod mirror, so the + * renderer's bare-string branch was ALWAYS live for a document that reached it + * without passing the published validator — `ListView.tsx`'s generated node, or + * any host not running `os check`. What the requiredness made impossible was a + * SCHEMA-VALID document reaching that branch. ⇒ ⛔ Do not read the render legs + * below as guarding the declaration; they pin renderer BEHAVIOUR, and the + * contract legs above are what pin the widening. Deleting the contract legs + * would leave this file fully green against a re-narrowed schema. + * + * ## ⛔ objectui#8993 is NOT reached from here, and that is asserted + * + * objectui#8993 (`bucketCardsIntoColumns` double-buckets a non-string lane id) + * lives AFTER that early return — the `knownIds` set and the `__uncolumned__` + * lane below it. A lane-less board returns before reaching any of it, and the + * picklist-materialised lanes whose ids come straight from `opt.value` are + * built under `if (schema.groupBy && ...)`, a branch a lane-less board cannot + * enter. ⇒ objectui#8993's reachable set is UNCHANGED by this card: admitting + * documents cannot shrink it, and none of the documents newly admitted can reach + * the defect. ⛔ Not "narrowed" — the claim is that nothing was widened. ⛔ And + * nothing here fixes #8993; it is a renderer behaviour change with its own card. + */ + +import React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, waitFor, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { SchemaRendererProvider, SchemaRenderer } from '@object-ui/react'; +import { ObjectKanbanSchema } from '@object-ui/types/zod'; +import { safeValidateSchema } from '@object-ui/types/zod'; +import { bucketCardsIntoColumns, KANBAN_UNCOLUMNED_ID } from '../index'; +// Registers `object-kanban`. +import '../index'; +// The board renders inside `KanbanRenderer`'s `React.lazy` boundary; importing +// the chunk at module scope bills the cold transform to the import phase +// instead of racing a `waitFor` budget (objectui#3010). +import '../KanbanImpl'; + +/** + * ⭐ The option VALUES and LABELS differ on purpose — that difference is what + * tells the bare-string arm apart from the picklist arm on screen. + */ +const OBJECT_SCHEMA = { + name: 'task', + fields: { + id: { type: 'text' }, + name: { type: 'text', label: 'Name' }, + status: { + type: 'select', + label: 'Status', + options: [ + { value: 'todo', label: 'To Do' }, + { value: 'doing', label: 'Doing' }, + ], + }, + }, +}; + +const ROWS = [ + { id: 'r1', name: 'Card one', status: 'todo' }, + { id: 'r2', name: 'Card two', status: 'doing' }, +]; + +const LANE_VALUES = ['todo', 'doing']; +const LANE_LABELS = ['To Do', 'Doing']; + +function makeDataSource() { + return { + find: vi.fn().mockResolvedValue({ data: ROWS, total: ROWS.length }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue(OBJECT_SCHEMA), + } as any; +} + +/** + * The board-level live region (objectui#8827): `role="status" aria-live="polite"`, + * titled "No cards". It is painted ONLY once the records have SETTLED, never + * while they are in flight — which is what makes it a legitimate settle signal + * for a board that is expected to show nothing. + */ +const emptyState = () => document.querySelector('[role="status"][aria-live="polite"]'); + +/** + * ⚠️ RIG DISCIPLINE. A "no cards on screen" assertion is only a reading if the + * rows had a chance to arrive first; asserted too early it passes for the wrong + * reason, against a board that is merely still loading. The first cut of this + * file settled on the dnd live region, which the board renders IMMEDIATELY, and + * the grouped CONTROL leg caught it by failing — the rows had not landed yet. + * + * So every leg here settles on a signal that PROVES the query resolved: + * - `expectCards` waits for a row to appear; + * - `expectSettledEmpty` waits for the objectui#8827 empty state, which the + * board withholds until its records settle, and additionally asserts `find` + * was actually called. + */ +async function renderBoard(schema: Record) { + const dataSource = makeDataSource(); + const result = render( + + + , + ); + return { ...result, find: dataSource.find as ReturnType }; +} + +/** Settle on a board that is expected to end up showing NOTHING. */ +async function expectSettledEmpty(find: ReturnType) { + await waitFor(() => expect(emptyState()).not.toBeNull()); + expect(find, 'RIG SELF-CHECK: the board must actually have queried').toHaveBeenCalled(); +} + +/** Settle on a board that is expected to end up showing cards. */ +async function expectCards(container: HTMLElement, name: string) { + await waitFor(() => expect(container.textContent).toContain(name)); +} + +/** Lane headings as drawn, in DOM order. */ +function laneTitles(container: HTMLElement): string[] { + return Array.from(container.querySelectorAll('[data-slot="kanban-column-title"], h3, h4')) + .map((el) => (el.textContent ?? '').trim()) + .filter(Boolean); +} + +afterEach(cleanup); + +describe('objectui#8990 — the contract admits a lane-less board on both entry paths', () => { + const laneless = { type: 'object-kanban', objectName: 'task', columns: LANE_VALUES }; + + it('a bare-string `columns` board with no `groupBy` parses green', () => { + const r = ObjectKanbanSchema.safeParse(laneless); + expect(r.success, 'this is the document the protocol accepts and this repo refused').toBe(true); + expect(safeValidateSchema(laneless).success, 'the union entry path must agree').toBe(true); + }); + + it('CONTROL — the same board WITH `groupBy` still parses green', () => { + // Guards the direction: this card widened the accept set, it did not swap + // one refusal for another. + expect(ObjectKanbanSchema.safeParse({ ...laneless, groupBy: 'status' }).success).toBe(true); + }); +}); + +describe('objectui#8990 — the bare-string `columns` arm FIRES on a lane-less board', () => { + it('draws its lanes titled by the RAW strings', async () => { + const { container, find } = await renderBoard({ + type: 'object-kanban', + objectName: 'task', + columns: LANE_VALUES, + }); + await expectSettledEmpty(find); + + const titles = laneTitles(container); + for (const value of LANE_VALUES) { + expect(titles, `the string arm returns { id: val, title: val }; lane "${value}" must be drawn`) + .toContain(value); + } + // ⭐ The discriminator: raw VALUES, never the picklist LABELS. Were the + // string branch skipped and the picklist branch taken, these would appear. + for (const label of LANE_LABELS) { + expect(titles, `"${label}" is the picklist LABEL — a lane-less board never localizes`) + .not.toContain(label); + } + }); + + it('FIRING CONTROL — the very same `columns` with `groupBy` set draws the picklist LABELS instead', async () => { + // This is the leg that proves the assertion above can fail, and that + // `if (!schema.groupBy)` is a live gate: with a lane key the string arm is + // skipped entirely and `effectiveColumns` materialises lanes from + // `field.options`, titled by their labels. + const { container } = await renderBoard({ + type: 'object-kanban', + objectName: 'task', + groupBy: 'status', + columns: LANE_VALUES, + }); + await expectCards(container, 'Card one'); + + const titles = laneTitles(container); + for (const label of LANE_LABELS) { + expect(titles, `a GROUPED board takes the picklist branch; "${label}" must be drawn`) + .toContain(label); + } + expect(titles, 'and the raw values must NOT be the headings on this leg') + .not.toContain('todo'); + }); + + it('⚠️ reachable is not populated — a lane-less board holds ZERO cards, the grouped control holds them', async () => { + const laneLess = await renderBoard({ + type: 'object-kanban', + objectName: 'task', + columns: LANE_VALUES, + }); + // Settled, not merely early: the empty state is withheld until the records + // land, so reaching it means the rows had their chance and none was placed. + await expectSettledEmpty(laneLess.find); + for (const row of ROWS) { + expect( + laneLess.container.textContent, + 'with no lane key the bucketer returns before distributing records', + ).not.toContain(row.name); + } + cleanup(); + + // CONTROL — the same rows DO reach the board once a lane key exists, so the + // absence above is the missing key and not a broken fixture. ⭐ This leg is + // what caught the first cut of this file, which asserted before the query + // resolved. + const grouped = await renderBoard({ + type: 'object-kanban', + objectName: 'task', + groupBy: 'status', + columns: LANE_VALUES, + }); + await expectCards(grouped.container, 'Card one'); + for (const row of ROWS) { + expect(grouped.container.textContent, 'CONTROL: the fixture rows do render when grouped') + .toContain(row.name); + } + }); + + it('a lane-less board with NO `columns` renders an EMPTY board rather than crashing', async () => { + // ⚠️ This leg cannot settle on the objectui#8827 empty state: `KanbanImpl` + // gates it on `boardColumns.length > 1`, so a ZERO-lane board never paints + // it. (Pre-existing and independent of this card — the predicate does not + // read `groupBy`.) It settles on the board region instead, and takes its + // credibility from the paired control below, which shares the whole rig and + // differs only by the lane key. + const laneLess = await renderBoard({ type: 'object-kanban', objectName: 'task' }); + await waitFor(() => expect(laneLess.find).toHaveBeenCalled()); + await waitFor(() => + expect(laneLess.container.querySelector('[role="region"][aria-label="Kanban board"]')).not.toBeNull(), + ); + // `effectiveColumns` falls past all three of its `schema.groupBy &&` guards + // and returns `[]`. + expect(laneTitles(laneLess.container), 'no lane key and no declared lanes: no lanes').toEqual([]); + for (const row of ROWS) { + expect(laneLess.container.textContent).not.toContain(row.name); + } + cleanup(); + + // CONTROL — same schema plus the lane key. `effectiveColumns` now takes the + // picklist branch, materialises both lanes from `field.options`, and the + // rows land. This is what proves the rig above was connected: the lanes and + // the cards are absent for want of a lane key, not for want of a working + // fixture. + const grouped = await renderBoard({ type: 'object-kanban', objectName: 'task', groupBy: 'status' }); + await expectCards(grouped.container, 'Card one'); + expect(laneTitles(grouped.container)).toEqual(expect.arrayContaining(LANE_LABELS)); + }); +}); + +describe('objectui#8990 — ⛔ the lane-less path does NOT reach objectui#8993', () => { + const lanes = [ + { id: 'todo', title: 'To Do' }, + { id: 'doing', title: 'Doing' }, + ]; + + it('with no lane key the bucketer early-returns: every lane empty, and NO `__uncolumned__` lane', () => { + const out = bucketCardsIntoColumns(lanes, ROWS, undefined, undefined, 'Uncategorized'); + expect(out.map((c: any) => c.id)).toEqual(['todo', 'doing']); + expect(out.every((c: any) => c.cards.length === 0)).toBe(true); + // The `knownIds` / `__uncolumned__` block — and objectui#8993's + // double-bucketing with it — sits BELOW the `!groupBy` return. + expect(out.map((c: any) => c.id)).not.toContain(KANBAN_UNCOLUMNED_ID); + }); + + it('CONTROL — the same call WITH a lane key does distribute, and can reach the uncolumned lane', () => { + const bucketed = bucketCardsIntoColumns(lanes, ROWS, 'status', undefined, 'Uncategorized'); + expect(bucketed.find((c: any) => c.id === 'todo').cards).toHaveLength(1); + expect(bucketed.find((c: any) => c.id === 'doing').cards).toHaveLength(1); + + // An off-lane record reaches the trailing lane — the code path the + // lane-less leg above never enters. + const withOrphan = bucketCardsIntoColumns( + lanes, + [...ROWS, { id: 'r3', name: 'Orphan', status: 'done' }], + 'status', + undefined, + 'Uncategorized', + ); + expect(withOrphan.map((c: any) => c.id)).toContain(KANBAN_UNCOLUMNED_ID); + }); +}); diff --git a/packages/types/src/__tests__/object-kanban-group-by-limit-7322.test.ts b/packages/types/src/__tests__/object-kanban-group-by-limit-7322.test.ts index 024dcf5499..f5f8308325 100644 --- a/packages/types/src/__tests__/object-kanban-group-by-limit-7322.test.ts +++ b/packages/types/src/__tests__/object-kanban-group-by-limit-7322.test.ts @@ -8,6 +8,16 @@ * objectui#7322 — `ObjectKanbanSchema.groupBy` / `.limit` declared and * `.groupField` RETIRED, on both faces. * + * ⚠️ SUPERSEDED IN ONE RESPECT (objectui#8990): this card also made `groupBy` + * REQUIRED, and that half is reversed — `@objectstack/spec` declares + * `groupBy: z.string().optional()`, so requiring it made this package refuse a + * document the protocol accepts. The two pins that asserted requiredness now + * assert optionality and say so at their site. Everything else this file pins + * — that `groupBy`/`limit` are DECLARED and ENFORCED, that `groupField` is a + * tombstone with zero read sites, and the read-site census — is unchanged, and + * the CONTROL added beside the flipped pin is what keeps "optional" from + * quietly decaying into "undeclared". + * * ## The defect * * `packages/plugin-kanban/src/ObjectKanban.tsx` — the component the @@ -130,12 +140,17 @@ type IsAny = 0 extends (1 & T) ? true : false; /** An object with no keys is assignable to `Pick` only when `K` is optional on `T`. */ type IsOptional = Record extends Pick ? true : false; -// `groupBy`: declared `string`, REQUIRED, not `any`. Were the member removed -// the indexed access would fall back to the index signature and resolve to -// `any`, and `Equal` is false. -export type _GroupByIsString = Expect>; +// `groupBy`: declared `string`, OPTIONAL since objectui#8990, not `any`. Were +// the member removed the indexed access would fall back to the index signature +// and resolve to `any`, and `Equal` is false — so the +// DECLARED-ness this card pinned is still pinned; only its requiredness moved. +export type _GroupByIsString = Expect>; export type _GroupByIsNotAny = Expect, false>>; -export type _GroupByIsRequired = Expect, false>>; +// objectui#8990 — `@objectstack/spec` declares `groupBy: z.string().optional()`; +// requiring it here made this package narrower than the protocol. The lane-key +// facts this card measured are untouched: the renderer still reads `groupBy` +// and never `groupField`, which is what the read-site census below pins. +export type _GroupByIsOptional = Expect>; // `limit`: declared `number`, optional, not `any`. export type _LimitIsNumberOrUndefined = Expect>; export type _LimitIsNotAny = Expect, false>>; @@ -158,9 +173,11 @@ const literal: TsObjectKanbanSchema = { ...NODE, limit: 250 }; // the tombstone is deleted or widened back to `string`. // @ts-expect-error — `groupField` is RETIRED on this node (objectui#7322); author `groupBy` const retiredLiteral: TsObjectKanbanSchema = { ...NODE, groupField: 'stage' }; -// …and REFUSES a lane-less node (TS2741): `groupBy` is required, as the retired -// `groupField` was. Making it optional turns this directive unused. -// @ts-expect-error — `groupBy` is required: a board is a grouping of records by one field +// …and ACCEPTS a lane-less node since objectui#8990. This was a +// `@ts-expect-error` (TS2741) while `groupBy` was required; the protocol +// declares the key optional, and the renderer guards every read of it, so the +// node below is one this package must annotate rather than refuse. Restoring +// the requirement turns this line red. const lanelessLiteral: TsObjectKanbanSchema = { type: 'object-kanban', objectName: 'opportunity' }; /* ── Off-disk derivations ─────────────────────────────────────────────────── */ @@ -273,16 +290,32 @@ describe('objectui#7322 — the zod mirror declares `groupBy` and `limit`', () = expect(safeValidateSchema(NODE).success).toBe(true); }); - it('`groupBy` is REQUIRED: a lane-less node is refused AT `groupBy` on both entry paths', () => { - // The retired contract required a lane field too; this is the - // required-ness carried across, not a new constraint. + // objectui#8990 — this `it` asserted the opposite until that card: a lane-less + // node was refused AT `groupBy` on both entry paths. `@objectstack/spec` + // declares `groupBy` OPTIONAL, so the refusal made this package narrower than + // the protocol on a published key. The requiredness is what moved; every + // other fact objectui#7322 established is asserted unchanged above and below. + it('`groupBy` is OPTIONAL (objectui#8990): a lane-less node parses green on both entry paths', () => { const laneless = { type: 'object-kanban', objectName: 'opportunity' }; const r = ObjectKanbanSchema.safeParse(laneless); - expect(r.success).toBe(false); - if (!r.success) expect(issuePaths(r.error.issues as readonly Issue[])).toContain('groupBy'); + expect(r.success, 'the protocol accepts a lane-less board; so must this face').toBe(true); + if (r.success) expect((r.data as Record).groupBy).toBeUndefined(); const u = safeValidateSchema(laneless); - expect(u.success).toBe(false); - if (!u.success) expect(issuePaths(u.error.issues as readonly Issue[])).toContain('groupBy'); + expect(u.success, 'the union entry path must agree with the direct one').toBe(true); + }); + + // CONTROL for the pin above — the key is still DECLARED and still ENFORCED + // when present. A widening that lost the declaration (letting `groupBy` fall + // back to `BaseSchema`'s index signature) would keep the lane-less pin green + // while silently un-judging every authored value; this is what separates the + // two. The wrong-typed case is covered by the table below. + it('CONTROL — optional does not mean unjudged: an authored `groupBy` is still typed', () => { + const good = ObjectKanbanSchema.safeParse({ type: 'object-kanban', objectName: 'opportunity', groupBy: 'stage' }); + expect(good.success).toBe(true); + if (good.success) expect((good.data as Record).groupBy).toBe('stage'); + const bad = ObjectKanbanSchema.safeParse({ type: 'object-kanban', objectName: 'opportunity', groupBy: 42 }); + expect(bad.success, 'a non-string lane key is still refused').toBe(false); + if (!bad.success) expect(issuePaths(bad.error.issues as readonly Issue[])).toContain('groupBy'); }); it.each([ diff --git a/packages/types/src/__tests__/object-kanban-record-source-7780.test.ts b/packages/types/src/__tests__/object-kanban-record-source-7780.test.ts index 38b70c2c92..3108b12c1b 100644 --- a/packages/types/src/__tests__/object-kanban-record-source-7780.test.ts +++ b/packages/types/src/__tests__/object-kanban-record-source-7780.test.ts @@ -56,15 +56,27 @@ * thing — the requiredness of `objectName` — and this file fails if a later * change quietly adds a rung under the same heading. * - * ## ⛔ `groupBy` is untouched, and that is pinned too + * ## `groupBy` was untouched HERE — and was moved later, by objectui#8990 * * objectui#7322 / PR #7774 made `groupBy` the REQUIRED lane key. Its * requiredness measurement deliberately EXCLUDED two readings from counting as * a lane-less mode: the `dataSource` json fragment in * `content/docs/utilities/data-objectstack.mdx`, and `ListView.tsx`'s * runtime-generated node. A presence rule over `bind` / `data` / `objectName` - * must not incidentally overturn that, so the `groupBy` half of the vector is - * asserted here alongside the record-source half. + * must not incidentally overturn that, so the `groupBy` half of the vector was + * asserted here alongside the record-source half — as a CONTROL on this card's + * blast radius, not as an endorsement of the requiredness. + * + * ⚠️ objectui#8990 then overturned it deliberately: `@objectstack/spec` declares + * `groupBy: z.string().optional()`, so requiring it made this package refuse a + * document the protocol accepts. Those two excluded readings turned out to be + * the corpus evidence — a documented board and a node this repo's own + * `ListView` emits, both lane-less, both refused. The `groupBy` half of the + * vector below is therefore INVERTED, and it now carries the sharper assertion: + * a lane-less board is accepted, while a SOURCE-less one is still refused AT + * THE REFINEMENT. Keeping the refusal PATH in the assertion is what stops the + * record-source rule this card exists for from decaying into a bare + * `success === false` that any reason could satisfy. */ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; @@ -137,13 +149,19 @@ export const OBJECT_BOARD: TsObjectKanbanSchema = { }; /** - * `groupBy` stayed REQUIRED (objectui#7322). This is the ONE deliberate error - * in this file, and it is the control that keeps the widening from having - * reached the lane key: delete `groupBy`'s requiredness and `TS2578` (unused - * `@ts-expect-error`) reddens `tsconfig.test.json`. + * `groupBy` stayed REQUIRED at objectui#7780 (objectui#7322), and this was the + * ONE deliberate `@ts-expect-error` in this file — the control proving THIS + * card's widening had not reached the lane key. + * + * objectui#8990 moved the lane key deliberately, on its own card and for its + * own reason (`@objectstack/spec` declares `groupBy` optional; requiring it + * made this package narrower than the protocol). So the directive is gone and + * the annotation stands on its own — the node below is now accepted. What this + * file still controls is the RECORD-SOURCE half: see the refinement pins below, + * where a board with no `bind`/`data`/`objectName` is still refused, now at the + * refinement rather than at `groupBy`. */ -// @ts-expect-error — groupBy is still required on ObjectKanbanSchema (the one deliberate error here) -export const LANELESS_BOARD_STILL_REFUSED: TsObjectKanbanSchema = { +export const LANELESS_BOARD_NOW_ACCEPTED: TsObjectKanbanSchema = { type: 'object-kanban', objectName: 'task', }; @@ -204,37 +222,65 @@ describe('objectui#7780 — the four documents, through the member and the publi }); }); -describe('objectui#7780 — `groupBy` requiredness is NOT overturned (objectui#7322 / PR #7774)', () => { - it.each(DOCUMENT_NAMES)('%s without `groupBy` is still refused AT `groupBy`', (name) => { +// objectui#8990 — this block asserted the opposite: that `groupBy` requiredness +// survived objectui#7780. It did not survive objectui#8990, which moved it for +// its own reason. The block is kept, inverted, because its DOCUMENTS are the +// evidence that mattered: PR #7774 excluded two readings from its requiredness +// measurement, and both are real lane-less producers this package was refusing. +// +// ⭐ The two halves must not blur. A lane key and a record source are different +// requirements: dropping `groupBy` is now fine, dropping all of +// `bind`/`data`/`objectName` is still refused — AT THE REFINEMENT, not at +// `groupBy`. Asserting the refusal PATH is what keeps this honest; a bare +// `success === false` would have gone on passing for the wrong reason. +describe('objectui#8990 — `groupBy` requiredness IS overturned; the record-source rule is not', () => { + it.each(DOCUMENT_NAMES)('%s without `groupBy` now parses green', (name) => { const { groupBy: _dropped, ...rest } = DOCUMENTS[name]; void _dropped; const laneless = { type: 'object-kanban', ...rest }; const r = ObjectKanbanSchema.safeParse(laneless); - expect(r.success).toBe(false); - if (r.success) return; - expect(r.error.issues.map((i) => i.path[0])).toContain('groupBy'); - expect(safeValidateSchema(laneless).success).toBe(false); + // `none` carries no record source, so it is still refused — at the + // refinement. Every other document has one and must now be accepted. + if (name === 'none') { + expect(r.success).toBe(false); + if (!r.success) { + expect(r.error.issues.map((i) => i.path[0])).not.toContain('groupBy'); + expect(r.error.issues.map((i) => (i as unknown as { params?: { code?: string } }).params?.code)) + .toContain('RECORD_SOURCE_REQUIRED'); + } + return; + } + expect(r.success, `${name} has a record source and no lane key: the protocol accepts it`).toBe(true); + expect(safeValidateSchema(laneless).success).toBe(true); }); - it("PR #7774's two EXCLUDED readings stay refused — a record source is not a lane key", () => { + it("PR #7774's two EXCLUDED readings — the corpus evidence that the requiredness was wrong", () => { // The `dataSource` json fragment taught in // `content/docs/utilities/data-objectstack.mdx`. `dataSource` is not a rung // of this ladder (`ElementDataSourceGate` maps its `object` ONTO // `objectName` upstream of the node), so this document has no record source - // AND no lane key, and is refused for both. + // — and it is STILL refused for that, which is the record-source rule doing + // its job. What changed is that `groupBy` is no longer among the reasons. const fragment = { type: 'object-kanban', dataSource: { object: 'task', filter: { project: 'acme' } } }; const f = ObjectKanbanSchema.safeParse(fragment); expect(f.success).toBe(false); if (!f.success) { - expect(f.error.issues.map((i) => i.path[0])).toContain('groupBy'); + expect( + f.error.issues.map((i) => i.path[0]), + 'the lane key is no longer a reason to refuse this documented fragment', + ).not.toContain('groupBy'); + expect(f.error.issues.map((i) => (i as unknown as { params?: { code?: string } }).params?.code)) + .toContain('RECORD_SOURCE_REQUIRED'); } // `ListView.tsx`'s runtime-generated node, in the shape it emits when the // view declared no lane field: `groupBy: laneField` with `laneField` - // undefined. Still refused, at `groupBy`. + // undefined (`laneField = groupByField || groupField || + // detectStatusField(objectDef) || undefined`). It carries `objectName`, so + // it now parses green — this package no longer refuses a node its own + // sibling emits and its own renderer serves. const generated = { type: 'object-kanban', objectName: 'task', groupBy: undefined, cardFields: [] }; const g = ObjectKanbanSchema.safeParse(generated); - expect(g.success).toBe(false); - if (!g.success) expect(g.error.issues.map((i) => i.path[0])).toContain('groupBy'); + expect(g.success, "ListView's generated lane-less node must be annotatable").toBe(true); }); it('the retired `groupField` is still refused BY NAME — the tombstone is untouched', () => { @@ -248,7 +294,7 @@ describe('objectui#7780 — the member is still an object, and NO rung was added const shape = () => (ObjectKanbanSchema as unknown as { shape: Record { success: boolean } }> }).shape; - it('`.shape` is exposed, with `objectName` optional and `groupBy` not', () => { + it('`.shape` is exposed, with `objectName` and `groupBy` both optional (objectui#8990)', () => { // zod 4 attaches a refinement in place; had it wrapped the object, `.shape` // would be gone — `object-kanban-group-by-limit-7322.test.ts` reads it, and // the parity census in `zod-mirror-parity.test.ts` would read the pair as @@ -256,7 +302,11 @@ describe('objectui#7780 — the member is still an object, and NO rung was added expect(Object.keys(shape())).toEqual(expect.arrayContaining(['objectName', 'groupBy', 'groupField', 'limit'])); expect(shape().objectName.safeParse(undefined).success).toBe(true); expect(shape().objectName.safeParse(5).success).toBe(false); - expect(shape().groupBy.safeParse(undefined).success).toBe(false); + // objectui#8990 — was `false`. Optional on the shape, and still TYPED when + // present, which is the control that separates optional from undeclared. + expect(shape().groupBy.safeParse(undefined).success).toBe(true); + expect(shape().groupBy.safeParse('status').success).toBe(true); + expect(shape().groupBy.safeParse(5).success).toBe(false); }); it('⛔ NO `staticData` rung, and `data` / `bind` are INHERITED, not re-declared — objectui#7651 refused both', () => { diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 49a3b557a5..0fc888e327 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -2923,19 +2923,106 @@ export interface ObjectKanbanSchema extends BaseSchema { * * The lane key the `object-kanban` renderer actually reads — * `packages/plugin-kanban/src/ObjectKanban.tsx` reads `schema.groupBy` at - * thirteen sites: lane materialisation (`:601`, `:625`, `:640`), card moves - * (`:747`, `:865`) and their effect deps. Undeclared here until + * thirteen sites, in three clusters: the `effectiveColumns` memo (its + * `localizeColumn` guard, its bare-string branch, its picklist branch and its + * from-data branch), `persistCardMove`, and the `handleCardMove` callback — + * plus their effect deps. + * + * ⚠️ Anchored by SYMBOL, deliberately. The docblock this replaces carried six + * bare line addresses into that file and EVERY ONE had rotted by the time this + * card re-derived them — the same reads now sit some 370 lines lower. The + * numbers are not restated here even as history: a literal address in prose is + * the thing that rots, and `check:new-cross-file-line-citations` says the same + * in its own words — shifting an already-false address by a hunk delta only + * moves a wrong pointer somewhere else. Undeclared here until * objectui#7322, so an authored value reached the renderer only through * {@link BaseSchema}'s `[key: string]: any` — admitted, never examined. * - * REQUIRED, as the retired `groupField` was: a board is a grouping of records - * by one field. The renderer's `if (!schema.groupBy)` branches (`:601`, - * `:613`) are defensive early-returns, not a lane-less mode — every - * documented and tested `object-kanban` node authors this key, and the - * `dataSource` binding (`ElementDataSourceGate`) supplies `objectName`, - * never a lane key. - */ - groupBy: string; + * OPTIONAL since objectui#8990, matching `@objectstack/spec`, which declares + * `groupBy: z.string().optional()` on `ObjectKanbanPropsSchema` + * (`packages/spec/src/ui/component.zod.ts`, read at objectstack + * `eabdd66f45f402eba0f8404a8a9de4a501fc83a6`). It was REQUIRED here on both + * faces, so this package REFUSED A DOCUMENT THE PROTOCOL ACCEPTS — the one + * direction the maintainer principle in force forbids (2026-09-09, recorded + * verbatim and untranslated): + * 「我们的项目以 objectstack 协议为准,文档应该以实际实现为准。协议不正确的应该先修改协议。」 + * + * ## The requiredness was refuted by the corpus, not just by the protocol + * + * objectui#7322 justified it as "every documented and tested `object-kanban` + * node authors this key". Two lane-less producers were excluded from that + * count at objectui#7780 and neither has gone away: + * + * 1. `content/docs/utilities/data-objectstack.mdx` documents an + * `object-kanban` node that is exactly `{ type, dataSource }` — no + * `groupBy`. ⚠️ WEAKER THAN IT LOOKS, and the limit is worth stating: that + * fragment is STILL refused after this card, at `RECORD_SOURCE_REQUIRED` + * — `dataSource` is not a rung of this ladder — so it is evidence that a + * lane-less board is a DOCUMENTED AUTHORING, not a document this card + * admits. ⛔ Do not cite it as "objectui refuses its own documented + * example" without that qualifier; the record-source half of the refusal + * is deliberate and survives. + * 2. `packages/plugin-list/src/ListView.tsx` GENERATES the node with + * `groupBy: laneField`, where + * `laneField = groupByField || groupField || detectStatusField(objectDef) || undefined`. + * ⭐ This is the load-bearing one, and it is stronger than the explicit + * `|| undefined` alone suggests: `objectDef` loads ASYNCHRONOUSLY, so + * `laneField` is `undefined` on EVERY load until it lands — transiently + * for every list-view kanban — and PERSISTENTLY in two cases from + * `detectStatusField` (`packages/types/src/record-semantics.ts`): the + * object declares no `stageField` role AND carries no field named + * `status` / `stage` / `state` / `phase` and none typed `status`/`stage`; + * or it sets `stageField: false`, which suppresses detection outright + * (ADR-0085 — a status-shaped field that is not a linear flow). The + * renderer serves that node and both published faces refused it. + * + * ⇒ Evidence 2 carries this on its own; evidence 1 supports the authoring + * shape, not the accept set. Same shape as {@link objectName} (objectui#7780): + * a key the renderer guards at every read, declared REQUIRED, refusing boards + * that render today. + * + * ## What a lane-less board actually does — MEASURED, not argued + * + * The `if (!schema.groupBy)` branches are defensive early-returns and the + * board degrades rather than breaking. Rendered through `SchemaRenderer` with + * a real `dataSource` (pinned in + * `plugin-kanban/src/__tests__/laneLessBoard-8990.test.tsx`): + * + * - no `groupBy`, no `columns` -> `effectiveColumns` falls past all three + * of its `schema.groupBy &&` guards and returns `[]`: an empty board, no + * lanes, no crash. + * - no `groupBy`, bare-string `columns` -> the lanes are DRAWN, titled by + * the raw strings (see {@link columns}); this is the arm this card + * unlocks. + * - EVERY lane-less board holds ZERO cards, whatever its `columns`. + * `bucketCardsIntoColumns` opens with `if (!data || !groupBy || + * !Array.isArray(data)) return columns.map(...)`, so with no lane key the + * records are never distributed. A lane-less board is lane HEADINGS, not + * a populated board — which is the honest rendering of "no field places + * these records", and is why relaxing this key is not a licence to author + * it away. + * - card moves are inert, by the same guards: `persistCardMove` and the + * `handleCardMove` callback both open `if (!groupBy) return`, so nothing + * is written back. + * + * ⇒ The widening admits documents that RENDER; it does not admit documents + * that crash. ⛔ It is NOT an invitation to omit the key: a board that groups + * by nothing shows no cards. + * + * ## ⛔ What this does NOT reach — objectui#8993 + * + * `bucketCardsIntoColumns`'s double-bucketing of a non-string lane id lives + * AFTER that `!groupBy` early return (the `knownIds` set and the + * `__uncolumned__` lane below it). A lane-less board returns before reaching + * it, and the picklist-materialised lanes whose ids come straight from + * `opt.value` are built under `if (schema.groupBy && ...)` — a branch a + * lane-less board cannot enter. So objectui#8993's reachable set is UNCHANGED + * by this card — admitting documents cannot shrink it, and none of the + * documents newly admitted can reach the defect. ⛔ Not "narrowed": the + * containment is that nothing was widened. Measured both ways in the pin file + * above. + */ + groupBy?: string; /** * RETIRED (objectui#7322) — the lane key this node's renderer never read. * `ObjectKanban.tsx` reads {@link groupBy} thirteen times and `groupField` @@ -3039,15 +3126,38 @@ export interface ObjectKanbanSchema extends BaseSchema { * from those read sites too — see {@link id} for the one this card had to * correct. * - * ## ⚠️ The bare-string arm is admitted for PROTOCOL PARITY and is inert here + * ## ⭐ The bare-string arm is REACHABLE since objectui#8990 * * The `object-kanban` renderer honours a bare-string lane list only when no * `groupBy` is authored (`ObjectKanban.tsx`, the `effectiveColumns` memo: - * the string branch returns only under `if (!schema.groupBy)`). `groupBy` is - * REQUIRED on this face, so no document that passes this schema can reach - * that branch — the arm is admitted because refusing it would make objectui - * narrower than the protocol, not because it does anything. The requiredness - * itself is objectui#8990; ⛔ this member does not wait on it. + * the string branch returns only under `if (!schema.groupBy)`). While + * {@link groupBy} was REQUIRED, no document that passed this schema could + * reach that branch, and objectui#8913 admitted the arm anyway — refusing it + * would have made objectui narrower than the protocol — recording it as + * inert. objectui#8990 made {@link groupBy} OPTIONAL, which is what makes the + * arm reachable BY A SCHEMA-VALID DOCUMENT: a `groupBy`-less board with + * `columns: ['todo', 'doing']` now parses AND draws those two lanes, titled by + * the raw strings. + * + * ⚠️ The qualifier is load-bearing, and matches {@link groupBy}'s. The RENDERER + * never consulted this package's validator — `SchemaRenderer` runs core's + * structural `validateSchema`, which carries no kanban rule — so the string + * branch was always live for a document that reached it WITHOUT passing the + * published faces (`ListView.tsx`'s generated node; any host not running + * `os check`). What the requiredness prevented was a document being valid AND + * getting there. ⛔ Do not restate this as "the arm was dead code". + * + * ⚠️ RAW strings, not localized labels, and the difference is the arm's + * signature. The string branch returns `{ id: val, title: val }` and never + * calls `localizeColumn`; the picklist branch a GROUPED board takes returns + * the option LABELS. Measured on one object whose `status` options are + * `todo -> 'To Do'` / `doing -> 'Doing'`: lane-less draws `todo` / `doing`, + * the grouped control draws `To Do` / `Doing`. + * + * ⚠️ Reachable is not populated: every lane-less board holds ZERO cards, + * because `bucketCardsIntoColumns` early-returns before distributing records + * when there is no lane key. See {@link groupBy} for the full measured vector. + * Pinned in `plugin-kanban/src/__tests__/laneLessBoard-8990.test.tsx`. * * ⚠️ An undeclared lane key is ACCEPTED AND DROPPED from the parsed output, * not refused — the lane arm is a plain (non-passthrough) object, the same diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 2ae2b6947d..0ab6f55f9c 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -1216,10 +1216,16 @@ export const KanbanConditionalFormattingRuleSchema = z.union([ * The protocol names no mixed example. A declaration that admits a shape the * renderer mishandles is the defect this card removes, so it is refused here. * - * ⚠️ The STRING arm is admitted for protocol parity and is INERT on this face: - * the renderer's string branch returns only under `if (!schema.groupBy)`, and - * `groupBy` is required here. Its requiredness is objectui#8990; ⛔ this - * schema does not wait on that card. + * ⭐ The STRING arm is REACHABLE since objectui#8990. The renderer's string + * branch returns only under `if (!schema.groupBy)`, and `groupBy` was required + * on this face — so objectui#8913 admitted the arm (refusing it would have + * been a second narrowing) while recording that nothing could reach it. + * objectui#8990 made `groupBy` OPTIONAL, and a `groupBy`-less board with + * `columns: ['todo', 'doing']` now parses AND draws those two lanes, titled by + * the RAW strings rather than the picklist labels a grouped board would show. + * ⚠️ Reachable is not populated — a lane-less board holds zero cards, because + * `bucketCardsIntoColumns` returns before distributing records when there is + * no lane key. * * ⛔ NOT `KanbanColumnSchema`, whose `cards` is REQUIRED: that mirror is the * RUNTIME lane (`bucketCardsIntoColumns` fills `cards` before `KanbanImpl` @@ -1268,7 +1274,16 @@ function requireKanbanRecordSource( export const ObjectKanbanSchema = BaseSchema.extend({ type: z.literal('object-kanban'), objectName: z.string().optional().describe('ObjectQL object name — the LAST rung of the board ladder, after the pre-fetched data prop, bind and the inline row array on data; one of bind, data, objectName must be present (objectui#7780)'), - groupBy: z.string().describe('Field whose value places a record in a lane — the lane key the object-kanban renderer reads (ObjectKanban.tsx, thirteen sites); required, as the retired groupField was'), + // objectui#8990 — OPTIONAL, mirroring `@objectstack/spec` + // (`ObjectKanbanPropsSchema.groupBy` is `z.string().optional()`). Required + // here until this card, so this validator refused a document the protocol + // accepts — including this repo's own documented `{ type, dataSource }` + // board (`content/docs/utilities/data-objectstack.mdx`) and the node + // `plugin-list/src/ListView.tsx` generates when a view declares no lane + // field (`groupBy: laneField`, `laneField = … || undefined`). Requiredness + // is the ONLY thing that moved; the measured behaviour of a lane-less board + // (lanes drawn, ZERO cards, moves inert) is reasoned on the TS twin. + groupBy: z.string().optional().describe('Field whose value places a record in a lane — the lane key the object-kanban renderer reads (ObjectKanban.tsx, thirteen sites). Optional, as @objectstack/spec declares it: a board with no lane key renders its declared lanes and holds no cards, since the bucketer needs a key to distribute records'), groupField: retirementTombstone('RETIRED (objectui#7322) — `groupField` is not read by the object-kanban renderer; author `groupBy`. (The view-level `kanban.groupField` alias is unaffected.)'), // objectui#8913 — the lane vocabulary the renderer reads and neither face // named. Mirrors `../objectql.ts` member for member; the element union, the