@open-mercato/ui 0.6.1-develop.3260.1.dc719cac84 → 0.6.1-develop.3278.1.a4d26475e4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/backend/DataTable.js +149 -53
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/filters/ActiveFilterChips.js +142 -0
- package/dist/backend/filters/ActiveFilterChips.js.map +7 -0
- package/dist/backend/filters/AdvancedFilterBuilder.js +689 -163
- package/dist/backend/filters/AdvancedFilterBuilder.js.map +2 -2
- package/dist/backend/filters/AdvancedFilterPanel.js +354 -0
- package/dist/backend/filters/AdvancedFilterPanel.js.map +7 -0
- package/dist/backend/filters/FilterEmptyState.js +41 -0
- package/dist/backend/filters/FilterEmptyState.js.map +7 -0
- package/dist/backend/filters/FilterFieldPicker.js +148 -0
- package/dist/backend/filters/FilterFieldPicker.js.map +7 -0
- package/dist/backend/filters/FilteredEmptyResults.js +26 -0
- package/dist/backend/filters/FilteredEmptyResults.js.map +7 -0
- package/dist/backend/filters/QuickFilters.js +41 -0
- package/dist/backend/filters/QuickFilters.js.map +7 -0
- package/dist/backend/filters/filterValidation.js +47 -0
- package/dist/backend/filters/filterValidation.js.map +7 -0
- package/dist/backend/filters/treeReducer.js +159 -0
- package/dist/backend/filters/treeReducer.js.map +7 -0
- package/dist/backend/hooks/useAdvancedFilter.js +82 -1
- package/dist/backend/hooks/useAdvancedFilter.js.map +2 -2
- package/dist/backend/utils/api.js +18 -1
- package/dist/backend/utils/api.js.map +2 -2
- package/dist/backend/utils/useAutoDiscoveredFields.js +1 -0
- package/dist/backend/utils/useAutoDiscoveredFields.js.map +2 -2
- package/dist/backend/utils/useCurrentUserId.js +31 -0
- package/dist/backend/utils/useCurrentUserId.js.map +7 -0
- package/dist/primitives/dialog.js +7 -5
- package/dist/primitives/dialog.js.map +2 -2
- package/dist/primitives/tag.js +4 -2
- package/dist/primitives/tag.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/DataTable.tsx +307 -63
- package/src/backend/__tests__/DataTable.extensions.test.tsx +170 -0
- package/src/backend/filters/ActiveFilterChips.tsx +179 -0
- package/src/backend/filters/AdvancedFilterBuilder.tsx +840 -237
- package/src/backend/filters/AdvancedFilterPanel.tsx +456 -0
- package/src/backend/filters/FilterEmptyState.tsx +51 -0
- package/src/backend/filters/FilterFieldPicker.tsx +139 -0
- package/src/backend/filters/FilteredEmptyResults.tsx +40 -0
- package/src/backend/filters/QuickFilters.tsx +62 -0
- package/src/backend/filters/__tests__/ActiveFilterChips.test.tsx +75 -0
- package/src/backend/filters/__tests__/AdvancedFilterBuilder.test.tsx +210 -0
- package/src/backend/filters/__tests__/AdvancedFilterPanel.test.tsx +212 -0
- package/src/backend/filters/__tests__/FilterEmptyState.test.tsx +31 -0
- package/src/backend/filters/__tests__/FilterFieldPicker.test.tsx +61 -0
- package/src/backend/filters/__tests__/FilteredEmptyResults.test.tsx +35 -0
- package/src/backend/filters/__tests__/QuickFilters.test.tsx +34 -0
- package/src/backend/filters/__tests__/filterValidation.test.ts +69 -0
- package/src/backend/filters/__tests__/treeReducer.test.ts +189 -0
- package/src/backend/filters/filterValidation.ts +61 -0
- package/src/backend/filters/treeReducer.ts +201 -0
- package/src/backend/hooks/__tests__/useAdvancedFilter.test.tsx +213 -0
- package/src/backend/hooks/useAdvancedFilter.ts +130 -0
- package/src/backend/utils/api.ts +27 -2
- package/src/backend/utils/useAutoDiscoveredFields.ts +2 -0
- package/src/backend/utils/useCurrentUserId.ts +40 -0
- package/src/primitives/__tests__/zindex-overlay.test.tsx +22 -0
- package/src/primitives/dialog.tsx +18 -7
- package/src/primitives/tag.tsx +3 -1
|
@@ -0,0 +1,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
|
+
}
|