@hubspot/ui-extensions 0.12.4 → 0.13.1

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.
Files changed (85) hide show
  1. package/dist/__generated__/version.d.ts +2 -0
  2. package/dist/__generated__/version.js +4 -0
  3. package/dist/__tests__/crm/hooks/useAssociations.spec.js +79 -0
  4. package/dist/__tests__/crm/utils/fetchAssociations.spec.js +0 -10
  5. package/dist/__tests__/experimental/hooks/useCrmSearch.spec.js +586 -0
  6. package/dist/__tests__/experimental/hooks/utils/fetchCrmSearch.spec.js +217 -0
  7. package/dist/__tests__/hooks/useDebounce.spec.js +123 -0
  8. package/dist/__tests__/hooks/utils/useFetchLifecycle.spec.js +324 -0
  9. package/dist/__tests__/internal/hook-utils.spec.js +17 -0
  10. package/dist/__tests__/test-d/extension-points.test-d.js +1 -0
  11. package/dist/crm/hooks/useAssociations.d.ts +3 -3
  12. package/dist/crm/hooks/useAssociations.js +55 -139
  13. package/dist/crm/hooks/useCrmProperties.js +29 -125
  14. package/dist/crm/utils/fetchAssociations.js +0 -3
  15. package/dist/experimental/hooks/useCrmSearch.d.ts +2 -0
  16. package/dist/experimental/hooks/useCrmSearch.js +206 -0
  17. package/dist/experimental/hooks/utils/fetchCrmSearch.d.ts +6 -0
  18. package/dist/experimental/hooks/utils/fetchCrmSearch.js +39 -0
  19. package/dist/experimental/index.d.ts +1 -0
  20. package/dist/experimental/index.js +1 -0
  21. package/dist/hooks/useDebounce.d.ts +19 -0
  22. package/dist/hooks/useDebounce.js +32 -0
  23. package/dist/hooks/utils/useFetchLifecycle.d.ts +35 -0
  24. package/dist/hooks/utils/useFetchLifecycle.js +103 -0
  25. package/dist/index.d.ts +1 -0
  26. package/dist/index.js +1 -0
  27. package/dist/internal/hook-utils.d.ts +6 -0
  28. package/dist/internal/hook-utils.js +16 -1
  29. package/dist/{experimental/pages → pages}/components/page-routes.d.ts +1 -2
  30. package/dist/{experimental/pages → pages}/components/page-routes.js +0 -4
  31. package/dist/{experimental/pages → pages}/create-page-router.d.ts +1 -3
  32. package/dist/{experimental/pages → pages}/create-page-router.js +1 -3
  33. package/dist/{experimental/pages → pages}/create-page-router.test.js +2 -2
  34. package/dist/{experimental/pages → pages}/hooks.d.ts +0 -1
  35. package/dist/{experimental/pages → pages}/hooks.js +0 -1
  36. package/dist/pages/index.d.ts +6 -1
  37. package/dist/pages/index.js +4 -1
  38. package/dist/{experimental/pages → pages}/internal/page-router-internal-types.d.ts +1 -1
  39. package/dist/pages/internal/useAppPageLocation.d.ts +1 -0
  40. package/dist/{experimental/pages → pages}/internal/useAppPageLocation.js +2 -2
  41. package/dist/{experimental/pages → pages}/types.d.ts +1 -2
  42. package/dist/pages/types.js +1 -0
  43. package/dist/shared/types/actions.d.ts +12 -2
  44. package/dist/shared/types/components/table.d.ts +16 -0
  45. package/dist/shared/types/context.d.ts +6 -0
  46. package/dist/shared/types/crm.d.ts +4 -0
  47. package/dist/shared/types/experimental.d.ts +1 -1
  48. package/dist/shared/types/extension-points.d.ts +9 -3
  49. package/dist/shared/types/extension-points.js +1 -0
  50. package/dist/shared/types/hooks.d.ts +72 -0
  51. package/dist/shared/types/hooks.js +1 -0
  52. package/dist/shared/types/shared.d.ts +8 -0
  53. package/dist/shared/types/worker-globals.d.ts +5 -0
  54. package/dist/testing/__tests__/createRenderer.spec.js +1 -1
  55. package/dist/testing/internal/mocks/index.d.ts +1 -1
  56. package/dist/testing/internal/mocks/mock-extension-point-api.js +21 -1
  57. package/dist/testing/internal/mocks/mock-hooks.js +26 -0
  58. package/dist/testing/internal/types-internal.d.ts +2 -0
  59. package/dist/testing/types.d.ts +11 -1
  60. package/dist/utils/pagination.d.ts +0 -9
  61. package/package.json +3 -2
  62. package/dist/experimental/pages/index.d.ts +0 -6
  63. package/dist/experimental/pages/index.js +0 -4
  64. package/dist/experimental/pages/internal/useAppPageLocation.d.ts +0 -1
  65. package/dist/shared/types/pages/app-pages-types.d.ts +0 -75
  66. package/dist/shared/types/pages/components/page-routes.d.ts +0 -115
  67. package/dist/shared/types/pages/index.d.ts +0 -1
  68. package/dist/shared/types/pages.d.ts +0 -1
  69. /package/dist/{experimental/pages/create-page-router.test.d.ts → __tests__/experimental/hooks/useCrmSearch.spec.d.ts} +0 -0
  70. /package/dist/{experimental/pages/internal/trie-router.test.d.ts → __tests__/experimental/hooks/utils/fetchCrmSearch.spec.d.ts} +0 -0
  71. /package/dist/{experimental/pages/types.js → __tests__/hooks/useDebounce.spec.d.ts} +0 -0
  72. /package/dist/{shared/types/pages.js → __tests__/hooks/utils/useFetchLifecycle.spec.d.ts} +0 -0
  73. /package/dist/{shared/types/pages/app-pages-types.js → __tests__/internal/hook-utils.spec.d.ts} +0 -0
  74. /package/dist/{experimental/pages → pages}/components/index.d.ts +0 -0
  75. /package/dist/{experimental/pages → pages}/components/index.js +0 -0
  76. /package/dist/{shared/types/pages/components/page-routes.js → pages/create-page-router.test.d.ts} +0 -0
  77. /package/dist/{experimental/pages → pages}/internal/app-page-route-context.d.ts +0 -0
  78. /package/dist/{experimental/pages → pages}/internal/app-page-route-context.js +0 -0
  79. /package/dist/{experimental/pages → pages}/internal/convert-page-routes-react-elements.d.ts +0 -0
  80. /package/dist/{experimental/pages → pages}/internal/convert-page-routes-react-elements.js +0 -0
  81. /package/dist/{experimental/pages → pages}/internal/page-router-internal-types.js +0 -0
  82. /package/dist/{experimental/pages → pages}/internal/trie-router.d.ts +0 -0
  83. /package/dist/{experimental/pages → pages}/internal/trie-router.js +0 -0
  84. /package/dist/{shared/types/pages/index.js → pages/internal/trie-router.test.d.ts} +0 -0
  85. /package/dist/{experimental/pages → pages}/internal/trie-router.test.js +0 -0
@@ -5,6 +5,7 @@ expectAssignable({
5
5
  addAlert: () => { },
6
6
  copyTextToClipboard: async () => { },
7
7
  closeOverlay: () => { },
8
+ navigateToPage: () => { },
8
9
  reloadPage: () => { },
9
10
  openIframeModal: () => { },
10
11
  },
@@ -1,10 +1,10 @@
1
- import { type PaginationResult } from '../../utils/pagination.ts';
1
+ import type { PaginationResult } from '../../shared/types/hooks.ts';
2
2
  import type { AssociationResult, FetchAssociationsRequest } from '../utils/fetchAssociations.ts';
3
3
  import { type FetchCrmPropertiesOptions } from '../utils/fetchCrmProperties.ts';
4
- export interface UseAssociationsOptions {
4
+ export type UseAssociationsOptions = {
5
5
  propertiesToFormat?: 'all' | string[];
6
6
  formattingOptions?: FetchCrmPropertiesOptions['formattingOptions'];
7
- }
7
+ };
8
8
  export interface UseAssociationsResult {
9
9
  results: AssociationResult[];
10
10
  error: Error | null;
@@ -1,15 +1,15 @@
1
- import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
2
- import { createMockAwareHook } from "../../internal/hook-utils.js";
1
+ import { useCallback, useReducer, useState } from 'react';
2
+ import { useFetchLifecycle } from "../../hooks/utils/useFetchLifecycle.js";
3
+ import { createMockAwareHook, useStableValue, } from "../../internal/hook-utils.js";
3
4
  import { calculatePaginationFlags, DEFAULT_PAGE_SIZE, } from "../../utils/pagination.js";
4
5
  import { fetchAssociations } from "../utils/fetchAssociations.js";
5
- function createInitialState(pageSize) {
6
+ function createInitialState() {
6
7
  return {
7
8
  results: [],
8
9
  error: null,
9
10
  isLoading: true,
10
11
  isRefetching: false,
11
12
  currentPage: 1,
12
- pageSize,
13
13
  hasMore: false,
14
14
  currentOffset: undefined,
15
15
  nextOffset: undefined,
@@ -71,6 +71,7 @@ function associationsReducer(state, action) {
71
71
  ...state,
72
72
  currentPage: 1,
73
73
  results: [],
74
+ isLoading: true,
74
75
  hasMore: false,
75
76
  error: null,
76
77
  currentOffset: undefined,
@@ -108,40 +109,18 @@ const DEFAULT_OPTIONS = {};
108
109
  */
109
110
  function useAssociationsInternal(config, options = DEFAULT_OPTIONS) {
110
111
  const pageSize = config?.pageLength ?? DEFAULT_PAGE_SIZE;
111
- const [state, dispatch] = useReducer(associationsReducer, useMemo(() => createInitialState(pageSize), [pageSize]));
112
+ const [state, dispatch] = useReducer(associationsReducer, createInitialState());
112
113
  /**
113
- * HOOK OPTIMIZATION:
114
- *
115
- * Create stable references for config and options to prevent unnecessary re-renders and API calls.
116
- * Then, external developers can pass inline objects without worrying about memoization
117
- * We handle the deep equality comparison ourselves, and return the same object reference when content is equivalent.
114
+ * Reset pagination when pageSize changes.
115
+ * Render-phase update (not useEffect) avoids committing a render with stale paginated results.
118
116
  */
119
- const lastConfigRef = useRef();
120
- const lastConfigKeyRef = useRef();
121
- const lastOptionsRef = useRef();
122
- const lastOptionsKeyRef = useRef();
123
- // Track in-flight refetch to support cancellation
124
- const refetchAbortRef = useRef(null);
125
- // Track refetch cleanup function to prevent memory leaks
126
- const refetchCleanupRef = useRef(null);
127
- const stableConfig = useMemo(() => {
128
- const configKey = JSON.stringify(config);
129
- if (configKey === lastConfigKeyRef.current) {
130
- return lastConfigRef.current;
131
- }
132
- lastConfigKeyRef.current = configKey;
133
- lastConfigRef.current = config;
134
- return config;
135
- }, [config]);
136
- const stableOptions = useMemo(() => {
137
- const optionsKey = JSON.stringify(options);
138
- if (optionsKey === lastOptionsKeyRef.current) {
139
- return lastOptionsRef.current;
140
- }
141
- lastOptionsKeyRef.current = optionsKey;
142
- lastOptionsRef.current = options;
143
- return options;
144
- }, [options]);
117
+ const [prevPageSize, setPrevPageSize] = useState(pageSize);
118
+ if (prevPageSize !== pageSize) {
119
+ setPrevPageSize(pageSize);
120
+ dispatch({ type: 'RESET' });
121
+ }
122
+ const stableConfig = useStableValue(config);
123
+ const stableOptions = useStableValue(options);
145
124
  // Pagination actions
146
125
  const nextPage = useCallback(() => {
147
126
  const paginationFlags = calculatePaginationFlags(state.currentPage, state.hasMore);
@@ -156,122 +135,59 @@ function useAssociationsInternal(config, options = DEFAULT_OPTIONS) {
156
135
  }
157
136
  }, [state.currentPage, state.hasMore]);
158
137
  const reset = useCallback(() => {
159
- dispatch({ type: 'RESET' });
160
- }, []);
161
- // Fetch the associations
162
- useEffect(() => {
163
- let cancelled = false;
164
- let cleanup = null;
165
- const fetchData = async () => {
166
- try {
167
- dispatch({ type: 'FETCH_START' });
168
- // Build request using current offset token
169
- const request = {
170
- toObjectType: stableConfig?.toObjectType,
171
- properties: stableConfig?.properties,
172
- pageLength: pageSize,
173
- offset: state.currentOffset,
174
- };
175
- const result = await fetchAssociations(request, {
176
- propertiesToFormat: stableOptions.propertiesToFormat,
177
- formattingOptions: stableOptions.formattingOptions,
178
- });
179
- if (!cancelled) {
180
- dispatch({
181
- type: 'FETCH_SUCCESS',
182
- payload: {
183
- results: result.data.results,
184
- hasMore: result.data.hasMore,
185
- nextOffset: result.data.nextOffset,
186
- currentOffset: state.currentOffset,
187
- },
188
- });
189
- cleanup = result.cleanup;
190
- }
191
- }
192
- catch (err) {
193
- if (!cancelled) {
194
- const errorData = err instanceof Error
195
- ? err
196
- : new Error('Failed to fetch associations');
197
- dispatch({ type: 'FETCH_ERROR', payload: errorData });
198
- }
199
- }
200
- };
201
- fetchData();
202
- return () => {
203
- cancelled = true;
204
- // Call cleanup function to release resources
205
- if (cleanup) {
206
- cleanup();
207
- }
208
- // Clean up any active refetch subscription
209
- if (refetchCleanupRef.current) {
210
- refetchCleanupRef.current();
211
- refetchCleanupRef.current = null;
212
- }
213
- };
214
- }, [
215
- stableConfig,
216
- stableOptions,
217
- state.currentPage,
218
- state.currentOffset,
219
- pageSize,
220
- ]);
221
- const refetch = useCallback(async () => {
222
- if (refetchAbortRef.current) {
223
- refetchAbortRef.current.cancelled = true;
224
- }
225
- if (refetchCleanupRef.current) {
226
- refetchCleanupRef.current();
227
- refetchCleanupRef.current = null;
138
+ const paginationFlags = calculatePaginationFlags(state.currentPage, state.hasMore);
139
+ // Only reset to first page if we're not already on the first page
140
+ if (paginationFlags.hasPreviousPage) {
141
+ dispatch({ type: 'RESET' });
228
142
  }
229
- const abortSignal = { cancelled: false };
230
- refetchAbortRef.current = abortSignal;
231
- try {
232
- dispatch({ type: 'REFETCH_START' });
233
- // Build request using current offset token to refetch current page
143
+ }, [state.currentPage, state.hasMore]);
144
+ const { refetch } = useFetchLifecycle({
145
+ fetchFn: () => {
234
146
  const request = {
235
147
  toObjectType: stableConfig?.toObjectType,
236
148
  properties: stableConfig?.properties,
237
149
  pageLength: pageSize,
238
150
  offset: state.currentOffset,
239
151
  };
240
- const result = await fetchAssociations(request, {
152
+ return fetchAssociations(request, {
241
153
  propertiesToFormat: stableOptions.propertiesToFormat,
242
154
  formattingOptions: stableOptions.formattingOptions,
243
155
  });
244
- if (!abortSignal.cancelled) {
245
- dispatch({
156
+ },
157
+ callbacks: {
158
+ onStart: ({ isRefetch }) => dispatch({ type: isRefetch ? 'REFETCH_START' : 'FETCH_START' }),
159
+ onSuccess: (data, { isRefetch }) => dispatch(isRefetch
160
+ ? {
246
161
  type: 'REFETCH_SUCCESS',
247
162
  payload: {
248
- results: result.data.results,
249
- hasMore: result.data.hasMore,
250
- nextOffset: result.data.nextOffset,
163
+ results: data.results,
164
+ hasMore: data.hasMore,
165
+ nextOffset: data.nextOffset,
251
166
  },
252
- });
253
- refetchCleanupRef.current = result.cleanup;
254
- }
255
- else {
256
- if (result.cleanup) {
257
- result.cleanup();
258
167
  }
259
- }
260
- }
261
- catch (err) {
262
- if (!abortSignal.cancelled) {
263
- const errorData = err instanceof Error
264
- ? err
265
- : new Error('Failed to refetch associations');
266
- dispatch({ type: 'REFETCH_ERROR', payload: errorData });
267
- }
268
- }
269
- finally {
270
- if (refetchAbortRef.current === abortSignal) {
271
- refetchAbortRef.current = null;
272
- }
273
- }
274
- }, [stableConfig, stableOptions, state.currentOffset, pageSize]);
168
+ : {
169
+ type: 'FETCH_SUCCESS',
170
+ payload: {
171
+ results: data.results,
172
+ hasMore: data.hasMore,
173
+ nextOffset: data.nextOffset,
174
+ currentOffset: state.currentOffset,
175
+ },
176
+ }),
177
+ onError: (error, { isRefetch }) => dispatch({
178
+ type: isRefetch ? 'REFETCH_ERROR' : 'FETCH_ERROR',
179
+ payload: error,
180
+ }),
181
+ },
182
+ deps: [
183
+ stableConfig,
184
+ stableOptions,
185
+ state.currentPage,
186
+ state.currentOffset,
187
+ pageSize,
188
+ ],
189
+ defaultErrorMessage: 'Failed to fetch associations',
190
+ });
275
191
  // Calculate pagination flags
276
192
  const paginationFlags = calculatePaginationFlags(state.currentPage, state.hasMore);
277
193
  return {
@@ -284,7 +200,7 @@ function useAssociationsInternal(config, options = DEFAULT_OPTIONS) {
284
200
  hasNextPage: paginationFlags.hasNextPage,
285
201
  hasPreviousPage: paginationFlags.hasPreviousPage,
286
202
  currentPage: state.currentPage,
287
- pageSize: state.pageSize,
203
+ pageSize,
288
204
  nextPage,
289
205
  previousPage,
290
206
  reset,
@@ -1,5 +1,6 @@
1
- import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
2
- import { createMockAwareHook } from "../../internal/hook-utils.js";
1
+ import { useMemo, useReducer } from 'react';
2
+ import { useFetchLifecycle } from "../../hooks/utils/useFetchLifecycle.js";
3
+ import { createMockAwareHook, useStableValue, } from "../../internal/hook-utils.js";
3
4
  import { fetchCrmProperties } from "../utils/fetchCrmProperties.js";
4
5
  const initialState = {
5
6
  properties: {},
@@ -58,130 +59,33 @@ const DEFAULT_OPTIONS = {};
58
59
  */
59
60
  function useCrmPropertiesInternal(propertyNames, options = DEFAULT_OPTIONS) {
60
61
  const [state, dispatch] = useReducer(crmPropertiesReducer, initialState);
61
- /**
62
- * HOOK OPTIMIZATION:
63
- *
64
- * Create stable references for propertyNames and options to prevent unnecessary re-renders and API calls.
65
- * Then, external developers can pass inline arrays/objects without worrying about memoization
66
- * We handle the deep equality comparison ourselves, and return the same object reference when content is equivalent.
67
- */
68
- const lastPropertyNamesRef = useRef();
69
- const lastPropertyNamesKeyRef = useRef();
70
- const lastOptionsRef = useRef();
71
- const lastOptionsKeyRef = useRef();
72
- // Track in-flight refetch to support cancellation
73
- const refetchAbortRef = useRef(null);
74
- // Track refetch cleanup function to prevent memory leaks
75
- const refetchCleanupRef = useRef(null);
76
- const stablePropertyNames = useMemo(() => {
77
- if (!Array.isArray(propertyNames)) {
78
- return propertyNames;
79
- }
80
- const sortedNames = [...propertyNames].sort();
81
- const propertyNamesKey = JSON.stringify(sortedNames);
82
- if (propertyNamesKey === lastPropertyNamesKeyRef.current) {
83
- return lastPropertyNamesRef.current;
84
- }
85
- lastPropertyNamesKeyRef.current = propertyNamesKey;
86
- lastPropertyNamesRef.current = sortedNames;
87
- return sortedNames;
88
- }, [propertyNames]);
89
- const stableOptions = useMemo(() => {
90
- const optionsKey = JSON.stringify(options);
91
- if (optionsKey === lastOptionsKeyRef.current) {
92
- return lastOptionsRef.current;
93
- }
94
- lastOptionsKeyRef.current = optionsKey;
95
- lastOptionsRef.current = options;
96
- return options;
97
- }, [options]);
98
- // Fetch the properties
99
- useEffect(() => {
100
- let cancelled = false;
101
- let cleanup = null;
102
- const fetchData = async () => {
103
- try {
104
- dispatch({ type: 'FETCH_START' });
105
- const result = await fetchCrmProperties(stablePropertyNames, (data) => {
106
- if (!cancelled) {
107
- dispatch({ type: 'FETCH_SUCCESS', payload: data });
108
- }
109
- }, stableOptions);
110
- if (!cancelled) {
111
- dispatch({ type: 'FETCH_SUCCESS', payload: result.data });
112
- cleanup = result.cleanup;
113
- }
62
+ // Sort property names so that order doesn't matter for cache keys
63
+ const sortedPropertyNames = useMemo(() => Array.isArray(propertyNames) ? [...propertyNames].sort() : propertyNames, [propertyNames]);
64
+ const stablePropertyNames = useStableValue(sortedPropertyNames);
65
+ const stableOptions = useStableValue(options);
66
+ const { refetch } = useFetchLifecycle({
67
+ fetchFn: (signal) => fetchCrmProperties(stablePropertyNames, (data) => {
68
+ if (!signal.cancelled) {
69
+ dispatch({
70
+ type: signal.isRefetch ? 'REFETCH_SUCCESS' : 'FETCH_SUCCESS',
71
+ payload: data,
72
+ });
114
73
  }
115
- catch (err) {
116
- if (!cancelled) {
117
- const errorData = err instanceof Error
118
- ? err
119
- : new Error('Failed to fetch CRM properties');
120
- dispatch({ type: 'FETCH_ERROR', payload: errorData });
121
- }
122
- }
123
- };
124
- fetchData();
125
- return () => {
126
- cancelled = true;
127
- // Call cleanup function to release RPC resources
128
- if (cleanup) {
129
- cleanup();
130
- }
131
- // Clean up any active refetch subscription
132
- if (refetchCleanupRef.current) {
133
- refetchCleanupRef.current();
134
- refetchCleanupRef.current = null;
135
- }
136
- };
137
- }, [stablePropertyNames, stableOptions]);
138
- const refetch = useCallback(async () => {
139
- // Cancel any in-flight refetch
140
- if (refetchAbortRef.current) {
141
- refetchAbortRef.current.cancelled = true;
142
- }
143
- // Clean up old refetch subscription to prevent memory leaks
144
- if (refetchCleanupRef.current) {
145
- refetchCleanupRef.current();
146
- refetchCleanupRef.current = null;
147
- }
148
- // Create new abort signal for this refetch
149
- const abortSignal = { cancelled: false };
150
- refetchAbortRef.current = abortSignal;
151
- try {
152
- dispatch({ type: 'REFETCH_START' });
153
- const result = await fetchCrmProperties(stablePropertyNames, (data) => {
154
- if (!abortSignal.cancelled) {
155
- dispatch({ type: 'REFETCH_SUCCESS', payload: data });
156
- }
157
- }, stableOptions);
158
- if (!abortSignal.cancelled) {
159
- dispatch({ type: 'REFETCH_SUCCESS', payload: result.data });
160
- // Store cleanup for next refetch or unmount
161
- refetchCleanupRef.current = result.cleanup;
162
- }
163
- else {
164
- // If cancelled, clean up immediately
165
- if (result.cleanup) {
166
- result.cleanup();
167
- }
168
- }
169
- }
170
- catch (err) {
171
- if (!abortSignal.cancelled) {
172
- const errorData = err instanceof Error
173
- ? err
174
- : new Error('Failed to refetch CRM properties');
175
- dispatch({ type: 'REFETCH_ERROR', payload: errorData });
176
- }
177
- }
178
- finally {
179
- // Clear the abort ref if this is still the current refetch
180
- if (refetchAbortRef.current === abortSignal) {
181
- refetchAbortRef.current = null;
182
- }
183
- }
184
- }, [stablePropertyNames, stableOptions]);
74
+ }, stableOptions),
75
+ callbacks: {
76
+ onStart: ({ isRefetch }) => dispatch({ type: isRefetch ? 'REFETCH_START' : 'FETCH_START' }),
77
+ onSuccess: (data, { isRefetch }) => dispatch({
78
+ type: isRefetch ? 'REFETCH_SUCCESS' : 'FETCH_SUCCESS',
79
+ payload: data,
80
+ }),
81
+ onError: (error, { isRefetch }) => dispatch({
82
+ type: isRefetch ? 'REFETCH_ERROR' : 'FETCH_ERROR',
83
+ payload: error,
84
+ }),
85
+ },
86
+ deps: [stablePropertyNames, stableOptions],
87
+ defaultErrorMessage: 'Failed to fetch CRM properties',
88
+ });
185
89
  return {
186
90
  ...state,
187
91
  refetch,
@@ -28,9 +28,6 @@ export const fetchAssociations = async (request, options) => {
28
28
  if (result.error) {
29
29
  throw new Error(result.error);
30
30
  }
31
- if (!response.ok) {
32
- throw new Error(`Failed to fetch associations: ${response.statusText}`);
33
- }
34
31
  if (!isAssociationsResponse(result.data)) {
35
32
  throw new Error('Invalid response format');
36
33
  }
@@ -0,0 +1,2 @@
1
+ import type { FetchCrmSearchRequest, UseCrmSearchOptions, UseCrmSearchResult } from '../../shared/types/hooks.ts';
2
+ export declare function useCrmSearch(config: Omit<FetchCrmSearchRequest, 'after'>, options?: UseCrmSearchOptions): UseCrmSearchResult;
@@ -0,0 +1,206 @@
1
+ import { useCallback, useReducer, useState } from 'react';
2
+ import { useFetchLifecycle } from "../../hooks/utils/useFetchLifecycle.js";
3
+ import { useStableValue } from "../../internal/hook-utils.js";
4
+ import { calculatePaginationFlags, DEFAULT_PAGE_SIZE, } from "../../utils/pagination.js";
5
+ import { fetchCrmSearch } from "./utils/fetchCrmSearch.js";
6
+ function createInitialState() {
7
+ return {
8
+ results: [],
9
+ total: 0,
10
+ error: null,
11
+ isLoading: true,
12
+ isRefetching: false,
13
+ currentPage: 1,
14
+ hasMore: false,
15
+ currentCursor: undefined,
16
+ nextCursor: undefined,
17
+ offsetHistory: [],
18
+ };
19
+ }
20
+ function crmSearchReducer(state, action) {
21
+ switch (action.type) {
22
+ case 'FETCH_START':
23
+ return {
24
+ ...state,
25
+ isLoading: true,
26
+ error: null,
27
+ };
28
+ case 'FETCH_SUCCESS':
29
+ return {
30
+ ...state,
31
+ isLoading: false,
32
+ results: action.payload.results,
33
+ total: action.payload.total,
34
+ hasMore: action.payload.hasMore,
35
+ currentCursor: action.payload.currentCursor,
36
+ nextCursor: action.payload.nextCursor,
37
+ error: null,
38
+ };
39
+ case 'FETCH_ERROR':
40
+ return {
41
+ ...state,
42
+ isLoading: false,
43
+ error: action.payload,
44
+ results: [],
45
+ total: 0,
46
+ hasMore: false,
47
+ currentCursor: undefined,
48
+ nextCursor: undefined,
49
+ };
50
+ case 'NEXT_PAGE':
51
+ return {
52
+ ...state,
53
+ currentPage: state.currentPage + 1,
54
+ offsetHistory: state.currentCursor !== undefined
55
+ ? [...state.offsetHistory, state.currentCursor]
56
+ : state.offsetHistory,
57
+ currentCursor: state.nextCursor,
58
+ nextCursor: undefined,
59
+ };
60
+ case 'PREVIOUS_PAGE': {
61
+ const newPage = Math.max(1, state.currentPage - 1);
62
+ const newHistory = [...state.offsetHistory];
63
+ const previousCursor = newHistory.pop();
64
+ return {
65
+ ...state,
66
+ currentPage: newPage,
67
+ offsetHistory: newHistory,
68
+ currentCursor: previousCursor,
69
+ nextCursor: undefined,
70
+ };
71
+ }
72
+ case 'RESET':
73
+ return {
74
+ ...state,
75
+ currentPage: 1,
76
+ results: [],
77
+ total: 0,
78
+ isLoading: true,
79
+ hasMore: false,
80
+ error: null,
81
+ currentCursor: undefined,
82
+ nextCursor: undefined,
83
+ offsetHistory: [],
84
+ };
85
+ case 'REFETCH_START':
86
+ return {
87
+ ...state,
88
+ isRefetching: true,
89
+ error: null,
90
+ };
91
+ case 'REFETCH_SUCCESS':
92
+ return {
93
+ ...state,
94
+ isRefetching: false,
95
+ results: action.payload.results,
96
+ total: action.payload.total,
97
+ hasMore: action.payload.hasMore,
98
+ nextCursor: action.payload.nextCursor,
99
+ error: null,
100
+ };
101
+ case 'REFETCH_ERROR':
102
+ return {
103
+ ...state,
104
+ isRefetching: false,
105
+ error: action.payload,
106
+ };
107
+ default:
108
+ return state;
109
+ }
110
+ }
111
+ const DEFAULT_OPTIONS = {};
112
+ export function useCrmSearch(config, options = DEFAULT_OPTIONS) {
113
+ const pageSize = config?.pageSize ?? DEFAULT_PAGE_SIZE;
114
+ const [state, dispatch] = useReducer(crmSearchReducer, createInitialState());
115
+ // Reset pagination when pageSize changes.
116
+ // Render-phase update (not useEffect) avoids committing a render with stale paginated results.
117
+ const [prevPageSize, setPrevPageSize] = useState(pageSize);
118
+ if (prevPageSize !== pageSize) {
119
+ setPrevPageSize(pageSize);
120
+ dispatch({ type: 'RESET' });
121
+ }
122
+ const stableConfig = useStableValue(config);
123
+ const stableOptions = useStableValue(options);
124
+ const nextPage = useCallback(() => {
125
+ const paginationFlags = calculatePaginationFlags(state.currentPage, state.hasMore);
126
+ if (paginationFlags.hasNextPage) {
127
+ dispatch({ type: 'NEXT_PAGE' });
128
+ }
129
+ }, [state.currentPage, state.hasMore]);
130
+ const previousPage = useCallback(() => {
131
+ const paginationFlags = calculatePaginationFlags(state.currentPage, state.hasMore);
132
+ if (paginationFlags.hasPreviousPage) {
133
+ dispatch({ type: 'PREVIOUS_PAGE' });
134
+ }
135
+ }, [state.currentPage, state.hasMore]);
136
+ const reset = useCallback(() => {
137
+ const paginationFlags = calculatePaginationFlags(state.currentPage, state.hasMore);
138
+ if (paginationFlags.hasPreviousPage) {
139
+ dispatch({ type: 'RESET' });
140
+ }
141
+ }, [state.currentPage, state.hasMore]);
142
+ const { refetch } = useFetchLifecycle({
143
+ fetchFn: () => {
144
+ const request = {
145
+ objectType: stableConfig?.objectType,
146
+ properties: stableConfig?.properties,
147
+ query: stableConfig?.query,
148
+ filterGroups: stableConfig?.filterGroups,
149
+ sorts: stableConfig?.sorts,
150
+ pageSize,
151
+ after: state.currentCursor,
152
+ };
153
+ return fetchCrmSearch(request, {
154
+ propertiesToFormat: stableOptions.propertiesToFormat,
155
+ formattingOptions: stableOptions.formattingOptions,
156
+ });
157
+ },
158
+ callbacks: {
159
+ onStart: ({ isRefetch }) => dispatch({ type: isRefetch ? 'REFETCH_START' : 'FETCH_START' }),
160
+ onSuccess: (data, { isRefetch }) => dispatch(isRefetch
161
+ ? {
162
+ type: 'REFETCH_SUCCESS',
163
+ payload: {
164
+ results: data.results,
165
+ total: data.total,
166
+ hasMore: data.hasMore,
167
+ nextCursor: data.after,
168
+ },
169
+ }
170
+ : {
171
+ type: 'FETCH_SUCCESS',
172
+ payload: {
173
+ results: data.results,
174
+ total: data.total,
175
+ hasMore: data.hasMore,
176
+ nextCursor: data.after,
177
+ currentCursor: state.currentCursor,
178
+ },
179
+ }),
180
+ onError: (error, { isRefetch }) => dispatch({
181
+ type: isRefetch ? 'REFETCH_ERROR' : 'FETCH_ERROR',
182
+ payload: error,
183
+ }),
184
+ },
185
+ deps: [stableConfig, stableOptions, state.currentCursor],
186
+ defaultErrorMessage: 'Failed to fetch CRM search results',
187
+ });
188
+ const paginationFlags = calculatePaginationFlags(state.currentPage, state.hasMore);
189
+ return {
190
+ results: state.results,
191
+ total: state.total,
192
+ error: state.error,
193
+ isLoading: state.isLoading,
194
+ isRefetching: state.isRefetching,
195
+ refetch,
196
+ pagination: {
197
+ hasNextPage: paginationFlags.hasNextPage,
198
+ hasPreviousPage: paginationFlags.hasPreviousPage,
199
+ currentPage: state.currentPage,
200
+ pageSize,
201
+ nextPage,
202
+ previousPage,
203
+ reset,
204
+ },
205
+ };
206
+ }
@@ -0,0 +1,6 @@
1
+ import type { CrmSearchResponse, FetchCrmSearchRequest, FetchHookOptions } from '../../../shared/types/hooks.ts';
2
+ export interface FetchCrmSearchResult {
3
+ data: CrmSearchResponse;
4
+ cleanup: () => void;
5
+ }
6
+ export declare const fetchCrmSearch: (request: FetchCrmSearchRequest, options?: FetchHookOptions) => Promise<FetchCrmSearchResult>;