diff --git a/soroscan-frontend/__tests__/ui-pagination.test.tsx b/soroscan-frontend/__tests__/ui-pagination.test.tsx new file mode 100644 index 00000000..580ff899 --- /dev/null +++ b/soroscan-frontend/__tests__/ui-pagination.test.tsx @@ -0,0 +1,117 @@ +import * as React from "react" +import { render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { Pagination } from "../components/ui/pagination" +import "@testing-library/jest-dom" + +describe("Pagination Component", () => { + it("should render correct number of pages when totalPages <= 7", () => { + const onPageChange = jest.fn() + render() + + expect(screen.getByRole("button", { name: "Page 1" })).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Go to page 5" })).toBeInTheDocument() + // 5 page buttons + 4 navigation buttons = 9 buttons + expect(screen.getAllByRole("button")).toHaveLength(9) + }) + + it("should disable First and Previous buttons on the first page", () => { + const onPageChange = jest.fn() + render() + + expect(screen.getByRole("button", { name: "Go to first page" })).toBeDisabled() + expect(screen.getByRole("button", { name: "Go to previous page" })).toBeDisabled() + expect(screen.getByRole("button", { name: "Go to next page" })).not.toBeDisabled() + expect(screen.getByRole("button", { name: "Go to last page" })).not.toBeDisabled() + }) + + it("should disable Next and Last buttons on the last page", () => { + const onPageChange = jest.fn() + render() + + expect(screen.getByRole("button", { name: "Go to first page" })).not.toBeDisabled() + expect(screen.getByRole("button", { name: "Go to previous page" })).not.toBeDisabled() + expect(screen.getByRole("button", { name: "Go to next page" })).toBeDisabled() + expect(screen.getByRole("button", { name: "Go to last page" })).toBeDisabled() + }) + + it("should call onPageChange with correct page number when a page button is clicked", async () => { + const user = userEvent.setup() + const onPageChange = jest.fn() + render() + + await user.click(screen.getByRole("button", { name: "Go to page 3" })) + expect(onPageChange).toHaveBeenCalledWith(3) + }) + + it("should call onPageChange with correct page number when navigation buttons are clicked", async () => { + const user = userEvent.setup() + const onPageChange = jest.fn() + render() + + await user.click(screen.getByRole("button", { name: "Go to first page" })) + expect(onPageChange).toHaveBeenCalledWith(1) + + await user.click(screen.getByRole("button", { name: "Go to previous page" })) + expect(onPageChange).toHaveBeenCalledWith(4) + + await user.click(screen.getByRole("button", { name: "Go to next page" })) + expect(onPageChange).toHaveBeenCalledWith(6) + + await user.click(screen.getByRole("button", { name: "Go to last page" })) + expect(onPageChange).toHaveBeenCalledWith(10) + }) + + it("should render page size selector if pageSize and onPageSizeChange are provided", () => { + const onPageChange = jest.fn() + const onPageSizeChange = jest.fn() + render( + + ) + + expect(screen.getByRole("combobox", { name: "Select page size" })).toBeInTheDocument() + expect(screen.getByText("20 / page")).toBeInTheDocument() + }) + + it("should call onPageSizeChange when a new page size is selected", async () => { + const user = userEvent.setup() + const onPageChange = jest.fn() + const onPageSizeChange = jest.fn() + render( + + ) + + // Open dropdown + const combobox = screen.getByRole("combobox", { name: "Select page size" }) + await user.click(combobox) + + // Select 50 / page + const option = screen.getByRole("option", { name: "50 / page" }) + await user.click(option) + + expect(onPageSizeChange).toHaveBeenCalledWith(50) + }) + + it("should apply proper ARIA attributes to the current page", () => { + const onPageChange = jest.fn() + render() + + const activePage = screen.getByRole("button", { name: "Page 3" }) + expect(activePage).toHaveAttribute("aria-current", "page") + + const inactivePage = screen.getByRole("button", { name: "Go to page 4" }) + expect(inactivePage).not.toHaveAttribute("aria-current") + }) +}) diff --git a/soroscan-frontend/components/ui/pagination.tsx b/soroscan-frontend/components/ui/pagination.tsx new file mode 100644 index 00000000..413d65ac --- /dev/null +++ b/soroscan-frontend/components/ui/pagination.tsx @@ -0,0 +1,160 @@ +import * as React from "react" +import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, MoreHorizontal } from "lucide-react" +import { cn } from "@/lib/utils" +import { Button } from "./button" +import { Dropdown } from "./dropdown" + +export interface PaginationProps extends React.HTMLAttributes { + currentPage: number + totalPages: number + onPageChange: (page: number) => void + pageSize?: number + onPageSizeChange?: (size: number) => void + pageSizeOptions?: number[] +} + +export function Pagination({ + currentPage, + totalPages, + onPageChange, + pageSize, + onPageSizeChange, + pageSizeOptions = [10, 20, 50, 100], + className, + ...props +}: PaginationProps) { + // Page number generation logic + const getPages = () => { + if (totalPages <= 7) { + return Array.from({ length: totalPages }, (_, i) => i + 1) + } + + if (currentPage <= 4) { + return [1, 2, 3, 4, 5, "ellipsis1", totalPages] + } + + if (currentPage >= totalPages - 3) { + return [ + 1, + "ellipsis1", + totalPages - 4, + totalPages - 3, + totalPages - 2, + totalPages - 1, + totalPages, + ] + } + + return [ + 1, + "ellipsis1", + currentPage - 1, + currentPage, + currentPage + 1, + "ellipsis2", + totalPages, + ] + } + + const pages = getPages() + + const dropdownOptions = pageSizeOptions.map((size) => ({ + label: `${size} / page`, + value: size.toString(), + })) + + return ( + + ) +} diff --git a/soroscan-frontend/package-lock.json b/soroscan-frontend/package-lock.json index 2755973e..f2da12d3 100644 --- a/soroscan-frontend/package-lock.json +++ b/soroscan-frontend/package-lock.json @@ -33,6 +33,7 @@ "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/jest": "^30.0.0", "@types/node": "^20", "@types/react": "^19", @@ -6242,6 +6243,70 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.8.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.8.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", @@ -6355,6 +6420,20 @@ } } }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@theguild/federation-composition": { "version": "0.21.3", "dev": true, diff --git a/soroscan-frontend/package.json b/soroscan-frontend/package.json index b526c64f..f8cc756b 100644 --- a/soroscan-frontend/package.json +++ b/soroscan-frontend/package.json @@ -39,6 +39,7 @@ "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/jest": "^30.0.0", "@types/node": "^20", "@types/react": "^19", diff --git a/soroscan-frontend/tsconfig.json b/soroscan-frontend/tsconfig.json index c5b56380..3a13f90a 100644 --- a/soroscan-frontend/tsconfig.json +++ b/soroscan-frontend/tsconfig.json @@ -20,8 +20,7 @@ ], "paths": { "@/*": ["./*"] - }, - "types": ["jest", "node"] + } }, "include": [ "next-env.d.ts",