diff --git a/.changeset/7742-kanban-arm-batch70.md b/.changeset/7742-kanban-arm-batch70.md new file mode 100644 index 0000000000..d081080901 --- /dev/null +++ b/.changeset/7742-kanban-arm-batch70.md @@ -0,0 +1,75 @@ +--- +'@object-ui/types': minor +'@object-ui/plugin-kanban': minor +--- + +**BREAKING (scored `minor` per this repo's version-alignment convention)** — the +`'kanban'` arm retires four accepted spellings and declares one read it never +named (objectui#7742, ADR-0049, maintainer decision batch #70, 2026-09-07: +「同意」). + +The accept set moves in **both** directions in one change, which is why the PR +carries `needs:contract-review`. + +## Narrowing — four keys the `'kanban'` arm no longer accepts + +Each is a `?: never` tombstone on the TypeScript face and a named refusal arm on +the `@object-ui/types/zod` mirror, so a document that names one is **refused by +name** with the remedy in the message. ⛔ None is dropped instead of refused: +`BaseSchema` is `.passthrough()`, so a dropped key is *kept*, not refused — the +failure objectui#7664's own first cut shipped at `onCardClick`. + +- **`allowCollapse`, `cardTemplates`, `columnWidths`** — declared on both faces + since the dialect was carried over, and read by **no registered board**. + Re-measured for this change over `packages/plugin-kanban/src`, every file + including tests: 0 hits / 0 files each, with `groupBy` (85 hits / 27 files), + `cardTitle` (18/9) and `coverImageField` (17/3) firing as controls on the same + instrument. An author who wrote `allowCollapse: true` validated green and got + a board that never collapsed off that key. Each capability exists on a + *different* channel, and the refusal message names it: a lane's own + `columns[].collapsed` for collapsing, a `templates` component prop for card + templates, a `useColumnWidths` hook option for widths. Wiring a board-level + switch to any of them would be new behaviour and is not ordered here. + `CardTemplate` and `ColumnWidthConfig` stay exported — the prop and the hook + still consume the types. +- **`titleField`** — ⚠️ **not** an inertness retirement, and reading it as one + gets the mechanism backwards. `ObjectKanban` still reads the key and the read + stays, because the **sibling `object-kanban` arm declares it and keeps it** + (objectui#7322 item ②). What retires is *this* arm's acceptance of the legacy + spelling: one arm, one spelling, and the refusal points at `cardTitle`. A + `type: "object-kanban"` document naming `titleField` still validates and still + renders; a `type: "kanban"` one is now refused. The key was never declared on + this face before — it rode `BaseSchema`'s index signature — so this is the + first time this face judges it at all. + +## Widening — one key the board reads and no face named + +- **`navigation`** is now declared on `KanbanSchema` (both faces), on the gantt + precedent objectui#5903. `ObjectKanban` reads it to choose the record-detail + overlay mode and defaults it to a drawer; until now an authored overlay mode + rode `BaseSchema`'s `[key: string]: any` — admitted, never examined — and the + read site had to spell itself `(schema as any).navigation`. That cast is gone. + The member list is `@objectstack/spec`'s `NavigationConfig` by reference, not + restated, so the vocabulary cannot fork. + +## `@object-ui/plugin-kanban` — `objectFields` moves from the schema bag to a React prop + +`objectFields` (the fetched object's field definitions, which card conditional +formatting needs so a rule comparing a relation sees the stored foreign key) is +now a **React prop on `KanbanRendererProps`**, a sibling of `schema`, rather than +a member of the `schema` bag. `ObjectKanban` — the one caller that fetches the +object definition — injects it there. Callers that render `KanbanRenderer` +directly and passed `objectFields` inside `schema` must move it to the prop. + +⚠️ **What this closes, stated at the arm level** — measured through the real +`SchemaRenderer`, not assumed. It closes the *schema read path*, and on the +`'kanban'` arm that closes the key outright: `ObjectKanbanRenderer` serves that +type key and discards its rest-spread (`void _props;`), so an authored +`objectFields` on a `type: "kanban"` node reaches nothing. It does **not** make +`objectFields` unreachable to an author in general. On the schema-only +`'kanban-ui'` entry, which `KanbanRenderer` serves directly, the key is absent +from `SchemaRenderer`'s stripped-metadata list, so it survives that renderer's +generic prop spread and arrives on the very prop this change adds — an authored +`objectFields` still reaches `resolveConditionalFormatting` there, as it did +before, and no schema face declares or judges it. Closing that entry is a +separate change and is not made here. diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index 9cd20de03d..fbd5518ddb 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -973,15 +973,15 @@ A static board carries its cards inline: | `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 columns to be collapsed. | | `conditionalFormatting` | `KanbanConditionalFormattingRule[]` | Card colouring rules — native `{ field, operator, value }` or spec `{ condition, style }`. | -| `cardTemplates` | `CardTemplate[]` | Predefined quick-add templates. | -| `columnWidths` | `ColumnWidthConfig` | Column width configuration. | | `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`. + > 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. **Related:** [ObjectViewSchema](#objectviewschema), [ObjectGridSchema](#objectgridschema) diff --git a/packages/plugin-kanban/src/ObjectKanban.tsx b/packages/plugin-kanban/src/ObjectKanban.tsx index 76baff1a91..68aa01ba9f 100644 --- a/packages/plugin-kanban/src/ObjectKanban.tsx +++ b/packages/plugin-kanban/src/ObjectKanban.tsx @@ -902,7 +902,10 @@ export const ObjectKanban: React.FC = ({ // 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. - const navConfig = (schema as any).navigation ?? { mode: 'drawer' }; + // `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. + 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. // We must always forward to the parent in that case — otherwise we'd open @@ -1143,23 +1146,30 @@ export const ObjectKanban: React.FC = ({ return ( <> - { - navigation.handleClick(card, event); - onCardClick?.(card); - }, - onCardMove: handleCardMove, - }} /> + // + // A PROP, not a schema key (objectui#7742, decision batch #70): it is an + // internal channel from the one caller that fetched the object + // definition, never an authoring surface. On the schema bag it was + // reachable by an author through `BaseSchema`'s passthrough. + objectFields={objectDef?.fields} + schema={{ + ...effectiveSchema, + // objectui#8307 — the lane headers count rows that came back, so when + // the fetch saturated its window they must say `77+`, not `77`. + countsAreWindowed, + onCardClick: (card: any, event?: any) => { + navigation.handleClick(card, event); + onCardClick?.(card); + }, + onCardMove: handleCardMove, + }} + /> {pendingMove && ( { + const title = await screen.findByText('Painted card'); + let el: HTMLElement | null = title; + while (el) { + const bg = el.style?.backgroundColor; + if (bg) return bg; + el = el.parentElement; + } + return ''; +} + +describe('`objectFields` reaches the predicate layer as a PROP (objectui#7742)', () => { + it('THE CONTROL — passed as a prop, the relation rule matches and the card is painted', async () => { + // ⭐ Asserted first and on purpose. It proves the fixture CAN produce a + // paint, so the negative below is a reading about the channel and not about + // a rule that never matched on any channel. + render(); + expect(await paintOfTheCard()).toBe(PAINT); + }); +}); + +describe('`objectFields` is no longer read off the schema bag (objectui#7742)', () => { + it('an AUTHORED `objectFields` inside `schema` does not reach the predicate layer', async () => { + // The same catalogue, the same rule, the same card — written where an + // author can write it. It must now do nothing. + render( + , + ); + expect(await paintOfTheCard()).not.toBe(PAINT); + }); + + it('and with neither channel supplied the card is unpainted — the paint is not unconditional', async () => { + // The third leg: without it, "unpainted" above could be the board's default + // for every card and would say nothing about `objectFields` at all. + render(); + expect(await paintOfTheCard()).not.toBe(PAINT); + }); +}); diff --git a/packages/plugin-kanban/src/index.tsx b/packages/plugin-kanban/src/index.tsx index 01240e7b21..c33616d641 100644 --- a/packages/plugin-kanban/src/index.tsx +++ b/packages/plugin-kanban/src/index.tsx @@ -152,36 +152,69 @@ export interface KanbanRendererProps { onQuickAdd?: (columnId: string, title: string) => void; coverImageField?: string; conditionalFormatting?: KanbanConditionalFormattingRule[]; - /** - * The object's field definitions, injected by `ObjectKanban` (the only - * entry point that fetches an object schema). Card conditional formatting - * needs them so a rule comparing a relation field sees the stored foreign - * key rather than the record `$expand` substituted for it — the board - * expands relations exactly as the grid does, so without this the SAME - * rule on the SAME view worked on the grid and silently never matched on - * the board (objectui#3501). Absent on the schema-only `kanban-ui` entry, - * which has no object schema to offer; there the payload is used verbatim, - * as before. - */ - objectFields?: unknown; /** * The lane counts below are counts of a fetched WINDOW, not of the group * (objectui#8307). Injected by `ObjectKanban`, the only entry point that * issues the windowed `$top` query and can therefore know the answer; - * absent on the schema-only `kanban-ui` entry, whose `data` arrives whole - * from its author and whose counts are complete by construction. Not an - * authorable input for exactly that reason — same shape as `objectFields` - * above, and likewise absent from this component's registry `inputs`. + * `ObjectKanban` supplies nothing on the schema-only `kanban-ui` entry, + * whose `data` arrives whole from its author and whose counts are complete + * by construction. Not MEANT to be an authorable input for exactly that + * reason — same shape and same argument as the `objectFields` prop BELOW, + * and likewise absent from this component's registry `inputs`. ⚠️ Unlike + * that prop it still rides this schema bag, so on `kanban-ui` an author can + * in fact write it; batch #70 did not name the key, so it is recorded here + * rather than moved (objectui#7742). */ countsAreWindowed?: boolean; }; + /** + * The object's field definitions, injected by `ObjectKanban` (the only entry + * point that fetches an object schema). Card conditional formatting needs + * them so a rule comparing a relation field sees the stored foreign key + * rather than the record `$expand` substituted for it — the board expands + * relations exactly as the grid does, so without this the SAME rule on the + * SAME view worked on the grid and silently never matched on the board + * (objectui#3501). + * + * ⛔ INTENDED AS AN INTERNAL CHANNEL, NOT AN AUTHORING SURFACE (objectui#7742, + * maintainer decision batch #70, 2026-09-07). It sits HERE — a React prop, a + * sibling of `schema` — and deliberately NOT inside `schema`, which is where + * it used to live. Inside `schema` it was reachable by an AUTHOR: `BaseSchema` + * is `.passthrough()`, `SchemaRenderer` hands the node through, and on the + * schema-only `kanban-ui` entry (which has no object schema of its own to + * substitute) an authored `objectFields` reached + * `resolveConditionalFormatting` verbatim. Nothing declared it on any schema + * face, so nothing judged it either. + * + * ⚠️ THE MOVE CLOSES THE `kanban` ARM, NOT THE KEY — measured through the + * real `SchemaRenderer`, so do NOT read this prop as proof that only + * `ObjectKanban` can write it. `ObjectKanbanRenderer` serves `type: 'kanban'` + * and discards its rest-spread (`void _props;`), so an authored `objectFields` + * on a `'kanban'` node reaches nothing — that arm is genuinely closed. But + * `objectFields` is NOT on `SchemaRenderer`'s stripped-metadata list (the + * destructure that feeds its `...componentProps` rest), so on the `kanban-ui` + * registration below — which THIS component serves — an authored + * `objectFields` survives the generic prop spread and lands right here, and + * still reaches `resolveConditionalFormatting` exactly as it did before. + * Stripping the key at that entry is a separate change, not made here. + * + * `ObjectKanban` supplies nothing on the schema-only `kanban-ui` entry — it + * has no object schema to offer — so unless an author wrote the key and it + * arrived by the spread above, conditional formatting there reads the card + * payload as before. + * + * ⚠️ `countsAreWindowed` above is the SAME shape and the same argument, and + * the batch #70 ruling did not name it — it stays on the schema bag, recorded + * rather than fixed here. + */ + objectFields?: unknown; } /** * KanbanRenderer - The public API for the kanban board component * This wrapper handles lazy loading internally using React.Suspense */ -export const KanbanRenderer: React.FC = ({ schema }) => { +export const KanbanRenderer: React.FC = ({ schema, objectFields }) => { const { t } = useUncolumnedT(); // ⚡️ Adapter: Map flat 'data' + 'groupBy' to nested 'cards' structure. const processedColumns = React.useMemo( @@ -207,7 +240,7 @@ export const KanbanRenderer: React.FC = ({ schema }) => { onQuickAdd={schema.onQuickAdd} coverImageField={schema.coverImageField} conditionalFormatting={schema.conditionalFormatting} - objectFields={schema.objectFields} + objectFields={objectFields} swimlaneField={schema.swimlaneField} countsAreWindowed={schema.countsAreWindowed} /> diff --git a/packages/types/src/__tests__/kanban-arm-batch70-7742.test.ts b/packages/types/src/__tests__/kanban-arm-batch70-7742.test.ts new file mode 100644 index 0000000000..0f5e54e453 --- /dev/null +++ b/packages/types/src/__tests__/kanban-arm-batch70-7742.test.ts @@ -0,0 +1,168 @@ +/** + * 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 index e6348a84d5..3808710de8 100644 --- a/packages/types/src/__tests__/kanban-plugin-dialect-authoritative-7664.test.ts +++ b/packages/types/src/__tests__/kanban-plugin-dialect-authoritative-7664.test.ts @@ -220,6 +220,11 @@ describe("the 'kanban' validator arm accepts what the registered renderer reads '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); } @@ -294,17 +299,27 @@ describe('the declared body is measured, not inherited (objectui#7664)', () => { })); } - it('KanbanSchema declares 20 live members — the dialect\'s 19 (the ruling said 18; `type` is the difference) plus `onCardClick` — and exactly 3 tombstones', () => { + 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', 'allowCollapse', - 'conditionalFormatting', 'cardTemplates', 'columnWidths', 'grouping', + 'onCardMove', 'onCardClick', 'className', 'quickAdd', 'onQuickAdd', 'coverImageField', + 'conditionalFormatting', 'grouping', 'navigation', + ]); + expect(live).toHaveLength(18); + expect(tombstoned).toEqual([ + 'allowCollapse', 'cardTemplates', 'columnWidths', 'titleField', + 'draggable', 'onColumnAdd', 'onCardAdd', ]); - expect(live).toHaveLength(20); - expect(tombstoned).toEqual(['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, diff --git a/packages/types/src/complex.ts b/packages/types/src/complex.ts index 0b86a76a1b..8c83a05d11 100644 --- a/packages/types/src/complex.ts +++ b/packages/types/src/complex.ts @@ -22,7 +22,7 @@ import type { GroupingConfig, } from '@objectstack/spec/ui'; import type { BaseSchema, SchemaNode } from './base.js'; -import type { KanbanConditionalFormattingRule } from './objectql.js'; +import type { KanbanConditionalFormattingRule, ViewNavigationConfig } from './objectql.js'; /** * Kanban card — the shape the registered `'kanban'` renderer reads. @@ -310,12 +310,6 @@ export interface KanbanSchema extends BaseSchema { */ coverImageField?: string; - /** - * Allow columns to be collapsed/expanded. - * @default false - */ - allowCollapse?: boolean; - /** * Conditional formatting rules for card coloring. Accepts the native * `{ field, operator, value }` shape and the spec `{ condition, style }` CEL @@ -324,22 +318,137 @@ export interface KanbanSchema extends BaseSchema { conditionalFormatting?: KanbanConditionalFormattingRule[]; /** - * Predefined card templates for quick-add. - * Each template pre-fills the quick-add form with default values. + * Grouping configuration from ListView. + * When set, the first grouping field is used as swimlaneField fallback. */ - cardTemplates?: CardTemplate[]; + grouping?: GroupingConfig; /** - * Custom column width configuration. - * Supports per-column overrides with min/max constraints. + * 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. */ - columnWidths?: ColumnWidthConfig; + navigation?: ViewNavigationConfig; /** - * Grouping configuration from ListView. - * When set, the first grouping field is used as swimlaneField fallback. + * 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. */ - grouping?: GroupingConfig; + 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` diff --git a/packages/types/src/zod/complex.zod.ts b/packages/types/src/zod/complex.zod.ts index 489fe69f45..adcafb97bf 100644 --- a/packages/types/src/zod/complex.zod.ts +++ b/packages/types/src/zod/complex.zod.ts @@ -24,6 +24,7 @@ import { 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'; @@ -88,6 +89,36 @@ 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}`, + ); + /** * Kanban Card Schema — mirrors {@link KanbanCard} in `../complex.ts` key for key. * @@ -186,11 +217,32 @@ export const KanbanSchema = BaseSchema.extend({ 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: z.boolean().optional().describe('Allow columns to be collapsed/expanded'), + 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: z.array(CardTemplateSchema).optional().describe('Predefined card templates for quick-add'), - columnWidths: ColumnWidthConfigSchema.optional().describe('Custom column width configuration'), + 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'),