diff --git a/awx/ui_next/src/screens/Host/shared/HostForm.jsx b/awx/ui_next/src/components/HostForm/HostForm.jsx similarity index 90% rename from awx/ui_next/src/screens/Host/shared/HostForm.jsx rename to awx/ui_next/src/components/HostForm/HostForm.jsx index 3b72f71081..5e662ea313 100644 --- a/awx/ui_next/src/screens/Host/shared/HostForm.jsx +++ b/awx/ui_next/src/components/HostForm/HostForm.jsx @@ -1,22 +1,19 @@ import React, { useState } from 'react'; -import { func, shape } from 'prop-types'; - -import { useRouteMatch } from 'react-router-dom'; +import { bool, func, shape } from 'prop-types'; import { Formik, useField } from 'formik'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Form, FormGroup } from '@patternfly/react-core'; - import FormField, { FormSubmitError, FieldTooltip, } from '@components/FormField'; import FormActionGroup from '@components/FormActionGroup/FormActionGroup'; import { VariablesField } from '@components/CodeMirrorInput'; -import { required } from '@util/validators'; import { InventoryLookup } from '@components/Lookup'; import { FormColumnLayout, FormFullWidthLayout } from '@components/FormLayout'; +import { required } from '@util/validators'; const InventoryLookupField = withI18n()(({ i18n, host }) => { const [inventory, setInventory] = useState( @@ -57,9 +54,14 @@ const InventoryLookupField = withI18n()(({ i18n, host }) => { ); }); -const HostForm = ({ handleCancel, handleSubmit, host, i18n, submitError }) => { - const hostAddMatch = useRouteMatch('/hosts/add'); - +const HostForm = ({ + handleCancel, + handleSubmit, + host, + isInventoryVisible, + i18n, + submitError, +}) => { return ( { type="text" label={i18n._(t`Description`)} /> - {hostAddMatch && } + {isInventoryVisible && } { label={i18n._(t`Variables`)} /> - + {submitError && } ', () => { - const meConfig = { - me: { - is_superuser: false, - }, - }; - const mockData = { - id: 1, - name: 'Foo', - description: 'Bar', - variables: '---', - inventory: 1, - summary_fields: { - inventory: { - id: 1, - name: 'Test Inv', - }, - }, - }; + let wrapper; + const handleSubmit = jest.fn(); + const handleCancel = jest.fn(); - afterEach(() => { - jest.clearAllMocks(); - }); - - test('changing inputs should update form values', async () => { - let wrapper; + beforeEach(async () => { await act(async () => { wrapper = mountWithContexts( ); }); + }); + afterEach(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('changing inputs should update form values', async () => { await act(async () => { wrapper.find('input#host-name').simulate('change', { target: { value: 'new foo', name: 'name' }, @@ -59,35 +59,30 @@ describe('', () => { }); test('calls handleSubmit when form submitted', async () => { - const handleSubmit = jest.fn(); - const wrapper = mountWithContexts( - - ); expect(handleSubmit).not.toHaveBeenCalled(); await act(async () => { wrapper.find('button[aria-label="Save"]').simulate('click'); }); - expect(handleSubmit).toHaveBeenCalled(); + expect(handleSubmit).toHaveBeenCalledTimes(1); }); test('calls "handleCancel" when Cancel button is clicked', () => { - const handleCancel = jest.fn(); - - const wrapper = mountWithContexts( - - ); expect(handleCancel).not.toHaveBeenCalled(); wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); - expect(handleCancel).toBeCalled(); + expect(handleCancel).toHaveBeenCalledTimes(1); + }); + + test('should hide inventory lookup field', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + expect(wrapper.find('InventoryLookupField').length).toBe(0); }); }); diff --git a/awx/ui_next/src/screens/Host/shared/index.js b/awx/ui_next/src/components/HostForm/index.js similarity index 100% rename from awx/ui_next/src/screens/Host/shared/index.js rename to awx/ui_next/src/components/HostForm/index.js diff --git a/awx/ui_next/src/screens/Host/HostAdd/HostAdd.jsx b/awx/ui_next/src/screens/Host/HostAdd/HostAdd.jsx index 422cabb721..122ce7dd27 100644 --- a/awx/ui_next/src/screens/Host/HostAdd/HostAdd.jsx +++ b/awx/ui_next/src/screens/Host/HostAdd/HostAdd.jsx @@ -1,34 +1,24 @@ import React, { useState } from 'react'; -import { useHistory, useRouteMatch } from 'react-router-dom'; +import { useHistory } from 'react-router-dom'; import { CardBody } from '@components/Card'; +import HostForm from '@components/HostForm'; import { HostsAPI } from '@api'; -import HostForm from '../shared'; function HostAdd() { const [formError, setFormError] = useState(null); const history = useHistory(); - const hostsMatch = useRouteMatch('/hosts'); - const inventoriesMatch = useRouteMatch('/inventories/inventory/:id/hosts'); - const url = hostsMatch ? hostsMatch.url : inventoriesMatch.url; const handleSubmit = async formData => { - const values = { - ...formData, - inventory: inventoriesMatch - ? inventoriesMatch.params.id - : formData.inventory, - }; - try { - const { data: response } = await HostsAPI.create(values); - history.push(`${url}/${response.id}/details`); + const { data: response } = await HostsAPI.create(formData); + history.push(`/hosts/${response.id}/details`); } catch (error) { setFormError(error); } }; const handleCancel = () => { - history.push(`${url}`); + history.push(`/hosts`); }; return ( 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 c7b6df05f3..1096b83ca6 100644 --- a/awx/ui_next/src/screens/Host/HostAdd/HostAdd.test.jsx +++ b/awx/ui_next/src/screens/Host/HostAdd/HostAdd.test.jsx @@ -1,27 +1,32 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { createMemoryHistory } from 'history'; -import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; import HostAdd from './HostAdd'; import { HostsAPI } from '@api'; jest.mock('@api'); +const hostData = { + name: 'new name', + description: 'new description', + inventory: 1, + variables: '---\nfoo: bar', +}; + +HostsAPI.create.mockResolvedValue({ + data: { + ...hostData, + id: 5, + }, +}); + describe('', () => { let wrapper; let history; - const hostData = { - name: 'new name', - description: 'new description', - inventory: 1, - variables: '---\nfoo: bar', - }; - beforeEach(async () => { - history = createMemoryHistory({ - initialEntries: ['/hosts/add'], - }); + history = createMemoryHistory(); await act(async () => { wrapper = mountWithContexts(, { context: { router: { history } }, @@ -29,13 +34,12 @@ describe('', () => { }); }); + afterEach(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + test('handleSubmit should post to api', async () => { - HostsAPI.create.mockResolvedValueOnce({ - data: { - ...hostData, - id: 5, - }, - }); await act(async () => { wrapper.find('HostForm').prop('handleSubmit')(hostData); }); @@ -43,21 +47,31 @@ describe('', () => { }); test('should navigate to hosts list when cancel is clicked', async () => { - wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); + }); expect(history.location.pathname).toEqual('/hosts'); }); test('successful form submission should trigger redirect', async () => { - HostsAPI.create.mockResolvedValueOnce({ - data: { - ...hostData, - id: 5, - }, - }); - await waitForElement(wrapper, 'button[aria-label="Save"]'); await act(async () => { wrapper.find('HostForm').invoke('handleSubmit')(hostData); }); + expect(wrapper.find('FormSubmitError').length).toBe(0); expect(history.location.pathname).toEqual('/hosts/5/details'); }); + + test('failed form submission should show an error message', async () => { + const error = { + response: { + data: { detail: 'An error occurred' }, + }, + }; + HostsAPI.create.mockImplementationOnce(() => Promise.reject(error)); + await act(async () => { + wrapper.find('HostForm').invoke('handleSubmit')(hostData); + }); + wrapper.update(); + expect(wrapper.find('FormSubmitError').length).toBe(1); + }); }); diff --git a/awx/ui_next/src/screens/Host/HostEdit/HostEdit.jsx b/awx/ui_next/src/screens/Host/HostEdit/HostEdit.jsx index d2ef0252e9..f6dc82e767 100644 --- a/awx/ui_next/src/screens/Host/HostEdit/HostEdit.jsx +++ b/awx/ui_next/src/screens/Host/HostEdit/HostEdit.jsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; import { useHistory, useRouteMatch } from 'react-router-dom'; import { CardBody } from '@components/Card'; +import HostForm from '@components/HostForm'; import { HostsAPI } from '@api'; import HostForm from '../shared'; diff --git a/awx/ui_next/src/screens/Host/Hosts.test.jsx b/awx/ui_next/src/screens/Host/Hosts.test.jsx index 0581b3371e..7db85fbb84 100644 --- a/awx/ui_next/src/screens/Host/Hosts.test.jsx +++ b/awx/ui_next/src/screens/Host/Hosts.test.jsx @@ -30,4 +30,23 @@ describe('', () => { expect(wrapper.find('BreadcrumbHeading').length).toBe(1); wrapper.unmount(); }); + + test('should render Host component', () => { + const history = createMemoryHistory({ + initialEntries: ['/hosts/1'], + }); + + const match = { + path: '/hosts/:id', + url: '/hosts/1', + isExact: true, + }; + + const wrapper = mountWithContexts(, { + context: { router: { history, route: { match } } }, + }); + + expect(wrapper.find('Host').length).toBe(1); + wrapper.unmount(); + }); }); diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostAdd/InventoryHostAdd.jsx b/awx/ui_next/src/screens/Inventory/InventoryHostAdd/InventoryHostAdd.jsx new file mode 100644 index 0000000000..9afb38c90b --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryHostAdd/InventoryHostAdd.jsx @@ -0,0 +1,42 @@ +import React, { useState } from 'react'; +import { useHistory } from 'react-router-dom'; +import { CardBody } from '@components/Card'; +import HostForm from '@components/HostForm'; + +import { HostsAPI } from '@api'; + +function InventoryHostAdd({ inventory }) { + const [formError, setFormError] = useState(null); + const hostsUrl = `/inventories/inventory/${inventory.id}/hosts`; + const history = useHistory(); + + const handleSubmit = async formData => { + try { + const values = { + ...formData, + inventory: inventory.id, + }; + const { data: response } = await HostsAPI.create(values); + history.push(`${hostsUrl}/${response.id}/details`); + } catch (error) { + setFormError(error); + } + }; + + const handleCancel = () => { + history.push(hostsUrl); + }; + + return ( + + + + ); +} + +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..d17216ac10 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryHostAdd/InventoryHostAdd.test.jsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import InventoryHostAdd from './InventoryHostAdd'; +import mockHost from '../shared/data.host.json'; +import { HostsAPI } from '@api'; + +jest.mock('@api'); + +HostsAPI.create.mockResolvedValue({ + data: { + ...mockHost, + }, +}); + +describe('', () => { + let wrapper; + let history; + + beforeAll(async () => { + history = createMemoryHistory(); + await act(async () => { + wrapper = mountWithContexts(, { + context: { router: { history } }, + }); + }); + }); + + afterAll(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('handleSubmit should post to api', async () => { + await act(async () => { + wrapper.find('HostForm').prop('handleSubmit')(mockHost); + }); + expect(HostsAPI.create).toHaveBeenCalledWith(mockHost); + }); + + test('should navigate to hosts list when cancel is clicked', () => { + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + expect(history.location.pathname).toEqual('/inventories/inventory/3/hosts'); + }); + + test('successful form submission should trigger redirect', async () => { + await act(async () => { + wrapper.find('HostForm').invoke('handleSubmit')(mockHost); + }); + expect(wrapper.find('FormSubmitError').length).toBe(0); + expect(history.location.pathname).toEqual( + '/inventories/inventory/3/hosts/2/details' + ); + }); + + test('failed form submission should show an error message', async () => { + const error = { + response: { + data: { detail: 'An error occurred' }, + }, + }; + HostsAPI.create.mockImplementationOnce(() => Promise.reject(error)); + await act(async () => { + wrapper.find('HostForm').invoke('handleSubmit')(mockHost); + }); + wrapper.update(); + expect(wrapper.find('FormSubmitError').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostAdd/index.js b/awx/ui_next/src/screens/Inventory/InventoryHostAdd/index.js new file mode 100644 index 0000000000..56bb7e05ad --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryHostAdd/index.js @@ -0,0 +1 @@ +export { default } from './InventoryHostAdd'; diff --git a/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.jsx b/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.jsx index 3257024cef..f8f4578c71 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.jsx @@ -1,28 +1,18 @@ import React from 'react'; import { Switch, Route } from 'react-router-dom'; -import Host from '../../Host/Host'; +import InventoryHostAdd from '../InventoryHostAdd'; import InventoryHostList from './InventoryHostList'; -import HostAdd from '../../Host/HostAdd'; function InventoryHosts({ setBreadcrumb, inventory }) { return ( - + + + + - ( - - )} - /> - } - /> ); } diff --git a/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.test.jsx new file mode 100644 index 0000000000..140a6e57ea --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.test.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { createMemoryHistory } from 'history'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import InventoryHosts from './InventoryHosts'; + +describe('', () => { + test('should render inventory host list', () => { + const history = createMemoryHistory({ + initialEntries: ['/inventories/inventory/1/hosts'], + }); + + const match = { + path: '/inventories/inventory/:id/hosts', + url: '/inventories/inventory/1/hosts', + isExact: true, + }; + + const wrapper = mountWithContexts(, { + context: { router: { history, route: { match } } }, + }); + + expect(wrapper.find('InventoryHostList').length).toBe(1); + wrapper.unmount(); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/shared/data.host.json b/awx/ui_next/src/screens/Inventory/shared/data.host.json new file mode 100644 index 0000000000..a4975ad01b --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/data.host.json @@ -0,0 +1,86 @@ +{ + "id": 2, + "type": "host", + "url": "/api/v2/hosts/2/", + "related": { + "created_by": "/api/v2/users/1/", + "modified_by": "/api/v2/users/1/", + "variable_data": "/api/v2/hosts/2/variable_data/", + "groups": "/api/v2/hosts/2/groups/", + "all_groups": "/api/v2/hosts/2/all_groups/", + "job_events": "/api/v2/hosts/2/job_events/", + "job_host_summaries": "/api/v2/hosts/2/job_host_summaries/", + "activity_stream": "/api/v2/hosts/2/activity_stream/", + "inventory_sources": "/api/v2/hosts/2/inventory_sources/", + "smart_inventories": "/api/v2/hosts/2/smart_inventories/", + "ad_hoc_commands": "/api/v2/hosts/2/ad_hoc_commands/", + "ad_hoc_command_events": "/api/v2/hosts/2/ad_hoc_command_events/", + "insights": "/api/v2/hosts/2/insights/", + "ansible_facts": "/api/v2/hosts/2/ansible_facts/", + "inventory": "/api/v2/inventories/3/", + "last_job": "/api/v2/jobs/3/", + "last_job_host_summary": "/api/v2/job_host_summaries/1/" + }, + "summary_fields": { + "inventory": { + "id": 3, + "name": "Mikes Inventory", + "description": "", + "has_active_failures": false, + "total_hosts": 3, + "hosts_with_active_failures": 0, + "total_groups": 0, + "groups_with_active_failures": 0, + "has_inventory_sources": true, + "total_inventory_sources": 1, + "inventory_sources_with_failures": 0, + "organization_id": 3, + "kind": "" + }, + "last_job": { + "id": 3, + "name": "Ping", + "description": "", + "finished": "2019-10-28T21:29:08.880572Z", + "status": "successful", + "failed": false, + "job_template_id": 9, + "job_template_name": "Ping" + }, + "last_job_host_summary": { + "id": 1, + "failed": false + }, + "user_capabilities": { + "edit": true, + "delete": true + }, + "groups": { + "count": 0, + "results": [] + }, + "recent_jobs": [ + { + "id": 3, + "name": "Ping", + "status": "successful", + "finished": "2019-10-28T21:29:08.880572Z", + "type": "job" + } + ] + }, + "created": "2019-10-28T21:26:54.508081Z", + "modified": "2019-10-29T20:18:41.915796Z", + "name": "localhost", + "description": "localhost description", + "inventory": 3, + "enabled": true, + "instance_id": "", + "variables": "---\nansible_connection: local", + "has_active_failures": false, + "has_inventory_sources": false, + "last_job": 3, + "last_job_host_summary": 1, + "insights_system_id": null, + "ansible_facts_modified": null +} \ No newline at end of file