Add pagination

This commit is contained in:
Moshfegh Hamedani 2026-02-23 10:16:53 -08:00
parent 77abd55f44
commit a2531b1ce0
4 changed files with 305 additions and 125 deletions

View file

@ -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(<TicketsPage />);
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(<TicketsPage />);
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(<TicketsPage />);
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(<TicketsPage />);
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(<TicketsPage />);
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(<TicketsPage />);
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(<TicketsPage />);
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(<TicketsPage />);
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(<TicketsPage />);
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(<TicketsPage />);
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(<TicketsPage />);
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(
<TicketsTable filters={{ status: "open" }} />
);
mockedAxios.get.mockResolvedValue(mockResponse([mockTickets[0]]));
renderWithQuery(<TicketsTable filters={{ status: "open" }} />);
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(
<TicketsTable filters={{ category: "refund_request" }} />
);
mockedAxios.get.mockResolvedValue(mockResponse([mockTickets[1]]));
renderWithQuery(<TicketsTable filters={{ category: "refund_request" }} />);
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(
<TicketsTable filters={{ search: "login" }} />
);
mockedAxios.get.mockResolvedValue(mockResponse([mockTickets[0]]));
renderWithQuery(<TicketsTable filters={{ search: "login" }} />);
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(<TicketsPage />);
await waitFor(() => {
expect(
screen.getByText("Showing 110 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(<TicketsPage />);
await waitFor(() => {
expect(
screen.getByText("Showing 110 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(<TicketsPage />);
await waitFor(() => {
expect(screen.getByText("Showing 13 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();
});
});

View file

@ -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<TicketStatus, "default" | "secondary" | "outline"> =
{
open: "default",
@ -88,35 +105,60 @@ const columns: ColumnDef<Ticket>[] = [
},
];
const PAGE_SIZE = 10;
export default function TicketsTable({ filters }: { filters: TicketFilters }) {
const [sorting, setSorting] = useState<SortingState>([
{ id: "createdAt", desc: true },
]);
const [pagination, setPagination] = useState<PaginationState>({
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<TicketsResponse>("/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,6 +172,7 @@ export default function TicketsTable({ filters }: { filters: TicketFilters }) {
}
return (
<div>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
@ -184,12 +227,71 @@ export default function TicketsTable({ filters }: { filters: TicketFilters }) {
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
{!isLoading && !error && (
<div className="flex items-center justify-between mt-4">
<p className="text-sm text-muted-foreground">
{total === 0
? "No tickets"
: `Showing ${pagination.pageIndex * pagination.pageSize + 1}${Math.min((pagination.pageIndex + 1) * pagination.pageSize, total)} of ${total} tickets`}
</p>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => table.firstPage()}
disabled={!table.getCanPreviousPage()}
aria-label="First page"
>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
aria-label="Previous page"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm px-2">
Page {pagination.pageIndex + 1} of {pageCount || 1}
</span>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
aria-label="Next page"
>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => table.lastPage()}
disabled={!table.getCanNextPage()}
aria-label="Last page"
>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
);
}

View file

@ -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),
});

View file

@ -29,7 +29,8 @@ router.get("/", requireAuth, async (req, res) => {
];
}
const tickets = await prisma.ticket.findMany({
const [tickets, total] = await Promise.all([
prisma.ticket.findMany({
select: {
id: true,
subject: true,
@ -41,8 +42,13 @@ router.get("/", requireAuth, async (req, res) => {
},
where,
orderBy: { [query.sortBy]: query.sortOrder },
});
res.json({ tickets });
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;