diff --git a/client/src/app/components/Autocomplete.tsx b/client/src/app/components/Autocomplete.tsx index 42576429e0..f6026a4a96 100644 --- a/client/src/app/components/Autocomplete.tsx +++ b/client/src/app/components/Autocomplete.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef } from "react"; +import React, { useState, useRef, useMemo } from "react"; import { Label, LabelProps, @@ -11,27 +11,49 @@ import { Popper, SearchInput, Divider, + Tooltip, } from "@patternfly/react-core"; +const toString = (input: string | (() => string)) => + typeof input === "function" ? input() : input; + +export interface AutocompleteOptionProps { + /** id for the option */ + id: number; + + /** the text to display for the option */ + name: string | (() => string); + + /** the text to display on a label when the option is selected, defaults to `name` if no supplied */ + labelName?: string | (() => string); + + /** the tooltip to display on the Label when the option has been selected */ + tooltip?: string | (() => string); +} + export interface IAutocompleteProps { - onChange: (selections: string[]) => void; + onChange: (selections: AutocompleteOptionProps[]) => void; id?: string; - allowUserOptions?: boolean; - options?: string[]; + + /** The set of options to use for selection */ + options?: AutocompleteOptionProps[]; + selections?: AutocompleteOptionProps[]; + placeholderText?: string; searchString?: string; searchInputAriaLabel?: string; labelColor?: LabelProps["color"]; - selections?: string[]; menuHeader?: string; noResultsMessage?: string; } +/** + * Multiple type-ahead with table complete and selection labels + */ export const Autocomplete: React.FC = ({ id = "", onChange, options = [], - allowUserOptions = false, placeholderText = "Search", searchString = "", searchInputAriaLabel = "Search input", @@ -41,117 +63,72 @@ export const Autocomplete: React.FC = ({ noResultsMessage = "No results found", }) => { const [inputValue, setInputValue] = useState(searchString); + const [tabSelectedItemId, setTabSelectedItemId] = useState(); const [menuIsOpen, setMenuIsOpen] = useState(false); - const [hint, setHint] = useState(""); - const [menuItems, setMenuItems] = useState([]); /** refs used to detect when clicks occur inside vs outside of the textInputGroup and menu popper */ const menuRef = useRef(null); const searchInputRef = useRef(null); - React.useEffect(() => { - buildMenu(); - }, [options]); - - const buildMenu = () => { - /** in the menu only show items that include the text in the input */ - const filteredMenuItems = options - .filter( - (item: string, index: number, arr: string[]) => - arr.indexOf(item) === index && - !selections.includes(item) && - (!inputValue || item.toLowerCase().includes(inputValue.toLowerCase())) - ) - .map((currentValue, index) => ( - - {currentValue} - - )); - - /** in the menu show a disabled "no result" when all menu items are filtered out */ - if (filteredMenuItems.length === 0) { - const noResultItem = ( - - {noResultsMessage} - - ); - setMenuItems([noResultItem]); - setHint(""); - return; + const selectedOptions = useMemo(() => { + if (!selections || selections.length === 0) { + return []; } + return options.filter( + ({ id }) => selections.findIndex((s) => s.id === id) > -1 + ); + }, [options, selections]); - /** The hint is set whenever there is only one autocomplete option left. */ - if (filteredMenuItems.length === 1 && inputValue.length) { - const hint = filteredMenuItems[0].props.children; - if (hint.toLowerCase().indexOf(inputValue.toLowerCase())) { - // the match was found in a place other than the start, so typeahead wouldn't work right - setHint(""); - } else { - // use the input for the first part, otherwise case difference could make things look wrong - setHint(inputValue + hint.substr(inputValue.length)); - } - } else { - setHint(""); + const filteredOptions = useMemo(() => { + // No input so do not filter! + if (!inputValue) { + return options; } - /** add a heading to the menu */ - const headingItem = ( - - {menuHeader} - + // filter to choose options that are 1. NOT selected, and 2. include the inputValue + return options.filter( + ({ id, name }) => + selections.findIndex((s) => s.id === id) === -1 && + toString(name).toLowerCase().includes(inputValue.toLocaleLowerCase()) ); + }, [options, selections, inputValue]); + + /** callback for removing a selection */ + const deleteSelectionByItemId = (idToDelete: number) => { + onChange(selections.filter(({ id }) => id !== idToDelete)); + }; - const divider = ; + /** lookup the option matching the itemId and add as a selection */ + const addSelectionByItemId = (itemId: string | number) => { + const asNumber = typeof itemId === "string" ? parseInt(itemId, 10) : itemId; + const matchingOption = options.find(({ id }) => id === asNumber); - if (menuHeader) { - setMenuItems([headingItem, divider, ...filteredMenuItems]); - } else { - setMenuItems(filteredMenuItems); - } + onChange([...selections, matchingOption].filter(Boolean)); + setInputValue(""); + setMenuIsOpen(false); }; /** callback for updating the inputValue state in this component so that the input can be controlled */ - const handleInputChange = ( + const handleSearchInputOnChange = ( _event: React.FormEvent, value: string ) => { setInputValue(value); - buildMenu(); - }; - - /** callback for removing a selection */ - const deleteSelection = (selectionToDelete: string) => { - onChange(selections.filter((s) => s !== selectionToDelete)); - }; - - /** add the given string as a selection */ - const addSelection = (newSelectionText: string) => { - if (!allowUserOptions) { - const matchingOption = options.find( - (o) => o.toLowerCase() === (hint || newSelectionText).toLowerCase() - ); - if (!matchingOption || selections.includes(matchingOption)) { - return; - } - newSelectionText = matchingOption; - } - onChange([...selections, newSelectionText]); - setInputValue(""); - setMenuIsOpen(false); }; /** add the current input value as a selection */ const handleEnter = () => { - if (inputValue.length) { - addSelection(inputValue); + if (tabSelectedItemId) { + addSelectionByItemId(tabSelectedItemId); + setTabSelectedItemId(undefined); } }; + /** close the menu, and if only 1 filtered option exists, select it */ const handleTab = (event: React.KeyboardEvent) => { - const firstItemIndex = menuHeader ? 2 : 0; - // if only 1 item (possibly including menu heading and divider) - if (menuItems.length === 1 + firstItemIndex) { - setInputValue(menuItems[firstItemIndex].props.children); + if (filteredOptions.length === 1) { + setInputValue(toString(filteredOptions[0].name)); + setTabSelectedItemId(filteredOptions[0].id); event.preventDefault(); } setMenuIsOpen(false); @@ -180,7 +157,7 @@ export const Autocomplete: React.FC = ({ }; /** enable keyboard only usage while focused on the text input */ - const handleTextInputKeyDown = (event: React.KeyboardEvent) => { + const handleSearchInputOnKeyDown = (event: React.KeyboardEvent) => { switch (event.key) { case "Enter": handleEnter(); @@ -207,18 +184,21 @@ export const Autocomplete: React.FC = ({ }; /** add the text of the selected menu item to the selected items */ - const onSelect = (event?: React.MouseEvent) => { - if (!event) { + const handleMenuItemOnSelect = ( + event: React.MouseEvent | undefined, + itemId: number + ) => { + if (!event || !itemId) { return; } - const selectedText = (event.target as HTMLElement).innerText; - addSelection(selectedText); + event.stopPropagation(); focusTextInput(true); + addSelectionByItemId(itemId); }; /** close the menu when a click occurs outside of the menu or text input group */ - const handleClick = (event?: MouseEvent) => { + const handleOnDocumentClick = (event?: MouseEvent) => { if (!event) { return; } @@ -236,7 +216,7 @@ export const Autocomplete: React.FC = ({ }; /** enable keyboard only usage while focused on the menu */ - const handleMenuKeyDown = (event: React.KeyboardEvent) => { + const handleMenuOnKeyDown = (event: React.KeyboardEvent) => { switch (event.key) { case "Tab": case "Escape": @@ -247,16 +227,36 @@ export const Autocomplete: React.FC = ({ } }; + const hint = useMemo(() => { + if (filteredOptions.length === 0) { + return ""; + } + + if (filteredOptions.length === 1 && inputValue) { + const fullHint = toString(filteredOptions[0].name); + + if (fullHint.toLowerCase().indexOf(inputValue.toLowerCase())) { + // the match was found in a place other than the start, so typeahead wouldn't work right + return ""; + } else { + // use the input for the first part, otherwise case difference could make things look wrong + return inputValue + fullHint.substring(inputValue.length); + } + } + + return ""; + }, [filteredOptions, inputValue]); + const inputGroup = (
setInputValue("")} onFocus={() => setMenuIsOpen(true)} - onKeyDown={handleTextInputKeyDown} + onKeyDown={handleSearchInputOnKeyDown} placeholder={placeholderText} aria-label={searchInputAriaLabel} /> @@ -266,12 +266,40 @@ export const Autocomplete: React.FC = ({ const menu = ( - {menuItems} + + {/* if supplied, add the menu heading */} + {menuHeader ? ( + <> + + {menuHeader} + + + + ) : undefined} + + {/* show a disabled "no result" when all menu items are filtered out */} + {filteredOptions.length === 0 ? ( + + {noResultsMessage} + + ) : undefined} + + {/* only show items that include the text in the input */} + {filteredOptions.map(({ id, name }, _index) => ( + handleMenuItemOnSelect(e, id)} + > + {toString(name)} + + ))} + ); @@ -286,19 +314,21 @@ export const Autocomplete: React.FC = ({ popperRef={menuRef} appendTo={() => searchInputRef.current || document.body} isVisible={menuIsOpen} - onDocumentClick={handleClick} + onDocumentClick={handleOnDocumentClick} /> - {selections.map((currentSelection) => ( - - + {selectedOptions.map(({ id, name, labelName, tooltip }) => ( + + + + ))} @@ -306,3 +336,13 @@ export const Autocomplete: React.FC = ({ ); }; + +const LabelToolip: React.FC<{ + content?: AutocompleteOptionProps["tooltip"]; + children: React.ReactElement; +}> = ({ content, children }) => + content ? ( + {toString(content)}
}>{children} + ) : ( + children + ); diff --git a/client/src/app/components/HookFormPFFields/HookFormAutocomplete.tsx b/client/src/app/components/HookFormPFFields/HookFormAutocomplete.tsx new file mode 100644 index 0000000000..fcfb089795 --- /dev/null +++ b/client/src/app/components/HookFormPFFields/HookFormAutocomplete.tsx @@ -0,0 +1,57 @@ +import React from "react"; +import { Control, FieldValues, Path } from "react-hook-form"; +import { HookFormPFGroupController } from "@app/components/HookFormPFFields"; +import { + Autocomplete, + AutocompleteOptionProps, +} from "@app/components/Autocomplete"; + +// TODO: Does not support select menu grouping by category +// TODO: Does not support select menu selection checkboxes +// TODO: Does not support rendering item labels with item category color +// TODO: Does not support rendering item labels in item category groups + +export const HookFormAutocomplete = ({ + items = [], + label, + fieldId, + name, + control, + noResultsMessage, + placeholderText, + searchInputAriaLabel, + isRequired = false, +}: { + items: AutocompleteOptionProps[]; + name: Path; + control: Control; + label: string; + fieldId: string; + noResultsMessage: string; + placeholderText: string; + searchInputAriaLabel: string; + isRequired?: boolean; +}) => ( + ( + { + onChange(selection); + }} + /> + )} + /> +); + +export default HookFormAutocomplete; diff --git a/client/src/app/components/HookFormPFFields/index.ts b/client/src/app/components/HookFormPFFields/index.ts index 03ce2a09fd..edb2e945d1 100644 --- a/client/src/app/components/HookFormPFFields/index.ts +++ b/client/src/app/components/HookFormPFFields/index.ts @@ -1,3 +1,4 @@ export * from "./HookFormPFGroupController"; export * from "./HookFormPFTextInput"; export * from "./HookFormPFTextArea"; +export * from "./HookFormAutocomplete"; diff --git a/client/src/app/components/items-select/items-select.tsx b/client/src/app/components/items-select/items-select.tsx deleted file mode 100644 index 83372fbadd..0000000000 --- a/client/src/app/components/items-select/items-select.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import React from "react"; -import { Control, FieldValues, Path } from "react-hook-form"; -import { HookFormPFGroupController } from "@app/components/HookFormPFFields"; -import { Autocomplete } from "@app/components/Autocomplete"; - -// TODO: Currently only supports working with tag names (which only work if item names are globally unique) -// TODO: Does not support select menu grouping by category -// TODO: Does not support select menu selection checkboxes -// TODO: Does not support rendering item labels with item category color -// TODO: Does not support rendering item labels in item category groups - -const ItemsSelect = < - ItemType extends { name: string }, - FormValues extends FieldValues, ->({ - items = [], - label, - fieldId, - name, - control, - noResultsMessage, - placeholderText, - searchInputAriaLabel, - isRequired = false, -}: { - items: ItemType[]; - name: Path; - control: Control; - label: string; - fieldId: string; - noResultsMessage: string; - placeholderText: string; - searchInputAriaLabel: string; - isRequired?: boolean; -}) => { - const itemsToName = () => items.map((item) => item.name).sort(); - - const normalizeSelections = (values: string | string[] | undefined) => - (Array.isArray(values) ? values : [values]).filter(Boolean) as string[]; - - return ( - ( - { - onChange(selection as any); - }} - /> - )} - /> - ); -}; - -export default ItemsSelect;