diff --git a/.changeset/17260-object-kanban-quick-add-retired.md b/.changeset/17260-object-kanban-quick-add-retired.md new file mode 100644 index 0000000000..4a611078cd --- /dev/null +++ b/.changeset/17260-object-kanban-quick-add-retired.md @@ -0,0 +1,73 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec)!: retire `ObjectKanbanProps.quickAdd` — the `object-kanban` board forwarded it and nothing ever read it (ADR-0049) + + + +**BREAKING** — `quickAdd` is retired from the `object-kanban` component props. Executes the +objectui#8285 director-seat ruling (decision batch #91, 2026-09-08, standing maintainer +delegation), ruled **option B**: the key leaves the board and stays only on the `kanban-ui` +block, where a React host can supply the runtime function the control needs. + +| | before | after | +|:--|:--|:--| +| `object-kanban` | `quickAdd: true` parsed clean and did nothing | refused by the tombstone, with the prescription | +| `kanban-ui` (objectui block) | the control works when the host passes `onQuickAdd` | **unchanged** | + +**What was actually wrong.** Measured at the `.objectui-sha` pin this repo builds against +(`53ded82bf`): the board FORWARDS the key — `ObjectKanban.tsx:931` spreads the authored bag +into `KanbanRenderer`, which passes `quickAdd={schema.quickAdd}` alongside +`onQuickAdd={schema.onQuickAdd}` (`plugin-kanban/src/index.tsx:196`) — but `KanbanImpl` +gates the affordance on **both** (`:355`, `:368`), and `onQuickAdd` is a host-supplied +FUNCTION that JSON cannot carry and that no producer puts on an `object-kanban` node. +`ObjectKanban.tsx` names neither half of the pair (0 occurrences each, against 6 for the +sibling `onCardClick` in the same file), so the gate was permanently false. + +**And the drop was not silent, which is what made it worse than silence.** objectui's html +tier reported the published key as `unknown-prop` — the same diagnostic a typo gets — and +its registry↔spec ledger records it as `ESCALATED (object-kanban.quickAdd — measured NOT +honoured)`. An author following the published contract met a tool that contradicted it, with +nothing in either message to say which side was wrong. The tombstone collapses both halves +onto one answer. + +## What to write instead + +Nothing, on this board: there is no per-column quick-add affordance on `object-kanban` and +there never was one. Delete the key. + +```ts +// before — parsed clean, rendered nothing +{ type: 'object-kanban', properties: { objectName: 'crm_task', groupBy: 'status', quickAdd: true } } +// after +{ type: 'object-kanban', properties: { objectName: 'crm_task', groupBy: 'status' } } +``` + +The control itself is not withdrawn from the platform. It stays on the `kanban-ui` block, +which a React host renders directly and can hand the `onQuickAdd` slot to — that is what the +ruling preserved deliberately. + +Existing sources: `os migrate meta --from 17` lists the mechanical edits; apply them by hand. + +The retirement kit: + +- a `retiredKey()` tombstone on `ObjectKanbanPropsSchema` — `tsc` types the key `never`, and + a value reaching the parse raises the prescription rather than a bare unknown-key verdict +- the D2 conversion `object-kanban-quick-add-removed` (`RETIRED_KEYS_BY_MAJOR[18]` entry + `ui/ObjectKanbanProps:quickAdd`, wired into the protocol-18 chain step) — a **pure lossless + delete**, since the key never had an effect to preserve, scoped by component `type` so the + live `kanban-ui` spelling stays out of its reach +- the `authorable-surface/ui.json` row becomes `ui/ObjectKanbanProps:quickAdd [RETIRED]`, and + the generated reference page prints the prescription in place of the old describe +- the schema docblock's read-point list is corrected in the same stroke: it named `quickAdd` + among the keys reached "via the forwarded schema", a sentence true about the FORWARD and + false about the READ — which is how the key kept re-authorizing itself +- pin tests (`ui/component.test.ts`): the refusal carries the prescription; a clean parse does + not materialize the key; and the control pair separating the tombstone's answer from the + strict unknown-key arm's, so a shape that had merely DROPPED the key could not pass +- no liveness-ledger row (component props are not an enrolled ledger type) and no form or + i18n edit: zero `object-kanban` components are authored anywhere under `examples/` or + `apps/` (control: `object-grid` 3, `object-metric` 8 in the same corpora, same instrument) +- `api-surface/` is unchanged, correctly: it ratchets export existence, and no export leaves — + `ObjectKanbanProps` still exists, one key narrower diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index f59531a029..e66744ba6d 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -502,7 +502,7 @@ Sort field and direction pair | **cardFields** | `string[]` | optional | Fields rendered on each card | | **swimlaneField** | `string` | optional | Field for horizontal swimlanes (in addition to columns) | | **grouping** | `any` | optional | View grouping config; its first field is the swimlane fallback | -| **quickAdd** | `boolean` | optional | Show the per-column quick-add affordance | +| **quickAdd** | `never` | optional | [REMOVED] `object-kanban` property `quickAdd` was removed in @objectstack/spec 17 (ADR-0049) — the board forwarded it, but the per-column affordance is gated on both `quickAdd` and `onQuickAdd`, and `onQuickAdd` is a host-supplied function JSON cannot carry and no producer ever put on an `object-kanban` node, so authoring it was a parse-clean no-op. Delete the key. The quick-add control is unchanged on the `kanban-ui` block, where a React host supplies the `onQuickAdd` slot the control needs. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **coverImageField** | `string` | optional | Image field rendered as the card cover | | **conditionalFormatting** | `any` | optional | Card conditional formatting rules | diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 375063a67f..625ae5cfcc 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -780,7 +780,7 @@ "ui/ObjectKanbanProps:grouping", "ui/ObjectKanbanProps:limit", "ui/ObjectKanbanProps:objectName", - "ui/ObjectKanbanProps:quickAdd", + "ui/ObjectKanbanProps:quickAdd [RETIRED]", "ui/ObjectKanbanProps:swimlaneField", "ui/ObjectKanbanProps:titleField", "ui/ObjectListView:addRecord", diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 1c779b0a87..8146604b2c 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -8010,6 +8010,189 @@ const objectGridDefaultSortRemoved: MetadataConversion = { }, }; +/** + * `object-kanban`'s per-column quick-add switch leaves the contract (protocol + * 18, #17260, ADR-0049 enforce-or-remove; the spec half of the objectui#8285 + * director-seat ruling, decision batch #91, 2026-09-08 — ruled option B, + * `quickAdd` is retired from the board and stays only on the `kanban-ui` + * block, where a React host can supply the runtime function the control + * needs). + * + * **A pure lossless delete.** The key never had an effect to preserve. + * Measured at the `.objectui-sha` pin (`53ded82bf`): the board FORWARDS it — + * `ObjectKanban.tsx:931` spreads the authored bag into `KanbanRenderer`, + * which passes `quickAdd={schema.quickAdd}` alongside + * `onQuickAdd={schema.onQuickAdd}` (`plugin-kanban/src/index.tsx:196`) — but + * `KanbanImpl` gates the affordance on BOTH (`:355`, `:368`), and + * `onQuickAdd` is a host-supplied FUNCTION that JSON cannot carry and that no + * producer puts on an `object-kanban` node (`ObjectKanban.tsx` names neither + * half of the pair: 0 occurrences each, against 6 for the sibling + * `onCardClick` in the same file). So the gate was permanently false and + * deleting the key preserves observed behaviour exactly. + * + * ⚠️ Scoped by component `type`, never by key name. `quickAdd` is LIVE on the + * `kanban-ui` block — the same renderer chain, reached by a React host that + * hands in `onQuickAdd` — and the ruling keeps it there deliberately. That + * block is objectui-side and is not a component type this spec declares, so no + * stack this walk reaches can carry it; the type scoping is what keeps the + * strip from generalising into a name-keyed one if it ever is declared. The + * fixture's non-carrier control is an `object-grid` authoring the same key + * name. + * + * The neighbouring forwarded keys `coverImageField` and `conditionalFormatting` + * are LIVE and survive untouched (both are read on this very path, by + * `KanbanRenderer` / `bucketCardsIntoColumns`). + * + * Zero authored occurrences in this repo's corpora — no `object-kanban` + * component is authored anywhere under `examples/` or `apps/` at all (control: + * `object-grid` 3, `object-metric` 8 in the same corpora, same instrument) — + * so this entry exists for stored `sys_metadata` rows and for authors outside + * the repo, which the filing seat explicitly could not measure. + */ +const objectKanbanQuickAddRemoved: MetadataConversion = { + id: 'object-kanban-quick-add-removed', + toMajor: 18, + retiredFromLoadPath: true, + surface: 'page.component.object-kanban.quickAdd', + summary: + "object-kanban component prop 'quickAdd' removed (#17260 — the affordance is gated on a " + + "host-supplied 'onQuickAdd' function no producer puts on an object-kanban node, so the key " + + "was accepted and dropped; the quick-add control stays on the React-host 'kanban-ui' block)", + apply(stack, emit) { + return mapPageComponents(stack, (component, path) => { + if (component.type !== 'object-kanban') return component; + const properties = component.properties; + if (!isDict(properties) || !('quickAdd' in properties)) return component; + const stripped = stripKeys(properties, ['quickAdd'], emit, `${path}.properties`); + return { ...component, properties: stripped }; + }); + }, + fixture: { + before: { + pages: [ + { + name: 'delivery_board', + regions: [ + { + name: 'main', + components: [ + // The carrier: an `object-kanban` authoring the retired key. + { + type: 'object-kanban', + id: 'k1', + properties: { objectName: 'crm_task', groupBy: 'status', quickAdd: true }, + }, + // ⚠️ The same key name on a component that is NOT an + // `object-kanban` — not this entry's key. Untouched: the strip + // is scoped by component type, never by key name, which is what + // keeps the LIVE `kanban-ui` spelling out of its reach. + { + type: 'object-grid', + id: 'g1', + properties: { objectName: 'crm_task', quickAdd: true }, + }, + // A board WITHOUT the key rides through untouched — the strip + // dispatches on key presence and the copy-on-write contract + // keeps the reference. + { + type: 'object-kanban', + id: 'k3', + properties: { objectName: 'crm_task', cardFields: ['title'] }, + }, + // The nested position (#6775's lesson): a board inside a + // card's `children` is still a component. + { + type: 'page:card', + id: 'c1', + properties: { + children: [ + { + type: 'object-kanban', + id: 'k4', + properties: { objectName: 'crm_lead', groupBy: 'stage', quickAdd: false }, + }, + ], + }, + }, + ], + }, + ], + }, + // The named-slot shape (#6776): a board authored into a slotted page. + { + name: 'delivery_board_detail', + kind: 'slotted', + regions: [], + slots: { + details: { + type: 'object-kanban', + id: 'k5', + properties: { objectName: 'crm_task', groupBy: 'status', quickAdd: true }, + }, + }, + }, + ], + }, + after: { + pages: [ + { + name: 'delivery_board', + regions: [ + { + name: 'main', + components: [ + { + type: 'object-kanban', + id: 'k1', + properties: { objectName: 'crm_task', groupBy: 'status' }, + }, + { + type: 'object-grid', + id: 'g1', + properties: { objectName: 'crm_task', quickAdd: true }, + }, + { + type: 'object-kanban', + id: 'k3', + properties: { objectName: 'crm_task', cardFields: ['title'] }, + }, + { + type: 'page:card', + id: 'c1', + properties: { + children: [ + { + type: 'object-kanban', + id: 'k4', + properties: { objectName: 'crm_lead', groupBy: 'stage' }, + }, + ], + }, + }, + ], + }, + ], + }, + { + name: 'delivery_board_detail', + kind: 'slotted', + regions: [], + slots: { + details: { + type: 'object-kanban', + id: 'k5', + properties: { objectName: 'crm_task', groupBy: 'status' }, + }, + }, + }, + ], + }, + // Three notices: the region-level board, the nested one and the slotted + // one. The `object-grid` sibling and the board without the key emit none. + expectedNotices: 3, + }, +}; + /** * Object-permission lifecycle bits `allowRestore` / `allowPurge` removed * (protocol 18, #12497 — ADR-0049 enforce-or-remove, maintainer ruling @@ -9170,6 +9353,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly> // (wrap-and-rename to `sort: [pair]` when `sort` is absent; a pure lossless // delete when `sort` is present, since the fallback was never read then). 'ui/ObjectGridProps:defaultSort', + // #17260 — ADR-0049 enforce-or-remove, executing the objectui#8285 + // director-seat ruling (comment 5583979207, decision batch #91, 2026-09-08, + // standing maintainer delegation): ruled option B — `quickAdd` is retired from + // the `object-kanban` board and stays only on the `kanban-ui` block, where a + // React host can supply the runtime function the control needs. + // The board FORWARDED the key but never honoured it: measured at the + // `.objectui-sha` pin `53ded82bf`, `ObjectKanban.tsx:931` spreads the authored + // bag into `KanbanRenderer` (`plugin-kanban/src/index.tsx:196` passes both + // `quickAdd` and `onQuickAdd`), and `KanbanImpl` gates the affordance on BOTH + // (`:355`, `:368`) — while `onQuickAdd` is a host-supplied FUNCTION that JSON + // cannot carry and no producer puts on an `object-kanban` node + // (`ObjectKanban.tsx` names neither half: 0 each, against 6 for the sibling + // `onCardClick` in the same file). The drop was not silent, which is the sharp + // edge: objectui's html tier reported the published key as `unknown-prop` — + // the SAME diagnostic a typo gets — and its registry-spec ledger records it as + // `ESCALATED (object-kanban.quickAdd — measured NOT honoured)`, so the author + // met a tool contradicting the contract with no way to tell which side was + // wrong. Tombstoned with `retiredKey()` in `ObjectKanbanPropsSchema` (the + // surface baseline line carries `[RETIRED]`); sources are stripped by the D2 + // conversion `object-kanban-quick-add-removed`, a pure lossless delete scoped + // by component `type` so the LIVE `kanban-ui` spelling is untouched. + // + // Registered under 18, not 17: v17.0.0 was cut before this landed, so the + // removal ships on the 17.x line (launch-window convention: accept-set + // narrowings ride minor releases) and the prescription lives at the major + // boundary where `migrate meta` users look — the `ui/ObjectGridProps:defaultSort` + // precedent one entry over. + 'ui/ObjectKanbanProps:quickAdd', // #17063 (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-09, decision // batch #107 item 1, verbatim 「撤」). `ObjectListView.pageName` named the published // page a `type: 'page'` view was to mount. Only the spec half of #13216 ever diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index 04ae33e91d..06262d8e56 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -3183,6 +3183,66 @@ describe('ObjectKanbanPropsSchema limit — the row cap four objectui faces alre }); }); +// #17260 — the board's per-column quick-add switch, retired by the +// objectui#8285 director-seat ruling (comment 5583979207, decision batch #91, +// 2026-09-08; ruled option B: `quickAdd` leaves `object-kanban` and stays only +// on the React-host `kanban-ui` block). Unlike `limit` above — a key four +// objectui faces already implemented, so the spec was the half that was wrong +// — `quickAdd` was FORWARDED and never read: at the pin this repo builds +// against (`.objectui-sha` = `53ded82bf`) `ObjectKanban.tsx:931` spreads the +// authored bag into `KanbanRenderer` and `KanbanImpl` gates the affordance on +// `quickAdd && onQuickAdd` (`:355`, `:368`), while `onQuickAdd` is a +// host-supplied FUNCTION no producer puts on an `object-kanban` node +// (`ObjectKanban.tsx` names neither half: 0 each, against 6 for the sibling +// `onCardClick` in the same file). +describe('ObjectKanbanPropsSchema quickAdd is retired (#17260)', () => { + const kanban = ComponentPropsMap['object-kanban']; + + it('rejects the retired `quickAdd` with the prescription, not a bare unknown-key verdict', () => { + // The prescription IS the payload: the author who hits this got + // `unknown-prop` from objectui's html tier before — the same message a + // typo gets — so the refusal has to say where the control still works. + expect(() => kanban.parse({ objectName: 'showcase_task', quickAdd: true })) + .toThrow(/`quickAdd`.*removed.*`kanban-ui`/s); + }); + + it('does not materialize the retired `quickAdd` on a clean parse', () => { + expect(kanban.parse({ objectName: 'showcase_task' })).not.toHaveProperty('quickAdd'); + }); + + it('refuses the key by the TOMBSTONE, not by the strict unknown-key arm — the two are different answers', () => { + // The control that makes the assertion above a reading: an undeclared + // sibling on the same node comes back as `unrecognized_keys`, while the + // tombstoned key does not — it is declared, and rejected with its own + // guidance. Without this pair a shape that had simply DROPPED the key + // would pass the first test on the strict arm's generic message. + const retired = kanban.safeParse({ objectName: 'showcase_task', quickAdd: true }); + expect(retired.success).toBe(false); + expect((retired.error?.issues ?? []).map((i) => i.code)).not.toContain('unrecognized_keys'); + + const undeclared = kanban.safeParse({ objectName: 'showcase_task', bogusProp: true }); + expect(undeclared.success).toBe(false); + const issue = undeclared.error?.issues.find((i) => i.code === 'unrecognized_keys') as + | { keys?: string[] } + | undefined; + expect(issue?.keys).toEqual(['bogusProp']); + }); + + it('keeps the neighbouring forwarded keys that ARE read on this path', () => { + // The retirement is one key wide. `coverImageField` and + // `conditionalFormatting` travel the same forward and ARE read + // (`KanbanRenderer` / `bucketCardsIntoColumns` at the same pin), so a + // sweep that took the whole forwarded list would be over-wide — this is + // the pin that would catch it. + const parsed = kanban.safeParse({ + objectName: 'showcase_task', + coverImageField: 'cover', + conditionalFormatting: [{ field: 'priority', value: 'high' }], + }); + expect(parsed.success).toBe(true); + }); +}); + // #10053 — the accept-pins for the last two `icon` slots in this file whose // describes stated only the VOCABULARY. "Icon name (Lucide)" is equally true of // the `page:header` `icon` retired in #6946 *because nothing reads it*, so the diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index d81a58da6c..e61fa0f793 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -2738,8 +2738,12 @@ export type ObjectMetricPropsParsed = z.infer; * value or bare strings, NOT a field projection), `filter` (:198, the * `$filter` handoff), `data` (:217-224), `cardTitle`/`titleField` (:233), * `cardFields` (:322), `swimlaneField`/`grouping` (:518-519), and via the - * forwarded schema `quickAdd`/`coverImageField`/`conditionalFormatting` - * (`KanbanRenderer`, index.tsx). `groupField` is the DESIGNER's spelling with + * forwarded schema `coverImageField`/`conditionalFormatting` (`KanbanRenderer`, + * index.tsx — `ObjectKanban.tsx:931` spreads the authored bag into it). + * `quickAdd` sat on that forwarded list and is RETIRED (#17260, tombstoned + * below): the sentence was true about the FORWARD and false about the READ, + * which is how the key kept re-authorizing itself. `groupField` is the + * DESIGNER's spelling with * zero read points (#7973 class) — aliased to the `groupBy` the board reads. * `limit` (#16503) was measured later, at the pin this repo builds against * (`.objectui-sha` = `53ded82bf`; re-READ there 2026-09-08, file @@ -2817,7 +2821,48 @@ export const ObjectKanbanPropsSchema = lazySchema(() => strictObject({ cardFields: z.array(z.string()).optional().describe('Fields rendered on each card'), swimlaneField: z.string().optional().describe('Field for horizontal swimlanes (in addition to columns)'), grouping: z.unknown().optional().describe('View grouping config; its first field is the swimlane fallback'), - quickAdd: z.boolean().optional().describe('Show the per-column quick-add affordance'), + /** + * RETIRED (#17260, ADR-0049 enforce-or-remove — the spec half of the + * objectui#8285 director-seat ruling, decision batch #91, 2026-09-08: + * option B, `quickAdd` leaves `object-kanban` and stays only on the + * React-host `kanban-ui` block). + * + * Measured at the objectui pin this repo builds against + * (`.objectui-sha` = `53ded82bf`): the board forwards the key — + * `ObjectKanban.tsx:931` spreads the authored bag into `KanbanRenderer`, + * which passes + * `quickAdd={schema.quickAdd}` and `onQuickAdd={schema.onQuickAdd}` + * (`plugin-kanban/src/index.tsx:196`) — but the affordance is gated on + * BOTH (`KanbanImpl.tsx:355` and `:368`), and `onQuickAdd` is a + * host-supplied FUNCTION that JSON cannot carry and no producer puts on an + * `object-kanban` node. `ObjectKanban.tsx` names neither half of the pair + * (0 occurrences each, against 6 for the sibling `onCardClick` in the same + * file). So the gate was permanently false and authoring the key was a + * parse-clean no-op — the accepted-and-dropped class. + * + * ⛔ Not a silent one, which is why the retirement is worth more than a + * tidy-up: objectui's registry↔spec ledger records the key verbatim as + * `ESCALATED (object-kanban.quickAdd — measured NOT honoured)` and its html + * tier reported it as `unknown-prop` — the SAME diagnostic a typo gets. An + * author following this published contract met a tool that contradicted it + * and could not tell which side was wrong. The tombstone collapses both + * halves onto one answer. + * + * The control itself is NOT withdrawn from the platform: it stays on + * `kanban-ui`, the block a React host renders directly and can hand the + * runtime function to. Sources are stripped by the D2 conversion + * `object-kanban-quick-add-removed` (a pure lossless delete — the key never + * had an effect to preserve). + */ + quickAdd: retiredKey( + '`object-kanban` property `quickAdd` was removed in @objectstack/spec 17 (ADR-0049) — ' + + 'the board forwarded it, but the per-column affordance is gated on both `quickAdd` and ' + + '`onQuickAdd`, and `onQuickAdd` is a host-supplied function JSON cannot carry and no ' + + 'producer ever put on an `object-kanban` node, so authoring it was a parse-clean no-op. ' + + 'Delete the key. The quick-add control is unchanged on the `kanban-ui` block, where a React ' + + 'host supplies the `onQuickAdd` slot the control needs. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', + ), coverImageField: z.string().optional().describe('Image field rendered as the card cover'), conditionalFormatting: z.unknown().optional().describe('Card conditional formatting rules'), }));