diff --git a/script/lint/lint_core_config.js b/script/lint/lint_core_config.js index 73326542755..92c4b51c47d 100644 --- a/script/lint/lint_core_config.js +++ b/script/lint/lint_core_config.js @@ -31,6 +31,7 @@ module.exports = { 'checkbox', 'clearbutton', 'clearfix', + 'combobox', 'comms', 'Composable', 'consts', @@ -88,6 +89,7 @@ module.exports = { 'labelledby', 'lang', 'ldap', + 'listbox', 'listitem', 'loc', 'locs', diff --git a/webpack/assets/javascripts/react_app/components/common/AutocompleteInput/AutocompleteInput.js b/webpack/assets/javascripts/react_app/components/common/AutocompleteInput/AutocompleteInput.js new file mode 100644 index 00000000000..aad82b9845b --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/AutocompleteInput/AutocompleteInput.js @@ -0,0 +1,317 @@ +import React, { useEffect, useRef, useState } from 'react'; +import PropTypes from 'prop-types'; +import { + Select, + SelectOption, + SelectList, + MenuToggle, + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities, + HelperTextItem, + HelperText, +} from '@patternfly/react-core'; +import { sprintf, translate as __ } from 'foremanReact/common/I18n'; + +const DEFAULT_PLACEHOLDER = __('Start typing to search'); +export const AutocompleteInputComponent = ({ + selected, + onSelect, + onChange, + options, + name, + placeholder, + validationStatus, + validationMsg, + isDisabled, +}) => { + if (validationStatus === 'error') validationStatus = 'danger'; + const NO_RESULTS = __('No matches found'); + const noOptions = [ + { value: '', isAriaDisabled: true, disabled: true, label: NO_RESULTS }, + ]; + + const displayOptions = options.length < 1 ? noOptions : options; + + const displayValue = + typeof selected === 'string' || typeof selected === 'number' + ? displayOptions.find(o => o.value === selected)?.label || selected + : selected?.label || selected || ''; + + const [isOpen, setIsOpen] = useState(false); + const [inputValue, setInputValue] = useState(displayValue); + const [filterValue, setFilterValue] = useState(''); + const [selectOptions, setSelectOptions] = useState(displayOptions); + const [focusedItemIndex, setFocusedItemIndex] = useState(null); + const [activeItemId, setActiveItemId] = useState(null); + const textInputRef = useRef(null); + const wrapperRef = useRef(null); + + useEffect(() => { + let newSelectOptions = displayOptions; + if (filterValue) { + newSelectOptions = displayOptions.filter(menuItem => + String(menuItem.label) + .toLowerCase() + .includes(filterValue.toLowerCase()) + ); + if (!newSelectOptions.length) { + newSelectOptions = [ + { + isAriaDisabled: true, + label: sprintf(__('No results found for %s'), filterValue), + value: NO_RESULTS, + }, + ]; + } + if (!isOpen) { + setIsOpen(true); + } + } + setSelectOptions(newSelectOptions); + + /* eslint-disable react-hooks/exhaustive-deps */ + }, [filterValue]); + + const createItemId = value => `select-typeahead-${value}`; + const setActiveAndFocusedItem = itemIndex => { + setFocusedItemIndex(itemIndex); + const focusedItem = selectOptions[itemIndex]; + setActiveItemId(createItemId(focusedItem.value)); + }; + const resetActiveAndFocusedItem = () => { + setFocusedItemIndex(null); + setActiveItemId(null); + }; + const closeMenu = () => { + setIsOpen(false); + resetActiveAndFocusedItem(); + }; + + const handleBlurCapture = e => { + const next = e.relatedTarget; + if (!wrapperRef.current?.contains(next)) { + closeMenu(); + } + setInputValue(displayValue); + setFilterValue(displayValue); + }; + + const onInputClick = () => { + if (!isOpen) { + setIsOpen(true); + } else if (!inputValue) { + closeMenu(); + } + }; + const selectOption = (value, content) => { + setInputValue(String(content)); + setFilterValue(''); + onSelect(value); + closeMenu(); + }; + const onSelectLocal = (_event, value) => { + if (value && value !== NO_RESULTS) { + const optionText = selectOptions.find(option => option.value === value) + ?.label; + selectOption(value, optionText); + } + }; + const onTextInputChange = (_event, value) => { + onChange(value); + setInputValue(value); + setFilterValue(value); + resetActiveAndFocusedItem(); + }; + const handleMenuArrowKeys = key => { + let indexToFocus = 0; + if (!isOpen) { + setIsOpen(true); + } + if (selectOptions.every(option => option.isDisabled)) { + return; + } + if (key === 'ArrowUp') { + if (focusedItemIndex === null || focusedItemIndex === 0) { + indexToFocus = selectOptions.length - 1; + } else { + indexToFocus = focusedItemIndex - 1; + } + while (selectOptions[indexToFocus].isDisabled) { + indexToFocus--; + if (indexToFocus === -1) { + indexToFocus = selectOptions.length - 1; + } + } + } + if (key === 'ArrowDown') { + if ( + focusedItemIndex === null || + focusedItemIndex === selectOptions.length - 1 + ) { + indexToFocus = 0; + } else { + indexToFocus = focusedItemIndex + 1; + } + while (selectOptions[indexToFocus].isDisabled) { + indexToFocus++; + if (indexToFocus === selectOptions.length) { + indexToFocus = 0; + } + } + } + setActiveAndFocusedItem(indexToFocus); + }; + const onInputKeyDown = event => { + const focusedItem = + focusedItemIndex !== null ? selectOptions[focusedItemIndex] : null; + // eslint-disable-next-line default-case + switch (event.key) { + case 'Enter': + if ( + isOpen && + focusedItem && + focusedItem.value !== NO_RESULTS && + !focusedItem.isAriaDisabled + ) { + selectOption(focusedItem.value, focusedItem.label); + } + if (!isOpen) { + setIsOpen(true); + } + break; + case 'ArrowUp': + case 'ArrowDown': + event.preventDefault(); + handleMenuArrowKeys(event.key); + break; + } + }; + const onToggleClick = () => { + setIsOpen(!isOpen); + // eslint-disable-next-line no-unused-expressions + textInputRef?.current?.focus(); + }; + + const toggle = toggleRef => ( + + + + + + + + ); + return ( +
+ + {validationStatus !== undefined && ( + + + {validationMsg} + + + )} +
+ ); +}; + +AutocompleteInputComponent.propTypes = { + selected: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + PropTypes.bool, + ]), + onSelect: PropTypes.func, + onChange: PropTypes.func, + options: PropTypes.arrayOf( + PropTypes.shape({ + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) + .isRequired, + label: PropTypes.string.isRequired, + }) + ), + name: PropTypes.string.isRequired, + placeholder: PropTypes.string, + validationStatus: PropTypes.string, + validationMsg: PropTypes.string, + isDisabled: PropTypes.bool, +}; + +AutocompleteInputComponent.defaultProps = { + options: [], + selected: undefined, + placeholder: DEFAULT_PLACEHOLDER, + validationStatus: undefined, + validationMsg: null, + onSelect: () => {}, + onChange: () => {}, + isDisabled: false, +}; + +export default AutocompleteInputComponent; diff --git a/webpack/assets/javascripts/react_app/components/common/AutocompleteInput/__tests__/AutocompleteInput.test.js b/webpack/assets/javascripts/react_app/components/common/AutocompleteInput/__tests__/AutocompleteInput.test.js new file mode 100644 index 00000000000..f3d1bba83e9 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/AutocompleteInput/__tests__/AutocompleteInput.test.js @@ -0,0 +1,201 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import AutocompleteInput from '../AutocompleteInput'; + +const setSelected = jest.fn(); +const defaultProps = { + selected: '', + onSelect: setSelected, + name: 'test-field', + options: [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2' }, + { value: 'option3', label: 'Option 3' }, + ], +}; + +describe('AutocompleteInput RTL Tests', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Rendering', () => { + test('renders input field with placeholder', () => { + render(); + + expect( + screen.getByPlaceholderText('Start typing to search') + ).toBeInTheDocument(); + }); + + test('renders with initial selected value', () => { + render(); + + const input = screen.getByRole('combobox'); + expect(input).toHaveValue('Option 1'); + }); + }); + + describe('Filtering', () => { + test('filters options based on input', async () => { + render(); + + const input = screen.getByRole('combobox'); + fireEvent.change(input, { target: { value: 'Option 1' } }); + + await waitFor(() => { + expect(screen.getByText('Option 1')).toBeInTheDocument(); + expect(screen.queryByText('Option 2')).not.toBeInTheDocument(); + expect(screen.queryByText('Option 3')).not.toBeInTheDocument(); + }); + }); + + test('shows no results message when no matches', async () => { + render(); + + const input = screen.getByRole('combobox'); + fireEvent.change(input, { target: { value: 'nonexistent' } }); + + await waitFor(() => { + expect( + screen.getByText('No results found for nonexistent') + ).toBeInTheDocument(); + }); + }); + + test('case insensitive filtering', async () => { + render(); + + const input = screen.getByRole('combobox'); + fireEvent.change(input, { target: { value: 'option 1' } }); + + await waitFor(() => { + expect(screen.getByText('Option 1')).toBeInTheDocument(); + }); + }); + }); + + describe('Selection', () => { + test('selects option when clicked', async () => { + render(); + + const input = screen.getByRole('combobox'); + fireEvent.click(input); + + await waitFor(() => { + const option = screen.getByText('Option 1'); + fireEvent.click(option); + }); + + await waitFor(() => { + expect(setSelected).toHaveBeenCalledWith('option1'); + }); + }); + + test('updates input value when option is selected', async () => { + render(); + + const input = screen.getByRole('combobox'); + fireEvent.click(input); + + await waitFor(() => { + const option = screen.getByText('Option 2'); + fireEvent.click(option); + }); + + expect(input).toHaveValue('Option 2'); + }); + + test('closes dropdown after selection', async () => { + render(); + + const input = screen.getByRole('combobox'); + fireEvent.click(input); + + await waitFor(() => { + const option = screen.getByText('Option 1'); + fireEvent.click(option); + }); + + await waitFor(() => { + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + }); + }); + + describe('Keyboard Navigation', () => { + test('opens dropdown on Enter key', async () => { + render(); + + const input = screen.getByRole('combobox'); + fireEvent.keyDown(input, { key: 'Enter' }); + + await waitFor(() => { + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + }); + + test('navigates options with arrow keys', async () => { + render(); + + const input = screen.getByRole('combobox'); + fireEvent.click(input); + + fireEvent.keyDown(input, { key: 'ArrowDown' }); + await waitFor(() => { + expect(screen.getByText('Option 1')).toHaveClass( + 'pf-v5-c-menu__item-text' + ); + }); + }); + }); + + describe('Edge Cases', () => { + test('handles empty options array', async () => { + render(); + + const input = screen.getByRole('combobox'); + fireEvent.click(input); + + await waitFor(() => { + expect(screen.getByText('No matches found')).toBeInTheDocument(); + }); + }); + + test('handles undefined selected value', () => { + render(); + + const input = screen.getByRole('combobox'); + expect(input).toHaveValue(''); + }); + + test('handles numeric selected value', () => { + render(); + + const input = screen.getByRole('combobox'); + expect(input).toHaveValue('123'); + }); + + test('handles boolean selected value', () => { + render(); + + const input = screen.getByRole('combobox'); + expect(input).toHaveValue('true'); + }); + }); + + describe('Accessibility', () => { + test('has proper ARIA attributes when focused', async () => { + render(); + + const input = screen.getByRole('combobox'); + fireEvent.click(input); + + await waitFor(() => { + fireEvent.keyDown(input, { key: 'ArrowDown' }); + expect(input).toHaveAttribute('aria-activedescendant'); + }); + }); + }); +});