@open-mercato/ui 0.6.1-develop.3260.1.dc719cac84 → 0.6.1-develop.3278.1.a4d26475e4
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/.turbo/turbo-build.log +1 -1
- package/dist/backend/DataTable.js +149 -53
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/filters/ActiveFilterChips.js +142 -0
- package/dist/backend/filters/ActiveFilterChips.js.map +7 -0
- package/dist/backend/filters/AdvancedFilterBuilder.js +689 -163
- package/dist/backend/filters/AdvancedFilterBuilder.js.map +2 -2
- package/dist/backend/filters/AdvancedFilterPanel.js +354 -0
- package/dist/backend/filters/AdvancedFilterPanel.js.map +7 -0
- package/dist/backend/filters/FilterEmptyState.js +41 -0
- package/dist/backend/filters/FilterEmptyState.js.map +7 -0
- package/dist/backend/filters/FilterFieldPicker.js +148 -0
- package/dist/backend/filters/FilterFieldPicker.js.map +7 -0
- package/dist/backend/filters/FilteredEmptyResults.js +26 -0
- package/dist/backend/filters/FilteredEmptyResults.js.map +7 -0
- package/dist/backend/filters/QuickFilters.js +41 -0
- package/dist/backend/filters/QuickFilters.js.map +7 -0
- package/dist/backend/filters/filterValidation.js +47 -0
- package/dist/backend/filters/filterValidation.js.map +7 -0
- package/dist/backend/filters/treeReducer.js +159 -0
- package/dist/backend/filters/treeReducer.js.map +7 -0
- package/dist/backend/hooks/useAdvancedFilter.js +82 -1
- package/dist/backend/hooks/useAdvancedFilter.js.map +2 -2
- package/dist/backend/utils/api.js +18 -1
- package/dist/backend/utils/api.js.map +2 -2
- package/dist/backend/utils/useAutoDiscoveredFields.js +1 -0
- package/dist/backend/utils/useAutoDiscoveredFields.js.map +2 -2
- package/dist/backend/utils/useCurrentUserId.js +31 -0
- package/dist/backend/utils/useCurrentUserId.js.map +7 -0
- package/dist/primitives/dialog.js +7 -5
- package/dist/primitives/dialog.js.map +2 -2
- package/dist/primitives/tag.js +4 -2
- package/dist/primitives/tag.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/DataTable.tsx +307 -63
- package/src/backend/__tests__/DataTable.extensions.test.tsx +170 -0
- package/src/backend/filters/ActiveFilterChips.tsx +179 -0
- package/src/backend/filters/AdvancedFilterBuilder.tsx +840 -237
- package/src/backend/filters/AdvancedFilterPanel.tsx +456 -0
- package/src/backend/filters/FilterEmptyState.tsx +51 -0
- package/src/backend/filters/FilterFieldPicker.tsx +139 -0
- package/src/backend/filters/FilteredEmptyResults.tsx +40 -0
- package/src/backend/filters/QuickFilters.tsx +62 -0
- package/src/backend/filters/__tests__/ActiveFilterChips.test.tsx +75 -0
- package/src/backend/filters/__tests__/AdvancedFilterBuilder.test.tsx +210 -0
- package/src/backend/filters/__tests__/AdvancedFilterPanel.test.tsx +212 -0
- package/src/backend/filters/__tests__/FilterEmptyState.test.tsx +31 -0
- package/src/backend/filters/__tests__/FilterFieldPicker.test.tsx +61 -0
- package/src/backend/filters/__tests__/FilteredEmptyResults.test.tsx +35 -0
- package/src/backend/filters/__tests__/QuickFilters.test.tsx +34 -0
- package/src/backend/filters/__tests__/filterValidation.test.ts +69 -0
- package/src/backend/filters/__tests__/treeReducer.test.ts +189 -0
- package/src/backend/filters/filterValidation.ts +61 -0
- package/src/backend/filters/treeReducer.ts +201 -0
- package/src/backend/hooks/__tests__/useAdvancedFilter.test.tsx +213 -0
- package/src/backend/hooks/useAdvancedFilter.ts +130 -0
- package/src/backend/utils/api.ts +27 -2
- package/src/backend/utils/useAutoDiscoveredFields.ts +2 -0
- package/src/backend/utils/useCurrentUserId.ts +40 -0
- package/src/primitives/__tests__/zindex-overlay.test.tsx +22 -0
- package/src/primitives/dialog.tsx +18 -7
- package/src/primitives/tag.tsx +3 -1
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { renderHook, act } from '@testing-library/react'
|
|
2
|
+
import { useAdvancedFilterTree } from '../useAdvancedFilter'
|
|
3
|
+
import { createEmptyTree } from '@open-mercato/shared/lib/query/advanced-filter-tree'
|
|
4
|
+
|
|
5
|
+
jest.useFakeTimers()
|
|
6
|
+
|
|
7
|
+
describe('useAdvancedFilterTree — auto-apply', () => {
|
|
8
|
+
it('debounces onApply by 400 ms when valid; suppresses when invalid', () => {
|
|
9
|
+
const onApply = jest.fn()
|
|
10
|
+
const { result } = renderHook(() => useAdvancedFilterTree({ initial: createEmptyTree(), fields: [], onApply }))
|
|
11
|
+
act(() => { result.current.dispatch({ type: 'addRule', groupId: result.current.tree.root.id }) })
|
|
12
|
+
expect(onApply).not.toHaveBeenCalled()
|
|
13
|
+
act(() => { jest.advanceTimersByTime(400) })
|
|
14
|
+
// tree has invalid rule (empty value) - should NOT apply
|
|
15
|
+
expect(onApply).not.toHaveBeenCalled()
|
|
16
|
+
expect(result.current.pendingErrors.length).toBeGreaterThan(0)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('flush() applies immediately if valid', () => {
|
|
20
|
+
const onApply = jest.fn()
|
|
21
|
+
const fields = [{ key: 'n', label: 'Name', type: 'text' as const }]
|
|
22
|
+
const { result } = renderHook(() => useAdvancedFilterTree({ initial: createEmptyTree(), fields, onApply }))
|
|
23
|
+
act(() => {
|
|
24
|
+
result.current.dispatch({ type: 'addRule', groupId: result.current.tree.root.id, defaultField: 'n' })
|
|
25
|
+
})
|
|
26
|
+
const ruleId = result.current.tree.root.children[0].id
|
|
27
|
+
act(() => {
|
|
28
|
+
// Substring text operators now require >= 3 characters to validate.
|
|
29
|
+
result.current.dispatch({ type: 'updateRule', ruleId, updates: { value: 'XYZ' } })
|
|
30
|
+
})
|
|
31
|
+
act(() => { result.current.flush() })
|
|
32
|
+
expect(onApply).toHaveBeenCalled()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('flush() suppresses apply on invalid tree', () => {
|
|
36
|
+
const onApply = jest.fn()
|
|
37
|
+
const fields = [{ key: 'n', label: 'Name', type: 'text' as const }]
|
|
38
|
+
const { result } = renderHook(() => useAdvancedFilterTree({ initial: createEmptyTree(), fields, onApply }))
|
|
39
|
+
act(() => { result.current.dispatch({ type: 'addRule', groupId: result.current.tree.root.id, defaultField: 'n' }) })
|
|
40
|
+
act(() => { result.current.flush() })
|
|
41
|
+
expect(onApply).not.toHaveBeenCalled()
|
|
42
|
+
expect(result.current.pendingErrors.length).toBe(1)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('hasActiveRules reflects tree.root.children.length', () => {
|
|
46
|
+
const { result } = renderHook(() => useAdvancedFilterTree({ initial: createEmptyTree(), fields: [], onApply: () => {} }))
|
|
47
|
+
expect(result.current.hasActiveRules).toBe(false)
|
|
48
|
+
act(() => { result.current.dispatch({ type: 'addRule', groupId: result.current.tree.root.id }) })
|
|
49
|
+
expect(result.current.hasActiveRules).toBe(true)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('clear() empties root children', () => {
|
|
53
|
+
const { result } = renderHook(() => useAdvancedFilterTree({ initial: createEmptyTree(), fields: [], onApply: () => {} }))
|
|
54
|
+
act(() => { result.current.dispatch({ type: 'addRule', groupId: result.current.tree.root.id }) })
|
|
55
|
+
expect(result.current.tree.root.children.length).toBe(1)
|
|
56
|
+
act(() => { result.current.clear() })
|
|
57
|
+
expect(result.current.tree.root.children.length).toBe(0)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('setTree replaces tree', () => {
|
|
61
|
+
const onApply = jest.fn()
|
|
62
|
+
const { result } = renderHook(() => useAdvancedFilterTree({ initial: createEmptyTree(), fields: [], onApply }))
|
|
63
|
+
const newTree = { root: { id: 'r2', type: 'group' as const, combinator: 'or' as const, children: [] } }
|
|
64
|
+
act(() => { result.current.setTree(newTree) })
|
|
65
|
+
expect(result.current.tree.root.id).toBe('r2')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('does NOT auto-apply on mount when initial is non-empty and valid', () => {
|
|
69
|
+
const onApply = jest.fn()
|
|
70
|
+
const fields = [{ key: 'n', label: 'Name', type: 'text' as const }]
|
|
71
|
+
const initial = {
|
|
72
|
+
root: {
|
|
73
|
+
id: 'r',
|
|
74
|
+
type: 'group' as const,
|
|
75
|
+
combinator: 'and' as const,
|
|
76
|
+
// Substring text operators now require >= 3 characters; use 'XYZ' so the
|
|
77
|
+
// initial tree is valid.
|
|
78
|
+
children: [{ id: 'a', type: 'rule' as const, field: 'n', operator: 'contains' as const, value: 'XYZ' } as any],
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
renderHook(() => useAdvancedFilterTree({ initial, fields, onApply }))
|
|
82
|
+
act(() => { jest.advanceTimersByTime(500) })
|
|
83
|
+
expect(onApply).not.toHaveBeenCalled()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('does auto-apply after a dispatch from a non-empty initial', () => {
|
|
87
|
+
const onApply = jest.fn()
|
|
88
|
+
const fields = [{ key: 'n', label: 'Name', type: 'text' as const }]
|
|
89
|
+
const initial = {
|
|
90
|
+
root: {
|
|
91
|
+
id: 'r',
|
|
92
|
+
type: 'group' as const,
|
|
93
|
+
combinator: 'and' as const,
|
|
94
|
+
children: [{ id: 'a', type: 'rule' as const, field: 'n', operator: 'contains' as const, value: 'XYZ' } as any],
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
const { result } = renderHook(() => useAdvancedFilterTree({ initial, fields, onApply }))
|
|
98
|
+
act(() => { result.current.dispatch({ type: 'updateRule', ruleId: 'a', updates: { value: 'WXY' } }) })
|
|
99
|
+
act(() => { jest.advanceTimersByTime(400) })
|
|
100
|
+
expect(onApply).toHaveBeenCalledTimes(1)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('auto-applies non-blank short substring text rules', () => {
|
|
104
|
+
const onApply = jest.fn()
|
|
105
|
+
const fields = [{ key: 'n', label: 'Name', type: 'text' as const }]
|
|
106
|
+
const { result } = renderHook(() => useAdvancedFilterTree({ initial: createEmptyTree(), fields, onApply }))
|
|
107
|
+
act(() => { result.current.dispatch({ type: 'addRule', groupId: result.current.tree.root.id, defaultField: 'n' }) })
|
|
108
|
+
const ruleId = result.current.tree.root.children[0].id
|
|
109
|
+
act(() => { result.current.dispatch({ type: 'updateRule', ruleId, updates: { value: 'a' } }) })
|
|
110
|
+
act(() => { jest.advanceTimersByTime(400) })
|
|
111
|
+
expect(onApply).toHaveBeenCalledTimes(1)
|
|
112
|
+
expect(result.current.pendingErrors).toEqual([])
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
describe('useAdvancedFilterTree — single source of truth', () => {
|
|
117
|
+
it('seeds appliedTree from initial and exposes it alongside tree', () => {
|
|
118
|
+
const initial = {
|
|
119
|
+
root: { id: 'r', type: 'group' as const, combinator: 'and' as const, children: [] },
|
|
120
|
+
}
|
|
121
|
+
const { result } = renderHook(() => useAdvancedFilterTree({ initial, fields: [], onApply: () => {} }))
|
|
122
|
+
expect(result.current.tree).toEqual(initial)
|
|
123
|
+
expect(result.current.appliedTree).toEqual(initial)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('appliedTree lags tree until the debounced apply fires', () => {
|
|
127
|
+
const fields = [{ key: 'n', label: 'Name', type: 'text' as const }]
|
|
128
|
+
const { result } = renderHook(() => useAdvancedFilterTree({ initial: createEmptyTree(), fields, onApply: () => {} }))
|
|
129
|
+
|
|
130
|
+
act(() => { result.current.dispatch({ type: 'addRule', groupId: result.current.tree.root.id, defaultField: 'n' }) })
|
|
131
|
+
const ruleId = result.current.tree.root.children[0].id
|
|
132
|
+
act(() => { result.current.dispatch({ type: 'updateRule', ruleId, updates: { value: 'abc' } }) })
|
|
133
|
+
|
|
134
|
+
// Editor advanced immediately, appliedTree is still empty until debounce fires.
|
|
135
|
+
expect(result.current.tree.root.children).toHaveLength(1)
|
|
136
|
+
expect(result.current.appliedTree.root.children).toHaveLength(0)
|
|
137
|
+
|
|
138
|
+
act(() => { jest.advanceTimersByTime(400) })
|
|
139
|
+
|
|
140
|
+
// Debounce + validation passed — appliedTree now matches editor.
|
|
141
|
+
expect(result.current.appliedTree.root.children).toHaveLength(1)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('appliedTree stays put when validation fails', () => {
|
|
145
|
+
const fields = [{ key: 'n', label: 'Name', type: 'text' as const }]
|
|
146
|
+
const { result } = renderHook(() => useAdvancedFilterTree({ initial: createEmptyTree(), fields, onApply: () => {} }))
|
|
147
|
+
|
|
148
|
+
// Empty-value rule is invalid.
|
|
149
|
+
act(() => { result.current.dispatch({ type: 'addRule', groupId: result.current.tree.root.id, defaultField: 'n' }) })
|
|
150
|
+
act(() => { jest.advanceTimersByTime(500) })
|
|
151
|
+
|
|
152
|
+
expect(result.current.tree.root.children).toHaveLength(1)
|
|
153
|
+
expect(result.current.appliedTree.root.children).toHaveLength(0)
|
|
154
|
+
expect(result.current.pendingErrors.length).toBeGreaterThan(0)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('clear() resets both tree and appliedTree immediately and fires onApply', () => {
|
|
158
|
+
const onApply = jest.fn()
|
|
159
|
+
const fields = [{ key: 'n', label: 'Name', type: 'text' as const }]
|
|
160
|
+
const initial = {
|
|
161
|
+
root: {
|
|
162
|
+
id: 'r',
|
|
163
|
+
type: 'group' as const,
|
|
164
|
+
combinator: 'and' as const,
|
|
165
|
+
children: [{ id: 'a', type: 'rule' as const, field: 'n', operator: 'contains' as const, value: 'XYZ' } as any],
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
const { result } = renderHook(() => useAdvancedFilterTree({ initial, fields, onApply }))
|
|
169
|
+
|
|
170
|
+
act(() => { result.current.clear() })
|
|
171
|
+
|
|
172
|
+
expect(result.current.tree.root.children).toHaveLength(0)
|
|
173
|
+
expect(result.current.appliedTree.root.children).toHaveLength(0)
|
|
174
|
+
expect(onApply).toHaveBeenCalledTimes(1)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('replaceTree() jumps both tree and appliedTree immediately (perspective restore)', () => {
|
|
178
|
+
const onApply = jest.fn()
|
|
179
|
+
const fields = [{ key: 'n', label: 'Name', type: 'text' as const }]
|
|
180
|
+
const { result } = renderHook(() => useAdvancedFilterTree({ initial: createEmptyTree(), fields, onApply }))
|
|
181
|
+
|
|
182
|
+
const restored = {
|
|
183
|
+
root: {
|
|
184
|
+
id: 'restored',
|
|
185
|
+
type: 'group' as const,
|
|
186
|
+
combinator: 'or' as const,
|
|
187
|
+
children: [{ id: 'b', type: 'rule' as const, field: 'n', operator: 'contains' as const, value: 'foo' } as any],
|
|
188
|
+
},
|
|
189
|
+
}
|
|
190
|
+
act(() => { result.current.replaceTree(restored) })
|
|
191
|
+
|
|
192
|
+
expect(result.current.tree.root.id).toBe('restored')
|
|
193
|
+
expect(result.current.appliedTree.root.id).toBe('restored')
|
|
194
|
+
expect(result.current.pendingErrors).toEqual([])
|
|
195
|
+
expect(onApply).toHaveBeenCalledWith(restored)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('flush() advances appliedTree to the validated editor tree', () => {
|
|
199
|
+
const fields = [{ key: 'n', label: 'Name', type: 'text' as const }]
|
|
200
|
+
const { result } = renderHook(() => useAdvancedFilterTree({ initial: createEmptyTree(), fields, onApply: () => {} }))
|
|
201
|
+
|
|
202
|
+
act(() => { result.current.dispatch({ type: 'addRule', groupId: result.current.tree.root.id, defaultField: 'n' }) })
|
|
203
|
+
const ruleId = result.current.tree.root.children[0].id
|
|
204
|
+
act(() => { result.current.dispatch({ type: 'updateRule', ruleId, updates: { value: 'abc' } }) })
|
|
205
|
+
|
|
206
|
+
// Before flush: appliedTree still empty.
|
|
207
|
+
expect(result.current.appliedTree.root.children).toHaveLength(0)
|
|
208
|
+
|
|
209
|
+
act(() => { result.current.flush() })
|
|
210
|
+
|
|
211
|
+
expect(result.current.appliedTree.root.children).toHaveLength(1)
|
|
212
|
+
})
|
|
213
|
+
})
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
import * as React from 'react'
|
|
3
3
|
import type { AdvancedFilterState, FilterCondition, FilterFieldDef } from '@open-mercato/shared/lib/query/advanced-filter'
|
|
4
4
|
import { createEmptyCondition, getDefaultOperator, normalizeAdvancedFilterState } from '@open-mercato/shared/lib/query/advanced-filter'
|
|
5
|
+
import type { AdvancedFilterTree } from '@open-mercato/shared/lib/query/advanced-filter-tree'
|
|
6
|
+
import { treeReducer, type TreeAction } from '../filters/treeReducer'
|
|
7
|
+
import { validateTreeForApply, type ValidationError } from '../filters/filterValidation'
|
|
5
8
|
|
|
6
9
|
export type UseAdvancedFilterOptions = {
|
|
7
10
|
fields: FilterFieldDef[]
|
|
@@ -75,3 +78,130 @@ export function useAdvancedFilter({ fields, onChange }: UseAdvancedFilterOptions
|
|
|
75
78
|
hasActiveConditions,
|
|
76
79
|
}
|
|
77
80
|
}
|
|
81
|
+
|
|
82
|
+
export type UseAdvancedFilterTreeArgs = {
|
|
83
|
+
initial: AdvancedFilterTree
|
|
84
|
+
fields: FilterFieldDef[]
|
|
85
|
+
/**
|
|
86
|
+
* Optional side-effect that fires whenever the tree is validated and applied
|
|
87
|
+
* (debounced auto-apply, explicit `flush()`, `clear()`, or `replaceTree()`).
|
|
88
|
+
* Use this for non-state effects such as resetting the page index when the
|
|
89
|
+
* filter changes. State propagation should read `appliedTree` directly — the
|
|
90
|
+
* hook is the single source of truth and the page MUST NOT keep a parallel
|
|
91
|
+
* copy.
|
|
92
|
+
*/
|
|
93
|
+
onApply?: (tree: AdvancedFilterTree) => void
|
|
94
|
+
debounceMs?: number
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type UseAdvancedFilterTreeResult = {
|
|
98
|
+
/** In-progress editor tree. Bound to the filter panel's `value` / `onChange`. */
|
|
99
|
+
tree: AdvancedFilterTree
|
|
100
|
+
/**
|
|
101
|
+
* Last validated, applied tree. Bound to URL serialization, query requests,
|
|
102
|
+
* active chips, and the filter-aware empty-state. Lags `tree` by `debounceMs`
|
|
103
|
+
* while the user is typing in an editor; advances immediately on `flush()`,
|
|
104
|
+
* `clear()`, and `replaceTree()`.
|
|
105
|
+
*/
|
|
106
|
+
appliedTree: AdvancedFilterTree
|
|
107
|
+
setTree: (t: AdvancedFilterTree) => void
|
|
108
|
+
dispatch: (a: TreeAction) => void
|
|
109
|
+
pendingErrors: ValidationError[]
|
|
110
|
+
flush: () => void
|
|
111
|
+
clear: () => void
|
|
112
|
+
/**
|
|
113
|
+
* Replace BOTH the editor and applied trees immediately, bypassing the
|
|
114
|
+
* debounce. Use this for perspective restoration and saved-filter apply —
|
|
115
|
+
* actions that semantically jump the filter to a new known-good state rather
|
|
116
|
+
* than building it up via user edits.
|
|
117
|
+
*/
|
|
118
|
+
replaceTree: (next: AdvancedFilterTree) => void
|
|
119
|
+
hasActiveRules: boolean
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Tree-shaped advanced filter state with debounced auto-apply, validation gating,
|
|
124
|
+
* and helpers for the CRM filter builder UI.
|
|
125
|
+
*
|
|
126
|
+
* **State ownership**: this hook is the single source of truth for the filter
|
|
127
|
+
* tree. The host page MUST NOT keep a parallel `useState<AdvancedFilterTree>` —
|
|
128
|
+
* read `appliedTree` for URL/query consumption and `tree` for the editor panel.
|
|
129
|
+
*
|
|
130
|
+
* - Debounced apply (default 400 ms) — fires only when the tree validates.
|
|
131
|
+
* `appliedTree` advances and the optional `onApply(tree)` callback fires.
|
|
132
|
+
* - `pendingErrors` exposes the latest validation errors for inline UI rendering.
|
|
133
|
+
* - `flush()` applies immediately if valid; `clear()` empties root children.
|
|
134
|
+
* - `replaceTree(t)` jumps BOTH editor and applied trees (perspective restore).
|
|
135
|
+
* - `hasActiveRules` mirrors `tree.root.children.length > 0`.
|
|
136
|
+
*/
|
|
137
|
+
export function useAdvancedFilterTree({
|
|
138
|
+
initial,
|
|
139
|
+
fields,
|
|
140
|
+
onApply,
|
|
141
|
+
debounceMs = 400,
|
|
142
|
+
}: UseAdvancedFilterTreeArgs): UseAdvancedFilterTreeResult {
|
|
143
|
+
const [tree, setTree] = React.useState<AdvancedFilterTree>(initial)
|
|
144
|
+
const [appliedTree, setAppliedTree] = React.useState<AdvancedFilterTree>(initial)
|
|
145
|
+
const [pendingErrors, setPendingErrors] = React.useState<ValidationError[]>([])
|
|
146
|
+
const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
147
|
+
const didMountRef = React.useRef(false)
|
|
148
|
+
const onApplyRef = React.useRef(onApply)
|
|
149
|
+
const fieldsRef = React.useRef(fields)
|
|
150
|
+
React.useEffect(() => { onApplyRef.current = onApply }, [onApply])
|
|
151
|
+
React.useEffect(() => { fieldsRef.current = fields }, [fields])
|
|
152
|
+
|
|
153
|
+
const tryApply = React.useCallback((candidate: AdvancedFilterTree): boolean => {
|
|
154
|
+
const result = validateTreeForApply(candidate, fieldsRef.current)
|
|
155
|
+
if (result.ok) {
|
|
156
|
+
setPendingErrors([])
|
|
157
|
+
setAppliedTree(candidate)
|
|
158
|
+
onApplyRef.current?.(candidate)
|
|
159
|
+
return true
|
|
160
|
+
}
|
|
161
|
+
setPendingErrors(result.errors)
|
|
162
|
+
return false
|
|
163
|
+
}, [])
|
|
164
|
+
|
|
165
|
+
React.useEffect(() => {
|
|
166
|
+
if (!didMountRef.current) {
|
|
167
|
+
didMountRef.current = true
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
if (timerRef.current) clearTimeout(timerRef.current)
|
|
171
|
+
const timer = setTimeout(() => { tryApply(tree) }, debounceMs)
|
|
172
|
+
timerRef.current = timer
|
|
173
|
+
return () => clearTimeout(timer)
|
|
174
|
+
}, [tree, debounceMs, tryApply])
|
|
175
|
+
|
|
176
|
+
const dispatch = React.useCallback((action: TreeAction) => {
|
|
177
|
+
setTree((prev) => treeReducer(prev, action))
|
|
178
|
+
}, [])
|
|
179
|
+
|
|
180
|
+
const flush = React.useCallback(() => {
|
|
181
|
+
if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null }
|
|
182
|
+
tryApply(tree)
|
|
183
|
+
}, [tree, tryApply])
|
|
184
|
+
|
|
185
|
+
const clear = React.useCallback(() => {
|
|
186
|
+
if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null }
|
|
187
|
+
setTree((prev) => {
|
|
188
|
+
const emptied: AdvancedFilterTree = { root: { ...prev.root, children: [] } }
|
|
189
|
+
setAppliedTree(emptied)
|
|
190
|
+
return emptied
|
|
191
|
+
})
|
|
192
|
+
setPendingErrors([])
|
|
193
|
+
onApplyRef.current?.({ root: { id: '', type: 'group', combinator: 'and', children: [] } })
|
|
194
|
+
}, [])
|
|
195
|
+
|
|
196
|
+
const replaceTree = React.useCallback((next: AdvancedFilterTree) => {
|
|
197
|
+
if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null }
|
|
198
|
+
setTree(next)
|
|
199
|
+
setAppliedTree(next)
|
|
200
|
+
setPendingErrors([])
|
|
201
|
+
onApplyRef.current?.(next)
|
|
202
|
+
}, [])
|
|
203
|
+
|
|
204
|
+
const hasActiveRules = tree.root.children.length > 0
|
|
205
|
+
|
|
206
|
+
return { tree, appliedTree, setTree, dispatch, pendingErrors, flush, clear, replaceTree, hasActiveRules }
|
|
207
|
+
}
|
package/src/backend/utils/api.ts
CHANGED
|
@@ -23,6 +23,23 @@ function readRedirectOverride(headers: Headers, headerName: string): boolean {
|
|
|
23
23
|
return headers.get(headerName) === '0'
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
function isSameOriginRequest(input: RequestInfo | URL): boolean {
|
|
27
|
+
if (typeof window === 'undefined') return false
|
|
28
|
+
const host = window.location?.host
|
|
29
|
+
if (!host) return false
|
|
30
|
+
let urlString: string
|
|
31
|
+
if (typeof input === 'string') urlString = input
|
|
32
|
+
else if (input instanceof URL) urlString = input.toString()
|
|
33
|
+
else if (typeof Request !== 'undefined' && input instanceof Request) urlString = input.url
|
|
34
|
+
else return false
|
|
35
|
+
if (!/^[a-z][a-z0-9+.-]*:/i.test(urlString)) return true
|
|
36
|
+
try {
|
|
37
|
+
return new URL(urlString).host === host
|
|
38
|
+
} catch {
|
|
39
|
+
return false
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
26
43
|
export async function withScopedApiHeaders<T>(headers: Record<string, string>, run: () => Promise<T>): Promise<T> {
|
|
27
44
|
return scopedHeaders.withScopedHeaders(headers, run)
|
|
28
45
|
}
|
|
@@ -109,9 +126,17 @@ export async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Pr
|
|
|
109
126
|
)
|
|
110
127
|
}
|
|
111
128
|
const scoped = scopedHeaders.resolveScopedHeaders()
|
|
112
|
-
const
|
|
129
|
+
const baseInit: RequestInit = Object.keys(scoped).length
|
|
113
130
|
? { ...(init ?? {}), headers: mergeHeaders(init?.headers, scoped) }
|
|
114
|
-
: init
|
|
131
|
+
: init ?? {}
|
|
132
|
+
// Only auto-inject credentials: 'include' for same-origin requests so cookies
|
|
133
|
+
// round-trip across Next.js proxy.ts rewrites (custom-domain portal flows)
|
|
134
|
+
// without leaking session cookies to third-party hosts.
|
|
135
|
+
const mergedInit: RequestInit = baseInit.credentials
|
|
136
|
+
? baseInit
|
|
137
|
+
: isSameOriginRequest(input)
|
|
138
|
+
? { ...baseInit, credentials: 'include' }
|
|
139
|
+
: baseInit
|
|
115
140
|
const requestHeaders = new Headers(mergedInit?.headers)
|
|
116
141
|
const disableUnauthorizedRedirect = readRedirectOverride(requestHeaders, 'x-om-unauthorized-redirect')
|
|
117
142
|
const disableForbiddenRedirect = readRedirectOverride(requestHeaders, 'x-om-forbidden-redirect')
|
|
@@ -17,6 +17,7 @@ type ColumnMeta = {
|
|
|
17
17
|
filterLoadOptions?: (query?: string) => Promise<FilterOption[]>
|
|
18
18
|
filterable?: boolean
|
|
19
19
|
filterGroup?: string
|
|
20
|
+
filterIconName?: string
|
|
20
21
|
columnChooserGroup?: string
|
|
21
22
|
alwaysVisible?: boolean
|
|
22
23
|
hidden?: boolean
|
|
@@ -85,6 +86,7 @@ export function useAutoDiscoveredFields({
|
|
|
85
86
|
type,
|
|
86
87
|
group: meta?.filterGroup ?? meta?.columnChooserGroup,
|
|
87
88
|
}
|
|
89
|
+
if (meta?.filterIconName) field.iconName = meta.filterIconName
|
|
88
90
|
if (meta?.filterOptions) field.options = meta.filterOptions
|
|
89
91
|
if (meta?.filterLoadOptions) field.loadOptions = meta.filterLoadOptions
|
|
90
92
|
filterFields.push(field)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import { apiCall } from './apiCall'
|
|
4
|
+
|
|
5
|
+
type FeatureCheckResponse = {
|
|
6
|
+
ok: boolean
|
|
7
|
+
granted: string[]
|
|
8
|
+
userId: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Read the current user's id on the client. Returns `''` until the request resolves.
|
|
13
|
+
*
|
|
14
|
+
* Implementation note: there is no first-class `/api/auth/me` endpoint yet, so this
|
|
15
|
+
* piggy-backs on `/api/auth/feature-check` with an empty `features` array — the response
|
|
16
|
+
* always carries the JWT `userId`. Replace with a dedicated `/api/auth/me` or an
|
|
17
|
+
* SSR-bootstrapped user context once available, and remove this hop.
|
|
18
|
+
*/
|
|
19
|
+
export function useCurrentUserId(): string {
|
|
20
|
+
const [userId, setUserId] = React.useState<string>('')
|
|
21
|
+
React.useEffect(() => {
|
|
22
|
+
let cancelled = false
|
|
23
|
+
void (async () => {
|
|
24
|
+
try {
|
|
25
|
+
const res = await apiCall<FeatureCheckResponse>('/api/auth/feature-check', {
|
|
26
|
+
method: 'POST',
|
|
27
|
+
headers: { 'content-type': 'application/json' },
|
|
28
|
+
body: JSON.stringify({ features: [] }),
|
|
29
|
+
})
|
|
30
|
+
if (!cancelled && res.ok && typeof res.result?.userId === 'string') {
|
|
31
|
+
setUserId(res.result.userId)
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
if (!cancelled) setUserId('')
|
|
35
|
+
}
|
|
36
|
+
})()
|
|
37
|
+
return () => { cancelled = true }
|
|
38
|
+
}, [])
|
|
39
|
+
return userId
|
|
40
|
+
}
|
|
@@ -14,6 +14,7 @@ const Z_INDEX_BY_TOKEN: Record<string, number> = {
|
|
|
14
14
|
'z-modal': 40,
|
|
15
15
|
'z-popover': 45,
|
|
16
16
|
'z-toast': 50,
|
|
17
|
+
'z-modal-elevated': 55,
|
|
17
18
|
'z-tooltip': 60,
|
|
18
19
|
'z-banner': 70,
|
|
19
20
|
'z-top': 100,
|
|
@@ -129,4 +130,25 @@ describe('Issue #1836: portaled overlay primitives sit above modals (z-popover >
|
|
|
129
130
|
expect(css).toContain('z-index: var(--z-index-popover);')
|
|
130
131
|
}
|
|
131
132
|
})
|
|
133
|
+
|
|
134
|
+
it('globals.css defines z-modal-elevated (55) above z-popover so dialogs opened from inside popovers are visible', () => {
|
|
135
|
+
const repoRoot = path.resolve(__dirname, '../../../../..')
|
|
136
|
+
const cssPaths = [
|
|
137
|
+
path.join(repoRoot, 'apps/mercato/src/app/globals.css'),
|
|
138
|
+
path.join(repoRoot, 'packages/create-app/template/src/app/globals.css'),
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
for (const cssPath of cssPaths) {
|
|
142
|
+
const css = fs.readFileSync(cssPath, 'utf8')
|
|
143
|
+
|
|
144
|
+
expect(css).toContain('@utility z-modal-elevated')
|
|
145
|
+
expect(css).toMatch(/z-index:\s*var\(--z-index-modal-elevated(?:,\s*\d+)?\);/)
|
|
146
|
+
|
|
147
|
+
const elevatedMatch = css.match(/--z-index-modal-elevated:\s*(\d+);/)
|
|
148
|
+
const popoverMatch = css.match(/--z-index-popover:\s*(\d+);/)
|
|
149
|
+
expect(elevatedMatch).not.toBeNull()
|
|
150
|
+
expect(popoverMatch).not.toBeNull()
|
|
151
|
+
expect(Number(elevatedMatch![1])).toBeGreaterThan(Number(popoverMatch![1]))
|
|
152
|
+
}
|
|
153
|
+
})
|
|
132
154
|
})
|
|
@@ -16,12 +16,17 @@ const DialogClose = DialogPrimitive.Close
|
|
|
16
16
|
|
|
17
17
|
const DialogOverlay = React.forwardRef<
|
|
18
18
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
|
19
|
-
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
|
20
|
-
|
|
19
|
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> & {
|
|
20
|
+
/** Render above popovers (z-modal-elevated, 55) instead of the default z-modal (40).
|
|
21
|
+
* Use when this dialog is opened from inside a popover so it isn't occluded. */
|
|
22
|
+
elevated?: boolean
|
|
23
|
+
}
|
|
24
|
+
>(({ className, elevated, ...props }, ref) => (
|
|
21
25
|
<DialogPrimitive.Overlay
|
|
22
26
|
ref={ref}
|
|
23
27
|
className={cn(
|
|
24
|
-
'fixed inset-0
|
|
28
|
+
'fixed inset-0 bg-black/50 backdrop-blur-sm transition-opacity data-[state=open]:animate-in data-[state=closed]:animate-out',
|
|
29
|
+
elevated ? 'z-modal-elevated' : 'z-modal',
|
|
25
30
|
className
|
|
26
31
|
)}
|
|
27
32
|
{...props}
|
|
@@ -31,8 +36,13 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
|
|
31
36
|
|
|
32
37
|
const DialogContent = React.forwardRef<
|
|
33
38
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
|
34
|
-
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
|
35
|
-
|
|
39
|
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
|
40
|
+
/** Render above popovers (z-modal-elevated, 55) instead of the default z-modal (40).
|
|
41
|
+
* Set on dialogs that open from inside another popover (e.g. the SaveFilterDialog
|
|
42
|
+
* inside the AdvancedFilterPanel popover) so they aren't hidden behind the popover. */
|
|
43
|
+
elevated?: boolean
|
|
44
|
+
}
|
|
45
|
+
>(({ className, children, elevated, ...props }, ref) => {
|
|
36
46
|
const t = useT()
|
|
37
47
|
|
|
38
48
|
React.useEffect(() => {
|
|
@@ -48,12 +58,13 @@ const DialogContent = React.forwardRef<
|
|
|
48
58
|
|
|
49
59
|
return (
|
|
50
60
|
<DialogPortal>
|
|
51
|
-
<DialogOverlay />
|
|
61
|
+
<DialogOverlay elevated={elevated} />
|
|
52
62
|
<DialogPrimitive.Content
|
|
53
63
|
ref={ref}
|
|
54
64
|
data-dialog-content=""
|
|
55
65
|
className={cn(
|
|
56
|
-
'fixed inset-x-0 bottom-0
|
|
66
|
+
'fixed inset-x-0 bottom-0 flex min-h-[50vh] max-h-[70vh] w-full translate-x-0 translate-y-0 flex-col gap-4 overflow-y-auto rounded-t-2xl border-t bg-card p-6 shadow-lg',
|
|
67
|
+
elevated ? 'z-modal-elevated' : 'z-modal',
|
|
57
68
|
'sm:inset-auto sm:left-1/2 sm:top-1/2 sm:min-h-0 sm:h-auto sm:w-full sm:max-w-lg sm:max-h-[90vh] sm:-translate-x-1/2 sm:-translate-y-1/2 sm:rounded-xl sm:border',
|
|
58
69
|
'focus-visible:outline-none data-[state=open]:animate-in data-[state=closed]:animate-out',
|
|
59
70
|
className,
|
package/src/primitives/tag.tsx
CHANGED
|
@@ -15,6 +15,7 @@ const tagVariants = cva(
|
|
|
15
15
|
info: 'border-status-info-border bg-status-info-bg text-status-info-text',
|
|
16
16
|
neutral: 'border-status-neutral-border bg-status-neutral-bg text-status-neutral-text',
|
|
17
17
|
brand: 'border-brand-violet/30 bg-brand-violet/10 text-brand-violet',
|
|
18
|
+
pink: 'border-status-pink-border bg-status-pink-bg text-status-pink-text',
|
|
18
19
|
},
|
|
19
20
|
shape: {
|
|
20
21
|
pill: 'rounded-full px-2.5 py-0.5',
|
|
@@ -33,9 +34,10 @@ const dotColorMap: Record<TagVariant, string> = {
|
|
|
33
34
|
info: 'bg-status-info-icon',
|
|
34
35
|
neutral: 'bg-status-neutral-icon',
|
|
35
36
|
brand: 'bg-brand-violet',
|
|
37
|
+
pink: 'bg-status-pink-icon',
|
|
36
38
|
}
|
|
37
39
|
|
|
38
|
-
export type TagVariant = 'default' | 'success' | 'warning' | 'error' | 'info' | 'neutral' | 'brand'
|
|
40
|
+
export type TagVariant = 'default' | 'success' | 'warning' | 'error' | 'info' | 'neutral' | 'brand' | 'pink'
|
|
39
41
|
|
|
40
42
|
export type TagShape = 'pill' | 'square'
|
|
41
43
|
|