257858a1f3
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>
199 lines
7.5 KiB
TypeScript
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");
|
|
});
|
|
});
|