diff --git a/locales/en/plugin__kubevirt-plugin.json b/locales/en/plugin__kubevirt-plugin.json index edfbb440f..88bc2d9f6 100644 --- a/locales/en/plugin__kubevirt-plugin.json +++ b/locales/en/plugin__kubevirt-plugin.json @@ -246,7 +246,6 @@ "Checking this option will create a new PVC of the bootsource for the new template": "Checking this option will create a new PVC of the bootsource for the new template", "Checkups": "Checkups", "Clear all filters": "Clear all filters", - "Clear input value": "Clear input value", "CLI": "CLI", "Click <1>Add bootable volume to add your first bootable volume": "Click <1>Add bootable volume to add your first bootable volume", "Click <1>Create MigrationPolicy to create your first policy": "Click <1>Create MigrationPolicy to create your first policy", diff --git a/src/utils/components/FolderSelect/FolderSelect.tsx b/src/utils/components/FolderSelect/FolderSelect.tsx index 425fee855..b19f724f2 100644 --- a/src/utils/components/FolderSelect/FolderSelect.tsx +++ b/src/utils/components/FolderSelect/FolderSelect.tsx @@ -2,8 +2,10 @@ import React, { FC } from 'react'; import { useKubevirtTranslation } from '@kubevirt-utils/hooks/useKubevirtTranslation'; +import SelectTypeahead from '../SelectTypeahead/SelectTypeahead'; +import { getCreateNewFolderOption } from '../SelectTypeahead/utils/utils'; + import useFolderOptions from './hooks/useFolderOptions'; -import SelectTypeahead from './SelectTypeahead'; type FoldersSelectProps = { isFullWidth?: boolean; @@ -24,6 +26,7 @@ const FolderSelect: FC = ({ getCreateNewFolderOption(inputValue, canCreate)} initialOptions={folderOptions} isFullWidth={isFullWidth} placeholder={t('Search folder')} diff --git a/src/utils/components/FolderSelect/SelectTypeahead.tsx b/src/utils/components/FolderSelect/SelectTypeahead.tsx deleted file mode 100644 index aaf74c6f0..000000000 --- a/src/utils/components/FolderSelect/SelectTypeahead.tsx +++ /dev/null @@ -1,275 +0,0 @@ -import React, { Dispatch, FC, SetStateAction, useEffect, useRef, useState } from 'react'; - -import { isEmpty } from '@kubevirt-utils/utils/utils'; -import { - Button, - ButtonVariant, - MenuToggle, - MenuToggleElement, - Select, - SelectList, - SelectOption, - SelectOptionProps, - TextInputGroup, - TextInputGroupMain, - TextInputGroupUtilities, -} from '@patternfly/react-core'; -import { FolderIcon, SearchIcon } from '@patternfly/react-icons'; -import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon'; - -import { CREATE_NEW } from './utils/constants'; -import { createItemId, getCreateNewFolderOption } from './utils/utils'; - -type SelectTypeaheadProps = { - canCreate?: boolean; - dataTestId?: string; - initialOptions: SelectOptionProps[]; - isFullWidth?: boolean; - placeholder?: string; - selected: string; - setInitialOptions: Dispatch>; - setSelected: (newFolder: string) => void; -}; -const SelectTypeahead: FC = ({ - canCreate = false, - dataTestId, - initialOptions, - isFullWidth = false, - placeholder, - selected, - setInitialOptions, - setSelected, -}) => { - const [isOpen, setIsOpen] = useState(false); - const [inputValue, setInputValue] = useState(selected); - const [filterValue, setFilterValue] = useState(''); - const [selectOptions, setSelectOptions] = useState(initialOptions); - const [focusedItemIndex, setFocusedItemIndex] = useState(null); - const [activeItemId, setActiveItemId] = useState(null); - const textInputRef = useRef(); - - useEffect(() => { - if (isEmpty(initialOptions)) { - setSelectOptions([getCreateNewFolderOption(filterValue, canCreate)]); - return; - } - let newSelectOptions: SelectOptionProps[] = initialOptions || []; - - // Filter menu items based on the text input value when one exists - if (filterValue) { - newSelectOptions = initialOptions?.filter((menuItem) => - String(menuItem.children).toLowerCase().includes(filterValue.toLowerCase()), - ); - - // If no option matches the filter exactly, display creation option - if (!initialOptions?.some((option) => option.value === filterValue) && canCreate) { - newSelectOptions = [...newSelectOptions, getCreateNewFolderOption(filterValue, canCreate)]; - } - } - - setSelectOptions(newSelectOptions); - }, [canCreate, filterValue, initialOptions]); - - const setActiveAndFocusedItem = (itemIndex: number) => { - setFocusedItemIndex(itemIndex); - const focusedItem = selectOptions[itemIndex]; - setActiveItemId(createItemId(focusedItem.value)); - }; - - const resetActiveAndFocusedItem = () => { - setFocusedItemIndex(null); - setActiveItemId(null); - }; - - const closeMenu = () => { - setIsOpen(false); - resetActiveAndFocusedItem(); - }; - - const selectOption = (value: number | string, content: number | string) => { - setInputValue(String(content)); - setFilterValue(''); - setSelected(String(value)); - - closeMenu(); - }; - - const onSelect = ( - _event: React.MouseEvent | undefined, - value: number | string | undefined, - ) => { - if (value) { - if (value === CREATE_NEW) { - if (!initialOptions?.some((item) => item.children === filterValue)) { - setInitialOptions((prevFolders) => [ - ...(prevFolders || []), - { children: filterValue, icon: , value: filterValue }, - ]); - } - setSelected(filterValue); - setFilterValue(''); - closeMenu(); - } else { - const optionText = selectOptions.find((option) => option.value === value)?.children; - selectOption(value, optionText as string); - } - } - }; - - const onTextInputChange = (_event: React.FormEvent, value: string) => { - setInputValue(value); - setFilterValue(value); - - if (!isEmpty(value) && !isOpen) setIsOpen(true); - - resetActiveAndFocusedItem(); - - if (value !== selected) { - setSelected(''); - } - }; - - const handleMenuArrowKeys = (key: string) => { - let indexToFocus = 0; - - if (!isOpen) { - setIsOpen(true); - } - - if (selectOptions.every((option) => option.isDisabled)) { - return; - } - - 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; - } - - // Skip disabled options - while (selectOptions[indexToFocus].isDisabled) { - indexToFocus--; - if (indexToFocus === -1) { - indexToFocus = selectOptions.length - 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; - } - - // Skip disabled options - while (selectOptions[indexToFocus].isDisabled) { - indexToFocus++; - if (indexToFocus === selectOptions.length) { - indexToFocus = 0; - } - } - } - - setActiveAndFocusedItem(indexToFocus); - }; - - const onInputKeyDown = (event: React.KeyboardEvent) => { - const focusedItem = focusedItemIndex !== null ? selectOptions[focusedItemIndex] : null; - - switch (event.key) { - case 'Enter': - if (isOpen && focusedItem && !focusedItem.isAriaDisabled) { - onSelect(undefined, focusedItem.value as string); - } - - if (!isOpen) { - setIsOpen(true); - } - - break; - case 'ArrowUp': - case 'ArrowDown': - event.preventDefault(); - handleMenuArrowKeys(event.key); - break; - } - }; - - const onToggleClick = () => { - setIsOpen((open) => !open); - textInputRef?.current?.focus(); - }; - - const onClearButtonClick = () => { - setSelected(''); - setInputValue(''); - setFilterValue(''); - resetActiveAndFocusedItem(); - textInputRef?.current?.focus(); - }; - - const toggle = (toggleRef: React.Ref) => ( - - - } - innerRef={textInputRef} - onChange={onTextInputChange} - onClick={onToggleClick} - onKeyDown={onInputKeyDown} - placeholder={placeholder} - value={inputValue} - {...(activeItemId && { 'aria-activedescendant': activeItemId })} - isExpanded={isOpen} - role="combobox" - /> - - {!isEmpty(inputValue) && ( - - - - )} - - - ); - - return ( - - ); -}; - -export default SelectTypeahead; diff --git a/src/utils/components/NetworkInterfaceModal/components/NetworkInterfaceNetworkSelect/NetworkInterfaceNetworkSelect.tsx b/src/utils/components/NetworkInterfaceModal/components/NetworkInterfaceNetworkSelect/NetworkInterfaceNetworkSelect.tsx index ec78ee080..39ff2161f 100644 --- a/src/utils/components/NetworkInterfaceModal/components/NetworkInterfaceNetworkSelect/NetworkInterfaceNetworkSelect.tsx +++ b/src/utils/components/NetworkInterfaceModal/components/NetworkInterfaceNetworkSelect/NetworkInterfaceNetworkSelect.tsx @@ -151,14 +151,15 @@ const NetworkInterfaceNetworkSelect: FC = ({ ) : ( ( + getCreateOption={(inputValue) => ( <> {t(`Use "{{inputValue}}"`, { inputValue })}{' '} )} - id="select-nad" - options={networkOptions} + dataTestId="select-nad" + initialOptions={networkOptions} + isFullWidth placeholder={t('Select a NetworkAttachmentDefinitions')} selected={networkName} setSelected={handleChange} diff --git a/src/utils/components/SelectTypeahead/SelectTypeahead.tsx b/src/utils/components/SelectTypeahead/SelectTypeahead.tsx index 329ba5e5a..1d3a0c5c2 100644 --- a/src/utils/components/SelectTypeahead/SelectTypeahead.tsx +++ b/src/utils/components/SelectTypeahead/SelectTypeahead.tsx @@ -1,97 +1,277 @@ -import React, { FC, ReactNode, Ref, useRef, useState } from 'react'; +import React, { Dispatch, FC, SetStateAction, useEffect, useRef, useState } from 'react'; -import { useKubevirtTranslation } from '@kubevirt-utils/hooks/useKubevirtTranslation'; import { isEmpty } from '@kubevirt-utils/utils/utils'; import { + Button, + ButtonVariant, + MenuToggle, MenuToggleElement, Select, SelectList, SelectOption, SelectOptionProps, + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities, } from '@patternfly/react-core'; +import { FolderIcon, SearchIcon } from '@patternfly/react-icons'; +import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon'; -import Toggle from './Toggle'; -import { filterOptions } from './utils'; +import { CREATE_NEW } from './utils/constants'; +import { createItemId } from './utils/utils'; type SelectTypeaheadProps = { - id: string; - newOptionComponent?: (inputValue: string) => ReactNode; - options: SelectOptionProps[]; - placeholder: string; + canCreate?: boolean; + dataTestId?: string; + getCreateOption?: (inputValue: string, canCreate?: boolean) => SelectOptionProps; + initialOptions: SelectOptionProps[]; + isFullWidth?: boolean; + placeholder?: string; selected: string; - setSelected: (newSelection: null | string) => void; + setInitialOptions?: Dispatch>; + setSelected: (newFolder: string) => void; }; const SelectTypeahead: FC = ({ - id, - newOptionComponent, - options, + canCreate = false, + dataTestId, + getCreateOption, + initialOptions, + isFullWidth = false, placeholder, selected, + setInitialOptions, setSelected, }) => { - const { t } = useKubevirtTranslation(); const [isOpen, setIsOpen] = useState(false); - const [inputValue, setInputValue] = useState(selected || ''); + const [inputValue, setInputValue] = useState(selected); + const [filterValue, setFilterValue] = useState(''); + const [selectOptions, setSelectOptions] = useState(initialOptions); const [focusedItemIndex, setFocusedItemIndex] = useState(null); + const [activeItemId, setActiveItemId] = useState(null); const textInputRef = useRef(); - const selectOptions = inputValue ? filterOptions(options, inputValue) : options; + useEffect(() => { + if (isEmpty(initialOptions)) { + setSelectOptions([getCreateOption(filterValue, canCreate)]); + return; + } + let newSelectOptions: SelectOptionProps[] = initialOptions || []; + + // Filter menu items based on the text input value when one exists + if (filterValue) { + newSelectOptions = initialOptions?.filter((menuItem) => + String(menuItem.children).toLowerCase().includes(filterValue.toLowerCase()), + ); + + // If no option matches the filter exactly, display creation option + if (!initialOptions?.some((option) => option.value === filterValue) && canCreate) { + newSelectOptions = [...newSelectOptions, getCreateOption(filterValue, canCreate)]; + } + } + + setSelectOptions(newSelectOptions); + }, [canCreate, filterValue, getCreateOption, initialOptions]); + + const setActiveAndFocusedItem = (itemIndex: number) => { + setFocusedItemIndex(itemIndex); + const focusedItem = selectOptions[itemIndex]; + setActiveItemId(createItemId(focusedItem.value)); + }; - const onSelect = (value: string) => { + const resetActiveAndFocusedItem = () => { + setFocusedItemIndex(null); + setActiveItemId(null); + }; + + const closeMenu = () => { + setIsOpen(false); + resetActiveAndFocusedItem(); + }; + + const selectOption = (value: number | string, content: number | string) => { + setInputValue(String(content)); + setFilterValue(''); + setSelected(String(value)); + + closeMenu(); + }; + + const onSelect = ( + _event: React.MouseEvent | undefined, + value: number | string | undefined, + ) => { if (value) { - setInputValue(value); + if (value === CREATE_NEW) { + if (!initialOptions?.some((item) => item.children === filterValue)) { + setInitialOptions?.((prevFolders) => [ + ...(prevFolders || []), + { children: filterValue, icon: , value: filterValue }, + ]); + } + setSelected(filterValue); + setFilterValue(''); + closeMenu(); + } else { + const optionText = selectOptions.find((option) => option.value === value)?.children; + selectOption(value, optionText as string); + } + } + }; + + const onTextInputChange = (_event: React.FormEvent, value: string) => { + setInputValue(value); + setFilterValue(value); + + if (!isEmpty(value) && !isOpen) setIsOpen(true); + + resetActiveAndFocusedItem(); + + if (value !== selected) { + setSelected(''); + } + }; + + const handleMenuArrowKeys = (key: string) => { + let indexToFocus = 0; + + if (!isOpen) { setIsOpen(true); } - textInputRef.current?.focus(); + if (selectOptions.every((option) => option.isDisabled)) { + return; + } + + 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; + } + + // Skip disabled options + while (selectOptions[indexToFocus].isDisabled) { + indexToFocus--; + if (indexToFocus === -1) { + indexToFocus = selectOptions.length - 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; + } + + // Skip disabled options + while (selectOptions[indexToFocus].isDisabled) { + indexToFocus++; + if (indexToFocus === selectOptions.length) { + indexToFocus = 0; + } + } + } + + setActiveAndFocusedItem(indexToFocus); }; - return ( - { + !open && closeMenu(); + }} + data-test={dataTestId} + id={dataTestId} isOpen={isOpen} - onOpenChange={() => setIsOpen(false)} - onSelect={(ev, selection) => onSelect(selection as string)} + onSelect={onSelect} selected={selected} + toggle={toggle} > - - {selectOptions.map((option, index) => ( + + {selectOptions?.map((option, index) => ( - {option.children || option.value} - + /> ))} - {!isEmpty(inputValue) && ( - - {newOptionComponent - ? newOptionComponent(inputValue) - : t(`Use "{{inputValue}}"`, { inputValue })} - - )} ); }; diff --git a/src/utils/components/SelectTypeahead/Toggle.tsx b/src/utils/components/SelectTypeahead/Toggle.tsx deleted file mode 100644 index dff2f2146..000000000 --- a/src/utils/components/SelectTypeahead/Toggle.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import React, { - Dispatch, - FC, - FormEvent, - KeyboardEvent, - Ref, - RefObject, - SetStateAction, -} from 'react'; - -import { useKubevirtTranslation } from '@kubevirt-utils/hooks/useKubevirtTranslation'; -import { isEmpty } from '@kubevirt-utils/utils/utils'; -import { - Button, - ButtonVariant, - MenuToggle, - MenuToggleElement, - SelectOptionProps, - TextInputGroup, - TextInputGroupMain, - TextInputGroupUtilities, -} from '@patternfly/react-core'; -import { TimesIcon } from '@patternfly/react-icons'; - -import { handleMenuArrowKeys } from './utils'; - -type ToggleProps = { - focusedItemIndex: null | number; - inputValue: string; - isOpen: boolean; - onSelect: (value: string) => void; - placeholder: string; - selected: string; - selectOptions: SelectOptionProps[]; - setFocusedItemIndex: (newValue: null | number) => void; - setInputValue: (newInput: string) => void; - setIsOpen: Dispatch>; - setSelected: (newSelection: string) => void; - textInputRef: RefObject; - toggleRef: Ref; -}; - -const Toggle: FC = ({ - focusedItemIndex, - inputValue, - isOpen, - onSelect, - placeholder, - selected, - selectOptions, - setFocusedItemIndex, - setInputValue, - setIsOpen, - setSelected, - textInputRef, - toggleRef, -}) => { - const { t } = useKubevirtTranslation(); - - const onInputKeyDown = (event: KeyboardEvent) => { - const enabledMenuItems = selectOptions.filter((menuItem) => !menuItem.isDisabled); - const [firstMenuItem] = enabledMenuItems; - const focusedItem = focusedItemIndex ? enabledMenuItems?.[focusedItemIndex] : firstMenuItem; - - switch (event.key) { - // Select the first available option - case 'Enter': - isOpen ? onSelect(focusedItem.value as string) : setIsOpen((prevIsOpen) => !prevIsOpen); - break; - case 'Escape': - setIsOpen(false); - break; - case 'ArrowUp': - case 'ArrowDown': - event.preventDefault(); - - if (isOpen) { - const indexToFocus = handleMenuArrowKeys(event.key, focusedItemIndex, selectOptions); - - setFocusedItemIndex(indexToFocus); - } - - if (!isOpen) setIsOpen(true); - break; - } - }; - - const onToggleClick = () => { - setIsOpen(!isOpen); - }; - - const onTextInputChange = (_event: FormEvent, value: string) => { - setInputValue(value); - setFocusedItemIndex(null); - - if (value) setIsOpen(true); - }; - - return ( - - - - - {!isEmpty(selected) && ( - - )} - - - - ); -}; - -export default Toggle; diff --git a/src/utils/components/SelectTypeahead/utils.ts b/src/utils/components/SelectTypeahead/utils.ts deleted file mode 100644 index 57c7d095e..000000000 --- a/src/utils/components/SelectTypeahead/utils.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { SelectOptionProps } from '@patternfly/react-core'; - -export const filterOptions = (options: SelectOptionProps[], inputValue: string) => - options.filter((menuItem) => - String(menuItem.children).toLowerCase().includes(inputValue.toLowerCase()), - ); - -export const handleMenuArrowKeys = ( - key: string, - focusedItemIndex: null | number, - selectOptions: SelectOptionProps[], -): null | number => { - 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) { - return selectOptions.length - 1; - } - - return 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) { - return 0; - } - return focusedItemIndex + 1; - } - - return null; -}; diff --git a/src/utils/components/FolderSelect/utils/constants.ts b/src/utils/components/SelectTypeahead/utils/constants.ts similarity index 100% rename from src/utils/components/FolderSelect/utils/constants.ts rename to src/utils/components/SelectTypeahead/utils/constants.ts diff --git a/src/utils/components/FolderSelect/utils/utils.ts b/src/utils/components/SelectTypeahead/utils/utils.ts similarity index 95% rename from src/utils/components/FolderSelect/utils/utils.ts rename to src/utils/components/SelectTypeahead/utils/utils.ts index e0c229a4f..5d540d9a2 100644 --- a/src/utils/components/FolderSelect/utils/utils.ts +++ b/src/utils/components/SelectTypeahead/utils/utils.ts @@ -3,7 +3,7 @@ import { isEmpty } from '@kubevirt-utils/utils/utils'; import { SelectOptionProps } from '@patternfly/react-core'; import { CREATE_NEW } from './constants'; -export const createItemId = (value: any) => `select-typeahead-${value.replace(' ', '-')}`; +export const createItemId = (value: any) => `select-typeahead-${value?.replace(' ', '-')}`; export const getCreateNewFolderOption = ( filterValue: string,