diff --git a/django-backend/soroscan/middleware.py b/django-backend/soroscan/middleware.py index 46e07fda0..84226b27e 100644 --- a/django-backend/soroscan/middleware.py +++ b/django-backend/soroscan/middleware.py @@ -151,4 +151,4 @@ def __call__(self, request): response["Sunset"] = config.get("sunset", "") response["Link"] = f'<{config.get("replacement", "")}>; rel="replacement"' break - return response \ No newline at end of file + return response diff --git a/django-backend/soroscan/settings_test.py b/django-backend/soroscan/settings_test.py index 16aa4462f..81824243e 100644 --- a/django-backend/soroscan/settings_test.py +++ b/django-backend/soroscan/settings_test.py @@ -40,6 +40,7 @@ "corsheaders.middleware.CorsMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "soroscan.middleware.RequestIdMiddleware", + "soroscan.middleware.Json404Middleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", diff --git a/soroscan-frontend/__tests__/pagination.test.tsx b/soroscan-frontend/__tests__/pagination.test.tsx new file mode 100644 index 000000000..92dc033b1 --- /dev/null +++ b/soroscan-frontend/__tests__/pagination.test.tsx @@ -0,0 +1,135 @@ +import { fireEvent, render, screen } from "@testing-library/react" +import "@testing-library/jest-dom" +import { Pagination } from "@/src/components/ui/Pagination" + +describe("Pagination", () => { + it("renders current and total page count", () => { + render( + + ) + + expect(screen.getByText("Page 3 of 12")).toBeInTheDocument() + }) + + it("disables First and Previous on the first page", () => { + render( + + ) + + expect(screen.getByRole("button", { name: "First page" })).toBeDisabled() + expect(screen.getByRole("button", { name: "Previous page" })).toBeDisabled() + }) + + it("disables Next and Last on the final page", () => { + render( + + ) + + expect(screen.getByRole("button", { name: "Next page" })).toBeDisabled() + expect(screen.getByRole("button", { name: "Last page" })).toBeDisabled() + }) + + it("calls callbacks for first, previous, next, and last actions", () => { + const onPageChange = jest.fn() + + render( + + ) + + fireEvent.click(screen.getByRole("button", { name: "First page" })) + fireEvent.click(screen.getByRole("button", { name: "Previous page" })) + fireEvent.click(screen.getByRole("button", { name: "Next page" })) + fireEvent.click(screen.getByRole("button", { name: "Last page" })) + + expect(onPageChange).toHaveBeenNthCalledWith(1, 1) + expect(onPageChange).toHaveBeenNthCalledWith(2, 4) + expect(onPageChange).toHaveBeenNthCalledWith(3, 6) + expect(onPageChange).toHaveBeenNthCalledWith(4, 10) + }) + + it("supports direct page number jumping", () => { + const onPageChange = jest.fn() + + render( + + ) + + fireEvent.click(screen.getByRole("button", { name: "Go to page 4" })) + expect(onPageChange).toHaveBeenCalledWith(4) + }) + + it("marks active page with aria-current=page", () => { + render( + + ) + + expect(screen.getByRole("button", { name: "Go to page 5" })).toHaveAttribute("aria-current", "page") + }) + + it("renders ellipsis for large page ranges", () => { + render( + + ) + + expect(screen.getAllByText("...").length).toBeGreaterThan(0) + }) + + it("calls onPageSizeChange when selecting page size", () => { + const onPageSizeChange = jest.fn() + + render( + + ) + + fireEvent.change(screen.getByLabelText("Page size"), { target: { value: "20" } }) + expect(onPageSizeChange).toHaveBeenCalledWith(20) + }) +}) diff --git a/soroscan-frontend/src/components/ui/Pagination.tsx b/soroscan-frontend/src/components/ui/Pagination.tsx new file mode 100644 index 000000000..7ac3f78e5 --- /dev/null +++ b/soroscan-frontend/src/components/ui/Pagination.tsx @@ -0,0 +1,193 @@ +"use client" + +import * as React from "react" +import { cn } from "@/lib/utils" + +type PaginationToken = number | "ellipsis" + +export interface PaginationProps { + totalItems: number + pageSize: number + currentPage: number + onPageChange: (page: number) => void + onPageSizeChange: (pageSize: number) => void + pageSizeOptions?: number[] + maxVisiblePages?: number + className?: string +} + +function clampPage(page: number, totalPages: number): number { + if (totalPages <= 0) { + return 1 + } + + if (page < 1) { + return 1 + } + + if (page > totalPages) { + return totalPages + } + + return page +} + +function getPaginationRange(currentPage: number, totalPages: number, maxVisiblePages: number): PaginationToken[] { + if (totalPages <= maxVisiblePages) { + return Array.from({ length: totalPages }, (_, i) => i + 1) + } + + const safeMaxVisible = Math.max(5, maxVisiblePages) + const siblingCount = Math.max(1, Math.floor((safeMaxVisible - 3) / 2)) + const leftSiblingIndex = Math.max(currentPage - siblingCount, 1) + const rightSiblingIndex = Math.min(currentPage + siblingCount, totalPages) + + const showLeftEllipsis = leftSiblingIndex > 2 + const showRightEllipsis = rightSiblingIndex < totalPages - 1 + + if (!showLeftEllipsis && showRightEllipsis) { + const leftItemCount = safeMaxVisible - 2 + const leftRange = Array.from({ length: leftItemCount }, (_, i) => i + 1) + return [...leftRange, "ellipsis", totalPages] + } + + if (showLeftEllipsis && !showRightEllipsis) { + const rightItemCount = safeMaxVisible - 2 + const start = totalPages - rightItemCount + 1 + const rightRange = Array.from({ length: rightItemCount }, (_, i) => start + i) + return [1, "ellipsis", ...rightRange] + } + + const middleRange = Array.from( + { length: rightSiblingIndex - leftSiblingIndex + 1 }, + (_, i) => leftSiblingIndex + i + ) + + return [1, "ellipsis", ...middleRange, "ellipsis", totalPages] +} + +export function Pagination({ + totalItems, + pageSize, + currentPage, + onPageChange, + onPageSizeChange, + pageSizeOptions = [10, 20, 50], + maxVisiblePages = 5, + className, +}: PaginationProps) { + const totalPages = Math.max(1, Math.ceil(totalItems / pageSize)) + const safeCurrentPage = clampPage(currentPage, totalPages) + + const pageRange = React.useMemo( + () => getPaginationRange(safeCurrentPage, totalPages, maxVisiblePages), + [safeCurrentPage, totalPages, maxVisiblePages] + ) + + const isFirstPage = safeCurrentPage === 1 + const isLastPage = safeCurrentPage === totalPages + + return ( + + ) +} + +export { getPaginationRange }