diff --git a/awx/ui_next/src/api/models/Groups.js b/awx/ui_next/src/api/models/Groups.js
index 92acd3af40..fae9fcdf6f 100644
--- a/awx/ui_next/src/api/models/Groups.js
+++ b/awx/ui_next/src/api/models/Groups.js
@@ -5,10 +5,15 @@ class Groups extends Base {
super(http);
this.baseUrl = '/api/v2/groups/';
+ this.createHost = this.createHost.bind(this);
this.readAllHosts = this.readAllHosts.bind(this);
this.disassociateHost = this.disassociateHost.bind(this);
}
+ createHost(id, data) {
+ return this.http.post(`${this.baseUrl}${id}/hosts/`, data);
+ }
+
readAllHosts(id, params) {
return this.http.get(`${this.baseUrl}${id}/all_hosts/`, { params });
}
diff --git a/awx/ui_next/src/screens/Inventory/Inventories.jsx b/awx/ui_next/src/screens/Inventory/Inventories.jsx
index 87f8add4a3..9becfc14c9 100644
--- a/awx/ui_next/src/screens/Inventory/Inventories.jsx
+++ b/awx/ui_next/src/screens/Inventory/Inventories.jsx
@@ -27,7 +27,7 @@ class Inventories extends Component {
};
}
- setBreadCrumbConfig = (inventory, nestedResource) => {
+ setBreadCrumbConfig = (inventory, nested) => {
const { i18n } = this.props;
if (!inventory) {
return;
@@ -36,57 +36,42 @@ class Inventories extends Component {
const inventoryKind =
inventory.kind === 'smart' ? 'smart_inventory' : 'inventory';
+ const inventoryPath = `/inventories/${inventoryKind}/${inventory.id}`;
+ const inventoryHostsPath = `/inventories/${inventoryKind}/${inventory.id}/hosts`;
+ const inventoryGroupsPath = `/inventories/${inventoryKind}/${inventory.id}/groups`;
+
const breadcrumbConfig = {
'/inventories': i18n._(t`Inventories`),
'/inventories/inventory/add': i18n._(t`Create New Inventory`),
'/inventories/smart_inventory/add': i18n._(t`Create New Smart Inventory`),
- [`/inventories/${inventoryKind}/${inventory.id}`]: `${inventory.name}`,
- [`/inventories/${inventoryKind}/${inventory.id}/access`]: i18n._(
- t`Access`
- ),
- [`/inventories/${inventoryKind}/${inventory.id}/completed_jobs`]: i18n._(
+ [inventoryPath]: `${inventory.name}`,
+ [`${inventoryPath}/access`]: i18n._(t`Access`),
+ [`${inventoryPath}/completed_jobs`]: i18n._(t`Completed Jobs`),
+ [`${inventoryPath}/details`]: i18n._(t`Details`),
+ [`${inventoryPath}/edit`]: i18n._(t`Edit Details`),
+ [`${inventoryPath}/sources`]: i18n._(t`Sources`),
+
+ [inventoryHostsPath]: i18n._(t`Hosts`),
+ [`${inventoryHostsPath}/add`]: i18n._(t`Create New Host`),
+ [`${inventoryHostsPath}/${nested?.id}`]: `${nested?.name}`,
+ [`${inventoryHostsPath}/${nested?.id}/edit`]: i18n._(t`Edit Details`),
+ [`${inventoryHostsPath}/${nested?.id}/details`]: i18n._(t`Host Details`),
+ [`${inventoryHostsPath}/${nested?.id}/completed_jobs`]: i18n._(
t`Completed Jobs`
),
- [`/inventories/${inventoryKind}/${inventory.id}/details`]: i18n._(
- t`Details`
- ),
- [`/inventories/${inventoryKind}/${inventory.id}/edit`]: i18n._(
- t`Edit Details`
- ),
- [`/inventories/${inventoryKind}/${inventory.id}/groups`]: i18n._(
- t`Groups`
- ),
- [`/inventories/${inventoryKind}/${inventory.id}/hosts`]: i18n._(t`Hosts`),
- [`/inventories/${inventoryKind}/${inventory.id}/sources`]: i18n._(
- t`Sources`
+ [inventoryGroupsPath]: i18n._(t`Groups`),
+ [`${inventoryGroupsPath}/add`]: i18n._(t`Create New Group`),
+ [`${inventoryGroupsPath}/${nested?.id}`]: `${nested?.name}`,
+ [`${inventoryGroupsPath}/${nested?.id}/edit`]: i18n._(t`Edit Details`),
+ [`${inventoryGroupsPath}/${nested?.id}/details`]: i18n._(
+ t`Group Details`
),
-
- [`/inventories/${inventoryKind}/${inventory.id}/hosts/add`]: i18n._(
+ [`${inventoryGroupsPath}/${nested?.id}/nested_hosts`]: i18n._(t`Hosts`),
+ [`${inventoryGroupsPath}/${nested?.id}/nested_hosts/add`]: i18n._(
t`Create New Host`
),
- [`/inventories/${inventoryKind}/${inventory.id}/hosts/${nestedResource &&
- nestedResource.id}`]: `${nestedResource && nestedResource.name}`,
- [`/inventories/${inventoryKind}/${inventory.id}/hosts/${nestedResource &&
- nestedResource.id}/edit`]: i18n._(t`Edit Details`),
- [`/inventories/${inventoryKind}/${inventory.id}/hosts/${nestedResource &&
- nestedResource.id}/details`]: i18n._(t`Host Details`),
- [`/inventories/${inventoryKind}/${inventory.id}/hosts/${nestedResource &&
- nestedResource.id}/completed_jobs`]: i18n._(t`Completed Jobs`),
- [`/inventories/${inventoryKind}/${inventory.id}/groups/add`]: i18n._(
- t`Create New Group`
- ),
- [`/inventories/${inventoryKind}/${inventory.id}/groups/${nestedResource &&
- nestedResource.id}`]: `${nestedResource && nestedResource.name}`,
- [`/inventories/${inventoryKind}/${inventory.id}/groups/${nestedResource &&
- nestedResource.id}/edit`]: i18n._(t`Edit Details`),
- [`/inventories/${inventoryKind}/${inventory.id}/groups/${nestedResource &&
- nestedResource.id}/details`]: i18n._(t`Group Details`),
- [`/inventories/${inventoryKind}/${inventory.id}/groups/${nestedResource &&
- nestedResource.id}`]: `${nestedResource && nestedResource.name}`,
- [`/inventories/${inventoryKind}/${inventory.id}/groups/${nestedResource &&
- nestedResource.id}/nested_hosts`]: i18n._(t`Hosts`),
};
this.setState({ breadcrumbConfig });
};
diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.jsx
index b1d3734a5f..ab913d3592 100644
--- a/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.jsx
+++ b/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.jsx
@@ -59,70 +59,58 @@ function InventoryGroup({ i18n, setBreadcrumb, inventory }) {
},
{
name: i18n._(t`Details`),
- link: `/inventories/inventory/${inventory.id}/groups/${inventoryGroup &&
- inventoryGroup.id}/details`,
+ link: `/inventories/inventory/${inventory.id}/groups/${inventoryGroup?.id}/details`,
id: 0,
},
{
name: i18n._(t`Related Groups`),
- link: `/inventories/inventory/${inventory.id}/groups/${inventoryGroup &&
- inventoryGroup.id}/nested_groups`,
+ link: `/inventories/inventory/${inventory.id}/groups/${inventoryGroup?.id}/nested_groups`,
id: 1,
},
{
name: i18n._(t`Hosts`),
- link: `/inventories/inventory/${inventory.id}/groups/${inventoryGroup &&
- inventoryGroup.id}/nested_hosts`,
+ link: `/inventories/inventory/${inventory.id}/groups/${inventoryGroup?.id}/nested_hosts`,
id: 2,
},
];
- // In cases where a user manipulates the url such that they try to navigate to a
- // Inventory Group that is not associated with the Inventory Id in the Url this
- // Content Error is thrown. Inventory Groups have a 1:1 relationship to Inventories
- // thus their Ids must corrolate.
-
if (contentLoading) {
return ;
}
- if (
- inventoryGroup.summary_fields.inventory.id !== parseInt(inventoryId, 10)
- ) {
- return (
-
- {inventoryGroup && (
-
- {i18n._(t`View Inventory Groups`)}
-
- )}
-
- );
- }
-
if (contentError) {
return ;
}
- let cardHeader = null;
+ // In cases where a user manipulates the url such that they try to navigate to a
+ // Inventory Group that is not associated with the Inventory Id in the Url this
+ // Content Error is thrown. Inventory Groups have a 1:1 relationship to Inventories
+ // thus their Ids must corrolate.
+
if (
- location.pathname.includes('groups/') &&
- !location.pathname.endsWith('edit')
+ inventoryGroup?.summary_fields?.inventory?.id !== parseInt(inventoryId, 10)
) {
- cardHeader = (
-
-
-
-
-
-
+ return (
+
+
+ {i18n._(t`View Inventory Groups`)}
+
+
);
}
+
return (
<>
- {cardHeader}
+ {['add', 'edit'].some(name => location.pathname.includes(name)) ? null : (
+
+
+
+
+
+
+ )}
{
- return ;
- }}
- />,
+ >
+
+ ,
+
+
+ ,
]}
-
-
-
({
+ ...jest.requireActual('react-router-dom'),
+ useParams: () => ({
+ id: 1,
+ groupId: 2,
+ }),
+}));
+
GroupsAPI.readDetail.mockResolvedValue({
data: {
id: 1,
@@ -23,10 +31,12 @@ GroupsAPI.readDetail.mockResolvedValue({
modified: '2020-04-25T01:23:45.678901Z',
},
});
+
describe('', () => {
let wrapper;
let history;
const inventory = { id: 1, name: 'Foo' };
+
beforeEach(async () => {
history = createMemoryHistory({
initialEntries: ['/inventories/inventory/1/groups/1/details'],
@@ -39,29 +49,20 @@ describe('', () => {
{}} inventory={inventory} />
)}
/>,
- {
- context: {
- router: {
- history,
- route: {
- location: history.location,
- match: {
- params: { id: 1 },
- },
- },
- },
- },
- }
+ { context: { router: { history } } }
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
+
afterEach(() => {
wrapper.unmount();
});
+
test('renders successfully', async () => {
expect(wrapper.length).toBe(1);
});
+
test('expect all tabs to exist, including Back to Groups', async () => {
expect(
wrapper.find('button[link="/inventories/inventory/1/groups"]').length
@@ -70,4 +71,27 @@ describe('', () => {
expect(wrapper.find('button[aria-label="Related Groups"]').length).toBe(1);
expect(wrapper.find('button[aria-label="Hosts"]').length).toBe(1);
});
+
+ test('should show content error when user attempts to navigate to erroneous route', async () => {
+ history = createMemoryHistory({
+ initialEntries: ['/inventories/inventory/1/groups/1/foobar'],
+ });
+ await act(async () => {
+ wrapper = mountWithContexts(
+ {}} inventory={inventory} />,
+ { context: { router: { history } } }
+ );
+ });
+ await waitForElement(wrapper, 'ContentError', el => el.length === 1);
+ });
+
+ test('should show content error when api throws error on initial render', async () => {
+ GroupsAPI.readDetail.mockImplementationOnce(() =>
+ Promise.reject(new Error())
+ );
+ await act(async () => {
+ wrapper = mountWithContexts();
+ });
+ await waitForElement(wrapper, 'ContentError', el => el.length === 1);
+ });
});
diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHostAdd/InventoryGroupHostAdd.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupHostAdd/InventoryGroupHostAdd.jsx
new file mode 100644
index 0000000000..e8bfea7cd7
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHostAdd/InventoryGroupHostAdd.jsx
@@ -0,0 +1,46 @@
+import React, { useState } from 'react';
+import { useHistory } from 'react-router-dom';
+import { CardBody } from '@components/Card';
+import HostForm from '@components/HostForm';
+
+import { GroupsAPI } from '@api';
+
+function InventoryGroupHostAdd({ inventoryGroup }) {
+ const [formError, setFormError] = useState(null);
+ const baseUrl = `/inventories/inventory/${inventoryGroup.inventory}`;
+ const history = useHistory();
+
+ const handleSubmit = async formData => {
+ try {
+ const values = {
+ ...formData,
+ inventory: inventoryGroup.inventory,
+ };
+
+ const { data: response } = await GroupsAPI.createHost(
+ inventoryGroup.id,
+ values
+ );
+ history.push(`${baseUrl}/hosts/${response.id}/details`);
+ } catch (error) {
+ setFormError(error);
+ }
+ };
+
+ const handleCancel = () => {
+ history.push(`${baseUrl}/groups/${inventoryGroup.id}/nested_hosts`);
+ };
+
+ return (
+
+
+
+ );
+}
+
+export default InventoryGroupHostAdd;
diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHostAdd/InventoryGroupHostAdd.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupHostAdd/InventoryGroupHostAdd.test.jsx
new file mode 100644
index 0000000000..2b10e8c5a7
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHostAdd/InventoryGroupHostAdd.test.jsx
@@ -0,0 +1,75 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { createMemoryHistory } from 'history';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import InventoryGroupHostAdd from './InventoryGroupHostAdd';
+import mockHost from '../shared/data.host.json';
+import { GroupsAPI } from '@api';
+
+jest.mock('@api');
+
+GroupsAPI.createHost.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(GroupsAPI.createHost).toHaveBeenCalledWith(123, mockHost);
+ });
+
+ test('should navigate to inventory group host list when cancel is clicked', () => {
+ wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
+ expect(history.location.pathname).toEqual(
+ '/inventories/inventory/3/groups/123/nested_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' },
+ },
+ };
+ GroupsAPI.createHost.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/InventoryGroupHostAdd/index.js b/awx/ui_next/src/screens/Inventory/InventoryGroupHostAdd/index.js
new file mode 100644
index 0000000000..7d79317ac6
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHostAdd/index.js
@@ -0,0 +1 @@
+export { default } from './InventoryGroupHostAdd';
diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHosts.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHosts.jsx
index dc3da57781..d0e4c34d70 100644
--- a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHosts.jsx
+++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHosts.jsx
@@ -1,11 +1,14 @@
import React from 'react';
import { Switch, Route } from 'react-router-dom';
+import InventoryGroupHostAdd from '../InventoryGroupHostAdd';
import InventoryGroupHostList from './InventoryGroupHostList';
-function InventoryGroupHosts() {
+function InventoryGroupHosts({ inventoryGroup }) {
return (
- {/* Route to InventoryGroupHostAddForm */}
+
+
+