@hubspot/ui-extensions 0.13.0 → 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.
- package/dist/__generated__/version.d.ts +2 -0
- package/dist/__generated__/version.js +4 -0
- package/dist/__tests__/crm/hooks/useAssociations.spec.js +51 -0
- package/dist/__tests__/crm/utils/fetchAssociations.spec.js +0 -10
- package/dist/__tests__/experimental/hooks/useCrmSearch.spec.d.ts +1 -0
- package/dist/__tests__/experimental/hooks/useCrmSearch.spec.js +586 -0
- package/dist/__tests__/experimental/hooks/utils/fetchCrmSearch.spec.d.ts +1 -0
- package/dist/__tests__/experimental/hooks/utils/fetchCrmSearch.spec.js +217 -0
- package/dist/crm/hooks/useAssociations.d.ts +1 -1
- package/dist/crm/hooks/useAssociations.js +14 -5
- package/dist/crm/utils/fetchAssociations.js +0 -3
- package/dist/experimental/hooks/useCrmSearch.d.ts +2 -0
- package/dist/experimental/hooks/useCrmSearch.js +206 -0
- package/dist/experimental/hooks/utils/fetchCrmSearch.d.ts +6 -0
- package/dist/experimental/hooks/utils/fetchCrmSearch.js +39 -0
- package/dist/experimental/index.d.ts +1 -0
- package/dist/experimental/index.js +1 -0
- package/dist/shared/types/components/table.d.ts +16 -0
- package/dist/shared/types/context.d.ts +1 -0
- package/dist/shared/types/experimental.d.ts +1 -1
- package/dist/shared/types/hooks.d.ts +72 -0
- package/dist/shared/types/hooks.js +1 -0
- package/dist/shared/types/worker-globals.d.ts +5 -0
- package/dist/testing/internal/mocks/mock-hooks.js +26 -0
- package/dist/testing/internal/types-internal.d.ts +2 -0
- package/dist/testing/types.d.ts +10 -0
- package/dist/utils/pagination.d.ts +0 -9
- package/package.json +2 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { vi } from 'vitest';
|
|
2
|
+
import { fetchCrmSearch } from "../../../../experimental/hooks/utils/fetchCrmSearch.js";
|
|
3
|
+
const mockFetchCrmSearch = vi.fn();
|
|
4
|
+
// Mock the global self object to match the hsWorkerAPI shape
|
|
5
|
+
Object.defineProperty(global, 'self', {
|
|
6
|
+
value: {
|
|
7
|
+
__HUBSPOT_EXTENSION_WORKER__: true,
|
|
8
|
+
hsWorkerAPI: {
|
|
9
|
+
fetchCrmSearch: mockFetchCrmSearch,
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
writable: true,
|
|
13
|
+
});
|
|
14
|
+
describe('fetchCrmSearch', () => {
|
|
15
|
+
const createMockResponse = (data, overrides = {}) => ({
|
|
16
|
+
ok: true,
|
|
17
|
+
json: vi.fn().mockResolvedValue({ data, cleanup: vi.fn() }),
|
|
18
|
+
...overrides,
|
|
19
|
+
});
|
|
20
|
+
const createBasicRequest = (overrides = {}) => ({
|
|
21
|
+
objectType: '0-1',
|
|
22
|
+
properties: ['firstname', 'lastname'],
|
|
23
|
+
...overrides,
|
|
24
|
+
});
|
|
25
|
+
const createValidResponseData = (overrides = {}) => ({
|
|
26
|
+
results: [
|
|
27
|
+
{
|
|
28
|
+
objectId: 1001,
|
|
29
|
+
properties: { firstname: 'John', lastname: 'Doe' },
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
total: 1,
|
|
33
|
+
hasMore: false,
|
|
34
|
+
...overrides,
|
|
35
|
+
});
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
vi.clearAllMocks();
|
|
38
|
+
});
|
|
39
|
+
describe('basic functionality', () => {
|
|
40
|
+
it('should successfully fetch CRM search results', async () => {
|
|
41
|
+
const data = createValidResponseData();
|
|
42
|
+
mockFetchCrmSearch.mockResolvedValueOnce(createMockResponse(data));
|
|
43
|
+
const request = createBasicRequest();
|
|
44
|
+
const result = await fetchCrmSearch(request);
|
|
45
|
+
expect(result).toHaveProperty('data');
|
|
46
|
+
expect(result).toHaveProperty('cleanup');
|
|
47
|
+
expect(typeof result.cleanup).toBe('function');
|
|
48
|
+
expect(result.data).toEqual(data);
|
|
49
|
+
});
|
|
50
|
+
it('should return a Promise', () => {
|
|
51
|
+
mockFetchCrmSearch.mockResolvedValueOnce(createMockResponse(createValidResponseData()));
|
|
52
|
+
const result = fetchCrmSearch(createBasicRequest());
|
|
53
|
+
expect(result).toBeInstanceOf(Promise);
|
|
54
|
+
});
|
|
55
|
+
it('should support all request fields (query, filterGroups, sorts, pageSize, after)', async () => {
|
|
56
|
+
mockFetchCrmSearch.mockResolvedValueOnce(createMockResponse(createValidResponseData({ hasMore: true, after: 'cursor-2' })));
|
|
57
|
+
const request = {
|
|
58
|
+
objectType: '0-1',
|
|
59
|
+
properties: ['firstname'],
|
|
60
|
+
query: 'john',
|
|
61
|
+
filterGroups: [
|
|
62
|
+
{
|
|
63
|
+
filters: [
|
|
64
|
+
{ propertyName: 'lifecyclestage', operator: 'EQ', value: 'lead' },
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
sorts: [
|
|
69
|
+
{ propertyName: 'createdate', direction: 'DESCENDING' },
|
|
70
|
+
],
|
|
71
|
+
pageSize: 25,
|
|
72
|
+
after: 'cursor-1',
|
|
73
|
+
};
|
|
74
|
+
await fetchCrmSearch(request);
|
|
75
|
+
expect(mockFetchCrmSearch).toHaveBeenCalledWith(request, undefined);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
describe('error handling', () => {
|
|
79
|
+
it('should throw an error when fetch fails', async () => {
|
|
80
|
+
mockFetchCrmSearch.mockRejectedValueOnce(new Error('Network error'));
|
|
81
|
+
await expect(fetchCrmSearch(createBasicRequest())).rejects.toThrow('Network error');
|
|
82
|
+
});
|
|
83
|
+
it('should handle unknown errors with a default message', async () => {
|
|
84
|
+
mockFetchCrmSearch.mockRejectedValueOnce('Unknown error');
|
|
85
|
+
await expect(fetchCrmSearch(createBasicRequest())).rejects.toThrow('Failed to fetch CRM search results: Unknown error');
|
|
86
|
+
});
|
|
87
|
+
it('should throw when server returns an error field', async () => {
|
|
88
|
+
mockFetchCrmSearch.mockResolvedValueOnce({
|
|
89
|
+
ok: true,
|
|
90
|
+
json: vi.fn().mockResolvedValue({
|
|
91
|
+
error: 'Permission denied',
|
|
92
|
+
cleanup: vi.fn(),
|
|
93
|
+
}),
|
|
94
|
+
});
|
|
95
|
+
await expect(fetchCrmSearch(createBasicRequest())).rejects.toThrow('Permission denied');
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
describe('response validation', () => {
|
|
99
|
+
it('should throw error when results field is missing', async () => {
|
|
100
|
+
mockFetchCrmSearch.mockResolvedValueOnce(createMockResponse({ total: 0, hasMore: false }) // missing results
|
|
101
|
+
);
|
|
102
|
+
await expect(fetchCrmSearch(createBasicRequest())).rejects.toThrow('Invalid response format');
|
|
103
|
+
});
|
|
104
|
+
it('should throw error when results is not an array', async () => {
|
|
105
|
+
mockFetchCrmSearch.mockResolvedValueOnce(createMockResponse({
|
|
106
|
+
results: 'not an array',
|
|
107
|
+
total: 0,
|
|
108
|
+
hasMore: false,
|
|
109
|
+
}));
|
|
110
|
+
await expect(fetchCrmSearch(createBasicRequest())).rejects.toThrow('Invalid response format');
|
|
111
|
+
});
|
|
112
|
+
it('should throw error when total is not a number', async () => {
|
|
113
|
+
mockFetchCrmSearch.mockResolvedValueOnce(createMockResponse({ results: [], total: 'zero', hasMore: false }));
|
|
114
|
+
await expect(fetchCrmSearch(createBasicRequest())).rejects.toThrow('Invalid response format');
|
|
115
|
+
});
|
|
116
|
+
it('should throw error when hasMore is not a boolean', async () => {
|
|
117
|
+
mockFetchCrmSearch.mockResolvedValueOnce(createMockResponse({ results: [], total: 0, hasMore: 'yes' }));
|
|
118
|
+
await expect(fetchCrmSearch(createBasicRequest())).rejects.toThrow('Invalid response format');
|
|
119
|
+
});
|
|
120
|
+
it('should throw error when data is null', async () => {
|
|
121
|
+
mockFetchCrmSearch.mockResolvedValueOnce(createMockResponse(null));
|
|
122
|
+
await expect(fetchCrmSearch(createBasicRequest())).rejects.toThrow('Invalid response format');
|
|
123
|
+
});
|
|
124
|
+
it('should throw error when a result is missing objectId', async () => {
|
|
125
|
+
mockFetchCrmSearch.mockResolvedValueOnce(createMockResponse({
|
|
126
|
+
results: [{ properties: {} }],
|
|
127
|
+
total: 1,
|
|
128
|
+
hasMore: false,
|
|
129
|
+
}));
|
|
130
|
+
await expect(fetchCrmSearch(createBasicRequest())).rejects.toThrow('Invalid response format');
|
|
131
|
+
});
|
|
132
|
+
it('should throw error when a result objectId is not a number', async () => {
|
|
133
|
+
mockFetchCrmSearch.mockResolvedValueOnce(createMockResponse({
|
|
134
|
+
results: [{ objectId: '1001', properties: {} }],
|
|
135
|
+
total: 1,
|
|
136
|
+
hasMore: false,
|
|
137
|
+
}));
|
|
138
|
+
await expect(fetchCrmSearch(createBasicRequest())).rejects.toThrow('Invalid response format');
|
|
139
|
+
});
|
|
140
|
+
it('should throw error when a result properties field is not an object', async () => {
|
|
141
|
+
mockFetchCrmSearch.mockResolvedValueOnce(createMockResponse({
|
|
142
|
+
results: [{ objectId: 1, properties: null }],
|
|
143
|
+
total: 1,
|
|
144
|
+
hasMore: false,
|
|
145
|
+
}));
|
|
146
|
+
await expect(fetchCrmSearch(createBasicRequest())).rejects.toThrow('Invalid response format');
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
describe('API integration', () => {
|
|
150
|
+
it('should call hsWorkerAPI.fetchCrmSearch with correct parameters and no options', async () => {
|
|
151
|
+
mockFetchCrmSearch.mockResolvedValueOnce(createMockResponse(createValidResponseData()));
|
|
152
|
+
const request = createBasicRequest({ pageSize: 50 });
|
|
153
|
+
await fetchCrmSearch(request);
|
|
154
|
+
expect(mockFetchCrmSearch).toHaveBeenCalledWith(request, undefined);
|
|
155
|
+
});
|
|
156
|
+
it('should return the cleanup function provided by the API response', async () => {
|
|
157
|
+
const mockCleanup = vi.fn();
|
|
158
|
+
mockFetchCrmSearch.mockResolvedValueOnce({
|
|
159
|
+
ok: true,
|
|
160
|
+
json: vi.fn().mockResolvedValue({
|
|
161
|
+
data: createValidResponseData(),
|
|
162
|
+
cleanup: mockCleanup,
|
|
163
|
+
}),
|
|
164
|
+
});
|
|
165
|
+
const result = await fetchCrmSearch(createBasicRequest());
|
|
166
|
+
expect(result.cleanup).toBe(mockCleanup);
|
|
167
|
+
result.cleanup();
|
|
168
|
+
expect(mockCleanup).toHaveBeenCalledTimes(1);
|
|
169
|
+
});
|
|
170
|
+
it('should provide a default cleanup function when none is provided', async () => {
|
|
171
|
+
mockFetchCrmSearch.mockResolvedValueOnce({
|
|
172
|
+
ok: true,
|
|
173
|
+
json: vi.fn().mockResolvedValue({ data: createValidResponseData() }),
|
|
174
|
+
});
|
|
175
|
+
const result = await fetchCrmSearch(createBasicRequest());
|
|
176
|
+
expect(typeof result.cleanup).toBe('function');
|
|
177
|
+
expect(() => result.cleanup()).not.toThrow();
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
describe('options handling', () => {
|
|
181
|
+
it('should pass formatting options to the underlying fetch function', async () => {
|
|
182
|
+
mockFetchCrmSearch.mockResolvedValueOnce(createMockResponse(createValidResponseData()));
|
|
183
|
+
const options = {
|
|
184
|
+
propertiesToFormat: ['firstname'],
|
|
185
|
+
formattingOptions: {
|
|
186
|
+
date: { format: 'MM/dd/yyyy' },
|
|
187
|
+
currency: { addSymbol: true },
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
await fetchCrmSearch(createBasicRequest(), options);
|
|
191
|
+
expect(mockFetchCrmSearch).toHaveBeenCalledWith(createBasicRequest(), options);
|
|
192
|
+
});
|
|
193
|
+
it('should handle propertiesToFormat set to "all"', async () => {
|
|
194
|
+
mockFetchCrmSearch.mockResolvedValueOnce(createMockResponse(createValidResponseData()));
|
|
195
|
+
const options = {
|
|
196
|
+
propertiesToFormat: 'all',
|
|
197
|
+
formattingOptions: { dateTime: { relative: true } },
|
|
198
|
+
};
|
|
199
|
+
await fetchCrmSearch(createBasicRequest(), options);
|
|
200
|
+
expect(mockFetchCrmSearch).toHaveBeenCalledWith(createBasicRequest(), options);
|
|
201
|
+
});
|
|
202
|
+
it('should preserve error handling with formatting options', async () => {
|
|
203
|
+
mockFetchCrmSearch.mockRejectedValueOnce(new Error('Network error'));
|
|
204
|
+
const options = {
|
|
205
|
+
propertiesToFormat: ['firstname'],
|
|
206
|
+
formattingOptions: { date: { format: 'MM/dd/yyyy' } },
|
|
207
|
+
};
|
|
208
|
+
await expect(fetchCrmSearch(createBasicRequest(), options)).rejects.toThrow('Network error');
|
|
209
|
+
expect(mockFetchCrmSearch).toHaveBeenCalledWith(createBasicRequest(), options);
|
|
210
|
+
});
|
|
211
|
+
it('should preserve response validation with formatting options', async () => {
|
|
212
|
+
mockFetchCrmSearch.mockResolvedValueOnce(createMockResponse('Invalid response') // data should be an object
|
|
213
|
+
);
|
|
214
|
+
await expect(fetchCrmSearch(createBasicRequest(), { propertiesToFormat: 'all' })).rejects.toThrow('Invalid response format');
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
4
|
export type UseAssociationsOptions = {
|
|
@@ -1,16 +1,15 @@
|
|
|
1
|
-
import { useCallback,
|
|
1
|
+
import { useCallback, useReducer, useState } from 'react';
|
|
2
2
|
import { useFetchLifecycle } from "../../hooks/utils/useFetchLifecycle.js";
|
|
3
3
|
import { createMockAwareHook, useStableValue, } from "../../internal/hook-utils.js";
|
|
4
4
|
import { calculatePaginationFlags, DEFAULT_PAGE_SIZE, } from "../../utils/pagination.js";
|
|
5
5
|
import { fetchAssociations } from "../utils/fetchAssociations.js";
|
|
6
|
-
function createInitialState(
|
|
6
|
+
function createInitialState() {
|
|
7
7
|
return {
|
|
8
8
|
results: [],
|
|
9
9
|
error: null,
|
|
10
10
|
isLoading: true,
|
|
11
11
|
isRefetching: false,
|
|
12
12
|
currentPage: 1,
|
|
13
|
-
pageSize,
|
|
14
13
|
hasMore: false,
|
|
15
14
|
currentOffset: undefined,
|
|
16
15
|
nextOffset: undefined,
|
|
@@ -72,6 +71,7 @@ function associationsReducer(state, action) {
|
|
|
72
71
|
...state,
|
|
73
72
|
currentPage: 1,
|
|
74
73
|
results: [],
|
|
74
|
+
isLoading: true,
|
|
75
75
|
hasMore: false,
|
|
76
76
|
error: null,
|
|
77
77
|
currentOffset: undefined,
|
|
@@ -109,7 +109,16 @@ const DEFAULT_OPTIONS = {};
|
|
|
109
109
|
*/
|
|
110
110
|
function useAssociationsInternal(config, options = DEFAULT_OPTIONS) {
|
|
111
111
|
const pageSize = config?.pageLength ?? DEFAULT_PAGE_SIZE;
|
|
112
|
-
const [state, dispatch] = useReducer(associationsReducer,
|
|
112
|
+
const [state, dispatch] = useReducer(associationsReducer, createInitialState());
|
|
113
|
+
/**
|
|
114
|
+
* Reset pagination when pageSize changes.
|
|
115
|
+
* Render-phase update (not useEffect) avoids committing a render with stale paginated results.
|
|
116
|
+
*/
|
|
117
|
+
const [prevPageSize, setPrevPageSize] = useState(pageSize);
|
|
118
|
+
if (prevPageSize !== pageSize) {
|
|
119
|
+
setPrevPageSize(pageSize);
|
|
120
|
+
dispatch({ type: 'RESET' });
|
|
121
|
+
}
|
|
113
122
|
const stableConfig = useStableValue(config);
|
|
114
123
|
const stableOptions = useStableValue(options);
|
|
115
124
|
// Pagination actions
|
|
@@ -191,7 +200,7 @@ function useAssociationsInternal(config, options = DEFAULT_OPTIONS) {
|
|
|
191
200
|
hasNextPage: paginationFlags.hasNextPage,
|
|
192
201
|
hasPreviousPage: paginationFlags.hasPreviousPage,
|
|
193
202
|
currentPage: state.currentPage,
|
|
194
|
-
pageSize
|
|
203
|
+
pageSize,
|
|
195
204
|
nextPage,
|
|
196
205
|
previousPage,
|
|
197
206
|
reset,
|
|
@@ -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,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>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { getWorkerGlobals } from "../../../internal/global-utils.js";
|
|
2
|
+
function isCrmSearchResponse(data) {
|
|
3
|
+
const d = data;
|
|
4
|
+
if (d === null ||
|
|
5
|
+
typeof d !== 'object' ||
|
|
6
|
+
!Array.isArray(d.results) ||
|
|
7
|
+
typeof d.total !== 'number' ||
|
|
8
|
+
typeof d.hasMore !== 'boolean') {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
return d.results.every((result) => result !== null &&
|
|
12
|
+
typeof result === 'object' &&
|
|
13
|
+
typeof result.objectId === 'number' &&
|
|
14
|
+
result.properties !== null &&
|
|
15
|
+
typeof result.properties === 'object');
|
|
16
|
+
}
|
|
17
|
+
export const fetchCrmSearch = async (request, options) => {
|
|
18
|
+
let response;
|
|
19
|
+
let result;
|
|
20
|
+
try {
|
|
21
|
+
response = await getWorkerGlobals().hsWorkerAPI.fetchCrmSearch(request, options);
|
|
22
|
+
result = await response.json();
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
throw error instanceof Error
|
|
26
|
+
? error
|
|
27
|
+
: new Error('Failed to fetch CRM search results: Unknown error');
|
|
28
|
+
}
|
|
29
|
+
if (result.error) {
|
|
30
|
+
throw new Error(result.error);
|
|
31
|
+
}
|
|
32
|
+
if (!isCrmSearchResponse(result.data)) {
|
|
33
|
+
throw new Error('Invalid response format');
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
data: result.data,
|
|
37
|
+
cleanup: result.cleanup || (() => { }),
|
|
38
|
+
};
|
|
39
|
+
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { useCrmProperties } from '../crm/hooks/useCrmProperties.ts';
|
|
2
2
|
export { useAssociations } from '../crm/hooks/useAssociations.ts';
|
|
3
|
+
export { useCrmSearch } from './hooks/useCrmSearch.ts';
|
|
3
4
|
export { Iframe, MediaObject, Stack2, Center, GridItem, Grid, SettingsView, ExpandableText, Popover, FileInput, } from '../shared/remoteComponents.tsx';
|
|
4
5
|
export type * from '../shared/types/experimental.ts';
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { useCrmProperties } from "../crm/hooks/useCrmProperties.js";
|
|
2
2
|
export { useAssociations } from "../crm/hooks/useAssociations.js";
|
|
3
|
+
export { useCrmSearch } from "./hooks/useCrmSearch.js";
|
|
3
4
|
export { Iframe, MediaObject, Stack2, Center, GridItem, Grid, SettingsView, ExpandableText, Popover, FileInput, } from "../shared/remoteComponents.js";
|
|
@@ -70,6 +70,14 @@ export interface SortedTableHeaderProps extends BaseTableHeaderProps {
|
|
|
70
70
|
* @category Component Props
|
|
71
71
|
*/
|
|
72
72
|
export type TableHeaderProps = SortedTableHeaderProps | UnSortedTableHeaderProps;
|
|
73
|
+
/**
|
|
74
|
+
* Controls row height and vertical padding for every cell in the table.
|
|
75
|
+
*
|
|
76
|
+
* - `'default'`: generous row height and padding around content.
|
|
77
|
+
* - `'condensed'`: moderate row height and padding around content.
|
|
78
|
+
* - `'compact'`: no min row height, minimal padding. Rows collapse to content height.
|
|
79
|
+
*/
|
|
80
|
+
export type TableDensity = 'default' | 'condensed' | 'compact';
|
|
73
81
|
interface BaseTableProps extends BaseComponentProps {
|
|
74
82
|
/**
|
|
75
83
|
* When set to `false`, the table will not include borders.
|
|
@@ -83,6 +91,14 @@ interface BaseTableProps extends BaseComponentProps {
|
|
|
83
91
|
* @defaultValue `false`
|
|
84
92
|
*/
|
|
85
93
|
flush?: boolean;
|
|
94
|
+
/**
|
|
95
|
+
* Sets row height and vertical padding for all header, body, and footer cells.
|
|
96
|
+
* Use `'condensed'` for denser data views and `'compact'` when rows should be
|
|
97
|
+
* sized only to their content.
|
|
98
|
+
*
|
|
99
|
+
* @defaultValue `'default'`
|
|
100
|
+
*/
|
|
101
|
+
density?: TableDensity;
|
|
86
102
|
/**
|
|
87
103
|
* Sets the content that will render inside the component. This prop is passed implicitly by providing sub-components. The children should be one of Table's subcomponents.
|
|
88
104
|
*/
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
2
|
import type { ReactionsHandler } from './reactions.ts';
|
|
3
3
|
import type { AllDistances, BaseComponentProps, ExtensionEvent } from './shared.ts';
|
|
4
|
+
export type { CrmSearchFilter, CrmSearchFilterGroup, CrmSearchResponse, CrmSearchResultItem, CrmSearchSort, FetchCrmSearchRequest, UseCrmSearchOptions, UseCrmSearchResult, FormattingOptions, } from './hooks.ts';
|
|
4
5
|
/**
|
|
5
6
|
* @ignore
|
|
6
7
|
* @experimental do not use in production
|
|
@@ -233,4 +234,3 @@ export interface BaseInputProps<T = string, V = string> {
|
|
|
233
234
|
*/
|
|
234
235
|
onFocus?: (value: V) => void;
|
|
235
236
|
}
|
|
236
|
-
export {};
|