Merge pull request #6235 from marshmalien/6142-inv-group-add-host-form

Add Inventory Group Host Add form

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot]
2020-03-10 17:48:27 +00:00
committed by GitHub
8 changed files with 230 additions and 104 deletions

View File

@@ -5,10 +5,15 @@ class Groups extends Base {
super(http); super(http);
this.baseUrl = '/api/v2/groups/'; this.baseUrl = '/api/v2/groups/';
this.createHost = this.createHost.bind(this);
this.readAllHosts = this.readAllHosts.bind(this); this.readAllHosts = this.readAllHosts.bind(this);
this.disassociateHost = this.disassociateHost.bind(this); this.disassociateHost = this.disassociateHost.bind(this);
} }
createHost(id, data) {
return this.http.post(`${this.baseUrl}${id}/hosts/`, data);
}
readAllHosts(id, params) { readAllHosts(id, params) {
return this.http.get(`${this.baseUrl}${id}/all_hosts/`, { params }); return this.http.get(`${this.baseUrl}${id}/all_hosts/`, { params });
} }

View File

@@ -27,7 +27,7 @@ class Inventories extends Component {
}; };
} }
setBreadCrumbConfig = (inventory, nestedResource) => { setBreadCrumbConfig = (inventory, nested) => {
const { i18n } = this.props; const { i18n } = this.props;
if (!inventory) { if (!inventory) {
return; return;
@@ -36,57 +36,42 @@ class Inventories extends Component {
const inventoryKind = const inventoryKind =
inventory.kind === 'smart' ? 'smart_inventory' : 'inventory'; 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 = { const breadcrumbConfig = {
'/inventories': i18n._(t`Inventories`), '/inventories': i18n._(t`Inventories`),
'/inventories/inventory/add': i18n._(t`Create New Inventory`), '/inventories/inventory/add': i18n._(t`Create New Inventory`),
'/inventories/smart_inventory/add': i18n._(t`Create New Smart Inventory`), '/inventories/smart_inventory/add': i18n._(t`Create New Smart Inventory`),
[`/inventories/${inventoryKind}/${inventory.id}`]: `${inventory.name}`,
[`/inventories/${inventoryKind}/${inventory.id}/access`]: i18n._( [inventoryPath]: `${inventory.name}`,
t`Access` [`${inventoryPath}/access`]: i18n._(t`Access`),
), [`${inventoryPath}/completed_jobs`]: i18n._(t`Completed Jobs`),
[`/inventories/${inventoryKind}/${inventory.id}/completed_jobs`]: i18n._( [`${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` 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._( [inventoryGroupsPath]: i18n._(t`Groups`),
t`Sources` [`${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`
), ),
[`${inventoryGroupsPath}/${nested?.id}/nested_hosts`]: i18n._(t`Hosts`),
[`/inventories/${inventoryKind}/${inventory.id}/hosts/add`]: i18n._( [`${inventoryGroupsPath}/${nested?.id}/nested_hosts/add`]: i18n._(
t`Create New Host` 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 }); this.setState({ breadcrumbConfig });
}; };

View File

@@ -59,70 +59,58 @@ function InventoryGroup({ i18n, setBreadcrumb, inventory }) {
}, },
{ {
name: i18n._(t`Details`), name: i18n._(t`Details`),
link: `/inventories/inventory/${inventory.id}/groups/${inventoryGroup && link: `/inventories/inventory/${inventory.id}/groups/${inventoryGroup?.id}/details`,
inventoryGroup.id}/details`,
id: 0, id: 0,
}, },
{ {
name: i18n._(t`Related Groups`), name: i18n._(t`Related Groups`),
link: `/inventories/inventory/${inventory.id}/groups/${inventoryGroup && link: `/inventories/inventory/${inventory.id}/groups/${inventoryGroup?.id}/nested_groups`,
inventoryGroup.id}/nested_groups`,
id: 1, id: 1,
}, },
{ {
name: i18n._(t`Hosts`), name: i18n._(t`Hosts`),
link: `/inventories/inventory/${inventory.id}/groups/${inventoryGroup && link: `/inventories/inventory/${inventory.id}/groups/${inventoryGroup?.id}/nested_hosts`,
inventoryGroup.id}/nested_hosts`,
id: 2, 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) { if (contentLoading) {
return <ContentLoading />; return <ContentLoading />;
} }
if (
inventoryGroup.summary_fields.inventory.id !== parseInt(inventoryId, 10)
) {
return (
<ContentError>
{inventoryGroup && (
<Link to={`/inventories/inventory/${inventory.id}/groups`}>
{i18n._(t`View Inventory Groups`)}
</Link>
)}
</ContentError>
);
}
if (contentError) { if (contentError) {
return <ContentError error={contentError} />; return <ContentError error={contentError} />;
} }
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 ( if (
location.pathname.includes('groups/') && inventoryGroup?.summary_fields?.inventory?.id !== parseInt(inventoryId, 10)
!location.pathname.endsWith('edit')
) { ) {
cardHeader = ( return (
<TabbedCardHeader> <ContentError isNotFound>
<RoutedTabs tabsArray={tabsArray} /> <Link to={`/inventories/inventory/${inventory.id}/groups`}>
<CardActions> {i18n._(t`View Inventory Groups`)}
<CardCloseButton </Link>
linkTo={`/inventories/inventory/${inventory.id}/groups`} </ContentError>
/>
</CardActions>
</TabbedCardHeader>
); );
} }
return ( return (
<> <>
{cardHeader} {['add', 'edit'].some(name => location.pathname.includes(name)) ? null : (
<TabbedCardHeader>
<RoutedTabs tabsArray={tabsArray} />
<CardActions>
<CardCloseButton
linkTo={`/inventories/inventory/${inventory.id}/groups`}
/>
</CardActions>
</TabbedCardHeader>
)}
<Switch> <Switch>
<Redirect <Redirect
from="/inventories/inventory/:id/groups/:groupId" from="/inventories/inventory/:id/groups/:groupId"
@@ -139,17 +127,16 @@ function InventoryGroup({ i18n, setBreadcrumb, inventory }) {
<Route <Route
key="details" key="details"
path="/inventories/inventory/:id/groups/:groupId/details" path="/inventories/inventory/:id/groups/:groupId/details"
render={() => { >
return <InventoryGroupDetail inventoryGroup={inventoryGroup} />; <InventoryGroupDetail inventoryGroup={inventoryGroup} />
}} </Route>,
/>, <Route
key="hosts"
path="/inventories/inventory/:id/groups/:groupId/nested_hosts"
>
<InventoryGroupHosts inventoryGroup={inventoryGroup} />
</Route>,
]} ]}
<Route
key="hosts"
path="/inventories/inventory/:id/groups/:groupId/nested_hosts"
>
<InventoryGroupHosts />
</Route>
<Route <Route
key="not-found" key="not-found"
path="*" path="*"

View File

@@ -8,6 +8,14 @@ import { createMemoryHistory } from 'history';
import InventoryGroup from './InventoryGroup'; import InventoryGroup from './InventoryGroup';
jest.mock('@api'); jest.mock('@api');
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: () => ({
id: 1,
groupId: 2,
}),
}));
GroupsAPI.readDetail.mockResolvedValue({ GroupsAPI.readDetail.mockResolvedValue({
data: { data: {
id: 1, id: 1,
@@ -23,10 +31,12 @@ GroupsAPI.readDetail.mockResolvedValue({
modified: '2020-04-25T01:23:45.678901Z', modified: '2020-04-25T01:23:45.678901Z',
}, },
}); });
describe('<InventoryGroup />', () => { describe('<InventoryGroup />', () => {
let wrapper; let wrapper;
let history; let history;
const inventory = { id: 1, name: 'Foo' }; const inventory = { id: 1, name: 'Foo' };
beforeEach(async () => { beforeEach(async () => {
history = createMemoryHistory({ history = createMemoryHistory({
initialEntries: ['/inventories/inventory/1/groups/1/details'], initialEntries: ['/inventories/inventory/1/groups/1/details'],
@@ -39,29 +49,20 @@ describe('<InventoryGroup />', () => {
<InventoryGroup setBreadcrumb={() => {}} inventory={inventory} /> <InventoryGroup setBreadcrumb={() => {}} inventory={inventory} />
)} )}
/>, />,
{ { context: { router: { history } } }
context: {
router: {
history,
route: {
location: history.location,
match: {
params: { id: 1 },
},
},
},
},
}
); );
}); });
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
}); });
afterEach(() => { afterEach(() => {
wrapper.unmount(); wrapper.unmount();
}); });
test('renders successfully', async () => { test('renders successfully', async () => {
expect(wrapper.length).toBe(1); expect(wrapper.length).toBe(1);
}); });
test('expect all tabs to exist, including Back to Groups', async () => { test('expect all tabs to exist, including Back to Groups', async () => {
expect( expect(
wrapper.find('button[link="/inventories/inventory/1/groups"]').length wrapper.find('button[link="/inventories/inventory/1/groups"]').length
@@ -70,4 +71,27 @@ describe('<InventoryGroup />', () => {
expect(wrapper.find('button[aria-label="Related Groups"]').length).toBe(1); expect(wrapper.find('button[aria-label="Related Groups"]').length).toBe(1);
expect(wrapper.find('button[aria-label="Hosts"]').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(
<InventoryGroup setBreadcrumb={() => {}} 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(<InventoryGroup inventory={inventory} />);
});
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
});
}); });

View File

@@ -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 (
<CardBody>
<HostForm
handleSubmit={handleSubmit}
handleCancel={handleCancel}
isInventoryVisible={false}
submitError={formError}
/>
</CardBody>
);
}
export default InventoryGroupHostAdd;

View File

@@ -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('<InventoryGroupHostAdd />', () => {
let wrapper;
let history;
beforeAll(async () => {
history = createMemoryHistory();
await act(async () => {
wrapper = mountWithContexts(
<InventoryGroupHostAdd inventoryGroup={{ id: 123, inventory: 3 }} />,
{
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);
});
});

View File

@@ -0,0 +1 @@
export { default } from './InventoryGroupHostAdd';

View File

@@ -1,11 +1,14 @@
import React from 'react'; import React from 'react';
import { Switch, Route } from 'react-router-dom'; import { Switch, Route } from 'react-router-dom';
import InventoryGroupHostAdd from '../InventoryGroupHostAdd';
import InventoryGroupHostList from './InventoryGroupHostList'; import InventoryGroupHostList from './InventoryGroupHostList';
function InventoryGroupHosts() { function InventoryGroupHosts({ inventoryGroup }) {
return ( return (
<Switch> <Switch>
{/* Route to InventoryGroupHostAddForm */} <Route path="/inventories/inventory/:id/groups/:groupId/nested_hosts/add">
<InventoryGroupHostAdd inventoryGroup={inventoryGroup} />
</Route>
<Route path="/inventories/inventory/:id/groups/:groupId/nested_hosts"> <Route path="/inventories/inventory/:id/groups/:groupId/nested_hosts">
<InventoryGroupHostList /> <InventoryGroupHostList />
</Route> </Route>