diff --git a/.changeset/25933-state-machine-board-drop-gate.md b/.changeset/25933-state-machine-board-drop-gate.md new file mode 100644 index 00000000000..eb4b4140370 --- /dev/null +++ b/.changeset/25933-state-machine-board-drop-gate.md @@ -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. diff --git a/scripts/sdui-manifest.sources/plugin-kanban.json b/scripts/sdui-manifest.sources/plugin-kanban.json index 4369a6366f8..385520ee88e 100644 --- a/scripts/sdui-manifest.sources/plugin-kanban.json +++ b/scripts/sdui-manifest.sources/plugin-kanban.json @@ -1 +1 @@ -{"package":"ui/packages/plugin-kanban","sources":17,"sha256":"ff448a0909873b5551d0819081287276326b3364a6014e5baa08b40b6c342f3d","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/plugin-kanban","sources":17,"sha256":"ce612105cd39bf19c3666925ffc9f77669f466feb09fc85f69fa3ebe558863cf","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} diff --git a/ui/packages/plugin-kanban/src/KanbanBoardCore.tsx b/ui/packages/plugin-kanban/src/KanbanBoardCore.tsx index 6b79c2be5ac..6644db305e4 100644 --- a/ui/packages/plugin-kanban/src/KanbanBoardCore.tsx +++ b/ui/packages/plugin-kanban/src/KanbanBoardCore.tsx @@ -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' }, @@ -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. */ @@ -92,6 +99,7 @@ export const KanbanBoardCore: React.FC = ({ objectFields, onCardMove, cardsMovable, + canDropCard, quickAdd, onQuickAdd, }) => { @@ -115,6 +123,7 @@ export const KanbanBoardCore: React.FC = ({ columns={processedColumns} onCardMove={onCardMove} cardsMovable={cardsMovable} + canDropCard={canDropCard} onCardClick={schema.onCardClick} className={schema.className} quickAdd={quickAdd} diff --git a/ui/packages/plugin-kanban/src/KanbanImpl.tsx b/ui/packages/plugin-kanban/src/KanbanImpl.tsx index c1ac5e6b186..c328339e840 100644 --- a/ui/packages/plugin-kanban/src/KanbanImpl.tsx +++ b/ui/packages/plugin-kanban/src/KanbanImpl.tsx @@ -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 } /** @@ -680,6 +696,7 @@ function KanbanColumnView({ suppressEmptyPlaceholder, countsAreWindowed, collapsed, + dropBlocked, onToggleCollapse, }: { column: KanbanColumn @@ -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 @@ -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. @@ -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", @@ -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 )} > @@ -879,14 +907,14 @@ 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 ( - {(dnd) => } + {(dnd) => } ) @@ -894,12 +922,12 @@ export default function KanbanBoard({ columns, onCardMove, onCardClick, classNam return ( - + ) } -function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, quickAdd, onQuickAdd, coverImageField: _coverImageField, conditionalFormatting, objectFields, swimlaneField, countsAreWindowed }: KanbanBoardProps & { dnd: ReturnType | null }) { +function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, quickAdd, onQuickAdd, coverImageField: _coverImageField, conditionalFormatting, objectFields, swimlaneField, countsAreWindowed, canDropCard }: KanbanBoardProps & { dnd: ReturnType | null }) { const { t } = useKanbanT() const [activeCard, setActiveCard] = React.useState(null) @@ -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(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) { @@ -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() @@ -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). // @@ -1334,6 +1396,7 @@ function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, qu collisionDetection={closestCorners} onDragStart={handleDragStart} onDragEnd={handleDragEnd} + onDragCancel={handleDragCancel} accessibility={dndAccessibility} >
@@ -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 (
{!collapsed && ( c.id)} strategy={verticalListSortingStrategy}> @@ -1602,6 +1668,7 @@ function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, qu ({ + onDragStart: undefined as undefined | ((event: unknown) => void), + onDragEnd: undefined as undefined | ((event: unknown) => void), + onDragCancel: undefined as undefined | ((event: unknown) => void), +})); + +vi.mock('@dnd-kit/core', async (importOriginal) => { + const actual = await importOriginal(); + const ReactMod = await import('react'); + const CapturingDndContext = (props: Record) => { + dnd.onDragStart = props.onDragStart as (event: unknown) => void; + dnd.onDragEnd = props.onDragEnd as (event: unknown) => void; + dnd.onDragCancel = props.onDragCancel as (event: unknown) => void; + return ReactMod.createElement(actual.DndContext, props as never); + }; + return { ...actual, DndContext: CapturingDndContext }; +}); + +registerAllFields(); + +const CARD = 'Fix the widget'; + +/** The showcase task's machine, as `/meta` serves it (update-only, severity at the default). */ +const objectDef = (rule: Record = {}) => ({ + name: 'task', + fields: { + title: { type: 'text', label: 'Title' }, + status: { + type: 'select', + label: 'Status', + options: [ + { value: 'backlog', label: 'Backlog' }, + { value: 'todo', label: 'To Do' }, + { value: 'done', label: 'Done' }, + ], + }, + }, + validations: [ + { + type: 'state_machine', + name: 'task_status_flow', + field: 'status', + events: ['update'], + transitions: { backlog: ['todo'], todo: ['done', 'backlog'], done: ['todo'] }, + ...rule, + }, + ], +}); + +const schema = { + type: 'object-kanban', + objectName: 'task', + groupBy: 'status', + cardTitle: 'title', + columns: [ + { id: 'backlog', title: 'Backlog' }, + { id: 'todo', title: 'To Do' }, + { id: 'done', title: 'Done' }, + ], +} satisfies ObjectKanbanSchema; + +function makeDataSource(def: unknown, update: DataSource['update']): DataSource { + return { + getObjectSchema: vi.fn(async () => def), + find: vi.fn(async () => ({ value: [{ id: 't1', title: CARD, status: 'backlog' }] })), + update, + } as unknown as DataSource; +} + +function cardsIn(columnTitle: string): string[] { + const list = screen.getByRole('list', { name: `${columnTitle} cards` }); + return within(list) + .queryAllByRole('listitem') + .map((el) => el.getAttribute('aria-label') ?? ''); +} + +/** The lanes drawn dimmed right now, by title. */ +function blockedLanes(): string[] { + return ['Backlog', 'To Do', 'Done'].filter((title) => + screen.getByRole('group', { name: title }).hasAttribute('data-kanban-drop-blocked'), + ); +} + +async function mountBoard(def: unknown, update: DataSource['update']) { + render(); + expect(await screen.findByText(CARD)).toBeInTheDocument(); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + await waitFor(() => expect(cardsIn('Backlog')).toEqual([CARD])); +} + +async function startDrag() { + expect(dnd.onDragStart).toBeTypeOf('function'); + await act(async () => { + dnd.onDragStart!({ active: { id: 't1' } }); + }); +} + +async function dropOn(columnId: string) { + await act(async () => { + dnd.onDragEnd!({ active: { id: 't1' }, over: { id: columnId } }); + }); +} + +beforeEach(() => { + dnd.onDragStart = undefined; + dnd.onDragEnd = undefined; + dnd.onDragCancel = undefined; + vi.spyOn(toast, 'error').mockImplementation(() => 'toast-id' as never); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('ObjectKanban — the state-machine drop gate (#25933)', () => { + it('dims only the lanes a dragged backlog card may not enter', async () => { + await mountBoard(objectDef(), vi.fn() as never); + expect(blockedLanes()).toEqual([]); + + await startDrag(); + + expect(blockedLanes()).toEqual(['Done']); + }); + + it('refuses a drop into a closed lane before any write', async () => { + const update = vi.fn(async () => ({ id: 't1', status: 'done' })); + await mountBoard(objectDef(), update as never); + + await startDrag(); + await dropOn('done'); + + expect(update).not.toHaveBeenCalled(); + expect(toast.error).not.toHaveBeenCalled(); + expect(cardsIn('Backlog')).toEqual([CARD]); + expect(cardsIn('Done')).toEqual([]); + // The drag is over: no lane stays dimmed. + expect(blockedLanes()).toEqual([]); + }); + + it('writes a drop into an open lane, as before', async () => { + const update = vi.fn(async () => ({ id: 't1', status: 'todo' })); + await mountBoard(objectDef(), update as never); + + await startDrag(); + await dropOn('todo'); + + expect(update).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledWith('task', 't1', { status: 'todo' }); + expect(cardsIn('To Do')).toEqual([CARD]); + }); + + it('puts the lanes back when the drag is cancelled', async () => { + await mountBoard(objectDef(), vi.fn() as never); + + await startDrag(); + expect(blockedLanes()).toEqual(['Done']); + + await act(async () => { + dnd.onDragCancel!({ active: { id: 't1' } }); + }); + + expect(blockedLanes()).toEqual([]); + }); + + it('CONTROL: a warning-severity machine closes no lane, and the drop is written', async () => { + const update = vi.fn(async () => ({ id: 't1', status: 'done' })); + await mountBoard(objectDef({ severity: 'warning' }), update as never); + + await startDrag(); + expect(blockedLanes()).toEqual([]); + await dropOn('done'); + + expect(update).toHaveBeenCalledTimes(1); + }); + + it('CONTROL: a machine on a field the board does not group by closes no lane', async () => { + const update = vi.fn(async () => ({ id: 't1', status: 'done' })); + await mountBoard(objectDef({ field: 'phase' }), update as never); + + await startDrag(); + expect(blockedLanes()).toEqual([]); + await dropOn('done'); + + expect(update).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/packages/plugin-kanban/src/ObjectKanban.tsx b/ui/packages/plugin-kanban/src/ObjectKanban.tsx index 045b5eed220..268d35043ba 100644 --- a/ui/packages/plugin-kanban/src/ObjectKanban.tsx +++ b/ui/packages/plugin-kanban/src/ObjectKanban.tsx @@ -44,6 +44,7 @@ import { resolveNameField, resolveAffordance, isDeadlineSettled, + stateMachineNextValues, type SchemaLike, } from '@object-ui/core'; import { getBadgeColorClasses, getBadgeHexAppearance, getCellRenderer, resolveCellRendererType } from '@object-ui/fields'; @@ -1805,6 +1806,25 @@ export const ObjectKanban: React.FC = ({ perms, }).allowed; + // The board's drop gate (#25933): a lane is a value of `groupBy`, so when the + // object's blocking `state_machine` rule governs that same field, a card may + // enter only the lanes its column's value may move to. The read is the one + // the grid's inline editor and the record form use (`stateMachineNextValues` + // in `@object-ui/core`), so a warning-only, inactive or insert-only machine + // closes nothing, and neither does an undeclared from-state. The + // "Uncategorized" lane is the mover's own business (it writes nothing for a + // drop INTO it), so the gate leaves it open. The server still refuses an + // invalid move and `handleCardMove`'s rollback stays. + const canDropCard = React.useMemo(() => { + const groupBy = schema.groupBy; + if (!groupBy || !objectDef) return undefined; + return (fromColumnId: string, toColumnId: string): boolean => { + if (toColumnId === KANBAN_UNCOLUMNED_ID) return true; + const reachable = stateMachineNextValues(objectDef, groupBy, fromColumnId); + return reachable === null || reachable.has(String(toColumnId)); + }; + }, [schema.groupBy, objectDef]); + // Pre-evaluate the target column's `requiredWhen` predicates and collect what // the move makes required BEFORE writing anything (objectui#4254). The board // used to PATCH the column value alone into a refusal the user could neither @@ -2011,6 +2031,8 @@ export const ObjectKanban: React.FC = ({ // and the cards are drawn not movable otherwise (see `cardMoveGranted`). onCardMove={cardMoveGranted ? handleCardMove : undefined} cardsMovable={cardMoveGranted} + // #25933: the lanes a dragged card may enter — see `canDropCard`. + canDropCard={canDropCard} schema={{ ...effectiveSchema, // `5591f03bd` — the lane headers count rows that came back, so when