Skip to content

Commit 3d155b7

Browse files
feat: add useInfiniteDataLoader
1 parent 70b7464 commit 3d155b7

File tree

5 files changed

+510
-43
lines changed

5 files changed

+510
-43
lines changed

.changeset/ninety-pianos-tan.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@scaleway/use-dataloader": minor
3+
---
4+
5+
Add useInfiniteDataLoader

packages/use-dataloader/src/DataLoaderProvider.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export type IDataLoaderContext = {
5050
) => DataLoader<ResultType, ErrorType>
5151
reload: (key?: string) => Promise<void>
5252
reloadAll: () => Promise<void>
53+
reloadGroup: (startKey?: string) => Promise<void>
5354
}
5455

5556
// @ts-expect-error we force the context to undefined, should be corrected with default values
@@ -136,6 +137,16 @@ const DataLoaderProvider = ({
136137
[getRequest],
137138
)
138139

140+
const reloadGroup = useCallback(async (startPrefix?: string) => {
141+
if (startPrefix && typeof startPrefix === 'string') {
142+
await Promise.all(
143+
Object.values(requestsRef.current)
144+
.filter(request => request.key.startsWith(startPrefix))
145+
.map(request => request.load(true)),
146+
)
147+
} else throw new Error(KEY_IS_NOT_STRING_ERROR)
148+
}, [])
149+
139150
const reloadAll = useCallback(async () => {
140151
await Promise.all(
141152
Object.values(requestsRef.current).map(request => request.load(true)),
@@ -189,6 +200,7 @@ const DataLoaderProvider = ({
189200
onError,
190201
reload,
191202
reloadAll,
203+
reloadGroup,
192204
}),
193205
[
194206
addRequest,
@@ -202,6 +214,7 @@ const DataLoaderProvider = ({
202214
onError,
203215
reload,
204216
reloadAll,
217+
reloadGroup,
205218
],
206219
)
207220

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import { renderHook, waitFor } from '@testing-library/react'
2+
import { type ReactNode, act } from 'react'
3+
import { describe, expect, it, vi } from 'vitest'
4+
import DataLoaderProvider from '../DataLoaderProvider'
5+
import type { UseInfiniteDataLoaderConfig } from '../types'
6+
import { useInfiniteDataLoader } from '../useInfiniteDataLoader'
7+
8+
const config: UseInfiniteDataLoaderConfig<
9+
{ nextPage: number; data: string },
10+
Error,
11+
{ page: number },
12+
'page'
13+
> = {
14+
getNextPage: result => result.nextPage,
15+
pageKey: 'page',
16+
params: {
17+
page: 1,
18+
},
19+
enabled: true,
20+
}
21+
22+
const getPrerequisite = (key: string) => {
23+
let counter = 1
24+
let canResolve = false
25+
const getNextData = vi.fn(
26+
() =>
27+
new Promise<{ nextPage: number; data: string }>(resolve => {
28+
const resolvePromise = () => {
29+
if (canResolve) {
30+
counter += 1
31+
resolve({ nextPage: counter, data: `Page ${counter - 1} data` })
32+
} else {
33+
setTimeout(() => {
34+
resolvePromise()
35+
}, 100)
36+
}
37+
}
38+
resolvePromise()
39+
}),
40+
)
41+
42+
return {
43+
initialProps: {
44+
config: {
45+
enabled: true,
46+
},
47+
key,
48+
method: getNextData,
49+
},
50+
setCanResolve: (newState: boolean) => {
51+
canResolve = newState
52+
},
53+
resetCounter: () => {
54+
counter = 1
55+
},
56+
canResolve,
57+
counter,
58+
}
59+
}
60+
const wrapper = ({ children }: { children?: ReactNode }) => (
61+
<DataLoaderProvider>{children}</DataLoaderProvider>
62+
)
63+
64+
describe('useInfinitDataLoader', () => {
65+
it('should get the first page on mount while enabled', async () => {
66+
const { setCanResolve, initialProps } = getPrerequisite('test1')
67+
const { result } = renderHook(
68+
props => useInfiniteDataLoader(props.key, props.method, config),
69+
{
70+
initialProps,
71+
wrapper,
72+
},
73+
)
74+
expect(result.current.data).toBe(undefined)
75+
expect(result.current.isLoading).toBe(true)
76+
expect(initialProps.method).toHaveBeenCalledTimes(1)
77+
setCanResolve(true)
78+
await waitFor(() => expect(result.current.isSuccess).toBe(true))
79+
expect(initialProps.method).toHaveBeenCalledTimes(1)
80+
expect(result.current.data).toStrictEqual([
81+
{ nextPage: 2, data: 'Page 1 data' },
82+
])
83+
expect(result.current.isLoading).toBe(false)
84+
})
85+
86+
it('should get the first and loadMore one page on mount while enabled', async () => {
87+
const { setCanResolve, initialProps } = getPrerequisite('test2')
88+
const { result } = renderHook(
89+
props => useInfiniteDataLoader(props.key, props.method, config),
90+
{
91+
initialProps,
92+
wrapper,
93+
},
94+
)
95+
expect(result.current.data).toBe(undefined)
96+
expect(result.current.isLoading).toBe(true)
97+
expect(initialProps.method).toHaveBeenCalledTimes(1)
98+
expect(initialProps.method).toHaveBeenCalledWith({
99+
page: 1,
100+
})
101+
setCanResolve(true)
102+
await waitFor(() => expect(result.current.isSuccess).toBe(true))
103+
expect(initialProps.method).toHaveBeenCalledTimes(1)
104+
expect(result.current.data).toStrictEqual([
105+
{ nextPage: 2, data: 'Page 1 data' },
106+
])
107+
expect(result.current.isLoading).toBe(false)
108+
setCanResolve(false)
109+
act(() => {
110+
result.current.loadMore()
111+
})
112+
expect(result.current.data).toStrictEqual([
113+
{ nextPage: 2, data: 'Page 1 data' },
114+
])
115+
await waitFor(() => expect(result.current.isLoading).toBe(true))
116+
expect(initialProps.method).toHaveBeenCalledTimes(2)
117+
expect(initialProps.method).toHaveBeenCalledWith({
118+
page: 2,
119+
})
120+
setCanResolve(true)
121+
await waitFor(() => expect(result.current.isSuccess).toBe(true))
122+
expect(result.current.data).toStrictEqual([
123+
{ nextPage: 2, data: 'Page 1 data' },
124+
{ nextPage: 3, data: 'Page 2 data' },
125+
])
126+
})
127+
128+
it('should get the first and loadMore one page on mount while enabled then reload', async () => {
129+
const { setCanResolve, initialProps, resetCounter } =
130+
getPrerequisite('test3')
131+
const { result } = renderHook(
132+
props => useInfiniteDataLoader(props.key, props.method, config),
133+
{
134+
initialProps,
135+
wrapper,
136+
},
137+
)
138+
expect(result.current.data).toBe(undefined)
139+
expect(result.current.isLoading).toBe(true)
140+
expect(initialProps.method).toHaveBeenCalledTimes(1)
141+
expect(initialProps.method).toHaveBeenCalledWith({
142+
page: 1,
143+
})
144+
setCanResolve(true)
145+
await waitFor(() => expect(result.current.isSuccess).toBe(true))
146+
setCanResolve(false)
147+
expect(initialProps.method).toHaveBeenCalledTimes(1)
148+
expect(result.current.data).toStrictEqual([
149+
{ nextPage: 2, data: 'Page 1 data' },
150+
])
151+
expect(result.current.isLoading).toBe(false)
152+
act(() => {
153+
result.current.loadMore()
154+
})
155+
await waitFor(() => expect(result.current.isLoading).toBe(true))
156+
expect(result.current.data).toStrictEqual([
157+
{ nextPage: 2, data: 'Page 1 data' },
158+
])
159+
expect(initialProps.method).toHaveBeenCalledTimes(2)
160+
expect(initialProps.method).toHaveBeenCalledWith({
161+
page: 2,
162+
})
163+
setCanResolve(true)
164+
await waitFor(() => expect(result.current.isSuccess).toBe(true))
165+
expect(result.current.data).toStrictEqual([
166+
{ nextPage: 2, data: 'Page 1 data' },
167+
{ nextPage: 3, data: 'Page 2 data' },
168+
])
169+
setCanResolve(false)
170+
resetCounter()
171+
act(() => {
172+
result.current.reload().catch(() => null)
173+
})
174+
await waitFor(() => expect(result.current.isLoading).toBe(true))
175+
setCanResolve(true)
176+
await waitFor(() => expect(result.current.isSuccess).toBe(true))
177+
expect(result.current.data).toStrictEqual([
178+
{ nextPage: 2, data: 'Page 1 data' },
179+
{ nextPage: 3, data: 'Page 2 data' },
180+
])
181+
})
182+
})

packages/use-dataloader/src/types.ts

Lines changed: 82 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -16,78 +16,117 @@ export type NeedPollingType<ResultType> =
1616
| boolean
1717
| ((data?: ResultType) => boolean)
1818

19-
/**
20-
* @typedef {Object} UseDataLoaderConfig
21-
* @property {Function} [onSuccess] callback when a request success
22-
* @property {Function} [onError] callback when a error is occured, this will override the onError specified on the Provider if any
23-
* @property {*} [initialData] initial data if no one is present in the cache before the request
24-
* @property {number} [pollingInterval] relaunch the request after the last success
25-
* @property {boolean} [enabled=true] launch request automatically (default true)
26-
* @property {boolean} [keepPreviousData=true] do we need to keep the previous data after reload (default true)
27-
* @property {number} [dataLifetime=undefined] Time before data from previous success is considered as outdated (in millisecond)
28-
* @property {NeedPollingType} [needPolling=true] When pollingInterval is set you can set a set a custom callback to know if polling is enabled
29-
*/
3019
export type UseDataLoaderConfig<ResultType, ErrorType> = {
20+
/**
21+
* Launch request automatically on mount
22+
* @default true
23+
*/
3124
enabled?: boolean
25+
/**
26+
* The initial data if no one is present in the cache before the request
27+
*/
3228
initialData?: ResultType
29+
/**
30+
* Do we need to keep the previous data after reload (default true)
31+
* @default true
32+
*/
3333
keepPreviousData?: boolean
34+
/*
35+
* Callback when a error is occured, this will override the onError specified on the Provider if any
36+
*/
3437
onError?: OnErrorFn<ErrorType>
38+
/**
39+
* Callback when a request success
40+
*/
3541
onSuccess?: OnSuccessFn<ResultType>
42+
/**
43+
* If you want to relaunch the request after the last success
44+
*/
3645
pollingInterval?: number
46+
/**
47+
* Time before data from previous success is considered as outdated (in millisecond)
48+
* @default undefined
49+
*/
3750
dataLifetime?: number
51+
/**
52+
* When pollingInterval is set you can set a set a custom callback to know if polling is enabled
53+
* @default true
54+
*/
3855
needPolling?: NeedPollingType<ResultType>
3956
}
4057

41-
/**
42-
* @typedef {Object} UseDataLoaderResult
43-
* @property {boolean} isIdle true if the hook in initial state
44-
* @property {boolean} isLoading true if the request is launched
45-
* @property {boolean} isSuccess true if the request success
46-
* @property {boolean} isError true if the request throw an error
47-
* @property {boolean} isPolling true if the request if enabled is true, pollingInterval is defined and the status is isLoading or isSuccess
48-
* @property {*} previousData if keepPreviousData is true it return the last data fetched
49-
* @property {*} data initialData if no data is fetched or not present in the cache otherwise return the data fetched
50-
* @property {string} error the error occured during the request
51-
* @property {Function} reload reload the data
52-
*/
5358
export type UseDataLoaderResult<ResultType, ErrorType> = {
59+
/**
60+
* Return initialData if no data is fetched or not present in the cache otherwise return the data fetched
61+
*/
5462
data?: ResultType
63+
/**
64+
* The error occured during the request
65+
*/
5566
error?: ErrorType
67+
/**
68+
* True if the request throw an error
69+
*/
5670
isError: boolean
71+
/**
72+
* True if the hook in initial state
73+
*/
5774
isIdle: boolean
75+
/**
76+
* True if the request is launched
77+
*/
5878
isLoading: boolean
79+
/**
80+
* True if the request if enabled is true, pollingInterval is defined and the status is isLoading or isSuccess
81+
*/
5982
isPolling: boolean
83+
/**
84+
* True if the request success
85+
*/
6086
isSuccess: boolean
87+
/**
88+
* If keepPreviousData is true it return the last data fetched
89+
*/
6190
previousData?: ResultType
91+
/**
92+
* Reload the data
93+
*/
6294
reload: () => Promise<void>
6395
}
6496

65-
/**
66-
* Params send to the method
67-
*/
68-
export type UsePaginatedDataLoaderMethodParams = {
69-
page: number
70-
perPage: number
97+
export type UseInfiniteDataLoaderConfig<
98+
ResultType,
99+
ErrorType extends Error,
100+
ParamsType,
101+
ParamsKey extends keyof ParamsType,
102+
> = Omit<UseDataLoaderConfig<ResultType, ErrorType>, 'initialData'> & {
103+
/**
104+
* Params will be forwarded to method
105+
*/
106+
params: ParamsType
107+
/**
108+
* The key to change in params
109+
*/
110+
pageKey: ParamsKey
111+
/**
112+
* If return undefined it consider that there are no remaining page to load
113+
*/
114+
getNextPage: (result: ResultType) => ParamsType[ParamsKey]
115+
/**
116+
* The initial data if no one is present in the cache before the request
117+
*/
118+
initialData?: ResultType[]
71119
}
72120

73-
export type UsePaginatedDataLoaderConfig<ResultType, ErrorType> =
74-
UseDataLoaderConfig<ResultType, ErrorType> & {
75-
initialPage?: number
76-
perPage?: number
77-
}
78-
79-
export type UsePaginatedDataLoaderResult<ResultType, ErrorType> = {
80-
pageData?: ResultType
81-
data?: Record<number, ResultType | undefined>
121+
export type UseInfiniteDataLoaderResult<ResultType, ErrorType> = {
122+
data?: ResultType[]
82123
error?: ErrorType
83124
isError: boolean
84125
isIdle: boolean
85126
isLoading: boolean
86-
isPolling: boolean
127+
isLoadingFirstPage: boolean
87128
isSuccess: boolean
129+
hasNextPage: boolean
88130
reload: () => Promise<void>
89-
goToPage: (page: number) => void
90-
goToNextPage: () => void
91-
goToPreviousPage: () => void
92-
page: number
131+
loadMore: () => void
93132
}

0 commit comments

Comments
 (0)