Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add 'No selection' option to GID selector #254

Merged
merged 2 commits into from
Feb 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
251 changes: 251 additions & 0 deletions src/components/TypeAheadSelectWithCreate.tsx
Original file line number Diff line number Diff line change
@@ -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<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") {
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;
74 changes: 27 additions & 47 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 @@ -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;
Expand Down Expand Up @@ -286,38 +287,26 @@ const AddUser = (props: PropsToAddUser) => {
});
};

// Select GID
const [GIDs, setGIDs] = useState<GroupId[]>([]);
const [isGidOpen, setIsGidOpen] = useState(false);
const [gidSelected, setGidSelected] = useState<string>("");
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<MenuToggleElement>) => (
<MenuToggle ref={toggleRef} onClick={gidOnToggle} className="pf-v5-u-w-100">
{gidSelected}
</MenuToggle>
);
const [GIDs, setGIDs] = useState<GroupId[]>([]);
const [gidSelected, setGidSelected] = useState<string>("");
const gidOptions = getGidOptions(GIDs);

// Checks if the passwords are filled and matches
const verifiedPasswords =
Expand Down Expand Up @@ -473,21 +462,12 @@ 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}
selected={gidSelected}
isOpen={isGidOpen}
aria-labelledby="gid"
>
{gidOptions.map((option, index) => (
<SelectOption key={index} value={option}>
{option}
</SelectOption>
))}
</Select>
onSelectedChange={setGidSelected}
/>
),
},
{
Expand Down
Loading