@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.
Files changed (57) hide show
  1. package/.github/workflows/main.yml +38 -0
  2. package/.vscode/settings.json +3 -0
  3. package/CHANGELOG.md +185 -0
  4. package/Makefile +25 -0
  5. package/eslint.config.js +30 -0
  6. package/mise.toml +3 -0
  7. package/package.json +33 -43
  8. package/prettier.config.js +3 -0
  9. package/src/lib/buildDefaultFilterInfo.ts +36 -0
  10. package/src/lib/getOffsetFromPage.ts +5 -0
  11. package/src/lib/getPageFromOffset.ts +5 -0
  12. package/src/lib/shallowEqual.test.ts +71 -0
  13. package/src/lib/shallowEqual.ts +26 -0
  14. package/src/store/index.ts +30 -0
  15. package/src/store/localStorageStore.ts +36 -0
  16. package/src/store/memoryStore.ts +27 -0
  17. package/src/store/reduxHelpers/createActions.test.ts +32 -0
  18. package/src/store/reduxHelpers/createActions.ts +56 -0
  19. package/src/store/reduxHelpers/createReducer.test.ts +65 -0
  20. package/src/store/reduxHelpers/createReducer.ts +47 -0
  21. package/src/store/reduxStore.ts +78 -0
  22. package/src/store/urlParamStore.test.ts +131 -0
  23. package/src/store/urlParamStore.ts +85 -0
  24. package/src/useFilter.test.tsx +822 -0
  25. package/src/useFilter.ts +524 -0
  26. package/src/useSimpleFilter.test.tsx +676 -0
  27. package/src/useSimpleFilter.ts +397 -0
  28. package/src/vitest-env.d.ts +1 -0
  29. package/tsconfig.json +76 -0
  30. package/tsdown.config.ts +30 -0
  31. package/vite.config.ts +9 -0
  32. package/dist/lib/buildDefaultFilterInfo.d.ts +0 -3
  33. package/dist/lib/buildDefaultFilterInfo.js +0 -35
  34. package/dist/lib/getOffsetFromPage.d.ts +0 -2
  35. package/dist/lib/getOffsetFromPage.js +0 -6
  36. package/dist/lib/getPageFromOffset.d.ts +0 -2
  37. package/dist/lib/getPageFromOffset.js +0 -6
  38. package/dist/lib/shallowEqual.d.ts +0 -2
  39. package/dist/lib/shallowEqual.js +0 -23
  40. package/dist/store/index.d.ts +0 -10
  41. package/dist/store/index.js +0 -16
  42. package/dist/store/localStorageStore.d.ts +0 -3
  43. package/dist/store/localStorageStore.js +0 -31
  44. package/dist/store/memoryStore.d.ts +0 -3
  45. package/dist/store/memoryStore.js +0 -23
  46. package/dist/store/reduxHelpers/createActions.d.ts +0 -16
  47. package/dist/store/reduxHelpers/createActions.js +0 -27
  48. package/dist/store/reduxHelpers/createReducer.d.ts +0 -8
  49. package/dist/store/reduxHelpers/createReducer.js +0 -26
  50. package/dist/store/reduxStore.d.ts +0 -15
  51. package/dist/store/reduxStore.js +0 -67
  52. package/dist/store/urlParamStore.d.ts +0 -2
  53. package/dist/store/urlParamStore.js +0 -86
  54. package/dist/useFilter.d.ts +0 -103
  55. package/dist/useFilter.js +0 -254
  56. package/dist/useSimpleFilter.d.ts +0 -86
  57. 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 })