diff --git a/awx/ui_next/src/screens/Host/Host.jsx b/awx/ui_next/src/screens/Host/Host.jsx index 430c847128..7a053c8459 100644 --- a/awx/ui_next/src/screens/Host/Host.jsx +++ b/awx/ui_next/src/screens/Host/Host.jsx @@ -1,7 +1,14 @@ -import React, { Component } from 'react'; +import React, { useEffect, useState } from 'react'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { Switch, Route, withRouter, Redirect, Link } from 'react-router-dom'; +import { + Switch, + Route, + Redirect, + Link, + useRouteMatch, + useLocation, +} from 'react-router-dom'; import { Card } from '@patternfly/react-core'; import { CaretLeftIcon } from '@patternfly/react-icons'; @@ -12,217 +19,190 @@ import ContentError from '@components/ContentError'; import ContentLoading from '@components/ContentLoading'; import HostFacts from './HostFacts'; import HostDetail from './HostDetail'; - import HostEdit from './HostEdit'; import HostGroups from './HostGroups'; import HostCompletedJobs from './HostCompletedJobs'; import { HostsAPI } from '@api'; -class Host extends Component { - constructor(props) { - super(props); +function Host({ inventory, i18n, setBreadcrumb }) { + const [host, setHost] = useState(null); + const [contentError, setContentError] = useState(null); + const [hasContentLoading, setHasContentLoading] = useState(true); - this.state = { - host: null, - hasContentLoading: true, - contentError: null, - isInitialized: false, - }; - this.loadHost = this.loadHost.bind(this); - } + 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`; - async componentDidMount() { - await this.loadHost(); - this.setState({ isInitialized: true }); - } - - async componentDidUpdate(prevProps) { - const { location, match } = this.props; - const url = `/hosts/${match.params.id}/`; - - if ( - prevProps.location.pathname.startsWith(url) && - prevProps.location !== location && - location.pathname === `${url}details` - ) { - await this.loadHost(); - } - } - - async loadHost() { - const { match, setBreadcrumb, history, inventory } = this.props; - - this.setState({ contentError: null, hasContentLoading: true }); + const loadHost = async () => { + setContentError(null); + setHasContentLoading(true); try { - const { data } = await HostsAPI.readDetail( - match.params.hostId || match.params.id - ); - this.setState({ host: data }); + const hostId = hostsMatch + ? hostsMatch.params.id + : inventoriesMatch.params.hostId; + const { data } = await HostsAPI.readDetail(hostId); + setHost(data); - if (history.location.pathname.startsWith('/hosts')) { + if (hostsMatch) { setBreadcrumb(data); - } else { + } else if (inventoriesMatch) { setBreadcrumb(inventory, data); } - } catch (err) { - this.setState({ contentError: err }); + } catch (error) { + setContentError(error); } finally { - this.setState({ hasContentLoading: false }); + setHasContentLoading(false); } + }; + + useEffect(() => { + loadHost(); + }, [location]); // eslint-disable-line react-hooks/exhaustive-deps + + const tabsArray = [ + { + name: i18n._(t`Details`), + link: `${baseUrl}/details`, + id: 0, + }, + { + name: i18n._(t`Facts`), + link: `${baseUrl}/facts`, + id: 1, + }, + { + name: i18n._(t`Groups`), + link: `${baseUrl}/groups`, + id: 2, + }, + { + name: i18n._(t`Completed Jobs`), + link: `${baseUrl}/completed_jobs`, + id: 3, + }, + ]; + + if (inventoriesMatch) { + tabsArray.unshift({ + name: ( + <> + + {i18n._(t`Back to Hosts`)} + + ), + link: hostListUrl, + id: 99, + }); } - render() { - const { location, match, history, i18n } = this.props; - const { host, hasContentLoading, isInitialized, contentError } = this.state; + let cardHeader = ( + + + + + ); - const tabsArray = [ - { - name: i18n._(t`Details`), - link: `${match.url}/details`, - id: 0, - }, - { - name: i18n._(t`Facts`), - link: `${match.url}/facts`, - id: 1, - }, - { - name: i18n._(t`Groups`), - link: `${match.url}/groups`, - id: 2, - }, - { - name: i18n._(t`Completed Jobs`), - link: `${match.url}/completed_jobs`, - id: 3, - }, - ]; + if (location.pathname.endsWith('edit')) { + cardHeader = null; + } - if (!history.location.pathname.startsWith('/hosts')) { - tabsArray.unshift({ - name: ( - <> - - {i18n._(t`Back to Hosts`)} - - ), - link: `/inventories/inventory/${match.params.id}/hosts`, - id: 99, - }); - } - - let cardHeader = ( - - - - - ); - - if (!isInitialized) { - cardHeader = null; - } - - if (location.pathname.endsWith('edit')) { - cardHeader = null; - } - - if (hasContentLoading) { - return ; - } - - if (!hasContentLoading && contentError) { - return ( - - - {contentError.response.status === 404 && ( - - {i18n._(`Host not found.`)}{' '} - {i18n._(`View all Hosts.`)} - - )} - - - ); - } - - const redirect = location.pathname.startsWith('/hosts') ? ( - - ) : ( - - ); + if (hasContentLoading) { + return ; + } + if (!hasContentLoading && contentError) { return ( - {cardHeader} - - {redirect} - {host && ( - ( - this.setState({ host: newHost })} - /> - )} - /> + + {contentError.response && contentError.response.status === 404 && ( + + {i18n._(`Host not found.`)}{' '} + {i18n._(`View all Hosts.`)} + )} - )) - {host && ( - } - /> - )} - {host && ( - } - /> - )} - {host && ( - } - /> - )} - {host && ( - } - /> - )} - - !hasContentLoading && ( - - {match.params.id && ( - - {i18n._(`View Host Details`)} - - )} - - ) - } - /> - , - + ); } + + const redirect = hostsMatch ? ( + + ) : ( + + ); + + return ( + + {cardHeader} + + {redirect} + {host && ( + + setHost(newHost)} + /> + + )} + {host && ( + } + /> + )} + {host && ( + } + /> + )} + {host && ( + } + /> + )} + {host && ( + } + /> + )} + + !hasContentLoading && ( + + + {i18n._(`View Host Details`)} + + + ) + } + /> + + + ); } -export default withI18n()(withRouter(Host)); +export default withI18n()(Host); export { Host as _Host }; diff --git a/awx/ui_next/src/screens/Host/Host.test.jsx b/awx/ui_next/src/screens/Host/Host.test.jsx index ca7cb6fe97..8d5b83d2ef 100644 --- a/awx/ui_next/src/screens/Host/Host.test.jsx +++ b/awx/ui_next/src/screens/Host/Host.test.jsx @@ -1,4 +1,5 @@ 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'; @@ -7,39 +8,70 @@ import Host from './Host'; jest.mock('@api'); -const mockMe = { - is_super_user: true, - is_system_auditor: false, -}; - describe('', () => { - test('initially renders succesfully', () => { - HostsAPI.readDetail.mockResolvedValue({ data: mockDetails }); - mountWithContexts( {}} me={mockMe} />); + let wrapper; + let history; + + HostsAPI.readDetail.mockResolvedValue({ + data: { ...mockDetails }, + }); + + afterEach(() => { + wrapper.unmount(); + }); + + test('initially renders succesfully', async () => { + history = createMemoryHistory({ + initialEntries: ['/hosts/1/edit'], + }); + + 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 () => { + HostsAPI.readDetail.mockRejectedValueOnce(new Error()); + await act(async () => { + wrapper = mountWithContexts( {}} />, { + context: { router: { history } }, + }); + }); + 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 () => { - const history = createMemoryHistory({ + history = createMemoryHistory({ initialEntries: ['/hosts/1/foobar'], }); - const wrapper = mountWithContexts( - {}} me={mockMe} />, - { - context: { - router: { - history, - route: { - location: history.location, - match: { - params: { id: 1 }, - url: '/hosts/1/foobar', - path: '/host/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/Host/HostAdd/HostAdd.jsx b/awx/ui_next/src/screens/Host/HostAdd/HostAdd.jsx index 5ab7a5d40b..f9d78a00e4 100644 --- a/awx/ui_next/src/screens/Host/HostAdd/HostAdd.jsx +++ b/awx/ui_next/src/screens/Host/HostAdd/HostAdd.jsx @@ -1,58 +1,42 @@ -import React from 'react'; -import { withRouter } from 'react-router-dom'; -import { withI18n } from '@lingui/react'; -import { PageSection, Card } from '@patternfly/react-core'; +import React, { useState } from 'react'; +import { useHistory, useRouteMatch } from 'react-router-dom'; import { CardBody } from '@components/Card'; import { HostsAPI } from '@api'; -import { Config } from '@contexts/Config'; import HostForm from '../shared'; -class HostAdd extends React.Component { - constructor(props) { - super(props); - this.handleSubmit = this.handleSubmit.bind(this); - this.handleCancel = this.handleCancel.bind(this); - this.state = { error: '' }; - } +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, + }; - async handleSubmit(values) { - const { history } = this.props; try { const { data: response } = await HostsAPI.create(values); - history.push(`/hosts/${response.id}`); + history.push(`${url}/${response.id}/details`); } catch (error) { - this.setState({ error }); + setFormError(error); } - } + }; - handleCancel() { - const { history } = this.props; - history.push('/hosts'); - } + const handleCancel = () => { + history.push(`${url}`); + }; - render() { - const { error } = this.state; - - return ( - - - - - {({ me }) => ( - - )} - - {error ?
error
: ''} -
-
-
- ); - } + return ( + + + {formError ?
error
: ''} +
+ ); } -export { HostAdd as _HostAdd }; -export default withI18n()(withRouter(HostAdd)); +export default HostAdd; 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 563b25bb62..6478856675 100644 --- a/awx/ui_next/src/screens/Host/HostAdd/HostAdd.test.jsx +++ b/awx/ui_next/src/screens/Host/HostAdd/HostAdd.test.jsx @@ -8,55 +8,48 @@ import { HostsAPI } from '@api'; jest.mock('@api'); describe('', () => { - test('handleSubmit should post to api', async () => { - let wrapper; - await act(async () => { - wrapper = mountWithContexts(); - }); - const updatedHostData = { - name: 'new name', - description: 'new description', - inventory: 1, - variables: '---\nfoo: bar', - }; - wrapper.find('HostForm').prop('handleSubmit')(updatedHostData); - expect(HostsAPI.create).toHaveBeenCalledWith(updatedHostData); - }); + let wrapper; + let history; - test('should navigate to hosts list when cancel is clicked', async () => { - const history = createMemoryHistory({}); - let wrapper; + const hostData = { + name: 'new name', + description: 'new description', + inventory: 1, + variables: '---\nfoo: bar', + }; + + beforeEach(async () => { + history = createMemoryHistory({ + initialEntries: ['/hosts/1/add'], + }); await act(async () => { wrapper = mountWithContexts(, { context: { router: { history } }, }); }); + }); + + test('handleSubmit should post to api', async () => { + act(() => { + wrapper.find('HostForm').prop('handleSubmit')(hostData); + }); + expect(HostsAPI.create).toHaveBeenCalledWith(hostData); + }); + + test('should navigate to hosts list when cancel is clicked', async () => { wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); expect(history.location.pathname).toEqual('/hosts'); }); test('successful form submission should trigger redirect', async () => { - const history = createMemoryHistory({}); - const hostData = { - name: 'new name', - description: 'new description', - inventory: 1, - variables: '---\nfoo: bar', - }; HostsAPI.create.mockResolvedValueOnce({ data: { id: 5, ...hostData, }, }); - let wrapper; - await act(async () => { - wrapper = mountWithContexts(, { - context: { router: { history } }, - }); - }); await waitForElement(wrapper, 'button[aria-label="Save"]'); await wrapper.find('HostForm').invoke('handleSubmit')(hostData); - expect(history.location.pathname).toEqual('/hosts/5'); + expect(history.location.pathname).toEqual('/hosts/5/details'); }); }); diff --git a/awx/ui_next/src/screens/Host/HostEdit/HostEdit.jsx b/awx/ui_next/src/screens/Host/HostEdit/HostEdit.jsx index 6f66483cc1..529ebbf425 100644 --- a/awx/ui_next/src/screens/Host/HostEdit/HostEdit.jsx +++ b/awx/ui_next/src/screens/Host/HostEdit/HostEdit.jsx @@ -1,72 +1,54 @@ -import React, { Component } from 'react'; +import React, { useState } from 'react'; import PropTypes from 'prop-types'; -import { withRouter } from 'react-router-dom'; +import { useHistory, useRouteMatch } from 'react-router-dom'; import { CardBody } from '@components/Card'; - import { HostsAPI } from '@api'; -import { Config } from '@contexts/Config'; - import HostForm from '../shared'; -class HostEdit extends Component { - constructor(props) { - super(props); +function HostEdit({ host }) { + const [formError, setFormError] = useState(null); + const hostsMatch = useRouteMatch('/hosts/:id/edit'); + const inventoriesMatch = useRouteMatch( + '/inventories/inventory/:id/hosts/:hostId/edit' + ); + const history = useHistory(); + let detailsUrl; - this.handleSubmit = this.handleSubmit.bind(this); - this.handleCancel = this.handleCancel.bind(this); - this.handleSuccess = this.handleSuccess.bind(this); - - this.state = { - error: '', - }; + if (hostsMatch) { + detailsUrl = `/hosts/${hostsMatch.params.id}/details`; } - async handleSubmit(values) { - const { host } = this.props; + 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 { await HostsAPI.update(host.id, values); - this.handleSuccess(); - } catch (err) { - this.setState({ error: err }); + history.push(detailsUrl); + } catch (error) { + setFormError(error); } - } + }; - handleCancel() { - const { - host: { id }, - history, - } = this.props; - history.push(`/hosts/${id}/details`); - } + const handleCancel = () => { + history.push(detailsUrl); + }; - handleSuccess() { - const { - host: { id }, - history, - } = this.props; - history.push(`/hosts/${id}/details`); - } - - render() { - const { host } = this.props; - const { error } = this.state; - - return ( - - - {({ me }) => ( - - )} - - {error ?
error
: null} -
- ); - } + return ( + + + {formError ?
error
: null} +
+ ); } HostEdit.propTypes = { @@ -74,4 +56,4 @@ HostEdit.propTypes = { }; export { HostEdit as _HostEdit }; -export default withRouter(HostEdit); +export default HostEdit; 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 fd097029b0..637dd64273 100644 --- a/awx/ui_next/src/screens/Host/HostEdit/HostEdit.test.jsx +++ b/awx/ui_next/src/screens/Host/HostEdit/HostEdit.test.jsx @@ -35,7 +35,9 @@ describe('', () => { }); test('should navigate to host detail when cancel is clicked', () => { - const history = createMemoryHistory({}); + const history = createMemoryHistory({ + initialEntries: ['/hosts/1/edit'], + }); const wrapper = mountWithContexts(, { context: { router: { history } }, }); diff --git a/awx/ui_next/src/screens/Host/Hosts.jsx b/awx/ui_next/src/screens/Host/Hosts.jsx index 1ea3e92905..ed48bd8bb0 100644 --- a/awx/ui_next/src/screens/Host/Hosts.jsx +++ b/awx/ui_next/src/screens/Host/Hosts.jsx @@ -2,9 +2,9 @@ import React, { Component, Fragment } from 'react'; import { Route, withRouter, Switch } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { PageSection } from '@patternfly/react-core'; import { Config } from '@contexts/Config'; +import { PageSection, Card } from '@patternfly/react-core'; import Breadcrumbs from '@components/Breadcrumbs/Breadcrumbs'; import HostList from './HostList'; @@ -54,11 +54,10 @@ class Hosts extends Component { - - } /> - ( + + + } /> + {({ me }) => ( )} - )} - /> - } /> - + + } /> + + ); diff --git a/awx/ui_next/src/screens/Host/shared/HostForm.jsx b/awx/ui_next/src/screens/Host/shared/HostForm.jsx index b9eb44d61b..4c2a1c38b4 100644 --- a/awx/ui_next/src/screens/Host/shared/HostForm.jsx +++ b/awx/ui_next/src/screens/Host/shared/HostForm.jsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { func, shape } from 'prop-types'; -import { withRouter } from 'react-router-dom'; +import { useRouteMatch } from 'react-router-dom'; import { Formik, Field } from 'formik'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; @@ -20,6 +20,8 @@ function HostForm({ handleSubmit, handleCancel, host, i18n }) { host ? host.summary_fields.inventory : '' ); + const hostAddMatch = useRouteMatch('/hosts/add'); + return ( - {!host.id && ( + {hostAddMatch && ( { - 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 deleted file mode 100644 index ac46ae34f4..0000000000 --- a/awx/ui_next/src/screens/Inventory/InventoryHostAdd/InventoryHostAdd.test.jsx +++ /dev/null @@ -1,100 +0,0 @@ -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 { sleep } from '@testUtils/testUtils'; -import { InventoriesAPI } from '@api'; -import InventoryHostAdd from './InventoryHostAdd'; - -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 }, - }); - - await act(async () => { - wrapper.find('FormField[id="host-name"] input').simulate('change', { - target: { value: 'new name', name: 'name' }, - }); - wrapper - .find('FormField[id="host-description"] input') - .simulate('change', { - target: { value: 'new description', name: 'description' }, - }); - wrapper.update(); - await sleep(0); - wrapper.find('FormActionGroup').invoke('onSubmit')(); - }); - wrapper.update(); - expect(InventoriesAPI.createHost).toHaveBeenCalledWith('1', { - name: 'new name', - description: 'new description', - variables: '---\n', - }); - }); - - test('handleSubmit should throw an error', async () => { - InventoriesAPI.createHost.mockImplementationOnce(() => - Promise.reject(new Error()) - ); - await act(async () => { - wrapper.find('FormField[id="host-name"] input').simulate('change', { - target: { value: 'new name', name: 'name' }, - }); - wrapper - .find('FormField[id="host-description"] input') - .simulate('change', { - target: { value: 'new description', name: 'description' }, - }); - }); - wrapper.update(); - 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/InventoryHostAdd/index.js b/awx/ui_next/src/screens/Inventory/InventoryHostAdd/index.js deleted file mode 100644 index 56bb7e05ad..0000000000 --- a/awx/ui_next/src/screens/Inventory/InventoryHostAdd/index.js +++ /dev/null @@ -1 +0,0 @@ -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 cbb0b4d6d3..3257024cef 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.jsx @@ -1,46 +1,30 @@ import React from 'react'; -import { Switch, Route, withRouter } from 'react-router-dom'; +import { Switch, Route } from 'react-router-dom'; import Host from '../../Host/Host'; import InventoryHostList from './InventoryHostList'; -import InventoryHostAdd from '../InventoryHostAdd'; +import HostAdd from '../../Host/HostAdd'; -function InventoryHosts({ match, setBreadcrumb, i18n, inventory }) { +function InventoryHosts({ setBreadcrumb, inventory }) { return ( - } - /> - , + + + ( - + )} /> - , ( - - )} + render={() => } /> - , ); } -export default withRouter(InventoryHosts); +export default InventoryHosts;