Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions admin/app/components/Pagination.tsx
Original file line number Diff line number Diff line change
@@ -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<PaginationProps> = ({
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(
<button
key={i}
onClick={() => handlePageChange(i)}
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors
${i === activePage
? 'bg-blue-600 text-white'
: 'text-gray-700 hover:bg-gray-100'}`}
aria-current={i === activePage ? 'page' : undefined}
aria-label={`Go to page ${i}`}
>
{i}
</button>
);
}
return buttons;
};

return (
<div className={`flex flex-col sm:flex-row items-center justify-between gap-4 p-4 ${className}`}>
<div className="flex items-center gap-2 text-sm text-gray-600">
<span>Page {activePage} of {totalPages}</span>
<span className="hidden sm:inline">({totalItems} items)</span>
</div>

<div className="flex items-center gap-1">
<button
onClick={() => handlePageChange(1)}
disabled={activePage === 1}
className="p-2 rounded-md text-gray-500 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed border border-transparent"
aria-label="First page"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
</svg>
</button>
<button
onClick={() => handlePageChange(activePage - 1)}
disabled={activePage === 1}
className="p-2 rounded-md text-gray-500 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed border border-transparent"
aria-label="Previous page"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>

<div className="flex items-center gap-1">
{renderPageButtons()}
</div>

<button
onClick={() => handlePageChange(activePage + 1)}
disabled={activePage === totalPages}
className="p-2 rounded-md text-gray-500 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed border border-transparent"
aria-label="Next page"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
<button
onClick={() => handlePageChange(totalPages)}
disabled={activePage === totalPages}
className="p-2 rounded-md text-gray-500 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed border border-transparent"
aria-label="Last page"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
</svg>
</button>
</div>

{onPageSizeChange && (
<div className="flex items-center gap-2">
<label htmlFor="page-size" className="text-sm text-gray-600">Rows per page:</label>
<select
id="page-size"
value={pageSize}
onChange={(e) => onPageSizeChange(Number(e.target.value))}
className="text-sm border rounded-md px-2 py-1 bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{pageSizeOptions.map(option => (
<option key={option} value={option}>{option}</option>
))}
</select>
</div>
)}
</div>
);
};

export default Pagination;
102 changes: 102 additions & 0 deletions admin/app/components/__tests__/Pagination.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Pagination {...defaultProps} />);
expect(screen.getByText('Page 1 of 10')).toBeInTheDocument();
expect(screen.getByText('(100 items)')).toBeInTheDocument();
});

it('renders page number buttons', () => {
render(<Pagination {...defaultProps} />);
// 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(<Pagination {...defaultProps} />);
fireEvent.click(screen.getByText('2'));
expect(defaultProps.onPageChange).toHaveBeenCalledWith(2);
});

it('disables previous and first buttons on the first page', () => {
render(<Pagination {...defaultProps} currentPage={1} />);
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(<Pagination {...defaultProps} currentPage={10} />);
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(<Pagination {...defaultProps} currentPage={5} />);
fireEvent.click(screen.getByLabelText('First page'));
expect(defaultProps.onPageChange).toHaveBeenCalledWith(1);
});

it('calls onPageChange with last page when last button is clicked', () => {
render(<Pagination {...defaultProps} currentPage={5} />);
fireEvent.click(screen.getByLabelText('Last page'));
expect(defaultProps.onPageChange).toHaveBeenCalledWith(10);
});

it('calls onPageChange with previous page when previous button is clicked', () => {
render(<Pagination {...defaultProps} currentPage={5} />);
fireEvent.click(screen.getByLabelText('Previous page'));
expect(defaultProps.onPageChange).toHaveBeenCalledWith(4);
});

it('calls onPageChange with next page when next button is clicked', () => {
render(<Pagination {...defaultProps} currentPage={5} />);
fireEvent.click(screen.getByLabelText('Next page'));
expect(defaultProps.onPageChange).toHaveBeenCalledWith(6);
});

it('calls onPageSizeChange when page size selector is changed', () => {
render(<Pagination {...defaultProps} />);
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(<Pagination {...defaultProps} currentPage={3} />);
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(<Pagination {...defaultProps} pageSizeOptions={pageSizeOptions} />);
const select = screen.getByLabelText('Rows per page:');
expect(select.querySelectorAll('option')).toHaveLength(3);
expect(screen.getByText('5')).toBeInTheDocument();
expect(screen.getByText('15')).toBeInTheDocument();
});
});
90 changes: 90 additions & 0 deletions admin/app/pagination-demo/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="min-h-screen bg-gray-50 p-8">
<div className="max-w-4xl mx-auto bg-white rounded-xl shadow-md overflow-hidden p-8">
<h1 className="text-2xl font-bold text-gray-900 mb-6">Pagination Component Demo</h1>

<div className="space-y-8">
<section>
<h2 className="text-lg font-semibold text-gray-700 mb-4">Default Pagination</h2>
<div className="border rounded-lg bg-white">
<Pagination
currentPage={currentPage}
totalItems={totalItems}
pageSize={pageSize}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
/>
</div>
</section>

<section>
<h2 className="text-lg font-semibold text-gray-700 mb-4">Current State</h2>
<div className="grid grid-cols-2 gap-4 bg-gray-100 p-4 rounded-lg font-mono text-sm">
<div>
<p className="text-gray-500">Current Page:</p>
<p className="text-blue-600 font-bold">{currentPage}</p>
</div>
<div>
<p className="text-gray-500">Page Size:</p>
<p className="text-blue-600 font-bold">{pageSize}</p>
</div>
<div>
<p className="text-gray-500">Total Items:</p>
<p className="text-gray-900">{totalItems}</p>
</div>
<div>
<p className="text-gray-500">Total Pages:</p>
<p className="text-gray-900">{Math.ceil(totalItems / pageSize)}</p>
</div>
</div>
</section>

<section>
<h2 className="text-lg font-semibold text-gray-700 mb-4">Pagination without Page Size Selector</h2>
<div className="border rounded-lg bg-white">
<Pagination
currentPage={currentPage}
totalItems={totalItems}
pageSize={20}
onPageChange={handlePageChange}
/>
</div>
</section>

<section>
<h2 className="text-lg font-semibold text-gray-700 mb-4">Small Dataset (1 page)</h2>
<div className="border rounded-lg bg-white">
<Pagination
currentPage={1}
totalItems={5}
pageSize={10}
onPageChange={() => {}}
/>
</div>
</section>
</div>
</div>
</div>
);
}
Loading
Loading