diff --git a/admin/app/components/Pagination.tsx b/admin/app/components/Pagination.tsx new file mode 100644 index 00000000..c47bb2eb --- /dev/null +++ b/admin/app/components/Pagination.tsx @@ -0,0 +1,146 @@ +'use client'; + +import React from 'react'; + +export interface PaginationProps { + /** Current active page (1-indexed) */ + currentPage: number; + /** Total number of items */ + totalItems: number; + /** Number of items per page */ + pageSize: number; + /** Callback when page changes */ + onPageChange: (page: number) => void; + /** Callback when page size changes */ + onPageSizeChange?: (pageSize: number) => void; + /** Available page sizes */ + pageSizeOptions?: number[]; + /** Additional CSS classes */ + className?: string; +} + +const Pagination: React.FC = ({ + currentPage, + totalItems, + pageSize, + onPageChange, + onPageSizeChange, + pageSizeOptions = [10, 20, 50, 100], + className = '', +}) => { + const totalPages = Math.max(1, Math.ceil(totalItems / pageSize)); + + // Ensure current page is within bounds + const activePage = Math.min(Math.max(1, currentPage), totalPages); + + const handlePageChange = (page: number) => { + if (page >= 1 && page <= totalPages && page !== activePage) { + onPageChange(page); + } + }; + + const renderPageButtons = () => { + const buttons = []; + const maxVisiblePages = 5; + + let startPage = Math.max(1, activePage - Math.floor(maxVisiblePages / 2)); + let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1); + + if (endPage - startPage + 1 < maxVisiblePages) { + startPage = Math.max(1, endPage - maxVisiblePages + 1); + } + + for (let i = startPage; i <= endPage; i++) { + buttons.push( + + ); + } + return buttons; + }; + + return ( +
+
+ Page {activePage} of {totalPages} + ({totalItems} items) +
+ +
+ + + +
+ {renderPageButtons()} +
+ + + +
+ + {onPageSizeChange && ( +
+ + +
+ )} +
+ ); +}; + +export default Pagination; diff --git a/admin/app/components/__tests__/Pagination.test.tsx b/admin/app/components/__tests__/Pagination.test.tsx new file mode 100644 index 00000000..b3ad2126 --- /dev/null +++ b/admin/app/components/__tests__/Pagination.test.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import Pagination from '../Pagination'; + +describe('Pagination', () => { + const defaultProps = { + currentPage: 1, + totalItems: 100, + pageSize: 10, + onPageChange: jest.fn(), + onPageSizeChange: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correct page information', () => { + render(); + expect(screen.getByText('Page 1 of 10')).toBeInTheDocument(); + expect(screen.getByText('(100 items)')).toBeInTheDocument(); + }); + + it('renders page number buttons', () => { + render(); + // Should show pages 1 to 5 by default (maxVisiblePages is 5) + expect(screen.getByText('1')).toBeInTheDocument(); + expect(screen.getByText('5')).toBeInTheDocument(); + expect(screen.queryByText('6')).not.toBeInTheDocument(); + }); + + it('calls onPageChange when a page button is clicked', () => { + render(); + fireEvent.click(screen.getByText('2')); + expect(defaultProps.onPageChange).toHaveBeenCalledWith(2); + }); + + it('disables previous and first buttons on the first page', () => { + render(); + expect(screen.getByLabelText('First page')).toBeDisabled(); + expect(screen.getByLabelText('Previous page')).toBeDisabled(); + expect(screen.getByLabelText('Next page')).not.toBeDisabled(); + expect(screen.getByLabelText('Last page')).not.toBeDisabled(); + }); + + it('disables next and last buttons on the last page', () => { + render(); + expect(screen.getByLabelText('First page')).not.toBeDisabled(); + expect(screen.getByLabelText('Previous page')).not.toBeDisabled(); + expect(screen.getByLabelText('Next page')).toBeDisabled(); + expect(screen.getByLabelText('Last page')).toBeDisabled(); + }); + + it('calls onPageChange with 1 when first button is clicked', () => { + render(); + fireEvent.click(screen.getByLabelText('First page')); + expect(defaultProps.onPageChange).toHaveBeenCalledWith(1); + }); + + it('calls onPageChange with last page when last button is clicked', () => { + render(); + fireEvent.click(screen.getByLabelText('Last page')); + expect(defaultProps.onPageChange).toHaveBeenCalledWith(10); + }); + + it('calls onPageChange with previous page when previous button is clicked', () => { + render(); + fireEvent.click(screen.getByLabelText('Previous page')); + expect(defaultProps.onPageChange).toHaveBeenCalledWith(4); + }); + + it('calls onPageChange with next page when next button is clicked', () => { + render(); + fireEvent.click(screen.getByLabelText('Next page')); + expect(defaultProps.onPageChange).toHaveBeenCalledWith(6); + }); + + it('calls onPageSizeChange when page size selector is changed', () => { + render(); + const select = screen.getByLabelText('Rows per page:'); + fireEvent.change(select, { target: { value: '20' } }); + expect(defaultProps.onPageSizeChange).toHaveBeenCalledWith(20); + }); + + it('highlights the current page button', () => { + render(); + const activeBtn = screen.getByText('3'); + expect(activeBtn).toHaveClass('bg-blue-600'); + expect(activeBtn).toHaveAttribute('aria-current', 'page'); + expect(activeBtn).toHaveAttribute('aria-label', 'Go to page 3'); + }); + + it('handles custom page size options', () => { + const pageSizeOptions = [5, 10, 15]; + render(); + const select = screen.getByLabelText('Rows per page:'); + expect(select.querySelectorAll('option')).toHaveLength(3); + expect(screen.getByText('5')).toBeInTheDocument(); + expect(screen.getByText('15')).toBeInTheDocument(); + }); +}); diff --git a/admin/app/pagination-demo/page.tsx b/admin/app/pagination-demo/page.tsx new file mode 100644 index 00000000..4b5bbf92 --- /dev/null +++ b/admin/app/pagination-demo/page.tsx @@ -0,0 +1,90 @@ +'use client'; + +import React, { useState } from 'react'; +import { Pagination } from '../components'; + +export default function PaginationDemo() { + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const totalItems = 250; + + const handlePageChange = (page: number) => { + console.log('Page changed to:', page); + setCurrentPage(page); + }; + + const handlePageSizeChange = (size: number) => { + console.log('Page size changed to:', size); + setPageSize(size); + setCurrentPage(1); // Reset to first page when page size changes + }; + + return ( +
+
+

Pagination Component Demo

+ +
+
+

Default Pagination

+
+ +
+
+ +
+

Current State

+
+
+

Current Page:

+

{currentPage}

+
+
+

Page Size:

+

{pageSize}

+
+
+

Total Items:

+

{totalItems}

+
+
+

Total Pages:

+

{Math.ceil(totalItems / pageSize)}

+
+
+
+ +
+

Pagination without Page Size Selector

+
+ +
+
+ +
+

Small Dataset (1 page)

+
+ {}} + /> +
+
+
+
+
+ ); +} 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/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",