diff --git a/.changeset/8802-8257-8008-kanban-gantt-family-retirement.md b/.changeset/8802-8257-8008-kanban-gantt-family-retirement.md new file mode 100644 index 0000000000..dc9d713057 --- /dev/null +++ b/.changeset/8802-8257-8008-kanban-gantt-family-retirement.md @@ -0,0 +1,144 @@ +--- +'@object-ui/types': minor +'@object-ui/plugin-kanban': minor +'@object-ui/plugin-gantt': minor +'@object-ui/components': patch +'@object-ui/cli': patch +'@object-ui/console': patch +'@object-ui/runner': patch +'@object-ui/sdui-parser': minor +--- + +Four node type keys retire, and the kanban and gantt families converge on their +`object-*` spellings: `kanban` (objectui#8802), `kanban-ui` and `kanban-enhanced` +(objectui#8257), and `gantt` (objectui#8008). All four were ruled by the +maintainer in one batch on 2026-09-09. + +**⛔ No stored document moves.** The strings `kanban` and `gantt` name two +different things at two different layers, and only one of them is retiring: + +| layer | value | who writes it | retired? | +| --- | --- | --- | --- | +| stored `NamedListView.type` | `"kanban"`, `"gantt"` | `CreateViewDialog`, persisted per tenant | **no — untouched** | +| node type key | `kanban`, `gantt` | hand-authored JSON | **yes** | + +`ObjectView`'s `switch (viewType)` maps a stored view type onto the node type it +renders, and it already emitted `object-kanban` and `object-gantt` — as it does +for all twelve stored view types. So every kanban and gantt view any user ever +created through the console already renders through the surviving spelling. +Nothing in a tenant database changes, and ⛔ nothing should be migrated there. + +**What each retirement was, measured.** Three of the four were +registration-only: no schema face in `@object-ui/types` ever declared +`kanban-ui`, `kanban-enhanced` or `gantt` as a component node type, so +unregistering is the whole retirement. The bare `kanban` key was the exception — +it had a declared arm on both faces (`KanbanSchema` in `complex.ts` and its Zod +mirror), and a plain deletion there would have been the objectui#7664 failure: +`BaseSchema` is `.passthrough()`, so a document naming a dropped key validates +green and renders nothing. It therefore retires as a **named refusal**: the Zod +union keeps an arm claiming the literal and answers a `{ "type": "kanban" }` +document with a message naming `object-kanban` as the remedy, while the +TypeScript half is the absence of the arm from `ComplexSchema` and of the key +from `SchemaRegistry`, so `tsc` refuses it at the authoring site. + +**⭐ This closes objectui#8818's `objectFields` hole — for that ENTRY, not for +the class.** `SchemaRenderer` strips a fixed enumerated metadata list and +spreads the rest as React props; `objectFields` is not on that list, and +`KanbanRenderer` — the component the `kanban-ui` key resolved to — declares +`objectFields` as a real prop, so an authored value reached the predicate layer +with no schema face judging it. With the registration gone, no authored node +reaches that component through the registry. ⚠️ The **class** is still open: the +hole returns the moment another registered renderer declares an `objectFields` +prop. objectui#8818's option (a) — stripping at the `SchemaRenderer` boundary — +is what would close the class. + +**⚠️ What the `kanban` arm took with it, stated because it is the cost of this +change.** That arm was the only schema face that ever declared `columns`, +`cardTitle`, `swimlaneField`, `grouping` and `navigation`, the only one that +refused `allowCollapse` / `cardTemplates` / `columnWidths` / `titleField` / +`draggable` / `onColumnAdd` / `onCardAdd` by name, and — through +`columns: KanbanColumn[]` — the only one that judged a lane's `cards` +(objectui#6939). The surviving `ObjectKanbanSchema` face declares none of them. +⛔ Nothing about an `object-kanban` document changes: it was never judged by the +`kanban` arm, so all of those keys have always ridden `BaseSchema`'s index +signature there. What is gone is the `kanban` document that had them. Declaring +them on `ObjectKanbanSchema` would WIDEN a published accept set, which is a +maintainer ruling and not part of this one; every one of these readings is +pinned where it can be seen rather than left to be rediscovered. + +**Migrating.** Replace `"type": "kanban"` with `"type": "object-kanban"` and +`"type": "gantt"` with `"type": "object-gantt"` in hand-authored documents. The +`object-kanban` face requires `groupBy` and one of `bind` / `data` / +`objectName`; a purely static board (lanes carrying their own cards, no record +source) adds `"groupBy"` and `"data": []`. `kanban-ui` and `kanban-enhanced` +have no authored documents anywhere in this repository to migrate. + +**⚠️ The namespaced spellings retire with the registrations — `view:kanban` and +`view:gantt` are the same two keys.** `ComponentRegistry.register(type, C, +{ namespace })` stores BOTH `namespace:type` and a bare-`type` fallback, so +every one of these keys had a namespaced twin that goes with it: + +| retired spelling | namespaced twin | author instead | +| --- | --- | --- | +| `kanban` | `view:kanban` | `object-kanban` | +| `gantt` | `view:gantt` | `object-gantt` | +| `kanban-ui` | `plugin-kanban:kanban-ui` | `object-kanban` | +| `kanban-enhanced` | `plugin-kanban:kanban-enhanced` | `object-kanban` | + +Both spellings are pinned as gone, each against a firing control on the +surviving key, in `plugin-kanban/src/__tests__/kanban-family-registry-keys-retired-8257.test.ts` +and `plugin-gantt/src/__tests__/bare-gantt-node-key-retired-8008.test.ts`. + +**What an unmigrated `view:kanban` / `view:gantt` node now renders depends on +the host.** In `apps/console` it renders the protocol **placeholder** panel, not +the OBJUI-001 "Unknown component type" error: the console calls the opt-in +`registerPlaceholders()` (`@object-ui/components`, `renderers/placeholders.tsx`) +*after* its plugin registrations, `view:kanban` and `view:gantt` are both in +that file's `PROTOCOL_COMPONENTS` list, and the placeholder only claims a key +nothing else has taken — which, until this change, `@object-ui/plugin-kanban` +and `@object-ui/plugin-gantt` had. In every other host, which does not call that +bootstrap, the same node renders OBJUI-001. + +**⚠️ `objectui check` will NOT flag either namespaced spelling.** The CLI's +`known-schema-types.ts` is generated from the repository's real registration +calls, and the placeholder registration is a real one — so `view:kanban` and +`view:gantt` are still on that list and still validate green, while the node +renders a placeholder rather than a board. The bare `kanban` / `gantt` entries +DID leave the generated list; only the namespaced pair survives, and only +because of the placeholder. Grep your documents for the namespaced spellings +directly; do not rely on `objectui check` to find them. + +`KanbanRenderer` is still exported from this package's entry point +(`@object-ui/plugin-kanban`); only its registry key is gone. ⚠️ `KanbanEnhanced` +is a different case, and the earlier draft of this note stated it wrongly: this +package's `exports` map has exactly two entries — `.` and `./style.css` — and +the barrel never re-exported the component, so +`@object-ui/plugin-kanban/KanbanEnhanced` has never been a resolvable specifier +for a consumer. With `kanban-enhanced` unregistered, `KanbanEnhanced.tsx` has +zero non-test importers. ⛔ The file is deliberately left in place: deleting +published-but-unreachable source is a further narrowing and needs its own +maintainer ruling, which this change does not have. + +**⚠️ `@object-ui/sdui-parser`: `QUICK_ADD_HOST_TYPES` loses `kanban` with the +registration.** The `inert-quick-add` diagnostic (objectui#8285) named the two +tags `ObjectKanbanRenderer` answered to; one of them retires here, so the set is +now `{ 'object-kanban' }`. ⛔ Nothing is silently dropped by that narrowing, and +this is measured rather than argued: `checkKanbanQuickAdd` has exactly one call +site — `validate.ts`'s per-prop walk — and that walk runs only in the branch +where the manifest RESOLVED the tag. A tag no registration produces is answered +one level up by `unknown-component`, an **error**, and its props are never +walked, so on a manifest built from the live registry a `` node +draws `error/unknown-component` and nothing else, against a firing control on +`` that still draws `warning/inert-quick-add`. Keeping +`kanban` in the set would have been reachable only through a hand-built manifest +declaring a component of that name — which, after this retirement, is somebody +else's component, and the message asserts things about `ObjectKanban` that would +be false of it. This supersedes the `kanban` half of the objectui#8285 entry. + +**The diagnostic's remedy text moves from a tag to a component.** It used to end +"render `` from a React host that passes `onQuickAdd`". That sentence +is falsified by this change: `kanban-ui` is no longer a node type key, so a page +written to the old advice draws `unknown-component`. It now names +`KanbanRenderer` from `@object-ui/plugin-kanban` — still exported, still +forwarding both halves by identity — which is the surviving way to get the pair. +`content/docs/plugins/plugin-kanban.mdx` says the same thing the same way. diff --git a/README.md b/README.md index d086dd5d0e..abccd5766c 100644 --- a/README.md +++ b/README.md @@ -315,7 +315,7 @@ export default App ```json { - "type": "kanban", + "type": "object-kanban", "objectName": "tasks", "groupBy": "status", "titleField": "title", diff --git a/apps/console/src/register-plugins.ts b/apps/console/src/register-plugins.ts index 5f2efd40ff..2e59e86148 100644 --- a/apps/console/src/register-plugins.ts +++ b/apps/console/src/register-plugins.ts @@ -85,10 +85,11 @@ ComponentRegistry.registerLazy('object-gantt', () => import('@object-ui/plugin-g namespace: 'plugin-gantt', category: 'view', }); -ComponentRegistry.registerLazy('gantt', () => import('@object-ui/plugin-gantt'), { - namespace: 'view', - category: 'view', -}); +// ⛔ The bare `gantt` node type key is RETIRED (objectui#8008, maintainer +// ruling 2026-09-09, route 3) — `object-gantt` above is the surviving spelling. +// The STORED `NamedListView.type` value `gantt` is a different layer and is +// untouched: `ObjectView`'s `switch (viewType)` already emits `object-gantt` +// for it. ComponentRegistry.registerLazy('markdown', () => import('@object-ui/plugin-markdown'), { namespace: 'plugin-markdown', @@ -121,16 +122,11 @@ ComponentRegistry.registerLazy('object-kanban', () => import('@object-ui/plugin- namespace: 'plugin-kanban', category: 'view', }); -ComponentRegistry.registerLazy('kanban', () => import('@object-ui/plugin-kanban'), { - namespace: 'view', - category: 'view', -}); -for (const variant of ['kanban-ui', 'kanban-enhanced']) { - ComponentRegistry.registerLazy(variant, () => import('@object-ui/plugin-kanban'), { - namespace: 'plugin-kanban', - category: 'view', - }); -} +// ⛔ The bare `kanban` key (objectui#8802) and the `kanban-ui` / +// `kanban-enhanced` variants (objectui#8257) are RETIRED — maintainer rulings +// 2026-09-09. `object-kanban` above is the surviving spelling. The STORED +// `NamedListView.type` value `kanban` is a different layer and is untouched: +// `ObjectView`'s `switch (viewType)` already emits `object-kanban` for it. ComponentRegistry.registerLazy('report', () => import('@object-ui/plugin-report'), { namespace: 'plugin-report', diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index 0f21bb0b87..327e7e2714 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -910,79 +910,43 @@ A complete object management interface combining grid, form, search, filters, an ## Complex Schemas -### KanbanSchema +### ObjectKanbanSchema -A drag-and-drop Kanban board. The `kanban` type key validates the shape the registered renderer (`@object-ui/plugin-kanban`) reads: bind the board to an object with `objectName` + `groupBy` (the lanes come from the group field's options), or author it statically with `columns`, each carrying its `cards`. +A drag-and-drop Kanban board. The `object-kanban` type key validates the shape the registered renderer (`@object-ui/plugin-kanban`) reads: bind the board to an object with `objectName` + `groupBy` — the lanes come from the group field's options — or hand it rows on `data`. + +> **The bare `kanban` node type key is retired** (objectui#8802, ruled 2026-09-09). It published two faces that disagreed with each other, and `object-kanban` is now the one spelling. A `{ "type": "kanban" }` document is refused by name and told to write `object-kanban`. +> +> ⚠️ The **stored view type** `"kanban"` — what `listViews[].type` and `defaultViewType` hold — is a **different layer and is unchanged**. Do not rewrite it: a saved kanban view already renders through the `object-kanban` node type, because `ObjectView` maps the stored type onto it. ```json { - "type": "kanban", + "type": "object-kanban", "objectName": "tasks", "groupBy": "status", - "cardTitle": "title", + "titleField": "title", "cardFields": ["assignee", "due_date"], "quickAdd": true } ``` -A static board carries its cards inline: - -```json -{ - "type": "kanban", - "columns": [ - { - "id": "todo", - "title": "To Do", - "cards": [ - { - "id": "task-1", - "title": "Design mockups", - "description": "Create wireframes for new feature", - "badges": [{ "label": "High", "variant": "destructive" }] - }, - { "id": "task-2", "title": "Write tests", "description": "Unit tests for auth module" } - ] - }, - { - "id": "in-progress", - "title": "In Progress", - "limit": 3, - "cards": [ - { "id": "task-3", "title": "API integration", "description": "Connect to payment gateway" } - ] - }, - { - "id": "done", - "title": "Done", - "cards": [] - } - ] -} -``` - | Property | Type | Description | |----------|------|-------------| | `objectName` | `string` | Object to fetch records from. | -| `groupBy` | `string` | Field whose values become the lanes (maps to column ids). | -| `swimlaneField` | `string` | Field for swimlane rows (2D grouping). | -| `cardTitle` | `string` | Field used as the card title. | +| `groupBy` | `string` | **Required.** Field whose values become the lanes (maps to column ids). | +| `titleField` | `string` | Field used as the card title. | | `cardFields` | `string[]` | Fields rendered on each card. | -| `data` | `any[]` | Inline records, bucketed into lanes by `groupBy`. | +| `filter` | `any[]` | Query filter, forwarded verbatim as `$filter`. | | `limit` | `number` | Fetch window for the board (default 100). | -| `columns` | `KanbanColumn[]` | Lanes, each with `id`, `title`, `cards`, and optional `limit` / `className` / `collapsed`. A card has `id`, `title`, optional `description` and `badges`. | | `quickAdd` | `boolean` | Show a Quick Add button at the bottom of each column. | | `coverImageField` | `string` | Field whose URL renders as the card cover image. | +| `allowCollapse` | `boolean` | Allow lanes to collapse and expand. | | `conditionalFormatting` | `KanbanConditionalFormattingRule[]` | Card colouring rules — native `{ field, operator, value }` or spec `{ condition, style }`. | -| `grouping` | `GroupingConfig` | ListView grouping config; its first field is the swimlane fallback. | -| `navigation` | `ViewNavigationConfig` | Record navigation behaviour when a card is clicked (drawer / dialog / page). Defaults to an inline right-side drawer. | -| `onCardMove` | `function` | Runtime slot supplied by a React host, `(cardId, fromColumnId, toColumnId, newIndex)`; not authorable in JSON. | -| `onCardClick` | `function` | Runtime slot supplied by a React host, `(card, event?)`; not authorable in JSON. On the object-bound board the host's handler runs alongside the record-detail overlay. | -| `onQuickAdd` | `function` | Runtime slot supplied by a React host, `(columnId, title)`; not authorable in JSON. | -> Four spellings the `kanban` arm once accepted are now refused by name (objectui#7742, ADR-0049). `allowCollapse`, `cardTemplates` and `columnWidths` were declared and read by no registered board — collapse a lane with `columns[].collapsed`; card templates and column widths reach the board through a component prop and a hook option, not through the node. `titleField` is the legacy spelling of `cardTitle` and is retired on this arm only: write `cardTitle`. An `object-kanban` node still accepts `titleField`. +> `groupField` is refused by name (objectui#7322): the renderer reads `groupBy`. + +> The retired `kanban` arm declared `columns`, `cardTitle`, `swimlaneField`, `grouping` and `navigation`; the `object-kanban` face never did, and it is unchanged. The renderer still reads those keys, 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. -> The former `@object-ui/types` kanban dialect — `DeclarativeKanbanSchema`, with a board-level `draggable`, a column `color` and card `labels` / `priority` — was retired in objectui#7664: no registered renderer read it, so a board written that way validated and rendered empty. `draggable` and a column `color` are now refused by name; a static board written with `columns[].cards[]` as above is the same document in both dialects and renders every card. +> `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": []`. **Related:** [ObjectViewSchema](#objectviewschema), [ObjectGridSchema](#objectgridschema) @@ -1237,8 +1201,9 @@ A toggle control that switches between different view types (list, grid, kanban, "label": "Kanban", "icon": "Kanban", "schema": { - "type": "kanban", - "columns": [] + "type": "object-kanban", + "objectName": "tasks", + "groupBy": "status" } } ] @@ -1256,7 +1221,7 @@ A toggle control that switches between different view types (list, grid, kanban, | `storageKey` | `string` | Storage key for persisting the preference. | | `onViewChange` | `string` | Expression or callback invoked on view change. | -**Related:** [ObjectViewSchema](#objectviewschema), [KanbanSchema](#kanbanschema), [CalendarViewSchema](#calendarviewschema) +**Related:** [ObjectViewSchema](#objectviewschema), [ObjectKanbanSchema](#objectkanbanschema), [CalendarViewSchema](#calendarviewschema) --- @@ -1331,7 +1296,7 @@ import type { ActionSchema, DetailSchema } from '@object-ui/types'; import type { ObjectGridSchema, ObjectFormSchema, ObjectViewSchema } from '@object-ui/types'; // Complex -import type { KanbanSchema, DashboardComponentSchema, CalendarViewSchema } from '@object-ui/types'; +import type { DashboardComponentSchema, CalendarViewSchema } from '@object-ui/types'; // Views import type { DetailViewSchema, ViewSwitcherSchema } from '@object-ui/types'; diff --git a/content/docs/guide/architecture-overview.md b/content/docs/guide/architecture-overview.md index 6b7a65da0d..9f87874b09 100644 --- a/content/docs/guide/architecture-overview.md +++ b/content/docs/guide/architecture-overview.md @@ -132,9 +132,9 @@ Plugins are self-contained packages that register heavy or complex views (grids, PluginScope.registerComponent(type, Component, meta) │ └─ Auto-prefixes with plugin namespace ▼ - ComponentRegistry.register('kanban-ui', KanbanRenderer, { + ComponentRegistry.register('object-kanban', ObjectKanbanRenderer, { namespace: 'plugin-kanban', - category: 'plugin' + category: 'view' }) ``` @@ -164,10 +164,10 @@ const KanbanRenderer: React.FC = ({ schema }) => ( ); // 3. Register in the global registry -ComponentRegistry.register('kanban-ui', KanbanRenderer, { +ComponentRegistry.register('object-kanban', KanbanRenderer, { namespace: 'plugin-kanban', - label: 'Kanban Board', - category: 'plugin', + label: 'Object Kanban', + category: 'view', inputs: [/* schema config */] }); ``` diff --git a/content/docs/guide/troubleshooting.md b/content/docs/guide/troubleshooting.md index fe98cec31f..41923adc28 100644 --- a/content/docs/guide/troubleshooting.md +++ b/content/docs/guide/troubleshooting.md @@ -9,7 +9,7 @@ This guide covers the most common issues you may encounter when working with Obj ## 1. "Component type X not found" -**Symptom:** The `SchemaRenderer` renders nothing or shows a fallback, and the console logs `component type "kanban-ui" not found in registry`. +**Symptom:** The `SchemaRenderer` renders nothing or shows a fallback, and the console logs `component type "object-kanban" not found in registry`. **Cause:** The plugin that provides the component type has not been imported, so it never registered itself with the `ComponentRegistry`. diff --git a/content/docs/plugins/plugin-kanban.mdx b/content/docs/plugins/plugin-kanban.mdx index fe0a76cfbd..79405c2b8b 100644 --- a/content/docs/plugins/plugin-kanban.mdx +++ b/content/docs/plugins/plugin-kanban.mdx @@ -38,11 +38,13 @@ This package publishes a stylesheet. Import it after the base sheets, or the boa ```tsx // Import once in your app entry point import '@object-ui/plugin-kanban' -import type { KanbanSchema } from '@object-ui/plugin-kanban' +import type { ObjectKanbanSchema } from '@object-ui/types' // Use in schemas -const schema: KanbanSchema = { - type: 'kanban', +const schema: ObjectKanbanSchema = { + type: 'object-kanban', + groupBy: 'status', + data: [], columns: [ { id: 'todo', @@ -56,10 +58,13 @@ const schema: KanbanSchema = { title: 'Done', cards: [] } - ], - onCardMove: (cardId, fromCol, toCol, index) => { - console.log(`Card ${cardId} moved`) - } + ] +} + +// `onCardMove` is NOT a document key — JSON has no function value. The host +// supplies it as a React prop, separate from `schema` above. +const onCardMove = (cardId: string, fromCol: string, toCol: string, index: number) => { + console.log(`Card ${cardId} moved`) } ``` @@ -73,11 +78,16 @@ const schema: KanbanSchema = { ## Schema API +`onCardMove` is NOT a document key: it is a React prop the host supplies (JSON +has no function value), never a member of the schema object below — +`packages/plugin-kanban/README.md` documents this the same way. + ```plaintext { - type: 'kanban', + type: 'object-kanban', + groupBy: 'status', + data: [], columns?: KanbanColumn[], - onCardMove?: (cardId, fromColumnId, toColumnId, newIndex) => void, className?: string } @@ -107,7 +117,6 @@ interface KanbanCard { | `columns` | KanbanColumn[] | Array of column definitions | | `filter` | ViewFilterRule[] | Query filter for an object-driven board, lowered to `$filter` | | `limit` | number | Rows fetched by an object-driven board (default 100) | -| `onCardMove` | function | Callback when a card is moved | | `className` | string | Additional Tailwind CSS classes | ### Column Properties @@ -182,10 +191,12 @@ names fills the cap only when the board leaves `limit` unset (see ### Project Task Board ```tsx -import type { KanbanSchema } from '@object-ui/plugin-kanban' +import type { ObjectKanbanSchema } from '@object-ui/types' -const taskBoard: KanbanSchema = { - type: 'kanban', +const taskBoard: ObjectKanbanSchema = { + type: 'object-kanban', + groupBy: 'status', + data: [], columns: [ { id: 'backlog', @@ -227,21 +238,26 @@ const taskBoard: KanbanSchema = { title: 'Done', cards: [] } - ], - onCardMove: (cardId, fromCol, toCol, index) => { - // Update backend/state - console.log(`Moved ${cardId} from ${fromCol} to ${toCol}`) - } + ] +} + +// `onCardMove` is NOT a document key — the host supplies it as a React prop, +// separate from `taskBoard` above. +const onCardMove = (cardId: string, fromCol: string, toCol: string, index: number) => { + // Update backend/state + console.log(`Moved ${cardId} from ${fromCol} to ${toCol}`) } ``` ### Support Ticket Board ```tsx -import type { KanbanSchema } from '@object-ui/plugin-kanban' +import type { ObjectKanbanSchema } from '@object-ui/types' -const ticketBoard: KanbanSchema = { - type: 'kanban', +const ticketBoard: ObjectKanbanSchema = { + type: 'object-kanban', + groupBy: 'status', + data: [], columns: [ { id: 'new', @@ -277,10 +293,12 @@ const ticketBoard: KanbanSchema = { ### Sales Pipeline ```tsx -import type { KanbanSchema } from '@object-ui/plugin-kanban' +import type { ObjectKanbanSchema } from '@object-ui/types' -const salesPipeline: KanbanSchema = { - type: 'kanban', +const salesPipeline: ObjectKanbanSchema = { + type: 'object-kanban', + groupBy: 'status', + data: [], columns: [ { id: 'leads', @@ -366,7 +384,8 @@ Handle card movements to update your backend or state: ```tsx import { useState } from 'react' -import type { KanbanColumn, KanbanSchema } from '@object-ui/plugin-kanban' +import type { KanbanColumn } from '@object-ui/plugin-kanban' +import type { ObjectKanbanSchema } from '@object-ui/types' declare function updateCardColumn(cardId: string, toColumnId: string, newIndex: number): Promise declare function moveCard(columns: KanbanColumn[], cardId: string, toColumnId: string, newIndex: number): KanbanColumn[] @@ -374,43 +393,54 @@ declare function moveCard(columns: KanbanColumn[], cardId: string, toColumnId: s export function useBoard(initialColumns: KanbanColumn[]) { const [columns, setColumns] = useState(initialColumns) - const schema: KanbanSchema = { - type: 'kanban', + const schema: ObjectKanbanSchema = { + type: 'object-kanban', + groupBy: 'status', + data: [], columns, - // `onCardMove` returns void, so the callback cannot be awaited by the board: - // start the write and update local state without blocking the drop. - onCardMove: (cardId, fromColumnId, toColumnId, newIndex) => { - // Update database - void updateCardColumn(cardId, toColumnId, newIndex) - - // Update local state - setColumns((prev) => moveCard(prev, cardId, toColumnId, newIndex)) - }, } - return schema + // `onCardMove` is NOT a document key — the host supplies it as a React prop, + // separate from `schema` above. It returns void, so the callback cannot be + // awaited by the board: start the write and update local state without + // blocking the drop. + const onCardMove = (cardId: string, fromColumnId: string, toColumnId: string, newIndex: number) => { + // Update database + void updateCardColumn(cardId, toColumnId, newIndex) + + // Update local state + setColumns((prev) => moveCard(prev, cardId, toColumnId, newIndex)) + } + + return { schema, onCardMove } } ``` -### Quick Add is a `kanban-ui` capability, not an object-driven one +### Quick Add is a React-host capability, not a document one The Quick Add button at the bottom of a lane is gated on **both** halves of a pair: `quickAdd`, and an `onQuickAdd` handler. `onQuickAdd` is a runtime slot — a function — so it can only be supplied by a React host that mounts the board -directly, which is the `kanban-ui` block above. +component directly: `KanbanRenderer`, exported from `@object-ui/plugin-kanban`, +forwards both halves by identity. + +⚠️ There is **no node type key** that reaches it. The `kanban-ui` registration +retired with objectui#8257, so a document naming that tag is now an +`unknown-component` **error** rather than a board with a working pair. Import the +component instead. An **object-driven** board (`object-kanban`, or a kanban view inside `ListView` / `ObjectView`) supplies no `onQuickAdd` of its own, and a page written as constrained JSX cannot write one either: that tier parses and never executes, so -a handler attribute is refused there. An authored `quickAdd: true` on those tags +a handler attribute is refused there. An authored `quickAdd: true` on that tag therefore renders no control. The authoring validator (`validateTree` in `@object-ui/sdui-parser`) reports it as an `inert-quick-add` **warning** naming the missing half — the document still parses, saves and renders. - `quickAdd` is being retired from `object-kanban` / `kanban`; it stays on - `kanban-ui`, where the pair works. Inline record creation from a board is not a - capability the object-driven boards have. + `quickAdd` is being retired from `object-kanban`; it stays on the + `KanbanRenderer` component, where the pair works. Inline record creation from + an object-driven board is not a capability those boards have. ## Bundle Size @@ -431,8 +461,14 @@ The kanban board includes: ## TypeScript Support +`ObjectKanbanSchema` is published by `@object-ui/types` (and its Zod mirror by +`@object-ui/types/zod`); `@object-ui/plugin-kanban` publishes the card and column +shapes. Importing all three from the plugin does not resolve — its barrel never +exported the schema type. + ```plaintext -import type { KanbanSchema, KanbanCard, KanbanColumn } from '@object-ui/plugin-kanban' +import type { ObjectKanbanSchema } from '@object-ui/types' +import type { KanbanCard, KanbanColumn } from '@object-ui/plugin-kanban' const column: KanbanColumn = { id: 'todo', @@ -441,8 +477,10 @@ const column: KanbanColumn = { limit: 5 } -const kanbanSchema: KanbanSchema = { - type: 'kanban', +const kanbanSchema: ObjectKanbanSchema = { + type: 'object-kanban', + groupBy: 'status', + data: [], columns: [column] } ``` diff --git a/content/docs/utilities/runner.mdx b/content/docs/utilities/runner.mdx index b5d72ac93e..4e61771b01 100644 --- a/content/docs/utilities/runner.mdx +++ b/content/docs/utilities/runner.mdx @@ -293,7 +293,7 @@ Test how multiple plugins work together: { "type": "div", "children": [ - { "type": "kanban", "..." }, + { "type": "object-kanban", "..." }, { "type": "bar-chart", "..." }, { "type": "data-table", "..." } ] @@ -372,7 +372,9 @@ serve it from your own backend and load it with `?api=`. ```json { - "type": "kanban", + "type": "object-kanban", + "groupBy": "status", + "data": [], "columns": [ { "id": "todo", diff --git a/examples/schema-catalog/src/schemas/plugin-kanban/advanced-kanban-with-badges-and-limits.json b/examples/schema-catalog/src/schemas/plugin-kanban/advanced-kanban-with-badges-and-limits.json index 71ab4eaf21..8b7a6bc3df 100644 --- a/examples/schema-catalog/src/schemas/plugin-kanban/advanced-kanban-with-badges-and-limits.json +++ b/examples/schema-catalog/src/schemas/plugin-kanban/advanced-kanban-with-badges-and-limits.json @@ -1,5 +1,7 @@ { - "type": "kanban", + "type": "object-kanban", + "groupBy": "status", + "data": [], "columns": [ { "id": "backlog", diff --git a/examples/schema-catalog/src/schemas/plugin-kanban/basic-kanban-board.json b/examples/schema-catalog/src/schemas/plugin-kanban/basic-kanban-board.json index bb08ff575e..cb438386b5 100644 --- a/examples/schema-catalog/src/schemas/plugin-kanban/basic-kanban-board.json +++ b/examples/schema-catalog/src/schemas/plugin-kanban/basic-kanban-board.json @@ -1,5 +1,7 @@ { - "type": "kanban", + "type": "object-kanban", + "groupBy": "status", + "data": [], "columns": [ { "id": "todo", diff --git a/examples/schema-catalog/test/catalog-gallery-render.test.tsx b/examples/schema-catalog/test/catalog-gallery-render.test.tsx index ad12fa98a8..ef6e7af483 100644 --- a/examples/schema-catalog/test/catalog-gallery-render.test.tsx +++ b/examples/schema-catalog/test/catalog-gallery-render.test.tsx @@ -623,7 +623,11 @@ describe('objectui#4616 — every catalog entry renders in the docs gallery', () 'chatbot', 'code-editor', 'object-gantt', - 'kanban', + // ⚠️ `'kanban'` until objectui#8802 retired the bare node type key + // (maintainer ruling 2026-09-09); `object-kanban` is the surviving key + // that this package's registration now publishes, and it is what the + // census needs from `@object-ui/plugin-kanban`. + 'object-kanban', 'object-map', 'markdown', 'timeline', diff --git a/examples/schema-catalog/test/kanban-column-cards-6939.test.tsx b/examples/schema-catalog/test/kanban-column-cards-6939.test.tsx index 3906721f59..27ccb1fea2 100644 --- a/examples/schema-catalog/test/kanban-column-cards-6939.test.tsx +++ b/examples/schema-catalog/test/kanban-column-cards-6939.test.tsx @@ -187,25 +187,49 @@ describe('objectui#6939 — the mirror now accepts the spelling every board read expect(reasons(getExample(id).schema)).toEqual([]); }); - it.each(IDS)('%s: the `items` spelling is now REFUSED', (id) => { - // The other half of the accept-set move. This document PASSED before the - // rename; a mirror that merely widened would still accept it. - expect(reasons(toItemsSpelling(getExample(id).schema))).not.toEqual([]); + it.each(IDS)('⚠️ %s: `columns[].items` is ACCEPTED again — objectui#6939\'s judging was ARM-SCOPED and the arm retired', (id) => { + // ⭐ This leg asserted `.not.toEqual([])` — REFUSED — until objectui#8802. + // The reading flipped, and it flipped for a structural reason worth + // spelling out rather than papering over: + // + // - objectui#6939 made `columns[].cards` a JUDGED key by declaring + // `columns: z.array(KanbanColumnSchema)` on the `kanban` arm; + // - objectui#8802 retired that arm with the bare node type key, and the + // surviving `object-kanban` face (`objectql.zod.ts#ObjectKanbanSchema`) + // has NEVER declared `columns`; + // - so `columns` now rides `BaseSchema`'s `.passthrough()` and nothing + // inside it is judged — not the `cards` / `items` spelling, not a + // card's required `title`, not `cards`'s type. + // + // ⛔ NOT repaired here: declaring `columns` on `ObjectKanbanSchema` WIDENS a + // published accept set, which is a ruling and not a repair. Recorded as an + // assertion so it cannot drift back in silence, and reported on the + // retirement PR for the maintainer. + expect(reasons(toItemsSpelling(getExample(id).schema))).toEqual([]); }); - it('`cards` is a real declaration, not a passthrough hole', () => { - // `BaseSchema` is `.passthrough()`, so an unknown key proves nothing here. - // Every probe below uses the DECLARED key. + it('⚠️ `cards` is no longer a judged declaration on the surviving arm — measured, with a firing control', () => { + // The same four probes objectui#6939 wrote, re-read on the surviving face. + // Every one of them is accepted now, INCLUDING the two that are nonsense + // (`cards: 'nope'`, a card with no `title`) and the one objectui#6939 + // required (`cards` present at all). const col = (extra: Record) => ({ - type: 'kanban', + type: 'object-kanban', + groupBy: 'status', + data: [], columns: [{ id: 'todo', title: 'To Do', ...extra }], }); - expect(safeValidateSchema(col({ cards: 'nope' })).success).toBe(false); - expect(safeValidateSchema(col({ cards: [{ id: '1' }] })).success).toBe(false); // card needs `title` - expect(safeValidateSchema(col({})).success).toBe(false); // `cards` is required - // …and the good shape passes, so the three above fail for their own reason. - expect(safeValidateSchema(col({ cards: [] })).success).toBe(true); - expect(safeValidateSchema(col({ cards: [{ id: '1', title: 'Task' }] })).success).toBe(true); + expect({ + cardsIsAString: safeValidateSchema(col({ cards: 'nope' })).success, + cardHasNoTitle: safeValidateSchema(col({ cards: [{ id: '1' }] })).success, + cardsAbsent: safeValidateSchema(col({})).success, + wellFormed: safeValidateSchema(col({ cards: [{ id: '1', title: 'Task' }] })).success, + }).toEqual({ cardsIsAString: true, cardHasNoTitle: true, cardsAbsent: true, wellFormed: true }); + + // FIRING CONTROL on the same call: the surviving arm is not accepting + // everything — its own tombstone still refuses by name, so the four `true`s + // above are readings about `columns` and not about a dead validator. + expect(safeValidateSchema({ ...col({ cards: [] }), groupField: 'status' }).success).toBe(false); }); }); diff --git a/packages/cli/src/utils/known-schema-types.ts b/packages/cli/src/utils/known-schema-types.ts index a3d3c41895..aa42333eed 100644 --- a/packages/cli/src/utils/known-schema-types.ts +++ b/packages/cli/src/utils/known-schema-types.ts @@ -195,7 +195,6 @@ export const KNOWN_SCHEMA_TYPES: readonly string[] = [ 'form', 'form-analytics', 'formula', - 'gantt', 'geolocation', 'global:notifications', 'global:search', @@ -220,9 +219,6 @@ export const KNOWN_SCHEMA_TYPES: readonly string[] = [ 'input', 'input-otp', 'ins', - 'kanban', - 'kanban-enhanced', - 'kanban-ui', 'kbd', 'label', 'layout:app-schema-renderer', @@ -336,8 +332,6 @@ export const KNOWN_SCHEMA_TYPES: readonly string[] = [ 'plugin-gantt:object-gantt', 'plugin-grid:import-wizard', 'plugin-grid:object-grid', - 'plugin-kanban:kanban-enhanced', - 'plugin-kanban:kanban-ui', 'plugin-kanban:object-kanban', 'plugin-list:list-view', 'plugin-list:object-gallery', diff --git a/packages/components/src/renderers/complex/README-KANBAN.md b/packages/components/src/renderers/complex/README-KANBAN.md index e80348e341..a82788d240 100644 --- a/packages/components/src/renderers/complex/README-KANBAN.md +++ b/packages/components/src/renderers/complex/README-KANBAN.md @@ -20,7 +20,9 @@ A fully functional, schema-driven Kanban board component for Object UI with drag ```json { - "type": "kanban", + "type": "object-kanban", + "groupBy": "status", + "data": [], "className": "w-full h-[600px]", "columns": [ { @@ -56,7 +58,9 @@ A fully functional, schema-driven Kanban board component for Object UI with drag ```json { - "type": "kanban", + "type": "object-kanban", + "groupBy": "status", + "data": [], "columns": [...], "onCardMove": "(event) => { console.log('Card moved:', event); }" } @@ -68,7 +72,7 @@ A fully functional, schema-driven Kanban board component for Object UI with drag | Property | Type | Required | Description | |----------|------|----------|-------------| -| `type` | `"kanban"` | Yes | Component type identifier | +| `type` | `"object-kanban"` | Yes | Component type identifier. ⚠️ The bare `kanban` spelling was RETIRED in objectui#8802 (ruled 2026-09-09) and is refused by name. | | `columns` | `KanbanColumn[]` | Yes | Array of column configurations | | `className` | `string` | No | Custom CSS classes | | `onCardMove` | `function` | No | Callback when a card is moved | @@ -105,7 +109,9 @@ A fully functional, schema-driven Kanban board component for Object UI with drag ```json { - "type": "kanban", + "type": "object-kanban", + "groupBy": "status", + "data": [], "columns": [ { "id": "backlog", @@ -137,7 +143,9 @@ A fully functional, schema-driven Kanban board component for Object UI with drag ```json { - "type": "kanban", + "type": "object-kanban", + "groupBy": "status", + "data": [], "columns": [ { "id": "new", @@ -175,7 +183,9 @@ The Kanban component uses Tailwind CSS and can be customized using the `classNam ```json { - "type": "kanban", + "type": "object-kanban", + "groupBy": "status", + "data": [], "className": "w-full h-[800px] bg-gray-50 p-4 rounded-lg", "columns": [...] } diff --git a/packages/plugin-gantt/README.md b/packages/plugin-gantt/README.md index 08de99d2f8..a38755b6b6 100644 --- a/packages/plugin-gantt/README.md +++ b/packages/plugin-gantt/README.md @@ -19,7 +19,7 @@ Gantt chart plugin for Object UI - Visualize project timelines and task dependen ### Create / Edit / Delete / View When used through `ObjectGantt` (the wiring the framework uses for the -`gantt` view type) the full CRUD lifecycle is wired automatically: +`gantt` stored view type) the full CRUD lifecycle is wired automatically: - **Create** — click the toolbar "+ New Task" button. A small dialog opens pre-filled with start/end (today → +7 days). On submit the component calls @@ -65,7 +65,7 @@ When used through `ObjectGantt` (the wiring the framework uses for the ### Drag-and-drop rescheduling When the renderer is used through `ObjectGantt` (the standard wiring used by -the framework's `gantt` view type) drag is enabled automatically: each bar +the framework's `gantt` stored view type) drag is enabled automatically: each bar shows a grab cursor; the body drags the entire task, and the two thin edge zones (≈6px) resize start or end. Pointer motion snaps to whole days using the current column width. On release `ObjectGantt` issues an optimistic local @@ -114,7 +114,7 @@ import '@object-ui/plugin-gantt'; // The gantt is RECORD-DRIVEN: it names a data source and the fields to read. // It does not take a task array — see "Schema API" below. const schema = { - type: 'gantt', + type: 'object-gantt', objectName: 'project_tasks', titleField: 'name', startDateField: 'start_date', @@ -134,11 +134,21 @@ claim these schema types: | Schema `type` | Namespaced key | Renderer | | --- | --- | --- | | `object-gantt` | `plugin-gantt:object-gantt` | `ObjectGanttRenderer` | -| `gantt` | `view:gantt` | `ObjectGanttRenderer` | -Both spellings resolve — `register` stores the namespaced key *and* a bare-`type` -fallback. Both keys declare the same two inputs: `objectName` (required) and the -`gantt` configuration object. +Both spellings of the surviving key resolve — `register` stores the namespaced key +*and* a bare-`type` fallback. It declares two inputs: `objectName` (required) and +the `gantt` configuration object. + +> **The bare `gantt` key is retired** (objectui#8008, ruled 2026-09-09). This +> table used to carry a second row, `gantt` / `view:gantt`, on the same renderer. +> The registry accepted both spellings while the published declaration admitted +> only one — `ObjectGanttSchema.type` is the literal `'object-gantt'` — so an +> author who annotated their node could not write the key the registry took. +> `object-gantt` is now the one spelling. +> +> ⚠️ The **stored view type** `"gantt"` — what a saved `listViews[].type` holds — +> is a **different layer and is unchanged**. `ObjectView` maps a stored `gantt` +> view onto the `object-gantt` node type, so no saved view moves. `ObjectGanttRenderer` is a thin wrapper: it pulls `dataSource` off the renderer context and hands the schema to `ObjectGantt`. @@ -150,7 +160,7 @@ The package exports components, helpers and their types — not a registry map: ```typescript import { ObjectGantt, // ObjectQL-integrated gantt: loads records, writes back edits - ObjectGanttRenderer, // the registered renderer for `object-gantt` / `gantt` + ObjectGanttRenderer, // the registered renderer for `object-gantt` GanttView, // the standalone timeline component QuickFilterBar, // the toolbar's quick-filter dropdowns ResourceWorkload, // resource × period workload grid @@ -220,7 +230,7 @@ chart renders empty: ```typescript const recordSource = { - type: 'gantt', + type: 'object-gantt', // Pick ONE of the three: objectName: 'project_tasks', // load through the host DataSource @@ -247,7 +257,7 @@ block was discarded silently. ```typescript const fieldMapping = { - type: 'gantt', + type: 'object-gantt', objectName: 'project_tasks', // (a) flat spelling — read only when there is no `gantt` block, @@ -280,7 +290,7 @@ the renderer's `'day'` fallback. Earlier revisions of this README showed a task-array schema. Those keys have no read site anywhere in `src/` — a schema built from them renders an **empty -chart with no diagnostic**, because `type: 'gantt'` *is* a registered type, so +chart with no diagnostic**, because `type: 'object-gantt'` *is* a registered type, so the node mounts and simply finds nothing to draw: | Key shown before | Status | Use instead | @@ -356,7 +366,7 @@ pulses it — useful in deep or long trees. ```json { - "type": "gantt", + "type": "object-gantt", "objectName": "project_task", "gantt": { "titleField": "name", @@ -432,7 +442,7 @@ names are yours; only the `*Field` keys are fixed vocabulary. ```typescript const schema = { - type: 'gantt', + type: 'object-gantt', viewMode: 'week', startDateField: 'start', endDateField: 'end', @@ -569,7 +579,7 @@ it needs the field mapping beside it (or a `gantt` block of its own): ```typescript const schema = { - type: 'gantt', + type: 'object-gantt', viewMode: 'month', objectName: 'project_tasks', startDateField: 'start_date', diff --git a/packages/plugin-gantt/src/__tests__/bare-gantt-node-key-retired-8008.test.ts b/packages/plugin-gantt/src/__tests__/bare-gantt-node-key-retired-8008.test.ts new file mode 100644 index 0000000000..856a99aabd --- /dev/null +++ b/packages/plugin-gantt/src/__tests__/bare-gantt-node-key-retired-8008.test.ts @@ -0,0 +1,79 @@ +/** + * 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. + */ + +/** + * The bare `gantt` NODE TYPE key is retired; `object-gantt` is the one spelling + * this plugin serves (objectui#8008, maintainer ruling 2026-09-09, route 3). + * + * ## ⚠️ Why this is a plain unregistration and not a named refusal + * + * `BaseSchema` is `.passthrough()`, so a dropped MEMBER key is KEPT, not + * refused, and a retirement that forgets it ships a document that validates + * green and renders nothing (objectui#7664). That hazard needs a schema face to + * arise on. This key never had one: `@object-ui/types` declares `gantt` as a + * component node type ZERO times — `ObjectGanttSchema.type` is the literal + * `'object-gantt'` and nothing else names the bare spelling. ⇒ Unregistering IS + * the retirement here, which is exactly what separates this card from its + * `kanban` sibling (objectui#8802), where an arm DID exist and therefore got a + * named refusal. + * + * ## ⛔ The layer this does not touch + * + * `gantt` also names a STORED `NamedListView.type`. `plugin-view`'s `ObjectView` + * maps a stored `gantt` view onto the node type `object-gantt` already, so no + * saved view moves. That layer is pinned in `@object-ui/types` + * (`__tests__/bare-kanban-node-key-retired-8802.test.ts`, which guards BOTH + * spellings) and deliberately not restated here — this package cannot see it. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { ComponentRegistry } from '@object-ui/core'; +import '../index'; + +const INDEX_TSX = join(dirname(fileURLToPath(import.meta.url)), '..', 'index.tsx'); + +describe('the bare `gantt` node type key is retired (objectui#8008)', () => { + it('`gantt` resolves to nothing, under the bare AND the namespaced spelling', () => { + // `register(key, C, { namespace })` stores BOTH, so checking one spelling + // would leave the other resolving. + expect(ComponentRegistry.has('gantt')).toBe(false); + expect(ComponentRegistry.getConfig('gantt', 'view')).toBeFalsy(); + }); + + it('FIRING CONTROL — `object-gantt` still resolves, so the `false` above is a reading', () => { + // Without this the assertion above would also pass against a registry that + // had failed to load this package at all. + expect(ComponentRegistry.has('object-gantt')).toBe(true); + expect(ComponentRegistry.getConfig('object-gantt', 'plugin-gantt')).toBeTruthy(); + }); + + it('the source registers ONE key, measured off disk rather than through the registry', () => { + // The registry answer above is about this process; this one is about the + // file, so a registration added in a form the registry happens not to reach + // still shows up. + // ⚠️ Comments are STRIPPED first, and that is load-bearing rather than + // tidy: the retirement left a tombstone docblock that quotes the removed + // `ComponentRegistry.register('gantt', …)` call verbatim, so a raw scan + // reads the retired key back out of the prose that records its removal. + // Measured — this exact leg failed that way on its first run. + const src = readFileSync(INDEX_TSX, 'utf8') + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/^[ \t]*\/\/.*$/gm, ''); + const keys = [...src.matchAll(/ComponentRegistry\.register\(\s*'([^']+)'/g)].map((m) => m[1]); + // Anti-vacuity for the extraction: a regex that matched nothing would make + // the equality below hold forever. + expect(keys.length).toBeGreaterThan(0); + expect(keys).toEqual(['object-gantt']); + // And the stripper is not eating the code: the surviving registration's own + // renderer name survives the strip. + expect(src).toContain('ObjectGanttRenderer'); + }); +}); diff --git a/packages/plugin-gantt/src/index.tsx b/packages/plugin-gantt/src/index.tsx index 122dae1872..7d6e1f321e 100644 --- a/packages/plugin-gantt/src/index.tsx +++ b/packages/plugin-gantt/src/index.tsx @@ -109,12 +109,45 @@ ComponentRegistry.register('object-gantt', ObjectGanttRenderer, { ], }); -ComponentRegistry.register('gantt', ObjectGanttRenderer, { - namespace: 'view', - label: 'Gantt View', - category: 'view', - inputs: [ - { name: 'objectName', type: 'string', required: true }, - { name: 'gantt', type: 'object', description: 'startDateField, endDateField, titleField, progressField, percentageField, colorField, dependenciesField' }, - ], -}); +/** + * ⛔ The bare `gantt` node type key is RETIRED (objectui#8008, maintainer + * ruling 2026-09-09, route 3). `object-gantt` is the one spelling this plugin + * serves. + * + * ## What was here, and why it went + * + * `ComponentRegistry.register('gantt', ObjectGanttRenderer, { namespace: + * 'view', ... })` — a second key on the SAME renderer, which stored both + * `view:gantt` and the bare `gantt` fallback. The declared face admitted only + * one of the two: `ObjectGanttSchema.type` is the literal `'object-gantt'`, so + * an author who annotated their node could not write the key the registry + * accepted (`TS2322`), while an author who left the literal bare got no + * checking at all. Two published faces, opposite verdicts. + * + * ## Why unregistering is the whole retirement here — measured, not assumed + * + * ⚠️ `BaseSchema` closes with `[key: string]: any` and `BaseSchemaCore` ends + * `.passthrough()`, so a dropped MEMBER KEY is KEPT, not refused (the + * objectui#7664 failure). That hazard does not reach a TYPE NAME on this + * surface, and the reason is structural rather than lucky: no schema face in + * `@object-ui/types` ever declared `gantt` as a component node type — measured + * whole-repo, zero declarations, against a firing control of two for + * `object-gantt` (`objectql.ts` + its Zod mirror). There is no arm to convert + * into a named refusal, so unregistering IS the retirement. + * + * ⇒ Registration-only retirement. Contrast the `kanban` sibling in the same + * batch (objectui#8802), which DID have a declared arm and therefore got a + * named refusal rather than a deletion. + * + * ## ⛔ Two layers, and only one of them moved + * + * The string `gantt` also names a STORED `NamedListView.type` — the value + * `CreateViewDialog` writes and every tenant's database holds. That layer is + * untouched: `packages/plugin-view/src/ObjectView.tsx`'s `switch (viewType)` + * maps the stored `gantt` view type onto the node type it emits, and it already + * emits `object-gantt`. ⇒ Every gantt view any user ever created through the + * console already renders through the surviving spelling; this retirement moves + * zero stored documents. + * + * Pinned in `src/__tests__/bare-gantt-node-key-retired-8008.test.ts`. + */ diff --git a/packages/plugin-kanban/README.md b/packages/plugin-kanban/README.md index 74d3d42d63..66d6b4a24e 100644 --- a/packages/plugin-kanban/README.md +++ b/packages/plugin-kanban/README.md @@ -5,7 +5,7 @@ A lazy-loaded kanban board component for Object UI based on @dnd-kit for drag-an ## Features - **Internal Lazy Loading**: @dnd-kit libraries are loaded on-demand using `React.lazy()` and `Suspense` -- **Zero Configuration**: Just import the package and use `type: 'kanban'` in your schema +- **Zero Configuration**: Just import the package and use `type: 'object-kanban'` in your schema - **Automatic Registration**: Components auto-register with the ComponentRegistry - **Skeleton Loading**: Shows a skeleton while @dnd-kit loads - **Drag and Drop**: Full drag-and-drop support for cards between columns @@ -39,9 +39,19 @@ themed styling at all ([#4929](https://github.com/objectstack-ai/objectui/issues // In your app entry point (e.g., App.tsx or main.tsx) import '@object-ui/plugin-kanban'; -// Now you can use kanban type in your schemas +// Now you can author the `object-kanban` node type in your schemas. +// ⛔ The bare `kanban` node type key RETIRED in objectui#8802 — a +// `{ type: 'kanban' }` document is now refused BY NAME and told to write +// `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. const schema = { - type: 'kanban', + type: 'object-kanban', + groupBy: 'status', + data: [], columns: [ { id: 'todo', @@ -76,7 +86,8 @@ Object.entries(kanbanComponents).forEach(([type, component]) => { The plugin exports TypeScript types for full type safety: ```typescript -import type { KanbanSchema, KanbanCard, KanbanColumn } from '@object-ui/plugin-kanban'; +import type { KanbanCard, KanbanColumn } from '@object-ui/plugin-kanban'; +import type { ObjectKanbanSchema } from '@object-ui/types'; const card: KanbanCard = { id: 'task-1', @@ -94,8 +105,13 @@ const column: KanbanColumn = { limit: 5 }; -const schema: KanbanSchema = { - type: 'kanban', +// ⚠️ `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. +const schema: ObjectKanbanSchema = { + type: 'object-kanban', + groupBy: 'status', + data: [], columns: [column] }; ``` @@ -103,27 +119,35 @@ const schema: KanbanSchema = { ## Schema API ```typescript -import type { KanbanSchema } from '@object-ui/plugin-kanban'; +import type { ObjectKanbanSchema } from '@object-ui/types'; declare const columns: KanbanColumn[]; -// The board document. `type` is the only required member — `columns`, -// `onCardMove` and `className` are all optional. The annotation is the type -// this package ships, so each member below is compiled against it rather than -// read as prose: a `columns` array of the wrong shape, or an `onCardMove` -// whose parameters drift from the shipped signature, fails here. An unknown -// key does not — `KanbanSchema` extends `BaseSchema`, whose index signature -// deliberately accepts type-specific extensions, so the compiler is not what -// catches a misspelt board key. -const board: KanbanSchema = { - type: 'kanban', +// The board document. `type` and `groupBy` are required, and so is ONE record +// source — `bind`, `data` or `objectName`. `columns` and `className` are +// optional. +// +// ⚠️ `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 +// parameter types below rather than inferred from the annotation. The +// annotation is the type this package's renderer consumes; an unknown key does +// not fail it — `ObjectKanbanSchema` extends `BaseSchema`, whose index +// signature deliberately accepts type-specific extensions, so the compiler is +// not what catches a misspelt board key. +const board: ObjectKanbanSchema = { + type: 'object-kanban', + groupBy: 'status', // required — the field that makes the lanes + data: [], // one record source is required columns, // Array of columns - onCardMove: (cardId, fromColumnId, toColumnId, newIndex) => { - // see "Example with Callbacks" below - }, className: 'h-full', // Tailwind classes }; +// Supplied by the React host, never authored in JSON: +const onCardMove = (cardId: string, fromColumnId: string, toColumnId: string, newIndex: number) => { + // see "Example with Callbacks" below + void [cardId, fromColumnId, toColumnId, newIndex]; +}; + // Column structure interface KanbanColumn { id: string; @@ -131,7 +155,9 @@ interface KanbanColumn { cards: KanbanCard[]; limit?: number; // WIP limit — the count at which the lane warns className?: string; - collapsed?: boolean; // Lane renders collapsed (honoured by KanbanEnhanced) + collapsed?: boolean; // Lane renders collapsed (read by the KanbanEnhanced + // source module, which objectui#8257 left + // with no registry key — see CHANGELOG) color?: never; // RETIRED — refused by name; style a lane through className } @@ -175,7 +201,7 @@ When bundled, Vite automatically creates separate chunks: - `index.js` (~200 bytes) - The entry point - `KanbanImpl-xxx.js` (~100-150 KB) - The lazy-loaded implementation -The @dnd-kit libraries are only downloaded when a `kanban` component is actually rendered, not on initial page load. +The @dnd-kit libraries are only downloaded when an `object-kanban` component is actually rendered, not on initial page load. ## Bundle Size Impact @@ -197,17 +223,22 @@ pnpm build ## Example with Callbacks ```typescript -import type { KanbanColumn, KanbanSchema } from '@object-ui/plugin-kanban'; +import type { KanbanColumn } from '@object-ui/plugin-kanban'; +import type { ObjectKanbanSchema } from '@object-ui/types'; declare const columns: KanbanColumn[]; -const schema: KanbanSchema = { - type: 'kanban', +const schema: ObjectKanbanSchema = { + type: 'object-kanban', + groupBy: 'status', + data: [], columns, - onCardMove: (cardId, fromColumnId, toColumnId, newIndex) => { - console.log(`Card ${cardId} moved from ${fromColumnId} to ${toColumnId} at index ${newIndex}`); - // Update your backend or state here - } +}; + +// The host supplies the handler as a React prop — JSON has no function value. +const onCardMove = (cardId: string, fromColumnId: string, toColumnId: string, newIndex: number) => { + console.log(`Card ${cardId} moved from ${fromColumnId} to ${toColumnId} at index ${newIndex}`); + // Update your backend or state here }; ``` diff --git a/packages/plugin-kanban/src/KanbanRenderer.uncolumned.test.tsx b/packages/plugin-kanban/src/KanbanRenderer.uncolumned.test.tsx index 7e42c98e2d..19b7125bee 100644 --- a/packages/plugin-kanban/src/KanbanRenderer.uncolumned.test.tsx +++ b/packages/plugin-kanban/src/KanbanRenderer.uncolumned.test.tsx @@ -40,7 +40,10 @@ describe('KanbanRenderer — uncategorized lane (#2792)', () => { render( { render( = ({ // CLOSED, not open — do not re-open it as a cleanup. If bucket-vocabulary // unification ever becomes a product direction that is a fresh ruling, // with visual-regression evidence across all four surfaces in one stroke. - // `navigation` is DECLARED on `KanbanSchema` since objectui#7742 (gantt - // precedent objectui#5903), so this read is typed rather than cast. It stayed - // `(schema as any)` for exactly as long as no schema face named the key. + // ⚠️ `navigation` was DECLARED on `KanbanSchema` by objectui#7742 (gantt + // precedent objectui#5903). That arm RETIRED with the bare `kanban` node key + // (objectui#8802), and the surviving `ObjectKanbanSchema` face never declared + // the key — so on an `object-kanban` document this read has ALWAYS ridden + // `BaseSchema`'s `[key: string]: any`, exactly as `filter` does. ⛔ Nothing + // about an `object-kanban` board changed here; what went is the only face + // that ever declared the key, and it only ever judged `kanban` documents. + // The designer face still declares it — `OBJECT_KANBAN_INPUTS` (`index.tsx`). + // Reported on the retirement PR as a follow-up for the `object-kanban` face. const navConfig = schema.navigation ?? { mode: 'drawer' }; // When this kanban is embedded in an ObjectView, the parent provides // `onRowClick`/`onCardClick` and owns the unified record-detail overlay. diff --git a/packages/plugin-kanban/src/__tests__/filterIsDeclaredInput-7712.test.ts b/packages/plugin-kanban/src/__tests__/filterIsDeclaredInput-7712.test.ts index 734126c139..358acec10f 100644 --- a/packages/plugin-kanban/src/__tests__/filterIsDeclaredInput-7712.test.ts +++ b/packages/plugin-kanban/src/__tests__/filterIsDeclaredInput-7712.test.ts @@ -75,10 +75,17 @@ import { manifestFromConfigs, validateTree } from '@object-ui/sdui-parser'; // bounded window). import '../index'; -/** The two tags this one renderer is published under. */ +/** + * The tag this one renderer is published under. + * + * ⚠️ It was a LIST OF TWO — `object-kanban` and `view:kanban` — until + * objectui#8802 retired the bare `kanban` node type key (maintainer ruling + * 2026-09-09). The `it.each` shape is deliberately KEPT over the one survivor: + * the rows below are per-(tag, key), and collapsing them to bare `it`s would + * make re-adding a tag a rewrite rather than a one-line edit. + */ const KANBAN_TAGS = [ { label: 'object-kanban', type: 'object-kanban', namespace: 'plugin-kanban' }, - { label: 'view:kanban', type: 'kanban', namespace: 'view' }, ] as const; /** A filter in the JSON-rules form `ObjectKanban` forwards as `$filter`. */ diff --git a/packages/plugin-kanban/src/__tests__/kanban-family-registry-keys-retired-8257.test.ts b/packages/plugin-kanban/src/__tests__/kanban-family-registry-keys-retired-8257.test.ts new file mode 100644 index 0000000000..f6a9cff7ba --- /dev/null +++ b/packages/plugin-kanban/src/__tests__/kanban-family-registry-keys-retired-8257.test.ts @@ -0,0 +1,145 @@ +/** + * 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. + */ + +/** + * The kanban family converged on ONE registered node type key (objectui#8257 + * for `kanban-ui` / `kanban-enhanced`, objectui#8802 for the bare `kanban` — + * both maintainer rulings, 2026-09-09). + * + * ## Why the three retirements are pinned in ONE file + * + * They are one convergence, not three coincidences, and the claim that matters + * is about the SET: exactly one key resolves. A file per key would let the set + * grow back a member without any single file going red. + * + * ## The two mechanisms, kept apart on purpose + * + * ⚠️ `kanban-ui` and `kanban-enhanced` were REGISTRATION-ONLY: measured + * whole-repo, no schema face in `@object-ui/types` ever declared either name as + * a component node type, so unregistering IS the whole retirement. The bare + * `kanban` key DID have a declared arm, so its retirement needed a NAMED + * REFUSAL as well — that half lives in `@object-ui/types` + * (`__tests__/bare-kanban-node-key-retired-8802.test.ts`) and is deliberately + * not restated here. + * + * ⛔ Do not "simplify" this file by asserting the refusal here too: this + * package cannot see the union the CLI applies, and a second copy of the claim + * is a second thing to keep true. + * + * ## ⭐ objectui#8818 — what retiring `kanban-ui` closes, and what it does not + * + * `SchemaRenderer` strips a fixed enumerated metadata list and spreads the rest + * as React props. `objectFields` is not on that list, and `KanbanRenderer` — + * the component `kanban-ui` resolved to — declares `objectFields` as a real + * prop (objectui#7742). An AUTHORED `objectFields` therefore reached the + * predicate layer on that entry with no schema face declaring or judging it. + * With the registration gone no authored node can reach `KanbanRenderer` + * through the registry at all, which closes that path. + * + * ⚠️ It closes the ENTRY, ⛔ NOT the CLASS: `SchemaRenderer` still spreads every + * unstripped key, so the hole returns the moment another registered renderer + * declares an `objectFields` prop. objectui#8818's option (a) — stripping at + * the `SchemaRenderer` boundary — is what would close the class, and it is + * still open. Suite 3 pins the entry half so nobody reads the class as closed. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { ComponentRegistry } from '@object-ui/core'; +import { KanbanRenderer, kanbanComponents } from './../index'; +import './../index'; + +const INDEX_TSX = join(dirname(fileURLToPath(import.meta.url)), '..', 'index.tsx'); + +/** The three retired keys, and the one that survives. */ +const RETIRED_KEYS = ['kanban', 'kanban-ui', 'kanban-enhanced'] as const; +const SURVIVOR = 'object-kanban'; + +describe('suite 1 — exactly one kanban node type key resolves', () => { + it.each(RETIRED_KEYS)('`%s` is NOT registered, under its bare spelling', (key) => { + expect(ComponentRegistry.has(key)).toBe(false); + }); + + it('`view:kanban` and `plugin-kanban:kanban-ui` — the namespaced spellings — are gone too', () => { + // `register(key, C, { namespace })` stores BOTH the namespaced key and a + // bare fallback, so a retirement that only removed one spelling would leave + // the other resolving. Measured through `getConfig`, which takes the + // namespace explicitly. + expect(ComponentRegistry.getConfig('kanban', 'view')).toBeFalsy(); + expect(ComponentRegistry.getConfig('kanban-ui', 'plugin-kanban')).toBeFalsy(); + expect(ComponentRegistry.getConfig('kanban-enhanced', 'plugin-kanban')).toBeFalsy(); + }); + + it('FIRING CONTROL — the survivor still resolves under both of its spellings', () => { + // Without this every assertion above would also pass against a registry + // that had failed to load this package at all. + expect(ComponentRegistry.has(SURVIVOR)).toBe(true); + expect(ComponentRegistry.getConfig(SURVIVOR, 'plugin-kanban')).toBeTruthy(); + }); +}); + +describe('suite 2 — the manual-integration map publishes the same one key', () => { + it('`kanbanComponents` names only the survivor', () => { + // A host that mounted the map's keys into its own registry would otherwise + // re-teach the retired spellings under a name this package still publishes. + expect(Object.keys(kanbanComponents)).toEqual([SURVIVOR]); + }); +}); + +describe('suite 3 — objectui#8818: the `objectFields` ENTRY is closed, the CLASS is not', () => { + it('no registry key resolves to `KanbanRenderer` any more — the authored-`objectFields` path is gone', () => { + // `KanbanRenderer` is still exported and still rendered (by `ObjectKanban`, + // which supplies `objectFields` itself as a prop). What is gone is any way + // for an AUTHOR to reach it: nothing in the registry resolves to it. + const src = readFileSync(INDEX_TSX, 'utf8'); + const registeredComponents = [ + ...src.matchAll(/ComponentRegistry\.register\(\s*'([^']+)'\s*,\s*(\w+)/g), + ].map((m) => ({ key: m[1], component: m[2] })); + + // Anti-vacuity for the extraction: it found the registrations it claims to + // read. A regex that matched nothing would make the filter below empty and + // the assertion green forever. + expect(registeredComponents.length).toBeGreaterThan(0); + expect(registeredComponents.map((r) => r.key)).toContain(SURVIVOR); + + expect(registeredComponents.filter((r) => r.component === 'KanbanRenderer')).toEqual([]); + // And through the live registry, not only off disk. + expect(ComponentRegistry.get(SURVIVOR)).not.toBe(KanbanRenderer); + }); + + it('⚠️ the CLASS is still open — this is recorded, not asserted shut', () => { + // `SchemaRenderer`'s stripped-metadata list is in `@object-ui/react` and + // still does not name `objectFields`. This package cannot fix that and does + // not claim to; the assertion here is only that `KanbanRenderer` still + // DECLARES the prop, which is the half that would make the hole return the + // moment any registered renderer takes it again. + const src = readFileSync(INDEX_TSX, 'utf8'); + expect(src).toContain('objectFields?: unknown;'); + }); +}); + +describe('suite 4 — `kanban-enhanced`\'s three keys lost their only authorable surface', () => { + it('none of `onColumnToggle` / `enableVirtualScrolling` / `virtualScrollThreshold` is declared anywhere in this package', () => { + // objectui#8257's own subject, CONFIRMED rather than assumed: the three + // keys were read by the `kanban-enhanced` registration and declared as its + // `inputs`, with no schema arm anywhere. With the registration gone they + // have no authorable surface at all — which resolves the card by removing + // its subject. + const src = readFileSync(INDEX_TSX, 'utf8'); + const declaredInputNames = [...src.matchAll(/name:\s*'([^']+)'/g)].map((m) => m[1]); + + // Anti-vacuity: the extraction reads a real, populated input list. + expect(declaredInputNames).toContain('groupBy'); + + for (const key of ['onColumnToggle', 'enableVirtualScrolling', 'virtualScrollThreshold']) { + expect(declaredInputNames, `\`${key}\` is still a declared input`).not.toContain(key); + } + }); +}); diff --git a/packages/plugin-kanban/src/__tests__/kanban-handler-slots-7664.test.tsx b/packages/plugin-kanban/src/__tests__/kanban-handler-slots-7664.test.tsx index e356e4ab20..29a00ffa9f 100644 --- a/packages/plugin-kanban/src/__tests__/kanban-handler-slots-7664.test.tsx +++ b/packages/plugin-kanban/src/__tests__/kanban-handler-slots-7664.test.tsx @@ -25,34 +25,52 @@ * holds its length constant and no assertion is derived from the read site. * Suite 3 below is that missing derivation. * + * ## ⭐ What the 2026-09-09 family retirement did to this file + * + * Three of the four registrations this file measured RETIRED — the bare + * `kanban` node key (objectui#8802) and the `kanban-ui` / `kanban-enhanced` + * variants (objectui#8257) — and the zod `'kanban'` arm retired with the first + * of them. ⛔ The MECHANISM the file exists to guard did not retire with them, + * so the legs were re-based rather than deleted: + * + * - the per-registration reachability probes collapse to the ONE surviving + * key, `'object-kanban'`; + * - `KanbanRenderer` is still a live component (`ObjectKanban` renders it), + * so its identity-forward probe is kept and driven DIRECTLY instead of + * through a registry key that no longer exists; + * - suite 3's derivation survives intact and its VERDICT flips, which is the + * honest reading: the three forwarded keys had exactly one declaring face, + * the `'kanban'` arm, and with the arm gone they have NONE. Recorded as an + * assertion so the state cannot drift back silently, and reported on the + * retirement PR rather than repaired here — declaring them on + * `ObjectKanbanSchema` would WIDEN a published accept set, which is a + * ruling and not a repair. + * * ## Suite 1 — runtime reachability, per registration * * The two lazy board chunks are replaced by prop recorders; three spies are * authored on the schema; the question is which of them reach the board. * - * - `'kanban-ui'` (`KanbanRenderer`, `../index.tsx`): all three arrive BY + * - `KanbanRenderer` rendered directly (`../index.tsx`): all three arrive BY * IDENTITY. This is the probe whose controls are lit — `onCardMove` and - * `onQuickAdd`, both kept as #6124 runtime slots by this PR, come out live - * on it, and `onCardClick` comes out live beside them off the same - * forward block. - * - `'kanban'` and `'object-kanban'` (both registered to - * `ObjectKanbanRenderer` → `ObjectKanban` → `KanbanRenderer`): - * `onQuickAdd` arrives by identity — the lit control ON THIS KEY, proving - * the schema-spread channel reaches the board here — while `onCardClick` - * AND `onCardMove` are BOTH replaced by `ObjectKanban`'s own functions - * (`ObjectKanban.tsx`, the `` literal). The two keys - * have the SAME reachability on this key, so "`ObjectKanban` overrides it" - * cannot retire one without retiring the other. - * - `'kanban-enhanced'`: `onCardMove` / `onQuickAdd` arrive; `onCardClick` is - * not forwarded there at all. + * `onQuickAdd`, both kept as #6124 runtime slots, come out live on it, and + * `onCardClick` comes out live beside them off the same forward block. + * - `'object-kanban'` (registered to `ObjectKanbanRenderer` → `ObjectKanban` + * → `KanbanRenderer`): `onQuickAdd` arrives by identity — the lit control + * ON THIS KEY, proving the schema-spread channel reaches the board here — + * while `onCardClick` AND `onCardMove` are BOTH replaced by + * `ObjectKanban`'s own functions (`ObjectKanban.tsx`, the + * `` literal). The two keys have the SAME + * reachability here, so "`ObjectKanban` overrides it" cannot retire one + * without retiring the other. * * ## Suite 2 — the prop channel, which only `onCardClick` has * * `SchemaRenderer` spreads every non-metadata schema key as a React prop * (`packages/react/src/SchemaRenderer.tsx`, the `...componentProps` line of its * `createElement` call), and `ObjectKanbanComponentProps` DECLARES - * `onCardClick` — there is no `onCardMove` prop. So on the `'kanban'` key an + * `onCardClick` — there is no `onCardMove` prop. So on the `'object-kanban'` key an * authored `onCardClick` is not merely overridden: `ObjectKanban`'s own * wrapper CALLS it. Suite 2 invokes the function the board was handed and * measures that the authored one runs, with the identity check from suite 1 as @@ -64,10 +82,9 @@ * ## Suite 3 — derived from the read site, so a deletion cannot hide * * The `schema.on*` reads inside `KanbanRenderer`'s body are extracted from - * `../index.tsx` and each is required to be a declared member of the zod - * `'kanban'` arm carrying the RUNTIME SLOT guidance. Nothing here is a list a - * re-key can hold constant: remove a forwarded key from the arm and the - * assertion goes red naming it. + * `../index.tsx`. Nothing here is a list a re-key can hold constant: the read + * site is measured, and each key's declaration status on the surviving + * `object-kanban` face is measured beside it. * * ## Predictions, written before the first run (red-first) * @@ -86,7 +103,8 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { ComponentRegistry } from '@object-ui/core'; import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; -import { KanbanSchema as KanbanZod } from '@object-ui/types/zod'; +import { ObjectKanbanSchema as ObjectKanbanZod } from '@object-ui/types/zod'; +import { KanbanRenderer } from '../index'; import '../index'; /** Every props object either board implementation was rendered with, in order. */ @@ -144,6 +162,26 @@ async function boardPropsFor(type: string, log: 'impl' | 'enhanced', schemaKeys: return lastBoardProps(log, before, unmount); } +/** + * Render `KanbanRenderer` DIRECTLY with an authored board and return the props + * its board implementation was handed last. + * + * ⚠️ Direct rather than through a registry key, and that is a re-based + * instrument rather than a weakened one: `kanban-ui` — the key this probe used + * to reach the component through — RETIRED (objectui#8257). The component is + * unchanged, still exported, and still rendered by `ObjectKanban`, so the + * forward block being measured is identical; what is gone is one lookup hop. + */ +async function kanbanRendererProps(schemaKeys: Record) { + const before = recorded.impl.length; + const { unmount } = render( + + + , + ); + return lastBoardProps('impl', before, unmount); +} + /** * The production path: author the key on the DOCUMENT and let `SchemaRenderer` * route it. This is the channel that turns an authored `onCardClick` into a @@ -160,9 +198,13 @@ async function boardPropsViaSchemaRenderer(schema: Record) { } describe('which authored handler keys reach a registered kanban board (objectui#7664)', () => { - it("`'kanban-ui'` (KanbanRenderer) forwards onCardClick, onCardMove and onQuickAdd by identity", async () => { + it('KanbanRenderer forwards onCardClick, onCardMove and onQuickAdd by identity', async () => { + // ⚠️ Driven DIRECTLY rather than through a registry key: `kanban-ui` + // retired (objectui#8257). The component is unchanged and still live — + // `ObjectKanban` renders it — so the forward block this leg measures is the + // same one it always measured. const spies = authored(); - const props = await boardPropsFor('kanban-ui', 'impl', spies); + const props = await kanbanRendererProps(spies); expect({ onCardClick: props.onCardClick === spies.onCardClick, onCardMove: props.onCardMove === spies.onCardMove, @@ -170,7 +212,7 @@ describe('which authored handler keys reach a registered kanban board (objectui# }).toEqual({ onCardClick: true, onCardMove: true, onQuickAdd: true }); }); - it.each(['kanban', 'object-kanban'])( + it.each(['object-kanban'])( "`'%s'` (ObjectKanban) passes onQuickAdd through and replaces BOTH onCardClick and onCardMove with its own", async (type) => { const spies = authored(); @@ -191,23 +233,25 @@ describe('which authored handler keys reach a registered kanban board (objectui# }, ); - it("`'kanban-enhanced'` forwards onCardMove and onQuickAdd; onCardClick is not forwarded there", async () => { - const spies = authored(); - const props = await boardPropsFor('kanban-enhanced', 'enhanced', spies); + it('the three RETIRED registrations resolve to nothing, and the survivor still does', () => { + // Replaces the `'kanban-enhanced'` reachability leg, whose subject retired + // (objectui#8257). Stated as a reading with its own firing control rather + // than as three bare `false`s. expect({ - onCardMove: props.onCardMove === spies.onCardMove, - onQuickAdd: props.onQuickAdd === spies.onQuickAdd, - onCardClick: props.onCardClick, - }).toEqual({ onCardMove: true, onQuickAdd: true, onCardClick: undefined }); + kanban: ComponentRegistry.has('kanban'), + kanbanUi: ComponentRegistry.has('kanban-ui'), + kanbanEnhanced: ComponentRegistry.has('kanban-enhanced'), + objectKanban: ComponentRegistry.has('object-kanban'), + }).toEqual({ kanban: false, kanbanUi: false, kanbanEnhanced: false, objectKanban: true }); }); }); describe("ObjectKanban's own onCardClick wrapper CALLS the authored handler (objectui#7664)", () => { - it("on the `'kanban'` key, an onCardClick authored on the DOCUMENT is run by the wrapper", async () => { + it("on the `'object-kanban'` key, an onCardClick authored on the DOCUMENT is run by the wrapper", async () => { const onCardClick = vi.fn(); const card = { id: '1', title: 'One' }; const props = await boardPropsViaSchemaRenderer({ - type: 'kanban', + type: 'object-kanban', columns: STATIC_COLUMNS, onCardClick, }); @@ -221,14 +265,18 @@ describe("ObjectKanban's own onCardClick wrapper CALLS the authored handler (obj }); }); -describe("every handler key KanbanRenderer forwards is declared on the 'kanban' arm (objectui#7664)", () => { +describe('the handler keys KanbanRenderer forwards, and where they are declared (objectui#7664)', () => { const INDEX_TSX = join(dirname(fileURLToPath(import.meta.url)), '..', 'index.tsx'); /** The `schema.on*` reads inside the `KanbanRenderer` component body, read off the source. */ function forwardedByKanbanRenderer(): string[] { const src = readFileSync(INDEX_TSX, 'utf8'); const start = src.indexOf('export const KanbanRenderer'); - const end = src.indexOf("ComponentRegistry.register(\n 'kanban-ui'", start); + // The component body ends at the `kanban-ui` retirement tombstone that + // follows it. The `it` below is this extraction's own anti-vacuity control: + // a marker that stopped matching would make `end` -1 and throw, and a + // marker that matched too early would drop keys from the measured set. + const end = src.indexOf('⛔ The `kanban-ui` node type key is RETIRED', start); if (start === -1 || end === -1) throw new Error('KanbanRenderer body not found in index.tsx'); return [...src.slice(start, end).matchAll(/schema\.(on[A-Z][A-Za-z0-9]*)\b/g)] .map((m) => m[1]) @@ -239,15 +287,26 @@ describe("every handler key KanbanRenderer forwards is declared on the 'kanban' expect(forwardedByKanbanRenderer()).toEqual(['onCardClick', 'onCardMove', 'onQuickAdd']); }); - it('each forwarded key is a declared arm member carrying the RUNTIME SLOT guidance — a bare deletion goes red here', () => { - const shape = KanbanZod.shape as Record; - const readings = forwardedByKanbanRenderer().map((key) => ({ - key, - declared: key in shape, - guidance: shape[key]?.description?.includes('RUNTIME SLOT') ?? false, - })); - expect(readings).toEqual( - ['onCardClick', 'onCardMove', 'onQuickAdd'].map((key) => ({ key, declared: true, guidance: true })), + it('⚠️ none of the three is declared on the surviving `object-kanban` face — the retirement moved this reading, and it is recorded rather than repaired', () => { + // ⭐ This leg USED to assert `declared: true, guidance: true` against the + // zod `'kanban'` arm, which carried all three as objectui#6124 RUNTIME + // SLOTS. objectui#8802 retired that arm with the bare node key, and the + // surviving `ObjectKanbanSchema` never declared them — so the honest + // reading today is the opposite one, and it is pinned so it cannot drift + // back in silence. + // + // ⛔ NOT repaired here: adding the three to `ObjectKanbanSchema` WIDENS a + // published accept set, which is a ruling, not a repair. Reported on the + // retirement PR for the maintainer. + const shape = ObjectKanbanZod.shape as Record; + const forwarded = forwardedByKanbanRenderer(); + expect(forwarded.map((key) => ({ key, declared: key in shape }))).toEqual( + ['onCardClick', 'onCardMove', 'onQuickAdd'].map((key) => ({ key, declared: false })), ); + // Firing control on the SAME instrument: a key this face really does + // declare reads `true`, so the three `false`s above are readings and not a + // shape lookup that answers `false` to everything (an unwrapped + // `.superRefine()` result, say, whose `.shape` is undefined). + expect('groupBy' in shape).toBe(true); }); }); diff --git a/packages/plugin-kanban/src/__tests__/kanban-plugin-dialect-authoritative-7664.test.ts b/packages/plugin-kanban/src/__tests__/kanban-plugin-dialect-authoritative-7664.test.ts index aa53adc52f..f8beee6578 100644 --- a/packages/plugin-kanban/src/__tests__/kanban-plugin-dialect-authoritative-7664.test.ts +++ b/packages/plugin-kanban/src/__tests__/kanban-plugin-dialect-authoritative-7664.test.ts @@ -7,22 +7,26 @@ */ /** - * The renderer's face IS the declared `'kanban'` arm (objectui#7664, maintainer - * ruling (a), 2026-09-05). + * The surviving renderer's face IS a declared arm (objectui#7664, maintainer + * ruling (a), 2026-09-05 — re-based on the 2026-09-09 family retirement). * - * ## Why this pin lives here + * ## What objectui#8802 / objectui#8257 did to this file, and why it is a + * rewrite rather than a deletion * - * `@object-ui/types` now declares the plugin dialect and this package imports - * it back (`../types` re-exports `KanbanSchema` and its four companions), so - * the ruling's "the four registered renderers' props still type-check against - * the declared schema" is a claim about THIS package's prop types — and this is - * the only package that can see both sides: the declaration through the - * workspace dependency, the renderers through `../index`. + * objectui#7664's ruling was about FOUR registered kanban keys and one shared + * declaration: `'kanban'`, `'kanban-ui'`, `'kanban-enhanced'`, `'object-kanban'`. + * Three of the four RETIRED on 2026-09-09 (objectui#8802 for the bare `kanban` + * node key, objectui#8257 for the other two), and `KanbanSchema` — the dialect + * this file was written to pin — retired with the first of them. * - * The retired objectui#7645 pin that sat here asserted two dialects still - * existed (`Equal` was `false`). With the - * declarative trio retired that pin has no second operand; what replaces it is - * the stronger claim the ruling makes — ONE declaration. + * ⛔ The claim objectui#7664 established did NOT retire with them: "the + * registered renderer's props type-check against the declared schema, and the + * declaration lives in `@object-ui/types` rather than being re-declared here." + * That claim is now about ONE key, and this file asserts it about that key. The + * legs that named a retired key moved to + * `kanban-family-registry-keys-retired-8257.test.ts`, which owns the retirement + * itself — including the anti-vacuity control that the surviving key is still + * registered. * * ## The instrument * @@ -32,17 +36,22 @@ * package's `type-check` script. That project sets `"paths": {}`, so * `@object-ui/types` resolves through the workspace dependency to * `packages/types/dist/index.d.ts` — BUILD `@object-ui/types` before believing - * either colour this file reports. The one runtime assertion — the four - * registrations exist — is the anti-vacuity control for the prop-type pins: + * either colour this file reports. The one runtime assertion — the surviving + * registration exists — is the anti-vacuity control for the prop-type pins: * a prop type is only worth pinning for a renderer that is registered. */ import { describe, it, expect } from 'vitest'; import { ComponentRegistry } from '@object-ui/core'; -import type { SchemaRegistry, KanbanSchema as DeclaredKanbanSchema, KanbanColumn as DeclaredKanbanColumn, KanbanCard as DeclaredKanbanCard } from '@object-ui/types'; -import type { KanbanSchema, KanbanColumn, KanbanCard } from '../types'; +import type { + SchemaRegistry, + ObjectQLComponentSchema, + ObjectKanbanSchema as DeclaredObjectKanbanSchema, + KanbanColumn as DeclaredKanbanColumn, + KanbanCard as DeclaredKanbanCard, +} from '@object-ui/types'; +import type { KanbanColumn, KanbanCard } from '../types'; import type { ObjectKanbanComponentProps } from '../ObjectKanban'; -import type { KanbanRendererProps } from '../index'; import '../index'; /* -------------------------------------------------------------------------- */ @@ -55,67 +64,65 @@ type IsAny = 0 extends 1 & T ? true : false; // Non-vacuity controls: `any` on either side would satisfy every `extends` // below while checking nothing. -type _PluginFaceIsReal = Assert, false>>; +type _FaceIsReal = Assert, false>>; type _RegistryIsReal = Assert, false>>; -// 1. ONE declaration: what this package exports as `KanbanSchema` / -// `KanbanColumn` / `KanbanCard` is the `@object-ui/types` declaration, not a -// structurally-equal copy. `Equal` is invariant, so a re-declared twin that -// drifted by one member turns this red. -type _SchemaIsTheDeclaredOne = Assert>; +// 1. ONE declaration: what this package exports as `KanbanColumn` / `KanbanCard` +// is the `@object-ui/types` declaration, not a structurally-equal copy. +// `Equal` is invariant, so a re-declared twin that drifted by one member +// turns this red. (`KanbanSchema` was the third member of this row until +// objectui#8802 retired it with the bare `kanban` node key; the two that +// remain are consumed by `KanbanImpl`, `CardTemplates` and `useColumnWidths` +// and are NOT retired.) type _ColumnIsTheDeclaredOne = Assert>; type _CardIsTheDeclaredOne = Assert>; -// 2. The map that calls itself the Single Source of Truth names the same type -// the registered renderer consumes — the objectui#7645 defect, closed by the -// ruling rather than by weakening the entry. -type _RegistryEntryIsThisFace = Assert>; +// 2. The map that calls itself the Single Source of Truth no longer offers the +// RETIRED key. objectui#7645's defect was that `SchemaRegistry['kanban']` +// named a type the registered renderer did not consume; objectui#8802 closed +// it the other way, by retiring the key. +// +// ⚠️ Measured rather than assumed, and NOT repaired here: this map has never +// carried an `'object-kanban'` entry either, so the positive half of this pin +// cannot be spelled against it. The union is where the surviving face is +// reachable, so that is where the positive leg is asserted. +type _RetiredKeyIsGoneFromTheMap = Assert>; +// Non-vacuity: a live sibling key IS still in the map, so the `false` above +// is a reading and not a `keyof` that answers `false` to everything. +type _MapStillHasLiveKeys = Assert>; +// And the surviving face is the one the ObjectQL union selects for the key. +type _UnionSelectsTheDeclaredFace = Assert< + Equal, DeclaredObjectKanbanSchema> +>; -// 3. The renderers' props type-check against the declared schema: -// - `ObjectKanban` (behind `ObjectKanbanRenderer`, registered for `'kanban'` -// AND `'object-kanban'`) ACCEPTS the declared face as its `schema`. +// 3. The renderer's props type-check against the declared schema: `ObjectKanban` +// (behind `ObjectKanbanRenderer`, now registered for `'object-kanban'` ALONE) +// accepts the declared face as its `schema`. // -// ⚠️ This leg read `Equal<…, KanbanSchema>` until objectui#7322 item ②. -// Identity was never what the ruling claimed — the ruling's words are that -// "the four registered renderers' props still type-check against the -// declared schema", i.e. assignability, which is the same form the -// `kanban-ui` leg below already uses and for the same reason. Identity was -// merely the shape the prop happened to have while it named ONE arm, and -// that was itself the defect objectui#7322 item ② filed: the same renderer -// is registered for `'object-kanban'` too, whose declared node type is -// `ObjectKanbanSchema`, and no such node was assignable to the prop. The -// prop is now the union of the two registered keys' declared types, so the -// ruling's claim holds on this arm and now holds on the other one as well. -// The union's own honesty is pinned in -// `object-kanban-component-props-7322.test.ts`, which derives the -// registered key set from `../index` off disk; this leg keeps guarding -// what objectui#7664 asserted — that THIS declaration is the one the -// renderer consumes. -type _ObjectKanbanTakesTheDeclaredFace = Assert; -// - `KanbanRenderer` (`'kanban-ui'`) accepts a declared board — its inline -// prop schema is a looser projection (`columns?: Array`), so the -// claim is assignability, not identity; -type _KanbanUiAcceptsTheDeclaredFace = Assert; -// - `'kanban-enhanced'` is registered as `({ schema }: { schema: any })` and -// accepts anything by construction — nothing to pin, and pinning `any` -// would be the vacuity the controls above exclude. +// ⚠️ Assignability, not identity, and deliberately so — that is the form +// objectui#7322 item ② settled on when the prop served two keys. It stays +// assignability now that it serves one, because the ruling's own words are +// that the props "type-check against the declared schema"; identity was only +// ever the shape the prop happened to have. +type _ObjectKanbanTakesTheDeclaredFace = Assert< + DeclaredObjectKanbanSchema extends ObjectKanbanComponentProps['schema'] ? true : false +>; // 4. The declared face is still a tagged node, and a raw record field on a // card still reads `any` (the open-record index signature survived the move). -type _FaceIsTagged = Assert>; +type _FaceIsTagged = Assert>; type _CardIsAnOpenRecord = Assert, true>>; -describe('the registered kanban renderers consume the declared arm (objectui#7664)', () => { +describe('the registered kanban renderer consumes the declared arm (objectui#7664)', () => { it('is pinned at compile time', () => { expect(true).toBe(true); }); - it('all four registrations the ruling counts exist — the prop-type pins above are about live renderers', () => { - for (const type of ['kanban', 'kanban-ui', 'kanban-enhanced', 'object-kanban']) { - expect(ComponentRegistry.has(type), `\`${type}\` is not registered`).toBe(true); - } - // `'kanban'` and `'object-kanban'` are the SAME renderer, which is why one - // prop-type pin (`ObjectKanbanComponentProps`) covers both keys. - expect(ComponentRegistry.get('kanban')).toBe(ComponentRegistry.get('object-kanban')); + it('the ONE surviving registration exists — the prop-type pins above are about a live renderer', () => { + expect(ComponentRegistry.has('object-kanban'), '`object-kanban` is not registered').toBe(true); + // Firing control on the same instrument: a name nothing registers answers + // `false`, so the `true` above is a reading rather than a matcher that + // agrees with everything. + expect(ComponentRegistry.has('zzz-not-a-type')).toBe(false); }); }); diff --git a/packages/plugin-kanban/src/__tests__/object-kanban-component-props-7322.test.ts b/packages/plugin-kanban/src/__tests__/object-kanban-component-props-7322.test.ts index 6718981d4d..e269cc27f8 100644 --- a/packages/plugin-kanban/src/__tests__/object-kanban-component-props-7322.test.ts +++ b/packages/plugin-kanban/src/__tests__/object-kanban-component-props-7322.test.ts @@ -12,8 +12,8 @@ * * ## The defect this pins closed * - * `ObjectKanbanRenderer` is registered under two keys — `'object-kanban'` and - * `'kanban'` — and the two keys have different declared node types + * `ObjectKanbanRenderer` used to be registered under two keys — + * `'object-kanban'` and `'kanban'` — with different declared node types * (`ObjectKanbanSchema`, `type: 'object-kanban'`; `KanbanSchema`, * `type: 'kanban'`). The prop named `KanbanSchema` alone, so no * `object-kanban` node was assignable to the component that renders it. The @@ -22,6 +22,17 @@ * `as never`, and the `titleField` read inside `ObjectKanban.tsx` was spelled * `(schema as any).titleField` because the named arm does not declare it. * + * ## ⭐ What objectui#8802 changed, and why this pin got STRONGER rather than weaker + * + * The bare `kanban` node key RETIRED (maintainer ruling 2026-09-09) and + * `KanbanSchema` retired with it, so the component is registered under ONE key + * again and the prop names ONE arm again. ⛔ That is not a revert of item ②: + * the invariant this file pins is "the prop union EQUALS the registered key + * set", and it is that invariant — not the number two — that keeps the two + * halves from drifting. The derivation below is what makes the difference + * legible: it reads the registrations off disk, so a retirement has to move + * BOTH sides or this file names the survivor that was left behind. + * * ## Why the derivation, and not a list of two strings * * A hand-written pair of keys is exactly what the defect survived under: the @@ -48,7 +59,7 @@ import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { ComponentRegistry } from '@object-ui/core'; -import type { KanbanSchema, ObjectKanbanSchema, ObjectGridSchema } from '@object-ui/types'; +import type { ObjectKanbanSchema, ObjectGridSchema } from '@object-ui/types'; import type { ObjectKanbanComponentProps } from '../ObjectKanban'; import { ObjectKanbanRenderer } from '../index'; import '../index'; @@ -67,13 +78,9 @@ type Prop = ObjectKanbanComponentProps['schema']; // while checking nothing — and `any` is precisely what this member would have // been widened to by the lazy fix. type _PropIsNotAny = Assert, false>>; -type _KanbanArmIsReal = Assert, false>>; type _ObjectKanbanArmIsReal = Assert, false>>; -// 1. Both registered node types are accepted. The second leg is the one this -// card added; the first is what objectui#7664 already required and must not -// regress. -type _AcceptsTheKanbanNode = Assert; +// 1. The registered node type is accepted. type _AcceptsTheObjectKanbanNode = Assert; // 2. And nothing else. `ObjectGridSchema` is the control: a sibling @@ -83,10 +90,16 @@ type _AcceptsTheObjectKanbanNode = Assert>; -// 3. The discriminant carries exactly the two registered keys. This is the +// 3. The discriminant carries exactly the registered key set. This is the // claim suite 2 checks the other half of: here, what the TYPE says; there, -// what `index.tsx` actually registers. -type _DiscriminantIsTheTwoKeys = Assert>; +// what `index.tsx` actually registers. ⚠️ It read `'kanban' | 'object-kanban'` +// until objectui#8802 retired the bare key; the equality — not the arity — +// is the invariant. +type _DiscriminantIsTheRegisteredKeys = Assert>; +// Non-vacuity for the line above: the RETIRED literal is no longer admitted. +// Without this leg an accidental widening back to `string` would still satisfy +// nothing visible here. +type _RetiredKanbanLiteralIsRefused = Assert>; /* -------------------------------------------------------------------------- */ /* Suite 2 — runtime, derived from the read site. */ @@ -108,13 +121,14 @@ describe('ObjectKanbanComponentProps.schema names every registered node type (ob expect(true).toBe(true); }); - it('index.tsx registers ObjectKanbanRenderer under exactly the two keys the prop union names', () => { - // Red when a third registration is added, or an existing key is renamed, - // without moving `ObjectKanbanComponentProps['schema']` to match. The - // literals here are the union's arms restated: `KanbanSchema['type']` is - // `'kanban'` and `ObjectKanbanSchema['type']` is `'object-kanban'`, which - // suite 1's `_DiscriminantIsTheTwoKeys` holds to the prop. - expect(registeredKeys()).toEqual(['kanban', 'object-kanban']); + it('index.tsx registers ObjectKanbanRenderer under exactly the keys the prop union names', () => { + // Red when a registration is added, or an existing key is renamed, without + // moving `ObjectKanbanComponentProps['schema']` to match. The literal here + // is the union restated: `ObjectKanbanSchema['type']` is `'object-kanban'`, + // which suite 1's `_DiscriminantIsTheRegisteredKeys` holds to the prop. + // ⚠️ It read `['kanban', 'object-kanban']` until objectui#8802 retired the + // bare key — and this line is what forced the prop to follow. + expect(registeredKeys()).toEqual(['object-kanban']); }); it('the extraction is lit — it finds the reader it claims to read, and no other renderer', () => { @@ -122,13 +136,22 @@ describe('ObjectKanbanComponentProps.schema names every registered node type (ob // return `[]` and a same-shaped `toEqual` against `[]` would pass forever. const keys = registeredKeys(); expect(keys.length).toBeGreaterThan(0); - // `'kanban-ui'` and `'kanban-enhanced'` are registered in the same file to - // OTHER renderers — the control that the pattern discriminates on the - // component, not merely on the word `register`. + // ⚠️ The control this leg used to run — `'kanban-ui'` / `'kanban-enhanced'` + // registered in the same file to OTHER renderers — RETIRED with those keys + // (objectui#8257). `KanbanRenderer` is still declared in the same file and + // is still NOT this renderer, so the discrimination the control existed to + // prove is asserted directly instead: the extraction must not pick up a + // `register(` call for a different component, and the retired keys must not + // resolve at all. expect(keys).not.toContain('kanban-ui'); expect(keys).not.toContain('kanban-enhanced'); - expect(ComponentRegistry.has('kanban-ui')).toBe(true); - expect(ComponentRegistry.has('kanban-enhanced')).toBe(true); + expect(ComponentRegistry.has('kanban-ui')).toBe(false); + expect(ComponentRegistry.has('kanban-enhanced')).toBe(false); + expect(ComponentRegistry.has('kanban')).toBe(false); + // Firing control for the three `false`s above — the same call answers + // `true` for the surviving key, so they are readings and not a registry + // that answers `false` to everything. + expect(ComponentRegistry.has('object-kanban')).toBe(true); }); it('every extracted key resolves to that renderer in the live registry', () => { diff --git a/packages/plugin-kanban/src/__tests__/objectFieldsIsAPropNotASchemaKey-7742.test.tsx b/packages/plugin-kanban/src/__tests__/objectFieldsIsAPropNotASchemaKey-7742.test.tsx index c5638d9f4b..5f215fffb9 100644 --- a/packages/plugin-kanban/src/__tests__/objectFieldsIsAPropNotASchemaKey-7742.test.tsx +++ b/packages/plugin-kanban/src/__tests__/objectFieldsIsAPropNotASchemaKey-7742.test.tsx @@ -84,8 +84,12 @@ const CARD = { id: 'c1', title: 'Painted card', owner: { _id: 'u1', name: 'Ann' /** The catalogue that makes `owner` collapsible. `lookup` is an expandable type. */ const FIELD_CATALOGUE = { owner: { type: 'lookup' } }; + // ⚠️ `object-kanban`, not the bare `kanban`: that node type key RETIRED + // (objectui#8802). The value is inert on this path — `KanbanRendererProps` + // types `schema.type` as `string` and nothing reads it — but a fixture that + // spells a retired key teaches it, and this file is read as an example. const BOARD = { - type: 'kanban', + type: 'object-kanban', columns: [{ id: 'todo', title: 'To Do', cards: [CARD] }], conditionalFormatting: RULE, } as const; diff --git a/packages/plugin-kanban/src/__tests__/quickAddIsDiagnosedNotDropped-8285.test.ts b/packages/plugin-kanban/src/__tests__/quickAddIsDiagnosedNotDropped-8285.test.ts index 976a5992bf..2097920bac 100644 --- a/packages/plugin-kanban/src/__tests__/quickAddIsDiagnosedNotDropped-8285.test.ts +++ b/packages/plugin-kanban/src/__tests__/quickAddIsDiagnosedNotDropped-8285.test.ts @@ -8,8 +8,18 @@ /** * objectui#8285 — what a REAL author is told when they write `quickAdd` on an - * `object-kanban` / `kanban` page (director-seat ruling of 2026-09-08, batch - * #91, slice C). + * `object-kanban` page (director-seat ruling of 2026-09-08, batch #91, slice + * C). + * + * ⚠️ This file landed while `ObjectKanbanRenderer` still answered to TWO keys. + * objectui#8802 / objectui#8257 retired `kanban`, `kanban-ui` and + * `kanban-enhanced`, and this file is the importer that retirement had to + * carry: it re-derives its host set from the registration calls and reads the + * LIVE registry, so it goes red on the merged tree the day the registrations + * go — which is the point of writing it this way. Its SUBJECT is unchanged and + * still pinned: the html tier NAMES the Quick Add pair instead of dropping the + * key. Only the set of tags that subject holds over has shrunk to the one key + * that resolves. * * The rule itself is pinned next door, over a fixture manifest * (`packages/sdui-parser/src/__tests__/kanban-quick-add-8285.test.ts`). THIS @@ -27,8 +37,9 @@ * * 3. THE TAG SET — `QUICK_ADD_HOST_TYPES` is restated data in a package that * must not depend on this one, so it is re-derived HERE from the - * registration calls. A third `ObjectKanbanRenderer` tag, or a rename, - * reddens this row rather than silently narrowing the diagnostic. + * registration calls. A second `ObjectKanbanRenderer` tag, or a rename, + * reddens this row rather than silently narrowing the diagnostic — and it + * is what caught the retirement. * 4. STILL NOT DECLARED — the ruling DIAGNOSES, it does not widen. A "fix" * that added `quickAdd` to `OBJECT_KANBAN_INPUTS` would publish a key the * renderer cannot honour, which is what objectui#8201 escalated rather @@ -90,9 +101,19 @@ const codesFor = (type: string, props: Record, key: string): st const declaredInputNames = (type: string, namespace?: string): string[] => ((ComponentRegistry.getConfig(type, namespace) as any)?.inputs ?? []).map((i: any) => i.name); +/** + * ⚠️ ONE entry since objectui#8802 retired the bare `kanban` key (and with it + * `view:kanban`, its namespaced twin). ⛔ Do not re-add a row here to "restore + * coverage": a tag no registration produces is not in the manifest, so + * `validate.ts` answers it with `unknown-component` and never walks its props + * — every row below would assert an empty diagnostic list for the wrong reason. + * The retired spellings are pinned as gone in + * `kanban-family-registry-keys-retired-8257.test.ts`, and the non-host half of + * the rule is pinned over a hand-built manifest in + * `packages/sdui-parser/src/__tests__/kanban-quick-add-8285.test.ts`. + */ const KANBAN_TAGS = [ { label: 'object-kanban', type: 'object-kanban', namespace: 'plugin-kanban' }, - { label: 'view:kanban', type: 'kanban', namespace: 'view' }, ] as const; /** The key names a strict parse of the block's spec schema refuses BY NAME. */ @@ -115,12 +136,19 @@ describe('objectui#8285 — the html tier names the Quick Add pair instead of dr it('the diagnostic covers exactly the tags `index.tsx` registers this renderer under', () => { expect(registeredKeys()).toEqual([...QUICK_ADD_HOST_TYPES].sort()); // Anti-vacuity + discrimination: a regex that matched nothing would return - // `[]`, and the two blocks registered in the SAME file to OTHER renderers - // must not be in the set — `kanban-ui` is the one the ruling keeps. - expect(registeredKeys().length).toBe(2); + // `[]`, so the count is asserted separately from the equality above. + expect(registeredKeys().length).toBe(1); + expect(registeredKeys()).not.toContain('kanban'); expect(registeredKeys()).not.toContain('kanban-ui'); expect(registeredKeys()).not.toContain('kanban-enhanced'); - expect(ComponentRegistry.has('kanban-ui')).toBe(true); + // ⚠️ Was `true`: the ruling this file landed under kept `kanban-ui`'s pair + // reachable as a TAG. objectui#8257 retired that registration, so the pair + // now survives only on the exported `KanbanRenderer` COMPONENT — which is + // what the diagnostic's message names. Kept as a reading rather than + // deleted: it is the discrimination that stops a future "restore the + // kanban-ish tags" fix from passing silently, and the retirement itself is + // pinned next door in `kanban-family-registry-keys-retired-8257.test.ts`. + expect(ComponentRegistry.has('kanban-ui')).toBe(false); for (const key of registeredKeys()) expect(ComponentRegistry.get(key)).toBe(ObjectKanbanRenderer); }); diff --git a/packages/plugin-kanban/src/__tests__/recordsSettledEmptyState-8827.test.tsx b/packages/plugin-kanban/src/__tests__/recordsSettledEmptyState-8827.test.tsx index 65bc151f58..5a75f70c92 100644 --- a/packages/plugin-kanban/src/__tests__/recordsSettledEmptyState-8827.test.tsx +++ b/packages/plugin-kanban/src/__tests__/recordsSettledEmptyState-8827.test.tsx @@ -54,6 +54,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, waitFor, act, cleanup } from '@testing-library/react'; import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; // Registers `object-kanban` and `kanban-ui`. +import { KanbanRenderer } from '../index'; import '../index'; /** @@ -276,18 +277,20 @@ describe('objectui#8827 — reverse: a genuinely empty board STILL announces', ( expect(find).not.toHaveBeenCalled(); }); - it('exit 5 — the schema-only `kanban-ui` entry has NO provider and DEFAULTS to settled', async () => { + it('exit 5 — the schema-only `KanbanRenderer` path has NO provider and DEFAULTS to settled', async () => { // No `ObjectKanban` on this path, so nothing supplies the signal. Its rows // arrive whole from their author and are settled by construction; the // context default is what says so. A default of `false` would leave every // authored board that happens to be empty silent forever. - render( - , - ); + // + // ⚠️ Driven by rendering `KanbanRenderer` DIRECTLY. It used to be reached + // through the `kanban-ui` registry key, which RETIRED (objectui#8257) — + // ⛔ and re-pointing this leg at `object-kanban` would have destroyed it + // rather than moved it: that key resolves to `ObjectKanbanRenderer`, which + // DOES mount `ObjectKanban` and therefore DOES supply the provider, so the + // "no provider" premise would be false and the assertion would pass for the + // wrong reason. The component is unchanged; only the lookup is gone. + render(); await waitFor(() => expect(emptyState()).not.toBeNull()); expect(emptyState()!.textContent).toContain('No cards'); diff --git a/packages/plugin-kanban/src/__tests__/scalarKeysAreDeclaredAndHonoured-8201.test.ts b/packages/plugin-kanban/src/__tests__/scalarKeysAreDeclaredAndHonoured-8201.test.ts index 8fd6d1f979..c8d504841d 100644 --- a/packages/plugin-kanban/src/__tests__/scalarKeysAreDeclaredAndHonoured-8201.test.ts +++ b/packages/plugin-kanban/src/__tests__/scalarKeysAreDeclaredAndHonoured-8201.test.ts @@ -70,10 +70,17 @@ import { manifestFromConfigs, validateTree } from '@object-ui/sdui-parser'; import { bucketCardsIntoColumns } from '../index'; import '../index'; -/** The two tags this one renderer is published under. */ +/** + * The tag this one renderer is published under. + * + * ⚠️ It was a LIST OF TWO — `object-kanban` and `view:kanban` — until + * objectui#8802 retired the bare `kanban` node type key (maintainer ruling + * 2026-09-09). The `it.each` shape is deliberately KEPT over the one survivor: + * the rows below are per-(tag, key), and collapsing them to bare `it`s would + * make re-adding a tag a rewrite rather than a one-line edit. + */ const KANBAN_TAGS = [ { label: 'object-kanban', type: 'object-kanban', namespace: 'plugin-kanban' }, - { label: 'view:kanban', type: 'kanban', namespace: 'view' }, ] as const; /** @@ -155,15 +162,20 @@ describe('objectui#8201 — object-kanban publishes the scalar keys it reads', ( expect(refusedByName({ objectName: 'task', bogusProp: 'x' })).toContain('bogusProp'); }); - it('both tags publish ONE shared list, so a hand-copy cannot drift', () => { - const [a, b] = KANBAN_TAGS.map( - ({ type, namespace }) => (ComponentRegistry.getConfig(type, namespace) as any)?.inputs, - ); - expect(a, 'object-kanban declares no inputs at all').toBeTruthy(); - expect(a.map((i: any) => i.name)).toEqual(b.map((i: any) => i.name)); - expect(a.map((i: any) => i.name)).toEqual( + it('⛔ the RETIRED `view:kanban` tag resolves to nothing, and the survivor still declares the list', () => { + // ⭐ This was "both tags publish ONE shared list, so a hand-copy cannot + // drift" (objectui#8201's row 5). Its second operand RETIRED with the bare + // `kanban` node type key (objectui#8802), so the sharing claim has nothing + // left to compare — and the honest replacement is the retirement itself, + // asserted with the surviving list as its firing control. + const survivor = (ComponentRegistry.getConfig('object-kanban', 'plugin-kanban') as any)?.inputs; + expect(survivor, 'object-kanban declares no inputs at all').toBeTruthy(); + expect(survivor.map((i: any) => i.name)).toEqual( expect.arrayContaining(DECLARED_SCALAR_KEYS.map(({ key }) => key)), ); + // The retired tag: gone from the registry under BOTH spellings it had. + expect(ComponentRegistry.getConfig('kanban', 'view')).toBeFalsy(); + expect(ComponentRegistry.has('kanban')).toBe(false); }); it('honoured — `groupBy` buckets records into the lane its value names', () => { diff --git a/packages/plugin-kanban/src/__tests__/structuredKeysAreDeclaredAndHonoured-8313.test.ts b/packages/plugin-kanban/src/__tests__/structuredKeysAreDeclaredAndHonoured-8313.test.ts index f09a527081..06c64600b0 100644 --- a/packages/plugin-kanban/src/__tests__/structuredKeysAreDeclaredAndHonoured-8313.test.ts +++ b/packages/plugin-kanban/src/__tests__/structuredKeysAreDeclaredAndHonoured-8313.test.ts @@ -76,10 +76,17 @@ import { manifestFromConfigs, validateTree } from '@object-ui/sdui-parser'; // bounded window). import '../index'; -/** The two tags this one renderer is published under. */ +/** + * The tag this one renderer is published under. + * + * ⚠️ It was a LIST OF TWO — `object-kanban` and `view:kanban` — until + * objectui#8802 retired the bare `kanban` node type key (maintainer ruling + * 2026-09-09). The `it.each` shape is deliberately KEPT over the one survivor: + * the rows below are per-(tag, key), and collapsing them to bare `it`s would + * make re-adding a tag a rewrite rather than a one-line edit. + */ const KANBAN_TAGS = [ { label: 'object-kanban', type: 'object-kanban', namespace: 'plugin-kanban' }, - { label: 'view:kanban', type: 'kanban', namespace: 'view' }, ] as const; /** @@ -179,15 +186,20 @@ describe('objectui#8313 — object-kanban publishes the array/object-armed keys expect(refusedByName({ objectName: 'task', bogusProp: 'x' })).toContain('bogusProp'); }); - it('both tags publish ONE shared list, so a hand-copy cannot drift', () => { - const [a, b] = KANBAN_TAGS.map( - ({ type, namespace }) => (ComponentRegistry.getConfig(type, namespace) as any)?.inputs, - ); - expect(a, 'object-kanban declares no inputs at all').toBeTruthy(); - expect(a.map((i: any) => i.name)).toEqual(b.map((i: any) => i.name)); - expect(a.map((i: any) => i.name)).toEqual( + it('⛔ the RETIRED `view:kanban` tag resolves to nothing, and the survivor still declares the list', () => { + // ⭐ This was "both tags publish ONE shared list, so a hand-copy cannot + // drift" (objectui#8201's row 5). Its second operand RETIRED with the bare + // `kanban` node type key (objectui#8802), so the sharing claim has nothing + // left to compare — and the honest replacement is the retirement itself, + // asserted with the surviving list as its firing control. + const survivor = (ComponentRegistry.getConfig('object-kanban', 'plugin-kanban') as any)?.inputs; + expect(survivor, 'object-kanban declares no inputs at all').toBeTruthy(); + expect(survivor.map((i: any) => i.name)).toEqual( expect.arrayContaining(DECLARED_STRUCTURED_KEYS.map(({ key }) => key)), ); + // The retired tag: gone from the registry under BOTH spellings it had. + expect(ComponentRegistry.getConfig('kanban', 'view')).toBeFalsy(); + expect(ComponentRegistry.has('kanban')).toBe(false); }); it('every declared description says what the renderer actually reads', () => { diff --git a/packages/plugin-kanban/src/index.test.ts b/packages/plugin-kanban/src/index.test.ts index 5fbf618588..55b0124f9c 100644 --- a/packages/plugin-kanban/src/index.test.ts +++ b/packages/plugin-kanban/src/index.test.ts @@ -6,6 +6,19 @@ * LICENSE file in the root directory of this source tree. */ +/** + * The plugin's registration surface — what importing this package puts in the + * registry. + * + * ⚠️ Every assertion in this file used to be about the `kanban-ui` entry: that + * it resolved, its label and category, its four declared `inputs`, and the + * `defaultProps` board it shipped. That entry RETIRED (objectui#8257, + * maintainer ruling 2026-09-09), together with `kanban-enhanced` and — from + * objectui#8802 — the bare `kanban` key. `object-kanban` is the one surviving + * registration, so the same questions are asked of it. The retirement itself is + * pinned in `__tests__/kanban-family-registry-keys-retired-8257.test.ts`. + */ + import { describe, it, expect } from 'vitest'; import { ComponentRegistry } from '@object-ui/core'; // Import all renderers to register them. This was a `beforeAll(async () => { @@ -18,97 +31,45 @@ import { ComponentRegistry } from '@object-ui/core'; import './index'; describe('Plugin Kanban', () => { - describe('kanban component', () => { + describe('object-kanban component', () => { it('should be registered in ComponentRegistry', () => { - const kanbanRenderer = ComponentRegistry.get('kanban-ui'); - expect(kanbanRenderer).toBeDefined(); + expect(ComponentRegistry.get('object-kanban')).toBeDefined(); + // Firing control on the same call: a name nothing registers is undefined, + // so the line above is a reading rather than a getter that always answers. + expect(ComponentRegistry.get('zzz-not-a-type')).toBeUndefined(); }); it('should have proper metadata', () => { - const config = ComponentRegistry.getConfig('kanban-ui'); + const config = ComponentRegistry.getConfig('object-kanban'); expect(config).toBeDefined(); - expect(config?.label).toBe('Kanban Board'); - expect(config?.icon).toBe('LayoutDashboard'); - expect(config?.category).toBe('plugin'); - expect(config?.inputs).toBeDefined(); - expect(config?.defaultProps).toBeDefined(); + expect(config?.label).toBe('Object Kanban'); + expect(config?.category).toBe('view'); + expect(config?.namespace).toBe('plugin-kanban'); }); it('should have expected inputs', () => { - const config = ComponentRegistry.getConfig('kanban-ui'); - const inputNames = config?.inputs?.map((input: any) => input.name) || []; - - expect(inputNames).toContain('columns'); - expect(inputNames).toContain('onCardMove'); - expect(inputNames).toContain('className'); - }); - - it('should have columns as required input', () => { - const config = ComponentRegistry.getConfig('kanban-ui'); - const columnsInput = config?.inputs?.find((input: any) => input.name === 'columns'); - - expect(columnsInput).toBeDefined(); - expect(columnsInput?.required).toBe(true); - expect(columnsInput?.type).toBe('array'); - expect(columnsInput?.description).toBeDefined(); - }); - - it('should have onCardMove as code input', () => { - const config = ComponentRegistry.getConfig('kanban-ui'); - const onCardMoveInput = config?.inputs?.find((input: any) => input.name === 'onCardMove'); - - expect(onCardMoveInput).toBeDefined(); - expect(onCardMoveInput?.type).toBe('code'); - expect(onCardMoveInput?.description).toBeDefined(); - }); - - it('should have sensible default props', () => { - const config = ComponentRegistry.getConfig('kanban-ui'); - const defaults = config?.defaultProps; - - expect(defaults).toBeDefined(); - expect(defaults?.columns).toBeDefined(); - expect(Array.isArray(defaults?.columns)).toBe(true); - expect(defaults?.columns.length).toBeGreaterThan(0); - expect(defaults?.className).toBe('w-full'); + const config = ComponentRegistry.getConfig('object-kanban'); + const names = (config?.inputs ?? []).map((i) => i.name); + expect(names).toEqual( + expect.arrayContaining(['objectName', 'groupBy', 'columns', 'cardTitle', 'conditionalFormatting']), + ); }); - it('should have default columns with proper structure', () => { - const config = ComponentRegistry.getConfig('kanban-ui'); - const defaults = config?.defaultProps; - const columns = defaults?.columns || []; - - // Verify at least 3 columns exist (todo, in-progress, done) - expect(columns.length).toBeGreaterThanOrEqual(3); - - // Verify each column has required properties - columns.forEach((column: any) => { - expect(column.id).toBeDefined(); - expect(column.title).toBeDefined(); - expect(column.cards).toBeDefined(); - expect(Array.isArray(column.cards)).toBe(true); - }); - - // Verify at least one column has cards - const hasCards = columns.some((column: any) => column.cards.length > 0); - expect(hasCards).toBe(true); + it('should have objectName as required input', () => { + const config = ComponentRegistry.getConfig('object-kanban'); + const objectName = (config?.inputs ?? []).find((i) => i.name === 'objectName'); + expect(objectName).toBeDefined(); + expect(objectName?.required).toBe(true); + // Control: not every declared input is required, so `true` above is a + // reading of this entry and not of a shape that marks everything. + expect((config?.inputs ?? []).find((i) => i.name === 'columns')?.required).toBeFalsy(); }); - it('should have cards with proper structure', () => { - const config = ComponentRegistry.getConfig('kanban-ui'); - const defaults = config?.defaultProps; - const columns = defaults?.columns || []; - - // Find a column with cards - const columnWithCards = columns.find((column: any) => column.cards.length > 0); - expect(columnWithCards).toBeDefined(); - - const card = columnWithCards.cards[0]; - expect(card.id).toBeDefined(); - expect(card.title).toBeDefined(); - expect(card.description).toBeDefined(); - expect(card.badges).toBeDefined(); - expect(Array.isArray(card.badges)).toBe(true); + it('every declared input carries a type', () => { + const config = ComponentRegistry.getConfig('object-kanban'); + const inputs = config?.inputs ?? []; + expect(inputs.length).toBeGreaterThan(0); + expect(inputs.filter((i) => !i.type)).toEqual([]); }); }); }); diff --git a/packages/plugin-kanban/src/index.tsx b/packages/plugin-kanban/src/index.tsx index 6d3e73909d..7ec8e6479e 100644 --- a/packages/plugin-kanban/src/index.tsx +++ b/packages/plugin-kanban/src/index.tsx @@ -108,7 +108,8 @@ export function bucketCardsIntoColumns( } // Export types for external use -export type { KanbanSchema, KanbanCard, KanbanColumn, CardTemplate, ColumnWidthConfig, InlineFieldDefinition } from './types'; +// ⛔ `KanbanSchema` RETIRED with the bare `kanban` node type key (objectui#8802). +export type { KanbanCard, KanbanColumn, CardTemplate, ColumnWidthConfig, InlineFieldDefinition } from './types'; export { ObjectKanban }; export type { ObjectKanbanComponentProps } from './ObjectKanban'; @@ -135,7 +136,6 @@ export type { UseQuickAddReorderOptions, UseQuickAddReorderReturn } from './useQ // 🚀 Lazy load the implementation files const LazyKanban = React.lazy(() => import('./KanbanImpl')); -const LazyKanbanEnhanced = React.lazy(() => import('./KanbanEnhanced')); export interface KanbanRendererProps { schema: { @@ -248,163 +248,103 @@ export const KanbanRenderer: React.FC = ({ schema, objectFi ); }; -// Register the component with the ComponentRegistry -ComponentRegistry.register( - 'kanban-ui', - KanbanRenderer, - { - namespace: 'plugin-kanban', - label: 'Kanban Board', - icon: 'LayoutDashboard', - category: 'plugin', - inputs: [ - { - name: 'columns', - type: 'array', - description: 'Array of { id, title, cards, limit, className }', - required: true - }, - { - name: 'onCardMove', - type: 'code', - description: 'Callback when a card is moved' }, - { - name: 'className', - type: 'string' } - ], - defaultProps: { - columns: [ - { - id: 'todo', - title: 'To Do', - cards: [ - { - id: 'card-1', - title: 'Task 1', - description: 'This is the first task', - badges: [ - { label: 'High Priority', variant: 'destructive' }, - { label: 'Feature', variant: 'default' } - ] - }, - { - id: 'card-2', - title: 'Task 2', - description: 'This is the second task', - badges: [ - { label: 'Bug', variant: 'destructive' } - ] - } - ] - }, - { - id: 'in-progress', - title: 'In Progress', - limit: 3, - cards: [ - { - id: 'card-3', - title: 'Task 3', - description: 'Currently working on this', - badges: [ - { label: 'In Progress', variant: 'default' } - ] - } - ] - }, - { - id: 'done', - title: 'Done', - cards: [ - { - id: 'card-4', - title: 'Task 4', - description: 'This task is completed', - badges: [ - { label: 'Completed', variant: 'outline' } - ] - }, - { - id: 'card-5', - title: 'Task 5', - description: 'Another completed task', - badges: [ - { label: 'Completed', variant: 'outline' } - ] - } - ] - } - ], - className: 'w-full' - } - } -); +/** + * ⛔ The `kanban-ui` node type key is RETIRED (objectui#8257, maintainer ruling + * 2026-09-09), together with `kanban-enhanced` below. `KanbanRenderer` itself + * stays exported and stays in use — `ObjectKanban` renders it — it is only the + * REGISTRY KEY that is gone. + * + * ## The measurement the ruling was taken on + * + * Exact node-type spellings, whole repo: `kanban-ui` was authored 0 times in + * JSON and 0 times in TS/TSX as a registry-resolved node, against a firing + * control of 2 JSON / 128 TS occurrences for the live sibling `object-grid` and + * a silent control (`zzz-not-a-type`, 0). ⇒ a registered type key no document + * in this repository has ever authored. Declaring an arm for it would have + * committed the repo to a validation face for a spelling with no writers — the + * opposite of what ADR-0049 enforce-or-remove asks. + * + * ## Why unregistering is the whole retirement + * + * ⚠️ `BaseSchema` closes with `[key: string]: any` and `BaseSchemaCore` ends + * `.passthrough()`, so a dropped MEMBER KEY is KEPT, not refused (objectui#7664). + * That hazard needs a schema face to arise on, and this key never had one: + * measured whole-repo, `@object-ui/types` declares `kanban-ui` as a component + * node type ZERO times (firing control: `object-kanban`, 2 — `objectql.ts` and + * its Zod mirror). There is no arm to convert into a named refusal. + * ⇒ Registration-only retirement. + * + * ## ⭐ What this closes as a side effect — objectui#8818 + * + * `SchemaRenderer` strips a fixed, enumerated metadata list and spreads the + * REST as React props. `objectFields` is not on that list, and `KanbanRenderer` + * — registered here for `kanban-ui` — declares `objectFields` as a real prop + * (objectui#7742). So an AUTHORED `objectFields` reached the predicate layer + * verbatim on this entry, with no schema face declaring or judging it. + * Retiring this registration closes that path: nothing resolves `kanban-ui` any + * more, so no authored node reaches `KanbanRenderer` through the registry. + * + * ⚠️ This closes the ENTRY, ⛔ not the CLASS. `SchemaRenderer` still spreads + * every unstripped key; if another renderer ever declares an `objectFields` + * prop the hole returns. objectui#8818's option (a) — stripping at the + * `SchemaRenderer` boundary — is the one that would close the class, and it is + * still open. + * + * Pinned in `src/__tests__/kanban-family-registry-keys-retired-8257.test.ts`. + */ -// Standard Export Protocol - for manual integration +/** + * Standard Export Protocol — for manual integration. + * + * ⛔ The `kanban`, `kanban-enhanced` and `kanban-ui` keys are RETIRED + * (objectui#8802 / objectui#8257, maintainer rulings 2026-09-09), so this map + * publishes the one surviving spelling. A host that mounted the retired keys + * from here was re-teaching them under its own registry; `object-kanban` is the + * key to mount. + */ export const kanbanComponents = { - 'kanban': KanbanRenderer, - 'kanban-enhanced': LazyKanbanEnhanced, 'object-kanban': ObjectKanban, }; -// Register enhanced Kanban -ComponentRegistry.register( - 'kanban-enhanced', - ({ schema }: { schema: any }) => { - const processedColumns = React.useMemo(() => { - const { columns = [], data, groupBy } = schema; - if (data && groupBy && Array.isArray(data)) { - const groups = data.reduce((acc, item) => { - const key = item[groupBy]; - if (!acc[key]) acc[key] = []; - acc[key].push(item); - return acc; - }, {} as Record); - return columns.map((col: any) => ({ - ...col, - cards: [...(col.cards || []), ...(groups[col.id] || [])] - })); - } - return columns; - }, [schema]); - - return ( - }> - - - ); - }, - { - namespace: 'plugin-kanban', - label: 'Kanban Board (Enhanced)', - icon: 'LayoutGrid', - category: 'plugin', - inputs: [ - { name: 'columns', type: 'array', required: true }, - { name: 'enableVirtualScrolling', type: 'boolean' }, - { name: 'virtualScrollThreshold', type: 'number' }, - { name: 'onCardMove', type: 'code' }, - { name: 'onColumnToggle', type: 'code' }, - { name: 'className', type: 'string' } - ], - defaultProps: { - columns: [], - enableVirtualScrolling: false, - virtualScrollThreshold: 50, - className: 'w-full' - } - } -); +/** + * ⛔ The `kanban-enhanced` node type key is RETIRED (objectui#8257, maintainer + * ruling 2026-09-09) — the card's own subject. + * + * ## What went, and what went with it + * + * The registration read `onColumnToggle`, `enableVirtualScrolling` and + * `virtualScrollThreshold` off `schema` and declared them as `inputs`, while + * `@object-ui/types` declared no `kanban-enhanced` arm at all: the type was + * dispatched by the registry and validated by nothing but `BaseSchema`'s + * passthrough. ⇒ Re-measured on this branch and CONFIRMED rather than assumed: + * with the registration gone those three keys have NO authorable surface left + * anywhere in the repo — 0 declarations on any schema face, 0 remaining + * `inputs` entries, 0 read sites (firing control on the same instrument: + * `groupBy`, which keeps 1 declaration + read sites on the surviving + * `object-kanban` face). objectui#8257's question is resolved by the removal of + * its subject, not by an answer. + * + * ## The measurement the ruling was taken on + * + * `kanban-enhanced` was authored 0 times in JSON and 0 times in TS/TSX, against + * the same firing control (`object-grid`, 2 JSON / 128 TS) and silent control + * (`zzz-not-a-type`, 0) the `kanban-ui` note above cites. + * + * `KanbanEnhanced.tsx` itself is untouched on disk. ⛔ It is NOT, and never + * was, reachable from outside this package: `package.json` `exports` publishes + * exactly two entries — `.` and `./style.css` — and this barrel does not + * re-export the component, so `@object-ui/plugin-kanban/KanbanEnhanced` has + * never been a resolvable specifier for a consumer. (An earlier revision of + * this note claimed it was; that claim was wrong and is corrected here rather + * than deleted, because it is what a reader would otherwise copy.) What this + * card removes is the registry key and the `React.lazy` wrapper that existed + * only to serve it; what it leaves behind is a module with zero non-test + * importers — `cardPredicateScope.test.tsx` reaches it by relative path. + * ⛔ Deleting the file is a FURTHER narrowing of published source and needs its + * own maintainer ruling, which this card does not carry, so it stays. + * + * Pinned in `src/__tests__/kanban-family-registry-keys-retired-8257.test.ts`. + */ /** * What `ObjectKanban` reads for its own query: `objectName`, `filter` and @@ -599,15 +539,40 @@ ComponentRegistry.register( inputs: [...OBJECT_KANBAN_INPUTS], } ); -ComponentRegistry.register( - 'kanban', - ObjectKanbanRenderer, - { - namespace: 'view', - label: 'Kanban Board', - category: 'view', - // Same renderer as `object-kanban`, therefore the same declared surface — - // now SHARED rather than hand-copied (objectui#8201). - inputs: [...OBJECT_KANBAN_INPUTS], - } -); \ No newline at end of file +/** + * ⛔ The bare `kanban` node type key is RETIRED (objectui#8802, maintainer + * ruling 2026-09-09: 「从我们的业务需求角度,我应该只需要 `object-kanban`」). + * `object-kanban` above is the one spelling this plugin serves. + * + * ## What this dissolves rather than patches + * + * The two published faces of this key returned OPPOSITE verdicts on the same + * document: the registry `inputs` above (shared into both registrations by + * objectui#8201) declared `titleField`, while the `kanban` Zod arm refused it + * BY NAME after batch #70. With the key gone there is no arm left to disagree + * with — objectui#8802's four options are all moot. + * + * ## ⚠️ Unlike its `gantt` / `kanban-ui` / `kanban-enhanced` siblings, this one + * had a DECLARED FACE, so unregistering is only half of it + * + * `@object-ui/types` declared `KanbanSchema` with `type: 'kanban'` and mirrored + * it in `zod/complex.zod.ts`. A plain deletion there would have been the + * objectui#7664 failure: `BaseSchema` is `.passthrough()`, so a document naming + * a dropped spelling validates GREEN and renders nothing. The Zod arm is + * therefore a NAMED REFUSAL (`retiredNodeType()`, `zod/tombstone.zod.ts`) + * pointing the author at `object-kanban`, and the TS face leaves `ComplexSchema` + * and `SchemaRegistry` so `tsc` refuses the literal at the authoring site. + * + * ## ⛔ Two layers, and only one of them moved + * + * `kanban` is ALSO a STORED `NamedListView.type` — the value `CreateViewDialog` + * writes and every tenant's database holds. That layer is untouched: + * `packages/plugin-view/src/ObjectView.tsx`'s `switch (viewType)` already emits + * `object-kanban` for a stored `kanban` view, as it emits `object-*` for all + * twelve view types. ⇒ Every kanban view any user ever created through the + * console already renders through the surviving spelling; this retirement moves + * zero stored documents. + * + * Pinned in `src/__tests__/kanban-family-registry-keys-retired-8257.test.ts` + * and `@object-ui/types`' `__tests__/bare-kanban-node-key-retired-8802.test.ts`. + */ \ No newline at end of file diff --git a/packages/plugin-kanban/src/types.ts b/packages/plugin-kanban/src/types.ts index 3ca61eefb7..13b3b85a76 100644 --- a/packages/plugin-kanban/src/types.ts +++ b/packages/plugin-kanban/src/types.ts @@ -32,10 +32,13 @@ * `InlineFieldDefinition` stays local: it is the quick-add FORM's field * definition (`InlineQuickAdd.tsx`), not a member of the authored board. */ +// ⛔ `KanbanSchema` is NOT re-exported any more: it RETIRED with the bare +// `kanban` node type key (objectui#8802, maintainer ruling 2026-09-09). +// `ObjectKanbanSchema` (also from `@object-ui/types`) is the surviving face, +// and `ObjectKanban` takes it directly. export type { KanbanCard, KanbanColumn, - KanbanSchema, CardTemplate, ColumnWidthConfig, } from '@object-ui/types'; diff --git a/packages/runner/src/plugin-integration.test.ts b/packages/runner/src/plugin-integration.test.ts index 3273700b83..f1224839f9 100644 --- a/packages/runner/src/plugin-integration.test.ts +++ b/packages/runner/src/plugin-integration.test.ts @@ -16,13 +16,32 @@ describe('Plugin Integration Protocol', () => { describe('Kanban Plugin', () => { it('should export components object for manual registration', () => { expect(kanbanComponents).toBeDefined(); - expect(kanbanComponents.kanban).toBeDefined(); + expect(kanbanComponents['object-kanban']).toBeDefined(); }); it('should contain valid React component', () => { - const Component = kanbanComponents.kanban; + const Component = kanbanComponents['object-kanban']; expect(typeof Component).toBe('function'); // React components are functions }); + + /** + * Control for the re-key above (objectui#8802 / objectui#8257). + * + * This map published a bare `kanban` key until those two cards retired the + * `kanban`, `kanban-ui` and `kanban-enhanced` node type keys. Asserting the + * surviving key on its own would pass just as well against a map that had + * merely been renamed wholesale, so pin BOTH directions: the new key + * resolves to a component, and the retired spellings resolve to nothing. + * `kanbanComponents` is the manual-registration face a host copies keys + * from, so a retired key silently reappearing here would re-teach it. + */ + it('publishes only the surviving `object-kanban` key', () => { + expect(Object.keys(kanbanComponents)).toEqual(['object-kanban']); + const retired = kanbanComponents as Record; + expect(retired.kanban).toBeUndefined(); + expect(retired['kanban-ui']).toBeUndefined(); + expect(retired['kanban-enhanced']).toBeUndefined(); + }); }); describe('Charts Plugin', () => { @@ -45,8 +64,8 @@ describe('Plugin Integration Protocol', () => { // Note: In a real app we wouldn't clear, but here we want to prove registration works // Act: Manually register - if (kanbanComponents?.kanban) { - ComponentRegistry.register('test-kanban-manual', kanbanComponents.kanban); + if (kanbanComponents?.['object-kanban']) { + ComponentRegistry.register('test-kanban-manual', kanbanComponents['object-kanban']); } // Assert diff --git a/packages/sdui-parser/src/__tests__/kanban-quick-add-8285.test.ts b/packages/sdui-parser/src/__tests__/kanban-quick-add-8285.test.ts index 9b6864a60c..84eead2d98 100644 --- a/packages/sdui-parser/src/__tests__/kanban-quick-add-8285.test.ts +++ b/packages/sdui-parser/src/__tests__/kanban-quick-add-8285.test.ts @@ -1,5 +1,5 @@ /** - * ObjectUI — `quickAdd` on the two `ObjectKanbanRenderer` tags is DIAGNOSED, + * ObjectUI — `quickAdd` on the `ObjectKanbanRenderer` tag is DIAGNOSED, * and diagnosed truthfully (objectui#8285, ruling of 2026-09-08, batch #91) * * The mechanism, over a hand-built manifest that mirrors the real @@ -23,9 +23,11 @@ * * - the unknown-prop control — a fix that suppressed the generic walk for * this block rather than for this one key; - * - `kanban-ui` — a fix scoped to "a kanban-ish tag", which would hit the one - * block the ruling explicitly keeps untouched (a React host there CAN pass - * the function); + * - the two RETIRED kanban-ish tags (`kanban`, `kanban-ui`) — a fix scoped + * to "a kanban-ish tag" rather than to the one registration that serves + * `ObjectKanbanRenderer`. Both left the registry with objectui#8802 / + * objectui#8257, so neither is a host any more; they stay declared in the + * hand-built manifest below precisely so this row can still discriminate; * - `quickAdd: false` — a fix keyed on the KEY's presence rather than on the * author asking for the control, which would warn about a value that got * exactly what it asked for; @@ -46,9 +48,17 @@ import { import type { Diagnostic, Manifest } from '../types.js'; /** - * The three kanban blocks, carrying the inputs their real registrations - * declare — `quickAdd` on NONE of them, which is the state this change leaves - * untouched. `card` is a non-kanban control block. + * The three kanban blocks, carrying the inputs their registrations declared — + * `quickAdd` on NONE of them, which is the state this change leaves untouched. + * `card` is a non-kanban control block. + * + * ⚠️ `kanban` and `kanban-ui` are RETIRED registrations (objectui#8802, + * objectui#8257) and are kept here ON PURPOSE, as the discrimination controls + * below: a manifest is an argument to `validateTree`, so this file can still + * ask what the rule says about a tag the live registry no longer produces — + * and the answer must be "not a host". ⛔ Do not read their presence as a + * claim that either tag resolves; the live-registry half of that question is + * the sibling test's, and it answers `unknown-component`. */ const manifest: Manifest = manifestFromConfigs([ { @@ -80,10 +90,19 @@ const codesFor = (node: Record, key: string): string[] => const HOST_TAGS = [...QUICK_ADD_HOST_TYPES].sort(); describe('objectui#8285 — an authored `quickAdd` is diagnosed on the ObjectKanban tags', () => { - it('the host set is the two ObjectKanbanRenderer tags, and is not empty', () => { + it('the host set is the one surviving ObjectKanbanRenderer tag, and is not empty', () => { // Anti-vacuity for every row below: an empty set would make the negative // rows trivially true and the positive rows unreachable. - expect(HOST_TAGS).toEqual(['kanban', 'object-kanban']); + // + // ⚠️ This row is the ONLY thing in this package that a change to + // `QUICK_ADD_HOST_TYPES` reddens — every other row is `it.each(HOST_TAGS)` + // and re-derives itself from the constant, so a narrowing would otherwise + // just delete cases silently. `kanban` left the set with its registration + // (objectui#8802): `checkKanbanQuickAdd` is reached only from + // `validate.ts`'s prop walk, which runs only for a tag the manifest + // RESOLVED, so a tag no registration produces is answered by + // `unknown-component` one level up and never reaches this module. + expect(HOST_TAGS).toEqual(['object-kanban']); }); it.each(HOST_TAGS)('<%s> — an authored `quickAdd: true` draws exactly one warning', (tag) => { @@ -109,7 +128,12 @@ describe('objectui#8285 — an authored `quickAdd` is diagnosed on the ObjectKan // to the contract with no explanation, which is the state being fixed. const [{ message }] = diagnose({ type: tag, objectName: 'task', quickAdd: true }); expect(message).toContain('onQuickAdd'); - expect(message).toContain('kanban-ui'); + // ⚠️ The remedy names the COMPONENT, not the `kanban-ui` TAG it used to + // name: objectui#8257 retired that registration, so a page written to the + // old advice would now draw `unknown-component` — an ERROR. `KanbanRenderer` + // is still exported from `@object-ui/plugin-kanban` and still forwards both + // halves by identity, so it is the surviving way to get the pair. + expect(message).toContain('KanbanRenderer'); }); it.each(HOST_TAGS)('<%s> — control: a genuinely unknown prop is still reported', (tag) => { @@ -139,16 +163,21 @@ describe('objectui#8285 — an authored `quickAdd` is diagnosed on the ObjectKan ).toEqual(['unknown-prop']); }); - it('control: `kanban-ui` is untouched — the ruling keeps its pair, a React host can supply it', () => { - // The one block where `quickAdd` / `onQuickAdd` is honoured, because - // `KanbanRenderer` forwards both halves by identity. Green in both worlds; - // guards a fix scoped to "a kanban-ish tag". - const diagnostics = diagnose({ type: 'kanban-ui', columns: [], quickAdd: true }); - expect(diagnostics.map((d) => d.code)).not.toContain(INERT_QUICK_ADD); - expect(codesFor({ type: 'kanban-ui', columns: [], quickAdd: true }, QUICK_ADD_KEY)).toEqual([ - 'unknown-prop', - ]); - }); + it.each(['kanban-ui', 'kanban'])( + 'control: `%s` is NOT a host — a kanban-ish tag alone does not arm this diagnostic', + (tag) => { + // Guards a fix scoped to "a kanban-ish tag". Both spellings are RETIRED + // registrations (objectui#8257, objectui#8802), so neither can reach this + // module through a manifest built from the live registry at all — they are + // declared in this file's hand-built manifest so the discrimination is + // still measurable here, which is the one thing a synthetic manifest can + // do that the live one cannot. `kanban-ui`'s pair itself survives on the + // exported `KanbanRenderer` component, which no tag resolves to. + const node = { type: tag, columns: [], quickAdd: true }; + expect(diagnose(node).map((d) => d.code)).not.toContain(INERT_QUICK_ADD); + expect(codesFor(node, QUICK_ADD_KEY)).toEqual(['unknown-prop']); + }, + ); it('control: a non-kanban block is untouched', () => { expect(diagnose({ type: 'card', quickAdd: true }).map((d) => d.code)).toEqual(['unknown-prop']); diff --git a/packages/sdui-parser/src/kanban-quick-add.ts b/packages/sdui-parser/src/kanban-quick-add.ts index 1562ef23b8..57e10ca01d 100644 --- a/packages/sdui-parser/src/kanban-quick-add.ts +++ b/packages/sdui-parser/src/kanban-quick-add.ts @@ -1,5 +1,5 @@ /** - * ObjectUI — the inert `quickAdd` on the two `ObjectKanbanRenderer` tags + * ObjectUI — the inert `quickAdd` on the `ObjectKanbanRenderer` tag * (objectui#8285, director-seat ruling of 2026-09-08, decision batch #91) * * ## What is wrong, measured rather than argued @@ -66,10 +66,14 @@ * * ## Scope — the three places this deliberately does not fire * - * - `kanban-ui`. The ruling keeps the `quickAdd` / `onQuickAdd` pair there - * untouched: that block is `KanbanRenderer`, which forwards both halves by - * identity, and a React host mounting it CAN pass the function. An authored - * `quickAdd` there still draws whatever the generic prop rules draw. + * - `KanbanRenderer`, the component. The ruling keeps the `quickAdd` / + * `onQuickAdd` pair there untouched: it forwards both halves by identity, + * and a React host mounting it CAN pass the function. ⚠️ It is no longer + * reachable as a TAG — objectui#8257 retired the `kanban-ui` registration, + * so on this tier `` is now an `unknown-component` ERROR, not a + * block with a working pair. The component stays exported from + * `@object-ui/plugin-kanban`, which is the surviving way to get the pair, + * and is what this module's message names. * - `quickAdd: false`, and any other falsy value. The renderer's own gate is * `quickAdd && onQuickAdd`, so a falsy value asks for no control and gets * none — the author got what they wrote, and nothing was dropped. The @@ -88,17 +92,35 @@ export const INERT_QUICK_ADD = 'inert-quick-add'; export const QUICK_ADD_KEY = 'quickAdd'; /** - * The tags served by `ObjectKanbanRenderer` — the two registrations in - * `packages/plugin-kanban/src/index.tsx`, which is where a third one would + * The tags served by `ObjectKanbanRenderer` — the registrations in + * `packages/plugin-kanban/src/index.tsx`, which is where another one would * appear. Restated here as data because this package is deliberately free of * any dependency on the registry or on a plugin (see `RegistryConfigLike` in * `index.ts`), and re-derived from that file's registration calls by * `packages/plugin-kanban/src/__tests__/quickAddIsDiagnosedNotDropped-8285.test.ts`, * so a renamed or added tag reddens a named row rather than silently narrowing - * this set. `kanban-ui` and `kanban-enhanced` are registered in the same file - * to OTHER renderers and are not here — see the scope notes above. + * this set. + * + * ⚠️ **`kanban` left this set with its registration** (objectui#8802, ruled + * 2026-09-09; `kanban-ui` and `kanban-enhanced` went the same way under + * objectui#8257). Keeping it would have been dead data, MEASURED and not + * assumed: `checkKanbanQuickAdd` has exactly one call site, inside + * `validate.ts`'s prop walk, and that walk runs only in the branch where + * `manifest.components[node.type]` RESOLVED. A tag no registration produces is + * answered one level up, by `unknown-component`, and its props are never walked + * at all — so on a manifest built from the live registry a `` + * node draws `error/unknown-component` and nothing else, against a firing + * control on `` that draws `warning/inert-quick-add`. + * ⛔ Nothing is silently dropped by the narrowing: the retired spelling is + * refused BY NAME at the tag, which is a louder answer than this warning, and + * stacking both would be the two-diagnostics-for-one-mistake shape + * `checkMemberTypes` already refuses (objectui#8067). The one path that could + * still reach a `kanban` entry is a HAND-BUILT manifest declaring a component + * of that name — which, after the retirement, is somebody else's component, and + * the message below asserts things about `ObjectKanban` that would be false of + * it. */ -export const QUICK_ADD_HOST_TYPES: ReadonlySet = new Set(['object-kanban', 'kanban']); +export const QUICK_ADD_HOST_TYPES: ReadonlySet = new Set(['object-kanban']); const isPlainObject = (v: unknown): v is Record => typeof v === 'object' && v !== null && !Array.isArray(v); @@ -133,7 +155,7 @@ export function checkKanbanQuickAdd(tag: string, key: string, value: unknown): D `BOTH "${QUICK_ADD_KEY}" and an "onQuickAdd" handler, and this block supplies neither half ` + `of the pair: "onQuickAdd" takes a FUNCTION, which no page on this tier can write (this tier ` + `parses, never executes) and which this board substitutes none of its own for. Drop the key, ` + - `or render from a React host that passes "onQuickAdd".`, + `or mount KanbanRenderer from "@object-ui/plugin-kanban" in a React host that passes "onQuickAdd".`, tag, }; } diff --git a/packages/types/README.md b/packages/types/README.md index 579faa8925..55d4f12e43 100644 --- a/packages/types/README.md +++ b/packages/types/README.md @@ -233,7 +233,7 @@ Menus and navigation: Advanced composite components: -- `KanbanSchema` - Kanban board (the dialect `@object-ui/plugin-kanban` renders) +- `ObjectKanbanSchema` - Kanban board (`object-kanban`; the bare `kanban` node type key and its `KanbanSchema` arm retired in objectui#8802) - `CalendarViewSchema` - Calendar with events - `FilterBuilderSchema` - Advanced filter builder - `CarouselSchema` - Image/content carousel diff --git a/packages/types/examples/zod-validation-example.ts b/packages/types/examples/zod-validation-example.ts index 69f1c08c3b..790e0dc6d5 100644 --- a/packages/types/examples/zod-validation-example.ts +++ b/packages/types/examples/zod-validation-example.ts @@ -18,7 +18,7 @@ import { FormSchema, CardSchema, DataTableSchema, - KanbanSchema, + ObjectKanbanSchema, } from '../src/zod/index.zod'; // The failure accessor below is `error.issues`. Zod 4 removed the `.errors` @@ -131,42 +131,21 @@ if (!dataTableResult.success) { console.error('DataTable errors:', dataTableResult.error.issues); } -// Example 6: Validate a Kanban component — the dialect `@object-ui/plugin-kanban` -// renders (objectui#7664): an object-bound board, plus static columns whose -// cards carry `badges`. +// Example 6: Validate a Kanban component. +// +// ⚠️ Spelled `object-kanban`, not the bare `kanban` this example used to author: +// the bare node type key RETIRED (objectui#8802, maintainer ruling 2026-09-09) +// and `KanbanSchema` retired with it. `ObjectKanbanSchema` is the surviving +// face, and `groupBy` is REQUIRED on it. const kanbanExample = { - type: 'kanban' as const, + type: 'object-kanban' as const, objectName: 'tasks', groupBy: 'status', - cardTitle: 'title', + titleField: 'title', cardFields: ['assignee', 'due_date'], - columns: [ - { - id: 'todo', - title: 'To Do', - cards: [ - { - id: '1', - title: 'Task 1', - description: 'Do something', - badges: [{ label: 'High', variant: 'destructive' as const }], - }, - ], - }, - { - id: 'in-progress', - title: 'In Progress', - cards: [], - }, - { - id: 'done', - title: 'Done', - cards: [], - }, - ], }; -const kanbanResult = KanbanSchema.safeParse(kanbanExample); +const kanbanResult = ObjectKanbanSchema.safeParse(kanbanExample); console.log('Kanban validation:', kanbanResult.success ? 'PASSED ✓' : 'FAILED ✗'); if (!kanbanResult.success) { console.error('Kanban errors:', kanbanResult.error.issues); diff --git a/packages/types/src/__tests__/bare-kanban-node-key-retired-8802.test.ts b/packages/types/src/__tests__/bare-kanban-node-key-retired-8802.test.ts new file mode 100644 index 0000000000..26f01dfd63 --- /dev/null +++ b/packages/types/src/__tests__/bare-kanban-node-key-retired-8802.test.ts @@ -0,0 +1,186 @@ +/** + * 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. + */ + +/** + * The bare `kanban` NODE TYPE key is retired, and a document naming it is + * refused BY NAME (objectui#8802 — maintainer ruling 2026-09-09, verbatim and + * untranslated: 「从我们的业务需求角度,我应该只需要 `object-kanban`」). + * + * ## What it supersedes, and why those files are gone rather than edited + * + * Two pin files had the retired arm as their whole subject and are replaced by + * this one — the third disposition in fixture triage, "the fixture pins exactly + * the branch you deleted", where an edit would have left assertions that keep + * passing because nothing is produced any more: + * + * - `kanban-arm-batch70-7742.test.ts` — batch #70's four arm-scoped refusals + * (`allowCollapse` / `cardTemplates` / `columnWidths` / `titleField`) and + * its one widening (`navigation`). Every one of them was a claim about + * `type: 'kanban'` documents, which no longer exist. + * - `kanban-plugin-dialect-authoritative-7664.test.ts` — that the `'kanban'` + * arm declared the plugin dialect, that `SchemaRegistry['kanban']` named + * it, and a 20-member census of it. + * + * ⭐ The one claim inside them that was NOT about the retired arm is carried + * forward here as suite 3: the SIBLING `object-kanban` arm's verdicts must not + * move. That is what turns this from a deletion into a retirement. + * + * ## ⚠️ The mechanism, which is NOT the passthrough rule + * + * `BaseSchema` is `.passthrough()`, so a dropped MEMBER key is KEPT, not + * refused — the failure objectui#7664's first cut shipped at `onCardClick`. + * ⛔ That rule does not govern a TYPE LITERAL. `AnyComponentSchema` is a + * DISCRIMINATED union (objectui#8498), so it selects one arm from the authored + * literal and an unclaimed literal matches nothing: deleting the arm would + * already refuse. What deleting would NOT do is say why or what to write + * instead — the union answers a missed discriminator with its own remedy-free + * `Invalid input`. So the arm stays, claiming the literal, and refuses through + * `retiredNodeType()`. Suite 1 measures the refusal AND its remedy; suite 4 + * measures that the generic path is still what an unknown literal gets, which + * is the control that separates the two. + * + * ## ⛔ The layer this retirement does NOT touch + * + * `kanban` also names a STORED `NamedListView.type` — the value + * `CreateViewDialog` writes and every tenant's database holds. Suite 5 pins that + * it is still admitted, because a migration that pattern-matched the string + * would have rewritten it and broken every stored kanban view in every + * deployment. `plugin-view`'s `ObjectView` maps a stored `kanban` view onto the + * `object-kanban` NODE type, so this retirement moves zero stored documents. + */ + +import { describe, it, expect } from 'vitest'; +import { safeValidateSchema } from '../zod/index.zod'; +import type { ComplexSchema } from '../complex'; +import type { SchemaRegistry, ComponentType } from '../registry'; +import type { NamedListView, ObjectKanbanSchema } from '../objectql'; + +/* -------------------------------------------------------------------------- */ +/* Compile-time pins — read by tsc -p tsconfig.test.json, not by vitest. */ +/* -------------------------------------------------------------------------- */ + +type Assert = T; +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +type IsNever = [T] extends [never] ? true : false; + +// 1. The `ComplexSchema` union no longer has an arm for the literal, so `tsc` +// refuses it at the authoring site. +type _ComplexHasNoKanbanArm = Assert>, true>>; +// Non-vacuity on the same instrument: a live sibling arm IS still selectable, +// so the `never` above is a reading and not an `Extract` that yields `never` +// for everything (which is what a broken import of `ComplexSchema` gives). +type _ComplexStillHasLiveArms = Assert>, false>>; + +// 2. The published `ComponentType` union no longer offers the retired key. +type _ComponentTypeDropsKanban = Assert>; +type _ComponentTypeKeepsLiveKeys = Assert>; +type _RegistryDropsKanban = Assert>; + +// 3. ⛔ The STORED view type is a DIFFERENT layer and keeps the spelling. This +// pin is the trap-guard: it goes red the moment someone "finishes" the +// retirement by pattern-matching the string. +type _StoredViewTypeKeepsKanban = Assert ? true : false, true>>; +type _StoredViewTypeKeepsGantt = Assert ? true : false, true>>; + +// 4. The surviving node face is untouched and still tagged with its own key. +type _SurvivorIsTagged = Assert>; + +/* -------------------------------------------------------------------------- */ +/* Runtime — through `safeValidateSchema`, the union the CLI applies. */ +/* -------------------------------------------------------------------------- */ + +interface IssueLike { + readonly path?: readonly PropertyKey[]; + readonly message?: string; + readonly code?: string; + readonly errors?: readonly (readonly IssueLike[])[]; +} + +/** Flatten zod's nested union issues into `{ path, message, code }` rows. */ +function flattenIssues(issues: readonly IssueLike[]): Array<{ path: string; message: string; code: string }> { + return issues.flatMap((issue) => [ + { path: (issue.path ?? []).join('.'), message: issue.message ?? '', code: issue.code ?? '' }, + ...(issue.errors ?? []).flatMap((nested) => flattenIssues(nested)), + ]); +} + +function refusals(schema: unknown) { + const result = safeValidateSchema(schema); + return result.success ? [] : flattenIssues(result.error.issues as unknown as IssueLike[]); +} + +/** The board that used to validate under the retired key, spelled unchanged. */ +const RETIRED_BOARD = { + type: 'kanban', + objectName: 'tasks', + groupBy: 'status', + cardTitle: 'title', +} as const; + +describe('suite 1 — a `type: "kanban"` document is REFUSED, by name (objectui#8802)', () => { + it('refuses the document', () => { + expect(refusals(RETIRED_BOARD)).not.toEqual([]); + }); + + it('the refusal names the remedy `object-kanban`, not just a type complaint', () => { + // ⭐ This is the whole reason the arm survives as a refusal instead of being + // deleted. Suite 4 is its control: an unknown literal gets the union's + // generic message, which names no remedy at all. + const text = refusals(RETIRED_BOARD).map((r) => r.message).join('\n'); + expect(text).toContain('object-kanban'); + expect(text).toContain('RETIRED'); + }); + + it('⛔ and it does NOT tell the author to rewrite their stored view type', () => { + // The trap this family's rulings warn about, asserted in the one place an + // author would read: the message must say the stored spelling is a + // different layer, so nobody "finishes the job" in their database. + const text = refusals(RETIRED_BOARD).map((r) => r.message).join('\n'); + expect(text).toContain('STORED'); + }); +}); + +describe('suite 2 — the refusal is not a validator that turned strict', () => { + it('an ACCEPTED document on the same call proves the instrument still says yes', () => { + // Firing control. Without it every assertion in suite 1 would also pass + // under a `safeValidateSchema` that had started refusing everything. + expect(refusals({ type: 'object-kanban', objectName: 'tasks', groupBy: 'status' })).toEqual([]); + }); +}); + +describe('suite 3 — the SIBLING `object-kanban` arm keeps every verdict it had', () => { + // Carried forward from `kanban-arm-batch70-7742.test.ts`, whose `titleField` + // pair is the reason this suite exists: the batch #70 refusals were + // ARM-SCOPED, so retiring the arm must not move the sibling's answers. + it.each([ + ['titleField', 'name'], + ['allowCollapse', true], + ['quickAdd', true], + ['coverImageField', 'cover'], + ])('accepts `%s`, exactly as it did before the retirement', (key, value) => { + expect(refusals({ type: 'object-kanban', objectName: 'tasks', groupBy: 'status', [key]: value })).toEqual([]); + }); + + it('and still REFUSES what it always refused — `groupField`, its own tombstone', () => { + // The other half: suite 3 would be vacuous if the sibling arm accepted + // everything. + const found = refusals({ type: 'object-kanban', objectName: 'tasks', groupBy: 'status', groupField: 'status' }); + expect(found.filter((f) => f.path === 'groupField')).not.toEqual([]); + }); +}); + +describe('suite 4 — control: an unknown literal takes the GENERIC path', () => { + it('a name no arm claims is refused without any remedy text', () => { + // ⭐ The separator. If this document ALSO named `object-kanban` in its + // message, suite 1's remedy assertion would be measuring the union's + // boilerplate rather than this arm's guidance. + const text = refusals({ type: 'zzz-not-a-type' }).map((r) => r.message).join('\n'); + expect(text).not.toEqual(''); + expect(text).not.toContain('object-kanban'); + }); +}); diff --git a/packages/types/src/__tests__/component-docs-retired-handler-keys-7340.test.ts b/packages/types/src/__tests__/component-docs-retired-handler-keys-7340.test.ts index 716d86d70b..72162ae676 100644 --- a/packages/types/src/__tests__/component-docs-retired-handler-keys-7340.test.ts +++ b/packages/types/src/__tests__/component-docs-retired-handler-keys-7340.test.ts @@ -253,12 +253,18 @@ const describeRow = (r: DocRow): string => `${r.page}:${r.line} ${r.owner}.${r.k /** Runtime-slot rows measured present on this tree — the blanket-sweep control. */ const CONTROL = [ - // objectui#7664: the page documents the plugin dialect under `KanbanSchema` + // ⛔ objectui#8802 removed the three `KanbanSchema` rows that sat here. The + // bare `kanban` node type key retired (maintainer ruling 2026-09-09), the arm + // retired with it, and `content/docs/api/schema-reference.md`'s + // `### KanbanSchema` section is now `### ObjectKanbanSchema` — which documents + // no handler keys at all, because the surviving face declares none. The three + // are still FORWARDED by `KanbanRenderer`; that they are now declared nowhere + // is measured and recorded in + // `plugin-kanban/src/__tests__/kanban-handler-slots-7664.test.tsx`, and + // reported rather than repaired. + // (was: objectui#7664, the page documenting the plugin dialect under `KanbanSchema`) // now, whose three runtime slots are `onCardMove` / `onCardClick` / // `onQuickAdd` — the three `KanbanRenderer` forwards off `schema.*`. - { page: 'api/schema-reference.md', owner: 'KanbanSchema', key: 'onCardMove' }, - { page: 'api/schema-reference.md', owner: 'KanbanSchema', key: 'onCardClick' }, - { page: 'api/schema-reference.md', owner: 'KanbanSchema', key: 'onQuickAdd' }, { page: 'components/basic/pagination.mdx', owner: 'PaginationSchema', key: 'onPageChange' }, { page: 'components/data-display/tree-view.mdx', owner: 'TreeViewSchema', key: 'onNodeClick' }, { page: 'components/form/button.mdx', owner: 'ButtonSchema', key: 'onClick' }, @@ -276,13 +282,22 @@ describe('the retired population is measured off the shipped tree (objectui#7340 // (`ActionCallback`, deleted), the third meaning of `onSuccess` — but the // same `on*?: never` shape this name-shaped census reads, so the move is // recorded here too (maintainer ruling option 1, 2026-09-05). + // + // ⭐ 28 → 26, `complex.ts` 4 → 2: objectui#8802 retired the bare `kanban` + // node type key and its `KanbanSchema` arm, taking that arm's two `?: never` + // handler tombstones (`onColumnAdd`, `onCardAdd`) out of the shipped tree. + // ⛔ A shrink here is normally the failure this census exists to catch — a + // key dropped from a LIVE interface stops being refused and, under + // `.passthrough()`, is KEPT. What separates this one is that the whole ARM + // went: a `{ "type": "kanban" }` document is refused BY NAME before any + // member is examined (`./bare-kanban-node-key-retired-8802.test.ts`). const split: Record = {}; for (const m of RETIRED) split[m.file] = (split[m.file] ?? 0) + 1; expect({ total: RETIRED.length, split }).toEqual({ - total: 28, + total: 26, split: { 'app.ts': 1, - 'complex.ts': 4, + 'complex.ts': 2, 'crud.ts': 3, 'data-display.ts': 4, 'feedback.ts': 1, @@ -303,11 +318,14 @@ describe('the retired population is measured off the shipped tree (objectui#7340 }); it('names the keys no shipped interface declares callable', () => { + // ⛔ `onCardAdd` and `onColumnAdd` left this list with the retired `kanban` + // arm (objectui#8802): no shipped interface declares either name at all any + // more, so the census — which reads NAMES off the shipped tree — has nothing + // to classify. They are not "live again": the document that could have + // carried them is refused at its `type`. expect(UNAMBIGUOUSLY_RETIRED_NAMES).toEqual([ - 'onCardAdd', 'onClose', 'onCollapsedChange', - 'onColumnAdd', 'onConfirm', 'onExpandChange', // objectui#7068: the legacy `ActionSchema.onFailure` callback object — no diff --git a/packages/types/src/__tests__/handler-keys-json-refusal-6124.test.ts b/packages/types/src/__tests__/handler-keys-json-refusal-6124.test.ts index e1d8397635..deeee2e653 100644 --- a/packages/types/src/__tests__/handler-keys-json-refusal-6124.test.ts +++ b/packages/types/src/__tests__/handler-keys-json-refusal-6124.test.ts @@ -87,7 +87,6 @@ import { ChatbotEnhancedSchema as ChatbotEnhancedZod, ChatbotFloatingSchema as ChatbotFloatingZod, FilterBuilderSchema as FilterBuilderZod, - KanbanSchema as KanbanZod, } from '../zod/complex.zod'; import { AlertSchema as AlertZod, @@ -145,7 +144,6 @@ import type { ChatbotEnhancedSchema, ChatbotFloatingSchema, FilterBuilderSchema, - KanbanSchema, } from '../complex'; import type { AlertSchema, DataTableSchema, ListItem, TreeViewSchema } from '../data-display'; import type { AccordionSchema, CollapsibleSchema, ToggleGroupSchema } from '../disclosure'; @@ -224,19 +222,29 @@ const objectOf = (mirror: z.ZodType, key: string): z.ZodObject => * `toFormControlDomProps` whitelist, card's ``). */ const RUNTIME_SLOT: readonly Site[] = [ - // objectui#7664 — the `'kanban'` arm is the plugin dialect now, and - // `KanbanRenderer` forwards all three of these off `schema.*` in one block - // (`plugin-kanban/src/index.tsx`). `onCardClick` is a slot on the retired - // declarative face AND on this one: on the `'kanban'` key `ObjectKanban` - // substitutes its own function, but it substitutes `onCardMove` in the same - // object literal, and its substitute CALLS an authored `onCardClick` through - // the prop `ObjectKanban` declares for it. Measured per registration in - // `plugin-kanban/src/__tests__/kanban-handler-slots-7664.test.tsx`; the first - // cut of this card dropped the key instead, which ACCEPTED a document this - // ledger had refused. - ['complex.zod.ts', 'KanbanSchema', 'onCardMove', KanbanZod], - ['complex.zod.ts', 'KanbanSchema', 'onCardClick', KanbanZod], - ['complex.zod.ts', 'KanbanSchema', 'onQuickAdd', KanbanZod], + // ⛔ objectui#7664's three `KanbanSchema` slots — `onCardMove`, `onCardClick`, + // `onQuickAdd` — LEFT this ledger with their arm: objectui#8802 retired the + // bare `kanban` node type key (maintainer ruling 2026-09-09) and + // `KanbanSchema` retired with it, on both faces. + // + // ⚠️ This is a ledger SHRINK, and the distinction matters because shrinking + // one is normally the exact failure this file was written to catch: the first + // cut of objectui#7664 DROPPED `onCardClick` from the arm, which turned a + // refused document into an accepted one while every ratchet stayed green. + // What separates the two is where the refusal went: + // - a key dropped from a LIVE arm stops being judged and the value is KEPT, + // because `BaseSchema` is `.passthrough()`; + // - an arm whose TYPE LITERAL retired stops being reachable at all — a + // `{ "type": "kanban" }` document is now refused BY NAME by + // `RetiredKanbanNodeSchema`, so all three keys are refused a fortiori, + // on a document that never reaches a member check. + // Pinned end to end in `./bare-kanban-node-key-retired-8802.test.ts`. + // + // ⚠️ `KanbanRenderer` still forwards all three off `schema.*`, and the + // SURVIVING `object-kanban` face declares none of them — measured, and + // recorded as a red-flag reading in + // `plugin-kanban/src/__tests__/kanban-handler-slots-7664.test.tsx` rather + // than repaired, because declaring them would WIDEN a published accept set. ['complex.zod.ts', 'CalendarViewSchema', 'onViewChange', CalendarViewZod], ['complex.zod.ts', 'FilterBuilderSchema', 'onChange', FilterBuilderZod], ['complex.zod.ts', 'ChatbotSchema', 'onError', ChatbotZod], @@ -296,10 +304,10 @@ const RUNTIME_SLOT: readonly Site[] = [ * from the declared `(value: string) => void`, not a consumer of it. */ const RETIRED: readonly Site[] = [ - // objectui#7664 carried these two tombstones onto the successor arm under the - // same `'kanban'` key, so the spelling keeps refusing by name. - ['complex.zod.ts', 'KanbanSchema', 'onColumnAdd', KanbanZod], - ['complex.zod.ts', 'KanbanSchema', 'onCardAdd', KanbanZod], + // ⛔ objectui#7664's two `KanbanSchema` tombstones — `onColumnAdd`, + // `onCardAdd` — LEFT this ledger with their arm (objectui#8802). Same + // reasoning as the three runtime slots above: the arm's TYPE LITERAL retired, + // so the document never reaches a member check. ['complex.zod.ts', 'CarouselSchema', 'onSlideChange', CarouselZod], ['complex.zod.ts', 'ChatbotSchema', 'onSendMessage', ChatbotZod], ['data-display.zod.ts', 'AlertSchema', 'onDismiss', AlertZod], @@ -384,20 +392,26 @@ describe('census: no on* key in the eight mirrors is declared z.function() (obje ]); }); - it('67 sites are ledgered, 45 runtime slots + 22 retired, with no key filed twice', () => { + it('62 sites are ledgered, 42 runtime slots + 20 retired, with no key filed twice', () => { // 58 from objectui#6124; the 59th is `ObjectDataTableSchema.onRowClick`, // minted with its arm by objectui#6576 / #6914; the 60th is // `AlertDialogSchema.onAction`, declared by objectui#7104 for a key the // renderer had been reading undeclared; 61–66 are the six slots the // `ChatbotEnhancedSchema` / `ChatbotFloatingSchema` twins were born with - // (objectui#7655); the 67th is `KanbanSchema.onQuickAdd`, the third - // `KanbanRenderer` forward, ledgered when objectui#7664 re-keyed this arm - // onto the plugin dialect — `onCardMove` and `onCardClick` carried over - // from the retired declarative face under the same `'kanban'` key. - expect(RUNTIME_SLOT).toHaveLength(45); - expect(RETIRED).toHaveLength(22); + // (objectui#7655); the 67th was `KanbanSchema.onQuickAdd`, ledgered when + // objectui#7664 re-keyed the `'kanban'` arm onto the plugin dialect. + // + // ⭐ 67 → 62: objectui#8802 retired the bare `kanban` node type key and its + // arm, taking FIVE `KanbanSchema` sites with it — three runtime slots + // (`onCardMove`, `onCardClick`, `onQuickAdd`) and two tombstones + // (`onColumnAdd`, `onCardAdd`). ⛔ A ledger SHRINK is normally this file's + // own failure mode; the two comment blocks above state why an ARM + // retirement is not that failure, and `./bare-kanban-node-key-retired-8802.test.ts` + // measures the refusal that replaced them. + expect(RUNTIME_SLOT).toHaveLength(42); + expect(RETIRED).toHaveLength(20); const ids = ALL_SITES.map(([file, schema, key]) => `${file}#${schema}.${key}`); - expect(new Set(ids).size).toBe(67); + expect(new Set(ids).size).toBe(62); }); it.each(ALL_SITES)('%s %s.%s is DECLARED on the mirror shape, with the objectui#6124 guidance as its description', (_file, _schema, key, mirror) => { @@ -520,8 +534,6 @@ type KeepsFunction = [Extract, (...args: never[]) => unknown>] : true; export type assertionRetiredKeysAreTombstoned = [ - Expect>, - Expect>, Expect>, Expect>, Expect>, @@ -545,9 +557,6 @@ export type assertionRetiredKeysAreTombstoned = [ ]; export type assertionRuntimeSlotsKeepTheirFunctionType = [ - Expect>, - Expect>, - Expect>, Expect>, Expect>, Expect>, diff --git a/packages/types/src/__tests__/kanban-arm-batch70-7742.test.ts b/packages/types/src/__tests__/kanban-arm-batch70-7742.test.ts deleted file mode 100644 index 0f5e54e453..0000000000 --- a/packages/types/src/__tests__/kanban-arm-batch70-7742.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -/** - * 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. - */ - -/** - * The `'kanban'` arm's accept set after decision batch #70 (objectui#7742, - * ADR-0049, maintainer 2026-09-07: 「同意」). - * - * ## Why this file exists next to `kanban-plugin-dialect-authoritative-7664` - * - * That file pins the arm's declared BODY — which members exist, live or - * tombstoned — with a TypeScript AST census. This one pins what a DOCUMENT gets - * back, through `safeValidateSchema` itself. The two are not interchangeable: - * `BaseSchema` is `.passthrough()`, so a key can be absent from the shape and - * still parse green, and a key can be present in the shape as a refusal arm and - * make a document fail. Only a parse answers the accept-set question. - * - * ## The unusual thing about this card, stated so a reader checks both halves - * - * The accept set moves in BOTH directions in one change, which is why the PR - * carries `needs:contract-review`: - * - * - NARROWER — `allowCollapse`, `cardTemplates`, `columnWidths` and - * `titleField` were accepted and are now refused BY NAME. - * - WIDER — `navigation` was undeclared (admitted through `BaseSchema`'s - * index signature, never examined) and is now declared and JUDGED. - * - * A file that only checked the narrowing would pass on a change that forgot the - * widening entirely, and the reverse. Both directions are asserted here. - * - * ## Every refusal is paired with a CONTROL that fires the other way - * - * A `.passthrough()` object accepts an unknown key. So "the document failed" - * proves nothing on its own — it has to be shown that an ARBITRARY key does - * NOT fail on the same call, or the refusal could just be strictness that - * arrived some other way. Each refusal assertion below therefore runs against a - * document that also carries a bogus key, and asserts the bogus key draws NO - * issue while the retired key draws one at its own path. - */ - -import { describe, it, expect } from 'vitest'; -import { safeValidateSchema } from '../zod/index.zod'; - -/** Zod 4 nests a failing `z.union` arm's issues under `invalid_union.errors`. */ -type IssueLike = { path: PropertyKey[]; message: string; errors?: IssueLike[][] }; -function flattenIssues(issues: IssueLike[]): Array<{ path: string; message: string }> { - return issues.flatMap((i) => - i.errors - ? i.errors.flat().flatMap((nested) => flattenIssues([nested])) - : [{ path: i.path.join('.'), message: i.message }], - ); -} -function refusals(schema: unknown): Array<{ path: string; message: string }> { - const r = safeValidateSchema(schema); - return r.success ? [] : flattenIssues(r.error.issues as unknown as IssueLike[]); -} - -/** A board that validates today, used as the base every probe is added to. */ -const LIVE_BOARD = { - type: 'kanban', - objectName: 'tasks', - groupBy: 'status', - cardTitle: 'title', -} as const; - -/** - * The key a control uses. It must be a name no arm of the union declares, so a - * `.passthrough()` accept is the only correct answer for it. - */ -const BOGUS = 'thisKeyIsDeclaredByNoArm'; - -/** The four keys batch #70 retired from this arm, with what each points at. */ -const RETIRED = [ - { key: 'allowCollapse', value: true, remedyMentions: 'collapsed' }, - { key: 'cardTemplates', value: [{ id: 't', name: 'Bug', values: {} }], remedyMentions: 'COMPONENT PROP' }, - { key: 'columnWidths', value: { defaultWidth: 280 }, remedyMentions: 'HOOK OPTION' }, - { key: 'titleField', value: 'name', remedyMentions: 'cardTitle' }, -] as const; - -describe('batch #70 NARROWS the `kanban` arm — four keys are refused by name (objectui#7742)', () => { - for (const { key, value, remedyMentions } of RETIRED) { - it(`refuses \`${key}\` at its own path, while an undeclared key on the same call is accepted`, () => { - const found = refusals({ ...LIVE_BOARD, [key]: value, [BOGUS]: 'anything' }); - - // The retired key draws an issue AT ITS OWN PATH — so the author reads - // which key is wrong, not just that the document is. - const atKey = found.filter((f) => f.path === key); - expect(atKey, `no refusal at \`${key}\`: ${JSON.stringify(found)}`).not.toEqual([]); - - // THE CONTROL, on the same parse: an arbitrary undeclared key draws - // nothing. Without this the assertion above would also pass under a - // validator that had simply become strict. - expect( - found.filter((f) => f.path === BOGUS), - 'the control key drew an issue — this arm is not passthrough any more, so the refusal above is not a NAMED one', - ).toEqual([]); - - // The message carries the remedy, not just a type complaint. This is what - // `retirementTombstone` exists for: zod's own text would read "expected - // never, received string" and tell the author nothing. - expect( - atKey.map((f) => f.message).join('\n'), - `\`${key}\`'s refusal does not name its remedy`, - ).toContain(remedyMentions); - }); - } - - it('the same four keys were ACCEPTED before this card — the base board without them still passes', () => { - // The other half of a narrowing claim: the narrowing is the KEY, not the - // board. A board that never named them is untouched. - expect(refusals(LIVE_BOARD)).toEqual([]); - }); - - it('`cardTitle` — the spelling `titleField`\'s refusal points at — still validates', () => { - expect(refusals({ type: 'kanban', objectName: 'tasks', cardTitle: 'name' })).toEqual([]); - }); -}); - -describe('the SIBLING `object-kanban` arm keeps `titleField` (objectui#7322 item ②, PR #8153)', () => { - it('accepts a `titleField` document, so the retirement is arm-scoped and not global', () => { - // ⛔ This is the assertion that makes the `titleField` row a RETIREMENT OF A - // SPELLING rather than a removal of a capability. `ObjectKanban` renders - // both node types and still reads the key; only the `kanban` arm stops - // accepting it. - expect(refusals({ type: 'object-kanban', objectName: 'tasks', groupBy: 'status', titleField: 'name' })).toEqual([]); - }); - - it('and the `kanban` arm refuses the same key on the same call shape — the two answers differ', () => { - // The pair is the point: identical documents but for `type`, opposite - // verdicts. Asserted together so neither can drift without the other. - // (`groupBy` is on both because the `object-kanban` arm REQUIRES it — a - // control that fired on the first cut of this file and is kept here as the - // reason the two probes are spelled the way they are.) - expect(refusals({ type: 'kanban', objectName: 'tasks', groupBy: 'status', titleField: 'name' })).not.toEqual([]); - }); -}); - -describe('batch #70 WIDENS the `kanban` arm — `navigation` is declared (objectui#7742, gantt precedent objectui#5903)', () => { - it('accepts a declared `navigation` config', () => { - expect(refusals({ ...LIVE_BOARD, navigation: { mode: 'drawer' } })).toEqual([]); - expect(refusals({ ...LIVE_BOARD, navigation: { mode: 'page' } })).toEqual([]); - }); - - it('JUDGES the value, not just the key — a bad `mode` is refused inside `navigation`', () => { - // ⭐ The verdict that separates "declared" from "admitted". Before this card - // `navigation` rode `BaseSchema`'s `[key: string]: any`, so ANY value passed - // — including this one. A test that only asserted the good config would - // have passed on `origin/main` too and pinned nothing. - const found = refusals({ ...LIVE_BOARD, navigation: { mode: 'not-a-mode' } }); - expect(found, 'an invalid navigation mode was accepted — the key is admitted, not judged').not.toEqual([]); - expect( - found.some((f) => f.path.startsWith('navigation')), - `no issue under \`navigation\`: ${JSON.stringify(found)}`, - ).toBe(true); - }); - - it('and the member list is the spec\'s — a key the spec does not declare is refused inside `navigation`', () => { - // The vocabulary is `@objectstack/spec`'s `NavigationConfig` BY REFERENCE, - // not restated here, so it cannot fork. `basePath` is the name the package - // README once showed and the spec never declared. - const found = refusals({ ...LIVE_BOARD, navigation: { mode: 'drawer', basePath: '/tasks' } }); - expect(found, 'an undeclared navigation member was accepted').not.toEqual([]); - }); -}); diff --git a/packages/types/src/__tests__/kanban-plugin-dialect-authoritative-7664.test.ts b/packages/types/src/__tests__/kanban-plugin-dialect-authoritative-7664.test.ts deleted file mode 100644 index 3808710de8..0000000000 --- a/packages/types/src/__tests__/kanban-plugin-dialect-authoritative-7664.test.ts +++ /dev/null @@ -1,343 +0,0 @@ -/** - * 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. - */ - -/** - * The `'kanban'` arm declares the plugin dialect, and the validator applies it - * (objectui#7664 — maintainer ruling (a), 2026-09-05, decision batch #41). - * - * ## The defect this pins shut - * - * For an authored `type: 'kanban'` document two different types were - * authoritative depending on who asked: `safeValidateSchema` (the CLI's - * `validate` / `check`) honoured `DeclarativeKanbanSchema` — `columns` with - * `color`, `draggable`, cards with `labels` / `priority` — while the renderer - * registered for the key (`ObjectKanbanRenderer`, `@object-ui/plugin-kanban`) - * consumed that package's own `KanbanSchema` — `objectName` / `groupBy` / - * `cardTitle` / `cardFields`, cards with `badges`. A board could pass - * validation and render EMPTY (objectui#6086 measured the consequence). The - * ruling: the PLUGIN dialect is authoritative. This package now declares it - * (`complex.ts`, mirrored in `zod/complex.zod.ts`), `SchemaRegistry['kanban']` - * names it, the plugin imports it back, and the declarative trio retired. - * - * ## What is pinned, and in which channel - * - * 1. COMPILE-TIME — `SchemaRegistry['kanban']` IS `KanbanSchema` (the - * objectui#7645 interim value `BaseSchema & { type: 'kanban' }` is gone, - * and the published `ComponentType` union still yields `'kanban'`, the - * `_KeyKept` pin carried forward from the retired 7645 file); the - * `ComplexSchema` arm is the same type; the retired keys read `undefined` - * off the interface (`?: never`); the two runtime slots stay callable. - * Vitest strips types without checking them, so these mean something only - * under `tsc -p packages/types/tsconfig.test.json` (chained off the - * package's `type-check` script). A green vitest run is NOT evidence about - * them. - * 2. RUNTIME — the ruling's three accept-set pins, through - * `safeValidateSchema` itself (the union the CLI applies): an - * `objectName` / `groupBy` board passes; a static `columns[].cards[]` - * board in the plugin dialect passes; a board in the retired dialect is - * REFUSED, and the refusal names the retired shape at the key that - * betrayed it. `z.union` nests a failing arm's issues under - * `invalid_union.errors`, so the reader below walks that tree — the CLI's - * arm-selection reader (`packages/cli/src/utils/union-arm-diagnostics.ts`) - * is the production counterpart. - * 3. CENSUS — the declared body is MEASURED off `complex.ts` with the - * TypeScript parser, not inherited: the ruling quoted "the 18-member - * `KanbanSchema`", an AST census on the same day read 19 (it counts - * `type`), and this file was told to trust neither. Pinned at 20 live - * members plus the 3 tombstones, by name — the plugin dialect's own 19, - * plus `onCardClick`. That twentieth member is the one this arm ADDS to - * the dialect it was modelled on: `plugin-kanban/src/types.ts` never - * declared it (measured on `origin/main`: zero occurrences in that file) - * while `KanbanRenderer` has always forwarded `schema.onCardClick`, so - * copying the dialect member for member reproduced its undeclared read - * and, under `.passthrough()`, ACCEPTED a document the retired arm had - * refused. Its reachability is measured per registration in - * `plugin-kanban/src/__tests__/kanban-handler-slots-7664.test.tsx`. - * - * ## Why the refusal keys are `draggable` and a column's `color`, not `columns` - * - * The ruling's pin reads "a `columns` / `cards` board is refused". Read - * literally that contradicts the ruled shape: the plugin dialect DECLARES - * `columns[].cards[]` (a static board — the two catalog entries, pinned in - * `examples/schema-catalog/test/kanban-column-cards-6939.test.tsx`, are exactly - * that and render every card). What distinguishes a board written in the - * RETIRED dialect is the keys it had and this one does not, and every one of - * them was measured inert in the plugin: the board's `draggable` and a - * column's `color` have zero read sites, so both are `?: never` tombstones - * refused by name. The retired CARD keys (`labels`, `assignees`, `dueDate`, - * `priority`, `content`) are deliberately NOT refused: a card is an open record - * (`[key: string]: any` — `bucketCardsIntoColumns` pushes raw records into - * lanes, and a task record legitimately carries `priority` or `dueDate`), so - * refusing those names would refuse real data. A retired-dialect board that - * uses none of the refused keys is, member for member, a valid static board of - * this dialect — and renders. - */ - -import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { dirname, join } from 'node:path'; -import ts from 'typescript'; -import type { - SchemaRegistry, - ComponentType, - ComplexSchema, - KanbanSchema, - KanbanColumn, - KanbanCard, -} from '../index'; -import { - BaseSchema as BaseZod, - KanbanSchema as KanbanZod, - KanbanColumnSchema as KanbanColumnZod, - ComplexSchema as ComplexZod, - safeValidateSchema, -} from '../zod/index.zod'; - -/* -------------------------------------------------------------------------- */ -/* Compile-time pins — compiled by tsconfig.test.json, chained off type-check. */ -/* -------------------------------------------------------------------------- */ - -type Assert = T; -type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; -type IsAny = 0 extends 1 & T ? true : false; -/** `?: never` reads as exactly `undefined` off the interface (the 6124 spelling). */ -type RetiredIsNever = Equal; -/** A runtime slot keeps a callable member: some function type survives the `Extract`. */ -type KeepsFunction = [Extract, (...args: never[]) => unknown>] extends [never] - ? false - : true; - -// Non-vacuity controls: `any` on either side satisfies every `extends` below -// while checking nothing, and `Equal` is `false`. -type _RegistryIsReal = Assert, false>>; -type _DeclaredIsReal = Assert, false>>; - -// 1. The published union still yields `'kanban'` — `_KeyKept`, carried forward -// from the retired objectui#7645 pin. `Extract` collapses to `never` if the -// key is ever removed, which fails this loudly. -type _KeyKept = Assert, 'kanban'>>; - -// 2. The value IS the declared arm — not the 7645 interim `BaseSchema & { type }` -// (which `Equal` would reject: it lacks every kanban member), and not the -// retired declarative face (gone from the package). -type _ValueIsTheDeclaredArm = Assert>; - -// 3. `safeValidateSchema`'s type-level counterpart: the `'kanban'` arm of the -// `ComplexSchema` union is the same declaration. -type _ComplexArmIsTheDeclaredArm = Assert, KanbanSchema>>; - -// 4. The retired dialect's own keys are tombstoned on the TypeScript face -// (`Equal`, not `extends`: `BaseSchema`'s index signature makes a DELETED -// member read `any`, which a one-way check would accept). -type _DraggableRetired = Assert>; -type _ColumnColorRetired = Assert>; -type _OnColumnAddStillRetired = Assert>; -type _OnCardAddStillRetired = Assert>; - -// 5. The two runtime slots the board forwards stay callable. -type _OnCardMoveCallable = Assert>; -type _OnQuickAddCallable = Assert>; - -// 6. A card is an open record — the index signature survived the move, so a -// raw record field reads `any` rather than failing. -type _CardIsAnOpenRecord = Assert, true>>; -// …and the helpers can fail (synthetic controls, both directions). -type _RetiredIsNeverCanFail = Assert void) | undefined>, false>>; -type _KeepsFunctionCanFail = Assert, false>>; - -/* -------------------------------------------------------------------------- */ -/* Runtime pins */ -/* -------------------------------------------------------------------------- */ - -/** Zod 4 nests a failing `z.union` arm's issues under `invalid_union.errors`. */ -type IssueLike = { path: PropertyKey[]; message: string; errors?: IssueLike[][] }; -function flattenIssues(issues: IssueLike[]): Array<{ path: string; message: string }> { - return issues.flatMap((i) => - i.errors ? i.errors.flat().flatMap((nested) => flattenIssues([nested])) : [{ path: i.path.join('.'), message: i.message }], - ); -} -function refusals(schema: unknown): Array<{ path: string; message: string }> { - const r = safeValidateSchema(schema); - return r.success ? [] : flattenIssues(r.error.issues as unknown as IssueLike[]); -} - -/** The ruling's first pin: an object-bound board, as `skills/objectui` teaches it. */ -const OBJECT_BOUND_BOARD = { - type: 'kanban', - objectName: 'tasks', - groupBy: 'status', - cardTitle: 'title', - cardFields: ['assignee', 'priority'], - bind: 'tasks', -}; - -/** A static board in the plugin dialect — the catalog entries' shape, badges included. */ -const STATIC_BOARD = { - type: 'kanban', - columns: [ - { - id: 'todo', - title: 'To Do', - cards: [{ id: '1', title: 'Design', description: 'Wireframes', badges: [{ label: 'High', variant: 'destructive' }] }], - }, - { id: 'done', title: 'Done', limit: 3, cards: [] }, - ], -}; - -/** The retired declarative dialect — `schema-reference.md`'s example before this card. */ -const RETIRED_DIALECT_BOARD = { - type: 'kanban', - draggable: true, - columns: [ - { id: 'todo', title: 'To Do', color: '#6366f1', cards: [{ id: 'task-1', title: 'Design mockups' }] }, - ], -}; - -describe("the 'kanban' validator arm accepts what the registered renderer reads (objectui#7664)", () => { - it('the compile-time pins above are read by tsc, not by this run', () => { - expect(true).toBe(true); - }); - - it('an objectName / groupBy board passes safeValidateSchema', () => { - expect(refusals(OBJECT_BOUND_BOARD)).toEqual([]); - }); - - it('a static columns[].cards[] board in the plugin dialect passes safeValidateSchema', () => { - expect(refusals(STATIC_BOARD)).toEqual([]); - }); - - it('the arm the union selects is the declared one, with every ruled member on its shape', () => { - // The declaration pin is `.shape`, not `safeParse`: `BaseSchema` is - // `.passthrough()`, so a DELETED key still parses green. - const declared = Object.keys(KanbanZod.shape); - for (const key of [ - 'objectName', 'groupBy', 'swimlaneField', 'cardTitle', 'cardFields', 'data', 'limit', 'columns', - 'onCardMove', 'className', 'quickAdd', 'onQuickAdd', 'coverImageField', 'allowCollapse', - 'conditionalFormatting', 'cardTemplates', 'columnWidths', 'grouping', - // objectui#7742 (decision batch #70): `navigation` joins as a LIVE member - // and `titleField` joins as a refusal arm. Both are on the shape for the - // same reason the four keys above still are — a REFUSED key is declared, - // and only a DROPPED key would be missing here. - 'navigation', 'titleField', - ]) { - expect(declared, `\`${key}\` missing from the kanban mirror's shape`).toContain(key); - } - expect(KanbanZod.shape.type.value).toBe('kanban'); - // The refusal arms exist as declared keys — a stripped key would not appear here. - expect(KanbanZod.shape.draggable).toBeDefined(); - expect(KanbanColumnZod.shape.color).toBeDefined(); - }); -}); - -describe('a board in the retired declarative dialect is refused, naming the retired shape (objectui#7664)', () => { - it('safeValidateSchema refuses it at `draggable` and at the column `color`', () => { - const found = refusals(RETIRED_DIALECT_BOARD); - expect(found).not.toEqual([]); - const at = (path: string) => found.filter((f) => f.path === path).map((f) => f.message); - for (const path of ['draggable', 'columns.0.color']) { - const messages = at(path); - expect(messages, `no refusal at \`${path}\`: ${JSON.stringify(found)}`).not.toEqual([]); - for (const message of messages) { - expect(message).toContain('DeclarativeKanbanSchema'); - expect(message).toContain('objectui#7664'); - expect(message).toContain('RETIRED'); - } - } - // The message says what to write instead — the named-refusal payload, not - // zod's bare `expected never`. - expect(at('draggable')[0]).toContain('`objectName` + `groupBy`'); - expect(at('columns.0.color')[0]).toContain('`className`'); - }); - - it('the same document is refused by the ComplexSchema arm directly, on the same two keys', () => { - // No union nesting here: the discriminated union selects the arm by `type`. - const r = ComplexZod.safeParse(RETIRED_DIALECT_BOARD); - expect(r.success).toBe(false); - if (r.success) return; - const paths = r.error.issues.map((i) => i.path.join('.')).sort(); - expect(paths).toEqual(['columns.0.color', 'draggable']); - }); - - it('control: the refusal is about those two keys — removing them makes the same board a valid static one', () => { - const { draggable: _d, ...board } = RETIRED_DIALECT_BOARD; - void _d; - const withoutColor = { - ...board, - columns: board.columns.map(({ color: _c, ...col }) => (void _c, col)), - }; - expect(refusals(withoutColor)).toEqual([]); - }); - - it('the retired handler keys carried over from the declarative face are still refused by name', () => { - const found = refusals({ ...OBJECT_BOUND_BOARD, onColumnAdd: { action: 'toast' } }); - expect(found.filter((f) => f.path === 'onColumnAdd').map((f) => f.message).join('\n')).toContain('RETIRED (objectui#6124'); - }); -}); - -/* -------------------------------------------------------------------------- */ -/* Census — the declared body, measured off the source, not quoted */ -/* -------------------------------------------------------------------------- */ - -describe('the declared body is measured, not inherited (objectui#7664)', () => { - const COMPLEX_TS = join(dirname(fileURLToPath(import.meta.url)), '..', 'complex.ts'); - - function membersOf(interfaceName: string): Array<{ name: string; never: boolean }> { - const sf = ts.createSourceFile(COMPLEX_TS, readFileSync(COMPLEX_TS, 'utf8'), ts.ScriptTarget.ESNext, false, ts.ScriptKind.TS); - const decl = sf.statements.find( - (s): s is ts.InterfaceDeclaration => ts.isInterfaceDeclaration(s) && s.name.text === interfaceName, - ); - if (!decl) throw new Error(`no top-level interface ${interfaceName} in ${COMPLEX_TS}`); - return decl.members.filter(ts.isPropertySignature).map((m) => ({ - name: ts.isIdentifier(m.name) || ts.isStringLiteral(m.name) ? m.name.text : m.name.getText(sf), - never: m.type?.kind === ts.SyntaxKind.NeverKeyword, - })); - } - - it('KanbanSchema declares 18 live members and exactly 7 tombstones after the batch #70 ruling', () => { - // objectui#7742 moved this count in BOTH directions in one change. It was - // 20 live / 3 tombstoned: three zero-read members (`allowCollapse`, - // `cardTemplates`, `columnWidths`) and the legacy `titleField` spelling - // retired, and `navigation` — a read this face never named — was declared. - // 20 - 3 + 1 = 18 live; 3 + 4 = 7 tombstones. `titleField` is the one that - // ADDS a member rather than converting one: it was never declared here at - // all, it rode `BaseSchema`'s index signature. - const members = membersOf('KanbanSchema'); - const live = members.filter((m) => !m.never).map((m) => m.name); - const tombstoned = members.filter((m) => m.never).map((m) => m.name); - expect(live).toEqual([ - 'type', 'objectName', 'groupBy', 'swimlaneField', 'cardTitle', 'cardFields', 'data', 'limit', 'columns', - 'onCardMove', 'onCardClick', 'className', 'quickAdd', 'onQuickAdd', 'coverImageField', - 'conditionalFormatting', 'grouping', 'navigation', - ]); - expect(live).toHaveLength(18); - expect(tombstoned).toEqual([ - 'allowCollapse', 'cardTemplates', 'columnWidths', 'titleField', - 'draggable', 'onColumnAdd', 'onCardAdd', - ]); - // Both directions against the mirror, so the number above is the mirror's - // too: every declared member is a key of the shape, and every shape key the - // arm ADDS over `BaseSchema` is a declared member. (The parity ratchet, - // `zod-mirror-parity.test.ts`, holds the TYPES; this holds the key sets.) - const declared = new Set([...live, ...tombstoned]); - for (const key of declared) expect(Object.keys(KanbanZod.shape), `\`${key}\` is declared but not mirrored`).toContain(key); - const baseKeys = new Set(Object.keys(BaseZod.shape)); - const addedByTheArm = Object.keys(KanbanZod.shape).filter((k) => !baseKeys.has(k)).sort(); - expect(addedByTheArm).toEqual([...declared].filter((k) => !baseKeys.has(k)).sort()); - }); - - it('KanbanColumn carries the one retired declarative key as its only tombstone', () => { - const members = membersOf('KanbanColumn'); - expect(members.filter((m) => m.never).map((m) => m.name)).toEqual(['color']); - expect(members.filter((m) => !m.never).map((m) => m.name)).toEqual(['id', 'title', 'cards', 'limit', 'className', 'collapsed']); - }); - - it('the census reader can fail — a name that is not there throws rather than reading empty', () => { - expect(() => membersOf('DeclarativeKanbanSchema')).toThrow(/no top-level interface DeclarativeKanbanSchema/); - }); -}); diff --git a/packages/types/src/__tests__/schema-registry-chatbot-keys-7704.test.ts b/packages/types/src/__tests__/schema-registry-chatbot-keys-7704.test.ts index ff2209d167..c149a64bd9 100644 --- a/packages/types/src/__tests__/schema-registry-chatbot-keys-7704.test.ts +++ b/packages/types/src/__tests__/schema-registry-chatbot-keys-7704.test.ts @@ -147,7 +147,12 @@ describe('SchemaRegistry declares all three chatbot registrations (objectui#7704 // If the parse collapsed, or the interface were renamed, every assertion // below would pass vacuously on an empty list. These are the controls. expect(keys.length).toBeGreaterThan(60); - expect(keys).toContain('kanban'); + // ⚠️ The lit control was `'kanban'` until objectui#8802 retired that key + // from the map with the bare node type. `'carousel'` is its replacement: + // another `// Complex` group entry, declared in the same block, so the + // control still proves the parse reached this group rather than merely + // returning a long list. + expect(keys).toContain('carousel'); expect(keys).toContain('chatbot'); expect(valueOf('chatbot')).toBe('ChatbotSchema'); }); @@ -161,7 +166,7 @@ describe('SchemaRegistry declares all three chatbot registrations (objectui#7704 it('they sit with the family, under the `// Complex` group', () => { const at = keys.indexOf('chatbot'); - expect(at).toBeGreaterThan(keys.indexOf('kanban')); + expect(at).toBeGreaterThan(keys.indexOf('carousel')); expect(keys[at + 1]).toBe('chatbot-enhanced'); expect(keys[at + 2]).toBe('chatbot-floating'); }); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 9427e7c150..f0c7b55c60 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -129,7 +129,14 @@ * a delta to this number; count the registry. Nothing asserts it against a written * one, so this line is prose and can rot; the pin that cannot is the one * comparing the two halves to each other. - * - **41 entries** in `KnownDrift`, **64 keys** across them — 41 / 63 until + * - **40 entries** in `KnownDrift`, **61 keys** across them — 41 / 64 until + * objectui#8802 RETIRED the bare `kanban` node type key (maintainer ruling + * 2026-09-09) and the `complex.zod.ts#KanbanSchema` pair with it, taking that + * entry's three RUNTIME SLOT keys (`onCardMove` / `onCardClick` / + * `onQuickAdd`) out of the ledger — ⭐ the first entry this ledger has lost by + * retiring a whole ARM rather than a key: the pair's TYPE LITERAL went, so a + * `{ "type": "kanban" }` document is refused before any member is compared. + * It was 41 / 64 until then, and 41 / 63 until * objectui#8344 added `component` to `complex.zod.ts#DashboardWidgetSchema`, an * existing entry (so the entry count did not move). 42 / 64 until * objectui#8338 RETIRED the `feedback.zod.ts#ToastSchema` key on BOTH faces @@ -358,7 +365,7 @@ * * ## KNOWN_DRIFT is a ratchet, not a waiver * - * 41 of the registered pairs carry TYPE drift TODAY (measured, not assumed). Each is + * 40 of the registered pairs carry TYPE drift TODAY (measured, not assumed). Each is * pinned to its EXACT drifted key set, so the entry fails when new drift appears on * that mirror AND when the recorded drift is fixed — a stale entry cannot rot * quietly. Correcting them is not one change: the pairs below split into DISJOINT @@ -392,7 +399,7 @@ import type { z } from 'zod'; import { AppActionSchema, AppComponentSchema, MenuItemSchema as AppMenuItemSchema, NavigationAreaSchema, NavigationItemSchema } from '../zod/app.zod.js'; import { BaseSchema, ComponentConfigSchema, ComponentInputSchema, ComponentMetaSchema, KeyedI18nLabelSchema, SchemaNodeSchema } from '../zod/base.zod.js'; -import { CalendarEventSchema, CalendarViewSchema, CarouselItemSchema, CarouselSchema, ChatbotSchema, ChatbotEnhancedSchema, ChatbotFloatingSchema, ChatMessageSchema, ChatMessageSourceSchema, ChatToolInvocationSchema, DashboardComponentSchema, DashboardConfigSchema, DashboardWidgetConfigSchema, DashboardWidgetLayoutSchema, DashboardWidgetSchema, FilterBuilderSchema, FilterFieldSchema, KanbanCardSchema, KanbanColumnSchema, KanbanSchema, CardTemplateSchema, ColumnWidthConfigSchema, FilterBuilderConditionSchema, FilterGroupSchema } from '../zod/complex.zod.js'; +import { CalendarEventSchema, CalendarViewSchema, CarouselItemSchema, CarouselSchema, ChatbotSchema, ChatbotEnhancedSchema, ChatbotFloatingSchema, ChatMessageSchema, ChatMessageSourceSchema, ChatToolInvocationSchema, DashboardComponentSchema, DashboardConfigSchema, DashboardWidgetConfigSchema, DashboardWidgetLayoutSchema, DashboardWidgetSchema, FilterBuilderSchema, FilterFieldSchema, KanbanCardSchema, KanbanColumnSchema, CardTemplateSchema, ColumnWidthConfigSchema, FilterBuilderConditionSchema, FilterGroupSchema } from '../zod/complex.zod.js'; import { ActionSchema, CRUDDialogSchema, DetailSchema } from '../zod/crud.zod.js'; import { AlertSchema, AvatarSchema, BadgeSchema, BarChartSchema, ChartDataSeriesSchema, ChartSchema, DataTableSchema, DrillDownConfigSchema, HtmlSchema, KbdSchema, ListItemSchema, ListSchema, MarkdownSchema, StaticTableColumnSchema, StatisticSchema, TableColumnSchema, TableSchema, TimelineEventSchema, TimelineSchema, TreeNodeSchema, TreeViewSchema } from '../zod/data-display.zod.js'; import { AccordionItemSchema, AccordionSchema, CollapsibleSchema, ToggleGroupItemSchema, ToggleGroupSchema } from '../zod/disclosure.zod.js'; @@ -407,7 +414,7 @@ import { DetailViewFieldSchema, DetailViewSchema, DetailViewSectionSchema, Detai import type { AppAction as Ts_AppAction, AppComponentSchema as Ts_AppComponentSchema, NavigationArea as Ts_NavigationArea } from '../app'; import type { BaseSchema as Ts_BaseSchema, ComponentConfig as Ts_ComponentConfig, ComponentInput as Ts_ComponentInput, ComponentMeta as Ts_ComponentMeta, KeyedI18nLabel as Ts_KeyedI18nLabel } from '../base'; -import type { CalendarEvent as Ts_CalendarEvent, CalendarViewSchema as Ts_CalendarViewSchema, CarouselItem as Ts_CarouselItem, CarouselSchema as Ts_CarouselSchema, ChatbotSchema as Ts_ChatbotSchema, ChatbotEnhancedSchema as Ts_ChatbotEnhancedSchema, ChatbotFloatingSchema as Ts_ChatbotFloatingSchema, ChatMessage as Ts_ChatMessage, ChatMessageSource as Ts_ChatMessageSource, ChatToolInvocation as Ts_ChatToolInvocation, DashboardComponentSchema as Ts_DashboardComponentSchema, DashboardWidgetLayout as Ts_DashboardWidgetLayout, DashboardWidgetSchema as Ts_DashboardWidgetSchema, FilterBuilderSchema as Ts_FilterBuilderSchema, FilterField as Ts_FilterField, KanbanCard as Ts_KanbanCard, KanbanColumn as Ts_KanbanColumn, KanbanSchema as Ts_KanbanSchema, CardTemplate as Ts_CardTemplate, ColumnWidthConfig as Ts_ColumnWidthConfig } from '../complex'; +import type { CalendarEvent as Ts_CalendarEvent, CalendarViewSchema as Ts_CalendarViewSchema, CarouselItem as Ts_CarouselItem, CarouselSchema as Ts_CarouselSchema, ChatbotSchema as Ts_ChatbotSchema, ChatbotEnhancedSchema as Ts_ChatbotEnhancedSchema, ChatbotFloatingSchema as Ts_ChatbotFloatingSchema, ChatMessage as Ts_ChatMessage, ChatMessageSource as Ts_ChatMessageSource, ChatToolInvocation as Ts_ChatToolInvocation, DashboardComponentSchema as Ts_DashboardComponentSchema, DashboardWidgetLayout as Ts_DashboardWidgetLayout, DashboardWidgetSchema as Ts_DashboardWidgetSchema, FilterBuilderSchema as Ts_FilterBuilderSchema, FilterField as Ts_FilterField, KanbanCard as Ts_KanbanCard, KanbanColumn as Ts_KanbanColumn, CardTemplate as Ts_CardTemplate, ColumnWidthConfig as Ts_ColumnWidthConfig } from '../complex'; import type { DashboardConfig as Ts_DashboardConfig, DashboardWidgetConfig as Ts_DashboardWidgetConfig } from '../designer'; import type { CRUDDialogSchema as Ts_CRUDDialogSchema, DetailSchema as Ts_DetailSchema } from '../crud'; import type { AlertSchema as Ts_AlertSchema, AvatarSchema as Ts_AvatarSchema, BadgeSchema as Ts_BadgeSchema, BarChartSchema as Ts_BarChartSchema, ChartDataSeries as Ts_ChartDataSeries, ChartSchema as Ts_ChartSchema, DataTableSchema as Ts_DataTableSchema, DrillDownConfig as Ts_DrillDownConfig, HtmlSchema as Ts_HtmlSchema, KbdSchema as Ts_KbdSchema, ListItem as Ts_ListItem, ListSchema as Ts_ListSchema, MarkdownSchema as Ts_MarkdownSchema, StaticTableColumn as Ts_StaticTableColumn, StatisticSchema as Ts_StatisticSchema, TableColumn as Ts_TableColumn, TableSchema as Ts_TableSchema, TimelineEvent as Ts_TimelineEvent, TimelineSchema as Ts_TimelineSchema, TreeViewSchema as Ts_TreeViewSchema, BreadcrumbItem as Ts_BreadcrumbItem, BreadcrumbSchema as Ts_BreadcrumbSchema } from '../data-display'; @@ -1086,7 +1093,6 @@ const MIRRORS = { 'complex.zod.ts#FilterFieldSchema': FilterFieldSchema, 'complex.zod.ts#KanbanCardSchema': KanbanCardSchema, 'complex.zod.ts#KanbanColumnSchema': KanbanColumnSchema, - 'complex.zod.ts#KanbanSchema': KanbanSchema, 'complex.zod.ts#CardTemplateSchema': CardTemplateSchema, 'complex.zod.ts#ColumnWidthConfigSchema': ColumnWidthConfigSchema, 'crud.zod.ts#CRUDDialogSchema': CRUDDialogSchema, @@ -1252,7 +1258,6 @@ interface Declared { 'complex.zod.ts#FilterFieldSchema': Ts_FilterField; 'complex.zod.ts#KanbanCardSchema': Ts_KanbanCard; 'complex.zod.ts#KanbanColumnSchema': Ts_KanbanColumn; - 'complex.zod.ts#KanbanSchema': Ts_KanbanSchema; 'complex.zod.ts#CardTemplateSchema': Ts_CardTemplate; 'complex.zod.ts#ColumnWidthConfigSchema': Ts_ColumnWidthConfig; 'crud.zod.ts#CRUDDialogSchema': Ts_CRUDDialogSchema; @@ -1492,32 +1497,20 @@ interface KnownDrift { /** DISJOINT vocabularies: TS declares `is_empty`/`is_not_empty`, the mirror declares `is_null`/`is_not_null`. One of the two is dead; which one is a ruling. */ 'complex.zod.ts#FilterFieldSchema': 'operators'; /** - * RUNTIME SLOT (objectui#6124) ×3: `plugin-kanban`'s `KanbanRenderer` forwards - * `onCardMove`, `onCardClick` and `onQuickAdd` off `schema.*` into the board, - * in one block (`plugin-kanban/src/index.tsx`). Re-keyed by objectui#7664 - * (ruling (a)): the pair was `DeclarativeKanbanSchema` with `onCardMove` / - * `onCardClick` until that face retired, and the plugin dialect this arm now - * declares carries all three. + * ⛔ `complex.zod.ts#KanbanSchema` LEFT this ledger with its pair: objectui#8802 + * retired the bare `kanban` node type key (maintainer ruling 2026-09-09) and + * both faces of the arm retired with it. The three RUNTIME SLOTS it recorded — + * `onCardMove`, `onCardClick`, `onQuickAdd` — are not drifting any more + * because there is no pair left to drift; a `{ "type": "kanban" }` document is + * refused BY NAME by `RetiredKanbanNodeSchema`, pinned in + * `./bare-kanban-node-key-retired-8802.test.ts`. * - * ⚠️ `onCardClick` is here on measurement, not by inheritance. On the - * `'kanban'` / `'object-kanban'` keys `ObjectKanban` substitutes its own - * function for it — but it substitutes `onCardMove` in the SAME object - * literal, and its substitute CALLS an authored `onCardClick` through the prop - * `ObjectKanban` declares for it (`SchemaRenderer` spreads every non-metadata - * schema key as a React prop; there is no `onCardMove` prop). The first cut of - * objectui#7664 read that substitution as "the object-bound board owns the - * click" and dropped the key, which under `.passthrough()` ACCEPTED a document - * this arm had refused. Per-registration readings: - * `plugin-kanban/src/__tests__/kanban-handler-slots-7664.test.tsx`. - * - * (`onColumnAdd` / `onCardAdd`, carried over as tombstones, and the retired - * declarative `draggable` are NOT here: `?: never` meets the refusal arm and - * the pair does not drift on them.) The runtime-computed card members — - * `cardFieldCells`, a badge's `colorStyle` — are passed through as `z.any()` - * (the `headerIcon` precedent, objectui#6424), so `KanbanCardSchema` and the - * `columns` key above it do not drift either. + * ⚠️ Recorded rather than repaired: `KanbanRenderer` still forwards all three, + * and the SURVIVING `objectql.zod.ts#ObjectKanbanSchema` pair declares none of + * them, so they are read-but-undeclared on the surviving face. Declaring them + * there would WIDEN a published accept set, which is a ruling and not a + * repair — reported on the retirement PR. */ - 'complex.zod.ts#KanbanSchema': 'onCardMove' | 'onCardClick' | 'onQuickAdd'; /** * RUNTIME SLOT (objectui#7344): `register('detail', DetailView)` — `DetailView`'s * `handleBack` calls `onBack()` when set. The mirror was `z.any()` (wider than @@ -2936,6 +2929,16 @@ export type assertionBaseSchemaKeysResolve = Expect< * in neither map. */ const EXCLUSIONS: Readonly> = { + // objectui#8802 — the RETIRED `kanban` node type key's refusal arm. It is not + // a mirror of anything and cannot drift: `retiredNodeType()` builds an object + // whose only member is the `type` literal it refuses on, so there is no TS + // declaration for it to restate. The arm exists purely so + // `AnyComponentSchema`'s discriminator routes a `{ "type": "kanban" }` + // document to a message naming `object-kanban`, instead of the union's own + // remedy-free `Invalid input`. Pinned in + // `./bare-kanban-node-key-retired-8802.test.ts`. + 'complex.zod.ts#RetiredKanbanNodeSchema': + 'a RETIRED node type refusal arm (objectui#8802), not a mirror — its only member is the `type` literal it refuses on, and the TS half of that retirement is the ABSENCE of an arm in `ComplexSchema`', // Renamed from `StylePropsSchema` by objectui#5928. Under the old name the // like-named `StyleProps` (../base.ts) — the Tailwind-scale vocabulary, sharing // ZERO keys with this `{ className, style }` object — read as its declaration, and @@ -3091,9 +3094,10 @@ const SPEC_DERIVED_PAIRS: readonly string[] = [ // and are pinned against the header by 'objectui#7279' below. 'complex.zod.ts#DashboardComponentSchema', 'complex.zod.ts#DashboardWidgetSchema', - // objectui#7664: `grouping` is `SpecGroupingConfigSchema` by reference, the - // same way `ObjectGallerySchema` below spells it. - 'complex.zod.ts#KanbanSchema', + // ⛔ `complex.zod.ts#KanbanSchema` removed with the retired `kanban` arm + // (objectui#8802) — it was listed here because `grouping` was + // `SpecGroupingConfigSchema` by reference, the way `ObjectGallerySchema` below + // still spells it. 'form.zod.ts#SelectOptionSchema', 'layout.zod.ts#PageNodeSchema', 'objectql.zod.ts#ObjectGallerySchema', @@ -3127,7 +3131,7 @@ const ZOD_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'zod'); * MINUEND under it had moved. Nothing failed on any of those days, because nothing * compared the registry to a number. objectui#7433 is that absence, not the digits. */ -const EXPECTED_MIRROR_PAIRS = 162; +const EXPECTED_MIRROR_PAIRS = 161; /** * A ledger this file can size from its own AST. `WiderThanDeclared` joined at diff --git a/packages/types/src/complex.ts b/packages/types/src/complex.ts index 6be16e8d86..ee3d93112c 100644 --- a/packages/types/src/complex.ts +++ b/packages/types/src/complex.ts @@ -19,10 +19,13 @@ import type { DashboardWidget as SpecDashboardWidget, DateRangeDefaultRange as SpecDateRangeDefaultRange, GlobalFilter as SpecGlobalFilter, - GroupingConfig, } from '@objectstack/spec/ui'; import type { BaseSchema, SchemaNode } from './base.js'; -import type { KanbanConditionalFormattingRule, ViewNavigationConfig } from './objectql.js'; +// `GroupingConfig`, `KanbanConditionalFormattingRule` and `ViewNavigationConfig` +// were imported for `KanbanSchema`'s `grouping`, `conditionalFormatting` and +// `navigation` members and had no other reader in this module; they left with +// the retired `'kanban'` arm (objectui#8802). All three are still declared and +// still exported from their own modules. /** * Kanban card — the shape the registered `'kanban'` renderer reads. @@ -135,7 +138,9 @@ export interface KanbanColumn { cards: KanbanCard[]; /** * WIP limit — the card count at which the lane warns. Never reaches the - * query; the board's fetch window is {@link KanbanSchema.limit}. + * query; the board's fetch window is `ObjectKanbanSchema.limit` + * (`./objectql.ts`) — the `kanban` arm that used to declare it retired with + * the node key (objectui#8802). */ limit?: number; className?: string; @@ -175,325 +180,59 @@ export interface KanbanColumn { } /** - * Kanban Board component schema — the `'kanban'` arm of {@link ComplexSchema} - * and the face `ObjectKanbanRenderer` (registered for `'kanban'` and - * `'object-kanban'`) consumes; `KanbanRenderer` (`'kanban-ui'`) and the - * `'kanban-enhanced'` registration read the same keys off `schema`. + * ⛔ `KanbanSchema` — the `'kanban'` arm — is RETIRED (objectui#8802, + * maintainer ruling 2026-09-09: 「从我们的业务需求角度,我应该只需要 + * `object-kanban`」, recorded verbatim and not translated). * - * Renders a drag-and-drop kanban board for task management: either bound to - * an object (`objectName` + `groupBy`, lanes materialised from the group - * field's options) or authored statically (`columns` carrying their `cards`). + * ## What this dissolves + * + * The bare `kanban` node type key published TWO faces with OPPOSITE verdicts on + * the same document: `@object-ui/plugin-kanban`'s registry `inputs` declared + * `titleField` while this arm refused it by name after batch #70 + * (objectui#7742). With the key retired there is no arm left to disagree with, + * so objectui#8802's four options are moot rather than chosen between. + * + * ## Where the vocabulary went + * + * ⛔ Nowhere, and that is the point: `object-kanban` has ALWAYS had its own + * declared face, {@link ObjectKanbanSchema} (`./objectql.ts`), and an + * `object-kanban` document has always been judged by it alone. Retiring this + * arm therefore changes the verdict on `type: 'kanban'` documents only. The + * keys this arm alone declared — `columns`, `cardTitle`, `swimlaneField`, + * `grouping`, `conditionalFormatting`, `navigation` — were never part of the + * `object-kanban` face and are not being removed from it. + * + * ## ⚠️ Why the Zod mirror is a NAMED REFUSAL and not a deletion + * + * {@link BaseSchema} closes with `[key: string]: any` and its Zod twin ends + * `.passthrough()`. A dropped MEMBER key is therefore KEPT, not refused — the + * failure objectui#7664's own first cut shipped at `onCardClick`. A dropped + * TYPE LITERAL behaves differently on a DISCRIMINATED union (`AnyComponentSchema` + * selects one arm from the authored literal), so removal alone would already + * refuse — but only with the union's generic `Invalid input`, naming no remedy. + * ⇒ `zod/complex.zod.ts` keeps an arm for the literal and refuses it BY NAME + * through `retiredNodeType()`, pointing the author at `object-kanban`. + * + * The TypeScript half of the refusal is this deletion: with no `kanban` arm in + * {@link ComplexSchema} and no `'kanban'` entry in `SchemaRegistry`, `tsc` + * refuses the literal at the authoring site. + * + * ## ⛔ The layer that did NOT move + * + * `kanban` also names a STORED `NamedListView.type` (`./objectql.ts`) — the + * value `CreateViewDialog` writes and every tenant's database holds. It is + * UNTOUCHED. `plugin-view`'s `ObjectView` maps a stored `kanban` view onto the + * node type `object-kanban` already, as it does for all twelve stored view + * types, so every board any user ever created through the console already + * renders through the surviving spelling and this retirement moves zero stored + * documents. + * + * {@link KanbanCard}, {@link KanbanColumn}, {@link CardTemplate} and + * {@link ColumnWidthConfig} are NOT retired — the renderer, the `CardTemplates` + * component and the `useColumnWidths` hook still consume them. + * + * Pinned in `./__tests__/bare-kanban-node-key-retired-8802.test.ts`. */ -export interface KanbanSchema extends BaseSchema { - type: 'kanban'; - - /** - * Object name to fetch data from. - */ - objectName?: string; - - /** - * Field to group records by (maps to column IDs). - */ - groupBy?: string; - - /** - * Field for swimlane rows (2D grouping). When set, cards are grouped - * vertically by `groupBy` (columns) and horizontally by `swimlaneField` (rows). - */ - swimlaneField?: string; - - /** - * Field to use as the card title. - */ - cardTitle?: string; - - /** - * Fields to display on the card. - */ - cardFields?: string[]; - - /** - * Static data or bound data. Stays a raw-row input: objectui#7651 (a - * record-source ladder for the board) was ruled B and closed not_planned. - */ - data?: any[]; - - /** - * Row cap for the fetch. Defaults to `DEFAULT_KANBAN_LIMIT` (100); a board - * renders every fetched record into a lane and has no pagination control, so - * this is the author's window rather than a page size. A bound `dataSource` - * writes it here too — the binding's own `limit`, or the named view's - * `pagination.pageSize`. - * - * Not to be confused with {@link KanbanColumn.limit}, one level down: that is - * a lane's WIP limit (the card count at which the lane warns) and never - * reaches the query. - */ - limit?: number; - - /** - * Array of columns to display in the kanban board. - * Each column contains an array of cards. - */ - columns?: KanbanColumn[]; - - /** - * Callback function when a card is moved between columns or reordered. - * - * RUNTIME SLOT (objectui#6124) — a host-supplied function, NOT authorable - * metadata: JSON has no function value, so the zod twin refuses this key by - * name and points at the node-type spelling. Kept callable here because - * `KanbanRenderer` forwards it (`onCardMove={schema.onCardMove}`); the - * object-bound board (`ObjectKanban`) supplies its own persisting handler. - */ - onCardMove?: (cardId: string, fromColumnId: string, toColumnId: string, newIndex: number) => void; - - /** - * Callback function when a card is clicked. - * - * RUNTIME SLOT (objectui#6124) — a host-supplied function, NOT authorable - * metadata: JSON has no function value, so the zod twin refuses this key by - * name and points at the node-type spelling. Kept callable here because it is - * read on every channel measured (objectui#7664, the contract review of - * PR #7743): - * - * - `KanbanRenderer` forwards it (`onCardClick={schema.onCardClick}`) in the - * same block as {@link KanbanSchema.onCardMove} and - * {@link KanbanSchema.onQuickAdd}; - * - on the `'kanban'` and `'object-kanban'` keys `ObjectKanban` substitutes - * its own function — and substitutes `onCardMove` in the very same object - * literal, so that reading retires both keys or neither; - * - and its substitute CALLS the authored handler: `ObjectKanban` declares - * an `onCardClick` PROP (`onCardMove` has none), which `SchemaRenderer` - * supplies by spreading every non-metadata schema key as a React prop. - * - * ⛔ Do not "simplify" this back into a deletion. `BaseSchema` is - * `.passthrough()`, so removing the key does not refuse it — it stops being - * judged and the value is kept, which is how the first cut of objectui#7664 - * turned a refused key into an accepted one with every ratchet green. - * `plugin-kanban/src/__tests__/kanban-handler-slots-7664.test.tsx` derives the - * forwarded key set from the read site and goes red on that deletion. - * - * The event is `unknown` rather than a mouse event because this package - * declares zero dependencies and has no React types; `KanbanImpl` narrows it - * to `React.MouseEvent` at the call site. - */ - onCardClick?: (card: KanbanCard, event?: unknown) => void; - - /** - * Optional CSS class name to apply custom styling. - */ - className?: string; - - /** - * Enable Quick Add button at the bottom of each column. - * When true, a "+" button appears allowing inline card creation. - * @default false - */ - quickAdd?: boolean; - - /** - * Callback when a new card is created via Quick Add. - * - * RUNTIME SLOT (objectui#6124) — a host-supplied function, NOT authorable - * metadata: JSON has no function value, so the zod twin refuses this key by - * name and points at the node-type spelling. Kept callable here because - * `KanbanRenderer` forwards it (`onQuickAdd={schema.onQuickAdd}`), and - * `ObjectKanban` spreads the authored schema into that renderer. - */ - onQuickAdd?: (columnId: string, title: string) => void; - - /** - * Field name to use as cover image on cards. - * The field value should be a URL string or file object with a `url` property. - */ - coverImageField?: string; - - /** - * Conditional formatting rules for card coloring. Accepts the native - * `{ field, operator, value }` shape and the spec `{ condition, style }` CEL - * shape (issue #1584). - */ - conditionalFormatting?: KanbanConditionalFormattingRule[]; - - /** - * Grouping configuration from ListView. - * When set, the first grouping field is used as swimlaneField fallback. - */ - grouping?: GroupingConfig; - - /** - * Record navigation behaviour when a card is clicked (drawer / dialog / - * page). Defaults to an inline right-side drawer; set `{ mode: 'page' }` to - * route to the standalone detail page instead. Read at `ObjectKanban.tsx` - * (`navConfig`), which feeds it to `useNavigationOverlay`. - * - * DECLARED by objectui#7742 (decision batch #70) on the gantt precedent - * objectui#5903 set: the read existed and this face did not name it, so an - * authored overlay mode rode {@link BaseSchema}'s `[key: string]: any` — - * admitted, never examined — and the read site had to spell itself - * `(schema as any).navigation`. Declaring it WIDENS the published accept set; - * it is the one widening in a card whose other rows all narrow. - * - * The spec owns the member list — `mode`, `view`, `preventNavigation`, - * `openNewTab`, `size`, `width` — and its schema REFUSES anything else. Do - * not restate the vocabulary here. - * - * Same spec type as {@link ObjectGanttSchema.navigation} and - * {@link ObjectGridSchema.navigation} — aligned with `@objectstack/spec` - * `ListView.navigation` rather than restated, so the vocabulary cannot fork. - */ - navigation?: ViewNavigationConfig; - - /** - * RETIRED (objectui#7742, ADR-0049, maintainer decision batch #70, - * 2026-09-07) — declared on both faces and read by NO registered board. - * - * Measured on this branch over `packages/plugin-kanban/src`, every file - * including tests: `allowCollapse` 0 hits / 0 files, with `groupBy` (85/27), - * `cardTitle` (18/9) and `coverImageField` (17/3) firing as controls on the - * same instrument, so the zero is a reading and not a dead grep. The - * capability EXISTS through another channel — `KanbanEnhanced` collapses a - * lane off `KanbanColumn.collapsed` — so an author who wrote - * `allowCollapse: true` validated green and got a board that never collapsed - * off that key. A board-level switch wired to the per-lane mechanism is a new - * card if it is ever wanted; the ruling did not order one. - * - * A tombstone rather than a plain removal on PRONG 2 of the discriminator the - * precedent changesets state (objectui#5941, #7526): the key was TAUGHT as - * working — `content/docs/api/schema-reference.md` carried the row - * "`allowCollapse` | `boolean` | Allow columns to be collapsed." Prong 1 does - * not apply: there is no live replacement KEY to name, only a different - * channel. - * - * ⚠️ Inertness is why the key is retired, not why it is tombstoned. {@link - * BaseSchema} is `.passthrough()`, so dropping it from the mirror would leave - * a document naming it silently ACCEPTED with the value kept — the failure - * objectui#7664's own first cut shipped at {@link KanbanSchema.onCardClick}. - * - * ⛔ NOT the same key as `ObjectKanbanSchema.allowCollapse` (`objectql.ts`), - * which the batch #70 ruling did not reach and which stays declared on the - * `object-kanban` arm. - * @deprecated Not part of this contract — the value was inert. - */ - allowCollapse?: never; - - /** - * RETIRED (objectui#7742, ADR-0049, maintainer decision batch #70, - * 2026-09-07) — declared on both faces and read by NO registered board. - * - * Measured with the census above: `cardTemplates` 0 hits / 0 files across - * `packages/plugin-kanban/src`, same run and same firing controls. The - * capability exists through a COMPONENT PROP — `CardTemplates.tsx` takes - * `templates: CardTemplate[]` — never off the schema, so nothing an author - * wrote here ever reached it. {@link CardTemplate} itself stays exported: - * that prop and `plugin-kanban`'s re-export still consume the type. - * - * Tombstoned on PRONG 2, same as {@link KanbanSchema.allowCollapse}: - * `content/docs/api/schema-reference.md` carried the row "`cardTemplates` | - * `CardTemplate[]` | Predefined quick-add templates." - * @deprecated Not part of this contract — the value was inert. - */ - cardTemplates?: never; - - /** - * RETIRED (objectui#7742, ADR-0049, maintainer decision batch #70, - * 2026-09-07) — declared on both faces and read by NO registered board. - * - * Measured with the census above: `columnWidths` 0 hits / 0 files across - * `packages/plugin-kanban/src`, same run and same firing controls. The - * capability exists through a HOOK OPTION — `useColumnWidths` takes a - * `ColumnWidthConfig` argument — never off the schema. {@link - * ColumnWidthConfig} itself stays exported: that hook and `plugin-kanban`'s - * re-export still consume the type. - * - * ⚠️ The repo-wide name census for this key is NOT zero (22 hits / 11 files) - * and every one of those is a DIFFERENT key of the same spelling on the grid - * surface — `data-table.tsx`, `ObjectGrid.tsx`, `RecordPickerDialog.tsx`. The - * board's zero is the `packages/plugin-kanban/src` reading, and the grid key - * is untouched by this retirement. - * - * Tombstoned on PRONG 2, same as {@link KanbanSchema.allowCollapse}: - * `content/docs/api/schema-reference.md` carried the row "`columnWidths` | - * `ColumnWidthConfig` | Column width configuration." - * @deprecated Not part of this contract — the value was inert. - */ - columnWidths?: never; - - /** - * RETIRED on THIS arm (objectui#7742, ADR-0049, maintainer decision batch - * #70, 2026-09-07) — one arm, one spelling: write {@link - * KanbanSchema.cardTitle}. - * - * ⚠️ This tombstone is NOT an inertness finding, and reading it as one gets - * the mechanism backwards. `ObjectKanban.tsx` DOES read the key — - * `schema.cardTitle || schema.titleField` and `schema.cardTitle ?? - * schema.titleField` — and that read is load-bearing for the SIBLING arm: - * `ObjectKanbanSchema` (`objectql.ts`) declares `titleField` and the batch #70 - * ruling says in as many words that the `object-kanban` arm KEEPS it - * (objectui#7322 item ②, PR #8153, re-measured here from `25907cd70`). What - * is retired is this arm's ACCEPTANCE of the legacy spelling, not the read. - * - * So a `{ "type": "kanban" }` document naming `titleField` is now refused BY - * NAME and pointed at `cardTitle`; a `{ "type": "object-kanban" }` document - * naming it still validates and still renders. One renderer, two node types, - * two accept sets — which is the shape objectui#7322 item ② already gave the - * component's prop union. - * - * ⚠️ Never declared on this face before now: it rode {@link BaseSchema}'s - * `[key: string]: any`, which is why both reads were spelled `(schema as - * any).titleField` until PR #8153 widened the prop to the two-arm union. - * Declaring the tombstone is therefore the FIRST time this face judges the - * key at all — a narrowing, not a re-narrowing. - * @deprecated Not part of this contract on the `kanban` arm — write `cardTitle`. - */ - titleField?: never; - - /** - * RETIRED with the declarative face (objectui#7664, ADR-0049) — `draggable` - * was a `DeclarativeKanbanSchema` member and no registered board reads it - * (measured: zero `draggable` read sites in `@object-ui/plugin-kanban`; - * drag-and-drop is always on). - * - * A tombstone rather than a plain removal on PRONG 2 of the discriminator the - * precedent changesets state (objectui#5941, #7526; the one-line form is - * under correction as objectui#7678) — a tombstone exists (1) to steer - * authors to a named live replacement KEY, or (2) to keep loud a key the docs - * taught as working. Prong 1 does not apply: drag-and-drop is unconditional, - * so there is no replacement key to name, and the remedy is to delete the - * member. Prong 2 carries it: `content/docs/api/schema-reference.md` taught - * this key as working — before this card its kanban example opened with - * `"draggable": true` and its property table read "`draggable` | `boolean` | - * Enable drag-and-drop between columns." - * - * ⚠️ Inertness is why the key is retired, not why it is tombstoned. A key - * this documented must be refused by NAME rather than dropped: {@link - * BaseSchema} is `.passthrough()`, so dropping it from the mirror would leave - * a document naming it silently accepted with the value kept — the failure - * this card's own first cut shipped at {@link KanbanSchema.onCardClick}. - * @deprecated Not part of this contract — the value was inert. - */ - draggable?: never; - /** - * RETIRED (objectui#6124, ADR-0049) — JSON has no function value, and the - * `kanban` renderer takes `({ schema })` and never reads it. Carried over - * from the retired declarative face so the successor arm under the same - * `'kanban'` key keeps refusing the spelling by name; author behaviour as a - * node type (`{ "type": "toast" }`, an `action:button` node) instead. - * @deprecated Not part of this contract — the value was inert. - */ - onColumnAdd?: never; - /** - * RETIRED (objectui#6124, ADR-0049) — JSON has no function value, and the - * `kanban` renderer takes `({ schema })` and never reads it. Carried over - * from the retired declarative face so the successor arm under the same - * `'kanban'` key keeps refusing the spelling by name; author behaviour as a - * node type (`{ "type": "toast" }`, an `action:button` node) instead. - * @deprecated Not part of this contract — the value was inert. - */ - onCardAdd?: never; -} /** * A predefined card template with pre-filled field values. @@ -2190,7 +1929,11 @@ export interface DashboardComponentSchema extends BaseSchema { * Union type of all complex schemas */ export type ComplexSchema = - | KanbanSchema + // ⛔ `KanbanSchema` (`type: 'kanban'`) RETIRED here — objectui#8802, + // maintainer ruling 2026-09-09. Its Zod twin keeps an arm for the literal so + // an authored `type: "kanban"` is refused BY NAME and pointed at + // `object-kanban`; on this face the retirement IS the absence, which is what + // makes `tsc` refuse the literal at the authoring site. | CalendarViewSchema | FilterBuilderSchema | CarouselSchema diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 2319b5936e..6b1e2cefda 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -309,7 +309,9 @@ export type { export type { KanbanCard, KanbanColumn, - KanbanSchema, + // ⛔ `KanbanSchema` RETIRED with the bare `kanban` node type key + // (objectui#8802, maintainer ruling 2026-09-09) — `ObjectKanbanSchema` below + // is the surviving kanban face. CardTemplate, ColumnWidthConfig, CalendarViewMode, diff --git a/packages/types/src/registry.ts b/packages/types/src/registry.ts index eb39756db6..d52e465588 100644 --- a/packages/types/src/registry.ts +++ b/packages/types/src/registry.ts @@ -92,7 +92,6 @@ import type { } from './navigation.js'; import type { - KanbanSchema, CalendarViewSchema, FilterBuilderSchema, CarouselSchema, @@ -184,21 +183,25 @@ export interface SchemaRegistry { 'breadcrumb': BreadcrumbSchema; 'pagination': PaginationSchema; - // Complex - // `'kanban'` names the face the registered renderer honours — the plugin - // dialect, declared in THIS package since objectui#7664 (maintainer ruling - // (a), 2026-09-05): `@object-ui/plugin-kanban` imports `KanbanSchema` from here - // and conforms to it, so the map's value and the renderer's prop type are one - // declaration. Between objectui#7645 (PR #7662) and that ruling this entry - // read `BaseSchema & { type: 'kanban' }` — the weakest true claim — because - // this layer could not name the plugin's type (a phantom dependency, and a - // cycle). Moving the dialect down here is the route that comment named, not - // the one it forbade. Pinned in - // `src/__tests__/kanban-plugin-dialect-authoritative-7664.test.ts` (the key - // survives in `keyof`; the value IS the declared arm) and the same file name - // under `plugin-kanban/src/__tests__/` (the renderer's own prop type IS this - // declaration). - 'kanban': KanbanSchema; + // ⛔ `'kanban'` RETIRED (objectui#8802, maintainer ruling 2026-09-09). The + // bare node type key published two faces with opposite verdicts — the plugin's + // registry `inputs` accepted `titleField`, the Zod arm refused it by name — and + // the maintainer dissolved the divergence by retiring the key rather than + // patching either face. `KanbanSchema` retired with it (`complex.ts`). + // + // ⇒ `ComponentType` (`keyof SchemaRegistry`) no longer offers `'kanban'`, which + // is the compile-time half of the refusal; the Zod half is + // `RetiredKanbanNodeSchema` in `zod/complex.zod.ts`, which names `object-kanban` + // as the remedy. The surviving spelling `'object-kanban'` is declared by + // `ObjectKanbanSchema` (`objectql.ts`) and reached through + // `ObjectQLComponentSchema`, and the STORED `NamedListView.type` value of the + // same spelling is a different layer and untouched. + // + // ⚠️ Measured, and reported rather than fixed here: this map has NEVER had an + // `'object-kanban'` entry, so with `'kanban'` gone the kanban family has no + // entry in it at all. That is the objectui#7665 sweep's territory (this map's + // coverage), not this retirement's — adding one would be a widening of + // `ComponentType`, which is a ruling. 'calendar-view': CalendarViewSchema; 'filter-builder': FilterBuilderSchema; 'carousel': CarouselSchema; diff --git a/packages/types/src/zod/complex.zod.ts b/packages/types/src/zod/complex.zod.ts index 0fd1e17a36..41a9ec1e03 100644 --- a/packages/types/src/zod/complex.zod.ts +++ b/packages/types/src/zod/complex.zod.ts @@ -17,17 +17,14 @@ */ import { z } from 'zod'; -import { handlerKeyRefusal, retirementTombstone } from './tombstone.zod.js'; +import { handlerKeyRefusal, retiredNodeType, retirementTombstone } from './tombstone.zod.js'; import { ChartTypeSchema as SpecChartTypeSchema, DashboardSchema as SpecDashboardSchema, DashboardWidgetSchema as SpecDashboardWidgetSchema, GlobalFilterSchema as SpecGlobalFilterSchema, - GroupingConfigSchema as SpecGroupingConfigSchema, - NavigationConfigSchema as SpecNavigationConfigSchema, } from '@objectstack/spec/ui'; import { BaseSchema, SchemaNodeSchema, specFieldsExcept } from './base.zod.js'; -import { KanbanConditionalFormattingRuleSchema } from './objectql.zod.js'; import { DASHBOARD_COLOR_VARIANTS, DASHBOARD_WIDGET_TYPES } from '../designer.js'; import { DASHBOARD_COMPONENT_WIDGET_TYPES, @@ -89,35 +86,12 @@ const retiredDeclarativeKanbanKey = (key: string, where: string, remedy: string) `an object-bound board, or \`columns[].cards[]\` for a static one. ${remedy}`, ); -/** - * The ZERO-READ members this arm declared, named once so every refusal below - * says the same thing (objectui#7742, ADR-0049, maintainer decision batch #70, - * 2026-09-07: 「同意」). - * - * ⛔ Deliberately NOT {@link retiredDeclarativeKanbanKey}. That helper's message - * says the key "belonged to the retired `DeclarativeKanbanSchema` dialect", - * which is TRUE of `draggable` and the column `color` and FALSE of these three: - * they were members of the PLUGIN dialect objectui#7664 ruled authoritative, - * carried over member-for-member, and retired one card later for a different - * reason — declared on both faces, read by no registered board. Sharing the - * older message would hand the author a false history of their own document. - * - * The zeros are readings, not a dead grep: measured over - * `packages/plugin-kanban/src` with every file including tests, `allowCollapse` - * / `cardTemplates` / `columnWidths` returned 0 hits / 0 files while `groupBy` - * (85/27), `cardTitle` (18/9) and `coverImageField` (17/3) fired as controls on - * the same instrument. - * - * Refused BY NAME rather than dropped, for the reason this file states twice - * already: {@link BaseSchema} is `.passthrough()`, so a dropped key is KEPT, - * not refused. - */ -const retiredZeroReadKanbanKey = (key: string, taught: string, remedy: string) => - retirementTombstone( - `\`${key}\` is RETIRED (objectui#7742, ADR-0049) — the \`kanban\` arm declared ` + - 'it on both faces and NO registered board ever read it, so a document that set ' + - `it validated green and changed nothing. ${taught} ${remedy}`, - ); +// ⛔ `retiredZeroReadKanbanKey` RETIRED with its only three call sites +// (objectui#8802): `allowCollapse` / `cardTemplates` / `columnWidths` were +// tombstones on the `kanban` arm, and the arm itself is gone. The batch #70 +// refusals it carried were arm-scoped by construction — a `type: "kanban"` +// document is now refused whole, and a `type: "object-kanban"` one is judged by +// `objectql.zod.ts#ObjectKanbanSchema` exactly as it always was. /** * Kanban Card Schema — mirrors {@link KanbanCard} in `../complex.ts` key for key. @@ -185,68 +159,35 @@ export const ColumnWidthConfigSchema = z.object({ }); /** - * Kanban Schema — the `'kanban'` arm of {@link ComplexSchema}, mirroring - * {@link KanbanSchema} in `../complex.ts` key for key: the shape - * `@object-ui/plugin-kanban`'s registered renderers read (objectui#7664). + * ⛔ The `'kanban'` arm is RETIRED (objectui#8802, maintainer ruling 2026-09-09) + * — this is its NAMED REFUSAL, the half a deletion would not have given. + * + * {@link KanbanSchema}'s member-by-member mirror of `../complex.ts` went with + * the TypeScript interface. What stays is an arm claiming the literal, so + * `AnyComponentSchema`'s discriminator still routes a `type: "kanban"` document + * HERE and the author reads why the spelling went and what to write instead — + * rather than the union's own remedy-free `Invalid input`. * - * `onCardMove` / `onCardClick` / `onQuickAdd` are RUNTIME SLOTS (objectui#6124): - * `KanbanRenderer` forwards all three off `schema.*` in one block, so the - * TypeScript twin keeps them callable and this mirror refuses them by name. - * ⛔ None of the three may be dropped instead of refused — `BaseSchema` is - * `.passthrough()`, so a dropped key is KEPT rather than refused (the first cut - * of objectui#7664 dropped `onCardClick` and turned a refused document into an - * accepted one). `onColumnAdd` / `onCardAdd` are the two - * retired handler keys carried over from the declarative face so the successor - * arm keeps refusing the spelling; `draggable` is that face's own retired key. - * `conditionalFormatting` and `grouping` are the same schemas the `object-kanban` - * and `object-gallery` arms use (`objectql.zod.ts`, `@objectstack/spec`). + * ⚠️ Read `retiredNodeType`'s own docblock before changing this: the reason an + * arm is needed at all is NOT `BaseSchema`'s `.passthrough()` (that rule governs + * dropped MEMBER keys), it is that the union's generic discriminator message + * names no remedy. + * + * The keys this arm alone declared — `columns`, `cardTitle`, `swimlaneField`, + * `grouping`, `conditionalFormatting`, `navigation` — were never on the + * `object-kanban` face (`objectql.zod.ts#ObjectKanbanSchema`) and are not being + * removed from it: an `object-kanban` document is judged exactly as it was. + * + * Pinned in `../__tests__/bare-kanban-node-key-retired-8802.test.ts`. */ -export const KanbanSchema = BaseSchema.extend({ - type: z.literal('kanban'), - objectName: z.string().optional().describe('Object name to fetch data from'), - groupBy: z.string().optional().describe('Field to group records by (maps to column IDs)'), - swimlaneField: z.string().optional().describe('Field for swimlane rows (2D grouping)'), - cardTitle: z.string().optional().describe('Field to use as the card title'), - cardFields: z.array(z.string()).optional().describe('Fields to display on the card'), - data: z.array(z.any()).optional().describe('Static data or bound data (raw rows)'), - limit: z.number().optional().describe('Row cap for the fetch (defaults to 100)'), - columns: z.array(KanbanColumnSchema).optional().describe('Columns to display, each carrying its cards'), - onCardMove: handlerKeyRefusal('onCardMove', 'runtime-slot', 'Card move handler'), - onCardClick: handlerKeyRefusal('onCardClick', 'runtime-slot', 'Card click handler'), - className: z.string().optional().describe('CSS class name'), - quickAdd: z.boolean().optional().describe('Enable the Quick Add button at the bottom of each column'), - onQuickAdd: handlerKeyRefusal('onQuickAdd', 'runtime-slot', 'Quick Add handler'), - coverImageField: z.string().optional().describe('Field name to use as cover image on cards'), - allowCollapse: retiredZeroReadKanbanKey( - 'allowCollapse', - 'The capability exists on another channel:', - 'the enhanced board collapses a lane off that lane\'s own `collapsed` key, so write `columns[].collapsed`.', - ), - conditionalFormatting: z.array(KanbanConditionalFormattingRuleSchema).optional().describe('Card conditional formatting rules'), - cardTemplates: retiredZeroReadKanbanKey( - 'cardTemplates', - 'The capability exists on another channel:', - '`CardTemplates` takes its `templates` as a COMPONENT PROP, not off the board node; there is no authorable spelling for it.', - ), - columnWidths: retiredZeroReadKanbanKey( - 'columnWidths', - 'The capability exists on another channel:', - '`useColumnWidths` takes its `ColumnWidthConfig` as a HOOK OPTION, not off the board node; there is no authorable spelling for it. (The grid surface has an unrelated key of the same name — that one is untouched.)', - ), - grouping: stripImportedDefaults(SpecGroupingConfigSchema).optional().describe('Grouping configuration from ListView; its first field is the swimlaneField fallback'), - navigation: stripImportedDefaults(SpecNavigationConfigSchema).optional().describe('Record navigation behaviour on card click (drawer/dialog/page)'), - titleField: retirementTombstone( - '`titleField` is RETIRED on the `kanban` arm (objectui#7742, ADR-0049) — one arm, one ' + - 'spelling. Write `cardTitle`, which selects the same record field and which this arm ' + - 'has always declared. ⛔ This is NOT an inertness retirement: `ObjectKanban` still ' + - 'reads the key, because the SIBLING `object-kanban` arm declares it and keeps it ' + - '(objectui#7322 item ②). A `type: "object-kanban"` document naming `titleField` is ' + - 'still accepted; a `type: "kanban"` one is not.', - ), - draggable: retiredDeclarativeKanbanKey('draggable', 'board', 'Drag-and-drop is always on; delete the key.'), - onColumnAdd: handlerKeyRefusal('onColumnAdd', 'retired', 'Column add handler'), - onCardAdd: handlerKeyRefusal('onCardAdd', 'retired', 'Card add handler'), -}); +export const RetiredKanbanNodeSchema = retiredNodeType( + 'kanban', + 'Author `object-kanban` instead — the same board, the same renderer, and the ' + + 'spelling every stored kanban view already renders through (`ObjectView` maps a ' + + 'stored `kanban` view type onto the `object-kanban` node type). ⚠️ The STORED ' + + '`NamedListView.type` value `"kanban"` is a DIFFERENT layer and is unaffected — ' + + 'do not rewrite it.', +); /** * Calendar View Mode — the registered renderer's rendered set. @@ -1183,7 +1124,7 @@ export const DashboardConfigSchema = z.object({ * Complex Schema Union - All complex component schemas */ export const ComplexSchema = z.discriminatedUnion('type', [ - KanbanSchema, + RetiredKanbanNodeSchema, CalendarViewSchema, FilterBuilderSchema, CarouselSchema, diff --git a/packages/types/src/zod/index.zod.ts b/packages/types/src/zod/index.zod.ts index d6fe74c4a6..2307622df6 100644 --- a/packages/types/src/zod/index.zod.ts +++ b/packages/types/src/zod/index.zod.ts @@ -251,7 +251,11 @@ export { KanbanColumnSchema, CardTemplateSchema, ColumnWidthConfigSchema, - KanbanSchema, + // ⛔ `KanbanSchema` RETIRED with the bare `kanban` node type key + // (objectui#8802, maintainer ruling 2026-09-09). `RetiredKanbanNodeSchema` + // takes its place inside `ComplexSchema` so an authored `type: "kanban"` is + // refused BY NAME and pointed at `object-kanban`; it is deliberately NOT + // exported — nothing outside this package parses against a refusal arm. CalendarViewModeSchema, CalendarEventSchema, CalendarViewSchema, diff --git a/packages/types/src/zod/tombstone.zod.ts b/packages/types/src/zod/tombstone.zod.ts index d431a1f9b2..7cc8bdcba2 100644 --- a/packages/types/src/zod/tombstone.zod.ts +++ b/packages/types/src/zod/tombstone.zod.ts @@ -187,3 +187,56 @@ export function aliasKeyRefusal(alias: string, canonical: string, surface: strin `Unrecognized key(s) on ${surface}: \`${alias}\`. Did you mean \`${alias}\` → \`${canonical}\`? ${detail}`; return z.never({ error: guidance }).optional().describe(guidance); } + +/** + * Declare a RETIRED NODE TYPE arm: a component `type` literal the vocabulary no + * longer serves, kept in the discriminated union ONLY so an authored document + * naming it is refused BY NAME and pointed at the surviving spelling + * (objectui#8802, maintainer ruling 2026-09-09). + * + * ## Why an arm rather than a deletion — and why that is not the passthrough rule + * + * The family's other three retirements in that batch (`gantt` objectui#8008, + * `kanban-ui` / `kanban-enhanced` objectui#8257) are registration-only: no + * schema face ever declared them, so unregistering IS the retirement. This + * helper exists for the fourth case, where a face DID declare the literal. + * + * ⚠️ Two different mechanisms, and conflating them is how a retirement ships as + * a silent accept: + * + * - a dropped MEMBER key is KEPT, not refused — {@link retirementTombstone}'s + * whole reason, because `BaseSchemaCore` ends `.passthrough()`; + * - a dropped TYPE LITERAL on a DISCRIMINATED union IS refused, because + * `AnyComponentSchema` selects one arm from the authored literal and an + * unclaimed literal matches none. + * + * ⇒ Deleting the arm would already refuse. What it would NOT do is say why, or + * what to write instead: the union answers a missed discriminator with its own + * `Invalid input`, naming no remedy. Measured on zod 4.4.3: an arm carrying the + * literal plus a failing check reports `custom` at `path: ['type']` with this + * guidance, while the same document against a union without the arm reports + * `invalid_union` / "No matching discriminator" and nothing else. + * + * ⛔ Not {@link retirementTombstone}: that helper's `z.never()` sits at a MEMBER + * position inside an arm. A discriminated union needs the arm itself to claim + * the literal (`propValues`), so the refusal has to be a check on the object, + * not a member type. + * + * Same discipline as its three siblings in this file: ONE string feeds BOTH + * author-facing channels — the parse-time issue message and the `.describe()` + * metadata — so they cannot drift apart. + * + * @param type the retired literal, spelled into the message so the issue is + * addressed even when read without its path + * @param guidance why it retired and what to author instead + */ +export function retiredNodeType(type: string, guidance: string) { + const text = `\`${type}\` is a RETIRED node type (ADR-0049). ${guidance}`; + return z + .object({ type: z.literal(type) }) + .catchall(z.unknown()) + .check((ctx) => { + ctx.issues.push({ code: 'custom', message: text, input: ctx.value, path: ['type'] }); + }) + .describe(text); +} diff --git a/scripts/__tests__/check-handler-key-read-sites.test.ts b/scripts/__tests__/check-handler-key-read-sites.test.ts index b67363099f..3c4056859a 100644 --- a/scripts/__tests__/check-handler-key-read-sites.test.ts +++ b/scripts/__tests__/check-handler-key-read-sites.test.ts @@ -441,18 +441,34 @@ describe('check-handler-key-read-sites — this repository', () => { ).toEqual([]); // Named rather than counted: these three are the reason this gate exists. - // They sit four hops from `ComponentRegistry.register('kanban', …)`, so a - // walk that stopped following the document would leave the green above - // intact while losing exactly the instance the card was filed for. + // They sit four hops from the kanban plugin's registration, so a walk that + // stopped following the document would leave the green above intact while + // losing exactly the instance the card was filed for. + // + // ⚠️ Re-keyed by objectui#8802, and the re-key CHANGES ONE READING rather + // than merely renaming a string. The rows used to be `kanban.*`, DECLARED, + // carrying the RUNTIME SLOT disposition off the `'kanban'` Zod arm. That + // arm retired with the bare node type key, and the surviving + // `object-kanban` face declares none of the three — so the walk still finds + // all three reads (which is what this leg is for) and now reports them + // UNDECLARED, waived by the `object-kanban::…` rows objectui#7804 already + // owns in `KNOWN_UNDECLARED_READS`. ⛔ Not repaired here: declaring them on + // `ObjectKanbanSchema` widens a published accept set, which is a ruling. const judged = result.census.map((c) => `${c.type}.${c.key}`); - expect(judged).toContain('kanban.onCardClick'); - expect(judged).toContain('kanban.onCardMove'); - expect(judged).toContain('kanban.onQuickAdd'); + expect(judged).toContain('object-kanban.onCardClick'); + expect(judged).toContain('object-kanban.onCardMove'); + expect(judged).toContain('object-kanban.onQuickAdd'); for (const key of ['onCardClick', 'onCardMove', 'onQuickAdd']) { - const row = result.census.find((c) => c.type === 'kanban' && c.key === key); - expect(row?.declared, `'kanban'.${key} must be a declared member`).toBe(true); - expect(row?.disposition, `'kanban'.${key} must carry the RUNTIME SLOT disposition`).toBe('runtime-slot'); + const row = result.census.find((c) => c.type === 'object-kanban' && c.key === key); + expect(row?.declared, `'object-kanban'.${key} is undeclared since objectui#8802`).toBe(false); } + + // FIRING CONTROL for the `false`s above: the census still reports DECLARED + // runtime slots elsewhere, so `declared: false` is a reading about this face + // and not a census that lost its dispositions. + const chatbotSend = result.census.find((c) => c.type === 'chatbot' && c.key === 'onSend'); + expect(chatbotSend?.declared).toBe(true); + expect(chatbotSend?.disposition).toBe('runtime-slot'); }); /** @@ -491,11 +507,29 @@ describe('check-handler-key-read-sites — this repository', () => { it('derives its arms from the mirrors, including the one the card is about', () => { const { arms } = collectArms(repoRoot); - const kanban = arms.get('kanban'); - expect(kanban?.schema).toBe('KanbanSchema'); - expect(kanban?.file).toBe('complex.zod.ts'); - expect(kanban?.unresolved, 'the `kanban` arm must resolve completely, or its reads go unjudged').toEqual([]); - expect(kanban?.members.get('onCardClick')).toBe('runtime-slot'); - expect(kanban?.members.get('onColumnAdd')).toBe('retired'); + + // ⚠️ The worked example was the `'kanban'` arm (`complex.zod.ts`, + // `KanbanSchema`) until objectui#8802 retired the bare node type key and the + // arm with it. `object-kanban` is the surviving kanban face and is what the + // gate now resolves the plugin's reads against. + const objectKanban = arms.get('object-kanban'); + expect(objectKanban?.schema).toBe('ObjectKanbanSchema'); + expect(objectKanban?.file).toBe('objectql.zod.ts'); + expect( + objectKanban?.unresolved, + 'the `object-kanban` arm must resolve completely, or its reads go unjudged', + ).toEqual([]); + // It declares real members — the anti-vacuity half, so "resolves completely" + // is not satisfied by an empty arm. + expect(objectKanban?.members.has('groupBy')).toBe(true); + // ⛔ And it declares NONE of the three handler keys the plugin reads. That is + // the reading objectui#8802 moved; it is recorded, not repaired. + expect(objectKanban?.members.get('onCardClick')).toBeUndefined(); + + // A live arm that still carries both dispositions, so this leg keeps + // proving the resolver can read them at all. + const chatbot = arms.get('chatbot'); + expect(chatbot?.members.get('onSend')).toBe('runtime-slot'); + expect(chatbot?.members.get('onSendMessage')).toBe('retired'); }); }); diff --git a/scripts/check-doc-component-types.mjs b/scripts/check-doc-component-types.mjs index dee45abae7..7d7f394b07 100644 --- a/scripts/check-doc-component-types.mjs +++ b/scripts/check-doc-component-types.mjs @@ -483,6 +483,15 @@ const OPEN_REGISTRATION_SITES = { */ const DOC_TYPE_EXEMPTIONS = { 'content/docs/api/schema-reference.md': { + kanban: + 'ViewSwitcher `views[].type` — the VIEW-TYPE vocabulary (`ViewType`, ' + + 'packages/types/src/views.ts), which is what a switcher tab names, not a node type; the ' + + 'nested `schema` in the same snippet carries `object-kanban`, the node type. Needed from ' + + 'objectui#8802, which retired the bare `kanban` NODE type key — until then the value ' + + 'passed by coincidence, the two vocabularies sharing one spelling. ⛔ The stored/view-type ' + + 'spelling is deliberately NOT retired (`ObjectView` maps a stored `kanban` view onto the ' + + '`object-kanban` node type). Same vocabulary as the `components/complex/view-switcher.mdx` ' + + 'entry below.', action: 'ActionSchema discriminant under an ACTION LIST, never a rendered child — an action\'s own ' + '`dialog.actions[]` and `chain[]`, a detail page\'s `actions[]` and a CRUD dialog\'s ' + @@ -530,6 +539,15 @@ const DOC_TYPE_EXEMPTIONS = { 'filter enum, alongside `date-picker` / `number-range`.', }, 'content/docs/components/complex/view-switcher.mdx': { + kanban: + 'ViewSwitcher `views[].type` — the VIEW-TYPE vocabulary (`ViewType`, ' + + 'packages/types/src/views.ts), which is what a switcher tab names, not a node type. ' + + 'The nested `schema` in the very same snippet carries the node type. Needed from ' + + 'objectui#8802, which retired the bare `kanban` NODE type key — until then the value ' + + 'passed by coincidence, the two vocabularies sharing one spelling. ⛔ The stored/view-type ' + + 'spelling is deliberately NOT retired: `ObjectView` maps a stored `kanban` view onto the ' + + '`object-kanban` node type, and renaming it would break every stored kanban view in every ' + + 'deployment.', share: 'First member of a TypeScript union of view-action ids (`\'share\' | \'settings\' | ' + '\'duplicate\' | \'delete\'`) in a Schema API declaration, not a node type.',