From da94b2dc9ec505bf18afbc1e8090c8786021471d Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Fri, 6 Mar 2020 00:51:55 -0500 Subject: [PATCH 1/3] Add InventoryHostAdd route file --- .../HostForm}/HostForm.jsx | 24 +++-- .../HostForm}/HostForm.test.jsx | 91 +++++++++---------- .../shared => components/HostForm}/index.js | 0 .../src/screens/Host/HostAdd/HostAdd.jsx | 20 +--- .../src/screens/Host/HostAdd/HostAdd.test.jsx | 64 ++++++++----- .../src/screens/Host/HostEdit/HostEdit.jsx | 1 + awx/ui_next/src/screens/Host/Hosts.test.jsx | 19 ++++ .../InventoryHostAdd/InventoryHostAdd.jsx | 42 +++++++++ .../InventoryHostAdd.test.jsx | 70 ++++++++++++++ .../Inventory/InventoryHostAdd/index.js | 1 + .../InventoryHosts/InventoryHosts.jsx | 20 +--- .../InventoryHosts/InventoryHosts.test.jsx | 25 +++++ .../screens/Inventory/shared/data.host.json | 86 ++++++++++++++++++ 13 files changed, 350 insertions(+), 113 deletions(-) rename awx/ui_next/src/{screens/Host/shared => components/HostForm}/HostForm.jsx (90%) rename awx/ui_next/src/{screens/Host/shared => components/HostForm}/HostForm.test.jsx (63%) rename awx/ui_next/src/{screens/Host/shared => components/HostForm}/index.js (100%) create mode 100644 awx/ui_next/src/screens/Inventory/InventoryHostAdd/InventoryHostAdd.jsx create mode 100644 awx/ui_next/src/screens/Inventory/InventoryHostAdd/InventoryHostAdd.test.jsx create mode 100644 awx/ui_next/src/screens/Inventory/InventoryHostAdd/index.js create mode 100644 awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.test.jsx create mode 100644 awx/ui_next/src/screens/Inventory/shared/data.host.json 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 From bb6d9af90b1e9893f5698b46d6d66aab58260764 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Fri, 6 Mar 2020 01:35:58 -0500 Subject: [PATCH 2/3] Create nested inventory host route files and components --- .../Inventory/InventoryHost/InventoryHost.jsx | 174 ++++++++++++++++++ .../InventoryHost/InventoryHost.test.jsx | 79 ++++++++ .../screens/Inventory/InventoryHost/index.js | 1 + .../InventoryHostDetail.jsx | 129 +++++++++++++ .../InventoryHostDetail.test.jsx | 88 +++++++++ .../Inventory/InventoryHostDetail/index.js | 1 + .../InventoryHostEdit/InventoryHostEdit.jsx | 44 +++++ .../InventoryHostEdit.test.jsx | 77 ++++++++ .../Inventory/InventoryHostEdit/index.js | 1 + .../InventoryHosts/InventoryHosts.jsx | 4 + 10 files changed, 598 insertions(+) create mode 100644 awx/ui_next/src/screens/Inventory/InventoryHost/InventoryHost.jsx create mode 100644 awx/ui_next/src/screens/Inventory/InventoryHost/InventoryHost.test.jsx create mode 100644 awx/ui_next/src/screens/Inventory/InventoryHost/index.js create mode 100644 awx/ui_next/src/screens/Inventory/InventoryHostDetail/InventoryHostDetail.jsx create mode 100644 awx/ui_next/src/screens/Inventory/InventoryHostDetail/InventoryHostDetail.test.jsx create mode 100644 awx/ui_next/src/screens/Inventory/InventoryHostDetail/index.js create mode 100644 awx/ui_next/src/screens/Inventory/InventoryHostEdit/InventoryHostEdit.jsx create mode 100644 awx/ui_next/src/screens/Inventory/InventoryHostEdit/InventoryHostEdit.test.jsx create mode 100644 awx/ui_next/src/screens/Inventory/InventoryHostEdit/index.js diff --git a/awx/ui_next/src/screens/Inventory/InventoryHost/InventoryHost.jsx b/awx/ui_next/src/screens/Inventory/InventoryHost/InventoryHost.jsx new file mode 100644 index 0000000000..89121e8cd5 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryHost/InventoryHost.jsx @@ -0,0 +1,174 @@ +import React, { useEffect, useCallback } from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { + Switch, + Route, + Redirect, + Link, + useRouteMatch, + useLocation, +} from 'react-router-dom'; +import useRequest from '@util/useRequest'; + +import { HostsAPI } from '@api'; +import { Card, CardActions } from '@patternfly/react-core'; +import { CaretLeftIcon } from '@patternfly/react-icons'; +import { TabbedCardHeader } from '@components/Card'; +import CardCloseButton from '@components/CardCloseButton'; +import ContentError from '@components/ContentError'; +import ContentLoading from '@components/ContentLoading'; +import RoutedTabs from '@components/RoutedTabs'; +import JobList from '@components/JobList'; +import InventoryHostDetail from '../InventoryHostDetail'; +import InventoryHostEdit from '../InventoryHostEdit'; + +function InventoryHost({ i18n, setBreadcrumb, inventory }) { + const location = useLocation(); + const match = useRouteMatch('/inventories/inventory/:id/hosts/:hostId'); + const hostListUrl = `/inventories/inventory/${inventory.id}/hosts`; + + const { + result: { host }, + error: contentError, + isLoading, + request: fetchHost, + } = useRequest( + useCallback(async () => { + const { data } = await HostsAPI.readDetail(match.params.hostId); + + return { + host: data, + }; + }, [match.params.hostId]), // eslint-disable-line react-hooks/exhaustive-deps + { + host: null, + } + ); + + useEffect(() => { + fetchHost(); + }, [fetchHost]); + + useEffect(() => { + if (inventory && host) { + setBreadcrumb(inventory, host); + } + }, [inventory, host, setBreadcrumb]); + + const tabsArray = [ + { + name: ( + <> + + {i18n._(t`Back to Hosts`)} + + ), + link: `${hostListUrl}`, + id: 0, + }, + { + name: i18n._(t`Details`), + link: `${match.url}/details`, + id: 1, + }, + { + name: i18n._(t`Facts`), + link: `${match.url}/facts`, + id: 2, + }, + { + name: i18n._(t`Groups`), + link: `${match.url}/groups`, + id: 3, + }, + { + name: i18n._(t`Completed Jobs`), + link: `${match.url}/completed_jobs`, + id: 4, + }, + ]; + + let cardHeader = ( + + + + + + + ); + + if (location.pathname.endsWith('edit')) { + cardHeader = null; + } + + if (isLoading) { + return ; + } + + if (!isLoading && contentError) { + return ( + + + {contentError.response && contentError.response.status === 404 && ( + + {i18n._(`Host not found.`)}{' '} + + {i18n._(`View all Inventory Hosts.`)} + + + )} + + + ); + } + + return ( + <> + {cardHeader} + + + {host && + inventory && [ + + + , + + + , + + + , + ]} + + !isLoading && ( + + + {i18n._(`View Inventory Host Details`)} + + + ) + } + /> + + + ); +} + +export default withI18n()(InventoryHost); diff --git a/awx/ui_next/src/screens/Inventory/InventoryHost/InventoryHost.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryHost/InventoryHost.test.jsx new file mode 100644 index 0000000000..bea26df827 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryHost/InventoryHost.test.jsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { HostsAPI } from '@api'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; +import mockHost from '../shared/data.host.json'; +import InventoryHost from './InventoryHost'; + +jest.mock('@api'); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useRouteMatch: () => ({ + url: '/inventories/inventory/1/hosts/1', + params: { id: 1, hostId: 1 }, + }), +})); + +HostsAPI.readDetail.mockResolvedValue({ + data: { ...mockHost }, +}); + +const mockInventory = { + id: 1, + name: 'Mock Inventory', +}; + +describe('', () => { + let wrapper; + let history; + + beforeEach(async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} /> + ); + }); + }); + + afterEach(() => { + wrapper.unmount(); + }); + + test('should render expected tabs', async () => { + const expectedTabs = [ + 'Back to Hosts', + 'Details', + 'Facts', + 'Groups', + 'Completed Jobs', + ]; + wrapper.find('RoutedTabs li').forEach((tab, index) => { + expect(tab.text()).toEqual(expectedTabs[index]); + }); + }); + + test('should show content error when api throws error on initial render', async () => { + HostsAPI.readDetail.mockRejectedValueOnce(new Error()); + await act(async () => { + wrapper = mountWithContexts( + {}} /> + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + await waitForElement(wrapper, 'ContentError', el => el.length === 1); + }); + + test('should show content error when user attempts to navigate to erroneous route', async () => { + history = createMemoryHistory({ + initialEntries: ['/inventories/inventory/1/hosts/1/foobar'], + }); + await act(async () => { + wrapper = mountWithContexts( + {}} />, + { context: { router: { history } } } + ); + }); + await waitForElement(wrapper, 'ContentError', el => el.length === 1); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryHost/index.js b/awx/ui_next/src/screens/Inventory/InventoryHost/index.js new file mode 100644 index 0000000000..5419035b15 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryHost/index.js @@ -0,0 +1 @@ +export { default } from './InventoryHost'; diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostDetail/InventoryHostDetail.jsx b/awx/ui_next/src/screens/Inventory/InventoryHostDetail/InventoryHostDetail.jsx new file mode 100644 index 0000000000..cfa36e4e83 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryHostDetail/InventoryHostDetail.jsx @@ -0,0 +1,129 @@ +import React, { useState } from 'react'; +import { Link, useHistory } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Host } from '@types'; +import { Button } from '@patternfly/react-core'; +import { CardBody, CardActionsRow } from '@components/Card'; +import AlertModal from '@components/AlertModal'; +import ErrorDetail from '@components/ErrorDetail'; +import { DetailList, Detail, UserDateDetail } from '@components/DetailList'; +import { VariablesDetail } from '@components/CodeMirrorInput'; +import Sparkline from '@components/Sparkline'; +import DeleteButton from '@components/DeleteButton'; +import { HostsAPI } from '@api'; +import HostToggle from '@components/HostToggle'; + +function InventoryHostDetail({ i18n, host }) { + const { + created, + description, + id, + modified, + name, + variables, + summary_fields: { + inventory, + recent_jobs, + created_by, + modified_by, + user_capabilities, + }, + } = host; + + const [isLoading, setIsloading] = useState(false); + const [deletionError, setDeletionError] = useState(false); + const history = useHistory(); + + const handleHostDelete = async () => { + setIsloading(true); + try { + await HostsAPI.destroy(id); + history.push(`/inventories/inventory/${inventory.id}/hosts`); + } catch (err) { + setDeletionError(err); + } finally { + setIsloading(false); + } + }; + + if (!isLoading && deletionError) { + return ( + setDeletionError(false)} + > + {i18n._(t`Failed to delete ${name}.`)} + + + ); + } + + const recentPlaybookJobs = recent_jobs.map(job => ({ ...job, type: 'job' })); + + return ( + + + + + } + /> + + + + + + + {user_capabilities?.edit && ( + + )} + {user_capabilities?.delete && ( + handleHostDelete()} + /> + )} + + {deletionError && ( + setDeletionError(null)} + > + {i18n._(t`Failed to delete host.`)} + + + )} + + ); +} + +InventoryHostDetail.propTypes = { + host: Host.isRequired, +}; + +export default withI18n()(InventoryHostDetail); diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostDetail/InventoryHostDetail.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryHostDetail/InventoryHostDetail.test.jsx new file mode 100644 index 0000000000..66735e19cb --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryHostDetail/InventoryHostDetail.test.jsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; +import InventoryHostDetail from './InventoryHostDetail'; +import { HostsAPI } from '@api'; +import mockHost from '../shared/data.host.json'; + +jest.mock('@api'); + +describe('', () => { + let wrapper; + + describe('User has edit permissions', () => { + beforeAll(() => { + wrapper = mountWithContexts(); + }); + + afterAll(() => { + wrapper.unmount(); + }); + + test('should render Details', async () => { + function assertDetail(label, value) { + expect(wrapper.find(`Detail[label="${label}"] dt`).text()).toBe(label); + expect(wrapper.find(`Detail[label="${label}"] dd`).text()).toBe(value); + } + + assertDetail('Name', 'localhost'); + assertDetail('Description', 'localhost description'); + assertDetail('Created', '10/28/2019, 9:26:54 PM'); + assertDetail('Last Modified', '10/29/2019, 8:18:41 PM'); + }); + + test('should show edit button for users with edit permission', () => { + const editButton = wrapper.find('Button[aria-label="edit"]'); + expect(editButton.text()).toEqual('Edit'); + expect(editButton.prop('to')).toBe( + '/inventories/inventory/3/hosts/2/edit' + ); + }); + + test('expected api call is made for delete', async () => { + await act(async () => { + wrapper.find('DeleteButton').invoke('onConfirm')(); + }); + expect(HostsAPI.destroy).toHaveBeenCalledTimes(1); + }); + + test('Error dialog shown for failed deletion', async () => { + HostsAPI.destroy.mockImplementationOnce(() => + Promise.reject(new Error()) + ); + await act(async () => { + wrapper.find('DeleteButton').invoke('onConfirm')(); + }); + await waitForElement( + wrapper, + 'Modal[title="Error!"]', + el => el.length === 1 + ); + await act(async () => { + wrapper.find('Modal[title="Error!"]').invoke('onClose')(); + }); + await waitForElement( + wrapper, + 'Modal[title="Error!"]', + el => el.length === 0 + ); + }); + }); + + describe('User has read-only permissions', () => { + beforeAll(() => { + const readOnlyHost = { ...mockHost }; + readOnlyHost.summary_fields.user_capabilities.edit = false; + + wrapper = mountWithContexts(); + }); + + afterAll(() => { + wrapper.unmount(); + }); + + test('should hide edit button for users without edit permission', async () => { + expect(wrapper.find('Button[aria-label="edit"]').length).toBe(0); + }); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostDetail/index.js b/awx/ui_next/src/screens/Inventory/InventoryHostDetail/index.js new file mode 100644 index 0000000000..df9deaf20d --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryHostDetail/index.js @@ -0,0 +1 @@ +export { default } from './InventoryHostDetail'; diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostEdit/InventoryHostEdit.jsx b/awx/ui_next/src/screens/Inventory/InventoryHostEdit/InventoryHostEdit.jsx new file mode 100644 index 0000000000..c7f0845bd4 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryHostEdit/InventoryHostEdit.jsx @@ -0,0 +1,44 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { useHistory } from 'react-router-dom'; +import { CardBody } from '@components/Card'; +import HostForm from '@components/HostForm'; + +import { HostsAPI } from '@api'; + +function InventoryHostEdit({ host, inventory }) { + const [formError, setFormError] = useState(null); + const detailsUrl = `/inventories/inventory/${inventory.id}/hosts/${host.id}/details`; + const history = useHistory(); + + const handleSubmit = async values => { + try { + await HostsAPI.update(host.id, values); + history.push(detailsUrl); + } catch (error) { + setFormError(error); + } + }; + + const handleCancel = () => { + history.push(detailsUrl); + }; + + return ( + + + + ); +} + +InventoryHostEdit.propTypes = { + host: PropTypes.shape().isRequired, +}; + +export default InventoryHostEdit; diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostEdit/InventoryHostEdit.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryHostEdit/InventoryHostEdit.test.jsx new file mode 100644 index 0000000000..f6a6ccb849 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryHostEdit/InventoryHostEdit.test.jsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { HostsAPI } from '@api'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import InventoryHostEdit from './InventoryHostEdit'; +import mockHost from '../shared/data.host.json'; + +jest.mock('@api'); + +describe('', () => { + let wrapper; + let history; + + const updatedHostData = { + name: 'new name', + description: 'new description', + variables: '---\nfoo: bar', + }; + + beforeAll(async () => { + history = createMemoryHistory(); + await act(async () => { + wrapper = mountWithContexts( + , + { + context: { router: { history } }, + } + ); + }); + }); + + afterAll(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('handleSubmit should call api update', async () => { + await act(async () => { + wrapper.find('HostForm').prop('handleSubmit')(updatedHostData); + }); + expect(HostsAPI.update).toHaveBeenCalledWith(2, updatedHostData); + }); + + test('should navigate to inventory host detail when cancel is clicked', async () => { + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); + }); + expect(history.location.pathname).toEqual( + '/inventories/inventory/123/hosts/2/details' + ); + }); + + test('should navigate to inventory host detail after successful submission', async () => { + await act(async () => { + wrapper.find('HostForm').invoke('handleSubmit')(updatedHostData); + }); + expect(wrapper.find('FormSubmitError').length).toBe(0); + expect(history.location.pathname).toEqual( + '/inventories/inventory/123/hosts/2/details' + ); + }); + + test('failed form submission should show an error message', async () => { + const error = { + response: { + data: { detail: 'An error occurred' }, + }, + }; + HostsAPI.update.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/InventoryHostEdit/index.js b/awx/ui_next/src/screens/Inventory/InventoryHostEdit/index.js new file mode 100644 index 0000000000..428da2e09c --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryHostEdit/index.js @@ -0,0 +1 @@ +export { default } from './InventoryHostEdit'; diff --git a/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.jsx b/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.jsx index f8f4578c71..a15fbebf6d 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.jsx @@ -1,6 +1,7 @@ import React from 'react'; import { Switch, Route } from 'react-router-dom'; +import InventoryHost from '../InventoryHost'; import InventoryHostAdd from '../InventoryHostAdd'; import InventoryHostList from './InventoryHostList'; @@ -10,6 +11,9 @@ function InventoryHosts({ setBreadcrumb, inventory }) { + + + From 3d5a002676b8c355c57f60275c717b2f3b0e00ee Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Fri, 6 Mar 2020 01:36:38 -0500 Subject: [PATCH 3/3] Remove all inventory route logic from Host screens --- awx/ui_next/src/screens/Host/Host.jsx | 123 ++++------------- awx/ui_next/src/screens/Host/Host.test.jsx | 52 +++----- .../screens/Host/HostDetail/HostDetail.jsx | 86 +++++------- .../Host/HostDetail/HostDetail.test.jsx | 126 ++++++++++-------- .../src/screens/Host/HostEdit/HostEdit.jsx | 21 +-- .../screens/Host/HostEdit/HostEdit.test.jsx | 85 +++++++----- awx/ui_next/src/screens/Host/data.host.json | 12 -- 7 files changed, 215 insertions(+), 290 deletions(-) diff --git a/awx/ui_next/src/screens/Host/Host.jsx b/awx/ui_next/src/screens/Host/Host.jsx index 37096fc024..2a8f286bd2 100644 --- a/awx/ui_next/src/screens/Host/Host.jsx +++ b/awx/ui_next/src/screens/Host/Host.jsx @@ -10,7 +10,6 @@ import { useLocation, } from 'react-router-dom'; import { Card, CardActions } from '@patternfly/react-core'; -import { CaretLeftIcon } from '@patternfly/react-icons'; import { TabbedCardHeader } from '@components/Card'; import CardCloseButton from '@components/CardCloseButton'; @@ -24,20 +23,13 @@ import HostEdit from './HostEdit'; import HostGroups from './HostGroups'; import { HostsAPI } from '@api'; -function Host({ inventory, i18n, setBreadcrumb }) { +function Host({ i18n, setBreadcrumb }) { const [host, setHost] = useState(null); const [contentError, setContentError] = useState(null); const [hasContentLoading, setHasContentLoading] = useState(true); const location = useLocation(); - const hostsMatch = useRouteMatch('/hosts/:id'); - const inventoriesMatch = useRouteMatch( - '/inventories/inventory/:id/hosts/:hostId' - ); - const baseUrl = hostsMatch ? hostsMatch.url : inventoriesMatch.url; - const hostListUrl = hostsMatch - ? '/hosts' - : `/inventories/inventory/${inventoriesMatch.params.id}/hosts`; + const match = useRouteMatch('/hosts/:id'); useEffect(() => { (async () => { @@ -45,17 +37,10 @@ function Host({ inventory, i18n, setBreadcrumb }) { setHasContentLoading(true); try { - const hostId = hostsMatch - ? hostsMatch.params.id - : inventoriesMatch.params.hostId; - const { data } = await HostsAPI.readDetail(hostId); - setHost(data); + const { data } = await HostsAPI.readDetail(match.params.id); - if (hostsMatch) { - setBreadcrumb(data); - } else if (inventoriesMatch) { - setBreadcrumb(inventory, data); - } + setHost(data); + setBreadcrumb(data); } catch (error) { setContentError(error); } finally { @@ -67,44 +52,31 @@ function Host({ inventory, i18n, setBreadcrumb }) { const tabsArray = [ { name: i18n._(t`Details`), - link: `${baseUrl}/details`, + link: `${match.url}/details`, id: 0, }, { name: i18n._(t`Facts`), - link: `${baseUrl}/facts`, + link: `${match.url}/facts`, id: 1, }, { name: i18n._(t`Groups`), - link: `${baseUrl}/groups`, + link: `${match.url}/groups`, id: 2, }, { name: i18n._(t`Completed Jobs`), - link: `${baseUrl}/completed_jobs`, + link: `${match.url}/completed_jobs`, id: 3, }, ]; - if (inventoriesMatch) { - tabsArray.unshift({ - name: ( - <> - - {i18n._(t`Back to Hosts`)} - - ), - link: hostListUrl, - id: 99, - }); - } - let cardHeader = ( - + ); @@ -124,7 +96,7 @@ function Host({ inventory, i18n, setBreadcrumb }) { {contentError.response && contentError.response.status === 404 && ( {i18n._(`Host not found.`)}{' '} - {i18n._(`View all Hosts.`)} + {i18n._(`View all Hosts.`)} )} @@ -132,72 +104,35 @@ function Host({ inventory, i18n, setBreadcrumb }) { ); } - const redirect = hostsMatch ? ( - - ) : ( - - ); - return ( {cardHeader} - {redirect} - {host && ( - - setHost(newHost)} - /> - - )} - {host && ( - } - /> - )} - {host && ( - } - /> - )} - {host && ( - } - /> - )} - {host?.id && ( - + + {host && [ + + + , + + + , + + + , + + + , + - - )} + , + ]} !hasContentLoading && ( - + {i18n._(`View Host Details`)} diff --git a/awx/ui_next/src/screens/Host/Host.test.jsx b/awx/ui_next/src/screens/Host/Host.test.jsx index 8d5b83d2ef..64c188fc06 100644 --- a/awx/ui_next/src/screens/Host/Host.test.jsx +++ b/awx/ui_next/src/screens/Host/Host.test.jsx @@ -3,53 +3,41 @@ import { act } from 'react-dom/test-utils'; import { createMemoryHistory } from 'history'; import { HostsAPI } from '@api'; import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; -import mockDetails from './data.host.json'; +import mockHost from './data.host.json'; import Host from './Host'; jest.mock('@api'); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useRouteMatch: () => ({ + url: '/hosts/1', + params: { id: 1 }, + }), +})); + +HostsAPI.readDetail.mockResolvedValue({ + data: { ...mockHost }, +}); describe('', () => { let wrapper; let history; - HostsAPI.readDetail.mockResolvedValue({ - data: { ...mockDetails }, + beforeEach(async () => { + await act(async () => { + wrapper = mountWithContexts( {}} />); + }); }); afterEach(() => { wrapper.unmount(); }); - test('initially renders succesfully', async () => { - history = createMemoryHistory({ - initialEntries: ['/hosts/1/edit'], + test('should render expected tabs', async () => { + const expectedTabs = ['Details', 'Facts', 'Groups', 'Completed Jobs']; + wrapper.find('RoutedTabs li').forEach((tab, index) => { + expect(tab.text()).toEqual(expectedTabs[index]); }); - - await act(async () => { - wrapper = mountWithContexts( {}} />, { - context: { router: { history } }, - }); - }); - await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); - expect(wrapper.find('Host').length).toBe(1); - }); - - test('should render "Back to Hosts" tab when navigating from inventories', async () => { - history = createMemoryHistory({ - initialEntries: ['/inventories/inventory/1/hosts/1'], - }); - await act(async () => { - wrapper = mountWithContexts( {}} />, { - context: { router: { history } }, - }); - }); - await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); - expect( - wrapper - .find('RoutedTabs li') - .first() - .text() - ).toBe('Back to Hosts'); }); test('should show content error when api throws error on initial render', async () => { diff --git a/awx/ui_next/src/screens/Host/HostDetail/HostDetail.jsx b/awx/ui_next/src/screens/Host/HostDetail/HostDetail.jsx index 316ac4e485..2cedbaa6e0 100644 --- a/awx/ui_next/src/screens/Host/HostDetail/HostDetail.jsx +++ b/awx/ui_next/src/screens/Host/HostDetail/HostDetail.jsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { Link, useHistory, useParams, useLocation } from 'react-router-dom'; +import { Link, useHistory } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Host } from '@types'; @@ -14,42 +14,36 @@ import DeleteButton from '@components/DeleteButton'; import { HostsAPI } from '@api'; import HostToggle from '@components/HostToggle'; -function HostDetail({ host, i18n, onUpdateHost }) { +function HostDetail({ i18n, host }) { const { created, description, id, modified, name, + variables, summary_fields: { inventory, recent_jobs, - kind, created_by, modified_by, user_capabilities, }, } = host; - const history = useHistory(); - const { pathname } = useLocation(); - const { id: inventoryId, hostId: inventoryHostId } = useParams(); const [isLoading, setIsloading] = useState(false); const [deletionError, setDeletionError] = useState(false); - - const recentPlaybookJobs = recent_jobs.map(job => ({ ...job, type: 'job' })); + const history = useHistory(); const handleHostDelete = async () => { setIsloading(true); try { await HostsAPI.destroy(id); - setIsloading(false); - const url = pathname.startsWith('/inventories') - ? `/inventories/inventory/${inventoryId}/hosts/` - : `/hosts`; - history.push(url); + history.push('/hosts'); } catch (err) { setDeletionError(err); + } finally { + setIsloading(false); } }; @@ -66,77 +60,71 @@ function HostDetail({ host, i18n, onUpdateHost }) { ); } + + const recentPlaybookJobs = recent_jobs.map(job => ({ ...job, type: 'job' })); return ( - - onUpdateHost({ - ...host, - enabled, - }) - } - css="padding-bottom: 40px" - /> + } label={i18n._(t`Activity`)} + value={} /> - {inventory && ( - - {inventory.name} - - } - /> - )} + + {inventory.name} + + } + /> - {user_capabilities && user_capabilities.edit && ( + {user_capabilities?.edit && ( )} - {user_capabilities && user_capabilities.delete && ( + {user_capabilities?.delete && ( handleHostDelete()} modalTitle={i18n._(t`Delete Host`)} - name={host.name} + name={name} /> )} + {deletionError && ( + setDeletionError(null)} + > + {i18n._(t`Failed to delete host.`)} + + + )} ); } diff --git a/awx/ui_next/src/screens/Host/HostDetail/HostDetail.test.jsx b/awx/ui_next/src/screens/Host/HostDetail/HostDetail.test.jsx index 0f1ccc037a..d809bf1732 100644 --- a/awx/ui_next/src/screens/Host/HostDetail/HostDetail.test.jsx +++ b/awx/ui_next/src/screens/Host/HostDetail/HostDetail.test.jsx @@ -1,66 +1,88 @@ import React from 'react'; - +import { act } from 'react-dom/test-utils'; import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; - import HostDetail from './HostDetail'; +import { HostsAPI } from '@api'; + +import mockHost from '../data.host.json'; jest.mock('@api'); describe('', () => { - const mockHost = { - id: 1, - name: 'Foo', - description: 'Bar', - inventory: 1, - created: '2015-07-07T17:21:26.429745Z', - modified: '2019-08-11T19:47:37.980466Z', - variables: '---', - summary_fields: { - inventory: { - id: 1, - name: 'test inventory', - }, - user_capabilities: { - edit: true, - }, - recent_jobs: [], - }, - }; + let wrapper; - test('initially renders succesfully', () => { - mountWithContexts(); + describe('User has edit permissions', () => { + beforeAll(() => { + wrapper = mountWithContexts(); + }); + + afterAll(() => { + wrapper.unmount(); + }); + + test('should render Details', async () => { + function assertDetail(label, value) { + expect(wrapper.find(`Detail[label="${label}"] dt`).text()).toBe(label); + expect(wrapper.find(`Detail[label="${label}"] dd`).text()).toBe(value); + } + + assertDetail('Name', 'localhost'); + assertDetail('Description', 'a good description'); + assertDetail('Inventory', 'Mikes Inventory'); + assertDetail('Created', '10/28/2019, 9:26:54 PM'); + assertDetail('Last Modified', '10/29/2019, 8:18:41 PM'); + }); + + test('should show edit button for users with edit permission', () => { + const editButton = wrapper.find('Button[aria-label="edit"]'); + expect(editButton.text()).toEqual('Edit'); + expect(editButton.prop('to')).toBe('/hosts/2/edit'); + }); + + test('expected api call is made for delete', async () => { + await act(async () => { + wrapper.find('DeleteButton').invoke('onConfirm')(); + }); + expect(HostsAPI.destroy).toHaveBeenCalledTimes(1); + }); + + test('Error dialog shown for failed deletion', async () => { + HostsAPI.destroy.mockImplementationOnce(() => + Promise.reject(new Error()) + ); + await act(async () => { + wrapper.find('DeleteButton').invoke('onConfirm')(); + }); + await waitForElement( + wrapper, + 'Modal[title="Error!"]', + el => el.length === 1 + ); + await act(async () => { + wrapper.find('Modal[title="Error!"]').invoke('onClose')(); + }); + await waitForElement( + wrapper, + 'Modal[title="Error!"]', + el => el.length === 0 + ); + }); }); - test('should render Details', async () => { - const wrapper = mountWithContexts(); - const testParams = [ - { label: 'Name', value: 'Foo' }, - { label: 'Description', value: 'Bar' }, - { label: 'Inventory', value: 'test inventory' }, - { label: 'Created', value: '7/7/2015, 5:21:26 PM' }, - { label: 'Last Modified', value: '8/11/2019, 7:47:37 PM' }, - ]; - // eslint-disable-next-line no-restricted-syntax - for (const { label, value } of testParams) { - // eslint-disable-next-line no-await-in-loop - const detail = await waitForElement(wrapper, `Detail[label="${label}"]`); - expect(detail.find('dt').text()).toBe(label); - expect(detail.find('dd').text()).toBe(value); - } - }); + describe('User has read-only permissions', () => { + beforeAll(() => { + const readOnlyHost = { ...mockHost }; + readOnlyHost.summary_fields.user_capabilities.edit = false; - test('should show edit button for users with edit permission', async () => { - const wrapper = mountWithContexts(); - const editButton = wrapper.find('Button[aria-label="edit"]'); - expect(editButton.text()).toEqual('Edit'); - expect(editButton.prop('to')).toBe('/hosts/1/edit'); - }); + wrapper = mountWithContexts(); + }); - test('should hide edit button for users without edit permission', async () => { - const readOnlyHost = { ...mockHost }; - readOnlyHost.summary_fields.user_capabilities.edit = false; - const wrapper = mountWithContexts(); - await waitForElement(wrapper, 'HostDetail'); - expect(wrapper.find('Button[aria-label="edit"]').length).toBe(0); + afterAll(() => { + wrapper.unmount(); + }); + + test('should hide edit button for users without edit permission', async () => { + expect(wrapper.find('Button[aria-label="edit"]').length).toBe(0); + }); }); }); diff --git a/awx/ui_next/src/screens/Host/HostEdit/HostEdit.jsx b/awx/ui_next/src/screens/Host/HostEdit/HostEdit.jsx index f6dc82e767..bddb692b14 100644 --- a/awx/ui_next/src/screens/Host/HostEdit/HostEdit.jsx +++ b/awx/ui_next/src/screens/Host/HostEdit/HostEdit.jsx @@ -1,31 +1,14 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; -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 HostEdit({ host }) { const [formError, setFormError] = useState(null); - const hostsMatch = useRouteMatch('/hosts/:id/edit'); - const inventoriesMatch = useRouteMatch( - '/inventories/inventory/:id/hosts/:hostId/edit' - ); + const detailsUrl = `/hosts/${host.id}/details`; const history = useHistory(); - let detailsUrl; - - if (hostsMatch) { - detailsUrl = `/hosts/${hostsMatch.params.id}/details`; - } - - if (inventoriesMatch) { - const kind = - host.summary_fields.inventory.kind === 'smart' - ? 'smart_inventory' - : 'inventory'; - detailsUrl = `/inventories/${kind}/${inventoriesMatch.params.id}/hosts/${inventoriesMatch.params.hostId}/details`; - } const handleSubmit = async values => { try { diff --git a/awx/ui_next/src/screens/Host/HostEdit/HostEdit.test.jsx b/awx/ui_next/src/screens/Host/HostEdit/HostEdit.test.jsx index 637dd64273..038ee90bbf 100644 --- a/awx/ui_next/src/screens/Host/HostEdit/HostEdit.test.jsx +++ b/awx/ui_next/src/screens/Host/HostEdit/HostEdit.test.jsx @@ -1,49 +1,70 @@ import React from 'react'; +import { act } from 'react-dom/test-utils'; import { createMemoryHistory } from 'history'; import { HostsAPI } from '@api'; import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import mockHost from '../data.host.json'; import HostEdit from './HostEdit'; jest.mock('@api'); describe('', () => { - const mockData = { - id: 1, - name: 'Foo', - description: 'Bar', - inventory: 1, - variables: '---', - summary_fields: { - inventory: { - id: 1, - name: 'test inventory', - }, - }, + let wrapper; + let history; + + const updatedHostData = { + name: 'new name', + description: 'new description', + variables: '---\nfoo: bar', }; - test('handleSubmit should call api update', () => { - const wrapper = mountWithContexts(); - - const updatedHostData = { - name: 'new name', - description: 'new description', - variables: '---\nfoo: bar', - }; - wrapper.find('HostForm').prop('handleSubmit')(updatedHostData); - - expect(HostsAPI.update).toHaveBeenCalledWith(1, updatedHostData); + beforeAll(async () => { + history = createMemoryHistory(); + await act(async () => { + wrapper = mountWithContexts(, { + context: { router: { history } }, + }); + }); }); - test('should navigate to host detail when cancel is clicked', () => { - const history = createMemoryHistory({ - initialEntries: ['/hosts/1/edit'], - }); - const wrapper = mountWithContexts(, { - context: { router: { history } }, - }); + afterAll(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); - wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); + test('handleSubmit should call api update', async () => { + await act(async () => { + wrapper.find('HostForm').prop('handleSubmit')(updatedHostData); + }); + expect(HostsAPI.update).toHaveBeenCalledWith(2, updatedHostData); + }); - expect(history.location.pathname).toEqual('/hosts/1/details'); + test('should navigate to host detail when cancel is clicked', async () => { + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); + }); + expect(history.location.pathname).toEqual('/hosts/2/details'); + }); + + test('should navigate to host detail after successful submission', async () => { + await act(async () => { + wrapper.find('HostForm').invoke('handleSubmit')(updatedHostData); + }); + expect(wrapper.find('FormSubmitError').length).toBe(0); + expect(history.location.pathname).toEqual('/hosts/2/details'); + }); + + test('failed form submission should show an error message', async () => { + const error = { + response: { + data: { detail: 'An error occurred' }, + }, + }; + HostsAPI.update.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/Host/data.host.json b/awx/ui_next/src/screens/Host/data.host.json index d2ef565610..aacc08f787 100644 --- a/awx/ui_next/src/screens/Host/data.host.json +++ b/awx/ui_next/src/screens/Host/data.host.json @@ -51,18 +51,6 @@ "id": 1, "failed": false }, - "created_by": { - "id": 1, - "username": "admin", - "first_name": "", - "last_name": "" - }, - "modified_by": { - "id": 1, - "username": "admin", - "first_name": "", - "last_name": "" - }, "user_capabilities": { "edit": true, "delete": true