@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.
@@ -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
+ }