diff --git a/src/hooks/usePagination/index.ts b/src/hooks/usePagination/index.ts new file mode 100644 index 000000000..0eac514e7 --- /dev/null +++ b/src/hooks/usePagination/index.ts @@ -0,0 +1 @@ +export { usePagination, extractPaginationMeta } from './usePagination' diff --git a/src/hooks/usePagination/usePagination.test.ts b/src/hooks/usePagination/usePagination.test.ts new file mode 100644 index 000000000..ad5565517 --- /dev/null +++ b/src/hooks/usePagination/usePagination.test.ts @@ -0,0 +1,177 @@ +import { describe, test, expect } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { usePagination, extractPaginationMeta } from './usePagination' + +const createMockHttpMeta = (headers: Record = {}) => ({ + response: { + headers: new Headers(headers), + } as Response, +}) + +describe('extractPaginationMeta', () => { + test('extracts totalPages from x-total-pages header', () => { + const httpMeta = createMockHttpMeta({ 'x-total-pages': '5' }) + const result = extractPaginationMeta(httpMeta) + expect(result.totalPages).toBe(5) + }) + + test('extracts totalItems from x-total-count header', () => { + const httpMeta = createMockHttpMeta({ 'x-total-count': '42' }) + const result = extractPaginationMeta(httpMeta) + expect(result.totalItems).toBe(42) + }) + + test('defaults totalPages to 1 when header missing', () => { + const httpMeta = createMockHttpMeta({}) + const result = extractPaginationMeta(httpMeta) + expect(result.totalPages).toBe(1) + }) + + test('defaults totalItems to 0 when header missing', () => { + const httpMeta = createMockHttpMeta({}) + const result = extractPaginationMeta(httpMeta) + expect(result.totalItems).toBe(0) + }) + + test('defaults totalPages to 1 when header is invalid', () => { + const httpMeta = createMockHttpMeta({ 'x-total-pages': 'invalid' }) + const result = extractPaginationMeta(httpMeta) + expect(result.totalPages).toBe(1) + }) + + test('defaults totalItems to 0 when header is invalid', () => { + const httpMeta = createMockHttpMeta({ 'x-total-count': 'abc' }) + const result = extractPaginationMeta(httpMeta) + expect(result.totalItems).toBe(0) + }) + + test('defaults totalPages to 1 when header is empty string', () => { + const httpMeta = createMockHttpMeta({ 'x-total-pages': '' }) + const result = extractPaginationMeta(httpMeta) + expect(result.totalPages).toBe(1) + }) +}) + +describe('usePagination', () => { + describe('initial state', () => { + test('defaults currentPage to 1', () => { + const httpMeta = createMockHttpMeta({ 'x-total-pages': '5' }) + const { result } = renderHook(() => usePagination(httpMeta)) + expect(result.current.currentPage).toBe(1) + }) + + test('defaults itemsPerPage to 5', () => { + const httpMeta = createMockHttpMeta({ 'x-total-pages': '5' }) + const { result } = renderHook(() => usePagination(httpMeta)) + expect(result.current.itemsPerPage).toBe(5) + }) + + test('uses initialPage from options', () => { + const httpMeta = createMockHttpMeta({ 'x-total-pages': '5' }) + const { result } = renderHook(() => usePagination(httpMeta, { initialPage: 3 })) + expect(result.current.currentPage).toBe(3) + }) + + test('uses initialItemsPerPage from options', () => { + const httpMeta = createMockHttpMeta({ 'x-total-pages': '5' }) + const { result } = renderHook(() => usePagination(httpMeta, { initialItemsPerPage: 50 })) + expect(result.current.itemsPerPage).toBe(50) + }) + }) + + describe('navigation handlers', () => { + test('handleFirstPage sets page to 1', () => { + const httpMeta = createMockHttpMeta({ 'x-total-pages': '5' }) + const { result } = renderHook(() => usePagination(httpMeta, { initialPage: 3 })) + + act(() => { + result.current.handleFirstPage() + }) + + expect(result.current.currentPage).toBe(1) + }) + + test('handlePreviousPage decrements page', () => { + const httpMeta = createMockHttpMeta({ 'x-total-pages': '5' }) + const { result } = renderHook(() => usePagination(httpMeta, { initialPage: 3 })) + + act(() => { + result.current.handlePreviousPage() + }) + + expect(result.current.currentPage).toBe(2) + }) + + test('handleNextPage increments page', () => { + const httpMeta = createMockHttpMeta({ 'x-total-pages': '5' }) + const { result } = renderHook(() => usePagination(httpMeta, { initialPage: 3 })) + + act(() => { + result.current.handleNextPage() + }) + + expect(result.current.currentPage).toBe(4) + }) + + test('handleLastPage sets page to totalPages', () => { + const httpMeta = createMockHttpMeta({ 'x-total-pages': '5' }) + const { result } = renderHook(() => usePagination(httpMeta)) + + act(() => { + result.current.handleLastPage() + }) + + expect(result.current.currentPage).toBe(5) + }) + + test('handleItemsPerPageChange updates itemsPerPage and resets to page 1', () => { + const httpMeta = createMockHttpMeta({ 'x-total-pages': '5' }) + const { result } = renderHook(() => usePagination(httpMeta, { initialPage: 3 })) + + act(() => { + result.current.handleItemsPerPageChange(50) + }) + + expect(result.current.itemsPerPage).toBe(50) + expect(result.current.currentPage).toBe(1) + }) + }) + + describe('edge cases', () => { + test('handlePreviousPage on page 1 stays on page 1', () => { + const httpMeta = createMockHttpMeta({ 'x-total-pages': '5' }) + const { result } = renderHook(() => usePagination(httpMeta, { initialPage: 1 })) + + act(() => { + result.current.handlePreviousPage() + }) + + expect(result.current.currentPage).toBe(1) + }) + + test('handleNextPage on last page stays on last page', () => { + const httpMeta = createMockHttpMeta({ 'x-total-pages': '5' }) + const { result } = renderHook(() => usePagination(httpMeta, { initialPage: 5 })) + + act(() => { + result.current.handleNextPage() + }) + + expect(result.current.currentPage).toBe(5) + }) + }) + + describe('header extraction', () => { + test('returns totalPages from httpMeta', () => { + const httpMeta = createMockHttpMeta({ 'x-total-pages': '10', 'x-total-count': '100' }) + const { result } = renderHook(() => usePagination(httpMeta)) + expect(result.current.totalPages).toBe(10) + }) + + test('returns totalItems from httpMeta', () => { + const httpMeta = createMockHttpMeta({ 'x-total-pages': '10', 'x-total-count': '100' }) + const { result } = renderHook(() => usePagination(httpMeta)) + expect(result.current.totalItems).toBe(100) + }) + }) +}) diff --git a/src/hooks/usePagination/usePagination.ts b/src/hooks/usePagination/usePagination.ts new file mode 100644 index 000000000..990106525 --- /dev/null +++ b/src/hooks/usePagination/usePagination.ts @@ -0,0 +1,82 @@ +import { useCallback, useMemo, useState } from 'react' +import type { PaginationItemsPerPage } from '@/components/Common/PaginationControl/PaginationControlTypes' + +type HttpMeta = { + response: Response +} + +type UsePaginationOptions = { + initialPage?: number + initialItemsPerPage?: PaginationItemsPerPage +} + +function parseHeaderInt(value: string | null, defaultValue: number): number { + if (value === null) return defaultValue + const parsed = parseInt(value, 10) + return Number.isNaN(parsed) ? defaultValue : parsed +} + +export function extractPaginationMeta(httpMeta?: HttpMeta | null) { + return { + totalPages: parseHeaderInt(httpMeta?.response.headers.get('x-total-pages') ?? null, 1), + totalItems: parseHeaderInt(httpMeta?.response.headers.get('x-total-count') ?? null, 0), + } +} + +export function usePagination(httpMeta: HttpMeta | undefined, options?: UsePaginationOptions) { + const [currentPage, setCurrentPage] = useState(options?.initialPage ?? 1) + const [itemsPerPage, setItemsPerPage] = useState( + options?.initialItemsPerPage ?? 5, + ) + + const { totalPages, totalItems } = extractPaginationMeta(httpMeta) + + const handleFirstPage = useCallback(() => { + setCurrentPage(1) + }, [setCurrentPage]) + + const handlePreviousPage = useCallback(() => { + setCurrentPage(prevPage => Math.max(prevPage - 1, 1)) + }, [setCurrentPage]) + + const handleNextPage = useCallback(() => { + setCurrentPage(prevPage => Math.min(prevPage + 1, totalPages)) + }, [setCurrentPage, totalPages]) + + const handleLastPage = useCallback(() => { + setCurrentPage(totalPages) + }, [setCurrentPage, totalPages]) + + const handleItemsPerPageChange = useCallback( + (n: PaginationItemsPerPage) => { + setItemsPerPage(n) + setCurrentPage(1) + }, + [setItemsPerPage, setCurrentPage], + ) + + return useMemo( + () => ({ + currentPage, + totalPages, + totalItems, + itemsPerPage, + handleFirstPage, + handlePreviousPage, + handleNextPage, + handleLastPage, + handleItemsPerPageChange, + }), + [ + currentPage, + totalPages, + totalItems, + itemsPerPage, + handleFirstPage, + handlePreviousPage, + handleNextPage, + handleLastPage, + handleItemsPerPageChange, + ], + ) +}