diff --git a/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.jsx b/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.jsx index 1de791ab58..cf860ab8f0 100644 --- a/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.jsx +++ b/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.jsx @@ -25,7 +25,7 @@ class AnsibleSelect extends React.Component { } render() { - const { id, data, i18n, isValid, onBlur, value } = this.props; + const { id, data, i18n, isValid, onBlur, value, className } = this.props; return ( - {data.map(datum => ( + {data.map(option => ( ))} @@ -49,19 +50,28 @@ class AnsibleSelect extends React.Component { } } +const Option = shape({ + key: oneOfType([string, number]).isRequired, + value: oneOfType([string, number]).isRequired, + label: string.isRequired, + isDisabled: bool, +}); + AnsibleSelect.defaultProps = { data: [], isValid: true, onBlur: () => {}, + className: '', }; AnsibleSelect.propTypes = { - data: arrayOf(shape()), + data: arrayOf(Option), id: string.isRequired, isValid: bool, onBlur: func, onChange: func.isRequired, value: oneOfType([string, number]).isRequired, + className: string, }; export { AnsibleSelect as _AnsibleSelect }; diff --git a/awx/ui_next/src/components/CheckboxListItem/CheckboxListItem.jsx b/awx/ui_next/src/components/CheckboxListItem/CheckboxListItem.jsx index 65958e47e8..9508672789 100644 --- a/awx/ui_next/src/components/CheckboxListItem/CheckboxListItem.jsx +++ b/awx/ui_next/src/components/CheckboxListItem/CheckboxListItem.jsx @@ -16,6 +16,7 @@ const CheckboxListItem = ({ label, isSelected, onSelect, + onDeselect, isRadio, }) => { const CheckboxRadio = isRadio ? DataListRadio : DataListCheck; @@ -25,7 +26,7 @@ const CheckboxListItem = ({ { label="Buzz" isSelected={false} onSelect={() => {}} + onDeselect={() => {}} /> ); expect(wrapper).toHaveLength(1); diff --git a/awx/ui_next/src/components/ContentLoading/ContentLoading.jsx b/awx/ui_next/src/components/ContentLoading/ContentLoading.jsx index f242bbca10..90747007da 100644 --- a/awx/ui_next/src/components/ContentLoading/ContentLoading.jsx +++ b/awx/ui_next/src/components/ContentLoading/ContentLoading.jsx @@ -4,8 +4,8 @@ import { withI18n } from '@lingui/react'; import { EmptyState, EmptyStateBody } from '@patternfly/react-core'; // TODO: Better loading state - skeleton lines / spinner, etc. -const ContentLoading = ({ i18n }) => ( - +const ContentLoading = ({ className, i18n }) => ( + {i18n._(t`Loading...`)} ); diff --git a/awx/ui_next/src/components/Lookup/CredentialLookup.jsx b/awx/ui_next/src/components/Lookup/CredentialLookup.jsx index 6872e09784..6b61b3c486 100644 --- a/awx/ui_next/src/components/Lookup/CredentialLookup.jsx +++ b/awx/ui_next/src/components/Lookup/CredentialLookup.jsx @@ -1,11 +1,20 @@ -import React from 'react'; -import { withI18n } from '@lingui/react'; +import React, { useEffect, useState } from 'react'; import { bool, func, number, string, oneOfType } from 'prop-types'; +import { withRouter } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; import { CredentialsAPI } from '@api'; import { Credential } from '@types'; -import { mergeParams } from '@util/qs'; +import { getQSConfig, parseQueryString, mergeParams } from '@util/qs'; import { FormGroup } from '@patternfly/react-core'; import Lookup from '@components/Lookup'; +import OptionsList from './shared/OptionsList'; +import LookupErrorMessage from './shared/LookupErrorMessage'; + +const QS_CONFIG = getQSConfig('credentials', { + page: 1, + page_size: 5, + order_by: 'name', +}); function CredentialLookup({ helperTextInvalid, @@ -16,11 +25,28 @@ function CredentialLookup({ required, credentialTypeId, value, + history, }) { - const getCredentials = async params => - CredentialsAPI.read( - mergeParams(params, { credential_type: credentialTypeId }) - ); + const [credentials, setCredentials] = useState([]); + const [count, setCount] = useState(0); + const [error, setError] = useState(null); + + useEffect(() => { + (async () => { + const params = parseQueryString(QS_CONFIG, history.location.search); + try { + const { data } = await CredentialsAPI.read( + mergeParams(params, { credential_type: credentialTypeId }) + ); + setCredentials(data.results); + setCount(data.count); + } catch (err) { + if (setError) { + setError(err); + } + } + })(); + }, [credentialTypeId, history.location.search]); return ( ( + dispatch({ type: 'SELECT_ITEM', item })} + deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })} + /> + )} /> + ); } @@ -65,4 +102,4 @@ CredentialLookup.defaultProps = { }; export { CredentialLookup as _CredentialLookup }; -export default withI18n()(CredentialLookup); +export default withI18n()(withRouter(CredentialLookup)); diff --git a/awx/ui_next/src/components/Lookup/CredentialLookup.test.jsx b/awx/ui_next/src/components/Lookup/CredentialLookup.test.jsx index 797cbdf6c2..658229163e 100644 --- a/awx/ui_next/src/components/Lookup/CredentialLookup.test.jsx +++ b/awx/ui_next/src/components/Lookup/CredentialLookup.test.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import { act } from 'react-dom/test-utils'; import { mountWithContexts } from '@testUtils/enzymeHelpers'; import CredentialLookup, { _CredentialLookup } from './CredentialLookup'; import { CredentialsAPI } from '@api'; @@ -9,19 +10,48 @@ describe('CredentialLookup', () => { let wrapper; beforeEach(() => { - wrapper = mountWithContexts( - {}} /> - ); + CredentialsAPI.read.mockResolvedValueOnce({ + data: { + results: [ + { id: 1, kind: 'cloud', name: 'Cred 1', url: 'www.google.com' }, + { id: 2, kind: 'ssh', name: 'Cred 2', url: 'www.google.com' }, + { id: 3, kind: 'Ansible', name: 'Cred 3', url: 'www.google.com' }, + { id: 4, kind: 'Machine', name: 'Cred 4', url: 'www.google.com' }, + { id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' }, + ], + count: 5, + }, + }); }); afterEach(() => { jest.clearAllMocks(); + wrapper.unmount(); }); - test('initially renders successfully', () => { + test('should render successfully', async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); expect(wrapper.find('CredentialLookup')).toHaveLength(1); }); - test('should fetch credentials', () => { + + test('should fetch credentials', async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); expect(CredentialsAPI.read).toHaveBeenCalledTimes(1); expect(CredentialsAPI.read).toHaveBeenCalledWith({ credential_type: 1, @@ -30,11 +60,31 @@ describe('CredentialLookup', () => { page_size: 5, }); }); - test('should display label', () => { + + test('should display label', async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); const title = wrapper.find('FormGroup .pf-c-form__label-text'); expect(title.text()).toEqual('Foo'); }); - test('should define default value for function props', () => { + + test('should define default value for function props', async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); expect(_CredentialLookup.defaultProps.onBlur).toBeInstanceOf(Function); expect(_CredentialLookup.defaultProps.onBlur).not.toThrow(); }); diff --git a/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx b/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx index 1e58f3eafa..20c2e0cf20 100644 --- a/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx +++ b/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx @@ -1,48 +1,69 @@ -import React from 'react'; -import PropTypes from 'prop-types'; +import React, { useState, useEffect } from 'react'; +import { arrayOf, string, func, object, bool } from 'prop-types'; +import { withRouter } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { FormGroup, Tooltip } from '@patternfly/react-core'; -import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons'; -import styled from 'styled-components'; - +import { FormGroup } from '@patternfly/react-core'; import { InstanceGroupsAPI } from '@api'; -import Lookup from '@components/Lookup'; +import { getQSConfig, parseQueryString } from '@util/qs'; +import { FieldTooltip } from '@components/FormField'; +import Lookup from './Lookup'; +import OptionsList from './shared/OptionsList'; +import LookupErrorMessage from './shared/LookupErrorMessage'; -const QuestionCircleIcon = styled(PFQuestionCircleIcon)` - margin-left: 10px; -`; +const QS_CONFIG = getQSConfig('instance_groups', { + page: 1, + page_size: 5, + order_by: 'name', +}); -const getInstanceGroups = async params => InstanceGroupsAPI.read(params); +function InstanceGroupsLookup(props) { + const { + value, + onChange, + tooltip, + className, + required, + history, + i18n, + } = props; + const [instanceGroups, setInstanceGroups] = useState([]); + const [count, setCount] = useState(0); + const [error, setError] = useState(null); -class InstanceGroupsLookup extends React.Component { - render() { - const { value, tooltip, onChange, className, i18n } = this.props; + useEffect(() => { + (async () => { + const params = parseQueryString(QS_CONFIG, history.location.search); + try { + const { data } = await InstanceGroupsAPI.read(params); + setInstanceGroups(data.results); + setCount(data.count); + } catch (err) { + setError(err); + } + })(); + }, [history.location]); - /* - Wrapping
added to workaround PF bug: - https://github.com/patternfly/patternfly-react/issues/2855 - */ - return ( -
- - {tooltip && ( - - - - )} - + {tooltip && } + ( + dispatch({ type: 'SELECT_ITEM', item })} + deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })} /> - -
- ); - } + )} + /> + + + ); } InstanceGroupsLookup.propTypes = { - value: PropTypes.arrayOf(PropTypes.object).isRequired, - tooltip: PropTypes.string, - onChange: PropTypes.func.isRequired, + value: arrayOf(object).isRequired, + tooltip: string, + onChange: func.isRequired, + className: string, + required: bool, }; InstanceGroupsLookup.defaultProps = { tooltip: '', + className: '', + required: false, }; -export default withI18n()(InstanceGroupsLookup); +export default withI18n()(withRouter(InstanceGroupsLookup)); diff --git a/awx/ui_next/src/components/Lookup/InventoryLookup.jsx b/awx/ui_next/src/components/Lookup/InventoryLookup.jsx index d570e79128..0286561a6a 100644 --- a/awx/ui_next/src/components/Lookup/InventoryLookup.jsx +++ b/awx/ui_next/src/components/Lookup/InventoryLookup.jsx @@ -1,5 +1,6 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { string, func, bool } from 'prop-types'; +import { withRouter } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { FormGroup } from '@patternfly/react-core'; @@ -7,61 +8,94 @@ import { InventoriesAPI } from '@api'; import { Inventory } from '@types'; import Lookup from '@components/Lookup'; import { FieldTooltip } from '@components/FormField'; +import { getQSConfig, parseQueryString } from '@util/qs'; +import OptionsList from './shared/OptionsList'; +import LookupErrorMessage from './shared/LookupErrorMessage'; -const getInventories = async params => InventoriesAPI.read(params); +const QS_CONFIG = getQSConfig('inventory', { + page: 1, + page_size: 5, + order_by: 'name', +}); -class InventoryLookup extends React.Component { - render() { - const { - value, - tooltip, - onChange, - onBlur, - required, - isValid, - helperTextInvalid, - i18n, - } = this.props; +function InventoryLookup({ + value, + tooltip, + onChange, + onBlur, + required, + isValid, + helperTextInvalid, + i18n, + history, +}) { + const [inventories, setInventories] = useState([]); + const [count, setCount] = useState(0); + const [error, setError] = useState(null); - return ( - - {tooltip && } - - - ); - } + useEffect(() => { + (async () => { + const params = parseQueryString(QS_CONFIG, history.location.search); + try { + const { data } = await InventoriesAPI.read(params); + setInventories(data.results); + setCount(data.count); + } catch (err) { + setError(err); + } + })(); + }, [history.location]); + + return ( + + {tooltip && } + ( + dispatch({ type: 'SELECT_ITEM', item })} + deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })} + /> + )} + /> + + + ); } InventoryLookup.propTypes = { @@ -77,4 +111,4 @@ InventoryLookup.defaultProps = { required: false, }; -export default withI18n()(InventoryLookup); +export default withI18n()(withRouter(InventoryLookup)); diff --git a/awx/ui_next/src/components/Lookup/Lookup.jsx b/awx/ui_next/src/components/Lookup/Lookup.jsx index ce2f85f24b..500c4ce986 100644 --- a/awx/ui_next/src/components/Lookup/Lookup.jsx +++ b/awx/ui_next/src/components/Lookup/Lookup.jsx @@ -1,4 +1,4 @@ -import React, { Fragment } from 'react'; +import React, { Fragment, useReducer, useEffect } from 'react'; import { string, bool, @@ -15,20 +15,14 @@ import { ButtonVariant, InputGroup as PFInputGroup, Modal, - ToolbarItem, } from '@patternfly/react-core'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import styled from 'styled-components'; -import AnsibleSelect from '../AnsibleSelect'; -import PaginatedDataList from '../PaginatedDataList'; -import VerticalSeperator from '../VerticalSeparator'; -import DataListToolbar from '../DataListToolbar'; -import CheckboxListItem from '../CheckboxListItem'; -import SelectedList from '../SelectedList'; -import { ChipGroup, Chip, CredentialChip } from '../Chip'; -import { getQSConfig, parseQueryString } from '../../util/qs'; +import reducer, { initReducer } from './shared/reducer'; +import { ChipGroup, Chip } from '../Chip'; +import { QSConfig } from '@types'; const SearchButton = styled(Button)` ::after { @@ -36,6 +30,7 @@ const SearchButton = styled(Button)` var(--pf-global--BorderColor--200); } `; +SearchButton.displayName = 'SearchButton'; const InputGroup = styled(PFInputGroup)` ${props => @@ -54,315 +49,124 @@ const ChipHolder = styled.div` border-bottom-right-radius: 3px; `; -class Lookup extends React.Component { - constructor(props) { - super(props); +function Lookup(props) { + const { + id, + header, + onChange, + onBlur, + value, + multiple, + required, + qsConfig, + renderItemChip, + renderOptionsList, + history, + i18n, + } = props; - this.assertCorrectValueType(); - let lookupSelectedItems = []; - if (props.value) { - lookupSelectedItems = props.multiple ? [...props.value] : [props.value]; - } - this.state = { - isModalOpen: false, - lookupSelectedItems, - results: [], - count: 0, - error: null, - }; - this.qsConfig = getQSConfig(props.qsNamespace, { - page: 1, - page_size: 5, - order_by: props.sortedColumnKey, - }); - this.handleModalToggle = this.handleModalToggle.bind(this); - this.toggleSelected = this.toggleSelected.bind(this); - this.saveModal = this.saveModal.bind(this); - this.getData = this.getData.bind(this); - this.clearQSParams = this.clearQSParams.bind(this); - } + const [state, dispatch] = useReducer( + reducer, + { value, multiple, required }, + initReducer + ); - componentDidMount() { - this.getData(); - } + useEffect(() => { + dispatch({ type: 'SET_MULTIPLE', value: multiple }); + }, [multiple]); - componentDidUpdate(prevProps) { - const { location, selectedCategory } = this.props; - if ( - location !== prevProps.location || - prevProps.selectedCategory !== selectedCategory - ) { - this.getData(); - } - } + useEffect(() => { + dispatch({ type: 'SET_VALUE', value }); + }, [value]); - assertCorrectValueType() { - const { multiple, value, selectCategoryOptions } = this.props; - if (selectCategoryOptions) { - return; - } - if (!multiple && Array.isArray(value)) { - throw new Error( - 'Lookup value must not be an array unless `multiple` is set' - ); - } - if (multiple && !Array.isArray(value)) { - throw new Error('Lookup value must be an array if `multiple` is set'); - } - } - - async getData() { - const { - getItems, - location: { search }, - } = this.props; - const queryParams = parseQueryString(this.qsConfig, search); - - this.setState({ error: false }); - try { - const { data } = await getItems(queryParams); - const { results, count } = data; - - this.setState({ - results, - count, - }); - } catch (err) { - this.setState({ error: true }); - } - } - - toggleSelected(row) { - const { - name, - onLookupSave, - multiple, - onToggleItem, - selectCategoryOptions, - } = this.props; - const { - lookupSelectedItems: updatedSelectedItems, - isModalOpen, - } = this.state; - - const selectedIndex = updatedSelectedItems.findIndex( - selectedRow => selectedRow.id === row.id - ); - if (multiple) { - if (selectCategoryOptions) { - onToggleItem(row, isModalOpen); - } - if (selectedIndex > -1) { - updatedSelectedItems.splice(selectedIndex, 1); - this.setState({ lookupSelectedItems: updatedSelectedItems }); - } else { - this.setState(prevState => ({ - lookupSelectedItems: [...prevState.lookupSelectedItems, row], - })); - } - } else { - this.setState({ lookupSelectedItems: [row] }); - } - - // Updates the selected items from parent state - // This handles the case where the user removes chips from the lookup input - // while the modal is closed - if (!isModalOpen) { - onLookupSave(updatedSelectedItems, name); - } - } - - handleModalToggle() { - const { isModalOpen } = this.state; - const { value, multiple, selectCategory } = this.props; - // Resets the selected items from parent state whenever modal is opened - // This handles the case where the user closes/cancels the modal and - // opens it again - if (!isModalOpen) { - let lookupSelectedItems = []; - if (value) { - lookupSelectedItems = multiple ? [...value] : [value]; - } - this.setState({ lookupSelectedItems }); - } else { - this.clearQSParams(); - if (selectCategory) { - selectCategory(null, 'Machine'); - } - } - this.setState(prevState => ({ - isModalOpen: !prevState.isModalOpen, - })); - } - - saveModal() { - const { onLookupSave, name, multiple } = this.props; - const { lookupSelectedItems } = this.state; - const value = multiple - ? lookupSelectedItems - : lookupSelectedItems[0] || null; - - this.handleModalToggle(); - onLookupSave(value, name); - } - - clearQSParams() { - const { history } = this.props; + const clearQSParams = () => { const parts = history.location.search.replace(/^\?/, '').split('&'); - const ns = this.qsConfig.namespace; + const ns = qsConfig.namespace; const otherParts = parts.filter(param => !param.startsWith(`${ns}.`)); history.push(`${history.location.pathname}?${otherParts.join('&')}`); - } + }; - render() { - const { - isModalOpen, - lookupSelectedItems, - error, - results, - count, - } = this.state; - const { - form, - id, - lookupHeader, - value, - columns, - multiple, - name, - onBlur, - selectCategory, - required, - i18n, - selectCategoryOptions, - selectedCategory, - } = this.props; - const header = lookupHeader || i18n._(t`Items`); - const canDelete = !required || (multiple && value.length > 1); - const chips = () => { - return selectCategoryOptions && selectCategoryOptions.length > 0 ? ( - - {(multiple ? value : [value]).map(chip => ( - this.toggleSelected(chip)} - isReadOnly={!canDelete} - credential={chip} - /> - ))} - - ) : ( - - {(multiple ? value : [value]).map(chip => ( - this.toggleSelected(chip)} - isReadOnly={!canDelete} - > - {chip.name} - - ))} - - ); - }; - return ( - - - - - - - {value ? chips(value) : null} - - - - {i18n._(t`Save`)} - , - , - ]} - > - {selectCategoryOptions && selectCategoryOptions.length > 0 && ( - - Selected Category - - - - )} - ( - i.id === item.id) - : lookupSelectedItems.some(i => i.id === item.id) - } - onSelect={() => this.toggleSelected(item)} - isRadio={ - !multiple || - (selectCategoryOptions && - selectCategoryOptions.length && - selectedCategory.value !== 'Vault') - } - /> - )} - renderToolbar={props => } - showPageSizeOptions={false} - /> - {lookupSelectedItems.length > 0 && ( - 0 - } - /> - )} - {error ?
error
: ''} -
-
- ); + const save = () => { + const { selectedItems } = state; + const val = multiple ? selectedItems : selectedItems[0] || null; + onChange(val); + clearQSParams(); + dispatch({ type: 'CLOSE_MODAL' }); + }; + + const removeItem = item => { + if (multiple) { + onChange(value.filter(i => i.id !== item.id)); + } else { + onChange(null); + } + }; + + const closeModal = () => { + clearQSParams(); + dispatch({ type: 'CLOSE_MODAL' }); + }; + + const { isModalOpen, selectedItems } = state; + const canDelete = !required || (multiple && value.length > 1); + let items = []; + if (multiple) { + items = value; + } else if (value) { + items.push(value); } + return ( + + + dispatch({ type: 'TOGGLE_MODAL' })} + variant={ButtonVariant.tertiary} + > + + + + + {items.map(item => + renderItemChip({ + item, + removeItem, + canDelete, + }) + )} + + + + + {i18n._(t`Select`)} + , + , + ]} + > + {renderOptionsList({ + state, + dispatch, + canDelete, + })} + + + ); } const Item = shape({ @@ -371,25 +175,33 @@ const Item = shape({ Lookup.propTypes = { id: string, - getItems: func.isRequired, - lookupHeader: string, - name: string, - onLookupSave: func.isRequired, + header: string, + onChange: func.isRequired, value: oneOfType([Item, arrayOf(Item)]), - sortedColumnKey: string.isRequired, multiple: bool, required: bool, - qsNamespace: string, + onBlur: func, + qsConfig: QSConfig.isRequired, + renderItemChip: func, + renderOptionsList: func.isRequired, }; Lookup.defaultProps = { id: 'lookup-search', - lookupHeader: null, - name: null, + header: null, value: null, multiple: false, required: false, - qsNamespace: 'lookup', + onBlur: () => {}, + renderItemChip: ({ item, removeItem, canDelete }) => ( + removeItem(item)} + isReadOnly={!canDelete} + > + {item.name} + + ), }; export { Lookup as _Lookup }; diff --git a/awx/ui_next/src/components/Lookup/Lookup.test.jsx b/awx/ui_next/src/components/Lookup/Lookup.test.jsx index 09a44a77b8..143ba5a709 100644 --- a/awx/ui_next/src/components/Lookup/Lookup.test.jsx +++ b/awx/ui_next/src/components/Lookup/Lookup.test.jsx @@ -1,11 +1,9 @@ /* eslint-disable react/jsx-pascal-case */ import React from 'react'; -import { createMemoryHistory } from 'history'; +import { act } from 'react-dom/test-utils'; import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; -import Lookup, { _Lookup } from './Lookup'; - -let mockData = [{ name: 'foo', id: 1, isChecked: false }]; -const mockColumns = [{ name: 'Name', key: 'name', isSortable: true }]; +import { getQSConfig } from '@util/qs'; +import Lookup from './Lookup'; /** * Check that an element is present on the document body @@ -44,348 +42,118 @@ async function checkInputTagValues(wrapper, expected) { }); } -/** - * Check lookup modal list for expected values - * @param {wrapper} enzyme wrapper instance - * @param {expected} array of [selected, text] pairs describing - * the expected visible state of the modal data list - */ -async function checkModalListValues(wrapper, expected) { - // fail if modal isn't actually visible - checkRootElementPresent('body div[role="dialog"]'); - // check list item values - const rows = await waitForElement( - wrapper, - 'DataListItemRow', - el => el.length === expected.length - ); - expect(rows).toHaveLength(expected.length); - rows.forEach((el, index) => { - const [expectedChecked, expectedText] = expected[index]; - expect(expectedText).toEqual(el.text()); - expect(expectedChecked).toEqual(el.find('input').props().checked); - }); -} - -/** - * Check lookup modal selection tags for expected values - * @param {wrapper} enzyme wrapper instance - * @param {expected} array of expected tag values - */ -async function checkModalTagValues(wrapper, expected) { - // fail if modal isn't actually visible - checkRootElementPresent('body div[role="dialog"]'); - // check modal chip values - const chips = await waitForElement( - wrapper, - 'Modal Chip span', - el => el.length === expected.length - ); - expect(chips).toHaveLength(expected.length); - chips.forEach((el, index) => { - expect(el.text()).toEqual(expected[index]); - }); -} - -describe('', () => { - let wrapper; - let onChange; - - beforeEach(() => { - const mockSelected = [{ name: 'foo', id: 1, url: '/api/v2/item/1' }]; - onChange = jest.fn(); - document.body.innerHTML = ''; - wrapper = mountWithContexts( - ({ - data: { - count: 2, - results: [ - ...mockSelected, - { name: 'bar', id: 2, url: '/api/v2/item/2' }, - ], - }, - })} - columns={mockColumns} - sortedColumnKey="name" - /> - ); - }); - - test('Initially renders succesfully', () => { - expect(wrapper.find('Lookup')).toHaveLength(1); - }); - - test('Expected items are shown', async done => { - expect(wrapper.find('Lookup')).toHaveLength(1); - await checkInputTagValues(wrapper, ['foo']); - done(); - }); - - test('Open and close modal', async done => { - checkRootElementNotPresent('body div[role="dialog"]'); - wrapper.find('button[aria-label="Search"]').simulate('click'); - checkRootElementPresent('body div[role="dialog"]'); - // This check couldn't pass unless api response was formatted properly - await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]); - wrapper.find('Modal button[aria-label="Close"]').simulate('click'); - checkRootElementNotPresent('body div[role="dialog"]'); - wrapper.find('button[aria-label="Search"]').simulate('click'); - checkRootElementPresent('body div[role="dialog"]'); - wrapper - .find('Modal button') - .findWhere(e => e.text() === 'Cancel') - .first() - .simulate('click'); - checkRootElementNotPresent('body div[role="dialog"]'); - done(); - }); - - test('Add item with checkbox then save', async done => { - wrapper.find('button[aria-label="Search"]').simulate('click'); - await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]); - wrapper - .find('DataListItemRow') - .findWhere(el => el.text() === 'bar') - .find('input[type="checkbox"]') - .simulate('change'); - await checkModalListValues(wrapper, [[true, 'foo'], [true, 'bar']]); - wrapper - .find('Modal button') - .findWhere(e => e.text() === 'Save') - .first() - .simulate('click'); - expect(onChange).toHaveBeenCalledTimes(1); - expect(onChange.mock.calls[0][0].map(({ name }) => name)).toEqual([ - 'foo', - 'bar', - ]); - done(); - }); - - test('Add item with checkbox then cancel', async done => { - wrapper.find('button[aria-label="Search"]').simulate('click'); - await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]); - wrapper - .find('DataListItemRow') - .findWhere(el => el.text() === 'bar') - .find('input[type="checkbox"]') - .simulate('change'); - await checkModalListValues(wrapper, [[true, 'foo'], [true, 'bar']]); - wrapper - .find('Modal button') - .findWhere(e => e.text() === 'Cancel') - .first() - .simulate('click'); - expect(onChange).toHaveBeenCalledTimes(0); - await checkInputTagValues(wrapper, ['foo']); - done(); - }); - - test('Remove item with checkbox', async done => { - wrapper.find('button[aria-label="Search"]').simulate('click'); - await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]); - await checkModalTagValues(wrapper, ['foo']); - wrapper - .find('DataListItemRow') - .findWhere(el => el.text() === 'foo') - .find('input[type="checkbox"]') - .simulate('change'); - await checkModalListValues(wrapper, [[false, 'foo'], [false, 'bar']]); - await checkModalTagValues(wrapper, []); - done(); - }); - - test('Remove item with selected icon button', async done => { - wrapper.find('button[aria-label="Search"]').simulate('click'); - await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]); - await checkModalTagValues(wrapper, ['foo']); - wrapper - .find('Modal Chip') - .findWhere(el => el.text() === 'foo') - .first() - .find('button') - .simulate('click'); - await checkModalListValues(wrapper, [[false, 'foo'], [false, 'bar']]); - await checkModalTagValues(wrapper, []); - done(); - }); - - test('Remove item with input group button', async done => { - await checkInputTagValues(wrapper, ['foo']); - wrapper - .find('Lookup InputGroup Chip') - .findWhere(el => el.text() === 'foo') - .first() - .find('button') - .simulate('click'); - expect(onChange).toHaveBeenCalledTimes(1); - expect(onChange).toHaveBeenCalledWith([], 'foobar'); - done(); - }); -}); +const QS_CONFIG = getQSConfig('test', {}); +const TestList = () =>
; describe('', () => { let wrapper; let onChange; + async function mountWrapper() { + const mockSelected = [{ name: 'foo', id: 1, url: '/api/v2/item/1' }]; + await act(async () => { + wrapper = mountWithContexts( + ( + + )} + /> + ); + }); + return wrapper; + } + beforeEach(() => { - const mockSelected = { name: 'foo', id: 1, url: '/api/v2/item/1' }; onChange = jest.fn(); document.body.innerHTML = ''; - wrapper = mountWithContexts( - ({ - data: { - count: 2, - results: [ - mockSelected, - { name: 'bar', id: 2, url: '/api/v2/item/2' }, - ], - }, - })} - columns={mockColumns} - sortedColumnKey="name" - /> - ); }); - test('Initially renders succesfully', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('should render succesfully', async () => { + wrapper = await mountWrapper(); expect(wrapper.find('Lookup')).toHaveLength(1); }); - test('Expected items are shown', async done => { + test('should show selected items', async () => { + wrapper = await mountWrapper(); expect(wrapper.find('Lookup')).toHaveLength(1); await checkInputTagValues(wrapper, ['foo']); - done(); }); - test('Open and close modal', async done => { - checkRootElementNotPresent('body div[role="dialog"]'); - wrapper.find('button[aria-label="Search"]').simulate('click'); - checkRootElementPresent('body div[role="dialog"]'); - // This check couldn't pass unless api response was formatted properly - await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]); - wrapper.find('Modal button[aria-label="Close"]').simulate('click'); + test('should open and close modal', async () => { + wrapper = await mountWrapper(); checkRootElementNotPresent('body div[role="dialog"]'); wrapper.find('button[aria-label="Search"]').simulate('click'); checkRootElementPresent('body div[role="dialog"]'); + const list = wrapper.find('TestList'); + expect(list).toHaveLength(1); + expect(list.prop('state')).toEqual({ + selectedItems: [{ id: 1, name: 'foo', url: '/api/v2/item/1' }], + value: [{ id: 1, name: 'foo', url: '/api/v2/item/1' }], + multiple: true, + isModalOpen: true, + required: false, + }); + expect(list.prop('dispatch')).toBeTruthy(); + expect(list.prop('canDelete')).toEqual(true); wrapper .find('Modal button') .findWhere(e => e.text() === 'Cancel') .first() .simulate('click'); checkRootElementNotPresent('body div[role="dialog"]'); - done(); }); - test('Change selected item with radio control then save', async done => { - wrapper.find('button[aria-label="Search"]').simulate('click'); - await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]); - await checkModalTagValues(wrapper, ['foo']); + test('should remove item when X button clicked', async () => { + wrapper = await mountWrapper(); + await checkInputTagValues(wrapper, ['foo']); wrapper - .find('DataListItemRow') - .findWhere(el => el.text() === 'bar') - .find('input[type="radio"]') - .simulate('change'); - await checkModalListValues(wrapper, [[false, 'foo'], [true, 'bar']]); - await checkModalTagValues(wrapper, ['bar']); - wrapper - .find('Modal button') - .findWhere(e => e.text() === 'Save') + .find('Lookup InputGroup Chip') + .findWhere(el => el.text() === 'foo') .first() - .simulate('click'); - expect(onChange).toHaveBeenCalledTimes(1); - const [[{ name }]] = onChange.mock.calls; - expect(name).toEqual('bar'); - done(); - }); - - test('Change selected item with checkbox then cancel', async done => { - wrapper.find('button[aria-label="Search"]').simulate('click'); - await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]); - await checkModalTagValues(wrapper, ['foo']); - wrapper - .find('DataListItemRow') - .findWhere(el => el.text() === 'bar') - .find('input[type="radio"]') - .simulate('change'); - await checkModalListValues(wrapper, [[false, 'foo'], [true, 'bar']]); - await checkModalTagValues(wrapper, ['bar']); - wrapper - .find('Modal button') - .findWhere(e => e.text() === 'Cancel') - .first() - .simulate('click'); - expect(onChange).toHaveBeenCalledTimes(0); - done(); - }); - - test('should re-fetch data when URL params change', async done => { - mockData = [{ name: 'foo', id: 1, isChecked: false }]; - const history = createMemoryHistory({ - initialEntries: ['/organizations/add'], - }); - const getItems = jest.fn(); - const LookupWrapper = mountWithContexts( - <_Lookup - multiple - name="foo" - lookupHeader="Foo Bar" - onLookupSave={() => {}} - value={mockData} - columns={mockColumns} - sortedColumnKey="name" - getItems={getItems} - location={{ history }} - i18n={{ _: val => val.toString() }} - /> - ); - expect(getItems).toHaveBeenCalledTimes(1); - history.push('organizations/add?page=2'); - LookupWrapper.setProps({ - location: { history }, - }); - LookupWrapper.update(); - expect(getItems).toHaveBeenCalledTimes(2); - done(); - }); - - test('should clear its query params when closed', async () => { - mockData = [{ name: 'foo', id: 1, isChecked: false }]; - const history = createMemoryHistory({ - initialEntries: ['/organizations/add?inventory.name=foo&bar=baz'], - }); - wrapper = mountWithContexts( - <_Lookup - multiple - name="foo" - lookupHeader="Foo Bar" - onLookupSave={() => {}} - value={mockData} - columns={mockColumns} - sortedColumnKey="name" - getItems={() => {}} - location={{ history }} - history={history} - qsNamespace="inventory" - i18n={{ _: val => val.toString() }} - /> - ); - wrapper - .find('InputGroup Button') - .at(0) .invoke('onClick')(); - wrapper.find('Modal').invoke('onClose')(); - expect(history.location.search).toEqual('?bar=baz'); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith([]); + }); + + test('should pass canDelete false if required single select', async () => { + await act(async () => { + const mockSelected = { name: 'foo', id: 1, url: '/api/v2/item/1' }; + wrapper = mountWithContexts( + ( + + )} + /> + ); + }); + wrapper.find('button[aria-label="Search"]').simulate('click'); + const list = wrapper.find('TestList'); + expect(list.prop('canDelete')).toEqual(false); }); }); diff --git a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx index 5b978b2c66..1effa9282d 100644 --- a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx +++ b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx @@ -1,146 +1,167 @@ -import React from 'react'; +import React, { Fragment, useState, useEffect } from 'react'; +import { withRouter } from 'react-router-dom'; import PropTypes from 'prop-types'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { FormGroup, Tooltip } from '@patternfly/react-core'; -import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons'; -import styled from 'styled-components'; - +import { FormGroup, ToolbarItem } from '@patternfly/react-core'; import { CredentialsAPI, CredentialTypesAPI } from '@api'; -import Lookup from '@components/Lookup'; +import AnsibleSelect from '@components/AnsibleSelect'; +import { FieldTooltip } from '@components/FormField'; +import { CredentialChip } from '@components/Chip'; +import VerticalSeperator from '@components/VerticalSeparator'; +import { getQSConfig, parseQueryString } from '@util/qs'; +import Lookup from './Lookup'; +import OptionsList from './shared/OptionsList'; -const QuestionCircleIcon = styled(PFQuestionCircleIcon)` - margin-left: 10px; -`; +const QS_CONFIG = getQSConfig('credentials', { + page: 1, + page_size: 5, + order_by: 'name', +}); -class MultiCredentialsLookup extends React.Component { - constructor(props) { - super(props); +async function loadCredentialTypes() { + const { data } = await CredentialTypesAPI.read(); + const acceptableTypes = ['machine', 'cloud', 'net', 'ssh', 'vault']; + return data.results.filter(type => acceptableTypes.includes(type.kind)); +} - this.state = { - selectedCredentialType: { label: 'Machine', id: 1, kind: 'ssh' }, - credentialTypes: [], - }; - this.loadCredentialTypes = this.loadCredentialTypes.bind(this); - this.handleCredentialTypeSelect = this.handleCredentialTypeSelect.bind( - this - ); - this.loadCredentials = this.loadCredentials.bind(this); - this.toggleCredentialSelection = this.toggleCredentialSelection.bind(this); - } +async function loadCredentials(params, selectedCredentialTypeId) { + params.credential_type = selectedCredentialTypeId || 1; + const { data } = await CredentialsAPI.read(params); + return data; +} - componentDidMount() { - this.loadCredentialTypes(); - } +function MultiCredentialsLookup(props) { + const { tooltip, value, onChange, onError, history, i18n } = props; + const [credentialTypes, setCredentialTypes] = useState([]); + const [selectedType, setSelectedType] = useState(null); + const [credentials, setCredentials] = useState([]); + const [credentialsCount, setCredentialsCount] = useState(0); - async loadCredentialTypes() { - const { onError } = this.props; - try { - const { data } = await CredentialTypesAPI.read(); - const acceptableTypes = ['machine', 'cloud', 'net', 'ssh', 'vault']; - const credentialTypes = []; - data.results.forEach(cred => { - acceptableTypes.forEach(aT => { - if (aT === cred.kind) { - // This object has several repeated values as some of it's children - // require different field values. - cred = { - id: cred.id, - key: cred.id, - kind: cred.kind, - type: cred.namespace, - value: cred.name, - label: cred.name, - isDisabled: false, - }; - credentialTypes.push(cred); - } - }); - }); - this.setState({ credentialTypes }); - } catch (err) { - onError(err); - } - } + useEffect(() => { + (async () => { + try { + const types = await loadCredentialTypes(); + setCredentialTypes(types); + const match = types.find(type => type.kind === 'ssh') || types[0]; + setSelectedType(match); + } catch (err) { + onError(err); + } + })(); + }, [onError]); - async loadCredentials(params) { - const { selectedCredentialType } = this.state; - params.credential_type = selectedCredentialType.id || 1; - return CredentialsAPI.read(params); - } + useEffect(() => { + (async () => { + if (!selectedType) { + return; + } + try { + const params = parseQueryString(QS_CONFIG, history.location.search); + const { results, count } = await loadCredentials( + params, + selectedType.id + ); + setCredentials(results); + setCredentialsCount(count); + } catch (err) { + onError(err); + } + })(); + }, [selectedType, history.location.search, onError]); - toggleCredentialSelection(newCredential) { - const { onChange, credentials: credentialsToUpdate } = this.props; + const renderChip = ({ item, removeItem, canDelete }) => ( + removeItem(item)} + isReadOnly={!canDelete} + credential={item} + /> + ); - let newCredentialsList; - const isSelectedCredentialInState = - credentialsToUpdate.filter(cred => cred.id === newCredential.id).length > - 0; + const isMultiple = selectedType && selectedType.kind === 'vault'; - if (isSelectedCredentialInState) { - newCredentialsList = credentialsToUpdate.filter( - cred => cred.id !== newCredential.id - ); - } else { - newCredentialsList = credentialsToUpdate.filter( - credential => - credential.kind === 'vault' || credential.kind !== newCredential.kind - ); - newCredentialsList = [...newCredentialsList, newCredential]; - } - onChange(newCredentialsList); - } - - handleCredentialTypeSelect(value, type) { - const { credentialTypes } = this.state; - const selectedType = credentialTypes.filter(item => item.label === type); - this.setState({ selectedCredentialType: selectedType[0] }); - } - - render() { - const { selectedCredentialType, credentialTypes } = this.state; - const { tooltip, i18n, credentials } = this.props; - return ( - - {tooltip && ( - - - - )} - {credentialTypes && ( - {}} - getItems={this.loadCredentials} - qsNamespace="credentials" - columns={[ - { - name: i18n._(t`Name`), - key: 'name', - isSortable: true, - isSearchable: true, - }, - ]} - sortedColumnKey="name" - /> - )} - - ); - } + return ( + + {tooltip && } + { + return ( + + {credentialTypes && credentialTypes.length > 0 && ( + +
{i18n._(t`Selected Category`)}
+ + ({ + key: type.id, + value: type.id, + label: type.name, + isDisabled: false, + }))} + value={selectedType && selectedType.id} + onChange={(e, id) => { + setSelectedType( + credentialTypes.find(o => o.id === parseInt(id, 10)) + ); + }} + /> +
+ )} + { + if (isMultiple) { + return dispatch({ type: 'SELECT_ITEM', item }); + } + const selectedItems = state.selectedItems.filter( + i => i.kind !== item.kind + ); + selectedItems.push(item); + return dispatch({ + type: 'SET_SELECTED_ITEMS', + selectedItems, + }); + }} + deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })} + renderItemChip={renderChip} + /> +
+ ); + }} + /> +
+ ); } MultiCredentialsLookup.propTypes = { tooltip: PropTypes.string, - credentials: PropTypes.arrayOf( + value: PropTypes.arrayOf( PropTypes.shape({ id: PropTypes.number, name: PropTypes.string, @@ -155,8 +176,8 @@ MultiCredentialsLookup.propTypes = { MultiCredentialsLookup.defaultProps = { tooltip: '', - credentials: [], + value: [], }; -export { MultiCredentialsLookup as _MultiCredentialsLookup }; -export default withI18n()(MultiCredentialsLookup); +export { MultiCredentialsLookup as _MultiCredentialsLookup }; +export default withI18n()(withRouter(MultiCredentialsLookup)); diff --git a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx index cc00396525..fa73edad3a 100644 --- a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx +++ b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx @@ -1,6 +1,6 @@ import React from 'react'; - -import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; import MultiCredentialsLookup from './MultiCredentialsLookup'; import { CredentialsAPI, CredentialTypesAPI } from '@api'; @@ -8,9 +8,6 @@ jest.mock('@api'); describe('', () => { let wrapper; - let lookup; - let credLookup; - let onChange; const credentials = [ { id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' }, @@ -18,8 +15,9 @@ describe('', () => { { name: 'Gatsby', id: 21, kind: 'vault' }, { name: 'Gatsby', id: 8, kind: 'Machine' }, ]; + beforeEach(() => { - CredentialTypesAPI.read.mockResolvedValue({ + CredentialTypesAPI.read.mockResolvedValueOnce({ data: { results: [ { @@ -46,17 +44,6 @@ describe('', () => { count: 3, }, }); - onChange = jest.fn(); - wrapper = mountWithContexts( - {}} - credentials={credentials} - onChange={onChange} - tooltip="This is credentials look up" - /> - ); - lookup = wrapper.find('Lookup'); - credLookup = wrapper.find('MultiCredentialsLookup'); }); afterEach(() => { @@ -64,16 +51,40 @@ describe('', () => { wrapper.unmount(); }); - test('MultiCredentialsLookup renders properly', () => { + test('MultiCredentialsLookup renders properly', async () => { + const onChange = jest.fn(); + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); expect(wrapper.find('MultiCredentialsLookup')).toHaveLength(1); expect(CredentialTypesAPI.read).toHaveBeenCalled(); }); test('onChange is called when you click to remove a credential from input', async () => { - const chip = wrapper.find('PFChip').find({ isOverflowChip: false }); - const button = chip.at(1).find('ChipButton'); + const onChange = jest.fn(); + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + const chip = wrapper.find('CredentialChip'); expect(chip).toHaveLength(4); - button.prop('onClick')(); + const button = chip.at(1).find('ChipButton'); + await act(async () => { + button.invoke('onClick')(); + }); expect(onChange).toBeCalledWith([ { id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' }, { id: 21, kind: 'vault', name: 'Gatsby' }, @@ -81,33 +92,122 @@ describe('', () => { ]); }); - test('can change credential types', () => { - lookup.prop('selectCategory')({}, 'Vault'); - expect(credLookup.state('selectedCredentialType')).toEqual({ - id: 500, - key: 500, - kind: 'vault', - type: 'buzz', - value: 'Vault', - label: 'Vault', - isDisabled: false, + test('should change credential types', async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} + onError={() => {}} + /> + ); }); - expect(CredentialsAPI.read).toHaveBeenCalled(); + const searchButton = await waitForElement(wrapper, 'SearchButton'); + await act(async () => { + searchButton.invoke('onClick')(); + }); + const select = await waitForElement(wrapper, 'AnsibleSelect'); + CredentialsAPI.read.mockResolvedValueOnce({ + data: { + results: [ + { id: 1, kind: 'cloud', name: 'New Cred', url: 'www.google.com' }, + ], + count: 1, + }, + }); + expect(CredentialsAPI.read).toHaveBeenCalledTimes(2); + await act(async () => { + select.invoke('onChange')({}, 500); + }); + wrapper.update(); + expect(CredentialsAPI.read).toHaveBeenCalledTimes(3); + expect(wrapper.find('OptionsList').prop('options')).toEqual([ + { id: 1, kind: 'cloud', name: 'New Cred', url: 'www.google.com' }, + ]); }); - test('Toggle credentials only adds 1 credential per credential type except vault(see below)', () => { - lookup.prop('onToggleItem')({ name: 'Party', id: 9, kind: 'Machine' }); + + test('should only add 1 credential per credential type except vault(see below)', async () => { + const onChange = jest.fn(); + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + const searchButton = await waitForElement(wrapper, 'SearchButton'); + await act(async () => { + searchButton.invoke('onClick')(); + }); + wrapper.update(); + const optionsList = wrapper.find('OptionsList'); + expect(optionsList.prop('multiple')).toEqual(false); + act(() => { + optionsList.invoke('selectItem')({ + id: 5, + kind: 'Machine', + name: 'Cred 5', + url: 'www.google.com', + }); + }); + wrapper.update(); + act(() => { + wrapper.find('Button[variant="primary"]').invoke('onClick')(); + }); expect(onChange).toBeCalledWith([ { id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' }, { id: 2, kind: 'ssh', name: 'Alex', url: 'www.google.com' }, { id: 21, kind: 'vault', name: 'Gatsby' }, - { id: 9, kind: 'Machine', name: 'Party' }, + { id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' }, ]); }); - test('Toggle credentials only adds 1 credential per credential type', () => { - lookup.prop('onToggleItem')({ name: 'Party', id: 22, kind: 'vault' }); + + test('should allow multiple vault credentials', async () => { + const onChange = jest.fn(); + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + const searchButton = await waitForElement(wrapper, 'SearchButton'); + await act(async () => { + searchButton.invoke('onClick')(); + }); + wrapper.update(); + const typeSelect = wrapper.find('AnsibleSelect'); + act(() => { + typeSelect.invoke('onChange')({}, 500); + }); + wrapper.update(); + const optionsList = wrapper.find('OptionsList'); + expect(optionsList.prop('multiple')).toEqual(true); + act(() => { + optionsList.invoke('selectItem')({ + id: 5, + kind: 'Machine', + name: 'Cred 5', + url: 'www.google.com', + }); + }); + wrapper.update(); + act(() => { + wrapper.find('Button[variant="primary"]').invoke('onClick')(); + }); expect(onChange).toBeCalledWith([ - ...credentials, - { name: 'Party', id: 22, kind: 'vault' }, + { id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' }, + { id: 2, kind: 'ssh', name: 'Alex', url: 'www.google.com' }, + { id: 21, kind: 'vault', name: 'Gatsby' }, + { id: 8, kind: 'Machine', name: 'Gatsby' }, + { id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' }, ]); }); }); diff --git a/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx b/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx index 8efb43b091..9fd5c4bb88 100644 --- a/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx +++ b/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx @@ -1,13 +1,21 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; +import { string, func, bool } from 'prop-types'; +import { withRouter } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { string, func, bool } from 'prop-types'; import { OrganizationsAPI } from '@api'; import { Organization } from '@types'; import { FormGroup } from '@patternfly/react-core'; -import Lookup from '@components/Lookup'; +import { getQSConfig, parseQueryString } from '@util/qs'; +import Lookup from './Lookup'; +import OptionsList from './shared/OptionsList'; +import LookupErrorMessage from './shared/LookupErrorMessage'; -const getOrganizations = async params => OrganizationsAPI.read(params); +const QS_CONFIG = getQSConfig('organizations', { + page: 1, + page_size: 5, + order_by: 'name', +}); function OrganizationLookup({ helperTextInvalid, @@ -17,7 +25,25 @@ function OrganizationLookup({ onChange, required, value, + history, }) { + const [organizations, setOrganizations] = useState([]); + const [count, setCount] = useState(0); + const [error, setError] = useState(null); + + useEffect(() => { + (async () => { + const params = parseQueryString(QS_CONFIG, history.location.search); + try { + const { data } = await OrganizationsAPI.read(params); + setOrganizations(data.results); + setCount(data.count); + } catch (err) { + setError(err); + } + })(); + }, [history.location]); + return ( ( + dispatch({ type: 'SELECT_ITEM', item })} + deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })} + /> + )} /> + ); } @@ -58,5 +98,5 @@ OrganizationLookup.defaultProps = { value: null, }; -export default withI18n()(OrganizationLookup); export { OrganizationLookup as _OrganizationLookup }; +export default withI18n()(withRouter(OrganizationLookup)); diff --git a/awx/ui_next/src/components/Lookup/OrganizationLookup.test.jsx b/awx/ui_next/src/components/Lookup/OrganizationLookup.test.jsx index fef9a90281..1470537e29 100644 --- a/awx/ui_next/src/components/Lookup/OrganizationLookup.test.jsx +++ b/awx/ui_next/src/components/Lookup/OrganizationLookup.test.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import { act } from 'react-dom/test-utils'; import { mountWithContexts } from '@testUtils/enzymeHelpers'; import OrganizationLookup, { _OrganizationLookup } from './OrganizationLookup'; import { OrganizationsAPI } from '@api'; @@ -8,18 +9,22 @@ jest.mock('@api'); describe('OrganizationLookup', () => { let wrapper; - beforeEach(() => { - wrapper = mountWithContexts( {}} />); - }); - afterEach(() => { jest.clearAllMocks(); + wrapper.unmount(); }); - test('initially renders successfully', () => { + test('should render successfully', async () => { + await act(async () => { + wrapper = mountWithContexts( {}} />); + }); expect(wrapper).toHaveLength(1); }); - test('should fetch organizations', () => { + + test('should fetch organizations', async () => { + await act(async () => { + wrapper = mountWithContexts( {}} />); + }); expect(OrganizationsAPI.read).toHaveBeenCalledTimes(1); expect(OrganizationsAPI.read).toHaveBeenCalledWith({ order_by: 'name', @@ -27,11 +32,19 @@ describe('OrganizationLookup', () => { page_size: 5, }); }); - test('should display "Organization" label', () => { + + test('should display "Organization" label', async () => { + await act(async () => { + wrapper = mountWithContexts( {}} />); + }); const title = wrapper.find('FormGroup .pf-c-form__label-text'); expect(title.text()).toEqual('Organization'); }); - test('should define default value for function props', () => { + + test('should define default value for function props', async () => { + await act(async () => { + wrapper = mountWithContexts( {}} />); + }); expect(_OrganizationLookup.defaultProps.onBlur).toBeInstanceOf(Function); expect(_OrganizationLookup.defaultProps.onBlur).not.toThrow(); }); diff --git a/awx/ui_next/src/components/Lookup/ProjectLookup.jsx b/awx/ui_next/src/components/Lookup/ProjectLookup.jsx index 90d36a64a7..983a214661 100644 --- a/awx/ui_next/src/components/Lookup/ProjectLookup.jsx +++ b/awx/ui_next/src/components/Lookup/ProjectLookup.jsx @@ -1,59 +1,90 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { string, func, bool } from 'prop-types'; +import { withRouter } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { FormGroup } from '@patternfly/react-core'; import { ProjectsAPI } from '@api'; import { Project } from '@types'; -import Lookup from '@components/Lookup'; import { FieldTooltip } from '@components/FormField'; +import { getQSConfig, parseQueryString } from '@util/qs'; +import Lookup from './Lookup'; +import OptionsList from './shared/OptionsList'; +import LookupErrorMessage from './shared/LookupErrorMessage'; -class ProjectLookup extends React.Component { - render() { - const { - helperTextInvalid, - i18n, - isValid, - onChange, - required, - tooltip, - value, - onBlur, - } = this.props; +const QS_CONFIG = getQSConfig('project', { + page: 1, + page_size: 5, + order_by: 'name', +}); - const loadProjects = async params => { - const response = await ProjectsAPI.read(params); - const { results, count } = response.data; - if (count === 1) { - onChange(results[0], 'project'); +function ProjectLookup({ + helperTextInvalid, + i18n, + isValid, + onChange, + required, + tooltip, + value, + onBlur, + history, +}) { + const [projects, setProjects] = useState([]); + const [count, setCount] = useState(0); + const [error, setError] = useState(null); + + useEffect(() => { + (async () => { + const params = parseQueryString(QS_CONFIG, history.location.search); + try { + const { data } = await ProjectsAPI.read(params); + setProjects(data.results); + setCount(data.count); + if (data.count === 1) { + onChange(data.results[0]); + } + } catch (err) { + setError(err); } - return response; - }; + })(); + }, [onChange, history.location]); - return ( - - {tooltip && } - - - ); - } + return ( + + {tooltip && } + ( + dispatch({ type: 'SELECT_ITEM', item })} + deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })} + /> + )} + /> + + + ); } ProjectLookup.propTypes = { @@ -75,4 +106,5 @@ ProjectLookup.defaultProps = { onBlur: () => {}, }; -export default withI18n()(ProjectLookup); +export { ProjectLookup as _ProjectLookup }; +export default withI18n()(withRouter(ProjectLookup)); diff --git a/awx/ui_next/src/components/Lookup/ProjectLookup.test.jsx b/awx/ui_next/src/components/Lookup/ProjectLookup.test.jsx index 00fd2ad4bf..743067745e 100644 --- a/awx/ui_next/src/components/Lookup/ProjectLookup.test.jsx +++ b/awx/ui_next/src/components/Lookup/ProjectLookup.test.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import { act } from 'react-dom/test-utils'; import { mountWithContexts } from '@testUtils/enzymeHelpers'; import { sleep } from '@testUtils/testUtils'; import { ProjectsAPI } from '@api'; @@ -15,9 +16,11 @@ describe('', () => { }, }); const onChange = jest.fn(); - mountWithContexts(); + await act(async () => { + mountWithContexts(); + }); await sleep(0); - expect(onChange).toHaveBeenCalledWith({ id: 1 }, 'project'); + expect(onChange).toHaveBeenCalledWith({ id: 1 }); }); test('should not auto-select project when multiple available', async () => { @@ -28,7 +31,9 @@ describe('', () => { }, }); const onChange = jest.fn(); - mountWithContexts(); + await act(async () => { + mountWithContexts(); + }); await sleep(0); expect(onChange).not.toHaveBeenCalled(); }); diff --git a/awx/ui_next/src/components/Lookup/README.md b/awx/ui_next/src/components/Lookup/README.md new file mode 100644 index 0000000000..4d5dc69674 --- /dev/null +++ b/awx/ui_next/src/components/Lookup/README.md @@ -0,0 +1,5 @@ +# Lookup + +required single select lookups should not include a close X on the tag... you would have to select something else to change it + +optional single select lookups should include a close X to remove it on the spot diff --git a/awx/ui_next/src/components/Lookup/shared/LookupErrorMessage.jsx b/awx/ui_next/src/components/Lookup/shared/LookupErrorMessage.jsx new file mode 100644 index 0000000000..3197417449 --- /dev/null +++ b/awx/ui_next/src/components/Lookup/shared/LookupErrorMessage.jsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; + +function LookupErrorMessage({ error, i18n }) { + if (!error) { + return null; + } + + return ( +
+ {error.message || i18n._(t`An error occured`)} +
+ ); +} + +export default withI18n()(LookupErrorMessage); diff --git a/awx/ui_next/src/components/Lookup/shared/OptionsList.jsx b/awx/ui_next/src/components/Lookup/shared/OptionsList.jsx new file mode 100644 index 0000000000..77b4611c61 --- /dev/null +++ b/awx/ui_next/src/components/Lookup/shared/OptionsList.jsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { + arrayOf, + shape, + bool, + func, + number, + string, + oneOfType, +} from 'prop-types'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import SelectedList from '../../SelectedList'; +import PaginatedDataList from '../../PaginatedDataList'; +import CheckboxListItem from '../../CheckboxListItem'; +import DataListToolbar from '../../DataListToolbar'; +import { QSConfig } from '@types'; + +function OptionsList({ + value, + options, + optionCount, + columns, + multiple, + header, + name, + qsConfig, + readOnly, + selectItem, + deselectItem, + renderItemChip, + isLoading, + i18n, +}) { + return ( +
+ {value.length > 0 && ( + deselectItem(item)} + isReadOnly={readOnly} + renderItemChip={renderItemChip} + /> + )} + ( + i.id === item.id)} + onSelect={() => selectItem(item)} + onDeselect={() => deselectItem(item)} + isRadio={!multiple} + /> + )} + renderToolbar={props => } + showPageSizeOptions={false} + /> +
+ ); +} + +const Item = shape({ + id: oneOfType([number, string]).isRequired, + name: string.isRequired, + url: string, +}); +OptionsList.propTypes = { + value: arrayOf(Item).isRequired, + options: arrayOf(Item).isRequired, + optionCount: number.isRequired, + columns: arrayOf(shape({})), + multiple: bool, + qsConfig: QSConfig.isRequired, + selectItem: func.isRequired, + deselectItem: func.isRequired, + renderItemChip: func, +}; +OptionsList.defaultProps = { + multiple: false, + renderItemChip: null, + columns: [], +}; + +export default withI18n()(OptionsList); diff --git a/awx/ui_next/src/components/Lookup/shared/OptionsList.test.jsx b/awx/ui_next/src/components/Lookup/shared/OptionsList.test.jsx new file mode 100644 index 0000000000..25108d790d --- /dev/null +++ b/awx/ui_next/src/components/Lookup/shared/OptionsList.test.jsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import { getQSConfig } from '@util/qs'; +import OptionsList from './OptionsList'; + +const qsConfig = getQSConfig('test', {}); + +describe('', () => { + it('should display list of options', () => { + const options = [ + { id: 1, name: 'foo', url: '/item/1' }, + { id: 2, name: 'bar', url: '/item/2' }, + { id: 3, name: 'baz', url: '/item/3' }, + ]; + const wrapper = mountWithContexts( + {}} + deselectItem={() => {}} + name="Item" + /> + ); + expect(wrapper.find('PaginatedDataList').prop('items')).toEqual(options); + expect(wrapper.find('SelectedList')).toHaveLength(0); + }); + + it('should render selected list', () => { + const options = [ + { id: 1, name: 'foo', url: '/item/1' }, + { id: 2, name: 'bar', url: '/item/2' }, + { id: 3, name: 'baz', url: '/item/3' }, + ]; + const wrapper = mountWithContexts( + {}} + deselectItem={() => {}} + name="Item" + /> + ); + const list = wrapper.find('SelectedList'); + expect(list).toHaveLength(1); + expect(list.prop('selected')).toEqual([options[1]]); + }); +}); diff --git a/awx/ui_next/src/components/Lookup/shared/reducer.js b/awx/ui_next/src/components/Lookup/shared/reducer.js new file mode 100644 index 0000000000..315f652846 --- /dev/null +++ b/awx/ui_next/src/components/Lookup/shared/reducer.js @@ -0,0 +1,96 @@ +export default function reducer(state, action) { + switch (action.type) { + case 'SELECT_ITEM': + return selectItem(state, action.item); + case 'DESELECT_ITEM': + return deselectItem(state, action.item); + case 'TOGGLE_MODAL': + return toggleModal(state); + case 'CLOSE_MODAL': + return closeModal(state); + case 'SET_MULTIPLE': + return { ...state, multiple: action.value }; + case 'SET_VALUE': + return { ...state, value: action.value }; + case 'SET_SELECTED_ITEMS': + return { ...state, selectedItems: action.selectedItems }; + default: + throw new Error(`Unrecognized action type: ${action.type}`); + } +} + +function selectItem(state, item) { + const { selectedItems, multiple } = state; + if (!multiple) { + return { + ...state, + selectedItems: [item], + }; + } + const index = selectedItems.findIndex(i => i.id === item.id); + if (index > -1) { + return state; + } + return { + ...state, + selectedItems: [...selectedItems, item], + }; +} + +function deselectItem(state, item) { + return { + ...state, + selectedItems: state.selectedItems.filter(i => i.id !== item.id), + }; +} + +function toggleModal(state) { + const { isModalOpen, value, multiple } = state; + if (isModalOpen) { + return closeModal(state); + } + let selectedItems = []; + if (multiple) { + selectedItems = [...value]; + } else if (value) { + selectedItems.push(value); + } + return { + ...state, + isModalOpen: !isModalOpen, + selectedItems, + }; +} + +function closeModal(state) { + return { + ...state, + isModalOpen: false, + }; +} + +export function initReducer({ value, multiple = false, required = false }) { + assertCorrectValueType(value, multiple); + let selectedItems = []; + if (value) { + selectedItems = multiple ? [...value] : [value]; + } + return { + selectedItems, + value, + multiple, + isModalOpen: false, + required, + }; +} + +function assertCorrectValueType(value, multiple) { + if (!multiple && Array.isArray(value)) { + throw new Error( + 'Lookup value must not be an array unless `multiple` is set' + ); + } + if (multiple && !Array.isArray(value)) { + throw new Error('Lookup value must be an array if `multiple` is set'); + } +} diff --git a/awx/ui_next/src/components/Lookup/shared/reducer.test.js b/awx/ui_next/src/components/Lookup/shared/reducer.test.js new file mode 100644 index 0000000000..62c963cbfb --- /dev/null +++ b/awx/ui_next/src/components/Lookup/shared/reducer.test.js @@ -0,0 +1,280 @@ +import reducer, { initReducer } from './reducer'; + +describe('Lookup reducer', () => { + describe('SELECT_ITEM', () => { + it('should add item to selected items (multiple select)', () => { + const state = { + selectedItems: [{ id: 1 }], + multiple: true, + }; + const result = reducer(state, { + type: 'SELECT_ITEM', + item: { id: 2 }, + }); + expect(result).toEqual({ + selectedItems: [{ id: 1 }, { id: 2 }], + multiple: true, + }); + }); + + it('should not duplicate item if already selected (multiple select)', () => { + const state = { + selectedItems: [{ id: 1 }], + multiple: true, + }; + const result = reducer(state, { + type: 'SELECT_ITEM', + item: { id: 1 }, + }); + expect(result).toEqual({ + selectedItems: [{ id: 1 }], + multiple: true, + }); + }); + + it('should replace selected item (single select)', () => { + const state = { + selectedItems: [{ id: 1 }], + multiple: false, + }; + const result = reducer(state, { + type: 'SELECT_ITEM', + item: { id: 2 }, + }); + expect(result).toEqual({ + selectedItems: [{ id: 2 }], + multiple: false, + }); + }); + + it('should not duplicate item if already selected (single select)', () => { + const state = { + selectedItems: [{ id: 1 }], + multiple: false, + }; + const result = reducer(state, { + type: 'SELECT_ITEM', + item: { id: 1 }, + }); + expect(result).toEqual({ + selectedItems: [{ id: 1 }], + multiple: false, + }); + }); + }); + + describe('DESELECT_ITEM', () => { + it('should de-select item (multiple)', () => { + const state = { + selectedItems: [{ id: 1 }, { id: 2 }], + multiple: true, + }; + const result = reducer(state, { + type: 'DESELECT_ITEM', + item: { id: 1 }, + }); + expect(result).toEqual({ + selectedItems: [{ id: 2 }], + multiple: true, + }); + }); + + it('should not change list if item not selected (multiple)', () => { + const state = { + selectedItems: [{ id: 1 }, { id: 2 }], + multiple: true, + }; + const result = reducer(state, { + type: 'DESELECT_ITEM', + item: { id: 3 }, + }); + expect(result).toEqual({ + selectedItems: [{ id: 1 }, { id: 2 }], + multiple: true, + }); + }); + + it('should de-select item (single select)', () => { + const state = { + selectedItems: [{ id: 1 }], + multiple: true, + }; + const result = reducer(state, { + type: 'DESELECT_ITEM', + item: { id: 1 }, + }); + expect(result).toEqual({ + selectedItems: [], + multiple: true, + }); + }); + }); + + describe('TOGGLE_MODAL', () => { + it('should open the modal (single)', () => { + const state = { + isModalOpen: false, + selectedItems: [], + value: { id: 1 }, + multiple: false, + }; + const result = reducer(state, { + type: 'TOGGLE_MODAL', + }); + expect(result).toEqual({ + isModalOpen: true, + selectedItems: [{ id: 1 }], + value: { id: 1 }, + multiple: false, + }); + }); + + it('should set null value to empty array', () => { + const state = { + isModalOpen: false, + selectedItems: [{ id: 1 }], + value: null, + multiple: false, + }; + const result = reducer(state, { + type: 'TOGGLE_MODAL', + }); + expect(result).toEqual({ + isModalOpen: true, + selectedItems: [], + value: null, + multiple: false, + }); + }); + + it('should open the modal (multiple)', () => { + const state = { + isModalOpen: false, + selectedItems: [], + value: [{ id: 1 }], + multiple: true, + }; + const result = reducer(state, { + type: 'TOGGLE_MODAL', + }); + expect(result).toEqual({ + isModalOpen: true, + selectedItems: [{ id: 1 }], + value: [{ id: 1 }], + multiple: true, + }); + }); + + it('should close the modal', () => { + const state = { + isModalOpen: true, + selectedItems: [{ id: 1 }], + value: [{ id: 1 }], + multiple: true, + }; + const result = reducer(state, { + type: 'TOGGLE_MODAL', + }); + expect(result).toEqual({ + isModalOpen: false, + selectedItems: [{ id: 1 }], + value: [{ id: 1 }], + multiple: true, + }); + }); + }); + + describe('CLOSE_MODAL', () => { + it('should close the modal', () => { + const state = { + isModalOpen: true, + selectedItems: [{ id: 1 }], + value: [{ id: 1 }], + multiple: true, + }; + const result = reducer(state, { + type: 'CLOSE_MODAL', + }); + expect(result).toEqual({ + isModalOpen: false, + selectedItems: [{ id: 1 }], + value: [{ id: 1 }], + multiple: true, + }); + }); + }); + + describe('SET_MULTIPLE', () => { + it('should set multiple to true', () => { + const state = { + isModalOpen: false, + selectedItems: [{ id: 1 }], + value: [{ id: 1 }], + multiple: false, + }; + const result = reducer(state, { + type: 'SET_MULTIPLE', + value: true, + }); + expect(result).toEqual({ + isModalOpen: false, + selectedItems: [{ id: 1 }], + value: [{ id: 1 }], + multiple: true, + }); + }); + + it('should set multiple to false', () => { + const state = { + isModalOpen: false, + selectedItems: [{ id: 1 }], + value: [{ id: 1 }], + multiple: true, + }; + const result = reducer(state, { + type: 'SET_MULTIPLE', + value: false, + }); + expect(result).toEqual({ + isModalOpen: false, + selectedItems: [{ id: 1 }], + value: [{ id: 1 }], + multiple: false, + }); + }); + }); + + describe('SET_VALUE', () => { + it('should set the value', () => { + const state = { + value: [{ id: 1 }], + multiple: true, + }; + const result = reducer(state, { + type: 'SET_VALUE', + value: [{ id: 3 }], + }); + expect(result).toEqual({ + value: [{ id: 3 }], + multiple: true, + }); + }); + }); +}); + +describe('initReducer', () => { + it('should init', () => { + const state = initReducer({ + value: [], + multiple: true, + required: true, + }); + expect(state).toEqual({ + selectedItems: [], + value: [], + multiple: true, + isModalOpen: false, + required: true, + }); + }); +}); diff --git a/awx/ui_next/src/components/SelectedList/SelectedList.jsx b/awx/ui_next/src/components/SelectedList/SelectedList.jsx index 8d7c716ef9..784d4f08d5 100644 --- a/awx/ui_next/src/components/SelectedList/SelectedList.jsx +++ b/awx/ui_next/src/components/SelectedList/SelectedList.jsx @@ -2,7 +2,7 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { Split as PFSplit, SplitItem } from '@patternfly/react-core'; import styled from 'styled-components'; -import { ChipGroup, Chip, CredentialChip } from '../Chip'; +import { ChipGroup, Chip } from '../Chip'; import VerticalSeparator from '../VerticalSeparator'; const Split = styled(PFSplit)` @@ -26,34 +26,31 @@ class SelectedList extends Component { onRemove, displayKey, isReadOnly, - isCredentialList, + renderItemChip, } = this.props; - const chips = isCredentialList - ? selected.map(item => ( - onRemove(item)} - credential={item} - > - {item[displayKey]} - - )) - : selected.map(item => ( - onRemove(item)} - > - {item[displayKey]} - - )); + + const renderChip = + renderItemChip || + (({ item, removeItem }) => ( + + {item[displayKey]} + + )); + return ( {label} - {chips} + + {selected.map(item => + renderChip({ + item, + removeItem: () => onRemove(item), + canDelete: !isReadOnly, + }) + )} + ); @@ -66,6 +63,7 @@ SelectedList.propTypes = { onRemove: PropTypes.func, selected: PropTypes.arrayOf(PropTypes.object).isRequired, isReadOnly: PropTypes.bool, + renderItemChip: PropTypes.func, }; SelectedList.defaultProps = { @@ -73,6 +71,7 @@ SelectedList.defaultProps = { label: 'Selected', onRemove: () => null, isReadOnly: false, + renderItemChip: null, }; export default SelectedList; diff --git a/awx/ui_next/src/screens/Host/HostAdd/HostAdd.test.jsx b/awx/ui_next/src/screens/Host/HostAdd/HostAdd.test.jsx index ab1be4ed6f..cd561c5815 100644 --- a/awx/ui_next/src/screens/Host/HostAdd/HostAdd.test.jsx +++ b/awx/ui_next/src/screens/Host/HostAdd/HostAdd.test.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import { act } from 'react-dom/test-utils'; import { createMemoryHistory } from 'history'; import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; import HostAdd from './HostAdd'; @@ -7,8 +8,11 @@ import { HostsAPI } from '@api'; jest.mock('@api'); describe('', () => { - test('handleSubmit should post to api', () => { - const wrapper = mountWithContexts(); + test('handleSubmit should post to api', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts(); + }); const updatedHostData = { name: 'new name', description: 'new description', @@ -19,21 +23,27 @@ describe('', () => { expect(HostsAPI.create).toHaveBeenCalledWith(updatedHostData); }); - test('should navigate to hosts list when cancel is clicked', () => { + test('should navigate to hosts list when cancel is clicked', async () => { const history = createMemoryHistory({}); - const wrapper = mountWithContexts(, { - context: { router: { history } }, + let wrapper; + await act(async () => { + wrapper = mountWithContexts(, { + context: { router: { history } }, + }); }); - wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); expect(history.location.pathname).toEqual('/hosts'); }); - test('should navigate to hosts list when close (x) is clicked', () => { + test('should navigate to hosts list when close (x) is clicked', async () => { const history = createMemoryHistory({}); - const wrapper = mountWithContexts(, { - context: { router: { history } }, + let wrapper; + await act(async () => { + wrapper = mountWithContexts(, { + context: { router: { history } }, + }); }); - wrapper.find('button[aria-label="Close"]').prop('onClick')(); + wrapper.find('button[aria-label="Close"]').invoke('onClick')(); expect(history.location.pathname).toEqual('/hosts'); }); @@ -51,11 +61,14 @@ describe('', () => { ...hostData, }, }); - const wrapper = mountWithContexts(, { - context: { router: { history } }, + let wrapper; + await act(async () => { + wrapper = mountWithContexts(, { + context: { router: { history } }, + }); }); await waitForElement(wrapper, 'button[aria-label="Save"]'); - await wrapper.find('HostForm').prop('handleSubmit')(hostData); + await wrapper.find('HostForm').invoke('handleSubmit')(hostData); expect(history.location.pathname).toEqual('/hosts/5'); }); }); diff --git a/awx/ui_next/src/screens/Organization/OrganizationAdd/OrganizationAdd.test.jsx b/awx/ui_next/src/screens/Organization/OrganizationAdd/OrganizationAdd.test.jsx index b5823fa1c4..c690c8b9f6 100644 --- a/awx/ui_next/src/screens/Organization/OrganizationAdd/OrganizationAdd.test.jsx +++ b/awx/ui_next/src/screens/Organization/OrganizationAdd/OrganizationAdd.test.jsx @@ -27,22 +27,24 @@ describe('', () => { test('should navigate to organizations list when cancel is clicked', async () => { const history = createMemoryHistory({}); + let wrapper; await act(async () => { - const wrapper = mountWithContexts(, { + wrapper = mountWithContexts(, { context: { router: { history } }, }); - wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); }); expect(history.location.pathname).toEqual('/organizations'); }); test('should navigate to organizations list when close (x) is clicked', async () => { const history = createMemoryHistory({}); + let wrapper; await act(async () => { - const wrapper = mountWithContexts(, { + wrapper = mountWithContexts(, { context: { router: { history } }, }); - wrapper.find('button[aria-label="Close"]').prop('onClick')(); + wrapper.find('button[aria-label="Close"]').invoke('onClick')(); }); expect(history.location.pathname).toEqual('/organizations'); }); @@ -63,8 +65,9 @@ describe('', () => { ...orgData, }, }); + let wrapper; await act(async () => { - const wrapper = mountWithContexts(, { + wrapper = mountWithContexts(, { context: { router: { history } }, }); await waitForElement(wrapper, 'button[aria-label="Save"]'); @@ -92,23 +95,27 @@ describe('', () => { ...orgData, }, }); + let wrapper; await act(async () => { - const wrapper = mountWithContexts(); - await waitForElement(wrapper, 'button[aria-label="Save"]'); - await wrapper.find('OrganizationForm').prop('handleSubmit')( - orgData, - [3], - [] - ); + wrapper = mountWithContexts(); }); + await waitForElement(wrapper, 'button[aria-label="Save"]'); + await wrapper.find('OrganizationForm').prop('handleSubmit')( + orgData, + [3], + [] + ); expect(OrganizationsAPI.associateInstanceGroup).toHaveBeenCalledWith(5, 3); }); test('AnsibleSelect component renders if there are virtual environments', async () => { + const config = { + custom_virtualenvs: ['foo', 'bar'], + }; let wrapper; await act(async () => { wrapper = mountWithContexts(, { - context: { config: { custom_virtualenvs: ['foo', 'bar'] } }, + context: { config }, }).find('AnsibleSelect'); }); expect(wrapper.find('FormSelect')).toHaveLength(1); @@ -122,10 +129,13 @@ describe('', () => { }); test('AnsibleSelect component does not render if there are 0 virtual environments', async () => { + const config = { + custom_virtualenvs: [], + }; let wrapper; await act(async () => { wrapper = mountWithContexts(, { - context: { config: { custom_virtualenvs: [] } }, + context: { config }, }).find('AnsibleSelect'); }); expect(wrapper.find('FormSelect')).toHaveLength(0); diff --git a/awx/ui_next/src/screens/Organization/OrganizationEdit/OrganizationEdit.test.jsx b/awx/ui_next/src/screens/Organization/OrganizationEdit/OrganizationEdit.test.jsx index 0a9a4f9e82..2ecf914440 100644 --- a/awx/ui_next/src/screens/Organization/OrganizationEdit/OrganizationEdit.test.jsx +++ b/awx/ui_next/src/screens/Organization/OrganizationEdit/OrganizationEdit.test.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import { act } from 'react-dom/test-utils'; import { createMemoryHistory } from 'history'; import { OrganizationsAPI } from '@api'; import { mountWithContexts } from '@testUtils/enzymeHelpers'; @@ -6,8 +7,6 @@ import OrganizationEdit from './OrganizationEdit'; jest.mock('@api'); -const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); - describe('', () => { const mockData = { name: 'Foo', @@ -19,10 +18,11 @@ describe('', () => { }, }; - test('handleSubmit should call api update', () => { - const wrapper = mountWithContexts( - - ); + test('handleSubmit should call api update', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts(); + }); const updatedOrgData = { name: 'new name', @@ -39,21 +39,23 @@ describe('', () => { }); test('handleSubmit associates and disassociates instance groups', async () => { - const wrapper = mountWithContexts( - - ); + let wrapper; + await act(async () => { + wrapper = mountWithContexts(); + }); const updatedOrgData = { name: 'new name', description: 'new description', custom_virtualenv: 'Buzz', }; - wrapper.find('OrganizationForm').prop('handleSubmit')( - updatedOrgData, - [3, 4], - [2] - ); - await sleep(1); + await act(async () => { + wrapper.find('OrganizationForm').invoke('handleSubmit')( + updatedOrgData, + [3, 4], + [2] + ); + }); expect(OrganizationsAPI.associateInstanceGroup).toHaveBeenCalledWith(1, 3); expect(OrganizationsAPI.associateInstanceGroup).toHaveBeenCalledWith(1, 4); @@ -63,14 +65,17 @@ describe('', () => { ); }); - test('should navigate to organization detail when cancel is clicked', () => { + test('should navigate to organization detail when cancel is clicked', async () => { const history = createMemoryHistory({}); - const wrapper = mountWithContexts( - , - { context: { router: { history } } } - ); + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + , + { context: { router: { history } } } + ); + }); - wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); expect(history.location.pathname).toEqual('/organizations/1/details'); }); diff --git a/awx/ui_next/src/screens/Organization/shared/OrganizationForm.test.jsx b/awx/ui_next/src/screens/Organization/shared/OrganizationForm.test.jsx index 52e234cf9c..9fda0b279d 100644 --- a/awx/ui_next/src/screens/Organization/shared/OrganizationForm.test.jsx +++ b/awx/ui_next/src/screens/Organization/shared/OrganizationForm.test.jsx @@ -1,5 +1,5 @@ import React from 'react'; - +import { act } from 'react-dom/test-utils'; import { mountWithContexts } from '@testUtils/enzymeHelpers'; import { sleep } from '@testUtils/testUtils'; import { OrganizationsAPI } from '@api'; @@ -30,18 +30,20 @@ describe('', () => { jest.clearAllMocks(); }); - test('should request related instance groups from api', () => { - mountWithContexts( - , - { - context: { network }, - } - ); + test('should request related instance groups from api', async () => { + await act(async () => { + mountWithContexts( + , + { + context: { network }, + } + ); + }); expect(OrganizationsAPI.readInstanceGroups).toHaveBeenCalledTimes(1); }); @@ -53,34 +55,39 @@ describe('', () => { results: mockInstanceGroups, }, }); - const wrapper = mountWithContexts( - , - { - context: { network }, - } - ); + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + , + { + context: { network }, + } + ); + }); - await sleep(0); expect(OrganizationsAPI.readInstanceGroups).toHaveBeenCalled(); expect(wrapper.find('OrganizationForm').state().instanceGroups).toEqual( mockInstanceGroups ); }); - test('changing instance group successfully sets instanceGroups state', () => { - const wrapper = mountWithContexts( - - ); + test('changing instance group successfully sets instanceGroups state', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + ); + }); const lookup = wrapper.find('InstanceGroupsLookup'); expect(lookup.length).toBe(1); @@ -102,15 +109,18 @@ describe('', () => { ]); }); - test('changing inputs should update form values', () => { - const wrapper = mountWithContexts( - - ); + test('changing inputs should update form values', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + ); + }); const form = wrapper.find('Formik'); wrapper.find('input#org-name').simulate('change', { @@ -127,21 +137,24 @@ describe('', () => { expect(form.state('values').max_hosts).toEqual('134'); }); - test('AnsibleSelect component renders if there are virtual environments', () => { + test('AnsibleSelect component renders if there are virtual environments', async () => { const config = { custom_virtualenvs: ['foo', 'bar'], }; - const wrapper = mountWithContexts( - , - { - context: { config }, - } - ); + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + , + { + context: { config }, + } + ); + }); expect(wrapper.find('FormSelect')).toHaveLength(1); expect(wrapper.find('FormSelectOption')).toHaveLength(3); expect( @@ -154,14 +167,17 @@ describe('', () => { test('calls handleSubmit when form submitted', async () => { const handleSubmit = jest.fn(); - const wrapper = mountWithContexts( - - ); + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + ); + }); expect(handleSubmit).not.toHaveBeenCalled(); wrapper.find('button[aria-label="Save"]').simulate('click'); await sleep(1); @@ -194,18 +210,20 @@ describe('', () => { OrganizationsAPI.update.mockResolvedValue(1, mockDataForm); OrganizationsAPI.associateInstanceGroup.mockResolvedValue('done'); OrganizationsAPI.disassociateInstanceGroup.mockResolvedValue('done'); - const wrapper = mountWithContexts( - , - { - context: { network }, - } - ); - await sleep(0); + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + , + { + context: { network }, + } + ); + }); wrapper.find('InstanceGroupsLookup').prop('onChange')( [{ name: 'One', id: 1 }, { name: 'Three', id: 3 }], 'instanceGroups' @@ -219,15 +237,17 @@ describe('', () => { test('handleSubmit is called with max_hosts value if it is in range', async () => { const handleSubmit = jest.fn(); - // normal mount - const wrapper = mountWithContexts( - - ); + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + ); + }); wrapper.find('button[aria-label="Save"]').simulate('click'); await sleep(0); expect(handleSubmit).toHaveBeenCalledWith( @@ -245,32 +265,38 @@ describe('', () => { test('handleSubmit does not get called if max_hosts value is out of range', async () => { const handleSubmit = jest.fn(); - // not mount with Negative value + // mount with negative value + let wrapper1; const mockDataNegative = JSON.parse(JSON.stringify(mockData)); mockDataNegative.max_hosts = -5; - const wrapper1 = mountWithContexts( - - ); + await act(async () => { + wrapper1 = mountWithContexts( + + ); + }); wrapper1.find('button[aria-label="Save"]').simulate('click'); await sleep(0); expect(handleSubmit).not.toHaveBeenCalled(); - // not mount with Out of Range value + // mount with out of range value + let wrapper2; const mockDataOoR = JSON.parse(JSON.stringify(mockData)); mockDataOoR.max_hosts = 999999999999; - const wrapper2 = mountWithContexts( - - ); + await act(async () => { + wrapper2 = mountWithContexts( + + ); + }); wrapper2.find('button[aria-label="Save"]').simulate('click'); await sleep(0); expect(handleSubmit).not.toHaveBeenCalled(); @@ -282,14 +308,17 @@ describe('', () => { // mount with String value (default to zero) const mockDataString = JSON.parse(JSON.stringify(mockData)); mockDataString.max_hosts = 'Bee'; - const wrapper = mountWithContexts( - - ); + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + ); + }); wrapper.find('button[aria-label="Save"]').simulate('click'); await sleep(0); expect(handleSubmit).toHaveBeenCalledWith( @@ -304,17 +333,20 @@ describe('', () => { ); }); - test('calls "handleCancel" when Cancel button is clicked', () => { + test('calls "handleCancel" when Cancel button is clicked', async () => { const handleCancel = jest.fn(); - const wrapper = mountWithContexts( - - ); + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + ); + }); expect(handleCancel).not.toHaveBeenCalled(); wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); expect(handleCancel).toBeCalled(); diff --git a/awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.test.jsx b/awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.test.jsx index 554ebf83ef..c4d5bc16f1 100644 --- a/awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.test.jsx +++ b/awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.test.jsx @@ -98,17 +98,19 @@ describe('', () => { }); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); const formik = wrapper.find('Formik').instance(); - const changeState = new Promise(resolve => { - formik.setState( - { - values: { - ...projectData, + await act(async () => { + const changeState = new Promise(resolve => { + formik.setState( + { + values: { + ...projectData, + }, }, - }, - () => resolve() - ); + () => resolve() + ); + }); + await changeState; }); - await changeState; await act(async () => { wrapper.find('form').simulate('submit'); }); @@ -146,7 +148,9 @@ describe('', () => { context: { router: { history } }, }).find('ProjectAdd CardHeader'); }); - wrapper.find('CardCloseButton').simulate('click'); + await act(async () => { + wrapper.find('CardCloseButton').simulate('click'); + }); expect(history.location.pathname).toEqual('/projects'); }); @@ -158,7 +162,9 @@ describe('', () => { }); }); await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0); - wrapper.find('ProjectAdd button[aria-label="Cancel"]').simulate('click'); + await act(async () => { + wrapper.find('ProjectAdd button[aria-label="Cancel"]').simulate('click'); + }); expect(history.location.pathname).toEqual('/projects'); }); }); diff --git a/awx/ui_next/src/screens/Project/ProjectEdit/ProjectEdit.test.jsx b/awx/ui_next/src/screens/Project/ProjectEdit/ProjectEdit.test.jsx index 8f8b400cf1..8927dff7c2 100644 --- a/awx/ui_next/src/screens/Project/ProjectEdit/ProjectEdit.test.jsx +++ b/awx/ui_next/src/screens/Project/ProjectEdit/ProjectEdit.test.jsx @@ -144,8 +144,8 @@ describe('', () => { wrapper = mountWithContexts(, { context: { router: { history } }, }); + wrapper.find('CardCloseButton').simulate('click'); }); - wrapper.find('CardCloseButton').simulate('click'); expect(history.location.pathname).toEqual('/projects/123/details'); }); @@ -157,7 +157,9 @@ describe('', () => { }); }); await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0); - wrapper.find('ProjectEdit button[aria-label="Cancel"]').simulate('click'); + await act(async () => { + wrapper.find('ProjectEdit button[aria-label="Cancel"]').simulate('click'); + }); expect(history.location.pathname).toEqual('/projects/123/details'); }); }); diff --git a/awx/ui_next/src/screens/Project/shared/ProjectForm.test.jsx b/awx/ui_next/src/screens/Project/shared/ProjectForm.test.jsx index 584287444e..d4b901c47c 100644 --- a/awx/ui_next/src/screens/Project/shared/ProjectForm.test.jsx +++ b/awx/ui_next/src/screens/Project/shared/ProjectForm.test.jsx @@ -131,17 +131,19 @@ describe('', () => { }); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); const formik = wrapper.find('Formik').instance(); - const changeState = new Promise(resolve => { - formik.setState( - { - values: { - ...mockData, + await act(async () => { + const changeState = new Promise(resolve => { + formik.setState( + { + values: { + ...mockData, + }, }, - }, - () => resolve() - ); + () => resolve() + ); + }); + await changeState; }); - await changeState; wrapper.update(); expect(wrapper.find('FormGroup[label="SCM URL"]').length).toBe(1); expect( @@ -191,18 +193,20 @@ describe('', () => { }); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); const formik = wrapper.find('Formik').instance(); - const changeState = new Promise(resolve => { - formik.setState( - { - values: { - ...mockData, - scm_type: 'insights', + await act(async () => { + const changeState = new Promise(resolve => { + formik.setState( + { + values: { + ...mockData, + scm_type: 'insights', + }, }, - }, - () => resolve() - ); + () => resolve() + ); + }); + await changeState; }); - await changeState; wrapper.update(); expect(wrapper.find('FormGroup[label="Insights Credential"]').length).toBe( 1 diff --git a/awx/ui_next/src/screens/Project/shared/ProjectSubForms/SharedFields.jsx b/awx/ui_next/src/screens/Project/shared/ProjectSubForms/SharedFields.jsx index 127ba54936..5a7e1434c1 100644 --- a/awx/ui_next/src/screens/Project/shared/ProjectSubForms/SharedFields.jsx +++ b/awx/ui_next/src/screens/Project/shared/ProjectSubForms/SharedFields.jsx @@ -51,7 +51,7 @@ export const ScmCredentialFormField = withI18n()( value={credential.value} onChange={value => { onCredentialSelection('scm', value); - form.setFieldValue('credential', value.id); + form.setFieldValue('credential', value ? value.id : ''); }} /> )} diff --git a/awx/ui_next/src/screens/Team/TeamAdd/TeamAdd.test.jsx b/awx/ui_next/src/screens/Team/TeamAdd/TeamAdd.test.jsx index 20eb762710..02225fa733 100644 --- a/awx/ui_next/src/screens/Team/TeamAdd/TeamAdd.test.jsx +++ b/awx/ui_next/src/screens/Team/TeamAdd/TeamAdd.test.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import { act } from 'react-dom/test-utils'; import { createMemoryHistory } from 'history'; import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; import TeamAdd from './TeamAdd'; @@ -7,32 +8,38 @@ import { TeamsAPI } from '@api'; jest.mock('@api'); describe('', () => { - test('handleSubmit should post to api', () => { + test('handleSubmit should post to api', async () => { const wrapper = mountWithContexts(); const updatedTeamData = { name: 'new name', description: 'new description', organization: 1, }; - wrapper.find('TeamForm').prop('handleSubmit')(updatedTeamData); + await act(async () => { + wrapper.find('TeamForm').invoke('handleSubmit')(updatedTeamData); + }); expect(TeamsAPI.create).toHaveBeenCalledWith(updatedTeamData); }); - test('should navigate to teams list when cancel is clicked', () => { + test('should navigate to teams list when cancel is clicked', async () => { const history = createMemoryHistory({}); const wrapper = mountWithContexts(, { context: { router: { history } }, }); - wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + }); expect(history.location.pathname).toEqual('/teams'); }); - test('should navigate to teams list when close (x) is clicked', () => { + test('should navigate to teams list when close (x) is clicked', async () => { const history = createMemoryHistory({}); const wrapper = mountWithContexts(, { context: { router: { history } }, }); - wrapper.find('button[aria-label="Close"]').prop('onClick')(); + await act(async () => { + wrapper.find('button[aria-label="Close"]').invoke('onClick')(); + }); expect(history.location.pathname).toEqual('/teams'); }); @@ -55,11 +62,16 @@ describe('', () => { }, }, }); - const wrapper = mountWithContexts(, { - context: { router: { history } }, + let wrapper; + await act(async () => { + wrapper = mountWithContexts(, { + context: { router: { history } }, + }); }); await waitForElement(wrapper, 'button[aria-label="Save"]'); - await wrapper.find('TeamForm').prop('handleSubmit')(teamData); + await act(async () => { + await wrapper.find('TeamForm').invoke('handleSubmit')(teamData); + }); expect(history.location.pathname).toEqual('/teams/5'); }); }); diff --git a/awx/ui_next/src/screens/Team/TeamEdit/TeamEdit.test.jsx b/awx/ui_next/src/screens/Team/TeamEdit/TeamEdit.test.jsx index 7ea335361a..cb410405b1 100644 --- a/awx/ui_next/src/screens/Team/TeamEdit/TeamEdit.test.jsx +++ b/awx/ui_next/src/screens/Team/TeamEdit/TeamEdit.test.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import { act } from 'react-dom/test-utils'; import { createMemoryHistory } from 'history'; import { TeamsAPI } from '@api'; import { mountWithContexts } from '@testUtils/enzymeHelpers'; @@ -19,25 +20,29 @@ describe('', () => { }, }; - test('handleSubmit should call api update', () => { + test('handleSubmit should call api update', async () => { const wrapper = mountWithContexts(); const updatedTeamData = { name: 'new name', description: 'new description', }; - wrapper.find('TeamForm').prop('handleSubmit')(updatedTeamData); + await act(async () => { + wrapper.find('TeamForm').invoke('handleSubmit')(updatedTeamData); + }); expect(TeamsAPI.update).toHaveBeenCalledWith(1, updatedTeamData); }); - test('should navigate to team detail when cancel is clicked', () => { + test('should navigate to team detail when cancel is clicked', async () => { const history = createMemoryHistory({}); const wrapper = mountWithContexts(, { context: { router: { history } }, }); - wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + }); expect(history.location.pathname).toEqual('/teams/1/details'); }); diff --git a/awx/ui_next/src/screens/Team/shared/TeamForm.test.jsx b/awx/ui_next/src/screens/Team/shared/TeamForm.test.jsx index 0d8a483417..da8a5d282e 100644 --- a/awx/ui_next/src/screens/Team/shared/TeamForm.test.jsx +++ b/awx/ui_next/src/screens/Team/shared/TeamForm.test.jsx @@ -30,15 +30,17 @@ describe('', () => { jest.clearAllMocks(); }); - test('changing inputs should update form values', () => { - wrapper = mountWithContexts( - - ); + test('changing inputs should update form values', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); const form = wrapper.find('Formik'); wrapper.find('input#team-name').simulate('change', { @@ -78,17 +80,19 @@ describe('', () => { expect(handleSubmit).toBeCalled(); }); - test('calls handleCancel when Cancel button is clicked', () => { + test('calls handleCancel when Cancel button is clicked', async () => { const handleCancel = jest.fn(); - wrapper = mountWithContexts( - - ); + await act(async () => { + wrapper = mountWithContexts( + + ); + }); expect(handleCancel).not.toHaveBeenCalled(); wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); expect(handleCancel).toBeCalled(); diff --git a/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.test.jsx b/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.test.jsx index 0539c657fe..bedb63e678 100644 --- a/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.test.jsx +++ b/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.test.jsx @@ -101,19 +101,21 @@ describe('', () => { }); await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0); const formik = wrapper.find('Formik').instance(); - const changeState = new Promise(resolve => { - formik.setState( - { - values: { - ...jobTemplateData, - labels: [], - instanceGroups: [], + await act(async () => { + const changeState = new Promise(resolve => { + formik.setState( + { + values: { + ...jobTemplateData, + labels: [], + instanceGroups: [], + }, }, - }, - () => resolve() - ); + () => resolve() + ); + }); + await changeState; }); - await changeState; wrapper.find('form').simulate('submit'); await sleep(1); expect(JobTemplatesAPI.create).toHaveBeenCalledWith(jobTemplateData); diff --git a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx index 846d7f4fb8..68aff316f0 100644 --- a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx +++ b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx @@ -79,6 +79,8 @@ class JobTemplateForm extends Component { }; this.handleProjectValidation = this.handleProjectValidation.bind(this); this.loadRelatedInstanceGroups = this.loadRelatedInstanceGroups.bind(this); + this.handleProjectUpdate = this.handleProjectUpdate.bind(this); + this.setContentError = this.setContentError.bind(this); } componentDidMount() { @@ -119,6 +121,16 @@ class JobTemplateForm extends Component { }; } + handleProjectUpdate(project) { + const { setFieldValue } = this.props; + setFieldValue('project', project.id); + this.setState({ project }); + } + + setContentError(contentError) { + this.setState({ contentError }); + } + render() { const { contentError, @@ -252,10 +264,7 @@ class JobTemplateForm extends Component { you want this job to execute.`)} isValid={!form.touched.project || !form.errors.project} helperTextInvalid={form.errors.project} - onChange={value => { - form.setFieldValue('project', value.id); - this.setState({ project: value }); - }} + onChange={this.handleProjectUpdate} required /> )} @@ -285,7 +294,7 @@ class JobTemplateForm extends Component { form={form} field={field} onBlur={() => form.setFieldTouched('playbook')} - onError={err => this.setState({ contentError: err })} + onError={this.setContentError} /> ); @@ -305,7 +314,7 @@ class JobTemplateForm extends Component { setFieldValue('labels', labels)} - onError={err => this.setState({ contentError: err })} + onError={this.setContentError} /> )} @@ -317,11 +326,11 @@ class JobTemplateForm extends Component { fieldId="template-credentials" render={({ field }) => ( setFieldValue('credentials', newCredentials) } - onError={err => this.setState({ contentError: err })} + onError={this.setContentError} tooltip={i18n._( t`Select credentials that allow Tower to access the nodes this job will be ran against. You can only select one credential of each type. For machine credentials (SSH), checking "Prompt on launch" without selecting credentials will require you to select a machine credential at run time. If you select credentials and check "Prompt on launch", the selected credential(s) become the defaults that can be updated at run time.` )} diff --git a/awx/ui_next/src/screens/User/UserList/UserList.test.jsx b/awx/ui_next/src/screens/User/UserList/UserList.test.jsx index cc54d9d0f5..a727cdae81 100644 --- a/awx/ui_next/src/screens/User/UserList/UserList.test.jsx +++ b/awx/ui_next/src/screens/User/UserList/UserList.test.jsx @@ -214,7 +214,7 @@ describe('UsersList with full permissions', () => { ); }); - test('api is called to delete users for each selected user.', () => { + test('api is called to delete users for each selected user.', async () => { UsersAPI.destroy = jest.fn(); wrapper.find('UsersList').setState({ users: mockUsers, @@ -223,7 +223,7 @@ describe('UsersList with full permissions', () => { isModalOpen: true, selected: mockUsers, }); - wrapper.find('ToolbarDeleteButton').prop('onDelete')(); + await wrapper.find('ToolbarDeleteButton').prop('onDelete')(); expect(UsersAPI.destroy).toHaveBeenCalledTimes(2); });