diff --git a/frontend/src/__tests__/cypress/cypress/pages/clusterStorage.ts b/frontend/src/__tests__/cypress/cypress/pages/clusterStorage.ts index c98adc0e50..a9611a8ba8 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/clusterStorage.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/clusterStorage.ts @@ -29,7 +29,7 @@ class ClusterStorageModal extends Modal { findWorkbenchConnectionSelect() { return this.find() .findByTestId('connect-existing-workbench-group') - .findByRole('button', { name: 'Options menu' }); + .findByRole('button', { name: 'Typeahead menu toggle' }); } findMountField() { diff --git a/frontend/src/__tests__/cypress/cypress/pages/dataConnection.ts b/frontend/src/__tests__/cypress/cypress/pages/dataConnection.ts index 93266f4247..1dcba70dee 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/dataConnection.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/dataConnection.ts @@ -36,7 +36,7 @@ class DataConnectionModal extends Modal { findWorkbenchConnectionSelect() { return cy .findByTestId('connect-existing-workbench-group') - .findByRole('button', { name: 'Options menu' }); + .findByRole('button', { name: 'Notebook select' }); } findNotebookRestartAlert() { diff --git a/frontend/src/__tests__/cypress/cypress/pages/modelRegistry.ts b/frontend/src/__tests__/cypress/cypress/pages/modelRegistry.ts index 7c29c8c019..1b9c2a1d9c 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/modelRegistry.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/modelRegistry.ts @@ -92,7 +92,7 @@ class ModelRegistry { } shouldModelRegistrySelectorExist() { - cy.get('#model-registry-selector-dropdown').should('exist'); + cy.findByTestId('model-registry-selector-dropdown').should('exist'); } shouldtableToolbarExist() { @@ -145,7 +145,7 @@ class ModelRegistry { } findModelRegistry() { - return cy.get('#model-registry-selector-dropdown'); + return cy.findByTestId('model-registry-selector-dropdown'); } findModelVersionsTableHeaderButton(name: string) { diff --git a/frontend/src/__tests__/cypress/cypress/pages/modelRegistryPermissions.ts b/frontend/src/__tests__/cypress/cypress/pages/modelRegistryPermissions.ts index 37b8fc2208..701987f12e 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/modelRegistryPermissions.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/modelRegistryPermissions.ts @@ -71,7 +71,7 @@ class PermissionTable extends Contextual { } findNameSelect() { - return this.find().get(`[aria-label="Name selection"]`); + return this.find().get(`[aria-label="Type to filter"]`); } getTableRow(name: string) { diff --git a/frontend/src/__tests__/cypress/cypress/pages/userManagement.ts b/frontend/src/__tests__/cypress/cypress/pages/userManagement.ts index 1dd1ef0041..232b4b772d 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/userManagement.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/userManagement.ts @@ -39,7 +39,7 @@ class GroupSettingSection extends Contextual { } findMultiGroupSelectButton() { - return this.find().findByRole('button', { name: 'Options menu' }); + return this.find().findByTestId('group-setting-select'); } selectMultiGroup(name: string) { diff --git a/frontend/src/__tests__/cypress/cypress/pages/workbench.ts b/frontend/src/__tests__/cypress/cypress/pages/workbench.ts index 88e5d96f45..d1d3f13170 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/workbench.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/workbench.ts @@ -8,11 +8,10 @@ class StorageModal extends Modal { } selectExistingPersistentStorage(name: string) { - this.find() - .findByTestId('persistent-storage-group') - .findByRole('button', { name: 'Options menu' }) - .findSelectOption(name) + cy.findByTestId('persistent-storage-group') + .findByRole('button', { name: 'Typeahead menu toggle' }) .click(); + cy.findByTestId('persistent-storage-group').findByRole('option', { name }).click(); } findSubmitButton() { @@ -190,9 +189,9 @@ class CreateSpawnerPage { selectExistingPersistentStorage(name: string) { cy.findByTestId('persistent-storage-group') - .findByRole('button', { name: 'Options menu' }) - .findSelectOption(name) + .findByRole('button', { name: 'Typeahead menu toggle' }) .click(); + cy.get('[id="dashboard-page-main"]').findByRole('option', { name }).click(); } selectPVSize(name: string) { @@ -271,9 +270,9 @@ class CreateSpawnerPage { selectExistingDataConnection(name: string) { cy.findByTestId('data-connection-group') - .findByRole('button', { name: 'Options menu' }) - .findSelectOption(name) + .findByRole('button', { name: 'Typeahead menu toggle' }) .click(); + cy.get('[id="dashboard-page-main"]').findByRole('option', { name }).click(); } findAwsNameInput() { diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/userManagement/userManagement.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/userManagement/userManagement.cy.ts index 7e6274f6fc..9283c0c224 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/userManagement/userManagement.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/userManagement/userManagement.cy.ts @@ -44,7 +44,7 @@ describe('User Management', () => { userGroupSection.findChipItem('system:authenticated').should('exist'); userGroupSection.clearMultiChipItem(); userGroupSection.findErrorText().should('exist'); - userGroupSection.selectMultiGroup('odh-admins'); + userGroupSection.selectMultiGroup('odh-admins', false); userGroupSection.findChipItem(/^odh-admins$/).should('exist'); userGroupSection.findMultiGroupSelectButton().click(); userManagement.findSubmitButton().should('be.enabled'); diff --git a/frontend/src/components/MultiSelection.tsx b/frontend/src/components/MultiSelection.tsx index 9cc8cb85fa..0f141bf77d 100644 --- a/frontend/src/components/MultiSelection.tsx +++ b/frontend/src/components/MultiSelection.tsx @@ -13,6 +13,8 @@ import { Button, HelperText, HelperTextItem, + SelectGroup, + Divider, } from '@patternfly/react-core'; import { TimesIcon } from '@patternfly/react-icons/dist/esm/icons/times-icon'; @@ -22,28 +24,94 @@ export type SelectionOptions = { selected?: boolean; }; +export type GroupSelectionOptions = { + id: number | string; + name: string; + values: SelectionOptions[]; +}; + type MultiSelectionProps = { - value: SelectionOptions[]; + 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 MultiSelection: React.FC = ({ value, setValue }) => { +export const MultiSelection: React.FC = ({ + value = [], + groupedValues = [], + setValue, + placeholder, + isDisabled, + ariaLabel = 'Options menu', + id, + toggleId, + toggleTestId, + selectionRequired, + noSelectedOptionsMessage = 'One or more options must be selected', +}) => { 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 selected = React.useMemo(() => value.filter((v) => v.selected), [value]); + + 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())), - [inputValue, value], + 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) { - setIsOpen(true); + setOpen(true); } setFocusedItemIndex(null); setActiveItem(null); @@ -52,20 +120,23 @@ export const MultiSelection: React.FC = ({ value, setValue const handleMenuArrowKeys = (key: string) => { let indexToFocus; if (!isOpen) { - setIsOpen(true); + setOpen(true); + setFocusedItemIndex(0); return; } + const optionsLength = visibleOptions.length; + if (key === 'ArrowUp') { if (focusedItemIndex === null || focusedItemIndex === 0) { - indexToFocus = selectOptions.length - 1; + indexToFocus = optionsLength - 1; } else { indexToFocus = focusedItemIndex - 1; } } if (key === 'ArrowDown') { - if (focusedItemIndex === null || focusedItemIndex === selectOptions.length - 1) { + if (focusedItemIndex === null || focusedItemIndex === optionsLength - 1) { indexToFocus = 0; } else { indexToFocus = focusedItemIndex + 1; @@ -74,13 +145,13 @@ export const MultiSelection: React.FC = ({ value, setValue if (indexToFocus != null) { setFocusedItemIndex(indexToFocus); - const focusedItem = selectOptions[indexToFocus]; + const focusedItem = visibleOptions[indexToFocus]; setActiveItem(`select-multi-typeahead-${focusedItem.name.replace(' ', '-')}`); } }; const onInputKeyDown = (event: React.KeyboardEvent) => { - const focusedItem = focusedItemIndex !== null ? selectOptions[focusedItemIndex] : null; + const focusedItem = focusedItemIndex !== null ? visibleOptions[focusedItemIndex] : null; switch (event.key) { case 'Enter': if (isOpen && focusedItem) { @@ -92,7 +163,7 @@ export const MultiSelection: React.FC = ({ value, setValue break; case 'Tab': case 'Escape': - setIsOpen(false); + setOpen(false); setActiveItem(null); break; case 'ArrowUp': @@ -104,7 +175,7 @@ export const MultiSelection: React.FC = ({ value, setValue }; const onToggleClick = () => { - setIsOpen(!isOpen); + setOpen(!isOpen); setTimeout(() => textInputRef.current?.focus(), 100); }; const onTextInputChange = (_event: React.FormEvent, valueOfInput: string) => { @@ -113,22 +184,26 @@ export const MultiSelection: React.FC = ({ value, setValue const onSelect = (menuItem?: SelectionOptions) => { if (menuItem) { setValue( - selected.includes(menuItem) - ? value.map((option) => (option === menuItem ? { ...option, selected: false } : option)) - : value.map((option) => (option === menuItem ? { ...option, selected: true } : option)), + allOptions.map((option) => + option.id === menuItem.id ? { ...option, selected: !option.selected } : option, + ), ); } textInputRef.current?.focus(); }; - const noSelectedItems = value.filter((option) => option.selected).length === 0; + const noSelectedItems = allOptions.filter((option) => option.selected).length === 0; const toggle = (toggleRef: React.Ref) => ( @@ -144,6 +219,7 @@ export const MultiSelection: React.FC = ({ value, setValue role="combobox" isExpanded={isOpen} aria-controls="select-multi-typeahead-listbox" + placeholder={placeholder} > {selected.map((selection, index) => ( @@ -165,7 +241,7 @@ export const MultiSelection: React.FC = ({ value, setValue variant="plain" onClick={() => { setInputValue(''); - setValue(value.map((option) => ({ ...option, selected: false }))); + setValue(allOptions.map((option) => ({ ...option, selected: false }))); textInputRef.current?.focus(); }} aria-label="Clear input value" @@ -181,34 +257,63 @@ export const MultiSelection: React.FC = ({ value, setValue return ( <> - {noSelectedItems && ( - + {noSelectedItems && selectionRequired && ( + - One or more group must be selected + {noSelectedOptionsMessage} )} diff --git a/frontend/src/components/SimpleSelect.tsx b/frontend/src/components/SimpleSelect.tsx index bea5843ec7..25904e3885 100644 --- a/frontend/src/components/SimpleSelect.tsx +++ b/frontend/src/components/SimpleSelect.tsx @@ -1,5 +1,13 @@ import * as React from 'react'; -import { Truncate, MenuToggle, Select, SelectList, SelectOption } from '@patternfly/react-core'; +import { + Truncate, + MenuToggle, + Select, + SelectList, + SelectOption, + SelectGroup, + Divider, +} from '@patternfly/react-core'; import { MenuToggleProps } from '@patternfly/react-core/src/components/MenuToggle/MenuToggle'; import './SimpleSelect.scss'; @@ -13,8 +21,15 @@ export type SimpleSelectOption = { isDisabled?: boolean; }; -type SimpleSelectProps = { +export type SimpleGroupSelectOption = { + key: string; + label: string; options: SimpleSelectOption[]; +}; + +type SimpleSelectProps = { + options?: SimpleSelectOption[]; + groupedOptions?: SimpleGroupSelectOption[]; value?: string; toggleLabel?: React.ReactNode; placeholder?: string; @@ -33,6 +48,7 @@ const SimpleSelect: React.FC = ({ isDisabled, onChange, options, + groupedOptions, placeholder = 'Select...', value, toggleLabel, @@ -43,7 +59,14 @@ const SimpleSelect: React.FC = ({ ...props }) => { const [open, setOpen] = React.useState(false); - const selectedOption = options.find(({ key }) => key === value); + + const findOptionForKey = (key: string) => + options?.find((option) => option.key === key) || + groupedOptions + ?.reduce((acc, group) => [...acc, ...group.options], []) + .find((o) => o.key === key); + + const selectedOption = value ? findOptionForKey(value) : undefined; const selectedLabel = selectedOption?.label ?? placeholder; return ( @@ -54,7 +77,7 @@ const SimpleSelect: React.FC = ({ onSelect={(e, selectValue) => { onChange( String(selectValue), - options.find((o) => o.key === selectValue)?.isPlaceholder ?? false, + !!selectValue && (findOptionForKey(String(selectValue))?.isPlaceholder ?? false), ); setOpen(false); }} @@ -76,19 +99,44 @@ const SimpleSelect: React.FC = ({ )} shouldFocusToggleOnSelect > - - {options.map(({ key, label, dropdownLabel, description, isDisabled: optionDisabled }) => ( - - {dropdownLabel || label} - - ))} - + {groupedOptions?.map((group, index) => ( + <> + {index > 0 ? : null} + + + {group.options.map( + ({ key, label, dropdownLabel, description, isDisabled: optionDisabled }) => ( + + {dropdownLabel || label} + + ), + )} + + + + )) ?? null} + {options?.length ? ( + + {groupedOptions?.length ? : null} + {options.map(({ key, label, dropdownLabel, description, isDisabled: optionDisabled }) => ( + + {dropdownLabel || label} + + ))} + + ) : null} ); }; diff --git a/frontend/src/components/TypeaheadSelect.tsx b/frontend/src/components/TypeaheadSelect.tsx new file mode 100644 index 0000000000..55a4b493b5 --- /dev/null +++ b/frontend/src/components/TypeaheadSelect.tsx @@ -0,0 +1,420 @@ +import React from 'react'; +import { + Select, + SelectOption, + SelectList, + SelectOptionProps, + MenuToggle, + MenuToggleElement, + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities, + Button, + MenuToggleProps, + SelectProps, +} 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; + /** 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())); + +const TypeaheadSelect: React.FunctionComponent = ({ + innerRef, + selectOptions, + onSelect, + onToggle, + onInputChange, + filterFunction = defaultFilterFunction, + onClearSelection, + 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 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [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); + } else if (isFiltering) { + closeMenu(); + } + }; + + 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 (selected && onSelect) { + onSelect(undefined, selected.value); + } + setFilterValue(''); + if (onInputChange) { + onInputChange(''); + } + setIsFiltering(false); + resetActiveAndFocusedItem(); + textInputRef.current?.focus(); + if (onClearSelection) { + onClearSelection(); + } + }; + + const toggle = (toggleRef: React.Ref) => ( + + + + + + + + + + ); + + return ( + + ); +}; + +export default TypeaheadSelect; diff --git a/frontend/src/concepts/pipelines/content/tables/ExperimentSearchInput.tsx b/frontend/src/concepts/pipelines/content/tables/ExperimentSearchInput.tsx index f0bbe187a8..deb2a036be 100644 --- a/frontend/src/concepts/pipelines/content/tables/ExperimentSearchInput.tsx +++ b/frontend/src/concepts/pipelines/content/tables/ExperimentSearchInput.tsx @@ -1,10 +1,10 @@ import * as React from 'react'; -import { Select, SelectOption, SelectVariant } from '@patternfly/react-core/deprecated'; import { SearchIcon } from '@patternfly/react-icons'; import { PipelinesFilter } from '~/concepts/pipelines/types'; import useDebounceCallback from '~/utilities/useDebounceCallback'; import { PipelinesFilterOp } from '~/concepts/pipelines/kfTypes'; import useExperiments from '~/concepts/pipelines/apiHooks/useExperiments'; +import TypeaheadSelect from '~/components/TypeaheadSelect'; type Props = { onChange: (selected?: { label: string; value: string }) => void; @@ -12,7 +12,6 @@ type Props = { }; const ExperimentSearchInput: React.FC = ({ selected, onChange }) => { - const [open, setOpen] = React.useState(false); const [filterText, setFilterText] = React.useState(''); const [filter, setFilter] = React.useState(); const [{ items }, loaded] = useExperiments({ pageSize: filter ? 10 : 0, filter }); @@ -32,55 +31,41 @@ const ExperimentSearchInput: React.FC = ({ selected, onChange }) => { ); }, [setDebouncedFilter, filterText]); - const children = loaded - ? experiments.map((experiment) => ( - - {experiment.display_name} - - )) - : []; - - const hasSelection = React.useMemo( + const selectOptions = React.useMemo( () => - selected?.value - ? experiments.find((experiment) => experiment.experiment_id === selected.value) - : undefined, - [experiments, selected?.value], + loaded + ? experiments.map((experiment) => ({ + value: experiment.experiment_id, + content: experiment.display_name, + selected: selected?.value === experiment.experiment_id, + })) + : [], + [experiments, loaded, selected?.value], ); return ( - + /> ); }; diff --git a/frontend/src/concepts/roleBinding/RoleBindingPermissionsNameInput.tsx b/frontend/src/concepts/roleBinding/RoleBindingPermissionsNameInput.tsx index 33f1deeefa..029d82f39f 100644 --- a/frontend/src/concepts/roleBinding/RoleBindingPermissionsNameInput.tsx +++ b/frontend/src/concepts/roleBinding/RoleBindingPermissionsNameInput.tsx @@ -1,9 +1,9 @@ import React from 'react'; import { TextInput } from '@patternfly/react-core'; -import { Select, SelectOption, SelectVariant } from '@patternfly/react-core/deprecated'; import { RoleBindingSubject } from '~/k8sTypes'; import { namespaceToProjectDisplayName } from '~/concepts/projects/utils'; import { ProjectsContext } from '~/concepts/projects/ProjectsContext'; +import TypeaheadSelect from '~/components/TypeaheadSelect'; import { RoleBindingPermissionsRBType } from './types'; type RoleBindingPermissionsNameInputProps = { @@ -26,7 +26,6 @@ const RoleBindingPermissionsNameInput: React.FC { const { projects } = React.useContext(ProjectsContext); - const [isOpen, setIsOpen] = React.useState(false); if (!typeAhead) { return ( @@ -48,33 +47,28 @@ const RoleBindingPermissionsNameInput: React.FC { + const displayName = isProjectSubject ? namespaceToProjectDisplayName(option, projects) : option; + return { value: displayName, content: displayName }; + }); + + if (value && !typeAhead.includes(value)) { + selectOptions.push({ value, content: value }); + } + return ( - + placeholder={placeholderText} + /> ); }; diff --git a/frontend/src/pages/groupSettings/GroupSettings.tsx b/frontend/src/pages/groupSettings/GroupSettings.tsx index 20d5ec9da8..48a1f45ea8 100644 --- a/frontend/src/pages/groupSettings/GroupSettings.tsx +++ b/frontend/src/pages/groupSettings/GroupSettings.tsx @@ -92,12 +92,15 @@ const GroupSettings: React.FC = () => { > ({ id: g.id, name: g.name, selected: g.enabled, }))} setValue={(newState) => handleMenuItemSelection(newState, GroupsConfigField.ADMIN)} + selectionRequired + noSelectedOptionsMessage="One or more group must be selected" /> {groupSettings.errorAdmin ? ( { > ({ id: g.id, name: g.name, selected: g.enabled, }))} setValue={(newState) => handleMenuItemSelection(newState, GroupsConfigField.USER)} + selectionRequired + noSelectedOptionsMessage="One or more group must be selected" /> {groupSettings.errorUser ? ( = ({ const options = [ - {modelRegistryServices.map((mr) => ( - - {getDisplayNameFromK8sResource(mr)} - - ))} + + {modelRegistryServices.map((mr) => ( + + {getDisplayNameFromK8sResource(mr)} + + ))} + , ]; + const createFavorites = (favIds: string[]) => { + const favorite: JSX.Element[] = []; + + options.forEach((item) => { + if (item.type === SelectList) { + item.props.children.filter( + (child: JSX.Element) => favIds.includes(child.props.value) && favorite.push(child), + ); + } else if (item.type === SelectGroup) { + item.props.children.props.children.filter( + (child: JSX.Element) => favIds.includes(child.props.value) && favorite.push(child), + ); + } else if (favIds.includes(item.props.value)) { + favorite.push(item); + } + }); + + return favorite; + }; + const selector = ( ); diff --git a/frontend/src/pages/modelServing/screens/metrics/bias/BiasMetricConfigSelector.tsx b/frontend/src/pages/modelServing/screens/metrics/bias/BiasMetricConfigSelector.tsx index f9196fa154..1327da0275 100644 --- a/frontend/src/pages/modelServing/screens/metrics/bias/BiasMetricConfigSelector.tsx +++ b/frontend/src/pages/modelServing/screens/metrics/bias/BiasMetricConfigSelector.tsx @@ -1,20 +1,8 @@ import React from 'react'; -import { - Select, - SelectGroup, - SelectOption, - SelectVariant, -} from '@patternfly/react-core/deprecated'; import { useModelBiasData } from '~/concepts/trustyai/context/useModelBiasData'; import { BiasMetricConfig } from '~/concepts/trustyai/types'; import { BiasMetricType } from '~/api'; -import { - byId, - byNotId, - createBiasSelectOption, - isBiasSelectOption, -} from '~/pages/modelServing/screens/metrics/utils'; -import { BiasSelectOption } from '~/pages/modelServing/screens/metrics/types'; +import { MultiSelection, SelectionOptions } from '~/components/MultiSelection'; type BiasMetricConfigSelectorProps = { onChange: (x: BiasMetricConfig[]) => void; @@ -26,71 +14,70 @@ const BiasMetricConfigSelector: React.FC = ({ initialSelections, }) => { const { biasMetricConfigs, loaded } = useModelBiasData(); - - const [isOpen, setIsOpen] = React.useState(false); - - const selected = React.useMemo( - () => initialSelections.map(createBiasSelectOption), - [initialSelections], - ); - + const [uiSelections, setUISelections] = React.useState(); + const [currentSelections, setCurrentSelections] = React.useState(); const elementId = React.useId(); - const changeState = React.useCallback( - (options: BiasSelectOption[]) => { - onChange(options.map((x) => x.biasMetricConfig)); - }, - [onChange], - ); + const spdConfigs = biasMetricConfigs.filter((x) => x.metricType === BiasMetricType.SPD); + const dirConfigs = biasMetricConfigs.filter((x) => x.metricType === BiasMetricType.DIR); return (
- + />
); }; diff --git a/frontend/src/pages/modelServing/screens/metrics/utils.tsx b/frontend/src/pages/modelServing/screens/metrics/utils.tsx index 5aa4d8d290..b3da7e4e25 100644 --- a/frontend/src/pages/modelServing/screens/metrics/utils.tsx +++ b/frontend/src/pages/modelServing/screens/metrics/utils.tsx @@ -2,7 +2,6 @@ import * as React from 'react'; import { ReactElement } from 'react'; import * as _ from 'lodash-es'; import { BreadcrumbItem } from '@patternfly/react-core'; -import { SelectOptionObject } from '@patternfly/react-core/deprecated'; import { Link } from 'react-router-dom'; import { TimeframeTitle } from '~/concepts/metrics/types'; import { InferenceServiceKind, ServingRuntimeKind } from '~/k8sTypes'; @@ -272,9 +271,6 @@ export const createBiasSelectOption = (biasMetricConfig: BiasMetricConfig): Bias compareTo: byId(id), }; }; -export const isBiasSelectOption = (obj: SelectOptionObject): obj is BiasSelectOption => - 'biasMetricConfig' in obj; - export const convertInputType = (input: string): number | boolean | string => { if (input !== '' && !Number.isNaN(Number(input))) { return Number(input); diff --git a/frontend/src/pages/projects/components/ExistingPVCField.tsx b/frontend/src/pages/projects/components/ExistingPVCField.tsx deleted file mode 100644 index 6d3eb5a953..0000000000 --- a/frontend/src/pages/projects/components/ExistingPVCField.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import * as React from 'react'; -import { Alert, FormGroup } from '@patternfly/react-core'; -import { Select, SelectOption } from '@patternfly/react-core/deprecated'; -import { PersistentVolumeClaimKind } from '~/k8sTypes'; -import { getDisplayNameFromK8sResource } from '~/concepts/k8s/utils'; - -type ExistingPVCFieldProps = { - fieldId: string; - storages: PersistentVolumeClaimKind[]; - loaded: boolean; - loadError?: Error; - selectedStorage?: string; - setStorage: (storage?: string) => void; - selectDirection?: 'up' | 'down'; - menuAppendTo?: HTMLElement | 'parent'; -}; - -const ExistingPVCField: React.FC = ({ - fieldId, - storages, - loaded, - loadError, - selectedStorage, - setStorage, - selectDirection = 'down', - menuAppendTo = 'parent', -}) => { - const [isOpen, setOpen] = React.useState(false); - - if (loadError) { - return ( - - {loadError.message} - - ); - } - - const empty = storages.length === 0; - let placeholderText: string; - if (!loaded) { - placeholderText = 'Loading storages...'; - } else if (empty) { - placeholderText = 'No existing storages available'; - } else { - placeholderText = 'Select a persistent storage'; - } - - return ( - - - - ); -}; - -export default ExistingPVCField; diff --git a/frontend/src/pages/projects/notebook/ConnectedNotebookField.tsx b/frontend/src/pages/projects/notebook/ConnectedNotebookField.tsx index 3744594837..b799cc277f 100644 --- a/frontend/src/pages/projects/notebook/ConnectedNotebookField.tsx +++ b/frontend/src/pages/projects/notebook/ConnectedNotebookField.tsx @@ -1,8 +1,10 @@ import * as React from 'react'; import { FormGroup, FormHelperText, HelperText, HelperTextItem } from '@patternfly/react-core'; -import { Select, SelectOption } from '@patternfly/react-core/deprecated'; +import { SearchIcon } from '@patternfly/react-icons'; import { NotebookKind } from '~/k8sTypes'; import { getDisplayNameFromK8sResource } from '~/concepts/k8s/utils'; +import { MultiSelection } from '~/components/MultiSelection'; +import TypeaheadSelect from '~/components/TypeaheadSelect'; type SelectNotebookFieldProps = { loaded: boolean; @@ -26,8 +28,6 @@ const ConnectedNotebookField: React.FC = ({ isMultiSelect, placeholder = 'Select a workbench to connect', }) => { - const [notebookSelectOpen, setNotebookSelectOpen] = React.useState(false); - const noNotebooks = notebooks.length === 0; const disabled = !!isDisabled || !loaded || noNotebooks; @@ -40,46 +40,57 @@ const ConnectedNotebookField: React.FC = ({ placeholderText = placeholder; } + const options = React.useMemo( + () => + notebooks.map((notebook) => ({ + value: notebook.metadata.name, + content: getDisplayNameFromK8sResource(notebook), + })), + [notebooks], + ); + return ( - + /> + ) : ( + onSelect([])} + onSelect={(_ev, value) => { + if (typeof value === 'string') { + const notebook = notebooks.find((n) => n.metadata.name === value); + if (notebook) { + onSelect([value]); + } + } + }} + placeholder={placeholderText} + noOptionsFoundMessage="Search for a workbench name" + toggleProps={{ + id: 'notebook-search-input', + icon: , + }} + data-testid="notebook-search-select" + /> + )} {!noNotebooks && selectionHelperText} diff --git a/frontend/src/pages/projects/screens/spawner/dataConnection/ExistingDataConnectionField.tsx b/frontend/src/pages/projects/screens/spawner/dataConnection/ExistingDataConnectionField.tsx index 65bad27099..183264cdca 100644 --- a/frontend/src/pages/projects/screens/spawner/dataConnection/ExistingDataConnectionField.tsx +++ b/frontend/src/pages/projects/screens/spawner/dataConnection/ExistingDataConnectionField.tsx @@ -1,8 +1,8 @@ import * as React from 'react'; import { Alert, FormGroup } from '@patternfly/react-core'; -import { Select, SelectOption } from '@patternfly/react-core/deprecated'; import { ProjectDetailsContext } from '~/pages/projects/ProjectDetailsContext'; import { getDataConnectionDisplayName } from '~/pages/projects/screens/detail/data-connections/utils'; +import TypeaheadSelect from '~/components/TypeaheadSelect'; type ExistingDataConnectionFieldProps = { fieldId: string; @@ -15,11 +15,21 @@ const ExistingDataConnectionField: React.FC = selectedDataConnection, setDataConnection, }) => { - const [isOpen, setOpen] = React.useState(false); const { dataConnections: { data: connections, loaded, error }, } = React.useContext(ProjectDetailsContext); + const selectOptions = React.useMemo( + () => + loaded + ? connections.map((connection) => ({ + value: connection.data.metadata.name, + content: getDataConnectionDisplayName(connection), + })) + : [], + [connections, loaded], + ); + if (error) { return ( @@ -45,32 +55,15 @@ const ExistingDataConnectionField: React.FC = fieldId={fieldId} data-testid="data-connection-group" > - + setDataConnection(String(selection))} + onClearSelection={() => setDataConnection()} + placeholder={placeholderText} + noOptionsFoundMessage={(filter) => `No data connection was found for "${filter}"`} + isDisabled={!loaded || connections.length === 0} + /> ); }; diff --git a/frontend/src/pages/projects/screens/spawner/storage/AddExistingStorageField.tsx b/frontend/src/pages/projects/screens/spawner/storage/AddExistingStorageField.tsx index 124843832c..dfeb9b5df6 100644 --- a/frontend/src/pages/projects/screens/spawner/storage/AddExistingStorageField.tsx +++ b/frontend/src/pages/projects/screens/spawner/storage/AddExistingStorageField.tsx @@ -1,7 +1,9 @@ import * as React from 'react'; +import { Alert, FormGroup } from '@patternfly/react-core'; import { ExistingStorageObject } from '~/pages/projects/types'; -import ExistingPVCField from '~/pages/projects/components/ExistingPVCField'; import { ProjectDetailsContext } from '~/pages/projects/ProjectDetailsContext'; +import { getDisplayNameFromK8sResource } from '~/concepts/k8s/utils'; +import TypeaheadSelect from '~/components/TypeaheadSelect'; import useAvailablePvcs from './useAvailablePvcs'; type AddExistingStorageFieldProps = { @@ -9,7 +11,7 @@ type AddExistingStorageFieldProps = { setData: (data: ExistingStorageObject) => void; editStorage?: string; selectDirection?: 'up' | 'down'; - menuAppendTo?: HTMLElement | 'parent'; + menuAppendTo?: HTMLElement; }; const AddExistingStorageField: React.FC = ({ @@ -23,23 +25,58 @@ const AddExistingStorageField: React.FC = ({ currentProject, notebooks: { data: allNotebooks }, } = React.useContext(ProjectDetailsContext); - const [pvcs, loaded, loadError] = useAvailablePvcs( + const [storages, loaded, loadError] = useAvailablePvcs( currentProject.metadata.name, allNotebooks, editStorage, ); + const selectOptions = React.useMemo( + () => + loaded + ? storages.map((pvc) => ({ + value: pvc.metadata.name, + content: getDisplayNameFromK8sResource(pvc), + })) + : [], + [loaded, storages], + ); + + if (loadError) { + return ( + + {loadError.message} + + ); + } + + let placeholderText: string; + + if (!loaded) { + placeholderText = 'Loading storages...'; + } else if (storages.length === 0) { + placeholderText = 'No existing storages available'; + } else { + placeholderText = 'Select a persistent storage'; + } + return ( - setData({ ...data, storage: storage || '' })} - selectDirection={selectDirection} - menuAppendTo={menuAppendTo} - /> + data-testid="persistent-storage-group" + > + setData({ ...data, storage: String(storage) || '' })} + placeholder={placeholderText} + noOptionsFoundMessage={(filter) => `No persistent storage was found for "${filter}"`} + popperProps={{ direction: selectDirection, appendTo: menuAppendTo }} + isDisabled={!loaded || storages.length === 0} + /> + ); };