Files
kandev/apps/web/e2e/tests/task/sidebar-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

290 lines
9.8 KiB
TypeScript

/**
* E2E: cmd/ctrl-click and shift-click multi-select in the task sidebar, plus the
* selection-aware right-click context menu (bulk archive / single fallback).
*/
import { type Page } from "@playwright/test";
import { test, expect } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
import { SidebarTasksPage } from "../../pages/sidebar-tasks-page";
type Created = { id: string; title: string };
async function seedTasks(
apiClient: { createTask: (ws: string, title: string, opts: object) => Promise<{ id: string }> },
seedData: { workspaceId: string; workflowId: string; startStepId: string },
titles: string[],
): Promise<Created[]> {
const out: Created[] = [];
for (const title of titles) {
const t = await apiClient.createTask(seedData.workspaceId, title, {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
out.push({ id: t.id, title });
}
return out;
}
/** Rendered top-to-bottom order of task ids in the sidebar. */
async function sidebarOrder(page: Page): Promise<string[]> {
return page
.getByTestId("task-sidebar")
.first()
.locator("[data-testid='sortable-task-block']")
.evaluateAll((els: Element[]) => els.map((e) => e.getAttribute("data-task-id") ?? ""));
}
async function openSidebarOn(page: Page, taskId: string): Promise<SidebarTasksPage> {
await page.goto(`/t/${taskId}`);
const session = new SessionPage(page);
await session.waitForLoad();
await expect(session.sidebar).toBeVisible({ timeout: 10_000 });
return new SidebarTasksPage(page);
}
test.describe("Sidebar cmd/shift multi-select", () => {
test("cmd-click selects a row without navigating", async ({ testPage, apiClient, seedData }) => {
const [nav, target] = await seedTasks(apiClient, seedData, ["SB Nav", "SB Target"]);
const sidebar = await openSidebarOn(testPage, nav.id);
await sidebar.cmdClick(target.id);
await sidebar.expectSelected(target.id, true);
// URL must not change — cmd-click selects, it doesn't open the task.
await expect(testPage).toHaveURL(new RegExp(`/t/${nav.id}`));
});
test("cmd-click toggles multiple rows in and out", async ({ testPage, apiClient, seedData }) => {
const [nav, a, b] = await seedTasks(apiClient, seedData, ["SB MultiNav", "SB A", "SB B"]);
const sidebar = await openSidebarOn(testPage, nav.id);
await sidebar.cmdClick(a.id);
await sidebar.cmdClick(b.id);
expect(await sidebar.selectedCount()).toBe(2);
await sidebar.cmdClick(a.id);
await sidebar.expectSelected(a.id, false);
await sidebar.expectSelected(b.id, true);
expect(await sidebar.selectedCount()).toBe(1);
});
test("shift-click selects a contiguous range of visible rows", async ({
testPage,
apiClient,
seedData,
}) => {
const [nav] = await seedTasks(apiClient, seedData, [
"SB Range Nav",
"SB Range 1",
"SB Range 2",
"SB Range 3",
]);
const sidebar = await openSidebarOn(testPage, nav.id);
await expect(sidebar.rows().first()).toBeVisible({ timeout: 10_000 });
const order = await sidebarOrder(testPage);
expect(order.length).toBeGreaterThanOrEqual(4);
await sidebar.cmdClick(order[0]);
await sidebar.shiftClick(order[2]);
await sidebar.expectSelected(order[0], true);
await sidebar.expectSelected(order[1], true);
await sidebar.expectSelected(order[2], true);
await sidebar.expectSelected(order[3], false);
});
test("plain click navigates when nothing is selected", async ({
testPage,
apiClient,
seedData,
}) => {
const [nav, target] = await seedTasks(apiClient, seedData, ["SB PlainNav", "SB PlainTarget"]);
const sidebar = await openSidebarOn(testPage, nav.id);
await sidebar.plainClick(target.id);
await expect(testPage).toHaveURL(new RegExp(`/t/${target.id}`));
});
test("plain click toggles while a selection is active", async ({
testPage,
apiClient,
seedData,
}) => {
const [nav, a, b] = await seedTasks(apiClient, seedData, ["SB PT Nav", "SB PT A", "SB PT B"]);
const sidebar = await openSidebarOn(testPage, nav.id);
await sidebar.cmdClick(a.id); // enter selection mode
await sidebar.plainClick(b.id); // plain click now toggles, not navigates
await sidebar.expectSelected(b.id, true);
await expect(testPage).toHaveURL(new RegExp(`/t/${nav.id}`));
expect(await sidebar.selectedCount()).toBe(2);
});
test("Escape clears the selection", async ({ testPage, apiClient, seedData }) => {
const [nav, a] = await seedTasks(apiClient, seedData, ["SB Esc Nav", "SB Esc A"]);
const sidebar = await openSidebarOn(testPage, nav.id);
await sidebar.cmdClick(a.id);
await sidebar.expectSelected(a.id, true);
await testPage.keyboard.press("Escape");
await sidebar.expectSelected(a.id, false);
expect(await sidebar.selectedCount()).toBe(0);
});
});
test.describe("Sidebar selection-aware context menu", () => {
test("right-clicking a selected row archives the whole selection", async ({
testPage,
apiClient,
seedData,
}) => {
const [nav, a, b] = await seedTasks(apiClient, seedData, [
"SB Arc Nav",
"SB Arc A",
"SB Arc B",
]);
const sidebar = await openSidebarOn(testPage, nav.id);
await sidebar.cmdClick(a.id);
await sidebar.cmdClick(b.id);
expect(await sidebar.selectedCount()).toBe(2);
await sidebar.rightClick(a.id);
const bulkItem = sidebar.bulkArchiveMenuItem(2);
await expect(bulkItem).toBeVisible({ timeout: 5_000 });
await bulkItem.click();
const confirm = sidebar.bulkArchiveConfirm();
await expect(confirm).toBeVisible({ timeout: 5_000 });
await confirm.click();
await expect(sidebar.row(a.id)).not.toBeVisible({ timeout: 10_000 });
await expect(sidebar.row(b.id)).not.toBeVisible({ timeout: 5_000 });
});
test("multi-selection menu offers only bulk-valid actions", async ({
testPage,
apiClient,
seedData,
}) => {
const [nav, a, b] = await seedTasks(apiClient, seedData, [
"SB Menu Nav",
"SB Menu A",
"SB Menu B",
]);
const sidebar = await openSidebarOn(testPage, nav.id);
await sidebar.cmdClick(a.id);
await sidebar.cmdClick(b.id);
await sidebar.rightClick(a.id);
// Bulk-valid actions are present…
await expect(sidebar.bulkPinMenuItem(2)).toBeVisible({ timeout: 5_000 });
await expect(sidebar.bulkArchiveMenuItem(2)).toBeVisible();
await expect(sidebar.bulkDeleteMenuItem(2)).toBeVisible();
// …and the single-task-only actions are hidden.
await expect(sidebar.menuItem("Rename")).not.toBeVisible();
await expect(sidebar.menuItem("Duplicate")).not.toBeVisible();
await testPage.keyboard.press("Escape");
});
test("bulk Delete prompts for the selection and removes the rows", async ({
testPage,
apiClient,
seedData,
}) => {
const [nav, a, b] = await seedTasks(apiClient, seedData, [
"SB Del Nav",
"SB Del A",
"SB Del B",
]);
const sidebar = await openSidebarOn(testPage, nav.id);
await sidebar.cmdClick(a.id);
await sidebar.cmdClick(b.id);
await sidebar.rightClick(a.id);
const del = sidebar.bulkDeleteMenuItem(2);
await expect(del).toBeVisible({ timeout: 5_000 });
await del.click();
const confirm = sidebar.bulkDeleteConfirm();
await expect(confirm).toBeVisible({ timeout: 5_000 });
await confirm.click();
await expect(sidebar.row(a.id)).not.toBeVisible({ timeout: 10_000 });
await expect(sidebar.row(b.id)).not.toBeVisible({ timeout: 5_000 });
});
test("bulk Pin pins all selected rows", async ({ testPage, apiClient, seedData }) => {
const [nav, a, b] = await seedTasks(apiClient, seedData, [
"SB Pin Nav",
"SB Pin A",
"SB Pin B",
]);
const sidebar = await openSidebarOn(testPage, nav.id);
await sidebar.cmdClick(a.id);
await sidebar.cmdClick(b.id);
await sidebar.rightClick(a.id);
const pin = sidebar.bulkPinMenuItem(2);
await expect(pin).toBeVisible({ timeout: 5_000 });
await pin.click();
// Both rows now render the pinned icon.
await expect(sidebar.row(a.id).getByTestId("task-pinned-icon")).toBeVisible({ timeout: 5_000 });
await expect(sidebar.row(b.id).getByTestId("task-pinned-icon")).toBeVisible({ timeout: 5_000 });
await sidebar.cmdClick(a.id);
await sidebar.cmdClick(b.id);
await sidebar.rightClick(a.id);
const unpin = sidebar.bulkUnpinMenuItem(2);
await expect(unpin).toBeVisible({ timeout: 5_000 });
await unpin.click();
await expect(sidebar.row(a.id).getByTestId("task-pinned-icon")).toBeHidden({
timeout: 5_000,
});
await expect(sidebar.row(b.id).getByTestId("task-pinned-icon")).toBeHidden({
timeout: 5_000,
});
});
test("right-clicking an unselected row acts on that row only", async ({
testPage,
apiClient,
seedData,
}) => {
const [nav, a, b, c] = await seedTasks(apiClient, seedData, [
"SB Solo Nav",
"SB Solo A",
"SB Solo B",
"SB Solo C",
]);
const sidebar = await openSidebarOn(testPage, nav.id);
await sidebar.cmdClick(a.id);
await sidebar.cmdClick(b.id);
expect(await sidebar.selectedCount()).toBe(2);
// Right-click a row that is NOT in the selection.
await sidebar.rightClick(c.id);
// The menu must offer the single-task action, not the bulk one.
await expect(sidebar.singleArchiveMenuItem()).toBeVisible({ timeout: 5_000 });
await expect(sidebar.bulkArchiveMenuItem(2)).not.toBeVisible();
// Opening the menu on an unselected row leaves the existing selection intact
// (asserted while the menu is open — Escape both closes the menu and clears
// the selection, which is the documented dismiss behaviour).
await sidebar.expectSelected(a.id, true);
await sidebar.expectSelected(b.id, true);
await testPage.keyboard.press("Escape");
});
});