@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.
Files changed (62) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/backend/DataTable.js +149 -53
  3. package/dist/backend/DataTable.js.map +2 -2
  4. package/dist/backend/filters/ActiveFilterChips.js +142 -0
  5. package/dist/backend/filters/ActiveFilterChips.js.map +7 -0
  6. package/dist/backend/filters/AdvancedFilterBuilder.js +689 -163
  7. package/dist/backend/filters/AdvancedFilterBuilder.js.map +2 -2
  8. package/dist/backend/filters/AdvancedFilterPanel.js +354 -0
  9. package/dist/backend/filters/AdvancedFilterPanel.js.map +7 -0
  10. package/dist/backend/filters/FilterEmptyState.js +41 -0
  11. package/dist/backend/filters/FilterEmptyState.js.map +7 -0
  12. package/dist/backend/filters/FilterFieldPicker.js +148 -0
  13. package/dist/backend/filters/FilterFieldPicker.js.map +7 -0
  14. package/dist/backend/filters/FilteredEmptyResults.js +26 -0
  15. package/dist/backend/filters/FilteredEmptyResults.js.map +7 -0
  16. package/dist/backend/filters/QuickFilters.js +41 -0
  17. package/dist/backend/filters/QuickFilters.js.map +7 -0
  18. package/dist/backend/filters/filterValidation.js +47 -0
  19. package/dist/backend/filters/filterValidation.js.map +7 -0
  20. package/dist/backend/filters/treeReducer.js +159 -0
  21. package/dist/backend/filters/treeReducer.js.map +7 -0
  22. package/dist/backend/hooks/useAdvancedFilter.js +82 -1
  23. package/dist/backend/hooks/useAdvancedFilter.js.map +2 -2
  24. package/dist/backend/utils/api.js +18 -1
  25. package/dist/backend/utils/api.js.map +2 -2
  26. package/dist/backend/utils/useAutoDiscoveredFields.js +1 -0
  27. package/dist/backend/utils/useAutoDiscoveredFields.js.map +2 -2
  28. package/dist/backend/utils/useCurrentUserId.js +31 -0
  29. package/dist/backend/utils/useCurrentUserId.js.map +7 -0
  30. package/dist/primitives/dialog.js +7 -5
  31. package/dist/primitives/dialog.js.map +2 -2
  32. package/dist/primitives/tag.js +4 -2
  33. package/dist/primitives/tag.js.map +2 -2
  34. package/package.json +3 -3
  35. package/src/backend/DataTable.tsx +307 -63
  36. package/src/backend/__tests__/DataTable.extensions.test.tsx +170 -0
  37. package/src/backend/filters/ActiveFilterChips.tsx +179 -0
  38. package/src/backend/filters/AdvancedFilterBuilder.tsx +840 -237
  39. package/src/backend/filters/AdvancedFilterPanel.tsx +456 -0
  40. package/src/backend/filters/FilterEmptyState.tsx +51 -0
  41. package/src/backend/filters/FilterFieldPicker.tsx +139 -0
  42. package/src/backend/filters/FilteredEmptyResults.tsx +40 -0
  43. package/src/backend/filters/QuickFilters.tsx +62 -0
  44. package/src/backend/filters/__tests__/ActiveFilterChips.test.tsx +75 -0
  45. package/src/backend/filters/__tests__/AdvancedFilterBuilder.test.tsx +210 -0
  46. package/src/backend/filters/__tests__/AdvancedFilterPanel.test.tsx +212 -0
  47. package/src/backend/filters/__tests__/FilterEmptyState.test.tsx +31 -0
  48. package/src/backend/filters/__tests__/FilterFieldPicker.test.tsx +61 -0
  49. package/src/backend/filters/__tests__/FilteredEmptyResults.test.tsx +35 -0
  50. package/src/backend/filters/__tests__/QuickFilters.test.tsx +34 -0
  51. package/src/backend/filters/__tests__/filterValidation.test.ts +69 -0
  52. package/src/backend/filters/__tests__/treeReducer.test.ts +189 -0
  53. package/src/backend/filters/filterValidation.ts +61 -0
  54. package/src/backend/filters/treeReducer.ts +201 -0
  55. package/src/backend/hooks/__tests__/useAdvancedFilter.test.tsx +213 -0
  56. package/src/backend/hooks/useAdvancedFilter.ts +130 -0
  57. package/src/backend/utils/api.ts +27 -2
  58. package/src/backend/utils/useAutoDiscoveredFields.ts +2 -0
  59. package/src/backend/utils/useCurrentUserId.ts +40 -0
  60. package/src/primitives/__tests__/zindex-overlay.test.tsx +22 -0
  61. package/src/primitives/dialog.tsx +18 -7
  62. package/src/primitives/tag.tsx +3 -1
@@ -0,0 +1,62 @@
1
+ // packages/ui/src/backend/filters/QuickFilters.tsx
2
+ 'use client'
3
+ import * as React from 'react'
4
+ import { Clock, Filter, type LucideIcon } from 'lucide-react'
5
+ import { Button } from '../../primitives/button'
6
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
7
+ import type { AdvancedFilterTree } from '@open-mercato/shared/lib/query/advanced-filter-tree'
8
+
9
+ export type FilterPreset = {
10
+ id: string
11
+ labelKey: string
12
+ iconName?: string
13
+ requiresUser?: boolean
14
+ build: (ctx: { userId: string; now: Date }) => AdvancedFilterTree
15
+ }
16
+
17
+ const ICONS: Record<string, LucideIcon> = { clock: Clock, filter: Filter }
18
+
19
+ export type QuickFiltersProps = {
20
+ presets: FilterPreset[]
21
+ userId: string
22
+ onApply: (tree: AdvancedFilterTree, preset: FilterPreset) => void
23
+ }
24
+
25
+ export function QuickFilters({ presets, userId, onApply }: QuickFiltersProps) {
26
+ const t = useT()
27
+ const availablePresets = React.useMemo(
28
+ () => presets.filter((preset) => !preset.requiresUser || userId.trim().length > 0),
29
+ [presets, userId],
30
+ )
31
+ if (!availablePresets.length) return null
32
+ return (
33
+ <div className="flex flex-col gap-2">
34
+ <div className="text-overline font-medium uppercase tracking-widest text-muted-foreground">
35
+ {t('ui.advancedFilter.quickFilters.heading', 'Quick filters')}
36
+ </div>
37
+ <div className="flex flex-wrap gap-2">
38
+ {availablePresets.map((p) => {
39
+ const Icon = p.iconName ? ICONS[p.iconName] ?? Filter : Filter
40
+ return (
41
+ <Button
42
+ key={p.id}
43
+ type="button"
44
+ variant="outline"
45
+ size="sm"
46
+ className="rounded-full h-8 px-3 gap-1.5"
47
+ onClick={() => onApply(p.build({ userId, now: new Date() }), p)}
48
+ >
49
+ <Icon className="size-3.5 text-muted-foreground" />
50
+ {t(p.labelKey, p.labelKey)}
51
+ </Button>
52
+ )
53
+ })}
54
+ </div>
55
+ {/* Footer hint mirrors the Figma SPEC-048 empty state — explains that
56
+ presets apply immediately and remain editable. */}
57
+ <p className="text-xs text-muted-foreground">
58
+ {t('ui.advancedFilter.quickFilters.hint', 'Click a quick filter to apply it instantly. Editable after applying.')}
59
+ </p>
60
+ </div>
61
+ )
62
+ }
@@ -0,0 +1,75 @@
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 { ActiveFilterChips } from '../ActiveFilterChips'
7
+ import type { AdvancedFilterTree } from '@open-mercato/shared/lib/query/advanced-filter-tree'
8
+ import type { FilterFieldDef } from '@open-mercato/shared/lib/query/advanced-filter'
9
+
10
+ const fields: FilterFieldDef[] = [
11
+ { key: 'status', label: 'Status', type: 'select', options: [{ value: 'active', label: 'Active', tone: 'success' }] },
12
+ { key: 'owner', label: 'Owner', type: 'text' },
13
+ { key: 'tags', label: 'Tags', type: 'tags', options: [{ value: 't1', label: 'Hot' }, { value: 't2', label: 'Cold' }] },
14
+ ]
15
+
16
+ function tree(...children: any[]): AdvancedFilterTree {
17
+ return { root: { id: 'r', type: 'group', combinator: 'and', children } }
18
+ }
19
+
20
+ describe('ActiveFilterChips', () => {
21
+ it('renders nothing when root has no children', () => {
22
+ const { container } = render(<ActiveFilterChips tree={tree()} fields={fields} popoverOpen={false} onRemoveNode={() => {}} onOpen={() => {}} />)
23
+ expect(container).toBeEmptyDOMElement()
24
+ })
25
+
26
+ it('hides chips when popover is open', () => {
27
+ const t = tree({ id: 'a', type: 'rule', field: 'status', operator: 'is', value: 'active' })
28
+ const { container } = render(<ActiveFilterChips tree={t} fields={fields} popoverOpen={true} onRemoveNode={() => {}} onOpen={() => {}} />)
29
+ expect(container).toBeEmptyDOMElement()
30
+ })
31
+
32
+ it('renders one chip per top-level rule with field label and value', () => {
33
+ const t = tree({ id: 'a', type: 'rule', field: 'status', operator: 'is', value: 'active' })
34
+ render(<ActiveFilterChips tree={t} fields={fields} popoverOpen={false} onRemoveNode={() => {}} onOpen={() => {}} />)
35
+ expect(screen.getByText(/Status/)).toBeInTheDocument()
36
+ expect(screen.getByText(/Active/)).toBeInTheDocument()
37
+ })
38
+
39
+ it('renders multi-value rule with first value + N badge', () => {
40
+ const t = tree({ id: 'a', type: 'rule', field: 'tags', operator: 'has_any_of', value: ['t1', 't2'] })
41
+ render(<ActiveFilterChips tree={t} fields={fields} popoverOpen={false} onRemoveNode={() => {}} onOpen={() => {}} />)
42
+ expect(screen.getByText(/Hot/)).toBeInTheDocument()
43
+ expect(screen.getByText(/\+1/)).toBeInTheDocument()
44
+ })
45
+
46
+ it('collapses sub-group into one chip with first rule + remaining count', () => {
47
+ const t = tree({
48
+ id: 'g1',
49
+ type: 'group',
50
+ combinator: 'or',
51
+ children: [
52
+ { id: 'r1', type: 'rule', field: 'status', operator: 'is', value: 'active' },
53
+ { id: 'r2', type: 'rule', field: 'status', operator: 'is', value: 'lead' },
54
+ { id: 'r3', type: 'rule', field: 'status', operator: 'is', value: 'won' },
55
+ ],
56
+ })
57
+ render(<ActiveFilterChips tree={t} fields={fields} popoverOpen={false} onRemoveNode={() => {}} onOpen={() => {}} />)
58
+ expect(screen.getByText(/\+2/)).toBeInTheDocument()
59
+ })
60
+
61
+ it('calls onRemoveNode with the rule id when × is clicked', () => {
62
+ const t = tree({ id: 'a', type: 'rule', field: 'status', operator: 'is', value: 'active' })
63
+ const onRemoveNode = jest.fn()
64
+ render(<ActiveFilterChips tree={t} fields={fields} popoverOpen={false} onRemoveNode={onRemoveNode} onOpen={() => {}} />)
65
+ fireEvent.click(screen.getByLabelText(/remove/i))
66
+ expect(onRemoveNode).toHaveBeenCalledWith('a')
67
+ })
68
+
69
+ it('truncates long values at 24 chars', () => {
70
+ const longLabel = 'A'.repeat(40)
71
+ const t = tree({ id: 'a', type: 'rule', field: 'owner', operator: 'is', value: longLabel })
72
+ render(<ActiveFilterChips tree={t} fields={fields} popoverOpen={false} onRemoveNode={() => {}} onOpen={() => {}} />)
73
+ expect(screen.getByText(/A{1,24}…/)).toBeInTheDocument()
74
+ })
75
+ })
@@ -0,0 +1,210 @@
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 { AdvancedFilterBuilder } from '../AdvancedFilterBuilder'
7
+ import { createEmptyTree } from '@open-mercato/shared/lib/query/advanced-filter-tree'
8
+
9
+ const fields = [
10
+ { key: 'name', label: 'Name', type: 'text' as const, group: 'CRM' },
11
+ {
12
+ key: 'status',
13
+ label: 'Status',
14
+ type: 'select' as const,
15
+ group: 'CRM',
16
+ options: [{ value: 'a', label: 'Active', tone: 'success' as const }],
17
+ },
18
+ ]
19
+
20
+ describe('AdvancedFilterBuilder — rewrite', () => {
21
+ it('renders nothing inside the body when tree has no rules', () => {
22
+ const tree = createEmptyTree()
23
+ render(
24
+ <AdvancedFilterBuilder
25
+ fields={fields}
26
+ value={tree}
27
+ onChange={() => {}}
28
+ onApply={() => {}}
29
+ onClear={() => {}}
30
+ />,
31
+ )
32
+ // Empty tree → no group toolbar, no row labels, no connector words.
33
+ expect(screen.queryByTestId('advanced-filter-combinator-toggle')).not.toBeInTheDocument()
34
+ expect(screen.queryByText(/^where:?$/i)).not.toBeInTheDocument()
35
+ expect(screen.queryByText(/^and$/i)).not.toBeInTheDocument()
36
+ expect(screen.queryByText(/^or$/i)).not.toBeInTheDocument()
37
+ })
38
+
39
+ it('renders the Kendo-style And/Or toggle once the root group has rules — no Where prefix, no row connector words', () => {
40
+ const tree = {
41
+ root: {
42
+ id: 'r',
43
+ type: 'group' as const,
44
+ combinator: 'and' as const,
45
+ children: [
46
+ {
47
+ id: 'a',
48
+ type: 'rule' as const,
49
+ field: 'name',
50
+ operator: 'contains' as const,
51
+ value: 'X',
52
+ } as any,
53
+ {
54
+ id: 'b',
55
+ type: 'rule' as const,
56
+ field: 'name',
57
+ operator: 'contains' as const,
58
+ value: 'Y',
59
+ } as any,
60
+ ],
61
+ },
62
+ }
63
+ render(
64
+ <AdvancedFilterBuilder
65
+ fields={fields}
66
+ value={tree}
67
+ onChange={() => {}}
68
+ onApply={() => {}}
69
+ onClear={() => {}}
70
+ />,
71
+ )
72
+ const toggle = screen.getByTestId('advanced-filter-combinator-toggle')
73
+ expect(toggle).toBeInTheDocument()
74
+ expect(toggle).toHaveAttribute('data-combinator', 'and')
75
+ // Toggle exposes both choices as buttons with aria-pressed reflecting selection.
76
+ const andBtn = screen.getByRole('button', { name: /^And$/, pressed: true })
77
+ const orBtn = screen.getByRole('button', { name: /^Or$/, pressed: false })
78
+ expect(andBtn).toBeInTheDocument()
79
+ expect(orBtn).toBeInTheDocument()
80
+ // No "Where" prefix and no inline `and` / `or` row words anywhere.
81
+ expect(screen.queryByText(/^where:?$/i)).not.toBeInTheDocument()
82
+ expect(screen.queryByText(/^and$/)).not.toBeInTheDocument()
83
+ expect(screen.queryByText(/^or$/)).not.toBeInTheDocument()
84
+ })
85
+
86
+ it('clicking the unselected side of the toggle dispatches updateGroupCombinator', () => {
87
+ const onChange = jest.fn()
88
+ const tree = {
89
+ root: {
90
+ id: 'r',
91
+ type: 'group' as const,
92
+ combinator: 'and' as const,
93
+ children: [
94
+ {
95
+ id: 'a',
96
+ type: 'rule' as const,
97
+ field: 'name',
98
+ operator: 'contains' as const,
99
+ value: 'X',
100
+ } as any,
101
+ ],
102
+ },
103
+ }
104
+ render(
105
+ <AdvancedFilterBuilder
106
+ fields={fields}
107
+ value={tree}
108
+ onChange={onChange}
109
+ onApply={() => {}}
110
+ onClear={() => {}}
111
+ />,
112
+ )
113
+ fireEvent.click(screen.getByRole('button', { name: /^Or$/, pressed: false }))
114
+ expect(onChange).toHaveBeenCalledTimes(1)
115
+ const next = onChange.mock.calls[0][0]
116
+ expect(next.root.combinator).toBe('or')
117
+ })
118
+
119
+ it('renders a nested toolbar for a sub-group with combinator=or', () => {
120
+ const tree = {
121
+ root: {
122
+ id: 'r',
123
+ type: 'group' as const,
124
+ combinator: 'and' as const,
125
+ children: [
126
+ {
127
+ id: 'g',
128
+ type: 'group' as const,
129
+ combinator: 'or' as const,
130
+ children: [
131
+ {
132
+ id: 'a',
133
+ type: 'rule' as const,
134
+ field: 'name',
135
+ operator: 'contains' as const,
136
+ value: 'X',
137
+ } as any,
138
+ ],
139
+ } as any,
140
+ ],
141
+ },
142
+ }
143
+ render(
144
+ <AdvancedFilterBuilder
145
+ fields={fields}
146
+ value={tree}
147
+ onChange={() => {}}
148
+ onApply={() => {}}
149
+ onClear={() => {}}
150
+ />,
151
+ )
152
+ const toggles = screen.getAllByTestId('advanced-filter-combinator-toggle')
153
+ expect(toggles).toHaveLength(2)
154
+ expect(toggles.map((el) => el.getAttribute('data-combinator')).sort()).toEqual(['and', 'or'])
155
+ })
156
+
157
+ it('Cmd+Enter calls onApply', () => {
158
+ const onApply = jest.fn()
159
+ const tree = createEmptyTree()
160
+ render(
161
+ <AdvancedFilterBuilder
162
+ fields={fields}
163
+ value={tree}
164
+ onChange={() => {}}
165
+ onApply={onApply}
166
+ onClear={() => {}}
167
+ />,
168
+ )
169
+ fireEvent.keyDown(window, { key: 'Enter', metaKey: true })
170
+ expect(onApply).toHaveBeenCalled()
171
+ })
172
+
173
+ it('renders red ring + translated error message when pendingErrors include the rule', () => {
174
+ const tree = {
175
+ root: {
176
+ id: 'r',
177
+ type: 'group' as const,
178
+ combinator: 'and' as const,
179
+ children: [
180
+ {
181
+ id: 'a',
182
+ type: 'rule' as const,
183
+ field: 'name',
184
+ operator: 'contains' as const,
185
+ value: '',
186
+ } as any,
187
+ ],
188
+ },
189
+ }
190
+ const errors = [
191
+ {
192
+ ruleId: 'a',
193
+ messageKey: 'ui.advancedFilter.error.missingValue',
194
+ message: 'Pick a value',
195
+ },
196
+ ]
197
+ const { container } = render(
198
+ <AdvancedFilterBuilder
199
+ fields={fields}
200
+ value={tree}
201
+ onChange={() => {}}
202
+ onApply={() => {}}
203
+ onClear={() => {}}
204
+ pendingErrors={errors}
205
+ />,
206
+ )
207
+ expect(container.querySelector('.border-status-error-border')).not.toBeNull()
208
+ expect(container.textContent).toContain('Pick a value')
209
+ })
210
+ })
@@ -0,0 +1,212 @@
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 { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
9
+ import { AdvancedFilterPanel } from '../AdvancedFilterPanel'
10
+ import { createEmptyTree } from '@open-mercato/shared/lib/query/advanced-filter-tree'
11
+ import type { FilterFieldDef } from '@open-mercato/shared/lib/query/advanced-filter'
12
+
13
+ const fields: FilterFieldDef[] = [{ key: 'name', label: 'Name', type: 'text', group: 'CRM' }]
14
+ const flashMock = jest.fn()
15
+
16
+ jest.mock('../../FlashMessages', () => ({
17
+ flash: (...args: unknown[]) => flashMock(...args),
18
+ }))
19
+
20
+ describe('AdvancedFilterPanel', () => {
21
+ beforeEach(() => {
22
+ window.localStorage.clear()
23
+ flashMock.mockReset()
24
+ })
25
+
26
+ it('renders the empty state when tree has no rules and popover is open', () => {
27
+ render(
28
+ <AdvancedFilterPanel
29
+ fields={fields}
30
+ value={createEmptyTree()}
31
+ onChange={() => {}}
32
+ onApply={() => {}}
33
+ onClear={() => {}}
34
+ pendingErrors={[]}
35
+ userId="u1"
36
+ presets={[]}
37
+ open
38
+ onOpenChange={() => {}}
39
+ triggerRef={{ current: null }}
40
+ />,
41
+ )
42
+ expect(screen.getByText(/no filters applied/i)).toBeInTheDocument()
43
+ })
44
+
45
+ it('shows validation banner when pendingErrors is non-empty', () => {
46
+ const tree = { root: { id: 'r', type: 'group' as const, combinator: 'and' as const, children: [
47
+ { id: 'a', type: 'rule' as const, field: 'name', operator: 'contains' as const, value: '' } as any,
48
+ ] } }
49
+ render(
50
+ <AdvancedFilterPanel
51
+ fields={fields}
52
+ value={tree}
53
+ onChange={() => {}}
54
+ onApply={() => {}}
55
+ onClear={() => {}}
56
+ pendingErrors={[{ ruleId: 'a', messageKey: 'ui.advancedFilter.error.missingValue', message: 'Pick a value' }]}
57
+ userId="u1"
58
+ presets={[]}
59
+ open
60
+ onOpenChange={() => {}}
61
+ triggerRef={{ current: null }}
62
+ />,
63
+ )
64
+ expect(screen.getByText(/1 filter is incomplete/i)).toBeInTheDocument()
65
+ })
66
+
67
+ it('renders quick filters in empty state when presets provided', () => {
68
+ const presets = [{ id: 'p1', labelKey: 'p1', build: () => createEmptyTree() }]
69
+ render(
70
+ <AdvancedFilterPanel
71
+ fields={fields}
72
+ value={createEmptyTree()}
73
+ onChange={() => {}}
74
+ onApply={() => {}}
75
+ onClear={() => {}}
76
+ pendingErrors={[]}
77
+ userId="u1"
78
+ presets={presets}
79
+ open
80
+ onOpenChange={() => {}}
81
+ triggerRef={{ current: null }}
82
+ />,
83
+ )
84
+ expect(screen.getByText(/quick filters/i)).toBeInTheDocument()
85
+ })
86
+
87
+ it('does not render the empty state when tree has rules', () => {
88
+ const tree = { root: { id: 'r', type: 'group' as const, combinator: 'and' as const, children: [
89
+ { id: 'a', type: 'rule' as const, field: 'name', operator: 'contains' as const, value: 'X' } as any,
90
+ ] } }
91
+ render(
92
+ <AdvancedFilterPanel
93
+ fields={fields}
94
+ value={tree}
95
+ onChange={() => {}}
96
+ onApply={() => {}}
97
+ onClear={() => {}}
98
+ pendingErrors={[]}
99
+ userId="u1"
100
+ presets={[]}
101
+ open
102
+ onOpenChange={() => {}}
103
+ triggerRef={{ current: null }}
104
+ />,
105
+ )
106
+ expect(screen.queryByText(/no filters applied/i)).not.toBeInTheDocument()
107
+ })
108
+
109
+ it('saves filters separately from perspectives', async () => {
110
+ const tree = { root: { id: 'r', type: 'group' as const, combinator: 'and' as const, children: [
111
+ { id: 'a', type: 'rule' as const, field: 'name', operator: 'contains' as const, value: 'Alice' } as any,
112
+ ] } }
113
+ const originalFetch = globalThis.fetch
114
+ const fetchMock = jest.fn()
115
+ ;(globalThis as typeof globalThis & { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch
116
+
117
+ try {
118
+ render(
119
+ <AdvancedFilterPanel
120
+ fields={fields}
121
+ value={tree}
122
+ onChange={() => {}}
123
+ onApply={() => {}}
124
+ onClear={() => {}}
125
+ pendingErrors={[]}
126
+ userId="u1"
127
+ presets={[]}
128
+ open
129
+ onOpenChange={() => {}}
130
+ triggerRef={{ current: null }}
131
+ savedFilterStorageKey="customers.people.list"
132
+ />,
133
+ )
134
+
135
+ fireEvent.click(screen.getByRole('button', { name: 'Save filter' }))
136
+ fireEvent.change(screen.getByLabelText('Filter name'), { target: { value: 'Owned leads' } })
137
+ const dialog = screen.getByRole('dialog', { name: 'Save filter' })
138
+ fireEvent.click(within(dialog).getByRole('button', { name: 'Save filter' }))
139
+
140
+ await waitFor(() => expect(flashMock).toHaveBeenCalledWith('Filter saved', 'success'))
141
+ expect(fetchMock).not.toHaveBeenCalled()
142
+ const raw = window.localStorage.getItem('open-mercato:advanced-filters:customers.people.list')
143
+ expect(raw).toBeTruthy()
144
+ const saved = JSON.parse(String(raw))
145
+ expect(saved).toEqual({
146
+ v: 1,
147
+ filters: [
148
+ expect.objectContaining({
149
+ name: 'Owned leads',
150
+ tree: expect.objectContaining({ v: 2 }),
151
+ }),
152
+ ],
153
+ })
154
+ } finally {
155
+ if (originalFetch) {
156
+ globalThis.fetch = originalFetch
157
+ } else {
158
+ delete (globalThis as typeof globalThis & { fetch?: typeof fetch }).fetch
159
+ }
160
+ }
161
+ })
162
+
163
+ it('applies a saved filter from the filter panel', async () => {
164
+ window.localStorage.setItem('open-mercato:advanced-filters:customers.people.list', JSON.stringify([
165
+ {
166
+ id: 'saved-1',
167
+ name: 'Owned leads',
168
+ tree: {
169
+ v: 2,
170
+ root: {
171
+ id: 'saved-root',
172
+ type: 'group',
173
+ combinator: 'and',
174
+ children: [
175
+ { id: 'saved-rule', type: 'rule', field: 'name', operator: 'contains', value: 'Alice' },
176
+ ],
177
+ },
178
+ },
179
+ createdAt: '2026-05-10T00:00:00.000Z',
180
+ updatedAt: '2026-05-10T00:00:00.000Z',
181
+ },
182
+ ]))
183
+ const onChange = jest.fn()
184
+ const onOpenChange = jest.fn()
185
+
186
+ render(
187
+ <AdvancedFilterPanel
188
+ fields={fields}
189
+ value={createEmptyTree()}
190
+ onChange={onChange}
191
+ onApply={() => {}}
192
+ onClear={() => {}}
193
+ pendingErrors={[]}
194
+ userId="u1"
195
+ presets={[]}
196
+ open
197
+ onOpenChange={onOpenChange}
198
+ triggerRef={{ current: null }}
199
+ savedFilterStorageKey="customers.people.list"
200
+ />,
201
+ )
202
+
203
+ fireEvent.click(await screen.findByRole('button', { name: 'Owned leads' }))
204
+
205
+ expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
206
+ root: expect.objectContaining({
207
+ children: [expect.objectContaining({ field: 'name', value: 'Alice' })],
208
+ }),
209
+ }))
210
+ expect(onOpenChange).toHaveBeenCalledWith(false)
211
+ })
212
+ })
@@ -0,0 +1,31 @@
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 { FilterEmptyState } from '../FilterEmptyState'
7
+
8
+ describe('FilterEmptyState', () => {
9
+ it('renders title, subtitle, and primary "Add condition" button', () => {
10
+ render(<FilterEmptyState onAddCondition={() => {}} addConditionRef={{ current: null }} />)
11
+ expect(screen.getByText(/no filters applied/i)).toBeInTheDocument()
12
+ expect(screen.getByRole('button', { name: /add condition/i })).toBeInTheDocument()
13
+ })
14
+
15
+ it('calls onAddCondition on click', () => {
16
+ const onAdd = jest.fn()
17
+ render(<FilterEmptyState onAddCondition={onAdd} addConditionRef={{ current: null }} />)
18
+ fireEvent.click(screen.getByRole('button', { name: /add condition/i }))
19
+ expect(onAdd).toHaveBeenCalledTimes(1)
20
+ })
21
+
22
+ it('renders quickFilters slot when provided', () => {
23
+ render(<FilterEmptyState onAddCondition={() => {}} addConditionRef={{ current: null }} quickFilters={<div data-testid="qf">qf</div>} />)
24
+ expect(screen.getByTestId('qf')).toBeInTheDocument()
25
+ })
26
+
27
+ it('renders aiSlot when provided', () => {
28
+ render(<FilterEmptyState onAddCondition={() => {}} addConditionRef={{ current: null }} aiSlot={<div data-testid="ai">try</div>} />)
29
+ expect(screen.getByTestId('ai')).toBeInTheDocument()
30
+ })
31
+ })
@@ -0,0 +1,61 @@
1
+ // packages/ui/src/backend/filters/__tests__/FilterFieldPicker.test.tsx
2
+ jest.mock('@open-mercato/shared/lib/i18n/context', () => ({
3
+ useT: () => (_key: string, fallback: string) => fallback,
4
+ }))
5
+
6
+ import { render, screen, fireEvent } from '@testing-library/react'
7
+ import { FilterFieldPicker } from '../FilterFieldPicker'
8
+ import type { FilterFieldDef } from '@open-mercato/shared/lib/query/advanced-filter'
9
+
10
+ const fields: FilterFieldDef[] = [
11
+ { key: 'owner', label: 'Owner', type: 'text', group: 'CRM', iconName: 'user-round' },
12
+ { key: 'status', label: 'Status', type: 'select', group: 'CRM' },
13
+ { key: 'created_at', label: 'Created date', type: 'date', group: 'Activity' },
14
+ { key: 'email', label: 'Email', type: 'text', group: 'Contact', iconName: 'mail' },
15
+ ]
16
+
17
+ describe('FilterFieldPicker', () => {
18
+ it('renders fields grouped by group label', () => {
19
+ render(<FilterFieldPicker fields={fields} open onSelect={() => {}} onOpenChange={() => {}} triggerRef={{ current: null }} />)
20
+ expect(screen.getByText('CRM')).toBeInTheDocument()
21
+ expect(screen.getByText('Activity')).toBeInTheDocument()
22
+ expect(screen.getByText('Contact')).toBeInTheDocument()
23
+ })
24
+
25
+ it('filters by search input substring (case-insensitive)', () => {
26
+ render(<FilterFieldPicker fields={fields} open onSelect={() => {}} onOpenChange={() => {}} triggerRef={{ current: null }} />)
27
+ fireEvent.change(screen.getByPlaceholderText(/search field/i), { target: { value: 'em' } })
28
+ expect(screen.getByText('Email')).toBeInTheDocument()
29
+ expect(screen.queryByText('Owner')).not.toBeInTheDocument()
30
+ })
31
+
32
+ it('selects a field on Enter via keyboard', () => {
33
+ const onSelect = jest.fn()
34
+ render(<FilterFieldPicker fields={fields} open onSelect={onSelect} onOpenChange={() => {}} triggerRef={{ current: null }} />)
35
+ fireEvent.change(screen.getByPlaceholderText(/search field/i), { target: { value: 'owner' } })
36
+ fireEvent.keyDown(window, { key: 'ArrowDown' })
37
+ fireEvent.keyDown(window, { key: 'Enter' })
38
+ expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ key: 'owner' }))
39
+ })
40
+
41
+ it('selects a field on click', () => {
42
+ const onSelect = jest.fn()
43
+ render(<FilterFieldPicker fields={fields} open onSelect={onSelect} onOpenChange={() => {}} triggerRef={{ current: null }} />)
44
+ fireEvent.click(screen.getByText('Status'))
45
+ expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ key: 'status' }))
46
+ })
47
+
48
+ it('closes on Escape', () => {
49
+ const onOpenChange = jest.fn()
50
+ render(<FilterFieldPicker fields={fields} open onSelect={() => {}} onOpenChange={onOpenChange} triggerRef={{ current: null }} />)
51
+ fireEvent.keyDown(window, { key: 'Escape' })
52
+ expect(onOpenChange).toHaveBeenCalledWith(false)
53
+ })
54
+
55
+ it('renders explicit iconName when provided', () => {
56
+ render(<FilterFieldPicker fields={fields} open onSelect={() => {}} onOpenChange={() => {}} triggerRef={{ current: null }} />)
57
+ // Email has iconName='mail'; row contains a Mail icon (lucide-react renders an svg with class)
58
+ const emailRow = screen.getByText('Email').closest('button, li, [role="option"]')!
59
+ expect(emailRow.querySelector('svg')).not.toBeNull()
60
+ })
61
+ })