test(web): cover kanban card menu delete/archive propagation + pointer-down guard (#867)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user