Skip to content

Commit a440e71

Browse files
feat: add paginated dataloader (#524)
1 parent f0129ed commit a440e71

File tree

4 files changed

+361
-0
lines changed

4 files changed

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

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)