@hubspot/ui-extensions 0.12.4 → 0.13.1

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 (85) hide show
  1. package/dist/__generated__/version.d.ts +2 -0
  2. package/dist/__generated__/version.js +4 -0
  3. package/dist/__tests__/crm/hooks/useAssociations.spec.js +79 -0
  4. package/dist/__tests__/crm/utils/fetchAssociations.spec.js +0 -10
  5. package/dist/__tests__/experimental/hooks/useCrmSearch.spec.js +586 -0
  6. package/dist/__tests__/experimental/hooks/utils/fetchCrmSearch.spec.js +217 -0
  7. package/dist/__tests__/hooks/useDebounce.spec.js +123 -0
  8. package/dist/__tests__/hooks/utils/useFetchLifecycle.spec.js +324 -0
  9. package/dist/__tests__/internal/hook-utils.spec.js +17 -0
  10. package/dist/__tests__/test-d/extension-points.test-d.js +1 -0
  11. package/dist/crm/hooks/useAssociations.d.ts +3 -3
  12. package/dist/crm/hooks/useAssociations.js +55 -139
  13. package/dist/crm/hooks/useCrmProperties.js +29 -125
  14. package/dist/crm/utils/fetchAssociations.js +0 -3
  15. package/dist/experimental/hooks/useCrmSearch.d.ts +2 -0
  16. package/dist/experimental/hooks/useCrmSearch.js +206 -0
  17. package/dist/experimental/hooks/utils/fetchCrmSearch.d.ts +6 -0
  18. package/dist/experimental/hooks/utils/fetchCrmSearch.js +39 -0
  19. package/dist/experimental/index.d.ts +1 -0
  20. package/dist/experimental/index.js +1 -0
  21. package/dist/hooks/useDebounce.d.ts +19 -0
  22. package/dist/hooks/useDebounce.js +32 -0
  23. package/dist/hooks/utils/useFetchLifecycle.d.ts +35 -0
  24. package/dist/hooks/utils/useFetchLifecycle.js +103 -0
  25. package/dist/index.d.ts +1 -0
  26. package/dist/index.js +1 -0
  27. package/dist/internal/hook-utils.d.ts +6 -0
  28. package/dist/internal/hook-utils.js +16 -1
  29. package/dist/{experimental/pages → pages}/components/page-routes.d.ts +1 -2
  30. package/dist/{experimental/pages → pages}/components/page-routes.js +0 -4
  31. package/dist/{experimental/pages → pages}/create-page-router.d.ts +1 -3
  32. package/dist/{experimental/pages → pages}/create-page-router.js +1 -3
  33. package/dist/{experimental/pages → pages}/create-page-router.test.js +2 -2
  34. package/dist/{experimental/pages → pages}/hooks.d.ts +0 -1
  35. package/dist/{experimental/pages → pages}/hooks.js +0 -1
  36. package/dist/pages/index.d.ts +6 -1
  37. package/dist/pages/index.js +4 -1
  38. package/dist/{experimental/pages → pages}/internal/page-router-internal-types.d.ts +1 -1
  39. package/dist/pages/internal/useAppPageLocation.d.ts +1 -0
  40. package/dist/{experimental/pages → pages}/internal/useAppPageLocation.js +2 -2
  41. package/dist/{experimental/pages → pages}/types.d.ts +1 -2
  42. package/dist/pages/types.js +1 -0
  43. package/dist/shared/types/actions.d.ts +12 -2
  44. package/dist/shared/types/components/table.d.ts +16 -0
  45. package/dist/shared/types/context.d.ts +6 -0
  46. package/dist/shared/types/crm.d.ts +4 -0
  47. package/dist/shared/types/experimental.d.ts +1 -1
  48. package/dist/shared/types/extension-points.d.ts +9 -3
  49. package/dist/shared/types/extension-points.js +1 -0
  50. package/dist/shared/types/hooks.d.ts +72 -0
  51. package/dist/shared/types/hooks.js +1 -0
  52. package/dist/shared/types/shared.d.ts +8 -0
  53. package/dist/shared/types/worker-globals.d.ts +5 -0
  54. package/dist/testing/__tests__/createRenderer.spec.js +1 -1
  55. package/dist/testing/internal/mocks/index.d.ts +1 -1
  56. package/dist/testing/internal/mocks/mock-extension-point-api.js +21 -1
  57. package/dist/testing/internal/mocks/mock-hooks.js +26 -0
  58. package/dist/testing/internal/types-internal.d.ts +2 -0
  59. package/dist/testing/types.d.ts +11 -1
  60. package/dist/utils/pagination.d.ts +0 -9
  61. package/package.json +3 -2
  62. package/dist/experimental/pages/index.d.ts +0 -6
  63. package/dist/experimental/pages/index.js +0 -4
  64. package/dist/experimental/pages/internal/useAppPageLocation.d.ts +0 -1
  65. package/dist/shared/types/pages/app-pages-types.d.ts +0 -75
  66. package/dist/shared/types/pages/components/page-routes.d.ts +0 -115
  67. package/dist/shared/types/pages/index.d.ts +0 -1
  68. package/dist/shared/types/pages.d.ts +0 -1
  69. /package/dist/{experimental/pages/create-page-router.test.d.ts → __tests__/experimental/hooks/useCrmSearch.spec.d.ts} +0 -0
  70. /package/dist/{experimental/pages/internal/trie-router.test.d.ts → __tests__/experimental/hooks/utils/fetchCrmSearch.spec.d.ts} +0 -0
  71. /package/dist/{experimental/pages/types.js → __tests__/hooks/useDebounce.spec.d.ts} +0 -0
  72. /package/dist/{shared/types/pages.js → __tests__/hooks/utils/useFetchLifecycle.spec.d.ts} +0 -0
  73. /package/dist/{shared/types/pages/app-pages-types.js → __tests__/internal/hook-utils.spec.d.ts} +0 -0
  74. /package/dist/{experimental/pages → pages}/components/index.d.ts +0 -0
  75. /package/dist/{experimental/pages → pages}/components/index.js +0 -0
  76. /package/dist/{shared/types/pages/components/page-routes.js → pages/create-page-router.test.d.ts} +0 -0
  77. /package/dist/{experimental/pages → pages}/internal/app-page-route-context.d.ts +0 -0
  78. /package/dist/{experimental/pages → pages}/internal/app-page-route-context.js +0 -0
  79. /package/dist/{experimental/pages → pages}/internal/convert-page-routes-react-elements.d.ts +0 -0
  80. /package/dist/{experimental/pages → pages}/internal/convert-page-routes-react-elements.js +0 -0
  81. /package/dist/{experimental/pages → pages}/internal/page-router-internal-types.js +0 -0
  82. /package/dist/{experimental/pages → pages}/internal/trie-router.d.ts +0 -0
  83. /package/dist/{experimental/pages → pages}/internal/trie-router.js +0 -0
  84. /package/dist/{shared/types/pages/index.js → pages/internal/trie-router.test.d.ts} +0 -0
  85. /package/dist/{experimental/pages → pages}/internal/trie-router.test.js +0 -0
@@ -0,0 +1,586 @@
1
+ import { renderHook, waitFor } from '@testing-library/react';
2
+ import { vi } from 'vitest';
3
+ import { useCrmSearch } from "../../../experimental/hooks/useCrmSearch.js";
4
+ import { fetchCrmSearch } from "../../../experimental/hooks/utils/fetchCrmSearch.js";
5
+ vi.mock('../../../logger', () => ({
6
+ logger: {
7
+ debug: vi.fn(),
8
+ info: vi.fn(),
9
+ warn: vi.fn(),
10
+ error: vi.fn(),
11
+ },
12
+ }));
13
+ vi.mock('../../../experimental/hooks/utils/fetchCrmSearch.ts', async (importOriginal) => {
14
+ const actual = (await importOriginal());
15
+ return {
16
+ ...actual,
17
+ fetchCrmSearch: vi.fn(),
18
+ };
19
+ });
20
+ const mockFetchCrmSearch = fetchCrmSearch;
21
+ const createSearchResult = (objectId) => ({
22
+ objectId,
23
+ properties: {},
24
+ });
25
+ const mockResponse = (results, { hasMore = false, total = results.length, after, } = {}) => ({
26
+ data: { results, total, hasMore, after },
27
+ cleanup: vi.fn(),
28
+ });
29
+ describe('useCrmSearch', () => {
30
+ let originalError;
31
+ beforeAll(() => {
32
+ originalError = console.error;
33
+ console.error = (...args) => {
34
+ if (typeof args[0] === 'string' &&
35
+ (args[0].includes('ReactDOMTestUtils.act') ||
36
+ args[0].includes('was not wrapped in act')))
37
+ return;
38
+ originalError.call(console, ...args);
39
+ };
40
+ });
41
+ beforeEach(() => {
42
+ mockFetchCrmSearch.mockReset();
43
+ });
44
+ afterAll(() => {
45
+ console.error = originalError;
46
+ });
47
+ describe('initial state', () => {
48
+ it('should initialize with proper pagination state', async () => {
49
+ mockFetchCrmSearch.mockResolvedValue(mockResponse([]));
50
+ const { result } = renderHook(() => useCrmSearch({
51
+ objectType: '0-1',
52
+ properties: ['firstname', 'lastname'],
53
+ pageSize: 10,
54
+ }));
55
+ await waitFor(() => {
56
+ expect(result.current.results).toEqual([]);
57
+ expect(result.current.total).toBe(0);
58
+ expect(result.current.error).toBeNull();
59
+ expect(result.current.isLoading).toBe(false);
60
+ expect(result.current.pagination.currentPage).toBe(1);
61
+ expect(result.current.pagination.pageSize).toBe(10);
62
+ expect(result.current.pagination.hasNextPage).toBe(false);
63
+ expect(result.current.pagination.hasPreviousPage).toBe(false);
64
+ });
65
+ });
66
+ it('should use default page size when not specified', async () => {
67
+ mockFetchCrmSearch.mockResolvedValue(mockResponse([]));
68
+ const { result } = renderHook(() => useCrmSearch({
69
+ objectType: '0-1',
70
+ properties: ['firstname'],
71
+ }));
72
+ await waitFor(() => {
73
+ expect(result.current.pagination.pageSize).toBe(10); // DEFAULT_PAGE_SIZE
74
+ });
75
+ });
76
+ it('should reflect updated pageSize in pagination.pageSize when config changes', async () => {
77
+ mockFetchCrmSearch.mockResolvedValue(mockResponse([]));
78
+ const { result, rerender } = renderHook(({ pageSize }) => useCrmSearch({
79
+ objectType: '0-1',
80
+ properties: ['firstname'],
81
+ pageSize,
82
+ }), { initialProps: { pageSize: 10 } });
83
+ await waitFor(() => {
84
+ expect(result.current.pagination.pageSize).toBe(10);
85
+ });
86
+ rerender({ pageSize: 25 });
87
+ await waitFor(() => {
88
+ expect(result.current.pagination.pageSize).toBe(25);
89
+ });
90
+ });
91
+ it('should reset pagination to first page when pageSize changes mid-session', async () => {
92
+ mockFetchCrmSearch
93
+ .mockResolvedValueOnce(mockResponse([createSearchResult(1)], {
94
+ hasMore: true,
95
+ total: 3,
96
+ after: 'cursor-1',
97
+ }))
98
+ .mockResolvedValueOnce(mockResponse([createSearchResult(2)], {
99
+ hasMore: true,
100
+ total: 3,
101
+ after: 'cursor-2',
102
+ }))
103
+ .mockResolvedValueOnce(mockResponse([createSearchResult(3)], { hasMore: true, total: 3 }));
104
+ const { result, rerender } = renderHook(({ pageSize }) => useCrmSearch({
105
+ objectType: '0-1',
106
+ pageSize,
107
+ }), { initialProps: { pageSize: 10 } });
108
+ await waitFor(() => {
109
+ expect(result.current.pagination.currentPage).toBe(1);
110
+ expect(result.current.pagination.hasNextPage).toBe(true);
111
+ });
112
+ result.current.pagination.nextPage();
113
+ await waitFor(() => {
114
+ expect(result.current.pagination.currentPage).toBe(2);
115
+ expect(result.current.pagination.hasPreviousPage).toBe(true);
116
+ });
117
+ rerender({ pageSize: 25 });
118
+ await waitFor(() => {
119
+ expect(result.current.pagination.pageSize).toBe(25);
120
+ expect(result.current.pagination.currentPage).toBe(1);
121
+ expect(result.current.pagination.hasPreviousPage).toBe(false);
122
+ });
123
+ expect(mockFetchCrmSearch).toHaveBeenLastCalledWith(expect.objectContaining({
124
+ pageSize: 25,
125
+ after: undefined,
126
+ }), expect.any(Object));
127
+ });
128
+ });
129
+ describe('data fetching', () => {
130
+ it('should successfully fetch and return CRM search results with pagination info', async () => {
131
+ mockFetchCrmSearch.mockResolvedValue({
132
+ data: {
133
+ results: [
134
+ {
135
+ objectId: 1001,
136
+ properties: { firstname: 'John', lastname: 'Doe' },
137
+ },
138
+ ],
139
+ total: 42,
140
+ hasMore: true,
141
+ after: 'next-cursor',
142
+ },
143
+ cleanup: vi.fn(),
144
+ });
145
+ const { result } = renderHook(() => useCrmSearch({
146
+ objectType: '0-1',
147
+ properties: ['firstname', 'lastname'],
148
+ pageSize: 10,
149
+ }));
150
+ await waitFor(() => {
151
+ expect(result.current.results).toHaveLength(1);
152
+ expect(result.current.results[0].objectId).toBe(1001);
153
+ expect(result.current.total).toBe(42);
154
+ expect(result.current.pagination.hasNextPage).toBe(true);
155
+ expect(result.current.pagination.hasPreviousPage).toBe(false);
156
+ expect(result.current.pagination.currentPage).toBe(1);
157
+ expect(result.current.isLoading).toBe(false);
158
+ });
159
+ expect(mockFetchCrmSearch).toHaveBeenCalledWith(expect.objectContaining({
160
+ objectType: '0-1',
161
+ properties: ['firstname', 'lastname'],
162
+ pageSize: 10,
163
+ after: undefined,
164
+ }), expect.any(Object));
165
+ });
166
+ it('should handle empty results correctly', async () => {
167
+ mockFetchCrmSearch.mockResolvedValue(mockResponse([]));
168
+ const { result } = renderHook(() => useCrmSearch({
169
+ objectType: '0-1',
170
+ properties: ['firstname'],
171
+ pageSize: 5,
172
+ }));
173
+ await waitFor(() => {
174
+ expect(result.current.results).toEqual([]);
175
+ expect(result.current.total).toBe(0);
176
+ expect(result.current.pagination.hasNextPage).toBe(false);
177
+ expect(result.current.pagination.hasPreviousPage).toBe(false);
178
+ expect(result.current.isLoading).toBe(false);
179
+ expect(result.current.error).toBeNull();
180
+ });
181
+ });
182
+ it('should pass query, filterGroups, and sorts through to fetchCrmSearch', async () => {
183
+ mockFetchCrmSearch.mockResolvedValue(mockResponse([]));
184
+ const config = {
185
+ objectType: '0-1',
186
+ properties: ['firstname'],
187
+ query: 'john',
188
+ filterGroups: [
189
+ {
190
+ filters: [
191
+ {
192
+ propertyName: 'lifecyclestage',
193
+ operator: 'EQ',
194
+ value: 'lead',
195
+ },
196
+ ],
197
+ },
198
+ ],
199
+ sorts: [
200
+ { propertyName: 'createdate', direction: 'DESCENDING' },
201
+ ],
202
+ pageSize: 10,
203
+ };
204
+ renderHook(() => useCrmSearch(config));
205
+ await waitFor(() => {
206
+ expect(mockFetchCrmSearch).toHaveBeenCalledWith(expect.objectContaining({
207
+ objectType: '0-1',
208
+ properties: ['firstname'],
209
+ query: 'john',
210
+ filterGroups: config.filterGroups,
211
+ sorts: config.sorts,
212
+ pageSize: 10,
213
+ after: undefined,
214
+ }), expect.any(Object));
215
+ });
216
+ });
217
+ });
218
+ describe('pagination actions', () => {
219
+ it('should navigate to next page using the after cursor', async () => {
220
+ mockFetchCrmSearch.mockResolvedValueOnce(mockResponse([createSearchResult(1)], {
221
+ hasMore: true,
222
+ total: 2,
223
+ after: 'cursor-page-2',
224
+ }));
225
+ mockFetchCrmSearch.mockResolvedValueOnce(mockResponse([createSearchResult(2)], { hasMore: false, total: 2 }));
226
+ const { result } = renderHook(() => useCrmSearch({ objectType: '0-1', pageSize: 10 }));
227
+ await waitFor(() => {
228
+ expect(result.current.pagination.currentPage).toBe(1);
229
+ expect(result.current.pagination.hasNextPage).toBe(true);
230
+ });
231
+ result.current.pagination.nextPage();
232
+ await waitFor(() => {
233
+ expect(result.current.pagination.currentPage).toBe(2);
234
+ expect(result.current.pagination.hasNextPage).toBe(false);
235
+ expect(result.current.pagination.hasPreviousPage).toBe(true);
236
+ });
237
+ expect(mockFetchCrmSearch).toHaveBeenLastCalledWith(expect.objectContaining({
238
+ after: 'cursor-page-2',
239
+ }), expect.any(Object));
240
+ });
241
+ it('should navigate to previous page using the stored cursor history', async () => {
242
+ mockFetchCrmSearch.mockResolvedValueOnce(mockResponse([createSearchResult(1)], {
243
+ hasMore: true,
244
+ total: 3,
245
+ after: 'cursor-page-2',
246
+ }));
247
+ mockFetchCrmSearch.mockResolvedValueOnce(mockResponse([createSearchResult(2)], {
248
+ hasMore: true,
249
+ total: 3,
250
+ after: 'cursor-page-3',
251
+ }));
252
+ mockFetchCrmSearch.mockResolvedValueOnce(mockResponse([createSearchResult(1)], {
253
+ hasMore: true,
254
+ total: 3,
255
+ after: 'cursor-page-2',
256
+ }));
257
+ const { result } = renderHook(() => useCrmSearch({ objectType: '0-1', pageSize: 10 }));
258
+ await waitFor(() => {
259
+ expect(result.current.pagination.currentPage).toBe(1);
260
+ expect(result.current.pagination.hasNextPage).toBe(true);
261
+ });
262
+ result.current.pagination.nextPage();
263
+ await waitFor(() => {
264
+ expect(result.current.pagination.currentPage).toBe(2);
265
+ expect(result.current.pagination.hasPreviousPage).toBe(true);
266
+ });
267
+ result.current.pagination.previousPage();
268
+ await waitFor(() => {
269
+ expect(result.current.pagination.currentPage).toBe(1);
270
+ expect(result.current.pagination.hasPreviousPage).toBe(false);
271
+ });
272
+ });
273
+ it('should reset to the first page correctly', async () => {
274
+ mockFetchCrmSearch.mockResolvedValue(mockResponse([], { hasMore: true, total: 30, after: 'next' }));
275
+ const { result } = renderHook(() => useCrmSearch({ objectType: '0-1', pageSize: 10 }));
276
+ await waitFor(() => {
277
+ expect(result.current.pagination.currentPage).toBe(1);
278
+ expect(result.current.pagination.hasNextPage).toBe(true);
279
+ });
280
+ result.current.pagination.nextPage();
281
+ await waitFor(() => {
282
+ expect(result.current.pagination.currentPage).toBe(2);
283
+ });
284
+ result.current.pagination.nextPage();
285
+ await waitFor(() => {
286
+ expect(result.current.pagination.currentPage).toBe(3);
287
+ });
288
+ result.current.pagination.reset();
289
+ await waitFor(() => {
290
+ expect(result.current.pagination.currentPage).toBe(1);
291
+ expect(result.current.pagination.hasPreviousPage).toBe(false);
292
+ });
293
+ });
294
+ it('should not refetch when reset is called on the first page', async () => {
295
+ mockFetchCrmSearch.mockResolvedValueOnce(mockResponse([createSearchResult(1)], {
296
+ hasMore: true,
297
+ total: 2,
298
+ after: 'next',
299
+ }));
300
+ const { result } = renderHook(() => useCrmSearch({ objectType: '0-1', pageSize: 10 }));
301
+ await waitFor(() => {
302
+ expect(result.current.pagination.currentPage).toBe(1);
303
+ });
304
+ result.current.pagination.reset();
305
+ await waitFor(() => {
306
+ expect(result.current.pagination.currentPage).toBe(1);
307
+ });
308
+ expect(result.current.results).toEqual([createSearchResult(1)]);
309
+ expect(mockFetchCrmSearch).toHaveBeenCalledTimes(1);
310
+ });
311
+ it('should not allow navigation beyond pagination boundaries', async () => {
312
+ mockFetchCrmSearch.mockResolvedValue(mockResponse([], { hasMore: false, total: 0 }));
313
+ const { result } = renderHook(() => useCrmSearch({ objectType: '0-1', pageSize: 10 }));
314
+ await waitFor(() => {
315
+ expect(result.current.pagination.currentPage).toBe(1);
316
+ expect(result.current.pagination.hasNextPage).toBe(false);
317
+ });
318
+ result.current.pagination.nextPage();
319
+ expect(result.current.pagination.currentPage).toBe(1);
320
+ result.current.pagination.previousPage();
321
+ expect(result.current.pagination.currentPage).toBe(1);
322
+ });
323
+ });
324
+ describe('error handling', () => {
325
+ it('should handle fetch errors correctly', async () => {
326
+ const errorMessage = 'Failed to fetch CRM search results';
327
+ mockFetchCrmSearch.mockRejectedValue(new Error(errorMessage));
328
+ const { result } = renderHook(() => useCrmSearch({
329
+ objectType: '0-1',
330
+ properties: ['firstname'],
331
+ pageSize: 10,
332
+ }));
333
+ await waitFor(() => {
334
+ expect(result.current.error).toBeInstanceOf(Error);
335
+ expect(result.current.error?.message).toBe(errorMessage);
336
+ expect(result.current.results).toEqual([]);
337
+ expect(result.current.total).toBe(0);
338
+ expect(result.current.isLoading).toBe(false);
339
+ });
340
+ });
341
+ it('should handle null config gracefully', async () => {
342
+ mockFetchCrmSearch.mockResolvedValue(mockResponse([]));
343
+ // @ts-expect-error - intentional type issue to check the failure state
344
+ const { result } = renderHook(() => useCrmSearch(null));
345
+ await waitFor(() => {
346
+ expect(result.current.isLoading).toBe(false);
347
+ expect(result.current.results).toEqual([]);
348
+ expect(result.current.pagination.pageSize).toBe(10); // DEFAULT_PAGE_SIZE
349
+ });
350
+ expect(mockFetchCrmSearch).toHaveBeenCalledWith(expect.objectContaining({
351
+ objectType: undefined,
352
+ properties: undefined,
353
+ query: undefined,
354
+ filterGroups: undefined,
355
+ sorts: undefined,
356
+ pageSize: 10,
357
+ after: undefined,
358
+ }), expect.any(Object));
359
+ });
360
+ it('should handle undefined config gracefully', async () => {
361
+ mockFetchCrmSearch.mockResolvedValue(mockResponse([]));
362
+ // @ts-expect-error - intentional type issue to check the failure state
363
+ const { result } = renderHook(() => useCrmSearch(undefined));
364
+ await waitFor(() => {
365
+ expect(result.current.isLoading).toBe(false);
366
+ expect(result.current.results).toEqual([]);
367
+ expect(result.current.pagination.pageSize).toBe(10); // DEFAULT_PAGE_SIZE
368
+ });
369
+ });
370
+ it('should handle empty config object gracefully', async () => {
371
+ mockFetchCrmSearch.mockResolvedValue(mockResponse([]));
372
+ // @ts-expect-error - intentional type issue to check the failure state
373
+ const { result } = renderHook(() => useCrmSearch({}));
374
+ await waitFor(() => {
375
+ expect(result.current.isLoading).toBe(false);
376
+ expect(result.current.results).toEqual([]);
377
+ expect(result.current.pagination.pageSize).toBe(10); // DEFAULT_PAGE_SIZE
378
+ });
379
+ expect(mockFetchCrmSearch).toHaveBeenCalledWith(expect.objectContaining({
380
+ objectType: undefined,
381
+ pageSize: 10,
382
+ after: undefined,
383
+ }), expect.any(Object));
384
+ });
385
+ it('should handle config missing objectType', async () => {
386
+ mockFetchCrmSearch.mockResolvedValue(mockResponse([]));
387
+ const { result } = renderHook(() =>
388
+ // @ts-expect-error - intentional type issue to check the failure state
389
+ useCrmSearch({ properties: ['firstname'], pageSize: 5 }));
390
+ await waitFor(() => {
391
+ expect(result.current.isLoading).toBe(false);
392
+ expect(result.current.pagination.pageSize).toBe(5);
393
+ });
394
+ expect(mockFetchCrmSearch).toHaveBeenCalledWith(expect.objectContaining({
395
+ objectType: undefined,
396
+ properties: ['firstname'],
397
+ pageSize: 5,
398
+ after: undefined,
399
+ }), expect.any(Object));
400
+ });
401
+ });
402
+ describe('options handling', () => {
403
+ it('should pass formatting options to fetchCrmSearch', async () => {
404
+ mockFetchCrmSearch.mockResolvedValue(mockResponse([]));
405
+ const config = {
406
+ objectType: '0-1',
407
+ properties: ['firstname', 'lastname'],
408
+ pageSize: 10,
409
+ };
410
+ const options = {
411
+ propertiesToFormat: ['firstname'],
412
+ formattingOptions: {
413
+ date: { format: 'MM/DD/YYYY', relative: true },
414
+ },
415
+ };
416
+ renderHook(() => useCrmSearch(config, options));
417
+ await waitFor(() => {
418
+ expect(mockFetchCrmSearch).toHaveBeenCalledWith(expect.objectContaining(config), options);
419
+ });
420
+ });
421
+ it('should use default empty options when no options provided', async () => {
422
+ mockFetchCrmSearch.mockResolvedValue(mockResponse([]));
423
+ const config = {
424
+ objectType: '0-1',
425
+ properties: ['firstname'],
426
+ pageSize: 10,
427
+ };
428
+ renderHook(() => useCrmSearch(config));
429
+ await waitFor(() => {
430
+ expect(mockFetchCrmSearch).toHaveBeenCalledWith(expect.objectContaining(config), {
431
+ propertiesToFormat: undefined,
432
+ formattingOptions: undefined,
433
+ });
434
+ });
435
+ });
436
+ });
437
+ describe('refetch', () => {
438
+ it('should handle complete refetch lifecycle with loading states and data preservation', async () => {
439
+ const initialData = [createSearchResult(1)];
440
+ const refetchedData = [createSearchResult(2)];
441
+ let resolveRefetch = () => { };
442
+ const refetchPromise = new Promise((resolve) => {
443
+ resolveRefetch = resolve;
444
+ });
445
+ mockFetchCrmSearch
446
+ .mockResolvedValueOnce(mockResponse(initialData))
447
+ .mockImplementationOnce(() => refetchPromise);
448
+ const { result } = renderHook(() => useCrmSearch({ objectType: '0-1', pageSize: 10 }));
449
+ await waitFor(() => {
450
+ expect(result.current.results).toEqual(initialData);
451
+ expect(result.current.isLoading).toBe(false);
452
+ expect(result.current.isRefetching).toBe(false);
453
+ });
454
+ const refetchCall = result.current.refetch();
455
+ await waitFor(() => expect(result.current.isRefetching).toBe(true));
456
+ expect(result.current.results).toEqual(initialData);
457
+ resolveRefetch(mockResponse(refetchedData, { hasMore: true, total: 5, after: 'next' }));
458
+ await refetchCall;
459
+ await waitFor(() => {
460
+ expect(result.current.isRefetching).toBe(false);
461
+ expect(result.current.results).toEqual(refetchedData);
462
+ expect(result.current.total).toBe(5);
463
+ });
464
+ });
465
+ it('should handle errors during refetch and allow recovery', async () => {
466
+ const initialData = [createSearchResult(1)];
467
+ const recoveredData = [createSearchResult(2)];
468
+ mockFetchCrmSearch
469
+ .mockResolvedValueOnce(mockResponse(initialData))
470
+ .mockRejectedValueOnce(new Error('Failed to refetch CRM search results'))
471
+ .mockResolvedValueOnce(mockResponse(recoveredData));
472
+ const { result } = renderHook(() => useCrmSearch({ objectType: '0-1', pageSize: 10 }));
473
+ await waitFor(() => expect(result.current.results).toEqual(initialData));
474
+ await result.current.refetch();
475
+ await waitFor(() => {
476
+ expect(result.current.error?.message).toBe('Failed to refetch CRM search results');
477
+ expect(result.current.results).toEqual(initialData);
478
+ });
479
+ await result.current.refetch();
480
+ await waitFor(() => {
481
+ expect(result.current.error).toBeNull();
482
+ expect(result.current.results).toEqual(recoveredData);
483
+ });
484
+ });
485
+ it('should cancel in-flight refetch when called again', async () => {
486
+ const initialData = [createSearchResult(1)];
487
+ const firstRefetchCleanup = vi.fn();
488
+ const secondRefetchCleanup = vi.fn();
489
+ mockFetchCrmSearch
490
+ .mockResolvedValueOnce(mockResponse(initialData))
491
+ .mockResolvedValueOnce({
492
+ ...mockResponse([createSearchResult(2)]),
493
+ cleanup: firstRefetchCleanup,
494
+ })
495
+ .mockResolvedValueOnce({
496
+ ...mockResponse([createSearchResult(3)]),
497
+ cleanup: secondRefetchCleanup,
498
+ });
499
+ const { result } = renderHook(() => useCrmSearch({ objectType: '0-1', pageSize: 10 }));
500
+ await waitFor(() => expect(result.current.results).toEqual(initialData));
501
+ await Promise.all([result.current.refetch(), result.current.refetch()]);
502
+ await waitFor(() => expect(result.current.results).toEqual([createSearchResult(3)]));
503
+ expect(firstRefetchCleanup).toHaveBeenCalledTimes(1);
504
+ expect(secondRefetchCleanup).not.toHaveBeenCalled();
505
+ });
506
+ it('should maintain current page and pagination state during refetch', async () => {
507
+ mockFetchCrmSearch
508
+ .mockResolvedValueOnce(mockResponse([createSearchResult(1)], {
509
+ hasMore: true,
510
+ total: 2,
511
+ after: 'cursor-2',
512
+ }))
513
+ .mockResolvedValueOnce(mockResponse([createSearchResult(2)], {
514
+ hasMore: true,
515
+ total: 2,
516
+ after: 'cursor-3',
517
+ }))
518
+ .mockResolvedValueOnce(mockResponse([createSearchResult(2)], {
519
+ hasMore: true,
520
+ total: 2,
521
+ after: 'cursor-3',
522
+ }));
523
+ const { result } = renderHook(() => useCrmSearch({ objectType: '0-1', pageSize: 10 }));
524
+ await waitFor(() => expect(result.current.pagination.currentPage).toBe(1));
525
+ result.current.pagination.nextPage();
526
+ await waitFor(() => expect(result.current.pagination.currentPage).toBe(2));
527
+ await result.current.refetch();
528
+ await waitFor(() => {
529
+ expect(result.current.pagination.currentPage).toBe(2);
530
+ expect(result.current.pagination.hasNextPage).toBe(true);
531
+ expect(result.current.pagination.hasPreviousPage).toBe(true);
532
+ });
533
+ expect(mockFetchCrmSearch).toHaveBeenLastCalledWith(expect.objectContaining({ after: 'cursor-2' }), expect.any(Object));
534
+ });
535
+ });
536
+ describe('lifecycle management', () => {
537
+ it('should call cleanup function on unmount', async () => {
538
+ const mockCleanup = vi.fn();
539
+ mockFetchCrmSearch.mockResolvedValue({
540
+ data: { results: [], total: 0, hasMore: false },
541
+ cleanup: mockCleanup,
542
+ });
543
+ const { unmount } = renderHook(() => useCrmSearch({ objectType: '0-1', pageSize: 10 }));
544
+ await waitFor(() => {
545
+ expect(mockFetchCrmSearch).toHaveBeenCalled();
546
+ });
547
+ unmount();
548
+ await waitFor(() => {
549
+ expect(mockCleanup).toHaveBeenCalled();
550
+ });
551
+ });
552
+ it('should handle stable reference optimization for config', async () => {
553
+ mockFetchCrmSearch.mockResolvedValue(mockResponse([]));
554
+ const config = {
555
+ objectType: '0-1',
556
+ properties: ['firstname'],
557
+ pageSize: 10,
558
+ };
559
+ const { rerender } = renderHook(({ configProp }) => useCrmSearch(configProp), { initialProps: { configProp: config } });
560
+ await waitFor(() => {
561
+ expect(mockFetchCrmSearch).toHaveBeenCalledTimes(1);
562
+ });
563
+ rerender({ configProp: { ...config } });
564
+ await waitFor(() => {
565
+ expect(mockFetchCrmSearch).toHaveBeenCalledTimes(1);
566
+ });
567
+ });
568
+ it('should refetch when query changes', async () => {
569
+ mockFetchCrmSearch.mockResolvedValue(mockResponse([]));
570
+ const { rerender } = renderHook(({ query }) => useCrmSearch({
571
+ objectType: '0-1',
572
+ properties: ['firstname'],
573
+ pageSize: 10,
574
+ query,
575
+ }), { initialProps: { query: 'john' } });
576
+ await waitFor(() => {
577
+ expect(mockFetchCrmSearch).toHaveBeenCalledTimes(1);
578
+ });
579
+ rerender({ query: 'jane' });
580
+ await waitFor(() => {
581
+ expect(mockFetchCrmSearch).toHaveBeenCalledTimes(2);
582
+ expect(mockFetchCrmSearch).toHaveBeenLastCalledWith(expect.objectContaining({ query: 'jane' }), expect.any(Object));
583
+ });
584
+ });
585
+ });
586
+ });