diff --git a/apps/web/components/kanban-card-menu-items.test.tsx b/apps/web/components/kanban-card-menu-items.test.tsx new file mode 100644 index 0000000000..75d088b2a4 --- /dev/null +++ b/apps/web/components/kanban-card-menu-items.test.tsx @@ -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( +
+ + open + + + + +
, + ); + } + + 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( +
+ + open + + + + +
, + ); + + fireEvent.pointerDown(screen.getByRole("menuitem", { name: /delete/i })); + + expect(parentOnPointerDown).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/components/kanban-card-menu-items.tsx b/apps/web/components/kanban-card-menu-items.tsx index c3da448b79..ea535a1ad0 100644 --- a/apps/web/components/kanban-card-menu-items.tsx +++ b/apps/web/components/kanban-card-menu-items.tsx @@ -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?.(); diff --git a/apps/web/e2e/tests/kanban/card-menu-delete-archive.spec.ts b/apps/web/e2e/tests/kanban/card-menu-delete-archive.spec.ts new file mode 100644 index 0000000000..9cec0783ba --- /dev/null +++ b/apps/web/e2e/tests/kanban/card-menu-delete-archive.spec.ts @@ -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(); + }); +});