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); + }); +});