@hubspot/ui-extensions 0.9.1 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,15 +8,19 @@ Object.defineProperty(global, 'self', {
8
8
  value: mockSelf,
9
9
  writable: true,
10
10
  });
11
- import { fetchCrmProperties, } from '../../../experimental/crm/fetchCrmProperties';
11
+ import { fetchCrmProperties } from '../../../experimental/crm/fetchCrmProperties';
12
+ const DEFAULT_OPTIONS = {};
12
13
  describe('fetchCrmProperties', () => {
13
14
  beforeEach(() => {
14
15
  jest.clearAllMocks();
15
16
  });
16
17
  it('successfully fetches CRM properties', async () => {
17
18
  const mockApiResponse = {
18
- firstname: 'Test value for firstname',
19
- lastname: 'Test value for lastname',
19
+ data: {
20
+ firstname: 'Test value for firstname',
21
+ lastname: 'Test value for lastname',
22
+ },
23
+ cleanup: jest.fn(),
20
24
  };
21
25
  const mockResponse = {
22
26
  ok: true,
@@ -24,11 +28,42 @@ describe('fetchCrmProperties', () => {
24
28
  };
25
29
  mockFetchCrmProperties.mockResolvedValue(mockResponse);
26
30
  const propertyNames = ['firstname', 'lastname'];
27
- const result = await fetchCrmProperties(propertyNames, jest.fn());
28
- expect(mockFetchCrmProperties).toHaveBeenCalledWith(propertyNames, expect.any(Function));
31
+ const result = await fetchCrmProperties(propertyNames, jest.fn(), DEFAULT_OPTIONS);
32
+ expect(mockFetchCrmProperties).toHaveBeenCalledWith(propertyNames, expect.any(Function), DEFAULT_OPTIONS);
29
33
  expect(result).toEqual({
30
- firstname: 'Test value for firstname',
31
- lastname: 'Test value for lastname',
34
+ data: {
35
+ firstname: 'Test value for firstname',
36
+ lastname: 'Test value for lastname',
37
+ },
38
+ cleanup: expect.any(Function),
39
+ });
40
+ });
41
+ it('successfully fetches CRM properties with null values', async () => {
42
+ const mockApiResponse = {
43
+ data: {
44
+ firstname: 'John',
45
+ lastname: null,
46
+ email: 'john@example.com',
47
+ phone: null,
48
+ },
49
+ cleanup: jest.fn(),
50
+ };
51
+ const mockResponse = {
52
+ ok: true,
53
+ json: jest.fn().mockResolvedValue(mockApiResponse),
54
+ };
55
+ mockFetchCrmProperties.mockResolvedValue(mockResponse);
56
+ const propertyNames = ['firstname', 'lastname', 'email', 'phone'];
57
+ const result = await fetchCrmProperties(propertyNames, jest.fn(), DEFAULT_OPTIONS);
58
+ expect(mockFetchCrmProperties).toHaveBeenCalledWith(propertyNames, expect.any(Function), DEFAULT_OPTIONS);
59
+ expect(result).toEqual({
60
+ data: {
61
+ firstname: 'John',
62
+ lastname: null,
63
+ email: 'john@example.com',
64
+ phone: null,
65
+ },
66
+ cleanup: expect.any(Function),
32
67
  });
33
68
  });
34
69
  it('throws an error when response is not OK', async () => {
@@ -38,28 +73,51 @@ describe('fetchCrmProperties', () => {
38
73
  };
39
74
  mockFetchCrmProperties.mockResolvedValue(mockResponse);
40
75
  const propertyNames = ['firstname'];
41
- await expect(fetchCrmProperties(propertyNames, jest.fn())).rejects.toThrow('Failed to fetch CRM properties: Not Found');
76
+ await expect(fetchCrmProperties(propertyNames, jest.fn(), DEFAULT_OPTIONS)).rejects.toThrow('Failed to fetch CRM properties: Not Found');
42
77
  });
43
78
  it('throws an error when fetch fails', async () => {
44
79
  mockFetchCrmProperties.mockRejectedValue(new Error('Network error'));
45
80
  const propertyNames = ['firstname'];
46
- await expect(fetchCrmProperties(propertyNames, jest.fn())).rejects.toThrow('Network error');
81
+ await expect(fetchCrmProperties(propertyNames, jest.fn(), DEFAULT_OPTIONS)).rejects.toThrow('Network error');
47
82
  });
48
83
  it('throws an error if the response is not an object', async () => {
49
- const mockApiResponse = 'Invalid response';
84
+ const mockApiResponse = {
85
+ data: 'Invalid response',
86
+ cleanup: jest.fn(),
87
+ };
50
88
  const mockResponse = {
51
89
  ok: true,
52
90
  json: jest.fn().mockResolvedValue(mockApiResponse),
53
91
  };
54
92
  mockFetchCrmProperties.mockResolvedValue(mockResponse);
55
93
  const propertyNames = ['firstname'];
56
- await expect(fetchCrmProperties(propertyNames, jest.fn())).rejects.toThrow('Invalid response format');
94
+ await expect(fetchCrmProperties(propertyNames, jest.fn(), DEFAULT_OPTIONS)).rejects.toThrow('Invalid response format');
95
+ });
96
+ it('throws an error if response contains invalid property values', async () => {
97
+ const mockApiResponse = {
98
+ data: {
99
+ firstname: 'John',
100
+ lastname: 123,
101
+ email: 'john@example.com',
102
+ },
103
+ cleanup: jest.fn(),
104
+ };
105
+ const mockResponse = {
106
+ ok: true,
107
+ json: jest.fn().mockResolvedValue(mockApiResponse),
108
+ };
109
+ mockFetchCrmProperties.mockResolvedValue(mockResponse);
110
+ const propertyNames = ['firstname', 'lastname', 'email'];
111
+ await expect(fetchCrmProperties(propertyNames, jest.fn(), DEFAULT_OPTIONS)).rejects.toThrow('Invalid response format');
57
112
  });
58
113
  it('passes the propertiesUpdatedCallback and allows it to be called', async () => {
59
114
  let capturedCallback;
60
115
  const mockApiResponse = {
61
- firstname: 'Initial',
62
- lastname: 'Initial',
116
+ data: {
117
+ firstname: 'Initial',
118
+ lastname: 'Initial',
119
+ },
120
+ cleanup: jest.fn(),
63
121
  };
64
122
  const mockResponse = {
65
123
  ok: true,
@@ -71,7 +129,7 @@ describe('fetchCrmProperties', () => {
71
129
  });
72
130
  const propertyNames = ['firstname', 'lastname'];
73
131
  const mockCallback = jest.fn();
74
- await fetchCrmProperties(propertyNames, mockCallback);
132
+ await fetchCrmProperties(propertyNames, mockCallback, DEFAULT_OPTIONS);
75
133
  expect(typeof capturedCallback).toBe('function');
76
134
  // Simulate the callback being called with new properties
77
135
  const updatedProps = { firstname: 'Updated', lastname: 'Updated' };
@@ -80,4 +138,111 @@ describe('fetchCrmProperties', () => {
80
138
  }
81
139
  expect(mockCallback).toHaveBeenCalledWith(updatedProps);
82
140
  });
141
+ it('passes the propertiesUpdatedCallback and allows it to be called with null values', async () => {
142
+ let capturedCallback;
143
+ const mockApiResponse = {
144
+ data: {
145
+ firstname: 'Initial',
146
+ lastname: null,
147
+ },
148
+ cleanup: jest.fn(),
149
+ };
150
+ const mockResponse = {
151
+ ok: true,
152
+ json: jest.fn().mockResolvedValue(mockApiResponse),
153
+ };
154
+ mockFetchCrmProperties.mockImplementation((propertyNames, callback) => {
155
+ capturedCallback = callback;
156
+ return Promise.resolve(mockResponse);
157
+ });
158
+ const propertyNames = ['firstname', 'lastname'];
159
+ const mockCallback = jest.fn();
160
+ await fetchCrmProperties(propertyNames, mockCallback, DEFAULT_OPTIONS);
161
+ expect(typeof capturedCallback).toBe('function');
162
+ // Simulate the callback being called with new properties including null values
163
+ const updatedProps = { firstname: null, lastname: 'Updated Value' };
164
+ if (capturedCallback) {
165
+ capturedCallback(updatedProps);
166
+ }
167
+ expect(mockCallback).toHaveBeenCalledWith(updatedProps);
168
+ });
169
+ it('passes formatting options to the underlying fetch function', async () => {
170
+ const mockApiResponse = {
171
+ data: {
172
+ firstname: 'John',
173
+ lastname: 'Doe',
174
+ },
175
+ cleanup: jest.fn(),
176
+ };
177
+ const mockResponse = {
178
+ ok: true,
179
+ json: jest.fn().mockResolvedValue(mockApiResponse),
180
+ };
181
+ mockFetchCrmProperties.mockResolvedValue(mockResponse);
182
+ const propertyNames = ['firstname', 'lastname'];
183
+ const options = {
184
+ propertiesToFormat: ['firstname'],
185
+ formattingOptions: {
186
+ date: {
187
+ format: 'MM/dd/yyyy',
188
+ },
189
+ currency: {
190
+ addSymbol: true,
191
+ },
192
+ },
193
+ };
194
+ await fetchCrmProperties(propertyNames, jest.fn(), options);
195
+ expect(mockFetchCrmProperties).toHaveBeenCalledWith(propertyNames, expect.any(Function), options);
196
+ });
197
+ it('preserves error handling with formatting options', async () => {
198
+ mockFetchCrmProperties.mockRejectedValue(new Error('Network error'));
199
+ const propertyNames = ['firstname'];
200
+ const options = {
201
+ propertiesToFormat: ['firstname'],
202
+ formattingOptions: {
203
+ date: {
204
+ format: 'MM/dd/yyyy',
205
+ },
206
+ },
207
+ };
208
+ await expect(fetchCrmProperties(propertyNames, jest.fn(), options)).rejects.toThrow('Network error');
209
+ expect(mockFetchCrmProperties).toHaveBeenCalledWith(propertyNames, expect.any(Function), options);
210
+ });
211
+ it('preserves response validation with formatting options', async () => {
212
+ const mockApiResponse = {
213
+ data: 'Invalid response',
214
+ cleanup: jest.fn(),
215
+ };
216
+ const mockResponse = {
217
+ ok: true,
218
+ json: jest.fn().mockResolvedValue(mockApiResponse),
219
+ };
220
+ mockFetchCrmProperties.mockResolvedValue(mockResponse);
221
+ const propertyNames = ['firstname'];
222
+ const options = {
223
+ propertiesToFormat: 'all',
224
+ };
225
+ await expect(fetchCrmProperties(propertyNames, jest.fn(), options)).rejects.toThrow('Invalid response format');
226
+ });
227
+ it('returns cleanup function that can be called', async () => {
228
+ const mockCleanup = jest.fn();
229
+ const mockApiResponse = {
230
+ data: {
231
+ firstname: 'John',
232
+ },
233
+ cleanup: mockCleanup,
234
+ };
235
+ const mockResponse = {
236
+ ok: true,
237
+ json: jest.fn().mockResolvedValue(mockApiResponse),
238
+ };
239
+ mockFetchCrmProperties.mockResolvedValue(mockResponse);
240
+ const propertyNames = ['firstname'];
241
+ const result = await fetchCrmProperties(propertyNames, jest.fn(), DEFAULT_OPTIONS);
242
+ expect(result.cleanup).toBe(mockCleanup);
243
+ expect(typeof result.cleanup).toBe('function');
244
+ // Verify cleanup can be called without errors
245
+ result.cleanup();
246
+ expect(mockCleanup).toHaveBeenCalledTimes(1);
247
+ });
83
248
  });
@@ -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
  });
@@ -1,4 +1,24 @@
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
+ cleanup: () => void;
23
+ }
24
+ export declare const fetchCrmProperties: (propertyNames: string[], propertiesUpdatedCallback: (properties: Record<string, string | null>) => void, options?: FetchCrmPropertiesOptions) => Promise<FetchCrmPropertiesResult>;
@@ -4,24 +4,25 @@ function isCrmPropertiesResponse(data) {
4
4
  // Confirm the data is a defined object
5
5
  data === null ||
6
6
  typeof data !== 'object' ||
7
- // Confirm all keys and values are strings
8
- !Object.keys(data).every((key) => typeof key === 'string' && typeof data[key] === 'string')) {
7
+ // Confirm all keys and values are strings, or null
8
+ !Object.keys(data).every((key) => typeof key === 'string' &&
9
+ (typeof data[key] === 'string' || data[key] === null))) {
9
10
  return false;
10
11
  }
11
12
  return true;
12
13
  }
13
- export const fetchCrmProperties = async (propertyNames, propertiesUpdatedCallback) => {
14
+ export const fetchCrmProperties = async (propertyNames, propertiesUpdatedCallback, options) => {
14
15
  try {
15
16
  // eslint-disable-next-line hubspot-dev/no-confusing-browser-globals
16
- const response = await self.fetchCrmProperties(propertyNames, propertiesUpdatedCallback);
17
+ const response = await self.fetchCrmProperties(propertyNames, propertiesUpdatedCallback, options);
17
18
  if (!response.ok) {
18
19
  throw new Error(`Failed to fetch CRM properties: ${response.statusText}`);
19
20
  }
20
- const data = await response.json();
21
- if (!isCrmPropertiesResponse(data)) {
21
+ const result = await response.json();
22
+ if (!isCrmPropertiesResponse(result.data)) {
22
23
  throw new Error('Invalid response format');
23
24
  }
24
- return data;
25
+ return result;
25
26
  }
26
27
  catch (error) {
27
28
  if (error instanceof Error) {
@@ -1,5 +1,6 @@
1
+ import { type FetchCrmPropertiesOptions } from '../crm/fetchCrmProperties';
1
2
  export interface CrmPropertiesState {
2
- properties: Record<string, string>;
3
+ properties: Record<string, string | null>;
3
4
  error: Error | null;
4
5
  isLoading: boolean;
5
6
  }
@@ -8,4 +9,4 @@ export interface CrmPropertiesState {
8
9
  *
9
10
  * @experimental This hook is experimental and might change or be removed in future versions.
10
11
  */
11
- export declare function useCrmProperties(propertyNames: string[]): CrmPropertiesState;
12
+ export declare function useCrmProperties(propertyNames: string[], options?: FetchCrmPropertiesOptions): CrmPropertiesState;
@@ -1,12 +1,13 @@
1
- import { useEffect, useState } from 'react';
1
+ import { useEffect, useState, useMemo, useRef } from 'react';
2
2
  import { logger } from '../../logger';
3
- import { fetchCrmProperties } from '../crm/fetchCrmProperties';
3
+ import { fetchCrmProperties, } from '../crm/fetchCrmProperties';
4
+ const DEFAULT_OPTIONS = {};
4
5
  /**
5
6
  * A hook for using and managing CRM properties.
6
7
  *
7
8
  * @experimental This hook is experimental and might change or be removed in future versions.
8
9
  */
9
- export function useCrmProperties(propertyNames) {
10
+ export function useCrmProperties(propertyNames, options = DEFAULT_OPTIONS) {
10
11
  const [properties, setProperties] = useState({});
11
12
  const [isLoading, setIsLoading] = useState(true);
12
13
  const [error, setError] = useState(null);
@@ -14,33 +15,74 @@ export function useCrmProperties(propertyNames) {
14
15
  useEffect(() => {
15
16
  logger.warn('useCrmProperties is an experimental hook and might change or be removed in the future.');
16
17
  }, []);
17
- const propertiesUpdatedCallback = (newProperties) => {
18
- setProperties(newProperties);
19
- };
18
+ /**
19
+ * HOOK OPTIMIZATION:
20
+ *
21
+ * Create stable references for propertyNames and options to prevent unnecessary re-renders and API calls.
22
+ * Then, external developers can pass inline arrays/objects without worrying about memoization
23
+ * We handle the deep equality comparison ourselves, and return the same object reference when content is equivalent.
24
+ */
25
+ const lastPropertyNamesRef = useRef();
26
+ const lastPropertyNamesKeyRef = useRef();
27
+ const lastOptionsRef = useRef();
28
+ const lastOptionsKeyRef = useRef();
29
+ const stablePropertyNames = useMemo(() => {
30
+ const sortedNames = [...propertyNames].sort();
31
+ const propertyNamesKey = JSON.stringify(sortedNames);
32
+ if (propertyNamesKey === lastPropertyNamesKeyRef.current) {
33
+ return lastPropertyNamesRef.current;
34
+ }
35
+ lastPropertyNamesKeyRef.current = propertyNamesKey;
36
+ lastPropertyNamesRef.current = sortedNames;
37
+ return sortedNames;
38
+ }, [propertyNames]);
39
+ const stableOptions = useMemo(() => {
40
+ const optionsKey = JSON.stringify(options);
41
+ if (optionsKey === lastOptionsKeyRef.current) {
42
+ return lastOptionsRef.current;
43
+ }
44
+ lastOptionsKeyRef.current = optionsKey;
45
+ lastOptionsRef.current = options;
46
+ return options;
47
+ }, [options]);
20
48
  // Fetch the properties
21
49
  useEffect(() => {
22
- (async () => {
50
+ let cancelled = false;
51
+ let cleanup = null;
52
+ const fetchData = async () => {
23
53
  try {
24
- const propertyData = await fetchCrmProperties(propertyNames, propertiesUpdatedCallback);
25
- setProperties(propertyData);
54
+ setIsLoading(true);
26
55
  setError(null);
56
+ const result = await fetchCrmProperties(stablePropertyNames, setProperties, stableOptions);
57
+ if (!cancelled) {
58
+ setProperties(result.data);
59
+ cleanup = result.cleanup;
60
+ }
27
61
  }
28
62
  catch (err) {
29
- const errorData = err instanceof Error
30
- ? err
31
- : new Error('Failed to fetch CRM properties');
32
- setError(errorData);
33
- setProperties({});
63
+ if (!cancelled) {
64
+ const errorData = err instanceof Error
65
+ ? err
66
+ : new Error('Failed to fetch CRM properties');
67
+ setError(errorData);
68
+ setProperties({});
69
+ }
34
70
  }
35
71
  finally {
36
- setIsLoading(false);
72
+ if (!cancelled) {
73
+ setIsLoading(false);
74
+ }
37
75
  }
38
- })().catch((err) => {
39
- setError(err);
40
- setProperties({});
41
- setIsLoading(false);
42
- });
43
- }, [propertyNames]);
76
+ };
77
+ fetchData();
78
+ return () => {
79
+ cancelled = true;
80
+ // Call cleanup function to release RPC resources
81
+ if (cleanup) {
82
+ cleanup();
83
+ }
84
+ };
85
+ }, [stablePropertyNames, stableOptions]);
44
86
  return {
45
87
  properties,
46
88
  error,
@@ -83,4 +83,18 @@ declare const FileInput: "FileInput" & {
83
83
  readonly props?: experimentalTypes.FileInputProps | undefined;
84
84
  readonly children?: true | undefined;
85
85
  } & import("@remote-ui/react").ReactComponentTypeFromRemoteComponentType<import("@remote-ui/types").RemoteComponentType<"FileInput", experimentalTypes.FileInputProps, true>>;
86
- export { Iframe, MediaObject, Inline, Stack2, Center, SimpleGrid, GridItem, Grid, SettingsView, ExpandableText, Popover, FileInput, };
86
+ /**
87
+ * The TimeInput component renders an input field where a user can select a time.
88
+ */
89
+ declare const TimeInput: "TimeInput" & {
90
+ readonly type?: "TimeInput" | undefined;
91
+ readonly props?: experimentalTypes.TimeInputProps | undefined;
92
+ readonly children?: true | undefined;
93
+ } & import("@remote-ui/react").ReactComponentTypeFromRemoteComponentType<import("@remote-ui/types").RemoteComponentType<"TimeInput", experimentalTypes.TimeInputProps, true>>;
94
+ /** @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. */
95
+ declare const CurrencyInput: "CurrencyInput" & {
96
+ readonly type?: "CurrencyInput" | undefined;
97
+ readonly props?: experimentalTypes.CurrencyInputProps | undefined;
98
+ readonly children?: true | undefined;
99
+ } & 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, };
@@ -34,8 +34,12 @@ const ExpandableText = createRemoteReactComponent('ExpandableText');
34
34
  *
35
35
  * - {@link https://developers.hubspot.com/docs/reference/ui-components/standard-components/popover Popover Docs}
36
36
  */
37
- const Popover = createRemoteReactComponent('Popover', {
38
- fragmentProps: ['header', 'body', 'footer'],
39
- });
37
+ const Popover = createRemoteReactComponent('Popover');
40
38
  const FileInput = createRemoteReactComponent('FileInput');
41
- export { Iframe, MediaObject, Inline, Stack2, Center, SimpleGrid, GridItem, Grid, SettingsView, ExpandableText, Popover, FileInput, };
39
+ /**
40
+ * The TimeInput component renders an input field where a user can select a time.
41
+ */
42
+ const TimeInput = createRemoteReactComponent('TimeInput');
43
+ /** @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
+ const CurrencyInput = createRemoteReactComponent('CurrencyInput');
45
+ export { Iframe, MediaObject, Inline, Stack2, Center, SimpleGrid, GridItem, Grid, SettingsView, ExpandableText, Popover, FileInput, TimeInput, CurrencyInput, };
@@ -127,17 +127,39 @@ export interface ExpandableTextProps {
127
127
  * @experimental do not use in production
128
128
  */
129
129
  export interface PopoverProps {
130
+ /**
131
+ * A unique ID for the popover. Used to identify the popover in the overlay system.
132
+ *
133
+ */
134
+ id: string;
135
+ /**
136
+ * The content to render inside the popover.
137
+ */
130
138
  children: ReactNode;
131
- open?: boolean;
132
- onOpenChange?: () => void;
139
+ /**
140
+ * The placement of the popover.
141
+ *
142
+ * @defaultValue `top`
143
+ */
133
144
  placement?: 'left' | 'right' | 'top' | 'bottom';
134
- header?: ReactNode;
135
- body?: ReactNode;
136
- footer?: ReactNode;
145
+ /**
146
+ * The variant of the popover.
147
+ *
148
+ * @defaultValue `default`
149
+ */
137
150
  variant?: 'default' | 'shepherd' | 'longform';
151
+ /**
152
+ * If set to `true`, will show the close button in the popover. PopoverHeader required to display close button.
153
+ *
154
+ * @defaultValue `false`
155
+ */
138
156
  showCloseButton?: boolean;
157
+ /**
158
+ * The size of the arrow in the popover. If set to `none`, the arrow will not be displayed.
159
+ *
160
+ * @defaultValue `small`
161
+ */
139
162
  arrowSize?: 'none' | 'small' | 'medium';
140
- onClick?: ReactionsHandler<ExtensionEvent>;
141
163
  }
142
164
  export interface FileInputProps {
143
165
  value?: File | {
@@ -146,4 +168,167 @@ export interface FileInputProps {
146
168
  name: string;
147
169
  onChange: (event: any) => void;
148
170
  }
171
+ /**
172
+ * @ignore
173
+ * @experimental do not use in production
174
+ */
175
+ export interface BaseTime {
176
+ /** The hour for the time (0 to 23) in 24-hour format (e.g. 0 = 12:00 AM, 9 = 9:00 AM, 15 = 3:00 PM). */
177
+ hours: number;
178
+ /** The minutes for the time (0 to 59). */
179
+ minutes: number;
180
+ }
181
+ /**
182
+ * Generic collection of props for all inputs (experimental version)
183
+ * @internal
184
+ * */
185
+ export interface BaseInputProps<T = string, V = string> {
186
+ /**
187
+ * The label text to display for the form input element.
188
+ */
189
+ label: string;
190
+ /**
191
+ * The unique identifier for the input element, this could be thought of as the HTML5 [Input element's name attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#name).
192
+ */
193
+ name: string;
194
+ /**
195
+ * The value of the input.
196
+ */
197
+ value?: T;
198
+ /**
199
+ * Determines if the required indicator should be displayed.
200
+ *
201
+ * @defaultValue `false`
202
+ */
203
+ required?: boolean;
204
+ /**
205
+ * Determines if the field is editable or not.
206
+ *
207
+ * @defaultValue `false`
208
+ */
209
+ readOnly?: boolean;
210
+ /**
211
+ * Instructional message to display to the user to help understand the purpose of the input.
212
+ */
213
+ description?: string;
214
+ /**
215
+ * Text that will appear in a tooltip next to the input label.
216
+ */
217
+ tooltip?: string;
218
+ /**
219
+ * Text that appears in the input when it has no value set.
220
+ */
221
+ placeholder?: string;
222
+ /**
223
+ * If set to `true`, `validationMessage` is displayed as an error message, if it was provided. The input will also render its error state to let the user know there is an error. If set to `false`, `validationMessage` is displayed as a success message.
224
+ *
225
+ * @defaultValue `false`
226
+ */
227
+ error?: boolean;
228
+ /**
229
+ * The value of the input on the first render.
230
+ */
231
+ defaultValue?: T;
232
+ /**
233
+ * The text to show under the input for error or success validations.
234
+ */
235
+ validationMessage?: string;
236
+ /**
237
+ * A callback function that is invoked when the value is committed. Currently these times are `onBlur` of the input and when the user submits the form.
238
+ *
239
+ * @event
240
+ */
241
+ onChange?: (value: V) => void;
242
+ /**
243
+ * A function that is called and passed the value every time the field is edited by the user. It is recommended that you do not use this value to update state, that is what `onChange` should be used for. Instead this should be used for validation.
244
+ *
245
+ * @event
246
+ */
247
+ onInput?: (value: V) => void;
248
+ /**
249
+ * A function that is called and passed the value every time the field loses focus.
250
+ *
251
+ * @event
252
+ */
253
+ onBlur?: (value: V) => void;
254
+ /**
255
+ * A function that is called and passed the value every time the field gets focused.
256
+ *
257
+ * @event
258
+ */
259
+ onFocus?: (value: V) => void;
260
+ }
261
+ /**
262
+ * @ignore
263
+ * @experimental do not use in production
264
+ *
265
+ * The values used to invoke events on the TimeInput component
266
+ */
267
+ export interface TimeInputEventsPayload extends BaseTime {
268
+ }
269
+ /**
270
+ * @internal
271
+ * @ignore
272
+ * */
273
+ type BaseTimeInputForTime = Omit<BaseInputProps<BaseTime | null, TimeInputEventsPayload>, 'onInput' | 'placeholder' | 'onChange'>;
274
+ /**
275
+ * @ignore
276
+ * @experimental do not use in production
277
+ */
278
+ export interface TimeInputProps extends BaseTimeInputForTime {
279
+ /**
280
+ * A callback function that is invoked when the value is changed.
281
+ *
282
+ * @event
283
+ */
284
+ onChange?: (value: TimeInputEventsPayload) => void;
285
+ /**
286
+ * Sets the earliest time that will be valid.
287
+ */
288
+ min?: BaseTime;
289
+ /**
290
+ * Sets the latest time that will be valid.
291
+ */
292
+ max?: BaseTime;
293
+ /**
294
+ * Sets the interval (in minutes) between the dropdown options.
295
+ *
296
+ * @defaultValue `30`
297
+ */
298
+ interval?: number;
299
+ /**
300
+ * Sets the timezone that the component will display alongside times in the TimePicker. This will not adjust the available valid inputs.
301
+ *
302
+ */
303
+ timezone?: 'userTz' | 'portalTz';
304
+ }
305
+ /**
306
+ * @ignore
307
+ * @experimental do not use in production
308
+ */
309
+ type BaseInputForNumber = Omit<BaseInputProps<number, number>, 'onInput'>;
310
+ /**
311
+ * @ignore
312
+ * @experimental do not use in production
313
+ */
314
+ export interface CurrencyInputProps extends BaseInputForNumber {
315
+ /**
316
+ * ISO 4217 currency code (e.g., "USD", "EUR", "JPY")
317
+ * @defaultValue "USD"
318
+ */
319
+ currency?: string;
320
+ /**
321
+ * Sets the number of decimal places for the currency
322
+ * If not provided, defaults to currency-specific precision
323
+ */
324
+ precision?: number;
325
+ /**
326
+ * Sets the lower bound of the input
327
+ */
328
+ min?: number;
329
+ /**
330
+ * Sets the upper bound of the input
331
+ */
332
+ max?: number;
333
+ }
149
334
  export {};
package/dist/types.d.ts CHANGED
@@ -1164,7 +1164,7 @@ export interface DateInputProps extends BaseDateInputForDate {
1164
1164
  */
1165
1165
  format?: 'YYYY-MM-DD' | 'L' | 'LL' | 'll' | 'short' | 'long' | 'medium' | 'standard';
1166
1166
  /**
1167
- * Sets the timezone that the component will user to calculate valid dates.
1167
+ * Sets the timezone that the component will use to calculate valid dates.
1168
1168
  *
1169
1169
  * @defaultValue `"userTz"`
1170
1170
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hubspot/ui-extensions",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
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": "c26a19d3df9eaef76a109947dfda71a9cc72451c"
69
+ "gitHead": "a5c7f8d19d0dc16b44e9235d3e12dacc2cc14fdf"
70
70
  }