@startsimpli/ui 0.4.7 → 0.4.9
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/package.json +21 -23
- package/src/__mocks__/next/link.js +11 -0
- package/src/components/account/__tests__/account.test.tsx +315 -0
- package/src/components/command-palette/CommandGroup.tsx +23 -0
- package/src/components/command-palette/CommandPalette.tsx +183 -200
- package/src/components/command-palette/CommandResultItem.tsx +59 -0
- package/src/components/command-palette/__tests__/CommandGroup.test.tsx +81 -0
- package/src/components/command-palette/__tests__/CommandResultItem.test.tsx +166 -0
- package/src/components/command-palette/__tests__/command-palette-context.test.tsx +166 -0
- package/src/components/command-palette/__tests__/useCommandPaletteSearch.test.ts +271 -0
- package/src/components/command-palette/index.ts +6 -0
- package/src/components/command-palette/useCommandPaletteSearch.ts +114 -0
- package/src/components/compose/__tests__/compose.test.tsx +656 -0
- package/src/components/dashboard/PipelineFunnel.tsx +126 -0
- package/src/components/dashboard/TopCampaigns.tsx +132 -0
- package/src/components/dashboard/__tests__/dashboard.test.tsx +785 -0
- package/src/components/dashboard/index.ts +6 -0
- package/src/components/dialog/ConfirmDialog.tsx +72 -0
- package/src/components/dialog/__tests__/ConfirmDialog.test.tsx +126 -0
- package/src/components/dialog/index.ts +3 -0
- package/src/components/email-dialogs/__tests__/email-dialogs.test.tsx +982 -0
- package/src/components/email-editor/BlockRenderer.tsx +120 -0
- package/src/components/email-editor/__tests__/BlockRenderer.test.tsx +332 -0
- package/src/components/email-editor/__tests__/block-renderers.test.ts +624 -0
- package/src/components/email-editor/__tests__/email-html-renderer.test.ts +376 -0
- package/src/components/email-editor/blocks/__tests__/blocks.test.tsx +818 -0
- package/src/components/email-editor/editor-sidebar.tsx +6 -731
- package/src/components/email-editor/email-editor.tsx +78 -467
- package/src/components/email-editor/hooks/__tests__/useDragDrop.test.ts +355 -0
- package/src/components/email-editor/hooks/__tests__/useEmailEditorState.test.ts +551 -0
- package/src/components/email-editor/hooks/useDragDrop.ts +181 -0
- package/src/components/email-editor/hooks/useEmailEditorState.ts +426 -0
- package/src/components/email-editor/index.ts +1 -0
- package/src/components/email-editor/panels/BlockPropertyPanel.tsx +637 -0
- package/src/components/email-editor/panels/GlobalStylesPanel.tsx +108 -0
- package/src/components/email-editor/panels/SectionSettingsPanel.tsx +80 -0
- package/src/components/email-editor/panels/__tests__/BlockPropertyPanel.test.tsx +707 -0
- package/src/components/email-editor/panels/__tests__/GlobalStylesPanel.test.tsx +226 -0
- package/src/components/email-editor/panels/index.ts +3 -0
- package/src/components/enrichment/__tests__/enrichment.test.tsx +184 -0
- package/src/components/gantt/GanttBoardView.tsx +71 -0
- package/src/components/gantt/GanttChart.tsx +134 -881
- package/src/components/gantt/GanttFilterBar.tsx +100 -0
- package/src/components/gantt/GanttListView.tsx +63 -0
- package/src/components/gantt/GanttTimelineView.tsx +215 -0
- package/src/components/gantt/__tests__/GanttBoardView.test.tsx +305 -0
- package/src/components/gantt/__tests__/GanttFilterBar.test.tsx +544 -0
- package/src/components/gantt/__tests__/GanttListView.test.tsx +337 -0
- package/src/components/gantt/__tests__/GanttTimelineView.test.tsx +375 -0
- package/src/components/gantt/__tests__/gantt-utils.test.ts +341 -0
- package/src/components/gantt/__tests__/useGanttState.test.ts +535 -0
- package/src/components/gantt/hooks/useGanttState.ts +644 -0
- package/src/components/gantt/index.ts +10 -0
- package/src/components/integrations/__tests__/integrations.test.tsx +191 -0
- package/src/components/kanban/__tests__/kanban.test.tsx +157 -0
- package/src/components/lists/__tests__/lists.test.tsx +263 -0
- package/src/components/loading/__tests__/loading.test.tsx +114 -0
- package/src/components/navigation/__tests__/navigation.test.tsx +194 -0
- package/src/components/pipeline/__tests__/pipeline.test.tsx +169 -0
- package/src/components/safe-html.tsx +9 -8
- package/src/components/settings/__tests__/settings.test.tsx +181 -0
- package/src/components/wizard/__tests__/wizard.test.tsx +97 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
2
|
+
import { CommandResultItem } from '../CommandResultItem'
|
|
3
|
+
|
|
4
|
+
// lucide-react icons are SVGs — mock them as simple elements so tests are
|
|
5
|
+
// not sensitive to SVG implementation details while still being renderable.
|
|
6
|
+
jest.mock('lucide-react', () => ({
|
|
7
|
+
Search: (props: React.SVGProps<SVGSVGElement>) => (
|
|
8
|
+
<svg data-testid="icon-search" {...props} />
|
|
9
|
+
),
|
|
10
|
+
ArrowRight: (props: React.SVGProps<SVGSVGElement>) => (
|
|
11
|
+
<svg data-testid="icon-arrow-right" {...props} />
|
|
12
|
+
),
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
const StarIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
|
16
|
+
<svg data-testid="icon-star" {...props} />
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
describe('CommandResultItem', () => {
|
|
20
|
+
const defaultProps = {
|
|
21
|
+
label: 'Go to Dashboard',
|
|
22
|
+
selected: false,
|
|
23
|
+
onClick: jest.fn(),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
jest.clearAllMocks()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('label', () => {
|
|
31
|
+
it('renders the label text', () => {
|
|
32
|
+
render(<CommandResultItem {...defaultProps} />)
|
|
33
|
+
expect(screen.getByText('Go to Dashboard')).toBeInTheDocument()
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('detail', () => {
|
|
38
|
+
it('renders detail text when provided', () => {
|
|
39
|
+
render(<CommandResultItem {...defaultProps} detail="Overview of metrics" />)
|
|
40
|
+
expect(screen.getByText('Overview of metrics')).toBeInTheDocument()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('does not render detail element when omitted', () => {
|
|
44
|
+
render(<CommandResultItem {...defaultProps} />)
|
|
45
|
+
// Only the label paragraph should be present; no second <p>
|
|
46
|
+
const paragraphs = document.querySelectorAll('p')
|
|
47
|
+
expect(paragraphs).toHaveLength(1)
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('shortcut', () => {
|
|
52
|
+
it('renders the shortcut in a kbd element when provided', () => {
|
|
53
|
+
render(<CommandResultItem {...defaultProps} shortcut="⌘K" />)
|
|
54
|
+
const kbd = screen.getByText('⌘K')
|
|
55
|
+
expect(kbd.tagName).toBe('KBD')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('does not render a kbd element when shortcut is omitted', () => {
|
|
59
|
+
const { container } = render(<CommandResultItem {...defaultProps} />)
|
|
60
|
+
expect(container.querySelector('kbd')).not.toBeInTheDocument()
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('icon', () => {
|
|
65
|
+
it('renders the provided icon', () => {
|
|
66
|
+
render(<CommandResultItem {...defaultProps} icon={StarIcon} />)
|
|
67
|
+
expect(screen.getByTestId('icon-star')).toBeInTheDocument()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('falls back to Search icon when no icon prop is given', () => {
|
|
71
|
+
render(<CommandResultItem {...defaultProps} />)
|
|
72
|
+
expect(screen.getByTestId('icon-search')).toBeInTheDocument()
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe('showArrow', () => {
|
|
77
|
+
it('does not render ArrowRight by default', () => {
|
|
78
|
+
render(<CommandResultItem {...defaultProps} />)
|
|
79
|
+
expect(screen.queryByTestId('icon-arrow-right')).not.toBeInTheDocument()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('renders ArrowRight when showArrow is true', () => {
|
|
83
|
+
render(<CommandResultItem {...defaultProps} showArrow />)
|
|
84
|
+
expect(screen.getByTestId('icon-arrow-right')).toBeInTheDocument()
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('selected state', () => {
|
|
89
|
+
it('sets aria-selected="true" when selected', () => {
|
|
90
|
+
render(<CommandResultItem {...defaultProps} selected />)
|
|
91
|
+
const item = screen.getByRole('option')
|
|
92
|
+
expect(item).toHaveAttribute('aria-selected', 'true')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('sets aria-selected="false" when not selected', () => {
|
|
96
|
+
render(<CommandResultItem {...defaultProps} selected={false} />)
|
|
97
|
+
const item = screen.getByRole('option')
|
|
98
|
+
expect(item).toHaveAttribute('aria-selected', 'false')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('applies bg-blue-50 highlight class when selected', () => {
|
|
102
|
+
render(<CommandResultItem {...defaultProps} selected />)
|
|
103
|
+
const item = screen.getByRole('option')
|
|
104
|
+
expect(item.className).toMatch(/bg-blue-50/)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('does not apply bg-blue-50 when not selected', () => {
|
|
108
|
+
render(<CommandResultItem {...defaultProps} selected={false} />)
|
|
109
|
+
const item = screen.getByRole('option')
|
|
110
|
+
expect(item.className).not.toMatch(/bg-blue-50/)
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
describe('colorClass', () => {
|
|
115
|
+
it('applies the default color class to the icon container', () => {
|
|
116
|
+
const { container } = render(<CommandResultItem {...defaultProps} />)
|
|
117
|
+
const iconContainer = container.querySelector('.w-8.h-8')
|
|
118
|
+
expect(iconContainer?.className).toMatch(/bg-gray-100/)
|
|
119
|
+
expect(iconContainer?.className).toMatch(/text-gray-600/)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('applies a custom colorClass to the icon container', () => {
|
|
123
|
+
const { container } = render(
|
|
124
|
+
<CommandResultItem {...defaultProps} colorClass="bg-blue-100 text-blue-600" />
|
|
125
|
+
)
|
|
126
|
+
const iconContainer = container.querySelector('.w-8.h-8')
|
|
127
|
+
expect(iconContainer?.className).toMatch(/bg-blue-100/)
|
|
128
|
+
expect(iconContainer?.className).toMatch(/text-blue-600/)
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
describe('onClick', () => {
|
|
133
|
+
it('fires onClick when the item is clicked', () => {
|
|
134
|
+
const onClick = jest.fn()
|
|
135
|
+
render(<CommandResultItem {...defaultProps} onClick={onClick} />)
|
|
136
|
+
fireEvent.click(screen.getByRole('option'))
|
|
137
|
+
expect(onClick).toHaveBeenCalledTimes(1)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('does not fire onClick when a different element is clicked', () => {
|
|
141
|
+
const onClick = jest.fn()
|
|
142
|
+
render(
|
|
143
|
+
<div>
|
|
144
|
+
<CommandResultItem {...defaultProps} onClick={onClick} label="Target" />
|
|
145
|
+
<button data-testid="other">Other</button>
|
|
146
|
+
</div>
|
|
147
|
+
)
|
|
148
|
+
fireEvent.click(screen.getByTestId('other'))
|
|
149
|
+
expect(onClick).not.toHaveBeenCalled()
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
describe('label typography without detail', () => {
|
|
154
|
+
it('uses text-gray-700 on label when no detail is present', () => {
|
|
155
|
+
render(<CommandResultItem {...defaultProps} />)
|
|
156
|
+
const label = screen.getByText('Go to Dashboard')
|
|
157
|
+
expect(label.className).toMatch(/text-gray-700/)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('uses text-gray-900 on label when detail is present', () => {
|
|
161
|
+
render(<CommandResultItem {...defaultProps} detail="some detail" />)
|
|
162
|
+
const label = screen.getByText('Go to Dashboard')
|
|
163
|
+
expect(label.className).toMatch(/text-gray-900/)
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
})
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { render, screen, act } from '@testing-library/react'
|
|
2
|
+
import { fireEvent } from '@testing-library/react'
|
|
3
|
+
import { renderHook } from '@testing-library/react'
|
|
4
|
+
import { CommandPaletteProvider, useCommandPalette } from '../command-palette-context'
|
|
5
|
+
|
|
6
|
+
// Wrapper that exposes the context value through data attributes so we can
|
|
7
|
+
// assert on it without tying tests to component internals.
|
|
8
|
+
function ContextProbe() {
|
|
9
|
+
const { isOpen, open, close, toggle } = useCommandPalette()
|
|
10
|
+
return (
|
|
11
|
+
<div
|
|
12
|
+
data-testid="probe"
|
|
13
|
+
data-open={String(isOpen)}
|
|
14
|
+
>
|
|
15
|
+
<button data-testid="btn-open" onClick={open}>open</button>
|
|
16
|
+
<button data-testid="btn-close" onClick={close}>close</button>
|
|
17
|
+
<button data-testid="btn-toggle" onClick={toggle}>toggle</button>
|
|
18
|
+
</div>
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function renderWithProvider() {
|
|
23
|
+
return render(
|
|
24
|
+
<CommandPaletteProvider>
|
|
25
|
+
<ContextProbe />
|
|
26
|
+
</CommandPaletteProvider>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function probe() {
|
|
31
|
+
return screen.getByTestId('probe')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('CommandPaletteProvider / useCommandPalette', () => {
|
|
35
|
+
describe('initial state', () => {
|
|
36
|
+
it('starts with isOpen = false', () => {
|
|
37
|
+
renderWithProvider()
|
|
38
|
+
expect(probe().dataset.open).toBe('false')
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('open()', () => {
|
|
43
|
+
it('sets isOpen to true', () => {
|
|
44
|
+
renderWithProvider()
|
|
45
|
+
fireEvent.click(screen.getByTestId('btn-open'))
|
|
46
|
+
expect(probe().dataset.open).toBe('true')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('is idempotent — calling open twice leaves isOpen true', () => {
|
|
50
|
+
renderWithProvider()
|
|
51
|
+
fireEvent.click(screen.getByTestId('btn-open'))
|
|
52
|
+
fireEvent.click(screen.getByTestId('btn-open'))
|
|
53
|
+
expect(probe().dataset.open).toBe('true')
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('close()', () => {
|
|
58
|
+
it('sets isOpen to false', () => {
|
|
59
|
+
renderWithProvider()
|
|
60
|
+
fireEvent.click(screen.getByTestId('btn-open'))
|
|
61
|
+
fireEvent.click(screen.getByTestId('btn-close'))
|
|
62
|
+
expect(probe().dataset.open).toBe('false')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('is idempotent — calling close when already closed keeps isOpen false', () => {
|
|
66
|
+
renderWithProvider()
|
|
67
|
+
fireEvent.click(screen.getByTestId('btn-close'))
|
|
68
|
+
expect(probe().dataset.open).toBe('false')
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('toggle()', () => {
|
|
73
|
+
it('opens when currently closed', () => {
|
|
74
|
+
renderWithProvider()
|
|
75
|
+
fireEvent.click(screen.getByTestId('btn-toggle'))
|
|
76
|
+
expect(probe().dataset.open).toBe('true')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('closes when currently open', () => {
|
|
80
|
+
renderWithProvider()
|
|
81
|
+
fireEvent.click(screen.getByTestId('btn-open'))
|
|
82
|
+
fireEvent.click(screen.getByTestId('btn-toggle'))
|
|
83
|
+
expect(probe().dataset.open).toBe('false')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('toggles back and forth correctly', () => {
|
|
87
|
+
renderWithProvider()
|
|
88
|
+
const btn = screen.getByTestId('btn-toggle')
|
|
89
|
+
fireEvent.click(btn)
|
|
90
|
+
expect(probe().dataset.open).toBe('true')
|
|
91
|
+
fireEvent.click(btn)
|
|
92
|
+
expect(probe().dataset.open).toBe('false')
|
|
93
|
+
fireEvent.click(btn)
|
|
94
|
+
expect(probe().dataset.open).toBe('true')
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('Cmd+K / Ctrl+K keyboard shortcut', () => {
|
|
99
|
+
it('toggles isOpen on Cmd+K (metaKey)', () => {
|
|
100
|
+
renderWithProvider()
|
|
101
|
+
act(() => {
|
|
102
|
+
fireEvent.keyDown(document, { key: 'k', metaKey: true })
|
|
103
|
+
})
|
|
104
|
+
expect(probe().dataset.open).toBe('true')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('toggles isOpen on Ctrl+K (ctrlKey)', () => {
|
|
108
|
+
renderWithProvider()
|
|
109
|
+
act(() => {
|
|
110
|
+
fireEvent.keyDown(document, { key: 'k', ctrlKey: true })
|
|
111
|
+
})
|
|
112
|
+
expect(probe().dataset.open).toBe('true')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('toggles closed on second Cmd+K', () => {
|
|
116
|
+
renderWithProvider()
|
|
117
|
+
act(() => { fireEvent.keyDown(document, { key: 'k', metaKey: true }) })
|
|
118
|
+
act(() => { fireEvent.keyDown(document, { key: 'k', metaKey: true }) })
|
|
119
|
+
expect(probe().dataset.open).toBe('false')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('does not toggle on plain k keypress', () => {
|
|
123
|
+
renderWithProvider()
|
|
124
|
+
act(() => { fireEvent.keyDown(document, { key: 'k' }) })
|
|
125
|
+
expect(probe().dataset.open).toBe('false')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('removes the global keydown listener on unmount (no state update after unmount)', () => {
|
|
129
|
+
const { unmount } = renderWithProvider()
|
|
130
|
+
unmount()
|
|
131
|
+
// Should not throw when dispatching after unmount
|
|
132
|
+
expect(() => {
|
|
133
|
+
act(() => { fireEvent.keyDown(document, { key: 'k', metaKey: true }) })
|
|
134
|
+
}).not.toThrow()
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
describe('Escape key', () => {
|
|
139
|
+
it('closes the palette when open', () => {
|
|
140
|
+
renderWithProvider()
|
|
141
|
+
// Open it first
|
|
142
|
+
fireEvent.click(screen.getByTestId('btn-open'))
|
|
143
|
+
expect(probe().dataset.open).toBe('true')
|
|
144
|
+
|
|
145
|
+
act(() => { fireEvent.keyDown(document, { key: 'Escape' }) })
|
|
146
|
+
expect(probe().dataset.open).toBe('false')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('does nothing when palette is already closed', () => {
|
|
150
|
+
renderWithProvider()
|
|
151
|
+
act(() => { fireEvent.keyDown(document, { key: 'Escape' }) })
|
|
152
|
+
expect(probe().dataset.open).toBe('false')
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
describe('useCommandPalette — usage outside provider', () => {
|
|
157
|
+
it('throws when used outside CommandPaletteProvider', () => {
|
|
158
|
+
// Suppress the expected React error boundary console output
|
|
159
|
+
const spy = jest.spyOn(console, 'error').mockImplementation(() => {})
|
|
160
|
+
expect(() => {
|
|
161
|
+
renderHook(() => useCommandPalette())
|
|
162
|
+
}).toThrow('useCommandPalette must be used within a CommandPaletteProvider')
|
|
163
|
+
spy.mockRestore()
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
})
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { renderHook, act } from '@testing-library/react'
|
|
2
|
+
import { useCommandPaletteSearch, CommandItem } from '../useCommandPaletteSearch'
|
|
3
|
+
|
|
4
|
+
const Home = () => null
|
|
5
|
+
Home.displayName = 'Home'
|
|
6
|
+
|
|
7
|
+
const Settings = () => null
|
|
8
|
+
Settings.displayName = 'Settings'
|
|
9
|
+
|
|
10
|
+
const COMMANDS: CommandItem[] = [
|
|
11
|
+
{
|
|
12
|
+
id: 'nav-home',
|
|
13
|
+
label: 'Go to Home',
|
|
14
|
+
detail: 'Dashboard overview',
|
|
15
|
+
group: 'Navigation',
|
|
16
|
+
onSelect: '/home',
|
|
17
|
+
icon: Home,
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
id: 'nav-settings',
|
|
21
|
+
label: 'Go to Settings',
|
|
22
|
+
detail: 'Account preferences',
|
|
23
|
+
group: 'Navigation',
|
|
24
|
+
onSelect: '/settings',
|
|
25
|
+
icon: Settings,
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: 'action-invite',
|
|
29
|
+
label: 'Invite User',
|
|
30
|
+
detail: 'Send invite email',
|
|
31
|
+
group: 'Actions',
|
|
32
|
+
onSelect: jest.fn(),
|
|
33
|
+
shortcut: '⌘I',
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: 'action-export',
|
|
37
|
+
label: 'Export Data',
|
|
38
|
+
group: 'Actions',
|
|
39
|
+
onSelect: jest.fn(),
|
|
40
|
+
},
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
function makeOpts(overrides: Partial<Parameters<typeof useCommandPaletteSearch>[0]> = {}) {
|
|
44
|
+
return {
|
|
45
|
+
commands: COMMANDS,
|
|
46
|
+
onExecute: jest.fn(),
|
|
47
|
+
isOpen: true,
|
|
48
|
+
...overrides,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function makeKeyEvent(key: string): React.KeyboardEvent {
|
|
53
|
+
return { key, preventDefault: jest.fn() } as unknown as React.KeyboardEvent
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('useCommandPaletteSearch', () => {
|
|
57
|
+
describe('initial state', () => {
|
|
58
|
+
it('returns empty query on open', () => {
|
|
59
|
+
const { result } = renderHook(() => useCommandPaletteSearch(makeOpts()))
|
|
60
|
+
expect(result.current.query).toBe('')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('selects first item (index 0) on open', () => {
|
|
64
|
+
const { result } = renderHook(() => useCommandPaletteSearch(makeOpts()))
|
|
65
|
+
expect(result.current.selectedIndex).toBe(0)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('exposes setQuery and onKeyDown functions', () => {
|
|
69
|
+
const { result } = renderHook(() => useCommandPaletteSearch(makeOpts()))
|
|
70
|
+
expect(typeof result.current.setQuery).toBe('function')
|
|
71
|
+
expect(typeof result.current.onKeyDown).toBe('function')
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe('filtering', () => {
|
|
76
|
+
it('shows all commands when query is empty', () => {
|
|
77
|
+
const { result } = renderHook(() => useCommandPaletteSearch(makeOpts()))
|
|
78
|
+
const allItems = result.current.filteredGroups.flatMap((g) => g.items)
|
|
79
|
+
expect(allItems).toHaveLength(COMMANDS.length)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('narrows results by label match (case-insensitive)', () => {
|
|
83
|
+
const { result } = renderHook(() => useCommandPaletteSearch(makeOpts()))
|
|
84
|
+
act(() => { result.current.setQuery('invite') })
|
|
85
|
+
const items = result.current.filteredGroups.flatMap((g) => g.items)
|
|
86
|
+
expect(items).toHaveLength(1)
|
|
87
|
+
expect(items[0].id).toBe('action-invite')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('matches on detail field', () => {
|
|
91
|
+
const { result } = renderHook(() => useCommandPaletteSearch(makeOpts()))
|
|
92
|
+
act(() => { result.current.setQuery('dashboard') })
|
|
93
|
+
const items = result.current.filteredGroups.flatMap((g) => g.items)
|
|
94
|
+
expect(items).toHaveLength(1)
|
|
95
|
+
expect(items[0].id).toBe('nav-home')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('is case-insensitive', () => {
|
|
99
|
+
const { result } = renderHook(() => useCommandPaletteSearch(makeOpts()))
|
|
100
|
+
act(() => { result.current.setQuery('EXPORT') })
|
|
101
|
+
const items = result.current.filteredGroups.flatMap((g) => g.items)
|
|
102
|
+
expect(items).toHaveLength(1)
|
|
103
|
+
expect(items[0].id).toBe('action-export')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('returns empty groups when no commands match', () => {
|
|
107
|
+
const { result } = renderHook(() => useCommandPaletteSearch(makeOpts()))
|
|
108
|
+
act(() => { result.current.setQuery('zzznomatch') })
|
|
109
|
+
expect(result.current.filteredGroups).toHaveLength(0)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('resets selection to 0 when query changes', () => {
|
|
113
|
+
const { result } = renderHook(() => useCommandPaletteSearch(makeOpts()))
|
|
114
|
+
// Move selection down first
|
|
115
|
+
act(() => { result.current.onKeyDown(makeKeyEvent('ArrowDown')) })
|
|
116
|
+
expect(result.current.selectedIndex).toBe(1)
|
|
117
|
+
// Now type to filter
|
|
118
|
+
act(() => { result.current.setQuery('Settings') })
|
|
119
|
+
expect(result.current.selectedIndex).toBe(0)
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
describe('grouping', () => {
|
|
124
|
+
it('groups items by their group field', () => {
|
|
125
|
+
const { result } = renderHook(() => useCommandPaletteSearch(makeOpts()))
|
|
126
|
+
const labels = result.current.filteredGroups.map((g) => g.label)
|
|
127
|
+
expect(labels).toContain('Navigation')
|
|
128
|
+
expect(labels).toContain('Actions')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('places each item in the correct group', () => {
|
|
132
|
+
const { result } = renderHook(() => useCommandPaletteSearch(makeOpts()))
|
|
133
|
+
const nav = result.current.filteredGroups.find((g) => g.label === 'Navigation')!
|
|
134
|
+
expect(nav.items.map((i) => i.id)).toEqual(['nav-home', 'nav-settings'])
|
|
135
|
+
const actions = result.current.filteredGroups.find((g) => g.label === 'Actions')!
|
|
136
|
+
expect(actions.items.map((i) => i.id)).toEqual(['action-invite', 'action-export'])
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('preserves group insertion order', () => {
|
|
140
|
+
const { result } = renderHook(() => useCommandPaletteSearch(makeOpts()))
|
|
141
|
+
const labels = result.current.filteredGroups.map((g) => g.label)
|
|
142
|
+
expect(labels[0]).toBe('Navigation')
|
|
143
|
+
expect(labels[1]).toBe('Actions')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('produces a single group when all matches share a group', () => {
|
|
147
|
+
const { result } = renderHook(() => useCommandPaletteSearch(makeOpts()))
|
|
148
|
+
act(() => { result.current.setQuery('Go to') })
|
|
149
|
+
expect(result.current.filteredGroups).toHaveLength(1)
|
|
150
|
+
expect(result.current.filteredGroups[0].label).toBe('Navigation')
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
describe('keyboard navigation — ArrowDown', () => {
|
|
155
|
+
it('moves selectedIndex down by one', () => {
|
|
156
|
+
const { result } = renderHook(() => useCommandPaletteSearch(makeOpts()))
|
|
157
|
+
act(() => { result.current.onKeyDown(makeKeyEvent('ArrowDown')) })
|
|
158
|
+
expect(result.current.selectedIndex).toBe(1)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('calls preventDefault on ArrowDown', () => {
|
|
162
|
+
const { result } = renderHook(() => useCommandPaletteSearch(makeOpts()))
|
|
163
|
+
const e = makeKeyEvent('ArrowDown')
|
|
164
|
+
act(() => { result.current.onKeyDown(e) })
|
|
165
|
+
expect(e.preventDefault).toHaveBeenCalled()
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('clamps at the last item', () => {
|
|
169
|
+
const { result } = renderHook(() => useCommandPaletteSearch(makeOpts()))
|
|
170
|
+
const lastIndex = COMMANDS.length - 1
|
|
171
|
+
// Jump to end then try to go further
|
|
172
|
+
for (let i = 0; i <= lastIndex + 2; i++) {
|
|
173
|
+
act(() => { result.current.onKeyDown(makeKeyEvent('ArrowDown')) })
|
|
174
|
+
}
|
|
175
|
+
expect(result.current.selectedIndex).toBe(lastIndex)
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
describe('keyboard navigation — ArrowUp', () => {
|
|
180
|
+
it('moves selectedIndex up by one', () => {
|
|
181
|
+
const { result } = renderHook(() => useCommandPaletteSearch(makeOpts()))
|
|
182
|
+
// Go down first so we have room to go up
|
|
183
|
+
act(() => { result.current.onKeyDown(makeKeyEvent('ArrowDown')) })
|
|
184
|
+
act(() => { result.current.onKeyDown(makeKeyEvent('ArrowDown')) })
|
|
185
|
+
act(() => { result.current.onKeyDown(makeKeyEvent('ArrowUp')) })
|
|
186
|
+
expect(result.current.selectedIndex).toBe(1)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('calls preventDefault on ArrowUp', () => {
|
|
190
|
+
const { result } = renderHook(() => useCommandPaletteSearch(makeOpts()))
|
|
191
|
+
act(() => { result.current.onKeyDown(makeKeyEvent('ArrowDown')) })
|
|
192
|
+
const e = makeKeyEvent('ArrowUp')
|
|
193
|
+
act(() => { result.current.onKeyDown(e) })
|
|
194
|
+
expect(e.preventDefault).toHaveBeenCalled()
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('clamps at index 0', () => {
|
|
198
|
+
const { result } = renderHook(() => useCommandPaletteSearch(makeOpts()))
|
|
199
|
+
act(() => { result.current.onKeyDown(makeKeyEvent('ArrowUp')) })
|
|
200
|
+
act(() => { result.current.onKeyDown(makeKeyEvent('ArrowUp')) })
|
|
201
|
+
expect(result.current.selectedIndex).toBe(0)
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
describe('keyboard navigation — Enter', () => {
|
|
206
|
+
it('calls onExecute with the currently selected item', () => {
|
|
207
|
+
const onExecute = jest.fn()
|
|
208
|
+
const { result } = renderHook(() => useCommandPaletteSearch(makeOpts({ onExecute })))
|
|
209
|
+
// selectedIndex is 0 → nav-home
|
|
210
|
+
const e = makeKeyEvent('Enter')
|
|
211
|
+
act(() => { result.current.onKeyDown(e) })
|
|
212
|
+
expect(onExecute).toHaveBeenCalledTimes(1)
|
|
213
|
+
expect(onExecute).toHaveBeenCalledWith(COMMANDS[0])
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('calls preventDefault on Enter', () => {
|
|
217
|
+
const { result } = renderHook(() => useCommandPaletteSearch(makeOpts()))
|
|
218
|
+
const e = makeKeyEvent('Enter')
|
|
219
|
+
act(() => { result.current.onKeyDown(e) })
|
|
220
|
+
expect(e.preventDefault).toHaveBeenCalled()
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('executes the correct item after navigating down', () => {
|
|
224
|
+
const onExecute = jest.fn()
|
|
225
|
+
const { result } = renderHook(() => useCommandPaletteSearch(makeOpts({ onExecute })))
|
|
226
|
+
act(() => { result.current.onKeyDown(makeKeyEvent('ArrowDown')) })
|
|
227
|
+
act(() => { result.current.onKeyDown(makeKeyEvent('ArrowDown')) })
|
|
228
|
+
act(() => { result.current.onKeyDown(makeKeyEvent('Enter')) })
|
|
229
|
+
expect(onExecute).toHaveBeenCalledWith(COMMANDS[2])
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('does not call onExecute when filtered list is empty', () => {
|
|
233
|
+
const onExecute = jest.fn()
|
|
234
|
+
const { result } = renderHook(() => useCommandPaletteSearch(makeOpts({ onExecute })))
|
|
235
|
+
act(() => { result.current.setQuery('zzznomatch') })
|
|
236
|
+
act(() => { result.current.onKeyDown(makeKeyEvent('Enter')) })
|
|
237
|
+
expect(onExecute).not.toHaveBeenCalled()
|
|
238
|
+
})
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
describe('isOpen lifecycle', () => {
|
|
242
|
+
it('resets query when palette opens', () => {
|
|
243
|
+
const { result, rerender } = renderHook(
|
|
244
|
+
(props: Parameters<typeof useCommandPaletteSearch>[0]) =>
|
|
245
|
+
useCommandPaletteSearch(props),
|
|
246
|
+
{ initialProps: makeOpts({ isOpen: true }) }
|
|
247
|
+
)
|
|
248
|
+
act(() => { result.current.setQuery('home') })
|
|
249
|
+
expect(result.current.query).toBe('home')
|
|
250
|
+
|
|
251
|
+
// Close then re-open
|
|
252
|
+
rerender(makeOpts({ isOpen: false }))
|
|
253
|
+
rerender(makeOpts({ isOpen: true }))
|
|
254
|
+
expect(result.current.query).toBe('')
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('resets selectedIndex when palette opens', () => {
|
|
258
|
+
const { result, rerender } = renderHook(
|
|
259
|
+
(props: Parameters<typeof useCommandPaletteSearch>[0]) =>
|
|
260
|
+
useCommandPaletteSearch(props),
|
|
261
|
+
{ initialProps: makeOpts({ isOpen: true }) }
|
|
262
|
+
)
|
|
263
|
+
act(() => { result.current.onKeyDown(makeKeyEvent('ArrowDown')) })
|
|
264
|
+
expect(result.current.selectedIndex).toBe(1)
|
|
265
|
+
|
|
266
|
+
rerender(makeOpts({ isOpen: false }))
|
|
267
|
+
rerender(makeOpts({ isOpen: true }))
|
|
268
|
+
expect(result.current.selectedIndex).toBe(0)
|
|
269
|
+
})
|
|
270
|
+
})
|
|
271
|
+
})
|
|
@@ -1,3 +1,9 @@
|
|
|
1
1
|
export { CommandPalette } from './CommandPalette'
|
|
2
2
|
export type { CommandPaletteProps, SearchResult, QuickAction, NavigationItem } from './CommandPalette'
|
|
3
3
|
export { CommandPaletteProvider, useCommandPalette } from './command-palette-context'
|
|
4
|
+
export { useCommandPaletteSearch } from './useCommandPaletteSearch'
|
|
5
|
+
export type { CommandItem, CommandGroup as CommandGroupType, UseCommandPaletteOptions, UseCommandPaletteReturn } from './useCommandPaletteSearch'
|
|
6
|
+
export { CommandGroup } from './CommandGroup'
|
|
7
|
+
export type { CommandGroupProps } from './CommandGroup'
|
|
8
|
+
export { CommandResultItem } from './CommandResultItem'
|
|
9
|
+
export type { CommandResultItemProps } from './CommandResultItem'
|