diff --git a/extensions/ql-vscode/src/view/common/SuggestBox/SuggestBox.tsx b/extensions/ql-vscode/src/view/common/SuggestBox/SuggestBox.tsx index 544de25c1..0e338892f 100644 --- a/extensions/ql-vscode/src/view/common/SuggestBox/SuggestBox.tsx +++ b/extensions/ql-vscode/src/view/common/SuggestBox/SuggestBox.tsx @@ -1,5 +1,5 @@ import type { FormEvent, ReactNode } from "react"; -import { useCallback, useMemo, useRef, useState, useEffect } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { autoUpdate, flip, @@ -50,7 +50,7 @@ const NoSuggestionsText = styled.div` padding-left: 22px; `; -type Props> = { +export type SuggestBoxProps> = { value?: string; onChange: (value: string) => void; options: T[]; @@ -76,6 +76,14 @@ type Props> = { disabled?: boolean; "aria-label"?: string; + + /** + * Can be used to render a different component for the input. This is used + * in testing to use default HTML components rather than the VSCodeTextField + * for easier testing. + * @param props The props returned by `getReferenceProps` of {@link useInteractions} + */ + renderInputComponent?: (props: Record) => ReactNode; }; export const SuggestBox = >({ @@ -87,7 +95,8 @@ export const SuggestBox = >({ getDetails, disabled, "aria-label": ariaLabel, -}: Props) => { + renderInputComponent = (props) => , +}: SuggestBoxProps) => { const [isOpen, setIsOpen] = useState(false); const [activeIndex, setActiveIndex] = useState(null); @@ -150,8 +159,8 @@ export const SuggestBox = >({ return ( <> - >({ } }, disabled, - })} - /> + }), + )} {isOpen && ( {value && suggestionItems.length === 0 && ( diff --git a/extensions/ql-vscode/src/view/common/SuggestBox/__tests__/SuggestBox.test.tsx b/extensions/ql-vscode/src/view/common/SuggestBox/__tests__/SuggestBox.test.tsx new file mode 100644 index 000000000..21225f898 --- /dev/null +++ b/extensions/ql-vscode/src/view/common/SuggestBox/__tests__/SuggestBox.test.tsx @@ -0,0 +1,240 @@ +import { render as reactRender, screen } from "@testing-library/react"; +import type { SuggestBoxProps } from "../SuggestBox"; +import { SuggestBox } from "../SuggestBox"; +import { userEvent } from "@testing-library/user-event"; + +type TestOption = { + label: string; + value: string; + followup?: TestOption[]; +}; + +const options: TestOption[] = [ + { + label: "Argument[self]", + value: "Argument[self]", + }, + { + label: "Argument[0]", + value: "Argument[0]", + followup: [ + { + label: "Element[0]", + value: "Argument[0].Element[0]", + }, + { + label: "Element[1]", + value: "Argument[0].Element[1]", + }, + { + label: "Element[any]", + value: "Argument[0].Element[any]", + }, + ], + }, + { + label: "Argument[1]", + value: "Argument[1]", + }, + { + label: "Argument[text_rep:]", + value: "Argument[text_rep:]", + }, + { + label: "Argument[block]", + value: "Argument[block]", + followup: [ + { + label: "Parameter[0]", + value: "Argument[block].Parameter[0]", + followup: [ + { + label: "Element[:query]", + value: "Argument[block].Parameter[0].Element[:query]", + }, + { + label: "Element[:parameters]", + value: "Argument[block].Parameter[0].Element[:parameters]", + }, + ], + }, + { + label: "Parameter[1]", + value: "Argument[block].Parameter[1]", + followup: [ + { + label: "Field[@query]", + value: "Argument[block].Parameter[1].Field[@query]", + }, + ], + }, + ], + }, + { + label: "ReturnValue", + value: "ReturnValue", + }, +]; + +describe("SuggestBox", () => { + const onChange = jest.fn(); + const parseValueToTokens = jest.fn(); + const render = (props?: Partial>) => + reactRender( + } + {...props} + />, + ); + + beforeEach(() => { + onChange.mockReset(); + parseValueToTokens + .mockReset() + .mockImplementation((value: string) => value.split(".")); + }); + + it("does not render the options by default", () => { + render(); + + expect(screen.queryByRole("option")).not.toBeInTheDocument(); + }); + + it("renders the options after clicking on the text field", async () => { + render(); + + await userEvent.click(screen.getByRole("combobox")); + + expect(screen.getAllByRole("option")).toHaveLength(options.length); + }); + + it("calls onChange after entering text", async () => { + render({ + value: "Argument[block]", + }); + + await userEvent.type(screen.getByRole("combobox"), "."); + + expect(onChange).toHaveBeenCalledWith("Argument[block]."); + }); + + it("calls onChange after clearing text", async () => { + render({ + value: "Argument[block].", + }); + + await userEvent.clear(screen.getByRole("combobox")); + + expect(onChange).toHaveBeenCalledWith(""); + }); + + it("renders matching options with a single token", async () => { + render({ + value: "block", + }); + + await userEvent.click(screen.getByRole("combobox")); + + expect(screen.getByRole("option")).toHaveTextContent("Argument[block]"); + }); + + it("renders followup options with a token and an empty token", async () => { + render({ + value: "Argument[block].", + }); + + await userEvent.click(screen.getByRole("combobox")); + + expect(screen.getAllByRole("option")).toHaveLength(2); + }); + + it("renders matching followup options with two tokens", async () => { + render({ + value: "Argument[block].1", + }); + + await userEvent.click(screen.getByRole("combobox")); + + expect(screen.getByRole("option")).toHaveTextContent("Parameter[1]"); + }); + + it("closes the options when selecting an option", async () => { + render({ + value: "Argument[block].1", + }); + + await userEvent.click(screen.getByRole("combobox")); + await userEvent.keyboard("{Enter}"); + + expect(screen.queryByRole("option")).not.toBeInTheDocument(); + }); + + it("shows no suggestions with no matching followup options", async () => { + render({ + value: "Argument[block].block", + }); + + await userEvent.click(screen.getByRole("combobox")); + + expect(screen.queryByRole("option")).not.toBeInTheDocument(); + expect(screen.getByText("No suggestions.")).toBeInTheDocument(); + }); + + it("can navigate the options using the keyboard", async () => { + render({ + value: "", + }); + + await userEvent.click(screen.getByRole("combobox")); + await userEvent.keyboard( + "{ArrowDown}{ArrowDown}{ArrowUp}{ArrowDown}{ArrowDown}{Enter}", + ); + + expect(onChange).toHaveBeenCalledWith("Argument[text_rep:]"); + expect(screen.queryByRole("option")).not.toBeInTheDocument(); + }); + + it("can use loop navigation when using the keyboard", async () => { + render({ + value: "", + }); + + await userEvent.click(screen.getByRole("combobox")); + await userEvent.keyboard("{ArrowUp}{ArrowUp}{Enter}"); + + expect(onChange).toHaveBeenCalledWith("Argument[block]"); + expect(screen.queryByRole("option")).not.toBeInTheDocument(); + }); + + it("can close the options using escape", async () => { + render({ + value: "", + }); + + await userEvent.click(screen.getByRole("combobox")); + + expect(screen.getAllByRole("option")).toHaveLength(options.length); + + await userEvent.keyboard("{Escape}"); + + expect(screen.queryByRole("option")).not.toBeInTheDocument(); + }); + + it("opens the options when using backspace on a selected option", async () => { + render({ + value: "Argument[block].1", + }); + + await userEvent.click(screen.getByRole("combobox")); + await userEvent.keyboard("{Enter}"); + + expect(screen.queryByRole("option")).not.toBeInTheDocument(); + + await userEvent.keyboard("{Backspace}"); + + expect(screen.getAllByRole("option")).toHaveLength(1); + }); +});