@startsimpli/hooks 0.1.1
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/dist/index.d.mts +223 -0
- package/dist/index.d.ts +223 -0
- package/dist/index.js +398 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +384 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +51 -0
- package/src/__tests__/useCRUDMutation.test.ts +116 -0
- package/src/__tests__/useRecentlyViewed.test.ts +142 -0
- package/src/__tests__/useSavedViews.test.ts +169 -0
- package/src/__tests__/useTableFilters.test.ts +110 -0
- package/src/__tests__/useWizard.test.ts +76 -0
- package/src/filter-encoding.ts +115 -0
- package/src/filter-types.ts +82 -0
- package/src/index.ts +37 -0
- package/src/useCRUDMutation.ts +26 -0
- package/src/useCSV.ts +158 -0
- package/src/useRecentlyViewed.ts +67 -0
- package/src/useSavedViews.ts +117 -0
- package/src/useTableFilters.ts +67 -0
- package/src/useWizard.ts +60 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
+
import { renderHook, act } from '@testing-library/react'
|
|
3
|
+
import { useRecentlyViewed } from '../useRecentlyViewed'
|
|
4
|
+
|
|
5
|
+
// Mock localStorage
|
|
6
|
+
const localStorageMock = (() => {
|
|
7
|
+
let store: Record<string, string> = {}
|
|
8
|
+
return {
|
|
9
|
+
getItem: vi.fn((key: string) => store[key] || null),
|
|
10
|
+
setItem: vi.fn((key: string, value: string) => {
|
|
11
|
+
store[key] = value
|
|
12
|
+
}),
|
|
13
|
+
removeItem: vi.fn((key: string) => {
|
|
14
|
+
delete store[key]
|
|
15
|
+
}),
|
|
16
|
+
clear: vi.fn(() => {
|
|
17
|
+
store = {}
|
|
18
|
+
}),
|
|
19
|
+
}
|
|
20
|
+
})()
|
|
21
|
+
|
|
22
|
+
Object.defineProperty(window, 'localStorage', { value: localStorageMock })
|
|
23
|
+
|
|
24
|
+
describe('useRecentlyViewed', () => {
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
localStorageMock.clear()
|
|
27
|
+
vi.clearAllMocks()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('starts with empty items', () => {
|
|
31
|
+
const { result } = renderHook(() =>
|
|
32
|
+
useRecentlyViewed<{ id: string; name: string }>('test_recent')
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
expect(result.current.items).toEqual([])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('tracks a viewed item', () => {
|
|
39
|
+
const { result } = renderHook(() =>
|
|
40
|
+
useRecentlyViewed<{ id: string; name: string }>('test_recent')
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
act(() => {
|
|
44
|
+
result.current.trackView({ id: '1', name: 'Item 1' })
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
expect(result.current.items).toHaveLength(1)
|
|
48
|
+
expect(result.current.items[0]).toEqual({ id: '1', name: 'Item 1' })
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('deduplicates by id, moving to front', () => {
|
|
52
|
+
const { result } = renderHook(() =>
|
|
53
|
+
useRecentlyViewed<{ id: string; name: string }>('test_recent')
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
act(() => {
|
|
57
|
+
result.current.trackView({ id: '1', name: 'Item 1' })
|
|
58
|
+
})
|
|
59
|
+
act(() => {
|
|
60
|
+
result.current.trackView({ id: '2', name: 'Item 2' })
|
|
61
|
+
})
|
|
62
|
+
act(() => {
|
|
63
|
+
result.current.trackView({ id: '1', name: 'Item 1 Updated' })
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
expect(result.current.items).toHaveLength(2)
|
|
67
|
+
expect(result.current.items[0]).toEqual({ id: '1', name: 'Item 1 Updated' })
|
|
68
|
+
expect(result.current.items[1]).toEqual({ id: '2', name: 'Item 2' })
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('respects maxItems limit', () => {
|
|
72
|
+
const { result } = renderHook(() =>
|
|
73
|
+
useRecentlyViewed<{ id: string; name: string }>('test_recent', 3)
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
act(() => {
|
|
77
|
+
result.current.trackView({ id: '1', name: 'Item 1' })
|
|
78
|
+
})
|
|
79
|
+
act(() => {
|
|
80
|
+
result.current.trackView({ id: '2', name: 'Item 2' })
|
|
81
|
+
})
|
|
82
|
+
act(() => {
|
|
83
|
+
result.current.trackView({ id: '3', name: 'Item 3' })
|
|
84
|
+
})
|
|
85
|
+
act(() => {
|
|
86
|
+
result.current.trackView({ id: '4', name: 'Item 4' })
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
expect(result.current.items).toHaveLength(3)
|
|
90
|
+
expect(result.current.items[0].id).toBe('4')
|
|
91
|
+
expect(result.current.items[2].id).toBe('2')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('persists to localStorage', () => {
|
|
95
|
+
const { result } = renderHook(() =>
|
|
96
|
+
useRecentlyViewed<{ id: string; name: string }>('test_recent')
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
act(() => {
|
|
100
|
+
result.current.trackView({ id: '1', name: 'Item 1' })
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
|
104
|
+
'test_recent',
|
|
105
|
+
expect.any(String)
|
|
106
|
+
)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('clears all items', () => {
|
|
110
|
+
const { result } = renderHook(() =>
|
|
111
|
+
useRecentlyViewed<{ id: string; name: string }>('test_recent')
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
act(() => {
|
|
115
|
+
result.current.trackView({ id: '1', name: 'Item 1' })
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
act(() => {
|
|
119
|
+
result.current.clear()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
expect(result.current.items).toEqual([])
|
|
123
|
+
expect(localStorageMock.removeItem).toHaveBeenCalledWith('test_recent')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('provides timestamps for viewed items', () => {
|
|
127
|
+
const { result } = renderHook(() =>
|
|
128
|
+
useRecentlyViewed<{ id: string; name: string }>('test_recent')
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
const before = Date.now()
|
|
132
|
+
act(() => {
|
|
133
|
+
result.current.trackView({ id: '1', name: 'Item 1' })
|
|
134
|
+
})
|
|
135
|
+
const after = Date.now()
|
|
136
|
+
|
|
137
|
+
expect(result.current.timestamps).toHaveLength(1)
|
|
138
|
+
expect(result.current.timestamps[0].id).toBe('1')
|
|
139
|
+
expect(result.current.timestamps[0].viewedAt).toBeGreaterThanOrEqual(before)
|
|
140
|
+
expect(result.current.timestamps[0].viewedAt).toBeLessThanOrEqual(after)
|
|
141
|
+
})
|
|
142
|
+
})
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { renderHook, act, waitFor } from '@testing-library/react'
|
|
3
|
+
import { useSavedViews } from '../useSavedViews'
|
|
4
|
+
import type { SavedView } from '../useSavedViews'
|
|
5
|
+
|
|
6
|
+
interface TestView extends SavedView {
|
|
7
|
+
filters: Record<string, unknown>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function makeView(id: string, name: string, isDefault = false): TestView {
|
|
11
|
+
return { id, name, isDefault, filters: {}, createdAt: new Date().toISOString() }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function makeOptions(views: TestView[]) {
|
|
15
|
+
const loadFn = vi.fn().mockResolvedValue(views)
|
|
16
|
+
const saveFn = vi.fn().mockImplementation((_resource: string, data: Omit<TestView, 'id' | 'createdAt'>) =>
|
|
17
|
+
Promise.resolve({ ...data, id: 'new-id', createdAt: new Date().toISOString() } as TestView)
|
|
18
|
+
)
|
|
19
|
+
const updateFn = vi.fn().mockResolvedValue(undefined)
|
|
20
|
+
const deleteFn = vi.fn().mockResolvedValue(undefined)
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
resource: 'test-resource',
|
|
24
|
+
loadFn,
|
|
25
|
+
saveFn,
|
|
26
|
+
updateFn,
|
|
27
|
+
deleteFn,
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('useSavedViews', () => {
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
vi.clearAllMocks()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('loads views on mount', async () => {
|
|
37
|
+
const views = [makeView('1', 'My View')]
|
|
38
|
+
const opts = makeOptions(views)
|
|
39
|
+
|
|
40
|
+
const { result } = renderHook(() => useSavedViews<TestView>(opts))
|
|
41
|
+
|
|
42
|
+
expect(result.current.loading).toBe(true)
|
|
43
|
+
|
|
44
|
+
await waitFor(() => {
|
|
45
|
+
expect(result.current.loading).toBe(false)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
expect(result.current.views).toHaveLength(1)
|
|
49
|
+
expect(result.current.views[0].name).toBe('My View')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('sets currentViewId to default view on load', async () => {
|
|
53
|
+
const views = [makeView('1', 'Normal'), makeView('2', 'Default', true)]
|
|
54
|
+
const opts = makeOptions(views)
|
|
55
|
+
|
|
56
|
+
const { result } = renderHook(() => useSavedViews<TestView>(opts))
|
|
57
|
+
|
|
58
|
+
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
59
|
+
|
|
60
|
+
expect(result.current.currentViewId).toBe('2')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('sets currentViewId to null when no default view', async () => {
|
|
64
|
+
const views = [makeView('1', 'No Default')]
|
|
65
|
+
const opts = makeOptions(views)
|
|
66
|
+
|
|
67
|
+
const { result } = renderHook(() => useSavedViews<TestView>(opts))
|
|
68
|
+
|
|
69
|
+
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
70
|
+
|
|
71
|
+
expect(result.current.currentViewId).toBeNull()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('handles load error', async () => {
|
|
75
|
+
const opts = {
|
|
76
|
+
...makeOptions([]),
|
|
77
|
+
loadFn: vi.fn().mockRejectedValue(new Error('Network error')),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const { result } = renderHook(() => useSavedViews<TestView>(opts))
|
|
81
|
+
|
|
82
|
+
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
83
|
+
|
|
84
|
+
expect(result.current.error).toBe('Network error')
|
|
85
|
+
expect(result.current.views).toHaveLength(0)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('saves a new view and adds it to the list', async () => {
|
|
89
|
+
const opts = makeOptions([])
|
|
90
|
+
const { result } = renderHook(() => useSavedViews<TestView>(opts))
|
|
91
|
+
|
|
92
|
+
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
93
|
+
|
|
94
|
+
await act(async () => {
|
|
95
|
+
await result.current.saveView({ name: 'New View', filters: {}, isDefault: false })
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
expect(result.current.views).toHaveLength(1)
|
|
99
|
+
expect(result.current.views[0].name).toBe('New View')
|
|
100
|
+
expect(result.current.currentViewId).toBe('new-id')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('loads a view by setting currentViewId', async () => {
|
|
104
|
+
const views = [makeView('1', 'First'), makeView('2', 'Second')]
|
|
105
|
+
const opts = makeOptions(views)
|
|
106
|
+
|
|
107
|
+
const { result } = renderHook(() => useSavedViews<TestView>(opts))
|
|
108
|
+
|
|
109
|
+
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
110
|
+
|
|
111
|
+
act(() => {
|
|
112
|
+
result.current.loadView('1')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
expect(result.current.currentViewId).toBe('1')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('getCurrentView returns the current view object', async () => {
|
|
119
|
+
const views = [makeView('1', 'First'), makeView('2', 'Second')]
|
|
120
|
+
const opts = makeOptions(views)
|
|
121
|
+
|
|
122
|
+
const { result } = renderHook(() => useSavedViews<TestView>(opts))
|
|
123
|
+
|
|
124
|
+
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
125
|
+
|
|
126
|
+
act(() => {
|
|
127
|
+
result.current.loadView('1')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
const current = result.current.getCurrentView()
|
|
131
|
+
expect(current?.id).toBe('1')
|
|
132
|
+
expect(current?.name).toBe('First')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('deleteView removes the view from the list', async () => {
|
|
136
|
+
const views = [makeView('1', 'First'), makeView('2', 'Second')]
|
|
137
|
+
const opts = makeOptions(views)
|
|
138
|
+
|
|
139
|
+
const { result } = renderHook(() => useSavedViews<TestView>(opts))
|
|
140
|
+
|
|
141
|
+
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
142
|
+
|
|
143
|
+
await act(async () => {
|
|
144
|
+
await result.current.deleteView('1')
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
expect(opts.deleteFn).toHaveBeenCalledWith('test-resource', '1')
|
|
148
|
+
expect(result.current.views.find(v => v.id === '1')).toBeUndefined()
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('clears currentViewId when deleting the current view', async () => {
|
|
152
|
+
const views = [makeView('1', 'Current')]
|
|
153
|
+
const opts = makeOptions(views)
|
|
154
|
+
|
|
155
|
+
const { result } = renderHook(() => useSavedViews<TestView>(opts))
|
|
156
|
+
|
|
157
|
+
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
158
|
+
|
|
159
|
+
act(() => {
|
|
160
|
+
result.current.loadView('1')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
await act(async () => {
|
|
164
|
+
await result.current.deleteView('1')
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
expect(result.current.currentViewId).toBeNull()
|
|
168
|
+
})
|
|
169
|
+
})
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { renderHook, act } from '@testing-library/react'
|
|
3
|
+
import { useTableFilters } from '../useTableFilters'
|
|
4
|
+
|
|
5
|
+
describe('useTableFilters', () => {
|
|
6
|
+
it('initializes with default page and pageSize', () => {
|
|
7
|
+
const { result } = renderHook(() => useTableFilters({}))
|
|
8
|
+
|
|
9
|
+
expect(result.current.filters.page).toBe(1)
|
|
10
|
+
expect(result.current.filters.pageSize).toBe(20)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('accepts custom initial values', () => {
|
|
14
|
+
const { result } = renderHook(() =>
|
|
15
|
+
useTableFilters({
|
|
16
|
+
page: 2,
|
|
17
|
+
pageSize: 50,
|
|
18
|
+
status: 'active',
|
|
19
|
+
})
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
expect(result.current.filters.page).toBe(2)
|
|
23
|
+
expect(result.current.filters.pageSize).toBe(50)
|
|
24
|
+
expect(result.current.filters.status).toBe('active')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('resets page to 1 when setting a filter', () => {
|
|
28
|
+
const { result } = renderHook(() =>
|
|
29
|
+
useTableFilters({ stage: '' as string })
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
// Navigate to page 3
|
|
33
|
+
act(() => {
|
|
34
|
+
result.current.setPage(3)
|
|
35
|
+
})
|
|
36
|
+
expect(result.current.filters.page).toBe(3)
|
|
37
|
+
|
|
38
|
+
// Setting a filter should reset page to 1
|
|
39
|
+
act(() => {
|
|
40
|
+
result.current.setFilter('stage', 'active')
|
|
41
|
+
})
|
|
42
|
+
expect(result.current.filters.page).toBe(1)
|
|
43
|
+
expect(result.current.filters.stage).toBe('active')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('resets page to 1 when setting search', () => {
|
|
47
|
+
const { result } = renderHook(() => useTableFilters({}))
|
|
48
|
+
|
|
49
|
+
act(() => {
|
|
50
|
+
result.current.setPage(5)
|
|
51
|
+
})
|
|
52
|
+
expect(result.current.filters.page).toBe(5)
|
|
53
|
+
|
|
54
|
+
act(() => {
|
|
55
|
+
result.current.setSearch('test query')
|
|
56
|
+
})
|
|
57
|
+
expect(result.current.filters.page).toBe(1)
|
|
58
|
+
expect(result.current.filters.search).toBe('test query')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('resets page to 1 when changing pageSize', () => {
|
|
62
|
+
const { result } = renderHook(() => useTableFilters({}))
|
|
63
|
+
|
|
64
|
+
act(() => {
|
|
65
|
+
result.current.setPage(3)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
act(() => {
|
|
69
|
+
result.current.setPageSize(50)
|
|
70
|
+
})
|
|
71
|
+
expect(result.current.filters.page).toBe(1)
|
|
72
|
+
expect(result.current.filters.pageSize).toBe(50)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('sets sort without resetting page', () => {
|
|
76
|
+
const { result } = renderHook(() => useTableFilters({}))
|
|
77
|
+
|
|
78
|
+
act(() => {
|
|
79
|
+
result.current.setPage(3)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
act(() => {
|
|
83
|
+
result.current.setSort('name', 'desc')
|
|
84
|
+
})
|
|
85
|
+
expect(result.current.filters.page).toBe(3)
|
|
86
|
+
expect(result.current.filters.sortField).toBe('name')
|
|
87
|
+
expect(result.current.filters.sortDirection).toBe('desc')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('resets all filters to initial state', () => {
|
|
91
|
+
const { result } = renderHook(() =>
|
|
92
|
+
useTableFilters({ status: '' as string })
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
act(() => {
|
|
96
|
+
result.current.setFilter('status', 'active')
|
|
97
|
+
result.current.setPage(5)
|
|
98
|
+
result.current.setSearch('hello')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
act(() => {
|
|
102
|
+
result.current.resetFilters()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
expect(result.current.filters.page).toBe(1)
|
|
106
|
+
expect(result.current.filters.pageSize).toBe(20)
|
|
107
|
+
expect(result.current.filters.status).toBe('')
|
|
108
|
+
expect(result.current.filters.search).toBeUndefined()
|
|
109
|
+
})
|
|
110
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { renderHook, act } from '@testing-library/react'
|
|
3
|
+
import { useWizard } from '../useWizard'
|
|
4
|
+
|
|
5
|
+
const STEPS = ['upload', 'mapping', 'importing', 'complete'] as const
|
|
6
|
+
|
|
7
|
+
describe('useWizard', () => {
|
|
8
|
+
it('starts on the initial step', () => {
|
|
9
|
+
const { result } = renderHook(() => useWizard(STEPS))
|
|
10
|
+
expect(result.current.currentStep).toBe('upload')
|
|
11
|
+
expect(result.current.stepIndex).toBe(0)
|
|
12
|
+
expect(result.current.totalSteps).toBe(4)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('respects a custom initial step', () => {
|
|
16
|
+
const { result } = renderHook(() => useWizard(STEPS, 'mapping'))
|
|
17
|
+
expect(result.current.currentStep).toBe('mapping')
|
|
18
|
+
expect(result.current.stepIndex).toBe(1)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('advances to next step', () => {
|
|
22
|
+
const { result } = renderHook(() => useWizard(STEPS))
|
|
23
|
+
act(() => result.current.next())
|
|
24
|
+
expect(result.current.currentStep).toBe('mapping')
|
|
25
|
+
expect(result.current.stepIndex).toBe(1)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('goes back to previous step', () => {
|
|
29
|
+
const { result } = renderHook(() => useWizard(STEPS, 'mapping'))
|
|
30
|
+
act(() => result.current.prev())
|
|
31
|
+
expect(result.current.currentStep).toBe('upload')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('does not go before first step', () => {
|
|
35
|
+
const { result } = renderHook(() => useWizard(STEPS))
|
|
36
|
+
act(() => result.current.prev())
|
|
37
|
+
expect(result.current.currentStep).toBe('upload')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('does not go past last step', () => {
|
|
41
|
+
const { result } = renderHook(() => useWizard(STEPS, 'complete'))
|
|
42
|
+
act(() => result.current.next())
|
|
43
|
+
expect(result.current.currentStep).toBe('complete')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('goTo navigates to any step', () => {
|
|
47
|
+
const { result } = renderHook(() => useWizard(STEPS))
|
|
48
|
+
act(() => result.current.goTo('importing'))
|
|
49
|
+
expect(result.current.currentStep).toBe('importing')
|
|
50
|
+
expect(result.current.stepIndex).toBe(2)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('reset returns to initial step', () => {
|
|
54
|
+
const { result } = renderHook(() => useWizard(STEPS))
|
|
55
|
+
act(() => result.current.next())
|
|
56
|
+
act(() => result.current.next())
|
|
57
|
+
act(() => result.current.reset())
|
|
58
|
+
expect(result.current.currentStep).toBe('upload')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('exposes correct flags on first step', () => {
|
|
62
|
+
const { result } = renderHook(() => useWizard(STEPS))
|
|
63
|
+
expect(result.current.isFirstStep).toBe(true)
|
|
64
|
+
expect(result.current.isLastStep).toBe(false)
|
|
65
|
+
expect(result.current.canGoBack).toBe(false)
|
|
66
|
+
expect(result.current.canGoNext).toBe(true)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('exposes correct flags on last step', () => {
|
|
70
|
+
const { result } = renderHook(() => useWizard(STEPS, 'complete'))
|
|
71
|
+
expect(result.current.isFirstStep).toBe(false)
|
|
72
|
+
expect(result.current.isLastStep).toBe(true)
|
|
73
|
+
expect(result.current.canGoBack).toBe(true)
|
|
74
|
+
expect(result.current.canGoNext).toBe(false)
|
|
75
|
+
})
|
|
76
|
+
})
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL filter encoding utilities.
|
|
3
|
+
*
|
|
4
|
+
* Provides base64 encode/decode for FilterConfig (so filter state can be
|
|
5
|
+
* stored in URL query params as compact strings) plus helpers for common
|
|
6
|
+
* filter operations.
|
|
7
|
+
*
|
|
8
|
+
* These are pure functions with no React or Next.js dependencies.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
FilterConfig,
|
|
13
|
+
FilterCondition,
|
|
14
|
+
FilterGroup,
|
|
15
|
+
FilterOperator,
|
|
16
|
+
FilterValue,
|
|
17
|
+
EncodedFilterState,
|
|
18
|
+
} from './filter-types'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Encode a FilterConfig to a URL-safe base64 string.
|
|
22
|
+
*/
|
|
23
|
+
export function encodeFilterConfig(config: FilterConfig): string {
|
|
24
|
+
const json = JSON.stringify(config)
|
|
25
|
+
return btoa(json).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Decode a URL-safe base64 string back to a FilterConfig.
|
|
30
|
+
* Returns null if the string is invalid or cannot be parsed.
|
|
31
|
+
*/
|
|
32
|
+
export function decodeFilterConfig(encoded: string): FilterConfig | null {
|
|
33
|
+
try {
|
|
34
|
+
// Convert URL-safe base64 back to standard base64
|
|
35
|
+
const standardBase64 = encoded.replace(/-/g, '+').replace(/_/g, '/')
|
|
36
|
+
// Add padding if needed
|
|
37
|
+
const padded = standardBase64 + '==='.slice(0, (4 - standardBase64.length % 4) % 4)
|
|
38
|
+
const decoded = atob(padded)
|
|
39
|
+
return JSON.parse(decoded) as FilterConfig
|
|
40
|
+
} catch {
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Parse URLSearchParams into an EncodedFilterState.
|
|
47
|
+
*/
|
|
48
|
+
export function parseUrlFilters(params: URLSearchParams): EncodedFilterState {
|
|
49
|
+
const result: EncodedFilterState = {}
|
|
50
|
+
|
|
51
|
+
if (params.has('f')) result.f = params.get('f')!
|
|
52
|
+
if (params.has('s')) result.s = params.get('s')!
|
|
53
|
+
if (params.has('d')) result.d = params.get('d')!
|
|
54
|
+
|
|
55
|
+
if (params.has('p')) {
|
|
56
|
+
const page = parseInt(params.get('p')!, 10)
|
|
57
|
+
if (!isNaN(page) && page > 0) result.p = page
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (params.has('l')) {
|
|
61
|
+
const limit = parseInt(params.get('l')!, 10)
|
|
62
|
+
if (!isNaN(limit) && limit > 0 && limit <= 1000) result.l = limit
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return result
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Build a single-condition FilterConfig.
|
|
70
|
+
*/
|
|
71
|
+
export function createSimpleFilter(
|
|
72
|
+
field: string,
|
|
73
|
+
operator: FilterOperator,
|
|
74
|
+
value: FilterValue
|
|
75
|
+
): FilterConfig {
|
|
76
|
+
return {
|
|
77
|
+
groups: [{ logic: 'AND', conditions: [{ field, operator, value }] }],
|
|
78
|
+
globalLogic: 'AND',
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Merge multiple FilterConfigs with AND logic.
|
|
84
|
+
*/
|
|
85
|
+
export function mergeFilters(...configs: FilterConfig[]): FilterConfig {
|
|
86
|
+
if (configs.length === 0) return { groups: [] }
|
|
87
|
+
if (configs.length === 1) return configs[0]
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
groups: configs.map(config => ({
|
|
91
|
+
logic: 'AND' as const,
|
|
92
|
+
conditions: [] as FilterCondition[],
|
|
93
|
+
groups: config.groups,
|
|
94
|
+
})),
|
|
95
|
+
globalLogic: 'AND',
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Get a human-readable description of a FilterConfig.
|
|
101
|
+
*/
|
|
102
|
+
export function getFilterDescription(config: FilterConfig): string {
|
|
103
|
+
if (config.groups.length === 0) return 'No filters applied'
|
|
104
|
+
|
|
105
|
+
function describeGroup(group: FilterGroup): string {
|
|
106
|
+
const conditionDescs = group.conditions.map(
|
|
107
|
+
c => `${c.field} ${c.operator} ${c.value}`
|
|
108
|
+
)
|
|
109
|
+
const groupDescs = group.groups?.map(g => `(${describeGroup(g)})`) ?? []
|
|
110
|
+
return [...conditionDescs, ...groupDescs].join(` ${group.logic} `)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const groupDescs = config.groups.map(describeGroup)
|
|
114
|
+
return groupDescs.join(` ${config.globalLogic ?? 'AND'} `)
|
|
115
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic filter types for table/list filtering across StartSimpli apps.
|
|
3
|
+
*
|
|
4
|
+
* These types define the shape of a filter configuration that supports
|
|
5
|
+
* AND/OR logic, multiple operators, and nested groups.
|
|
6
|
+
*
|
|
7
|
+
* Domain-specific field types (e.g. FirmFilterField) extend these in each app.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type FilterOperator =
|
|
11
|
+
| 'equals'
|
|
12
|
+
| 'not_equals'
|
|
13
|
+
| 'in'
|
|
14
|
+
| 'not_in'
|
|
15
|
+
| 'contains'
|
|
16
|
+
| 'not_contains'
|
|
17
|
+
| 'starts_with'
|
|
18
|
+
| 'ends_with'
|
|
19
|
+
| 'greater_than'
|
|
20
|
+
| 'greater_than_or_equal'
|
|
21
|
+
| 'less_than'
|
|
22
|
+
| 'less_than_or_equal'
|
|
23
|
+
| 'is_null'
|
|
24
|
+
| 'is_not_null'
|
|
25
|
+
| 'between'
|
|
26
|
+
| 'not_between'
|
|
27
|
+
| 'fuzzy_search'
|
|
28
|
+
|
|
29
|
+
export type FilterValue =
|
|
30
|
+
| string
|
|
31
|
+
| number
|
|
32
|
+
| boolean
|
|
33
|
+
| null
|
|
34
|
+
| string[]
|
|
35
|
+
| number[]
|
|
36
|
+
| [number, number]
|
|
37
|
+
| [string, string]
|
|
38
|
+
|
|
39
|
+
export interface FilterCondition {
|
|
40
|
+
field: string
|
|
41
|
+
operator: FilterOperator
|
|
42
|
+
value: FilterValue
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface FilterGroup {
|
|
46
|
+
logic: 'AND' | 'OR'
|
|
47
|
+
conditions: FilterCondition[]
|
|
48
|
+
groups?: FilterGroup[]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface FilterConfig {
|
|
52
|
+
groups: FilterGroup[]
|
|
53
|
+
globalLogic?: 'AND' | 'OR'
|
|
54
|
+
metadata?: {
|
|
55
|
+
name?: string
|
|
56
|
+
description?: string
|
|
57
|
+
tags?: string[]
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** URL-encoded representation of table state */
|
|
62
|
+
export interface EncodedFilterState {
|
|
63
|
+
f?: string // filter config (base64 encoded)
|
|
64
|
+
s?: string // sort field
|
|
65
|
+
d?: string // sort direction
|
|
66
|
+
p?: number // page
|
|
67
|
+
l?: number // limit
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface FilterValidationError {
|
|
71
|
+
field?: string
|
|
72
|
+
operator?: string
|
|
73
|
+
value?: FilterValue
|
|
74
|
+
message: string
|
|
75
|
+
code: string
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface FilterValidationResult {
|
|
79
|
+
isValid: boolean
|
|
80
|
+
errors: FilterValidationError[]
|
|
81
|
+
warnings: string[]
|
|
82
|
+
}
|