chore(ui): replace react-autosuggest with downshift

This commit is contained in:
Łukasz Mierzwa
2021-04-11 14:04:08 +01:00
committed by Łukasz Mierzwa
parent 7e0b77193e
commit e327da26b7
6 changed files with 199 additions and 209 deletions
+4 -51
View File
@@ -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",
+1 -2
View File
@@ -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",
@@ -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 };
@@ -4,11 +4,11 @@ exports[`<FilterInput /> matches snapshot with no filters 1`] = `
"
<div class=\\"input-group w-100 mr-2 components-filterinput-outer bg-transparent\\">
<div class=\\"form-control components-filterinput border-0 rounded-0 bg-inherit\\">
<div role=\\"combobox\\"
<div class=\\"autosuggest d-inline-block mw-100\\"
role=\\"combobox\\"
aria-haspopup=\\"listbox\\"
aria-owns=\\"react-autowhatever-1\\"
aria-owns=\\"downshift-0-menu\\"
aria-expanded=\\"false\\"
class=\\"autosuggest d-inline-block mw-100\\"
>
<span class=\\"input-group-text d-inline-block mr-2 border-0 bg-inherit px-1\\">
<svg aria-hidden=\\"true\\"
@@ -26,21 +26,22 @@ exports[`<FilterInput /> matches snapshot with no filters 1`] = `
</path>
</svg>
</span>
<input type=\\"text\\"
class=\\"components-filterinput-wrapper text-white mw-100\\"
placeholder
<input class=\\"components-filterinput-wrapper text-white mw-100\\"
size=\\"1\\"
value
autocomplete=\\"off\\"
id=\\"downshift-0-input\\"
aria-autocomplete=\\"list\\"
aria-controls=\\"react-autowhatever-1\\"
aria-controls=\\"downshift-0-menu\\"
aria-labelledby=\\"downshift-0-label\\"
autocomplete=\\"off\\"
value
>
<div id=\\"react-autowhatever-1\\"
role=\\"listbox\\"
class=\\"dropdown\\"
>
</div>
</div>
<span class=\\"dropdown\\"
id=\\"downshift-0-menu\\"
role=\\"listbox\\"
aria-labelledby=\\"downshift-0-label\\"
>
</span>
</div>
<div class=\\"input-group-append\\">
<span class=\\"input-group-text border-0 rounded-0 bg-inherit px-0\\">
@@ -153,27 +154,28 @@ exports[`<FilterInput /> matches snapshot with some filters 1`] = `
</path>
</svg>
</button>
<div role=\\"combobox\\"
<div class=\\"autosuggest d-inline-block mw-100\\"
role=\\"combobox\\"
aria-haspopup=\\"listbox\\"
aria-owns=\\"react-autowhatever-1\\"
aria-owns=\\"downshift-1-menu\\"
aria-expanded=\\"false\\"
class=\\"autosuggest d-inline-block mw-100\\"
>
<input type=\\"text\\"
class=\\"components-filterinput-wrapper text-white mw-100\\"
placeholder
<input class=\\"components-filterinput-wrapper text-white mw-100\\"
size=\\"1\\"
value
autocomplete=\\"off\\"
id=\\"downshift-1-input\\"
aria-autocomplete=\\"list\\"
aria-controls=\\"react-autowhatever-1\\"
aria-controls=\\"downshift-1-menu\\"
aria-labelledby=\\"downshift-1-label\\"
autocomplete=\\"off\\"
value
>
<div id=\\"react-autowhatever-1\\"
role=\\"listbox\\"
class=\\"dropdown\\"
>
</div>
</div>
<span class=\\"dropdown\\"
id=\\"downshift-1-menu\\"
role=\\"listbox\\"
aria-labelledby=\\"downshift-1-label\\"
>
</span>
</div>
<div class=\\"input-group-append\\">
<span class=\\"input-group-text border-0 rounded-0 bg-inherit px-0\\">
@@ -124,22 +124,39 @@ describe("<FilterInput />", () => {
).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("<FilterInput Autosuggest />", () => {
describe("<FilterInput autocomplete />", () => {
it("fetches suggestions on input change", async () => {
const tree = MountedInput();
tree.find("input").simulate("change", { target: { value: "cluster" } });
@@ -151,6 +168,7 @@ describe("<FilterInput Autosuggest />", () => {
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("<FilterInput Autosuggest />", () => {
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("<FilterInput Autosuggest />", () => {
// 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);
+123 -114
View File
@@ -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<Autosuggest | null>(null);
const inputRef = useRef<HTMLElement | null>(null);
const formRef = useRef<HTMLDivElement | null>(null);
const [suggestions, setSuggestions] = useState<string[]>([]);
const [value, setValue] = useState<string>("");
const inputRef = useRef<HTMLInputElement | null>(null);
const [isFocused, setIsFocused] = useState<boolean>(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<string[]>([]);
const [term, setTerm] = useState<string>("");
const debouncedSearchTerm = useDebounce<string>(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<string>,
actionAndChanges: UseComboboxStateChangeOptions<string>
) => {
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 (
<span>
{parts.map((part, i) => (
<span
key={i}
style={
part.toLowerCase() === query.toLowerCase()
? { fontWeight: "bold" }
: {}
}
>
{part}
</span>
))}
</span>
);
};
useEffect(() => {
if (debouncedSearchTerm) {
get();
} else {
setSuggestions([]);
}
return () => cancelGet();
}, [get, cancelGet, debouncedSearchTerm]);
const renderInputComponent: FC<{ value: string }> = ({
value,
...inputProps
}) => {
return (
<>
{alertStore.filters.values.length ? null : (
<span className="input-group-text d-inline-block mr-2 border-0 bg-inherit px-1">
<FontAwesomeIcon icon={faSearch} />
</span>
)}
<input
className="components-filterinput-wrapper text-white mw-100"
placeholder=""
size={value.length + 1}
value={value}
{...inputProps}
/>
</>
);
};
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<{
<form
className="form-inline flex-grow-1 flex-shrink-1 mr-auto"
style={{ minWidth: "0px" }}
onSubmit={onSubmit}
onSubmit={(event) => {
event.preventDefault();
applyFilter(inputValue);
}}
>
<div
ref={formRef}
@@ -171,26 +154,52 @@ const FilterInput: FC<{
filter={filter}
/>
))}
<Autosuggest
ref={autosuggestRef}
suggestions={suggestions}
onSuggestionsFetchRequested={({ value }) => 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}
/>
<div
className="autosuggest d-inline-block mw-100"
{...getComboboxProps()}
>
{alertStore.filters.values.length ? null : (
<span className="input-group-text d-inline-block mr-2 border-0 bg-inherit px-1">
<FontAwesomeIcon icon={faSearch} />
</span>
)}
<input
className="components-filterinput-wrapper text-white mw-100"
size={inputValue ? inputValue.length + 1 : 1}
onClick={() => setIsFocused(true)}
{...getInputProps({ ref: inputRef })}
/>
</div>
<span className="dropdown" {...getMenuProps()}>
{isOpen && inputValue && suggestions.length ? (
<div className="dropdown-menu show shadow">
{suggestions.slice(0, 10).map((item, index) => (
<li
className={`dropdown-item cursor-pointer ${
highlightedIndex === index ? "active" : ""
}`}
key={`${item}${index}`}
{...getItemProps({ item, index })}
>
{item
.split(new RegExp(`(${inputValue})`, "gi"))
.map((part, i) => (
<span
key={i}
style={
part.toLowerCase() === inputValue.toLowerCase()
? { fontWeight: "bold" }
: {}
}
>
{part}
</span>
))}
</li>
))}
</div>
) : null}
</span>
</div>
<div className="input-group-append">
<History alertStore={alertStore} settingsStore={settingsStore} />