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 */} + + +