upcomingDrop.test.ts13.6 KBView on GitHub import {
MISC_KEY,
UPCOMING_KEY,
insertionIndexForY,
planTaskDrop,
sortableCardId,
type DroppableTask,
} from '@/modules/userTasks/utils/task-drop-plan';
import type { DroppableColumn } from '@/modules/userTasks/utils/task-order';
/**
* What a board drop means, before anything is written.
*
* The Upcoming lane is the interesting half. It is the only column whose membership is DERIVED
* (from `dueDate`) rather than stored, which is what makes a drop onto it a question — "until
* when?" — instead of a write, and what makes dismissing that question need no rollback: the card
* never moved, because nothing that decides where it renders ever changed.
*/
const card = (id: string, over: Partial<DroppableTask> = {}): DroppableTask => ({
id,
sortOrder: 1,
taskGroupId: 'g1',
dueDate: '2026-08-20T09:00:00Z',
...over,
});
const NOW = new Date('2026-08-30T12:00:00Z');
function board(over: {
sales?: DroppableTask[];
misc?: DroppableTask[];
upcoming?: DroppableTask[];
}): DroppableColumn<DroppableTask>[] {
return [
{ key: 'g1', droppableId: 'g1', tasks: over.sales ?? [] },
{ key=[redacted], droppableId: MISC_KEY, tasks: over.misc ?? [] },
{ key=[redacted], droppableId: UPCOMING_KEY, tasks: over.upcoming ?? [] },
];
}
/**
* The unmodified gesture: file the card into the lane the pointer is in, and let the board's
* ordering place it. `columns` is the board AS RENDERED, which for an unmodified drag still shows
* the card in the lane it came from.
*/
const plan = (
columns: DroppableColumn<DroppableTask>[],
taskId: string,
targetColumnKey=[redacted] | null,
opts: {
columnBy?: 'group' | 'due' | 'channel';
reorder?: boolean;
originColumnKey?: string;
} = {},
) =>
planTaskDrop({
columns,
taskId,
targetColumnKey,
columnBy: opts.columnBy ?? 'group',
reorder: opts.reorder ?? false,
...(opts.originColumnKey ? { originColumnKey=[redacted] } : {}),
now: NOW,
});
/**
* The modified gesture — the card is already SHOWN in the target lane, because holding the
* modifier is what moves it there mid-drag. The plan reads its position back off that arrangement,
* so the tests have to build the board the same way the board does.
*/
const place = (
columns: DroppableColumn<DroppableTask>[],
taskId: string,
targetColumnKey=[redacted],
opts: { columnBy?: 'group' | 'due' | 'channel'; originColumnKey?: string } = {},
) => {
const shown = columns.map((c) => ({
...c,
tasks: c.tasks.filter((t) => t.id !== taskId),
}));
const card = columns.flatMap((c) => c.tasks).find((t) => t.id === taskId)!;
const origin =
opts.originColumnKey ?? columns.find((c) => c.tasks.some((t) => t.id === taskId))!.key;
const target = shown.find((c) => c.key === targetColumnKey)!;
target.tasks = [...target.tasks, card];
return planTaskDrop({
columns: shown,
taskId,
targetColumnKey,
originColumnKey=[redacted],
columnBy: opts.columnBy ?? 'group',
reorder: true,
now: NOW,
});
};
describe('dropping onto Upcoming', () => {
const columns = board({ sales: [card('a')], upcoming: [card('u', { sortOrder: 9 })] });
it('asks for a date and writes nothing — including no sortOrder', () => {
const result = plan(columns, 'a', UPCOMING_KEY, { originColumnKey: 'g1' });
expect(result).toEqual({ kind: 'ask-upcoming', taskId: 'a' });
// The exhaustive form of "writes nothing": there is no field on this plan to write.
expect(result).not.toHaveProperty('sortOrder');
expect(result).not.toHaveProperty('dueDate');
expect(result).not.toHaveProperty('groupId');
});
it('asks even when the lane already holds other cards', () => {
expect(plan(columns, 'a', UPCOMING_KEY, { originColumnKey: 'g1' })).toEqual({
kind: 'ask-upcoming',
taskId: 'a',
});
});
it('asks whether or not the reorder modifier is held', () => {
// Upcoming is the one lane whose membership is derived rather than stored, so a drop onto it
// is a question — and holding ⌘ does not turn a question into a write.
expect(plan(columns, 'a', UPCOMING_KEY, { originColumnKey: 'g1' }).kind).toBe('ask-upcoming');
expect(place(columns, 'a', UPCOMING_KEY, { originColumnKey: 'g1' }).kind).toBe('ask-upcoming');
});
/**
* The dismissal case. `ask-upcoming` carries nothing to undo, so a caller that drops the plan on
* the floor has performed the entire rollback — which is the point.
*/
it('needs no rollback: the plan holds nothing that changed the card', () => {
expect(Object.keys(plan(columns, 'a', UPCOMING_KEY, { originColumnKey: 'g1' }))).toEqual([
'kind',
'taskId',
]);
});
});
describe('dropping out of Upcoming', () => {
const columns = board({
sales: [card('a', { sortOrder: 1 }), card('b', { sortOrder: 2 })],
upcoming: [card('u', { sortOrder: 9, dueDate: '2026-09-15T09:00:00Z' })],
});
it('re-files AND pulls the due date into the present, without claiming a position', () => {
// Without the date the card re-derives straight back into Upcoming and the drag looks broken.
// The position is left to the board's ordering, exactly as any other unmodified drop.
const result = plan(columns, 'u', 'g1', { originColumnKey=[redacted] });
expect(result).toEqual({
kind: 'move',
taskId: 'u',
groupId: 'g1',
refiles: true,
dueDate: NOW,
});
expect(result).not.toHaveProperty('sortOrder');
});
it('places it as well when the modifier is held', () => {
expect(place(columns, 'u', 'g1')).toMatchObject({ sortOrder: 3, dueDate: NOW, refiles: true });
});
it('files to Misc as a null group, still with a date in the present', () => {
expect(plan(columns, 'u', MISC_KEY, { originColumnKey=[redacted] })).toMatchObject({
groupId: null,
dueDate: NOW,
});
});
});
describe('dropping within the board', () => {
const columns = board({
sales: [card('a', { sortOrder: 1 }), card('b', { sortOrder: 2 }), card('c', { sortOrder: 3 })],
misc: [card('m', { sortOrder: 5, taskGroupId: null })],
});
it('an unmodified same-lane drop does nothing — the board is already sorted', () => {
// Nothing was asked for: the card is in the lane it was dropped on and its position is the
// board's to decide. Writing a `sortOrder` here would pin the row for a gesture never made.
expect(plan(columns, 'c', 'g1')).toEqual({ kind: 'none' });
});
it('a modified same-lane drop writes a position and does not re-file', () => {
expect(place(columns, 'c', 'g1')).toEqual({
kind: 'move',
taskId: 'c',
groupId: 'g1',
refiles: false,
sortOrder: 3,
});
});
it('an unmodified cross-lane drop files the card and leaves its position alone', () => {
// The common gesture, and the one Linear's board makes: move it to that lane, and let the
// ordering put it where it belongs.
const result = plan(columns, 'm', 'g1', { originColumnKey=[redacted] });
expect(result).toEqual({ kind: 'move', taskId: 'm', groupId: 'g1', refiles: true });
expect(result).not.toHaveProperty('sortOrder');
});
it('a modified cross-lane drop carries the new group AND the new position', () => {
expect(place(columns, 'm', 'g1')).toEqual({
kind: 'move',
taskId: 'm',
groupId: 'g1',
refiles: true,
sortOrder: 4,
});
});
it('files to Misc as a null group', () => {
expect(plan(columns, 'a', MISC_KEY, { originColumnKey: 'g1' })).toMatchObject({
groupId: null,
refiles: true,
});
});
it('never re-files on a due or channel axis — those lanes describe a task, they do not hold it', () => {
const byDue: DroppableColumn<DroppableTask>[] = [
{ key=[redacted], droppableId: 'overdue', tasks: [card('a', { sortOrder: 1 })] },
{ key=[redacted], droppableId: 'today', tasks: [card('b', { sortOrder: 2 })] },
];
// Unmodified: nothing to do at all, since the lane cannot be filed into.
expect(plan(byDue, 'a', 'today', { columnBy: 'due', originColumnKey=[redacted] })).toEqual({
kind: 'none',
});
// Modified: still no re-file, but position is a property of the task, so it is written.
expect(place(byDue, 'a', 'today', { columnBy: 'due' })).toMatchObject({
refiles: false,
groupId: 'g1',
});
});
it('ignores a drop on nothing, and one on a lane the board does not have', () => {
expect(plan(columns, 'a', null)).toEqual({ kind: 'none' });
expect(plan(columns, 'a', 'nowhere')).toEqual({ kind: 'none' });
expect(plan(columns, 'ghost', 'g1')).toEqual({ kind: 'none' });
});
});
/**
* The lane the card came FROM is passed in, not looked up.
*
* Holding the modifier moves the card into the hovered lane mid-drag, so by the time the drop is
* planned the board already shows it in its destination. Asking "which lane is it in?" would
* answer with the destination and every cross-lane drop would read as a same-lane one.
*/
describe('originColumnKey', () => {
const midDrag = board({
sales: [card('a', { sortOrder: 1 }), card('m', { sortOrder: 5, taskGroupId: null })],
misc: [],
});
it('re-files even though the card is already displayed in the target lane', () => {
expect(plan(midDrag, 'm', 'g1', { originColumnKey=[redacted] })).toMatchObject({
kind: 'move',
groupId: 'g1',
refiles: true,
});
});
it('without it, the same drop is misread as a same-lane move and files nothing', () => {
// Documents the failure, so a refactor that drops the parameter fails here loudly rather than
// silently losing every cross-lane drop.
expect(plan(midDrag, 'm', 'g1')).toEqual({ kind: 'none' });
});
it('still treats a genuine same-lane drop as one', () => {
expect(plan(midDrag, 'm', 'g1', { originColumnKey: 'g1' })).toEqual({ kind: 'none' });
});
it('reads the Upcoming pull-back from the origin, not from where the card is shown', () => {
const fromUpcoming = board({
sales: [card('a', { sortOrder: 1 }), card('u', { sortOrder: 9 })],
upcoming: [],
});
expect(plan(fromUpcoming, 'u', 'g1', { originColumnKey=[redacted] })).toMatchObject({
refiles: true,
dueDate: NOW,
});
});
it('still asks for a date when the destination is Upcoming', () => {
const toUpcoming = board({ sales: [], upcoming: [card('a', { sortOrder: 1 })] });
expect(plan(toUpcoming, 'a', UPCOMING_KEY, { originColumnKey: 'g1' })).toEqual({
kind: 'ask-upcoming',
taskId: 'a',
});
});
});
describe('sortableCardId', () => {
const ID = '11111111-1111-4111-8111-111111111111';
it('registers a real card under its own task id, so a drop can resolve back to the task', () => {
expect(sortableCardId(ID, false)).toBe(ID);
});
it('registers the overlay copy under something else', () => {
expect(sortableCardId(ID, true)).not.toBe(ID);
});
it('is not a lane key either, so a stray overlay id can never resolve to a drop target', () => {
const columns = board({ sales: [card(ID)] });
expect(plan(columns, ID, sortableCardId(ID, true))).toEqual({ kind: 'none' });
});
it('is stable — the same card yields the same pair every render', () => {
expect(sortableCardId(ID, false)).toBe(sortableCardId(ID, false));
expect(sortableCardId(ID, true)).toBe(sortableCardId(ID, true));
});
});
/**
* Which slot the pointer falls into, given the vertical midpoints of a lane's cards.
*
* The regression this replaces: the slot used to be derived from whichever card dnd-kit reported
* under the cursor. Inserting the card opened a gap, the gap moved the cards, a DIFFERENT card was
* now under the cursor, the slot changed back, and the gap flickered between two positions while
* the pointer sat still — "the hit box moves up and down". A slot computed from the pointer against
* FIXED midpoints cannot do that: nothing the board renders can move either input.
*/
describe('insertionIndexForY', () => {
// Three 100px cards stacked from y=0, so midpoints at 50, 150, 250.
const mids = [50, 150, 250];
it('puts the pointer above every card at index 0', () => {
expect(insertionIndexForY(mids, 10)).toBe(0);
expect(insertionIndexForY(mids, 49)).toBe(0);
});
it('advances one slot per midpoint crossed', () => {
expect(insertionIndexForY(mids, 51)).toBe(1);
expect(insertionIndexForY(mids, 151)).toBe(2);
expect(insertionIndexForY(mids, 251)).toBe(3);
});
it('puts the pointer below every card at the end', () => {
expect(insertionIndexForY(mids, 9999)).toBe(mids.length);
});
it('is monotonic in y — the property that rules out flicker', () => {
// Dragging steadily downwards can only ever move the slot down. If this can decrease while y
// increases, the gap can chase the pointer and oscillate.
let previous = -1;
for (let y = 0; y <= 320; y += 4) {
const index = insertionIndexForY(mids, y);
expect(index).toBeGreaterThanOrEqual(previous);
previous = index;
}
});
it('is a pure function of y — the same pointer always yields the same slot', () => {
// The old implementation read the live DOM, so the answer depended on what the previous answer
// had already moved. Same input, same output, every time.
expect(insertionIndexForY(mids, 151)).toBe(insertionIndexForY(mids, 151));
expect(insertionIndexForY(mids, 151)).toBe(2);
});
it('places into an empty lane at 0', () => {
expect(insertionIndexForY([], 500)).toBe(0);
});
it('never returns an index past the number of cards', () => {
for (let y = -500; y <= 900; y += 7) {
const index = insertionIndexForY(mids, y);
expect(index).toBeGreaterThanOrEqual(0);
expect(index).toBeLessThanOrEqual(mids.length);
}
});
});