Skip to content

Commit 2b90148

Browse files
feat: add paginated dataloader
1 parent e8d6ccb commit 2b90148

File tree

4 files changed

+362
-0
lines changed

4 files changed

+362
-0
lines changed
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
/* eslint-disable no-console */
2+
import { act } from '@testing-library/react'
3+
import { renderHook } from '@testing-library/react-hooks'
4+
import React from 'react'
5+
import DataLoaderProvider from '../DataLoaderProvider'
6+
import { KEY_IS_NOT_STRING_ERROR } from '../constants'
7+
import {
8+
UsePaginatedDataLoaderConfig,
9+
UsePaginatedDataLoaderMethodParams,
10+
UsePaginatedDataLoaderResult,
11+
} from '../types'
12+
import usePaginatedDataLoader from '../usePaginatedDataLoader'
13+
14+
type UseDataLoaderHookProps = {
15+
config: UsePaginatedDataLoaderConfig<unknown>
16+
key: string
17+
method: (params: UsePaginatedDataLoaderMethodParams) => Promise<unknown>
18+
}
19+
20+
const PROMISE_TIMEOUT = 5
21+
22+
const initialProps = {
23+
config: {
24+
enabled: true,
25+
},
26+
key: 'test',
27+
method: jest.fn(
28+
({ page, perPage }: UsePaginatedDataLoaderMethodParams) =>
29+
new Promise(resolve => {
30+
setTimeout(() => resolve(`${page}-${perPage}`), PROMISE_TIMEOUT)
31+
}),
32+
),
33+
}
34+
// eslint-disable-next-line react/prop-types
35+
const wrapper = ({ children }: { children?: React.ReactNode }) => (
36+
<DataLoaderProvider>{children}</DataLoaderProvider>
37+
)
38+
39+
describe('useDataLoader', () => {
40+
test('should render correctly without options', async () => {
41+
const { result, waitForNextUpdate } = renderHook<
42+
UseDataLoaderHookProps,
43+
UsePaginatedDataLoaderResult
44+
>(props => usePaginatedDataLoader(props.key, props.method), {
45+
initialProps,
46+
wrapper,
47+
})
48+
expect(result.current.data).toStrictEqual({})
49+
expect(result.current.pageData).toBe(undefined)
50+
expect(result.current.isLoading).toBe(true)
51+
await waitForNextUpdate()
52+
expect(initialProps.method).toBeCalledTimes(1)
53+
expect(result.current.isLoading).toBe(false)
54+
expect(result.current.isSuccess).toBe(true)
55+
expect(result.current.data).toStrictEqual({ 1: '1-1' })
56+
expect(result.current.pageData).toBe('1-1')
57+
})
58+
59+
test('should render correctly without request enabled then enable it', async () => {
60+
const method = jest.fn(
61+
() =>
62+
new Promise(resolve => {
63+
setTimeout(() => resolve(true), PROMISE_TIMEOUT)
64+
}),
65+
)
66+
let enabled = false
67+
const { rerender, result, waitForNextUpdate } = renderHook<
68+
UseDataLoaderHookProps,
69+
UsePaginatedDataLoaderResult
70+
>(
71+
props =>
72+
usePaginatedDataLoader(props.key, props.method, {
73+
enabled,
74+
}),
75+
{
76+
initialProps: {
77+
...initialProps,
78+
key: 'test-not-enabled-then-reload',
79+
method,
80+
},
81+
wrapper,
82+
},
83+
)
84+
expect(result.current.pageData).toBe(undefined)
85+
expect(result.current.isLoading).toBe(false)
86+
expect(method).toBeCalledTimes(0)
87+
enabled = true
88+
rerender()
89+
expect(method).toBeCalledTimes(1)
90+
expect(result.current.pageData).toBe(undefined)
91+
expect(result.current.isLoading).toBe(true)
92+
await waitForNextUpdate()
93+
expect(result.current.isLoading).toBe(false)
94+
expect(result.current.isSuccess).toBe(true)
95+
expect(result.current.pageData).toBe(true)
96+
})
97+
98+
test('should render correctly without valid key', () => {
99+
const { result } = renderHook<
100+
UseDataLoaderHookProps,
101+
UsePaginatedDataLoaderResult
102+
>(props => usePaginatedDataLoader(props.key, props.method), {
103+
initialProps: {
104+
...initialProps,
105+
// @ts-expect-error used because we test with bad key
106+
key: 2,
107+
},
108+
wrapper,
109+
})
110+
expect(result.error?.message).toBe(KEY_IS_NOT_STRING_ERROR)
111+
})
112+
113+
test('should render correctly with result null', async () => {
114+
const { result, waitForNextUpdate } = renderHook<
115+
UseDataLoaderHookProps,
116+
UsePaginatedDataLoaderResult
117+
>(props => usePaginatedDataLoader(props.key, props.method, props.config), {
118+
initialProps: {
119+
...initialProps,
120+
key: 'test-3',
121+
method: () =>
122+
new Promise(resolve => {
123+
setTimeout(() => resolve(null), PROMISE_TIMEOUT)
124+
}),
125+
},
126+
wrapper,
127+
})
128+
expect(result.current.pageData).toBe(undefined)
129+
expect(result.current.isLoading).toBe(true)
130+
await waitForNextUpdate()
131+
expect(result.current.pageData).toBe(undefined)
132+
expect(result.current.isSuccess).toBe(true)
133+
expect(result.current.isLoading).toBe(false)
134+
})
135+
136+
test('should render correctly then change page', async () => {
137+
const { result, waitForNextUpdate } = renderHook<
138+
UseDataLoaderHookProps,
139+
UsePaginatedDataLoaderResult
140+
>(props => usePaginatedDataLoader(props.key, props.method), {
141+
initialProps: {
142+
...initialProps,
143+
key: 'test-4',
144+
},
145+
wrapper,
146+
})
147+
expect(result.current.data).toStrictEqual({})
148+
expect(result.current.pageData).toBe(undefined)
149+
expect(result.current.isLoading).toBe(true)
150+
await waitForNextUpdate()
151+
expect(result.current.isLoading).toBe(false)
152+
expect(result.current.isSuccess).toBe(true)
153+
expect(result.current.data).toStrictEqual({ 1: '1-1' })
154+
expect(result.current.pageData).toBe('1-1')
155+
156+
act(() => {
157+
result.current.goToNextPage()
158+
})
159+
expect(result.current.page).toBe(2)
160+
expect(result.current.pageData).toBe(undefined)
161+
expect(result.current.isLoading).toBe(true)
162+
await waitForNextUpdate()
163+
expect(result.current.isLoading).toBe(false)
164+
expect(result.current.isSuccess).toBe(true)
165+
expect(result.current.data).toStrictEqual({ 1: '1-1', 2: '2-1' })
166+
expect(result.current.pageData).toBe('2-1')
167+
act(() => {
168+
result.current.goToPreviousPage()
169+
result.current.goToPreviousPage()
170+
result.current.goToPreviousPage()
171+
result.current.goToPreviousPage()
172+
})
173+
expect(result.current.page).toBe(1)
174+
expect(result.current.pageData).toBe('1-1')
175+
act(() => {
176+
result.current.goToPage(2)
177+
result.current.goToPage(-21)
178+
result.current.goToPage(0)
179+
})
180+
expect(result.current.page).toBe(1)
181+
expect(result.current.pageData).toBe('1-1')
182+
})
183+
184+
test('should render correctly go to next page, change key and should be on page 1', async () => {
185+
const hookProps = {
186+
...initialProps,
187+
key: 'test-5',
188+
}
189+
const { rerender, result, waitForNextUpdate } = renderHook<
190+
UseDataLoaderHookProps,
191+
UsePaginatedDataLoaderResult
192+
>(props => usePaginatedDataLoader(props.key, props.method), {
193+
initialProps: hookProps,
194+
wrapper,
195+
})
196+
await waitForNextUpdate()
197+
act(() => {
198+
result.current.goToNextPage()
199+
})
200+
await waitForNextUpdate()
201+
expect(result.current.data).toStrictEqual({ 1: '1-1', 2: '2-1' })
202+
hookProps.key = 'test-5-bis'
203+
rerender()
204+
expect(result.current.isLoading).toBe(true)
205+
expect(result.current.pageData).toBe(undefined)
206+
expect(result.current.data).toStrictEqual({})
207+
await waitForNextUpdate()
208+
expect(result.current.data).toStrictEqual({ 1: '1-1' })
209+
expect(result.current.pageData).toBe('1-1')
210+
expect(result.current.isSuccess).toBe(true)
211+
expect(result.current.isLoading).toBe(false)
212+
})
213+
})

packages/use-dataloader/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export {
33
useDataLoaderContext,
44
} from './DataLoaderProvider'
55
export { default as useDataLoader } from './useDataLoader'
6+
export { default as usePaginatedDataLoader } from './usePaginatedDataLoader'

packages/use-dataloader/src/types.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,43 @@ export interface UseDataLoaderResult<T = unknown> {
5656
previousData?: T
5757
reload: () => Promise<void>
5858
}
59+
60+
/**
61+
* Params send to the method
62+
*/
63+
export type UsePaginatedDataLoaderMethodParams = {
64+
page: number
65+
perPage: number
66+
}
67+
68+
export type UsePaginatedDataLoaderConfig<T = unknown> = {
69+
enabled?: boolean
70+
initialData?: T
71+
keepPreviousData?: boolean
72+
onError?: OnErrorFn
73+
onSuccess?: OnSuccessFn
74+
pollingInterval?: number
75+
/**
76+
* Max time before data from previous success is considered as outdated (in millisecond)
77+
*/
78+
maxDataLifetime?: number
79+
needPolling?: NeedPollingType
80+
initialPage?: number
81+
perPage?: number
82+
}
83+
84+
export type UsePaginatedDataLoaderResult<T = unknown> = {
85+
pageData?: T
86+
data?: Record<number, T | undefined>
87+
error?: Error
88+
isError: boolean
89+
isIdle: boolean
90+
isLoading: boolean
91+
isPolling: boolean
92+
isSuccess: boolean
93+
reload: () => Promise<void>
94+
goToPage: (page: number) => void
95+
goToNextPage: () => void
96+
goToPreviousPage: () => void
97+
page: number
98+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { useCallback, useEffect, useState } from 'react'
2+
import { KEY_IS_NOT_STRING_ERROR } from './constants'
3+
import {
4+
PromiseType,
5+
UsePaginatedDataLoaderConfig,
6+
UsePaginatedDataLoaderMethodParams,
7+
UsePaginatedDataLoaderResult,
8+
} from './types'
9+
import useDataLoader from './useDataLoader'
10+
11+
/**
12+
* @param {string} baseFetchKey base key used to cache data. Hook append -page-X to that key for each page you load
13+
* @param {() => PromiseType} method a method that return a promise
14+
* @param {useDataLoaderConfig} config hook configuration
15+
* @returns {useDataLoaderResult} hook result containing data, request state, and method to reload the data
16+
*/
17+
const usePaginatedDataLoader = <T>(
18+
baseFetchKey: string,
19+
method: (params: UsePaginatedDataLoaderMethodParams) => PromiseType<T>,
20+
{
21+
enabled = true,
22+
initialData,
23+
keepPreviousData = true,
24+
onError,
25+
onSuccess,
26+
pollingInterval,
27+
maxDataLifetime,
28+
needPolling,
29+
initialPage,
30+
perPage = 1,
31+
}: UsePaginatedDataLoaderConfig<T> = {},
32+
): UsePaginatedDataLoaderResult<T> => {
33+
if (typeof baseFetchKey !== 'string') {
34+
throw new Error(KEY_IS_NOT_STRING_ERROR)
35+
}
36+
37+
const [data, setData] = useState<Record<number, T | undefined>>({})
38+
const [page, setPage] = useState<number>(initialPage ?? 1)
39+
40+
const pageMethod = useCallback(
41+
() => method({ page, perPage }),
42+
[method, page, perPage],
43+
)
44+
const {
45+
data: pageData,
46+
isError,
47+
isIdle,
48+
isLoading,
49+
isPolling,
50+
isSuccess,
51+
reload,
52+
error,
53+
} = useDataLoader(`${baseFetchKey}-page-${page}`, pageMethod, {
54+
enabled,
55+
initialData,
56+
keepPreviousData,
57+
maxDataLifetime,
58+
needPolling,
59+
onError,
60+
onSuccess,
61+
pollingInterval,
62+
})
63+
64+
const goToNextPage = useCallback(() => {
65+
setPage(current => current + 1)
66+
}, [])
67+
68+
const goToPreviousPage = useCallback(() => {
69+
setPage(current => (current > 1 ? current - 1 : 1))
70+
}, [])
71+
72+
const goToPage = useCallback((newPage: number) => {
73+
setPage(newPage > 1 ? newPage : 1)
74+
}, [])
75+
76+
useEffect(() => {
77+
setData(current => {
78+
if (pageData !== current[page]) {
79+
return { ...current, [page]: pageData }
80+
}
81+
82+
return current
83+
})
84+
}, [pageData, page])
85+
86+
useEffect(() => {
87+
setPage(1)
88+
setData({})
89+
}, [baseFetchKey])
90+
91+
return {
92+
data,
93+
error,
94+
goToNextPage,
95+
goToPage,
96+
goToPreviousPage,
97+
isError,
98+
isIdle,
99+
isLoading,
100+
isPolling,
101+
isSuccess,
102+
page,
103+
pageData,
104+
reload,
105+
}
106+
}
107+
108+
export default usePaginatedDataLoader

0 commit comments

Comments
 (0)