@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,35 @@
1
+ jest.mock('@open-mercato/shared/lib/i18n/context', () => ({
2
+ useT: () => (_key: string, fallback: string, vars?: Record<string, unknown>) => {
3
+ if (!vars) return fallback
4
+ return Object.entries(vars).reduce((s, [k, v]) => s.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v)), fallback)
5
+ },
6
+ }))
7
+
8
+ import { render, screen, fireEvent } from '@testing-library/react'
9
+ import { FilteredEmptyResults } from '../FilteredEmptyResults'
10
+
11
+ describe('FilteredEmptyResults', () => {
12
+ it('renders title with entityNamePlural interpolated', () => {
13
+ render(<FilteredEmptyResults entityNamePlural="people" canRemoveLast onClearAll={() => {}} onRemoveLast={() => {}} />)
14
+ expect(screen.getByText(/no people match these filters/i)).toBeInTheDocument()
15
+ })
16
+
17
+ it('calls onClearAll on Clear button click', () => {
18
+ const onClearAll = jest.fn()
19
+ render(<FilteredEmptyResults entityNamePlural="people" canRemoveLast onClearAll={onClearAll} onRemoveLast={() => {}} />)
20
+ fireEvent.click(screen.getByRole('button', { name: /clear all filters/i }))
21
+ expect(onClearAll).toHaveBeenCalled()
22
+ })
23
+
24
+ it('calls onRemoveLast on Remove last click', () => {
25
+ const onRemoveLast = jest.fn()
26
+ render(<FilteredEmptyResults entityNamePlural="people" canRemoveLast onClearAll={() => {}} onRemoveLast={onRemoveLast} />)
27
+ fireEvent.click(screen.getByRole('button', { name: /remove last filter/i }))
28
+ expect(onRemoveLast).toHaveBeenCalled()
29
+ })
30
+
31
+ it('disables remove last when canRemoveLast=false', () => {
32
+ render(<FilteredEmptyResults entityNamePlural="people" canRemoveLast={false} onClearAll={() => {}} onRemoveLast={() => {}} />)
33
+ expect(screen.getByRole('button', { name: /remove last filter/i })).toBeDisabled()
34
+ })
35
+ })
@@ -0,0 +1,34 @@
1
+ jest.mock('@open-mercato/shared/lib/i18n/context', () => ({
2
+ useT: () => (_key: string, fallback: string) => fallback,
3
+ }))
4
+
5
+ import { render, screen, fireEvent } from '@testing-library/react'
6
+ import { QuickFilters, type FilterPreset } from '../QuickFilters'
7
+ import { createEmptyTree } from '@open-mercato/shared/lib/query/advanced-filter-tree'
8
+
9
+ const presets: FilterPreset[] = [
10
+ { id: 'recent', labelKey: 'ui.test.recent', iconName: 'clock', build: () => createEmptyTree() },
11
+ { id: 'mine', labelKey: 'ui.test.mine', build: () => createEmptyTree() },
12
+ ]
13
+
14
+ describe('QuickFilters', () => {
15
+ it('renders nothing when no presets', () => {
16
+ const { container } = render(<QuickFilters presets={[]} userId="u1" onApply={() => {}} />)
17
+ expect(container).toBeEmptyDOMElement()
18
+ })
19
+
20
+ it('renders a chip per preset', () => {
21
+ render(<QuickFilters presets={presets} userId="u1" onApply={() => {}} />)
22
+ expect(screen.getByRole('button', { name: /ui.test.recent/i })).toBeInTheDocument()
23
+ expect(screen.getByRole('button', { name: /ui.test.mine/i })).toBeInTheDocument()
24
+ })
25
+
26
+ it('calls onApply with the built tree on click and passes userId+now', () => {
27
+ const onApply = jest.fn()
28
+ const build = jest.fn(() => createEmptyTree())
29
+ render(<QuickFilters presets={[{ id: 'mine', labelKey: 'k', build }]} userId="u-42" onApply={onApply} />)
30
+ fireEvent.click(screen.getByRole('button'))
31
+ expect(build).toHaveBeenCalledWith(expect.objectContaining({ userId: 'u-42', now: expect.any(Date) }))
32
+ expect(onApply).toHaveBeenCalledTimes(1)
33
+ })
34
+ })
@@ -0,0 +1,69 @@
1
+ // packages/ui/src/backend/filters/__tests__/filterValidation.test.ts
2
+ import { validateTreeForApply, type ValidationError } from '../filterValidation'
3
+ import type { AdvancedFilterTree } from '@open-mercato/shared/lib/query/advanced-filter-tree'
4
+ import type { FilterFieldDef } from '@open-mercato/shared/lib/query/advanced-filter'
5
+
6
+ const fields: FilterFieldDef[] = [
7
+ { key: 'name', label: 'Name', type: 'text' },
8
+ { key: 'created_at', label: 'Created', type: 'date' },
9
+ { key: 'tags', label: 'Tags', type: 'tags' },
10
+ { key: 'status', label: 'Status', type: 'select' },
11
+ ]
12
+
13
+ function tree(...children: any[]): AdvancedFilterTree {
14
+ return { root: { id: 'r', type: 'group', combinator: 'and', children } }
15
+ }
16
+
17
+ describe('validateTreeForApply', () => {
18
+ it('returns ok for empty tree', () => {
19
+ expect(validateTreeForApply(tree(), fields)).toEqual({ ok: true })
20
+ })
21
+
22
+ it('flags rule with empty value on value-required op', () => {
23
+ const t = tree({ id: 'a', type: 'rule', field: 'name', operator: 'contains', value: '' })
24
+ const r = validateTreeForApply(t, fields)
25
+ expect(r.ok).toBe(false)
26
+ if (!r.ok) expect(r.errors[0].ruleId).toBe('a')
27
+ })
28
+
29
+ it('passes valueless operator without value', () => {
30
+ const t = tree({ id: 'a', type: 'rule', field: 'name', operator: 'is_empty', value: undefined })
31
+ expect(validateTreeForApply(t, fields)).toEqual({ ok: true })
32
+ })
33
+
34
+ it('flags multi-value op with empty array', () => {
35
+ const t = tree({ id: 'a', type: 'rule', field: 'tags', operator: 'has_any_of', value: [] })
36
+ const r = validateTreeForApply(t, fields)
37
+ expect(r.ok).toBe(false)
38
+ })
39
+
40
+ it('flags between op with one endpoint missing', () => {
41
+ const t = tree({ id: 'a', type: 'rule', field: 'created_at', operator: 'between', value: ['2026-01-01', ''] })
42
+ const r = validateTreeForApply(t, fields)
43
+ expect(r.ok).toBe(false)
44
+ })
45
+
46
+ it('walks nested groups and reports errors from sub-groups', () => {
47
+ const t = tree(
48
+ { id: 'a', type: 'rule', field: 'name', operator: 'contains', value: 'okay' },
49
+ { id: 'g1', type: 'group', combinator: 'or', children: [
50
+ { id: 'b', type: 'rule', field: 'name', operator: 'contains', value: '' },
51
+ ] },
52
+ )
53
+ const r = validateTreeForApply(t, fields)
54
+ expect(r.ok).toBe(false)
55
+ if (!r.ok) expect(r.errors.map(e => e.ruleId)).toEqual(['b'])
56
+ })
57
+
58
+ it('allows non-blank one-character substring text operators', () => {
59
+ const t = tree({ id: 'a', type: 'rule', field: 'name', operator: 'contains', value: 'a' })
60
+ expect(validateTreeForApply(t, fields)).toEqual({ ok: true })
61
+ })
62
+
63
+ it('allows all non-blank substring operators regardless of length', () => {
64
+ for (const op of ['contains', 'does_not_contain', 'starts_with', 'ends_with'] as const) {
65
+ const short = tree({ id: 'a', type: 'rule', field: 'name', operator: op, value: 'ab' })
66
+ expect(validateTreeForApply(short, fields)).toEqual({ ok: true })
67
+ }
68
+ })
69
+ })
@@ -0,0 +1,189 @@
1
+ /** @jest-environment node */
2
+ import {
3
+ treeReducer,
4
+ canAddRule,
5
+ canAddGroup,
6
+ } from '../treeReducer'
7
+ import type { AdvancedFilterTree } from '@open-mercato/shared/lib/query/advanced-filter-tree'
8
+
9
+ const seed: AdvancedFilterTree = {
10
+ root: {
11
+ id: 'r', type: 'group', combinator: 'and',
12
+ children: [{ id: 'a', type: 'rule', field: 'display_name', operator: 'contains', value: 'Daniel' }],
13
+ },
14
+ }
15
+
16
+ describe('treeReducer', () => {
17
+ it('addRule appends a rule to the target group', () => {
18
+ const next = treeReducer(seed, { type: 'addRule', groupId: 'r' })
19
+ expect(next.root.children).toHaveLength(2)
20
+ expect(next.root.children[1].type).toBe('rule')
21
+ })
22
+
23
+ it('addGroup appends a nested group with one empty rule', () => {
24
+ const next = treeReducer(seed, { type: 'addGroup', groupId: 'r' })
25
+ const last = next.root.children[next.root.children.length - 1]
26
+ expect(last.type).toBe('group')
27
+ if (last.type === 'group') {
28
+ expect(last.combinator).toBe('and')
29
+ expect(last.children).toHaveLength(1)
30
+ expect(last.children[0].type).toBe('rule')
31
+ }
32
+ })
33
+
34
+ it('removeNode removes by id', () => {
35
+ const next = treeReducer(seed, { type: 'removeNode', nodeId: 'a' })
36
+ expect(next.root.children).toHaveLength(0)
37
+ })
38
+
39
+ it('updateRule patches field/op/value', () => {
40
+ const next = treeReducer(seed, { type: 'updateRule', ruleId: 'a', updates: { value: 'Naomi' } })
41
+ expect((next.root.children[0] as any).value).toBe('Naomi')
42
+ })
43
+
44
+ it('updateGroupCombinator flips and|or', () => {
45
+ const next = treeReducer(seed, { type: 'updateGroupCombinator', groupId: 'r', combinator: 'or' })
46
+ expect(next.root.combinator).toBe('or')
47
+ })
48
+
49
+ it('addRule rejected when group has 15 children', () => {
50
+ const big: AdvancedFilterTree = {
51
+ root: {
52
+ id: 'r', type: 'group', combinator: 'and',
53
+ children: Array.from({ length: 15 }, (_, i) => ({
54
+ id: `r${i}`, type: 'rule' as const, field: 'f', operator: 'is' as const, value: 'x',
55
+ })),
56
+ },
57
+ }
58
+ const next = treeReducer(big, { type: 'addRule', groupId: 'r' })
59
+ expect(next).toBe(big) // rejected; identity preserved
60
+ })
61
+
62
+ it('addGroup rejected at level 3', () => {
63
+ const t: AdvancedFilterTree = {
64
+ root: {
65
+ id: 'r', type: 'group', combinator: 'and', children: [
66
+ {
67
+ id: 'g2', type: 'group', combinator: 'and', children: [
68
+ {
69
+ id: 'g3', type: 'group', combinator: 'and', children: [
70
+ { id: 'rr', type: 'rule', field: 'f', operator: 'is', value: 'x' },
71
+ ],
72
+ },
73
+ ],
74
+ },
75
+ ],
76
+ },
77
+ }
78
+ const next = treeReducer(t, { type: 'addGroup', groupId: 'g3' })
79
+ expect(next).toBe(t)
80
+ })
81
+
82
+ it('addRule rejected at total cap', () => {
83
+ // 50 rules across 4 sub-groups (15+15+15+5), each under width cap
84
+ const mk = (prefix: string, n: number) =>
85
+ Array.from({ length: n }, (_, i) => ({
86
+ id: `${prefix}${i}`, type: 'rule' as const, field: 'f', operator: 'is' as const, value: 'x',
87
+ }))
88
+ const t: AdvancedFilterTree = {
89
+ root: {
90
+ id: 'r', type: 'group', combinator: 'and', children: [
91
+ { id: 'a', type: 'group', combinator: 'and', children: mk('a', 15) },
92
+ { id: 'b', type: 'group', combinator: 'and', children: mk('b', 15) },
93
+ { id: 'c', type: 'group', combinator: 'and', children: mk('c', 15) },
94
+ { id: 'd', type: 'group', combinator: 'and', children: mk('d', 5) },
95
+ ],
96
+ },
97
+ }
98
+ const next = treeReducer(t, { type: 'addRule', groupId: 'a' })
99
+ expect(next).toBe(t)
100
+ })
101
+ })
102
+
103
+ describe('canAddRule / canAddGroup', () => {
104
+ it('canAddGroup returns false at level 3', () => {
105
+ const t: AdvancedFilterTree = {
106
+ root: {
107
+ id: 'r', type: 'group', combinator: 'and', children: [
108
+ {
109
+ id: 'g2', type: 'group', combinator: 'and', children: [
110
+ { id: 'g3', type: 'group', combinator: 'and', children: [] },
111
+ ],
112
+ },
113
+ ],
114
+ },
115
+ }
116
+ expect(canAddGroup(t, 'g3')).toBe(false)
117
+ expect(canAddGroup(t, 'g2')).toBe(true) // adding into g2 produces a level-3 group
118
+ expect(canAddGroup(t, 'r')).toBe(true)
119
+ })
120
+
121
+ it('canAddRule returns false at width cap', () => {
122
+ const t: AdvancedFilterTree = {
123
+ root: {
124
+ id: 'r', type: 'group', combinator: 'and',
125
+ children: Array.from({ length: 15 }, (_, i) => ({
126
+ id: `r${i}`, type: 'rule' as const, field: 'f', operator: 'is' as const, value: 'x',
127
+ })),
128
+ },
129
+ }
130
+ expect(canAddRule(t, 'r')).toBe(false)
131
+ })
132
+ })
133
+
134
+ describe('treeReducer — reorderChildren', () => {
135
+ it('swaps two children inside the same group', () => {
136
+ const tree: AdvancedFilterTree = { root: { id: 'r', type: 'group', combinator: 'and', children: [
137
+ { id: 'a', type: 'rule', field: 'name', operator: 'contains', value: 'X' } as any,
138
+ { id: 'b', type: 'rule', field: 'name', operator: 'contains', value: 'Y' } as any,
139
+ { id: 'c', type: 'rule', field: 'name', operator: 'contains', value: 'Z' } as any,
140
+ ] } }
141
+ const out = treeReducer(tree, { type: 'reorderChildren', groupId: 'r', fromIdx: 0, toIdx: 2 })
142
+ expect(out.root.children.map((c: any) => c.id)).toEqual(['b', 'c', 'a'])
143
+ })
144
+ it('no-op when group not found', () => {
145
+ const tree: AdvancedFilterTree = { root: { id: 'r', type: 'group', combinator: 'and', children: [] } }
146
+ expect(treeReducer(tree, { type: 'reorderChildren', groupId: 'missing', fromIdx: 0, toIdx: 1 })).toBe(tree)
147
+ })
148
+ })
149
+
150
+ describe('treeReducer — removeLast', () => {
151
+ it('removes the rule with the highest addedAt', () => {
152
+ const tree: AdvancedFilterTree = { root: { id: 'r', type: 'group', combinator: 'and', children: [
153
+ { id: 'a', type: 'rule', field: 'name', operator: 'contains', value: 'X', addedAt: 1 } as any,
154
+ { id: 'b', type: 'rule', field: 'name', operator: 'contains', value: 'Y', addedAt: 3 } as any,
155
+ { id: 'c', type: 'rule', field: 'name', operator: 'contains', value: 'Z', addedAt: 2 } as any,
156
+ ] } }
157
+ const out = treeReducer(tree, { type: 'removeLast' })
158
+ expect(out.root.children.map((c: any) => c.id)).toEqual(['a', 'c'])
159
+ })
160
+ it('no-op when no node has addedAt', () => {
161
+ const tree: AdvancedFilterTree = { root: { id: 'r', type: 'group', combinator: 'and', children: [] } }
162
+ expect(treeReducer(tree, { type: 'removeLast' })).toBe(tree)
163
+ })
164
+ })
165
+
166
+ describe('treeReducer — replaceRoot', () => {
167
+ it('overwrites the root group', () => {
168
+ const tree: AdvancedFilterTree = { root: { id: 'r', type: 'group', combinator: 'and', children: [] } }
169
+ const newRoot = { id: 'r2', type: 'group' as const, combinator: 'or' as const, children: [] }
170
+ const out = treeReducer(tree, { type: 'replaceRoot', root: newRoot })
171
+ expect(out.root.id).toBe('r2')
172
+ expect(out.root.combinator).toBe('or')
173
+ })
174
+ })
175
+
176
+ describe('treeReducer — addedAt stamping', () => {
177
+ it('stamps addedAt on addRule', () => {
178
+ const before: AdvancedFilterTree = { root: { id: 'r', type: 'group', combinator: 'and', children: [] } }
179
+ const after = treeReducer(before, { type: 'addRule', groupId: 'r' })
180
+ const inserted = after.root.children[0] as any
181
+ expect(typeof inserted.addedAt).toBe('number')
182
+ })
183
+ it('stamps addedAt on addGroup', () => {
184
+ const before: AdvancedFilterTree = { root: { id: 'r', type: 'group', combinator: 'and', children: [] } }
185
+ const after = treeReducer(before, { type: 'addGroup', groupId: 'r' })
186
+ const inserted = after.root.children[0] as any
187
+ expect(typeof inserted.addedAt).toBe('number')
188
+ })
189
+ })
@@ -0,0 +1,61 @@
1
+ // packages/ui/src/backend/filters/filterValidation.ts
2
+ import type { AdvancedFilterTree, FilterRule, FilterGroup } from '@open-mercato/shared/lib/query/advanced-filter-tree'
3
+ import type { FilterFieldDef } from '@open-mercato/shared/lib/query/advanced-filter'
4
+ import { isValuelessOperator } from '@open-mercato/shared/lib/query/advanced-filter'
5
+
6
+ export type ValidationError = {
7
+ ruleId: string
8
+ messageKey: string
9
+ message: string
10
+ }
11
+
12
+ export type ValidationResult =
13
+ | { ok: true }
14
+ | { ok: false; errors: ValidationError[] }
15
+
16
+ const MULTI_VALUE_OPS = new Set(['is_any_of', 'is_none_of', 'has_any_of', 'has_all_of', 'has_none_of'])
17
+
18
+ function isBlank(value: unknown): boolean {
19
+ if (value == null) return true
20
+ if (typeof value === 'string') return value.trim().length === 0
21
+ return false
22
+ }
23
+
24
+ function validateRule(rule: FilterRule): ValidationError | null {
25
+ if (isValuelessOperator(rule.operator)) return null
26
+ const op = rule.operator
27
+ if (op === 'between') {
28
+ if (!Array.isArray(rule.value) || rule.value.length !== 2 || isBlank(rule.value[0]) || isBlank(rule.value[1])) {
29
+ return { ruleId: rule.id, messageKey: 'ui.advancedFilter.error.betweenIncomplete', message: 'Pick both endpoints' }
30
+ }
31
+ return null
32
+ }
33
+ if (MULTI_VALUE_OPS.has(op)) {
34
+ if (!Array.isArray(rule.value) || rule.value.length === 0) {
35
+ return { ruleId: rule.id, messageKey: 'ui.advancedFilter.error.missingValue', message: 'Pick at least one value' }
36
+ }
37
+ return null
38
+ }
39
+ if (isBlank(rule.value)) {
40
+ return { ruleId: rule.id, messageKey: 'ui.advancedFilter.error.missingValue', message: 'Pick a value to apply this filter' }
41
+ }
42
+ return null
43
+ }
44
+
45
+ function walkGroup(group: FilterGroup, acc: ValidationError[]): void {
46
+ for (const child of group.children) {
47
+ if (child.type === 'rule') {
48
+ const err = validateRule(child)
49
+ if (err) acc.push(err)
50
+ } else {
51
+ walkGroup(child, acc)
52
+ }
53
+ }
54
+ }
55
+
56
+ export function validateTreeForApply(tree: AdvancedFilterTree, fields: FilterFieldDef[]): ValidationResult {
57
+ void fields
58
+ const errors: ValidationError[] = []
59
+ walkGroup(tree.root, errors)
60
+ return errors.length === 0 ? { ok: true } : { ok: false, errors }
61
+ }
@@ -0,0 +1,201 @@
1
+ import {
2
+ type AdvancedFilterTree,
3
+ type FilterRule,
4
+ type FilterGroup,
5
+ type FilterCombinator,
6
+ validateTreeLimits,
7
+ TREE_LIMITS,
8
+ } from '@open-mercato/shared/lib/query/advanced-filter-tree'
9
+ import type { FilterOperator } from '@open-mercato/shared/lib/query/advanced-filter'
10
+
11
+ export type TreeAction =
12
+ | { type: 'addRule'; groupId: string; defaultField?: string; defaultOperator?: FilterOperator }
13
+ | { type: 'addGroup'; groupId: string; defaultField?: string; defaultOperator?: FilterOperator }
14
+ | { type: 'removeNode'; nodeId: string }
15
+ | { type: 'updateRule'; ruleId: string; updates: Partial<Pick<FilterRule, 'field' | 'operator' | 'value'>> }
16
+ | { type: 'updateGroupCombinator'; groupId: string; combinator: FilterCombinator }
17
+ | { type: 'reorderChildren'; groupId: string; fromIdx: number; toIdx: number }
18
+ | { type: 'removeLast' }
19
+ | { type: 'replaceRoot'; root: FilterGroup }
20
+
21
+ // Pure monotonic counter for tracking insert order within a session.
22
+ // MUST NOT be mixed with wall-clock time — that leaks load timing into tests
23
+ // (fixture ordering depended on `Date.now()` resolution across runs).
24
+ // Runtime-only — never serialized.
25
+ let addedAtCounter = 0
26
+ function nextAddedAt(): number {
27
+ addedAtCounter += 1
28
+ return addedAtCounter
29
+ }
30
+
31
+ function emptyRule(defaultField?: string, defaultOperator?: FilterOperator): FilterRule {
32
+ return {
33
+ id: crypto.randomUUID(),
34
+ type: 'rule',
35
+ field: defaultField ?? '',
36
+ operator: defaultOperator ?? 'contains',
37
+ value: '',
38
+ addedAt: nextAddedAt(),
39
+ }
40
+ }
41
+
42
+ function emptyGroup(defaultField?: string, defaultOperator?: FilterOperator): FilterGroup {
43
+ return {
44
+ id: crypto.randomUUID(),
45
+ type: 'group',
46
+ combinator: 'and',
47
+ children: [emptyRule(defaultField, defaultOperator)],
48
+ addedAt: nextAddedAt(),
49
+ }
50
+ }
51
+
52
+ type WithAddedAt = { addedAt?: number }
53
+
54
+ function findMaxAddedAtNode(
55
+ group: FilterGroup,
56
+ ): { parent: FilterGroup; idx: number; addedAt: number } | null {
57
+ let best: { parent: FilterGroup; idx: number; addedAt: number } | null = null
58
+ function walk(g: FilterGroup) {
59
+ g.children.forEach((c, idx) => {
60
+ const a = (c as unknown as WithAddedAt).addedAt
61
+ if (typeof a === 'number') {
62
+ if (!best || a > best.addedAt) best = { parent: g, idx, addedAt: a }
63
+ }
64
+ if (c.type === 'group') walk(c)
65
+ })
66
+ }
67
+ walk(group)
68
+ return best
69
+ }
70
+
71
+ function findGroupAndDepth(root: FilterGroup, id: string, depth = 1): { group: FilterGroup; depth: number } | null {
72
+ if (root.id === id) return { group: root, depth }
73
+ for (const child of root.children) {
74
+ if (child.type === 'group') {
75
+ const r = findGroupAndDepth(child, id, depth + 1)
76
+ if (r) return r
77
+ }
78
+ }
79
+ return null
80
+ }
81
+
82
+ function countRules(group: FilterGroup): number {
83
+ let n = 0
84
+ for (const c of group.children) {
85
+ if (c.type === 'rule') n += 1
86
+ else n += countRules(c)
87
+ }
88
+ return n
89
+ }
90
+
91
+ export function treeReducer(state: AdvancedFilterTree, action: TreeAction): AdvancedFilterTree {
92
+ // replaceRoot bypasses cloning/walking — it overwrites the entire root.
93
+ if (action.type === 'replaceRoot') {
94
+ const next: AdvancedFilterTree = { root: action.root }
95
+ const v = validateTreeLimits(next)
96
+ return v.ok ? next : state
97
+ }
98
+
99
+ // Deep clone so React detects state change. The tree is JSON-safe so JSON
100
+ // round-tripping is sufficient; the Jest jsdom env doesn't expose
101
+ // `structuredClone`, so avoid relying on it.
102
+ const next: AdvancedFilterTree = JSON.parse(JSON.stringify(state))
103
+
104
+ if (action.type === 'removeLast') {
105
+ const found = findMaxAddedAtNode(next.root)
106
+ if (!found) return state
107
+ found.parent.children.splice(found.idx, 1)
108
+ const v = validateTreeLimits(next)
109
+ return v.ok ? next : state
110
+ }
111
+
112
+ let mutated = false
113
+
114
+ function apply(group: FilterGroup): boolean {
115
+ switch (action.type) {
116
+ case 'addRule':
117
+ if (group.id === action.groupId) {
118
+ group.children.push(emptyRule(action.defaultField, action.defaultOperator))
119
+ return true
120
+ }
121
+ break
122
+ case 'addGroup':
123
+ if (group.id === action.groupId) {
124
+ group.children.push(emptyGroup(action.defaultField, action.defaultOperator))
125
+ return true
126
+ }
127
+ break
128
+ case 'updateGroupCombinator':
129
+ if (group.id === action.groupId) {
130
+ group.combinator = action.combinator
131
+ return true
132
+ }
133
+ break
134
+ case 'removeNode': {
135
+ const idx = group.children.findIndex((c) => c.id === action.nodeId)
136
+ if (idx >= 0) {
137
+ group.children.splice(idx, 1)
138
+ return true
139
+ }
140
+ break
141
+ }
142
+ case 'updateRule':
143
+ for (const c of group.children) {
144
+ if (c.type === 'rule' && c.id === action.ruleId) {
145
+ Object.assign(c, action.updates)
146
+ return true
147
+ }
148
+ }
149
+ break
150
+ case 'reorderChildren':
151
+ if (group.id === action.groupId) {
152
+ const arr = group.children
153
+ if (
154
+ action.fromIdx >= 0 &&
155
+ action.fromIdx < arr.length &&
156
+ action.toIdx >= 0 &&
157
+ action.toIdx < arr.length &&
158
+ action.fromIdx !== action.toIdx
159
+ ) {
160
+ const [moved] = arr.splice(action.fromIdx, 1)
161
+ arr.splice(action.toIdx, 0, moved)
162
+ return true
163
+ }
164
+ }
165
+ break
166
+ }
167
+ for (const c of group.children) {
168
+ if (c.type === 'group' && apply(c)) return true
169
+ }
170
+ return false
171
+ }
172
+
173
+ mutated = apply(next.root)
174
+ if (!mutated) return state
175
+
176
+ const v = validateTreeLimits(next)
177
+ if (!v.ok) return state // reject by returning original (caller can show a tooltip)
178
+ return next
179
+ }
180
+
181
+ export function canAddRule(tree: AdvancedFilterTree, groupId: string): boolean {
182
+ const found = findGroupAndDepth(tree.root, groupId)
183
+ if (!found) return false
184
+ if (found.group.children.length >= TREE_LIMITS.maxChildrenPerGroup) return false
185
+ if (countRules(tree.root) >= TREE_LIMITS.maxTotalRules) return false
186
+ return true
187
+ }
188
+
189
+ export function canAddGroup(tree: AdvancedFilterTree, groupId: string): boolean {
190
+ const found = findGroupAndDepth(tree.root, groupId)
191
+ if (!found) return false
192
+ if (found.group.children.length >= TREE_LIMITS.maxChildrenPerGroup) return false
193
+ if (found.depth >= TREE_LIMITS.maxGroupLevel) return false
194
+ if (countRules(tree.root) >= TREE_LIMITS.maxTotalRules) return false
195
+ return true
196
+ }
197
+
198
+ export function getGroupDepth(tree: AdvancedFilterTree, groupId: string): number | null {
199
+ const found = findGroupAndDepth(tree.root, groupId)
200
+ return found?.depth ?? null
201
+ }