Skip to content

Commit 9021fa4

Browse files
committed
feat: add reusable usePagination hook
- Extract pagination logic into reusable hook - Add null-safety for undefined httpMeta - Include comprehensive unit tests
1 parent 18b282b commit 9021fa4

File tree

3 files changed

+285
-0
lines changed

3 files changed

+285
-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: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import { describe, test, expect, vi } from 'vitest'
2+
import { renderHook, act } from '@testing-library/react'
3+
import { usePagination, extractPaginationMeta } from './usePagination'
4+
import type { PaginationItemsPerPage } from '@/components/Common/PaginationControl/PaginationControlTypes'
5+
6+
const createMockHttpMeta = (headers: Record<string, string> = {}) => ({
7+
response: {
8+
headers: new Headers(headers),
9+
} as Response,
10+
})
11+
12+
describe('extractPaginationMeta', () => {
13+
test('extracts totalPages from x-total-pages header', () => {
14+
const httpMeta = createMockHttpMeta({ 'x-total-pages': '5' })
15+
const result = extractPaginationMeta(httpMeta)
16+
expect(result.totalPages).toBe(5)
17+
})
18+
19+
test('extracts totalItems from x-total-count header', () => {
20+
const httpMeta = createMockHttpMeta({ 'x-total-count': '42' })
21+
const result = extractPaginationMeta(httpMeta)
22+
expect(result.totalItems).toBe(42)
23+
})
24+
25+
test('defaults totalPages to 1 when header missing', () => {
26+
const httpMeta = createMockHttpMeta({})
27+
const result = extractPaginationMeta(httpMeta)
28+
expect(result.totalPages).toBe(1)
29+
})
30+
31+
test('defaults totalItems to 0 when header missing', () => {
32+
const httpMeta = createMockHttpMeta({})
33+
const result = extractPaginationMeta(httpMeta)
34+
expect(result.totalItems).toBe(0)
35+
})
36+
})
37+
38+
describe('usePagination', () => {
39+
const createState = (
40+
overrides: Partial<{
41+
currentPage: number
42+
itemsPerPage: PaginationItemsPerPage
43+
setCurrentPage: React.Dispatch<React.SetStateAction<number>>
44+
setItemsPerPage: React.Dispatch<React.SetStateAction<PaginationItemsPerPage>>
45+
}> = {},
46+
) => ({
47+
currentPage: 1,
48+
itemsPerPage: 10 as PaginationItemsPerPage,
49+
setCurrentPage: vi.fn(),
50+
setItemsPerPage: vi.fn(),
51+
...overrides,
52+
})
53+
54+
describe('state management', () => {
55+
test('returns currentPage from state', () => {
56+
const httpMeta = createMockHttpMeta({ 'x-total-pages': '5', 'x-total-count': '50' })
57+
const state = createState({ currentPage: 3 })
58+
59+
const { result } = renderHook(() => usePagination(httpMeta, state))
60+
61+
expect(result.current.currentPage).toBe(3)
62+
})
63+
64+
test('returns itemsPerPage from state', () => {
65+
const httpMeta = createMockHttpMeta({ 'x-total-pages': '5', 'x-total-count': '50' })
66+
const state = createState({ itemsPerPage: 50 })
67+
68+
const { result } = renderHook(() => usePagination(httpMeta, state))
69+
70+
expect(result.current.itemsPerPage).toBe(50)
71+
})
72+
73+
test('handleFirstPage sets page to 1', () => {
74+
const httpMeta = createMockHttpMeta({ 'x-total-pages': '5', 'x-total-count': '50' })
75+
const setCurrentPage = vi.fn()
76+
const state = createState({ currentPage: 3, setCurrentPage })
77+
78+
const { result } = renderHook(() => usePagination(httpMeta, state))
79+
80+
act(() => {
81+
result.current.handleFirstPage()
82+
})
83+
84+
expect(setCurrentPage).toHaveBeenCalledWith(1)
85+
})
86+
87+
test('handlePreviousPage decrements page', () => {
88+
const httpMeta = createMockHttpMeta({ 'x-total-pages': '5', 'x-total-count': '50' })
89+
const setCurrentPage = vi.fn()
90+
const state = createState({ currentPage: 3, setCurrentPage })
91+
92+
const { result } = renderHook(() => usePagination(httpMeta, state))
93+
94+
act(() => {
95+
result.current.handlePreviousPage()
96+
})
97+
98+
expect(setCurrentPage).toHaveBeenCalled()
99+
const call = setCurrentPage.mock.calls[0]
100+
expect(call).toBeDefined()
101+
const updateFn = call![0] as (prev: number) => number
102+
expect(updateFn(3)).toBe(2)
103+
})
104+
105+
test('handleNextPage increments page', () => {
106+
const httpMeta = createMockHttpMeta({ 'x-total-pages': '5', 'x-total-count': '50' })
107+
const setCurrentPage = vi.fn()
108+
const state = createState({ currentPage: 3, setCurrentPage })
109+
110+
const { result } = renderHook(() => usePagination(httpMeta, state))
111+
112+
act(() => {
113+
result.current.handleNextPage()
114+
})
115+
116+
expect(setCurrentPage).toHaveBeenCalled()
117+
const call = setCurrentPage.mock.calls[0]
118+
expect(call).toBeDefined()
119+
const updateFn = call![0] as (prev: number) => number
120+
expect(updateFn(3)).toBe(4)
121+
})
122+
123+
test('handleLastPage sets page to totalPages', () => {
124+
const httpMeta = createMockHttpMeta({ 'x-total-pages': '5', 'x-total-count': '50' })
125+
const setCurrentPage = vi.fn()
126+
const state = createState({ setCurrentPage })
127+
128+
const { result } = renderHook(() => usePagination(httpMeta, state))
129+
130+
act(() => {
131+
result.current.handleLastPage()
132+
})
133+
134+
expect(setCurrentPage).toHaveBeenCalledWith(5)
135+
})
136+
137+
test('handleItemsPerPageChange updates itemsPerPage and resets to page 1', () => {
138+
const httpMeta = createMockHttpMeta({ 'x-total-pages': '5', 'x-total-count': '50' })
139+
const setCurrentPage = vi.fn()
140+
const setItemsPerPage = vi.fn()
141+
const state = createState({ currentPage: 3, setCurrentPage, setItemsPerPage })
142+
143+
const { result } = renderHook(() => usePagination(httpMeta, state))
144+
145+
act(() => {
146+
result.current.handleItemsPerPageChange(50)
147+
})
148+
149+
expect(setItemsPerPage).toHaveBeenCalledWith(50)
150+
expect(setCurrentPage).toHaveBeenCalledWith(1)
151+
})
152+
})
153+
154+
describe('edge cases', () => {
155+
test('handlePreviousPage on page 1 stays on page 1', () => {
156+
const httpMeta = createMockHttpMeta({ 'x-total-pages': '5', 'x-total-count': '50' })
157+
const setCurrentPage = vi.fn()
158+
const state = createState({ currentPage: 1, setCurrentPage })
159+
160+
const { result } = renderHook(() => usePagination(httpMeta, state))
161+
162+
act(() => {
163+
result.current.handlePreviousPage()
164+
})
165+
166+
const call = setCurrentPage.mock.calls[0]
167+
expect(call).toBeDefined()
168+
const updateFn = call![0] as (prev: number) => number
169+
expect(updateFn(1)).toBe(1)
170+
})
171+
172+
test('handleNextPage on last page stays on last page', () => {
173+
const httpMeta = createMockHttpMeta({ 'x-total-pages': '5', 'x-total-count': '50' })
174+
const setCurrentPage = vi.fn()
175+
const state = createState({ currentPage: 5, setCurrentPage })
176+
177+
const { result } = renderHook(() => usePagination(httpMeta, state))
178+
179+
act(() => {
180+
result.current.handleNextPage()
181+
})
182+
183+
const call = setCurrentPage.mock.calls[0]
184+
expect(call).toBeDefined()
185+
const updateFn = call![0] as (prev: number) => number
186+
expect(updateFn(5)).toBe(5)
187+
})
188+
})
189+
190+
describe('header extraction', () => {
191+
test('returns totalPages from httpMeta', () => {
192+
const httpMeta = createMockHttpMeta({ 'x-total-pages': '10', 'x-total-count': '100' })
193+
const state = createState()
194+
195+
const { result } = renderHook(() => usePagination(httpMeta, state))
196+
197+
expect(result.current.totalPages).toBe(10)
198+
})
199+
200+
test('returns totalItems from httpMeta', () => {
201+
const httpMeta = createMockHttpMeta({ 'x-total-pages': '10', 'x-total-count': '100' })
202+
const state = createState()
203+
204+
const { result } = renderHook(() => usePagination(httpMeta, state))
205+
206+
expect(result.current.totalItems).toBe(100)
207+
})
208+
})
209+
})
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { useCallback, useMemo } from 'react'
2+
import type { PaginationItemsPerPage } from '@/components/Common/PaginationControl/PaginationControlTypes'
3+
4+
type HttpMeta = {
5+
response: Response
6+
}
7+
8+
type PaginationState = {
9+
currentPage: number
10+
itemsPerPage: PaginationItemsPerPage
11+
setCurrentPage: React.Dispatch<React.SetStateAction<number>>
12+
setItemsPerPage: React.Dispatch<React.SetStateAction<PaginationItemsPerPage>>
13+
}
14+
15+
export function extractPaginationMeta(httpMeta?: HttpMeta | null) {
16+
return {
17+
totalPages: Number(httpMeta?.response.headers.get('x-total-pages') ?? 1),
18+
totalItems: Number(httpMeta?.response.headers.get('x-total-count') ?? 0),
19+
}
20+
}
21+
22+
export function usePagination(httpMeta: HttpMeta | undefined, state: PaginationState) {
23+
const { currentPage, itemsPerPage, setCurrentPage, setItemsPerPage } = state
24+
25+
const { totalPages, totalItems } = extractPaginationMeta(httpMeta)
26+
27+
const handleFirstPage = useCallback(() => {
28+
setCurrentPage(1)
29+
}, [setCurrentPage])
30+
31+
const handlePreviousPage = useCallback(() => {
32+
setCurrentPage(prevPage => Math.max(prevPage - 1, 1))
33+
}, [setCurrentPage])
34+
35+
const handleNextPage = useCallback(() => {
36+
setCurrentPage(prevPage => Math.min(prevPage + 1, totalPages))
37+
}, [setCurrentPage, totalPages])
38+
39+
const handleLastPage = useCallback(() => {
40+
setCurrentPage(totalPages)
41+
}, [setCurrentPage, totalPages])
42+
43+
const handleItemsPerPageChange = useCallback(
44+
(n: PaginationItemsPerPage) => {
45+
setItemsPerPage(n)
46+
setCurrentPage(1)
47+
},
48+
[setItemsPerPage, setCurrentPage],
49+
)
50+
51+
return useMemo(
52+
() => ({
53+
currentPage,
54+
totalPages,
55+
totalItems,
56+
itemsPerPage,
57+
handleFirstPage,
58+
handlePreviousPage,
59+
handleNextPage,
60+
handleLastPage,
61+
handleItemsPerPageChange,
62+
}),
63+
[
64+
currentPage,
65+
totalPages,
66+
totalItems,
67+
itemsPerPage,
68+
handleFirstPage,
69+
handlePreviousPage,
70+
handleNextPage,
71+
handleLastPage,
72+
handleItemsPerPageChange,
73+
],
74+
)
75+
}

0 commit comments

Comments
 (0)