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