test(web): cover kanban card menu delete/archive propagation + pointer-down guard (#867)

This commit is contained in:
Carlos Florêncio
2026-05-11 17:51:26 +01:00
committed by GitHub
parent 14c19ea538
commit 6139dce03e
3 changed files with 168 additions and 1 deletions
@@ -0,0 +1,83 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@kandev/ui/dropdown-menu";
import { KanbanCardDropdownMenuItems, type KanbanCardMenuEntry } from "./kanban-card-menu-items";
// Regression: React synthetic events bubble through the fiber tree from a Radix portal; without stopPropagation the parent Card's onClick fires instead of the confirm dialog.
describe("KanbanCardDropdownMenuItems — click propagation", () => {
function renderWithParent(entries: KanbanCardMenuEntry[], parentOnClick: () => void) {
return render(
<div data-testid="parent-card" onClick={parentOnClick}>
<DropdownMenu defaultOpen>
<DropdownMenuTrigger>open</DropdownMenuTrigger>
<DropdownMenuContent>
<KanbanCardDropdownMenuItems entries={entries} />
</DropdownMenuContent>
</DropdownMenu>
</div>,
);
}
it("clicking a menu item does not call the parent card's onClick", () => {
const onDelete = vi.fn();
const parentOnClick = vi.fn();
const entries: KanbanCardMenuEntry[] = [
{
kind: "item",
key: "delete",
label: "Delete",
onSelect: onDelete,
},
];
renderWithParent(entries, parentOnClick);
const deleteItem = screen.getByRole("menuitem", { name: /delete/i });
fireEvent.click(deleteItem);
expect(onDelete).toHaveBeenCalledTimes(1);
expect(parentOnClick).not.toHaveBeenCalled();
});
it("clicking an archive menu item does not call the parent card's onClick", () => {
const onArchive = vi.fn();
const parentOnClick = vi.fn();
const entries: KanbanCardMenuEntry[] = [
{
kind: "item",
key: "archive",
label: "Archive",
onSelect: onArchive,
},
];
renderWithParent(entries, parentOnClick);
fireEvent.click(screen.getByRole("menuitem", { name: /archive/i }));
expect(onArchive).toHaveBeenCalledTimes(1);
expect(parentOnClick).not.toHaveBeenCalled();
});
it("pointer-down on a menu item does not reach the parent (dnd-kit guard)", () => {
const parentOnPointerDown = vi.fn();
const entries: KanbanCardMenuEntry[] = [
{ kind: "item", key: "delete", label: "Delete", onSelect: vi.fn() },
];
render(
<div data-testid="parent-card" onPointerDown={parentOnPointerDown}>
<DropdownMenu defaultOpen>
<DropdownMenuTrigger>open</DropdownMenuTrigger>
<DropdownMenuContent>
<KanbanCardDropdownMenuItems entries={entries} />
</DropdownMenuContent>
</DropdownMenu>
</div>,
);
fireEvent.pointerDown(screen.getByRole("menuitem", { name: /delete/i }));
expect(parentOnPointerDown).not.toHaveBeenCalled();
});
});
@@ -404,8 +404,9 @@ function DropdownEntry({ entry }: { entry: KanbanCardMenuEntry }) {
data-testid={entry.testId}
disabled={entry.disabled}
className={entry.destructive ? "text-destructive focus:text-destructive" : undefined}
// React events bubble through the React tree even from a portal — stop here so the card's onClick doesn't navigate.
// React events bubble through the React tree even from a portal - stop here so click/pointer don't reach the parent Card's onClick or dnd-kit listeners.
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onSelect={(event) => {
event.stopPropagation();
if (!entry.disabled) entry.onSelect?.();
@@ -0,0 +1,83 @@
import { test, expect } from "../../fixtures/test-base";
import { KanbanPage } from "../../pages/kanban-page";
// Regression: clicking Delete / Archive must show the confirm dialog, not navigate to the task.
test.describe("Kanban card actions menu — delete/archive does not navigate", () => {
test("clicking Delete in card dropdown shows confirm dialog and does not navigate", async ({
testPage,
apiClient,
seedData,
}) => {
const task = await apiClient.createTask(seedData.workspaceId, "Card Menu Delete Task", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
const kanban = new KanbanPage(testPage);
await kanban.goto();
const startUrl = testPage.url();
await kanban.openTaskActionsMenu(task.id);
const deleteItem = testPage.getByRole("menuitem", { name: "Delete" });
await expect(deleteItem).toBeVisible();
await deleteItem.click();
// Confirm dialog must appear with the task title
await expect(testPage.getByRole("alertdialog")).toBeVisible();
await expect(testPage.getByRole("alertdialog")).toContainText("Card Menu Delete Task");
// URL must not have changed (no navigation to /tasks/:id and no ?taskId=)
expect(testPage.url()).toBe(startUrl);
});
test("clicking Archive in card dropdown shows confirm dialog and does not navigate", async ({
testPage,
apiClient,
seedData,
}) => {
const task = await apiClient.createTask(seedData.workspaceId, "Card Menu Archive Task", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
const kanban = new KanbanPage(testPage);
await kanban.goto();
const startUrl = testPage.url();
await kanban.openTaskActionsMenu(task.id);
const archiveItem = testPage.getByRole("menuitem", { name: "Archive" });
await expect(archiveItem).toBeVisible();
await archiveItem.click();
await expect(testPage.getByRole("alertdialog")).toBeVisible();
await expect(testPage.getByRole("alertdialog")).toContainText("Card Menu Archive Task");
expect(testPage.url()).toBe(startUrl);
});
test("clicking Delete with preview-on-click enabled does not open preview", async ({
testPage,
apiClient,
seedData,
}) => {
await apiClient.saveUserSettings({ enable_preview_on_click: true });
const task = await apiClient.createTask(seedData.workspaceId, "Card Menu Preview Task", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
const kanban = new KanbanPage(testPage);
await kanban.goto();
const startUrl = testPage.url();
await kanban.openTaskActionsMenu(task.id);
await testPage.getByRole("menuitem", { name: "Delete" }).click();
await expect(testPage.getByRole("alertdialog")).toBeVisible();
// Preview-on-click must not have fired — URL should still be the start URL
expect(testPage.url()).toBe(startUrl);
await expect(testPage.getByTestId("task-preview-panel")).not.toBeVisible();
});
});