Create nested inventory host route files and components

This commit is contained in:
Marliana Lara 2020-03-06 01:35:58 -05:00
parent da94b2dc9e
commit bb6d9af90b
No known key found for this signature in database
GPG Key ID: 38C73B40DFA809EE
10 changed files with 598 additions and 0 deletions

View File

@ -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: (
<>
<CaretLeftIcon />
{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 = (
<TabbedCardHeader>
<RoutedTabs tabsArray={tabsArray} />
<CardActions>
<CardCloseButton linkTo={hostListUrl} />
</CardActions>
</TabbedCardHeader>
);
if (location.pathname.endsWith('edit')) {
cardHeader = null;
}
if (isLoading) {
return <ContentLoading />;
}
if (!isLoading && contentError) {
return (
<Card>
<ContentError error={contentError}>
{contentError.response && contentError.response.status === 404 && (
<span>
{i18n._(`Host not found.`)}{' '}
<Link to={hostListUrl}>
{i18n._(`View all Inventory Hosts.`)}
</Link>
</span>
)}
</ContentError>
</Card>
);
}
return (
<>
{cardHeader}
<Switch>
<Redirect
from="/inventories/inventory/:id/hosts/:hostId"
to="/inventories/inventory/:id/hosts/:hostId/details"
exact
/>
{host &&
inventory && [
<Route
key="details"
path="/inventories/inventory/:id/hosts/:hostId/details"
>
<InventoryHostDetail host={host} />
</Route>,
<Route
key="edit"
path="/inventories/inventory/:id/hosts/:hostId/edit"
>
<InventoryHostEdit host={host} inventory={inventory} />
</Route>,
<Route
key="completed-jobs"
path="/inventories/inventory/:id/hosts/:hostId/completed_jobs"
>
<JobList defaultParams={{ job__hosts: host.id }} />
</Route>,
]}
<Route
key="not-found"
path="*"
render={() =>
!isLoading && (
<ContentError isNotFound>
<Link to={`${match.url}/details`}>
{i18n._(`View Inventory Host Details`)}
</Link>
</ContentError>
)
}
/>
</Switch>
</>
);
}
export default withI18n()(InventoryHost);

View File

@ -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('<InventoryHost />', () => {
let wrapper;
let history;
beforeEach(async () => {
await act(async () => {
wrapper = mountWithContexts(
<InventoryHost inventory={mockInventory} setBreadcrumb={() => {}} />
);
});
});
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(
<InventoryHost inventory={mockInventory} setBreadcrumb={() => {}} />
);
});
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(
<InventoryHost inventory={mockInventory} setBreadcrumb={() => {}} />,
{ context: { router: { history } } }
);
});
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
});
});

View File

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

View File

@ -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 (
<AlertModal
isOpen={deletionError}
variant="error"
title={i18n._(t`Error!`)}
onClose={() => setDeletionError(false)}
>
{i18n._(t`Failed to delete ${name}.`)}
<ErrorDetail error={deletionError} />
</AlertModal>
);
}
const recentPlaybookJobs = recent_jobs.map(job => ({ ...job, type: 'job' }));
return (
<CardBody>
<HostToggle host={host} css="padding-bottom: 40px" />
<DetailList gutter="sm">
<Detail label={i18n._(t`Name`)} value={name} />
<Detail
label={i18n._(t`Activity`)}
value={<Sparkline jobs={recentPlaybookJobs} />}
/>
<Detail label={i18n._(t`Description`)} value={description} />
<UserDateDetail
date={created}
label={i18n._(t`Created`)}
user={created_by}
/>
<UserDateDetail
date={modified}
label={i18n._(t`Last Modified`)}
user={modified_by}
/>
<VariablesDetail
label={i18n._(t`Variables`)}
rows={4}
value={variables}
/>
</DetailList>
<CardActionsRow>
{user_capabilities?.edit && (
<Button
aria-label={i18n._(t`edit`)}
component={Link}
to={`/inventories/inventory/${inventory.id}/hosts/${id}/edit`}
>
{i18n._(t`Edit`)}
</Button>
)}
{user_capabilities?.delete && (
<DeleteButton
name={name}
modalTitle={i18n._(t`Delete Host`)}
onConfirm={() => handleHostDelete()}
/>
)}
</CardActionsRow>
{deletionError && (
<AlertModal
isOpen={deletionError}
variant="error"
title={i18n._(t`Error!`)}
onClose={() => setDeletionError(null)}
>
{i18n._(t`Failed to delete host.`)}
<ErrorDetail error={deletionError} />
</AlertModal>
)}
</CardBody>
);
}
InventoryHostDetail.propTypes = {
host: Host.isRequired,
};
export default withI18n()(InventoryHostDetail);

View File

@ -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('<InventoryHostDetail />', () => {
let wrapper;
describe('User has edit permissions', () => {
beforeAll(() => {
wrapper = mountWithContexts(<InventoryHostDetail host={mockHost} />);
});
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(<InventoryHostDetail host={mockHost} />);
});
afterAll(() => {
wrapper.unmount();
});
test('should hide edit button for users without edit permission', async () => {
expect(wrapper.find('Button[aria-label="edit"]').length).toBe(0);
});
});
});

View File

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

View File

@ -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 (
<CardBody>
<HostForm
host={host}
handleSubmit={handleSubmit}
handleCancel={handleCancel}
isInventoryVisible={false}
submitError={formError}
/>
</CardBody>
);
}
InventoryHostEdit.propTypes = {
host: PropTypes.shape().isRequired,
};
export default InventoryHostEdit;

View File

@ -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('<InventoryHostEdit />', () => {
let wrapper;
let history;
const updatedHostData = {
name: 'new name',
description: 'new description',
variables: '---\nfoo: bar',
};
beforeAll(async () => {
history = createMemoryHistory();
await act(async () => {
wrapper = mountWithContexts(
<InventoryHostEdit host={mockHost} inventory={{ id: 123 }} />,
{
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);
});
});

View File

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

View File

@ -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 }) {
<Route key="host-add" path="/inventories/inventory/:id/hosts/add">
<InventoryHostAdd inventory={inventory} />
</Route>
<Route key="host" path="/inventories/inventory/:id/hosts/:hostId">
<InventoryHost setBreadcrumb={setBreadcrumb} inventory={inventory} />
</Route>
<Route key="host-list" path="/inventories/inventory/:id/hosts">
<InventoryHostList />
</Route>