diff --git a/webpack/ForemanWebhooks/Routes/Webhooks/Components/WebhookForm/Components/AutocompleteInput.js b/webpack/ForemanWebhooks/Routes/Webhooks/Components/WebhookForm/Components/AutocompleteInput.js new file mode 100644 index 0000000..aad82b9 --- /dev/null +++ b/webpack/ForemanWebhooks/Routes/Webhooks/Components/WebhookForm/Components/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/ForemanWebhooks/Routes/Webhooks/Components/WebhookForm/Components/FieldConstructor.js b/webpack/ForemanWebhooks/Routes/Webhooks/Components/WebhookForm/Components/FieldConstructor.js new file mode 100644 index 0000000..958385b --- /dev/null +++ b/webpack/ForemanWebhooks/Routes/Webhooks/Components/WebhookForm/Components/FieldConstructor.js @@ -0,0 +1,310 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { translate as __ } from 'foremanReact/common/I18n'; +import { + ExclamationCircleIcon, + HelpIcon, + PencilAltIcon, +} from '@patternfly/react-icons'; +import { + TextInput, + Checkbox, + Button, + FormHelperText, + HelperText, + HelperTextItem, + Icon, + TextArea, + FormGroup, + Popover, + Grid, + GridItem, +} from '@patternfly/react-core'; + +import AutocompleteInput from './AutocompleteInput'; + +const FormField = ({ + name, + type, + required, + options, + isLoading, + validated, + value, + disabled, + setValue, + placeholder, + errMsg, + ...props +}) => { + const [fieldValidated, setFieldValidated] = useState('default'); + const [firstLoad, setFirstLoad] = useState(true); + const [isDisabled, setIsDisabled] = useState(disabled); + + const requiredValidate = () => { + if (firstLoad || !required) return; + if (!value || value === '' || validated === 'error') + setFieldValidated('error'); + else setFieldValidated('success'); + }; + + const localHandler = (_event, newValue) => { + setValue(name, newValue); + }; + + useEffect(() => { + setFirstLoad(false); + }, []); + + useEffect(() => { + setIsDisabled(disabled); + }, [disabled]); + + useEffect(() => { + requiredValidate(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]); + + if (type === 'checkbox') { + return ( + <> + { + setValue(name, newValue); + }} + isDisabled={isLoading || isDisabled} + isRequired={required} + type={type} + validated={fieldValidated} + /> + + + ); + } else if (type === 'textarea') { + return ( + <> +