Skip to content

Commit 0227e4c

Browse files
committed
feat(ui): the board dims the lanes a dragged card's state machine closes and refuses a drop into one
Claude-Session: https://claude.ai/code/session_015Kzn5VKHAGKe251waHoUqj Co-authored-by: Claude <noreply@anthropic.com>
1 parent df8aac4 commit 0227e4c

4 files changed

Lines changed: 329 additions & 6 deletions

File tree

‎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)