Skip to content

Commit 96a1355

Browse files
committedJan 16, 2023
feat: adds usePagination hook
Adds a new hook for manageing pagination state fix #61
1 parent ce2b617 commit 96a1355

File tree

5 files changed

+467
-0
lines changed

5 files changed

+467
-0
lines changed
 

‎src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export * from './useKeyboard'
1111
export * from './useLocalState'
1212
export * from './useMergedRefs'
1313
export * from './useModal'
14+
export * from './usePagination'
1415
export * from './usePoll'
1516
export * from './useTimeout'
1617
export * from './useTitle'

‎src/usePagination/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './usePagination'
+218
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import {
2+
Button,
3+
Card,
4+
CardBody,
5+
Chip,
6+
Column,
7+
Monospace,
8+
Pagination,
9+
Row,
10+
Slider,
11+
} from '@committed/components'
12+
import { Meta, Story } from '@storybook/react'
13+
import React, { useEffect, useMemo } from 'react'
14+
import useSwr from 'swr'
15+
import { usePagination } from '.'
16+
17+
export interface UsePaginationDocsProps {
18+
/** The total number of items, if know when initializing, use setTotalItems if not */
19+
totalItems: number
20+
/** The page to start on */
21+
startPage: number
22+
/** The size of each page */
23+
pageSize: number
24+
}
25+
26+
/**
27+
* Utility hook for Pagination state
28+
*
29+
* returns an object containing the current page, additional useful state and functions for manipulation.
30+
*
31+
*/
32+
export const UsePaginationDocs: React.FC<UsePaginationDocsProps> = (
33+
_props: UsePaginationDocsProps
34+
) => null
35+
UsePaginationDocs.defaultProps = {
36+
totalItems: 0,
37+
startPage: 1,
38+
pageSize: 20,
39+
}
40+
41+
export default {
42+
title: 'Hooks/usePagination',
43+
component: UsePaginationDocs,
44+
excludeStories: ['UsePaginationDocs'],
45+
} as Meta
46+
47+
const Template: Story<UsePaginationDocsProps> = (args) => {
48+
const {
49+
page,
50+
pageSize,
51+
totalPages,
52+
startIndex,
53+
endIndex,
54+
isNextDisabled,
55+
isPreviousDisabled,
56+
setPage,
57+
} = usePagination(args)
58+
return (
59+
<Column css={{ gap: '$2' }}>
60+
<Card>
61+
<CardBody>
62+
<Monospace>
63+
{JSON.stringify(
64+
{
65+
page,
66+
pageSize,
67+
totalPages,
68+
startIndex,
69+
endIndex,
70+
isNextDisabled,
71+
isPreviousDisabled,
72+
},
73+
null,
74+
2
75+
)}
76+
</Monospace>
77+
</CardBody>
78+
</Card>
79+
<Pagination page={page} onPageChange={setPage} count={totalPages} />
80+
</Column>
81+
)
82+
}
83+
84+
export const Default = Template.bind({})
85+
Default.args = { totalItems: 100, startPage: 1, pageSize: 20 }
86+
87+
export const ClientSide: Story = () => {
88+
const items = Array.from({ length: 100 }, (v, i) => i)
89+
const { page, totalPages, startIndex, endIndex, setPage } = usePagination({
90+
totalItems: 100,
91+
pageSize: 20,
92+
})
93+
94+
return (
95+
<>
96+
<Row css={{ gap: '$2', mb: '$3' }}>
97+
{items.slice(startIndex, endIndex).map((item) => (
98+
<div>{item}</div>
99+
))}
100+
</Row>
101+
<Pagination page={page} onPageChange={setPage} count={totalPages} />
102+
</>
103+
)
104+
}
105+
ClientSide.parameters = {
106+
docs: {
107+
description: {
108+
story:
109+
'The hook can use used to manage pagination for a known long list on the client side by providing the relevant start data. Then use the `startIndex` and `endIndex` to slice the data.',
110+
},
111+
},
112+
}
113+
114+
type User = {
115+
id: number
116+
name: string
117+
email: string
118+
gender: string
119+
status: string
120+
}
121+
122+
export const ServerSide: Story = () => {
123+
const { page, pageSize, totalPages, setPage, setTotalItems, setPageSize } =
124+
usePagination()
125+
126+
const { data } = useSwr(
127+
['https://gorest.co.in/public/v2/users', page, pageSize],
128+
async ([url, page, pageSize]) => {
129+
const res = await fetch(`${url}?page=${page - 1}&per_page=${pageSize}`)
130+
131+
const users = (await res.json()) as User[]
132+
const totalItems = Number(res.headers.get('x-pagination-total'))
133+
134+
return {
135+
users,
136+
totalItems,
137+
}
138+
},
139+
{ refreshInterval: 0, shouldRetryOnError: false }
140+
)
141+
142+
const users = useMemo(() => {
143+
return data?.users ?? []
144+
}, [data])
145+
146+
useEffect(() => {
147+
if (data?.totalItems) {
148+
setTotalItems(data.totalItems)
149+
}
150+
}, [data])
151+
152+
return (
153+
<Column css={{ gap: '$3' }}>
154+
<Pagination page={page} onPageChange={setPage} count={totalPages} />
155+
<Slider
156+
labelFunction={(value) => `Page size ${value}`}
157+
value={[pageSize]}
158+
onValueChange={(value) => setPageSize(value[0])}
159+
/>
160+
<div>
161+
{users.map((item) => (
162+
<div>{item.name}</div>
163+
))}
164+
</div>
165+
</Column>
166+
)
167+
}
168+
ServerSide.parameters = {
169+
docs: {
170+
description: {
171+
story:
172+
'The hook can use used to manage pagination for remote data by setting the total items after the first call. The `startIndex` or `page` can be used in the API query.',
173+
},
174+
},
175+
}
176+
177+
export const MakeYourOwn: Story = () => {
178+
const items = Array.from({ length: 100 }, (v, i) => i)
179+
const {
180+
page,
181+
startIndex,
182+
endIndex,
183+
setNextPage,
184+
setPreviousPage,
185+
isNextDisabled,
186+
isPreviousDisabled,
187+
} = usePagination({
188+
totalItems: 100,
189+
pageSize: 20,
190+
})
191+
192+
return (
193+
<>
194+
<Row css={{ gap: '$2', mb: '$3' }}>
195+
{items.slice(startIndex, endIndex).map((item) => (
196+
<div>{item}</div>
197+
))}
198+
</Row>
199+
<Row css={{ gap: '$2' }}>
200+
<Button disabled={isPreviousDisabled} onClick={setPreviousPage}>
201+
Previous
202+
</Button>
203+
<Chip>{`Page ${page}`}</Chip>
204+
<Button disabled={isNextDisabled} onClick={setNextPage}>
205+
Next
206+
</Button>
207+
</Row>
208+
</>
209+
)
210+
}
211+
MakeYourOwn.parameters = {
212+
docs: {
213+
description: {
214+
story:
215+
'The data can be used to manage a `Pagination` component or you can create your own.',
216+
},
217+
},
218+
}
+134
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { act, renderHook } from '@testing-library/react-hooks'
2+
import { usePagination } from './usePagination'
3+
4+
test('should start in given state', () => {
5+
const { result } = renderHook(() => usePagination())
6+
const {
7+
page,
8+
totalPages,
9+
startIndex,
10+
endIndex,
11+
pageSize,
12+
isPreviousDisabled,
13+
isNextDisabled,
14+
} = result.current
15+
expect(page).toBe(1)
16+
expect(totalPages).toBe(0)
17+
expect(startIndex).toBe(0)
18+
expect(endIndex).toBe(0)
19+
expect(pageSize).toBe(20)
20+
expect(isNextDisabled).toBe(false)
21+
expect(isPreviousDisabled).toBe(true)
22+
})
23+
24+
test('should start using the provided start state', () => {
25+
const { result } = renderHook(() =>
26+
usePagination({ page: 5, totalItems: 100, pageSize: 10 })
27+
)
28+
const {
29+
page,
30+
totalPages,
31+
startIndex,
32+
endIndex,
33+
pageSize,
34+
isPreviousDisabled,
35+
isNextDisabled,
36+
} = result.current
37+
expect(page).toBe(5)
38+
expect(totalPages).toBe(10)
39+
expect(startIndex).toBe(40)
40+
expect(endIndex).toBe(50)
41+
expect(pageSize).toBe(10)
42+
expect(isNextDisabled).toBe(false)
43+
expect(isPreviousDisabled).toBe(false)
44+
})
45+
46+
test('can set page', () => {
47+
const { result } = renderHook(() =>
48+
usePagination({ totalItems: 100, pageSize: 10 })
49+
)
50+
51+
act(() => {
52+
result.current.setNextPage()
53+
})
54+
55+
expect(result.current.page).toBe(2)
56+
expect(result.current.startIndex).toBe(10)
57+
expect(result.current.endIndex).toBe(20)
58+
59+
act(() => {
60+
result.current.setPage(5)
61+
})
62+
63+
expect(result.current.page).toBe(5)
64+
expect(result.current.startIndex).toBe(40)
65+
expect(result.current.endIndex).toBe(50)
66+
67+
act(() => {
68+
result.current.setPreviousPage()
69+
})
70+
71+
expect(result.current.page).toBe(4)
72+
expect(result.current.startIndex).toBe(30)
73+
expect(result.current.endIndex).toBe(40)
74+
})
75+
76+
test('can not set page too high', () => {
77+
const { result } = renderHook(() =>
78+
usePagination({ page: 10, totalItems: 100, pageSize: 10 })
79+
)
80+
expect(result.current.page).toBe(10)
81+
82+
act(() => {
83+
result.current.setNextPage()
84+
})
85+
expect(result.current.page).toBe(10)
86+
87+
act(() => {
88+
result.current.setPage(20)
89+
})
90+
expect(result.current.page).toBe(10)
91+
})
92+
93+
test('can not set page too low', () => {
94+
const { result } = renderHook(() =>
95+
usePagination({ page: 1, totalItems: 100, pageSize: 10 })
96+
)
97+
expect(result.current.page).toBe(1)
98+
99+
act(() => {
100+
result.current.setPreviousPage()
101+
})
102+
expect(result.current.page).toBe(1)
103+
104+
act(() => {
105+
result.current.setPage(-20)
106+
})
107+
expect(result.current.page).toBe(1)
108+
})
109+
110+
test('can set page size', () => {
111+
const { result } = renderHook(() =>
112+
usePagination({ page: 10, totalItems: 100, pageSize: 10 })
113+
)
114+
115+
act(() => {
116+
result.current.setPageSize(20)
117+
})
118+
119+
expect(result.current.page).toBe(5)
120+
expect(result.current.pageSize).toBe(20)
121+
})
122+
123+
test('can set total items', () => {
124+
const { result } = renderHook(() =>
125+
usePagination({ page: 10, totalItems: 100, pageSize: 10 })
126+
)
127+
128+
act(() => {
129+
result.current.setTotalItems(200)
130+
})
131+
132+
expect(result.current.page).toBe(10)
133+
expect(result.current.totalPages).toBe(20)
134+
})

‎src/usePagination/usePagination.ts

+113
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { useCallback, useEffect, useMemo, useState } from 'react'
2+
3+
/**
4+
* Utility hook for handling pagination state
5+
*
6+
* returns the current page and functions to manipulate.
7+
*
8+
*/
9+
export function usePagination({
10+
totalItems: startTotalItems = 0,
11+
page: startPage = 1,
12+
pageSize: startPageSize = 20,
13+
}: Partial<{
14+
totalItems: number
15+
page: number
16+
pageSize: number
17+
}> = {}): {
18+
/** The current page */
19+
page: number
20+
/** The total number of pages */
21+
totalPages: number
22+
/** The start index of the page */
23+
startIndex: number
24+
/** The end index of the page */
25+
endIndex: number
26+
/** Is next button disabled */
27+
isNextDisabled: boolean
28+
/** Is previous button disabled */
29+
isPreviousDisabled: boolean
30+
/** The page size */
31+
pageSize: number
32+
/** Set the page */
33+
setPage: (page: number) => void
34+
/** Move to the next page */
35+
setNextPage: () => void
36+
/** Move to the previous */
37+
setPreviousPage: () => void
38+
/** Set the page size */
39+
setPageSize: (pageSize: number) => void
40+
/** set the number of items */
41+
setTotalItems: (totalItemsSize: number) => void
42+
} {
43+
const [totalItems, setTotalItemsInternal] = useState(startTotalItems)
44+
const [pageSize, setPageSizeInternal] = useState(Math.max(startPageSize, 1))
45+
const [page, setPageInternal] = useState(Math.max(startPage, 1))
46+
47+
const {
48+
totalPages,
49+
startIndex,
50+
endIndex,
51+
isNextDisabled,
52+
isPreviousDisabled,
53+
} = useMemo(
54+
() => getDerivedData(totalItems, pageSize, page),
55+
[page, pageSize, totalItems]
56+
)
57+
58+
const setTotalItems = useCallback((newTotalItems: number) => {
59+
setTotalItemsInternal(Math.max(0, newTotalItems))
60+
}, [])
61+
62+
const setPageSize = useCallback((newPageSize: number) => {
63+
setPageSizeInternal(Math.max(1, newPageSize))
64+
}, [])
65+
66+
const setPage = useCallback(
67+
(newPage: number) => {
68+
setPageInternal(Math.max(1, Math.min(newPage, totalPages)))
69+
},
70+
[page, totalPages]
71+
)
72+
73+
const setNextPage = useCallback(() => {
74+
setPage(page + 1)
75+
}, [page])
76+
77+
const setPreviousPage = useCallback(() => {
78+
setPage(page - 1)
79+
}, [page])
80+
81+
useEffect(() => {
82+
setPageInternal((page) => Math.max(1, Math.min(page, totalPages)))
83+
}, [totalPages])
84+
85+
return {
86+
page,
87+
pageSize,
88+
totalPages,
89+
startIndex,
90+
endIndex,
91+
isNextDisabled,
92+
isPreviousDisabled,
93+
setPage,
94+
setNextPage,
95+
setPreviousPage,
96+
setPageSize,
97+
setTotalItems,
98+
}
99+
}
100+
function getDerivedData(totalItems: number, pageSize: number, page: number) {
101+
const totalPages = Math.ceil(totalItems / pageSize)
102+
const startIndex = pageSize * (page - 1)
103+
const endIndex = Math.min(totalItems, pageSize * page)
104+
const isNextDisabled = page === totalPages
105+
const isPreviousDisabled = page === 1
106+
return {
107+
totalPages,
108+
startIndex,
109+
endIndex,
110+
isNextDisabled,
111+
isPreviousDisabled,
112+
}
113+
}

0 commit comments

Comments
 (0)
Please sign in to comment.