Skip to content

Commit 8c1aec1

Browse files
Add Search/Filter Functionality to API Provider Selection in Settings (#5278)
* Enhance provider selection with search functionality * fix: add SearchableSelect mock to SettingsView.spec.tsx - Added SearchableSelect to the @/components/ui mock in SettingsView.spec.tsx - This resolves test failures after the SearchableSelect component was introduced - All 497 tests now pass successfully * test: add comprehensive tests for SearchableSelect component * feat: address PR feedback * fix: internationalize SearchableSelect placeholder in test mock - Replace hardcoded 'Select...' with i18n key 'settings:common.select' - Ensures consistency with actual component usage - Addresses PR review feedback --------- Co-authored-by: Daniel Riccio <[email protected]>
1 parent 83da295 commit 8c1aec1

26 files changed

+628
-171
lines changed
Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
import React from "react"
2+
import { render, screen, act, cleanup, waitFor, within, fireEvent } from "@/utils/test-utils"
3+
import { SearchableSelect, SearchableSelectOption } from "@/components/ui/searchable-select"
4+
import userEvent from "@testing-library/user-event"
5+
6+
describe("SearchableSelect", () => {
7+
const mockOptions: SearchableSelectOption[] = [
8+
{ value: "option1", label: "Option 1" },
9+
{ value: "option2", label: "Option 2" },
10+
{ value: "option3", label: "Option 3", disabled: true },
11+
]
12+
13+
const defaultProps = {
14+
options: mockOptions,
15+
placeholder: "Select an option",
16+
searchPlaceholder: "Search options...",
17+
emptyMessage: "No options found",
18+
onValueChange: vi.fn(),
19+
}
20+
21+
beforeEach(() => {
22+
vi.clearAllMocks()
23+
})
24+
25+
afterEach(() => {
26+
cleanup()
27+
vi.useRealTimers()
28+
})
29+
30+
it("renders with placeholder when no value is selected", () => {
31+
render(<SearchableSelect {...defaultProps} />)
32+
expect(screen.getByText("Select an option")).toBeInTheDocument()
33+
})
34+
35+
it("renders with selected option label when value is provided", () => {
36+
render(<SearchableSelect {...defaultProps} value="option1" />)
37+
expect(screen.getByText("Option 1")).toBeInTheDocument()
38+
})
39+
40+
it("opens dropdown when clicked", async () => {
41+
const user = userEvent.setup()
42+
render(<SearchableSelect {...defaultProps} />)
43+
44+
const trigger = screen.getByRole("combobox")
45+
await user.click(trigger)
46+
47+
expect(screen.getByPlaceholderText("Search options...")).toBeInTheDocument()
48+
expect(screen.getByText("Option 1")).toBeInTheDocument()
49+
expect(screen.getByText("Option 2")).toBeInTheDocument()
50+
})
51+
52+
it("filters options based on search input", async () => {
53+
const user = userEvent.setup()
54+
render(<SearchableSelect {...defaultProps} />)
55+
56+
const trigger = screen.getByRole("combobox")
57+
await user.click(trigger)
58+
59+
// Verify all options are initially visible
60+
await waitFor(() => {
61+
expect(screen.getByText("Option 1")).toBeInTheDocument()
62+
expect(screen.getByText("Option 2")).toBeInTheDocument()
63+
expect(screen.getByText("Option 3")).toBeInTheDocument()
64+
})
65+
66+
const searchInput = screen.getByPlaceholderText("Search options...")
67+
68+
// Use fireEvent for cmdk input
69+
fireEvent.change(searchInput, { target: { value: "1" } })
70+
71+
// Wait for the filtering to take effect
72+
await waitFor(() => {
73+
expect(screen.getByText("Option 1")).toBeInTheDocument()
74+
expect(screen.queryByText("Option 2")).not.toBeInTheDocument()
75+
expect(screen.queryByText("Option 3")).not.toBeInTheDocument()
76+
})
77+
})
78+
79+
it("calls onValueChange when an option is selected", async () => {
80+
const user = userEvent.setup()
81+
render(<SearchableSelect {...defaultProps} />)
82+
83+
const trigger = screen.getByRole("combobox")
84+
await user.click(trigger)
85+
86+
const option = screen.getByText("Option 2")
87+
await user.click(option)
88+
89+
expect(defaultProps.onValueChange).toHaveBeenCalledWith("option2")
90+
})
91+
92+
it("does not call onValueChange when a disabled option is clicked", async () => {
93+
const user = userEvent.setup()
94+
render(<SearchableSelect {...defaultProps} />)
95+
96+
const trigger = screen.getByRole("combobox")
97+
await user.click(trigger)
98+
99+
const disabledOption = screen.getByText("Option 3")
100+
await user.click(disabledOption)
101+
102+
expect(defaultProps.onValueChange).not.toHaveBeenCalled()
103+
})
104+
105+
it("clears search value when dropdown is closed", async () => {
106+
const user = userEvent.setup()
107+
render(<SearchableSelect {...defaultProps} />)
108+
109+
const trigger = screen.getByRole("combobox")
110+
await user.click(trigger)
111+
112+
const searchInput = screen.getByPlaceholderText("Search options...")
113+
114+
// Use fireEvent for cmdk input
115+
fireEvent.change(searchInput, { target: { value: "test" } })
116+
117+
// Verify the search filters the options
118+
await waitFor(() => {
119+
expect(screen.queryByText("Option 1")).not.toBeInTheDocument()
120+
expect(screen.queryByText("Option 2")).not.toBeInTheDocument()
121+
})
122+
123+
// Close the dropdown by clicking outside
124+
await user.click(document.body)
125+
126+
// Wait for dropdown to close
127+
await waitFor(() => {
128+
expect(screen.queryByRole("dialog")).not.toBeInTheDocument()
129+
})
130+
131+
// Wait a bit for the timeout to clear search
132+
await new Promise((resolve) => setTimeout(resolve, 200))
133+
134+
// Open again to check if search was cleared
135+
await user.click(trigger)
136+
137+
// All options should be visible again
138+
await waitFor(() => {
139+
expect(screen.getByText("Option 1")).toBeInTheDocument()
140+
expect(screen.getByText("Option 2")).toBeInTheDocument()
141+
expect(screen.getByText("Option 3")).toBeInTheDocument()
142+
})
143+
})
144+
145+
it("clears search value when clicking the clear button", async () => {
146+
const user = userEvent.setup()
147+
render(<SearchableSelect {...defaultProps} />)
148+
149+
const trigger = screen.getByRole("combobox")
150+
await user.click(trigger)
151+
152+
const searchInput = screen.getByPlaceholderText("Search options...")
153+
154+
// Use fireEvent for cmdk input
155+
fireEvent.change(searchInput, { target: { value: "test" } })
156+
157+
// Wait for the X button to appear and options to be filtered
158+
await waitFor(() => {
159+
expect(screen.getByTestId("clear-search-button")).toBeInTheDocument()
160+
expect(screen.queryByText("Option 1")).not.toBeInTheDocument()
161+
})
162+
163+
// Find and click the X icon by its container
164+
const clearButton = screen.getByTestId("clear-search-button")
165+
await user.click(clearButton)
166+
167+
// All options should be visible again
168+
await waitFor(() => {
169+
expect(screen.getByText("Option 1")).toBeInTheDocument()
170+
expect(screen.getByText("Option 2")).toBeInTheDocument()
171+
expect(screen.getByText("Option 3")).toBeInTheDocument()
172+
})
173+
})
174+
175+
it("handles component unmounting without memory leaks", async () => {
176+
vi.useFakeTimers()
177+
const { unmount, rerender } = render(<SearchableSelect {...defaultProps} value="option1" />)
178+
179+
// Change the value prop to trigger the effect with timeout
180+
rerender(<SearchableSelect {...defaultProps} value="option2" />)
181+
182+
// Immediately unmount the component before the timeout completes
183+
act(() => {
184+
unmount()
185+
})
186+
187+
// This test ensures that no setState calls happen after unmount
188+
// If there was a memory leak, this would throw an error
189+
expect(() => {
190+
// Wait for any pending timeouts
191+
act(() => {
192+
vi.runAllTimers()
193+
})
194+
}).not.toThrow()
195+
})
196+
197+
it("cleans up timeouts on unmount", () => {
198+
vi.useFakeTimers()
199+
const clearTimeoutSpy = vi.spyOn(global, "clearTimeout")
200+
const { unmount } = render(<SearchableSelect {...defaultProps} />)
201+
202+
act(() => {
203+
unmount()
204+
})
205+
206+
// Verify that clearTimeout was called during cleanup
207+
expect(clearTimeoutSpy).toHaveBeenCalled()
208+
clearTimeoutSpy.mockRestore()
209+
})
210+
211+
it("resets search value when value prop changes", async () => {
212+
const user = userEvent.setup()
213+
const { rerender } = render(<SearchableSelect {...defaultProps} value="option1" />)
214+
215+
// Open dropdown and type something
216+
const trigger = screen.getByRole("combobox")
217+
await user.click(trigger)
218+
219+
const searchInput = screen.getByPlaceholderText("Search options...")
220+
fireEvent.change(searchInput, { target: { value: "2" } })
221+
222+
// Verify search is working - use within to scope to dropdown
223+
const dropdown = screen.getByRole("dialog")
224+
await waitFor(() => {
225+
expect(within(dropdown).queryByText("Option 1")).not.toBeInTheDocument()
226+
expect(within(dropdown).getByText("Option 2")).toBeInTheDocument()
227+
})
228+
229+
// Close dropdown
230+
await user.click(document.body)
231+
232+
// Change the value prop
233+
rerender(<SearchableSelect {...defaultProps} value="option2" />)
234+
235+
// Wait for the component to update
236+
await waitFor(() => {
237+
expect(screen.getByRole("combobox")).toHaveTextContent("Option 2")
238+
})
239+
240+
// Wait for the effect to run (100ms timeout in component)
241+
await new Promise((resolve) => setTimeout(resolve, 150))
242+
243+
// Open dropdown again
244+
await user.click(trigger)
245+
246+
// All options should be visible (search cleared) - use within to scope
247+
const newDropdown = screen.getByRole("dialog")
248+
await waitFor(() => {
249+
expect(within(newDropdown).getByText("Option 1")).toBeInTheDocument()
250+
expect(within(newDropdown).getByText("Option 2")).toBeInTheDocument()
251+
expect(within(newDropdown).getByText("Option 3")).toBeInTheDocument()
252+
})
253+
})
254+
255+
it("handles rapid value changes without issues", async () => {
256+
const { rerender } = render(<SearchableSelect {...defaultProps} value="option1" />)
257+
258+
// Rapidly change values
259+
rerender(<SearchableSelect {...defaultProps} value="option2" />)
260+
rerender(<SearchableSelect {...defaultProps} value="option3" />)
261+
rerender(<SearchableSelect {...defaultProps} value="option1" />)
262+
263+
// Wait for the final value to be reflected
264+
await waitFor(() => {
265+
const trigger = screen.getByRole("combobox")
266+
expect(trigger).toHaveTextContent("Option 1")
267+
})
268+
269+
// Component should still be functional - open dropdown
270+
const user = userEvent.setup()
271+
const trigger = screen.getByRole("combobox")
272+
await user.click(trigger)
273+
274+
// Should be able to search
275+
const searchInput = screen.getByPlaceholderText("Search options...")
276+
fireEvent.change(searchInput, { target: { value: "2" } })
277+
278+
// Check filtering works - use within to scope to dropdown
279+
const dropdown = screen.getByRole("dialog")
280+
await waitFor(() => {
281+
expect(within(dropdown).getByText("Option 2")).toBeInTheDocument()
282+
expect(within(dropdown).queryByText("Option 1")).not.toBeInTheDocument()
283+
expect(within(dropdown).queryByText("Option 3")).not.toBeInTheDocument()
284+
})
285+
})
286+
})

0 commit comments

Comments
 (0)