From a2531b1ce02ca0e3983a750c0fc681b00b09eb7e Mon Sep 17 00:00:00 2001 From: Moshfegh Hamedani Date: Mon, 23 Feb 2026 10:16:53 -0800 Subject: [PATCH] Add pagination --- client/src/pages/TicketsPage.test.tsx | 152 +++++++++++----- client/src/pages/TicketsTable.tsx | 242 ++++++++++++++++++-------- core/schemas/tickets.ts | 2 + server/src/routes/tickets.ts | 34 ++-- 4 files changed, 305 insertions(+), 125 deletions(-) diff --git a/client/src/pages/TicketsPage.test.tsx b/client/src/pages/TicketsPage.test.tsx index ee840b3..d27d678 100644 --- a/client/src/pages/TicketsPage.test.tsx +++ b/client/src/pages/TicketsPage.test.tsx @@ -1,4 +1,4 @@ -import { screen, waitFor, fireEvent } from "@testing-library/react"; +import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, it, expect, vi, beforeEach } from "vitest"; import axios from "axios"; @@ -39,9 +39,9 @@ const mockTickets = [ }, ]; -const defaultParams = { - params: { sortBy: "createdAt", sortOrder: "desc" }, -}; +function mockResponse(tickets = mockTickets, total = tickets.length) { + return { data: { tickets, total, page: 1, pageSize: 10 } }; +} beforeEach(() => { vi.resetAllMocks(); @@ -64,7 +64,7 @@ describe("TicketsPage", () => { }); it("should display tickets in a table after loading", async () => { - mockedAxios.get.mockResolvedValue({ data: { tickets: mockTickets } }); + mockedAxios.get.mockResolvedValue(mockResponse()); renderWithQuery(); await waitFor(() => { @@ -83,7 +83,7 @@ describe("TicketsPage", () => { }); it("should display sender name and email", async () => { - mockedAxios.get.mockResolvedValue({ data: { tickets: mockTickets } }); + mockedAxios.get.mockResolvedValue(mockResponse()); renderWithQuery(); await waitFor(() => { @@ -96,7 +96,7 @@ describe("TicketsPage", () => { }); it("should display status badges", async () => { - mockedAxios.get.mockResolvedValue({ data: { tickets: mockTickets } }); + mockedAxios.get.mockResolvedValue(mockResponse()); renderWithQuery(); await waitFor(() => { @@ -108,7 +108,7 @@ describe("TicketsPage", () => { }); it("should display category with underscores replaced by spaces", async () => { - mockedAxios.get.mockResolvedValue({ data: { tickets: mockTickets } }); + mockedAxios.get.mockResolvedValue(mockResponse()); renderWithQuery(); await waitFor(() => { @@ -119,7 +119,7 @@ describe("TicketsPage", () => { }); it("should show dash for null category", async () => { - mockedAxios.get.mockResolvedValue({ data: { tickets: mockTickets } }); + mockedAxios.get.mockResolvedValue(mockResponse()); renderWithQuery(); await waitFor(() => { @@ -132,7 +132,7 @@ describe("TicketsPage", () => { }); it("should format createdAt as a locale date string", async () => { - mockedAxios.get.mockResolvedValue({ data: { tickets: [mockTickets[0]] } }); + mockedAxios.get.mockResolvedValue(mockResponse([mockTickets[0]])); renderWithQuery(); const expectedDate = new Date( @@ -168,7 +168,7 @@ describe("TicketsPage", () => { }); it("should render an empty table body when there are no tickets", async () => { - mockedAxios.get.mockResolvedValue({ data: { tickets: [] } }); + mockedAxios.get.mockResolvedValue(mockResponse([], 0)); renderWithQuery(); await waitFor(() => { @@ -179,23 +179,28 @@ describe("TicketsPage", () => { expect(screen.getByRole("table")).toBeInTheDocument(); expect(screen.getAllByRole("row")).toHaveLength(1); // header row only + expect(screen.getByText("No tickets")).toBeInTheDocument(); }); - it("should call axios.get with /api/tickets and default sort params", async () => { - mockedAxios.get.mockResolvedValue({ data: { tickets: [] } }); + it("should call axios.get with default sort and pagination params", async () => { + mockedAxios.get.mockResolvedValue(mockResponse([], 0)); renderWithQuery(); await waitFor(() => { - expect(mockedAxios.get).toHaveBeenCalledWith( - "/api/tickets", - defaultParams - ); + expect(mockedAxios.get).toHaveBeenCalledWith("/api/tickets", { + params: { + sortBy: "createdAt", + sortOrder: "desc", + page: 1, + pageSize: 10, + }, + }); }); }); it("should sort by column when clicking a column header", async () => { const user = userEvent.setup(); - mockedAxios.get.mockResolvedValue({ data: { tickets: mockTickets } }); + mockedAxios.get.mockResolvedValue(mockResponse()); renderWithQuery(); await waitFor(() => { @@ -205,20 +210,24 @@ describe("TicketsPage", () => { }); mockedAxios.get.mockClear(); - mockedAxios.get.mockResolvedValue({ data: { tickets: mockTickets } }); + mockedAxios.get.mockResolvedValue(mockResponse()); await user.click(screen.getByRole("button", { name: /Subject/ })); await waitFor(() => { expect(mockedAxios.get).toHaveBeenCalledWith("/api/tickets", { - params: { sortBy: "subject", sortOrder: "asc" }, + params: expect.objectContaining({ + sortBy: "subject", + sortOrder: "asc", + page: 1, + }), }); }); }); it("should toggle sort order when clicking the same column header twice", async () => { const user = userEvent.setup(); - mockedAxios.get.mockResolvedValue({ data: { tickets: mockTickets } }); + mockedAxios.get.mockResolvedValue(mockResponse()); renderWithQuery(); await waitFor(() => { @@ -228,26 +237,30 @@ describe("TicketsPage", () => { }); mockedAxios.get.mockClear(); - mockedAxios.get.mockResolvedValue({ data: { tickets: mockTickets } }); + mockedAxios.get.mockResolvedValue(mockResponse()); - // First click: sort by Subject ascending await user.click(screen.getByRole("button", { name: /Subject/ })); await waitFor(() => { expect(mockedAxios.get).toHaveBeenCalledWith("/api/tickets", { - params: { sortBy: "subject", sortOrder: "asc" }, + params: expect.objectContaining({ + sortBy: "subject", + sortOrder: "asc", + }), }); }); mockedAxios.get.mockClear(); - mockedAxios.get.mockResolvedValue({ data: { tickets: mockTickets } }); + mockedAxios.get.mockResolvedValue(mockResponse()); - // Second click: sort by Subject descending await user.click(screen.getByRole("button", { name: /Subject/ })); await waitFor(() => { expect(mockedAxios.get).toHaveBeenCalledWith("/api/tickets", { - params: { sortBy: "subject", sortOrder: "desc" }, + params: expect.objectContaining({ + sortBy: "subject", + sortOrder: "desc", + }), }); }); }); @@ -265,7 +278,7 @@ describe("TicketsPage", () => { it("should send search param when typing in the search input", async () => { const user = userEvent.setup(); - mockedAxios.get.mockResolvedValue({ data: { tickets: mockTickets } }); + mockedAxios.get.mockResolvedValue(mockResponse()); renderWithQuery(); await waitFor(() => { @@ -275,7 +288,7 @@ describe("TicketsPage", () => { }); mockedAxios.get.mockClear(); - mockedAxios.get.mockResolvedValue({ data: { tickets: [mockTickets[0]] } }); + mockedAxios.get.mockResolvedValue(mockResponse([mockTickets[0]])); await user.type( screen.getByPlaceholderText("Search tickets..."), @@ -290,10 +303,8 @@ describe("TicketsPage", () => { }); it("should include status filter in API request", async () => { - mockedAxios.get.mockResolvedValue({ data: { tickets: [mockTickets[0]] } }); - renderWithQuery( - - ); + mockedAxios.get.mockResolvedValue(mockResponse([mockTickets[0]])); + renderWithQuery(); await waitFor(() => { expect(mockedAxios.get).toHaveBeenCalledWith("/api/tickets", { @@ -303,10 +314,8 @@ describe("TicketsPage", () => { }); it("should include category filter in API request", async () => { - mockedAxios.get.mockResolvedValue({ data: { tickets: [mockTickets[1]] } }); - renderWithQuery( - - ); + mockedAxios.get.mockResolvedValue(mockResponse([mockTickets[1]])); + renderWithQuery(); await waitFor(() => { expect(mockedAxios.get).toHaveBeenCalledWith("/api/tickets", { @@ -316,10 +325,8 @@ describe("TicketsPage", () => { }); it("should include search filter in API request", async () => { - mockedAxios.get.mockResolvedValue({ data: { tickets: [mockTickets[0]] } }); - renderWithQuery( - - ); + mockedAxios.get.mockResolvedValue(mockResponse([mockTickets[0]])); + renderWithQuery(); await waitFor(() => { expect(mockedAxios.get).toHaveBeenCalledWith("/api/tickets", { @@ -327,4 +334,67 @@ describe("TicketsPage", () => { }); }); }); + + it("should display pagination info and controls", async () => { + mockedAxios.get.mockResolvedValue(mockResponse(mockTickets, 50)); + renderWithQuery(); + + await waitFor(() => { + expect( + screen.getByText("Showing 1–10 of 50 tickets") + ).toBeInTheDocument(); + }); + + expect(screen.getByText("Page 1 of 5")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "First page" })).toBeDisabled(); + expect( + screen.getByRole("button", { name: "Previous page" }) + ).toBeDisabled(); + expect( + screen.getByRole("button", { name: "Next page" }) + ).toBeEnabled(); + expect(screen.getByRole("button", { name: "Last page" })).toBeEnabled(); + }); + + it("should fetch page 2 when clicking the next page button", async () => { + const user = userEvent.setup(); + mockedAxios.get.mockResolvedValue(mockResponse(mockTickets, 50)); + renderWithQuery(); + + await waitFor(() => { + expect( + screen.getByText("Showing 1–10 of 50 tickets") + ).toBeInTheDocument(); + }); + + mockedAxios.get.mockClear(); + mockedAxios.get.mockResolvedValue({ + data: { tickets: mockTickets, total: 50, page: 2, pageSize: 10 }, + }); + + await user.click(screen.getByRole("button", { name: "Next page" })); + + await waitFor(() => { + expect(mockedAxios.get).toHaveBeenCalledWith("/api/tickets", { + params: expect.objectContaining({ page: 2, pageSize: 10 }), + }); + }); + }); + + it("should disable all pagination buttons on the last page", async () => { + mockedAxios.get.mockResolvedValue(mockResponse(mockTickets, 3)); + renderWithQuery(); + + await waitFor(() => { + expect(screen.getByText("Showing 1–3 of 3 tickets")).toBeInTheDocument(); + }); + + expect(screen.getByText("Page 1 of 1")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "First page" })).toBeDisabled(); + expect( + screen.getByRole("button", { name: "Previous page" }) + ).toBeDisabled(); + expect(screen.getByRole("button", { name: "Next page" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Last page" })).toBeDisabled(); + }); }); diff --git a/client/src/pages/TicketsTable.tsx b/client/src/pages/TicketsTable.tsx index d3bc126..12089be 100644 --- a/client/src/pages/TicketsTable.tsx +++ b/client/src/pages/TicketsTable.tsx @@ -1,9 +1,10 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import axios from "axios"; import { type ColumnDef, type SortingState, + type PaginationState, useReactTable, getCoreRowModel, flexRender, @@ -22,7 +23,16 @@ import { TableRow, } from "@/components/ui/table"; import { Skeleton } from "@/components/ui/skeleton"; -import { AlertCircle, ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react"; +import { + AlertCircle, + ArrowDown, + ArrowUp, + ArrowUpDown, + ChevronLeft, + ChevronRight, + ChevronsLeft, + ChevronsRight, +} from "lucide-react"; import type { TicketFilters } from "./TicketsPage"; interface Ticket { @@ -35,6 +45,13 @@ interface Ticket { createdAt: string; } +interface TicketsResponse { + tickets: Ticket[]; + total: number; + page: number; + pageSize: number; +} + const statusVariant: Record = { open: "default", @@ -88,35 +105,60 @@ const columns: ColumnDef[] = [ }, ]; +const PAGE_SIZE = 10; + export default function TicketsTable({ filters }: { filters: TicketFilters }) { const [sorting, setSorting] = useState([ { id: "createdAt", desc: true }, ]); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: PAGE_SIZE, + }); + + useEffect(() => { + setPagination((prev) => ({ ...prev, pageIndex: 0 })); + }, [filters]); const sortBy = sorting[0]?.id ?? "createdAt"; const sortOrder = sorting[0]?.desc ?? true ? "desc" : "asc"; const { - data: tickets, + data, isLoading, error, } = useQuery({ - queryKey: ["tickets", sortBy, sortOrder, filters], + queryKey: ["tickets", sortBy, sortOrder, filters, pagination.pageIndex], queryFn: async () => { - const { data } = await axios.get<{ tickets: Ticket[] }>("/api/tickets", { - params: { sortBy, sortOrder, ...filters }, + const { data } = await axios.get("/api/tickets", { + params: { + sortBy, + sortOrder, + ...filters, + page: pagination.pageIndex + 1, + pageSize: pagination.pageSize, + }, }); - return data.tickets; + return data; }, }); + const total = data?.total ?? 0; + const pageCount = Math.ceil(total / pagination.pageSize); + const table = useReactTable({ - data: tickets ?? [], + data: data?.tickets ?? [], columns, - state: { sorting }, - onSortingChange: setSorting, + state: { sorting, pagination }, + onSortingChange: (updater) => { + setSorting(updater); + setPagination((prev) => ({ ...prev, pageIndex: 0 })); + }, + onPaginationChange: setPagination, manualSorting: true, + manualPagination: true, enableMultiSort: false, + pageCount, getCoreRowModel: getCoreRowModel(), }); @@ -130,66 +172,126 @@ export default function TicketsTable({ filters }: { filters: TicketFilters }) { } return ( - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - - - ))} - - ))} - - - {isLoading - ? Array.from({ length: 5 }).map((_, i) => ( - - - - - - - - - - - - - - - - - - )) - : table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + + + ))} + + ))} + + + {isLoading + ? Array.from({ length: 5 }).map((_, i) => ( + + + - ))} - - ))} - -
+ + + + + + + + + + + + + + )) + : table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + ))} + + + + {!isLoading && !error && ( +
+

+ {total === 0 + ? "No tickets" + : `Showing ${pagination.pageIndex * pagination.pageSize + 1}–${Math.min((pagination.pageIndex + 1) * pagination.pageSize, total)} of ${total} tickets`} +

+
+ + + + Page {pagination.pageIndex + 1} of {pageCount || 1} + + + +
+
+ )} + ); } diff --git a/core/schemas/tickets.ts b/core/schemas/tickets.ts index d384062..12cebd7 100644 --- a/core/schemas/tickets.ts +++ b/core/schemas/tickets.ts @@ -28,4 +28,6 @@ export const ticketListQuerySchema = z.object({ status: z.enum(ticketStatuses).optional(), category: z.enum(ticketCategories).optional(), search: z.string().optional(), + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(100).default(10), }); diff --git a/server/src/routes/tickets.ts b/server/src/routes/tickets.ts index c52cd31..0356b74 100644 --- a/server/src/routes/tickets.ts +++ b/server/src/routes/tickets.ts @@ -29,20 +29,26 @@ router.get("/", requireAuth, async (req, res) => { ]; } - const tickets = await prisma.ticket.findMany({ - select: { - id: true, - subject: true, - status: true, - category: true, - senderName: true, - senderEmail: true, - createdAt: true, - }, - where, - orderBy: { [query.sortBy]: query.sortOrder }, - }); - res.json({ tickets }); + const [tickets, total] = await Promise.all([ + prisma.ticket.findMany({ + select: { + id: true, + subject: true, + status: true, + category: true, + senderName: true, + senderEmail: true, + createdAt: true, + }, + where, + orderBy: { [query.sortBy]: query.sortOrder }, + skip: (query.page - 1) * query.pageSize, + take: query.pageSize, + }), + prisma.ticket.count({ where }), + ]); + + res.json({ tickets, total, page: query.page, pageSize: query.pageSize }); }); export default router;