@startsimpli/hooks 0.4.7 → 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 +1 -1
- 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 +5 -1
- package/src/useAsyncOptions.ts +60 -0
- package/src/useEntityTable.ts +134 -0
- package/src/useWizard.ts +39 -4
package/package.json
CHANGED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { renderHook, act, waitFor } from '@testing-library/react'
|
|
3
|
+
import { useAsyncOptions } from '../useAsyncOptions'
|
|
4
|
+
|
|
5
|
+
describe('useAsyncOptions', () => {
|
|
6
|
+
it('fetches data on mount and returns it', async () => {
|
|
7
|
+
const items = [{ id: 1 }, { id: 2 }]
|
|
8
|
+
const fetcher = vi.fn().mockResolvedValue(items)
|
|
9
|
+
|
|
10
|
+
const { result } = renderHook(() => useAsyncOptions(fetcher))
|
|
11
|
+
|
|
12
|
+
expect(result.current.loading).toBe(true)
|
|
13
|
+
expect(result.current.data).toEqual([])
|
|
14
|
+
|
|
15
|
+
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
16
|
+
|
|
17
|
+
expect(result.current.data).toEqual(items)
|
|
18
|
+
expect(result.current.error).toBeNull()
|
|
19
|
+
expect(fetcher).toHaveBeenCalledOnce()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('returns defaultValue while loading', async () => {
|
|
23
|
+
const defaultValue = [{ id: 0 }]
|
|
24
|
+
const fetcher = vi.fn().mockResolvedValue([{ id: 1 }])
|
|
25
|
+
|
|
26
|
+
const { result } = renderHook(() =>
|
|
27
|
+
useAsyncOptions(fetcher, { defaultValue })
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
expect(result.current.data).toEqual(defaultValue)
|
|
31
|
+
|
|
32
|
+
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
33
|
+
expect(result.current.data).toEqual([{ id: 1 }])
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('does not fetch when enabled is false', async () => {
|
|
37
|
+
const fetcher = vi.fn().mockResolvedValue([])
|
|
38
|
+
|
|
39
|
+
const { result } = renderHook(() =>
|
|
40
|
+
useAsyncOptions(fetcher, { enabled: false })
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
// Give it a tick to ensure nothing fires
|
|
44
|
+
await act(async () => {
|
|
45
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
expect(fetcher).not.toHaveBeenCalled()
|
|
49
|
+
expect(result.current.loading).toBe(false)
|
|
50
|
+
expect(result.current.data).toEqual([])
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('fetches when enabled changes from false to true', async () => {
|
|
54
|
+
const items = [{ id: 1 }]
|
|
55
|
+
const fetcher = vi.fn().mockResolvedValue(items)
|
|
56
|
+
|
|
57
|
+
const { result, rerender } = renderHook(
|
|
58
|
+
({ enabled }) => useAsyncOptions(fetcher, { enabled }),
|
|
59
|
+
{ initialProps: { enabled: false } }
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
expect(fetcher).not.toHaveBeenCalled()
|
|
63
|
+
|
|
64
|
+
rerender({ enabled: true })
|
|
65
|
+
|
|
66
|
+
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
67
|
+
|
|
68
|
+
expect(result.current.data).toEqual(items)
|
|
69
|
+
expect(fetcher).toHaveBeenCalledOnce()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('captures errors', async () => {
|
|
73
|
+
const fetcher = vi.fn().mockRejectedValue(new Error('Network error'))
|
|
74
|
+
|
|
75
|
+
const { result } = renderHook(() => useAsyncOptions(fetcher))
|
|
76
|
+
|
|
77
|
+
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
78
|
+
|
|
79
|
+
expect(result.current.error).toBeInstanceOf(Error)
|
|
80
|
+
expect(result.current.error?.message).toBe('Network error')
|
|
81
|
+
expect(result.current.data).toEqual([])
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('wraps non-Error rejections', async () => {
|
|
85
|
+
const fetcher = vi.fn().mockRejectedValue('string error')
|
|
86
|
+
|
|
87
|
+
const { result } = renderHook(() => useAsyncOptions(fetcher))
|
|
88
|
+
|
|
89
|
+
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
90
|
+
|
|
91
|
+
expect(result.current.error).toBeInstanceOf(Error)
|
|
92
|
+
expect(result.current.error?.message).toBe('string error')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('refresh re-triggers the fetch', async () => {
|
|
96
|
+
let callCount = 0
|
|
97
|
+
const fetcher = vi.fn().mockImplementation(() => {
|
|
98
|
+
callCount++
|
|
99
|
+
return Promise.resolve([{ id: callCount }])
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
const { result } = renderHook(() => useAsyncOptions(fetcher))
|
|
103
|
+
|
|
104
|
+
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
105
|
+
expect(result.current.data).toEqual([{ id: 1 }])
|
|
106
|
+
|
|
107
|
+
act(() => {
|
|
108
|
+
result.current.refresh()
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
112
|
+
expect(result.current.data).toEqual([{ id: 2 }])
|
|
113
|
+
expect(fetcher).toHaveBeenCalledTimes(2)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('does not set state after unmount', async () => {
|
|
117
|
+
let resolvePromise: (value: unknown[]) => void
|
|
118
|
+
const fetcher = vi.fn().mockImplementation(
|
|
119
|
+
() => new Promise<unknown[]>((resolve) => { resolvePromise = resolve })
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
const { result, unmount } = renderHook(() => useAsyncOptions(fetcher))
|
|
123
|
+
|
|
124
|
+
expect(result.current.loading).toBe(true)
|
|
125
|
+
|
|
126
|
+
unmount()
|
|
127
|
+
|
|
128
|
+
// Resolve after unmount — should not throw or update state
|
|
129
|
+
await act(async () => {
|
|
130
|
+
resolvePromise!([{ id: 1 }])
|
|
131
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
// No error thrown = success (React would warn on set-state-after-unmount)
|
|
135
|
+
})
|
|
136
|
+
})
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { renderHook, act } from '@testing-library/react'
|
|
3
|
+
import { useEntityTable } from '../useEntityTable'
|
|
4
|
+
|
|
5
|
+
interface TestEntity {
|
|
6
|
+
id: string
|
|
7
|
+
name: string
|
|
8
|
+
value: number
|
|
9
|
+
createdAt: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function makeEntity(id: string, name: string, value: number): TestEntity {
|
|
13
|
+
return { id, name, value, createdAt: '2026-01-15T00:00:00Z' }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function makeOptions(items: TestEntity[]) {
|
|
17
|
+
return {
|
|
18
|
+
items,
|
|
19
|
+
csvFilename: 'test-export',
|
|
20
|
+
csvColumns: ['Name', 'Value', 'Created'],
|
|
21
|
+
csvRowMapper: (item: TestEntity) => [
|
|
22
|
+
item.name,
|
|
23
|
+
item.value.toString(),
|
|
24
|
+
new Date(item.createdAt).toLocaleDateString(),
|
|
25
|
+
],
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Mock URL APIs for CSV export
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
vi.clearAllMocks()
|
|
32
|
+
vi.stubGlobal('URL', {
|
|
33
|
+
createObjectURL: vi.fn().mockReturnValue('blob:test'),
|
|
34
|
+
revokeObjectURL: vi.fn(),
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Patches document.createElement so only 'a' tags return a fake anchor,
|
|
40
|
+
* while React's internal DOM calls go through the real implementation.
|
|
41
|
+
*/
|
|
42
|
+
function mockAnchorElement() {
|
|
43
|
+
const clickSpy = vi.fn()
|
|
44
|
+
const realCreateElement = document.createElement.bind(document)
|
|
45
|
+
const spy = vi.spyOn(document, 'createElement').mockImplementation((tag: string, options?: ElementCreationOptions) => {
|
|
46
|
+
if (tag === 'a') {
|
|
47
|
+
return { href: '', download: '', click: clickSpy } as unknown as HTMLAnchorElement
|
|
48
|
+
}
|
|
49
|
+
return realCreateElement(tag, options)
|
|
50
|
+
})
|
|
51
|
+
return { clickSpy, restore: () => spy.mockRestore() }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('useEntityTable', () => {
|
|
55
|
+
describe('modal state', () => {
|
|
56
|
+
it('initializes with null/closed state', () => {
|
|
57
|
+
const { result } = renderHook(() =>
|
|
58
|
+
useEntityTable<TestEntity>(makeOptions([]))
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
expect(result.current.selectedItem).toBeNull()
|
|
62
|
+
expect(result.current.editingItem).toBeNull()
|
|
63
|
+
expect(result.current.viewMode).toBeNull()
|
|
64
|
+
expect(result.current.showCreateForm).toBe(false)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('openView sets selectedItem and viewMode to view', () => {
|
|
68
|
+
const entity = makeEntity('1', 'Acme', 100)
|
|
69
|
+
const { result } = renderHook(() =>
|
|
70
|
+
useEntityTable<TestEntity>(makeOptions([entity]))
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
act(() => {
|
|
74
|
+
result.current.openView(entity)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
expect(result.current.selectedItem).toBe(entity)
|
|
78
|
+
expect(result.current.editingItem).toBeNull()
|
|
79
|
+
expect(result.current.viewMode).toBe('view')
|
|
80
|
+
expect(result.current.showCreateForm).toBe(false)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('openEdit sets editingItem and viewMode to edit', () => {
|
|
84
|
+
const entity = makeEntity('1', 'Acme', 100)
|
|
85
|
+
const { result } = renderHook(() =>
|
|
86
|
+
useEntityTable<TestEntity>(makeOptions([entity]))
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
act(() => {
|
|
90
|
+
result.current.openEdit(entity)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
expect(result.current.selectedItem).toBeNull()
|
|
94
|
+
expect(result.current.editingItem).toBe(entity)
|
|
95
|
+
expect(result.current.viewMode).toBe('edit')
|
|
96
|
+
expect(result.current.showCreateForm).toBe(false)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('openCreate shows create form and clears other state', () => {
|
|
100
|
+
const entity = makeEntity('1', 'Acme', 100)
|
|
101
|
+
const { result } = renderHook(() =>
|
|
102
|
+
useEntityTable<TestEntity>(makeOptions([entity]))
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
act(() => {
|
|
106
|
+
result.current.openView(entity)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
act(() => {
|
|
110
|
+
result.current.openCreate()
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
expect(result.current.selectedItem).toBeNull()
|
|
114
|
+
expect(result.current.editingItem).toBeNull()
|
|
115
|
+
expect(result.current.viewMode).toBeNull()
|
|
116
|
+
expect(result.current.showCreateForm).toBe(true)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('closePanel resets all modal state', () => {
|
|
120
|
+
const entity = makeEntity('1', 'Acme', 100)
|
|
121
|
+
const { result } = renderHook(() =>
|
|
122
|
+
useEntityTable<TestEntity>(makeOptions([entity]))
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
act(() => {
|
|
126
|
+
result.current.openEdit(entity)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
act(() => {
|
|
130
|
+
result.current.closePanel()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
expect(result.current.selectedItem).toBeNull()
|
|
134
|
+
expect(result.current.editingItem).toBeNull()
|
|
135
|
+
expect(result.current.viewMode).toBeNull()
|
|
136
|
+
expect(result.current.showCreateForm).toBe(false)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('openView clears create form', () => {
|
|
140
|
+
const entity = makeEntity('1', 'Acme', 100)
|
|
141
|
+
const { result } = renderHook(() =>
|
|
142
|
+
useEntityTable<TestEntity>(makeOptions([entity]))
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
act(() => {
|
|
146
|
+
result.current.openCreate()
|
|
147
|
+
})
|
|
148
|
+
expect(result.current.showCreateForm).toBe(true)
|
|
149
|
+
|
|
150
|
+
act(() => {
|
|
151
|
+
result.current.openView(entity)
|
|
152
|
+
})
|
|
153
|
+
expect(result.current.showCreateForm).toBe(false)
|
|
154
|
+
expect(result.current.viewMode).toBe('view')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('openEdit clears create form', () => {
|
|
158
|
+
const entity = makeEntity('1', 'Acme', 100)
|
|
159
|
+
const { result } = renderHook(() =>
|
|
160
|
+
useEntityTable<TestEntity>(makeOptions([entity]))
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
act(() => {
|
|
164
|
+
result.current.openCreate()
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
act(() => {
|
|
168
|
+
result.current.openEdit(entity)
|
|
169
|
+
})
|
|
170
|
+
expect(result.current.showCreateForm).toBe(false)
|
|
171
|
+
expect(result.current.viewMode).toBe('edit')
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
describe('CSV export', () => {
|
|
176
|
+
let anchorMock: { clickSpy: ReturnType<typeof vi.fn>; restore: () => void }
|
|
177
|
+
|
|
178
|
+
afterEach(() => {
|
|
179
|
+
anchorMock?.restore()
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('exports all items when no IDs provided', async () => {
|
|
183
|
+
const items = [
|
|
184
|
+
makeEntity('1', 'Acme', 100),
|
|
185
|
+
makeEntity('2', 'Beta', 200),
|
|
186
|
+
]
|
|
187
|
+
const { result } = renderHook(() =>
|
|
188
|
+
useEntityTable<TestEntity>(makeOptions(items))
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
anchorMock = mockAnchorElement()
|
|
192
|
+
|
|
193
|
+
await act(async () => {
|
|
194
|
+
await result.current.exportCSV()
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
expect(anchorMock.clickSpy).toHaveBeenCalledOnce()
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('exports only selected items when IDs provided', async () => {
|
|
201
|
+
const items = [
|
|
202
|
+
makeEntity('1', 'Acme', 100),
|
|
203
|
+
makeEntity('2', 'Beta', 200),
|
|
204
|
+
makeEntity('3', 'Gamma', 300),
|
|
205
|
+
]
|
|
206
|
+
const { result } = renderHook(() =>
|
|
207
|
+
useEntityTable<TestEntity>(makeOptions(items))
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
anchorMock = mockAnchorElement()
|
|
211
|
+
|
|
212
|
+
await act(async () => {
|
|
213
|
+
await result.current.exportCSV(new Set(['1', '3']))
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
expect(anchorMock.clickSpy).toHaveBeenCalledOnce()
|
|
217
|
+
// After export, exportIdsRef should be cleared
|
|
218
|
+
expect(result.current.exportIdsRef.current).toBeNull()
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('clears exportIdsRef after export completes', async () => {
|
|
222
|
+
const items = [makeEntity('1', 'Acme', 100)]
|
|
223
|
+
const { result } = renderHook(() =>
|
|
224
|
+
useEntityTable<TestEntity>(makeOptions(items))
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
anchorMock = mockAnchorElement()
|
|
228
|
+
result.current.exportIdsRef.current = new Set(['1'])
|
|
229
|
+
|
|
230
|
+
await act(async () => {
|
|
231
|
+
await result.current.exportCSV()
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
expect(result.current.exportIdsRef.current).toBeNull()
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('provides exportIdsRef for external use', () => {
|
|
238
|
+
const { result } = renderHook(() =>
|
|
239
|
+
useEntityTable<TestEntity>(makeOptions([]))
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
expect(result.current.exportIdsRef).toBeDefined()
|
|
243
|
+
expect(result.current.exportIdsRef.current).toBeNull()
|
|
244
|
+
})
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
describe('custom idField', () => {
|
|
248
|
+
interface CustomIdEntity {
|
|
249
|
+
internalId: string
|
|
250
|
+
label: string
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
let anchorMock: { clickSpy: ReturnType<typeof vi.fn>; restore: () => void }
|
|
254
|
+
|
|
255
|
+
afterEach(() => {
|
|
256
|
+
anchorMock?.restore()
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('uses custom idField for CSV filtering', async () => {
|
|
260
|
+
const items: CustomIdEntity[] = [
|
|
261
|
+
{ internalId: 'a', label: 'Alpha' },
|
|
262
|
+
{ internalId: 'b', label: 'Bravo' },
|
|
263
|
+
]
|
|
264
|
+
|
|
265
|
+
const { result } = renderHook(() =>
|
|
266
|
+
useEntityTable<CustomIdEntity>({
|
|
267
|
+
items,
|
|
268
|
+
idField: 'internalId',
|
|
269
|
+
csvFilename: 'custom',
|
|
270
|
+
csvColumns: ['Label'],
|
|
271
|
+
csvRowMapper: (item) => [item.label],
|
|
272
|
+
})
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
anchorMock = mockAnchorElement()
|
|
276
|
+
|
|
277
|
+
await act(async () => {
|
|
278
|
+
await result.current.exportCSV(new Set(['a']))
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
// Should not throw; idField 'internalId' was used for filtering
|
|
282
|
+
expect(result.current.exportIdsRef.current).toBeNull()
|
|
283
|
+
})
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
describe('generic typing', () => {
|
|
287
|
+
it('preserves entity type in selectedItem', () => {
|
|
288
|
+
const entity = makeEntity('1', 'Acme', 100)
|
|
289
|
+
const { result } = renderHook(() =>
|
|
290
|
+
useEntityTable<TestEntity>(makeOptions([entity]))
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
act(() => {
|
|
294
|
+
result.current.openView(entity)
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
// TypeScript compile-time check: selectedItem is TestEntity | null
|
|
298
|
+
const item = result.current.selectedItem
|
|
299
|
+
if (item) {
|
|
300
|
+
expect(item.name).toBe('Acme')
|
|
301
|
+
expect(item.value).toBe(100)
|
|
302
|
+
}
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it('preserves entity type in editingItem', () => {
|
|
306
|
+
const entity = makeEntity('1', 'Acme', 100)
|
|
307
|
+
const { result } = renderHook(() =>
|
|
308
|
+
useEntityTable<TestEntity>(makeOptions([entity]))
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
act(() => {
|
|
312
|
+
result.current.openEdit(entity)
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
const item = result.current.editingItem
|
|
316
|
+
if (item) {
|
|
317
|
+
expect(item.name).toBe('Acme')
|
|
318
|
+
expect(item.value).toBe(100)
|
|
319
|
+
}
|
|
320
|
+
})
|
|
321
|
+
})
|
|
322
|
+
})
|
|
@@ -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
|
@@ -42,8 +42,10 @@ export { useSavedViews } from './useSavedViews'
|
|
|
42
42
|
export type { SavedView as SavedViewEntry } from './useSavedViews'
|
|
43
43
|
export { useRecentlyViewed } from './useRecentlyViewed'
|
|
44
44
|
export { useWizard } from './useWizard'
|
|
45
|
-
export type { WizardState } from './useWizard'
|
|
45
|
+
export type { WizardState, WizardOptions } from './useWizard'
|
|
46
46
|
export { useCSVImport, useCSVExport } from './useCSV'
|
|
47
|
+
export { useEntityTable } from './useEntityTable'
|
|
48
|
+
export type { UseEntityTableOptions, UseEntityTableReturn } from './useEntityTable'
|
|
47
49
|
export { useTargetListDetail, useTargetListMutations, TARGET_LIST_KEYS } from './useTargetLists'
|
|
48
50
|
export type { TargetListApiFns, UseTargetListMutationsOptions } from './useTargetLists'
|
|
49
51
|
export { useEnrichment, useBatchEnrichment, useQueueStatus } from './useEnrichment'
|
|
@@ -62,3 +64,5 @@ export type {
|
|
|
62
64
|
CSVImportStep,
|
|
63
65
|
UseCSVImportState,
|
|
64
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
|
+
}
|
|
@@ -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
|
+
}
|
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
|
}
|