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 }) { + + +