Skip to content

Commit c14e910

Browse files
feat(ui): the board dims the lanes a card's state machine closes and refuses a drop into one (#26253)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi955CwQcSiWwJCH5htb8Ncqt)_ Fixes #25933 — the board half. The edit form half landed in #26033. Clause-②: yes Metadata-protocol: none ## What changes On a board grouped by a field that an error-severity `state_machine` validation governs (the showcase Task Board's `status`), a `backlog` card could be dragged onto any column. The server refused the move and the board rolled it back with a toast, which works correctly, and nothing during the drag marked the columns the card could not enter. Now: - **While a card is dragged**, each lane its column's value may not move to is dimmed (`opacity-40`) and draws no drop highlight, with `aria-disabled` and `data-kanban-drop-blocked` on the lane. This covers the flat layout (`KanbanColumnView`) and every swimlane row's cells. - **A drop into a closed lane is refused on the client** in `handleDragEnd`, before the optimistic move and before `onCardMove`, so nothing is written and the card stays in its column. A new `onDragCancel` handler clears the dimming when a drag is cancelled. Before this PR the board had no cancel handler, and `activeCard` stayed set after Escape. - **The gate is a new optional prop**, `canDropCard(fromColumnId, toColumnId)`, on `KanbanBoardProps` (`KanbanImpl.tsx`), forwarded by `KanbanBoardCore.tsx`. `ObjectKanban.tsx` derives it from `objectDef.validations` through `stateMachineNextValues` (`@object-ui/core`, from #26033). A lane is an option value of `groupBy`, so only a rule on the board's own `groupBy` field can close a lane, and only one the server enforces on update: `severity` `error` (the default), `active`, `events` covering `update`. An undeclared from-state stays open. The Uncategorized lane also stays open, because the mover writes nothing for a drop into it. `KanbanRenderer` (a host-mounted board) passes no gate and is unchanged. - The server still refuses an invalid move, and `handleCardMove`'s rollback stays. No new visible text, so no i18n. ## Tests Run at head `c0333981` from `ui/` under the verify lock: - `packages/plugin-kanban/src/ObjectKanban.stateMachineDropGate-25933.test.tsx` (new, 6, driven through `DndContext`'s captured handlers like the rollback test). Dragging a `backlog` card dims only Done. A drop on Done writes nothing, shows no toast, leaves the card in Backlog and clears the dimming. A drop on To Do writes `{ status: 'todo' }`. A cancelled drag clears the dimming. CONTROLS: a warning-severity machine closes no lane and the drop is written, and so does a machine on a field the board does not group by. - `packages/plugin-kanban/src/ObjectKanban.rejectedMoveRollback.test.tsx`: still green, so the server-refusal rollback is untouched. - The whole `@object-ui/plugin-kanban` package: 79 files, 531 tests, all green. `pnpm --filter @object-ui/plugin-kanban run type-check` (source and test configs) exits 0. Both ran in the same locked run that re-recorded `scripts/sdui-manifest.sources/plugin-kanban.json`; `node scripts/check-sdui-manifest.mjs` exits 0 at the head. **Ablations** (fix committed first; `scripts/ablation-replace.mjs`, mutation proven on disk, restored to the HEAD blob each time): - `canDropCard={undefined}` in `ObjectKanban.tsx` turned 3 pins red: the dimming, the refusal and the cancel. The 3 that expect an open lane stayed green. - The `handleDragEnd` guard replaced by `false` in `KanbanImpl.tsx` turned exactly the refusal pin red. The dimming stays driven by the gate, so the refusal is pinned on its own. **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 47 commands at `c0333981`. All 47 exited 0 there. `--ran` reconciliation: 47 derived, 47 run, 0 NOT-MEASURED (a derived zero: every row carries its exit code). Repo-wide scans (`pnpm lint`, the full `check:*` farm) and the Console build are left to CI. ## Acceptance notes - A refused drop is silent to a screen reader. dnd-kit's drop announcement does not speak for cross-column drops (#13054 moved that to the write's outcome), and with no write there is no outcome sentence. Adding one would be new copy, which this card ruled out. The dimmed lanes carry `aria-disabled`. - The pointer can still hover a closed lane, because collision detection is not filtered. The lane just draws no highlight and the drop is refused. Filtering the droppables in `collisionDetection` would be a deeper change to the dnd wiring. - Like the form, only top-level `state_machine` rules are read. A machine nested in a `conditional` rule leaves lanes open, never stricter than the server. Session: `session_015Kzn5VKHAGKe251waHoUqj` (card thread, `domain:ui#2`) --- _Generated by [Claude Code](https://claude.ai/code/session_015Kzn5VKHAGKe251waHoUqj)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 733b3c8 commit c14e910

6 files changed

Lines changed: 345 additions & 7 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@object-ui/plugin-kanban': patch
3+
---
4+
5+
feat(plugin-kanban): the board dims the lanes a dragged card's `state_machine` closes and refuses a drop into one
6+
7+
Clause-②: yes
8+
Metadata-protocol: none
9+
10+
A board grouped by a field that an error-severity `state_machine` validation governs (the showcase Task Board's `status`, for one) used to accept a card dragged onto any column. The server refused the invalid move and the board rolled it back with a toast, and nothing during the drag marked the columns the card could not enter.
11+
12+
- **While a card is dragged**, every lane its column's value may not move to is dimmed and draws no drop highlight (`aria-disabled`, `data-kanban-drop-blocked`), on the flat layout and in every swimlane row.
13+
- **A drop into such a lane is refused on the client**, before the optimistic move and before any write: the card stays in its column. A cancelled drag puts the lanes back.
14+
- **Which lanes close** is read through `stateMachineNextValues` from `@object-ui/core`, the same read the record form and the grid's inline editor use. Only a rule on the board's own `groupBy` field that the server enforces on update closes a lane (`severity` `error`, `active`, `events` covering `update`). An undeclared from-state and the Uncategorized lane stay open.
15+
- `KanbanBoard` (the lazily loaded board both entry points render) takes a new optional prop, `canDropCard(fromColumnId, toColumnId)`. `ObjectKanban` derives it; `KanbanRenderer` passes none, so a host-mounted board is unchanged. The server still refuses an invalid move, and the mover's rollback stays. No new visible text.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"package":"ui/packages/plugin-kanban","sources":17,"sha256":"ff448a0909873b5551d0819081287276326b3364a6014e5baa08b40b6c342f3d","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
1+
{"package":"ui/packages/plugin-kanban","sources":17,"sha256":"ce612105cd39bf19c3666925ffc9f77669f466feb09fc85f69fa3ebe558863cf","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}

‎ui/packages/plugin-kanban/src/KanbanBoardCore.tsx‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { createSafeTranslation } from '@object-ui/i18n';
1414
// which is the same shape `ObjectKanban.tsx` <-> `index.tsx` already has.
1515
import { bucketCardsIntoColumns } from './index';
1616
import type { KanbanRendererProps } from './index';
17+
import type { KanbanBoardProps } from './KanbanImpl';
1718

1819
const useUncolumnedT = createSafeTranslation(
1920
{ 'kanban.uncategorized': 'Uncategorized' },
@@ -44,6 +45,12 @@ export interface KanbanBoardCoreProps {
4445
* no move the caller may not make.
4546
*/
4647
cardsMovable?: boolean;
48+
/**
49+
* The per-column drop gate — see `KanbanBoardProps.canDropCard` (#25933). A
50+
* PROP, never a schema key, for the reason `cardsMovable` is: `ObjectKanban`
51+
* derives it from the object's `state_machine` rule on the grouped field.
52+
*/
53+
canDropCard?: KanbanBoardProps['canDropCard'];
4754
/** Half of the Quick Add pair. Only `KanbanRenderer` supplies it. */
4855
quickAdd?: boolean;
4956
/** The other half of the Quick Add pair. Only `KanbanRenderer` supplies it. */
@@ -92,6 +99,7 @@ export const KanbanBoardCore: React.FC<KanbanBoardCoreProps> = ({
9299
objectFields,
93100
onCardMove,
94101
cardsMovable,
102+
canDropCard,
95103
quickAdd,
96104
onQuickAdd,
97105
}) => {
@@ -115,6 +123,7 @@ export const KanbanBoardCore: React.FC<KanbanBoardCoreProps> = ({
115123
columns={processedColumns}
116124
onCardMove={onCardMove}
117125
cardsMovable={cardsMovable}
126+
canDropCard={canDropCard}
118127
onCardClick={schema.onCardClick}
119128
className={schema.className}
120129
quickAdd={quickAdd}

‎ui/packages/plugin-kanban/src/KanbanImpl.tsx‎

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,22 @@ export interface KanbanBoardProps {
223223
* (objectui#12082) — so no card offers a drag that would be refused.
224224
*/
225225
cardsMovable?: boolean
226+
/**
227+
* The per-column drop gate (#25933): whether a card that sits in
228+
* `fromColumnId` may be dropped into `toColumnId`. Absent, every column takes
229+
* every card, as before. `ObjectKanban` derives it from the object's
230+
* blocking `state_machine` rule on the grouped field, so a move the server is
231+
* bound to refuse is never offered:
232+
*
233+
* - while a card is dragged, every column it may not enter is dimmed
234+
* (`data-kanban-drop-blocked`) and draws no drop highlight;
235+
* - a drop into such a column is refused here, before the mover runs: the
236+
* card stays where it was and `onCardMove` is not called.
237+
*
238+
* The card's own column is never asked — a same-column drop moves nothing.
239+
* The server still refuses an invalid move, and the mover's rollback stays.
240+
*/
241+
canDropCard?: (fromColumnId: string, toColumnId: string) => boolean
226242
}
227243

228244
/**
@@ -680,6 +696,7 @@ function KanbanColumnView({
680696
suppressEmptyPlaceholder,
681697
countsAreWindowed,
682698
collapsed,
699+
dropBlocked,
683700
onToggleCollapse,
684701
}: {
685702
column: KanbanColumn
@@ -707,6 +724,12 @@ function KanbanColumnView({
707724
* The board owns that resolution; this component only paints it.
708725
*/
709726
collapsed?: boolean
727+
/**
728+
* The card being dragged may not enter this lane (#25933,
729+
* {@link KanbanBoardProps.canDropCard}): the lane is dimmed and draws no
730+
* drop highlight. A drop here is refused by the board's drag-end handler.
731+
*/
732+
dropBlocked?: boolean
710733
/**
711734
* Toggles the lane's collapse. Present only on a lane that DECLARED
712735
* `collapsed: true`, and its presence is what puts the disclosure in the
@@ -716,12 +739,14 @@ function KanbanColumnView({
716739
}) {
717740
const { t } = useKanbanT()
718741
const safeCards = cards || [];
719-
const { setNodeRef, isOver } = useSortable({
742+
const { setNodeRef, isOver: pointerOver } = useSortable({
720743
id: column.id,
721744
data: {
722745
type: "column",
723746
},
724747
})
748+
// A lane the dragged card may not enter never claims the drop (#25933).
749+
const isOver = pointerOver && !dropBlocked
725750

726751
// objectui#12619 — a lane the board pages for itself carries the server's
727752
// count of its records and a "Load more" while it holds more than it drew.
@@ -755,6 +780,8 @@ function KanbanColumnView({
755780
ref={setNodeRef}
756781
role="group"
757782
aria-label={column.title}
783+
aria-disabled={dropBlocked || undefined}
784+
data-kanban-drop-blocked={dropBlocked ? "" : undefined}
758785
style={box.style}
759786
className={cn(
760787
"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",
@@ -764,6 +791,7 @@ function KanbanColumnView({
764791
// drop. This is critical for empty columns where there's no card
765792
// gap-indicator from SortableContext to show drop position.
766793
isOver && "ring-2 ring-primary/60 bg-primary/5",
794+
dropBlocked && "opacity-40",
767795
column.className
768796
)}
769797
>
@@ -879,27 +907,27 @@ function moveOutcomeSentence(outcome: unknown, movedSentence: string): string |
879907
return movedSentence
880908
}
881909

882-
export default function KanbanBoard({ columns, onCardMove, onCardClick, className, quickAdd, onQuickAdd, coverImageField, conditionalFormatting, objectFields, swimlaneField, countsAreWindowed, cardsMovable = true }: KanbanBoardProps) {
910+
export default function KanbanBoard({ columns, onCardMove, onCardClick, className, quickAdd, onQuickAdd, coverImageField, conditionalFormatting, objectFields, swimlaneField, countsAreWindowed, cardsMovable = true, canDropCard }: KanbanBoardProps) {
883911
const hasDnd = useHasDndProvider()
884912

885913
if (hasDnd) {
886914
return (
887915
<KanbanCardsMovable.Provider value={cardsMovable}>
888916
<DndBridge>
889-
{(dnd) => <KanbanBoardInner columns={columns} onCardMove={onCardMove} onCardClick={onCardClick} className={className} dnd={dnd} quickAdd={quickAdd} onQuickAdd={onQuickAdd} coverImageField={coverImageField} conditionalFormatting={conditionalFormatting} objectFields={objectFields} swimlaneField={swimlaneField} countsAreWindowed={countsAreWindowed} />}
917+
{(dnd) => <KanbanBoardInner columns={columns} onCardMove={onCardMove} onCardClick={onCardClick} className={className} dnd={dnd} quickAdd={quickAdd} onQuickAdd={onQuickAdd} coverImageField={coverImageField} conditionalFormatting={conditionalFormatting} objectFields={objectFields} swimlaneField={swimlaneField} countsAreWindowed={countsAreWindowed} canDropCard={canDropCard} />}
890918
</DndBridge>
891919
</KanbanCardsMovable.Provider>
892920
)
893921
}
894922

895923
return (
896924
<KanbanCardsMovable.Provider value={cardsMovable}>
897-
<KanbanBoardInner columns={columns} onCardMove={onCardMove} onCardClick={onCardClick} className={className} dnd={null} quickAdd={quickAdd} onQuickAdd={onQuickAdd} coverImageField={coverImageField} conditionalFormatting={conditionalFormatting} objectFields={objectFields} swimlaneField={swimlaneField} countsAreWindowed={countsAreWindowed} />
925+
<KanbanBoardInner columns={columns} onCardMove={onCardMove} onCardClick={onCardClick} className={className} dnd={null} quickAdd={quickAdd} onQuickAdd={onQuickAdd} coverImageField={coverImageField} conditionalFormatting={conditionalFormatting} objectFields={objectFields} swimlaneField={swimlaneField} countsAreWindowed={countsAreWindowed} canDropCard={canDropCard} />
898926
</KanbanCardsMovable.Provider>
899927
)
900928
}
901929

902-
function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, quickAdd, onQuickAdd, coverImageField: _coverImageField, conditionalFormatting, objectFields, swimlaneField, countsAreWindowed }: KanbanBoardProps & { dnd: ReturnType<typeof useDnd> | null }) {
930+
function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, quickAdd, onQuickAdd, coverImageField: _coverImageField, conditionalFormatting, objectFields, swimlaneField, countsAreWindowed, canDropCard }: KanbanBoardProps & { dnd: ReturnType<typeof useDnd> | null }) {
903931
const { t } = useKanbanT()
904932
const [activeCard, setActiveCard] = React.useState<KanbanCard | null>(null)
905933

@@ -1066,10 +1094,23 @@ function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, qu
10661094
})
10671095
)
10681096

1097+
/**
1098+
* The column the dragged card left, for the drop gate (#25933). Held only
1099+
* while a drag is live; `null` otherwise, which leaves every lane open.
1100+
*/
1101+
const [dragFromColumnId, setDragFromColumnId] = React.useState<string | null>(null)
1102+
/** Whether the dragged card may NOT enter `columnId` — see {@link KanbanBoardProps.canDropCard}. */
1103+
const isDropBlocked = (columnId: string): boolean =>
1104+
canDropCard != null &&
1105+
dragFromColumnId != null &&
1106+
String(columnId) !== String(dragFromColumnId) &&
1107+
!canDropCard(String(dragFromColumnId), String(columnId))
1108+
10691109
const handleDragStart = (event: DragStartEvent) => {
10701110
const { active } = event
10711111
const card = findCard(active.id as string)
10721112
setActiveCard(card)
1113+
setDragFromColumnId(card ? findColumnByCardId(card.id)?.id ?? null : null)
10731114

10741115
// Bridge to ObjectUI spec DnD system
10751116
if (dnd && card) {
@@ -1110,9 +1151,18 @@ function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, qu
11101151
}
11111152
}
11121153

1154+
// A cancelled drag (Escape, or the pointer lost) moves nothing; it only has
1155+
// to put the lanes the drop gate dimmed back to normal.
1156+
const handleDragCancel = () => {
1157+
setActiveCard(null)
1158+
setDragFromColumnId(null)
1159+
if (dnd) dnd.endDrag()
1160+
}
1161+
11131162
const handleDragEnd = (event: DragEndEvent) => {
11141163
const { active, over } = event
11151164
setActiveCard(null)
1165+
setDragFromColumnId(null)
11161166

11171167
if (!over) {
11181168
if (dnd) dnd.endDrag()
@@ -1135,6 +1185,18 @@ function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, qu
11351185
return
11361186
}
11371187

1188+
if (
1189+
activeColumn.id !== overColumn.id &&
1190+
canDropCard != null &&
1191+
!canDropCard(String(activeColumn.id), String(overColumn.id))
1192+
) {
1193+
// A lane the drop gate closed (#25933): refused here, before any
1194+
// optimistic move or write — the card stays in its column, and the
1195+
// mover is not asked for a move the server is bound to refuse.
1196+
if (dnd) dnd.endDrag()
1197+
return
1198+
}
1199+
11381200
if (activeColumn.id === overColumn.id) {
11391201
// Same-column drop: the board does NOTHING, deliberately (objectui#8826).
11401202
//
@@ -1334,6 +1396,7 @@ function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, qu
13341396
collisionDetection={closestCorners}
13351397
onDragStart={handleDragStart}
13361398
onDragEnd={handleDragEnd}
1399+
onDragCancel={handleDragCancel}
13371400
accessibility={dndAccessibility}
13381401
>
13391402
<div ref={boardRef} className="flex flex-col min-w-0 min-h-0 h-full">
@@ -1571,11 +1634,14 @@ function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, qu
15711634
// misalignment objectui#8797 measured, self-inflicted.
15721635
const collapsed = isColumnCollapsed(col)
15731636
const box = laneBox(columnInlineStyle, collapsed)
1637+
const dropBlocked = isDropBlocked(col.id)
15741638
return (
15751639
<div
15761640
key={col.id}
15771641
style={box.style}
1578-
className={cn(box.className, "min-h-[60px] rounded-md bg-card/20 p-2")}
1642+
aria-disabled={dropBlocked || undefined}
1643+
data-kanban-drop-blocked={dropBlocked ? "" : undefined}
1644+
className={cn(box.className, "min-h-[60px] rounded-md bg-card/20 p-2", dropBlocked && "opacity-40")}
15791645
>
15801646
{!collapsed && (
15811647
<SortableContext items={laneCards.map(c => c.id)} strategy={verticalListSortingStrategy}>
@@ -1602,6 +1668,7 @@ function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, qu
16021668
<KanbanColumnView
16031669
key={column.id}
16041670
column={column}
1671+
dropBlocked={isDropBlocked(column.id)}
16051672
cards={column.cards}
16061673
onCardClick={onCardClick}
16071674
quickAdd={quickAdd}

0 commit comments

Comments
 (0)