diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bd1af47c14..2201f183d8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -30,10 +30,13 @@ jobs: - name: Install dependencies if: steps.cache-node-modules.outputs.cache-hit != 'true' - run: npm ci + run: npm ci --legacy-peer-deps env: DATABASE_URL: "postgresql://dummy:dummy@localhost:5432/dummy" + - name: Generate Prisma client + run: npx prisma generate + - name: Cache Next.js build uses: actions/cache@v4 with: diff --git a/src/__tests__/unit/components/whiteboard/WhiteboardsPage.test.tsx b/src/__tests__/unit/components/whiteboard/WhiteboardsPage.test.tsx new file mode 100644 index 0000000000..f7bf57b31d --- /dev/null +++ b/src/__tests__/unit/components/whiteboard/WhiteboardsPage.test.tsx @@ -0,0 +1,195 @@ +/** + * @vitest-environment jsdom + */ +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +globalThis.React = React; + +// Mock next-auth/react +vi.mock("next-auth/react", () => ({ + useSession: () => ({ data: { user: { id: "user-1", name: "Test User" } }, status: "authenticated" }), +})); + +// Mock Next.js navigation +const mockRouterPush = vi.fn(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: mockRouterPush }), +})); + +// Mock Next.js Link — renders as so click events behave like real anchors +vi.mock("next/link", () => ({ + default: ({ href, children, className, onClick }: any) => ( + + {children} + + ), +})); + +// Mock workspace hook +vi.mock("@/hooks/useWorkspace", () => ({ + useWorkspace: () => ({ + id: "workspace-1", + slug: "test-workspace", + role: "OWNER", + workspaces: [], + }), +})); + +// Mock PageHeader +vi.mock("@/components/ui/page-header", () => ({ + PageHeader: ({ title, actions }: any) => ( +
+

{title}

+ {actions} +
+ ), +})); + +// Mock fetch +const mockFetch = vi.fn(); +globalThis.fetch = mockFetch; + +import WhiteboardsPage from "@/app/w/[slug]/whiteboards/page"; + +const mockWhiteboards = [ + { + id: "wb-1", + name: "Whiteboard One", + featureId: null, + feature: null, + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-02T00:00:00Z", + }, + { + id: "wb-2", + name: "Whiteboard Two", + featureId: "feat-1", + feature: { id: "feat-1", title: "Feature Alpha" }, + createdAt: "2024-01-03T00:00:00Z", + updatedAt: "2024-01-04T00:00:00Z", + }, +]; + +/** Open a Radix UI DropdownMenu trigger in jsdom. */ +async function openDropdown(trigger: Element) { + await act(async () => { + trigger.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); + fireEvent.click(trigger); + }); +} + +describe("WhiteboardsPage — delete button", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ success: true, data: mockWhiteboards }), + }); + }); + + async function renderAndWait() { + const result = render(); + // Wait for whiteboards to load + await waitFor(() => { + expect(screen.getByText("Whiteboard One")).toBeInTheDocument(); + }); + return result; + } + + /** Open the dropdown for the first whiteboard card and click "Delete". */ + async function openDeleteDialogForFirstCard() { + // Find the MoreHorizontal dropdown trigger (aria-haspopup="menu") for the first card + const menuTriggers = screen.getAllByRole("button").filter( + (btn) => btn.getAttribute("aria-haspopup") === "menu" + ); + const firstMoreBtn = menuTriggers[0]; + await openDropdown(firstMoreBtn); + + // Click the Delete menu item + await waitFor(() => { + expect(screen.getByRole("menuitem", { name: /delete/i })).toBeInTheDocument(); + }); + fireEvent.click(screen.getByRole("menuitem", { name: /delete/i })); + } + + it("calls e.preventDefault() and e.stopPropagation() and sets deleteId when delete button is clicked", async () => { + await renderAndWait(); + await openDeleteDialogForFirstCard(); + + // After clicking Delete in the dropdown, the confirmation dialog should appear + await waitFor(() => { + expect(screen.getByText("Delete whiteboard?")).toBeInTheDocument(); + }); + }); + + it("opens delete dialog without navigating when delete button is clicked", async () => { + await renderAndWait(); + await openDeleteDialogForFirstCard(); + + // Dialog should open + await waitFor(() => { + expect(screen.getByText("Delete whiteboard?")).toBeInTheDocument(); + }); + + // Router should NOT have been called (no navigation) + expect(mockRouterPush).not.toHaveBeenCalled(); + }); + + it("does not open delete dialog when clicking the card body link", async () => { + await renderAndWait(); + + // Click the card title text (part of the Link body, not the delete button) + const cardTitle = screen.getByText("Whiteboard One"); + fireEvent.click(cardTitle); + + // Dialog should NOT open + expect(screen.queryByText("Delete whiteboard?")).not.toBeInTheDocument(); + }); + + it("removes the whiteboard from the list after confirming deletion", async () => { + await renderAndWait(); + + // Setup DELETE response + mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({}) }); + + await openDeleteDialogForFirstCard(); + + await waitFor(() => { + expect(screen.getByText("Delete whiteboard?")).toBeInTheDocument(); + }); + + // Click the Delete confirm button + const confirmBtn = screen.getByRole("button", { name: /^Delete$/ }); + fireEvent.click(confirmBtn); + + await waitFor(() => { + expect(screen.queryByText("Whiteboard One")).not.toBeInTheDocument(); + }); + + // Other whiteboard should still be there + expect(screen.getByText("Whiteboard Two")).toBeInTheDocument(); + }); + + it("keeps the whiteboard list intact and closes dialog on cancel", async () => { + await renderAndWait(); + + await openDeleteDialogForFirstCard(); + + await waitFor(() => { + expect(screen.getByText("Delete whiteboard?")).toBeInTheDocument(); + }); + + const cancelBtn = screen.getByRole("button", { name: /cancel/i }); + fireEvent.click(cancelBtn); + + await waitFor(() => { + expect(screen.queryByText("Delete whiteboard?")).not.toBeInTheDocument(); + }); + + // Whiteboard should still be in the list + expect(screen.getByText("Whiteboard One")).toBeInTheDocument(); + }); +});