Merge pull request #6211 from marshmalien/6141-inv-host-components

Remove screen -> screen imports in Inventories and Hosts

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot] 2020-03-06 20:02:36 +00:00 committed by GitHub
commit 0c9c349fb9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 1163 additions and 403 deletions

View File

@ -1,22 +1,19 @@
import React, { useState } from 'react';
import { func, shape } from 'prop-types';
import { useRouteMatch } from 'react-router-dom';
import { bool, func, shape } from 'prop-types';
import { Formik, useField } from 'formik';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Form, FormGroup } from '@patternfly/react-core';
import FormField, {
FormSubmitError,
FieldTooltip,
} from '@components/FormField';
import FormActionGroup from '@components/FormActionGroup/FormActionGroup';
import { VariablesField } from '@components/CodeMirrorInput';
import { required } from '@util/validators';
import { InventoryLookup } from '@components/Lookup';
import { FormColumnLayout, FormFullWidthLayout } from '@components/FormLayout';
import { required } from '@util/validators';
const InventoryLookupField = withI18n()(({ i18n, host }) => {
const [inventory, setInventory] = useState(
@ -57,9 +54,14 @@ const InventoryLookupField = withI18n()(({ i18n, host }) => {
);
});
const HostForm = ({ handleCancel, handleSubmit, host, i18n, submitError }) => {
const hostAddMatch = useRouteMatch('/hosts/add');
const HostForm = ({
handleCancel,
handleSubmit,
host,
isInventoryVisible,
i18n,
submitError,
}) => {
return (
<Formik
initialValues={{
@ -87,7 +89,7 @@ const HostForm = ({ handleCancel, handleSubmit, host, i18n, submitError }) => {
type="text"
label={i18n._(t`Description`)}
/>
{hostAddMatch && <InventoryLookupField host={host} />}
{isInventoryVisible && <InventoryLookupField host={host} />}
<FormFullWidthLayout>
<VariablesField
id="host-variables"
@ -95,7 +97,7 @@ const HostForm = ({ handleCancel, handleSubmit, host, i18n, submitError }) => {
label={i18n._(t`Variables`)}
/>
</FormFullWidthLayout>
<FormSubmitError error={submitError} />
{submitError && <FormSubmitError error={submitError} />}
<FormActionGroup
onCancel={handleCancel}
onSubmit={formik.handleSubmit}
@ -111,6 +113,7 @@ HostForm.propTypes = {
handleCancel: func.isRequired,
handleSubmit: func.isRequired,
host: shape({}),
isInventoryVisible: bool,
submitError: shape({}),
};
@ -124,6 +127,7 @@ HostForm.defaultProps = {
inventory: null,
},
},
isInventoryVisible: true,
submitError: null,
};

View File

@ -6,43 +6,43 @@ import HostForm from './HostForm';
jest.mock('@api');
const mockData = {
id: 1,
name: 'Foo',
description: 'Bar',
variables: '---',
inventory: 1,
summary_fields: {
inventory: {
id: 1,
name: 'Test Inv',
},
},
};
describe('<HostForm />', () => {
const meConfig = {
me: {
is_superuser: false,
},
};
const mockData = {
id: 1,
name: 'Foo',
description: 'Bar',
variables: '---',
inventory: 1,
summary_fields: {
inventory: {
id: 1,
name: 'Test Inv',
},
},
};
let wrapper;
const handleSubmit = jest.fn();
const handleCancel = jest.fn();
afterEach(() => {
jest.clearAllMocks();
});
test('changing inputs should update form values', async () => {
let wrapper;
beforeEach(async () => {
await act(async () => {
wrapper = mountWithContexts(
<HostForm
host={mockData}
handleSubmit={jest.fn()}
handleCancel={jest.fn()}
me={meConfig.me}
handleSubmit={handleSubmit}
handleCancel={handleCancel}
/>
);
});
});
afterEach(() => {
jest.clearAllMocks();
wrapper.unmount();
});
test('changing inputs should update form values', async () => {
await act(async () => {
wrapper.find('input#host-name').simulate('change', {
target: { value: 'new foo', name: 'name' },
@ -59,35 +59,30 @@ describe('<HostForm />', () => {
});
test('calls handleSubmit when form submitted', async () => {
const handleSubmit = jest.fn();
const wrapper = mountWithContexts(
<HostForm
host={mockData}
handleSubmit={handleSubmit}
handleCancel={jest.fn()}
me={meConfig.me}
/>
);
expect(handleSubmit).not.toHaveBeenCalled();
await act(async () => {
wrapper.find('button[aria-label="Save"]').simulate('click');
});
expect(handleSubmit).toHaveBeenCalled();
expect(handleSubmit).toHaveBeenCalledTimes(1);
});
test('calls "handleCancel" when Cancel button is clicked', () => {
const handleCancel = jest.fn();
const wrapper = mountWithContexts(
<HostForm
host={mockData}
handleSubmit={jest.fn()}
handleCancel={handleCancel}
me={meConfig.me}
/>
);
expect(handleCancel).not.toHaveBeenCalled();
wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
expect(handleCancel).toBeCalled();
expect(handleCancel).toHaveBeenCalledTimes(1);
});
test('should hide inventory lookup field', async () => {
await act(async () => {
wrapper = mountWithContexts(
<HostForm
host={mockData}
handleSubmit={jest.fn()}
handleCancel={jest.fn()}
isInventoryVisible={false}
/>
);
});
expect(wrapper.find('InventoryLookupField').length).toBe(0);
});
});

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,34 +1,24 @@
import React, { useState } from 'react';
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 HostAdd() {
const [formError, setFormError] = useState(null);
const history = useHistory();
const hostsMatch = useRouteMatch('/hosts');
const inventoriesMatch = useRouteMatch('/inventories/inventory/:id/hosts');
const url = hostsMatch ? hostsMatch.url : inventoriesMatch.url;
const handleSubmit = async formData => {
const values = {
...formData,
inventory: inventoriesMatch
? inventoriesMatch.params.id
: formData.inventory,
};
try {
const { data: response } = await HostsAPI.create(values);
history.push(`${url}/${response.id}/details`);
const { data: response } = await HostsAPI.create(formData);
history.push(`/hosts/${response.id}/details`);
} catch (error) {
setFormError(error);
}
};
const handleCancel = () => {
history.push(`${url}`);
history.push(`/hosts`);
};
return (

View File

@ -1,27 +1,32 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import HostAdd from './HostAdd';
import { HostsAPI } from '@api';
jest.mock('@api');
const hostData = {
name: 'new name',
description: 'new description',
inventory: 1,
variables: '---\nfoo: bar',
};
HostsAPI.create.mockResolvedValue({
data: {
...hostData,
id: 5,
},
});
describe('<HostAdd />', () => {
let wrapper;
let history;
const hostData = {
name: 'new name',
description: 'new description',
inventory: 1,
variables: '---\nfoo: bar',
};
beforeEach(async () => {
history = createMemoryHistory({
initialEntries: ['/hosts/add'],
});
history = createMemoryHistory();
await act(async () => {
wrapper = mountWithContexts(<HostAdd />, {
context: { router: { history } },
@ -29,13 +34,12 @@ describe('<HostAdd />', () => {
});
});
afterEach(() => {
jest.clearAllMocks();
wrapper.unmount();
});
test('handleSubmit should post to api', async () => {
HostsAPI.create.mockResolvedValueOnce({
data: {
...hostData,
id: 5,
},
});
await act(async () => {
wrapper.find('HostForm').prop('handleSubmit')(hostData);
});
@ -43,21 +47,31 @@ describe('<HostAdd />', () => {
});
test('should navigate to hosts list when cancel is clicked', async () => {
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
await act(async () => {
wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
});
expect(history.location.pathname).toEqual('/hosts');
});
test('successful form submission should trigger redirect', async () => {
HostsAPI.create.mockResolvedValueOnce({
data: {
...hostData,
id: 5,
},
});
await waitForElement(wrapper, 'button[aria-label="Save"]');
await act(async () => {
wrapper.find('HostForm').invoke('handleSubmit')(hostData);
});
expect(wrapper.find('FormSubmitError').length).toBe(0);
expect(history.location.pathname).toEqual('/hosts/5/details');
});
test('failed form submission should show an error message', async () => {
const error = {
response: {
data: { detail: 'An error occurred' },
},
};
HostsAPI.create.mockImplementationOnce(() => Promise.reject(error));
await act(async () => {
wrapper.find('HostForm').invoke('handleSubmit')(hostData);
});
wrapper.update();
expect(wrapper.find('FormSubmitError').length).toBe(1);
});
});

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,30 +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

@ -30,4 +30,23 @@ describe('<Hosts />', () => {
expect(wrapper.find('BreadcrumbHeading').length).toBe(1);
wrapper.unmount();
});
test('should render Host component', () => {
const history = createMemoryHistory({
initialEntries: ['/hosts/1'],
});
const match = {
path: '/hosts/:id',
url: '/hosts/1',
isExact: true,
};
const wrapper = mountWithContexts(<Hosts />, {
context: { router: { history, route: { match } } },
});
expect(wrapper.find('Host').length).toBe(1);
wrapper.unmount();
});
});

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

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,42 @@
import React, { useState } from 'react';
import { useHistory } from 'react-router-dom';
import { CardBody } from '@components/Card';
import HostForm from '@components/HostForm';
import { HostsAPI } from '@api';
function InventoryHostAdd({ inventory }) {
const [formError, setFormError] = useState(null);
const hostsUrl = `/inventories/inventory/${inventory.id}/hosts`;
const history = useHistory();
const handleSubmit = async formData => {
try {
const values = {
...formData,
inventory: inventory.id,
};
const { data: response } = await HostsAPI.create(values);
history.push(`${hostsUrl}/${response.id}/details`);
} catch (error) {
setFormError(error);
}
};
const handleCancel = () => {
history.push(hostsUrl);
};
return (
<CardBody>
<HostForm
handleSubmit={handleSubmit}
handleCancel={handleCancel}
isInventoryVisible={false}
submitError={formError}
/>
</CardBody>
);
}
export default InventoryHostAdd;

View File

@ -0,0 +1,70 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import InventoryHostAdd from './InventoryHostAdd';
import mockHost from '../shared/data.host.json';
import { HostsAPI } from '@api';
jest.mock('@api');
HostsAPI.create.mockResolvedValue({
data: {
...mockHost,
},
});
describe('<InventoryHostAdd />', () => {
let wrapper;
let history;
beforeAll(async () => {
history = createMemoryHistory();
await act(async () => {
wrapper = mountWithContexts(<InventoryHostAdd inventory={{ id: 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(HostsAPI.create).toHaveBeenCalledWith(mockHost);
});
test('should navigate to hosts list when cancel is clicked', () => {
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
expect(history.location.pathname).toEqual('/inventories/inventory/3/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' },
},
};
HostsAPI.create.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 './InventoryHostAdd';

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,28 +1,22 @@
import React from 'react';
import { Switch, Route } from 'react-router-dom';
import Host from '../../Host/Host';
import InventoryHost from '../InventoryHost';
import InventoryHostAdd from '../InventoryHostAdd';
import InventoryHostList from './InventoryHostList';
import HostAdd from '../../Host/HostAdd';
function InventoryHosts({ setBreadcrumb, inventory }) {
return (
<Switch>
<Route key="host-add" path="/inventories/inventory/:id/hosts/add">
<HostAdd />
<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>
<Route
key="host"
path="/inventories/inventory/:id/hosts/:hostId"
render={() => (
<Host setBreadcrumb={setBreadcrumb} inventory={inventory} />
)}
/>
<Route
key="host-list"
path="/inventories/inventory/:id/hosts/"
render={() => <InventoryHostList setBreadcrumb={setBreadcrumb} />}
/>
</Switch>
);
}

View File

@ -0,0 +1,25 @@
import React from 'react';
import { createMemoryHistory } from 'history';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import InventoryHosts from './InventoryHosts';
describe('<InventoryHosts />', () => {
test('should render inventory host list', () => {
const history = createMemoryHistory({
initialEntries: ['/inventories/inventory/1/hosts'],
});
const match = {
path: '/inventories/inventory/:id/hosts',
url: '/inventories/inventory/1/hosts',
isExact: true,
};
const wrapper = mountWithContexts(<InventoryHosts />, {
context: { router: { history, route: { match } } },
});
expect(wrapper.find('InventoryHostList').length).toBe(1);
wrapper.unmount();
});
});

View File

@ -0,0 +1,86 @@
{
"id": 2,
"type": "host",
"url": "/api/v2/hosts/2/",
"related": {
"created_by": "/api/v2/users/1/",
"modified_by": "/api/v2/users/1/",
"variable_data": "/api/v2/hosts/2/variable_data/",
"groups": "/api/v2/hosts/2/groups/",
"all_groups": "/api/v2/hosts/2/all_groups/",
"job_events": "/api/v2/hosts/2/job_events/",
"job_host_summaries": "/api/v2/hosts/2/job_host_summaries/",
"activity_stream": "/api/v2/hosts/2/activity_stream/",
"inventory_sources": "/api/v2/hosts/2/inventory_sources/",
"smart_inventories": "/api/v2/hosts/2/smart_inventories/",
"ad_hoc_commands": "/api/v2/hosts/2/ad_hoc_commands/",
"ad_hoc_command_events": "/api/v2/hosts/2/ad_hoc_command_events/",
"insights": "/api/v2/hosts/2/insights/",
"ansible_facts": "/api/v2/hosts/2/ansible_facts/",
"inventory": "/api/v2/inventories/3/",
"last_job": "/api/v2/jobs/3/",
"last_job_host_summary": "/api/v2/job_host_summaries/1/"
},
"summary_fields": {
"inventory": {
"id": 3,
"name": "Mikes Inventory",
"description": "",
"has_active_failures": false,
"total_hosts": 3,
"hosts_with_active_failures": 0,
"total_groups": 0,
"groups_with_active_failures": 0,
"has_inventory_sources": true,
"total_inventory_sources": 1,
"inventory_sources_with_failures": 0,
"organization_id": 3,
"kind": ""
},
"last_job": {
"id": 3,
"name": "Ping",
"description": "",
"finished": "2019-10-28T21:29:08.880572Z",
"status": "successful",
"failed": false,
"job_template_id": 9,
"job_template_name": "Ping"
},
"last_job_host_summary": {
"id": 1,
"failed": false
},
"user_capabilities": {
"edit": true,
"delete": true
},
"groups": {
"count": 0,
"results": []
},
"recent_jobs": [
{
"id": 3,
"name": "Ping",
"status": "successful",
"finished": "2019-10-28T21:29:08.880572Z",
"type": "job"
}
]
},
"created": "2019-10-28T21:26:54.508081Z",
"modified": "2019-10-29T20:18:41.915796Z",
"name": "localhost",
"description": "localhost description",
"inventory": 3,
"enabled": true,
"instance_id": "",
"variables": "---\nansible_connection: local",
"has_active_failures": false,
"has_inventory_sources": false,
"last_job": 3,
"last_job_host_summary": 1,
"insights_system_id": null,
"ansible_facts_modified": null
}