Skip to content

Commit 6ec727a

Browse files
committed
Add tests for SuggestBox component
1 parent 0df0cca commit 6ec727a

File tree

2 files changed

+256
-7
lines changed

2 files changed

+256
-7
lines changed

extensions/ql-vscode/src/view/common/SuggestBox/SuggestBox.tsx

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { FormEvent, ReactNode } from "react";
2-
import { useCallback, useMemo, useRef, useState, useEffect } from "react";
2+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
33
import {
44
autoUpdate,
55
flip,
@@ -50,7 +50,7 @@ const NoSuggestionsText = styled.div`
5050
padding-left: 22px;
5151
`;
5252

53-
type Props<T extends Option<T>> = {
53+
export type SuggestBoxProps<T extends Option<T>> = {
5454
value?: string;
5555
onChange: (value: string) => void;
5656
options: T[];
@@ -76,6 +76,14 @@ type Props<T extends Option<T>> = {
7676
disabled?: boolean;
7777

7878
"aria-label"?: string;
79+
80+
/**
81+
* Can be used to render a different component for the input. This is used
82+
* in testing to use default HTML components rather than the VSCodeTextField
83+
* for easier testing.
84+
* @param props The props returned by `getReferenceProps` of {@link useInteractions}
85+
*/
86+
renderInputComponent?: (props: Record<string, unknown>) => ReactNode;
7987
};
8088

8189
export const SuggestBox = <T extends Option<T>>({
@@ -87,7 +95,8 @@ export const SuggestBox = <T extends Option<T>>({
8795
getDetails,
8896
disabled,
8997
"aria-label": ariaLabel,
90-
}: Props<T>) => {
98+
renderInputComponent = (props) => <Input {...props} />,
99+
}: SuggestBoxProps<T>) => {
91100
const [isOpen, setIsOpen] = useState(false);
92101
const [activeIndex, setActiveIndex] = useState<number | null>(null);
93102

@@ -150,8 +159,8 @@ export const SuggestBox = <T extends Option<T>>({
150159

151160
return (
152161
<>
153-
<Input
154-
{...getReferenceProps({
162+
{renderInputComponent(
163+
getReferenceProps({
155164
ref: refs.setReference,
156165
value,
157166
onInput: handleInput,
@@ -170,8 +179,8 @@ export const SuggestBox = <T extends Option<T>>({
170179
}
171180
},
172181
disabled,
173-
})}
174-
/>
182+
}),
183+
)}
175184
{isOpen && (
176185
<FloatingPortal>
177186
{value && suggestionItems.length === 0 && (
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
import { render as reactRender, screen } from "@testing-library/react";
2+
import type { SuggestBoxProps } from "../SuggestBox";
3+
import { SuggestBox } from "../SuggestBox";
4+
import { userEvent } from "@testing-library/user-event";
5+
6+
type TestOption = {
7+
label: string;
8+
value: string;
9+
followup?: TestOption[];
10+
};
11+
12+
const options: TestOption[] = [
13+
{
14+
label: "Argument[self]",
15+
value: "Argument[self]",
16+
},
17+
{
18+
label: "Argument[0]",
19+
value: "Argument[0]",
20+
followup: [
21+
{
22+
label: "Element[0]",
23+
value: "Argument[0].Element[0]",
24+
},
25+
{
26+
label: "Element[1]",
27+
value: "Argument[0].Element[1]",
28+
},
29+
{
30+
label: "Element[any]",
31+
value: "Argument[0].Element[any]",
32+
},
33+
],
34+
},
35+
{
36+
label: "Argument[1]",
37+
value: "Argument[1]",
38+
},
39+
{
40+
label: "Argument[text_rep:]",
41+
value: "Argument[text_rep:]",
42+
},
43+
{
44+
label: "Argument[block]",
45+
value: "Argument[block]",
46+
followup: [
47+
{
48+
label: "Parameter[0]",
49+
value: "Argument[block].Parameter[0]",
50+
followup: [
51+
{
52+
label: "Element[:query]",
53+
value: "Argument[block].Parameter[0].Element[:query]",
54+
},
55+
{
56+
label: "Element[:parameters]",
57+
value: "Argument[block].Parameter[0].Element[:parameters]",
58+
},
59+
],
60+
},
61+
{
62+
label: "Parameter[1]",
63+
value: "Argument[block].Parameter[1]",
64+
followup: [
65+
{
66+
label: "Field[@query]",
67+
value: "Argument[block].Parameter[1].Field[@query]",
68+
},
69+
],
70+
},
71+
],
72+
},
73+
{
74+
label: "ReturnValue",
75+
value: "ReturnValue",
76+
},
77+
];
78+
79+
describe("SuggestBox", () => {
80+
const onChange = jest.fn();
81+
const parseValueToTokens = jest.fn();
82+
const render = (props?: Partial<SuggestBoxProps<TestOption>>) =>
83+
reactRender(
84+
<SuggestBox
85+
options={options}
86+
onChange={onChange}
87+
parseValueToTokens={parseValueToTokens}
88+
renderInputComponent={(props) => <input {...props} />}
89+
{...props}
90+
/>,
91+
);
92+
93+
beforeEach(() => {
94+
onChange.mockReset();
95+
parseValueToTokens
96+
.mockReset()
97+
.mockImplementation((value: string) => value.split("."));
98+
});
99+
100+
it("does not render the options by default", () => {
101+
render();
102+
103+
expect(screen.queryByRole("option")).not.toBeInTheDocument();
104+
});
105+
106+
it("renders the options after clicking on the text field", async () => {
107+
render();
108+
109+
await userEvent.click(screen.getByRole("combobox"));
110+
111+
expect(screen.getAllByRole("option")).toHaveLength(options.length);
112+
});
113+
114+
it("calls onChange after entering text", async () => {
115+
render({
116+
value: "Argument[block]",
117+
});
118+
119+
await userEvent.type(screen.getByRole("combobox"), ".");
120+
121+
expect(onChange).toHaveBeenCalledWith("Argument[block].");
122+
});
123+
124+
it("calls onChange after clearing text", async () => {
125+
render({
126+
value: "Argument[block].",
127+
});
128+
129+
await userEvent.clear(screen.getByRole("combobox"));
130+
131+
expect(onChange).toHaveBeenCalledWith("");
132+
});
133+
134+
it("renders matching options with a single token", async () => {
135+
render({
136+
value: "block",
137+
});
138+
139+
await userEvent.click(screen.getByRole("combobox"));
140+
141+
expect(screen.getByRole("option")).toHaveTextContent("Argument[block]");
142+
});
143+
144+
it("renders followup options with a token and an empty token", async () => {
145+
render({
146+
value: "Argument[block].",
147+
});
148+
149+
await userEvent.click(screen.getByRole("combobox"));
150+
151+
expect(screen.getAllByRole("option")).toHaveLength(2);
152+
});
153+
154+
it("renders matching followup options with two tokens", async () => {
155+
render({
156+
value: "Argument[block].1",
157+
});
158+
159+
await userEvent.click(screen.getByRole("combobox"));
160+
161+
expect(screen.getByRole("option")).toHaveTextContent("Parameter[1]");
162+
});
163+
164+
it("closes the options when selecting an option", async () => {
165+
render({
166+
value: "Argument[block].1",
167+
});
168+
169+
await userEvent.click(screen.getByRole("combobox"));
170+
await userEvent.keyboard("{Enter}");
171+
172+
expect(screen.queryByRole("option")).not.toBeInTheDocument();
173+
});
174+
175+
it("shows no suggestions with no matching followup options", async () => {
176+
render({
177+
value: "Argument[block].block",
178+
});
179+
180+
await userEvent.click(screen.getByRole("combobox"));
181+
182+
expect(screen.queryByRole("option")).not.toBeInTheDocument();
183+
expect(screen.getByText("No suggestions.")).toBeInTheDocument();
184+
});
185+
186+
it("can navigate the options using the keyboard", async () => {
187+
render({
188+
value: "",
189+
});
190+
191+
await userEvent.click(screen.getByRole("combobox"));
192+
await userEvent.keyboard(
193+
"{ArrowDown}{ArrowDown}{ArrowUp}{ArrowDown}{ArrowDown}{Enter}",
194+
);
195+
196+
expect(onChange).toHaveBeenCalledWith("Argument[text_rep:]");
197+
expect(screen.queryByRole("option")).not.toBeInTheDocument();
198+
});
199+
200+
it("can use loop navigation when using the keyboard", async () => {
201+
render({
202+
value: "",
203+
});
204+
205+
await userEvent.click(screen.getByRole("combobox"));
206+
await userEvent.keyboard("{ArrowUp}{ArrowUp}{Enter}");
207+
208+
expect(onChange).toHaveBeenCalledWith("Argument[block]");
209+
expect(screen.queryByRole("option")).not.toBeInTheDocument();
210+
});
211+
212+
it("can close the options using escape", async () => {
213+
render({
214+
value: "",
215+
});
216+
217+
await userEvent.click(screen.getByRole("combobox"));
218+
219+
expect(screen.getAllByRole("option")).toHaveLength(options.length);
220+
221+
await userEvent.keyboard("{Escape}");
222+
223+
expect(screen.queryByRole("option")).not.toBeInTheDocument();
224+
});
225+
226+
it("opens the options when using backspace on a selected option", async () => {
227+
render({
228+
value: "Argument[block].1",
229+
});
230+
231+
await userEvent.click(screen.getByRole("combobox"));
232+
await userEvent.keyboard("{Enter}");
233+
234+
expect(screen.queryByRole("option")).not.toBeInTheDocument();
235+
236+
await userEvent.keyboard("{Backspace}");
237+
238+
expect(screen.getAllByRole("option")).toHaveLength(1);
239+
});
240+
});

0 commit comments

Comments
 (0)