@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.
Files changed (131) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +23 -14
  3. package/dist/backend/AppShell.js +8 -32
  4. package/dist/backend/AppShell.js.map +2 -2
  5. package/dist/backend/CrudForm.js +18 -0
  6. package/dist/backend/CrudForm.js.map +2 -2
  7. package/dist/backend/DataTable.js +149 -53
  8. package/dist/backend/DataTable.js.map +2 -2
  9. package/dist/backend/FilterBar.js +11 -14
  10. package/dist/backend/FilterBar.js.map +2 -2
  11. package/dist/backend/columns/ColumnChooserPanel.js +4 -6
  12. package/dist/backend/columns/ColumnChooserPanel.js.map +2 -2
  13. package/dist/backend/filters/ActiveFilterChips.js +142 -0
  14. package/dist/backend/filters/ActiveFilterChips.js.map +7 -0
  15. package/dist/backend/filters/AdvancedFilterBuilder.js +689 -163
  16. package/dist/backend/filters/AdvancedFilterBuilder.js.map +2 -2
  17. package/dist/backend/filters/AdvancedFilterPanel.js +354 -0
  18. package/dist/backend/filters/AdvancedFilterPanel.js.map +7 -0
  19. package/dist/backend/filters/FilterEmptyState.js +41 -0
  20. package/dist/backend/filters/FilterEmptyState.js.map +7 -0
  21. package/dist/backend/filters/FilterFieldPicker.js +148 -0
  22. package/dist/backend/filters/FilterFieldPicker.js.map +7 -0
  23. package/dist/backend/filters/FilteredEmptyResults.js +26 -0
  24. package/dist/backend/filters/FilteredEmptyResults.js.map +7 -0
  25. package/dist/backend/filters/QuickFilters.js +41 -0
  26. package/dist/backend/filters/QuickFilters.js.map +7 -0
  27. package/dist/backend/filters/filterValidation.js +47 -0
  28. package/dist/backend/filters/filterValidation.js.map +7 -0
  29. package/dist/backend/filters/treeReducer.js +159 -0
  30. package/dist/backend/filters/treeReducer.js.map +7 -0
  31. package/dist/backend/hooks/useAdvancedFilter.js +82 -1
  32. package/dist/backend/hooks/useAdvancedFilter.js.map +2 -2
  33. package/dist/backend/inputs/ComboboxInput.js +11 -7
  34. package/dist/backend/inputs/ComboboxInput.js.map +2 -2
  35. package/dist/backend/inputs/EventPatternInput.js +5 -2
  36. package/dist/backend/inputs/EventPatternInput.js.map +2 -2
  37. package/dist/backend/inputs/EventSelect.js +10 -4
  38. package/dist/backend/inputs/EventSelect.js.map +2 -2
  39. package/dist/backend/inputs/LookupSelect.js +51 -47
  40. package/dist/backend/inputs/LookupSelect.js.map +2 -2
  41. package/dist/backend/inputs/PhoneNumberField.js +196 -45
  42. package/dist/backend/inputs/PhoneNumberField.js.map +2 -2
  43. package/dist/backend/inputs/SwitchableMarkdownInput.js.map +2 -2
  44. package/dist/backend/messages/message-compose-form-groups.js +2 -2
  45. package/dist/backend/messages/message-compose-form-groups.js.map +2 -2
  46. package/dist/backend/utils/api.js +18 -1
  47. package/dist/backend/utils/api.js.map +2 -2
  48. package/dist/backend/utils/serverErrors.js +6 -2
  49. package/dist/backend/utils/serverErrors.js.map +2 -2
  50. package/dist/backend/utils/useAutoDiscoveredFields.js +1 -0
  51. package/dist/backend/utils/useAutoDiscoveredFields.js.map +2 -2
  52. package/dist/backend/utils/useCurrentUserId.js +31 -0
  53. package/dist/backend/utils/useCurrentUserId.js.map +7 -0
  54. package/dist/portal/utils/nav.js +17 -0
  55. package/dist/portal/utils/nav.js.map +2 -2
  56. package/dist/primitives/amount-input.js +128 -0
  57. package/dist/primitives/amount-input.js.map +7 -0
  58. package/dist/primitives/button-input.js +47 -0
  59. package/dist/primitives/button-input.js.map +7 -0
  60. package/dist/primitives/card-input.js +133 -0
  61. package/dist/primitives/card-input.js.map +7 -0
  62. package/dist/primitives/dialog.js +7 -5
  63. package/dist/primitives/dialog.js.map +2 -2
  64. package/dist/primitives/email-input.js +29 -0
  65. package/dist/primitives/email-input.js.map +7 -0
  66. package/dist/primitives/password-input.js +64 -0
  67. package/dist/primitives/password-input.js.map +7 -0
  68. package/dist/primitives/search-input.js +57 -0
  69. package/dist/primitives/search-input.js.map +7 -0
  70. package/dist/primitives/select.js +43 -5
  71. package/dist/primitives/select.js.map +2 -2
  72. package/dist/primitives/tag.js +4 -2
  73. package/dist/primitives/tag.js.map +2 -2
  74. package/dist/primitives/website-input.js +56 -0
  75. package/dist/primitives/website-input.js.map +7 -0
  76. package/package.json +3 -3
  77. package/src/backend/AppShell.tsx +7 -30
  78. package/src/backend/CrudForm.tsx +17 -0
  79. package/src/backend/DataTable.tsx +307 -63
  80. package/src/backend/FilterBar.tsx +5 -6
  81. package/src/backend/__tests__/DataTable.extensions.test.tsx +170 -0
  82. package/src/backend/__tests__/serverErrors.test.ts +79 -1
  83. package/src/backend/columns/ColumnChooserPanel.tsx +4 -5
  84. package/src/backend/filters/ActiveFilterChips.tsx +179 -0
  85. package/src/backend/filters/AdvancedFilterBuilder.tsx +840 -237
  86. package/src/backend/filters/AdvancedFilterPanel.tsx +456 -0
  87. package/src/backend/filters/FilterEmptyState.tsx +51 -0
  88. package/src/backend/filters/FilterFieldPicker.tsx +139 -0
  89. package/src/backend/filters/FilteredEmptyResults.tsx +40 -0
  90. package/src/backend/filters/QuickFilters.tsx +62 -0
  91. package/src/backend/filters/__tests__/ActiveFilterChips.test.tsx +75 -0
  92. package/src/backend/filters/__tests__/AdvancedFilterBuilder.test.tsx +210 -0
  93. package/src/backend/filters/__tests__/AdvancedFilterPanel.test.tsx +212 -0
  94. package/src/backend/filters/__tests__/FilterEmptyState.test.tsx +31 -0
  95. package/src/backend/filters/__tests__/FilterFieldPicker.test.tsx +61 -0
  96. package/src/backend/filters/__tests__/FilteredEmptyResults.test.tsx +35 -0
  97. package/src/backend/filters/__tests__/QuickFilters.test.tsx +34 -0
  98. package/src/backend/filters/__tests__/filterValidation.test.ts +69 -0
  99. package/src/backend/filters/__tests__/treeReducer.test.ts +189 -0
  100. package/src/backend/filters/filterValidation.ts +61 -0
  101. package/src/backend/filters/treeReducer.ts +201 -0
  102. package/src/backend/hooks/__tests__/useAdvancedFilter.test.tsx +213 -0
  103. package/src/backend/hooks/useAdvancedFilter.ts +130 -0
  104. package/src/backend/inputs/ComboboxInput.tsx +32 -26
  105. package/src/backend/inputs/EventPatternInput.tsx +11 -2
  106. package/src/backend/inputs/EventSelect.tsx +13 -4
  107. package/src/backend/inputs/LookupSelect.tsx +75 -53
  108. package/src/backend/inputs/PhoneNumberField.tsx +237 -47
  109. package/src/backend/inputs/SwitchableMarkdownInput.tsx +30 -0
  110. package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +4 -0
  111. package/src/backend/inputs/__tests__/PhoneNumberField.test.tsx +4 -0
  112. package/src/backend/messages/message-compose-form-groups.tsx +2 -2
  113. package/src/backend/utils/api.ts +27 -2
  114. package/src/backend/utils/serverErrors.ts +15 -8
  115. package/src/backend/utils/useAutoDiscoveredFields.ts +2 -0
  116. package/src/backend/utils/useCurrentUserId.ts +40 -0
  117. package/src/portal/utils/__tests__/nav.test.ts +80 -0
  118. package/src/portal/utils/nav.ts +23 -1
  119. package/src/primitives/__tests__/password-input.test.tsx +93 -0
  120. package/src/primitives/__tests__/search-input.test.tsx +95 -0
  121. package/src/primitives/__tests__/zindex-overlay.test.tsx +26 -0
  122. package/src/primitives/amount-input.tsx +184 -0
  123. package/src/primitives/button-input.tsx +68 -0
  124. package/src/primitives/card-input.tsx +193 -0
  125. package/src/primitives/dialog.tsx +18 -7
  126. package/src/primitives/email-input.tsx +36 -0
  127. package/src/primitives/password-input.tsx +100 -0
  128. package/src/primitives/search-input.tsx +87 -0
  129. package/src/primitives/select.tsx +98 -11
  130. package/src/primitives/tag.tsx +3 -1
  131. 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-2 text-sm disabled:bg-muted disabled:text-muted-foreground disabled:cursor-not-allowed"
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={placeholder || 'Type to search...'}
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-lg max-h-48 sm:max-h-60 overflow-auto">
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-3 py-2 text-xs text-muted-foreground">Loading suggestions…</div>
328
+ <div className="px-2 py-1.5 text-xs text-muted-foreground">{loadingLabel}</div>
325
329
  ) : (
326
- filteredSuggestions.map((option, index) => (
327
- <Button
328
- key={option.value}
329
- type="button"
330
- variant="ghost"
331
- size="sm"
332
- className={[
333
- 'w-full h-auto justify-start font-normal text-left flex flex-col items-start px-3 py-2',
334
- index === selectedIndex ? 'bg-accent' : '',
335
- ]
336
- .filter(Boolean)
337
- .join(' ')}
338
- onMouseDown={(event) => event.preventDefault()}
339
- onClick={() => selectValue(option.value)}
340
- onMouseEnter={() => setSelectedIndex(index)}
341
- >
342
- <span className="font-medium">{option.label}</span>
343
- {option.description ? (
344
- <span className="text-xs text-muted-foreground">{option.description}</span>
345
- ) : null}
346
- </Button>
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 = 'sales.orders.created',
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={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 = 'Select an event...',
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="lg" className={className}>
127
+ <SelectTrigger size={size} className={className}>
119
128
  <SelectValue
120
- placeholder={isLoading ? 'Loading...' : isEmpty ? 'No events available' : placeholder}
129
+ placeholder={isLoading ? loadingPlaceholder : isEmpty ? emptyPlaceholder : resolvedPlaceholder}
121
130
  />
122
131
  </SelectTrigger>
123
132
  <SelectContent>