@hubspot/ui-extensions 0.9.4 → 0.9.5

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.
@@ -8,7 +8,7 @@ Object.defineProperty(global, 'self', {
8
8
  value: mockSelf,
9
9
  writable: true,
10
10
  });
11
- import { fetchAssociations } from '../../../experimental/crm/fetchAssociations';
11
+ import { fetchAssociations, pageToOffset, offsetToPage, calculatePaginationFlags, } from '../../../experimental/crm/fetchAssociations';
12
12
  describe('fetchAssociations', () => {
13
13
  // Helper functions
14
14
  const createMockResponse = (data, overrides = {}) => ({
@@ -393,4 +393,50 @@ describe('fetchAssociations', () => {
393
393
  await expect(fetchAssociations(request, options)).rejects.toThrow('Invalid response format');
394
394
  });
395
395
  });
396
+ describe('pagination utilities', () => {
397
+ describe('pageToOffset', () => {
398
+ it('should convert page numbers to offsets', () => {
399
+ expect(pageToOffset(1, 10)).toBe(0);
400
+ expect(pageToOffset(3, 10)).toBe(20);
401
+ expect(pageToOffset(2, 25)).toBe(25);
402
+ });
403
+ it('should always return 0 for page 1', () => {
404
+ expect(pageToOffset(1, 10)).toBe(0);
405
+ expect(pageToOffset(1, 50)).toBe(0);
406
+ });
407
+ });
408
+ describe('offsetToPage', () => {
409
+ it('should convert offsets to page numbers', () => {
410
+ expect(offsetToPage(0, 10)).toBe(1);
411
+ expect(offsetToPage(20, 10)).toBe(3);
412
+ expect(offsetToPage(15, 10)).toBe(2); // partial page
413
+ });
414
+ it('should always return 1 for offset 0', () => {
415
+ expect(offsetToPage(0, 10)).toBe(1);
416
+ expect(offsetToPage(0, 25)).toBe(1);
417
+ });
418
+ });
419
+ describe('calculatePaginationFlags', () => {
420
+ it('should calculate pagination flags correctly', () => {
421
+ // Page 1 scenarios
422
+ expect(calculatePaginationFlags(1, true)).toEqual({
423
+ hasNextPage: true,
424
+ hasPreviousPage: false,
425
+ });
426
+ expect(calculatePaginationFlags(1, false)).toEqual({
427
+ hasNextPage: false,
428
+ hasPreviousPage: false,
429
+ });
430
+ // Page 2+ scenarios
431
+ expect(calculatePaginationFlags(2, true)).toEqual({
432
+ hasNextPage: true,
433
+ hasPreviousPage: true,
434
+ });
435
+ expect(calculatePaginationFlags(2, false)).toEqual({
436
+ hasNextPage: false,
437
+ hasPreviousPage: true,
438
+ });
439
+ });
440
+ });
441
+ });
396
442
  });
@@ -1,5 +1,6 @@
1
1
  import { renderHook, waitFor } from '@testing-library/react';
2
2
  import { useAssociations } from '../../../experimental/hooks/useAssociations';
3
+ import { fetchAssociations } from '../../../experimental/crm/fetchAssociations';
3
4
  // Mock the logger module
4
5
  jest.mock('../../../logger', () => ({
5
6
  logger: {
@@ -9,18 +10,22 @@ jest.mock('../../../logger', () => ({
9
10
  error: jest.fn(),
10
11
  },
11
12
  }));
12
- // Mock the fetchAssociations module
13
- jest.mock('../../../experimental/crm/fetchAssociations');
14
- const mockFetchAssociations = jest.fn();
15
- import * as fetchAssociationsModule from '../../../experimental/crm/fetchAssociations';
16
- fetchAssociationsModule.fetchAssociations = mockFetchAssociations;
17
- describe('useAssociations', () => {
13
+ // Mock the fetchAssociations function, keep utility functions
14
+ jest.mock('../../../experimental/crm/fetchAssociations', () => ({
15
+ ...jest.requireActual('../../../experimental/crm/fetchAssociations'),
16
+ fetchAssociations: jest.fn(),
17
+ }));
18
+ // Get reference to the mocked function
19
+ const mockFetchAssociations = fetchAssociations;
20
+ describe('useAssociations with Pagination', () => {
18
21
  let originalError;
19
22
  beforeAll(() => {
20
- // Suppress React act() warning coming from @testing-library/react
23
+ // Suppress React act() warnings coming from @testing-library/react
21
24
  originalError = console.error;
22
25
  console.error = (...args) => {
23
- if (args[0]?.includes('ReactDOMTestUtils.act'))
26
+ if (typeof args[0] === 'string' &&
27
+ (args[0].includes('ReactDOMTestUtils.act') ||
28
+ args[0].includes('was not wrapped in act')))
24
29
  return;
25
30
  originalError.call(console, ...args);
26
31
  };
@@ -33,24 +38,50 @@ describe('useAssociations', () => {
33
38
  console.error = originalError;
34
39
  });
35
40
  describe('initial state', () => {
36
- it('should return the initial values for results, error, isLoading, hasMore, and nextOffset', async () => {
41
+ it('should initialize with proper pagination state', async () => {
42
+ mockFetchAssociations.mockResolvedValue({
43
+ data: {
44
+ results: [],
45
+ hasMore: false,
46
+ nextOffset: 0,
47
+ },
48
+ cleanup: jest.fn(),
49
+ });
37
50
  const { result } = renderHook(() => useAssociations({
38
51
  toObjectType: '0-1',
39
52
  properties: ['firstname', 'lastname'],
40
- pageLength: 25,
41
- offset: 0,
53
+ pageLength: 10,
42
54
  }));
43
55
  await waitFor(() => {
44
56
  expect(result.current.results).toEqual([]);
45
57
  expect(result.current.error).toBeNull();
46
- expect(result.current.isLoading).toBe(true);
47
- expect(result.current.hasMore).toBe(false);
48
- expect(result.current.nextOffset).toBe(0);
58
+ expect(result.current.isLoading).toBe(false);
59
+ expect(result.current.pagination.currentPage).toBe(1);
60
+ expect(result.current.pagination.pageSize).toBe(10);
61
+ expect(result.current.pagination.hasNextPage).toBe(false);
62
+ expect(result.current.pagination.hasPreviousPage).toBe(false);
63
+ });
64
+ });
65
+ it('should use default page size when not specified', async () => {
66
+ mockFetchAssociations.mockResolvedValue({
67
+ data: {
68
+ results: [],
69
+ hasMore: false,
70
+ nextOffset: 0,
71
+ },
72
+ cleanup: jest.fn(),
73
+ });
74
+ const { result } = renderHook(() => useAssociations({
75
+ toObjectType: '0-1',
76
+ properties: ['firstname'],
77
+ }));
78
+ await waitFor(() => {
79
+ expect(result.current.pagination.pageSize).toBe(10); // DEFAULT_PAGE_SIZE
49
80
  });
50
81
  });
51
82
  });
52
- describe('successful data fetching', () => {
53
- it('should successfully fetch and return associations', async () => {
83
+ describe('data fetching', () => {
84
+ it('should successfully fetch and return associations with pagination info', async () => {
54
85
  mockFetchAssociations.mockResolvedValue({
55
86
  data: {
56
87
  results: [
@@ -64,57 +95,31 @@ describe('useAssociations', () => {
64
95
  lastname: 'Doe',
65
96
  },
66
97
  },
67
- {
68
- toObjectId: 1002,
69
- associationTypes: [
70
- { category: 'USER_DEFINED', typeId: 100, label: 'Custom' },
71
- ],
72
- properties: {
73
- firstname: 'Jane',
74
- lastname: 'Smith',
75
- },
76
- },
77
98
  ],
78
99
  hasMore: true,
79
- nextOffset: 25,
100
+ nextOffset: 10,
80
101
  },
81
102
  cleanup: jest.fn(),
82
103
  });
83
- const request = {
104
+ const { result } = renderHook(() => useAssociations({
84
105
  toObjectType: '0-1',
85
106
  properties: ['firstname', 'lastname'],
86
- pageLength: 25,
87
- offset: 0,
88
- };
89
- const { result } = renderHook(() => useAssociations(request));
107
+ pageLength: 10,
108
+ }));
90
109
  await waitFor(() => {
91
- expect(result.current.results).toEqual([
92
- {
93
- toObjectId: 1001,
94
- associationTypes: [
95
- { category: 'HUBSPOT_DEFINED', typeId: 1, label: 'Primary' },
96
- ],
97
- properties: {
98
- firstname: 'John',
99
- lastname: 'Doe',
100
- },
101
- },
102
- {
103
- toObjectId: 1002,
104
- associationTypes: [
105
- { category: 'USER_DEFINED', typeId: 100, label: 'Custom' },
106
- ],
107
- properties: {
108
- firstname: 'Jane',
109
- lastname: 'Smith',
110
- },
111
- },
112
- ]);
113
- expect(result.current.error).toBeNull();
110
+ expect(result.current.results).toHaveLength(1);
111
+ expect(result.current.results[0].toObjectId).toBe(1001);
112
+ expect(result.current.pagination.hasNextPage).toBe(true);
113
+ expect(result.current.pagination.hasPreviousPage).toBe(false);
114
+ expect(result.current.pagination.currentPage).toBe(1);
114
115
  expect(result.current.isLoading).toBe(false);
115
- expect(result.current.hasMore).toBe(true);
116
- expect(result.current.nextOffset).toBe(25);
117
116
  });
117
+ expect(mockFetchAssociations).toHaveBeenCalledWith(expect.objectContaining({
118
+ toObjectType: '0-1',
119
+ properties: ['firstname', 'lastname'],
120
+ pageLength: 10,
121
+ offset: 0,
122
+ }), expect.any(Object));
118
123
  });
119
124
  it('should handle empty results correctly', async () => {
120
125
  mockFetchAssociations.mockResolvedValue({
@@ -125,136 +130,186 @@ describe('useAssociations', () => {
125
130
  },
126
131
  cleanup: jest.fn(),
127
132
  });
128
- const request = {
133
+ const { result } = renderHook(() => useAssociations({
129
134
  toObjectType: '0-1',
130
135
  properties: ['firstname'],
131
- pageLength: 25,
132
- offset: 0,
133
- };
134
- const { result } = renderHook(() => useAssociations(request));
136
+ pageLength: 5,
137
+ }));
135
138
  await waitFor(() => {
136
139
  expect(result.current.results).toEqual([]);
137
- expect(result.current.hasMore).toBe(false);
138
- expect(result.current.nextOffset).toBe(0);
140
+ expect(result.current.pagination.hasNextPage).toBe(false);
141
+ expect(result.current.pagination.hasPreviousPage).toBe(false);
139
142
  expect(result.current.isLoading).toBe(false);
140
143
  expect(result.current.error).toBeNull();
141
144
  });
142
145
  });
143
- it('should handle different object types correctly', async () => {
144
- mockFetchAssociations.mockResolvedValue({
146
+ });
147
+ describe('pagination actions', () => {
148
+ it('should navigate to next page correctly', async () => {
149
+ // First page response
150
+ mockFetchAssociations.mockResolvedValueOnce({
145
151
  data: {
146
- results: [
147
- {
148
- toObjectId: 2001,
149
- associationTypes: [
150
- {
151
- category: 'HUBSPOT_DEFINED',
152
- typeId: 2,
153
- label: 'Company Primary',
154
- },
155
- ],
156
- properties: {
157
- name: 'Test Company',
158
- },
159
- },
160
- ],
152
+ results: [{ toObjectId: 1, associationTypes: [], properties: {} }],
153
+ hasMore: true,
154
+ nextOffset: 10,
155
+ },
156
+ cleanup: jest.fn(),
157
+ });
158
+ // Second page response
159
+ mockFetchAssociations.mockResolvedValueOnce({
160
+ data: {
161
+ results: [{ toObjectId: 2, associationTypes: [], properties: {} }],
161
162
  hasMore: false,
162
- nextOffset: 25,
163
+ nextOffset: 20,
163
164
  },
164
165
  cleanup: jest.fn(),
165
166
  });
166
- const request = {
167
- toObjectType: '0-2',
168
- properties: ['name'],
169
- pageLength: 25,
170
- offset: 0,
171
- };
172
- const { result } = renderHook(() => useAssociations(request));
167
+ const { result } = renderHook(() => useAssociations({
168
+ toObjectType: '0-1',
169
+ pageLength: 10,
170
+ }));
171
+ // Wait for initial load
173
172
  await waitFor(() => {
174
- expect(result.current.results).toEqual([
175
- {
176
- toObjectId: 2001,
177
- associationTypes: [
178
- {
179
- category: 'HUBSPOT_DEFINED',
180
- typeId: 2,
181
- label: 'Company Primary',
182
- },
183
- ],
184
- properties: {
185
- name: 'Test Company',
186
- },
187
- },
188
- ]);
189
- expect(result.current.isLoading).toBe(false);
173
+ expect(result.current.pagination.currentPage).toBe(1);
174
+ expect(result.current.pagination.hasNextPage).toBe(true);
190
175
  });
191
- expect(mockFetchAssociations).toHaveBeenCalledWith(expect.objectContaining({
192
- toObjectType: '0-2',
193
- }), {});
176
+ // Navigate to next page
177
+ result.current.pagination.nextPage();
178
+ await waitFor(() => {
179
+ expect(result.current.pagination.currentPage).toBe(2);
180
+ expect(result.current.pagination.hasNextPage).toBe(false);
181
+ expect(result.current.pagination.hasPreviousPage).toBe(true);
182
+ });
183
+ // Verify API was called with correct offset for page 2
184
+ expect(mockFetchAssociations).toHaveBeenLastCalledWith(expect.objectContaining({
185
+ offset: 10, // (page 2 - 1) * pageSize 10 = 10
186
+ }), expect.any(Object));
194
187
  });
195
- });
196
- describe('error handling', () => {
197
- it('should handle fetch errors correctly', async () => {
198
- const errorMessage = 'Failed to fetch associations';
199
- mockFetchAssociations.mockRejectedValue(new Error(errorMessage));
200
- const request = {
188
+ it('should navigate to previous page correctly', async () => {
189
+ // First call: page 1 with hasMore: true (allows next page)
190
+ mockFetchAssociations.mockResolvedValueOnce({
191
+ data: {
192
+ results: [{ toObjectId: 1, associationTypes: [], properties: {} }],
193
+ hasMore: true,
194
+ nextOffset: 10,
195
+ },
196
+ cleanup: jest.fn(),
197
+ });
198
+ // Second call: page 2 with hasMore: false
199
+ mockFetchAssociations.mockResolvedValueOnce({
200
+ data: {
201
+ results: [{ toObjectId: 2, associationTypes: [], properties: {} }],
202
+ hasMore: false,
203
+ nextOffset: 20,
204
+ },
205
+ cleanup: jest.fn(),
206
+ });
207
+ // Third call: back to page 1
208
+ mockFetchAssociations.mockResolvedValueOnce({
209
+ data: {
210
+ results: [{ toObjectId: 1, associationTypes: [], properties: {} }],
211
+ hasMore: true,
212
+ nextOffset: 10,
213
+ },
214
+ cleanup: jest.fn(),
215
+ });
216
+ const { result } = renderHook(() => useAssociations({
201
217
  toObjectType: '0-1',
202
- properties: ['firstname'],
203
- pageLength: 25,
204
- offset: 0,
205
- };
206
- const { result } = renderHook(() => useAssociations(request));
218
+ pageLength: 10,
219
+ }));
220
+ // Wait for initial load (page 1)
207
221
  await waitFor(() => {
208
- expect(result.current.error).toBeInstanceOf(Error);
209
- expect(result.current.error?.message).toBe(errorMessage);
210
- expect(result.current.results).toEqual([]);
211
- expect(result.current.isLoading).toBe(false);
212
- expect(result.current.hasMore).toBe(false);
213
- expect(result.current.nextOffset).toBe(0);
222
+ expect(result.current.pagination.currentPage).toBe(1);
223
+ expect(result.current.pagination.hasNextPage).toBe(true);
224
+ });
225
+ // Go to page 2
226
+ result.current.pagination.nextPage();
227
+ await waitFor(() => {
228
+ expect(result.current.pagination.currentPage).toBe(2);
229
+ expect(result.current.pagination.hasPreviousPage).toBe(true);
230
+ });
231
+ // Go back to previous page
232
+ result.current.pagination.previousPage();
233
+ await waitFor(() => {
234
+ expect(result.current.pagination.currentPage).toBe(1);
235
+ expect(result.current.pagination.hasPreviousPage).toBe(false);
214
236
  });
215
237
  });
216
- });
217
- describe('pagination handling', () => {
218
- it('should handle pagination correctly', async () => {
238
+ it('should reset to first page correctly', async () => {
239
+ // Initial page 1 - has more pages
219
240
  mockFetchAssociations.mockResolvedValue({
220
241
  data: {
221
- results: [
222
- {
223
- toObjectId: 1025,
224
- associationTypes: [
225
- { category: 'HUBSPOT_DEFINED', typeId: 1, label: 'Primary' },
226
- ],
227
- properties: {
228
- firstname: 'Page 2 User',
229
- },
230
- },
231
- ],
242
+ results: [],
243
+ hasMore: true,
244
+ nextOffset: 10,
245
+ },
246
+ cleanup: jest.fn(),
247
+ });
248
+ const { result } = renderHook(() => useAssociations({
249
+ toObjectType: '0-1',
250
+ pageLength: 10,
251
+ }));
252
+ // Wait for initial load
253
+ await waitFor(() => {
254
+ expect(result.current.pagination.currentPage).toBe(1);
255
+ expect(result.current.pagination.hasNextPage).toBe(true);
256
+ });
257
+ // Navigate to page 2, then page 3
258
+ result.current.pagination.nextPage();
259
+ await waitFor(() => {
260
+ expect(result.current.pagination.currentPage).toBe(2);
261
+ });
262
+ result.current.pagination.nextPage();
263
+ await waitFor(() => {
264
+ expect(result.current.pagination.currentPage).toBe(3);
265
+ });
266
+ // Reset to first page
267
+ result.current.pagination.reset();
268
+ await waitFor(() => {
269
+ expect(result.current.pagination.currentPage).toBe(1);
270
+ expect(result.current.pagination.hasPreviousPage).toBe(false);
271
+ });
272
+ });
273
+ it('should not allow navigation beyond boundaries', async () => {
274
+ mockFetchAssociations.mockResolvedValue({
275
+ data: {
276
+ results: [],
232
277
  hasMore: false,
233
- nextOffset: 50,
278
+ nextOffset: 10,
234
279
  },
235
280
  cleanup: jest.fn(),
236
281
  });
237
- const request = {
282
+ const { result } = renderHook(() => useAssociations({
283
+ toObjectType: '0-1',
284
+ pageLength: 10,
285
+ }));
286
+ await waitFor(() => {
287
+ expect(result.current.pagination.currentPage).toBe(1);
288
+ expect(result.current.pagination.hasNextPage).toBe(false);
289
+ });
290
+ // Try to go to next page when there isn't one
291
+ result.current.pagination.nextPage();
292
+ // Should stay on page 1
293
+ expect(result.current.pagination.currentPage).toBe(1);
294
+ // Try to go to previous page from page 1
295
+ result.current.pagination.previousPage();
296
+ // Should stay on page 1
297
+ expect(result.current.pagination.currentPage).toBe(1);
298
+ });
299
+ });
300
+ describe('error handling', () => {
301
+ it('should handle fetch errors correctly', async () => {
302
+ const errorMessage = 'Failed to fetch associations';
303
+ mockFetchAssociations.mockRejectedValue(new Error(errorMessage));
304
+ const { result } = renderHook(() => useAssociations({
238
305
  toObjectType: '0-1',
239
306
  properties: ['firstname'],
240
- pageLength: 25,
241
- offset: 25, // Second page
242
- };
243
- const { result } = renderHook(() => useAssociations(request));
307
+ pageLength: 10,
308
+ }));
244
309
  await waitFor(() => {
245
- expect(result.current.results).toEqual([
246
- {
247
- toObjectId: 1025,
248
- associationTypes: [
249
- { category: 'HUBSPOT_DEFINED', typeId: 1, label: 'Primary' },
250
- ],
251
- properties: {
252
- firstname: 'Page 2 User',
253
- },
254
- },
255
- ]);
256
- expect(result.current.hasMore).toBe(false);
257
- expect(result.current.nextOffset).toBe(50);
310
+ expect(result.current.error).toBeInstanceOf(Error);
311
+ expect(result.current.error?.message).toBe(errorMessage);
312
+ expect(result.current.results).toEqual([]);
258
313
  expect(result.current.isLoading).toBe(false);
259
314
  });
260
315
  });
@@ -269,11 +324,10 @@ describe('useAssociations', () => {
269
324
  },
270
325
  cleanup: jest.fn(),
271
326
  });
272
- const request = {
327
+ const config = {
273
328
  toObjectType: '0-1',
274
329
  properties: ['firstname', 'lastname'],
275
- pageLength: 25,
276
- offset: 0,
330
+ pageLength: 10,
277
331
  };
278
332
  const options = {
279
333
  propertiesToFormat: ['firstname'],
@@ -284,9 +338,9 @@ describe('useAssociations', () => {
284
338
  },
285
339
  },
286
340
  };
287
- renderHook(() => useAssociations(request, options));
341
+ renderHook(() => useAssociations(config, options));
288
342
  await waitFor(() => {
289
- expect(mockFetchAssociations).toHaveBeenCalledWith(request, options);
343
+ expect(mockFetchAssociations).toHaveBeenCalledWith(expect.objectContaining(config), options);
290
344
  });
291
345
  });
292
346
  it('should use default empty options when no options provided', async () => {
@@ -298,15 +352,14 @@ describe('useAssociations', () => {
298
352
  },
299
353
  cleanup: jest.fn(),
300
354
  });
301
- const request = {
355
+ const config = {
302
356
  toObjectType: '0-1',
303
357
  properties: ['firstname'],
304
- pageLength: 25,
305
- offset: 0,
358
+ pageLength: 10,
306
359
  };
307
- renderHook(() => useAssociations(request));
360
+ renderHook(() => useAssociations(config));
308
361
  await waitFor(() => {
309
- expect(mockFetchAssociations).toHaveBeenCalledWith(request, {});
362
+ expect(mockFetchAssociations).toHaveBeenCalledWith(expect.objectContaining(config), {});
310
363
  });
311
364
  });
312
365
  });
@@ -321,13 +374,12 @@ describe('useAssociations', () => {
321
374
  },
322
375
  cleanup: mockCleanup,
323
376
  });
324
- const request = {
377
+ const config = {
325
378
  toObjectType: '0-1',
326
379
  properties: ['firstname'],
327
- pageLength: 25,
328
- offset: 0,
380
+ pageLength: 10,
329
381
  };
330
- const { unmount } = renderHook(() => useAssociations(request));
382
+ const { unmount } = renderHook(() => useAssociations(config));
331
383
  await waitFor(() => {
332
384
  expect(mockFetchAssociations).toHaveBeenCalled();
333
385
  });
@@ -336,7 +388,7 @@ describe('useAssociations', () => {
336
388
  expect(mockCleanup).toHaveBeenCalled();
337
389
  });
338
390
  });
339
- it('should handle stable reference optimization for request', async () => {
391
+ it('should handle stable reference optimization for config', async () => {
340
392
  mockFetchAssociations.mockResolvedValue({
341
393
  data: {
342
394
  results: [],
@@ -345,20 +397,19 @@ describe('useAssociations', () => {
345
397
  },
346
398
  cleanup: jest.fn(),
347
399
  });
348
- const request = {
400
+ const config = {
349
401
  toObjectType: '0-1',
350
402
  properties: ['firstname'],
351
- pageLength: 25,
352
- offset: 0,
403
+ pageLength: 10,
353
404
  };
354
- const { rerender } = renderHook(({ requestProp }) => useAssociations(requestProp), {
355
- initialProps: { requestProp: request },
405
+ const { rerender } = renderHook(({ configProp }) => useAssociations(configProp), {
406
+ initialProps: { configProp: config },
356
407
  });
357
408
  await waitFor(() => {
358
409
  expect(mockFetchAssociations).toHaveBeenCalledTimes(1);
359
410
  });
360
- // Rerender with the same request object content but different reference
361
- rerender({ requestProp: { ...request } });
411
+ // Rerender with the same config object content but different reference
412
+ rerender({ configProp: { ...config } });
362
413
  await waitFor(() => {
363
414
  // Should not call fetchAssociations again due to stable reference optimization
364
415
  expect(mockFetchAssociations).toHaveBeenCalledTimes(1);
@@ -794,3 +794,15 @@ export declare const SearchInput: "SearchInput" & {
794
794
  readonly props?: types.SearchInputProps | undefined;
795
795
  readonly children?: true | undefined;
796
796
  } & import("@remote-ui/react").ReactComponentTypeFromRemoteComponentType<import("@remote-ui/types").RemoteComponentType<"SearchInput", types.SearchInputProps, true>>;
797
+ /**
798
+ * The `TimeInput` component renders an input field where a user can select a time. Commonly used within the `Form` component.
799
+ *
800
+ * **Links:**
801
+ *
802
+ * - {@link https://developers.hubspot.com/docs/reference/ui-components/standard-components/time-input Docs}
803
+ */
804
+ export declare const TimeInput: "TimeInput" & {
805
+ readonly type?: "TimeInput" | undefined;
806
+ readonly props?: types.TimeInputProps | undefined;
807
+ readonly children?: true | undefined;
808
+ } & import("@remote-ui/react").ReactComponentTypeFromRemoteComponentType<import("@remote-ui/types").RemoteComponentType<"TimeInput", types.TimeInputProps, true>>;
@@ -534,3 +534,11 @@ export const Tooltip = createRemoteReactComponent('Tooltip');
534
534
  * - {@link https://developers.hubspot.com/docs/reference/ui-components/standard-components/search-input SearchInput Docs}
535
535
  */
536
536
  export const SearchInput = createRemoteReactComponent('SearchInput');
537
+ /**
538
+ * The `TimeInput` component renders an input field where a user can select a time. Commonly used within the `Form` component.
539
+ *
540
+ * **Links:**
541
+ *
542
+ * - {@link https://developers.hubspot.com/docs/reference/ui-components/standard-components/time-input Docs}
543
+ */
544
+ export const TimeInput = createRemoteReactComponent('TimeInput');
@@ -1,4 +1,20 @@
1
1
  import { FetchCrmPropertiesOptions } from './fetchCrmProperties';
2
+ export declare const DEFAULT_PAGE_SIZE = 10;
3
+ /**
4
+ * Convert page number and page size to offset for API calls
5
+ */
6
+ export declare function pageToOffset(page: number, pageSize: number): number;
7
+ /**
8
+ * Convert offset and page size to page number
9
+ */
10
+ export declare function offsetToPage(offset: number, pageSize: number): number;
11
+ /**
12
+ * Calculate pagination flags based on current page and API hasMore flag
13
+ */
14
+ export declare function calculatePaginationFlags(currentPage: number, hasMore: boolean): {
15
+ hasNextPage: boolean;
16
+ hasPreviousPage: boolean;
17
+ };
2
18
  export type AssociationResult = {
3
19
  toObjectId: number;
4
20
  associationTypes: {
@@ -1,3 +1,25 @@
1
+ export const DEFAULT_PAGE_SIZE = 10;
2
+ /**
3
+ * Convert page number and page size to offset for API calls
4
+ */
5
+ export function pageToOffset(page, pageSize) {
6
+ return (page - 1) * pageSize;
7
+ }
8
+ /**
9
+ * Convert offset and page size to page number
10
+ */
11
+ export function offsetToPage(offset, pageSize) {
12
+ return Math.floor(offset / pageSize) + 1;
13
+ }
14
+ /**
15
+ * Calculate pagination flags based on current page and API hasMore flag
16
+ */
17
+ export function calculatePaginationFlags(currentPage, hasMore) {
18
+ return {
19
+ hasNextPage: hasMore,
20
+ hasPreviousPage: currentPage > 1,
21
+ };
22
+ }
1
23
  function isAssociationsResponse(data) {
2
24
  if (data === null ||
3
25
  typeof data !== 'object' ||
@@ -1,10 +1,22 @@
1
- import { type FetchAssociationsRequest, type AssociationsResponse } from '../crm/fetchAssociations';
1
+ import { type FetchAssociationsRequest, type AssociationResult } from '../crm/fetchAssociations';
2
2
  import { type FetchCrmPropertiesOptions } from '../crm/fetchCrmProperties';
3
- export interface AssociationsState {
4
- results: AssociationsResponse['results'];
3
+ export interface UseAssociationsOptions {
4
+ propertiesToFormat?: 'all' | string[];
5
+ formattingOptions?: FetchCrmPropertiesOptions['formattingOptions'];
6
+ }
7
+ export interface UseAssociationsPagination {
8
+ hasNextPage: boolean;
9
+ hasPreviousPage: boolean;
10
+ currentPage: number;
11
+ pageSize: number;
12
+ nextPage: () => void;
13
+ previousPage: () => void;
14
+ reset: () => void;
15
+ }
16
+ export interface UseAssociationsResult {
17
+ results: AssociationResult[];
5
18
  error: Error | null;
6
19
  isLoading: boolean;
7
- hasMore: boolean;
8
- nextOffset: number;
20
+ pagination: UseAssociationsPagination;
9
21
  }
10
- export declare function useAssociations(request: FetchAssociationsRequest, options?: FetchCrmPropertiesOptions): AssociationsState;
22
+ export declare function useAssociations(config: Omit<FetchAssociationsRequest, 'offset'>, options?: UseAssociationsOptions): UseAssociationsResult;
@@ -1,13 +1,16 @@
1
- import { useEffect, useReducer, useMemo, useRef } from 'react';
1
+ import { useEffect, useReducer, useMemo, useRef, useCallback } from 'react';
2
2
  import { logger } from '../../logger';
3
- import { fetchAssociations, } from '../crm/fetchAssociations';
4
- const initialState = {
5
- results: [],
6
- error: null,
7
- isLoading: true,
8
- hasMore: false,
9
- nextOffset: 0,
10
- };
3
+ import { fetchAssociations, DEFAULT_PAGE_SIZE, pageToOffset, calculatePaginationFlags, } from '../crm/fetchAssociations';
4
+ function createInitialState(pageSize) {
5
+ return {
6
+ results: [],
7
+ error: null,
8
+ isLoading: true,
9
+ currentPage: 1,
10
+ pageSize,
11
+ hasMore: false,
12
+ };
13
+ }
11
14
  function associationsReducer(state, action) {
12
15
  switch (action.type) {
13
16
  case 'FETCH_START':
@@ -22,7 +25,6 @@ function associationsReducer(state, action) {
22
25
  isLoading: false,
23
26
  results: action.payload.results,
24
27
  hasMore: action.payload.hasMore,
25
- nextOffset: action.payload.nextOffset,
26
28
  error: null,
27
29
  };
28
30
  case 'FETCH_ERROR':
@@ -32,15 +34,33 @@ function associationsReducer(state, action) {
32
34
  error: action.payload,
33
35
  results: [],
34
36
  hasMore: false,
35
- nextOffset: 0,
37
+ };
38
+ case 'NEXT_PAGE':
39
+ return {
40
+ ...state,
41
+ currentPage: state.currentPage + 1,
42
+ };
43
+ case 'PREVIOUS_PAGE':
44
+ return {
45
+ ...state,
46
+ currentPage: Math.max(1, state.currentPage - 1),
47
+ };
48
+ case 'RESET':
49
+ return {
50
+ ...state,
51
+ currentPage: 1,
52
+ results: [],
53
+ hasMore: false,
54
+ error: null,
36
55
  };
37
56
  default:
38
57
  return state;
39
58
  }
40
59
  }
41
60
  const DEFAULT_OPTIONS = {};
42
- export function useAssociations(request, options = DEFAULT_OPTIONS) {
43
- const [state, dispatch] = useReducer(associationsReducer, initialState);
61
+ export function useAssociations(config, options = DEFAULT_OPTIONS) {
62
+ const pageSize = config.pageLength ?? DEFAULT_PAGE_SIZE;
63
+ const [state, dispatch] = useReducer(associationsReducer, useMemo(() => createInitialState(pageSize), [pageSize]));
44
64
  // Log experimental warning once on mount
45
65
  useEffect(() => {
46
66
  logger.warn('useAssociations is an experimental hook and might change or be removed in the future.');
@@ -48,23 +68,23 @@ export function useAssociations(request, options = DEFAULT_OPTIONS) {
48
68
  /**
49
69
  * HOOK OPTIMIZATION:
50
70
  *
51
- * Create stable references for request and options to prevent unnecessary re-renders and API calls.
71
+ * Create stable references for config and options to prevent unnecessary re-renders and API calls.
52
72
  * Then, external developers can pass inline objects without worrying about memoization
53
73
  * We handle the deep equality comparison ourselves, and return the same object reference when content is equivalent.
54
74
  */
55
- const lastRequestRef = useRef();
56
- const lastRequestKeyRef = useRef();
75
+ const lastConfigRef = useRef();
76
+ const lastConfigKeyRef = useRef();
57
77
  const lastOptionsRef = useRef();
58
78
  const lastOptionsKeyRef = useRef();
59
- const stableRequest = useMemo(() => {
60
- const requestKey = JSON.stringify(request);
61
- if (requestKey === lastRequestKeyRef.current) {
62
- return lastRequestRef.current;
79
+ const stableConfig = useMemo(() => {
80
+ const configKey = JSON.stringify(config);
81
+ if (configKey === lastConfigKeyRef.current) {
82
+ return lastConfigRef.current;
63
83
  }
64
- lastRequestKeyRef.current = requestKey;
65
- lastRequestRef.current = request;
66
- return request;
67
- }, [request]);
84
+ lastConfigKeyRef.current = configKey;
85
+ lastConfigRef.current = config;
86
+ return config;
87
+ }, [config]);
68
88
  const stableOptions = useMemo(() => {
69
89
  const optionsKey = JSON.stringify(options);
70
90
  if (optionsKey === lastOptionsKeyRef.current) {
@@ -74,6 +94,22 @@ export function useAssociations(request, options = DEFAULT_OPTIONS) {
74
94
  lastOptionsRef.current = options;
75
95
  return options;
76
96
  }, [options]);
97
+ // Pagination actions
98
+ const nextPage = useCallback(() => {
99
+ const paginationFlags = calculatePaginationFlags(state.currentPage, state.hasMore);
100
+ if (paginationFlags.hasNextPage) {
101
+ dispatch({ type: 'NEXT_PAGE' });
102
+ }
103
+ }, [state.currentPage, state.hasMore]);
104
+ const previousPage = useCallback(() => {
105
+ const paginationFlags = calculatePaginationFlags(state.currentPage, state.hasMore);
106
+ if (paginationFlags.hasPreviousPage) {
107
+ dispatch({ type: 'PREVIOUS_PAGE' });
108
+ }
109
+ }, [state.currentPage, state.hasMore]);
110
+ const reset = useCallback(() => {
111
+ dispatch({ type: 'RESET' });
112
+ }, []);
77
113
  // Fetch the associations
78
114
  useEffect(() => {
79
115
  let cancelled = false;
@@ -81,14 +117,23 @@ export function useAssociations(request, options = DEFAULT_OPTIONS) {
81
117
  const fetchData = async () => {
82
118
  try {
83
119
  dispatch({ type: 'FETCH_START' });
84
- const result = await fetchAssociations(stableRequest, stableOptions);
120
+ // Build request using current page and pagination utilities
121
+ const request = {
122
+ toObjectType: stableConfig.toObjectType,
123
+ properties: stableConfig.properties,
124
+ pageLength: pageSize,
125
+ offset: pageToOffset(state.currentPage, pageSize),
126
+ };
127
+ const result = await fetchAssociations(request, {
128
+ propertiesToFormat: stableOptions.propertiesToFormat,
129
+ formattingOptions: stableOptions.formattingOptions,
130
+ });
85
131
  if (!cancelled) {
86
132
  dispatch({
87
133
  type: 'FETCH_SUCCESS',
88
134
  payload: {
89
135
  results: result.data.results,
90
136
  hasMore: result.data.hasMore,
91
- nextOffset: result.data.nextOffset,
92
137
  },
93
138
  });
94
139
  cleanup = result.cleanup;
@@ -111,6 +156,21 @@ export function useAssociations(request, options = DEFAULT_OPTIONS) {
111
156
  cleanup();
112
157
  }
113
158
  };
114
- }, [stableRequest, stableOptions]);
115
- return state;
159
+ }, [stableConfig, stableOptions, state.currentPage, pageSize]);
160
+ // Calculate pagination flags
161
+ const paginationFlags = calculatePaginationFlags(state.currentPage, state.hasMore);
162
+ return {
163
+ results: state.results,
164
+ error: state.error,
165
+ isLoading: state.isLoading,
166
+ pagination: {
167
+ hasNextPage: paginationFlags.hasNextPage,
168
+ hasPreviousPage: paginationFlags.hasPreviousPage,
169
+ currentPage: state.currentPage,
170
+ pageSize: state.pageSize,
171
+ nextPage,
172
+ previousPage,
173
+ reset,
174
+ },
175
+ };
116
176
  }
@@ -84,14 +84,6 @@ declare const FileInput: "FileInput" & {
84
84
  readonly props?: experimentalTypes.FileInputProps | undefined;
85
85
  readonly children?: true | undefined;
86
86
  } & import("@remote-ui/react").ReactComponentTypeFromRemoteComponentType<import("@remote-ui/types").RemoteComponentType<"FileInput", experimentalTypes.FileInputProps, true>>;
87
- /**
88
- * The TimeInput component renders an input field where a user can select a time.
89
- */
90
- declare const TimeInput: "TimeInput" & {
91
- readonly type?: "TimeInput" | undefined;
92
- readonly props?: experimentalTypes.TimeInputProps | undefined;
93
- readonly children?: true | undefined;
94
- } & import("@remote-ui/react").ReactComponentTypeFromRemoteComponentType<import("@remote-ui/types").RemoteComponentType<"TimeInput", experimentalTypes.TimeInputProps, true>>;
95
87
  /** @experimental This component is experimental. Avoid using it in production due to potential breaking changes. Your feedback is valuable for improvements. Stay tuned for updates. */
96
88
  declare const CurrencyInput: "CurrencyInput" & {
97
89
  readonly type?: "CurrencyInput" | undefined;
@@ -116,4 +108,4 @@ declare const SecondaryHeaderActionButton: "SecondaryHeaderActionButton" & {
116
108
  readonly props?: experimentalTypes.HeaderActionButtonProps | undefined;
117
109
  readonly children?: true | undefined;
118
110
  } & import("@remote-ui/react").ReactComponentTypeFromRemoteComponentType<import("@remote-ui/types").RemoteComponentType<"SecondaryHeaderActionButton", experimentalTypes.HeaderActionButtonProps, true>>;
119
- export { Iframe, MediaObject, Inline, Stack2, Center, SimpleGrid, GridItem, Grid, SettingsView, ExpandableText, Popover, FileInput, TimeInput, CurrencyInput, HeaderActions, PrimaryHeaderActionButton, SecondaryHeaderActionButton, };
111
+ export { Iframe, MediaObject, Inline, Stack2, Center, SimpleGrid, GridItem, Grid, SettingsView, ExpandableText, Popover, FileInput, CurrencyInput, HeaderActions, PrimaryHeaderActionButton, SecondaryHeaderActionButton, };
@@ -37,10 +37,6 @@ const ExpandableText = createRemoteReactComponent('ExpandableText');
37
37
  */
38
38
  const Popover = createRemoteReactComponent('Popover');
39
39
  const FileInput = createRemoteReactComponent('FileInput');
40
- /**
41
- * The TimeInput component renders an input field where a user can select a time.
42
- */
43
- const TimeInput = createRemoteReactComponent('TimeInput');
44
40
  /** @experimental This component is experimental. Avoid using it in production due to potential breaking changes. Your feedback is valuable for improvements. Stay tuned for updates. */
45
41
  const CurrencyInput = createRemoteReactComponent('CurrencyInput');
46
42
  /** @experimental This component is experimental. Avoid using it in production due to potential breaking changes. Your feedback is valuable for improvements. Stay tuned for updates. */
@@ -53,4 +49,4 @@ const PrimaryHeaderActionButton = createRemoteReactComponent('PrimaryHeaderActio
53
49
  const SecondaryHeaderActionButton = createRemoteReactComponent('SecondaryHeaderActionButton', {
54
50
  fragmentProps: ['overlay'],
55
51
  });
56
- export { Iframe, MediaObject, Inline, Stack2, Center, SimpleGrid, GridItem, Grid, SettingsView, ExpandableText, Popover, FileInput, TimeInput, CurrencyInput, HeaderActions, PrimaryHeaderActionButton, SecondaryHeaderActionButton, };
52
+ export { Iframe, MediaObject, Inline, Stack2, Center, SimpleGrid, GridItem, Grid, SettingsView, ExpandableText, Popover, FileInput, CurrencyInput, HeaderActions, PrimaryHeaderActionButton, SecondaryHeaderActionButton, };
@@ -168,16 +168,6 @@ export interface FileInputProps {
168
168
  name: string;
169
169
  onChange: (event: any) => void;
170
170
  }
171
- /**
172
- * @ignore
173
- * @experimental do not use in production
174
- */
175
- export interface BaseTime {
176
- /** The hour for the time (0 to 23) in 24-hour format (e.g. 0 = 12:00 AM, 9 = 9:00 AM, 15 = 3:00 PM). */
177
- hours: number;
178
- /** The minutes for the time (0 to 59). */
179
- minutes: number;
180
- }
181
171
  /**
182
172
  * Generic collection of props for all inputs (experimental version)
183
173
  * @internal
@@ -258,50 +248,6 @@ export interface BaseInputProps<T = string, V = string> {
258
248
  */
259
249
  onFocus?: (value: V) => void;
260
250
  }
261
- /**
262
- * @ignore
263
- * @experimental do not use in production
264
- *
265
- * The values used to invoke events on the TimeInput component
266
- */
267
- export interface TimeInputEventsPayload extends BaseTime {
268
- }
269
- /**
270
- * @internal
271
- * @ignore
272
- * */
273
- type BaseTimeInputForTime = Omit<BaseInputProps<BaseTime | null, TimeInputEventsPayload>, 'onInput' | 'placeholder' | 'onChange'>;
274
- /**
275
- * @ignore
276
- * @experimental do not use in production
277
- */
278
- export interface TimeInputProps extends BaseTimeInputForTime {
279
- /**
280
- * A callback function that is invoked when the value is changed.
281
- *
282
- * @event
283
- */
284
- onChange?: (value: TimeInputEventsPayload) => void;
285
- /**
286
- * Sets the earliest time that will be valid.
287
- */
288
- min?: BaseTime;
289
- /**
290
- * Sets the latest time that will be valid.
291
- */
292
- max?: BaseTime;
293
- /**
294
- * Sets the interval (in minutes) between the dropdown options.
295
- *
296
- * @defaultValue `30`
297
- */
298
- interval?: number;
299
- /**
300
- * Sets the timezone that the component will display alongside times in the TimePicker. This will not adjust the available valid inputs.
301
- *
302
- */
303
- timezone?: 'userTz' | 'portalTz';
304
- }
305
251
  /**
306
252
  * @ignore
307
253
  * @experimental do not use in production
package/dist/types.d.ts CHANGED
@@ -1186,6 +1186,59 @@ export interface DateInputProps extends BaseDateInputForDate {
1186
1186
  */
1187
1187
  todayButtonLabel?: string;
1188
1188
  }
1189
+ /**
1190
+ * Object that represents times.
1191
+ */
1192
+ export interface BaseTime {
1193
+ /** The hour for the time (0 to 23) in 24-hour format (e.g. 0 = 12:00 AM, 9 = 9:00 AM, 15 = 3:00 PM). */
1194
+ hours: number;
1195
+ /** The minutes for the time (0 to 59). */
1196
+ minutes: number;
1197
+ }
1198
+ /**
1199
+ * @ignore
1200
+ *
1201
+ * The values used to invoke events on the TimeInput component
1202
+ */
1203
+ export interface TimeInputEventsPayload extends BaseTime {
1204
+ }
1205
+ /**
1206
+ * @internal
1207
+ * @ignore
1208
+ * */
1209
+ type BaseTimeInputForTime = Omit<BaseInputProps<BaseTime | null, TimeInputEventsPayload>, 'onInput' | 'placeholder' | 'onChange'>;
1210
+ /**
1211
+ * The props type for {@link !components.TimeInput}.
1212
+ *
1213
+ * @category Component Props
1214
+ */
1215
+ export interface TimeInputProps extends BaseTimeInputForTime {
1216
+ /**
1217
+ * A callback function that is invoked when the value is changed.
1218
+ *
1219
+ * @event
1220
+ */
1221
+ onChange?: (value: TimeInputEventsPayload) => void;
1222
+ /**
1223
+ * Sets the earliest time that will be valid.
1224
+ */
1225
+ min?: BaseTime;
1226
+ /**
1227
+ * Sets the latest time that will be valid.
1228
+ */
1229
+ max?: BaseTime;
1230
+ /**
1231
+ * Sets the interval (in minutes) between the dropdown options.
1232
+ *
1233
+ * @defaultValue `30`
1234
+ */
1235
+ interval?: number;
1236
+ /**
1237
+ * Sets the timezone that the component will display alongside times in the TimeInput. This will not modify the available valid inputs.
1238
+ *
1239
+ */
1240
+ timezone?: 'userTz' | 'portalTz';
1241
+ }
1189
1242
  /**
1190
1243
  * The props type for {@link !components.SearchInput}.
1191
1244
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hubspot/ui-extensions",
3
- "version": "0.9.4",
3
+ "version": "0.9.5",
4
4
  "description": "",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -66,5 +66,5 @@
66
66
  "ts-jest": "^29.1.1",
67
67
  "typescript": "5.0.4"
68
68
  },
69
- "gitHead": "65046f5f3ce4c51652bc454e4425b57bd33aedbf"
69
+ "gitHead": "8344193a13ab5874924b02d67413dad052558ecf"
70
70
  }