@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/hooks",
3
- "version": "0.4.7",
3
+ "version": "0.4.8",
4
4
  "description": "Shared React hooks for StartSimpli apps",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -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: () => void
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
- initialStep?: TStep
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
  }