@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
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { useState, useCallback, useRef, useMemo } from 'react'
|
|
2
|
+
import { useCSVExport } from './useCSV'
|
|
3
|
+
|
|
4
|
+
export interface UseEntityTableOptions<T> {
|
|
5
|
+
/** All items currently available (from Redux store, React Query, etc.) */
|
|
6
|
+
items: T[]
|
|
7
|
+
/** Field to use as the unique identifier (defaults to 'id') */
|
|
8
|
+
idField?: keyof T
|
|
9
|
+
/** Filename for CSV exports (date suffix added automatically) */
|
|
10
|
+
csvFilename: string
|
|
11
|
+
/** CSV column headers */
|
|
12
|
+
csvColumns: string[]
|
|
13
|
+
/** Maps an entity to an array of CSV cell values */
|
|
14
|
+
csvRowMapper: (item: T) => string[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface UseEntityTableReturn<T> {
|
|
18
|
+
/** Currently selected item for viewing */
|
|
19
|
+
selectedItem: T | null
|
|
20
|
+
/** Currently selected item for editing */
|
|
21
|
+
editingItem: T | null
|
|
22
|
+
/** Current view mode */
|
|
23
|
+
viewMode: 'view' | 'edit' | null
|
|
24
|
+
/** Whether the create form is open */
|
|
25
|
+
showCreateForm: boolean
|
|
26
|
+
/** Ref to set selected IDs for CSV export filtering */
|
|
27
|
+
exportIdsRef: React.MutableRefObject<Set<string> | null>
|
|
28
|
+
/** Export CSV, optionally filtered to the given IDs */
|
|
29
|
+
exportCSV: (selectedIds?: Set<string>) => Promise<void>
|
|
30
|
+
/** Whether a CSV export is in progress */
|
|
31
|
+
isExporting: boolean
|
|
32
|
+
/** Open the view panel for an item */
|
|
33
|
+
openView: (item: T) => void
|
|
34
|
+
/** Open the edit panel for an item */
|
|
35
|
+
openEdit: (item: T) => void
|
|
36
|
+
/** Open the create form */
|
|
37
|
+
openCreate: () => void
|
|
38
|
+
/** Close view/edit panel and create form */
|
|
39
|
+
closePanel: () => void
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function useEntityTable<T>({
|
|
43
|
+
items,
|
|
44
|
+
idField = 'id' as keyof T,
|
|
45
|
+
csvFilename,
|
|
46
|
+
csvColumns,
|
|
47
|
+
csvRowMapper,
|
|
48
|
+
}: UseEntityTableOptions<T>): UseEntityTableReturn<T> {
|
|
49
|
+
const [selectedItem, setSelectedItem] = useState<T | null>(null)
|
|
50
|
+
const [editingItem, setEditingItem] = useState<T | null>(null)
|
|
51
|
+
const [viewMode, setViewMode] = useState<'view' | 'edit' | null>(null)
|
|
52
|
+
const [showCreateForm, setShowCreateForm] = useState(false)
|
|
53
|
+
const exportIdsRef = useRef<Set<string> | null>(null)
|
|
54
|
+
|
|
55
|
+
const buildCSV = useCallback(
|
|
56
|
+
(rows: T[]): string => {
|
|
57
|
+
const dataRows = rows.map((item) =>
|
|
58
|
+
csvRowMapper(item).map((cell) => `"${cell}"`)
|
|
59
|
+
)
|
|
60
|
+
return [
|
|
61
|
+
csvColumns.map((col) => `"${col}"`).join(','),
|
|
62
|
+
...dataRows.map((row) => row.join(',')),
|
|
63
|
+
].join('\n')
|
|
64
|
+
},
|
|
65
|
+
[csvColumns, csvRowMapper]
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
const dateSuffix = useMemo(() => new Date().toISOString().split('T')[0], [])
|
|
69
|
+
const filename = `${csvFilename}-${dateSuffix}.csv`
|
|
70
|
+
|
|
71
|
+
const { exportCSV: doExport, isExporting } = useCSVExport({
|
|
72
|
+
exportFn: async () => {
|
|
73
|
+
const ids = exportIdsRef.current
|
|
74
|
+
const filtered = ids
|
|
75
|
+
? items.filter((item) => ids.has(String(item[idField])))
|
|
76
|
+
: items
|
|
77
|
+
return buildCSV(filtered)
|
|
78
|
+
},
|
|
79
|
+
filename,
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
const exportCSV = useCallback(
|
|
83
|
+
async (selectedIds?: Set<string>) => {
|
|
84
|
+
if (selectedIds) {
|
|
85
|
+
exportIdsRef.current = selectedIds
|
|
86
|
+
}
|
|
87
|
+
await doExport()
|
|
88
|
+
exportIdsRef.current = null
|
|
89
|
+
},
|
|
90
|
+
[doExport]
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
const openView = useCallback((item: T) => {
|
|
94
|
+
setSelectedItem(item)
|
|
95
|
+
setEditingItem(null)
|
|
96
|
+
setViewMode('view')
|
|
97
|
+
setShowCreateForm(false)
|
|
98
|
+
}, [])
|
|
99
|
+
|
|
100
|
+
const openEdit = useCallback((item: T) => {
|
|
101
|
+
setSelectedItem(null)
|
|
102
|
+
setEditingItem(item)
|
|
103
|
+
setViewMode('edit')
|
|
104
|
+
setShowCreateForm(false)
|
|
105
|
+
}, [])
|
|
106
|
+
|
|
107
|
+
const openCreate = useCallback(() => {
|
|
108
|
+
setSelectedItem(null)
|
|
109
|
+
setEditingItem(null)
|
|
110
|
+
setViewMode(null)
|
|
111
|
+
setShowCreateForm(true)
|
|
112
|
+
}, [])
|
|
113
|
+
|
|
114
|
+
const closePanel = useCallback(() => {
|
|
115
|
+
setSelectedItem(null)
|
|
116
|
+
setEditingItem(null)
|
|
117
|
+
setViewMode(null)
|
|
118
|
+
setShowCreateForm(false)
|
|
119
|
+
}, [])
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
selectedItem,
|
|
123
|
+
editingItem,
|
|
124
|
+
viewMode,
|
|
125
|
+
showCreateForm,
|
|
126
|
+
exportIdsRef,
|
|
127
|
+
exportCSV,
|
|
128
|
+
isExporting,
|
|
129
|
+
openView,
|
|
130
|
+
openEdit,
|
|
131
|
+
openCreate,
|
|
132
|
+
closePanel,
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
2
|
+
import type { MessageTemplatesApi, MessageTemplateFilters, CreateMessageTemplateInput } from '@startsimpli/api'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* List message templates with optional filters (paginated)
|
|
6
|
+
*/
|
|
7
|
+
export function useMessageTemplates(api: MessageTemplatesApi, filters?: MessageTemplateFilters) {
|
|
8
|
+
return useQuery({
|
|
9
|
+
queryKey: ['messageTemplates', filters],
|
|
10
|
+
queryFn: () => api.list(filters),
|
|
11
|
+
})
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Get a single message template by ID
|
|
16
|
+
*/
|
|
17
|
+
export function useMessageTemplate(api: MessageTemplatesApi, id: string) {
|
|
18
|
+
return useQuery({
|
|
19
|
+
queryKey: ['messageTemplates', id],
|
|
20
|
+
queryFn: () => api.get(id),
|
|
21
|
+
enabled: !!id,
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Create a new message template
|
|
27
|
+
*/
|
|
28
|
+
export function useCreateMessageTemplate(api: MessageTemplatesApi) {
|
|
29
|
+
const queryClient = useQueryClient()
|
|
30
|
+
|
|
31
|
+
return useMutation({
|
|
32
|
+
mutationFn: (data: CreateMessageTemplateInput) => api.create(data),
|
|
33
|
+
onSuccess: () => {
|
|
34
|
+
queryClient.invalidateQueries({ queryKey: ['messageTemplates'] })
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Update an existing message template
|
|
41
|
+
*/
|
|
42
|
+
export function useUpdateMessageTemplate(api: MessageTemplatesApi) {
|
|
43
|
+
const queryClient = useQueryClient()
|
|
44
|
+
|
|
45
|
+
return useMutation({
|
|
46
|
+
mutationFn: ({ id, data }: { id: string; data: Partial<CreateMessageTemplateInput> }) =>
|
|
47
|
+
api.update(id, data),
|
|
48
|
+
onSuccess: (_, variables) => {
|
|
49
|
+
queryClient.invalidateQueries({ queryKey: ['messageTemplates'] })
|
|
50
|
+
queryClient.invalidateQueries({ queryKey: ['messageTemplates', variables.id] })
|
|
51
|
+
},
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Delete a message template
|
|
57
|
+
*/
|
|
58
|
+
export function useDeleteMessageTemplate(api: MessageTemplatesApi) {
|
|
59
|
+
const queryClient = useQueryClient()
|
|
60
|
+
|
|
61
|
+
return useMutation({
|
|
62
|
+
mutationFn: (id: string) => api.delete(id),
|
|
63
|
+
onSuccess: () => {
|
|
64
|
+
queryClient.invalidateQueries({ queryKey: ['messageTemplates'] })
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
2
|
+
import type { MessagesApi, MessageFilters, CreateMessageInput, ScheduleMessageInput, SendTestInput } from '@startsimpli/api'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* List messages with optional filters (paginated)
|
|
6
|
+
*/
|
|
7
|
+
export function useMessages(api: MessagesApi, filters?: MessageFilters) {
|
|
8
|
+
return useQuery({
|
|
9
|
+
queryKey: ['messages', filters],
|
|
10
|
+
queryFn: () => api.list(filters),
|
|
11
|
+
})
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Get a single message by ID
|
|
16
|
+
*/
|
|
17
|
+
export function useMessage(api: MessagesApi, id: string) {
|
|
18
|
+
return useQuery({
|
|
19
|
+
queryKey: ['messages', id],
|
|
20
|
+
queryFn: () => api.get(id),
|
|
21
|
+
enabled: !!id,
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Create a new message (draft)
|
|
27
|
+
*/
|
|
28
|
+
export function useCreateMessage(api: MessagesApi) {
|
|
29
|
+
const queryClient = useQueryClient()
|
|
30
|
+
|
|
31
|
+
return useMutation({
|
|
32
|
+
mutationFn: (data: CreateMessageInput) => api.create(data),
|
|
33
|
+
onSuccess: () => {
|
|
34
|
+
queryClient.invalidateQueries({ queryKey: ['messages'] })
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Send a message immediately
|
|
41
|
+
*/
|
|
42
|
+
export function useSendMessage(api: MessagesApi, id: string) {
|
|
43
|
+
const queryClient = useQueryClient()
|
|
44
|
+
|
|
45
|
+
return useMutation({
|
|
46
|
+
mutationFn: () => api.sendNow(id),
|
|
47
|
+
onSuccess: () => {
|
|
48
|
+
queryClient.invalidateQueries({ queryKey: ['messages'] })
|
|
49
|
+
queryClient.invalidateQueries({ queryKey: ['messages', id] })
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Schedule a message for future sending
|
|
56
|
+
*/
|
|
57
|
+
export function useScheduleMessage(api: MessagesApi, id: string) {
|
|
58
|
+
const queryClient = useQueryClient()
|
|
59
|
+
|
|
60
|
+
return useMutation({
|
|
61
|
+
mutationFn: (input: ScheduleMessageInput) => api.schedule(id, input),
|
|
62
|
+
onSuccess: () => {
|
|
63
|
+
queryClient.invalidateQueries({ queryKey: ['messages'] })
|
|
64
|
+
queryClient.invalidateQueries({ queryKey: ['messages', id] })
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Send a test message
|
|
71
|
+
*/
|
|
72
|
+
export function useSendTestMessage(api: MessagesApi, id: string) {
|
|
73
|
+
return useMutation({
|
|
74
|
+
mutationFn: (input?: SendTestInput) => api.sendTest(id, input),
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* List available messaging channels
|
|
80
|
+
*/
|
|
81
|
+
export function useMessageChannels(api: MessagesApi) {
|
|
82
|
+
return useQuery({
|
|
83
|
+
queryKey: ['messages', 'channels'],
|
|
84
|
+
queryFn: () => api.getChannels(),
|
|
85
|
+
})
|
|
86
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
2
|
+
import type {
|
|
3
|
+
TargetList,
|
|
4
|
+
TargetListMember,
|
|
5
|
+
CreateTargetListInput,
|
|
6
|
+
UpdateTargetListInput,
|
|
7
|
+
TargetListFilters,
|
|
8
|
+
TargetListMemberFilters,
|
|
9
|
+
AddMembersResult,
|
|
10
|
+
RemoveMembersResult,
|
|
11
|
+
RefreshResult,
|
|
12
|
+
PaginatedResponse,
|
|
13
|
+
} from '@startsimpli/api'
|
|
14
|
+
|
|
15
|
+
export const TARGET_LIST_KEYS = {
|
|
16
|
+
all: ['target-lists'] as const,
|
|
17
|
+
lists: (filters?: TargetListFilters) => [...TARGET_LIST_KEYS.all, filters] as const,
|
|
18
|
+
detail: (id: string) => ['target-list', id] as const,
|
|
19
|
+
members: (listId: string, filters?: TargetListMemberFilters) =>
|
|
20
|
+
['list-members', listId, filters] as const,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface TargetListApiFns {
|
|
24
|
+
list: (filters?: TargetListFilters) => Promise<PaginatedResponse<TargetList>>
|
|
25
|
+
get: (id: string) => Promise<TargetList>
|
|
26
|
+
create: (data: CreateTargetListInput) => Promise<TargetList>
|
|
27
|
+
update: (id: string, data: UpdateTargetListInput) => Promise<TargetList>
|
|
28
|
+
delete: (id: string) => Promise<void>
|
|
29
|
+
getMembers: (listId: string, filters?: TargetListMemberFilters) => Promise<PaginatedResponse<TargetListMember>>
|
|
30
|
+
addMembers: (listId: string, contactIds: string[]) => Promise<AddMembersResult>
|
|
31
|
+
removeMembers: (listId: string, contactIds: string[]) => Promise<RemoveMembersResult>
|
|
32
|
+
refresh: (listId: string) => Promise<RefreshResult>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function useTargetListDetail(
|
|
36
|
+
id: string,
|
|
37
|
+
apiFns: Pick<TargetListApiFns, 'get'>
|
|
38
|
+
) {
|
|
39
|
+
return useQuery({
|
|
40
|
+
queryKey: TARGET_LIST_KEYS.detail(id),
|
|
41
|
+
queryFn: () => apiFns.get(id),
|
|
42
|
+
enabled: !!id,
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface UseTargetListMutationsOptions {
|
|
47
|
+
apiFns: Pick<TargetListApiFns, 'create' | 'update' | 'delete' | 'addMembers' | 'removeMembers' | 'refresh'>
|
|
48
|
+
onSuccess?: (action: string) => void
|
|
49
|
+
onError?: (action: string) => void
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function useTargetListMutations({
|
|
53
|
+
apiFns,
|
|
54
|
+
onSuccess,
|
|
55
|
+
onError,
|
|
56
|
+
}: UseTargetListMutationsOptions) {
|
|
57
|
+
const queryClient = useQueryClient()
|
|
58
|
+
|
|
59
|
+
const invalidateList = (listId?: string) => {
|
|
60
|
+
queryClient.invalidateQueries({ queryKey: TARGET_LIST_KEYS.all })
|
|
61
|
+
if (listId) {
|
|
62
|
+
queryClient.invalidateQueries({ queryKey: TARGET_LIST_KEYS.detail(listId) })
|
|
63
|
+
queryClient.invalidateQueries({ queryKey: ['list-members', listId] })
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const createList = useMutation({
|
|
68
|
+
mutationFn: (data: CreateTargetListInput) => apiFns.create(data),
|
|
69
|
+
onSuccess: () => {
|
|
70
|
+
invalidateList()
|
|
71
|
+
onSuccess?.('List created')
|
|
72
|
+
},
|
|
73
|
+
onError: () => onError?.('Failed to create list'),
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const updateList = useMutation({
|
|
77
|
+
mutationFn: ({ id, data }: { id: string; data: UpdateTargetListInput }) =>
|
|
78
|
+
apiFns.update(id, data),
|
|
79
|
+
onSuccess: (_, variables) => {
|
|
80
|
+
invalidateList(variables.id)
|
|
81
|
+
onSuccess?.('List updated')
|
|
82
|
+
},
|
|
83
|
+
onError: () => onError?.('Failed to update list'),
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const deleteList = useMutation({
|
|
87
|
+
mutationFn: (id: string) => apiFns.delete(id),
|
|
88
|
+
onSuccess: () => {
|
|
89
|
+
invalidateList()
|
|
90
|
+
onSuccess?.('List deleted')
|
|
91
|
+
},
|
|
92
|
+
onError: () => onError?.('Failed to delete list'),
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
const addMembersMutation = useMutation({
|
|
96
|
+
mutationFn: ({ listId, contactIds }: { listId: string; contactIds: string[] }) =>
|
|
97
|
+
apiFns.addMembers(listId, contactIds),
|
|
98
|
+
onSuccess: (_, variables) => {
|
|
99
|
+
invalidateList(variables.listId)
|
|
100
|
+
onSuccess?.('Members added')
|
|
101
|
+
},
|
|
102
|
+
onError: () => onError?.('Failed to add members'),
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
const removeMembersMutation = useMutation({
|
|
106
|
+
mutationFn: ({ listId, contactIds }: { listId: string; contactIds: string[] }) =>
|
|
107
|
+
apiFns.removeMembers(listId, contactIds),
|
|
108
|
+
onSuccess: (_, variables) => {
|
|
109
|
+
invalidateList(variables.listId)
|
|
110
|
+
onSuccess?.('Members removed')
|
|
111
|
+
},
|
|
112
|
+
onError: () => onError?.('Failed to remove members'),
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
const refreshList = useMutation({
|
|
116
|
+
mutationFn: (listId: string) => apiFns.refresh(listId),
|
|
117
|
+
onSuccess: (_, listId) => {
|
|
118
|
+
invalidateList(listId)
|
|
119
|
+
onSuccess?.('List refreshed')
|
|
120
|
+
},
|
|
121
|
+
onError: () => onError?.('Failed to refresh list'),
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
createList,
|
|
126
|
+
updateList,
|
|
127
|
+
deleteList,
|
|
128
|
+
addMembers: addMembersMutation,
|
|
129
|
+
removeMembers: removeMembersMutation,
|
|
130
|
+
refreshList,
|
|
131
|
+
}
|
|
132
|
+
}
|
package/src/useWizard.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import { useState, useCallback } from 'react'
|
|
1
|
+
import { useState, useCallback, useRef } from 'react'
|
|
2
|
+
|
|
3
|
+
export interface WizardOptions<TStep extends string> {
|
|
4
|
+
validate?: Partial<Record<TStep, () => Record<string, string> | null>>
|
|
5
|
+
}
|
|
2
6
|
|
|
3
7
|
export interface WizardState<TStep extends string> {
|
|
4
8
|
currentStep: TStep
|
|
@@ -8,23 +12,41 @@ export interface WizardState<TStep extends string> {
|
|
|
8
12
|
isLastStep: boolean
|
|
9
13
|
canGoBack: boolean
|
|
10
14
|
canGoNext: boolean
|
|
15
|
+
errors: Record<string, string> | null
|
|
11
16
|
goTo: (step: TStep) => void
|
|
12
|
-
next: () =>
|
|
17
|
+
next: () => Record<string, string> | null
|
|
13
18
|
prev: () => void
|
|
14
19
|
reset: () => void
|
|
20
|
+
clearErrors: () => void
|
|
15
21
|
}
|
|
16
22
|
|
|
17
23
|
export function useWizard<TStep extends string>(
|
|
18
24
|
steps: readonly TStep[],
|
|
19
|
-
|
|
25
|
+
initialStepOrOpts?: TStep | WizardOptions<TStep>,
|
|
26
|
+
opts?: WizardOptions<TStep>
|
|
20
27
|
): WizardState<TStep> {
|
|
28
|
+
// Support both old signature (steps, initialStep?) and new (steps, opts?) and (steps, initialStep, opts?)
|
|
29
|
+
const initialStep: TStep | undefined =
|
|
30
|
+
typeof initialStepOrOpts === 'string' ? initialStepOrOpts : undefined
|
|
31
|
+
const options: WizardOptions<TStep> | undefined =
|
|
32
|
+
typeof initialStepOrOpts === 'object' ? initialStepOrOpts : opts
|
|
33
|
+
|
|
21
34
|
const [currentStep, setCurrentStep] = useState<TStep>(initialStep ?? steps[0])
|
|
35
|
+
const [errors, setErrors] = useState<Record<string, string> | null>(null)
|
|
36
|
+
|
|
37
|
+
// Keep validate ref stable to avoid stale closures while keeping validators current
|
|
38
|
+
const validateRef = useRef(options?.validate)
|
|
39
|
+
validateRef.current = options?.validate
|
|
22
40
|
|
|
23
41
|
const stepIndex = steps.indexOf(currentStep)
|
|
24
42
|
const totalSteps = steps.length
|
|
25
43
|
const isFirstStep = stepIndex === 0
|
|
26
44
|
const isLastStep = stepIndex === totalSteps - 1
|
|
27
45
|
|
|
46
|
+
const clearErrors = useCallback(() => {
|
|
47
|
+
setErrors(null)
|
|
48
|
+
}, [])
|
|
49
|
+
|
|
28
50
|
const goTo = useCallback(
|
|
29
51
|
(step: TStep) => {
|
|
30
52
|
if (steps.includes(step)) setCurrentStep(step)
|
|
@@ -32,8 +54,18 @@ export function useWizard<TStep extends string>(
|
|
|
32
54
|
[steps]
|
|
33
55
|
)
|
|
34
56
|
|
|
35
|
-
const next = useCallback(() => {
|
|
57
|
+
const next = useCallback((): Record<string, string> | null => {
|
|
58
|
+
const validator = validateRef.current?.[steps[stepIndex] as TStep]
|
|
59
|
+
if (validator) {
|
|
60
|
+
const result = validator()
|
|
61
|
+
if (result && Object.keys(result).length > 0) {
|
|
62
|
+
setErrors(result)
|
|
63
|
+
return result
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
setErrors(null)
|
|
36
67
|
if (!isLastStep) setCurrentStep(steps[stepIndex + 1])
|
|
68
|
+
return null
|
|
37
69
|
}, [isLastStep, stepIndex, steps])
|
|
38
70
|
|
|
39
71
|
const prev = useCallback(() => {
|
|
@@ -42,6 +74,7 @@ export function useWizard<TStep extends string>(
|
|
|
42
74
|
|
|
43
75
|
const reset = useCallback(() => {
|
|
44
76
|
setCurrentStep(initialStep ?? steps[0])
|
|
77
|
+
setErrors(null)
|
|
45
78
|
}, [initialStep, steps])
|
|
46
79
|
|
|
47
80
|
return {
|
|
@@ -52,9 +85,11 @@ export function useWizard<TStep extends string>(
|
|
|
52
85
|
isLastStep,
|
|
53
86
|
canGoBack: !isFirstStep,
|
|
54
87
|
canGoNext: !isLastStep,
|
|
88
|
+
errors,
|
|
55
89
|
goTo,
|
|
56
90
|
next,
|
|
57
91
|
prev,
|
|
58
92
|
reset,
|
|
93
|
+
clearErrors,
|
|
59
94
|
}
|
|
60
95
|
}
|