Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/25933-state-machine-board-drop-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@object-ui/plugin-kanban': patch
---

feat(plugin-kanban): the board dims the lanes a dragged card's `state_machine` closes and refuses a drop into one

Clause-②: yes
Metadata-protocol: none

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.

- **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.
- **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.
- **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.
- `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.
2 changes: 1 addition & 1 deletion scripts/sdui-manifest.sources/plugin-kanban.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"package":"ui/packages/plugin-kanban","sources":17,"sha256":"ff448a0909873b5551d0819081287276326b3364a6014e5baa08b40b6c342f3d","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
{"package":"ui/packages/plugin-kanban","sources":17,"sha256":"ce612105cd39bf19c3666925ffc9f77669f466feb09fc85f69fa3ebe558863cf","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
9 changes: 9 additions & 0 deletions ui/packages/plugin-kanban/src/KanbanBoardCore.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { createSafeTranslation } from '@object-ui/i18n';
// which is the same shape `ObjectKanban.tsx` <-> `index.tsx` already has.
import { bucketCardsIntoColumns } from './index';
import type { KanbanRendererProps } from './index';
import type { KanbanBoardProps } from './KanbanImpl';

const useUncolumnedT = createSafeTranslation(
{ 'kanban.uncategorized': 'Uncategorized' },
Expand Down Expand Up @@ -44,6 +45,12 @@ export interface KanbanBoardCoreProps {
* no move the caller may not make.
*/
cardsMovable?: boolean;
/**
* The per-column drop gate — see `KanbanBoardProps.canDropCard` (#25933). A
* PROP, never a schema key, for the reason `cardsMovable` is: `ObjectKanban`
* derives it from the object's `state_machine` rule on the grouped field.
*/
canDropCard?: KanbanBoardProps['canDropCard'];
/** Half of the Quick Add pair. Only `KanbanRenderer` supplies it. */
quickAdd?: boolean;
/** The other half of the Quick Add pair. Only `KanbanRenderer` supplies it. */
Expand Down Expand Up @@ -92,6 +99,7 @@ export const KanbanBoardCore: React.FC<KanbanBoardCoreProps> = ({
objectFields,
onCardMove,
cardsMovable,
canDropCard,
quickAdd,
onQuickAdd,
}) => {
Expand All @@ -115,6 +123,7 @@ export const KanbanBoardCore: React.FC<KanbanBoardCoreProps> = ({
columns={processedColumns}
onCardMove={onCardMove}
cardsMovable={cardsMovable}
canDropCard={canDropCard}
onCardClick={schema.onCardClick}
className={schema.className}
quickAdd={quickAdd}
Expand Down
79 changes: 73 additions & 6 deletions ui/packages/plugin-kanban/src/KanbanImpl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,22 @@ export interface KanbanBoardProps {
* (objectui#12082) — so no card offers a drag that would be refused.
*/
cardsMovable?: boolean
/**
* The per-column drop gate (#25933): whether a card that sits in
* `fromColumnId` may be dropped into `toColumnId`. Absent, every column takes
* every card, as before. `ObjectKanban` derives it from the object's
* blocking `state_machine` rule on the grouped field, so a move the server is
* bound to refuse is never offered:
*
* - while a card is dragged, every column it may not enter is dimmed
* (`data-kanban-drop-blocked`) and draws no drop highlight;
* - a drop into such a column is refused here, before the mover runs: the
* card stays where it was and `onCardMove` is not called.
*
* The card's own column is never asked — a same-column drop moves nothing.
* The server still refuses an invalid move, and the mover's rollback stays.
*/
canDropCard?: (fromColumnId: string, toColumnId: string) => boolean
}

/**
Expand Down Expand Up @@ -680,6 +696,7 @@ function KanbanColumnView({
suppressEmptyPlaceholder,
countsAreWindowed,
collapsed,
dropBlocked,
onToggleCollapse,
}: {
column: KanbanColumn
Expand Down Expand Up @@ -707,6 +724,12 @@ function KanbanColumnView({
* The board owns that resolution; this component only paints it.
*/
collapsed?: boolean
/**
* The card being dragged may not enter this lane (#25933,
* {@link KanbanBoardProps.canDropCard}): the lane is dimmed and draws no
* drop highlight. A drop here is refused by the board's drag-end handler.
*/
dropBlocked?: 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
Expand All @@ -716,12 +739,14 @@ function KanbanColumnView({
}) {
const { t } = useKanbanT()
const safeCards = cards || [];
const { setNodeRef, isOver } = useSortable({
const { setNodeRef, isOver: pointerOver } = useSortable({
id: column.id,
data: {
type: "column",
},
})
// A lane the dragged card may not enter never claims the drop (#25933).
const isOver = pointerOver && !dropBlocked

// objectui#12619 — a lane the board pages for itself carries the server's
// count of its records and a "Load more" while it holds more than it drew.
Expand Down Expand Up @@ -755,6 +780,8 @@ function KanbanColumnView({
ref={setNodeRef}
role="group"
aria-label={column.title}
aria-disabled={dropBlocked || undefined}
data-kanban-drop-blocked={dropBlocked ? "" : undefined}
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",
Expand All @@ -764,6 +791,7 @@ function KanbanColumnView({
// drop. This is critical for empty columns where there's no card
// gap-indicator from SortableContext to show drop position.
isOver && "ring-2 ring-primary/60 bg-primary/5",
dropBlocked && "opacity-40",
column.className
)}
>
Expand Down Expand Up @@ -879,27 +907,27 @@ function moveOutcomeSentence(outcome: unknown, movedSentence: string): string |
return movedSentence
}

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

if (hasDnd) {
return (
<KanbanCardsMovable.Provider value={cardsMovable}>
<DndBridge>
{(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} />}
{(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} />}
</DndBridge>
</KanbanCardsMovable.Provider>
)
}

return (
<KanbanCardsMovable.Provider value={cardsMovable}>
<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} />
<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} />
</KanbanCardsMovable.Provider>
)
}

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

Expand Down Expand Up @@ -1066,10 +1094,23 @@ function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, qu
})
)

/**
* The column the dragged card left, for the drop gate (#25933). Held only
* while a drag is live; `null` otherwise, which leaves every lane open.
*/
const [dragFromColumnId, setDragFromColumnId] = React.useState<string | null>(null)
/** Whether the dragged card may NOT enter `columnId` — see {@link KanbanBoardProps.canDropCard}. */
const isDropBlocked = (columnId: string): boolean =>
canDropCard != null &&
dragFromColumnId != null &&
String(columnId) !== String(dragFromColumnId) &&
!canDropCard(String(dragFromColumnId), String(columnId))

const handleDragStart = (event: DragStartEvent) => {
const { active } = event
const card = findCard(active.id as string)
setActiveCard(card)
setDragFromColumnId(card ? findColumnByCardId(card.id)?.id ?? null : null)

// Bridge to ObjectUI spec DnD system
if (dnd && card) {
Expand Down Expand Up @@ -1110,9 +1151,18 @@ function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, qu
}
}

// A cancelled drag (Escape, or the pointer lost) moves nothing; it only has
// to put the lanes the drop gate dimmed back to normal.
const handleDragCancel = () => {
setActiveCard(null)
setDragFromColumnId(null)
if (dnd) dnd.endDrag()
}

const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
setActiveCard(null)
setDragFromColumnId(null)

if (!over) {
if (dnd) dnd.endDrag()
Expand All @@ -1135,6 +1185,18 @@ function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, qu
return
}

if (
activeColumn.id !== overColumn.id &&
canDropCard != null &&
!canDropCard(String(activeColumn.id), String(overColumn.id))
) {
// A lane the drop gate closed (#25933): refused here, before any
// optimistic move or write — the card stays in its column, and the
// mover is not asked for a move the server is bound to refuse.
if (dnd) dnd.endDrag()
return
}

if (activeColumn.id === overColumn.id) {
// Same-column drop: the board does NOTHING, deliberately (objectui#8826).
//
Expand Down Expand Up @@ -1334,6 +1396,7 @@ function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, qu
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
accessibility={dndAccessibility}
>
<div ref={boardRef} className="flex flex-col min-w-0 min-h-0 h-full">
Expand Down Expand Up @@ -1571,11 +1634,14 @@ function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, qu
// misalignment objectui#8797 measured, self-inflicted.
const collapsed = isColumnCollapsed(col)
const box = laneBox(columnInlineStyle, collapsed)
const dropBlocked = isDropBlocked(col.id)
return (
<div
key={col.id}
style={box.style}
className={cn(box.className, "min-h-[60px] rounded-md bg-card/20 p-2")}
aria-disabled={dropBlocked || undefined}
data-kanban-drop-blocked={dropBlocked ? "" : undefined}
className={cn(box.className, "min-h-[60px] rounded-md bg-card/20 p-2", dropBlocked && "opacity-40")}
>
{!collapsed && (
<SortableContext items={laneCards.map(c => c.id)} strategy={verticalListSortingStrategy}>
Expand All @@ -1602,6 +1668,7 @@ function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, qu
<KanbanColumnView
key={column.id}
column={column}
dropBlocked={isDropBlocked(column.id)}
cards={column.cards}
onCardClick={onCardClick}
quickAdd={quickAdd}
Expand Down
Loading
Loading