mirror of
https://github.com/ansible/awx.git
synced 2026-05-19 14:57:39 -02:30
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:
@@ -1,22 +1,19 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { func, shape } from 'prop-types';
|
import { bool, func, shape } from 'prop-types';
|
||||||
|
|
||||||
import { useRouteMatch } from 'react-router-dom';
|
|
||||||
import { Formik, useField } from 'formik';
|
import { Formik, useField } from 'formik';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
|
|
||||||
import { Form, FormGroup } from '@patternfly/react-core';
|
import { Form, FormGroup } from '@patternfly/react-core';
|
||||||
|
|
||||||
import FormField, {
|
import FormField, {
|
||||||
FormSubmitError,
|
FormSubmitError,
|
||||||
FieldTooltip,
|
FieldTooltip,
|
||||||
} from '@components/FormField';
|
} from '@components/FormField';
|
||||||
import FormActionGroup from '@components/FormActionGroup/FormActionGroup';
|
import FormActionGroup from '@components/FormActionGroup/FormActionGroup';
|
||||||
import { VariablesField } from '@components/CodeMirrorInput';
|
import { VariablesField } from '@components/CodeMirrorInput';
|
||||||
import { required } from '@util/validators';
|
|
||||||
import { InventoryLookup } from '@components/Lookup';
|
import { InventoryLookup } from '@components/Lookup';
|
||||||
import { FormColumnLayout, FormFullWidthLayout } from '@components/FormLayout';
|
import { FormColumnLayout, FormFullWidthLayout } from '@components/FormLayout';
|
||||||
|
import { required } from '@util/validators';
|
||||||
|
|
||||||
const InventoryLookupField = withI18n()(({ i18n, host }) => {
|
const InventoryLookupField = withI18n()(({ i18n, host }) => {
|
||||||
const [inventory, setInventory] = useState(
|
const [inventory, setInventory] = useState(
|
||||||
@@ -57,9 +54,14 @@ const InventoryLookupField = withI18n()(({ i18n, host }) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const HostForm = ({ handleCancel, handleSubmit, host, i18n, submitError }) => {
|
const HostForm = ({
|
||||||
const hostAddMatch = useRouteMatch('/hosts/add');
|
handleCancel,
|
||||||
|
handleSubmit,
|
||||||
|
host,
|
||||||
|
isInventoryVisible,
|
||||||
|
i18n,
|
||||||
|
submitError,
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={{
|
initialValues={{
|
||||||
@@ -87,7 +89,7 @@ const HostForm = ({ handleCancel, handleSubmit, host, i18n, submitError }) => {
|
|||||||
type="text"
|
type="text"
|
||||||
label={i18n._(t`Description`)}
|
label={i18n._(t`Description`)}
|
||||||
/>
|
/>
|
||||||
{hostAddMatch && <InventoryLookupField host={host} />}
|
{isInventoryVisible && <InventoryLookupField host={host} />}
|
||||||
<FormFullWidthLayout>
|
<FormFullWidthLayout>
|
||||||
<VariablesField
|
<VariablesField
|
||||||
id="host-variables"
|
id="host-variables"
|
||||||
@@ -95,7 +97,7 @@ const HostForm = ({ handleCancel, handleSubmit, host, i18n, submitError }) => {
|
|||||||
label={i18n._(t`Variables`)}
|
label={i18n._(t`Variables`)}
|
||||||
/>
|
/>
|
||||||
</FormFullWidthLayout>
|
</FormFullWidthLayout>
|
||||||
<FormSubmitError error={submitError} />
|
{submitError && <FormSubmitError error={submitError} />}
|
||||||
<FormActionGroup
|
<FormActionGroup
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
onSubmit={formik.handleSubmit}
|
onSubmit={formik.handleSubmit}
|
||||||
@@ -111,6 +113,7 @@ HostForm.propTypes = {
|
|||||||
handleCancel: func.isRequired,
|
handleCancel: func.isRequired,
|
||||||
handleSubmit: func.isRequired,
|
handleSubmit: func.isRequired,
|
||||||
host: shape({}),
|
host: shape({}),
|
||||||
|
isInventoryVisible: bool,
|
||||||
submitError: shape({}),
|
submitError: shape({}),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -124,6 +127,7 @@ HostForm.defaultProps = {
|
|||||||
inventory: null,
|
inventory: null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
isInventoryVisible: true,
|
||||||
submitError: null,
|
submitError: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -6,43 +6,43 @@ import HostForm from './HostForm';
|
|||||||
|
|
||||||
jest.mock('@api');
|
jest.mock('@api');
|
||||||
|
|
||||||
|
const mockData = {
|
||||||
|
id: 1,
|
||||||
|
name: 'Foo',
|
||||||
|
description: 'Bar',
|
||||||
|
variables: '---',
|
||||||
|
inventory: 1,
|
||||||
|
summary_fields: {
|
||||||
|
inventory: {
|
||||||
|
id: 1,
|
||||||
|
name: 'Test Inv',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
describe('<HostForm />', () => {
|
describe('<HostForm />', () => {
|
||||||
const meConfig = {
|
let wrapper;
|
||||||
me: {
|
const handleSubmit = jest.fn();
|
||||||
is_superuser: false,
|
const handleCancel = jest.fn();
|
||||||
},
|
|
||||||
};
|
|
||||||
const mockData = {
|
|
||||||
id: 1,
|
|
||||||
name: 'Foo',
|
|
||||||
description: 'Bar',
|
|
||||||
variables: '---',
|
|
||||||
inventory: 1,
|
|
||||||
summary_fields: {
|
|
||||||
inventory: {
|
|
||||||
id: 1,
|
|
||||||
name: 'Test Inv',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
afterEach(() => {
|
beforeEach(async () => {
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('changing inputs should update form values', async () => {
|
|
||||||
let wrapper;
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(
|
wrapper = mountWithContexts(
|
||||||
<HostForm
|
<HostForm
|
||||||
host={mockData}
|
host={mockData}
|
||||||
handleSubmit={jest.fn()}
|
handleSubmit={handleSubmit}
|
||||||
handleCancel={jest.fn()}
|
handleCancel={handleCancel}
|
||||||
me={meConfig.me}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('changing inputs should update form values', async () => {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper.find('input#host-name').simulate('change', {
|
wrapper.find('input#host-name').simulate('change', {
|
||||||
target: { value: 'new foo', name: 'name' },
|
target: { value: 'new foo', name: 'name' },
|
||||||
@@ -59,35 +59,30 @@ describe('<HostForm />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('calls handleSubmit when form submitted', async () => {
|
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();
|
expect(handleSubmit).not.toHaveBeenCalled();
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper.find('button[aria-label="Save"]').simulate('click');
|
wrapper.find('button[aria-label="Save"]').simulate('click');
|
||||||
});
|
});
|
||||||
expect(handleSubmit).toHaveBeenCalled();
|
expect(handleSubmit).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('calls "handleCancel" when Cancel button is clicked', () => {
|
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();
|
expect(handleCancel).not.toHaveBeenCalled();
|
||||||
wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
useLocation,
|
useLocation,
|
||||||
} from 'react-router-dom';
|
} from 'react-router-dom';
|
||||||
import { Card, CardActions } from '@patternfly/react-core';
|
import { Card, CardActions } from '@patternfly/react-core';
|
||||||
import { CaretLeftIcon } from '@patternfly/react-icons';
|
|
||||||
|
|
||||||
import { TabbedCardHeader } from '@components/Card';
|
import { TabbedCardHeader } from '@components/Card';
|
||||||
import CardCloseButton from '@components/CardCloseButton';
|
import CardCloseButton from '@components/CardCloseButton';
|
||||||
@@ -24,20 +23,13 @@ import HostEdit from './HostEdit';
|
|||||||
import HostGroups from './HostGroups';
|
import HostGroups from './HostGroups';
|
||||||
import { HostsAPI } from '@api';
|
import { HostsAPI } from '@api';
|
||||||
|
|
||||||
function Host({ inventory, i18n, setBreadcrumb }) {
|
function Host({ i18n, setBreadcrumb }) {
|
||||||
const [host, setHost] = useState(null);
|
const [host, setHost] = useState(null);
|
||||||
const [contentError, setContentError] = useState(null);
|
const [contentError, setContentError] = useState(null);
|
||||||
const [hasContentLoading, setHasContentLoading] = useState(true);
|
const [hasContentLoading, setHasContentLoading] = useState(true);
|
||||||
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const hostsMatch = useRouteMatch('/hosts/:id');
|
const match = 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`;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -45,17 +37,10 @@ function Host({ inventory, i18n, setBreadcrumb }) {
|
|||||||
setHasContentLoading(true);
|
setHasContentLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const hostId = hostsMatch
|
const { data } = await HostsAPI.readDetail(match.params.id);
|
||||||
? hostsMatch.params.id
|
|
||||||
: inventoriesMatch.params.hostId;
|
|
||||||
const { data } = await HostsAPI.readDetail(hostId);
|
|
||||||
setHost(data);
|
|
||||||
|
|
||||||
if (hostsMatch) {
|
setHost(data);
|
||||||
setBreadcrumb(data);
|
setBreadcrumb(data);
|
||||||
} else if (inventoriesMatch) {
|
|
||||||
setBreadcrumb(inventory, data);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setContentError(error);
|
setContentError(error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -67,44 +52,31 @@ function Host({ inventory, i18n, setBreadcrumb }) {
|
|||||||
const tabsArray = [
|
const tabsArray = [
|
||||||
{
|
{
|
||||||
name: i18n._(t`Details`),
|
name: i18n._(t`Details`),
|
||||||
link: `${baseUrl}/details`,
|
link: `${match.url}/details`,
|
||||||
id: 0,
|
id: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: i18n._(t`Facts`),
|
name: i18n._(t`Facts`),
|
||||||
link: `${baseUrl}/facts`,
|
link: `${match.url}/facts`,
|
||||||
id: 1,
|
id: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: i18n._(t`Groups`),
|
name: i18n._(t`Groups`),
|
||||||
link: `${baseUrl}/groups`,
|
link: `${match.url}/groups`,
|
||||||
id: 2,
|
id: 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: i18n._(t`Completed Jobs`),
|
name: i18n._(t`Completed Jobs`),
|
||||||
link: `${baseUrl}/completed_jobs`,
|
link: `${match.url}/completed_jobs`,
|
||||||
id: 3,
|
id: 3,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (inventoriesMatch) {
|
|
||||||
tabsArray.unshift({
|
|
||||||
name: (
|
|
||||||
<>
|
|
||||||
<CaretLeftIcon />
|
|
||||||
{i18n._(t`Back to Hosts`)}
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
link: hostListUrl,
|
|
||||||
id: 99,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let cardHeader = (
|
let cardHeader = (
|
||||||
<TabbedCardHeader>
|
<TabbedCardHeader>
|
||||||
<RoutedTabs tabsArray={tabsArray} />
|
<RoutedTabs tabsArray={tabsArray} />
|
||||||
<CardActions>
|
<CardActions>
|
||||||
<CardCloseButton linkTo={hostListUrl} />
|
<CardCloseButton linkTo="/hosts" />
|
||||||
</CardActions>
|
</CardActions>
|
||||||
</TabbedCardHeader>
|
</TabbedCardHeader>
|
||||||
);
|
);
|
||||||
@@ -124,7 +96,7 @@ function Host({ inventory, i18n, setBreadcrumb }) {
|
|||||||
{contentError.response && contentError.response.status === 404 && (
|
{contentError.response && contentError.response.status === 404 && (
|
||||||
<span>
|
<span>
|
||||||
{i18n._(`Host not found.`)}{' '}
|
{i18n._(`Host not found.`)}{' '}
|
||||||
<Link to={hostListUrl}>{i18n._(`View all Hosts.`)}</Link>
|
<Link to="/hosts">{i18n._(`View all Hosts.`)}</Link>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</ContentError>
|
</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 (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
{cardHeader}
|
{cardHeader}
|
||||||
<Switch>
|
<Switch>
|
||||||
{redirect}
|
<Redirect from="/hosts/:id" to="/hosts/:id/details" exact />
|
||||||
{host && (
|
{host && [
|
||||||
<Route
|
<Route path="/hosts/:id/details" key="details">
|
||||||
path={[
|
<HostDetail host={host} />
|
||||||
'/hosts/:id/details',
|
</Route>,
|
||||||
'/inventories/inventory/:id/hosts/:hostId/details',
|
<Route path="/hosts/:id/edit" key="edit">
|
||||||
]}
|
<HostEdit host={host} />
|
||||||
>
|
</Route>,
|
||||||
<HostDetail
|
<Route path="/hosts/:id/facts" key="facts">
|
||||||
host={host}
|
<HostFacts host={host} />
|
||||||
onUpdateHost={newHost => setHost(newHost)}
|
</Route>,
|
||||||
/>
|
<Route path="/hosts/:id/groups" key="groups">
|
||||||
</Route>
|
<HostGroups host={host} />
|
||||||
)}
|
</Route>,
|
||||||
{host && (
|
<Route path="/hosts/:id/completed_jobs" key="completed-jobs">
|
||||||
<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',
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<JobList defaultParams={{ job__hosts: host.id }} />
|
<JobList defaultParams={{ job__hosts: host.id }} />
|
||||||
</Route>
|
</Route>,
|
||||||
)}
|
]}
|
||||||
<Route
|
<Route
|
||||||
key="not-found"
|
key="not-found"
|
||||||
path="*"
|
path="*"
|
||||||
render={() =>
|
render={() =>
|
||||||
!hasContentLoading && (
|
!hasContentLoading && (
|
||||||
<ContentError isNotFound>
|
<ContentError isNotFound>
|
||||||
<Link to={`${baseUrl}/details`}>
|
<Link to={`${match.url}/details`}>
|
||||||
{i18n._(`View Host Details`)}
|
{i18n._(`View Host Details`)}
|
||||||
</Link>
|
</Link>
|
||||||
</ContentError>
|
</ContentError>
|
||||||
|
|||||||
@@ -3,53 +3,41 @@ import { act } from 'react-dom/test-utils';
|
|||||||
import { createMemoryHistory } from 'history';
|
import { createMemoryHistory } from 'history';
|
||||||
import { HostsAPI } from '@api';
|
import { HostsAPI } from '@api';
|
||||||
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
|
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
|
||||||
import mockDetails from './data.host.json';
|
import mockHost from './data.host.json';
|
||||||
import Host from './Host';
|
import Host from './Host';
|
||||||
|
|
||||||
jest.mock('@api');
|
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 />', () => {
|
describe('<Host />', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
let history;
|
let history;
|
||||||
|
|
||||||
HostsAPI.readDetail.mockResolvedValue({
|
beforeEach(async () => {
|
||||||
data: { ...mockDetails },
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(<Host setBreadcrumb={() => {}} />);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('initially renders succesfully', async () => {
|
test('should render expected tabs', async () => {
|
||||||
history = createMemoryHistory({
|
const expectedTabs = ['Details', 'Facts', 'Groups', 'Completed Jobs'];
|
||||||
initialEntries: ['/hosts/1/edit'],
|
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 () => {
|
test('should show content error when api throws error on initial render', async () => {
|
||||||
|
|||||||
@@ -1,34 +1,24 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useHistory, useRouteMatch } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
import { CardBody } from '@components/Card';
|
import { CardBody } from '@components/Card';
|
||||||
|
import HostForm from '@components/HostForm';
|
||||||
import { HostsAPI } from '@api';
|
import { HostsAPI } from '@api';
|
||||||
import HostForm from '../shared';
|
|
||||||
|
|
||||||
function HostAdd() {
|
function HostAdd() {
|
||||||
const [formError, setFormError] = useState(null);
|
const [formError, setFormError] = useState(null);
|
||||||
const history = useHistory();
|
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 handleSubmit = async formData => {
|
||||||
const values = {
|
|
||||||
...formData,
|
|
||||||
inventory: inventoriesMatch
|
|
||||||
? inventoriesMatch.params.id
|
|
||||||
: formData.inventory,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data: response } = await HostsAPI.create(values);
|
const { data: response } = await HostsAPI.create(formData);
|
||||||
history.push(`${url}/${response.id}/details`);
|
history.push(`/hosts/${response.id}/details`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setFormError(error);
|
setFormError(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
history.push(`${url}`);
|
history.push(`/hosts`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,27 +1,32 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { act } from 'react-dom/test-utils';
|
import { act } from 'react-dom/test-utils';
|
||||||
import { createMemoryHistory } from 'history';
|
import { createMemoryHistory } from 'history';
|
||||||
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
|
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||||
import HostAdd from './HostAdd';
|
import HostAdd from './HostAdd';
|
||||||
import { HostsAPI } from '@api';
|
import { HostsAPI } from '@api';
|
||||||
|
|
||||||
jest.mock('@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 />', () => {
|
describe('<HostAdd />', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
let history;
|
let history;
|
||||||
|
|
||||||
const hostData = {
|
|
||||||
name: 'new name',
|
|
||||||
description: 'new description',
|
|
||||||
inventory: 1,
|
|
||||||
variables: '---\nfoo: bar',
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
history = createMemoryHistory({
|
history = createMemoryHistory();
|
||||||
initialEntries: ['/hosts/add'],
|
|
||||||
});
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(<HostAdd />, {
|
wrapper = mountWithContexts(<HostAdd />, {
|
||||||
context: { router: { history } },
|
context: { router: { history } },
|
||||||
@@ -29,13 +34,12 @@ describe('<HostAdd />', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
test('handleSubmit should post to api', async () => {
|
test('handleSubmit should post to api', async () => {
|
||||||
HostsAPI.create.mockResolvedValueOnce({
|
|
||||||
data: {
|
|
||||||
...hostData,
|
|
||||||
id: 5,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper.find('HostForm').prop('handleSubmit')(hostData);
|
wrapper.find('HostForm').prop('handleSubmit')(hostData);
|
||||||
});
|
});
|
||||||
@@ -43,21 +47,31 @@ describe('<HostAdd />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should navigate to hosts list when cancel is clicked', async () => {
|
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');
|
expect(history.location.pathname).toEqual('/hosts');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('successful form submission should trigger redirect', async () => {
|
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 () => {
|
await act(async () => {
|
||||||
wrapper.find('HostForm').invoke('handleSubmit')(hostData);
|
wrapper.find('HostForm').invoke('handleSubmit')(hostData);
|
||||||
});
|
});
|
||||||
|
expect(wrapper.find('FormSubmitError').length).toBe(0);
|
||||||
expect(history.location.pathname).toEqual('/hosts/5/details');
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
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 { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Host } from '@types';
|
import { Host } from '@types';
|
||||||
@@ -14,42 +14,36 @@ import DeleteButton from '@components/DeleteButton';
|
|||||||
import { HostsAPI } from '@api';
|
import { HostsAPI } from '@api';
|
||||||
import HostToggle from '@components/HostToggle';
|
import HostToggle from '@components/HostToggle';
|
||||||
|
|
||||||
function HostDetail({ host, i18n, onUpdateHost }) {
|
function HostDetail({ i18n, host }) {
|
||||||
const {
|
const {
|
||||||
created,
|
created,
|
||||||
description,
|
description,
|
||||||
id,
|
id,
|
||||||
modified,
|
modified,
|
||||||
name,
|
name,
|
||||||
|
variables,
|
||||||
summary_fields: {
|
summary_fields: {
|
||||||
inventory,
|
inventory,
|
||||||
recent_jobs,
|
recent_jobs,
|
||||||
kind,
|
|
||||||
created_by,
|
created_by,
|
||||||
modified_by,
|
modified_by,
|
||||||
user_capabilities,
|
user_capabilities,
|
||||||
},
|
},
|
||||||
} = host;
|
} = host;
|
||||||
|
|
||||||
const history = useHistory();
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
const { id: inventoryId, hostId: inventoryHostId } = useParams();
|
|
||||||
const [isLoading, setIsloading] = useState(false);
|
const [isLoading, setIsloading] = useState(false);
|
||||||
const [deletionError, setDeletionError] = useState(false);
|
const [deletionError, setDeletionError] = useState(false);
|
||||||
|
const history = useHistory();
|
||||||
const recentPlaybookJobs = recent_jobs.map(job => ({ ...job, type: 'job' }));
|
|
||||||
|
|
||||||
const handleHostDelete = async () => {
|
const handleHostDelete = async () => {
|
||||||
setIsloading(true);
|
setIsloading(true);
|
||||||
try {
|
try {
|
||||||
await HostsAPI.destroy(id);
|
await HostsAPI.destroy(id);
|
||||||
setIsloading(false);
|
history.push('/hosts');
|
||||||
const url = pathname.startsWith('/inventories')
|
|
||||||
? `/inventories/inventory/${inventoryId}/hosts/`
|
|
||||||
: `/hosts`;
|
|
||||||
history.push(url);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setDeletionError(err);
|
setDeletionError(err);
|
||||||
|
} finally {
|
||||||
|
setIsloading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -66,77 +60,71 @@ function HostDetail({ host, i18n, onUpdateHost }) {
|
|||||||
</AlertModal>
|
</AlertModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const recentPlaybookJobs = recent_jobs.map(job => ({ ...job, type: 'job' }));
|
||||||
return (
|
return (
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<HostToggle
|
<HostToggle host={host} css="padding-bottom: 40px" />
|
||||||
host={host}
|
|
||||||
onToggle={enabled =>
|
|
||||||
onUpdateHost({
|
|
||||||
...host,
|
|
||||||
enabled,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
css="padding-bottom: 40px"
|
|
||||||
/>
|
|
||||||
<DetailList gutter="sm">
|
<DetailList gutter="sm">
|
||||||
<Detail label={i18n._(t`Name`)} value={name} />
|
<Detail label={i18n._(t`Name`)} value={name} />
|
||||||
<Detail
|
<Detail
|
||||||
value={<Sparkline jobs={recentPlaybookJobs} />}
|
|
||||||
label={i18n._(t`Activity`)}
|
label={i18n._(t`Activity`)}
|
||||||
|
value={<Sparkline jobs={recentPlaybookJobs} />}
|
||||||
/>
|
/>
|
||||||
<Detail label={i18n._(t`Description`)} value={description} />
|
<Detail label={i18n._(t`Description`)} value={description} />
|
||||||
{inventory && (
|
<Detail
|
||||||
<Detail
|
label={i18n._(t`Inventory`)}
|
||||||
label={i18n._(t`Inventory`)}
|
value={
|
||||||
value={
|
<Link to={`/inventories/inventory/${inventory.id}/details`}>
|
||||||
<Link
|
{inventory.name}
|
||||||
to={`/inventories/${
|
</Link>
|
||||||
kind === 'smart' ? 'smart_inventory' : 'inventory'
|
}
|
||||||
}/${inventoryId}/details`}
|
/>
|
||||||
>
|
|
||||||
{inventory.name}
|
|
||||||
</Link>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<UserDateDetail
|
<UserDateDetail
|
||||||
date={created}
|
date={created}
|
||||||
label={i18n._(t`Created`)}
|
label={i18n._(t`Created`)}
|
||||||
user={created_by}
|
user={created_by}
|
||||||
/>
|
/>
|
||||||
<UserDateDetail
|
<UserDateDetail
|
||||||
|
date={modified}
|
||||||
label={i18n._(t`Last Modified`)}
|
label={i18n._(t`Last Modified`)}
|
||||||
user={modified_by}
|
user={modified_by}
|
||||||
date={modified}
|
|
||||||
/>
|
/>
|
||||||
<VariablesDetail
|
<VariablesDetail
|
||||||
value={host.variables}
|
|
||||||
rows={4}
|
|
||||||
label={i18n._(t`Variables`)}
|
label={i18n._(t`Variables`)}
|
||||||
|
rows={4}
|
||||||
|
value={variables}
|
||||||
/>
|
/>
|
||||||
</DetailList>
|
</DetailList>
|
||||||
<CardActionsRow>
|
<CardActionsRow>
|
||||||
{user_capabilities && user_capabilities.edit && (
|
{user_capabilities?.edit && (
|
||||||
<Button
|
<Button
|
||||||
aria-label={i18n._(t`edit`)}
|
aria-label={i18n._(t`edit`)}
|
||||||
component={Link}
|
component={Link}
|
||||||
to={
|
to={`/hosts/${id}/edit`}
|
||||||
pathname.startsWith('/inventories')
|
|
||||||
? `/inventories/inventory/${inventoryId}/hosts/${inventoryHostId}/edit`
|
|
||||||
: `/hosts/${id}/edit`
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{i18n._(t`Edit`)}
|
{i18n._(t`Edit`)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{user_capabilities && user_capabilities.delete && (
|
{user_capabilities?.delete && (
|
||||||
<DeleteButton
|
<DeleteButton
|
||||||
onConfirm={() => handleHostDelete()}
|
onConfirm={() => handleHostDelete()}
|
||||||
modalTitle={i18n._(t`Delete Host`)}
|
modalTitle={i18n._(t`Delete Host`)}
|
||||||
name={host.name}
|
name={name}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</CardActionsRow>
|
</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>
|
</CardBody>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,66 +1,88 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
|
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
|
||||||
|
|
||||||
import HostDetail from './HostDetail';
|
import HostDetail from './HostDetail';
|
||||||
|
import { HostsAPI } from '@api';
|
||||||
|
|
||||||
|
import mockHost from '../data.host.json';
|
||||||
|
|
||||||
jest.mock('@api');
|
jest.mock('@api');
|
||||||
|
|
||||||
describe('<HostDetail />', () => {
|
describe('<HostDetail />', () => {
|
||||||
const mockHost = {
|
let wrapper;
|
||||||
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: [],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
test('initially renders succesfully', () => {
|
describe('User has edit permissions', () => {
|
||||||
mountWithContexts(<HostDetail host={mockHost} />);
|
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 () => {
|
describe('User has read-only permissions', () => {
|
||||||
const wrapper = mountWithContexts(<HostDetail host={mockHost} />);
|
beforeAll(() => {
|
||||||
const testParams = [
|
const readOnlyHost = { ...mockHost };
|
||||||
{ label: 'Name', value: 'Foo' },
|
readOnlyHost.summary_fields.user_capabilities.edit = false;
|
||||||
{ 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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should show edit button for users with edit permission', async () => {
|
wrapper = mountWithContexts(<HostDetail host={mockHost} />);
|
||||||
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');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should hide edit button for users without edit permission', async () => {
|
afterAll(() => {
|
||||||
const readOnlyHost = { ...mockHost };
|
wrapper.unmount();
|
||||||
readOnlyHost.summary_fields.user_capabilities.edit = false;
|
});
|
||||||
const wrapper = mountWithContexts(<HostDetail host={readOnlyHost} />);
|
|
||||||
await waitForElement(wrapper, 'HostDetail');
|
test('should hide edit button for users without edit permission', async () => {
|
||||||
expect(wrapper.find('Button[aria-label="edit"]').length).toBe(0);
|
expect(wrapper.find('Button[aria-label="edit"]').length).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,30 +1,14 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { useHistory, useRouteMatch } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
import { CardBody } from '@components/Card';
|
import { CardBody } from '@components/Card';
|
||||||
|
import HostForm from '@components/HostForm';
|
||||||
import { HostsAPI } from '@api';
|
import { HostsAPI } from '@api';
|
||||||
import HostForm from '../shared';
|
|
||||||
|
|
||||||
function HostEdit({ host }) {
|
function HostEdit({ host }) {
|
||||||
const [formError, setFormError] = useState(null);
|
const [formError, setFormError] = useState(null);
|
||||||
const hostsMatch = useRouteMatch('/hosts/:id/edit');
|
const detailsUrl = `/hosts/${host.id}/details`;
|
||||||
const inventoriesMatch = useRouteMatch(
|
|
||||||
'/inventories/inventory/:id/hosts/:hostId/edit'
|
|
||||||
);
|
|
||||||
const history = useHistory();
|
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 => {
|
const handleSubmit = async values => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,49 +1,70 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
import { createMemoryHistory } from 'history';
|
import { createMemoryHistory } from 'history';
|
||||||
import { HostsAPI } from '@api';
|
import { HostsAPI } from '@api';
|
||||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||||
|
import mockHost from '../data.host.json';
|
||||||
import HostEdit from './HostEdit';
|
import HostEdit from './HostEdit';
|
||||||
|
|
||||||
jest.mock('@api');
|
jest.mock('@api');
|
||||||
|
|
||||||
describe('<HostEdit />', () => {
|
describe('<HostEdit />', () => {
|
||||||
const mockData = {
|
let wrapper;
|
||||||
id: 1,
|
let history;
|
||||||
name: 'Foo',
|
|
||||||
description: 'Bar',
|
const updatedHostData = {
|
||||||
inventory: 1,
|
name: 'new name',
|
||||||
variables: '---',
|
description: 'new description',
|
||||||
summary_fields: {
|
variables: '---\nfoo: bar',
|
||||||
inventory: {
|
|
||||||
id: 1,
|
|
||||||
name: 'test inventory',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
test('handleSubmit should call api update', () => {
|
beforeAll(async () => {
|
||||||
const wrapper = mountWithContexts(<HostEdit host={mockData} />);
|
history = createMemoryHistory();
|
||||||
|
await act(async () => {
|
||||||
const updatedHostData = {
|
wrapper = mountWithContexts(<HostEdit host={mockHost} />, {
|
||||||
name: 'new name',
|
context: { router: { history } },
|
||||||
description: 'new description',
|
});
|
||||||
variables: '---\nfoo: bar',
|
});
|
||||||
};
|
|
||||||
wrapper.find('HostForm').prop('handleSubmit')(updatedHostData);
|
|
||||||
|
|
||||||
expect(HostsAPI.update).toHaveBeenCalledWith(1, updatedHostData);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should navigate to host detail when cancel is clicked', () => {
|
afterAll(() => {
|
||||||
const history = createMemoryHistory({
|
jest.clearAllMocks();
|
||||||
initialEntries: ['/hosts/1/edit'],
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
const wrapper = mountWithContexts(<HostEdit host={mockData} />, {
|
|
||||||
context: { router: { history } },
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,4 +30,23 @@ describe('<Hosts />', () => {
|
|||||||
expect(wrapper.find('BreadcrumbHeading').length).toBe(1);
|
expect(wrapper.find('BreadcrumbHeading').length).toBe(1);
|
||||||
wrapper.unmount();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -51,18 +51,6 @@
|
|||||||
"id": 1,
|
"id": 1,
|
||||||
"failed": false
|
"failed": false
|
||||||
},
|
},
|
||||||
"created_by": {
|
|
||||||
"id": 1,
|
|
||||||
"username": "admin",
|
|
||||||
"first_name": "",
|
|
||||||
"last_name": ""
|
|
||||||
},
|
|
||||||
"modified_by": {
|
|
||||||
"id": 1,
|
|
||||||
"username": "admin",
|
|
||||||
"first_name": "",
|
|
||||||
"last_name": ""
|
|
||||||
},
|
|
||||||
"user_capabilities": {
|
"user_capabilities": {
|
||||||
"edit": true,
|
"edit": true,
|
||||||
"delete": true
|
"delete": true
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
1
awx/ui_next/src/screens/Inventory/InventoryHost/index.js
Normal file
1
awx/ui_next/src/screens/Inventory/InventoryHost/index.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './InventoryHost';
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './InventoryHostAdd';
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './InventoryHostDetail';
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './InventoryHostEdit';
|
||||||
@@ -1,28 +1,22 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Switch, Route } from 'react-router-dom';
|
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 InventoryHostList from './InventoryHostList';
|
||||||
import HostAdd from '../../Host/HostAdd';
|
|
||||||
|
|
||||||
function InventoryHosts({ setBreadcrumb, inventory }) {
|
function InventoryHosts({ setBreadcrumb, inventory }) {
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route key="host-add" path="/inventories/inventory/:id/hosts/add">
|
<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>
|
||||||
<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>
|
</Switch>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
86
awx/ui_next/src/screens/Inventory/shared/data.host.json
Normal file
86
awx/ui_next/src/screens/Inventory/shared/data.host.json
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user