@hubspot/ui-extensions 0.9.3 → 0.9.4

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.
@@ -0,0 +1,396 @@
1
+ // Set up the mock before importing the module
2
+ const mockFetchAssociations = jest.fn();
3
+ const mockSelf = {
4
+ fetchAssociations: mockFetchAssociations,
5
+ };
6
+ // Mock the global self object
7
+ Object.defineProperty(global, 'self', {
8
+ value: mockSelf,
9
+ writable: true,
10
+ });
11
+ import { fetchAssociations } from '../../../experimental/crm/fetchAssociations';
12
+ describe('fetchAssociations', () => {
13
+ // Helper functions
14
+ const createMockResponse = (data, overrides = {}) => ({
15
+ ok: true,
16
+ json: jest.fn().mockResolvedValue({ data, cleanup: jest.fn() }),
17
+ ...overrides,
18
+ });
19
+ const createErrorResponse = (statusText) => ({
20
+ ok: false,
21
+ statusText,
22
+ json: jest.fn().mockResolvedValue({}),
23
+ });
24
+ const expectError = async (request, expectedMessage) => {
25
+ await expect(fetchAssociations(request)).rejects.toThrow(expectedMessage);
26
+ };
27
+ const createBasicRequest = (overrides = {}) => ({
28
+ toObjectType: '0-1',
29
+ properties: ['firstname', 'lastname'],
30
+ ...overrides,
31
+ });
32
+ beforeEach(() => {
33
+ jest.clearAllMocks();
34
+ // Set up dynamic mock that responds to request parameters
35
+ mockFetchAssociations.mockImplementation((request) => {
36
+ const { pageLength = 100, offset = 0, properties = [] } = request;
37
+ // Generate mock properties based on the request
38
+ const mockProperties = {};
39
+ properties.forEach((prop) => {
40
+ mockProperties[prop] = `Mock ${prop} (page ${Math.floor(offset / pageLength) + 1})`;
41
+ });
42
+ const mockResults = [
43
+ {
44
+ toObjectId: 1000 + offset,
45
+ associationTypes: [
46
+ { category: 'HUBSPOT_DEFINED', typeId: 1, label: 'Primary' },
47
+ ],
48
+ properties: mockProperties,
49
+ },
50
+ {
51
+ toObjectId: 1001 + offset,
52
+ associationTypes: [
53
+ {
54
+ category: 'USER_DEFINED',
55
+ typeId: 100,
56
+ label: 'Custom Association',
57
+ },
58
+ ],
59
+ properties: mockProperties,
60
+ },
61
+ ];
62
+ const mockData = {
63
+ results: mockResults,
64
+ hasMore: offset < 200,
65
+ nextOffset: offset + pageLength,
66
+ };
67
+ return Promise.resolve(createMockResponse(mockData));
68
+ });
69
+ });
70
+ describe('basic functionality', () => {
71
+ it('should return mock associations with default parameters', async () => {
72
+ const request = createBasicRequest();
73
+ const result = await fetchAssociations(request);
74
+ expect(result).toHaveProperty('data');
75
+ expect(result).toHaveProperty('cleanup');
76
+ expect(typeof result.cleanup).toBe('function');
77
+ const { data } = result;
78
+ expect(data.results).toHaveLength(2);
79
+ expect(data.hasMore).toBe(true);
80
+ expect(data.nextOffset).toBe(100);
81
+ // Check first association
82
+ expect(data.results[0]).toEqual({
83
+ toObjectId: 1000,
84
+ associationTypes: [
85
+ { category: 'HUBSPOT_DEFINED', typeId: 1, label: 'Primary' },
86
+ ],
87
+ properties: {
88
+ firstname: 'Mock firstname (page 1)',
89
+ lastname: 'Mock lastname (page 1)',
90
+ },
91
+ });
92
+ // Check second association
93
+ expect(data.results[1]).toEqual({
94
+ toObjectId: 1001,
95
+ associationTypes: [
96
+ {
97
+ category: 'USER_DEFINED',
98
+ typeId: 100,
99
+ label: 'Custom Association',
100
+ },
101
+ ],
102
+ properties: {
103
+ firstname: 'Mock firstname (page 1)',
104
+ lastname: 'Mock lastname (page 1)',
105
+ },
106
+ });
107
+ });
108
+ it('should return a cleanup function', async () => {
109
+ const request = createBasicRequest({ properties: ['firstname'] });
110
+ const result = await fetchAssociations(request);
111
+ expect(typeof result.cleanup).toBe('function');
112
+ // Should not throw when called
113
+ expect(() => result.cleanup()).not.toThrow();
114
+ });
115
+ it('should return a Promise', () => {
116
+ const request = createBasicRequest({ properties: ['firstname'] });
117
+ const result = fetchAssociations(request);
118
+ expect(result).toBeInstanceOf(Promise);
119
+ });
120
+ });
121
+ describe('pagination handling', () => {
122
+ it('should handle custom page length and offset', async () => {
123
+ const request = createBasicRequest({
124
+ properties: ['email'],
125
+ pageLength: 25,
126
+ offset: 50,
127
+ });
128
+ const result = await fetchAssociations(request);
129
+ const { data } = result;
130
+ expect(data.results).toHaveLength(2);
131
+ expect(data.nextOffset).toBe(75); // 50 + 25
132
+ expect(data.hasMore).toBe(true);
133
+ // Check that offset is reflected in object IDs
134
+ expect(data.results[0].toObjectId).toBe(1050); // 1000 + 50
135
+ expect(data.results[1].toObjectId).toBe(1051); // 1001 + 50
136
+ // Check that page number is reflected in properties
137
+ expect(data.results[0].properties.email).toBe('Mock email (page 3)'); // Math.floor(50 / 25) + 1 = 3
138
+ });
139
+ it('should calculate hasMore correctly based on offset', async () => {
140
+ // Test when there are more results
141
+ const request1 = createBasicRequest({
142
+ properties: ['firstname'],
143
+ offset: 100,
144
+ });
145
+ const result1 = await fetchAssociations(request1);
146
+ expect(result1.data.hasMore).toBe(true);
147
+ // Test when there are no more results
148
+ const request2 = createBasicRequest({
149
+ properties: ['firstname'],
150
+ offset: 200,
151
+ });
152
+ const result2 = await fetchAssociations(request2);
153
+ expect(result2.data.hasMore).toBe(false);
154
+ // Test edge case at the boundary
155
+ const request3 = createBasicRequest({
156
+ properties: ['firstname'],
157
+ offset: 199,
158
+ });
159
+ const result3 = await fetchAssociations(request3);
160
+ expect(result3.data.hasMore).toBe(true);
161
+ });
162
+ it('should generate different page numbers for different offsets', async () => {
163
+ const request1 = createBasicRequest({
164
+ properties: ['firstname'],
165
+ pageLength: 10,
166
+ offset: 0,
167
+ });
168
+ const result1 = await fetchAssociations(request1);
169
+ expect(result1.data.results[0].properties.firstname).toBe('Mock firstname (page 1)');
170
+ const request2 = createBasicRequest({
171
+ properties: ['firstname'],
172
+ pageLength: 10,
173
+ offset: 20,
174
+ });
175
+ const result2 = await fetchAssociations(request2);
176
+ expect(result2.data.results[0].properties.firstname).toBe('Mock firstname (page 3)'); // Math.floor(20 / 10) + 1 = 3
177
+ });
178
+ });
179
+ describe('properties handling', () => {
180
+ it('should handle empty properties array', async () => {
181
+ const request = createBasicRequest({ properties: [] });
182
+ const result = await fetchAssociations(request);
183
+ const { data } = result;
184
+ expect(data.results).toHaveLength(2);
185
+ expect(data.results[0].properties).toEqual({});
186
+ expect(data.results[1].properties).toEqual({});
187
+ });
188
+ it('should handle missing properties in request', async () => {
189
+ const request = createBasicRequest();
190
+ // Remove properties to test missing case
191
+ delete request.properties;
192
+ const result = await fetchAssociations(request);
193
+ const { data } = result;
194
+ expect(data.results).toHaveLength(2);
195
+ expect(data.results[0].properties).toEqual({});
196
+ expect(data.results[1].properties).toEqual({});
197
+ });
198
+ it('should handle multiple properties correctly', async () => {
199
+ const request = createBasicRequest({
200
+ properties: ['firstname', 'lastname', 'email', 'phone'],
201
+ });
202
+ const result = await fetchAssociations(request);
203
+ const { data } = result;
204
+ expect(data.results[0].properties).toEqual({
205
+ firstname: 'Mock firstname (page 1)',
206
+ lastname: 'Mock lastname (page 1)',
207
+ email: 'Mock email (page 1)',
208
+ phone: 'Mock phone (page 1)',
209
+ });
210
+ });
211
+ it('should handle different object types', async () => {
212
+ const request = createBasicRequest({
213
+ toObjectType: '0-2',
214
+ properties: ['name'],
215
+ });
216
+ const result = await fetchAssociations(request);
217
+ const { data } = result;
218
+ expect(data.results).toHaveLength(2);
219
+ expect(data.results[0].properties.name).toBe('Mock name (page 1)');
220
+ });
221
+ });
222
+ describe('error handling', () => {
223
+ it('should throw an error when response is not OK', async () => {
224
+ mockFetchAssociations.mockResolvedValueOnce(createErrorResponse('Not Found'));
225
+ const request = createBasicRequest({ properties: ['firstname'] });
226
+ await expectError(request, 'Failed to fetch associations: Not Found');
227
+ });
228
+ it('should throw an error when fetch fails', async () => {
229
+ mockFetchAssociations.mockRejectedValueOnce(new Error('Network error'));
230
+ const request = createBasicRequest({ properties: ['firstname'] });
231
+ await expectError(request, 'Network error');
232
+ });
233
+ it('should handle unknown errors', async () => {
234
+ mockFetchAssociations.mockRejectedValueOnce('Unknown error');
235
+ const request = createBasicRequest({ properties: ['firstname'] });
236
+ await expectError(request, 'Failed to fetch associations: Unknown error');
237
+ });
238
+ });
239
+ describe('response validation', () => {
240
+ it('should throw error for invalid response format - missing results', async () => {
241
+ const invalidData = { hasMore: true, nextOffset: 0 }; // missing results
242
+ mockFetchAssociations.mockResolvedValueOnce(createMockResponse(invalidData));
243
+ const request = createBasicRequest({ properties: ['firstname'] });
244
+ await expectError(request, 'Invalid response format');
245
+ });
246
+ it('should throw error for invalid response format - null data', async () => {
247
+ mockFetchAssociations.mockResolvedValueOnce(createMockResponse(null));
248
+ const request = createBasicRequest({ properties: ['firstname'] });
249
+ await expectError(request, 'Invalid response format');
250
+ });
251
+ it('should throw error for invalid association result structure', async () => {
252
+ const invalidData = {
253
+ results: [{ invalidStructure: true }],
254
+ hasMore: false,
255
+ nextOffset: 0,
256
+ };
257
+ mockFetchAssociations.mockResolvedValueOnce(createMockResponse(invalidData));
258
+ const request = createBasicRequest({ properties: ['firstname'] });
259
+ await expectError(request, 'Invalid response format');
260
+ });
261
+ it('should throw error for invalid association types array', async () => {
262
+ const invalidData = {
263
+ results: [
264
+ {
265
+ toObjectId: 1000,
266
+ associationTypes: 'not an array',
267
+ properties: {},
268
+ },
269
+ ],
270
+ hasMore: false,
271
+ nextOffset: 0,
272
+ };
273
+ mockFetchAssociations.mockResolvedValueOnce(createMockResponse(invalidData));
274
+ const request = createBasicRequest({ properties: ['firstname'] });
275
+ await expectError(request, 'Invalid response format');
276
+ });
277
+ it('should throw error for missing hasMore field', async () => {
278
+ const invalidData = {
279
+ results: [],
280
+ nextOffset: 0, // missing hasMore
281
+ };
282
+ mockFetchAssociations.mockResolvedValueOnce(createMockResponse(invalidData));
283
+ const request = createBasicRequest({ properties: ['firstname'] });
284
+ await expectError(request, 'Invalid response format');
285
+ });
286
+ });
287
+ describe('API integration', () => {
288
+ it('should call self.fetchAssociations with correct parameters', async () => {
289
+ const emptyData = { results: [], hasMore: false, nextOffset: 0 };
290
+ mockFetchAssociations.mockResolvedValueOnce(createMockResponse(emptyData));
291
+ const request = createBasicRequest({
292
+ properties: ['firstname'],
293
+ pageLength: 50,
294
+ });
295
+ await fetchAssociations(request);
296
+ expect(mockFetchAssociations).toHaveBeenCalledWith(request, undefined);
297
+ });
298
+ it('should return cleanup function from API response', async () => {
299
+ const mockCleanup = jest.fn();
300
+ const emptyData = { results: [], hasMore: false, nextOffset: 0 };
301
+ const mockApiResponse = { data: emptyData, cleanup: mockCleanup };
302
+ mockFetchAssociations.mockResolvedValueOnce({
303
+ ok: true,
304
+ json: jest.fn().mockResolvedValue(mockApiResponse),
305
+ });
306
+ const result = await fetchAssociations(createBasicRequest());
307
+ expect(result.cleanup).toBe(mockCleanup);
308
+ result.cleanup();
309
+ expect(mockCleanup).toHaveBeenCalledTimes(1);
310
+ });
311
+ it('should provide default cleanup function when none provided', async () => {
312
+ const emptyData = { results: [], hasMore: false, nextOffset: 0 };
313
+ const mockApiResponse = { data: emptyData }; // no cleanup function provided
314
+ mockFetchAssociations.mockResolvedValueOnce({
315
+ ok: true,
316
+ json: jest.fn().mockResolvedValue(mockApiResponse),
317
+ });
318
+ const result = await fetchAssociations(createBasicRequest());
319
+ expect(typeof result.cleanup).toBe('function');
320
+ expect(() => result.cleanup()).not.toThrow();
321
+ });
322
+ it('should successfully fetch associations with valid response', async () => {
323
+ const validData = {
324
+ results: [
325
+ {
326
+ toObjectId: 2000,
327
+ associationTypes: [
328
+ { category: 'HUBSPOT_DEFINED', typeId: 1, label: 'Primary' },
329
+ ],
330
+ properties: {
331
+ firstname: 'John',
332
+ lastname: 'Doe',
333
+ },
334
+ },
335
+ ],
336
+ hasMore: true,
337
+ nextOffset: 100,
338
+ };
339
+ mockFetchAssociations.mockResolvedValueOnce(createMockResponse(validData));
340
+ const request = createBasicRequest();
341
+ const result = await fetchAssociations(request);
342
+ expect(mockFetchAssociations).toHaveBeenCalledWith(request, undefined);
343
+ expect(result.data).toEqual(validData);
344
+ expect(typeof result.cleanup).toBe('function');
345
+ });
346
+ });
347
+ describe('options handling', () => {
348
+ it('should pass formatting options to the underlying fetch function', async () => {
349
+ const emptyData = { results: [], hasMore: false, nextOffset: 0 };
350
+ mockFetchAssociations.mockResolvedValueOnce(createMockResponse(emptyData));
351
+ const options = {
352
+ propertiesToFormat: ['firstname'],
353
+ formattingOptions: {
354
+ date: { format: 'MM/dd/yyyy' },
355
+ currency: { addSymbol: true },
356
+ },
357
+ };
358
+ const request = createBasicRequest();
359
+ await fetchAssociations(request, options);
360
+ expect(mockFetchAssociations).toHaveBeenCalledWith(request, options);
361
+ });
362
+ it('should handle propertiesToFormat set to "all"', async () => {
363
+ const emptyData = { results: [], hasMore: false, nextOffset: 0 };
364
+ mockFetchAssociations.mockResolvedValueOnce(createMockResponse(emptyData));
365
+ const options = {
366
+ propertiesToFormat: 'all',
367
+ formattingOptions: {
368
+ dateTime: { relative: true },
369
+ },
370
+ };
371
+ const request = createBasicRequest();
372
+ await fetchAssociations(request, options);
373
+ expect(mockFetchAssociations).toHaveBeenCalledWith(request, options);
374
+ });
375
+ it('should preserve error handling with formatting options', async () => {
376
+ mockFetchAssociations.mockRejectedValueOnce(new Error('Network error'));
377
+ const options = {
378
+ propertiesToFormat: ['firstname'],
379
+ formattingOptions: {
380
+ date: { format: 'MM/dd/yyyy' },
381
+ },
382
+ };
383
+ const request = createBasicRequest({ properties: ['firstname'] });
384
+ await expect(fetchAssociations(request, options)).rejects.toThrow('Network error');
385
+ expect(mockFetchAssociations).toHaveBeenCalledWith(request, options);
386
+ });
387
+ it('should preserve response validation with formatting options', async () => {
388
+ mockFetchAssociations.mockResolvedValueOnce(createMockResponse('Invalid response')); // data should be an object
389
+ const options = {
390
+ propertiesToFormat: 'all',
391
+ };
392
+ const request = createBasicRequest({ properties: ['firstname'] });
393
+ await expect(fetchAssociations(request, options)).rejects.toThrow('Invalid response format');
394
+ });
395
+ });
396
+ });
@@ -70,6 +70,7 @@ describe('fetchCrmProperties', () => {
70
70
  const mockResponse = {
71
71
  ok: false,
72
72
  statusText: 'Not Found',
73
+ json: jest.fn().mockResolvedValue({}),
73
74
  };
74
75
  mockFetchCrmProperties.mockResolvedValue(mockResponse);
75
76
  const propertyNames = ['firstname'];
@@ -0,0 +1,368 @@
1
+ import { renderHook, waitFor } from '@testing-library/react';
2
+ import { useAssociations } from '../../../experimental/hooks/useAssociations';
3
+ // Mock the logger module
4
+ jest.mock('../../../logger', () => ({
5
+ logger: {
6
+ debug: jest.fn(),
7
+ info: jest.fn(),
8
+ warn: jest.fn(),
9
+ error: jest.fn(),
10
+ },
11
+ }));
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', () => {
18
+ let originalError;
19
+ beforeAll(() => {
20
+ // Suppress React act() warning coming from @testing-library/react
21
+ originalError = console.error;
22
+ console.error = (...args) => {
23
+ if (args[0]?.includes('ReactDOMTestUtils.act'))
24
+ return;
25
+ originalError.call(console, ...args);
26
+ };
27
+ });
28
+ beforeEach(() => {
29
+ // Reset the mock before each test
30
+ mockFetchAssociations.mockReset();
31
+ });
32
+ afterAll(() => {
33
+ console.error = originalError;
34
+ });
35
+ describe('initial state', () => {
36
+ it('should return the initial values for results, error, isLoading, hasMore, and nextOffset', async () => {
37
+ const { result } = renderHook(() => useAssociations({
38
+ toObjectType: '0-1',
39
+ properties: ['firstname', 'lastname'],
40
+ pageLength: 25,
41
+ offset: 0,
42
+ }));
43
+ await waitFor(() => {
44
+ expect(result.current.results).toEqual([]);
45
+ 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);
49
+ });
50
+ });
51
+ });
52
+ describe('successful data fetching', () => {
53
+ it('should successfully fetch and return associations', async () => {
54
+ mockFetchAssociations.mockResolvedValue({
55
+ data: {
56
+ results: [
57
+ {
58
+ toObjectId: 1001,
59
+ associationTypes: [
60
+ { category: 'HUBSPOT_DEFINED', typeId: 1, label: 'Primary' },
61
+ ],
62
+ properties: {
63
+ firstname: 'John',
64
+ lastname: 'Doe',
65
+ },
66
+ },
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
+ ],
78
+ hasMore: true,
79
+ nextOffset: 25,
80
+ },
81
+ cleanup: jest.fn(),
82
+ });
83
+ const request = {
84
+ toObjectType: '0-1',
85
+ properties: ['firstname', 'lastname'],
86
+ pageLength: 25,
87
+ offset: 0,
88
+ };
89
+ const { result } = renderHook(() => useAssociations(request));
90
+ 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();
114
+ expect(result.current.isLoading).toBe(false);
115
+ expect(result.current.hasMore).toBe(true);
116
+ expect(result.current.nextOffset).toBe(25);
117
+ });
118
+ });
119
+ it('should handle empty results correctly', async () => {
120
+ mockFetchAssociations.mockResolvedValue({
121
+ data: {
122
+ results: [],
123
+ hasMore: false,
124
+ nextOffset: 0,
125
+ },
126
+ cleanup: jest.fn(),
127
+ });
128
+ const request = {
129
+ toObjectType: '0-1',
130
+ properties: ['firstname'],
131
+ pageLength: 25,
132
+ offset: 0,
133
+ };
134
+ const { result } = renderHook(() => useAssociations(request));
135
+ await waitFor(() => {
136
+ expect(result.current.results).toEqual([]);
137
+ expect(result.current.hasMore).toBe(false);
138
+ expect(result.current.nextOffset).toBe(0);
139
+ expect(result.current.isLoading).toBe(false);
140
+ expect(result.current.error).toBeNull();
141
+ });
142
+ });
143
+ it('should handle different object types correctly', async () => {
144
+ mockFetchAssociations.mockResolvedValue({
145
+ 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
+ ],
161
+ hasMore: false,
162
+ nextOffset: 25,
163
+ },
164
+ cleanup: jest.fn(),
165
+ });
166
+ const request = {
167
+ toObjectType: '0-2',
168
+ properties: ['name'],
169
+ pageLength: 25,
170
+ offset: 0,
171
+ };
172
+ const { result } = renderHook(() => useAssociations(request));
173
+ 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);
190
+ });
191
+ expect(mockFetchAssociations).toHaveBeenCalledWith(expect.objectContaining({
192
+ toObjectType: '0-2',
193
+ }), {});
194
+ });
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 = {
201
+ toObjectType: '0-1',
202
+ properties: ['firstname'],
203
+ pageLength: 25,
204
+ offset: 0,
205
+ };
206
+ const { result } = renderHook(() => useAssociations(request));
207
+ 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);
214
+ });
215
+ });
216
+ });
217
+ describe('pagination handling', () => {
218
+ it('should handle pagination correctly', async () => {
219
+ mockFetchAssociations.mockResolvedValue({
220
+ 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
+ ],
232
+ hasMore: false,
233
+ nextOffset: 50,
234
+ },
235
+ cleanup: jest.fn(),
236
+ });
237
+ const request = {
238
+ toObjectType: '0-1',
239
+ properties: ['firstname'],
240
+ pageLength: 25,
241
+ offset: 25, // Second page
242
+ };
243
+ const { result } = renderHook(() => useAssociations(request));
244
+ 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);
258
+ expect(result.current.isLoading).toBe(false);
259
+ });
260
+ });
261
+ });
262
+ describe('options handling', () => {
263
+ it('should pass formatting options to fetchAssociations', async () => {
264
+ mockFetchAssociations.mockResolvedValue({
265
+ data: {
266
+ results: [],
267
+ hasMore: false,
268
+ nextOffset: 0,
269
+ },
270
+ cleanup: jest.fn(),
271
+ });
272
+ const request = {
273
+ toObjectType: '0-1',
274
+ properties: ['firstname', 'lastname'],
275
+ pageLength: 25,
276
+ offset: 0,
277
+ };
278
+ const options = {
279
+ propertiesToFormat: ['firstname'],
280
+ formattingOptions: {
281
+ date: {
282
+ format: 'MM/DD/YYYY',
283
+ relative: true,
284
+ },
285
+ },
286
+ };
287
+ renderHook(() => useAssociations(request, options));
288
+ await waitFor(() => {
289
+ expect(mockFetchAssociations).toHaveBeenCalledWith(request, options);
290
+ });
291
+ });
292
+ it('should use default empty options when no options provided', async () => {
293
+ mockFetchAssociations.mockResolvedValue({
294
+ data: {
295
+ results: [],
296
+ hasMore: false,
297
+ nextOffset: 0,
298
+ },
299
+ cleanup: jest.fn(),
300
+ });
301
+ const request = {
302
+ toObjectType: '0-1',
303
+ properties: ['firstname'],
304
+ pageLength: 25,
305
+ offset: 0,
306
+ };
307
+ renderHook(() => useAssociations(request));
308
+ await waitFor(() => {
309
+ expect(mockFetchAssociations).toHaveBeenCalledWith(request, {});
310
+ });
311
+ });
312
+ });
313
+ describe('lifecycle management', () => {
314
+ it('should call cleanup function on unmount', async () => {
315
+ const mockCleanup = jest.fn();
316
+ mockFetchAssociations.mockResolvedValue({
317
+ data: {
318
+ results: [],
319
+ hasMore: false,
320
+ nextOffset: 0,
321
+ },
322
+ cleanup: mockCleanup,
323
+ });
324
+ const request = {
325
+ toObjectType: '0-1',
326
+ properties: ['firstname'],
327
+ pageLength: 25,
328
+ offset: 0,
329
+ };
330
+ const { unmount } = renderHook(() => useAssociations(request));
331
+ await waitFor(() => {
332
+ expect(mockFetchAssociations).toHaveBeenCalled();
333
+ });
334
+ unmount();
335
+ await waitFor(() => {
336
+ expect(mockCleanup).toHaveBeenCalled();
337
+ });
338
+ });
339
+ it('should handle stable reference optimization for request', async () => {
340
+ mockFetchAssociations.mockResolvedValue({
341
+ data: {
342
+ results: [],
343
+ hasMore: false,
344
+ nextOffset: 0,
345
+ },
346
+ cleanup: jest.fn(),
347
+ });
348
+ const request = {
349
+ toObjectType: '0-1',
350
+ properties: ['firstname'],
351
+ pageLength: 25,
352
+ offset: 0,
353
+ };
354
+ const { rerender } = renderHook(({ requestProp }) => useAssociations(requestProp), {
355
+ initialProps: { requestProp: request },
356
+ });
357
+ await waitFor(() => {
358
+ expect(mockFetchAssociations).toHaveBeenCalledTimes(1);
359
+ });
360
+ // Rerender with the same request object content but different reference
361
+ rerender({ requestProp: { ...request } });
362
+ await waitFor(() => {
363
+ // Should not call fetchAssociations again due to stable reference optimization
364
+ expect(mockFetchAssociations).toHaveBeenCalledTimes(1);
365
+ });
366
+ });
367
+ });
368
+ });
@@ -0,0 +1,29 @@
1
+ import { FetchCrmPropertiesOptions } from './fetchCrmProperties';
2
+ export type AssociationResult = {
3
+ toObjectId: number;
4
+ associationTypes: {
5
+ category: string;
6
+ typeId: number;
7
+ label: string;
8
+ }[];
9
+ properties: {
10
+ [key: string]: string | null;
11
+ };
12
+ };
13
+ export type AssociationsResponse = {
14
+ results: AssociationResult[];
15
+ hasMore: boolean;
16
+ nextOffset: number;
17
+ };
18
+ export type FetchAssociationsRequest = {
19
+ toObjectType: string;
20
+ properties?: string[];
21
+ pageLength?: number;
22
+ offset?: number;
23
+ };
24
+ export interface FetchAssociationsResult {
25
+ data: AssociationsResponse;
26
+ error?: string;
27
+ cleanup: () => void;
28
+ }
29
+ export declare const fetchAssociations: (request: FetchAssociationsRequest, options?: FetchCrmPropertiesOptions) => Promise<FetchAssociationsResult>;
@@ -0,0 +1,42 @@
1
+ function isAssociationsResponse(data) {
2
+ if (data === null ||
3
+ typeof data !== 'object' ||
4
+ !Array.isArray(data.results) ||
5
+ typeof data.hasMore !== 'boolean' ||
6
+ typeof data.nextOffset !== 'number') {
7
+ return false;
8
+ }
9
+ return data.results.every((result) => result !== null &&
10
+ typeof result === 'object' &&
11
+ typeof result.toObjectId === 'number' &&
12
+ Array.isArray(result.associationTypes) &&
13
+ result.properties !== null &&
14
+ typeof result.properties === 'object');
15
+ }
16
+ export const fetchAssociations = async (request, options) => {
17
+ let response;
18
+ let result;
19
+ try {
20
+ // eslint-disable-next-line hubspot-dev/no-confusing-browser-globals
21
+ response = await self.fetchAssociations(request, options);
22
+ result = await response.json();
23
+ }
24
+ catch (error) {
25
+ throw error instanceof Error
26
+ ? error
27
+ : new Error('Failed to fetch associations: Unknown error');
28
+ }
29
+ if (result.error) {
30
+ throw new Error(result.error);
31
+ }
32
+ if (!response.ok) {
33
+ throw new Error(`Failed to fetch associations: ${response.statusText}`);
34
+ }
35
+ if (!isAssociationsResponse(result.data)) {
36
+ throw new Error('Invalid response format');
37
+ }
38
+ return {
39
+ data: result.data,
40
+ cleanup: result.cleanup || (() => { }),
41
+ };
42
+ };
@@ -19,6 +19,7 @@ export type FetchCrmPropertiesOptions = {
19
19
  };
20
20
  export interface FetchCrmPropertiesResult {
21
21
  data: Record<string, string | null>;
22
+ error?: string;
22
23
  cleanup: () => void;
23
24
  }
24
25
  export declare const fetchCrmProperties: (propertyNames: string[], propertiesUpdatedCallback: (properties: Record<string, string | null>) => void, options?: FetchCrmPropertiesOptions) => Promise<FetchCrmPropertiesResult>;
@@ -12,22 +12,27 @@ function isCrmPropertiesResponse(data) {
12
12
  return true;
13
13
  }
14
14
  export const fetchCrmProperties = async (propertyNames, propertiesUpdatedCallback, options) => {
15
+ let response;
16
+ let result;
15
17
  try {
16
18
  // eslint-disable-next-line hubspot-dev/no-confusing-browser-globals
17
- const response = await self.fetchCrmProperties(propertyNames, propertiesUpdatedCallback, options);
18
- if (!response.ok) {
19
- throw new Error(`Failed to fetch CRM properties: ${response.statusText}`);
20
- }
21
- const result = await response.json();
22
- if (!isCrmPropertiesResponse(result.data)) {
23
- throw new Error('Invalid response format');
24
- }
25
- return result;
19
+ response = await self.fetchCrmProperties(propertyNames, propertiesUpdatedCallback, options);
20
+ result = await response.json();
26
21
  }
27
22
  catch (error) {
28
- if (error instanceof Error) {
29
- throw error;
30
- }
31
- throw new Error('Failed to fetch CRM properties: Unknown error');
23
+ // Only handle network/parsing errors, not our validation errors
24
+ throw error instanceof Error
25
+ ? error
26
+ : new Error('Failed to fetch CRM properties: Unknown error');
32
27
  }
28
+ if (result.error) {
29
+ throw new Error(result.error);
30
+ }
31
+ if (!response.ok) {
32
+ throw new Error(`Failed to fetch CRM properties: ${response.statusText}`);
33
+ }
34
+ if (!isCrmPropertiesResponse(result.data)) {
35
+ throw new Error('Invalid response format');
36
+ }
37
+ return result;
33
38
  };
@@ -0,0 +1,10 @@
1
+ import { type FetchAssociationsRequest, type AssociationsResponse } from '../crm/fetchAssociations';
2
+ import { type FetchCrmPropertiesOptions } from '../crm/fetchCrmProperties';
3
+ export interface AssociationsState {
4
+ results: AssociationsResponse['results'];
5
+ error: Error | null;
6
+ isLoading: boolean;
7
+ hasMore: boolean;
8
+ nextOffset: number;
9
+ }
10
+ export declare function useAssociations(request: FetchAssociationsRequest, options?: FetchCrmPropertiesOptions): AssociationsState;
@@ -0,0 +1,116 @@
1
+ import { useEffect, useReducer, useMemo, useRef } from 'react';
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
+ };
11
+ function associationsReducer(state, action) {
12
+ switch (action.type) {
13
+ case 'FETCH_START':
14
+ return {
15
+ ...state,
16
+ isLoading: true,
17
+ error: null,
18
+ };
19
+ case 'FETCH_SUCCESS':
20
+ return {
21
+ ...state,
22
+ isLoading: false,
23
+ results: action.payload.results,
24
+ hasMore: action.payload.hasMore,
25
+ nextOffset: action.payload.nextOffset,
26
+ error: null,
27
+ };
28
+ case 'FETCH_ERROR':
29
+ return {
30
+ ...state,
31
+ isLoading: false,
32
+ error: action.payload,
33
+ results: [],
34
+ hasMore: false,
35
+ nextOffset: 0,
36
+ };
37
+ default:
38
+ return state;
39
+ }
40
+ }
41
+ const DEFAULT_OPTIONS = {};
42
+ export function useAssociations(request, options = DEFAULT_OPTIONS) {
43
+ const [state, dispatch] = useReducer(associationsReducer, initialState);
44
+ // Log experimental warning once on mount
45
+ useEffect(() => {
46
+ logger.warn('useAssociations is an experimental hook and might change or be removed in the future.');
47
+ }, []);
48
+ /**
49
+ * HOOK OPTIMIZATION:
50
+ *
51
+ * Create stable references for request and options to prevent unnecessary re-renders and API calls.
52
+ * Then, external developers can pass inline objects without worrying about memoization
53
+ * We handle the deep equality comparison ourselves, and return the same object reference when content is equivalent.
54
+ */
55
+ const lastRequestRef = useRef();
56
+ const lastRequestKeyRef = useRef();
57
+ const lastOptionsRef = useRef();
58
+ const lastOptionsKeyRef = useRef();
59
+ const stableRequest = useMemo(() => {
60
+ const requestKey = JSON.stringify(request);
61
+ if (requestKey === lastRequestKeyRef.current) {
62
+ return lastRequestRef.current;
63
+ }
64
+ lastRequestKeyRef.current = requestKey;
65
+ lastRequestRef.current = request;
66
+ return request;
67
+ }, [request]);
68
+ const stableOptions = useMemo(() => {
69
+ const optionsKey = JSON.stringify(options);
70
+ if (optionsKey === lastOptionsKeyRef.current) {
71
+ return lastOptionsRef.current;
72
+ }
73
+ lastOptionsKeyRef.current = optionsKey;
74
+ lastOptionsRef.current = options;
75
+ return options;
76
+ }, [options]);
77
+ // Fetch the associations
78
+ useEffect(() => {
79
+ let cancelled = false;
80
+ let cleanup = null;
81
+ const fetchData = async () => {
82
+ try {
83
+ dispatch({ type: 'FETCH_START' });
84
+ const result = await fetchAssociations(stableRequest, stableOptions);
85
+ if (!cancelled) {
86
+ dispatch({
87
+ type: 'FETCH_SUCCESS',
88
+ payload: {
89
+ results: result.data.results,
90
+ hasMore: result.data.hasMore,
91
+ nextOffset: result.data.nextOffset,
92
+ },
93
+ });
94
+ cleanup = result.cleanup;
95
+ }
96
+ }
97
+ catch (err) {
98
+ if (!cancelled) {
99
+ const errorData = err instanceof Error
100
+ ? err
101
+ : new Error('Failed to fetch associations');
102
+ dispatch({ type: 'FETCH_ERROR', payload: errorData });
103
+ }
104
+ }
105
+ };
106
+ fetchData();
107
+ return () => {
108
+ cancelled = true;
109
+ // Call cleanup function to release resources
110
+ if (cleanup) {
111
+ cleanup();
112
+ }
113
+ };
114
+ }, [stableRequest, stableOptions]);
115
+ return state;
116
+ }
@@ -1,6 +1,37 @@
1
- import { useEffect, useState, useMemo, useRef } from 'react';
1
+ import { useEffect, useReducer, useMemo, useRef } from 'react';
2
2
  import { logger } from '../../logger';
3
3
  import { fetchCrmProperties, } from '../crm/fetchCrmProperties';
4
+ const initialState = {
5
+ properties: {},
6
+ error: null,
7
+ isLoading: true,
8
+ };
9
+ function crmPropertiesReducer(state, action) {
10
+ switch (action.type) {
11
+ case 'FETCH_START':
12
+ return {
13
+ ...state,
14
+ isLoading: true,
15
+ error: null,
16
+ };
17
+ case 'FETCH_SUCCESS':
18
+ return {
19
+ ...state,
20
+ isLoading: false,
21
+ properties: action.payload,
22
+ error: null,
23
+ };
24
+ case 'FETCH_ERROR':
25
+ return {
26
+ ...state,
27
+ isLoading: false,
28
+ error: action.payload,
29
+ properties: {},
30
+ };
31
+ default:
32
+ return state;
33
+ }
34
+ }
4
35
  const DEFAULT_OPTIONS = {};
5
36
  /**
6
37
  * A hook for using and managing CRM properties.
@@ -8,9 +39,7 @@ const DEFAULT_OPTIONS = {};
8
39
  * @experimental This hook is experimental and might change or be removed in future versions.
9
40
  */
10
41
  export function useCrmProperties(propertyNames, options = DEFAULT_OPTIONS) {
11
- const [properties, setProperties] = useState({});
12
- const [isLoading, setIsLoading] = useState(true);
13
- const [error, setError] = useState(null);
42
+ const [state, dispatch] = useReducer(crmPropertiesReducer, initialState);
14
43
  // Log experimental warning once on mount
15
44
  useEffect(() => {
16
45
  logger.warn('useCrmProperties is an experimental hook and might change or be removed in the future.');
@@ -51,11 +80,14 @@ export function useCrmProperties(propertyNames, options = DEFAULT_OPTIONS) {
51
80
  let cleanup = null;
52
81
  const fetchData = async () => {
53
82
  try {
54
- setIsLoading(true);
55
- setError(null);
56
- const result = await fetchCrmProperties(stablePropertyNames, setProperties, stableOptions);
83
+ dispatch({ type: 'FETCH_START' });
84
+ const result = await fetchCrmProperties(stablePropertyNames, (data) => {
85
+ if (!cancelled) {
86
+ dispatch({ type: 'FETCH_SUCCESS', payload: data });
87
+ }
88
+ }, stableOptions);
57
89
  if (!cancelled) {
58
- setProperties(result.data);
90
+ dispatch({ type: 'FETCH_SUCCESS', payload: result.data });
59
91
  cleanup = result.cleanup;
60
92
  }
61
93
  }
@@ -64,13 +96,7 @@ export function useCrmProperties(propertyNames, options = DEFAULT_OPTIONS) {
64
96
  const errorData = err instanceof Error
65
97
  ? err
66
98
  : new Error('Failed to fetch CRM properties');
67
- setError(errorData);
68
- setProperties({});
69
- }
70
- }
71
- finally {
72
- if (!cancelled) {
73
- setIsLoading(false);
99
+ dispatch({ type: 'FETCH_ERROR', payload: errorData });
74
100
  }
75
101
  }
76
102
  };
@@ -83,9 +109,5 @@ export function useCrmProperties(propertyNames, options = DEFAULT_OPTIONS) {
83
109
  }
84
110
  };
85
111
  }, [stablePropertyNames, stableOptions]);
86
- return {
87
- properties,
88
- error,
89
- isLoading,
90
- };
112
+ return state;
91
113
  }
@@ -1,6 +1,7 @@
1
1
  import type * as types from '../types';
2
2
  import type * as experimentalTypes from './types';
3
3
  export { useCrmProperties } from './hooks/useCrmProperties';
4
+ export { useAssociations } from './hooks/useAssociations';
4
5
  /** @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. */
5
6
  declare const Iframe: "Iframe" & {
6
7
  readonly type?: "Iframe" | undefined;
@@ -97,4 +98,22 @@ declare const CurrencyInput: "CurrencyInput" & {
97
98
  readonly props?: experimentalTypes.CurrencyInputProps | undefined;
98
99
  readonly children?: true | undefined;
99
100
  } & import("@remote-ui/react").ReactComponentTypeFromRemoteComponentType<import("@remote-ui/types").RemoteComponentType<"CurrencyInput", experimentalTypes.CurrencyInputProps, true>>;
100
- export { Iframe, MediaObject, Inline, Stack2, Center, SimpleGrid, GridItem, Grid, SettingsView, ExpandableText, Popover, FileInput, TimeInput, CurrencyInput, };
101
+ /** @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. */
102
+ declare const HeaderActions: "HeaderActions" & {
103
+ readonly type?: "HeaderActions" | undefined;
104
+ readonly props?: experimentalTypes.HeaderActionsProps | undefined;
105
+ readonly children?: true | undefined;
106
+ } & import("@remote-ui/react").ReactComponentTypeFromRemoteComponentType<import("@remote-ui/types").RemoteComponentType<"HeaderActions", experimentalTypes.HeaderActionsProps, true>>;
107
+ /** @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. */
108
+ declare const PrimaryHeaderActionButton: "PrimaryHeaderActionButton" & {
109
+ readonly type?: "PrimaryHeaderActionButton" | undefined;
110
+ readonly props?: experimentalTypes.HeaderActionButtonProps | undefined;
111
+ readonly children?: true | undefined;
112
+ } & import("@remote-ui/react").ReactComponentTypeFromRemoteComponentType<import("@remote-ui/types").RemoteComponentType<"PrimaryHeaderActionButton", experimentalTypes.HeaderActionButtonProps, true>>;
113
+ /** @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. */
114
+ declare const SecondaryHeaderActionButton: "SecondaryHeaderActionButton" & {
115
+ readonly type?: "SecondaryHeaderActionButton" | undefined;
116
+ readonly props?: experimentalTypes.HeaderActionButtonProps | undefined;
117
+ readonly children?: true | undefined;
118
+ } & 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, };
@@ -1,5 +1,6 @@
1
1
  import { createRemoteReactComponent } from '@remote-ui/react';
2
2
  export { useCrmProperties } from './hooks/useCrmProperties';
3
+ export { useAssociations } from './hooks/useAssociations';
3
4
  /** @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. */
4
5
  const Iframe = createRemoteReactComponent('Iframe');
5
6
  /** @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. */
@@ -42,4 +43,14 @@ const FileInput = createRemoteReactComponent('FileInput');
42
43
  const TimeInput = createRemoteReactComponent('TimeInput');
43
44
  /** @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. */
44
45
  const CurrencyInput = createRemoteReactComponent('CurrencyInput');
45
- export { Iframe, MediaObject, Inline, Stack2, Center, SimpleGrid, GridItem, Grid, SettingsView, ExpandableText, Popover, FileInput, TimeInput, CurrencyInput, };
46
+ /** @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. */
47
+ const HeaderActions = createRemoteReactComponent('HeaderActions');
48
+ /** @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. */
49
+ const PrimaryHeaderActionButton = createRemoteReactComponent('PrimaryHeaderActionButton', {
50
+ fragmentProps: ['overlay'],
51
+ });
52
+ /** @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
+ const SecondaryHeaderActionButton = createRemoteReactComponent('SecondaryHeaderActionButton', {
54
+ fragmentProps: ['overlay'],
55
+ });
56
+ export { Iframe, MediaObject, Inline, Stack2, Center, SimpleGrid, GridItem, Grid, SettingsView, ExpandableText, Popover, FileInput, TimeInput, CurrencyInput, HeaderActions, PrimaryHeaderActionButton, SecondaryHeaderActionButton, };
@@ -331,4 +331,25 @@ export interface CurrencyInputProps extends BaseInputForNumber {
331
331
  */
332
332
  max?: number;
333
333
  }
334
+ /**
335
+ * @ignore
336
+ * @experimental do not use in production
337
+ */
338
+ export interface HeaderActionsProps {
339
+ children: ReactNode;
340
+ }
341
+ /**
342
+ * @ignore
343
+ * @experimental do not use in production
344
+ */
345
+ export interface HeaderActionButtonProps {
346
+ onClick?: ReactionsHandler<ExtensionEvent>;
347
+ href?: string | {
348
+ url: string;
349
+ external?: boolean;
350
+ };
351
+ disabled?: boolean;
352
+ children: ReactNode;
353
+ overlay?: RemoteFragment;
354
+ }
334
355
  export {};
package/dist/types.d.ts CHANGED
@@ -231,6 +231,10 @@ export interface SettingsActions {
231
231
  addAlert: AddAlertAction;
232
232
  }
233
233
  /** @ignore */
234
+ export interface AppHomeActions {
235
+ addAlert: AddAlertAction;
236
+ }
237
+ /** @ignore */
234
238
  export interface UiePlatformActions {
235
239
  copyTextToClipboard: Clipboard['writeText'];
236
240
  closeOverlay: CloseOverlayAction;
@@ -2461,6 +2465,10 @@ export interface SettingsContext extends BaseContext {
2461
2465
  location: 'settings';
2462
2466
  }
2463
2467
  /** @ignore */
2468
+ export interface AppHomeContext extends BaseContext {
2469
+ location: 'home';
2470
+ }
2471
+ /** @ignore */
2464
2472
  export interface GenericContext extends BaseContext {
2465
2473
  location: 'uie.playground.middle';
2466
2474
  }
@@ -2793,6 +2801,11 @@ export interface SettingsExtensionPoint extends ExtensionPointContract {
2793
2801
  actions: SettingsActions & UiePlatformActions;
2794
2802
  context: SettingsContext;
2795
2803
  }
2804
+ /** @ignore */
2805
+ export interface AppHomeExtensionPoint extends ExtensionPointContract {
2806
+ actions: AppHomeActions;
2807
+ context: AppHomeContext;
2808
+ }
2796
2809
  export interface ExampleCrmComponentProps {
2797
2810
  name: string;
2798
2811
  size: 'sm' | 'md' | 'lg';
@@ -2808,7 +2821,7 @@ interface RemotePlaygroundExtensionPoint extends ExtensionPointContract {
2808
2821
  ExampleCrmComponent: ComponentType<ExampleCrmComponentProps>;
2809
2822
  };
2810
2823
  }
2811
- export declare const EXTENSION_POINT_LOCATIONS: readonly ["crm.preview", "crm.record.sidebar", "crm.record.tab", "helpdesk.sidebar", "uie.playground.middle", "settings"];
2824
+ export declare const EXTENSION_POINT_LOCATIONS: readonly ["crm.preview", "crm.record.sidebar", "crm.record.tab", "helpdesk.sidebar", "uie.playground.middle", "settings", "home"];
2812
2825
  export type ExtensionPointLocation = TypesOfReadOnlyArray<typeof EXTENSION_POINT_LOCATIONS>;
2813
2826
  /** @ignore */
2814
2827
  interface LocationToExtensionPoint {
@@ -2818,6 +2831,7 @@ interface LocationToExtensionPoint {
2818
2831
  'crm.record.sidebar': CrmSidebarExtensionPoint;
2819
2832
  'helpdesk.sidebar': StandardCrmExtensionPoint;
2820
2833
  settings: SettingsExtensionPoint;
2834
+ home: AppHomeExtensionPoint;
2821
2835
  }
2822
2836
  /**
2823
2837
  * While this resolves to the same result as LocationToExtensionPoint, it ensures that every location
package/dist/types.js CHANGED
@@ -235,6 +235,7 @@ export const EXTENSION_POINT_LOCATIONS = [
235
235
  'helpdesk.sidebar',
236
236
  'uie.playground.middle',
237
237
  'settings',
238
+ 'home',
238
239
  ];
239
240
  /**
240
241
  * @category Serverless
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hubspot/ui-extensions",
3
- "version": "0.9.3",
3
+ "version": "0.9.4",
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": "a5c7f8d19d0dc16b44e9235d3e12dacc2cc14fdf"
69
+ "gitHead": "65046f5f3ce4c51652bc454e4425b57bd33aedbf"
70
70
  }