@hubspot/ui-extensions 0.9.2 → 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,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
+ });
@@ -42,8 +42,11 @@ describe('useCrmProperties', () => {
42
42
  });
43
43
  it('should successfully fetch and return CRM properties', async () => {
44
44
  mockFetchCrmProperties.mockResolvedValue({
45
- firstname: 'Test value for firstname',
46
- lastname: 'Test value for lastname',
45
+ data: {
46
+ firstname: 'Test value for firstname',
47
+ lastname: 'Test value for lastname',
48
+ },
49
+ cleanup: jest.fn(),
47
50
  });
48
51
  const propertyNames = ['firstname', 'lastname'];
49
52
  const { result } = renderHook(() => useCrmProperties(propertyNames));
@@ -73,7 +76,10 @@ describe('useCrmProperties', () => {
73
76
  let capturedCallback;
74
77
  mockFetchCrmProperties.mockImplementation((_propertyNames, propertiesUpdatedCallback) => {
75
78
  capturedCallback = propertiesUpdatedCallback;
76
- return { firstname: 'Initial', lastname: 'Initial' };
79
+ return {
80
+ data: { firstname: 'Initial', lastname: 'Initial' },
81
+ cleanup: jest.fn(),
82
+ };
77
83
  });
78
84
  const propertyNames = ['firstname', 'lastname'];
79
85
  const { result } = renderHook(() => useCrmProperties(propertyNames));
@@ -87,9 +93,150 @@ describe('useCrmProperties', () => {
87
93
  const updatedProperties = { firstname: 'Updated', lastname: 'Updated' };
88
94
  await waitFor(() => {
89
95
  if (capturedCallback) {
90
- capturedCallback(updatedProperties);
96
+ capturedCallback?.(updatedProperties);
97
+ expect(result.current.properties).toEqual(updatedProperties);
98
+ }
99
+ });
100
+ });
101
+ it('should update properties when propertiesUpdatedCallback is called with null values', async () => {
102
+ // Capture the callback so we can simulate an external update to CRM properties
103
+ let capturedCallback;
104
+ mockFetchCrmProperties.mockImplementation((_propertyNames, propertiesUpdatedCallback) => {
105
+ capturedCallback = propertiesUpdatedCallback;
106
+ return {
107
+ data: { firstname: 'Initial', lastname: null },
108
+ cleanup: jest.fn(),
109
+ };
110
+ });
111
+ const propertyNames = ['firstname', 'lastname'];
112
+ const { result } = renderHook(() => useCrmProperties(propertyNames));
113
+ await waitFor(() => {
114
+ expect(result.current.properties).toEqual({
115
+ firstname: 'Initial',
116
+ lastname: null,
117
+ });
118
+ expect(result.current.isLoading).toBe(false);
119
+ });
120
+ const updatedProperties = { firstname: null, lastname: 'Updated Value' };
121
+ await waitFor(() => {
122
+ if (capturedCallback) {
123
+ capturedCallback?.(updatedProperties);
91
124
  expect(result.current.properties).toEqual(updatedProperties);
92
125
  }
93
126
  });
94
127
  });
128
+ // This will become "should pass formatting options to fetchCrmProperties" in the next PR
129
+ it('should pass formatting options to fetchCrmProperties', async () => {
130
+ mockFetchCrmProperties.mockResolvedValue({
131
+ data: {
132
+ firstname: 'John',
133
+ lastname: 'Doe',
134
+ },
135
+ cleanup: jest.fn(),
136
+ });
137
+ const propertyNames = ['firstname', 'lastname'];
138
+ const options = {
139
+ propertiesToFormat: ['firstname'],
140
+ };
141
+ renderHook(() => useCrmProperties(propertyNames, options));
142
+ await waitFor(() => {
143
+ expect(mockFetchCrmProperties).toHaveBeenCalledWith(propertyNames, expect.any(Function), options);
144
+ });
145
+ });
146
+ it('should use default empty options when no options provided', async () => {
147
+ mockFetchCrmProperties.mockResolvedValue({
148
+ data: {
149
+ firstname: 'John',
150
+ },
151
+ cleanup: jest.fn(),
152
+ });
153
+ const propertyNames = ['firstname'];
154
+ const defaultOptions = {};
155
+ renderHook(() => useCrmProperties(propertyNames));
156
+ await waitFor(() => {
157
+ expect(mockFetchCrmProperties).toHaveBeenCalledWith(['firstname'], expect.any(Function), defaultOptions);
158
+ });
159
+ });
160
+ it('should not re-fetch when options object reference changes but content is the same', async () => {
161
+ mockFetchCrmProperties.mockResolvedValue({
162
+ data: {
163
+ firstname: 'John',
164
+ },
165
+ cleanup: jest.fn(),
166
+ });
167
+ const propertyNames = ['firstname'];
168
+ const initialOptions = { propertiesToFormat: ['firstname'] };
169
+ const { rerender } = renderHook(({ options }) => useCrmProperties(propertyNames, options), { initialProps: { options: initialOptions } });
170
+ await waitFor(() => {
171
+ expect(mockFetchCrmProperties).toHaveBeenCalledTimes(1);
172
+ });
173
+ const newOptionsWithSameContent = { propertiesToFormat: ['firstname'] };
174
+ rerender({ options: newOptionsWithSameContent });
175
+ await waitFor(() => {
176
+ expect(mockFetchCrmProperties).toHaveBeenCalledTimes(1);
177
+ });
178
+ });
179
+ it('should re-fetch when options content actually changes', async () => {
180
+ mockFetchCrmProperties.mockResolvedValue({
181
+ data: {
182
+ firstname: 'John',
183
+ },
184
+ cleanup: jest.fn(),
185
+ });
186
+ const propertyNames = ['firstname'];
187
+ const initialOptions = { propertiesToFormat: ['firstname'] };
188
+ const { rerender } = renderHook(({ options }) => useCrmProperties(propertyNames, options), { initialProps: { options: initialOptions } });
189
+ await waitFor(() => {
190
+ expect(mockFetchCrmProperties).toHaveBeenCalledTimes(1);
191
+ expect(mockFetchCrmProperties).toHaveBeenLastCalledWith(['firstname'], expect.any(Function), initialOptions);
192
+ });
193
+ const newOptions = {
194
+ propertiesToFormat: ['firstname'],
195
+ formattingOptions: { dateFormat: 'yyyy-MM-dd' },
196
+ };
197
+ rerender({ options: newOptions });
198
+ await waitFor(() => {
199
+ expect(mockFetchCrmProperties).toHaveBeenCalledTimes(2);
200
+ expect(mockFetchCrmProperties).toHaveBeenLastCalledWith(['firstname'], expect.any(Function), newOptions);
201
+ });
202
+ });
203
+ it('should call cleanup function when component unmounts', async () => {
204
+ const mockCleanup = jest.fn();
205
+ mockFetchCrmProperties.mockResolvedValue({
206
+ data: {
207
+ firstname: 'John',
208
+ },
209
+ cleanup: mockCleanup,
210
+ });
211
+ const propertyNames = ['firstname'];
212
+ const { unmount } = renderHook(() => useCrmProperties(propertyNames));
213
+ await waitFor(() => {
214
+ expect(mockFetchCrmProperties).toHaveBeenCalledTimes(1);
215
+ });
216
+ unmount();
217
+ expect(mockCleanup).toHaveBeenCalledTimes(1);
218
+ });
219
+ it('should call cleanup function when dependencies change', async () => {
220
+ const mockCleanup1 = jest.fn();
221
+ const mockCleanup2 = jest.fn();
222
+ mockFetchCrmProperties
223
+ .mockResolvedValueOnce({
224
+ data: { firstname: 'John' },
225
+ cleanup: mockCleanup1,
226
+ })
227
+ .mockResolvedValueOnce({
228
+ data: { firstname: 'Jane' },
229
+ cleanup: mockCleanup2,
230
+ });
231
+ const { rerender } = renderHook(({ propertyNames }) => useCrmProperties(propertyNames), { initialProps: { propertyNames: ['firstname'] } });
232
+ await waitFor(() => {
233
+ expect(mockFetchCrmProperties).toHaveBeenCalledTimes(1);
234
+ });
235
+ // Change dependencies to trigger cleanup
236
+ rerender({ propertyNames: ['lastname'] });
237
+ await waitFor(() => {
238
+ expect(mockFetchCrmProperties).toHaveBeenCalledTimes(2);
239
+ expect(mockCleanup1).toHaveBeenCalledTimes(1);
240
+ });
241
+ });
95
242
  });
@@ -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
+ };
@@ -1,4 +1,25 @@
1
1
  export type CrmPropertiesResponse = {
2
- [key: string]: string;
2
+ [key: string]: string | null;
3
3
  };
4
- export declare const fetchCrmProperties: (propertyNames: string[], propertiesUpdatedCallback: (properties: Record<string, string>) => void) => Promise<Record<string, string>>;
4
+ export type FetchCrmPropertiesOptions = {
5
+ propertiesToFormat?: string[] | 'all';
6
+ formattingOptions?: {
7
+ date?: {
8
+ format?: string;
9
+ relative?: boolean;
10
+ };
11
+ dateTime?: {
12
+ format?: string;
13
+ relative?: boolean;
14
+ };
15
+ currency?: {
16
+ addSymbol?: boolean;
17
+ };
18
+ };
19
+ };
20
+ export interface FetchCrmPropertiesResult {
21
+ data: Record<string, string | null>;
22
+ error?: string;
23
+ cleanup: () => void;
24
+ }
25
+ export declare const fetchCrmProperties: (propertyNames: string[], propertiesUpdatedCallback: (properties: Record<string, string | null>) => void, options?: FetchCrmPropertiesOptions) => Promise<FetchCrmPropertiesResult>;