diff --git a/packages/common/src/components/TypeaheadSelect/TypeaheadSelect.tsx b/packages/common/src/components/TypeaheadSelect/TypeaheadSelect.tsx new file mode 100644 index 000000000..f82af1f82 --- /dev/null +++ b/packages/common/src/components/TypeaheadSelect/TypeaheadSelect.tsx @@ -0,0 +1,425 @@ +import React from 'react'; + +import { + Button, + MenuToggle, + MenuToggleElement, + MenuToggleProps, + Select, + SelectList, + SelectOption, + SelectOptionProps, + SelectProps, + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities, +} from '@patternfly/react-core'; +import { TimesIcon } from '@patternfly/react-icons'; + +export interface TypeaheadSelectOption extends Omit { + /** Content of the select option. */ + content: string | number; + /** Value of the select option. */ + value: string | number; + /** Indicator for option being selected */ + isSelected?: boolean; +} + +export interface TypeaheadSelectProps extends Omit { + /** Options of the select */ + selectOptions: TypeaheadSelectOption[]; + /** Callback triggered on selection. */ + onSelect?: ( + _event: + | React.MouseEvent + | React.KeyboardEvent + | undefined, + selection: string | number, + ) => void; + /** Callback triggered when the select opens or closes. */ + onToggle?: (nextIsOpen: boolean) => void; + /** Callback triggered when the text in the input field changes. */ + onInputChange?: (newValue: string) => void; + /** Function to return items matching the current filter value */ + filterFunction?: ( + filterValue: string, + options: TypeaheadSelectOption[], + ) => TypeaheadSelectOption[]; + /** Callback triggered when the clear button is selected */ + onClearSelection?: () => void; + /** Flag to allow clear current selection */ + allowClear?: boolean; + /** Placeholder text for the select input. */ + placeholder?: string; + /** Flag to indicate if the typeahead select allows new items */ + isCreatable?: boolean; + /** Flag to indicate if create option should be at top of typeahead */ + isCreateOptionOnTop?: boolean; + /** Message to display to create a new option */ + createOptionMessage?: string | ((newValue: string) => string); + /** Message to display when no options are available. */ + noOptionsAvailableMessage?: string; + /** Message to display when no options match the filter. */ + noOptionsFoundMessage?: string | ((filter: string) => string); + /** Flag indicating the select should be disabled. */ + isDisabled?: boolean; + /** Width of the toggle. */ + toggleWidth?: string; + /** Additional props passed to the toggle. */ + toggleProps?: MenuToggleProps; +} + +const defaultNoOptionsFoundMessage = (filter: string) => `No results found for "${filter}"`; +const defaultCreateOptionMessage = (newValue: string) => `Create "${newValue}"`; +const defaultFilterFunction = (filterValue: string, options: TypeaheadSelectOption[]) => + options.filter((o) => String(o.content).toLowerCase().includes(filterValue.toLowerCase())); + +export const TypeaheadSelect: React.FC = ({ + innerRef, + selectOptions, + onSelect, + onToggle, + onInputChange, + filterFunction = defaultFilterFunction, + onClearSelection, + allowClear, + placeholder = 'Select an option', + noOptionsAvailableMessage = 'No options are available', + noOptionsFoundMessage = defaultNoOptionsFoundMessage, + isCreatable = false, + isCreateOptionOnTop = false, + createOptionMessage = defaultCreateOptionMessage, + isDisabled, + toggleWidth, + toggleProps, + ...props +}: TypeaheadSelectProps) => { + const [isOpen, setIsOpen] = React.useState(false); + const [filterValue, setFilterValue] = React.useState(''); + const [isFiltering, setIsFiltering] = React.useState(false); + const [focusedItemIndex, setFocusedItemIndex] = React.useState(null); + const [activeItemId, setActiveItemId] = React.useState(null); + const textInputRef = React.useRef(); + + const NO_RESULTS = 'no results'; + + const selected = React.useMemo( + () => selectOptions.find((option) => option.value === props.selected || option.isSelected), + [props.selected, selectOptions], + ); + + const filteredSelections = React.useMemo(() => { + let newSelectOptions: TypeaheadSelectOption[] = selectOptions; + + // Filter menu items based on the text input value when one exists + if (isFiltering && filterValue) { + newSelectOptions = filterFunction(filterValue, selectOptions); + + if ( + isCreatable && + filterValue.trim() && + !newSelectOptions.find((o) => String(o.content).toLowerCase() === filterValue.toLowerCase()) + ) { + const createOption = { + content: + typeof createOptionMessage === 'string' + ? createOptionMessage + : createOptionMessage(filterValue), + value: filterValue, + }; + newSelectOptions = isCreateOptionOnTop + ? [createOption, ...newSelectOptions] + : [...newSelectOptions, createOption]; + } + + // When no options are found after filtering, display 'No results found' + if (!newSelectOptions.length) { + newSelectOptions = [ + { + isAriaDisabled: true, + content: + typeof noOptionsFoundMessage === 'string' + ? noOptionsFoundMessage + : noOptionsFoundMessage(filterValue), + value: NO_RESULTS, + }, + ]; + } + } + + // When no options are available, display 'No options available' + if (!newSelectOptions.length) { + newSelectOptions = [ + { + isAriaDisabled: true, + content: noOptionsAvailableMessage, + value: NO_RESULTS, + }, + ]; + } + + return newSelectOptions; + }, [ + isFiltering, + filterValue, + filterFunction, + selectOptions, + noOptionsFoundMessage, + isCreatable, + isCreateOptionOnTop, + createOptionMessage, + noOptionsAvailableMessage, + ]); + + React.useEffect(() => { + if (isFiltering) { + openMenu(); + } + // Don't update on openMenu changes + }, [isFiltering]); + + const setActiveAndFocusedItem = (itemIndex: number) => { + setFocusedItemIndex(itemIndex); + const focusedItem = selectOptions[itemIndex]; + setActiveItemId(String(focusedItem.value)); + }; + + const resetActiveAndFocusedItem = () => { + setFocusedItemIndex(null); + setActiveItemId(null); + }; + + const openMenu = () => { + if (!isOpen) { + if (onToggle) { + onToggle(true); + } + setIsOpen(true); + } + }; + + const closeMenu = () => { + if (onToggle) { + onToggle(false); + } + setIsOpen(false); + resetActiveAndFocusedItem(); + setIsFiltering(false); + setFilterValue(String(selected?.content ?? '')); + }; + + const onInputClick = () => { + if (!isOpen) { + openMenu(); + } + setTimeout(() => { + textInputRef.current?.focus(); + }, 100); + }; + + const selectOption = ( + _event: + | React.MouseEvent + | React.KeyboardEvent + | undefined, + option: TypeaheadSelectOption, + ) => { + if (onSelect) { + onSelect(_event, option.value); + } + closeMenu(); + }; + + const handleSelect = ( + _event: React.MouseEvent | undefined, + value: string | number | undefined, + ) => { + if (value && value !== NO_RESULTS) { + const optionToSelect = selectOptions.find((option) => option.value === value); + if (optionToSelect) { + selectOption(_event, optionToSelect); + } else if (isCreatable) { + selectOption(_event, { value, content: value }); + } + } + }; + + const onTextInputChange = (_event: React.FormEvent, value: string) => { + setFilterValue(value || ''); + setIsFiltering(true); + if (onInputChange) { + onInputChange(value); + } + + resetActiveAndFocusedItem(); + }; + + const handleMenuArrowKeys = (key: string) => { + let indexToFocus = 0; + + openMenu(); + + if (filteredSelections.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 = filteredSelections.length - 1; + } else { + indexToFocus = focusedItemIndex - 1; + } + + // Skip disabled options + while (filteredSelections[indexToFocus].isDisabled) { + indexToFocus--; + if (indexToFocus === -1) { + indexToFocus = filteredSelections.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 === filteredSelections.length - 1) { + indexToFocus = 0; + } else { + indexToFocus = focusedItemIndex + 1; + } + + // Skip disabled options + while (filteredSelections[indexToFocus].isDisabled) { + indexToFocus++; + if (indexToFocus === filteredSelections.length) { + indexToFocus = 0; + } + } + } + + setActiveAndFocusedItem(indexToFocus); + }; + + const onInputKeyDown = (event: React.KeyboardEvent) => { + const focusedItem = focusedItemIndex !== null ? filteredSelections[focusedItemIndex] : null; + + switch (event.key) { + case 'Enter': + if ( + isOpen && + focusedItem && + focusedItem.value !== NO_RESULTS && + !focusedItem.isAriaDisabled + ) { + selectOption(event, focusedItem); + } + + openMenu(); + + break; + case 'ArrowUp': + case 'ArrowDown': + event.preventDefault(); + handleMenuArrowKeys(event.key); + break; + } + }; + + const onToggleClick = () => { + if (!isOpen) { + openMenu(); + } else { + closeMenu(); + } + textInputRef.current?.focus(); + }; + + const onClearButtonClick = () => { + if (isFiltering && filterValue) { + if (selected && onSelect) { + onSelect(undefined, selected.value); + } + setFilterValue(''); + if (onInputChange) { + onInputChange(''); + } + setIsFiltering(false); + } + + resetActiveAndFocusedItem(); + textInputRef.current?.focus(); + + if (onClearSelection) { + onClearSelection(); + } + }; + + const toggle = (toggleRef: React.Ref) => ( + + + + {(isFiltering && filterValue) || (allowClear && selected) ? ( + + + + } + > + onSelect(String(value))} + onClearSelection={() => onSelect('')} + isDisabled={isDisabled} + /> + + ); +}; diff --git a/packages/forklift-console-plugin/src/modules/Plans/views/create/steps/SelectSourceProvider/SelectSourceProvider.tsx b/packages/forklift-console-plugin/src/modules/Plans/views/create/steps/SelectSourceProvider/SelectSourceProvider.tsx index 10df4be05..88023b47d 100644 --- a/packages/forklift-console-plugin/src/modules/Plans/views/create/steps/SelectSourceProvider/SelectSourceProvider.tsx +++ b/packages/forklift-console-plugin/src/modules/Plans/views/create/steps/SelectSourceProvider/SelectSourceProvider.tsx @@ -1,4 +1,6 @@ import React from 'react'; +import { CreateVmMigration, PageAction } from 'src/modules/Providers/views/migrate/reducer/actions'; +import { CreateVmMigrationPageState } from 'src/modules/Providers/views/migrate/types'; import { useForkliftTranslation } from 'src/utils/i18n'; import { V1beta1Provider } from '@kubev2v/types'; @@ -10,12 +12,22 @@ import { PlanCreateForm } from './../../components'; import { MemoizedProviderVirtualMachinesList } from './MemoizedProviderVirtualMachinesList'; export const SelectSourceProvider: React.FC<{ - namespace: string; + projectName: string; filterState: PlanCreatePageState; - filterDispatch: React.Dispatch; providers: V1beta1Provider[]; selectedProvider: V1beta1Provider; -}> = ({ filterState, filterDispatch, providers, selectedProvider }) => { + state: CreateVmMigrationPageState; + dispatch: React.Dispatch>; + filterDispatch: React.Dispatch; +}> = ({ + filterState, + providers, + selectedProvider, + state, + projectName, + dispatch, + filterDispatch, +}) => { const { t } = useForkliftTranslation(); // Get the ready providers (note: currently forklift does not allow filter be status.phase) @@ -39,6 +51,9 @@ export const SelectSourceProvider: React.FC<{ providers={filteredProviders} filterState={filterState} filterDispatch={filterDispatch} + dispatch={dispatch} + state={state} + projectName={projectName} /> {filterState.selectedProviderUID && ( diff --git a/packages/forklift-console-plugin/src/modules/Providers/views/create/ProvidersCreatePage.tsx b/packages/forklift-console-plugin/src/modules/Providers/views/create/ProvidersCreatePage.tsx index 86fe094b3..d572fe78e 100644 --- a/packages/forklift-console-plugin/src/modules/Providers/views/create/ProvidersCreatePage.tsx +++ b/packages/forklift-console-plugin/src/modules/Providers/views/create/ProvidersCreatePage.tsx @@ -2,9 +2,10 @@ import React, { useReducer } from 'react'; import { useHistory } from 'react-router'; import { Base64 } from 'js-base64'; import SectionHeading from 'src/components/headers/SectionHeading'; -import { ForkliftTrans, useForkliftTranslation } from 'src/utils/i18n'; +import { useForkliftTranslation } from 'src/utils/i18n'; import { IoK8sApiCoreV1Secret, ProviderModelRef, V1beta1Provider } from '@kubev2v/types'; +import { useActiveNamespace } from '@openshift-console/dynamic-plugin-sdk'; import { Alert, Button, @@ -29,6 +30,7 @@ import './ProvidersCreatePage.style.css'; interface ProvidersCreatePageState { newSecret: IoK8sApiCoreV1Secret; newProvider: V1beta1Provider; + projectName: string; validationError: ValidationMsg; apiError: Error | null; } @@ -39,24 +41,26 @@ export const ProvidersCreatePage: React.FC<{ const { t } = useForkliftTranslation(); const history = useHistory(); const [isLoading, toggleIsLoading] = useToggle(); - + const [activeNamespace, setActiveNamespace] = useActiveNamespace(); const [providerNames] = useK8sWatchProviderNames({ namespace }); - const defaultNamespace = process?.env?.DEFAULT_NAMESPACE || 'default'; + const projectName = activeNamespace === '#ALL_NS#' ? 'openshift-mtv' : activeNamespace; + const initialNamespace = namespace || projectName || defaultNamespace; const initialState: ProvidersCreatePageState = { + projectName, newSecret: { ...secretTemplate, metadata: { ...secretTemplate.metadata, - namespace: namespace || defaultNamespace, + namespace: initialNamespace, }, }, newProvider: { ...providerTemplate, metadata: { ...providerTemplate.metadata, - namespace: namespace || defaultNamespace, + namespace: initialNamespace, }, }, validationError: { type: 'error', msg: 'Missing provider name' }, @@ -109,6 +113,21 @@ export const ProvidersCreatePage: React.FC<{ apiError: null, }; } + case 'SET_PROJECT_NAME': { + const value = action.payload; + let validationError: ValidationMsg = { type: 'default' }; + + if (!value) { + validationError = { type: 'error', msg: 'Missing project name' }; + } + + return { + ...state, + validationError, + projectName: String(value), + apiError: null, + }; + } case 'SET_API_ERROR': { const value = action.payload as Error | null; return { ...state, apiError: value }; @@ -230,26 +249,13 @@ export const ProvidersCreatePage: React.FC<{ )} - {!namespace && ( - - - This provider will be created in {defaultNamespace} namespace, if you - wish to choose another namespace please cancel, and choose a namespace from the top - bar. - - - )} - dispatch({ type: 'SET_PROJECT_NAME', payload: value })} providerNames={providerNames} /> @@ -259,7 +265,10 @@ export const ProvidersCreatePage: React.FC<{