Remove all inventory route logic from Host screens

This commit is contained in:
Marliana Lara
2020-03-06 01:36:38 -05:00
parent bb6d9af90b
commit 3d5a002676
7 changed files with 215 additions and 290 deletions

View File

@@ -10,7 +10,6 @@ import {
useLocation,
} from 'react-router-dom';
import { Card, CardActions } from '@patternfly/react-core';
import { CaretLeftIcon } from '@patternfly/react-icons';
import { TabbedCardHeader } from '@components/Card';
import CardCloseButton from '@components/CardCloseButton';
@@ -24,20 +23,13 @@ import HostEdit from './HostEdit';
import HostGroups from './HostGroups';
import { HostsAPI } from '@api';
function Host({ inventory, i18n, setBreadcrumb }) {
function Host({ i18n, setBreadcrumb }) {
const [host, setHost] = useState(null);
const [contentError, setContentError] = useState(null);
const [hasContentLoading, setHasContentLoading] = useState(true);
const location = useLocation();
const hostsMatch = useRouteMatch('/hosts/:id');
const inventoriesMatch = useRouteMatch(
'/inventories/inventory/:id/hosts/:hostId'
);
const baseUrl = hostsMatch ? hostsMatch.url : inventoriesMatch.url;
const hostListUrl = hostsMatch
? '/hosts'
: `/inventories/inventory/${inventoriesMatch.params.id}/hosts`;
const match = useRouteMatch('/hosts/:id');
useEffect(() => {
(async () => {
@@ -45,17 +37,10 @@ function Host({ inventory, i18n, setBreadcrumb }) {
setHasContentLoading(true);
try {
const hostId = hostsMatch
? hostsMatch.params.id
: inventoriesMatch.params.hostId;
const { data } = await HostsAPI.readDetail(hostId);
setHost(data);
const { data } = await HostsAPI.readDetail(match.params.id);
if (hostsMatch) {
setBreadcrumb(data);
} else if (inventoriesMatch) {
setBreadcrumb(inventory, data);
}
setHost(data);
setBreadcrumb(data);
} catch (error) {
setContentError(error);
} finally {
@@ -67,44 +52,31 @@ function Host({ inventory, i18n, setBreadcrumb }) {
const tabsArray = [
{
name: i18n._(t`Details`),
link: `${baseUrl}/details`,
link: `${match.url}/details`,
id: 0,
},
{
name: i18n._(t`Facts`),
link: `${baseUrl}/facts`,
link: `${match.url}/facts`,
id: 1,
},
{
name: i18n._(t`Groups`),
link: `${baseUrl}/groups`,
link: `${match.url}/groups`,
id: 2,
},
{
name: i18n._(t`Completed Jobs`),
link: `${baseUrl}/completed_jobs`,
link: `${match.url}/completed_jobs`,
id: 3,
},
];
if (inventoriesMatch) {
tabsArray.unshift({
name: (
<>
<CaretLeftIcon />
{i18n._(t`Back to Hosts`)}
</>
),
link: hostListUrl,
id: 99,
});
}
let cardHeader = (
<TabbedCardHeader>
<RoutedTabs tabsArray={tabsArray} />
<CardActions>
<CardCloseButton linkTo={hostListUrl} />
<CardCloseButton linkTo="/hosts" />
</CardActions>
</TabbedCardHeader>
);
@@ -124,7 +96,7 @@ function Host({ inventory, i18n, setBreadcrumb }) {
{contentError.response && contentError.response.status === 404 && (
<span>
{i18n._(`Host not found.`)}{' '}
<Link to={hostListUrl}>{i18n._(`View all Hosts.`)}</Link>
<Link to="/hosts">{i18n._(`View all Hosts.`)}</Link>
</span>
)}
</ContentError>
@@ -132,72 +104,35 @@ function Host({ inventory, i18n, setBreadcrumb }) {
);
}
const redirect = hostsMatch ? (
<Redirect from="/hosts/:id" to="/hosts/:id/details" exact />
) : (
<Redirect
from="/inventories/inventory/:id/hosts/:hostId"
to="/inventories/inventory/:id/hosts/:hostId/details"
exact
/>
);
return (
<Card>
{cardHeader}
<Switch>
{redirect}
{host && (
<Route
path={[
'/hosts/:id/details',
'/inventories/inventory/:id/hosts/:hostId/details',
]}
>
<HostDetail
host={host}
onUpdateHost={newHost => setHost(newHost)}
/>
</Route>
)}
{host && (
<Route
path={[
'/hosts/:id/edit',
'/inventories/inventory/:id/hosts/:hostId/edit',
]}
render={() => <HostEdit host={host} />}
/>
)}
{host && (
<Route
path="/hosts/:id/facts"
render={() => <HostFacts host={host} />}
/>
)}
{host && (
<Route
path="/hosts/:id/groups"
render={() => <HostGroups host={host} />}
/>
)}
{host?.id && (
<Route
path={[
'/hosts/:id/completed_jobs',
'/inventories/inventory/:id/hosts/:hostId/completed_jobs',
]}
>
<Redirect from="/hosts/:id" to="/hosts/:id/details" exact />
{host && [
<Route path="/hosts/:id/details" key="details">
<HostDetail host={host} />
</Route>,
<Route path="/hosts/:id/edit" key="edit">
<HostEdit host={host} />
</Route>,
<Route path="/hosts/:id/facts" key="facts">
<HostFacts host={host} />
</Route>,
<Route path="/hosts/:id/groups" key="groups">
<HostGroups host={host} />
</Route>,
<Route path="/hosts/:id/completed_jobs" key="completed-jobs">
<JobList defaultParams={{ job__hosts: host.id }} />
</Route>
)}
</Route>,
]}
<Route
key="not-found"
path="*"
render={() =>
!hasContentLoading && (
<ContentError isNotFound>
<Link to={`${baseUrl}/details`}>
<Link to={`${match.url}/details`}>
{i18n._(`View Host Details`)}
</Link>
</ContentError>

View File

@@ -3,53 +3,41 @@ import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { HostsAPI } from '@api';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import mockDetails from './data.host.json';
import mockHost from './data.host.json';
import Host from './Host';
jest.mock('@api');
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useRouteMatch: () => ({
url: '/hosts/1',
params: { id: 1 },
}),
}));
HostsAPI.readDetail.mockResolvedValue({
data: { ...mockHost },
});
describe('<Host />', () => {
let wrapper;
let history;
HostsAPI.readDetail.mockResolvedValue({
data: { ...mockDetails },
beforeEach(async () => {
await act(async () => {
wrapper = mountWithContexts(<Host setBreadcrumb={() => {}} />);
});
});
afterEach(() => {
wrapper.unmount();
});
test('initially renders succesfully', async () => {
history = createMemoryHistory({
initialEntries: ['/hosts/1/edit'],
test('should render expected tabs', async () => {
const expectedTabs = ['Details', 'Facts', 'Groups', 'Completed Jobs'];
wrapper.find('RoutedTabs li').forEach((tab, index) => {
expect(tab.text()).toEqual(expectedTabs[index]);
});
await act(async () => {
wrapper = mountWithContexts(<Host setBreadcrumb={() => {}} />, {
context: { router: { history } },
});
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('Host').length).toBe(1);
});
test('should render "Back to Hosts" tab when navigating from inventories', async () => {
history = createMemoryHistory({
initialEntries: ['/inventories/inventory/1/hosts/1'],
});
await act(async () => {
wrapper = mountWithContexts(<Host setBreadcrumb={() => {}} />, {
context: { router: { history } },
});
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(
wrapper
.find('RoutedTabs li')
.first()
.text()
).toBe('Back to Hosts');
});
test('should show content error when api throws error on initial render', async () => {

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { Link, useHistory, useParams, useLocation } from 'react-router-dom';
import { Link, useHistory } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Host } from '@types';
@@ -14,42 +14,36 @@ import DeleteButton from '@components/DeleteButton';
import { HostsAPI } from '@api';
import HostToggle from '@components/HostToggle';
function HostDetail({ host, i18n, onUpdateHost }) {
function HostDetail({ i18n, host }) {
const {
created,
description,
id,
modified,
name,
variables,
summary_fields: {
inventory,
recent_jobs,
kind,
created_by,
modified_by,
user_capabilities,
},
} = host;
const history = useHistory();
const { pathname } = useLocation();
const { id: inventoryId, hostId: inventoryHostId } = useParams();
const [isLoading, setIsloading] = useState(false);
const [deletionError, setDeletionError] = useState(false);
const recentPlaybookJobs = recent_jobs.map(job => ({ ...job, type: 'job' }));
const history = useHistory();
const handleHostDelete = async () => {
setIsloading(true);
try {
await HostsAPI.destroy(id);
setIsloading(false);
const url = pathname.startsWith('/inventories')
? `/inventories/inventory/${inventoryId}/hosts/`
: `/hosts`;
history.push(url);
history.push('/hosts');
} catch (err) {
setDeletionError(err);
} finally {
setIsloading(false);
}
};
@@ -66,77 +60,71 @@ function HostDetail({ host, i18n, onUpdateHost }) {
</AlertModal>
);
}
const recentPlaybookJobs = recent_jobs.map(job => ({ ...job, type: 'job' }));
return (
<CardBody>
<HostToggle
host={host}
onToggle={enabled =>
onUpdateHost({
...host,
enabled,
})
}
css="padding-bottom: 40px"
/>
<HostToggle host={host} css="padding-bottom: 40px" />
<DetailList gutter="sm">
<Detail label={i18n._(t`Name`)} value={name} />
<Detail
value={<Sparkline jobs={recentPlaybookJobs} />}
label={i18n._(t`Activity`)}
value={<Sparkline jobs={recentPlaybookJobs} />}
/>
<Detail label={i18n._(t`Description`)} value={description} />
{inventory && (
<Detail
label={i18n._(t`Inventory`)}
value={
<Link
to={`/inventories/${
kind === 'smart' ? 'smart_inventory' : 'inventory'
}/${inventoryId}/details`}
>
{inventory.name}
</Link>
}
/>
)}
<Detail
label={i18n._(t`Inventory`)}
value={
<Link to={`/inventories/inventory/${inventory.id}/details`}>
{inventory.name}
</Link>
}
/>
<UserDateDetail
date={created}
label={i18n._(t`Created`)}
user={created_by}
/>
<UserDateDetail
date={modified}
label={i18n._(t`Last Modified`)}
user={modified_by}
date={modified}
/>
<VariablesDetail
value={host.variables}
rows={4}
label={i18n._(t`Variables`)}
rows={4}
value={variables}
/>
</DetailList>
<CardActionsRow>
{user_capabilities && user_capabilities.edit && (
{user_capabilities?.edit && (
<Button
aria-label={i18n._(t`edit`)}
component={Link}
to={
pathname.startsWith('/inventories')
? `/inventories/inventory/${inventoryId}/hosts/${inventoryHostId}/edit`
: `/hosts/${id}/edit`
}
to={`/hosts/${id}/edit`}
>
{i18n._(t`Edit`)}
</Button>
)}
{user_capabilities && user_capabilities.delete && (
{user_capabilities?.delete && (
<DeleteButton
onConfirm={() => handleHostDelete()}
modalTitle={i18n._(t`Delete Host`)}
name={host.name}
name={name}
/>
)}
</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>
);
}

View File

@@ -1,66 +1,88 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import HostDetail from './HostDetail';
import { HostsAPI } from '@api';
import mockHost from '../data.host.json';
jest.mock('@api');
describe('<HostDetail />', () => {
const mockHost = {
id: 1,
name: 'Foo',
description: 'Bar',
inventory: 1,
created: '2015-07-07T17:21:26.429745Z',
modified: '2019-08-11T19:47:37.980466Z',
variables: '---',
summary_fields: {
inventory: {
id: 1,
name: 'test inventory',
},
user_capabilities: {
edit: true,
},
recent_jobs: [],
},
};
let wrapper;
test('initially renders succesfully', () => {
mountWithContexts(<HostDetail host={mockHost} />);
describe('User has edit permissions', () => {
beforeAll(() => {
wrapper = mountWithContexts(<HostDetail 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', 'a good description');
assertDetail('Inventory', 'Mikes Inventory');
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('/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
);
});
});
test('should render Details', async () => {
const wrapper = mountWithContexts(<HostDetail host={mockHost} />);
const testParams = [
{ label: 'Name', value: 'Foo' },
{ label: 'Description', value: 'Bar' },
{ label: 'Inventory', value: 'test inventory' },
{ label: 'Created', value: '7/7/2015, 5:21:26 PM' },
{ label: 'Last Modified', value: '8/11/2019, 7:47:37 PM' },
];
// eslint-disable-next-line no-restricted-syntax
for (const { label, value } of testParams) {
// eslint-disable-next-line no-await-in-loop
const detail = await waitForElement(wrapper, `Detail[label="${label}"]`);
expect(detail.find('dt').text()).toBe(label);
expect(detail.find('dd').text()).toBe(value);
}
});
describe('User has read-only permissions', () => {
beforeAll(() => {
const readOnlyHost = { ...mockHost };
readOnlyHost.summary_fields.user_capabilities.edit = false;
test('should show edit button for users with edit permission', async () => {
const wrapper = mountWithContexts(<HostDetail host={mockHost} />);
const editButton = wrapper.find('Button[aria-label="edit"]');
expect(editButton.text()).toEqual('Edit');
expect(editButton.prop('to')).toBe('/hosts/1/edit');
});
wrapper = mountWithContexts(<HostDetail host={mockHost} />);
});
test('should hide edit button for users without edit permission', async () => {
const readOnlyHost = { ...mockHost };
readOnlyHost.summary_fields.user_capabilities.edit = false;
const wrapper = mountWithContexts(<HostDetail host={readOnlyHost} />);
await waitForElement(wrapper, 'HostDetail');
expect(wrapper.find('Button[aria-label="edit"]').length).toBe(0);
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

@@ -1,31 +1,14 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { useHistory, useRouteMatch } from 'react-router-dom';
import { useHistory } from 'react-router-dom';
import { CardBody } from '@components/Card';
import HostForm from '@components/HostForm';
import { HostsAPI } from '@api';
import HostForm from '../shared';
function HostEdit({ host }) {
const [formError, setFormError] = useState(null);
const hostsMatch = useRouteMatch('/hosts/:id/edit');
const inventoriesMatch = useRouteMatch(
'/inventories/inventory/:id/hosts/:hostId/edit'
);
const detailsUrl = `/hosts/${host.id}/details`;
const history = useHistory();
let detailsUrl;
if (hostsMatch) {
detailsUrl = `/hosts/${hostsMatch.params.id}/details`;
}
if (inventoriesMatch) {
const kind =
host.summary_fields.inventory.kind === 'smart'
? 'smart_inventory'
: 'inventory';
detailsUrl = `/inventories/${kind}/${inventoriesMatch.params.id}/hosts/${inventoriesMatch.params.hostId}/details`;
}
const handleSubmit = async values => {
try {

View File

@@ -1,49 +1,70 @@
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 mockHost from '../data.host.json';
import HostEdit from './HostEdit';
jest.mock('@api');
describe('<HostEdit />', () => {
const mockData = {
id: 1,
name: 'Foo',
description: 'Bar',
inventory: 1,
variables: '---',
summary_fields: {
inventory: {
id: 1,
name: 'test inventory',
},
},
let wrapper;
let history;
const updatedHostData = {
name: 'new name',
description: 'new description',
variables: '---\nfoo: bar',
};
test('handleSubmit should call api update', () => {
const wrapper = mountWithContexts(<HostEdit host={mockData} />);
const updatedHostData = {
name: 'new name',
description: 'new description',
variables: '---\nfoo: bar',
};
wrapper.find('HostForm').prop('handleSubmit')(updatedHostData);
expect(HostsAPI.update).toHaveBeenCalledWith(1, updatedHostData);
beforeAll(async () => {
history = createMemoryHistory();
await act(async () => {
wrapper = mountWithContexts(<HostEdit host={mockHost} />, {
context: { router: { history } },
});
});
});
test('should navigate to host detail when cancel is clicked', () => {
const history = createMemoryHistory({
initialEntries: ['/hosts/1/edit'],
});
const wrapper = mountWithContexts(<HostEdit host={mockData} />, {
context: { router: { history } },
});
afterAll(() => {
jest.clearAllMocks();
wrapper.unmount();
});
wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
test('handleSubmit should call api update', async () => {
await act(async () => {
wrapper.find('HostForm').prop('handleSubmit')(updatedHostData);
});
expect(HostsAPI.update).toHaveBeenCalledWith(2, updatedHostData);
});
expect(history.location.pathname).toEqual('/hosts/1/details');
test('should navigate to host detail when cancel is clicked', async () => {
await act(async () => {
wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
});
expect(history.location.pathname).toEqual('/hosts/2/details');
});
test('should navigate to 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('/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

@@ -51,18 +51,6 @@
"id": 1,
"failed": false
},
"created_by": {
"id": 1,
"username": "admin",
"first_name": "",
"last_name": ""
},
"modified_by": {
"id": 1,
"username": "admin",
"first_name": "",
"last_name": ""
},
"user_capabilities": {
"edit": true,
"delete": true