diff --git a/awx/ui_next/src/api/models/Inventories.js b/awx/ui_next/src/api/models/Inventories.js index 9001376671..08640173d4 100644 --- a/awx/ui_next/src/api/models/Inventories.js +++ b/awx/ui_next/src/api/models/Inventories.js @@ -19,6 +19,10 @@ class Inventories extends InstanceGroupsMixin(Base) { }); } + createHost(id, data) { + return this.http.post(`${this.baseUrl}${id}/hosts/`, data); + } + readHosts(id, params) { return this.http.get(`${this.baseUrl}${id}/hosts/`, { params }); } diff --git a/awx/ui_next/src/screens/Host/HostAdd/HostAdd.jsx b/awx/ui_next/src/screens/Host/HostAdd/HostAdd.jsx index 8804b8ed91..1919721d06 100644 --- a/awx/ui_next/src/screens/Host/HostAdd/HostAdd.jsx +++ b/awx/ui_next/src/screens/Host/HostAdd/HostAdd.jsx @@ -1,20 +1,10 @@ import React from 'react'; import { withRouter } from 'react-router-dom'; import { withI18n } from '@lingui/react'; -import { t } from '@lingui/macro'; -import { - PageSection, - Card, - CardHeader, - CardBody, - Tooltip, -} from '@patternfly/react-core'; - +import { PageSection, Card, CardBody } from '@patternfly/react-core'; import { HostsAPI } from '@api'; import { Config } from '@contexts/Config'; -import CardCloseButton from '@components/CardCloseButton'; - -import HostForm from '../shared/HostForm'; +import HostForm from '../shared'; class HostAdd extends React.Component { constructor(props) { @@ -41,16 +31,10 @@ class HostAdd extends React.Component { render() { const { error } = this.state; - const { i18n } = this.props; return ( - - - - - {({ me }) => ( 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 cd561c5815..563b25bb62 100644 --- a/awx/ui_next/src/screens/Host/HostAdd/HostAdd.test.jsx +++ b/awx/ui_next/src/screens/Host/HostAdd/HostAdd.test.jsx @@ -35,18 +35,6 @@ describe('', () => { expect(history.location.pathname).toEqual('/hosts'); }); - test('should navigate to hosts list when close (x) is clicked', async () => { - const history = createMemoryHistory({}); - let wrapper; - await act(async () => { - wrapper = mountWithContexts(, { - context: { router: { history } }, - }); - }); - wrapper.find('button[aria-label="Close"]').invoke('onClick')(); - expect(history.location.pathname).toEqual('/hosts'); - }); - test('successful form submission should trigger redirect', async () => { const history = createMemoryHistory({}); const hostData = { diff --git a/awx/ui_next/src/screens/Host/HostEdit/HostEdit.jsx b/awx/ui_next/src/screens/Host/HostEdit/HostEdit.jsx index e57f41baec..44665eb771 100644 --- a/awx/ui_next/src/screens/Host/HostEdit/HostEdit.jsx +++ b/awx/ui_next/src/screens/Host/HostEdit/HostEdit.jsx @@ -6,7 +6,7 @@ import { CardBody } from '@patternfly/react-core'; import { HostsAPI } from '@api'; import { Config } from '@contexts/Config'; -import HostForm from '../shared/HostForm'; +import HostForm from '../shared'; class HostEdit extends Component { constructor(props) { diff --git a/awx/ui_next/src/screens/Host/shared/HostForm.jsx b/awx/ui_next/src/screens/Host/shared/HostForm.jsx index 2cf42c54d2..f7f4e472a7 100644 --- a/awx/ui_next/src/screens/Host/shared/HostForm.jsx +++ b/awx/ui_next/src/screens/Host/shared/HostForm.jsx @@ -1,5 +1,5 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; +import React, { useState } from 'react'; +import { func, shape } from 'prop-types'; import { withRouter } from 'react-router-dom'; import { Formik, Field } from 'formik'; @@ -15,120 +15,86 @@ import { VariablesField } from '@components/CodeMirrorInput'; import { required } from '@util/validators'; import { InventoryLookup } from '@components/Lookup'; -class HostForm extends Component { - constructor(props) { - super(props); +function HostForm({ handleSubmit, handleCancel, host, i18n }) { + const [inventory, setInventory] = useState( + host ? host.summary_fields.inventory : '' + ); - this.handleSubmit = this.handleSubmit.bind(this); - - this.state = { - formIsValid: true, - inventory: props.host.summary_fields.inventory, - }; - } - - handleSubmit(values) { - const { handleSubmit } = this.props; - - handleSubmit(values); - } - - render() { - const { host, handleCancel, i18n } = this.props; - const { formIsValid, inventory, error } = this.state; - - const initialValues = !host.id - ? { - name: host.name, - description: host.description, - inventory: host.inventory || '', - variables: host.variables, - } - : { - name: host.name, - description: host.description, - variables: host.variables, - }; - - return ( - ( -
- - - - {!host.id && ( - ( - form.setFieldTouched('inventory')} - tooltip={i18n._( - t`Select the inventory that this host will belong to.` - )} - isValid={ - !form.touched.inventory || !form.errors.inventory - } - helperTextInvalid={form.errors.inventory} - onChange={value => { - form.setFieldValue('inventory', value.id); - this.setState({ inventory: value }); - }} - required - touched={form.touched.inventory} - error={form.errors.inventory} - /> - )} - /> - )} - - - - - ( + + + - {error ?
error
: null} - - )} - /> - ); - } + + {!host.id && ( + ( + form.setFieldTouched('inventory')} + tooltip={i18n._( + t`Select the inventory that this host will belong to.` + )} + isValid={!form.touched.inventory || !form.errors.inventory} + helperTextInvalid={form.errors.inventory} + onChange={value => { + form.setFieldValue('inventory', value.id); + setInventory(value); + }} + required + touched={form.touched.inventory} + error={form.errors.inventory} + /> + )} + /> + )} +
+ + + + + + )} + /> + ); } -FormField.propTypes = { - label: PropTypes.oneOfType([PropTypes.object, PropTypes.string]).isRequired, -}; - HostForm.propTypes = { - host: PropTypes.shape(), - handleSubmit: PropTypes.func.isRequired, - handleCancel: PropTypes.func.isRequired, + handleSubmit: func.isRequired, + handleCancel: func.isRequired, + host: shape({}), }; HostForm.defaultProps = { diff --git a/awx/ui_next/src/screens/Host/shared/HostForm.test.jsx b/awx/ui_next/src/screens/Host/shared/HostForm.test.jsx index 58078f1674..f0466f1954 100644 --- a/awx/ui_next/src/screens/Host/shared/HostForm.test.jsx +++ b/awx/ui_next/src/screens/Host/shared/HostForm.test.jsx @@ -65,11 +65,7 @@ describe('', () => { expect(handleSubmit).not.toHaveBeenCalled(); wrapper.find('button[aria-label="Save"]').simulate('click'); await sleep(1); - expect(handleSubmit).toHaveBeenCalledWith({ - name: 'Foo', - description: 'Bar', - variables: '---', - }); + expect(handleSubmit).toHaveBeenCalled(); }); test('calls "handleCancel" when Cancel button is clicked', () => { diff --git a/awx/ui_next/src/screens/Host/shared/index.js b/awx/ui_next/src/screens/Host/shared/index.js index e69de29bb2..9755f2184b 100644 --- a/awx/ui_next/src/screens/Host/shared/index.js +++ b/awx/ui_next/src/screens/Host/shared/index.js @@ -0,0 +1 @@ +export { default } from './HostForm'; diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostAdd/InventoryHostAdd.jsx b/awx/ui_next/src/screens/Inventory/InventoryHostAdd/InventoryHostAdd.jsx index 574707bd71..e7ab823f7a 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryHostAdd/InventoryHostAdd.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryHostAdd/InventoryHostAdd.jsx @@ -1,8 +1,36 @@ -import React from 'react'; +import React, { useState } from 'react'; +import { useHistory, useParams } from 'react-router-dom'; import { CardBody } from '@patternfly/react-core'; +import InventoryHostForm from '../shared/InventoryHostForm'; +import { InventoriesAPI } from '@api'; function InventoryHostAdd() { - return Coming soon :); + const [formError, setFormError] = useState(null); + const history = useHistory(); + const { id } = useParams(); + + const handleSubmit = async values => { + try { + const { data: response } = await InventoriesAPI.createHost(id, values); + history.push(`/inventories/inventory/${id}/hosts/${response.id}/details`); + } catch (error) { + setFormError(error); + } + }; + + const handleCancel = () => { + history.push(`/inventories/inventory/${id}/hosts`); + }; + + return ( + + + {formError ?
error
: ''} +
+ ); } export default InventoryHostAdd; diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostAdd/InventoryHostAdd.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryHostAdd/InventoryHostAdd.test.jsx new file mode 100644 index 0000000000..d5b6cdc0a2 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryHostAdd/InventoryHostAdd.test.jsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { Route } from 'react-router-dom'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; +import InventoryHostAdd from './InventoryHostAdd'; +import { InventoriesAPI } from '@api'; + +jest.mock('@api'); + +describe('', () => { + let wrapper; + let history; + + const mockHostData = { + name: 'new name', + description: 'new description', + inventory: 1, + variables: '---\nfoo: bar', + }; + + beforeEach(async () => { + history = createMemoryHistory({ + initialEntries: ['/inventories/inventory/1/hosts/add'], + }); + + await act(async () => { + wrapper = mountWithContexts( + } + />, + { + context: { + router: { history, route: { location: history.location } }, + }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + + afterEach(() => { + wrapper.unmount(); + }); + + test('handleSubmit should post to api', async () => { + InventoriesAPI.createHost.mockResolvedValue({ + data: { ...mockHostData }, + }); + + const formik = wrapper.find('Formik').instance(); + await act(async () => { + const changeState = new Promise(resolve => { + formik.setState( + { + values: { + ...mockHostData, + }, + }, + () => resolve() + ); + }); + await changeState; + }); + await act(async () => { + wrapper.find('form').simulate('submit'); + }); + wrapper.update(); + expect(InventoriesAPI.createHost).toHaveBeenCalledWith('1', mockHostData); + }); + + test('handleSubmit should throw an error', async () => { + InventoriesAPI.createHost.mockImplementationOnce(() => + Promise.reject(new Error()) + ); + const formik = wrapper.find('Formik').instance(); + await act(async () => { + const changeState = new Promise(resolve => { + formik.setState( + { + values: { + ...mockHostData, + }, + }, + () => resolve() + ); + }); + await changeState; + }); + await act(async () => { + wrapper.find('form').simulate('submit'); + }); + wrapper.update(); + expect(wrapper.find('InventoryHostAdd .formSubmitError').length).toBe(1); + }); + + test('should navigate to inventory hosts list when cancel is clicked', async () => { + wrapper.find('button[aria-label="Cancel"]').simulate('click'); + expect(history.location.pathname).toEqual('/inventories/inventory/1/hosts'); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHostItem.jsx b/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHostItem.jsx index 27bd8fa5f6..ae58ad5977 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHostItem.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHostItem.jsx @@ -23,6 +23,7 @@ import { Host } from '@types'; function InventoryHostItem(props) { const { detailUrl, + editUrl, host, i18n, isSelected, @@ -79,7 +80,7 @@ function InventoryHostItem(props) { diff --git a/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.jsx b/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.jsx index b9d2fbeca4..27bd1f2e38 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.jsx @@ -178,7 +178,8 @@ function InventoryHosts({ i18n, location, match }) { row.id === o.id)} onSelect={() => handleSelect(o)} toggleHost={handleToggle} diff --git a/awx/ui_next/src/screens/Inventory/shared/InventoryHostForm.jsx b/awx/ui_next/src/screens/Inventory/shared/InventoryHostForm.jsx new file mode 100644 index 0000000000..85e5a901ea --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/InventoryHostForm.jsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { func, shape } from 'prop-types'; +import { Formik } from 'formik'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Form } from '@patternfly/react-core'; +import FormRow from '@components/FormRow'; +import FormField from '@components/FormField'; +import FormActionGroup from '@components/FormActionGroup/FormActionGroup'; +import { VariablesField } from '@components/CodeMirrorInput'; +import { required } from '@util/validators'; + +function InventoryHostForm({ handleSubmit, handleCancel, host, i18n }) { + return ( + ( +
+ + + + + + + + + + )} + /> + ); +} + +InventoryHostForm.propTypes = { + handleSubmit: func.isRequired, + handleCancel: func.isRequired, + host: shape({}), +}; + +InventoryHostForm.defaultProps = { + host: { + name: '', + description: '', + variables: '---\n', + }, +}; + +export default withI18n()(InventoryHostForm); diff --git a/awx/ui_next/src/screens/Inventory/shared/InventoryHostForm.test.jsx b/awx/ui_next/src/screens/Inventory/shared/InventoryHostForm.test.jsx new file mode 100644 index 0000000000..f247914420 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/InventoryHostForm.test.jsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import { sleep } from '@testUtils/testUtils'; +import InventoryHostForm from './InventoryHostForm'; + +jest.mock('@api'); + +describe('', () => { + let wrapper; + + const handleSubmit = jest.fn(); + const handleCancel = jest.fn(); + + const mockHostData = { + name: 'foo', + description: 'bar', + inventory: 1, + variables: '---\nfoo: bar', + }; + + beforeEach(async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + }); + + afterEach(() => { + wrapper.unmount(); + }); + + test('should display form fields', () => { + expect(wrapper.find('FormGroup[label="Name"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Description"]').length).toBe(1); + expect(wrapper.find('VariablesField').length).toBe(1); + }); + + test('should call handleSubmit when Submit button is clicked', async () => { + expect(handleSubmit).not.toHaveBeenCalled(); + wrapper.find('button[aria-label="Save"]').simulate('click'); + await sleep(1); + expect(handleSubmit).toHaveBeenCalled(); + }); + + test('should call handleCancel when Cancel button is clicked', async () => { + expect(handleCancel).not.toHaveBeenCalled(); + wrapper.find('button[aria-label="Cancel"]').simulate('click'); + await sleep(1); + expect(handleCancel).toHaveBeenCalled(); + }); +});