@open-mercato/ui 0.6.1-develop.3276.1.1fcd145078 → 0.6.1-develop.3287.1.450f4ffb56
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/AGENTS.md +23 -14
- package/dist/backend/AppShell.js +8 -32
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/CrudForm.js +18 -0
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/DataTable.js +149 -53
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/FilterBar.js +11 -14
- package/dist/backend/FilterBar.js.map +2 -2
- package/dist/backend/columns/ColumnChooserPanel.js +4 -6
- package/dist/backend/columns/ColumnChooserPanel.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/inputs/ComboboxInput.js +11 -7
- package/dist/backend/inputs/ComboboxInput.js.map +2 -2
- package/dist/backend/inputs/EventPatternInput.js +5 -2
- package/dist/backend/inputs/EventPatternInput.js.map +2 -2
- package/dist/backend/inputs/EventSelect.js +10 -4
- package/dist/backend/inputs/EventSelect.js.map +2 -2
- package/dist/backend/inputs/LookupSelect.js +51 -47
- package/dist/backend/inputs/LookupSelect.js.map +2 -2
- package/dist/backend/inputs/PhoneNumberField.js +196 -45
- package/dist/backend/inputs/PhoneNumberField.js.map +2 -2
- package/dist/backend/inputs/SwitchableMarkdownInput.js.map +2 -2
- package/dist/backend/messages/message-compose-form-groups.js +2 -2
- package/dist/backend/messages/message-compose-form-groups.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/serverErrors.js +6 -2
- package/dist/backend/utils/serverErrors.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/portal/utils/nav.js +17 -0
- package/dist/portal/utils/nav.js.map +2 -2
- package/dist/primitives/amount-input.js +128 -0
- package/dist/primitives/amount-input.js.map +7 -0
- package/dist/primitives/button-input.js +47 -0
- package/dist/primitives/button-input.js.map +7 -0
- package/dist/primitives/card-input.js +133 -0
- package/dist/primitives/card-input.js.map +7 -0
- package/dist/primitives/dialog.js +7 -5
- package/dist/primitives/dialog.js.map +2 -2
- package/dist/primitives/email-input.js +29 -0
- package/dist/primitives/email-input.js.map +7 -0
- package/dist/primitives/password-input.js +64 -0
- package/dist/primitives/password-input.js.map +7 -0
- package/dist/primitives/search-input.js +57 -0
- package/dist/primitives/search-input.js.map +7 -0
- package/dist/primitives/select.js +43 -5
- package/dist/primitives/select.js.map +2 -2
- package/dist/primitives/tag.js +4 -2
- package/dist/primitives/tag.js.map +2 -2
- package/dist/primitives/website-input.js +56 -0
- package/dist/primitives/website-input.js.map +7 -0
- package/package.json +3 -3
- package/src/backend/AppShell.tsx +7 -30
- package/src/backend/CrudForm.tsx +17 -0
- package/src/backend/DataTable.tsx +307 -63
- package/src/backend/FilterBar.tsx +5 -6
- package/src/backend/__tests__/DataTable.extensions.test.tsx +170 -0
- package/src/backend/__tests__/serverErrors.test.ts +79 -1
- package/src/backend/columns/ColumnChooserPanel.tsx +4 -5
- 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/inputs/ComboboxInput.tsx +32 -26
- package/src/backend/inputs/EventPatternInput.tsx +11 -2
- package/src/backend/inputs/EventSelect.tsx +13 -4
- package/src/backend/inputs/LookupSelect.tsx +75 -53
- package/src/backend/inputs/PhoneNumberField.tsx +237 -47
- package/src/backend/inputs/SwitchableMarkdownInput.tsx +30 -0
- package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +4 -0
- package/src/backend/inputs/__tests__/PhoneNumberField.test.tsx +4 -0
- package/src/backend/messages/message-compose-form-groups.tsx +2 -2
- package/src/backend/utils/api.ts +27 -2
- package/src/backend/utils/serverErrors.ts +15 -8
- package/src/backend/utils/useAutoDiscoveredFields.ts +2 -0
- package/src/backend/utils/useCurrentUserId.ts +40 -0
- package/src/portal/utils/__tests__/nav.test.ts +80 -0
- package/src/portal/utils/nav.ts +23 -1
- package/src/primitives/__tests__/password-input.test.tsx +93 -0
- package/src/primitives/__tests__/search-input.test.tsx +95 -0
- package/src/primitives/__tests__/zindex-overlay.test.tsx +26 -0
- package/src/primitives/amount-input.tsx +184 -0
- package/src/primitives/button-input.tsx +68 -0
- package/src/primitives/card-input.tsx +193 -0
- package/src/primitives/dialog.tsx +18 -7
- package/src/primitives/email-input.tsx +36 -0
- package/src/primitives/password-input.tsx +100 -0
- package/src/primitives/search-input.tsx +87 -0
- package/src/primitives/select.tsx +98 -11
- package/src/primitives/tag.tsx +3 -1
- package/src/primitives/website-input.tsx +72 -0
|
@@ -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
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import * as React from 'react'
|
|
4
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
4
5
|
import { Button } from '../../primitives/button'
|
|
5
6
|
|
|
6
7
|
export type ComboboxOption = {
|
|
@@ -62,6 +63,9 @@ export function ComboboxInput({
|
|
|
62
63
|
disabled = false,
|
|
63
64
|
allowCustomValues = true,
|
|
64
65
|
}: ComboboxInputProps) {
|
|
66
|
+
const t = useT()
|
|
67
|
+
const resolvedPlaceholder = placeholder ?? t('ui.inputs.comboboxInput.placeholder', 'Type to search...')
|
|
68
|
+
const loadingLabel = t('ui.inputs.comboboxInput.loading', 'Loading suggestions…')
|
|
65
69
|
const [input, setInput] = React.useState('')
|
|
66
70
|
const [asyncOptions, setAsyncOptions] = React.useState<ComboboxOption[]>([])
|
|
67
71
|
const [resolvedOptions, setResolvedOptions] = React.useState<ComboboxOption[]>([])
|
|
@@ -288,9 +292,9 @@ export function ComboboxInput({
|
|
|
288
292
|
<input
|
|
289
293
|
ref={inputRef}
|
|
290
294
|
type="text"
|
|
291
|
-
className="w-full h-9 rounded border px-
|
|
295
|
+
className="w-full h-9 rounded-md border border-input bg-background px-3 text-sm shadow-xs transition-colors outline-none placeholder:text-muted-foreground focus-visible:shadow-focus focus-visible:border-foreground disabled:bg-bg-disabled disabled:border-border-disabled disabled:text-muted-foreground disabled:cursor-not-allowed"
|
|
292
296
|
value={input}
|
|
293
|
-
placeholder={
|
|
297
|
+
placeholder={resolvedPlaceholder}
|
|
294
298
|
autoFocus={autoFocus}
|
|
295
299
|
data-crud-focus-target=""
|
|
296
300
|
disabled={disabled}
|
|
@@ -319,32 +323,34 @@ export function ComboboxInput({
|
|
|
319
323
|
/>
|
|
320
324
|
|
|
321
325
|
{showSuggestions && !disabled && (loading || filteredSuggestions.length > 0) && (
|
|
322
|
-
<div className="absolute z-popover w-full mt-1 rounded border bg-popover shadow-
|
|
326
|
+
<div className="absolute z-popover w-full mt-1 rounded-md border border-input bg-popover p-2 shadow-md max-h-48 sm:max-h-60 overflow-auto">
|
|
323
327
|
{loading && touched ? (
|
|
324
|
-
<div className="px-
|
|
328
|
+
<div className="px-2 py-1.5 text-xs text-muted-foreground">{loadingLabel}</div>
|
|
325
329
|
) : (
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
330
|
+
<div className="flex flex-col gap-1">
|
|
331
|
+
{filteredSuggestions.map((option, index) => (
|
|
332
|
+
<Button
|
|
333
|
+
key={option.value}
|
|
334
|
+
type="button"
|
|
335
|
+
variant="ghost"
|
|
336
|
+
size="sm"
|
|
337
|
+
className={[
|
|
338
|
+
'w-full h-auto justify-start font-normal text-left flex flex-col items-start rounded-lg p-2',
|
|
339
|
+
index === selectedIndex ? 'bg-muted' : '',
|
|
340
|
+
]
|
|
341
|
+
.filter(Boolean)
|
|
342
|
+
.join(' ')}
|
|
343
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
344
|
+
onClick={() => selectValue(option.value)}
|
|
345
|
+
onMouseEnter={() => setSelectedIndex(index)}
|
|
346
|
+
>
|
|
347
|
+
<span className="font-medium text-foreground">{option.label}</span>
|
|
348
|
+
{option.description ? (
|
|
349
|
+
<span className="text-xs text-muted-foreground">{option.description}</span>
|
|
350
|
+
) : null}
|
|
351
|
+
</Button>
|
|
352
|
+
))}
|
|
353
|
+
</div>
|
|
348
354
|
)}
|
|
349
355
|
</div>
|
|
350
356
|
)}
|
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import * as React from 'react'
|
|
4
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
4
5
|
import { ComboboxInput, type ComboboxOption } from './ComboboxInput'
|
|
5
6
|
import { useAvailableEvents } from './EventSelect'
|
|
6
7
|
|
|
7
8
|
export interface EventPatternInputProps {
|
|
8
9
|
value: string
|
|
9
10
|
onChange: (pattern: string) => void
|
|
11
|
+
/**
|
|
12
|
+
* Placeholder hint shown when the field is empty. Defaults to a translated
|
|
13
|
+
* `ui.inputs.eventPatternInput.placeholder` key (English fallback
|
|
14
|
+
* `'sales.orders.created'`) — an example of a valid event id with wildcards.
|
|
15
|
+
* Pass a custom string to override per-surface.
|
|
16
|
+
*/
|
|
10
17
|
placeholder?: string
|
|
11
18
|
disabled?: boolean
|
|
12
19
|
categories?: Array<'crud' | 'lifecycle' | 'system' | 'custom'>
|
|
@@ -16,11 +23,13 @@ export interface EventPatternInputProps {
|
|
|
16
23
|
export function EventPatternInput({
|
|
17
24
|
value,
|
|
18
25
|
onChange,
|
|
19
|
-
placeholder
|
|
26
|
+
placeholder,
|
|
20
27
|
disabled,
|
|
21
28
|
categories,
|
|
22
29
|
modules,
|
|
23
30
|
}: EventPatternInputProps) {
|
|
31
|
+
const t = useT()
|
|
32
|
+
const resolvedPlaceholder = placeholder ?? t('ui.inputs.eventPatternInput.placeholder', 'sales.orders.created')
|
|
24
33
|
const { events } = useAvailableEvents({ categories, modules })
|
|
25
34
|
|
|
26
35
|
const suggestions = React.useMemo<ComboboxOption[]>(
|
|
@@ -37,7 +46,7 @@ export function EventPatternInput({
|
|
|
37
46
|
<ComboboxInput
|
|
38
47
|
value={value}
|
|
39
48
|
onChange={onChange}
|
|
40
|
-
placeholder={
|
|
49
|
+
placeholder={resolvedPlaceholder}
|
|
41
50
|
suggestions={suggestions}
|
|
42
51
|
allowCustomValues={true}
|
|
43
52
|
disabled={disabled}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import * as React from 'react'
|
|
4
4
|
import { useMemo } from 'react'
|
|
5
5
|
import { useQuery } from '@tanstack/react-query'
|
|
6
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
7
|
import { apiCall } from '../utils/apiCall'
|
|
7
8
|
import {
|
|
8
9
|
Select,
|
|
@@ -32,7 +33,7 @@ export interface EventSelectProps {
|
|
|
32
33
|
value: string
|
|
33
34
|
/** Called when event is selected */
|
|
34
35
|
onChange: (eventId: string) => void
|
|
35
|
-
/** Placeholder text when no event selected */
|
|
36
|
+
/** Placeholder text when no event selected. Defaults to a translated string. */
|
|
36
37
|
placeholder?: string
|
|
37
38
|
/** Additional CSS classes */
|
|
38
39
|
className?: string
|
|
@@ -44,6 +45,8 @@ export interface EventSelectProps {
|
|
|
44
45
|
modules?: string[]
|
|
45
46
|
/** Whether to exclude events marked as excludeFromTriggers (default: true) */
|
|
46
47
|
excludeTriggerExcluded?: boolean
|
|
48
|
+
/** Trigger size — defaults to `'default'` (DS row-height contract). */
|
|
49
|
+
size?: 'sm' | 'default' | 'lg'
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
/**
|
|
@@ -54,13 +57,19 @@ export interface EventSelectProps {
|
|
|
54
57
|
export function EventSelect({
|
|
55
58
|
value,
|
|
56
59
|
onChange,
|
|
57
|
-
placeholder
|
|
60
|
+
placeholder,
|
|
58
61
|
className,
|
|
59
62
|
disabled,
|
|
60
63
|
categories,
|
|
61
64
|
modules,
|
|
62
65
|
excludeTriggerExcluded = true,
|
|
66
|
+
size = 'default',
|
|
63
67
|
}: EventSelectProps) {
|
|
68
|
+
const t = useT()
|
|
69
|
+
const resolvedPlaceholder = placeholder ?? t('ui.inputs.eventSelect.placeholder', 'Select an event...')
|
|
70
|
+
const loadingPlaceholder = t('ui.inputs.eventSelect.loading', 'Loading...')
|
|
71
|
+
const emptyPlaceholder = t('ui.inputs.eventSelect.empty', 'No events available')
|
|
72
|
+
|
|
64
73
|
// Fetch events from the API
|
|
65
74
|
const { data: allEvents = [], isLoading } = useQuery({
|
|
66
75
|
queryKey: ['declared-events', excludeTriggerExcluded],
|
|
@@ -115,9 +124,9 @@ export function EventSelect({
|
|
|
115
124
|
onValueChange={(next) => onChange(next ?? '')}
|
|
116
125
|
disabled={disabled || isLoading}
|
|
117
126
|
>
|
|
118
|
-
<SelectTrigger size=
|
|
127
|
+
<SelectTrigger size={size} className={className}>
|
|
119
128
|
<SelectValue
|
|
120
|
-
placeholder={isLoading ?
|
|
129
|
+
placeholder={isLoading ? loadingPlaceholder : isEmpty ? emptyPlaceholder : resolvedPlaceholder}
|
|
121
130
|
/>
|
|
122
131
|
</SelectTrigger>
|
|
123
132
|
<SelectContent>
|