@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.
- package/dist/__tests__/experimental/crm/fetchAssociations.spec.d.ts +1 -0
- package/dist/__tests__/experimental/crm/fetchAssociations.spec.js +396 -0
- package/dist/__tests__/experimental/crm/fetchCrmProperties.spec.js +1 -0
- package/dist/__tests__/experimental/hooks/useAssociations.spec.d.ts +1 -0
- package/dist/__tests__/experimental/hooks/useAssociations.spec.js +368 -0
- package/dist/experimental/crm/fetchAssociations.d.ts +29 -0
- package/dist/experimental/crm/fetchAssociations.js +42 -0
- package/dist/experimental/crm/fetchCrmProperties.d.ts +1 -0
- package/dist/experimental/crm/fetchCrmProperties.js +18 -13
- package/dist/experimental/hooks/useAssociations.d.ts +10 -0
- package/dist/experimental/hooks/useAssociations.js +116 -0
- package/dist/experimental/hooks/useCrmProperties.js +42 -20
- package/dist/experimental/index.d.ts +20 -1
- package/dist/experimental/index.js +12 -1
- package/dist/experimental/types.d.ts +21 -0
- package/dist/types.d.ts +15 -1
- package/dist/types.js +1 -0
- package/package.json +2 -2
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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,
|
|
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 [
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hubspot/ui-extensions",
|
|
3
|
-
"version": "0.9.
|
|
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": "
|
|
69
|
+
"gitHead": "65046f5f3ce4c51652bc454e4425b57bd33aedbf"
|
|
70
70
|
}
|