From 1f1ec5614a30296b5cab911ad5cce6cb3b57aadd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 16:28:41 +0000 Subject: [PATCH] fix(plugin-kanban): stop showing a card order that is persisted nowhere (objectui#8826) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dragging a card inside its own column reordered the board on the spot and recorded that order nowhere: 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. The next data round-trip re-synced the mirror from `columns` and put the card back, with no error, no toast and no rollback animation — a success the user watched happen and then watched disappear. Ruled letter B: the board stops claiming a capability it does not have. - same-column drop: no optimistic reorder; the card returns to its position. - cross-column move: the membership change stays and is still persisted; the landing index is still handed to `onCardMove` and still discarded there, but it is no longer painted — the branch appends instead of splicing at the dropped slot, so no position is claimed that the round-trip cannot reproduce. Letter A (persist the order) was refused as a STARTING POINT, not on the merits: there is no declared ordering slot in `@objectstack/spec` to write one to, and inventing a UI-local one is Prime Directive 5's workaround. Letter C (document the false success) was refused as formalising it — the two README lines that advertised in-column reordering say what is true instead. Pins, both legs, in `sameColumnDropIsNotClaimed-8826.test.tsx`: a same-column drop leaves DOM order equal to the data order on both data ownerships, and the lit control shows a cross-column move still emitting the new membership snapshot and still reaching the wire. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_012EpHzwH4wTy5sd7ibkD2yq --- .changeset/kanban-8826-order-not-claimed.md | 22 ++ .../src/renderers/complex/README-KANBAN.md | 4 +- packages/plugin-kanban/README.md | 8 +- packages/plugin-kanban/src/KanbanImpl.tsx | 62 ++-- .../columnsReachDomSameCommit-8534.test.tsx | 49 +++- .../crossColumnLandingPosition-8826.test.tsx | 41 ++- .../sameColumnDropIsNotClaimed-8826.test.tsx | 272 ++++++++++++++++++ 7 files changed, 417 insertions(+), 41 deletions(-) create mode 100644 .changeset/kanban-8826-order-not-claimed.md create mode 100644 packages/plugin-kanban/src/__tests__/sameColumnDropIsNotClaimed-8826.test.tsx diff --git a/.changeset/kanban-8826-order-not-claimed.md b/.changeset/kanban-8826-order-not-claimed.md new file mode 100644 index 0000000000..674152d795 --- /dev/null +++ b/.changeset/kanban-8826-order-not-claimed.md @@ -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. diff --git a/packages/components/src/renderers/complex/README-KANBAN.md b/packages/components/src/renderers/complex/README-KANBAN.md index 5f4b653fb1..b4525257e9 100644 --- a/packages/components/src/renderers/complex/README-KANBAN.md +++ b/packages/components/src/renderers/complex/README-KANBAN.md @@ -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 diff --git a/packages/plugin-kanban/README.md b/packages/plugin-kanban/README.md index 690e04d065..e8c0d7cad8 100644 --- a/packages/plugin-kanban/README.md +++ b/packages/plugin-kanban/README.md @@ -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 diff --git a/packages/plugin-kanban/src/KanbanImpl.tsx b/packages/plugin-kanban/src/KanbanImpl.tsx index 7b505b9032..5cd6f3b0ea 100644 --- a/packages/plugin-kanban/src/KanbanImpl.tsx +++ b/packages/plugin-kanban/src/KanbanImpl.tsx @@ -20,7 +20,6 @@ import { } from "@dnd-kit/core" import { SortableContext, - arrayMove, useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable" @@ -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: @@ -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] @@ -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) => { diff --git a/packages/plugin-kanban/src/__tests__/columnsReachDomSameCommit-8534.test.tsx b/packages/plugin-kanban/src/__tests__/columnsReachDomSameCommit-8534.test.tsx index ba94675451..468aae08e2 100644 --- a/packages/plugin-kanban/src/__tests__/columnsReachDomSameCommit-8534.test.tsx +++ b/packages/plugin-kanban/src/__tests__/columnsReachDomSameCommit-8534.test.tsx @@ -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 @@ -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']); }); diff --git a/packages/plugin-kanban/src/__tests__/crossColumnLandingPosition-8826.test.tsx b/packages/plugin-kanban/src/__tests__/crossColumnLandingPosition-8826.test.tsx index 132bec799f..5923cb417d 100644 --- a/packages/plugin-kanban/src/__tests__/crossColumnLandingPosition-8826.test.tsx +++ b/packages/plugin-kanban/src/__tests__/crossColumnLandingPosition-8826.test.tsx @@ -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 @@ -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. }); diff --git a/packages/plugin-kanban/src/__tests__/sameColumnDropIsNotClaimed-8826.test.tsx b/packages/plugin-kanban/src/__tests__/sameColumnDropIsNotClaimed-8826.test.tsx new file mode 100644 index 0000000000..9651e3c214 --- /dev/null +++ b/packages/plugin-kanban/src/__tests__/sameColumnDropIsNotClaimed-8826.test.tsx @@ -0,0 +1,272 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The board stops claiming an order it cannot keep — objectui#8826, letter B. + * + * ── What was ruled ──────────────────────────────────────────────────────── + * objectui#8826 measured that a SAME-column reorder is persisted nowhere: the + * card moved on screen, nothing recorded it, and the next data round-trip put + * it back without a word. Its sibling measurement (question 3, pinned in + * `crossColumnLandingPosition-8826.test.tsx`) found the landing POSITION of a + * CROSS-column move is lost the same way, on both data ownerships. + * + * The maintainer ruled letter B: the board stops claiming a capability it does + * not have. Not letter A (persist the order) — there is no declared ordering + * slot in `@objectstack/spec` to write one to, and inventing a UI-local one is + * the workaround Prime Directive 5 forbids. Not letter C (document the false + * success) — documenting it formalises it. + * + * ⇒ Same-column drop: no optimistic reorder. The card returns to its position. + * ⇒ Cross-column move: the membership change stays — it IS persisted — and the + * landing position is not claimed. + * + * ── The two legs, and why BOTH are required ─────────────────────────────── + * A pin that only witnessed "nothing moved" would be green on a board whose + * drag path was broken outright, which is a far worse product than the one + * being repaired. So the second leg is not a bonus: it is the LIT CONTROL that + * proves this instrument drives a real drop, and that the capability the board + * DOES have still works and still reaches the wire. + * + * LEG 1 — a same-column drop leaves DOM order equal to the DATA order, on + * both ownerships, and reaches no `update`. + * LEG 2 — a cross-column drop still emits a new `columns` snapshot carrying + * the membership change, and still writes it. + * + * ── The instrument ──────────────────────────────────────────────────────── + * The recorder objectui#8826 was measured with, reused rather than re-invented: + * a module mock of `../KanbanImpl` that records the `columns` prop on every + * render and then renders the real board, plus a module mock of `@dnd-kit/core` + * that captures the board's real `onDragEnd`. dnd-kit's pointer sensors need + * layout and pointer capture that happy-dom does not provide, so a synthesized + * drop on the production handler is the closest honest reproduction; everything + * downstream of that call is the real path. + * + * ── The fixture is built so "did not move" is distinguishable ────────────── + * `Backlog` holds two cards, and the drop asks for the reverse of the order the + * records are in. A single-card column, or a drop onto the slot the card is + * already in, would read green whether the reorder was removed or not. + */ + +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, within, act, cleanup, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { registerAllFields } from '@object-ui/fields'; +import { toast } from '@object-ui/components'; +import type { DataSource, ObjectKanbanSchema } from '@object-ui/types'; +import { ObjectKanban } from '../ObjectKanban'; + +// Pay the board's lazy chunk at import time rather than racing it against a +// `findBy` budget (AGENTS.md 测试纪律); specifier resolves to the same module as +// `./KanbanImpl` in `../index`, so the `React.lazy` factory hits the cache. +import '../KanbanImpl'; + +// `vi.hoisted` so the mock factories — hoisted above every import — can reach +// these boxes. A plain `const` would still be in its TDZ when the mocked +// modules are first requested. +const dnd = vi.hoisted(() => ({ + onDragEnd: undefined as undefined | ((event: unknown) => void), +})); +const recorder = vi.hoisted(() => ({ snapshots: [] as string[] })); + +// Capture the board's real `onDragEnd` while still rendering the real provider, +// so `@dnd-kit/sortable`'s hooks keep reading the same context instance. +vi.mock('@dnd-kit/core', async (importOriginal) => { + const actual = await importOriginal(); + const ReactMod = await import('react'); + const CapturingDndContext = (props: Record) => { + dnd.onDragEnd = props.onDragEnd as (event: unknown) => void; + return ReactMod.createElement(actual.DndContext, props as never); + }; + return { ...actual, DndContext: CapturingDndContext }; +}); + +// THE INSTRUMENT: record the `columns` prop on every render of the board, then +// render the real board with it. Recording in the component body (not an +// effect) is deliberate — it is the prop AS DELIVERED that is under test. +vi.mock('../KanbanImpl', async (importOriginal) => { + const actual = await importOriginal(); + const ReactMod = await import('react'); + const RecordingKanbanBoard = (props: Record) => { + const cols = (props.columns as Array> | undefined) ?? []; + recorder.snapshots.push( + cols + .map((c) => { + const cards = (c.cards as Array> | undefined) ?? []; + return `${String(c.id)}:${cards.map((k) => String(k.title)).join(',')}`; + }) + .join(' | '), + ); + return ReactMod.createElement(actual.default as never, props as never); + }; + return { ...actual, default: RecordingKanbanBoard }; +}); + +registerAllFields(); + +const objectDef = { + name: 'task', + fields: { + title: { type: 'text', label: 'Title' }, + status: { + type: 'picklist', + label: 'Status', + options: [ + { value: 'backlog', label: 'Backlog' }, + { value: 'in_progress', label: 'In Progress' }, + ], + }, + }, +}; + +/** Record order IS the board's order: `bucketCardsIntoColumns` preserves it. */ +const RECORDS = () => [ + { id: 'a', title: 'Alpha', status: 'backlog' }, + { id: 'b', title: 'Beta', status: 'backlog' }, + { id: 'g', title: 'Gamma', status: 'in_progress' }, +]; + +/** What the data says, at mount and after any honest same-column drop. */ +const DATA_ORDER = 'backlog:Alpha,Beta | in_progress:Gamma'; +/** What the same-column drop below ASKS for. Must never be shown. */ +const DROP_ORDER = 'backlog:Beta,Alpha | in_progress:Gamma'; + +const schema = { + type: 'object-kanban', + objectName: 'task', + groupBy: 'status', + cardTitle: 'title', + columns: [ + { id: 'backlog', title: 'Backlog' }, + { id: 'in_progress', title: 'In Progress' }, + ], +} satisfies ObjectKanbanSchema; + +function makeDataSource(update: DataSource['update']): DataSource { + return { + getObjectSchema: vi.fn(async () => objectDef), + find: vi.fn(async () => ({ value: RECORDS() })), + update, + } as unknown as DataSource; +} + +/** The cards currently rendered inside a column, by the column's visible title. */ +function cardsIn(columnTitle: string): string[] { + const list = screen.getByRole('list', { name: `${columnTitle} cards` }); + return within(list) + .queryAllByRole('listitem') + .map((el) => el.getAttribute('aria-label') ?? ''); +} + +const acceptingUpdate = () => vi.fn(async () => ({ id: 'a', status: 'in_progress' })); + +/** + * Mount on one of the two data ownerships and wait until it has settled: the + * object-schema fetch and (internal path) the record fetch both land as async + * state updates, and a stray one arriving AFTER the drag would re-render the + * board and add a snapshot that belongs to neither reading. + */ +async function mountBoard(mode: 'external' | 'internal', dataSource: DataSource) { + render( + , + ); + expect(await screen.findByText('Alpha')).toBeInTheDocument(); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + await waitFor(() => expect(cardsIn('Backlog')).toEqual(['Alpha', 'Beta'])); + expect(recorder.snapshots[recorder.snapshots.length - 1]).toBe(DATA_ORDER); + return recorder.snapshots.length; +} + +/** Drop `Beta` onto `Alpha` — a SAME-column reorder asking for the reverse. */ +async function dropBetaOntoAlpha() { + expect(dnd.onDragEnd).toBeTypeOf('function'); + await act(async () => { + dnd.onDragEnd!({ active: { id: 'b' }, over: { id: 'a' } }); + }); +} + +/** Drop `Alpha` onto `Gamma` — a CROSS-column move, the capability that stays. */ +async function dropAlphaOntoGamma() { + expect(dnd.onDragEnd).toBeTypeOf('function'); + await act(async () => { + dnd.onDragEnd!({ active: { id: 'a' }, over: { id: 'g' } }); + }); +} + +beforeEach(() => { + dnd.onDragEnd = undefined; + recorder.snapshots.length = 0; + vi.spyOn(toast, 'error').mockImplementation(() => 'toast-id' as never); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('ObjectKanban — an unpersistable order is no longer shown as persisted (#8826)', () => { + for (const mode of ['internal', 'external'] as const) { + it(`LEG 1 (${mode} data): a same-column drop leaves DOM order equal to the DATA order`, async () => { + const update = acceptingUpdate(); + const before = await mountBoard(mode, makeDataSource(update as never)); + + await dropBetaOntoAlpha(); + + // The ruling, as an assertion: the card returns to its position. + expect(cardsIn('Backlog')).toEqual(['Alpha', 'Beta']); + expect(cardsIn('In Progress')).toEqual(['Gamma']); + // ...and nothing anywhere ever saw the order the user dropped in. + expect(recorder.snapshots).not.toContain(DROP_ORDER); + expect(recorder.snapshots.slice(before)).toEqual([]); + // No write, because there is nothing to write it to. + expect(update).not.toHaveBeenCalled(); + }); + } + + it('LEG 2 — LIT CONTROL: a cross-column move still emits the new membership snapshot, and still writes it', async () => { + // Without this leg, LEG 1 would be green on a board whose drag path was + // broken outright. It is the same synthesized drop, on the same instrument. + const update = acceptingUpdate(); + const before = await mountBoard('internal', makeDataSource(update as never)); + + await dropAlphaOntoGamma(); + + const fresh = recorder.snapshots.slice(before); + expect(fresh.length).toBeGreaterThan(0); + const latest = fresh[fresh.length - 1]; + expect(latest).not.toBe(DATA_ORDER); + // Membership DID flow back: `Alpha` left `backlog` and is in `in_progress`. + expect(latest).toMatch(/^backlog:Beta \| in_progress:/); + expect(latest).toContain('Alpha'); + // And it reached the wire: exact body, so an added positional key fails here. + expect(update).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledWith('task', 'a', { status: 'in_progress' }); + }); + + it('LEG 2 — LIT CONTROL (external data): the membership change is on screen without any prop reflow', async () => { + // The ownership where nothing re-renders the board: the local mirror is the + // only thing that can show the move, and it still does. This is also the + // control that fails any "fix" which deletes that mirror. + const update = acceptingUpdate(); + await mountBoard('external', makeDataSource(update as never)); + + await dropAlphaOntoGamma(); + + expect(cardsIn('Backlog')).toEqual(['Beta']); + expect(cardsIn('In Progress')).toContain('Alpha'); + expect(update).toHaveBeenCalledTimes(1); + }); +});