From 4e2236eaf5891afc601036f7a3adabd195363038 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 19:33:12 +0000 Subject: [PATCH] feat(plugin-kanban,types): honour `columns[].collapsed` on the registered board MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `KanbanColumn.collapsed` was declared on both published faces of the `object-kanban` arm and read only by `KanbanEnhanced`, a module no production source imports. An authored `collapsed: true` parsed green and reached a board that did nothing with it — `KanbanImpl`'s only collapse is the swimlane row's, held in viewer state and never keyed to a lane's declared value. The repair is at the reader, not at the declaration: retiring the key would narrow a published accept set and strand objectui#8801's tombstone, which names this key as where lane collapse lives. Honouring it moves no accept set — both faces already declare the member and `@objectstack/spec` declares the lane element as `z.unknown()`. A lane authored `collapsed: true` now renders narrowed to a title spine with its cards withheld, on both of the board's layouts, and its heading is a disclosure the viewer can open. The authored value is the initial state; the viewer's own toggle wins after that and survives the data refreshes that rebuild the lane objects. A board that authors the key nowhere renders the DOM it rendered before. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UanLVj6xvbS6puBCewLr8L --- ...01-object-kanban-allow-collapse-retired.md | 15 +- .../9628-kanban-column-collapsed-honoured.md | 41 +++ content/docs/plugins/plugin-kanban.mdx | 4 +- packages/plugin-kanban/README.md | 6 +- packages/plugin-kanban/src/KanbanImpl.tsx | 249 ++++++++++++++++-- .../columnCollapsedHonoured-9628.test.tsx | 222 ++++++++++++++++ packages/types/src/complex.ts | 17 +- packages/types/src/objectql.ts | 32 ++- packages/types/src/zod/complex.zod.ts | 2 +- packages/types/src/zod/objectql.zod.ts | 9 +- 10 files changed, 542 insertions(+), 55 deletions(-) create mode 100644 .changeset/9628-kanban-column-collapsed-honoured.md create mode 100644 packages/plugin-kanban/src/__tests__/columnCollapsedHonoured-9628.test.tsx diff --git a/.changeset/8801-object-kanban-allow-collapse-retired.md b/.changeset/8801-object-kanban-allow-collapse-retired.md index ac726c52a4..f897016a3f 100644 --- a/.changeset/8801-object-kanban-allow-collapse-retired.md +++ b/.changeset/8801-object-kanban-allow-collapse-retired.md @@ -50,13 +50,14 @@ near miss of the same spelling parses GREEN rather than red — this arm is not strict, which is precisely why the tombstone rather than a deletion is what does the refusing here. -**What replaces it: nothing you author.** Lane collapse is -`KanbanColumn.collapsed`'s — a per-lane member — not a board-level authored -toggle. On the board this arm actually renders it is the VIEWER's rather than -the author's: `KanbanImpl` collapses a swimlane when its header button is -clicked and persists that set per `swimlaneField`. The implementation that does -honour a lane's own `collapsed`, `KanbanEnhanced`, is referenced by no -registration since the `kanban-enhanced` node key retired (objectui#8257). +**What replaces it: the per-LANE member, not a board-level toggle.** Lane +collapse is `KanbanColumn.collapsed`'s — written on the lane you want collapsed, +inside `columns` — and the board this arm renders honours it (objectui#9628, in +this same release): the lane starts collapsed and the viewer can open it again. +A board-level "may lanes collapse at all" switch is what has no replacement. +SWIMLANE collapse remains the viewer's alone: `KanbanImpl` collapses a swimlane +row when its header button is clicked and persists that set per `swimlaneField`, +and no authored key reaches it. **Migration, in this repository: no authored document changed.** Over every tracked file, the only occurrences of the name were the two declarations retired diff --git a/.changeset/9628-kanban-column-collapsed-honoured.md b/.changeset/9628-kanban-column-collapsed-honoured.md new file mode 100644 index 0000000000..c3d5ca456c --- /dev/null +++ b/.changeset/9628-kanban-column-collapsed-honoured.md @@ -0,0 +1,41 @@ +--- +'@object-ui/plugin-kanban': minor +'@object-ui/types': minor +--- + +Honour `columns[].collapsed` on the registered kanban board (objectui#9628). + +The key was declared on **both** published faces of the `object-kanban` arm — the lane +element of `ObjectKanbanSchema` (`objectql.ts` and its Zod mirror) and the runtime lane +`KanbanColumn` (`complex.ts` and its mirror) — and read by `KanbanEnhanced` alone, a +module no production source imports. An authored `{ "id": "todo", "title": "To Do", +"collapsed": true }` therefore parsed green on both faces and reached a board that did +nothing with it: `KanbanImpl`'s only collapse is the SWIMLANE row's, held in viewer +state under `objectui:kanban-collapsed:` and never keyed to a lane's +declared value. That is the ADR-0049 declared-but-unhonoured shape. + +**The repair is at the reader, not at the declaration.** Retiring the key would narrow a +published accept set, and it would also strand objectui#8801's retirement tombstone, +which names `KanbanColumn.collapsed` as where lane collapse lives. Honouring it moves no +accept set in either direction: both faces already declare the member, and +`@objectstack/spec` declares the lane element as `z.unknown()`, so the protocol neither +names the key nor refuses it. Nothing that parsed before parses differently now. + +**What an authored `collapsed: true` does.** The lane renders narrowed to a title spine +with its cards withheld, on **both** of the board's layouts — the flat lane and the +column cells of each swimlane row — and its heading becomes a disclosure the viewer can +open. That is the meaning `KanbanEnhanced` already gave the key, so the platform states +one behaviour for it rather than two. + +The authored value is the lane's **initial** state. A viewer's own toggle wins after +that and survives the data refreshes that rebuild the lane objects: the board holds the +viewer's overrides and resolves them against the current prop, rather than copying the +authored flags into state where every refetch would re-collapse a lane the viewer had +opened. A lane whose cards could never be reached again — and whose drop target was +blind — would have been a worse board than the one that ignored the key. + +**No change to a board that does not author the key.** The disclosure appears only on a +lane that declared `collapsed: true`, so an omitted key and an authored `collapsed: +false` are indistinguishable, and a board authoring neither renders the DOM it rendered +before. The published `describe` text and docblocks on both faces, the plugin README and +the plugin docs page stop pointing authors at the unregistered board. diff --git a/content/docs/plugins/plugin-kanban.mdx b/content/docs/plugins/plugin-kanban.mdx index e377e0e703..3a2bbed699 100644 --- a/content/docs/plugins/plugin-kanban.mdx +++ b/content/docs/plugins/plugin-kanban.mdx @@ -143,7 +143,7 @@ interface KanbanLane { cards?: KanbanCard[] // a STATIC board's lane only limit?: number // Max cards allowed (WIP limit) className?: string - collapsed?: boolean // honoured by the enhanced board + collapsed?: boolean // lane starts collapsed; the viewer can reopen it } interface KanbanCard { @@ -177,7 +177,7 @@ An undeclared lane key is accepted and dropped, not refused. | `cards` | KanbanCard[] | Cards this lane carries — **optional**, and a static board's option; an object-bound board's cards arrive from the record source | | `limit` | number | Max cards allowed (WIP limit). Never reaches the query — the fetch window is the board's `limit` | | `className` | string | Additional Tailwind CSS classes | -| `collapsed` | boolean | Whether the lane renders collapsed (honoured by the enhanced board) | +| `collapsed` | boolean | Whether the lane renders collapsed — narrowed to a title spine with its cards withheld, and its heading a disclosure the viewer can open. The authored value is the lane's INITIAL state: once a viewer toggles the lane, their choice holds for the session, including across data refreshes | ### Card Properties diff --git a/packages/plugin-kanban/README.md b/packages/plugin-kanban/README.md index e8c0d7cad8..976d06caf6 100644 --- a/packages/plugin-kanban/README.md +++ b/packages/plugin-kanban/README.md @@ -165,9 +165,9 @@ interface KanbanColumn { cards: KanbanCard[]; limit?: number; // WIP limit — the count at which the lane warns className?: string; - collapsed?: boolean; // Lane renders collapsed (read by the KanbanEnhanced - // source module, which objectui#8257 left - // with no registry key — see CHANGELOG) + collapsed?: boolean; // Lane starts collapsed — narrowed to a title + // spine, cards withheld, heading a disclosure + // the viewer can reopen (objectui#9628) color?: never; // RETIRED — refused by name; style a lane through className } diff --git a/packages/plugin-kanban/src/KanbanImpl.tsx b/packages/plugin-kanban/src/KanbanImpl.tsx index 5cd6f3b0ea..6ecfdd141e 100644 --- a/packages/plugin-kanban/src/KanbanImpl.tsx +++ b/packages/plugin-kanban/src/KanbanImpl.tsx @@ -346,6 +346,105 @@ function columnWidthClasses(columnStyle?: React.CSSProperties): string { : "w-[85vw] sm:w-80 shrink-0"; } +/** The width a collapsed lane occupies — a spine wide enough for its title. */ +const COLLAPSED_COLUMN_WIDTH = 56 + +/** + * The lane box — width classes AND inline width — for BOTH board layouts, with + * the collapsed case folded in (objectui#9628). + * + * It wraps `columnWidthClasses` rather than replacing it: an expanded lane is + * exactly what that function already decided, and the only thing added here is + * what a COLLAPSED lane is. A collapsed lane always carries an explicit inline + * width, so its class list is the bare `shrink-0` — the viewport-relative + * fallback would otherwise paint an 85vw "collapsed" lane before the board's + * first `useResizeObserver` reading. + * + * ⚠️ It has to be ONE function for the same reason `columnWidthClasses` does, + * and the swimlane layout makes the reason sharper: that layout paints the + * column-title row and each lane's content row as two independent scrollers + * whose `scrollLeft` is synced (objectui#8448), and equal `scrollLeft` is equal + * ALIGNMENT only while both rows give a given column the same width + * (objectui#8797). A collapsed column that narrowed in one row and not the + * other would put every title past it over the wrong column. Both rows call + * this; neither computes a width of its own. + */ +function laneBox( + columnStyle: React.CSSProperties | undefined, + collapsed: boolean, +): { className: string; style: React.CSSProperties | undefined } { + return collapsed + ? { className: "shrink-0", style: { ...columnStyle, width: COLLAPSED_COLUMN_WIDTH } } + : { className: columnWidthClasses(columnStyle), style: columnStyle } +} + +/** + * The lane heading, as a disclosure when the lane declared `collapsed` + * (objectui#9628). + * + * ## Why the heading itself is the control + * + * The button's accessible name is the lane title, so no new user-facing string + * enters the product and no catalogue gains a key — the same reason the + * swimlane rows' own collapse control carries its lane name. It is the + * `heading > button` disclosure shape, which is valid HTML (a ` + ) +} + /** * How a lane count is WRITTEN, in ONE place, for every header on this board. * @@ -394,6 +493,8 @@ function KanbanColumnView({ columnStyle, suppressEmptyPlaceholder, countsAreWindowed, + collapsed, + onToggleCollapse, }: { column: KanbanColumn cards: KanbanCard[] @@ -414,6 +515,18 @@ function KanbanColumnView({ suppressEmptyPlaceholder?: boolean /** The cards handed in are a fetched window — see `laneCountLabel` (objectui#8307). */ countsAreWindowed?: boolean + /** + * Whether this lane renders collapsed right now — the authored + * `KanbanColumn.collapsed` until the viewer says otherwise (objectui#9628). + * The board owns that resolution; this component only paints it. + */ + collapsed?: boolean + /** + * Toggles the lane's collapse. Present only on a lane that DECLARED + * `collapsed: true`, and its presence is what puts the disclosure in the + * heading — see `LaneHeading`. + */ + onToggleCollapse?: () => void }) { const { t } = useKanbanT() const safeCards = cards || []; @@ -427,9 +540,11 @@ function KanbanColumnView({ const isLimitExceeded = column.limit && safeCards.length >= column.limit // When the parent passes inline width, drop the viewport-relative classes - // so they don't fight with the container-derived value. Shared with the - // swimlane layout's own cells — see `columnWidthClasses`. - const widthClasses = columnWidthClasses(columnStyle); + // so they don't fight with the container-derived value. A collapsed lane + // takes its own width instead. Shared with the swimlane layout's own cells — + // see `laneBox`. + const isCollapsed = collapsed === true + const box = laneBox(columnStyle, isCollapsed); // Stage progress indicator: the colored top stripe was distracting on // boards with many columns ("rainbow stripe" effect). The lane border @@ -441,10 +556,10 @@ function KanbanColumnView({ ref={setNodeRef} role="group" aria-label={column.title} - style={columnStyle} + style={box.style} className={cn( "relative flex flex-col rounded-xl border border-border/60 bg-muted/15 snap-start max-h-full min-h-0 transition-all duration-200 shadow-sm hover:shadow-md overflow-hidden", - widthClasses, + box.className, // P2-5: when a card is being dragged over this column, highlight the // whole column so users can see exactly which lane will receive the // drop. This is critical for empty columns where there's no card @@ -453,9 +568,16 @@ function KanbanColumnView({ column.className )} > -
-
-

{column.title}

+
+
+

+ onToggleCollapse?.()} + /> +

+ {/* A collapsed lane withholds its cards — that is what the authored + `collapsed` asks for (objectui#9628). The lane BOX stays mounted and + keeps `setNodeRef`, so it remains a drop target: a card dragged onto a + collapsed lane still lands in it, and the count above updates. */} + {!isCollapsed && ( c.id)} @@ -508,6 +635,7 @@ function KanbanColumnView({ )} + )}
) } @@ -588,6 +716,40 @@ function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, qu return new Set() }) + /** + * Per-LANE collapse — the authored `KanbanColumn.collapsed`, with the + * viewer's own decision layered over it (objectui#9628). + * + * ⚠️ The authored value is not copied into state, and that is the whole + * design. `columns` is re-derived on every data round-trip (`ObjectKanban` + * re-buckets, so the lane objects are new on each pass), so a state seeded + * from them and re-seeded on prop change would re-collapse a lane the viewer + * had opened, every time the records refreshed — AGENTS.md #8's "state that + * must survive a data refresh". Holding only the OVERRIDES and resolving + * against the current prop keeps the viewer's decision and the author's + * default independent, and depends on no object identity (#10). + * + * The override map is deliberately NOT persisted: the swimlane ROW collapse + * beside it is keyed by `swimlaneField`, and a lane key that stable does not + * exist here — a board has no identity of its own at this level. An authored + * default that survives reload plus a per-view viewer override is the shape + * the declaration describes. + */ + const [laneCollapseOverrides, setLaneCollapseOverrides] = React.useState>( + () => new Map(), + ) + const isColumnCollapsed = React.useCallback( + (col: KanbanColumn) => laneCollapseOverrides.get(String(col.id)) ?? col.collapsed === true, + [laneCollapseOverrides], + ) + const toggleColumnCollapse = React.useCallback((columnId: string, next: boolean) => { + setLaneCollapseOverrides(prev => { + const updated = new Map(prev) + updated.set(String(columnId), next) + return updated + }) + }, []) + // Ensure we always have valid columns with cards array const safeColumns = React.useMemo(() => { return (columns || []).map(col => ({ @@ -1042,16 +1204,33 @@ function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, qu onScroll={syncSwimlaneScroll} data-swimlane-scroll-row="" > - {boardColumns.map(col => ( -
- {col.title} - ({laneCountLabel(col.cards.length, countsAreWindowed)}) -
- ))} + {/* objectui#9628 — this row carries the column-collapse control on + the swimlane layout, because it is the only place a column is + drawn once rather than once per lane. The width comes from + `laneBox`, the same source the lane content cells below read, + which is what keeps the two synced scrollers on equal ranges. */} + {boardColumns.map(col => { + const collapsed = isColumnCollapsed(col) + const box = laneBox(columnInlineStyle, collapsed) + return ( +
+ toggleColumnCollapse(col.id, !collapsed)} + className=" text-xs sm:text-sm font-semibold tracking-wider text-primary/90 uppercase" + /> + {!collapsed && ( + ({laneCountLabel(col.cards.length, countsAreWindowed)}) + )} +
+ ) + })}
{/* Swimlane rows */} @@ -1085,19 +1264,27 @@ function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, qu const laneCards = col.cards.filter(c => (c[swimlaneField!] != null ? String(c[swimlaneField!]) : UNCATEGORIZED_LANE) === lane ) + // A collapsed column narrows in EVERY row and withholds + // its cards in each of them (objectui#9628) — a column + // collapsed in the title row and open here would be the + // misalignment objectui#8797 measured, self-inflicted. + const collapsed = isColumnCollapsed(col) + const box = laneBox(columnInlineStyle, collapsed) return (
- c.id)} strategy={verticalListSortingStrategy}> -
- {laneCards.map(card => ( - - ))} -
-
+ {!collapsed && ( + c.id)} strategy={verticalListSortingStrategy}> +
+ {laneCards.map(card => ( + + ))} +
+
+ )}
) })} @@ -1138,6 +1325,14 @@ function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, qu // clause above is TRUE there instead of merely vacuous. suppressEmptyPlaceholder={isBoardEmpty || !recordsSettled} countsAreWindowed={countsAreWindowed} + // objectui#9628 — the authored lane default, the viewer's toggle + // over it, and the disclosure only where the author asked for one. + collapsed={isColumnCollapsed(column)} + onToggleCollapse={ + column.collapsed === true + ? () => toggleColumnCollapse(column.id, !isColumnCollapsed(column)) + : undefined + } /> ))}
diff --git a/packages/plugin-kanban/src/__tests__/columnCollapsedHonoured-9628.test.tsx b/packages/plugin-kanban/src/__tests__/columnCollapsedHonoured-9628.test.tsx new file mode 100644 index 0000000000..e86a14f4ce --- /dev/null +++ b/packages/plugin-kanban/src/__tests__/columnCollapsedHonoured-9628.test.tsx @@ -0,0 +1,222 @@ +/** + * 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. + */ + +/** + * `columns[].collapsed` is HONOURED BY THE REGISTERED BOARD — objectui#9628. + * + * ## The defect this file reproduces + * + * The key is declared on BOTH published faces of the surviving `object-kanban` + * arm — the lane element of `ObjectKanbanSchema` (`@object-ui/types` + * `objectql.ts` + its Zod mirror) and the runtime lane `KanbanColumn` + * (`complex.ts` + `complex.zod.ts`) — and, until this card, was read by + * `KanbanEnhanced` alone: a module NO production source imports (two test files + * do). The board an authored document actually reaches is `KanbanImpl`, whose + * only collapse is the SWIMLANE row's, held in viewer state under + * `objectui:kanban-collapsed:` and never keyed to a lane's + * declared `collapsed`. So an author wrote the key, both faces took it, and the + * board did nothing — the ADR-0049 declared-but-unhonoured shape. + * + * ## The repair direction, and why it is this one + * + * Honouring at the reader, ⛔ not retiring the key. Retirement narrows a + * published accept set (a maintainer floor), and here it would also strand + * objectui#8801's tombstone, which names THIS key as where lane collapse lives. + * Honouring moves no accept set: both faces already declare the member, and the + * protocol (`@objectstack/spec` `ObjectKanbanPropsSchema.columns`) declares the + * lane element as `z.unknown()`, so it neither names nor refuses the key. + * + * ## What "honoured" means here, and why it is the sibling's meaning + * + * `KanbanEnhanced` — the implementation the docblocks pointed authors at — + * renders a `collapsed` lane narrow, hides its cards, and lets the viewer + * toggle it back. This board now gives the key that same meaning, so the + * declaration means one thing on the platform rather than two. The authored + * value is the lane's INITIAL state; a viewer's own toggle wins after that + * (AGENTS.md #8 — and a lane whose cards could never be reached again would be + * a worse board than the one that ignored the key). + * + * ⚠️ The toggle appears only on a lane that AUTHORED `collapsed: true`. A board + * that authors the key nowhere renders exactly as it did before this card — + * which is what keeps every existing pin in this package green, and is the + * reason an authored `collapsed: false` is not observably different from an + * omitted one. + * + * ## The firing controls, in the same run + * + * Two, both on the authored lane objects this very test hands in: + * + * - `className` on the SAME lane that declares `collapsed` — proves that + * lane's authored members reach the reader, so an inert `collapsed` is not + * a plumbing failure of that one lane; + * - `limit` on a SIBLING lane — proves the board renders authored lane + * members into the DOM at all (the `Full` badge is painted by that member + * alone). + * + * Before the repair both controls were green while every `collapsed` + * expectation below was red, which is what made the zero a reading. + */ + +import React from 'react'; +import { describe, it, expect } from 'vitest'; +import { render, screen, within, act } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { KanbanColumnSchema, ObjectKanbanSchema } from '@object-ui/types/zod'; + +import { KanbanRenderer } from '../index'; +// Pay the board's lazy chunk at import time rather than inside a `findBy` +// budget — the specifier must stay byte-identical to `../index`'s, which is +// what makes that module's own `React.lazy` factory resolve immediately. +import '../KanbanImpl'; + +/** The authored lanes. `todo` collapses; `doing` carries the sibling control. */ +const COLUMNS = [ + { id: 'todo', title: 'To Do', collapsed: true, className: 'repro-9628-lane' }, + { id: 'doing', title: 'Doing', limit: 1 }, +]; + +const DATA = [ + { id: 'r1', title: 'Alpha', status: 'todo' }, + { id: 'r2', title: 'Beta', status: 'doing' }, +]; + +function renderBoard() { + return render( + , + ); +} + +/** The lane box, found by the `role="group"` every column view carries. */ +const lane = (title: string) => screen.getByRole('group', { name: title }); + +describe('both published faces declare the key (objectui#9628)', () => { + it('the `object-kanban` lane element accepts `collapsed`', () => { + const parsed = ObjectKanbanSchema.safeParse({ + type: 'object-kanban', + data: [], + groupBy: 'status', + columns: [{ id: 'todo', title: 'To Do', collapsed: true }], + }); + expect(parsed.success).toBe(true); + expect( + parsed.success && (parsed.data as { columns: Array<{ collapsed?: boolean }> }).columns[0].collapsed, + ).toBe(true); + }); + + it('the runtime lane mirror accepts `collapsed`', () => { + const parsed = KanbanColumnSchema.safeParse({ + id: 'todo', + title: 'To Do', + cards: [], + collapsed: true, + }); + expect(parsed.success).toBe(true); + expect(parsed.success && (parsed.data as { collapsed?: boolean }).collapsed).toBe(true); + }); + + it('CONTROL — an undeclared lane key is dropped rather than kept, so the two above are readings', () => { + const parsed = KanbanColumnSchema.safeParse({ + id: 'todo', + title: 'To Do', + cards: [], + collapsedd: true, + }); + expect(parsed.success).toBe(true); + expect(parsed.success && 'collapsedd' in (parsed.data as object)).toBe(false); + }); +}); + +describe('the registered board honours `columns[].collapsed` (objectui#9628)', () => { + it('renders the declared lane collapsed, with its cards withheld', async () => { + renderBoard(); + + // The board is on screen — asserted through the sibling lane's card, so a + // failure below cannot be "nothing rendered". + expect(await screen.findByText('Beta')).toBeInTheDocument(); + + // CONTROL 1 — this very lane's authored `className` reached the reader. + expect(lane('To Do')).toHaveClass('repro-9628-lane'); + + // CONTROL 2 — an authored lane member reaches the DOM on the sibling lane. + // The `Full` badge is painted by `column.limit` alone: no limit, no badge. + const doing = lane('Doing'); + expect(within(doing).getByText('Full')).toBeInTheDocument(); + + // THE SUBJECT — the collapsed lane withholds its cards and says so. + expect(screen.queryByText('Alpha')).not.toBeInTheDocument(); + expect(screen.queryByRole('list', { name: 'To Do cards' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'To Do' })).toHaveAttribute('aria-expanded', 'false'); + + // The sibling lane is untouched: no disclosure, cards on screen. + expect(screen.queryByRole('button', { name: 'Doing' })).not.toBeInTheDocument(); + expect(within(doing).getByRole('list', { name: 'Doing cards' })).toBeInTheDocument(); + }); + + it('the viewer can expand it again, and the authored value does not win back', async () => { + renderBoard(); + expect(await screen.findByText('Beta')).toBeInTheDocument(); + + const toggle = screen.getByRole('button', { name: 'To Do' }); + act(() => { toggle.click(); }); + + expect(screen.getByRole('button', { name: 'To Do' })).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByText('Alpha')).toBeInTheDocument(); + expect(within(lane('To Do')).getByRole('list', { name: 'To Do cards' })).toBeInTheDocument(); + }); + + it('honours it on the SWIMLANE layout too, in the title row and in every lane row', async () => { + render( + , + ); + + // CONTROL — the swimlane layout is the one on screen, and the sibling + // column's cells are painted in the lane row. + expect(await screen.findByText('Beta')).toBeInTheDocument(); + expect(screen.getByRole('list', { name: 'Doing - west cards' })).toBeInTheDocument(); + + // THE SUBJECT — the collapsed column is withheld from the lane row, and the + // title row carries its disclosure. + expect(screen.queryByRole('list', { name: 'To Do - west cards' })).not.toBeInTheDocument(); + expect(screen.queryByText('Alpha')).not.toBeInTheDocument(); + const toggle = screen.getByRole('button', { name: 'To Do' }); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + + act(() => { toggle.click(); }); + expect(screen.getByRole('list', { name: 'To Do - west cards' })).toBeInTheDocument(); + expect(screen.getByText('Alpha')).toBeInTheDocument(); + }); + + it('a board that authors the key nowhere renders no disclosure at all', async () => { + render( + , + ); + + expect(await screen.findByText('Alpha')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'To Do' })).not.toBeInTheDocument(); + }); +}); diff --git a/packages/types/src/complex.ts b/packages/types/src/complex.ts index 5058cc9fab..e3787c6aa6 100644 --- a/packages/types/src/complex.ts +++ b/packages/types/src/complex.ts @@ -145,8 +145,21 @@ export interface KanbanColumn { limit?: number; className?: string; /** - * Whether the lane renders collapsed. Honoured by `KanbanEnhanced` (the - * implementation that ships column collapsing); the plain board ignores it. + * Whether the lane renders collapsed: narrowed to a title spine, its cards + * withheld, and its heading a disclosure the viewer can open again. + * + * The AUTHORED value is the lane's initial state only — once a viewer toggles + * the lane, their decision holds for that session, including across the data + * refreshes that rebuild these lane objects. Reading it as a locked state + * would make a lane's cards permanently unreachable and its drop target + * blind. + * + * Honoured by the registered board (`@object-ui/plugin-kanban`'s `KanbanImpl`, + * on BOTH of its layouts — the flat lane and each row of the swimlane grid) + * since objectui#9628, and by `KanbanEnhanced` before it. Until that card the + * key was declared on both published faces and read only by `KanbanEnhanced`, + * which no production source imports — so an authored value parsed green and + * reached nothing. */ collapsed?: boolean; /** diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 033c24f957..cd6a280fde 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -3703,7 +3703,12 @@ export interface ObjectKanbanSchema extends BaseSchema { /** WIP limit — the card count at which the lane warns. Never reaches the query. */ limit?: number; className?: string; - /** Whether the lane renders collapsed (honoured by the enhanced board). */ + /** + * Whether the lane renders collapsed — narrowed to a title spine with + * its cards withheld, and reopenable by the viewer, who then owns the + * state for the session. Honoured on both of the registered board's + * layouts since objectui#9628; see {@link KanbanColumn.collapsed}. + */ collapsed?: boolean; }>; /** @@ -3835,15 +3840,24 @@ export interface ObjectKanbanSchema extends BaseSchema { * ## Where collapse actually lives, so the remedy is not a second fiction * * Lane collapse is {@link KanbanColumn.collapsed}'s — a PER-LANE member, not - * a board-level authored toggle. ⚠️ Measured rather than inherited: on the - * board THIS arm renders, collapse is the VIEWER's and not the author's at - * all. `KanbanImpl` collapses a swimlane when its header button is clicked + * a board-level authored toggle: it is written on the lane the author wants + * collapsed, inside `columns`, and the board THIS arm renders honours it on + * both of its layouts since objectui#9628 — the lane starts collapsed and the + * viewer can open it again. + * + * ⚠️ When this key retired that per-lane member was itself + * declared-but-unhonoured on the registered board, read only by + * `KanbanEnhanced`, which is referenced by no registration since the + * `kanban-enhanced` node key retired (objectui#8257); objectui#9628 repaired + * that at the reader rather than by retiring a second key. The distinction + * this key's retirement rests on is unchanged either way: per-LANE collapse + * is the author's to declare, while SWIMLANE collapse is the VIEWER's alone + * — `KanbanImpl` collapses a swimlane row when its header button is clicked * and persists that set per `swimlaneField` under the storage key - * `objectui:kanban-collapsed:` + that field; it reads no `collapsed` member - * on a lane. The implementation that does honour one, `KanbanEnhanced`, is - * referenced by no registration since the `kanban-enhanced` node key retired - * (objectui#8257). ⇒ delete the key; there is no board-level replacement to - * rename it to, and nothing that worked stops working. + * `objectui:kanban-collapsed:` + that field, and no authored key reaches it. + * + * ⇒ delete the key; there is no board-level replacement to rename it to, and + * nothing that worked stops working. * * ## Why a tombstone rather than a plain deletion * diff --git a/packages/types/src/zod/complex.zod.ts b/packages/types/src/zod/complex.zod.ts index 656fc015a8..56f931cdb5 100644 --- a/packages/types/src/zod/complex.zod.ts +++ b/packages/types/src/zod/complex.zod.ts @@ -134,7 +134,7 @@ export const KanbanColumnSchema = z.object({ cards: z.array(KanbanCardSchema).describe('Column cards'), limit: z.number().optional().describe('WIP limit — the card count at which the lane warns'), className: z.string().optional().describe('Column class name'), - collapsed: z.boolean().optional().describe('Whether the lane renders collapsed (honoured by the enhanced board)'), + collapsed: z.boolean().optional().describe('Whether the lane renders collapsed — narrowed to a title spine with its cards withheld, and reopenable by the viewer; the authored value is the initial state'), color: retiredDeclarativeKanbanKey('color', 'column', 'Style a lane through its `className`.'), }); diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 5e9fefd653..492cbebe7d 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -2041,7 +2041,7 @@ const ObjectKanbanLaneSchema = z.object({ cards: z.array(KanbanCardSchema).optional().describe('Cards this lane carries — a STATIC board only; an object-bound board buckets records into the lane by groupBy'), limit: z.number().optional().describe('WIP limit — the card count at which the lane warns; never reaches the query'), className: z.string().optional().describe('Lane class name'), - collapsed: z.boolean().optional().describe('Whether the lane renders collapsed (honoured by the enhanced board)'), + collapsed: z.boolean().optional().describe('Whether the lane renders collapsed — narrowed to a title spine with its cards withheld, and reopenable by the viewer; the authored value is the initial state'), }); const KANBAN_RECORD_SOURCE_KEYS = ['bind', 'data', 'objectName'] as const; @@ -2149,9 +2149,10 @@ export const ObjectKanbanSchema = BaseSchema.extend({ "and collapsed nothing. `@objectstack/spec`'s `ComponentPropsMap['object-kanban']` never " + 'declared the key and refuses it by name at publish, so a document carrying it was already ' + 'being rejected whole. Lane collapse is `KanbanColumn.collapsed`’s — a PER-LANE member — not ' + - 'a board-level authored toggle, and on the board this arm renders it is the VIEWER’s rather ' + - 'than the author’s: `KanbanImpl` collapses a swimlane when its header is clicked and persists ' + - 'that per `swimlaneField`. Delete the key; there is no board-level replacement to rename it to.', + 'a board-level authored toggle: write it on the lane you want collapsed, inside `columns`, and ' + + 'the board honours it (objectui#9628). SWIMLANE collapse stays the VIEWER’s: `KanbanImpl` ' + + 'collapses a swimlane row when its header is clicked and persists that per `swimlaneField`. ' + + 'Delete the key; there is no board-level replacement to rename it to.', ), conditionalFormatting: z.array(KanbanConditionalFormattingRuleSchema).optional().describe('Card conditional formatting rules'), // ── objectui#7804 — the three handler keys `KanbanRenderer` reads off the