From 2d398f5c126175c39bbc552bf14760e661768531 Mon Sep 17 00:00:00 2001 From: Rassl Date: Mon, 6 Apr 2026 16:57:04 +0000 Subject: [PATCH 1/4] Generated with Hive: Add server-side sort and pagination to whiteboards list API and page --- .../unit/api/whiteboards-route.test.ts | 6 + src/app/api/whiteboards/route.ts | 67 +++++-- src/app/w/[slug]/whiteboards/page.tsx | 163 +++++++++++++++++- 3 files changed, 211 insertions(+), 25 deletions(-) diff --git a/src/__tests__/unit/api/whiteboards-route.test.ts b/src/__tests__/unit/api/whiteboards-route.test.ts index e1e7982079..9f1e2456d2 100644 --- a/src/__tests__/unit/api/whiteboards-route.test.ts +++ b/src/__tests__/unit/api/whiteboards-route.test.ts @@ -10,6 +10,7 @@ vi.mock("@/lib/db", () => ({ }, whiteboard: { findMany: vi.fn(), + count: vi.fn(), create: vi.fn(), findUnique: vi.fn(), }, @@ -70,10 +71,12 @@ describe("GET /api/whiteboards", () => { beforeEach(() => { vi.clearAllMocks(); (db.workspace.findFirst as Mock).mockResolvedValue(mockWorkspace); + (db.whiteboard.count as Mock).mockResolvedValue(0); }); test("returns whiteboards with createdBy field", async () => { (db.whiteboard.findMany as Mock).mockResolvedValue([mockWhiteboard]); + (db.whiteboard.count as Mock).mockResolvedValue(1); const req = authedGetRequest("http://localhost/api/whiteboards?workspaceId=ws1"); const res = await GET(req); @@ -87,6 +90,7 @@ describe("GET /api/whiteboards", () => { test("filters by createdById when param provided", async () => { (db.whiteboard.findMany as Mock).mockResolvedValue([mockWhiteboard]); + (db.whiteboard.count as Mock).mockResolvedValue(1); const req = authedGetRequest( "http://localhost/api/whiteboards?workspaceId=ws1&createdById=user1" @@ -110,6 +114,7 @@ describe("GET /api/whiteboards", () => { mockWhiteboard, mockWhiteboardOtherCreator, ]); + (db.whiteboard.count as Mock).mockResolvedValue(2); const req = authedGetRequest( "http://localhost/api/whiteboards?workspaceId=ws1&createdById=ALL" @@ -128,6 +133,7 @@ describe("GET /api/whiteboards", () => { test("returns null createdBy for legacy whiteboards", async () => { const legacyBoard = { ...mockWhiteboard, createdBy: null }; (db.whiteboard.findMany as Mock).mockResolvedValue([legacyBoard]); + (db.whiteboard.count as Mock).mockResolvedValue(1); const req = authedGetRequest("http://localhost/api/whiteboards?workspaceId=ws1"); const res = await GET(req); diff --git a/src/app/api/whiteboards/route.ts b/src/app/api/whiteboards/route.ts index d2d1fad109..d3b0193be8 100644 --- a/src/app/api/whiteboards/route.ts +++ b/src/app/api/whiteboards/route.ts @@ -88,25 +88,58 @@ export async function GET(request: NextRequest) { whereClause.createdById = createdByIdParam; } - const whiteboards = await db.whiteboard.findMany({ - where: whereClause, - orderBy: { updatedAt: "desc" }, - select: { - id: true, - name: true, - featureId: true, - feature: { - select: { id: true, title: true }, - }, - createdAt: true, - updatedAt: true, - createdBy: { - select: { id: true, name: true, image: true }, + // Sort & pagination params + const sortByParam = searchParams.get("sortBy") ?? "updatedAt"; + const sortOrderParam = searchParams.get("sortOrder") ?? "desc"; + const pageParam = Math.max(1, parseInt(searchParams.get("page") ?? "1", 10) || 1); + const limitParam = Math.min(100, Math.max(1, parseInt(searchParams.get("limit") ?? "24", 10) || 24)); + + const validSortBy = ["createdAt", "updatedAt"] as const; + const validSortOrder = ["asc", "desc"] as const; + if (!validSortBy.includes(sortByParam as (typeof validSortBy)[number])) { + return NextResponse.json({ error: "Invalid sortBy value" }, { status: 400 }); + } + if (!validSortOrder.includes(sortOrderParam as (typeof validSortOrder)[number])) { + return NextResponse.json({ error: "Invalid sortOrder value" }, { status: 400 }); + } + + const orderByClause = { [sortByParam]: sortOrderParam }; + + const [whiteboards, totalCount] = await Promise.all([ + db.whiteboard.findMany({ + where: whereClause, + orderBy: orderByClause, + skip: (pageParam - 1) * limitParam, + take: limitParam, + select: { + id: true, + name: true, + featureId: true, + feature: { + select: { id: true, title: true }, + }, + createdAt: true, + updatedAt: true, + createdBy: { + select: { id: true, name: true, image: true }, + }, }, + }), + db.whiteboard.count({ where: whereClause }), + ]); + + const totalPages = Math.ceil(totalCount / limitParam); + return NextResponse.json({ + success: true, + data: whiteboards, + pagination: { + page: pageParam, + limit: limitParam, + totalCount, + totalPages, + hasMore: pageParam < totalPages, }, - }); - - return NextResponse.json({ success: true, data: whiteboards }, { status: 200 }); + }, { status: 200 }); } catch (error) { console.error("Error fetching whiteboards:", error); return NextResponse.json({ error: "Failed to fetch whiteboards" }, { status: 500 }); diff --git a/src/app/w/[slug]/whiteboards/page.tsx b/src/app/w/[slug]/whiteboards/page.tsx index cf07835b47..be34ea9ac8 100644 --- a/src/app/w/[slug]/whiteboards/page.tsx +++ b/src/app/w/[slug]/whiteboards/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect, useCallback } from "react"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams, usePathname } from "next/navigation"; import { useSession } from "next-auth/react"; import Link from "next/link"; import { PageHeader } from "@/components/ui/page-header"; @@ -32,8 +32,17 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { FilterDropdownHeader } from "@/components/features/TableColumnHeaders"; +import { FilterDropdownHeader, SortableColumnHeader } from "@/components/features/TableColumnHeaders"; import { MoveWhiteboardDialog } from "@/components/whiteboard/MoveWhiteboardDialog"; +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "@/components/ui/pagination"; const STORAGE_KEY = "whiteboards-filters-preference"; @@ -53,8 +62,18 @@ interface CreatorOption { image: string | null; } +function getPageRange(current: number, total: number): number[] { + const range: number[] = []; + const start = Math.max(2, current - 2); + const end = Math.min(total - 1, current + 2); + for (let i = start; i <= end; i++) range.push(i); + return range; +} + export default function WhiteboardsPage() { const router = useRouter(); + const searchParams = useSearchParams(); + const pathname = usePathname(); const { id: workspaceId, slug, role } = useWorkspace(); const { data: session } = useSession(); const currentUserId = session?.user?.id; @@ -67,6 +86,24 @@ export default function WhiteboardsPage() { const [deleteId, setDeleteId] = useState(null); const [deleting, setDeleting] = useState(false); const [moveTarget, setMoveTarget] = useState(null); + const [page, setPage] = useState(() => parseInt(searchParams?.get("page") ?? "1", 10) || 1); + const [totalPages, setTotalPages] = useState(1); + + const [sortBy, setSortBy] = useState<"createdAt" | "updatedAt">(() => { + if (typeof window !== "undefined") { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) { try { return JSON.parse(saved).sortBy || "updatedAt"; } catch {} } + } + return "updatedAt"; + }); + + const [sortOrder, setSortOrder] = useState<"asc" | "desc">(() => { + if (typeof window !== "undefined") { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) { try { return JSON.parse(saved).sortOrder || "desc"; } catch {} } + } + return "desc"; + }); const [creatorFilter, setCreatorFilter] = useState(() => { if (typeof window !== "undefined") { @@ -83,20 +120,44 @@ export default function WhiteboardsPage() { return "ALL"; }); - // Persist filter to localStorage + const goToPage = useCallback((n: number) => { + setPage(n); + const params = new URLSearchParams(searchParams?.toString() || ""); + if (n <= 1) { params.delete("page"); } else { params.set("page", n.toString()); } + const newUrl = params.toString() ? `${pathname}?${params.toString()}` : pathname; + router.replace(newUrl, { scroll: false }); + }, [pathname, router, searchParams]); + + const handleSort = useCallback((field: "createdAt" | "updatedAt", order: "asc" | "desc" | null) => { + if (order === null) { + setSortBy("updatedAt"); + setSortOrder("desc"); + } else { + if (sortBy !== field) goToPage(1); + setSortBy(field); + setSortOrder(order); + } + }, [sortBy, goToPage]); + + // Persist filter + sort to localStorage const handleCreatorFilterChange = useCallback((value: string | string[]) => { const next = Array.isArray(value) ? value[0] : value; setCreatorFilter(next); + goToPage(1); + }, [goToPage]); + + // Persist filter + sort to localStorage whenever they change + useEffect(() => { if (typeof window !== "undefined") { - localStorage.setItem(STORAGE_KEY, JSON.stringify({ creatorFilter: next })); + localStorage.setItem(STORAGE_KEY, JSON.stringify({ creatorFilter, sortBy, sortOrder })); } - }, []); + }, [creatorFilter, sortBy, sortOrder]); // Initial load: fetch all (unfiltered) to build creator options list const loadCreatorOptions = useCallback(async () => { if (!workspaceId) return; try { - const res = await fetch(`/api/whiteboards?workspaceId=${workspaceId}`); + const res = await fetch(`/api/whiteboards?workspaceId=${workspaceId}&limit=100`); const data = await res.json(); if (data.success) { const seen = new Set(); @@ -121,7 +182,7 @@ export default function WhiteboardsPage() { const loadWhiteboards = useCallback(async () => { if (!workspaceId) return; try { - const params = new URLSearchParams({ workspaceId }); + const params = new URLSearchParams({ workspaceId, sortBy, sortOrder, page: String(page), limit: "24" }); if (creatorFilter !== "ALL") { params.set("createdById", creatorFilter); } @@ -129,13 +190,16 @@ export default function WhiteboardsPage() { const data = await res.json(); if (data.success) { setWhiteboards(data.data); + if (data.pagination) { + setTotalPages(data.pagination.totalPages); + } } } catch (error) { console.error("Error loading whiteboards:", error); } finally { setLoading(false); } - }, [workspaceId, creatorFilter]); + }, [workspaceId, creatorFilter, sortBy, sortOrder, page]); // Load creator options once on mount useEffect(() => { @@ -237,6 +301,18 @@ export default function WhiteboardsPage() { showSearch={true} showAvatars={true} /> + handleSort("updatedAt", order)} + /> + handleSort("createdAt", order)} + /> {whiteboards.length === 0 ? ( @@ -341,6 +417,77 @@ export default function WhiteboardsPage() { )} + {/* Pagination */} + {totalPages > 1 && ( +
+
+ Page {page} of {totalPages} +
+ + + + { e.preventDefault(); goToPage(Math.max(1, page - 1)); }} + aria-disabled={page === 1} + className={page === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"} + /> + + {totalPages > 0 && ( + + { e.preventDefault(); goToPage(1); }} + isActive={page === 1} + className={page === 1 ? "pointer-events-none" : "cursor-pointer"} + > + 1 + + + )} + {page > 4 && totalPages > 7 && ( + + )} + {getPageRange(page, totalPages).map((pageNum) => ( + + { e.preventDefault(); goToPage(pageNum); }} + isActive={page === pageNum} + className={page === pageNum ? "pointer-events-none" : "cursor-pointer"} + > + {pageNum} + + + ))} + {page < totalPages - 3 && totalPages > 7 && ( + + )} + {totalPages > 1 && ( + + { e.preventDefault(); goToPage(totalPages); }} + isActive={page === totalPages} + className={page === totalPages ? "pointer-events-none" : "cursor-pointer"} + > + {totalPages} + + + )} + + { e.preventDefault(); goToPage(Math.min(totalPages, page + 1)); }} + aria-disabled={page >= totalPages} + className={page >= totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"} + /> + + + +
+ )} + Date: Mon, 6 Apr 2026 17:36:04 +0000 Subject: [PATCH 2/4] Generated with Hive: Add unit tests for WhiteboardsPage delete button functionality --- .../whiteboard/WhiteboardsPage.test.tsx | 284 ++++++++++++++++++ 1 file changed, 284 insertions(+) create mode 100644 src/__tests__/unit/components/whiteboard/WhiteboardsPage.test.tsx 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..425ab56c5e --- /dev/null +++ b/src/__tests__/unit/components/whiteboard/WhiteboardsPage.test.tsx @@ -0,0 +1,284 @@ +// @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"; + +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) =>
    {children}
, + 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(); + }); +}); From 6ab63f251b40ee0013cfd8612cfe14b337334b4a Mon Sep 17 00:00:00 2001 From: tomsmith8 Date: Mon, 6 Apr 2026 18:41:13 +0000 Subject: [PATCH 3/4] Fix: ensure WhiteboardsPage test triggers fresh CI run with useSearchParams mock --- .../unit/components/whiteboard/WhiteboardsPage.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/__tests__/unit/components/whiteboard/WhiteboardsPage.test.tsx b/src/__tests__/unit/components/whiteboard/WhiteboardsPage.test.tsx index 425ab56c5e..4c5c16c66f 100644 --- a/src/__tests__/unit/components/whiteboard/WhiteboardsPage.test.tsx +++ b/src/__tests__/unit/components/whiteboard/WhiteboardsPage.test.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +// Tests for WhiteboardsPage — delete button, pagination, and navigation import React from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; From 69ddaae9aba7ce66fb90be0eec53e079c67cbe08 Mon Sep 17 00:00:00 2001 From: tomsmith8 Date: Mon, 6 Apr 2026 18:56:16 +0000 Subject: [PATCH 4/4] Fix: retrigger CI for WhiteboardsPage useSearchParams mock fix --- .../unit/components/whiteboard/WhiteboardsPage.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__tests__/unit/components/whiteboard/WhiteboardsPage.test.tsx b/src/__tests__/unit/components/whiteboard/WhiteboardsPage.test.tsx index 4c5c16c66f..fa10a98e96 100644 --- a/src/__tests__/unit/components/whiteboard/WhiteboardsPage.test.tsx +++ b/src/__tests__/unit/components/whiteboard/WhiteboardsPage.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -// Tests for WhiteboardsPage — delete button, pagination, and navigation +// 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";