diff --git a/frontend/src/components/MultiSelectionTypeahead.tsx b/frontend/src/components/MultiSelectionTypeahead.tsx new file mode 100644 index 0000000000..86db014587 --- /dev/null +++ b/frontend/src/components/MultiSelectionTypeahead.tsx @@ -0,0 +1,330 @@ +import * as React from 'react'; +import { + Select, + SelectOption, + SelectList, + MenuToggle, + MenuToggleElement, + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities, + ChipGroup, + Chip, + Button, + SelectGroup, + Divider, +} from '@patternfly/react-core'; +import { TimesIcon } from '@patternfly/react-icons/dist/esm/icons/times-icon'; + +export type SelectionOptions = { + id: number | string; + name: string; + selected?: boolean; + isNew?: boolean; +}; + +export type GroupSelectionOptions = { + id: number | string; + name: string; + values: SelectionOptions[]; +}; + +type MultiSelectionTypeaheadProps = { + id?: string; + value?: SelectionOptions[]; + groupedValues?: GroupSelectionOptions[]; + setValue: (itemSelection: SelectionOptions[]) => void; + toggleId?: string; + ariaLabel: string; + placeholder?: string; + isDisabled?: boolean; + selectionRequired?: boolean; + noSelectedOptionsMessage?: string; + toggleTestId?: string; +}; + +export const MultiSelectionTypeahead: React.FC = ({ + value = [], + groupedValues = [], + setValue, + placeholder = 'Select a category', + isDisabled, + ariaLabel = 'Options menu', + id, + toggleId, + toggleTestId, +}) => { + const [isOpen, setIsOpen] = React.useState(false); + const [inputValue, setInputValue] = React.useState(''); + const [focusedItemIndex, setFocusedItemIndex] = React.useState(null); + const [activeItem, setActiveItem] = React.useState(null); + const textInputRef = React.useRef(); + + const selectGroups = React.useMemo(() => { + let counter = 0; + return groupedValues + .map((g) => { + const values = g.values.filter( + (v) => !inputValue || v.name.toLowerCase().includes(inputValue.toLowerCase()), + ); + return { + ...g, + values: values.map((v) => ({ ...v, index: counter++ })), + }; + }) + .filter((g) => g.values.length); + }, [inputValue, groupedValues]); + + const setOpen = (open: boolean) => { + setIsOpen(open); + if (!open) { + setInputValue(''); + } + }; + const groupOptions = selectGroups.reduce((acc, g) => { + acc.push(...g.values); + return acc; + }, []); + + const selectOptions = React.useMemo( + () => + value + .filter((v) => !inputValue || v.name.toLowerCase().includes(inputValue.toLowerCase())) + .map((v, index) => ({ ...v, index: groupOptions.length + index })), + [groupOptions, inputValue, value], + ); + + const allOptions = React.useMemo(() => { + const options = []; + groupedValues.forEach((group) => options.push(...group.values)); + options.push(...value); + + return options; + }, [groupedValues, value]); + + const visibleOptions = [...groupOptions, ...selectOptions]; + + const selected = React.useMemo(() => allOptions.filter((v) => v.selected), [allOptions]); + + React.useEffect(() => { + if (inputValue) { + setOpen(true); + } + setFocusedItemIndex(null); + setActiveItem(null); + }, [inputValue]); + + const handleMenuArrowKeys = (key: string) => { + let indexToFocus; + if (!isOpen) { + setOpen(true); + setFocusedItemIndex(0); + return; + } + + const optionsLength = visibleOptions.length; + + if (key === 'ArrowUp') { + if (focusedItemIndex === null || focusedItemIndex === 0) { + indexToFocus = optionsLength - 1; + } else { + indexToFocus = focusedItemIndex - 1; + } + } + + if (key === 'ArrowDown') { + if (focusedItemIndex === null || focusedItemIndex === optionsLength - 1) { + indexToFocus = 0; + } else { + indexToFocus = focusedItemIndex + 1; + } + } + + if (indexToFocus != null) { + setFocusedItemIndex(indexToFocus); + const focusedItem = visibleOptions[indexToFocus]; + setActiveItem(`select-multi-typeahead-${focusedItem.name.replace(' ', '-')}`); + } + }; + + const onInputKeyDown = (event: React.KeyboardEvent) => { + const focusedItem = focusedItemIndex !== null ? visibleOptions[focusedItemIndex] : null; + switch (event.key) { + case 'Enter': + if (isOpen && focusedItem) { + onSelect(focusedItem); + } else if (isOpen && inputValue) { + createOption(); + } + if (!isOpen) { + setIsOpen(true); + } + break; + case 'Tab': + case 'Escape': + setOpen(false); + setActiveItem(null); + break; + case 'ArrowUp': + case 'ArrowDown': + event.preventDefault(); + handleMenuArrowKeys(event.key); + break; + } + }; + + const onToggleClick = () => { + setOpen(!isOpen); + setTimeout(() => textInputRef.current?.focus(), 100); + }; + + const onTextInputChange = (_event: React.FormEvent, valueOfInput: string) => { + setInputValue(valueOfInput); + }; + + const onSelect = (menuItem?: SelectionOptions) => { + if (menuItem) { + setValue( + allOptions.map((option) => + option.id === menuItem.id ? { ...option, selected: !option.selected } : option, + ), + ); + } + textInputRef.current?.focus(); + }; + + const createOption = () => { + const newOption = { + id: `new-${inputValue}`, + name: inputValue, + selected: true, + isNew: true, + }; + setValue([...value, newOption]); + setInputValue(''); + }; + + const toggle = (toggleRef: React.Ref) => ( + + + + + {selected.map((selection, index) => ( + { + ev.stopPropagation(); + onSelect(selection); + }} + > + {selection.name} + + ))} + + + + {selected.length > 0 && ( + + )} + + + + ); + + return ( + <> + + + ); +}; \ No newline at end of file diff --git a/frontend/src/pages/connectionTypes/manage/ManageConnectionTypePage.tsx b/frontend/src/pages/connectionTypes/manage/ManageConnectionTypePage.tsx index c6b7ec6794..1918228977 100644 --- a/frontend/src/pages/connectionTypes/manage/ManageConnectionTypePage.tsx +++ b/frontend/src/pages/connectionTypes/manage/ManageConnectionTypePage.tsx @@ -24,19 +24,26 @@ import { NameDescType } from '~/pages/projects/types'; import CreateConnectionTypeFooter from './ManageConnectionTypeFooter'; import ManageConnectionTypeFieldsTable from './ManageConnectionTypeFieldsTable'; import ManageConnectionTypeBreadcrumbs from './ManageConnectionTypeBreadcrumbs'; +import { SelectionOptions } from '~/components/MultiSelection'; +import { GroupsConfigField } from '~/pages/groupSettings/groupTypes'; +import { useWatchGroups } from '~/utilities/useWatchGroups'; +import { MultiSelectionTypeahead } from '~/components/MultiSelectionTypeahead'; type Props = { prefill?: ConnectionTypeConfigMapObj; isEdit?: boolean; onSave: (obj: ConnectionTypeConfigMapObj) => Promise; + typeAhead?: string[]; }; -const ManageConnectionTypePage: React.FC = ({ prefill, isEdit, onSave }) => { +const ManageConnectionTypePage: React.FC = ({ prefill, isEdit, onSave, typeAhead }) => { const navigate = useNavigate(); const { username: currentUsername } = useUser(); const [isDrawerExpanded, setIsDrawerExpanded] = React.useState(false); + const { groupSettings, setGroupSettings, setIsGroupSettingsChanged } = useWatchGroups(); + const { k8sName: prefillK8sName, name: prefillName, @@ -82,6 +89,34 @@ const ManageConnectionTypePage: React.FC = ({ prefill, isEdit, onSave }) navigate('/connectionTypes'); }; + const userDesc = '*** NEED TO ADD ****'; + + const handleMenuItemSelection = (newState: SelectionOptions[], field: GroupsConfigField) => { + switch (field) { + case GroupsConfigField.ADMIN: + setGroupSettings({ + ...groupSettings, + adminGroups: newState.map((opt) => ({ + id: Number(opt.id), + name: opt.name, + enabled: opt.selected || false, + })), + }); + break; + case GroupsConfigField.USER: + setGroupSettings({ + ...groupSettings, + allowedGroups: newState.map((opt) => ({ + id: Number(opt.id), + name: opt.name, + enabled: opt.selected || false, + })), + }); + break; + } + setIsGroupSettingsChanged(true); + }; + return ( = ({ prefill, isEdit, onSave }) setData={setConnectionNameDesc} autoFocusName /> + + + ({ + id: g.id, + name: g.name, + selected: g.enabled, + }))} + setValue={(newState) => + handleMenuItemSelection(newState, GroupsConfigField.ADMIN) + } + selectionRequired + /> + + = ({ prefill, isEdit, onSave }) ); }; -export default ManageConnectionTypePage; +export default ManageConnectionTypePage; \ No newline at end of file