@hubspot/ui-extensions 0.11.4 → 0.11.6

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 (155) hide show
  1. package/dist/__tests__/crm/hooks/useAssociations.spec.js +96 -0
  2. package/dist/__tests__/crm/hooks/useCrmProperties.spec.js +170 -1
  3. package/dist/crm/hooks/useAssociations.d.ts +2 -0
  4. package/dist/crm/hooks/useAssociations.js +87 -0
  5. package/dist/crm/hooks/useCrmProperties.d.ts +5 -1
  6. package/dist/crm/hooks/useCrmProperties.js +81 -2
  7. package/dist/hooks/useExtensionActions.d.ts +4 -0
  8. package/dist/hooks/useExtensionActions.js +6 -0
  9. package/dist/hooks/useExtensionContext.d.ts +4 -0
  10. package/dist/hooks/useExtensionContext.js +6 -0
  11. package/dist/index.d.ts +2 -0
  12. package/dist/index.js +2 -0
  13. package/dist/internal/global-utils.js +4 -0
  14. package/dist/internal/hook-utils.d.ts +10 -3
  15. package/dist/internal/hook-utils.js +10 -1
  16. package/dist/shared/types/components/accordion.d.ts +5 -5
  17. package/dist/shared/types/components/alert.d.ts +2 -2
  18. package/dist/shared/types/components/button-row.d.ts +5 -2
  19. package/dist/shared/types/components/button.d.ts +16 -10
  20. package/dist/shared/types/components/chart.d.ts +3 -3
  21. package/dist/shared/types/components/description-list.d.ts +2 -2
  22. package/dist/shared/types/components/dropdown.d.ts +8 -8
  23. package/dist/shared/types/components/empty-state.d.ts +5 -7
  24. package/dist/shared/types/components/error-state.d.ts +2 -2
  25. package/dist/shared/types/components/form.d.ts +2 -2
  26. package/dist/shared/types/components/heading.d.ts +1 -1
  27. package/dist/shared/types/components/icon.d.ts +4 -5
  28. package/dist/shared/types/components/illustration.d.ts +12 -0
  29. package/dist/shared/types/components/image.d.ts +9 -4
  30. package/dist/shared/types/components/inputs.d.ts +51 -64
  31. package/dist/shared/types/components/layouts.d.ts +17 -24
  32. package/dist/shared/types/components/link.d.ts +8 -5
  33. package/dist/shared/types/components/loading-spinner.d.ts +3 -3
  34. package/dist/shared/types/components/modal.d.ts +5 -5
  35. package/dist/shared/types/components/panel.d.ts +7 -7
  36. package/dist/shared/types/components/progress-bar.d.ts +4 -4
  37. package/dist/shared/types/components/selects.d.ts +11 -20
  38. package/dist/shared/types/components/statistics.d.ts +2 -2
  39. package/dist/shared/types/components/status-tag.d.ts +5 -5
  40. package/dist/shared/types/components/step-indicator.d.ts +5 -7
  41. package/dist/shared/types/components/table.d.ts +22 -12
  42. package/dist/shared/types/components/tabs.d.ts +10 -10
  43. package/dist/shared/types/components/tag.d.ts +2 -2
  44. package/dist/shared/types/components/text.d.ts +15 -21
  45. package/dist/shared/types/components/tile.d.ts +2 -2
  46. package/dist/shared/types/components/toggle.d.ts +12 -14
  47. package/dist/shared/types/components/toggleInputs.d.ts +26 -19
  48. package/dist/shared/types/components/tooltip.d.ts +1 -1
  49. package/dist/shared/types/crm.d.ts +52 -0
  50. package/dist/shared/types/http-requests.d.ts +2 -2
  51. package/dist/shared/types/shared.d.ts +123 -78
  52. package/dist/shared/types/shared.js +123 -78
  53. package/dist/shared/types/worker-globals.d.ts +15 -0
  54. package/dist/{experimental/testing → testing}/__tests__/debug.spec.js +1 -1
  55. package/dist/{experimental/testing → testing}/__tests__/find.spec.js +1 -1
  56. package/dist/{experimental/testing → testing}/__tests__/findAll.spec.js +1 -1
  57. package/dist/{experimental/testing → testing}/__tests__/findAllChildren.spec.js +1 -1
  58. package/dist/{experimental/testing → testing}/__tests__/findByTestId.spec.js +1 -1
  59. package/dist/{experimental/testing → testing}/__tests__/findChild.spec.js +1 -1
  60. package/dist/{experimental/testing → testing}/__tests__/fragments.spec.js +1 -1
  61. package/dist/{experimental/testing → testing}/__tests__/invalid-components.spec.js +1 -1
  62. package/dist/{experimental/testing → testing}/__tests__/isMatch.spec.js +1 -1
  63. package/dist/{experimental/testing → testing}/__tests__/logger.spec.js +1 -1
  64. package/dist/{experimental/testing → testing}/__tests__/maybeFind.spec.js +1 -1
  65. package/dist/{experimental/testing → testing}/__tests__/maybeFindByTestId.spec.js +1 -1
  66. package/dist/{experimental/testing → testing}/__tests__/maybeFindChild.spec.js +1 -1
  67. package/dist/{experimental/testing → testing}/__tests__/mocks.runServerlessFunction.spec.js +1 -1
  68. package/dist/testing/__tests__/mocks.useAssociations.spec.js +135 -0
  69. package/dist/testing/__tests__/mocks.useCrmProperties.spec.js +106 -0
  70. package/dist/testing/__tests__/mocks.useExtensionActions.spec.js +32 -0
  71. package/dist/testing/__tests__/mocks.useExtensionContext.spec.js +46 -0
  72. package/dist/{experimental/testing → testing}/__tests__/props.spec.js +1 -1
  73. package/dist/{experimental/testing → testing}/__tests__/testId.spec.js +1 -1
  74. package/dist/{experimental/testing → testing}/__tests__/trigger.spec.js +1 -1
  75. package/dist/{experimental/testing → testing}/__tests__/type-utils.spec.js +1 -1
  76. package/dist/testing/__tests__/waitFor.spec.d.ts +1 -0
  77. package/dist/{experimental/testing → testing}/__tests__/waitFor.spec.js +1 -1
  78. package/dist/{experimental/testing → testing}/internal/convert.js +1 -1
  79. package/dist/{experimental/testing → testing}/internal/element.d.ts +1 -1
  80. package/dist/{experimental/testing → testing}/internal/errors.js +1 -1
  81. package/dist/{experimental/testing → testing}/internal/match.d.ts +1 -1
  82. package/dist/{experimental/testing → testing}/internal/mocks/index.d.ts +1 -1
  83. package/dist/{experimental/testing → testing}/internal/mocks/mock-extension-point-api.d.ts +1 -1
  84. package/dist/{experimental/testing → testing}/internal/mocks/mock-extension-point-api.js +1 -1
  85. package/dist/testing/internal/mocks/mock-hooks.d.ts +3 -0
  86. package/dist/{experimental/testing → testing}/internal/mocks/mock-hooks.js +14 -0
  87. package/dist/{experimental/testing → testing}/internal/print.js +1 -1
  88. package/dist/{experimental/testing → testing}/internal/query.d.ts +1 -1
  89. package/dist/{experimental/testing → testing}/internal/query.js +1 -1
  90. package/dist/{experimental/testing → testing}/internal/types-internal.d.ts +7 -3
  91. package/dist/testing/internal/types-internal.js +1 -0
  92. package/dist/{experimental/testing → testing}/render.d.ts +1 -1
  93. package/dist/{experimental/testing → testing}/render.js +7 -7
  94. package/dist/{experimental/testing → testing}/types.d.ts +25 -5
  95. package/dist/{experimental/testing → testing}/utils.d.ts +1 -1
  96. package/dist/{experimental/testing → testing}/utils.js +1 -1
  97. package/package.json +3 -3
  98. package/dist/experimental/testing/__tests__/mocks.useAssociations.spec.js +0 -47
  99. package/dist/experimental/testing/__tests__/mocks.useCrmProperties.spec.js +0 -58
  100. package/dist/experimental/testing/internal/mocks/mock-hooks.d.ts +0 -2
  101. /package/dist/{experimental/testing → testing}/__tests__/createRenderer.spec.d.ts +0 -0
  102. /package/dist/{experimental/testing → testing}/__tests__/createRenderer.spec.js +0 -0
  103. /package/dist/{experimental/testing → testing}/__tests__/debug.spec.d.ts +0 -0
  104. /package/dist/{experimental/testing → testing}/__tests__/find.spec.d.ts +0 -0
  105. /package/dist/{experimental/testing → testing}/__tests__/findAll.spec.d.ts +0 -0
  106. /package/dist/{experimental/testing → testing}/__tests__/findAllChildren.spec.d.ts +0 -0
  107. /package/dist/{experimental/testing → testing}/__tests__/findByTestId.spec.d.ts +0 -0
  108. /package/dist/{experimental/testing → testing}/__tests__/findChild.spec.d.ts +0 -0
  109. /package/dist/{experimental/testing → testing}/__tests__/fragments.spec.d.ts +0 -0
  110. /package/dist/{experimental/testing → testing}/__tests__/invalid-components.spec.d.ts +0 -0
  111. /package/dist/{experimental/testing → testing}/__tests__/isMatch.spec.d.ts +0 -0
  112. /package/dist/{experimental/testing → testing}/__tests__/logger.spec.d.ts +0 -0
  113. /package/dist/{experimental/testing → testing}/__tests__/maybeFind.spec.d.ts +0 -0
  114. /package/dist/{experimental/testing → testing}/__tests__/maybeFindByTestId.spec.d.ts +0 -0
  115. /package/dist/{experimental/testing → testing}/__tests__/maybeFindChild.spec.d.ts +0 -0
  116. /package/dist/{experimental/testing → testing}/__tests__/mocks.actions.spec.d.ts +0 -0
  117. /package/dist/{experimental/testing → testing}/__tests__/mocks.actions.spec.js +0 -0
  118. /package/dist/{experimental/testing → testing}/__tests__/mocks.context.spec.d.ts +0 -0
  119. /package/dist/{experimental/testing → testing}/__tests__/mocks.context.spec.js +0 -0
  120. /package/dist/{experimental/testing → testing}/__tests__/mocks.runServerlessFunction.spec.d.ts +0 -0
  121. /package/dist/{experimental/testing → testing}/__tests__/mocks.useAssociations.spec.d.ts +0 -0
  122. /package/dist/{experimental/testing → testing}/__tests__/mocks.useCrmProperties.spec.d.ts +0 -0
  123. /package/dist/{experimental/testing/__tests__/props.spec.d.ts → testing/__tests__/mocks.useExtensionActions.spec.d.ts} +0 -0
  124. /package/dist/{experimental/testing/__tests__/testId.spec.d.ts → testing/__tests__/mocks.useExtensionContext.spec.d.ts} +0 -0
  125. /package/dist/{experimental/testing/__tests__/trigger.spec.d.ts → testing/__tests__/props.spec.d.ts} +0 -0
  126. /package/dist/{experimental/testing/__tests__/type-utils.spec.d.ts → testing/__tests__/testId.spec.d.ts} +0 -0
  127. /package/dist/{experimental/testing/__tests__/waitFor.spec.d.ts → testing/__tests__/trigger.spec.d.ts} +0 -0
  128. /package/dist/{experimental/testing/internal/types-internal.js → testing/__tests__/type-utils.spec.d.ts} +0 -0
  129. /package/dist/{experimental/testing → testing}/index.d.ts +0 -0
  130. /package/dist/{experimental/testing → testing}/index.js +0 -0
  131. /package/dist/{experimental/testing → testing}/internal/constants.d.ts +0 -0
  132. /package/dist/{experimental/testing → testing}/internal/constants.js +0 -0
  133. /package/dist/{experimental/testing → testing}/internal/convert.d.ts +0 -0
  134. /package/dist/{experimental/testing → testing}/internal/debug.d.ts +0 -0
  135. /package/dist/{experimental/testing → testing}/internal/debug.js +0 -0
  136. /package/dist/{experimental/testing → testing}/internal/document.d.ts +0 -0
  137. /package/dist/{experimental/testing → testing}/internal/document.js +0 -0
  138. /package/dist/{experimental/testing → testing}/internal/element.js +0 -0
  139. /package/dist/{experimental/testing → testing}/internal/errors.d.ts +0 -0
  140. /package/dist/{experimental/testing → testing}/internal/fragment.d.ts +0 -0
  141. /package/dist/{experimental/testing → testing}/internal/fragment.js +0 -0
  142. /package/dist/{experimental/testing → testing}/internal/match.js +0 -0
  143. /package/dist/{experimental/testing → testing}/internal/mocks/index.js +0 -0
  144. /package/dist/{experimental/testing → testing}/internal/print.d.ts +0 -0
  145. /package/dist/{experimental/testing → testing}/internal/root.d.ts +0 -0
  146. /package/dist/{experimental/testing → testing}/internal/root.js +0 -0
  147. /package/dist/{experimental/testing → testing}/internal/text.d.ts +0 -0
  148. /package/dist/{experimental/testing → testing}/internal/text.js +0 -0
  149. /package/dist/{experimental/testing → testing}/internal/type-utils-internal.d.ts +0 -0
  150. /package/dist/{experimental/testing → testing}/internal/type-utils-internal.js +0 -0
  151. /package/dist/{experimental/testing → testing}/internal/utils/promise-utils.d.ts +0 -0
  152. /package/dist/{experimental/testing → testing}/internal/utils/promise-utils.js +0 -0
  153. /package/dist/{experimental/testing → testing}/type-utils.d.ts +0 -0
  154. /package/dist/{experimental/testing → testing}/type-utils.js +0 -0
  155. /package/dist/{experimental/testing → testing}/types.js +0 -0
@@ -21,6 +21,16 @@ vi.mock('../../../crm/utils/fetchAssociations.ts', async (importOriginal) => {
21
21
  });
22
22
  // Get reference to the mocked function
23
23
  const mockFetchAssociations = fetchAssociations;
24
+ // Shared test helpers
25
+ const createAssociation = (id) => ({
26
+ toObjectId: id,
27
+ associationTypes: [],
28
+ properties: {},
29
+ });
30
+ const mockResponse = (results, hasMore = false, nextOffset = 10) => ({
31
+ data: { results, hasMore, nextOffset },
32
+ cleanup: vi.fn(),
33
+ });
24
34
  describe('useAssociations with Pagination', () => {
25
35
  let originalError;
26
36
  beforeAll(() => {
@@ -539,4 +549,90 @@ describe('useAssociations with Pagination', () => {
539
549
  });
540
550
  });
541
551
  });
552
+ describe('refetch', () => {
553
+ it('should handle complete refetch lifecycle with loading states and data preservation', async () => {
554
+ const initialData = [createAssociation(1)];
555
+ const refetchedData = [createAssociation(2)];
556
+ let resolveRefetch;
557
+ const refetchPromise = new Promise((resolve) => {
558
+ resolveRefetch = resolve;
559
+ });
560
+ mockFetchAssociations
561
+ .mockResolvedValueOnce(mockResponse(initialData))
562
+ .mockImplementationOnce(() => refetchPromise);
563
+ const { result } = renderHook(() => useAssociations({ toObjectType: '0-1', pageLength: 10 }));
564
+ await waitFor(() => {
565
+ expect(result.current.results).toEqual(initialData);
566
+ expect(result.current.isLoading).toBe(false);
567
+ expect(result.current.isRefetching).toBe(false);
568
+ });
569
+ const refetchCall = result.current.refetch();
570
+ await waitFor(() => expect(result.current.isRefetching).toBe(true));
571
+ expect(result.current.results).toEqual(initialData);
572
+ resolveRefetch(mockResponse(refetchedData, true, 20));
573
+ await refetchCall;
574
+ await waitFor(() => {
575
+ expect(result.current.isRefetching).toBe(false);
576
+ expect(result.current.results).toEqual(refetchedData);
577
+ });
578
+ });
579
+ it('should handle errors during refetch and allow recovery', async () => {
580
+ const initialData = [createAssociation(1)];
581
+ const recoveredData = [createAssociation(2)];
582
+ mockFetchAssociations
583
+ .mockResolvedValueOnce(mockResponse(initialData))
584
+ .mockRejectedValueOnce(new Error('Failed to refetch associations'))
585
+ .mockResolvedValueOnce(mockResponse(recoveredData));
586
+ const { result } = renderHook(() => useAssociations({ toObjectType: '0-1', pageLength: 10 }));
587
+ await waitFor(() => expect(result.current.results).toEqual(initialData));
588
+ await result.current.refetch();
589
+ await waitFor(() => {
590
+ expect(result.current.error?.message).toBe('Failed to refetch associations');
591
+ expect(result.current.results).toEqual(initialData);
592
+ });
593
+ await result.current.refetch();
594
+ await waitFor(() => {
595
+ expect(result.current.error).toBeNull();
596
+ expect(result.current.results).toEqual(recoveredData);
597
+ });
598
+ });
599
+ it('should cancel in-flight refetch when called again', async () => {
600
+ const initialData = [createAssociation(1)];
601
+ const firstRefetchCleanup = vi.fn();
602
+ const secondRefetchCleanup = vi.fn();
603
+ mockFetchAssociations
604
+ .mockResolvedValueOnce(mockResponse(initialData))
605
+ .mockResolvedValueOnce({
606
+ ...mockResponse([createAssociation(2)], false, 20),
607
+ cleanup: firstRefetchCleanup,
608
+ })
609
+ .mockResolvedValueOnce({
610
+ ...mockResponse([createAssociation(3)], false, 30),
611
+ cleanup: secondRefetchCleanup,
612
+ });
613
+ const { result } = renderHook(() => useAssociations({ toObjectType: '0-1', pageLength: 10 }));
614
+ await waitFor(() => expect(result.current.results).toEqual(initialData));
615
+ await Promise.all([result.current.refetch(), result.current.refetch()]);
616
+ await waitFor(() => expect(result.current.results).toEqual([createAssociation(3)]));
617
+ expect(firstRefetchCleanup).toHaveBeenCalledTimes(1);
618
+ expect(secondRefetchCleanup).not.toHaveBeenCalled();
619
+ });
620
+ it('should maintain current page and pagination state during refetch', async () => {
621
+ mockFetchAssociations
622
+ .mockResolvedValueOnce(mockResponse([createAssociation(1)], true, 10))
623
+ .mockResolvedValueOnce(mockResponse([createAssociation(2)], true, 20))
624
+ .mockResolvedValueOnce(mockResponse([createAssociation(2)], true, 20));
625
+ const { result } = renderHook(() => useAssociations({ toObjectType: '0-1', pageLength: 10 }));
626
+ await waitFor(() => expect(result.current.pagination.currentPage).toBe(1));
627
+ result.current.pagination.nextPage();
628
+ await waitFor(() => expect(result.current.pagination.currentPage).toBe(2));
629
+ await result.current.refetch();
630
+ await waitFor(() => {
631
+ expect(result.current.pagination.currentPage).toBe(2);
632
+ expect(result.current.pagination.hasNextPage).toBe(true);
633
+ expect(result.current.pagination.hasPreviousPage).toBe(true);
634
+ });
635
+ expect(mockFetchAssociations).toHaveBeenLastCalledWith(expect.objectContaining({ offset: 10 }), expect.any(Object));
636
+ });
637
+ });
542
638
  });
@@ -21,7 +21,9 @@ describe('useCrmProperties', () => {
21
21
  // Suppress React act() warning coming from @testing-library/react
22
22
  originalError = console.error;
23
23
  console.error = (...args) => {
24
- if (args[0]?.includes('ReactDOMTestUtils.act'))
24
+ if (typeof args[0] === 'string' &&
25
+ (args[0].includes('ReactDOMTestUtils.act') ||
26
+ args[0].includes('was not wrapped in act')))
25
27
  return;
26
28
  originalError.call(console, ...args);
27
29
  };
@@ -254,4 +256,171 @@ describe('useCrmProperties', () => {
254
256
  });
255
257
  expect(mockFetchCrmProperties).toHaveBeenCalledWith(2, expect.any(Function), {});
256
258
  });
259
+ describe('refetch', () => {
260
+ it('should successfully refetch and update properties', async () => {
261
+ const initialData = { firstname: 'John', lastname: 'Doe' };
262
+ const refetchedData = { firstname: 'Jane', lastname: 'Smith' };
263
+ mockFetchCrmProperties
264
+ .mockResolvedValueOnce({
265
+ data: initialData,
266
+ cleanup: vi.fn(),
267
+ })
268
+ .mockResolvedValueOnce({
269
+ data: refetchedData,
270
+ cleanup: vi.fn(),
271
+ });
272
+ const propertyNames = ['firstname', 'lastname'];
273
+ const { result } = renderHook(() => useCrmProperties(propertyNames));
274
+ await waitFor(() => {
275
+ expect(result.current.properties).toEqual(initialData);
276
+ expect(result.current.isLoading).toBe(false);
277
+ expect(result.current.isRefetching).toBe(false);
278
+ });
279
+ // Call refetch and wait for it to complete
280
+ await result.current.refetch();
281
+ // Wait for state to update with refetched data
282
+ await waitFor(() => {
283
+ expect(result.current.properties).toEqual(refetchedData);
284
+ expect(result.current.isRefetching).toBe(false);
285
+ expect(result.current.error).toBeNull();
286
+ });
287
+ expect(mockFetchCrmProperties).toHaveBeenCalledTimes(2);
288
+ });
289
+ it('should set isRefetching to true during refetch', async () => {
290
+ const initialData = { firstname: 'John', lastname: 'Doe' };
291
+ const refetchedData = { firstname: 'Jane', lastname: 'Smith' };
292
+ let resolveRefetch;
293
+ const refetchPromise = new Promise((resolve) => {
294
+ resolveRefetch = resolve;
295
+ });
296
+ mockFetchCrmProperties
297
+ .mockResolvedValueOnce({
298
+ data: initialData,
299
+ cleanup: vi.fn(),
300
+ })
301
+ .mockImplementationOnce(() => refetchPromise);
302
+ const propertyNames = ['firstname', 'lastname'];
303
+ const { result } = renderHook(() => useCrmProperties(propertyNames));
304
+ await waitFor(() => {
305
+ expect(result.current.properties).toEqual(initialData);
306
+ expect(result.current.isLoading).toBe(false);
307
+ expect(result.current.isRefetching).toBe(false);
308
+ });
309
+ // Call refetch but don't await it
310
+ const refetchCall = result.current.refetch();
311
+ // Check that isRefetching is true immediately after calling refetch
312
+ await waitFor(() => {
313
+ expect(result.current.isRefetching).toBe(true);
314
+ });
315
+ // Verify properties haven't changed yet
316
+ expect(result.current.properties).toEqual(initialData);
317
+ expect(result.current.error).toBeNull();
318
+ // Resolve the refetch promise
319
+ resolveRefetch({
320
+ data: refetchedData,
321
+ cleanup: vi.fn(),
322
+ });
323
+ // Wait for refetch to complete
324
+ await refetchCall;
325
+ // Verify isRefetching is false and properties are updated
326
+ await waitFor(() => {
327
+ expect(result.current.isRefetching).toBe(false);
328
+ expect(result.current.properties).toEqual(refetchedData);
329
+ });
330
+ });
331
+ it('should handle refetch errors correctly', async () => {
332
+ const initialData = { firstname: 'John', lastname: 'Doe' };
333
+ const errorMessage = 'Failed to refetch CRM properties';
334
+ mockFetchCrmProperties
335
+ .mockResolvedValueOnce({
336
+ data: initialData,
337
+ cleanup: vi.fn(),
338
+ })
339
+ .mockRejectedValueOnce(new Error(errorMessage));
340
+ const propertyNames = ['firstname', 'lastname'];
341
+ const { result } = renderHook(() => useCrmProperties(propertyNames));
342
+ await waitFor(() => {
343
+ expect(result.current.properties).toEqual(initialData);
344
+ expect(result.current.isLoading).toBe(false);
345
+ expect(result.current.error).toBeNull();
346
+ });
347
+ // Call refetch and wait for it to complete (will throw error)
348
+ await result.current.refetch();
349
+ // Wait for error state to update
350
+ await waitFor(() => {
351
+ expect(result.current.error).toBeInstanceOf(Error);
352
+ expect(result.current.error?.message).toBe(errorMessage);
353
+ expect(result.current.properties).toEqual(initialData);
354
+ expect(result.current.isRefetching).toBe(false);
355
+ });
356
+ });
357
+ it('should clear error on successful refetch', async () => {
358
+ const errorMessage = 'Failed to fetch CRM properties';
359
+ const refetchedData = { firstname: 'Jane', lastname: 'Smith' };
360
+ mockFetchCrmProperties
361
+ .mockRejectedValueOnce(new Error(errorMessage))
362
+ .mockResolvedValueOnce({
363
+ data: refetchedData,
364
+ cleanup: vi.fn(),
365
+ });
366
+ const propertyNames = ['firstname', 'lastname'];
367
+ const { result } = renderHook(() => useCrmProperties(propertyNames));
368
+ await waitFor(() => {
369
+ expect(result.current.error).toBeInstanceOf(Error);
370
+ expect(result.current.error?.message).toBe(errorMessage);
371
+ expect(result.current.isLoading).toBe(false);
372
+ });
373
+ // Call refetch and wait for it to complete
374
+ await result.current.refetch();
375
+ // Wait for error to clear and data to update
376
+ await waitFor(() => {
377
+ expect(result.current.error).toBeNull();
378
+ expect(result.current.properties).toEqual(refetchedData);
379
+ expect(result.current.isRefetching).toBe(false);
380
+ });
381
+ });
382
+ it('should handle multiple rapid refetch calls correctly', async () => {
383
+ const initialData = { firstname: 'John', lastname: 'Doe' };
384
+ const firstRefetchData = { firstname: 'First', lastname: 'Refetch' };
385
+ const secondRefetchData = { firstname: 'Second', lastname: 'Refetch' };
386
+ const initialCleanup = vi.fn();
387
+ const firstRefetchCleanup = vi.fn();
388
+ const secondRefetchCleanup = vi.fn();
389
+ mockFetchCrmProperties
390
+ .mockResolvedValueOnce({
391
+ data: initialData,
392
+ cleanup: initialCleanup,
393
+ })
394
+ .mockResolvedValueOnce({
395
+ data: firstRefetchData,
396
+ cleanup: firstRefetchCleanup,
397
+ })
398
+ .mockResolvedValueOnce({
399
+ data: secondRefetchData,
400
+ cleanup: secondRefetchCleanup,
401
+ });
402
+ const propertyNames = ['firstname', 'lastname'];
403
+ const { result } = renderHook(() => useCrmProperties(propertyNames));
404
+ // Wait for initial fetch to complete
405
+ await waitFor(() => {
406
+ expect(result.current.properties).toEqual(initialData);
407
+ expect(result.current.isLoading).toBe(false);
408
+ });
409
+ // Call refetch twice rapidly (concurrently) - the second should cancel the first
410
+ const firstRefetch = result.current.refetch();
411
+ const secondRefetch = result.current.refetch();
412
+ // Wait for both to complete
413
+ await Promise.all([firstRefetch, secondRefetch]);
414
+ // Verify the final refetch's data is in state
415
+ await waitFor(() => {
416
+ expect(result.current.properties).toEqual(secondRefetchData);
417
+ expect(result.current.isRefetching).toBe(false);
418
+ expect(result.current.error).toBeNull();
419
+ });
420
+ // Verify cancellation behavior: first refetch was cancelled and cleaned up
421
+ expect(firstRefetchCleanup).toHaveBeenCalledTimes(1);
422
+ // Second refetch's cleanup should NOT be called yet (still active subscription)
423
+ expect(secondRefetchCleanup).not.toHaveBeenCalled();
424
+ });
425
+ });
257
426
  });
@@ -17,7 +17,9 @@ export interface UseAssociationsResult {
17
17
  results: AssociationResult[];
18
18
  error: Error | null;
19
19
  isLoading: boolean;
20
+ isRefetching: boolean;
20
21
  pagination: UseAssociationsPagination;
22
+ refetch: () => Promise<void>;
21
23
  }
22
24
  /**
23
25
  * A hook to fetch and manage associations between CRM objects with pagination support.
@@ -6,6 +6,7 @@ function createInitialState(pageSize) {
6
6
  results: [],
7
7
  error: null,
8
8
  isLoading: true,
9
+ isRefetching: false,
9
10
  currentPage: 1,
10
11
  pageSize,
11
12
  hasMore: false,
@@ -75,6 +76,27 @@ function associationsReducer(state, action) {
75
76
  nextOffset: undefined,
76
77
  offsetHistory: [],
77
78
  };
79
+ case 'REFETCH_START':
80
+ return {
81
+ ...state,
82
+ isRefetching: true,
83
+ error: null,
84
+ };
85
+ case 'REFETCH_SUCCESS':
86
+ return {
87
+ ...state,
88
+ isRefetching: false,
89
+ results: action.payload.results,
90
+ hasMore: action.payload.hasMore,
91
+ nextOffset: action.payload.nextOffset,
92
+ error: null,
93
+ };
94
+ case 'REFETCH_ERROR':
95
+ return {
96
+ ...state,
97
+ isRefetching: false,
98
+ error: action.payload,
99
+ };
78
100
  default:
79
101
  return state;
80
102
  }
@@ -97,6 +119,10 @@ function useAssociationsInternal(config, options = DEFAULT_OPTIONS) {
97
119
  const lastConfigKeyRef = useRef();
98
120
  const lastOptionsRef = useRef();
99
121
  const lastOptionsKeyRef = useRef();
122
+ // Track in-flight refetch to support cancellation
123
+ const refetchAbortRef = useRef(null);
124
+ // Track refetch cleanup function to prevent memory leaks
125
+ const refetchCleanupRef = useRef(null);
100
126
  const stableConfig = useMemo(() => {
101
127
  const configKey = JSON.stringify(config);
102
128
  if (configKey === lastConfigKeyRef.current) {
@@ -178,6 +204,11 @@ function useAssociationsInternal(config, options = DEFAULT_OPTIONS) {
178
204
  if (cleanup) {
179
205
  cleanup();
180
206
  }
207
+ // Clean up any active refetch subscription
208
+ if (refetchCleanupRef.current) {
209
+ refetchCleanupRef.current();
210
+ refetchCleanupRef.current = null;
211
+ }
181
212
  };
182
213
  }, [
183
214
  stableConfig,
@@ -186,12 +217,68 @@ function useAssociationsInternal(config, options = DEFAULT_OPTIONS) {
186
217
  state.currentOffset,
187
218
  pageSize,
188
219
  ]);
220
+ const refetch = useCallback(async () => {
221
+ if (refetchAbortRef.current) {
222
+ refetchAbortRef.current.cancelled = true;
223
+ }
224
+ if (refetchCleanupRef.current) {
225
+ refetchCleanupRef.current();
226
+ refetchCleanupRef.current = null;
227
+ }
228
+ const abortSignal = { cancelled: false };
229
+ refetchAbortRef.current = abortSignal;
230
+ try {
231
+ dispatch({ type: 'REFETCH_START' });
232
+ // Build request using current offset token to refetch current page
233
+ const request = {
234
+ toObjectType: stableConfig?.toObjectType,
235
+ properties: stableConfig?.properties,
236
+ pageLength: pageSize,
237
+ offset: state.currentOffset,
238
+ };
239
+ const result = await fetchAssociations(request, {
240
+ propertiesToFormat: stableOptions.propertiesToFormat,
241
+ formattingOptions: stableOptions.formattingOptions,
242
+ });
243
+ if (!abortSignal.cancelled) {
244
+ dispatch({
245
+ type: 'REFETCH_SUCCESS',
246
+ payload: {
247
+ results: result.data.results,
248
+ hasMore: result.data.hasMore,
249
+ nextOffset: result.data.nextOffset,
250
+ },
251
+ });
252
+ refetchCleanupRef.current = result.cleanup;
253
+ }
254
+ else {
255
+ if (result.cleanup) {
256
+ result.cleanup();
257
+ }
258
+ }
259
+ }
260
+ catch (err) {
261
+ if (!abortSignal.cancelled) {
262
+ const errorData = err instanceof Error
263
+ ? err
264
+ : new Error('Failed to refetch associations');
265
+ dispatch({ type: 'REFETCH_ERROR', payload: errorData });
266
+ }
267
+ }
268
+ finally {
269
+ if (refetchAbortRef.current === abortSignal) {
270
+ refetchAbortRef.current = null;
271
+ }
272
+ }
273
+ }, [stableConfig, stableOptions, state.currentOffset, pageSize]);
189
274
  // Calculate pagination flags
190
275
  const paginationFlags = calculatePaginationFlags(state.currentPage, state.hasMore);
191
276
  return {
192
277
  results: state.results,
193
278
  error: state.error,
194
279
  isLoading: state.isLoading,
280
+ isRefetching: state.isRefetching,
281
+ refetch,
195
282
  pagination: {
196
283
  hasNextPage: paginationFlags.hasNextPage,
197
284
  hasPreviousPage: paginationFlags.hasPreviousPage,
@@ -3,10 +3,14 @@ export interface CrmPropertiesState {
3
3
  properties: Record<string, string | null>;
4
4
  error: Error | null;
5
5
  isLoading: boolean;
6
+ isRefetching: boolean;
7
+ }
8
+ export interface UseCrmPropertiesResult extends CrmPropertiesState {
9
+ refetch: () => Promise<void>;
6
10
  }
7
11
  /**
8
12
  * A hook for using and managing CRM properties.
9
13
  */
10
- declare function useCrmPropertiesInternal(propertyNames: string[], options?: FetchCrmPropertiesOptions): CrmPropertiesState;
14
+ declare function useCrmPropertiesInternal(propertyNames: string[], options?: FetchCrmPropertiesOptions): UseCrmPropertiesResult;
11
15
  export declare const useCrmProperties: typeof useCrmPropertiesInternal;
12
16
  export {};
@@ -1,10 +1,11 @@
1
- import { useEffect, useReducer, useMemo, useRef } from 'react';
1
+ import { useEffect, useReducer, useMemo, useRef, useCallback } from 'react';
2
2
  import { fetchCrmProperties, } from "../utils/fetchCrmProperties.js";
3
3
  import { createMockAwareHook } from "../../internal/hook-utils.js";
4
4
  const initialState = {
5
5
  properties: {},
6
6
  error: null,
7
7
  isLoading: true,
8
+ isRefetching: false,
8
9
  };
9
10
  function crmPropertiesReducer(state, action) {
10
11
  switch (action.type) {
@@ -28,6 +29,25 @@ function crmPropertiesReducer(state, action) {
28
29
  error: action.payload,
29
30
  properties: {},
30
31
  };
32
+ case 'REFETCH_START':
33
+ return {
34
+ ...state,
35
+ isRefetching: true,
36
+ error: null,
37
+ };
38
+ case 'REFETCH_SUCCESS':
39
+ return {
40
+ ...state,
41
+ isRefetching: false,
42
+ properties: action.payload,
43
+ error: null,
44
+ };
45
+ case 'REFETCH_ERROR':
46
+ return {
47
+ ...state,
48
+ isRefetching: false,
49
+ error: action.payload,
50
+ };
31
51
  default:
32
52
  return state;
33
53
  }
@@ -49,6 +69,10 @@ function useCrmPropertiesInternal(propertyNames, options = DEFAULT_OPTIONS) {
49
69
  const lastPropertyNamesKeyRef = useRef();
50
70
  const lastOptionsRef = useRef();
51
71
  const lastOptionsKeyRef = useRef();
72
+ // Track in-flight refetch to support cancellation
73
+ const refetchAbortRef = useRef(null);
74
+ // Track refetch cleanup function to prevent memory leaks
75
+ const refetchCleanupRef = useRef(null);
52
76
  const stablePropertyNames = useMemo(() => {
53
77
  if (!Array.isArray(propertyNames)) {
54
78
  return propertyNames;
@@ -104,8 +128,63 @@ function useCrmPropertiesInternal(propertyNames, options = DEFAULT_OPTIONS) {
104
128
  if (cleanup) {
105
129
  cleanup();
106
130
  }
131
+ // Clean up any active refetch subscription
132
+ if (refetchCleanupRef.current) {
133
+ refetchCleanupRef.current();
134
+ refetchCleanupRef.current = null;
135
+ }
107
136
  };
108
137
  }, [stablePropertyNames, stableOptions]);
109
- return state;
138
+ const refetch = useCallback(async () => {
139
+ // Cancel any in-flight refetch
140
+ if (refetchAbortRef.current) {
141
+ refetchAbortRef.current.cancelled = true;
142
+ }
143
+ // Clean up old refetch subscription to prevent memory leaks
144
+ if (refetchCleanupRef.current) {
145
+ refetchCleanupRef.current();
146
+ refetchCleanupRef.current = null;
147
+ }
148
+ // Create new abort signal for this refetch
149
+ const abortSignal = { cancelled: false };
150
+ refetchAbortRef.current = abortSignal;
151
+ try {
152
+ dispatch({ type: 'REFETCH_START' });
153
+ const result = await fetchCrmProperties(stablePropertyNames, (data) => {
154
+ if (!abortSignal.cancelled) {
155
+ dispatch({ type: 'REFETCH_SUCCESS', payload: data });
156
+ }
157
+ }, stableOptions);
158
+ if (!abortSignal.cancelled) {
159
+ dispatch({ type: 'REFETCH_SUCCESS', payload: result.data });
160
+ // Store cleanup for next refetch or unmount
161
+ refetchCleanupRef.current = result.cleanup;
162
+ }
163
+ else {
164
+ // If cancelled, clean up immediately
165
+ if (result.cleanup) {
166
+ result.cleanup();
167
+ }
168
+ }
169
+ }
170
+ catch (err) {
171
+ if (!abortSignal.cancelled) {
172
+ const errorData = err instanceof Error
173
+ ? err
174
+ : new Error('Failed to refetch CRM properties');
175
+ dispatch({ type: 'REFETCH_ERROR', payload: errorData });
176
+ }
177
+ }
178
+ finally {
179
+ // Clear the abort ref if this is still the current refetch
180
+ if (refetchAbortRef.current === abortSignal) {
181
+ refetchAbortRef.current = null;
182
+ }
183
+ }
184
+ }, [stablePropertyNames, stableOptions]);
185
+ return {
186
+ ...state,
187
+ refetch,
188
+ };
110
189
  }
111
190
  export const useCrmProperties = createMockAwareHook('useCrmProperties', useCrmPropertiesInternal);
@@ -0,0 +1,4 @@
1
+ import type { ExtensionPoints } from '../shared/types/extension-points.ts';
2
+ declare function useExtensionActionsInternal<ExtensionPoint extends keyof ExtensionPoints>(): import("../shared/types/extension-points.ts").ExtensionPointApiActions<ExtensionPoint>;
3
+ export declare const useExtensionActions: typeof useExtensionActionsInternal;
4
+ export {};
@@ -0,0 +1,6 @@
1
+ import { getWorkerGlobals } from "../internal/global-utils.js";
2
+ import { createMockAwareHook } from "../internal/hook-utils.js";
3
+ function useExtensionActionsInternal() {
4
+ return getWorkerGlobals().hsWorkerAPI.useExtensionActions();
5
+ }
6
+ export const useExtensionActions = createMockAwareHook('useExtensionActions', useExtensionActionsInternal);
@@ -0,0 +1,4 @@
1
+ import type { ExtensionPoints } from '../shared/types/extension-points.ts';
2
+ declare function useExtensionContextInternal<ExtensionPoint extends keyof ExtensionPoints>(): import("../shared/types/extension-points.ts").ExtensionPointApiContext<ExtensionPoint>;
3
+ export declare const useExtensionContext: typeof useExtensionContextInternal;
4
+ export {};
@@ -0,0 +1,6 @@
1
+ import { getWorkerGlobals } from "../internal/global-utils.js";
2
+ import { createMockAwareHook } from "../internal/hook-utils.js";
3
+ function useExtensionContextInternal() {
4
+ return getWorkerGlobals().hsWorkerAPI.useExtensionContext();
5
+ }
6
+ export const useExtensionContext = createMockAwareHook('useExtensionContext', useExtensionContextInternal);
package/dist/index.d.ts CHANGED
@@ -3,3 +3,5 @@ export { hubspot } from './hubspot.ts';
3
3
  export { logger } from './logger.ts';
4
4
  export * from './shared/types/index.ts';
5
5
  export { Accordion, Alert, AutoGrid, BarChart, Box, Button, ButtonRow, Card, Checkbox, CurrencyInput, DateInput, DescriptionList, DescriptionListItem, Divider, Dropdown, EmptyState, ErrorState, Flex, Form, Heading, Icon, Illustration, Image, Inline, Input, LineChart, Link, List, LoadingButton, LoadingSpinner, Modal, ModalBody, ModalFooter, MultiSelect, NumberInput, Panel, PanelBody, PanelFooter, PanelSection, ProgressBar, RadioButton, SearchInput, Select, Stack, Statistics, StatisticsItem, StatisticsTrend, StatusTag, StepIndicator, StepperInput, Tab, Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, Tabs, Tag, Text, TextArea, Textarea, Tile, TimeInput, Toggle, ToggleGroup, Tooltip, } from './shared/remoteComponents.tsx';
6
+ export { useExtensionContext } from './hooks/useExtensionContext.tsx';
7
+ export { useExtensionActions } from './hooks/useExtensionActions.tsx';
package/dist/index.js CHANGED
@@ -4,3 +4,5 @@ export { hubspot } from "./hubspot.js";
4
4
  export { logger } from "./logger.js";
5
5
  export * from "./shared/types/index.js";
6
6
  export { Accordion, Alert, AutoGrid, BarChart, Box, Button, ButtonRow, Card, Checkbox, CurrencyInput, DateInput, DescriptionList, DescriptionListItem, Divider, Dropdown, EmptyState, ErrorState, Flex, Form, Heading, Icon, Illustration, Image, Inline, Input, LineChart, Link, List, LoadingButton, LoadingSpinner, Modal, ModalBody, ModalFooter, MultiSelect, NumberInput, Panel, PanelBody, PanelFooter, PanelSection, ProgressBar, RadioButton, SearchInput, Select, Stack, Statistics, StatisticsItem, StatisticsTrend, StatusTag, StepIndicator, StepperInput, Tab, Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, Tabs, Tag, Text, TextArea, Textarea, Tile, TimeInput, Toggle, ToggleGroup, Tooltip, } from "./shared/remoteComponents.js";
7
+ export { useExtensionContext } from "./hooks/useExtensionContext.js";
8
+ export { useExtensionActions } from "./hooks/useExtensionActions.js";
@@ -25,6 +25,10 @@ const fakeWorkerGlobals = {
25
25
  extend_V2: () => {
26
26
  // No-op in test environment
27
27
  },
28
+ // @ts-expect-error we are not using the worker endpoint in tests env.
29
+ __useExtensionContext: () => {
30
+ // No-op in test environment
31
+ },
28
32
  };
29
33
  /**
30
34
  * Gets the worker globals object for the current environment.
@@ -1,4 +1,4 @@
1
- import { RendererMocksInternal } from '../experimental/testing/internal/types-internal.ts';
1
+ import { RendererSpies } from '../testing/types.ts';
2
2
  type AnyFunction = (...args: any[]) => any;
3
3
  /**
4
4
  * Creates a mock-aware hook function that can be used to mock the original hook function.
@@ -8,12 +8,19 @@ type AnyFunction = (...args: any[]) => any;
8
8
  * @param originalHookFunction The original hook function to call if no mock is found
9
9
  * @returns The mocked hook function or the original hook function if no mock is found
10
10
  */
11
- export declare const createMockAwareHook: <THookName extends keyof RendererMocksInternal, THookFunction extends AnyFunction>(hookName: THookName, originalHookFunction: THookFunction) => THookFunction;
11
+ export declare const createMockAwareHook: <THookName extends keyof RendererSpies, THookFunction extends AnyFunction>(hookName: THookName, originalHookFunction: THookFunction) => THookFunction;
12
+ /**
13
+ * A hook that provides access to the Mocks context.
14
+ * Returns the mocks object if inside a MocksContextProvider, otherwise returns null.
15
+ *
16
+ * @returns The mocks object or null if not in a test environment.
17
+ */
18
+ export declare function useMocksContext(): RendererSpies<"home" | "settings" | "crm.record.tab" | "crm.record.sidebar" | "crm.preview" | "helpdesk.sidebar" | "uie.playground.middle"> | null;
12
19
  /**
13
20
  * A React component that provides the Mocks context that can be used to provide mocks to the mock-aware hook functions.
14
21
  *
15
22
  * @param children The children to render.
16
23
  * @returns The children wrapped in the Mocks context provider.
17
24
  */
18
- export declare const MocksContextProvider: import("react").Provider<RendererMocksInternal | null>;
25
+ export declare const MocksContextProvider: import("react").Provider<RendererSpies<"home" | "settings" | "crm.record.tab" | "crm.record.sidebar" | "crm.preview" | "helpdesk.sidebar" | "uie.playground.middle"> | null>;
19
26
  export {};
@@ -10,7 +10,7 @@ const MocksContext = createContext(null);
10
10
  */
11
11
  export const createMockAwareHook = (hookName, originalHookFunction) => {
12
12
  const useWrapper = (...args) => {
13
- const mocks = useContext(MocksContext);
13
+ const mocks = useMocksContext();
14
14
  if (!mocks) {
15
15
  // If no mocks are provided, call the original hook function
16
16
  return originalHookFunction(...args);
@@ -25,6 +25,15 @@ export const createMockAwareHook = (hookName, originalHookFunction) => {
25
25
  };
26
26
  return useWrapper;
27
27
  };
28
+ /**
29
+ * A hook that provides access to the Mocks context.
30
+ * Returns the mocks object if inside a MocksContextProvider, otherwise returns null.
31
+ *
32
+ * @returns The mocks object or null if not in a test environment.
33
+ */
34
+ export function useMocksContext() {
35
+ return useContext(MocksContext);
36
+ }
28
37
  /**
29
38
  * A React component that provides the Mocks context that can be used to provide mocks to the mock-aware hook functions.
30
39
  *