diff --git a/webpack/assets/javascripts/react_app/components/common/forms/ForemanForm/ForemanForm.js b/webpack/assets/javascripts/react_app/components/common/forms/ForemanForm/ForemanForm.js index 17c16ac1db..f5a793bd0c 100644 --- a/webpack/assets/javascripts/react_app/components/common/forms/ForemanForm/ForemanForm.js +++ b/webpack/assets/javascripts/react_app/components/common/forms/ForemanForm/ForemanForm.js @@ -14,6 +14,7 @@ const ForemanForm = ({ validationSchema, enableReinitialize, onCancel, + isPF5, }) => ( {cloneChildren(children, { formProps, disabled })} @@ -63,11 +67,13 @@ ForemanForm.propTypes = { validationSchema: PropTypes.object, children: PropTypes.oneOfType([PropTypes.object, PropTypes.array]).isRequired, enableReinitialize: PropTypes.bool, + isPF5: PropTypes.bool, }; ForemanForm.defaultProps = { validationSchema: undefined, enableReinitialize: false, + isPF5: false, }; export default ForemanForm; diff --git a/webpack/assets/javascripts/react_app/components/common/forms/ForemanForm/__snapshots__/ForemanForm.test.js.snap b/webpack/assets/javascripts/react_app/components/common/forms/ForemanForm/__snapshots__/ForemanForm.test.js.snap index 8cdefd7ac4..6e22874135 100644 --- a/webpack/assets/javascripts/react_app/components/common/forms/ForemanForm/__snapshots__/ForemanForm.test.js.snap +++ b/webpack/assets/javascripts/react_app/components/common/forms/ForemanForm/__snapshots__/ForemanForm.test.js.snap @@ -31,6 +31,7 @@ exports[`ForemanForm render foreman form with fields 1`] = ` "surname": "Lindbergh", } } + isPF5={false} onCancel={[Function]} onSubmit={[Function]} validationSchema={ diff --git a/webpack/assets/javascripts/react_app/components/common/forms/FormField.fixtures.js b/webpack/assets/javascripts/react_app/components/common/forms/FormField.fixtures.js index 4e94162850..66ccdd5c6c 100644 --- a/webpack/assets/javascripts/react_app/components/common/forms/FormField.fixtures.js +++ b/webpack/assets/javascripts/react_app/components/common/forms/FormField.fixtures.js @@ -46,6 +46,7 @@ export const formAutocompleteDataProps = { searchQuery: '', name: 'Filter[search]', id: 'form-search', + }; export const counterProps = { diff --git a/webpack/assets/javascripts/react_app/components/common/forms/FormField.js b/webpack/assets/javascripts/react_app/components/common/forms/FormField.js index a82b368c14..c77c7f3e2d 100644 --- a/webpack/assets/javascripts/react_app/components/common/forms/FormField.js +++ b/webpack/assets/javascripts/react_app/components/common/forms/FormField.js @@ -8,11 +8,53 @@ import { HelpBlock, FieldLevelHelp, } from 'patternfly-react'; -import { Icon } from '@patternfly/react-core'; +import { + FormGroup as PF5FormGroup, + FormHelperText, + HelperText, + HelperTextItem, + Icon, +} from '@patternfly/react-core'; + +import LabelIcon from 'foremanReact/components/common/LabelIcon'; import { WarningTriangleIcon, ErrorCircleOIcon } from '@patternfly/react-icons'; import InputFactory from './InputFactory'; import { noop } from '../../../common/helpers'; +const InlineMessagePF5 = ({ error, warning, helpInline }) => { + if (!error && !warning && !helpInline) { + return null; + } + const validationState = Object.entries({ error, warning }).find( + ([_, v]) => v + )?.[0]; + const icon = () => + ({ + error: { icon: }, + warning: { icon: }, + }[validationState]); + + return ( + + + + {error || warning || helpInline} + + + + ); +}; +InlineMessagePF5.propTypes = { + error: PropTypes.string, + warning: PropTypes.string, + helpInline: PropTypes.string, +}; +InlineMessagePF5.defaultProps = { + error: null, + warning: null, + helpInline: null, +}; + const InlineMessage = ({ error, warning, helpInline }) => { if (!error && !warning && !helpInline) { return null; @@ -66,6 +108,7 @@ const FormField = ({ onChange, children, inputProps, + isPF5, ...otherProps }) => { const [innerError, setError] = useState(error); @@ -88,6 +131,28 @@ const FormField = ({ if (innerWarning) validationState = 'warning'; if (innerError) validationState = 'error'; + if (isPF5) { + return ( + : null + } + isRequired={required} + isStack + disabled={disabled} + > + {children} + + + ); + } return ( { + const initState = (selectedItem || [{ value: '', label: '' }])[0]; + const [isOpen, setIsOpen] = React.useState(false); + const [selected, setSelected] = React.useState(initState.value); + const [inputText, setInputText] = React.useState(initState.label); + const [filterValue, setFilterValue] = React.useState(''); + const [selectOptions, setSelectOptions] = React.useState(options); + const [focusedItemIndex, setFocusedItemIndex] = React.useState(null); + const [activeItemId, setActiveItemId] = React.useState(null); + const textInputRef = React.useRef(); + const NO_RESULTS = 'no results'; + + React.useEffect(() => { + let newSelectOptions = options; + if (filterValue) { + newSelectOptions = options.filter(menuItem => + String(menuItem.value) + .toLowerCase() + .includes(filterValue.toLowerCase()) + ); + if (!newSelectOptions.length) { + newSelectOptions = [ + { + isAriaDisabled: true, + children: `No results found for "${filterValue}"`, + value: NO_RESULTS, + }, + ]; + } + if (!isOpen) { + setIsOpen(true); + } + } + setSelectOptions(newSelectOptions); + }, [filterValue, isOpen, options]); + + const createItemId = value => + `select-typeahead-${String(value || '').replace(' ', '-')}`; + 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 onInputClick = () => { + if (!isOpen) { + setIsOpen(true); + } else if (!inputText) { + closeMenu(); + } + }; + const selectOption = (value, content) => { + // console.log('selected', content); + setInputText(String(content)); + setFilterValue(''); + setSelected(String(value)); + onChange([{ value }]); + closeMenu(); + }; + const onSelect = (_event, value) => { + if (value && value !== NO_RESULTS) { + const optionText = selectOptions.find(option => option.value === value); + selectOption(value, optionText?.label || optionText?.children); + } + }; + const onTextInputChange = (_event, value) => { + setInputText(value); + setFilterValue(value); + resetActiveAndFocusedItem(); + if (value !== selected) { + setSelected(''); + } + }; + 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; + switch (event.key) { + case 'Enter': + if ( + isOpen && + focusedItem && + focusedItem.value !== NO_RESULTS && + !focusedItem.isAriaDisabled + ) { + selectOption(focusedItem.value, focusedItem.children); + } + if (!isOpen) { + setIsOpen(true); + } + break; + case 'ArrowUp': + case 'ArrowDown': + event.preventDefault(); + handleMenuArrowKeys(event.key); + break; + default: + break; + } + }; + const onToggleClick = () => { + setIsOpen(!isOpen); + // eslint-disable-next-line no-unused-expressions + textInputRef?.current?.focus(); + }; + const onClearButtonClick = () => { + setSelected(''); + setInputText(''); + setFilterValue(''); + resetActiveAndFocusedItem(); + // eslint-disable-next-line no-unused-expressions + textInputRef?.current?.focus(); + }; + const toggle = toggleRef => ( + + + + + + + + + + ); + return ( + + ); +}; + +TypeAheadSelect.propTypes = { + placeholder: PropTypes.string, + options: PropTypes.arrayOf( + PropTypes.shape({ + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) + .isRequired, + label: PropTypes.string.isRequired, + className: PropTypes.string, + }) + ).isRequired, + onChange: PropTypes.func.isRequired, + selectedItem: PropTypes.array, +}; + +TypeAheadSelect.defaultProps = { + placeholder: '', + selectedItem: [], +}; + +export default TypeAheadSelect; diff --git a/webpack/assets/javascripts/react_app/components/common/forms/__tests__/FormField.test.js b/webpack/assets/javascripts/react_app/components/common/forms/__tests__/FormField.test.js index 15dcbe4d79..06bbb77a5b 100644 --- a/webpack/assets/javascripts/react_app/components/common/forms/__tests__/FormField.test.js +++ b/webpack/assets/javascripts/react_app/components/common/forms/__tests__/FormField.test.js @@ -1,4 +1,7 @@ -import { testComponentSnapshotsWithFixtures } from '../../../../common/testHelpers'; +import React from 'react'; +import { screen, fireEvent, render, act } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; + import { dateTimeWithErrorProps, textFieldWithHelpProps, @@ -18,6 +21,38 @@ const fixtures = { describe('FormField', () => { describe('rendering', () => { - testComponentSnapshotsWithFixtures(FormField, fixtures); - }); -}); + it('renders text input', () => { + const { container } = render(); + expect(container.querySelector('input[type="text"]')).toBeInTheDocument(); + }); + + it('renders Date input', () => { + const { container } = render(); + expect(container.querySelector('input[aria-label="date-time-picker-input"]')).toBeInTheDocument(); + }); + + it('renders Time input', () => { + const { container } = render(); + expect(container.querySelector('input[aria-label="date-time-picker-input"]')).toBeInTheDocument(); + }) + + it('renders DateTime input', () => { + const { container } = render(); + expect(container.querySelector('input[aria-label="date-picker-input"]')).toBeInTheDocument(); + }) + + it('renders text complex options and help', () => { + const { container } = render(); + expect(container.querySelector('input[name="group[textfield]"]')).toBeInTheDocument(); + }) + + it('renders DateTime complex options and error', () => { + render(); + expect(screen.getByLabelText('DateTime with error')).toBeInTheDocument(); + }) + + xit('renders AutoComplete', () => { // skipping since already updated in another test + render(); + }) + }) +}) diff --git a/webpack/assets/javascripts/react_app/components/common/forms/__tests__/__snapshots__/FormField.test.js.snap b/webpack/assets/javascripts/react_app/components/common/forms/__tests__/__snapshots__/FormField.test.js.snap index 401c18901c..475c6f9a6a 100644 --- a/webpack/assets/javascripts/react_app/components/common/forms/__tests__/__snapshots__/FormField.test.js.snap +++ b/webpack/assets/javascripts/react_app/components/common/forms/__tests__/__snapshots__/FormField.test.js.snap @@ -45,234 +45,3 @@ exports[`FormField rendering renders AutoComplete 1`] = ` /> `; - -exports[`FormField rendering renders Date input 1`] = ` - - - - - - - -`; - -exports[`FormField rendering renders DateTime complex options and error 1`] = ` - - - DateTime with error - - - - - - -`; - -exports[`FormField rendering renders DateTime input 1`] = ` - - - - - - - -`; - -exports[`FormField rendering renders Time input 1`] = ` - - - - - - - -`; - -exports[`FormField rendering renders text complex options and help 1`] = ` - - - textField - - This is more helpful text - - } - placement="right" - rootClose={true} - /> - - - - - - -`; - -exports[`FormField rendering renders text input 1`] = ` - - - - - - - -`;