diff --git a/frontend/src/__mocks__/mockConnectionType.ts b/frontend/src/__mocks__/mockConnectionType.ts index e713e10fc7..0e5c5e2688 100644 --- a/frontend/src/__mocks__/mockConnectionType.ts +++ b/frontend/src/__mocks__/mockConnectionType.ts @@ -4,6 +4,7 @@ import { ConnectionTypeField, } from '~/concepts/connectionTypes/types'; import { toConnectionTypeConfigMap } from '~/concepts/connectionTypes/utils'; +import { GroupsConfig } from '~/pages/groupSettings/groupTypes'; type MockConnectionTypeConfigMap = { name?: string; @@ -542,3 +543,10 @@ const mockFields: ConnectionTypeField[] = [ }, }, ]; + +export const categoryOptions = [ + { id: 'object-storage', name: 'Object storage', selected: false }, + { id: 'database', name: 'Database', selected: false }, + { id: 'model-registry', name: 'Model registry', selected: false }, + { id: 'uri', name: 'URI', selected: false }, +]; diff --git a/frontend/src/components/MultiSelection.tsx b/frontend/src/components/MultiSelection.tsx index 0f141bf77d..723fd6b22e 100644 --- a/frontend/src/components/MultiSelection.tsx +++ b/frontend/src/components/MultiSelection.tsx @@ -42,6 +42,9 @@ type MultiSelectionProps = { selectionRequired?: boolean; noSelectedOptionsMessage?: string; toggleTestId?: string; + isCreatable?: boolean; + isCreateOptionOnTop?: boolean; + createOptionMessage?: string; }; export const MultiSelection: React.FC = ({ @@ -56,6 +59,9 @@ export const MultiSelection: React.FC = ({ toggleTestId, selectionRequired, noSelectedOptionsMessage = 'One or more options must be selected', + isCreatable = false, + isCreateOptionOnTop = false, + createOptionMessage = 'Create "{value}"', }) => { const [isOpen, setIsOpen] = React.useState(false); const [inputValue, setInputValue] = React.useState(''); @@ -84,6 +90,7 @@ export const MultiSelection: React.FC = ({ setInputValue(''); } }; + const groupOptions = selectGroups.reduce((acc, g) => { acc.push(...g.values); return acc; @@ -156,9 +163,8 @@ export const MultiSelection: React.FC = ({ case 'Enter': if (isOpen && focusedItem) { onSelect(focusedItem); - } - if (!isOpen) { - setIsOpen(true); + } else if (isCreatable && inputValue.trim()) { + createOption(inputValue); } break; case 'Tab': @@ -178,9 +184,11 @@ export const MultiSelection: React.FC = ({ setOpen(!isOpen); setTimeout(() => textInputRef.current?.focus(), 100); }; + const onTextInputChange = (_event: React.FormEvent, valueOfInput: string) => { setInputValue(valueOfInput); }; + const onSelect = (menuItem?: SelectionOptions) => { if (menuItem) { setValue( @@ -192,6 +200,12 @@ export const MultiSelection: React.FC = ({ textInputRef.current?.focus(); }; + const createOption = (newOption: string) => { + const newSelection = { id: newOption, name: newOption, selected: true }; + setValue([...allOptions, newSelection]); + setInputValue(''); + }; + const noSelectedItems = allOptions.filter((option) => option.selected).length === 0; const toggle = (toggleRef: React.Ref) => ( @@ -267,6 +281,15 @@ export const MultiSelection: React.FC = ({ onOpenChange={() => setOpen(false)} toggle={toggle} > + {isCreatable && inputValue && !allOptions.some(option => option.name.toLowerCase() === inputValue.toLowerCase()) && ( + createOption(inputValue)} + > + {createOptionMessage.replace('{value}', inputValue)} + + )} {visibleOptions.length === 0 && inputValue ? ( No results found diff --git a/frontend/src/components/MultiSelectionTypeahead.tsx b/frontend/src/components/MultiSelectionTypeahead.tsx deleted file mode 100644 index 86db014587..0000000000 --- a/frontend/src/components/MultiSelectionTypeahead.tsx +++ /dev/null @@ -1,330 +0,0 @@ -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 1918228977..afd6a2072e 100644 --- a/frontend/src/pages/connectionTypes/manage/ManageConnectionTypePage.tsx +++ b/frontend/src/pages/connectionTypes/manage/ManageConnectionTypePage.tsx @@ -27,7 +27,8 @@ 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'; +import { MultiSelection } from '~/components/MultiSelection'; +import { categoryOptions } from '~/__mocks__/mockConnectionType'; type Props = { prefill?: ConnectionTypeConfigMapObj; @@ -164,22 +165,17 @@ const ManageConnectionTypePage: React.FC = ({ prefill, isEdit, onSave, ty setData={setConnectionNameDesc} autoFocusName /> - - ({ - id: g.id, - name: g.name, - selected: g.enabled, - }))} + value={categoryOptions} setValue={(newState) => handleMenuItemSelection(newState, GroupsConfigField.ADMIN) } selectionRequired + isCreatable /> - = ({ prefill, isEdit, onSave, ty ); }; -export default ManageConnectionTypePage; \ No newline at end of file +export default ManageConnectionTypePage;