diff --git a/.changeset/9342-kanban-oncardmove-prop.md b/.changeset/9342-kanban-oncardmove-prop.md new file mode 100644 index 0000000000..34201350e5 --- /dev/null +++ b/.changeset/9342-kanban-oncardmove-prop.md @@ -0,0 +1,62 @@ +--- +'@object-ui/types': minor +'@object-ui/plugin-kanban': minor +'@object-ui/components': minor +--- + +**BREAKING (scored `minor` per this repo's version-alignment convention)** — +`KanbanRenderer` takes `onCardMove` as an explicit React prop, and the +`object-kanban` document face tombstones the key (objectui#9342, executing the +`domain:ui` seat's option-B ruling on PR objectui#9338; the objectui#7742 remedy +`objectFields` took one member over, maintainer decision batch #70). + +A `major` is unavailable by convention, not by preference: every package in +`.changeset/config.json`'s `fixed` group ships as one family whose major tracks +`@objectstack`, so `scripts/check-changeset-no-major.mjs` rejects a `major` +declaration outright. Breaking semantics are stated here instead. + +## `@object-ui/plugin-kanban` — the move, and what it breaks + +`onCardMove` is now a **React prop on `KanbanRendererProps`**, a sibling of +`schema`, and is **no longer a member of the `schema` bag**. `ObjectKanban` +passes its own `handleCardMove` through that prop. + +⚠️ **This narrows a published props surface.** A host that renders +`KanbanRenderer` directly and wrote the handler inside `schema` must move it to +the prop: `schema={board} onCardMove={handler}`. A typed host gets a TS error; an +untyped one gets a silent drop, which is why the change carries +`needs:contract-review`. + +## `@object-ui/types` — the key is refused by name, as a TOMBSTONE + +`ObjectKanbanSchema.onCardMove` is `?: never` on the TypeScript face and +`handlerKeyRefusal('onCardMove', 'retired', …)` on the `@object-ui/types/zod` +mirror. An authored `onCardMove` was **accepted and silently dropped** before +this: `BaseSchema` is `.passthrough()`, so an undeclared key is not refused — it +stops being judged and the value is KEPT — and `ObjectKanban` then substituted +its own mover over it. + +⛔ **RETIRED, not a RUNTIME SLOT**, and the two are not interchangeable here. A +slot keeps the TypeScript twin callable, which would publish a key the +object-bound board DROPS — the resolution this package's `quickAdd` carve-out +forbids in as many words. The sibling `onCardClick` is a slot because its +function reaches the board through a React prop `ObjectKanban` declares; +`onCardMove` has no such prop, and objectui#7804 measured that by driving the +handler the board was actually handed, with `onCardClick` as the lit control on +the same document and the same render. + +⭐ **Why it took a second card.** The disposition did not move — objectui#7804 +already measured `'retired'`. What blocked it was `check:handler-key-reads`, +which refuses a tombstone while a renderer still reads the key off the document +("a tombstone exists precisely because nothing reads the key — it has no read +site BY CONSTRUCTION"). What that gate cannot see is that the value at the read +was substituted one hop earlier. Moving the READ is what makes both sides true +at once, and it drains the key's `KNOWN_UNDECLARED_READS` row. + +## `@object-ui/components` — the doc this makes wrong + +`src/renderers/complex/README-KANBAN.md` taught an `object-kanban` document +carrying `"onCardMove": "(event) => …"` — a function spelled as a string, +accepted and silently dropped on the day it was written and refused by name from +now on. It teaches the React prop instead, and its prop table spells the key +`never`. diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index e8a68e05d8..94a65a7e1d 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -951,7 +951,7 @@ A drag-and-drop Kanban board. The `object-kanban` type key validates the shape t > > The other keys the retired `kanban` arm alone declared — `cardTitle`, `swimlaneField`, `grouping` and `navigation` — are still undeclared on this face. The renderer reads them, so a board may carry them; they are simply not judged. -> **Handler keys are not authorable in JSON, and two of the three now say so by name.** Since objectui#7804 this face declares `onCardClick` and `onQuickAdd` as objectui#6124 **runtime slots**: a React host supplies the function through the TypeScript interface or as a React prop, and this validator **refuses the key by name** with a message pointing at the node-type spelling (`{ "type": "toast", … }`, an `action:button` node). Until then an authored `onCardClick: { "action": "toast" }` parsed **green** — `BaseSchema` is `.passthrough()`, so a key no arm declares is not refused, it stops being judged and the value is kept, then reaches a call site expecting a function. ⚠️ `onCardMove` is the third key the board component reads and it is **still undeclared**: an authored one is accepted and dropped, because on an object-bound board the renderer substitutes its own mover. That gap is open on objectui#7804. +> **Handler keys are not authorable in JSON, and all three now say so by name.** Since objectui#7804 this face declares `onCardClick` and `onQuickAdd` as objectui#6124 **runtime slots**: a React host supplies the function through the TypeScript interface or as a React prop, and this validator **refuses the key by name** with a message pointing at the node-type spelling (`{ "type": "toast", … }`, an `action:button` node). Until then an authored `onCardClick: { "action": "toast" }` parsed **green** — `BaseSchema` is `.passthrough()`, so a key no arm declares is not refused, it stops being judged and the value is kept, then reaches a call site expecting a function. ⭐ The third key, `onCardMove`, is a **tombstone** since objectui#9342, not a runtime slot: an authored one reached **nothing** even as a function, because an object-bound board substitutes its own mover — so the TypeScript twin is `?: never` rather than callable, and the mover lives on `KanbanRenderer`'s React prop of the same name, a sibling of its `schema`. > `data` and `bind` are [`BaseSchema`](#baseschema) members, not narrowed here, but this face requires **one of** `bind`, `data`, `objectName` — the renderer's own record-source ladder (an external `data` prop → `bind` via `useDataScope` → this schema's own `data` → a fetch keyed by `objectName`). A purely static board (lanes carrying their own cards, no record source) authors `"groupBy"` and `"data": []`. ⚠️ The record-source rule is **separate** from the lane key and is unaffected by objectui#8990: omitting `groupBy` is fine, omitting all of `bind` / `data` / `objectName` is still refused, at the refinement rather than at `groupBy`. diff --git a/content/docs/plugins/plugin-kanban.mdx b/content/docs/plugins/plugin-kanban.mdx index c37c484261..e377e0e703 100644 --- a/content/docs/plugins/plugin-kanban.mdx +++ b/content/docs/plugins/plugin-kanban.mdx @@ -88,10 +88,16 @@ objectui#6124 **runtime slots**, so an authored `onCardClick: { "action": "toast" }` is now refused with a message pointing at the node-type spelling, where before it parsed green and was handed to a call site expecting a function. A React host still supplies either function through the TypeScript interface or -as a React prop; only the JSON face refuses them. ⚠️ `onCardMove` itself is -still *undeclared* on that validator — an authored one is accepted and silently -dropped, because an object-bound board substitutes its own mover — and that gap -is open on objectui#7804. +as a React prop; only the JSON face refuses them. + +⭐ `onCardMove` is declared too, since objectui#9342 — but as a **tombstone**, +not a runtime slot. An authored one used to be accepted and silently dropped, +because an object-bound board substitutes its own mover and `ObjectKanban` +declares no `onCardMove` React prop; the key reached **nothing**, so the +TypeScript twin is `?: never` rather than callable and the validator refuses the +key by name. The mover is a prop on **`KanbanRenderer`**, a sibling of its +`schema` — which is where a host that mounts the board component directly +supplies it, and the move that let the arm carry the tombstone at all. `columns` are the board's **swimlanes**, not a field projection — the fields drawn on a card are `cardFields`. It is **one of two array shapes**, the pair diff --git a/packages/components/src/renderers/complex/README-KANBAN.md b/packages/components/src/renderers/complex/README-KANBAN.md index a82788d240..5f4b653fb1 100644 --- a/packages/components/src/renderers/complex/README-KANBAN.md +++ b/packages/components/src/renderers/complex/README-KANBAN.md @@ -56,16 +56,49 @@ A fully functional, schema-driven Kanban board component for Object UI with drag ### With Event Handling +⛔ **`onCardMove` is not a document key, and never was.** JSON has no function +value, so it cannot be authored — not as a function, and not as the string +spelling this page used to teach: + ```json { "type": "object-kanban", - "groupBy": "status", - "data": [], - "columns": [...], "onCardMove": "(event) => { console.log('Card moved:', event); }" } ``` +That document was **accepted and silently dropped** until objectui#9342: the +`object-kanban` validator did not declare the key, `BaseSchema` is +`.passthrough()`, and `ObjectKanban` substituted its own mover on the schema it +hands the board — so an author who wrote it got a board that never called it and +no error saying why. It is now **refused by name**, with the remedy in the +message. + +The board's mover is a **React prop**, supplied by the host that owns the write: + +```tsx +import { KanbanRenderer } from '@object-ui/plugin-kanban'; + +const board = { type: 'object-kanban', groupBy: 'status', data: [], columns }; + +// A sibling of `schema`, never a member of it (objectui#9342). +const onCardMove = ( + cardId: string, + fromColumnId: string, + toColumnId: string, + newIndex: number, +) => { + console.log(`Card ${cardId} moved from ${fromColumnId} to ${toColumnId} at ${newIndex}`); +}; + +; +``` + +⚠️ On an **object-bound** board (``, the `object-kanban` registry +key) the host does **not** supply it: `ObjectKanban` owns the mover, because the +same function owns the optimistic write, the required-fields dialog and the +rollback. The prop above is for a host that mounts `KanbanRenderer` directly. + ## Schema Reference ### Kanban Props @@ -75,7 +108,7 @@ A fully functional, schema-driven Kanban board component for Object UI with drag | `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 | +| `onCardMove` | `never` | — | ⛔ RETIRED (objectui#9342) — refused by name. Not a document key: the mover is a React prop on `KanbanRenderer`, or `ObjectKanban`'s own on an object-bound board. See "With Event Handling" above. | ### KanbanColumn diff --git a/packages/plugin-kanban/README.md b/packages/plugin-kanban/README.md index afa9b8528f..690e04d065 100644 --- a/packages/plugin-kanban/README.md +++ b/packages/plugin-kanban/README.md @@ -136,9 +136,12 @@ declare const columns: KanbanColumn[]; // // ⚠️ `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 +// parameter types below rather than inferred from the annotation. Since +// objectui#9342 the document face says so BY NAME — `ObjectKanbanSchema` carries +// a `?: never` tombstone and the zod mirror refuses the key with the remedy in +// the message, where before an authored one was accepted and silently dropped. +// 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 = { @@ -243,12 +246,19 @@ const schema: ObjectKanbanSchema = { }; // The host supplies the handler as a React prop — JSON has no function value. +// It is a SIBLING of `schema` on `KanbanRenderer`, never a member of it +// (objectui#9342): ``. 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 }; ``` +⚠️ An **object-bound** board does not take this prop. `ObjectKanban` owns the +mover — the same function owns the optimistic write, the required-fields dialog +and the rollback — so on the `object-kanban` registry key there is no host +handler to supply, and the document key is refused by name. + ## Links - 📚 [Documentation](https://www.objectui.org/docs/plugins/plugin-kanban) diff --git a/packages/plugin-kanban/src/ObjectKanban.tsx b/packages/plugin-kanban/src/ObjectKanban.tsx index c3de2dfe56..1b5cbbd939 100644 --- a/packages/plugin-kanban/src/ObjectKanban.tsx +++ b/packages/plugin-kanban/src/ObjectKanban.tsx @@ -1428,6 +1428,15 @@ export const ObjectKanban: React.FC = ({ // definition, never an authoring surface. On the schema bag it was // reachable by an author through `BaseSchema`'s passthrough. objectFields={objectDef?.fields} + // A PROP, not a schema key (objectui#9342, executing the ruling on PR + // objectui#9338) — the same remedy `objectFields` above took one card + // earlier. `handleCardMove` owns the optimistic write, the + // required-fields dialog and the objectui#4138 rollback, so it is this + // board's mover and never an authored one. While it rode the `schema` + // bag below, an authored `onCardMove` was accepted by the passthrough, + // substituted here, and silently dropped; the arm can only tombstone the + // key once no renderer reads it off the document. + onCardMove={handleCardMove} schema={{ ...effectiveSchema, // objectui#8307 — the lane headers count rows that came back, so when @@ -1455,7 +1464,6 @@ export const ObjectKanban: React.FC = ({ captureAnchor(event); navigation.handleClick(card, event); }, - onCardMove: handleCardMove, }} /> diff --git a/packages/plugin-kanban/src/__tests__/handlerKeyDispositionsMeasured-7804.test.tsx b/packages/plugin-kanban/src/__tests__/handlerKeyDispositionsMeasured-7804.test.tsx index ee649c30e9..2dc5b7380a 100644 --- a/packages/plugin-kanban/src/__tests__/handlerKeyDispositionsMeasured-7804.test.tsx +++ b/packages/plugin-kanban/src/__tests__/handlerKeyDispositionsMeasured-7804.test.tsx @@ -7,11 +7,12 @@ */ /** - * Two of the three handler keys `KanbanRenderer` reads off the authored - * document are now JUDGED by the `object-kanban` arm, and each one's - * objectui#6124 disposition is MEASURED here rather than shared across the - * prefix (objectui#7804, the `plugin-kanban` slice of the 39-row finding; - * director seat ruling of 2026-09-07, decision batch #69). + * All three handler keys the kanban board consumes are now JUDGED by the + * `object-kanban` arm, and each one's objectui#6124 disposition is MEASURED + * here rather than shared across the prefix (objectui#7804, the `plugin-kanban` + * slice of the 39-row finding; director seat ruling of 2026-09-07, decision + * batch #69 — with the third key landing on objectui#9342, which moved the READ + * that had blocked its tombstone). * * ## The exposure this closes * @@ -39,36 +40,44 @@ * where `handleClick` gives it full priority and calls it. The authored * function runs. (Until objectui#9341 the wrapper ALSO called it directly, * which is why it ran twice — see the correction at the end of this block.) - * - `onCardMove` — the reading is `'retired'` and it does NOT land here. - * `ObjectKanban` replaces the schema key with `handleCardMove` and declares - * NO `onCardMove` prop (its rest parameter is discarded), so neither - * channel delivers and an authored value reaches nothing on this arm. + * - `onCardMove` — the reading is `'retired'`, and since objectui#9342 it + * lands here as a TOMBSTONE on both faces. `ObjectKanban` replaces the + * schema key with `handleCardMove` and declares NO `onCardMove` prop (its + * rest parameter is discarded), so neither channel delivers and an authored + * value reaches nothing on this arm. * * ⛔ "`ObjectKanban` overrides it" does not separate `onCardClick` from * `onCardMove` — it is true of both. What separates them is the PROP channel, * which only `onCardClick` has, so that is what suite 2 drives. * - * ## ⚠️ Why the third key is recorded here instead of declared + * ## ⭐ Why the third key took a second card — and what closed it * * `check:handler-key-reads` — the gate of record for this class — refuses the * `'retired'` spelling while a renderer still reads the key, and prints * `declares it RETIRED, but a renderer still reads it`. Its own contract says * why: a tombstone "exists precisely because nothing reads the key — it has no - * read site BY CONSTRUCTION". `KanbanRenderer` does read `schema.onCardMove`, - * off the document `ObjectKanban` hands it; what the gate cannot see is that - * the value at that read was substituted one hop earlier. + * read site BY CONSTRUCTION". `KanbanRenderer` DID read the key off the + * document `ObjectKanban` hands it; what the gate cannot see is that the value + * at that read was substituted one hop earlier. * - * ⛔ The two spellings that would make it green are both worse. Declaring - * `'runtime-slot'` keeps the TypeScript twin callable and so publishes a key - * the object-bound board DROPS — the one resolution this package's `quickAdd` - * carve-out records as forbidden. Deleting the read narrows `KanbanRenderer`'s - * published props, which is the objectui#7742 remedy (`objectFields`, one file - * over, maintainer decision batch #70) and a ruling rather than a repair. + * ⛔ Of the two spellings that would have made it green, one is forbidden: + * declaring `'runtime-slot'` keeps the TypeScript twin callable and so + * publishes a key the object-bound board DROPS — the resolution this package's + * `quickAdd` carve-out records as forbidden. * - * ⇒ `onCardMove` keeps its `KNOWN_UNDECLARED_READS` row naming objectui#7804, - * which stays open and stays the parent. Suite 1 pins it as STILL ACCEPTED AND - * KEPT, so the exposure cannot drift silently and the day it is closed this - * file goes red pointing at the reading that closed it. + * ⇒ objectui#9342 took the other one, as a RULING rather than a repair (the + * `domain:ui` seat on PR objectui#9338, option B; the objectui#7742 remedy + * `objectFields` took one file over under maintainer decision batch #70): + * `KanbanRenderer` now takes `onCardMove` as an explicit React PROP, a sibling + * of `schema`, `ObjectKanban` passes `handleCardMove` through it, and the arm + * carries `handlerKeyRefusal(…, 'retired', …)`. That narrows a published props + * surface, which is why the change carries `needs:contract-review`. + * + * ⚠️ The DISPOSITION did not move. objectui#7804 measured `'retired'` and suite + * 2 below still drives it dead on the same instrument; what objectui#9342 + * supplied is the precondition the gate demanded. Suite 1 used to pin the key + * as STILL ACCEPTED AND KEPT, with the note that the day the read moved THAT + * leg would go red — it did, and it now reads the named refusal instead. * * ## Every control here can fire * @@ -86,13 +95,15 @@ * * - suite 1 (the accept set) FAILS on the two declared keys: both parse * GREEN and the authored value survives into the parsed output, which is - * the exposure restated as a reading. Its `onCardMove` leg PASSES before - * and after — that key is the one this slice does not close; + * the exposure restated as a reading. Its `onCardMove` leg PASSED before + * and after — that key was the one this slice did not close, and + * objectui#9342 flipped that leg to the named refusal; * - suite 3 (both faces) FAILS on the two: nothing is declared on either * face, so there is no disposition to read; * - suite 4 (the drained ledger) FAILS listing the two * `object-kanban::ObjectKanbanSchema.*` rows this slice drains, while its - * `onCardMove` row is expected to survive; + * `onCardMove` row is expected to survive (⭐ objectui#9342 drained that + * third row too, and this suite now asserts the set is EMPTY); * - suite 2 (the channels) PASSES UNCHANGED, before and after. It measures * the renderer, and this card changes no renderer — which is exactly why it * is the evidence the dispositions are derived from rather than a @@ -222,18 +233,26 @@ describe('suite 1 — the accept set: an authored handler key on `object-kanban` expect(parsed.success).toBe(false); }); - it('⚠️ `onCardMove` is STILL ACCEPTED AND KEPT — the row this slice did not close', () => { - // ⛔ Not an oversight and not a passing grade: this is the objectui#7664 - // exposure, still open on this one key, pinned so it cannot drift in - // silence. The header says why declaring it is refused by the gate of - // record and why the two greener spellings are worse. The day the read - // moves to an explicit React prop and the key is tombstoned, THIS leg goes - // red — which is the point of writing it down as a reading. + it('⭐ `onCardMove` is REFUSED BY NAME — the row this slice could not close (objectui#9342)', () => { + // ⭐ THE LEG THAT FLIPPED, and it flipped by design. It used to read + // `{ accepted: true, kept: { action: 'toast' } }` — the objectui#7664 + // exposure restated as a reading, pinned so it could not drift in silence — + // with the note that the day the read moved to an explicit React prop and + // the key was tombstoned, THIS leg would go red. objectui#9342 is that day: + // `KanbanRenderer` takes `onCardMove` as a React prop, so the arm carries + // `handlerKeyRefusal(…, 'retired', …)` and the authored value is refused BY + // NAME instead of being accepted and dropped. + // + // ⚠️ The DISPOSITION did not move — objectui#7804 measured it `'retired'` + // and suite 2 below still drives it dead. Only the precondition the gate of + // record demanded was supplied. const parsed = ObjectKanbanZod.safeParse(board({ onCardMove: { action: 'toast' } })); expect({ accepted: parsed.success, + code: parsed.success ? null : parsed.error.issues[0]?.code, + path: parsed.success ? null : parsed.error.issues[0]?.path.join('.'), kept: parsed.success ? (parsed.data as Record).onCardMove : null, - }).toEqual({ accepted: true, kept: { action: 'toast' } }); + }).toEqual({ accepted: false, code: 'custom', path: 'onCardMove', kept: null }); }); it('CONTROLS — the scope did not move: a declared key still parses, and an undeclared one is still KEPT', () => { @@ -349,14 +368,14 @@ describe('suite 3 — the disposition is legible on BOTH faces, and they agree ( return src.slice(start, end); } - it('the zod mirror refuses the two by name — and still declares nothing for the third', () => { + it('the zod mirror refuses all three by name — the third as a tombstone (objectui#9342)', () => { const shape = ObjectKanbanZod.shape as Record; expect(KEYS.map((key) => ({ key, declared: key in shape }))).toEqual([ { key: 'onCardClick', declared: true }, - { key: 'onCardMove', declared: false }, + { key: 'onCardMove', declared: true }, { key: 'onQuickAdd', declared: true }, ]); - for (const key of DECLARED) { + for (const key of KEYS) { expect(shape[key]?.description, `\`${key}\` carries no author-facing guidance`).toContain( 'objectui#6124', ); @@ -364,7 +383,7 @@ describe('suite 3 — the disposition is legible on BOTH faces, and they agree ( } }); - it('the mirror spells RUNTIME SLOT on both landed keys, and nothing at all on the third', () => { + it('the mirror spells RUNTIME SLOT on the two live slots and RETIRED on the third', () => { const shape = ObjectKanbanZod.shape as Record; const said = (key: string) => shape[key]?.description?.includes('RUNTIME SLOT') @@ -378,12 +397,15 @@ describe('suite 3 — the disposition is legible on BOTH faces, and they agree ( onQuickAdd: said('onQuickAdd'), }).toEqual({ onCardClick: 'runtime-slot', - onCardMove: 'undeclared', + // ⭐ objectui#9342. `'undefined'` here until the read moved; the reading + // itself is objectui#7804's, and `said()` is the same reader for all + // three, so this is the one that changed and not the instrument. + onCardMove: 'retired', onQuickAdd: 'runtime-slot', }); }); - it('the TypeScript twin keeps both live slots callable, and declares no third member', () => { + it('the TypeScript twin keeps both live slots callable and tombstones the third', () => { const body = objectKanbanInterface(); const member = (key: string) => new RegExp(`^\\s{2}${key}\\?:\\s*([^;]+);`, 'm').exec(body)?.[1]?.trim(); expect({ @@ -397,11 +419,13 @@ describe('suite 3 — the disposition is legible on BOTH faces, and they agree ( // deleted call. A signature pin read off disk, so it moves WITH the // declaration and cannot drift from it. onCardClick: '(card: any, event?: any) => void', - // ⚠️ `undefined` is the reading, not a gap in the regex — the firing - // control below reads a member this interface has always had. A - // `?: never` tombstone here is what the measurement asks for and what - // the gate of record refuses; see the header. - onCardMove: undefined, + // ⭐ objectui#9342 — the `?: never` tombstone the measurement always + // asked for. It read `undefined` (no member at all) while the gate of + // record refused the spelling; moving `KanbanRenderer`'s read to an + // explicit React prop is what made it writable. ⛔ `never`, NOT a + // callable: a callable twin publishes a key the object-bound board + // DROPS, which is the `quickAdd` carve-out's forbidden resolution. + onCardMove: 'never', onQuickAdd: '(columnId: string, title: string) => void', }); @@ -413,18 +437,15 @@ describe('suite 3 — the disposition is legible on BOTH faces, and they agree ( }); describe('suite 4 — the ledger drained with the fix (objectui#7804)', () => { - it('the two landed rows are gone, and the one this slice did not close is still named', () => { + it('every `object-kanban` row is drained — the last one by objectui#9342', () => { const rows = [...ledger.keys()].filter((row) => row.startsWith('object-kanban::'), ); expect( rows, - 'a ledger row naming a read that is now declared is a live waiver for a defect that is gone; a ' + - 'read that is still undeclared must keep its row or the gate loses it', - ).toEqual(['object-kanban::ObjectKanbanSchema.onCardMove']); - expect( - ledger.get('object-kanban::ObjectKanbanSchema.onCardMove'), - ).toBe('objectui#7804'); + 'a ledger row naming a read that is now declared is a live waiver for a defect that is gone — ' + + '`staleExemptions()` in the gate reddens on exactly that', + ).toEqual([]); }); it('CONTROL — the ledger still carries the rows this slice did NOT take', () => { 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 4d7b8d6763..fd608943ad 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 @@ -219,22 +219,50 @@ async function boardPropsViaSchemaRenderer(schema: Record) { } describe('which authored handler keys reach a registered kanban board (objectui#7664)', () => { - it('KanbanRenderer forwards onCardClick, onCardMove and onQuickAdd by identity', async () => { + it('KanbanRenderer forwards onCardClick and onQuickAdd off the schema bag 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. + // + // ⭐ objectui#9342 — `onCardMove` LEFT this leg, and its absence is the + // change rather than a gap. It is no longer read off the `schema` bag at + // all: `KanbanRendererProps` declares it as an explicit React PROP, a + // sibling of `schema`, so a key written INSIDE `schema` reaches nothing. + // The leg below measures both directions on the same render. const spies = authored(); const props = await kanbanRendererProps(spies); expect({ onCardClick: props.onCardClick === spies.onCardClick, - onCardMove: props.onCardMove === spies.onCardMove, onQuickAdd: props.onQuickAdd === spies.onQuickAdd, - }).toEqual({ onCardClick: true, onCardMove: true, onQuickAdd: true }); + }).toEqual({ onCardClick: true, onQuickAdd: true }); + }); + + it('⭐ onCardMove travels the PROP and NOT the schema bag (objectui#9342)', async () => { + // Both channels on ONE render, so the contrast is about the channel and not + // about how each was authored. The prop is the firing control for the + // schema-bag reading: without it, "the schema key did nothing" would also + // be produced by a forward block that stopped forwarding anything. + const onSchemaBag = vi.fn(); + const onProp = vi.fn(); + const before = recorded.impl.length; + const { unmount } = render( + + + , + ); + const props = await lastBoardProps('impl', before, unmount); + expect({ + fromProp: props.onCardMove === onProp, + fromSchemaBag: props.onCardMove === onSchemaBag, + }).toEqual({ fromProp: true, fromSchemaBag: false }); }); it.each(['object-kanban'])( - "`'%s'` (ObjectKanban) passes onQuickAdd through and replaces BOTH onCardClick and onCardMove with its own", + "`'%s'` (ObjectKanban) passes onQuickAdd through and supplies its OWN onCardClick and onCardMove", async (type) => { const spies = authored(); const props = await boardPropsFor(type, 'impl', spies); @@ -296,7 +324,10 @@ describe("ObjectKanban's own onCardClick wrapper CALLS the authored handler (obj 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. */ + /** The `schema.on*` reads inside the `KanbanRenderer` component body, read off the source. + * ⚠️ `schema.`-anchored ON PURPOSE: this measures the DOCUMENT read path, so + * a handler that arrives as an explicit React prop (`onCardMove` since + * objectui#9342) is correctly absent rather than missed. */ function forwardedByKanbanRenderer(): string[] { const src = readFileSync(INDEX_TSX, 'utf8'); const start = src.indexOf('export const KanbanRenderer'); @@ -311,8 +342,12 @@ describe('the handler keys KanbanRenderer forwards, and where they are declared .sort(); } - it('the read site is measured, not listed: KanbanRenderer forwards exactly these three', () => { - expect(forwardedByKanbanRenderer()).toEqual(['onCardClick', 'onCardMove', 'onQuickAdd']); + it('the read site is measured, not listed: KanbanRenderer reads exactly these two off the document', () => { + // ⭐ THREE until objectui#9342 moved `onCardMove` onto an explicit React + // prop. That shrink is the deliverable, not a drift: the gate of record + // refuses a `'retired'` tombstone while a renderer still reads the key off + // the document, so the read had to go before the arm could carry one. + expect(forwardedByKanbanRenderer()).toEqual(['onCardClick', 'onQuickAdd']); }); it('⭐ two of the three are declared on the surviving `object-kanban` face — measured per key, not per prefix', () => { @@ -327,18 +362,24 @@ describe('the handler keys KanbanRenderer forwards, and where they are declared // 1 above is where the two channels that make them so are measured — // identity through the schema spread for the second, and the React prop // the wrapper calls (suite 2) for the first. - // - `onCardMove` is still declared by NOTHING. Its authored value reaches - // neither channel, which is the `'retired'` disposition, and - // `check:handler-key-reads` refuses that spelling while this very - // forward block still reads the key. It keeps its - // `KNOWN_UNDECLARED_READS` row on objectui#7804, which stays open. + // - `onCardMove` was declared by NOTHING and is no longer read off the + // document at all. Its authored value reaches neither channel, which is + // the `'retired'` disposition; `check:handler-key-reads` refused that + // spelling while this very forward block read the key, so objectui#9342 + // moved the read to an explicit React prop and the arm now carries the + // tombstone. The third assertion below reads it off the mirror. const shape = ObjectKanbanZod.shape as Record; const forwarded = forwardedByKanbanRenderer(); expect(forwarded.map((key) => ({ key, declared: key in shape }))).toEqual([ { key: 'onCardClick', declared: true }, - { key: 'onCardMove', declared: false }, { key: 'onQuickAdd', declared: true }, ]); + // The key that LEFT the forward block is declared all the same — as a + // tombstone, which is the whole point of moving the read (objectui#9342). + expect({ + declared: 'onCardMove' in shape, + retired: shape.onCardMove?.description?.includes('RETIRED'), + }).toEqual({ declared: true, retired: true }); // 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 diff --git a/packages/plugin-kanban/src/index.tsx b/packages/plugin-kanban/src/index.tsx index da52ecb3cc..4596106d3a 100644 --- a/packages/plugin-kanban/src/index.tsx +++ b/packages/plugin-kanban/src/index.tsx @@ -181,7 +181,6 @@ export interface KanbanRendererProps { data?: Array; groupBy?: string; swimlaneField?: string; - onCardMove?: (cardId: string, fromColumnId: string, toColumnId: string, newIndex: number) => void; onCardClick?: (card: any) => void; quickAdd?: boolean; onQuickAdd?: (columnId: string, title: string) => void; @@ -243,13 +242,43 @@ export interface KanbanRendererProps { * rather than fixed here. */ objectFields?: unknown; + + /** + * The board's card-move callback, injected by the host that owns the write. + * + * ⛔ A React PROP, a sibling of `schema`, and deliberately NOT a member of the + * `schema` bag (objectui#9342, executing the ruling on PR objectui#9338; the + * objectui#7742 remedy `objectFields` above already took, one member over, + * under maintainer decision batch #70). + * + * Inside `schema` the key was reachable by an AUTHOR and reached NOTHING. + * `BaseSchema` is `.passthrough()`, so `onCardMove` was accepted and KEPT on + * an `object-kanban` document, and then dropped: `ObjectKanban` substitutes + * its own `handleCardMove` on the schema it hands down — that wrapper owns the + * optimistic write, the required-fields dialog and the rollback — and declares + * no `onCardMove` React prop of its own (its rest parameter is discarded), so + * neither channel delivered. Measured, driven rather than inferred, with + * `onCardClick` as the lit control on the same document and the same render + * (`__tests__/handlerKeyDispositionsMeasured-7804.test.tsx`). + * + * Moving the READ here is what lets the `object-kanban` arm tombstone the key + * with `handlerKeyRefusal(…, 'retired', …)` and still satisfy + * `check:handler-key-reads`, whose contract is that a tombstone "has no read + * site BY CONSTRUCTION". What that gate could not see is that the value at the + * old read was substituted one hop earlier. + * + * ⚠️ NARROWS A PUBLISHED PROPS SURFACE. A host that rendered `KanbanRenderer` + * directly and wrote the key inside `schema` gets a TS error if it is typed + * and a silent drop if it is not; it must move the function to this prop. + */ + onCardMove?: (cardId: string, fromColumnId: string, toColumnId: string, newIndex: number) => void; } /** * KanbanRenderer - The public API for the kanban board component * This wrapper handles lazy loading internally using React.Suspense */ -export const KanbanRenderer: React.FC = ({ schema, objectFields }) => { +export const KanbanRenderer: React.FC = ({ schema, objectFields, onCardMove }) => { const { t } = useUncolumnedT(); // ⚡️ Adapter: Map flat 'data' + 'groupBy' to nested 'cards' structure. const processedColumns = React.useMemo( @@ -268,7 +297,7 @@ export const KanbanRenderer: React.FC = ({ schema, objectFi }> = {}; for (const m of RETIRED) split[m.file] = (split[m.file] ?? 0) + 1; expect({ total: RETIRED.length, split }).toEqual({ - total: 26, + total: 27, split: { 'app.ts': 1, 'complex.ts': 2, @@ -303,6 +311,7 @@ describe('the retired population is measured off the shipped tree (objectui#7340 'feedback.ts': 1, 'form.ts': 8, 'navigation.ts': 3, + 'objectql.ts': 1, 'overlay.ts': 2, 'reports.ts': 2, }, @@ -324,6 +333,11 @@ describe('the retired population is measured off the shipped tree (objectui#7340 // to classify. They are not "live again": the document that could have // carried them is refused at its `type`. expect(UNAMBIGUOUSLY_RETIRED_NAMES).toEqual([ + // objectui#9342 — no shipped interface declares an `onCardMove` callable + // any more. `KanbanRendererProps.onCardMove` is a React prop on + // `@object-ui/plugin-kanban`, not a member of a `@object-ui/types` + // document interface, so it is not in this census's population. + 'onCardMove', 'onClose', 'onCollapsedChange', 'onConfirm', 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 027e2c0f19..b5d1e68b5b 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 @@ -300,11 +300,12 @@ const RUNTIME_SLOT: readonly Site[] = [ // and is live anyway through the other channel — `SchemaRenderer` spreads // the authored key as a React prop, `ObjectKanbanComponentProps` declares // it, and the substituted wrapper CALLS it. - // ⚠️ The third key `KanbanRenderer` forwards, `onCardMove`, is NOT here. Its + // ⚠️ The third key, `onCardMove`, is NOT in this half and never was: its // authored value reaches nothing on this entry, which is the `'retired'` - // disposition, and `check:handler-key-reads` refuses that spelling while the - // renderer still reads the key — so it keeps its `KNOWN_UNDECLARED_READS` row - // on objectui#7804 instead of being guessed into either half of this ledger. + // disposition. It is filed in `RETIRED` below since objectui#9342, which + // moved `KanbanRenderer`'s read off the document and onto an explicit React + // prop — until then `check:handler-key-reads` refused the tombstone spelling + // while the renderer still read the key. ['objectql.zod.ts', 'ObjectKanbanSchema', 'onCardClick', ObjectKanbanZod], ['objectql.zod.ts', 'ObjectKanbanSchema', 'onQuickAdd', ObjectKanbanZod], ['overlay.zod.ts', 'DialogSchema', 'onOpenChange', DialogZod], @@ -320,8 +321,12 @@ const RUNTIME_SLOT: readonly Site[] = [ ]; /** - * 22 keys NO renderer reads — the TypeScript interface carries the `?: never` - * tombstone. Measured per key: the renderer takes `({ schema })` only, or + * The keys NO renderer reads — the TypeScript interface carries the `?: never` + * tombstone. ⚠️ The population is whatever this array holds and the length + * assertion below states; a figure repeated in this sentence would be derived + * once and never again (AGENTS.md #9), and it already drifted here — it read + * `22` while the assertion read `20`, across objectui#8802's arm retirement. + * Measured per key: the renderer takes `({ schema })` only, or * strips the key through a `toFormControlDomProps` whitelist, or spreads it * onto a DOM element / primitive that has no such prop (React warns about an * unknown event handler and attaches nothing). `CommandSchema.onChange` is the @@ -352,6 +357,16 @@ const RETIRED: readonly Site[] = [ ['navigation.zod.ts', 'BreadcrumbItemSchema', 'onClick', BreadcrumbItemZod], ['navigation.zod.ts', 'SidebarSchema', 'onCollapsedChange', SidebarZod], ['navigation.zod.ts', 'ButtonGroupButtonSchema', 'onClick', ButtonGroupButtonZod], + // ⭐ objectui#9342 — the THIRD `ObjectKanbanSchema` handler key, and the one + // its own slice could not file. Its disposition was measured `'retired'` by + // objectui#7804 (an authored value reaches nothing: `ObjectKanban` + // substitutes its own mover and declares no `onCardMove` React prop), but + // `check:handler-key-reads` refuses a tombstone while a renderer still reads + // the key off the document, so the row sat in that gate's + // `KNOWN_UNDECLARED_READS` instead. `KanbanRenderer` now takes `onCardMove` + // as an explicit React prop — the objectui#7742 remedy `objectFields` took — + // and the tombstone is spelled on both faces. + ['objectql.zod.ts', 'ObjectKanbanSchema', 'onCardMove', ObjectKanbanZod], ['overlay.zod.ts', 'AlertDialogSchema', 'onConfirm', AlertDialogZod], ['overlay.zod.ts', 'AlertDialogSchema', 'onCancel', AlertDialogZod], ]; @@ -429,7 +444,7 @@ describe('census: no on* key in the eight mirrors is declared z.function() (obje ]); }); - it('64 sites are ledgered, 44 runtime slots + 20 retired, with no key filed twice', () => { + it('65 sites are ledgered, 44 runtime slots + 21 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 @@ -449,14 +464,19 @@ describe('census: no on* key in the eight mirrors is declared z.function() (obje // ⭐ 62 → 64: objectui#7804 declared `ObjectKanbanSchema.onCardClick` and // `.onQuickAdd`, two of the three reads the retirement above left on the // SURVIVING face with nothing declaring them. ⚠️ TWO, not three — the - // third (`onCardMove`) is measured `'retired'` and the gate of record - // refuses that spelling while the renderer still reads the key, so it stays + // third (`onCardMove`) was measured `'retired'` and the gate of record + // refused that spelling while the renderer still read the key, so it stayed // in `KNOWN_UNDECLARED_READS` rather than being filed here under a // disposition nothing measured. + // + // ⭐ 64 → 65: objectui#9342 moved that read to an explicit React prop on + // `KanbanRendererProps`, which is what let the arm carry the tombstone the + // measurement always asked for. A ledger GROWTH on the retired half, and + // the disposition is the one objectui#7804 measured — not a new reading. expect(RUNTIME_SLOT).toHaveLength(44); - expect(RETIRED).toHaveLength(20); + expect(RETIRED).toHaveLength(21); const ids = ALL_SITES.map(([file, schema, key]) => `${file}#${schema}.${key}`); - expect(new Set(ids).size).toBe(64); + expect(new Set(ids).size).toBe(65); }); 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) => { diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 30ad6fd282..d2647911e1 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -1516,11 +1516,12 @@ interface KnownDrift { * refused BY NAME by `RetiredKanbanNodeSchema`, pinned in * `./bare-kanban-node-key-retired-8802.test.ts`. * - * ⚠️ 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. + * ⚠️ Recorded rather than repaired AT THE TIME: `KanbanRenderer` forwarded + * all three and the SURVIVING `objectql.zod.ts#ObjectKanbanSchema` pair + * declared none, so they were read-but-undeclared on the surviving face. + * Both halves of that have since landed — objectui#7804 declared the two + * live slots, and objectui#9342 moved the `onCardMove` READ to an explicit + * React prop and tombstoned the key on both faces. */ /** * RUNTIME SLOT (objectui#7344): `register('detail', DetailView)` — `DetailView`'s @@ -1655,12 +1656,18 @@ interface KnownDrift { * spread — arrives instead as the React PROP `ObjectKanbanComponentProps` * declares, which `ObjectKanban`'s own wrapper CALLS. * - * ⚠️ The THIRD key, `onCardMove`, is deliberately NOT here and is not drift: - * neither face declares it, so `undefined` meets `undefined`. Its authored - * value reaches nothing on this entry — the `'retired'` disposition — and - * `check:handler-key-reads` refuses that spelling while the renderer still - * reads the key, so it keeps its `KNOWN_UNDECLARED_READS` row on - * objectui#7804 rather than joining either face. + * ⚠️ The THIRD key, `onCardMove`, is still NOT here, and the reason CHANGED + * with objectui#9342 — it is not drift either way. It used to be absent + * because NEITHER face declared it (`undefined` meeting `undefined`); since + * objectui#9342 BOTH faces declare it as a tombstone — `?: never` on the + * TypeScript twin, `handlerKeyRefusal(…, 'retired', …)` on this mirror — and + * a matched pair of tombstones is the shape `complex.zod.ts#CarouselSchema` + * already carries for `onSlideChange`, which is likewise not in this ledger. + * Its `'retired'` reading is objectui#7804's measurement (an authored value + * reaches nothing: `ObjectKanban` substitutes its own mover and declares no + * `onCardMove` React prop); what was missing was the precondition, and + * objectui#9342 supplied it by moving `KanbanRenderer`'s read onto an + * explicit React prop so `check:handler-key-reads` would accept a tombstone. */ 'objectql.zod.ts#ObjectKanbanSchema': 'onCardClick' | 'onQuickAdd'; /** diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 3ed667b7ee..cc68e92401 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -3367,12 +3367,12 @@ export interface ObjectKanbanSchema extends BaseSchema { * substitutes its own wrapper on the schema it hands down, because that * wrapper also owns the record-detail overlay. Reachability here is the PROP * channel, and that is the whole difference between this key and the sibling - * `onCardMove`, which this face still does NOT declare: "the wrapper - * overrides it" is true of both and separates neither. `onCardMove` has no - * prop channel — `ObjectKanban` declares no such prop and discards its rest - * parameter — so an authored one reaches nothing, which is a `'retired'` - * reading that `check:handler-key-reads` refuses while `KanbanRenderer` still - * reads the key. It keeps its `KNOWN_UNDECLARED_READS` row on objectui#7804. + * `onCardMove` BELOW, which is a tombstone: "the wrapper overrides it" is true + * of both and separates neither. `onCardMove` has no prop channel on this + * element — `ObjectKanban` declares no such prop and discards its rest + * parameter — so an authored one reaches nothing, which is the `'retired'` + * reading objectui#9342 landed once `KanbanRenderer`'s read had moved off the + * document and onto an explicit React prop. * * ⚠️ TWO PARAMETERS since objectui#9341, and the second is what the surviving * channel actually delivers: `handleClick` forwards `onRowClick(record, @@ -3392,6 +3392,34 @@ export interface ObjectKanbanSchema extends BaseSchema { */ onCardClick?: (card: any, event?: any) => void; + /** + * RETIRED (objectui#6124, ADR-0049; landed by objectui#9342 on the ruling + * recorded on PR objectui#9338) — JSON has no function value, and on this + * element an authored `onCardMove` reached NOTHING even as a programmatic + * value. `ObjectKanban` substitutes its own `handleCardMove` on the schema it + * hands down — that wrapper owns the optimistic write, the required-fields + * dialog and the objectui#4138 rollback — and declares no `onCardMove` React + * prop, so neither channel delivered. Measured by driving the handler the + * board was actually handed, with `onCardClick` as the lit control on the + * same document and the same render. + * + * ⚠️ RETIRED, not a RUNTIME SLOT, and the two are not interchangeable here. + * A slot keeps this member callable, which would publish a key the + * object-bound board DROPS — the resolution this package's `quickAdd` + * carve-out forbids in as many words. The sibling `onCardClick` above IS a + * slot because its function reaches the board through a React prop this + * element declares; `onCardMove` has no such prop. + * + * Where the mover lives now: `KanbanRendererProps.onCardMove`, an explicit + * React prop of `@object-ui/plugin-kanban`'s `KanbanRenderer`, a sibling of + * its `schema` — the objectui#7742 remedy `objectFields` took one card + * earlier. Until that read moved, `check:handler-key-reads` refused this very + * tombstone, because a tombstone "has no read site BY CONSTRUCTION". + * + * @deprecated Not part of this contract — the value was accepted and dropped. + */ + onCardMove?: never; + /** * Quick Add handler. * diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 5348cd630b..428c92716f 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -1509,19 +1509,24 @@ export const ObjectKanbanSchema = BaseSchema.extend({ // `__tests__/handlerKeyDispositionsMeasured-7804.test.tsx`; the twin // docblocks in `../objectql.ts` carry the reasons member by member. // - // ⚠️ TWO of the three land here. `onCardMove` is the third and it is NOT - // declared, deliberately: its authored value is measured to reach NOTHING on - // this entry (`ObjectKanban` substitutes its own mover and declares no - // `onCardMove` React prop), which is the `'retired'` disposition — and - // `check:handler-key-reads` REFUSES that spelling while `KanbanRenderer` - // still reads the key off the document it is handed, printing - // `declares it RETIRED, but a renderer still reads it`. Closing that needs - // the READ to move to an explicit React prop — the objectui#7742 remedy this - // same file already applied to `objectFields` — which narrows a published - // component's props and is a ruling, not a repair. So the key keeps its - // `KNOWN_UNDECLARED_READS` row naming objectui#7804, which stays open and - // stays the parent, and this arm does not pretend to judge it. + // ⚠️ The three do NOT share a disposition. Two are RUNTIME SLOTS, whose + // function value reaches the board on this face; the third, `onCardMove`, is + // a TOMBSTONE, and reading it as a slot because it shares the prefix is the + // error batch #69 forbids. Its authored value was measured to reach NOTHING + // here — `ObjectKanban` substitutes its own `handleCardMove` and declares no + // `onCardMove` React prop — which is the `'retired'` disposition. + // + // ⭐ It could not be spelled until objectui#9342 (the ruling recorded on PR + // objectui#9338) moved the READ. `check:handler-key-reads` REFUSES a + // tombstone while a renderer still reads the key off the document, printing + // `declares it RETIRED, but a renderer still reads it` — a tombstone "has no + // read site BY CONSTRUCTION". `KanbanRenderer` now takes `onCardMove` as an + // explicit React prop, a sibling of its `schema`, which is the objectui#7742 + // remedy this same file already applied to `objectFields`; it narrows a + // published component's props, so it is a ruling and not a repair, and the + // change carries `needs:contract-review` on its own merits. onCardClick: handlerKeyRefusal('onCardClick', 'runtime-slot', 'Card click handler'), + onCardMove: handlerKeyRefusal('onCardMove', 'retired', 'Card move handler'), onQuickAdd: handlerKeyRefusal('onQuickAdd', 'runtime-slot', 'Quick Add handler'), }).superRefine(requireKanbanRecordSource); diff --git a/scripts/__tests__/check-handler-key-read-sites.test.ts b/scripts/__tests__/check-handler-key-read-sites.test.ts index 2425dc5edb..9260d7ced7 100644 --- a/scripts/__tests__/check-handler-key-read-sites.test.ts +++ b/scripts/__tests__/check-handler-key-read-sites.test.ts @@ -589,14 +589,20 @@ describe('check-handler-key-read-sites — this repository', () => { // ⭐ objectui#7804 closed TWO of them on the surviving face, each measured // at its own channel, and the split is asserted rather than averaged: a // reading that put all three in one bucket would be the error that ruling - // forbids. `onCardMove` is the third — its authored value reaches nothing + // forbids. `onCardMove` was the third — its authored value reaches nothing // on this entry, which is the `'retired'` disposition, and THIS GATE - // refuses that spelling while `KanbanRenderer` still reads the key — so it - // keeps its `KNOWN_UNDECLARED_READS` row on objectui#7804. + // refused that spelling while `KanbanRenderer` still read the key. + // + // ⭐ objectui#9342 resolved that standoff by moving the READ: `onCardMove` + // is an explicit React prop on `KanbanRendererProps` now, so it is no + // longer a READ SITE at all and correctly leaves this census — while the + // arm carries the tombstone. ⚠️ Its absence below is therefore a reading, + // and the two survivors are what keep it from being a census that lost the + // kanban walk altogether (the hop this whole leg exists for). const judged = result.census.map((c) => `${c.type}.${c.key}`); expect(judged).toContain('object-kanban.onCardClick'); - expect(judged).toContain('object-kanban.onCardMove'); expect(judged).toContain('object-kanban.onQuickAdd'); + expect(judged).not.toContain('object-kanban.onCardMove'); const kanbanRow = (key: string) => result.census.find((c) => c.type === 'object-kanban' && c.key === key); expect( @@ -607,7 +613,10 @@ describe('check-handler-key-read-sites — this repository', () => { })), ).toEqual([ { key: 'onCardClick', declared: true, disposition: 'runtime-slot' }, - { key: 'onCardMove', declared: false, disposition: undefined }, + // No census row: the key is declared on the arm (as a tombstone) but the + // renderer no longer reads it off the document, so there is nothing to + // judge. The arm-side reading is asserted in the resolver leg below. + { key: 'onCardMove', declared: undefined, disposition: undefined }, { key: 'onQuickAdd', declared: true, disposition: 'runtime-slot' }, ]); @@ -707,17 +716,18 @@ describe('check-handler-key-read-sites — this repository', () => { expect(objectKanban?.members.has('groupBy')).toBe(true); // ⭐ And the resolver reads its handler dispositions PER KEY, which is what // objectui#7804 measured this face on: two of the three keys the plugin - // reads are objectui#6124 RUNTIME SLOTS, and `onCardMove` is declared by - // nothing — its authored value reaches neither channel, which is the - // `'retired'` disposition, and this gate refuses that spelling while the - // renderer still reads the key. + // consumes are objectui#6124 RUNTIME SLOTS, and `onCardMove` is `'retired'` + // — its authored value reaches neither channel. ⭐ `undefined` here until + // objectui#9342: this gate refuses a tombstone while a renderer still reads + // the key, so the arm could not carry one until that read moved to an + // explicit React prop on `KanbanRendererProps`. expect({ onCardClick: objectKanban?.members.get('onCardClick'), onCardMove: objectKanban?.members.get('onCardMove'), onQuickAdd: objectKanban?.members.get('onQuickAdd'), }).toEqual({ onCardClick: 'runtime-slot', - onCardMove: undefined, + onCardMove: 'retired', onQuickAdd: 'runtime-slot', }); diff --git a/scripts/check-handler-key-read-sites.mjs b/scripts/check-handler-key-read-sites.mjs index 407acf0edf..fec1ae8eef 100644 --- a/scripts/check-handler-key-read-sites.mjs +++ b/scripts/check-handler-key-read-sites.mjs @@ -160,26 +160,21 @@ export const KNOWN_UNDECLARED_READS = new Map([ ['form::FormSchema.onSuccess', 'objectui#7804'], ['object-grid::ObjectGridSchema.onNavigate', 'objectui#7804'], ['grid::GridSchema.onNavigate', 'objectui#7804'], - // ⭐ TWO of the three `object-kanban::ObjectKanbanSchema` rows LANDED and are - // gone (objectui#7804's `plugin-kanban` slice): `onCardClick` and - // `onQuickAdd` are now objectui#6124 RUNTIME SLOTS on the arm, each measured - // at its own channel — the first by the React PROP `ObjectKanban` declares - // and its click wrapper CALLS, the second by identity through the schema - // spread. Draining them is part of the landing, not cleanup after it: a row - // that outlived its read reddens `staleExemptions()` below. + // ⭐ ALL THREE `object-kanban::ObjectKanbanSchema` rows LANDED and are gone. + // `onCardClick` and `onQuickAdd` went with objectui#7804's `plugin-kanban` + // slice — objectui#6124 RUNTIME SLOTS on the arm, each measured at its own + // channel, the first by the React PROP `ObjectKanban` declares and its click + // wrapper CALLS, the second by identity through the schema spread. // - // ⚠️ `onCardMove` STAYS, and the row is not a leftover. Measured on the same - // instrument and driven rather than inferred, an authored `onCardMove` on an - // `object-kanban` document reaches NOTHING — `ObjectKanban` substitutes its - // own mover on the schema it hands down and declares no `onCardMove` React - // prop — so the objectui#6124 disposition is `'retired'`, and THIS GATE - // refuses that spelling while `KanbanRenderer` still reads the key - // (`retired-but-read`). The exit is to move the READ to an explicit React - // prop, the objectui#7742 remedy already applied to `objectFields` one file - // over, which narrows a published component's props and is a ruling rather - // than a repair. So the key stays undeclared, accepted and KEPT, and the row - // keeps naming objectui#7804 — which stays open and stays the parent. - ['object-kanban::ObjectKanbanSchema.onCardMove', 'objectui#7804'], + // `onCardMove` went with objectui#9342, and it is the one row this gate + // itself blocked. Its disposition was `'retired'` — an authored value reaches + // NOTHING, `ObjectKanban` substituting its own mover — but a tombstone "has + // no read site BY CONSTRUCTION", so this gate refused the spelling as + // `retired-but-read` while `KanbanRenderer` still read `schema.onCardMove`. + // That read is now an explicit React prop on `KanbanRendererProps`, the + // objectui#7742 remedy `objectFields` took one file over, and the arm carries + // the tombstone. Draining a row is part of the landing, not cleanup after it: + // a row that outlived its read reddens `staleExemptions()` below. ['list-view::ListViewSchema.onAddRecord', 'objectui#7804'], ['list-view::ListViewSchema.onBulkAction', 'objectui#7804'], ['list-view::ListViewSchema.onDensityChange', 'objectui#7804'],