Skip to content

Commit 6add8b3

Browse files
authored
feat: add reusable usePagination hook (#921)
* feat: add reusable usePagination hook - Extract pagination logic into reusable hook - Add null-safety for undefined httpMeta - Include comprehensive unit tests * fix: add NaN-safe header parsing and remove non-null assertions - Add parseHeaderInt helper with parseInt and NaN validation - Replace non-null assertions with optional chaining in tests - Add tests for invalid header values (NaN handling) * refactor(usePagination): manage state internally with optional overrides
1 parent 18b282b commit 6add8b3

File tree

3 files changed

+260
-0
lines changed

3 files changed

+260
-0
lines changed

src/hooks/usePagination/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { usePagination, extractPaginationMeta } from './usePagination'
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { describe, test, expect } from 'vitest'
2+
import { renderHook, act } from '@testing-library/react'
3+
import { usePagination, extractPaginationMeta } from './usePagination'
4+
5+
const createMockHttpMeta = (headers: Record<string, string> = {}) => ({
6+
response: {
7+
headers: new Headers(headers),
8+
} as Response,
9+
})
10+
11+
describe('extractPaginationMeta', () => {
12+
test('extracts totalPages from x-total-pages header', () => {
13+
const httpMeta = createMockHttpMeta({ 'x-total-pages': '5' })
14+
const result = extractPaginationMeta(httpMeta)
15+
expect(result.totalPages).toBe(5)
16+
})
17+
18+
test('extracts totalItems from x-total-count header', () => {
19+
const httpMeta = createMockHttpMeta({ 'x-total-count': '42' })
20+
const result = extractPaginationMeta(httpMeta)
21+
expect(result.totalItems).toBe(42)
22+
})
23+
24+
test('defaults totalPages to 1 when header missing', () => {
25+
const httpMeta = createMockHttpMeta({})
26+
const result = extractPaginationMeta(httpMeta)
27+
expect(result.totalPages).toBe(1)
28+
})
29+
30+
test('defaults totalItems to 0 when header missing', () => {
31+
const httpMeta = createMockHttpMeta({})
32+
const result = extractPaginationMeta(httpMeta)
33+
expect(result.totalItems).toBe(0)
34+
})
35+
36+
test('defaults totalPages to 1 when header is invalid', () => {
37+
const httpMeta = createMockHttpMeta({ 'x-total-pages': 'invalid' })
38+
const result = extractPaginationMeta(httpMeta)
39+
expect(result.totalPages).toBe(1)
40+
})
41+
42+
test('defaults totalItems to 0 when header is invalid', () => {
43+
const httpMeta = createMockHttpMeta({ 'x-total-count': 'abc' })
44+
const result = extractPaginationMeta(httpMeta)
45+
expect(result.totalItems).toBe(0)
46+
})
47+
48+
test('defaults totalPages to 1 when header is empty string', () => {
49+
const httpMeta = createMockHttpMeta({ 'x-total-pages': '' })
50+
const result = extractPaginationMeta(httpMeta)
51+
expect(result.totalPages).toBe(1)
52+
})
53+
})
54+
55+
describe('usePagination', () => {
56+
describe('initial state', () => {
57+
test('defaults currentPage to 1', () => {
58+
const httpMeta = createMockHttpMeta({ 'x-total-pages': '5' })
59+
const { result } = renderHook(() => usePagination(httpMeta))
60+
expect(result.current.currentPage).toBe(1)
61+
})
62+
63+
test('defaults itemsPerPage to 5', () => {
64+
const httpMeta = createMockHttpMeta({ 'x-total-pages': '5' })
65+
const { result } = renderHook(() => usePagination(httpMeta))
66+
expect(result.current.itemsPerPage).toBe(5)
67+
})
68+
69+
test('uses initialPage from options', () => {
70+
const httpMeta = createMockHttpMeta({ 'x-total-pages': '5' })
71+
const { result } = renderHook(() => usePagination(httpMeta, { initialPage: 3 }))
72+
expect(result.current.currentPage).toBe(3)
73+
})
74+
75+
test('uses initialItemsPerPage from options', () => {
76+
const httpMeta = createMockHttpMeta({ 'x-total-pages': '5' })
77+
const { result } = renderHook(() => usePagination(httpMeta, { initialItemsPerPage: 50 }))
78+
expect(result.current.itemsPerPage).toBe(50)
79+
})
80+
})
81+
82+
describe('navigation handlers', () => {
83+
test('handleFirstPage sets page to 1', () => {
84+
const httpMeta = createMockHttpMeta({ 'x-total-pages': '5' })
85+
const { result } = renderHook(() => usePagination(httpMeta, { initialPage: 3 }))
86+
87+
act(() => {
88+
result.current.handleFirstPage()
89+
})
90+
91+
expect(result.current.currentPage).toBe(1)
92+
})
93+
94+
test('handlePreviousPage decrements page', () => {
95+
const httpMeta = createMockHttpMeta({ 'x-total-pages': '5' })
96+
const { result } = renderHook(() => usePagination(httpMeta, { initialPage: 3 }))
97+
98+
act(() => {
99+
result.current.handlePreviousPage()
100+
})
101+
102+
expect(result.current.currentPage).toBe(2)
103+
})
104+
105+
test('handleNextPage increments page', () => {
106+
const httpMeta = createMockHttpMeta({ 'x-total-pages': '5' })
107+
const { result } = renderHook(() => usePagination(httpMeta, { initialPage: 3 }))
108+
109+
act(() => {
110+
result.current.handleNextPage()
111+
})
112+
113+
expect(result.current.currentPage).toBe(4)
114+
})
115+
116+
test('handleLastPage sets page to totalPages', () => {
117+
const httpMeta = createMockHttpMeta({ 'x-total-pages': '5' })
118+
const { result } = renderHook(() => usePagination(httpMeta))
119+
120+
act(() => {
121+
result.current.handleLastPage()
122+
})
123+
124+
expect(result.current.currentPage).toBe(5)
125+
})
126+
127+
test('handleItemsPerPageChange updates itemsPerPage and resets to page 1', () => {
128+
const httpMeta = createMockHttpMeta({ 'x-total-pages': '5' })
129+
const { result } = renderHook(() => usePagination(httpMeta, { initialPage: 3 }))
130+
131+
act(() => {
132+
result.current.handleItemsPerPageChange(50)
133+
})
134+
135+
expect(result.current.itemsPerPage).toBe(50)
136+
expect(result.current.currentPage).toBe(1)
137+
})
138+
})
139+
140+
describe('edge cases', () => {
141+
test('handlePreviousPage on page 1 stays on page 1', () => {
142+
const httpMeta = createMockHttpMeta({ 'x-total-pages': '5' })
143+
const { result } = renderHook(() => usePagination(httpMeta, { initialPage: 1 }))
144+
145+
act(() => {
146+
result.current.handlePreviousPage()
147+
})
148+
149+
expect(result.current.currentPage).toBe(1)
150+
})
151+
152+
test('handleNextPage on last page stays on last page', () => {
153+
const httpMeta = createMockHttpMeta({ 'x-total-pages': '5' })
154+
const { result } = renderHook(() => usePagination(httpMeta, { initialPage: 5 }))
155+
156+
act(() => {
157+
result.current.handleNextPage()
158+
})
159+
160+
expect(result.current.currentPage).toBe(5)
161+
})
162+
})
163+
164+
describe('header extraction', () => {
165+
test('returns totalPages from httpMeta', () => {
166+
const httpMeta = createMockHttpMeta({ 'x-total-pages': '10', 'x-total-count': '100' })
167+
const { result } = renderHook(() => usePagination(httpMeta))
168+
expect(result.current.totalPages).toBe(10)
169+
})
170+
171+
test('returns totalItems from httpMeta', () => {
172+
const httpMeta = createMockHttpMeta({ 'x-total-pages': '10', 'x-total-count': '100' })
173+
const { result } = renderHook(() => usePagination(httpMeta))
174+
expect(result.current.totalItems).toBe(100)
175+
})
176+
})
177+
})
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { useCallback, useMemo, useState } from 'react'
2+
import type { PaginationItemsPerPage } from '@/components/Common/PaginationControl/PaginationControlTypes'
3+
4+
type HttpMeta = {
5+
response: Response
6+
}
7+
8+
type UsePaginationOptions = {
9+
initialPage?: number
10+
initialItemsPerPage?: PaginationItemsPerPage
11+
}
12+
13+
function parseHeaderInt(value: string | null, defaultValue: number): number {
14+
if (value === null) return defaultValue
15+
const parsed = parseInt(value, 10)
16+
return Number.isNaN(parsed) ? defaultValue : parsed
17+
}
18+
19+
export function extractPaginationMeta(httpMeta?: HttpMeta | null) {
20+
return {
21+
totalPages: parseHeaderInt(httpMeta?.response.headers.get('x-total-pages') ?? null, 1),
22+
totalItems: parseHeaderInt(httpMeta?.response.headers.get('x-total-count') ?? null, 0),
23+
}
24+
}
25+
26+
export function usePagination(httpMeta: HttpMeta | undefined, options?: UsePaginationOptions) {
27+
const [currentPage, setCurrentPage] = useState(options?.initialPage ?? 1)
28+
const [itemsPerPage, setItemsPerPage] = useState<PaginationItemsPerPage>(
29+
options?.initialItemsPerPage ?? 5,
30+
)
31+
32+
const { totalPages, totalItems } = extractPaginationMeta(httpMeta)
33+
34+
const handleFirstPage = useCallback(() => {
35+
setCurrentPage(1)
36+
}, [setCurrentPage])
37+
38+
const handlePreviousPage = useCallback(() => {
39+
setCurrentPage(prevPage => Math.max(prevPage - 1, 1))
40+
}, [setCurrentPage])
41+
42+
const handleNextPage = useCallback(() => {
43+
setCurrentPage(prevPage => Math.min(prevPage + 1, totalPages))
44+
}, [setCurrentPage, totalPages])
45+
46+
const handleLastPage = useCallback(() => {
47+
setCurrentPage(totalPages)
48+
}, [setCurrentPage, totalPages])
49+
50+
const handleItemsPerPageChange = useCallback(
51+
(n: PaginationItemsPerPage) => {
52+
setItemsPerPage(n)
53+
setCurrentPage(1)
54+
},
55+
[setItemsPerPage, setCurrentPage],
56+
)
57+
58+
return useMemo(
59+
() => ({
60+
currentPage,
61+
totalPages,
62+
totalItems,
63+
itemsPerPage,
64+
handleFirstPage,
65+
handlePreviousPage,
66+
handleNextPage,
67+
handleLastPage,
68+
handleItemsPerPageChange,
69+
}),
70+
[
71+
currentPage,
72+
totalPages,
73+
totalItems,
74+
itemsPerPage,
75+
handleFirstPage,
76+
handlePreviousPage,
77+
handleNextPage,
78+
handleLastPage,
79+
handleItemsPerPageChange,
80+
],
81+
)
82+
}

0 commit comments

Comments
 (0)