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
22 changes: 22 additions & 0 deletions .changeset/kanban-8826-order-not-claimed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@object-ui/plugin-kanban': patch
---

fix(plugin-kanban): the board stops showing a card order it persists nowhere

Dragging a card **within its own column** used to reorder the board on the spot.
Nothing recorded that order — the same-column branch notified no one, the mover
returns early when the source and target columns are equal, and the write body
carries the grouped field and no positional key — so the next data round-trip
(a refetch, a poll, any reflow from the host) silently put the card back. The
user performed an action, watched it succeed, and then watched it disappear with
no error, no toast and no rollback animation.

A card dropped inside its own column now returns to its position. On a
cross-column move the column change still happens and is still persisted; the
landing **position** is no longer claimed — the card lands where the data order
puts it rather than in the slot it was released over, on both data ownerships.

Order persistence is not a capability of this board: there is no declared
ordering slot in `@objectstack/spec` for one to be written to. Ruled on
objectui#8826.
4 changes: 3 additions & 1 deletion packages/components/src/renderers/complex/README-KANBAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ A fully functional, schema-driven Kanban board component for Object UI with drag
- **Multiple Columns**: Create unlimited columns with customizable titles
- **Rich Cards**: Cards support title, description, and multiple badges
- **Drag & Drop**: Smooth drag-and-drop functionality powered by @dnd-kit
- **Reordering**: Reorder cards within the same column
- **Reordering**: ⛔ Not supported — card order is persisted nowhere, so a card
dropped inside its own column returns to its position rather than showing a
reorder the next data round-trip would silently undo (objectui#8826)
- **Cross-Column Moves**: Move cards between different columns
- **Column Limits**: Optional capacity limits with visual indicators
- **Card Counters**: Shows current count and limit per column
Expand Down
8 changes: 7 additions & 1 deletion packages/plugin-kanban/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,13 @@ interface KanbanCard {

## Features

- **Drag and Drop**: Drag cards between columns or reorder within a column
- **Drag and Drop**: Drag cards between columns — the column change is persisted.
⛔ **Not in-column ordering.** A card dropped inside its own column returns to
its position: card order is persisted nowhere, there is no ordering slot in
`@objectstack/spec` to write it to, and a board that animated the reorder
anyway would be showing a success the next data round-trip silently undoes
(objectui#8826). The landing position of a cross-column move is not claimed
either — the card lands where the data order puts it.
- **Column Limits**: Set maximum card limits and get visual feedback when full
- **Card Badges**: Add colored badges to cards for status/priority
- **Responsive**: Horizontal scrolling for many columns
Expand Down
62 changes: 42 additions & 20 deletions packages/plugin-kanban/src/KanbanImpl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import {
} from "@dnd-kit/core"
import {
SortableContext,
arrayMove,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable"
Expand Down Expand Up @@ -597,13 +596,18 @@ function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, qu
}));
}, [columns]);

// The board keeps its own copy of the columns, and that mirror STAYS: the drag
// path writes it optimistically (`handleDragEnd` below), and the `columns`
// prop never carries card ORDER back. A same-column reorder notifies nobody —
// `handleDragEnd` takes its local branch without calling `onCardMove` — and
// `ObjectKanban.handleCardMove` early-returns on `fromColumnId === toColumnId`
// and discards `newIndex` outright. Order truth is local-only, so deriving
// this away would roll a committed reorder back on the next prop change.
// The board keeps its own copy of the columns, and that mirror STAYS: the
// cross-column drag path writes it optimistically (`handleDragEnd` below), so
// a card the user moved between columns is in its new column on screen before
// any data round-trip carries that membership back — and on the ownership
// where a parent owns the records, nothing re-renders this board until that
// parent reflows. Deriving the mirror away would strand such a move.
//
// ⚠️ What the mirror no longer holds is card ORDER (objectui#8826). The
// `columns` prop never carried order back, so an optimistic reorder was a
// claim nothing could keep; the same-column branch below now leaves the
// mirror alone, and the cross-column branch appends rather than honouring the
// slot the card was dropped in. Order is whatever the data round-trip says.
//
// What does NOT stay is syncing it from a passive effect. An effect runs after
// commit, so every prop-driven column change reached the DOM one commit late:
Expand Down Expand Up @@ -701,17 +705,24 @@ function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, qu
}

if (activeColumn.id === overColumn.id) {
// Same column reordering
const cards = [...activeColumn.cards]
const oldIndex = cards.findIndex((c) => c.id === activeId)
const newIndex = cards.findIndex((c) => c.id === overId)

const newCards = arrayMove(cards, oldIndex, newIndex)
setBoardColumns((prev) =>
prev.map((col) =>
col.id === activeColumn.id ? { ...col, cards: newCards } : col
)
)
// Same-column drop: the board does NOTHING, deliberately (objectui#8826).
//
// Card ORDER is persisted nowhere. This branch notifies nobody, the
// `onCardMove` consumer this board is given (`ObjectKanban`'s mover)
// returns early when the source and target columns are equal, and the
// write it would otherwise send carries the grouped field and no
// positional key. There is no declared ordering slot in
// `@objectstack/spec` to write one to.
//
// An optimistic `arrayMove` here therefore showed the user a success the
// board cannot keep: the reorder landed on screen, nothing recorded it,
// and the next prop change — a refetch, a poll, any data round-trip —
// re-synced this mirror from `columns` and silently put the card back.
// A drop that visibly returns the card to its position is duller and
// TRUE: nothing is persisted, so nothing is shown as persisted.
//
// ⛔ Do not restore the reorder without an ordering slot to write it to.
// Ruled on objectui#8826 (letter B); a spec card is that slot's entry.
} else {
// Moving between columns
const activeCards = [...activeColumn.cards]
Expand All @@ -725,7 +736,18 @@ function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, qu
: overCards.findIndex((c) => c.id === overId)

const [movedCard] = activeCards.splice(activeIndex, 1)
overCards.splice(overIndex, 0, movedCard)
// Membership moves; the landing POSITION is not claimed (objectui#8826).
// `overIndex` is where the pointer was released. It is still handed to
// `onCardMove` below — the prop's contract is unchanged — and the
// consumer still discards it, so nothing persists it. Splicing the card
// in at that slot painted a position the next data round-trip cannot
// reproduce: the board re-buckets in record order, and on the ownership
// that re-renders it snapped out of the dropped slot within the same
// commit, while on the ownership that does not it sat in a slot no
// reload would ever reproduce. Appending claims only what the board can
// say for itself — the card is in this column now — and leaves the
// position to the round-trip, which is the only thing that decides it.
overCards.push(movedCard)

setBoardColumns((prev) =>
prev.map((col) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,20 @@
*
* ── Why the mirror is still there ─────────────────────────────────────────
* Deriving `boardColumns` away is NOT available, and this file's second test is
* the reason. The drag path writes the mirror optimistically, and the `columns`
* prop never carries card ORDER back: `handleDragEnd`'s same-column branch
* calls no callback at all, and `ObjectKanban.handleCardMove` early-returns on
* `fromColumnId === toColumnId` and discards `newIndex`. Order truth is local.
* A `useMemo`-derived board would roll a committed reorder back on the next
* prop change — which is why test 2 is a REQUIRED control, not a nicety: it
* fails any "fix" that deletes the mirror.
* the reason. The CROSS-column drag path writes the mirror optimistically, and
* on the ownership where a parent owns the records nothing re-renders this
* board at all until that parent reflows — so a `useMemo`-derived board would
* drop the user's move on the floor until then. That is why test 2 is a
* REQUIRED control, not a nicety: it fails any "fix" that deletes the mirror.
*
* ⚠️ Test 2 used to defend the mirror with a SAME-column reorder instead, and
* that is no longer the behaviour: objectui#8826 ruled that an order which is
* persisted nowhere may not be shown as persisted, so the same-column branch
* leaves the mirror alone and the cross-column branch appends rather than
* honouring the dropped slot. The mirror's remaining job is MEMBERSHIP, which
* is what this control now drives. The ruled behaviour itself is pinned in
* `sameColumnDropIsNotClaimed-8826.test.tsx`, not here — this file is #8534's,
* and its subject is still the commit the prop reaches the DOM in.
*
* ── How "one commit late" is observable without layout ────────────────────
* happy-dom performs no layout, but it does COMMIT. The harness below owns the
Expand Down Expand Up @@ -175,19 +182,37 @@ describe('KanbanImpl — prop-driven columns reach the DOM in the same commit (#
expect(firstWithHeaders!.cards).toEqual(['Alpha', 'Beta', 'Gamma']);
});

it('REQUIRED CONTROL: an optimistic same-column reorder shows the new order immediately', () => {
// Order truth is LOCAL — nothing pushes it back through the prop. Any fix
// that derives `boardColumns` from the prop fails here.
it('REQUIRED CONTROL: an optimistic cross-column move shows the new membership immediately', () => {
// MEMBERSHIP truth is local until a round-trip carries it back, and this
// board is mounted with a plain `columns` prop that nothing updates — the
// shape of the ownership where a parent owns the records. Any fix that
// derives `boardColumns` from the prop fails here.
mountHarness(POPULATED);
expect(cardsIn('Backlog')).toEqual(['Alpha', 'Beta']);

expect(dnd.onDragEnd).toBeTypeOf('function');
act(() => {
dnd.onDragEnd!({ active: { id: 't1' }, over: { id: 't2' } });
dnd.onDragEnd!({ active: { id: 't1' }, over: { id: 't3' } });
});

expect(cardsIn('Backlog')).toEqual(['Beta', 'Alpha']);
expect(cardsIn('Backlog')).toEqual(['Beta']);
// ...and it must survive the re-render that follows, not flash back.
expect(cardsIn('In Progress')).toEqual(['Gamma', 'Alpha']);
});

it('REQUIRED CONTROL: a same-column drop changes nothing — the order is the DATA order (#8826)', () => {
// The sibling of the control above, and the reason it had to change: the
// drop below asks for the reverse order and gets none of it. Pinned in
// full, on both data ownerships, in `sameColumnDropIsNotClaimed-8826`.
mountHarness(POPULATED);
expect(cardsIn('Backlog')).toEqual(['Alpha', 'Beta']);

expect(dnd.onDragEnd).toBeTypeOf('function');
act(() => {
dnd.onDragEnd!({ active: { id: 't1' }, over: { id: 't2' } });
});

expect(cardsIn('Backlog')).toEqual(['Alpha', 'Beta']);
expect(cardsIn('In Progress')).toEqual(['Gamma']);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,23 @@
* ⇒ The position is lost on BOTH paths. They differ only in WHEN the user sees
* it happen.
*
* ── ⭐ WHAT THIS FILE ANNOUNCED, AND WHAT WAS THEN RULED ───────────────────
* The paragraph above is the reading this file was written to freeze, and the
* maintainer ruled on it: objectui#8826 letter B — the board stops claiming a
* capability it does not have. Two of the assertions below moved as a result,
* and this note is the announcement the file exists to make:
* - the EXTERNAL-path asymmetry no longer leaves the card in the slot it was
* dropped in. The cross-column branch appends instead of splicing at the
* drop index, so the board claims membership and says nothing about
* position. ⚠️ In THIS fixture appended and record order coincide (`Alpha`
* is last in the record array), so what the assertion distinguishes is the
* dropped slot from everything else — not "appended" from "record order".
* - the internal-path snap is unchanged in outcome and no longer a snap: the
* card never occupies the dropped slot to begin with.
* The wire, both controls and the record-order reading are untouched, and
* still say what they said. The ruled behaviour is pinned in
* `sameColumnDropIsNotClaimed-8826.test.tsx`.
*
* ── The instrument ────────────────────────────────────────────────────────
* The card's recorder: the `columns` prop the board receives, captured on every
* render, via a module mock of `../KanbanImpl` that records and then renders the
Expand Down Expand Up @@ -280,27 +297,37 @@ describe('ObjectKanban — a cross-column drop loses the landing position too (#
expect(recorder.snapshots.slice(before)).toEqual([RECORD_ORDER]);
});

it('THE MEASUREMENT (internal data): the card visibly leaves the dropped slot in the same act', async () => {
it('THE MEASUREMENT (internal data): the card is never in the dropped slot', async () => {
await mountBoard('internal', makeDataSource(acceptingUpdate() as never));

await dropAlphaOntoXray();

// Dropped at slot 0; on screen at slot 2. The optimistic write re-renders
// the board, which re-buckets and re-syncs the mirror from the prop.
// Dropped at slot 0; on screen at slot 2. Two things agree on that now:
// the branch appends rather than honouring the drop index, and the
// optimistic write re-renders the board, which re-buckets and re-syncs the
// mirror from the prop. Before objectui#8826 this was a visible snap out of
// slot 0 inside this same act.
expect(cardsIn('In Progress')).toEqual(['Xray', 'Yankee', 'Alpha']);
expect(cardsIn('Backlog')).toEqual([]);
});

it('THE ASYMMETRY (external data): no snapshot at all, so the dropped slot survives on screen', async () => {
it('THE ASYMMETRY (external data): still no snapshot at all — and the dropped slot is no longer claimed', async () => {
const before = await mountBoard('external', makeDataSource(acceptingUpdate() as never));

await dropAlphaOntoXray();

// The optimistic write is skipped on this ownership, so nothing re-renders
// and `KanbanImpl`'s mirror is never reset — the local move stands.
// and `KanbanImpl`'s mirror is never reset — the local move stands, and on
// this path it is the ONLY thing the user sees until the parent reflows.
// That is exactly why it may not encode the drop index: this board would
// otherwise hold a position no reload reproduces, indefinitely.
expect(recorder.snapshots.slice(before)).toEqual([]);
expect(cardsIn('In Progress')).toEqual(['Alpha', 'Xray', 'Yankee']);
// ...and it is still not persisted anywhere: the next reflow from the
// ⚠️ `Alpha` is last in the record array, so appended and record order are
// the same list here. What this distinguishes is the DROPPED slot (`Alpha`
// first) from everything else — which is the claim that was removed.
expect(cardsIn('In Progress')).toEqual(['Xray', 'Yankee', 'Alpha']);
expect(cardsIn('In Progress')).not.toEqual(['Alpha', 'Xray', 'Yankee']);
// ...and the order is still persisted nowhere: the next reflow from the
// parent will carry membership and not order.
});

Expand Down
Loading
Loading