Files
kanban/frontend/e2e/sidebar-dropzone.spec.ts
egutierrez 257858a1f3 feat(kanban): drag-aware sidebar dropzone (issue 0091)
Add a 32px invisible strip on the left edge of the viewport that
auto-opens the sidebar when the user drags a card and dwells near the
edge for >=400ms. Removes the manual toggle step when moving cards to
sidebar-located columns.

- App.tsx: global mousemove listener while drag is active; 400ms hover
  timer; sets navOpen(true) when triggered; cancels on pointer leave or
  drag end. No auto-close on drag end (user keeps sidebar open).
- dropzone.css: subtle inset blue glow with pulse animation while
  pointer is inside the strip and a drag is active.
- KanbanColumn.tsx: add data-column-id and data-column-location to the
  Paper root for stable e2e selectors.
- e2e/sidebar-dropzone.spec.ts: Playwright test driving a slow drag
  to the left edge, asserting the strip arms, sidebar opens, and the
  card moves to a sidebar column via /api/board.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:57:14 +02:00

199 lines
7.5 KiB
TypeScript

import { test, expect } from "@playwright/test";
import { pw_kanban_login } from "../../../../frontend/functions/browser/pw_kanban_login";
import { pw_drag_drop } from "../../../../frontend/functions/browser/pw_drag_drop";
import { pw_wait_predicate } from "../../../../frontend/functions/browser/pw_wait_predicate";
const USER = process.env.KANBAN_USER || "e2e_user";
const PWD = process.env.KANBAN_PWD || "e2e_test_pw_2026";
interface BoardColumn {
id: string;
name: string;
location: "board" | "sidebar";
position: number;
}
interface BoardCard {
id: string;
column_id: string;
title: string;
}
interface BoardResponse {
columns: BoardColumn[];
cards: BoardCard[];
}
test.describe("Issue 0091 — sidebar drag dropzone", () => {
test("drag near left edge opens sidebar and drop moves card to sidebar column", async ({ page }) => {
await page.goto("/");
await pw_kanban_login(page, { username: USER, password: PWD });
// Pre-req: ensure there is at least one sidebar column and a card on the board.
const initialBoard: BoardResponse = await page.request
.get("/api/board")
.then((r) => r.json());
let sidebarCol = initialBoard.columns.find((c) => c.location === "sidebar");
if (!sidebarCol) {
const created = await page.request
.post("/api/columns", {
data: { name: "E2E Sidebar", location: "sidebar" },
})
.then((r) => r.json());
sidebarCol = created as BoardColumn;
}
const boardCol = initialBoard.columns.find((c) => c.location !== "sidebar");
if (!boardCol) {
test.skip(true, "no board column to drag a card from");
return;
}
// Ensure at least one card exists in a board column we can drag.
let card = initialBoard.cards.find((c) => c.column_id === boardCol.id);
if (!card) {
const created = await page.request
.post("/api/cards", {
data: {
column_id: boardCol.id,
title: `e2e dropzone card ${Date.now()}`,
requester: "e2e",
},
})
.then((r) => r.json());
card = created as BoardCard;
// Reload UI so the new card appears.
await page.reload();
await page.waitForLoadState("networkidle");
}
// Sanity: side bar should start closed. The toggle button has aria-label="Toggle sidebar".
const toggleBtn = page.locator('button[aria-label="Toggle sidebar"]');
await expect(toggleBtn).toBeVisible();
// The Mantine Navbar has a known data attribute (data-mantine-component=AppShellNavbar)
// but the simplest check is: when collapsed, the desktop navbar is hidden via display:none.
// We use the strip element's visibility too.
const strip = page.locator('[data-test="kanban-drag-edge"]');
await expect(strip).toHaveCount(1);
// While not dragging, strip is_active=0.
await expect(strip).toHaveAttribute("data-active", "0");
const cardLocator = page.locator(`[data-card-id="${card!.id}"]`);
await expect(cardLocator).toBeVisible();
// Build a "left edge" target by creating a 1x100 box near x=10 to drop on.
// pw_drag_drop expects a Locator for the target; we use the strip itself
// even though pointer-events:none — page.mouse.move works against the
// viewport so its bounding box only drives where the pointer goes.
// We override hoverMs=700 so the 400ms timer fires well within the hover.
// Get the card bounding box.
const cardBox = await cardLocator.boundingBox();
if (!cardBox) throw new Error("card has no bounding box");
// Manually drive the pointer: press down on card, drag to x=10, dwell 700ms,
// assert sidebar opened (via predicate on toggle button aria-pressed OR the
// strip's data-active attribute observed), then drop on sidebar column.
const sx = cardBox.x + cardBox.width / 2;
const sy = cardBox.y + cardBox.height / 2;
await page.mouse.move(sx, sy);
await page.mouse.down();
// Cross dnd-kit's 5px activation threshold (we configured PointerSensor distance:5).
await page.mouse.move(sx + 15, sy, { steps: 4 });
// Glide towards x=10 (inside the 32px strip).
const edgeX = 10;
const edgeY = sy; // keep vertical, change horizontal.
const steps = 25;
for (let i = 1; i <= steps; i++) {
const t = i / steps;
const xi = (sx + 15) + (edgeX - (sx + 15)) * t;
const yi = sy + (edgeY - sy) * t;
await page.mouse.move(xi, yi);
await page.waitForTimeout(16);
}
// Now dwell inside the strip — the 400ms timer should fire.
// While dwelling, every ~50ms we nudge the mouse 1px to keep dnd-kit pointer events alive
// but stay inside the strip.
const dwellMs = 700;
const nudgeStart = Date.now();
while (Date.now() - nudgeStart < dwellMs) {
await page.mouse.move(edgeX + ((Date.now() / 50) % 2), edgeY);
await page.waitForTimeout(50);
}
// Assert: the strip is now armed AND the sidebar opened.
await expect(strip).toHaveAttribute("data-armed", "1");
// Wait for sidebar column header text to appear (sidebar opened).
await pw_wait_predicate(
page,
(sidebarName: string) => {
const els = Array.from(document.querySelectorAll('[data-column-location="sidebar"]'));
// Element must be visible (offsetParent != null is a good proxy for display!=none).
return els.some((el) => (el as HTMLElement).offsetParent !== null);
},
{
arg: sidebarCol!.name,
timeoutMs: 3000,
pollMs: 100,
message: "sidebar column did not become visible after dwell",
}
);
// Now move pointer to the sidebar column and release.
const sidebarColLoc = page.locator(`[data-column-id="${sidebarCol!.id}"]`).first();
await expect(sidebarColLoc).toBeVisible();
const sbBox = await sidebarColLoc.boundingBox();
if (!sbBox) throw new Error("sidebar column has no bounding box");
const tx = sbBox.x + sbBox.width / 2;
const ty = sbBox.y + sbBox.height / 2;
const dropSteps = 15;
let lastX = edgeX;
let lastY = edgeY;
for (let i = 1; i <= dropSteps; i++) {
const t = i / dropSteps;
const xi = lastX + (tx - lastX) * t;
const yi = lastY + (ty - lastY) * t;
await page.mouse.move(xi, yi);
await page.waitForTimeout(20);
}
await page.waitForTimeout(150);
await page.mouse.up();
// Validate via API the card moved to the sidebar column.
await pw_wait_predicate(
page,
async (args: { id: string; col: string }) => {
const res = await fetch("/api/board", { credentials: "same-origin" });
const b = await res.json();
const c = (b.cards as BoardCard[]).find((x) => x.id === args.id);
return c?.column_id === args.col;
},
{
arg: { id: card!.id, col: sidebarCol!.id },
timeoutMs: 5000,
pollMs: 200,
message: "card did not land in sidebar column after drop",
}
);
});
test("strip stays inactive when there is no drag", async ({ page }) => {
await page.goto("/");
await pw_kanban_login(page, { username: USER, password: PWD });
const strip = page.locator('[data-test="kanban-drag-edge"]');
await expect(strip).toHaveCount(1);
await expect(strip).toHaveAttribute("data-active", "0");
await expect(strip).toHaveAttribute("data-armed", "0");
// Move the pointer over the left edge — without a drag, strip must stay disarmed.
await page.mouse.move(10, 200);
await page.waitForTimeout(600);
await expect(strip).toHaveAttribute("data-armed", "0");
});
});