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