@open-mercato/ui 0.6.1-develop.3276.1.1fcd145078 → 0.6.1-develop.3287.1.450f4ffb56
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +23 -14
- package/dist/backend/AppShell.js +8 -32
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/CrudForm.js +18 -0
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/DataTable.js +149 -53
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/FilterBar.js +11 -14
- package/dist/backend/FilterBar.js.map +2 -2
- package/dist/backend/columns/ColumnChooserPanel.js +4 -6
- package/dist/backend/columns/ColumnChooserPanel.js.map +2 -2
- package/dist/backend/filters/ActiveFilterChips.js +142 -0
- package/dist/backend/filters/ActiveFilterChips.js.map +7 -0
- package/dist/backend/filters/AdvancedFilterBuilder.js +689 -163
- package/dist/backend/filters/AdvancedFilterBuilder.js.map +2 -2
- package/dist/backend/filters/AdvancedFilterPanel.js +354 -0
- package/dist/backend/filters/AdvancedFilterPanel.js.map +7 -0
- package/dist/backend/filters/FilterEmptyState.js +41 -0
- package/dist/backend/filters/FilterEmptyState.js.map +7 -0
- package/dist/backend/filters/FilterFieldPicker.js +148 -0
- package/dist/backend/filters/FilterFieldPicker.js.map +7 -0
- package/dist/backend/filters/FilteredEmptyResults.js +26 -0
- package/dist/backend/filters/FilteredEmptyResults.js.map +7 -0
- package/dist/backend/filters/QuickFilters.js +41 -0
- package/dist/backend/filters/QuickFilters.js.map +7 -0
- package/dist/backend/filters/filterValidation.js +47 -0
- package/dist/backend/filters/filterValidation.js.map +7 -0
- package/dist/backend/filters/treeReducer.js +159 -0
- package/dist/backend/filters/treeReducer.js.map +7 -0
- package/dist/backend/hooks/useAdvancedFilter.js +82 -1
- package/dist/backend/hooks/useAdvancedFilter.js.map +2 -2
- package/dist/backend/inputs/ComboboxInput.js +11 -7
- package/dist/backend/inputs/ComboboxInput.js.map +2 -2
- package/dist/backend/inputs/EventPatternInput.js +5 -2
- package/dist/backend/inputs/EventPatternInput.js.map +2 -2
- package/dist/backend/inputs/EventSelect.js +10 -4
- package/dist/backend/inputs/EventSelect.js.map +2 -2
- package/dist/backend/inputs/LookupSelect.js +51 -47
- package/dist/backend/inputs/LookupSelect.js.map +2 -2
- package/dist/backend/inputs/PhoneNumberField.js +196 -45
- package/dist/backend/inputs/PhoneNumberField.js.map +2 -2
- package/dist/backend/inputs/SwitchableMarkdownInput.js.map +2 -2
- package/dist/backend/messages/message-compose-form-groups.js +2 -2
- package/dist/backend/messages/message-compose-form-groups.js.map +2 -2
- package/dist/backend/utils/api.js +18 -1
- package/dist/backend/utils/api.js.map +2 -2
- package/dist/backend/utils/serverErrors.js +6 -2
- package/dist/backend/utils/serverErrors.js.map +2 -2
- package/dist/backend/utils/useAutoDiscoveredFields.js +1 -0
- package/dist/backend/utils/useAutoDiscoveredFields.js.map +2 -2
- package/dist/backend/utils/useCurrentUserId.js +31 -0
- package/dist/backend/utils/useCurrentUserId.js.map +7 -0
- package/dist/portal/utils/nav.js +17 -0
- package/dist/portal/utils/nav.js.map +2 -2
- package/dist/primitives/amount-input.js +128 -0
- package/dist/primitives/amount-input.js.map +7 -0
- package/dist/primitives/button-input.js +47 -0
- package/dist/primitives/button-input.js.map +7 -0
- package/dist/primitives/card-input.js +133 -0
- package/dist/primitives/card-input.js.map +7 -0
- package/dist/primitives/dialog.js +7 -5
- package/dist/primitives/dialog.js.map +2 -2
- package/dist/primitives/email-input.js +29 -0
- package/dist/primitives/email-input.js.map +7 -0
- package/dist/primitives/password-input.js +64 -0
- package/dist/primitives/password-input.js.map +7 -0
- package/dist/primitives/search-input.js +57 -0
- package/dist/primitives/search-input.js.map +7 -0
- package/dist/primitives/select.js +43 -5
- package/dist/primitives/select.js.map +2 -2
- package/dist/primitives/tag.js +4 -2
- package/dist/primitives/tag.js.map +2 -2
- package/dist/primitives/website-input.js +56 -0
- package/dist/primitives/website-input.js.map +7 -0
- package/package.json +3 -3
- package/src/backend/AppShell.tsx +7 -30
- package/src/backend/CrudForm.tsx +17 -0
- package/src/backend/DataTable.tsx +307 -63
- package/src/backend/FilterBar.tsx +5 -6
- package/src/backend/__tests__/DataTable.extensions.test.tsx +170 -0
- package/src/backend/__tests__/serverErrors.test.ts +79 -1
- package/src/backend/columns/ColumnChooserPanel.tsx +4 -5
- package/src/backend/filters/ActiveFilterChips.tsx +179 -0
- package/src/backend/filters/AdvancedFilterBuilder.tsx +840 -237
- package/src/backend/filters/AdvancedFilterPanel.tsx +456 -0
- package/src/backend/filters/FilterEmptyState.tsx +51 -0
- package/src/backend/filters/FilterFieldPicker.tsx +139 -0
- package/src/backend/filters/FilteredEmptyResults.tsx +40 -0
- package/src/backend/filters/QuickFilters.tsx +62 -0
- package/src/backend/filters/__tests__/ActiveFilterChips.test.tsx +75 -0
- package/src/backend/filters/__tests__/AdvancedFilterBuilder.test.tsx +210 -0
- package/src/backend/filters/__tests__/AdvancedFilterPanel.test.tsx +212 -0
- package/src/backend/filters/__tests__/FilterEmptyState.test.tsx +31 -0
- package/src/backend/filters/__tests__/FilterFieldPicker.test.tsx +61 -0
- package/src/backend/filters/__tests__/FilteredEmptyResults.test.tsx +35 -0
- package/src/backend/filters/__tests__/QuickFilters.test.tsx +34 -0
- package/src/backend/filters/__tests__/filterValidation.test.ts +69 -0
- package/src/backend/filters/__tests__/treeReducer.test.ts +189 -0
- package/src/backend/filters/filterValidation.ts +61 -0
- package/src/backend/filters/treeReducer.ts +201 -0
- package/src/backend/hooks/__tests__/useAdvancedFilter.test.tsx +213 -0
- package/src/backend/hooks/useAdvancedFilter.ts +130 -0
- package/src/backend/inputs/ComboboxInput.tsx +32 -26
- package/src/backend/inputs/EventPatternInput.tsx +11 -2
- package/src/backend/inputs/EventSelect.tsx +13 -4
- package/src/backend/inputs/LookupSelect.tsx +75 -53
- package/src/backend/inputs/PhoneNumberField.tsx +237 -47
- package/src/backend/inputs/SwitchableMarkdownInput.tsx +30 -0
- package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +4 -0
- package/src/backend/inputs/__tests__/PhoneNumberField.test.tsx +4 -0
- package/src/backend/messages/message-compose-form-groups.tsx +2 -2
- package/src/backend/utils/api.ts +27 -2
- package/src/backend/utils/serverErrors.ts +15 -8
- package/src/backend/utils/useAutoDiscoveredFields.ts +2 -0
- package/src/backend/utils/useCurrentUserId.ts +40 -0
- package/src/portal/utils/__tests__/nav.test.ts +80 -0
- package/src/portal/utils/nav.ts +23 -1
- package/src/primitives/__tests__/password-input.test.tsx +93 -0
- package/src/primitives/__tests__/search-input.test.tsx +95 -0
- package/src/primitives/__tests__/zindex-overlay.test.tsx +26 -0
- package/src/primitives/amount-input.tsx +184 -0
- package/src/primitives/button-input.tsx +68 -0
- package/src/primitives/card-input.tsx +193 -0
- package/src/primitives/dialog.tsx +18 -7
- package/src/primitives/email-input.tsx +36 -0
- package/src/primitives/password-input.tsx +100 -0
- package/src/primitives/search-input.tsx +87 -0
- package/src/primitives/select.tsx +98 -11
- package/src/primitives/tag.tsx +3 -1
- package/src/primitives/website-input.tsx +72 -0
|
@@ -0,0 +1,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
|
+
})
|