@promoboxx/use-filter 1.11.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/main.yml +38 -0
- package/.vscode/settings.json +3 -0
- package/CHANGELOG.md +185 -0
- package/Makefile +25 -0
- package/eslint.config.js +30 -0
- package/mise.toml +3 -0
- package/package.json +33 -43
- package/prettier.config.js +3 -0
- package/src/lib/buildDefaultFilterInfo.ts +36 -0
- package/src/lib/getOffsetFromPage.ts +5 -0
- package/src/lib/getPageFromOffset.ts +5 -0
- package/src/lib/shallowEqual.test.ts +71 -0
- package/src/lib/shallowEqual.ts +26 -0
- package/src/store/index.ts +30 -0
- package/src/store/localStorageStore.ts +36 -0
- package/src/store/memoryStore.ts +27 -0
- package/src/store/reduxHelpers/createActions.test.ts +32 -0
- package/src/store/reduxHelpers/createActions.ts +56 -0
- package/src/store/reduxHelpers/createReducer.test.ts +65 -0
- package/src/store/reduxHelpers/createReducer.ts +47 -0
- package/src/store/reduxStore.ts +78 -0
- package/src/store/urlParamStore.test.ts +131 -0
- package/src/store/urlParamStore.ts +85 -0
- package/src/useFilter.test.tsx +822 -0
- package/src/useFilter.ts +524 -0
- package/src/useSimpleFilter.test.tsx +676 -0
- package/src/useSimpleFilter.ts +397 -0
- package/src/vitest-env.d.ts +1 -0
- package/tsconfig.json +76 -0
- package/tsdown.config.ts +30 -0
- package/vite.config.ts +9 -0
- package/dist/lib/buildDefaultFilterInfo.d.ts +0 -3
- package/dist/lib/buildDefaultFilterInfo.js +0 -35
- package/dist/lib/getOffsetFromPage.d.ts +0 -2
- package/dist/lib/getOffsetFromPage.js +0 -6
- package/dist/lib/getPageFromOffset.d.ts +0 -2
- package/dist/lib/getPageFromOffset.js +0 -6
- package/dist/lib/shallowEqual.d.ts +0 -2
- package/dist/lib/shallowEqual.js +0 -23
- package/dist/store/index.d.ts +0 -10
- package/dist/store/index.js +0 -16
- package/dist/store/localStorageStore.d.ts +0 -3
- package/dist/store/localStorageStore.js +0 -31
- package/dist/store/memoryStore.d.ts +0 -3
- package/dist/store/memoryStore.js +0 -23
- package/dist/store/reduxHelpers/createActions.d.ts +0 -16
- package/dist/store/reduxHelpers/createActions.js +0 -27
- package/dist/store/reduxHelpers/createReducer.d.ts +0 -8
- package/dist/store/reduxHelpers/createReducer.js +0 -26
- package/dist/store/reduxStore.d.ts +0 -15
- package/dist/store/reduxStore.js +0 -67
- package/dist/store/urlParamStore.d.ts +0 -2
- package/dist/store/urlParamStore.js +0 -86
- package/dist/useFilter.d.ts +0 -103
- package/dist/useFilter.js +0 -254
- package/dist/useSimpleFilter.d.ts +0 -86
- package/dist/useSimpleFilter.js +0 -173
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
|
2
|
+
import { useEffect, useState } from 'react'
|
|
3
|
+
|
|
4
|
+
import buildDefaultFilterInfo from './lib/buildDefaultFilterInfo'
|
|
5
|
+
import type { FilterStore } from './store'
|
|
6
|
+
import { setFilterStore } from './store'
|
|
7
|
+
import localStorageStore from './store/localStorageStore'
|
|
8
|
+
import memoryStore from './store/memoryStore'
|
|
9
|
+
import { createUrlParamStore } from './store/urlParamStore'
|
|
10
|
+
import type { SimpleFilterInfo } from './useSimpleFilter'
|
|
11
|
+
import useSimpleFilter from './useSimpleFilter'
|
|
12
|
+
|
|
13
|
+
async function runFilterTest(testOptions: { store: FilterStore }) {
|
|
14
|
+
describe('useSimpleFilter', () => {
|
|
15
|
+
const defaultFilterInfo: any = {
|
|
16
|
+
filter: {
|
|
17
|
+
foo: 'bar',
|
|
18
|
+
},
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
it('calls on mount', async () => {
|
|
22
|
+
const { onChangeMock, onDebounceChange } = await setup({
|
|
23
|
+
defaultFilterInfo,
|
|
24
|
+
})
|
|
25
|
+
await actAndWait(
|
|
26
|
+
() => {},
|
|
27
|
+
// Intentionally wait a little longer to make sure no sneaky hooks are
|
|
28
|
+
// unintentionally fired.
|
|
29
|
+
DEBOUNCE_DURATION * 2,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
expect(onChangeMock).toHaveBeenCalledTimes(1)
|
|
33
|
+
expect(onChangeMock).toHaveBeenLastCalledWith(
|
|
34
|
+
expect.objectContaining({ filter: { foo: 'bar' } }),
|
|
35
|
+
)
|
|
36
|
+
expect(onDebounceChange).toHaveBeenCalledTimes(1)
|
|
37
|
+
expect(onDebounceChange).toHaveBeenLastCalledWith(
|
|
38
|
+
expect.objectContaining({ filter: { foo: 'bar' } }),
|
|
39
|
+
)
|
|
40
|
+
expect(screen.getByTestId('updateReason').textContent).toBe('initial')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('calls when data changes', async () => {
|
|
44
|
+
const { onChangeMock, onDebounceChange } = await setup({
|
|
45
|
+
defaultFilterInfo,
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
await actAndWait(
|
|
49
|
+
() => {
|
|
50
|
+
fireEvent.click(screen.getByTestId('updateFilterButton'))
|
|
51
|
+
},
|
|
52
|
+
// Intentionally don't wait here, as we're going to test that our debounce
|
|
53
|
+
// hooks haven't fired too early.
|
|
54
|
+
0,
|
|
55
|
+
)
|
|
56
|
+
expect(onChangeMock).toHaveBeenCalledTimes(2)
|
|
57
|
+
expect(onChangeMock).toHaveBeenLastCalledWith(
|
|
58
|
+
expect.objectContaining({ filter: { foo: '42' } }),
|
|
59
|
+
)
|
|
60
|
+
expect(onDebounceChange).toHaveBeenCalledTimes(1)
|
|
61
|
+
expect(onDebounceChange).toHaveBeenLastCalledWith(
|
|
62
|
+
expect.objectContaining({ filter: { foo: 'bar' } }),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
// Now we can wait and make sure our debounce hook as fired.
|
|
66
|
+
await actAndWait(() => {})
|
|
67
|
+
expect(onDebounceChange).toHaveBeenCalledTimes(2)
|
|
68
|
+
expect(onDebounceChange).toHaveBeenLastCalledWith(
|
|
69
|
+
expect.objectContaining({ filter: { foo: '42' } }),
|
|
70
|
+
)
|
|
71
|
+
expect(screen.getByTestId('updateReason').textContent).toBe('filter')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('uses shallowEqual be default', async () => {
|
|
75
|
+
const { onChangeMock } = await setup({ defaultFilterInfo })
|
|
76
|
+
|
|
77
|
+
await actAndWait(() => {
|
|
78
|
+
fireEvent.click(screen.getByTestId('updateFilterButton'))
|
|
79
|
+
}, 0)
|
|
80
|
+
await actAndWait(() => {
|
|
81
|
+
fireEvent.click(screen.getByTestId('updateFilterButton'))
|
|
82
|
+
}, 0)
|
|
83
|
+
// 2 is still the correct number of times, as there is one from the initial
|
|
84
|
+
// mount and the other from updateFilter.
|
|
85
|
+
expect(onChangeMock).toHaveBeenCalledTimes(2)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('can reset and keeps defaults', async () => {
|
|
89
|
+
const { onChangeMock } = await setup({ defaultFilterInfo })
|
|
90
|
+
|
|
91
|
+
await actAndWait(() => {
|
|
92
|
+
fireEvent.click(screen.getByTestId('resetFilterButton'))
|
|
93
|
+
})
|
|
94
|
+
expect(onChangeMock).toHaveBeenCalledTimes(2)
|
|
95
|
+
expect(onChangeMock).toHaveBeenLastCalledWith(
|
|
96
|
+
expect.objectContaining({ filter: { foo: 'bar' } }),
|
|
97
|
+
)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('can setSort', async () => {
|
|
101
|
+
const { onChangeMock } = await setup({ defaultFilterInfo })
|
|
102
|
+
|
|
103
|
+
await actAndWait(() => {
|
|
104
|
+
fireEvent.click(screen.getByTestId('setSortButton'))
|
|
105
|
+
})
|
|
106
|
+
expect(onChangeMock).toHaveBeenCalledTimes(2)
|
|
107
|
+
expect(onChangeMock).toHaveBeenLastCalledWith(
|
|
108
|
+
expect.objectContaining({ filter: { foo: 'bar' }, sort: 'foo:bar' }),
|
|
109
|
+
)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('renders the current filter', async () => {
|
|
113
|
+
await setup({ defaultFilterInfo })
|
|
114
|
+
|
|
115
|
+
expect(
|
|
116
|
+
safeJsonParse(screen.getByTestId('filterJson').textContent),
|
|
117
|
+
).toEqual(expect.objectContaining({ filter: { foo: 'bar' } }))
|
|
118
|
+
|
|
119
|
+
await actAndWait(() => {
|
|
120
|
+
fireEvent.click(screen.getByTestId('updateFilterButton'))
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
expect(
|
|
124
|
+
safeJsonParse(screen.getByTestId('filterJson').textContent),
|
|
125
|
+
).toEqual(expect.objectContaining({ filter: { foo: '42' } }))
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('persists across mounts', async () => {
|
|
129
|
+
const TIMES_CALLED_IN_SETUP = 1
|
|
130
|
+
const { ExampleComponent, onChangeMock, onDebounceChange } = await setup({
|
|
131
|
+
defaultFilterInfo,
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
const ToggleComponent = () => {
|
|
135
|
+
const [shouldShow, setShouldShow] = useState(true)
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<div>
|
|
139
|
+
<button
|
|
140
|
+
data-testid="toggleShouldShow"
|
|
141
|
+
onClick={() => setShouldShow(!shouldShow)}
|
|
142
|
+
/>
|
|
143
|
+
{shouldShow ? <ExampleComponent /> : null}
|
|
144
|
+
</div>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Simulate the filter being visible.
|
|
149
|
+
await actAndWait(() => {
|
|
150
|
+
// Cleanup the render done in `setup`.
|
|
151
|
+
cleanup()
|
|
152
|
+
render(<ToggleComponent />)
|
|
153
|
+
})
|
|
154
|
+
expect(screen.queryByTestId('updateFilterButton')).toBeTruthy()
|
|
155
|
+
expect(onChangeMock).toHaveBeenCalledTimes(1 + TIMES_CALLED_IN_SETUP)
|
|
156
|
+
expect(onDebounceChange).toHaveBeenCalledTimes(1 + TIMES_CALLED_IN_SETUP)
|
|
157
|
+
|
|
158
|
+
// Update the filter
|
|
159
|
+
await actAndWait(() => {
|
|
160
|
+
fireEvent.click(screen.getByTestId('updateFilterButton'))
|
|
161
|
+
})
|
|
162
|
+
expect(onChangeMock).toHaveBeenCalledTimes(2 + TIMES_CALLED_IN_SETUP)
|
|
163
|
+
expect(onDebounceChange).toHaveBeenCalledTimes(2 + TIMES_CALLED_IN_SETUP)
|
|
164
|
+
|
|
165
|
+
// Simulate removing the filter.
|
|
166
|
+
await actAndWait(() => {
|
|
167
|
+
fireEvent.click(screen.getByTestId('toggleShouldShow'))
|
|
168
|
+
}, 0)
|
|
169
|
+
expect(screen.queryByTestId('updateFilterButton')).toBeFalsy()
|
|
170
|
+
|
|
171
|
+
// Simulate the filter being visible, again.
|
|
172
|
+
await actAndWait(() => {
|
|
173
|
+
fireEvent.click(screen.getByTestId('toggleShouldShow'))
|
|
174
|
+
})
|
|
175
|
+
expect(screen.queryByTestId('updateFilterButton')).toBeTruthy()
|
|
176
|
+
expect(onChangeMock).toHaveBeenCalledTimes(3 + TIMES_CALLED_IN_SETUP)
|
|
177
|
+
expect(onDebounceChange).toHaveBeenCalledTimes(3 + TIMES_CALLED_IN_SETUP)
|
|
178
|
+
|
|
179
|
+
// Ensure it's the updated filter.
|
|
180
|
+
expect(
|
|
181
|
+
safeJsonParse(screen.getByTestId('filterJson').textContent),
|
|
182
|
+
).toEqual(expect.objectContaining({ filter: { foo: '42' } }))
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('uses onBeforeSaveFilter to control what is saved', async () => {
|
|
186
|
+
const { ExampleComponent } = await setup({
|
|
187
|
+
defaultFilterInfo,
|
|
188
|
+
onBeforeSaveFilter: (filterInfo) => {
|
|
189
|
+
return {
|
|
190
|
+
...filterInfo,
|
|
191
|
+
filter: {
|
|
192
|
+
...filterInfo.filter,
|
|
193
|
+
somethingAddedInOnBeforeSaveFilter: 'hello world',
|
|
194
|
+
},
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
const ToggleComponent = () => {
|
|
200
|
+
const [shouldShow, setShouldShow] = useState(true)
|
|
201
|
+
|
|
202
|
+
return (
|
|
203
|
+
<div>
|
|
204
|
+
<button
|
|
205
|
+
data-testid="toggleShouldShow"
|
|
206
|
+
onClick={() => setShouldShow(!shouldShow)}
|
|
207
|
+
/>
|
|
208
|
+
{shouldShow ? <ExampleComponent /> : null}
|
|
209
|
+
</div>
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Simulate the filter being visible.
|
|
214
|
+
await actAndWait(() => {
|
|
215
|
+
// Cleanup the render done in `setup`.
|
|
216
|
+
cleanup()
|
|
217
|
+
render(<ToggleComponent />)
|
|
218
|
+
})
|
|
219
|
+
expect(screen.queryByTestId('updateFilterButton')).toBeTruthy()
|
|
220
|
+
|
|
221
|
+
// Do something to ensure filter is saved.
|
|
222
|
+
await actAndWait(() => {
|
|
223
|
+
fireEvent.click(screen.getByTestId('updateFilterButton'))
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
// Simulate removing the filter.
|
|
227
|
+
await actAndWait(() => {
|
|
228
|
+
fireEvent.click(screen.getByTestId('toggleShouldShow'))
|
|
229
|
+
})
|
|
230
|
+
expect(screen.queryByTestId('updateFilterButton')).toBeFalsy()
|
|
231
|
+
|
|
232
|
+
// Simulate the filter being visible, again.
|
|
233
|
+
await actAndWait(() => {
|
|
234
|
+
fireEvent.click(screen.getByTestId('toggleShouldShow'))
|
|
235
|
+
})
|
|
236
|
+
expect(screen.queryByTestId('updateFilterButton')).toBeTruthy()
|
|
237
|
+
|
|
238
|
+
// Verify that the data we added in `onBeforeSaveFilter` is there.
|
|
239
|
+
expect(
|
|
240
|
+
safeJsonParse(screen.getByTestId('filterJson').textContent),
|
|
241
|
+
).toEqual(
|
|
242
|
+
expect.objectContaining({
|
|
243
|
+
filter: expect.objectContaining({
|
|
244
|
+
somethingAddedInOnBeforeSaveFilter: 'hello world',
|
|
245
|
+
}),
|
|
246
|
+
}),
|
|
247
|
+
)
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('has pagingInfo helper', async () => {
|
|
251
|
+
await setup({ defaultFilterInfo })
|
|
252
|
+
|
|
253
|
+
expect(screen.getByTestId('totalPages').textContent).toEqual('5')
|
|
254
|
+
expect(screen.getByTestId('totalResults').textContent).toEqual('100')
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('can setPageSize', async () => {
|
|
258
|
+
const defaultFilterInfo = buildDefaultFilterInfo({
|
|
259
|
+
filter: {
|
|
260
|
+
foo: 'bar',
|
|
261
|
+
},
|
|
262
|
+
pageSize: 20,
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
const children: TestCaseChildren = (api) => (
|
|
266
|
+
<>
|
|
267
|
+
<button
|
|
268
|
+
data-testid="setPageButton"
|
|
269
|
+
onClick={() => api.setPage(api.filterInfo.page + 1)}
|
|
270
|
+
/>
|
|
271
|
+
<button
|
|
272
|
+
data-testid="setPageSizeButton"
|
|
273
|
+
onClick={() => api.setPageSize(5)}
|
|
274
|
+
/>
|
|
275
|
+
</>
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
const { onDebounceChange } = await setup({ defaultFilterInfo, children })
|
|
279
|
+
|
|
280
|
+
await actAndWait(() => {
|
|
281
|
+
fireEvent.click(screen.getByTestId('setPageButton'))
|
|
282
|
+
})
|
|
283
|
+
expect(onDebounceChange).toBeCalledTimes(2)
|
|
284
|
+
expect(onDebounceChange).toHaveBeenLastCalledWith(
|
|
285
|
+
expect.objectContaining({
|
|
286
|
+
page: 2,
|
|
287
|
+
offset: 20,
|
|
288
|
+
}),
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
await actAndWait(() => {
|
|
292
|
+
fireEvent.click(screen.getByTestId('setPageSizeButton'))
|
|
293
|
+
})
|
|
294
|
+
expect(onDebounceChange).toBeCalledTimes(3)
|
|
295
|
+
expect(onDebounceChange).toHaveBeenLastCalledWith(
|
|
296
|
+
expect.objectContaining({
|
|
297
|
+
page: 5,
|
|
298
|
+
offset: 20,
|
|
299
|
+
}),
|
|
300
|
+
)
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('lets you pass your own store', async () => {
|
|
304
|
+
const mockStore: FilterStore = {
|
|
305
|
+
getFilter: vitest.fn(),
|
|
306
|
+
saveFilter: vitest.fn(),
|
|
307
|
+
getData: vitest.fn(),
|
|
308
|
+
saveData: vitest.fn(),
|
|
309
|
+
clear: vitest.fn(),
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
await setup({
|
|
313
|
+
defaultFilterInfo,
|
|
314
|
+
store: mockStore,
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
expect(mockStore.getFilter).toHaveBeenCalled()
|
|
318
|
+
expect(mockStore.saveFilter).toHaveBeenCalled()
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('handles stores not returning a full filterInfo', async () => {
|
|
322
|
+
const mockStore: FilterStore = {
|
|
323
|
+
getFilter: vitest
|
|
324
|
+
.fn()
|
|
325
|
+
.mockReturnValue({ filter: { fromStore: 'lol' } }),
|
|
326
|
+
saveFilter: vitest.fn(),
|
|
327
|
+
getData: vitest.fn(),
|
|
328
|
+
saveData: vitest.fn(),
|
|
329
|
+
clear: vitest.fn(),
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
await setup({
|
|
333
|
+
defaultFilterInfo,
|
|
334
|
+
store: mockStore,
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
expect(
|
|
338
|
+
safeJsonParse(screen.getByTestId('filterJson').textContent),
|
|
339
|
+
).toEqual(
|
|
340
|
+
expect.objectContaining({
|
|
341
|
+
// This asserts that when a store returns a `Partial<FilterInfo>` it's
|
|
342
|
+
// turned into a full `FilterInfo`.
|
|
343
|
+
page: 1,
|
|
344
|
+
}),
|
|
345
|
+
)
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
it('merges stored filters with whats passed to the hook', async () => {
|
|
349
|
+
const mockStore: FilterStore = {
|
|
350
|
+
getFilter: vitest
|
|
351
|
+
.fn()
|
|
352
|
+
.mockReturnValue({ filter: { fromStore: 'lol' } }),
|
|
353
|
+
saveFilter: vitest.fn(),
|
|
354
|
+
getData: vitest.fn(),
|
|
355
|
+
saveData: vitest.fn(),
|
|
356
|
+
clear: vitest.fn(),
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
await setup({
|
|
360
|
+
defaultFilterInfo,
|
|
361
|
+
store: mockStore,
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
expect(
|
|
365
|
+
safeJsonParse(screen.getByTestId('filterJson').textContent),
|
|
366
|
+
).toEqual(
|
|
367
|
+
expect.objectContaining({
|
|
368
|
+
filter: {
|
|
369
|
+
// This asserts that whatever the store returns is included.
|
|
370
|
+
fromStore: 'lol',
|
|
371
|
+
// This asserts that whatever defaultFilterInfo passed to the hook is
|
|
372
|
+
// included.
|
|
373
|
+
foo: 'bar',
|
|
374
|
+
},
|
|
375
|
+
}),
|
|
376
|
+
)
|
|
377
|
+
})
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
describe('page based', () => {
|
|
381
|
+
const defaultFilterInfo = buildDefaultFilterInfo({
|
|
382
|
+
filter: {
|
|
383
|
+
foo: 'bar',
|
|
384
|
+
},
|
|
385
|
+
pageSize: 20,
|
|
386
|
+
page: 2,
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
const children: TestCaseChildren = (api) => (
|
|
390
|
+
<button data-testid="setPageButton" onClick={() => api.setPage(3)} />
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
it('accepts initial page', async () => {
|
|
394
|
+
await setup({ defaultFilterInfo, children })
|
|
395
|
+
|
|
396
|
+
expect(
|
|
397
|
+
safeJsonParse(screen.getByTestId('filterJson').textContent),
|
|
398
|
+
).toEqual(expect.objectContaining({ page: 2, offset: 20 }))
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
it('can set page', async () => {
|
|
402
|
+
const { onChangeMock } = await setup({ defaultFilterInfo, children })
|
|
403
|
+
|
|
404
|
+
await actAndWait(() => {
|
|
405
|
+
fireEvent.click(screen.getByTestId('setPageButton'))
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
expect(onChangeMock).toHaveBeenCalledTimes(2)
|
|
409
|
+
expect(onChangeMock).toHaveBeenLastCalledWith(
|
|
410
|
+
expect.objectContaining({ page: 3, offset: 40 }),
|
|
411
|
+
)
|
|
412
|
+
expect(screen.getByTestId('updateReason').textContent).toBe('pagination')
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
it('resets pagination when filters change', async () => {
|
|
416
|
+
const { onChangeMock } = await setup({ defaultFilterInfo, children })
|
|
417
|
+
|
|
418
|
+
await actAndWait(() => {
|
|
419
|
+
fireEvent.click(screen.getByTestId('setPageButton'))
|
|
420
|
+
})
|
|
421
|
+
expect(onChangeMock).toHaveBeenCalledTimes(2)
|
|
422
|
+
expect(onChangeMock).toHaveBeenLastCalledWith(
|
|
423
|
+
expect.objectContaining({ page: 3, offset: 40 }),
|
|
424
|
+
)
|
|
425
|
+
expect(screen.getByTestId('updateReason').textContent).toBe('pagination')
|
|
426
|
+
|
|
427
|
+
await actAndWait(() => {
|
|
428
|
+
fireEvent.click(screen.getByTestId('updateFilterButton'))
|
|
429
|
+
})
|
|
430
|
+
expect(onChangeMock).toHaveBeenCalledTimes(3)
|
|
431
|
+
expect(onChangeMock).toHaveBeenLastCalledWith(
|
|
432
|
+
expect.objectContaining({ page: 1 }),
|
|
433
|
+
)
|
|
434
|
+
expect(screen.getByTestId('updateReason').textContent).toBe('filter')
|
|
435
|
+
})
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
describe('offset based', () => {
|
|
439
|
+
const defaultFilterInfo = buildDefaultFilterInfo({
|
|
440
|
+
filter: {
|
|
441
|
+
foo: 'bar',
|
|
442
|
+
},
|
|
443
|
+
pageSize: 20,
|
|
444
|
+
offset: 80,
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
const children: TestCaseChildren = (api) => (
|
|
448
|
+
<button
|
|
449
|
+
data-testid="setOffsetButton"
|
|
450
|
+
onClick={() => api.setOffset(100)}
|
|
451
|
+
/>
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
it('accepts initial offset', async () => {
|
|
455
|
+
await setup({ defaultFilterInfo, children })
|
|
456
|
+
|
|
457
|
+
expect(
|
|
458
|
+
safeJsonParse(screen.getByTestId('filterJson').textContent),
|
|
459
|
+
).toEqual(expect.objectContaining({ offset: 80, page: 5 }))
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
it('can set offset', async () => {
|
|
463
|
+
const { onChangeMock } = await setup({ defaultFilterInfo, children })
|
|
464
|
+
|
|
465
|
+
await actAndWait(() => {
|
|
466
|
+
fireEvent.click(screen.getByTestId('setOffsetButton'))
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
expect(onChangeMock).toHaveBeenCalledTimes(2)
|
|
470
|
+
expect(onChangeMock).toHaveBeenLastCalledWith(
|
|
471
|
+
expect.objectContaining({ page: 6, offset: 100 }),
|
|
472
|
+
)
|
|
473
|
+
expect(screen.getByTestId('updateReason').textContent).toBe('pagination')
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
it('resets offset when filters change', async () => {
|
|
477
|
+
const { onChangeMock } = await setup({ defaultFilterInfo, children })
|
|
478
|
+
|
|
479
|
+
await actAndWait(() => {
|
|
480
|
+
fireEvent.click(screen.getByTestId('setOffsetButton'))
|
|
481
|
+
})
|
|
482
|
+
expect(onChangeMock).toHaveBeenCalledTimes(2)
|
|
483
|
+
expect(onChangeMock).toHaveBeenLastCalledWith(
|
|
484
|
+
expect.objectContaining({ page: 6, offset: 100 }),
|
|
485
|
+
)
|
|
486
|
+
expect(screen.getByTestId('updateReason').textContent).toBe('pagination')
|
|
487
|
+
|
|
488
|
+
await actAndWait(() => {
|
|
489
|
+
fireEvent.click(screen.getByTestId('updateFilterButton'))
|
|
490
|
+
})
|
|
491
|
+
expect(onChangeMock).toHaveBeenCalledTimes(3)
|
|
492
|
+
expect(onChangeMock).toHaveBeenLastCalledWith(
|
|
493
|
+
expect.objectContaining({ page: 1 }),
|
|
494
|
+
)
|
|
495
|
+
expect(screen.getByTestId('updateReason').textContent).toBe('filter')
|
|
496
|
+
})
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
describe('cursor based', () => {
|
|
500
|
+
const defaultFilterInfo = buildDefaultFilterInfo({
|
|
501
|
+
filter: {
|
|
502
|
+
foo: 'bar',
|
|
503
|
+
},
|
|
504
|
+
cursor: 'currentCursor',
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
const children: TestCaseChildren = (api) => (
|
|
508
|
+
<button
|
|
509
|
+
data-testid="setCursorButton"
|
|
510
|
+
onClick={() => api.setCursor('nextCursor')}
|
|
511
|
+
/>
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
it('accepts initial cursor', async () => {
|
|
515
|
+
await setup({ defaultFilterInfo, children })
|
|
516
|
+
|
|
517
|
+
expect(
|
|
518
|
+
safeJsonParse(screen.getByTestId('filterJson').textContent),
|
|
519
|
+
).toEqual(expect.objectContaining({ cursor: 'currentCursor' }))
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
it('can set cursor', async () => {
|
|
523
|
+
const { onChangeMock } = await setup({ defaultFilterInfo, children })
|
|
524
|
+
|
|
525
|
+
await actAndWait(() => {
|
|
526
|
+
fireEvent.click(screen.getByTestId('setCursorButton'))
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
expect(onChangeMock).toHaveBeenCalledTimes(2)
|
|
530
|
+
expect(onChangeMock).toHaveBeenLastCalledWith(
|
|
531
|
+
expect.objectContaining({ cursor: 'nextCursor' }),
|
|
532
|
+
)
|
|
533
|
+
expect(screen.getByTestId('updateReason').textContent).toBe('pagination')
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
it('resets cursor when filters change', async () => {
|
|
537
|
+
const { onChangeMock } = await setup({ defaultFilterInfo, children })
|
|
538
|
+
|
|
539
|
+
await actAndWait(() => {
|
|
540
|
+
fireEvent.click(screen.getByTestId('setCursorButton'))
|
|
541
|
+
})
|
|
542
|
+
expect(onChangeMock).toHaveBeenCalledTimes(2)
|
|
543
|
+
expect(onChangeMock).toHaveBeenLastCalledWith(
|
|
544
|
+
expect.objectContaining({ cursor: 'nextCursor' }),
|
|
545
|
+
)
|
|
546
|
+
expect(screen.getByTestId('updateReason').textContent).toBe('pagination')
|
|
547
|
+
|
|
548
|
+
await actAndWait(() => {
|
|
549
|
+
fireEvent.click(screen.getByTestId('updateFilterButton'))
|
|
550
|
+
})
|
|
551
|
+
expect(onChangeMock).toHaveBeenCalledTimes(3)
|
|
552
|
+
expect(onChangeMock).toHaveBeenLastCalledWith(
|
|
553
|
+
expect.objectContaining({ cursor: undefined }),
|
|
554
|
+
)
|
|
555
|
+
expect(screen.getByTestId('updateReason').textContent).toBe('filter')
|
|
556
|
+
})
|
|
557
|
+
})
|
|
558
|
+
|
|
559
|
+
function wait(n = 0) {
|
|
560
|
+
return new Promise((resolve) => setTimeout(resolve, n))
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function safeJsonParse(input: string | undefined | null = '') {
|
|
564
|
+
if (input) {
|
|
565
|
+
return JSON.parse(input)
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async function actAndWait(fn: () => void, n = DEBOUNCE_DURATION) {
|
|
570
|
+
act(fn)
|
|
571
|
+
await act(async () => {
|
|
572
|
+
await wait(n)
|
|
573
|
+
})
|
|
574
|
+
// You'd think you could just write this:
|
|
575
|
+
// await act(async () => {
|
|
576
|
+
// fn();
|
|
577
|
+
// await wait(n);
|
|
578
|
+
// });
|
|
579
|
+
// But for some reason, doing it that way needs a longer wait duration than
|
|
580
|
+
// just a single tick. /shrug.
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
type TestCaseChildren = (
|
|
584
|
+
api: ReturnType<typeof useSimpleFilter>,
|
|
585
|
+
) => React.ReactNode
|
|
586
|
+
|
|
587
|
+
const DEBOUNCE_DURATION = 100
|
|
588
|
+
|
|
589
|
+
async function setup<TFilterInfo extends SimpleFilterInfo<any>>(options: {
|
|
590
|
+
defaultFilterInfo: TFilterInfo
|
|
591
|
+
children?: TestCaseChildren
|
|
592
|
+
onBeforeSaveFilter?: (filterInfo: TFilterInfo) => TFilterInfo
|
|
593
|
+
store?: FilterStore
|
|
594
|
+
}) {
|
|
595
|
+
testOptions.store.clear()
|
|
596
|
+
setFilterStore(testOptions.store)
|
|
597
|
+
|
|
598
|
+
const onChangeMock = vitest.fn()
|
|
599
|
+
const onDebounceChange = vitest.fn()
|
|
600
|
+
|
|
601
|
+
const ExampleComponent = () => {
|
|
602
|
+
const api = useSimpleFilter('namespace', {
|
|
603
|
+
defaultFilterInfo: options.defaultFilterInfo,
|
|
604
|
+
debounceDuration: DEBOUNCE_DURATION,
|
|
605
|
+
store: options.store,
|
|
606
|
+
// This is a very annoying warning in TypeScript:
|
|
607
|
+
//
|
|
608
|
+
// 'FilterInfo<any>' is assignable to the constraint of type
|
|
609
|
+
// 'TFilterInfo', but 'TFilterInfo' could be instantiated with a
|
|
610
|
+
// different subtype of constraint 'FilterInfo<any>'
|
|
611
|
+
//
|
|
612
|
+
// Which I don't think is valid, because you cannot do what's is saying is
|
|
613
|
+
// possible, as this errors (as expected):
|
|
614
|
+
//
|
|
615
|
+
// setup({
|
|
616
|
+
// defaultFilterInfo: {
|
|
617
|
+
// lol: '24',
|
|
618
|
+
// }
|
|
619
|
+
// })
|
|
620
|
+
//
|
|
621
|
+
// So I don't know why TS thinks it can be something else, it's already
|
|
622
|
+
// preventing you from doing that.
|
|
623
|
+
onBeforeSaveFilter: options.onBeforeSaveFilter as unknown as any,
|
|
624
|
+
})
|
|
625
|
+
const pagingInfo = api.pagingInfo(100)
|
|
626
|
+
|
|
627
|
+
useEffect(() => {
|
|
628
|
+
onChangeMock(api.filterInfo)
|
|
629
|
+
}, [api.filterInfo])
|
|
630
|
+
|
|
631
|
+
useEffect(() => {
|
|
632
|
+
onDebounceChange(api.debouncedFilterInfo)
|
|
633
|
+
}, [api.debouncedFilterInfo])
|
|
634
|
+
|
|
635
|
+
return (
|
|
636
|
+
<div>
|
|
637
|
+
{options.children?.(api)}
|
|
638
|
+
<button
|
|
639
|
+
data-testid="updateFilterButton"
|
|
640
|
+
onClick={() => api.updateFilter({ foo: '42' })}
|
|
641
|
+
/>
|
|
642
|
+
<button
|
|
643
|
+
data-testid="resetFilterButton"
|
|
644
|
+
onClick={() => api.resetFilter()}
|
|
645
|
+
/>
|
|
646
|
+
<button
|
|
647
|
+
data-testid="forceRefreshButton"
|
|
648
|
+
onClick={() => api.forceRefresh()}
|
|
649
|
+
/>
|
|
650
|
+
<button
|
|
651
|
+
data-testid="setSortButton"
|
|
652
|
+
onClick={() => api.setSort('foo:bar')}
|
|
653
|
+
/>
|
|
654
|
+
<div data-testid="totalPages">{pagingInfo.totalPages}</div>
|
|
655
|
+
<div data-testid="totalResults">{pagingInfo.totalResults}</div>
|
|
656
|
+
<div data-testid="filterJson">{JSON.stringify(api.filterInfo)}</div>
|
|
657
|
+
<div data-testid="updateReason">{api.updateReason}</div>
|
|
658
|
+
</div>
|
|
659
|
+
)
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
await actAndWait(() => {
|
|
663
|
+
render(<ExampleComponent />)
|
|
664
|
+
})
|
|
665
|
+
|
|
666
|
+
return {
|
|
667
|
+
onChangeMock,
|
|
668
|
+
onDebounceChange,
|
|
669
|
+
ExampleComponent,
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
runFilterTest({ store: memoryStore })
|
|
675
|
+
runFilterTest({ store: createUrlParamStore() })
|
|
676
|
+
runFilterTest({ store: localStorageStore })
|