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..fa10a98e96 --- /dev/null +++ b/src/__tests__/unit/components/whiteboard/WhiteboardsPage.test.tsx @@ -0,0 +1,285 @@ +// @vitest-environment jsdom +// Tests for WhiteboardsPage — delete button, pagination, and navigation (v2) +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; + +globalThis.React = React; + +const mockRouterPush = vi.fn(); +const mockRouterReplace = vi.fn(); +const mockSearchParamsGet = vi.fn(() => null); +const mockSearchParamsToString = vi.fn(() => ""); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: mockRouterPush, replace: mockRouterReplace }), + useSearchParams: () => ({ + get: mockSearchParamsGet, + toString: mockSearchParamsToString, + }), + usePathname: () => "/w/test-workspace/whiteboards", +})); + +vi.mock("next-auth/react", () => ({ + useSession: vi.fn(() => ({ data: { user: { id: "user-1", name: "Test User" } } })), +})); + +vi.mock("@/hooks/useWorkspace", () => ({ + useWorkspace: vi.fn(() => ({ id: "workspace-1", slug: "test-workspace", role: "OWNER" })), +})); + +vi.mock("@/components/ui/page-header", () => ({ + PageHeader: ({ title, actions }: { title: string; actions?: React.ReactNode }) => ( +
+

{title}

+ {actions} +
+ ), +})); + +vi.mock("@/components/ui/button", () => ({ + Button: ({ children, onClick, disabled, variant, size, className }: any) => ( + + ), +})); + +vi.mock("@/components/ui/badge", () => ({ + Badge: ({ children }: any) => {children}, +})); + +vi.mock("@/components/ui/card", () => ({ + Card: ({ children, className }: any) =>
{children}
, + CardHeader: ({ children, className }: any) =>
{children}
, + CardTitle: ({ children }: any) =>

{children}

, + CardDescription: ({ children }: any) =>

{children}

, +})); + +vi.mock("@/components/features/TableColumnHeaders", () => ({ + FilterDropdownHeader: ({ label, onChange, value }: any) => ( + + ), + SortableColumnHeader: ({ label, onSort }: any) => ( + + ), +})); + +vi.mock("@/components/whiteboard/MoveWhiteboardDialog", () => ({ + MoveWhiteboardDialog: () => null, +})); + +vi.mock("@/components/ui/pagination", () => ({ + Pagination: ({ children }: any) => , + PaginationContent: ({ children }: any) => , + PaginationItem: ({ children }: any) =>
  • {children}
  • , + PaginationLink: ({ children, onClick }: any) => {children}, + PaginationPrevious: ({ onClick }: any) => Prev, + PaginationNext: ({ onClick }: any) => Next, + PaginationEllipsis: () => ..., +})); + +// Shared ref so AlertDialogCancel can call the parent's onOpenChange +let _alertDialogOnOpenChange: ((open: boolean) => void) | null = null; + +vi.mock("@/components/ui/alert-dialog", () => ({ + AlertDialog: ({ children, open, onOpenChange }: any) => { + _alertDialogOnOpenChange = onOpenChange; + return open ?
    {children}
    : null; + }, + AlertDialogContent: ({ children }: any) =>
    {children}
    , + AlertDialogHeader: ({ children }: any) =>
    {children}
    , + AlertDialogTitle: ({ children }: any) =>

    {children}

    , + AlertDialogDescription: ({ children }: any) =>

    {children}

    , + AlertDialogFooter: ({ children }: any) =>
    {children}
    , + AlertDialogAction: ({ children, onClick, disabled }: any) => ( + + ), + AlertDialogCancel: ({ children, disabled }: any) => ( + + ), +})); + +vi.mock("@/components/ui/dropdown-menu", () => ({ + DropdownMenu: ({ children }: any) =>
    {children}
    , + DropdownMenuTrigger: ({ children }: any) =>
    {children}
    , + DropdownMenuContent: ({ children, onClick }: any) =>
    {children}
    , + DropdownMenuItem: ({ children, onClick, className }: any) => ( + + ), +})); + +vi.mock("@/components/ui/avatar", () => ({ + Avatar: ({ children }: any) =>
    {children}
    , + AvatarImage: ({ src }: any) => , + AvatarFallback: ({ children }: any) => {children}, +})); + +const mockWhiteboards = [ + { + id: "wb-1", + name: "Test Whiteboard 1", + featureId: null, + feature: null, + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-02T00:00:00Z", + createdBy: { id: "user-1", name: "Test User", image: null }, + }, + { + id: "wb-2", + name: "Test Whiteboard 2", + featureId: null, + feature: null, + createdAt: "2024-01-03T00:00:00Z", + updatedAt: "2024-01-04T00:00:00Z", + createdBy: { id: "user-2", name: "Another User", image: null }, + }, +]; + +const pagination = { totalPages: 1, total: 2, page: 1, limit: 24 }; + +function makeFetchMock(deleteOk = true) { + return vi.fn().mockImplementation((url: string, options?: any) => { + if (options?.method === "DELETE") { + return Promise.resolve({ ok: deleteOk, json: () => Promise.resolve({ success: deleteOk }) }); + } + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ success: true, data: mockWhiteboards, pagination }), + }); + }); +} + +async function renderPage() { + const { default: WhiteboardsPage } = await import("@/app/w/[slug]/whiteboards/page"); + let result: ReturnType; + await act(async () => { + result = render(); + }); + await waitFor(() => { + expect(screen.queryByText("Test Whiteboard 1")).toBeTruthy(); + }); + return result!; +} + +describe("WhiteboardsPage — delete button", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockSearchParamsGet.mockReturnValue(null); + mockSearchParamsToString.mockReturnValue(""); + global.fetch = makeFetchMock() as any; + Object.defineProperty(global, "localStorage", { + value: { + getItem: vi.fn(() => null), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + length: 0, + key: vi.fn(), + }, + writable: true, + }); + }); + + it("calls e.preventDefault() and e.stopPropagation() and sets deleteId when delete button is clicked", async () => { + await renderPage(); + + const deleteButtons = screen.getAllByText("Delete"); + expect(deleteButtons.length).toBeGreaterThan(0); + + await act(async () => { + fireEvent.click(deleteButtons[0]); + }); + + await waitFor(() => { + expect(screen.getByRole("dialog")).toBeTruthy(); + }); + }); + + it("opens delete dialog without navigating when delete button is clicked", async () => { + await renderPage(); + + const deleteButtons = screen.getAllByText("Delete"); + await act(async () => { + fireEvent.click(deleteButtons[0]); + }); + + await waitFor(() => { + expect(screen.getByText("Delete whiteboard?")).toBeTruthy(); + }); + expect(mockRouterPush).not.toHaveBeenCalled(); + }); + + it("does not open delete dialog when clicking the card body link", async () => { + await renderPage(); + + const cardLink = screen.getByText("Test Whiteboard 1").closest("a"); + expect(cardLink).toBeTruthy(); + + await act(async () => { + fireEvent.click(cardLink!); + }); + + expect(screen.queryByText("Delete whiteboard?")).toBeNull(); + }); + + it("removes the whiteboard from the list after confirming deletion", async () => { + global.fetch = makeFetchMock(true) as any; + + await renderPage(); + + const deleteButtons = screen.getAllByText("Delete"); + await act(async () => { + fireEvent.click(deleteButtons[0]); + }); + + await waitFor(() => { + expect(screen.getByTestId("confirm-delete")).toBeTruthy(); + }); + + await act(async () => { + fireEvent.click(screen.getByTestId("confirm-delete")); + }); + + await waitFor(() => { + expect(screen.queryByText("Test Whiteboard 1")).toBeNull(); + }); + }); + + it("keeps the whiteboard list intact and closes dialog on cancel", async () => { + await renderPage(); + + const deleteButtons = screen.getAllByText("Delete"); + await act(async () => { + fireEvent.click(deleteButtons[0]); + }); + + await waitFor(() => { + expect(screen.getByTestId("cancel-delete")).toBeTruthy(); + }); + + await act(async () => { + fireEvent.click(screen.getByTestId("cancel-delete")); + }); + + await waitFor(() => { + expect(screen.queryByText("Delete whiteboard?")).toBeNull(); + }); + + expect(screen.getByText("Test Whiteboard 1")).toBeTruthy(); + }); +});