Skip to content

Commit

Permalink
Change Select into typeahead mode
Browse files Browse the repository at this point in the history
To replicate the same behavior as in the
current WebUI, the Select component should
also have the option to add a new GID value.

Signed-off-by: Carla Martinez <carlmart@redhat.com>
  • Loading branch information
carma12 committed Feb 22, 2024
1 parent 5519243 commit 97af0c3
Show file tree
Hide file tree
Showing 2 changed files with 285 additions and 52 deletions.
259 changes: 259 additions & 0 deletions src/components/TypeAheadSelectWithCreate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
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[];
onOptionsChange: (options: SelectOptionProps[]) => void;
selected: string;
onSelectedChange: (selected: string) => void;
}

const TypeAheadSelectWithCreate = (props: PropsToTypeAheadSelectWithCreate) => {
const [isOpen, setIsOpen] = React.useState(false);
const [inputValue, setInputValue] = React.useState<string>("");
const [filterValue, setFilterValue] = React.useState<string>("");
const [selectOptions, setSelectOptions] = React.useState<SelectOptionProps[]>(
props.options
);
const [focusedItemIndex, setFocusedItemIndex] = React.useState<number | null>(
null
);
const [activeItem, setActiveItem] = React.useState<string | null>(null);
const [onCreation, setOnCreation] = React.useState<boolean>(false); // Boolean to refresh filter state after new option is created
const textInputRef = React.useRef<HTMLInputElement>();

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<Element, MouseEvent> | undefined,
value: string | number | undefined
) => {
// eslint-disable-next-line no-console
if (value) {
if (value === "create") {
if (!props.options.some((item) => item.value === filterValue)) {
const newSelectOptions = [
...props.options,
{ value: filterValue, children: filterValue },
];
props.onOptionsChange(newSelectOptions);
}
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<HTMLInputElement>,
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<HTMLInputElement>) => {
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<MenuToggleElement>) => (
<MenuToggle
ref={toggleRef}
variant="typeahead"
onClick={onToggleClick}
isExpanded={isOpen}
isFullWidth
>
<TextInputGroup isPlain>
<TextInputGroupMain
value={inputValue}
onClick={onToggleClick}
onChange={onTextInputChange}
onKeyDown={onInputKeyDown}
id={props.id + "-select-create-typeahead"}
autoComplete="off"
innerRef={textInputRef}
{...(activeItem && { "aria-activedescendant": activeItem })}
role="combobox"
isExpanded={isOpen}
aria-controls="select-create-typeahead-listbox"
/>

<TextInputGroupUtilities>
{!!inputValue && (
<Button
variant="plain"
onClick={() => {
props.onSelectedChange("");
setInputValue("");
setFilterValue("");
textInputRef?.current?.focus();
}}
aria-label="Clear input value"
>
<TimesIcon aria-hidden />
</Button>
)}
</TextInputGroupUtilities>
</TextInputGroup>
</MenuToggle>
);

return (
<Select
id={props.id + "-select"}
isOpen={isOpen}
selected={props.selected}
onSelect={onSelect}
onOpenChange={() => {
setIsOpen(false);
}}
toggle={toggle}
>
<SelectList id={props.id + "-select-list"}>
{selectOptions.map((option, index) => (
<SelectOption
key={option.value || option.children}
isFocused={focusedItemIndex === index}
className={option.className}
id={props.id + "-select-option-" + index}
{...option}
ref={null}
/>
))}
</SelectList>
</Select>
);
};

export default TypeAheadSelectWithCreate;
78 changes: 26 additions & 52 deletions src/components/modals/AddUser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,7 @@ import {
Flex,
HelperText,
HelperTextItem,
MenuToggle,
MenuToggleElement,
Select,
SelectOption,
SelectOptionProps,
TextInput,
ValidatedOptions,
} from "@patternfly/react-core";
Expand Down Expand Up @@ -40,6 +37,8 @@ import ErrorModal from "./ErrorModal";
import useAlerts from "src/hooks/useAlerts";
// Utils
import { NO_SELECTION_OPTION } from "src/utils/constUtils";
// Components
import TypeAheadSelectWithCreate from "../TypeAheadSelectWithCreate";

interface GroupId {
cn: string;
Expand Down Expand Up @@ -288,48 +287,31 @@ const AddUser = (props: PropsToAddUser) => {
});
};

// Select GID
const assignGidOptions = () => {
const newGidOptions = GIDs.map((gid) => gid.cn);
newGidOptions.unshift(NO_SELECTION_OPTION);
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);
});

newGidOptions.unshift({
value: "",
children: NO_SELECTION_OPTION,
});
return newGidOptions;
};

const [GIDs, setGIDs] = useState<GroupId[]>([]);
const [isGidOpen, setIsGidOpen] = useState(false);
const [gidSelected, setGidSelected] = useState<string>("");
const gidOptions = assignGidOptions();
let gidOptions = getGidOptions(GIDs);

const gidOnToggle = () => {
setIsGidOpen(!isGidOpen);
const onChangeGidOptions = (newList: SelectOptionProps[]) => {
gidOptions = newList;
};

// Given a gid name, return gid number
const getGIDNumberFromName = (gidName: string) => {
if (gidName === NO_SELECTION_OPTION) {
return "";
}
for (let i = 0; i < GIDs.length; i++) {
if (gidName === GIDs[i].cn[0]) {
return GIDs[i].gidnumber[0];
}
}
};

// 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);
};

// Toggle
const toggleGid = (toggleRef: React.Ref<MenuToggleElement>) => (
<MenuToggle ref={toggleRef} onClick={gidOnToggle} className="pf-v5-u-w-100">
{gidSelected}
</MenuToggle>
);

// Checks if the passwords are filled and matches
const verifiedPasswords =
(newPassword === verifyNewPassword &&
Expand Down Expand Up @@ -484,21 +466,13 @@ const AddUser = (props: PropsToAddUser) => {
id: "gid-form",
name: "GID",
pfComponent: (
<Select
id="gidnumber"
aria-label="Select Input"
toggle={toggleGid}
onSelect={gidOnSelect}
<TypeAheadSelectWithCreate
id={"modal-form-gid"}
options={gidOptions}
onOptionsChange={onChangeGidOptions}
selected={gidSelected}
isOpen={isGidOpen}
aria-labelledby="gid"
>
{gidOptions.map((option, index) => (
<SelectOption key={index} value={option}>
{option}
</SelectOption>
))}
</Select>
onSelectedChange={setGidSelected}
/>
),
},
{
Expand Down

0 comments on commit 97af0c3

Please sign in to comment.