diff --git a/src/components/TypeAheadSelectWithCreate.tsx b/src/components/TypeAheadSelectWithCreate.tsx new file mode 100644 index 00000000..0f85eb7f --- /dev/null +++ b/src/components/TypeAheadSelectWithCreate.tsx @@ -0,0 +1,251 @@ +import React from "react"; +// PatternFly +import { + Button, + MenuToggle, + MenuToggleElement, + Select, + SelectList, + SelectOption, + SelectOptionProps, + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities, +} from "@patternfly/react-core"; +import TimesIcon from "@patternfly/react-icons/dist/esm/icons/times-icon"; + +interface PropsToTypeAheadSelectWithCreate { + id: string; + options: SelectOptionProps[]; + selected: string; + onSelectedChange: (selected: string) => void; +} + +const TypeAheadSelectWithCreate = (props: PropsToTypeAheadSelectWithCreate) => { + const [isOpen, setIsOpen] = React.useState(false); + const [inputValue, setInputValue] = React.useState(""); + const [filterValue, setFilterValue] = React.useState(""); + const [selectOptions, setSelectOptions] = React.useState( + props.options + ); + const [focusedItemIndex, setFocusedItemIndex] = React.useState( + null + ); + const [activeItem, setActiveItem] = React.useState(null); + const [onCreation, setOnCreation] = React.useState(false); // Boolean to refresh filter state after new option is created + const textInputRef = React.useRef(); + + React.useEffect(() => { + setSelectOptions(props.options); + }, [props.options]); + + React.useEffect(() => { + let newSelectOptions: SelectOptionProps[] = props.options; + + // Filter menu items based on the text input value when one exists + if (filterValue) { + newSelectOptions = props.options.filter((menuItem) => + String(menuItem.children) + .toLowerCase() + .includes(filterValue.toLowerCase()) + ); + + // When no options are found after filtering, display creation option + if (!newSelectOptions.length) { + newSelectOptions = [ + { + isDisabled: false, + children: `Create new option "${filterValue}"`, + value: "create", + }, + ]; + } + + // Open the menu when the input value changes and the new value is not empty + if (!isOpen) { + setIsOpen(true); + } + } + + setSelectOptions(newSelectOptions); + setActiveItem(null); + setFocusedItemIndex(null); + }, [filterValue, onCreation]); + + const onToggleClick = () => { + setIsOpen(!isOpen); + }; + + const onSelect = ( + _event: React.MouseEvent | undefined, + value: string | number | undefined + ) => { + // eslint-disable-next-line no-console + if (value) { + if (value === "create") { + props.onSelectedChange(filterValue); + setOnCreation(!onCreation); + setFilterValue(""); + } else { + setInputValue(value as string); + setFilterValue(""); + props.onSelectedChange(value as string); + } + } + + setIsOpen(false); + setFocusedItemIndex(null); + setActiveItem(null); + }; + + const onTextInputChange = ( + _event: React.FormEvent, + value: string + ) => { + setInputValue(value); + setFilterValue(value); + }; + + const handleMenuArrowKeys = (key: string) => { + let indexToFocus; + + if (isOpen) { + if (key === "ArrowUp") { + // When no index is set or at the first index, focus to the last, otherwise decrement focus index + if (focusedItemIndex === null || focusedItemIndex === 0) { + indexToFocus = selectOptions.length - 1; + } else { + indexToFocus = focusedItemIndex - 1; + } + } + + if (key === "ArrowDown") { + // When no index is set or at the last index, focus to the first, otherwise increment focus index + if ( + focusedItemIndex === null || + focusedItemIndex === selectOptions.length - 1 + ) { + indexToFocus = 0; + } else { + indexToFocus = focusedItemIndex + 1; + } + } + + setFocusedItemIndex(indexToFocus); + const focusedItem = selectOptions.filter((option) => !option.isDisabled)[ + indexToFocus + ]; + setActiveItem( + `select-create-typeahead-${focusedItem.value.replace(" ", "-")}` + ); + } + }; + + const onInputKeyDown = (event: React.KeyboardEvent) => { + const enabledMenuItems = selectOptions.filter( + (option) => !option.isDisabled + ); + const [firstMenuItem] = enabledMenuItems; + const focusedItem = focusedItemIndex + ? enabledMenuItems[focusedItemIndex] + : firstMenuItem; + + switch (event.key) { + // Select the first available option + case "Enter": + if (isOpen) { + onSelect(undefined, focusedItem.value as string); + setIsOpen((prevIsOpen) => !prevIsOpen); + setFocusedItemIndex(null); + setActiveItem(null); + } + + setIsOpen((prevIsOpen) => !prevIsOpen); + setFocusedItemIndex(null); + setActiveItem(null); + + break; + case "Tab": + case "Escape": + setIsOpen(false); + setActiveItem(null); + break; + case "ArrowUp": + case "ArrowDown": + event.preventDefault(); + handleMenuArrowKeys(event.key); + break; + } + }; + + const toggle = (toggleRef: React.Ref) => ( + + + + + + {!!inputValue && ( + + )} + + + + ); + + return ( + + ); +}; + +export default TypeAheadSelectWithCreate; diff --git a/src/components/modals/AddUser.tsx b/src/components/modals/AddUser.tsx index 39ddfc66..6a1f3f59 100644 --- a/src/components/modals/AddUser.tsx +++ b/src/components/modals/AddUser.tsx @@ -6,10 +6,7 @@ import { Flex, HelperText, HelperTextItem, - MenuToggle, - MenuToggleElement, - Select, - SelectOption, + SelectOptionProps, TextInput, ValidatedOptions, } from "@patternfly/react-core"; @@ -38,6 +35,10 @@ import { SerializedError } from "@reduxjs/toolkit"; import ErrorModal from "./ErrorModal"; // Hooks import useAlerts from "src/hooks/useAlerts"; +// Utils +import { NO_SELECTION_OPTION } from "src/utils/constUtils"; +// Components +import TypeAheadSelectWithCreate from "../TypeAheadSelectWithCreate"; interface GroupId { cn: string; @@ -286,38 +287,26 @@ const AddUser = (props: PropsToAddUser) => { }); }; - // Select GID - const [GIDs, setGIDs] = useState([]); - const [isGidOpen, setIsGidOpen] = useState(false); - const [gidSelected, setGidSelected] = useState(""); - const gidOptions = GIDs.map((gid) => gid.cn); - - const gidOnToggle = () => { - setIsGidOpen(!isGidOpen); - }; - - // Given a gid name, return gid number - const getGIDNumberFromName = (gidName: string) => { - for (let i = 0; i < GIDs.length; i++) { - if (gidName === GIDs[i].cn[0]) { - return GIDs[i].gidnumber[0]; - } - } - }; + const getGidOptions = (groups: GroupId[]) => { + const newGidOptions: SelectOptionProps[] = []; + groups.map((gid) => { + const item = { + value: gid.gidnumber[0], + children: gid.cn[0], + } as SelectOptionProps; + newGidOptions.push(item); + }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const gidOnSelect = (selection: any) => { - const gidnumber = getGIDNumberFromName(selection.target.textContent); - setGidSelected(gidnumber as string); - setIsGidOpen(false); + newGidOptions.unshift({ + value: "", + children: NO_SELECTION_OPTION, + }); + return newGidOptions; }; - // Toggle - const toggleGid = (toggleRef: React.Ref) => ( - - {gidSelected} - - ); + const [GIDs, setGIDs] = useState([]); + const [gidSelected, setGidSelected] = useState(""); + const gidOptions = getGidOptions(GIDs); // Checks if the passwords are filled and matches const verifiedPasswords = @@ -473,21 +462,12 @@ const AddUser = (props: PropsToAddUser) => { id: "gid-form", name: "GID", pfComponent: ( - + onSelectedChange={setGidSelected} + /> ), }, {