@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.
@@ -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
- csv_column: string
5
- target_field: string
4
+ csvColumn: string
5
+ targetField: string
6
6
  }
7
7
 
8
8
  export interface CSVPreviewResult<TField extends string = string> {
9
9
  columns: string[]
10
- sample_rows: Record<string, string>[]
11
- suggested_mappings?: CSVColumnMapping[]
12
- total_rows?: number
10
+ sampleRows: Record<string, string>[]
11
+ suggestedMappings?: CSVColumnMapping[]
12
+ totalRows?: number
13
13
  }
14
14
 
15
15
  export interface CSVImportResult {
16
- total_rows: number
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.suggested_mappings ?? [])
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.csv_column === csvColumn)
79
+ const existing = prev.find((m) => m.csvColumn === csvColumn)
80
80
  if (existing) {
81
81
  return prev.map((m) =>
82
- m.csv_column === csvColumn ? { ...m, target_field: targetField } : m
82
+ m.csvColumn === csvColumn ? { ...m, targetField: targetField } : m
83
83
  )
84
84
  }
85
- return [...prev, { csv_column: csvColumn, target_field: targetField }]
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
+ }