diff --git a/lib/foreman_webhooks/engine.rb b/lib/foreman_webhooks/engine.rb index 675eb60..a1af628 100644 --- a/lib/foreman_webhooks/engine.rb +++ b/lib/foreman_webhooks/engine.rb @@ -14,7 +14,7 @@ class Engine < ::Rails::Engine initializer 'foreman_webhooks.register_plugin', before: :finisher_hook do |app| app.reloader.to_prepare do Foreman::Plugin.register :foreman_webhooks do - requires_foreman '>= 3.13' + requires_foreman '>= 3.18' register_gettext apipie_documented_controllers ["#{ForemanWebhooks::Engine.root}/app/controllers/api/v2/*.rb"] diff --git a/webpack/ForemanWebhooks/Routes/Webhooks/Components/WebhookForm/Components/AutocompleteInput.js b/webpack/ForemanWebhooks/Routes/Webhooks/Components/WebhookForm/Components/AutocompleteInput.js deleted file mode 100644 index e85fda8..0000000 --- a/webpack/ForemanWebhooks/Routes/Webhooks/Components/WebhookForm/Components/AutocompleteInput.js +++ /dev/null @@ -1,328 +0,0 @@ -/* eslint-disable max-lines */ - -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, - fieldId, -}) => { - 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(() => { - const input = textInputRef.current; - if (!input) return; - input.setAttribute('autocomplete', 'off'); - }, []); - - 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); - }; - - 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, - fieldId: PropTypes.string, -}; - -AutocompleteInputComponent.defaultProps = { - options: [], - selected: undefined, - placeholder: DEFAULT_PLACEHOLDER, - validationStatus: undefined, - validationMsg: null, - onSelect: () => {}, - onChange: () => {}, - isDisabled: false, - fieldId: undefined, -}; - -export default AutocompleteInputComponent; diff --git a/webpack/ForemanWebhooks/Routes/Webhooks/Components/WebhookForm/Components/FieldConstructor.js b/webpack/ForemanWebhooks/Routes/Webhooks/Components/WebhookForm/Components/FieldConstructor.js index c616908..d47efe1 100644 --- a/webpack/ForemanWebhooks/Routes/Webhooks/Components/WebhookForm/Components/FieldConstructor.js +++ b/webpack/ForemanWebhooks/Routes/Webhooks/Components/WebhookForm/Components/FieldConstructor.js @@ -1,6 +1,7 @@ /* eslint-disable max-lines */ import React, { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; +import AutocompleteInput from 'foremanReact/components/common/AutocompleteInput/AutocompleteInput'; import { translate as __ } from 'foremanReact/common/I18n'; import { ExclamationCircleIcon, @@ -22,8 +23,6 @@ import { GridItem, } from '@patternfly/react-core'; -import AutocompleteInput from './AutocompleteInput'; - const FormField = ({ name, type, diff --git a/webpack/ForemanWebhooks/Routes/Webhooks/Components/WebhookForm/Components/__tests__/AutocompleteInput.test.js b/webpack/ForemanWebhooks/Routes/Webhooks/Components/WebhookForm/Components/__tests__/AutocompleteInput.test.js deleted file mode 100644 index f3d1bba..0000000 --- a/webpack/ForemanWebhooks/Routes/Webhooks/Components/WebhookForm/Components/__tests__/AutocompleteInput.test.js +++ /dev/null @@ -1,201 +0,0 @@ -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'); - }); - }); - }); -}); diff --git a/webpack/ForemanWebhooks/Routes/Webhooks/Components/WebhookForm/Components/__tests__/FieldConstructor.test.js b/webpack/ForemanWebhooks/Routes/Webhooks/Components/WebhookForm/Components/__tests__/FieldConstructor.test.js index f349099..6b7dbc1 100644 --- a/webpack/ForemanWebhooks/Routes/Webhooks/Components/WebhookForm/Components/__tests__/FieldConstructor.test.js +++ b/webpack/ForemanWebhooks/Routes/Webhooks/Components/WebhookForm/Components/__tests__/FieldConstructor.test.js @@ -114,6 +114,22 @@ describe('FieldConstructor RTL Tests', () => { }); }); + describe('Autocomplete render test', () => { + test('renders autocomplete', () => { + const props = { + ...defaultProps, + onChange: defaultProps.setValue, + options: [ + { value: 'host_created.event.foreman', label: 'Host Created' }, + { value: 'host_updated.event.foreman', label: 'Host Updated' }, + ], + }; + render(); + + expect(screen.getByRole('combobox')).toBeInTheDocument(); + }); + }); + describe('Label Help', () => { test('renders help icon when labelHelp is provided', () => { render(