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(