@hubspot/ui-extensions 0.13.0 → 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 (28) 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 +51 -0
  4. package/dist/__tests__/crm/utils/fetchAssociations.spec.js +0 -10
  5. package/dist/__tests__/experimental/hooks/useCrmSearch.spec.d.ts +1 -0
  6. package/dist/__tests__/experimental/hooks/useCrmSearch.spec.js +586 -0
  7. package/dist/__tests__/experimental/hooks/utils/fetchCrmSearch.spec.d.ts +1 -0
  8. package/dist/__tests__/experimental/hooks/utils/fetchCrmSearch.spec.js +217 -0
  9. package/dist/crm/hooks/useAssociations.d.ts +1 -1
  10. package/dist/crm/hooks/useAssociations.js +14 -5
  11. package/dist/crm/utils/fetchAssociations.js +0 -3
  12. package/dist/experimental/hooks/useCrmSearch.d.ts +2 -0
  13. package/dist/experimental/hooks/useCrmSearch.js +206 -0
  14. package/dist/experimental/hooks/utils/fetchCrmSearch.d.ts +6 -0
  15. package/dist/experimental/hooks/utils/fetchCrmSearch.js +39 -0
  16. package/dist/experimental/index.d.ts +1 -0
  17. package/dist/experimental/index.js +1 -0
  18. package/dist/shared/types/components/table.d.ts +16 -0
  19. package/dist/shared/types/context.d.ts +1 -0
  20. package/dist/shared/types/experimental.d.ts +1 -1
  21. package/dist/shared/types/hooks.d.ts +72 -0
  22. package/dist/shared/types/hooks.js +1 -0
  23. package/dist/shared/types/worker-globals.d.ts +5 -0
  24. package/dist/testing/internal/mocks/mock-hooks.js +26 -0
  25. package/dist/testing/internal/types-internal.d.ts +2 -0
  26. package/dist/testing/types.d.ts +10 -0
  27. package/dist/utils/pagination.d.ts +0 -9
  28. package/package.json +2 -1
@@ -0,0 +1,2 @@
1
+ export declare const SDK_VERSION = "0.13.1";
2
+ export declare const SCHEMA_VERSION = "v0";
@@ -0,0 +1,4 @@
1
+ // Auto-generated from package.json — do not edit manually.
2
+ // Regenerated by scripts/generate-version.js before each build.
3
+ export const SDK_VERSION = '0.13.1';
4
+ export const SCHEMA_VERSION = 'v0';
@@ -93,6 +93,57 @@ describe('useAssociations with Pagination', () => {
93
93
  expect(result.current.pagination.pageSize).toBe(10); // DEFAULT_PAGE_SIZE
94
94
  });
95
95
  });
96
+ it('should reflect updated pageLength in pagination.pageSize when config changes', async () => {
97
+ mockFetchAssociations.mockResolvedValue({
98
+ data: {
99
+ results: [],
100
+ hasMore: false,
101
+ nextOffset: 0,
102
+ },
103
+ cleanup: vi.fn(),
104
+ });
105
+ const { result, rerender } = renderHook(({ pageLength }) => useAssociations({
106
+ toObjectType: '0-1',
107
+ properties: ['firstname'],
108
+ pageLength,
109
+ }), { initialProps: { pageLength: 10 } });
110
+ await waitFor(() => {
111
+ expect(result.current.pagination.pageSize).toBe(10);
112
+ });
113
+ rerender({ pageLength: 25 });
114
+ await waitFor(() => {
115
+ expect(result.current.pagination.pageSize).toBe(25);
116
+ });
117
+ });
118
+ it('should reset pagination to first page when pageLength changes mid-session', async () => {
119
+ mockFetchAssociations
120
+ .mockResolvedValueOnce(mockResponse([createAssociation(1)], true, 10))
121
+ .mockResolvedValueOnce(mockResponse([createAssociation(2)], true, 20))
122
+ .mockResolvedValueOnce(mockResponse([createAssociation(3)], true, 25));
123
+ const { result, rerender } = renderHook(({ pageLength }) => useAssociations({
124
+ toObjectType: '0-1',
125
+ pageLength,
126
+ }), { initialProps: { pageLength: 10 } });
127
+ await waitFor(() => {
128
+ expect(result.current.pagination.currentPage).toBe(1);
129
+ expect(result.current.pagination.hasNextPage).toBe(true);
130
+ });
131
+ result.current.pagination.nextPage();
132
+ await waitFor(() => {
133
+ expect(result.current.pagination.currentPage).toBe(2);
134
+ expect(result.current.pagination.hasPreviousPage).toBe(true);
135
+ });
136
+ rerender({ pageLength: 25 });
137
+ await waitFor(() => {
138
+ expect(result.current.pagination.pageSize).toBe(25);
139
+ expect(result.current.pagination.currentPage).toBe(1);
140
+ expect(result.current.pagination.hasPreviousPage).toBe(false);
141
+ });
142
+ expect(mockFetchAssociations).toHaveBeenLastCalledWith(expect.objectContaining({
143
+ pageLength: 25,
144
+ offset: undefined,
145
+ }), expect.any(Object));
146
+ });
96
147
  });
97
148
  describe('data fetching', () => {
98
149
  it('should successfully fetch and return associations with pagination info', async () => {
@@ -18,11 +18,6 @@ describe('fetchAssociations', () => {
18
18
  json: vi.fn().mockResolvedValue({ data, cleanup: vi.fn() }),
19
19
  ...overrides,
20
20
  });
21
- const createErrorResponse = (statusText) => ({
22
- ok: false,
23
- statusText,
24
- json: vi.fn().mockResolvedValue({}),
25
- });
26
21
  const expectError = async (request, expectedMessage) => {
27
22
  await expect(fetchAssociations(request)).rejects.toThrow(expectedMessage);
28
23
  };
@@ -222,11 +217,6 @@ describe('fetchAssociations', () => {
222
217
  });
223
218
  });
224
219
  describe('error handling', () => {
225
- it('should throw an error when response is not OK', async () => {
226
- mockFetchAssociations.mockResolvedValueOnce(createErrorResponse('Not Found'));
227
- const request = createBasicRequest({ properties: ['firstname'] });
228
- await expectError(request, 'Failed to fetch associations: Not Found');
229
- });
230
220
  it('should throw an error when fetch fails', async () => {
231
221
  mockFetchAssociations.mockRejectedValueOnce(new Error('Network error'));
232
222
  const request = createBasicRequest({ properties: ['firstname'] });
@@ -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
+ });