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