@startsimpli/hooks 0.4.6 → 0.4.8
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/package.json +7 -2
- package/src/__tests__/useAsyncOptions.test.ts +136 -0
- package/src/__tests__/useEntityTable.test.ts +322 -0
- package/src/__tests__/useWizard.test.ts +201 -9
- package/src/index.ts +32 -1
- package/src/useAsyncOptions.ts +60 -0
- package/src/useCSV.ts +10 -10
- package/src/useEnrichment.ts +156 -0
- package/src/useEntityTable.ts +134 -0
- package/src/useMessageTemplates.ts +67 -0
- package/src/useMessages.ts +86 -0
- package/src/useTargetLists.ts +132 -0
- package/src/useWizard.ts +39 -4
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
2
|
import { renderHook, act } from '@testing-library/react'
|
|
3
3
|
import { useWizard } from '../useWizard'
|
|
4
4
|
|
|
5
5
|
const STEPS = ['upload', 'mapping', 'importing', 'complete'] as const
|
|
6
6
|
|
|
7
7
|
describe('useWizard', () => {
|
|
8
|
+
// ---------- backward compatibility ----------
|
|
9
|
+
|
|
8
10
|
it('starts on the initial step', () => {
|
|
9
11
|
const { result } = renderHook(() => useWizard(STEPS))
|
|
10
12
|
expect(result.current.currentStep).toBe('upload')
|
|
@@ -20,41 +22,41 @@ describe('useWizard', () => {
|
|
|
20
22
|
|
|
21
23
|
it('advances to next step', () => {
|
|
22
24
|
const { result } = renderHook(() => useWizard(STEPS))
|
|
23
|
-
act(() => result.current.next())
|
|
25
|
+
act(() => { result.current.next() })
|
|
24
26
|
expect(result.current.currentStep).toBe('mapping')
|
|
25
27
|
expect(result.current.stepIndex).toBe(1)
|
|
26
28
|
})
|
|
27
29
|
|
|
28
30
|
it('goes back to previous step', () => {
|
|
29
31
|
const { result } = renderHook(() => useWizard(STEPS, 'mapping'))
|
|
30
|
-
act(() => result.current.prev())
|
|
32
|
+
act(() => { result.current.prev() })
|
|
31
33
|
expect(result.current.currentStep).toBe('upload')
|
|
32
34
|
})
|
|
33
35
|
|
|
34
36
|
it('does not go before first step', () => {
|
|
35
37
|
const { result } = renderHook(() => useWizard(STEPS))
|
|
36
|
-
act(() => result.current.prev())
|
|
38
|
+
act(() => { result.current.prev() })
|
|
37
39
|
expect(result.current.currentStep).toBe('upload')
|
|
38
40
|
})
|
|
39
41
|
|
|
40
42
|
it('does not go past last step', () => {
|
|
41
43
|
const { result } = renderHook(() => useWizard(STEPS, 'complete'))
|
|
42
|
-
act(() => result.current.next())
|
|
44
|
+
act(() => { result.current.next() })
|
|
43
45
|
expect(result.current.currentStep).toBe('complete')
|
|
44
46
|
})
|
|
45
47
|
|
|
46
48
|
it('goTo navigates to any step', () => {
|
|
47
49
|
const { result } = renderHook(() => useWizard(STEPS))
|
|
48
|
-
act(() => result.current.goTo('importing'))
|
|
50
|
+
act(() => { result.current.goTo('importing') })
|
|
49
51
|
expect(result.current.currentStep).toBe('importing')
|
|
50
52
|
expect(result.current.stepIndex).toBe(2)
|
|
51
53
|
})
|
|
52
54
|
|
|
53
55
|
it('reset returns to initial step', () => {
|
|
54
56
|
const { result } = renderHook(() => useWizard(STEPS))
|
|
55
|
-
act(() => result.current.next())
|
|
56
|
-
act(() => result.current.next())
|
|
57
|
-
act(() => result.current.reset())
|
|
57
|
+
act(() => { result.current.next() })
|
|
58
|
+
act(() => { result.current.next() })
|
|
59
|
+
act(() => { result.current.reset() })
|
|
58
60
|
expect(result.current.currentStep).toBe('upload')
|
|
59
61
|
})
|
|
60
62
|
|
|
@@ -73,4 +75,194 @@ describe('useWizard', () => {
|
|
|
73
75
|
expect(result.current.canGoBack).toBe(true)
|
|
74
76
|
expect(result.current.canGoNext).toBe(false)
|
|
75
77
|
})
|
|
78
|
+
|
|
79
|
+
it('next() returns null when no validation is configured', () => {
|
|
80
|
+
const { result } = renderHook(() => useWizard(STEPS))
|
|
81
|
+
let returnVal: ReturnType<typeof result.current.next>
|
|
82
|
+
act(() => { returnVal = result.current.next() })
|
|
83
|
+
expect(returnVal!).toBeNull()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('errors is null by default', () => {
|
|
87
|
+
const { result } = renderHook(() => useWizard(STEPS))
|
|
88
|
+
expect(result.current.errors).toBeNull()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// ---------- per-step validation ----------
|
|
92
|
+
|
|
93
|
+
describe('with validation', () => {
|
|
94
|
+
const FORM_STEPS = ['details', 'sequence', 'review'] as const
|
|
95
|
+
|
|
96
|
+
it('blocks next() when validator returns errors', () => {
|
|
97
|
+
const validate = {
|
|
98
|
+
details: () => ({ name: 'Name is required' }),
|
|
99
|
+
}
|
|
100
|
+
const { result } = renderHook(() =>
|
|
101
|
+
useWizard(FORM_STEPS, { validate })
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
let errors: Record<string, string> | null
|
|
105
|
+
act(() => { errors = result.current.next() })
|
|
106
|
+
|
|
107
|
+
expect(errors!).toEqual({ name: 'Name is required' })
|
|
108
|
+
expect(result.current.currentStep).toBe('details')
|
|
109
|
+
expect(result.current.errors).toEqual({ name: 'Name is required' })
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('advances when validator returns null', () => {
|
|
113
|
+
const validate = {
|
|
114
|
+
details: () => null,
|
|
115
|
+
}
|
|
116
|
+
const { result } = renderHook(() =>
|
|
117
|
+
useWizard(FORM_STEPS, { validate })
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
let returnVal: Record<string, string> | null
|
|
121
|
+
act(() => { returnVal = result.current.next() })
|
|
122
|
+
|
|
123
|
+
expect(returnVal!).toBeNull()
|
|
124
|
+
expect(result.current.currentStep).toBe('sequence')
|
|
125
|
+
expect(result.current.errors).toBeNull()
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('advances when validator returns empty object', () => {
|
|
129
|
+
const validate = {
|
|
130
|
+
details: () => ({}),
|
|
131
|
+
}
|
|
132
|
+
const { result } = renderHook(() =>
|
|
133
|
+
useWizard(FORM_STEPS, { validate })
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
act(() => { result.current.next() })
|
|
137
|
+
expect(result.current.currentStep).toBe('sequence')
|
|
138
|
+
expect(result.current.errors).toBeNull()
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('skips validation for steps without a validator', () => {
|
|
142
|
+
const validate = {
|
|
143
|
+
details: () => null,
|
|
144
|
+
// no validator for 'sequence'
|
|
145
|
+
}
|
|
146
|
+
const { result } = renderHook(() =>
|
|
147
|
+
useWizard(FORM_STEPS, { validate })
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
// advance past details (has validator returning null)
|
|
151
|
+
act(() => { result.current.next() })
|
|
152
|
+
expect(result.current.currentStep).toBe('sequence')
|
|
153
|
+
|
|
154
|
+
// advance past sequence (no validator)
|
|
155
|
+
act(() => { result.current.next() })
|
|
156
|
+
expect(result.current.currentStep).toBe('review')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('clearErrors resets errors to null', () => {
|
|
160
|
+
const validate = {
|
|
161
|
+
details: () => ({ name: 'Required' }),
|
|
162
|
+
}
|
|
163
|
+
const { result } = renderHook(() =>
|
|
164
|
+
useWizard(FORM_STEPS, { validate })
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
act(() => { result.current.next() })
|
|
168
|
+
expect(result.current.errors).toEqual({ name: 'Required' })
|
|
169
|
+
|
|
170
|
+
act(() => { result.current.clearErrors() })
|
|
171
|
+
expect(result.current.errors).toBeNull()
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('reset clears errors', () => {
|
|
175
|
+
const validate = {
|
|
176
|
+
details: () => ({ name: 'Required' }),
|
|
177
|
+
}
|
|
178
|
+
const { result } = renderHook(() =>
|
|
179
|
+
useWizard(FORM_STEPS, { validate })
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
act(() => { result.current.next() })
|
|
183
|
+
expect(result.current.errors).not.toBeNull()
|
|
184
|
+
|
|
185
|
+
act(() => { result.current.reset() })
|
|
186
|
+
expect(result.current.errors).toBeNull()
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('works with initialStep and opts together', () => {
|
|
190
|
+
const validate = {
|
|
191
|
+
sequence: () => ({ steps: 'Add at least one step' }),
|
|
192
|
+
}
|
|
193
|
+
const { result } = renderHook(() =>
|
|
194
|
+
useWizard(FORM_STEPS, 'sequence', { validate })
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
expect(result.current.currentStep).toBe('sequence')
|
|
198
|
+
|
|
199
|
+
let errors: Record<string, string> | null
|
|
200
|
+
act(() => { errors = result.current.next() })
|
|
201
|
+
|
|
202
|
+
expect(errors!).toEqual({ steps: 'Add at least one step' })
|
|
203
|
+
expect(result.current.currentStep).toBe('sequence')
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('calls the validator for the current step only', () => {
|
|
207
|
+
const detailsValidator = vi.fn(() => null)
|
|
208
|
+
const sequenceValidator = vi.fn(() => null)
|
|
209
|
+
|
|
210
|
+
const validate = {
|
|
211
|
+
details: detailsValidator,
|
|
212
|
+
sequence: sequenceValidator,
|
|
213
|
+
}
|
|
214
|
+
const { result } = renderHook(() =>
|
|
215
|
+
useWizard(FORM_STEPS, { validate })
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
act(() => { result.current.next() })
|
|
219
|
+
expect(detailsValidator).toHaveBeenCalledTimes(1)
|
|
220
|
+
expect(sequenceValidator).not.toHaveBeenCalled()
|
|
221
|
+
|
|
222
|
+
act(() => { result.current.next() })
|
|
223
|
+
expect(sequenceValidator).toHaveBeenCalledTimes(1)
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('clears previous errors on successful next()', () => {
|
|
227
|
+
let shouldFail = true
|
|
228
|
+
const validate = {
|
|
229
|
+
details: () => (shouldFail ? { name: 'Required' } : null),
|
|
230
|
+
}
|
|
231
|
+
const { result } = renderHook(() =>
|
|
232
|
+
useWizard(FORM_STEPS, { validate })
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
// First call fails
|
|
236
|
+
act(() => { result.current.next() })
|
|
237
|
+
expect(result.current.errors).toEqual({ name: 'Required' })
|
|
238
|
+
expect(result.current.currentStep).toBe('details')
|
|
239
|
+
|
|
240
|
+
// Fix the data, try again
|
|
241
|
+
shouldFail = false
|
|
242
|
+
act(() => { result.current.next() })
|
|
243
|
+
expect(result.current.errors).toBeNull()
|
|
244
|
+
expect(result.current.currentStep).toBe('sequence')
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('returns multiple errors from a single validator', () => {
|
|
248
|
+
const validate = {
|
|
249
|
+
details: () => ({
|
|
250
|
+
name: 'Name is required',
|
|
251
|
+
channel: 'Select a channel',
|
|
252
|
+
}),
|
|
253
|
+
}
|
|
254
|
+
const { result } = renderHook(() =>
|
|
255
|
+
useWizard(FORM_STEPS, { validate })
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
let errors: Record<string, string> | null
|
|
259
|
+
act(() => { errors = result.current.next() })
|
|
260
|
+
|
|
261
|
+
expect(errors!).toEqual({
|
|
262
|
+
name: 'Name is required',
|
|
263
|
+
channel: 'Select a channel',
|
|
264
|
+
})
|
|
265
|
+
expect(result.current.currentStep).toBe('details')
|
|
266
|
+
})
|
|
267
|
+
})
|
|
76
268
|
})
|
package/src/index.ts
CHANGED
|
@@ -22,12 +22,41 @@ export {
|
|
|
22
22
|
getFilterDescription,
|
|
23
23
|
} from './filter-encoding'
|
|
24
24
|
export { useCRUDMutation } from './useCRUDMutation'
|
|
25
|
+
export {
|
|
26
|
+
useMessages,
|
|
27
|
+
useMessage,
|
|
28
|
+
useCreateMessage,
|
|
29
|
+
useSendMessage,
|
|
30
|
+
useScheduleMessage,
|
|
31
|
+
useSendTestMessage,
|
|
32
|
+
useMessageChannels,
|
|
33
|
+
} from './useMessages'
|
|
34
|
+
export {
|
|
35
|
+
useMessageTemplates,
|
|
36
|
+
useMessageTemplate,
|
|
37
|
+
useCreateMessageTemplate,
|
|
38
|
+
useUpdateMessageTemplate,
|
|
39
|
+
useDeleteMessageTemplate,
|
|
40
|
+
} from './useMessageTemplates'
|
|
25
41
|
export { useSavedViews } from './useSavedViews'
|
|
26
42
|
export type { SavedView as SavedViewEntry } from './useSavedViews'
|
|
27
43
|
export { useRecentlyViewed } from './useRecentlyViewed'
|
|
28
44
|
export { useWizard } from './useWizard'
|
|
29
|
-
export type { WizardState } from './useWizard'
|
|
45
|
+
export type { WizardState, WizardOptions } from './useWizard'
|
|
30
46
|
export { useCSVImport, useCSVExport } from './useCSV'
|
|
47
|
+
export { useEntityTable } from './useEntityTable'
|
|
48
|
+
export type { UseEntityTableOptions, UseEntityTableReturn } from './useEntityTable'
|
|
49
|
+
export { useTargetListDetail, useTargetListMutations, TARGET_LIST_KEYS } from './useTargetLists'
|
|
50
|
+
export type { TargetListApiFns, UseTargetListMutationsOptions } from './useTargetLists'
|
|
51
|
+
export { useEnrichment, useBatchEnrichment, useQueueStatus } from './useEnrichment'
|
|
52
|
+
export type {
|
|
53
|
+
UseEnrichmentState,
|
|
54
|
+
UseEnrichmentOptions,
|
|
55
|
+
UseBatchEnrichmentState,
|
|
56
|
+
UseBatchEnrichmentOptions,
|
|
57
|
+
UseQueueStatusState,
|
|
58
|
+
UseQueueStatusOptions,
|
|
59
|
+
} from './useEnrichment'
|
|
31
60
|
export type {
|
|
32
61
|
CSVColumnMapping,
|
|
33
62
|
CSVPreviewResult,
|
|
@@ -35,3 +64,5 @@ export type {
|
|
|
35
64
|
CSVImportStep,
|
|
36
65
|
UseCSVImportState,
|
|
37
66
|
} from './useCSV'
|
|
67
|
+
export { useAsyncOptions } from './useAsyncOptions'
|
|
68
|
+
export type { UseAsyncOptionsResult } from './useAsyncOptions'
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
2
|
+
|
|
3
|
+
export interface UseAsyncOptionsResult<T> {
|
|
4
|
+
data: T[]
|
|
5
|
+
loading: boolean
|
|
6
|
+
error: Error | null
|
|
7
|
+
refresh: () => void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function useAsyncOptions<T>(
|
|
11
|
+
fetcher: () => Promise<T[]>,
|
|
12
|
+
opts?: { enabled?: boolean; defaultValue?: T[] }
|
|
13
|
+
): UseAsyncOptionsResult<T> {
|
|
14
|
+
const enabled = opts?.enabled ?? true
|
|
15
|
+
const defaultValue = opts?.defaultValue ?? []
|
|
16
|
+
|
|
17
|
+
const [data, setData] = useState<T[]>(defaultValue)
|
|
18
|
+
const [loading, setLoading] = useState(enabled)
|
|
19
|
+
const [error, setError] = useState<Error | null>(null)
|
|
20
|
+
const mountedRef = useRef(true)
|
|
21
|
+
const fetcherRef = useRef(fetcher)
|
|
22
|
+
fetcherRef.current = fetcher
|
|
23
|
+
|
|
24
|
+
const doFetch = useCallback(() => {
|
|
25
|
+
setLoading(true)
|
|
26
|
+
setError(null)
|
|
27
|
+
|
|
28
|
+
fetcherRef.current()
|
|
29
|
+
.then((result) => {
|
|
30
|
+
if (mountedRef.current) {
|
|
31
|
+
setData(result)
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
.catch((err: unknown) => {
|
|
35
|
+
if (mountedRef.current) {
|
|
36
|
+
setError(err instanceof Error ? err : new Error(String(err)))
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
.finally(() => {
|
|
40
|
+
if (mountedRef.current) {
|
|
41
|
+
setLoading(false)
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
}, [])
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
mountedRef.current = true
|
|
48
|
+
return () => {
|
|
49
|
+
mountedRef.current = false
|
|
50
|
+
}
|
|
51
|
+
}, [])
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (enabled) {
|
|
55
|
+
doFetch()
|
|
56
|
+
}
|
|
57
|
+
}, [enabled, doFetch])
|
|
58
|
+
|
|
59
|
+
return { data, loading, error, refresh: doFetch }
|
|
60
|
+
}
|
package/src/useCSV.ts
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
import { useState, useCallback } from 'react'
|
|
2
2
|
|
|
3
3
|
export interface CSVColumnMapping {
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
csvColumn: string
|
|
5
|
+
targetField: string
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
export interface CSVPreviewResult<TField extends string = string> {
|
|
9
9
|
columns: string[]
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
sampleRows: Record<string, string>[]
|
|
11
|
+
suggestedMappings?: CSVColumnMapping[]
|
|
12
|
+
totalRows?: number
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export interface CSVImportResult {
|
|
16
|
-
|
|
16
|
+
totalRows: number
|
|
17
17
|
successful: number
|
|
18
18
|
failed: number
|
|
19
19
|
errors: Array<{ row: number; message: string }>
|
|
@@ -63,7 +63,7 @@ export function useCSVImport<TField extends string = string>({
|
|
|
63
63
|
try {
|
|
64
64
|
const previewData = await previewFn(selectedFile)
|
|
65
65
|
setPreview(previewData)
|
|
66
|
-
setMappings(previewData.
|
|
66
|
+
setMappings(previewData.suggestedMappings ?? [])
|
|
67
67
|
setStep('mapping')
|
|
68
68
|
} catch {
|
|
69
69
|
setError('Failed to preview CSV file')
|
|
@@ -76,13 +76,13 @@ export function useCSVImport<TField extends string = string>({
|
|
|
76
76
|
|
|
77
77
|
const updateMapping = useCallback((csvColumn: string, targetField: string) => {
|
|
78
78
|
setMappings((prev) => {
|
|
79
|
-
const existing = prev.find((m) => m.
|
|
79
|
+
const existing = prev.find((m) => m.csvColumn === csvColumn)
|
|
80
80
|
if (existing) {
|
|
81
81
|
return prev.map((m) =>
|
|
82
|
-
m.
|
|
82
|
+
m.csvColumn === csvColumn ? { ...m, targetField: targetField } : m
|
|
83
83
|
)
|
|
84
84
|
}
|
|
85
|
-
return [...prev, {
|
|
85
|
+
return [...prev, { csvColumn: csvColumn, targetField: targetField }]
|
|
86
86
|
})
|
|
87
87
|
}, [])
|
|
88
88
|
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { useState, useCallback, useRef } from 'react'
|
|
2
|
+
import type { EnrichmentResult, QueueStatus } from '@startsimpli/api'
|
|
3
|
+
|
|
4
|
+
export interface EnrichFn {
|
|
5
|
+
(contactId: string): Promise<EnrichmentResult>
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface BulkEnrichFn {
|
|
9
|
+
(contactIds: string[], onProgress?: (completed: number, total: number) => void): Promise<EnrichmentResult[]>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface QueueStatusFn {
|
|
13
|
+
(): Promise<QueueStatus>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface UseEnrichmentOptions {
|
|
17
|
+
enrichFn: EnrichFn
|
|
18
|
+
onSuccess?: (result: EnrichmentResult) => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface UseEnrichmentState {
|
|
22
|
+
isEnriching: boolean
|
|
23
|
+
result: EnrichmentResult | null
|
|
24
|
+
error: string | null
|
|
25
|
+
enrich: (contactId: string) => Promise<void>
|
|
26
|
+
reset: () => void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function useEnrichment({ enrichFn, onSuccess }: UseEnrichmentOptions): UseEnrichmentState {
|
|
30
|
+
const [isEnriching, setIsEnriching] = useState(false)
|
|
31
|
+
const [result, setResult] = useState<EnrichmentResult | null>(null)
|
|
32
|
+
const [error, setError] = useState<string | null>(null)
|
|
33
|
+
|
|
34
|
+
const enrich = useCallback(
|
|
35
|
+
async (contactId: string) => {
|
|
36
|
+
setIsEnriching(true)
|
|
37
|
+
setError(null)
|
|
38
|
+
try {
|
|
39
|
+
const enrichResult = await enrichFn(contactId)
|
|
40
|
+
setResult(enrichResult)
|
|
41
|
+
onSuccess?.(enrichResult)
|
|
42
|
+
} catch (err: unknown) {
|
|
43
|
+
setError(err instanceof Error ? err.message : 'Enrichment failed')
|
|
44
|
+
} finally {
|
|
45
|
+
setIsEnriching(false)
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
[enrichFn, onSuccess]
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
const reset = useCallback(() => {
|
|
52
|
+
setResult(null)
|
|
53
|
+
setError(null)
|
|
54
|
+
}, [])
|
|
55
|
+
|
|
56
|
+
return { isEnriching, result, error, enrich, reset }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface UseBatchEnrichmentOptions {
|
|
60
|
+
bulkEnrichFn: BulkEnrichFn
|
|
61
|
+
onComplete?: (results: EnrichmentResult[]) => void
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface UseBatchEnrichmentState {
|
|
65
|
+
isEnriching: boolean
|
|
66
|
+
progress: { completed: number; total: number }
|
|
67
|
+
results: EnrichmentResult[]
|
|
68
|
+
error: string | null
|
|
69
|
+
enrichBatch: (contactIds: string[]) => Promise<void>
|
|
70
|
+
cancel: () => void
|
|
71
|
+
reset: () => void
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function useBatchEnrichment({
|
|
75
|
+
bulkEnrichFn,
|
|
76
|
+
onComplete,
|
|
77
|
+
}: UseBatchEnrichmentOptions): UseBatchEnrichmentState {
|
|
78
|
+
const [isEnriching, setIsEnriching] = useState(false)
|
|
79
|
+
const [progress, setProgress] = useState({ completed: 0, total: 0 })
|
|
80
|
+
const [results, setResults] = useState<EnrichmentResult[]>([])
|
|
81
|
+
const [error, setError] = useState<string | null>(null)
|
|
82
|
+
const cancelledRef = useRef(false)
|
|
83
|
+
|
|
84
|
+
const enrichBatch = useCallback(
|
|
85
|
+
async (contactIds: string[]) => {
|
|
86
|
+
cancelledRef.current = false
|
|
87
|
+
setIsEnriching(true)
|
|
88
|
+
setError(null)
|
|
89
|
+
setProgress({ completed: 0, total: contactIds.length })
|
|
90
|
+
setResults([])
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const batchResults = await bulkEnrichFn(contactIds, (completed, total) => {
|
|
94
|
+
if (!cancelledRef.current) {
|
|
95
|
+
setProgress({ completed, total })
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
if (!cancelledRef.current) {
|
|
99
|
+
setResults(batchResults)
|
|
100
|
+
onComplete?.(batchResults)
|
|
101
|
+
}
|
|
102
|
+
} catch (err: unknown) {
|
|
103
|
+
if (!cancelledRef.current) {
|
|
104
|
+
setError(err instanceof Error ? err.message : 'Batch enrichment failed')
|
|
105
|
+
}
|
|
106
|
+
} finally {
|
|
107
|
+
if (!cancelledRef.current) {
|
|
108
|
+
setIsEnriching(false)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
[bulkEnrichFn, onComplete]
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
const cancel = useCallback(() => {
|
|
116
|
+
cancelledRef.current = true
|
|
117
|
+
setIsEnriching(false)
|
|
118
|
+
}, [])
|
|
119
|
+
|
|
120
|
+
const reset = useCallback(() => {
|
|
121
|
+
setResults([])
|
|
122
|
+
setError(null)
|
|
123
|
+
setProgress({ completed: 0, total: 0 })
|
|
124
|
+
}, [])
|
|
125
|
+
|
|
126
|
+
return { isEnriching, progress, results, error, enrichBatch, cancel, reset }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface UseQueueStatusOptions {
|
|
130
|
+
fetchQueueStatus: QueueStatusFn
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface UseQueueStatusState {
|
|
134
|
+
queueStatus: QueueStatus | null
|
|
135
|
+
isLoading: boolean
|
|
136
|
+
refresh: () => Promise<void>
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function useQueueStatus({ fetchQueueStatus }: UseQueueStatusOptions): UseQueueStatusState {
|
|
140
|
+
const [queueStatus, setQueueStatus] = useState<QueueStatus | null>(null)
|
|
141
|
+
const [isLoading, setIsLoading] = useState(false)
|
|
142
|
+
|
|
143
|
+
const refresh = useCallback(async () => {
|
|
144
|
+
setIsLoading(true)
|
|
145
|
+
try {
|
|
146
|
+
const status = await fetchQueueStatus()
|
|
147
|
+
setQueueStatus(status)
|
|
148
|
+
} catch {
|
|
149
|
+
// Queue status may not be available
|
|
150
|
+
} finally {
|
|
151
|
+
setIsLoading(false)
|
|
152
|
+
}
|
|
153
|
+
}, [fetchQueueStatus])
|
|
154
|
+
|
|
155
|
+
return { queueStatus, isLoading, refresh }
|
|
156
|
+
}
|