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
commit bbe5789e70
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 230 additions and 104 deletions

View File

@ -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 });
}

View File

@ -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 });
};

View File

@ -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 <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) {
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 (
location.pathname.includes('groups/') &&
!location.pathname.endsWith('edit')
inventoryGroup?.summary_fields?.inventory?.id !== parseInt(inventoryId, 10)
) {
cardHeader = (
<TabbedCardHeader>
<RoutedTabs tabsArray={tabsArray} />
<CardActions>
<CardCloseButton
linkTo={`/inventories/inventory/${inventory.id}/groups`}
/>
</CardActions>
</TabbedCardHeader>
return (
<ContentError isNotFound>
<Link to={`/inventories/inventory/${inventory.id}/groups`}>
{i18n._(t`View Inventory Groups`)}
</Link>
</ContentError>
);
}
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>
<Redirect
from="/inventories/inventory/:id/groups/:groupId"
@ -139,17 +127,16 @@ function InventoryGroup({ i18n, setBreadcrumb, inventory }) {
<Route
key="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
key="not-found"
path="*"

View File

@ -8,6 +8,14 @@ import { createMemoryHistory } from 'history';
import InventoryGroup from './InventoryGroup';
jest.mock('@api');
jest.mock('react-router-dom', () => ({
...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('<InventoryGroup />', () => {
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('<InventoryGroup />', () => {
<InventoryGroup setBreadcrumb={() => {}} 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('<InventoryGroup />', () => {
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(
<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 { Switch, Route } from 'react-router-dom';
import InventoryGroupHostAdd from '../InventoryGroupHostAdd';
import InventoryGroupHostList from './InventoryGroupHostList';
function InventoryGroupHosts() {
function InventoryGroupHosts({ inventoryGroup }) {
return (
<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">
<InventoryGroupHostList />
</Route>