Files
kandev/apps/web/e2e/tests/kanban/cmd-shift-multi-select.spec.ts
T
José Almeida 5105ac224c feat: cmd+click and shift+click multi-select for kanban and sidebar (#1518)
* feat: add anchored range selection to multi-select reducer

* feat: cmd+click and shift+click multi-select on kanban board

* feat: add cmd/shift multi-select to sidebar task list

* test: e2e coverage for cmd/shift multi-select

* fix: harden multi-select selection lifecycle from review feedback

* test: scope multi-select e2e helpers to active containers

* fix: add sidebar multi-select hook tests and tighten bulk-action lifecycle

* fix: prune hidden sidebar selections and order bulk moves by view

* test: cover visible-order bulk move and anchor realignment

* fix: harden sidebar bulk-move scope and keyboard selection parity

* fix: order kanban bulk moves by board position

* test: cover multi-select helpers and fix stale-id sort fallback

* fix: order kanban card context-menu moves by board position

* feat: sidebar multi-select shows only bulk-valid actions with bulk pin and delete

* fix: harden multi-select bulk ordering, selection clearing, and a11y

* fix: gate kanban card move-to-step for mixed-workflow selections

* fix: unpin fully pinned sidebar selections

* refactor: keep sidebar task components under limits

* test: restore sidebar selection console spy safely
2026-07-06 17:16:14 +01:00

202 lines
7.8 KiB
TypeScript

/**
* E2E: cmd/ctrl-click and shift-click multi-select on the Kanban board.
*
* Covers the modifier-driven entry into multi-select mode (no toggle button
* needed), per-card toggle, same-column shift range, the cross-column fallback,
* and plain-click toggle behaviour while in multi-select mode.
*/
import { type Page } from "@playwright/test";
import { test, expect } from "../../fixtures/test-base";
import { KanbanPage } from "../../pages/kanban-page";
// Scope DOM reads to the active board so a hidden/stale mounted layout can't
// satisfy these selectors (dock/mobile layouts can coexist in the DOM).
function activeColumn(page: Page, stepId: string) {
return page.getByTestId("kanban-board").getByTestId(`kanban-column-${stepId}`);
}
/** Rendered (display-order) task ids inside a column. */
async function columnTaskIds(page: Page, stepId: string): Promise<string[]> {
return activeColumn(page, stepId)
.locator("[data-testid^='task-card-']:not([data-testid='task-card-title'])")
.evaluateAll((els: Element[]) =>
els.map((e) => (e.getAttribute("data-testid") ?? "").replace("task-card-", "")),
);
}
/** Task ids of the selected (primary-ring) cards in a column. */
async function columnSelectedIds(page: Page, stepId: string): Promise<string[]> {
return activeColumn(page, stepId)
.locator("[data-testid^='task-card-']:not([data-testid='task-card-title'])")
.evaluateAll((els: Element[]) =>
els
.filter((e) => e.className.includes("ring-primary"))
.map((e) => (e.getAttribute("data-testid") ?? "").replace("task-card-", "")),
);
}
test.describe("Kanban cmd/shift multi-select", () => {
test("cmd-click enters multi-select and shows the toolbar without the toggle", async ({
testPage,
apiClient,
seedData,
}) => {
const task = await apiClient.createTask(seedData.workspaceId, "Cmd Enter Task", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
const kanban = new KanbanPage(testPage);
await kanban.goto();
await expect(kanban.multiSelectToolbar).not.toBeVisible();
await kanban.cmdClickCard(task.id);
await expect(kanban.multiSelectToolbar).toBeVisible();
await expect(kanban.multiSelectToolbar).toContainText("1 selected");
await kanban.expectCardSelected(task.id);
});
test("cmd-click toggles individual cards in and out of the selection", async ({
testPage,
apiClient,
seedData,
}) => {
const t1 = await apiClient.createTask(seedData.workspaceId, "Cmd Toggle 1", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
const t2 = await apiClient.createTask(seedData.workspaceId, "Cmd Toggle 2", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
const kanban = new KanbanPage(testPage);
await kanban.goto();
await kanban.cmdClickCard(t1.id);
await kanban.cmdClickCard(t2.id);
await expect(kanban.multiSelectToolbar).toContainText("2 selected");
// Cmd-click an already-selected card removes it from the selection.
await kanban.cmdClickCard(t1.id);
await expect(kanban.multiSelectToolbar).toContainText("1 selected");
await kanban.expectCardSelected(t1.id, false);
await kanban.expectCardSelected(t2.id, true);
});
test("shift-click selects a contiguous range within the column", async ({
testPage,
apiClient,
seedData,
}) => {
// Four tasks in the same column; create sequentially for a stable order.
for (const title of ["Range A", "Range B", "Range C", "Range D"]) {
await apiClient.createTask(seedData.workspaceId, title, {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
}
const kanban = new KanbanPage(testPage);
await kanban.goto();
await expect(kanban.taskCardByTitle("Range A")).toBeVisible({ timeout: 10_000 });
const order = await columnTaskIds(testPage, seedData.startStepId);
expect(order.length).toBeGreaterThanOrEqual(4);
// Anchor on the first card, shift-click the third → first three selected.
await kanban.cmdClickCard(order[0]);
await kanban.shiftClickCard(order[2]);
await expect(kanban.multiSelectToolbar).toContainText("3 selected");
const selected = await columnSelectedIds(testPage, seedData.startStepId);
expect(selected.sort()).toEqual(order.slice(0, 3).sort());
await kanban.expectCardSelected(order[3], false);
});
test("shift-click does not extend the range across columns", async ({
testPage,
apiClient,
seedData,
}) => {
const otherStep = seedData.steps.find((s) => s.id !== seedData.startStepId);
test.skip(!otherStep, "Workflow needs at least two steps");
const a1 = await apiClient.createTask(seedData.workspaceId, "Col A One", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
await apiClient.createTask(seedData.workspaceId, "Col A Two", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
const b1 = await apiClient.createTask(seedData.workspaceId, "Col B One", {
workflow_id: seedData.workflowId,
workflow_step_id: otherStep!.id,
});
const kanban = new KanbanPage(testPage);
await kanban.goto();
await expect(kanban.taskCard(b1.id)).toBeVisible({ timeout: 10_000 });
// Anchor in column A, shift-click into column B. The anchor isn't in B's
// column order, so the range falls back to union-with-previous and re-anchors
// to the clicked card — A stays selected, B is added, A's column isn't spanned.
await kanban.cmdClickCard(a1.id);
await kanban.shiftClickCard(b1.id);
await expect(kanban.multiSelectToolbar).toContainText("2 selected");
await kanban.expectCardSelected(a1.id, true);
await kanban.expectCardSelected(b1.id, true);
});
test("plain click toggles selection while in multi-select mode", async ({
testPage,
apiClient,
seedData,
}) => {
const t1 = await apiClient.createTask(seedData.workspaceId, "Plain Toggle 1", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
const t2 = await apiClient.createTask(seedData.workspaceId, "Plain Toggle 2", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
const kanban = new KanbanPage(testPage);
await kanban.goto();
// Enter mode via cmd-click, then plain-click another card to add it.
await kanban.cmdClickCard(t1.id);
await kanban.plainClickCard(t2.id);
await expect(kanban.multiSelectToolbar).toContainText("2 selected");
// Still on the board — a plain in-mode click must not navigate away.
await expect(kanban.board).toBeVisible();
// Plain-click the same card again toggles it back out.
await kanban.plainClickCard(t2.id);
await expect(kanban.multiSelectToolbar).toContainText("1 selected");
});
test("the multi-select toggle button still works alongside cmd-click", async ({
testPage,
apiClient,
seedData,
}) => {
const task = await apiClient.createTask(seedData.workspaceId, "Toggle Coexist", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
const kanban = new KanbanPage(testPage);
await kanban.goto();
await expect(kanban.taskCard(task.id)).toBeVisible({ timeout: 10_000 });
// Enter multi-select via the toggle button, then a Cmd-click must still
// select a card (the two entry paths coexist).
await kanban.multiSelectToggle.first().click();
await expect(testPage.locator('[data-multi-select-active="true"]').first()).toBeVisible();
await kanban.cmdClickCard(task.id);
await kanban.expectCardSelected(task.id, true);
await expect(kanban.multiSelectToolbar).toContainText("1 selected");
});
});