From e327da26b710cc2e3793a1af16227e85655cb919 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Sun, 11 Apr 2021 13:54:46 +0100 Subject: [PATCH] chore(ui): replace react-autosuggest with downshift --- ui/package-lock.json | 55 +--- ui/package.json | 3 +- .../NavBar/FilterInput/Constants.ts | 10 - .../__snapshots__/index.test.tsx.snap | 58 ++--- .../NavBar/FilterInput/index.test.tsx | 45 +++- .../Components/NavBar/FilterInput/index.tsx | 237 +++++++++--------- 6 files changed, 199 insertions(+), 209 deletions(-) delete mode 100644 ui/src/Components/NavBar/FilterInput/Constants.ts diff --git a/ui/package-lock.json b/ui/package-lock.json index a0c7a8baa..81f9907d2 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -4539,14 +4539,6 @@ "csstype": "^3.0.2" } }, - "@types/react-autosuggest": { - "version": "10.1.4", - "resolved": "https://registry.npmjs.org/@types/react-autosuggest/-/react-autosuggest-10.1.4.tgz", - "integrity": "sha512-NdSUpRX6u1VsLpF63ss3yBGGcTFDJZg35D9XPTOgXPaUXZTJcfglcjyYgQwiMYRsbnz438dWt2gff/qIb7sWcw==", - "requires": { - "@types/react": "*" - } - }, "@types/react-dom": { "version": "17.0.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.3.tgz", @@ -7338,8 +7330,7 @@ "compute-scroll-into-view": { "version": "1.0.17", "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.17.tgz", - "integrity": "sha512-j4dx+Fb0URmzbwwMUrhqWM2BEWHdFGx+qZ9qqASHRPqvTYdqvWnHg0H1hIbcyLnvgnoNAVMlwkepyqM3DaIFUg==", - "dev": true + "integrity": "sha512-j4dx+Fb0URmzbwwMUrhqWM2BEWHdFGx+qZ9qqASHRPqvTYdqvWnHg0H1hIbcyLnvgnoNAVMlwkepyqM3DaIFUg==" }, "concat-map": { "version": "0.0.1", @@ -8801,7 +8792,6 @@ "version": "6.1.2", "resolved": "https://registry.npmjs.org/downshift/-/downshift-6.1.2.tgz", "integrity": "sha512-WnPoQ6miic4+uEzPEfqgeen0t5YREOUabMopU/Juo/UYDMZl0ZACkO6ykWCRg48dlEUmEt6zfaJlj1x7kEy78g==", - "dev": true, "requires": { "@babel/runtime": "^7.13.10", "compute-scroll-into-view": "^1.0.17", @@ -8812,8 +8802,7 @@ "react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" } } }, @@ -9184,7 +9173,8 @@ "es6-promise": { "version": "4.2.8", "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", - "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==" + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==", + "dev": true }, "es6-promise-pool": { "version": "2.5.0", @@ -16781,18 +16771,6 @@ "whatwg-fetch": "^3.4.1" } }, - "react-autosuggest": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/react-autosuggest/-/react-autosuggest-10.1.0.tgz", - "integrity": "sha512-/azBHmc6z/31s/lBf6irxPf/7eejQdR0IqnZUzjdSibtlS8+Rw/R79pgDAo6Ft5QqCUTyEQ+f0FhL+1olDQ8OA==", - "requires": { - "es6-promise": "^4.2.8", - "prop-types": "^15.7.2", - "react-themeable": "^1.1.0", - "section-iterator": "^2.0.0", - "shallow-equal": "^1.2.1" - } - }, "react-colorful": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.1.2.tgz", @@ -17370,21 +17348,6 @@ "use-latest": "^1.0.0" } }, - "react-themeable": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/react-themeable/-/react-themeable-1.1.0.tgz", - "integrity": "sha1-fURm3ZsrX6dQWHJ4JenxUro3mg4=", - "requires": { - "object-assign": "^3.0.0" - }, - "dependencies": { - "object-assign": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-3.0.0.tgz", - "integrity": "sha1-m+3VygiXlJvKR+f/QIBi1Un1h/I=" - } - } - }, "react-transition-group": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.1.tgz", @@ -18322,11 +18285,6 @@ "lodash": "^4.17.4" } }, - "section-iterator": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/section-iterator/-/section-iterator-2.0.0.tgz", - "integrity": "sha1-v0RNev7rlK1Dw5rS+yYVFifMuio=" - }, "select": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/select/-/select-1.1.2.tgz", @@ -18544,11 +18502,6 @@ "safe-buffer": "^5.0.1" } }, - "shallow-equal": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-1.2.1.tgz", - "integrity": "sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==" - }, "shallowequal": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", diff --git a/ui/package.json b/ui/package.json index 0fcf19c5e..3019af4c0 100644 --- a/ui/package.json +++ b/ui/package.json @@ -28,7 +28,6 @@ "@types/promise-retry": "1.1.3", "@types/qs": "6.9.6", "@types/react": "17.0.3", - "@types/react-autosuggest": "10.1.4", "@types/react-dom": "17.0.3", "@types/react-js-pagination": "3.0.3", "@types/react-select": "4.0.14", @@ -41,6 +40,7 @@ "copy-to-clipboard": "3.3.1", "csshake": "1.5.3", "date-fns": "2.20.1", + "downshift": "6.1.2", "eslint-config-prettier": "8.1.0", "eslint-plugin-prettier": "3.3.1", "favico.js": "0.3.10", @@ -59,7 +59,6 @@ "qs": "6.10.1", "react": "17.0.2", "react-app-polyfill": "2.0.0", - "react-autosuggest": "10.1.0", "react-cool-dimensions": "2.0.1", "react-day-picker": "7.4.10", "react-dom": "17.0.2", diff --git a/ui/src/Components/NavBar/FilterInput/Constants.ts b/ui/src/Components/NavBar/FilterInput/Constants.ts deleted file mode 100644 index 3d15b6a24..000000000 --- a/ui/src/Components/NavBar/FilterInput/Constants.ts +++ /dev/null @@ -1,10 +0,0 @@ -const AutosuggestTheme = { - container: "autosuggest d-inline-block mw-100", - suggestionsContainer: "dropdown", - suggestionsList: "dropdown-menu show shadow", - suggestion: "dropdown-item cursor-pointer", - suggestionFocused: "active", - suggestionHighlighted: "active", -}; - -export { AutosuggestTheme }; diff --git a/ui/src/Components/NavBar/FilterInput/__snapshots__/index.test.tsx.snap b/ui/src/Components/NavBar/FilterInput/__snapshots__/index.test.tsx.snap index 206609862..8497ebc60 100644 --- a/ui/src/Components/NavBar/FilterInput/__snapshots__/index.test.tsx.snap +++ b/ui/src/Components/NavBar/FilterInput/__snapshots__/index.test.tsx.snap @@ -4,11 +4,11 @@ exports[` matches snapshot with no filters 1`] = ` "
-
matches snapshot with no filters 1`] = ` - -
-
+ +
@@ -153,27 +154,28 @@ exports[` matches snapshot with some filters 1`] = ` -
- -
-
+ +
diff --git a/ui/src/Components/NavBar/FilterInput/index.test.tsx b/ui/src/Components/NavBar/FilterInput/index.test.tsx index e2ce298fc..9e9a7615d 100644 --- a/ui/src/Components/NavBar/FilterInput/index.test.tsx +++ b/ui/src/Components/NavBar/FilterInput/index.test.tsx @@ -124,22 +124,39 @@ describe("", () => { ).toBe(true); }); + it("clicking input changes background color", () => { + const tree = MountedInput(); + tree.find("input").simulate("click"); + expect(toDiffableHtml(tree.html())).toMatch(/bg-focused/); + }); + it("focusing input changes background color", () => { + const tree = MountedInput(); + const input = tree.find("input"); + input.simulate("focus"); + expect(toDiffableHtml(tree.html())).toMatch(/bg-focused/); + }); + + it("focusing form changes background color", () => { const tree = MountedInput(); const formControl = tree.find(".form-control"); formControl.find("input").simulate("focus"); expect(toDiffableHtml(tree.html())).toMatch(/bg-focused/); }); - it("bluring input changes background color", () => { + it("bluring input changes background color", async () => { const tree = MountedInput(); const formControl = tree.find(".form-control"); + formControl + .find("input") + .simulate("change", { target: { value: "cluster" } }); formControl.find("input").simulate("blur"); expect(toDiffableHtml(tree.html())).not.toMatch(/bg-focused/); + tree.unmount(); }); }); -describe("", () => { +describe("", () => { it("fetches suggestions on input change", async () => { const tree = MountedInput(); tree.find("input").simulate("change", { target: { value: "cluster" } }); @@ -151,6 +168,7 @@ describe("", () => { expect(useFetchGetMock.fetch.calls[0]).toContain( "./autocomplete.json?term=cluster" ); + tree.unmount(); }); it("doesn't fetch any suggestion if the input value is empty", () => { @@ -160,6 +178,26 @@ describe("", () => { jest.runOnlyPendingTimers(); }); expect(useFetchGetMock.fetch.calls).toHaveLength(0); + tree.unmount(); + }); + + it("highliting a suggestion makes it active", async () => { + const tree = MountedInput(); + tree.find("input").simulate("change", { target: { value: "cluster" } }); + act(() => { + jest.runOnlyPendingTimers(); + }); + + // suggestions are rendered only when input is focused + tree.find("input").simulate("focus"); + // find() doesn't pick up suggestions even when tree.html() shows them + // forcing update seems to solve it + // https://github.com/airbnb/enzyme/issues/1233#issuecomment-343449560 + tree.update(); + + tree.find("input").simulate("keydown", { keyCode: 40, key: "ArrowDown" }); + tree.update(); + expect(tree.find(".dropdown-item").at(0).html()).toMatch(/active/); }); it("clicking on a suggestion adds it to filters", async () => { @@ -176,8 +214,7 @@ describe("", () => { // forcing update seems to solve it // https://github.com/airbnb/enzyme/issues/1233#issuecomment-343449560 tree.update(); - // not sure why but suggestions are being found twice - const suggestion = tree.find(".dropdown-item").at(2); + const suggestion = tree.find(".dropdown-item").at(1); expect(suggestion.text()).toBe("cluster=prod"); suggestion.simulate("click"); expect(alertStore.filters.values).toHaveLength(1); diff --git a/ui/src/Components/NavBar/FilterInput/index.tsx b/ui/src/Components/NavBar/FilterInput/index.tsx index 909420e59..55691e0db 100644 --- a/ui/src/Components/NavBar/FilterInput/index.tsx +++ b/ui/src/Components/NavBar/FilterInput/index.tsx @@ -2,7 +2,11 @@ import { FC, useEffect, useState, useRef, useCallback } from "react"; import { observer } from "mobx-react-lite"; -import Autosuggest from "react-autosuggest"; +import { + useCombobox, + UseComboboxState, + UseComboboxStateChangeOptions, +} from "downshift"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faSearch } from "@fortawesome/free-solid-svg-icons/faSearch"; @@ -14,57 +18,25 @@ import { useFetchGet } from "Hooks/useFetchGet"; import { useDebounce } from "Hooks/useDebounce"; import { useOnClickOutside } from "Hooks/useOnClickOutside"; import { FilterInputLabel } from "Components/Labels/FilterInputLabel"; -import { AutosuggestTheme } from "./Constants"; import { History } from "./History"; const FilterInput: FC<{ alertStore: AlertStore; settingsStore: Settings; }> = observer(({ alertStore, settingsStore }) => { - const autosuggestRef = useRef(null); - const inputRef = useRef(null); const formRef = useRef(null); - - const [suggestions, setSuggestions] = useState([]); - const [value, setValue] = useState(""); + const inputRef = useRef(null); const [isFocused, setIsFocused] = useState(false); - - const onSuggestionsClearRequested = useCallback(() => setSuggestions([]), []); - - const onSuggestionSelected = useCallback( - (_, { suggestion }) => { - setValue(""); - alertStore.filters.addFilter(suggestion); - }, - [alertStore.filters] - ); - - const onChange = useCallback((_, { newValue }) => setValue(newValue), []); - - const onSubmit = useCallback( - (event) => { - event.preventDefault(); - if (value !== "") { - alertStore.filters.addFilter(value); - setValue(""); - } - }, - [alertStore.filters, value] - ); - useEffect(() => { - inputRef.current = ((autosuggestRef.current as Autosuggest) - .input as HTMLInputElement).parentElement; if (!IsMobile()) { - ((autosuggestRef.current as Autosuggest) - .input as HTMLInputElement).focus(); + inputRef.current?.focus(); setIsFocused(true); } }, []); - const onBlur = useCallback(() => setIsFocused(false), []); useOnClickOutside(formRef, onBlur, true); + const [suggestions, setSuggestions] = useState([]); const [term, setTerm] = useState(""); const debouncedSearchTerm = useDebounce(term, 300); @@ -73,76 +45,84 @@ const FilterInput: FC<{ { autorun: false } ); - useEffect(() => { - if (debouncedSearchTerm) { - get(); + // eslint-disable-next-line @typescript-eslint/ban-types + const stateReducer = ( + state: UseComboboxState, + actionAndChanges: UseComboboxStateChangeOptions + ) => { + const { type, changes } = actionAndChanges; + switch (type) { + case useCombobox.stateChangeTypes.InputBlur: { + onBlur(); + return changes; + } + default: + return changes; } - return () => cancelGet(); - }, [get, cancelGet, debouncedSearchTerm]); + }; - useEffect(() => { - if (error) { - onSuggestionsClearRequested(); - } else if (!isLoading && response !== null) { - setSuggestions(response); - } - }, [response, error, isLoading, onSuggestionsClearRequested]); + const { + isOpen, + getMenuProps, + getInputProps, + getComboboxProps, + highlightedIndex, + getItemProps, + setInputValue, + inputValue, + } = useCombobox({ + stateReducer: stateReducer, + items: suggestions, + onInputValueChange: ({ inputValue }) => { + if (inputValue) { + setTerm(inputValue); + } else { + setTerm(""); + setSuggestions([]); + } + }, + onSelectedItemChange: ({ selectedItem }) => { + applyFilter(selectedItem); + }, + }); + + const applyFilter = useCallback( + (inputValue: string | null | undefined) => { + if (inputValue) { + alertStore.filters.addFilter(inputValue); + } + setInputValue(""); + setSuggestions([]); + }, + [alertStore.filters, setInputValue] + ); const onInputClick = (className: string) => { if ( typeof className === "string" && className.split(" ").includes("form-control") ) { - ((autosuggestRef.current as Autosuggest) - .input as HTMLInputElement).focus(); + inputRef.current?.focus(); setIsFocused(true); } }; - const renderSuggestion = ( - suggestion: string, - { query }: { query: string } - ) => { - const parts = suggestion.split(new RegExp(`(${query})`, "gi")); - return ( - - {parts.map((part, i) => ( - - {part} - - ))} - - ); - }; + useEffect(() => { + if (debouncedSearchTerm) { + get(); + } else { + setSuggestions([]); + } + return () => cancelGet(); + }, [get, cancelGet, debouncedSearchTerm]); - const renderInputComponent: FC<{ value: string }> = ({ - value, - ...inputProps - }) => { - return ( - <> - {alertStore.filters.values.length ? null : ( - - - - )} - - - ); - }; + useEffect(() => { + if (error) { + setSuggestions([]); + } else if (!isLoading && response !== null) { + setSuggestions(response); + } + }, [response, error, isLoading]); return ( // data-filters is there to register filters for observation in mobx @@ -150,7 +130,10 @@ const FilterInput: FC<{
{ + event.preventDefault(); + applyFilter(inputValue); + }} >
))} - setTerm(value)} - onSuggestionsClearRequested={onSuggestionsClearRequested} - onSuggestionSelected={onSuggestionSelected} - shouldRenderSuggestions={(value) => - value ? value.trim().length > 1 : false - } - getSuggestionValue={(suggestion) => suggestion} - renderSuggestion={renderSuggestion} - renderInputComponent={renderInputComponent} - inputProps={{ - value: value, - onChange: onChange, - onFocus: () => setIsFocused(true), - onBlur: onBlur, - }} - theme={AutosuggestTheme} - /> +
+ {alertStore.filters.values.length ? null : ( + + + + )} + setIsFocused(true)} + {...getInputProps({ ref: inputRef })} + /> +
+ + {isOpen && inputValue && suggestions.length ? ( +
+ {suggestions.slice(0, 10).map((item, index) => ( +
  • + {item + .split(new RegExp(`(${inputValue})`, "gi")) + .map((part, i) => ( + + {part} + + ))} +
  • + ))} +
    + ) : null} +