Merge pull request #5491 from marshmalien/inv-host-add

Add Inventory Host Add form 

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot] 2019-12-16 16:06:24 +00:00 committed by GitHub
commit c5b4681bf4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 350 additions and 151 deletions

View File

@ -19,6 +19,10 @@ class Inventories extends InstanceGroupsMixin(Base) {
});
}
createHost(id, data) {
return this.http.post(`${this.baseUrl}${id}/hosts/`, data);
}
readHosts(id, params) {
return this.http.get(`${this.baseUrl}${id}/hosts/`, { params });
}

View File

@ -1,20 +1,10 @@
import React from 'react';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
PageSection,
Card,
CardHeader,
CardBody,
Tooltip,
} from '@patternfly/react-core';
import { PageSection, Card, CardBody } from '@patternfly/react-core';
import { HostsAPI } from '@api';
import { Config } from '@contexts/Config';
import CardCloseButton from '@components/CardCloseButton';
import HostForm from '../shared/HostForm';
import HostForm from '../shared';
class HostAdd extends React.Component {
constructor(props) {
@ -41,16 +31,10 @@ class HostAdd extends React.Component {
render() {
const { error } = this.state;
const { i18n } = this.props;
return (
<PageSection>
<Card>
<CardHeader className="at-u-textRight">
<Tooltip content={i18n._(t`Close`)} position="top">
<CardCloseButton onClick={this.handleCancel} />
</Tooltip>
</CardHeader>
<CardBody>
<Config>
{({ me }) => (

View File

@ -35,18 +35,6 @@ describe('<HostAdd />', () => {
expect(history.location.pathname).toEqual('/hosts');
});
test('should navigate to hosts list when close (x) is clicked', async () => {
const history = createMemoryHistory({});
let wrapper;
await act(async () => {
wrapper = mountWithContexts(<HostAdd />, {
context: { router: { history } },
});
});
wrapper.find('button[aria-label="Close"]').invoke('onClick')();
expect(history.location.pathname).toEqual('/hosts');
});
test('successful form submission should trigger redirect', async () => {
const history = createMemoryHistory({});
const hostData = {

View File

@ -6,7 +6,7 @@ import { CardBody } from '@patternfly/react-core';
import { HostsAPI } from '@api';
import { Config } from '@contexts/Config';
import HostForm from '../shared/HostForm';
import HostForm from '../shared';
class HostEdit extends Component {
constructor(props) {

View File

@ -1,5 +1,5 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import React, { useState } from 'react';
import { func, shape } from 'prop-types';
import { withRouter } from 'react-router-dom';
import { Formik, Field } from 'formik';
@ -15,120 +15,86 @@ import { VariablesField } from '@components/CodeMirrorInput';
import { required } from '@util/validators';
import { InventoryLookup } from '@components/Lookup';
class HostForm extends Component {
constructor(props) {
super(props);
function HostForm({ handleSubmit, handleCancel, host, i18n }) {
const [inventory, setInventory] = useState(
host ? host.summary_fields.inventory : ''
);
this.handleSubmit = this.handleSubmit.bind(this);
this.state = {
formIsValid: true,
inventory: props.host.summary_fields.inventory,
};
}
handleSubmit(values) {
const { handleSubmit } = this.props;
handleSubmit(values);
}
render() {
const { host, handleCancel, i18n } = this.props;
const { formIsValid, inventory, error } = this.state;
const initialValues = !host.id
? {
name: host.name,
description: host.description,
inventory: host.inventory || '',
variables: host.variables,
}
: {
name: host.name,
description: host.description,
variables: host.variables,
};
return (
<Formik
initialValues={initialValues}
onSubmit={this.handleSubmit}
render={formik => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormRow>
<FormField
id="host-name"
name="name"
type="text"
label={i18n._(t`Name`)}
validate={required(null, i18n)}
isRequired
/>
<FormField
id="host-description"
name="description"
type="text"
label={i18n._(t`Description`)}
/>
{!host.id && (
<Field
name="inventory"
validate={required(
i18n._(t`Select a value for this field`),
i18n
)}
render={({ form }) => (
<InventoryLookup
value={inventory}
onBlur={() => form.setFieldTouched('inventory')}
tooltip={i18n._(
t`Select the inventory that this host will belong to.`
)}
isValid={
!form.touched.inventory || !form.errors.inventory
}
helperTextInvalid={form.errors.inventory}
onChange={value => {
form.setFieldValue('inventory', value.id);
this.setState({ inventory: value });
}}
required
touched={form.touched.inventory}
error={form.errors.inventory}
/>
)}
/>
)}
</FormRow>
<FormRow>
<VariablesField
id="host-variables"
name="variables"
label={i18n._(t`Variables`)}
/>
</FormRow>
<FormActionGroup
onCancel={handleCancel}
onSubmit={formik.handleSubmit}
submitDisabled={!formIsValid}
return (
<Formik
initialValues={{
name: host.name,
description: host.description,
inventory: host.inventory || '',
variables: host.variables,
}}
onSubmit={handleSubmit}
render={formik => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormRow>
<FormField
id="host-name"
name="name"
type="text"
label={i18n._(t`Name`)}
validate={required(null, i18n)}
isRequired
/>
{error ? <div>error</div> : null}
</Form>
)}
/>
);
}
<FormField
id="host-description"
name="description"
type="text"
label={i18n._(t`Description`)}
/>
{!host.id && (
<Field
name="inventory"
validate={required(
i18n._(t`Select a value for this field`),
i18n
)}
render={({ form }) => (
<InventoryLookup
value={inventory}
onBlur={() => form.setFieldTouched('inventory')}
tooltip={i18n._(
t`Select the inventory that this host will belong to.`
)}
isValid={!form.touched.inventory || !form.errors.inventory}
helperTextInvalid={form.errors.inventory}
onChange={value => {
form.setFieldValue('inventory', value.id);
setInventory(value);
}}
required
touched={form.touched.inventory}
error={form.errors.inventory}
/>
)}
/>
)}
</FormRow>
<FormRow>
<VariablesField
id="host-variables"
name="variables"
label={i18n._(t`Variables`)}
/>
</FormRow>
<FormActionGroup
onCancel={handleCancel}
onSubmit={formik.handleSubmit}
/>
</Form>
)}
/>
);
}
FormField.propTypes = {
label: PropTypes.oneOfType([PropTypes.object, PropTypes.string]).isRequired,
};
HostForm.propTypes = {
host: PropTypes.shape(),
handleSubmit: PropTypes.func.isRequired,
handleCancel: PropTypes.func.isRequired,
handleSubmit: func.isRequired,
handleCancel: func.isRequired,
host: shape({}),
};
HostForm.defaultProps = {

View File

@ -65,11 +65,7 @@ describe('<HostForm />', () => {
expect(handleSubmit).not.toHaveBeenCalled();
wrapper.find('button[aria-label="Save"]').simulate('click');
await sleep(1);
expect(handleSubmit).toHaveBeenCalledWith({
name: 'Foo',
description: 'Bar',
variables: '---',
});
expect(handleSubmit).toHaveBeenCalled();
});
test('calls "handleCancel" when Cancel button is clicked', () => {

View File

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

View File

@ -1,8 +1,36 @@
import React from 'react';
import React, { useState } from 'react';
import { useHistory, useParams } from 'react-router-dom';
import { CardBody } from '@patternfly/react-core';
import InventoryHostForm from '../shared/InventoryHostForm';
import { InventoriesAPI } from '@api';
function InventoryHostAdd() {
return <CardBody>Coming soon :)</CardBody>;
const [formError, setFormError] = useState(null);
const history = useHistory();
const { id } = useParams();
const handleSubmit = async values => {
try {
const { data: response } = await InventoriesAPI.createHost(id, values);
history.push(`/inventories/inventory/${id}/hosts/${response.id}/details`);
} catch (error) {
setFormError(error);
}
};
const handleCancel = () => {
history.push(`/inventories/inventory/${id}/hosts`);
};
return (
<CardBody>
<InventoryHostForm
handleSubmit={handleSubmit}
handleCancel={handleCancel}
/>
{formError ? <div className="formSubmitError">error</div> : ''}
</CardBody>
);
}
export default InventoryHostAdd;

View File

@ -0,0 +1,102 @@
import React from 'react';
import { Route } from 'react-router-dom';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import InventoryHostAdd from './InventoryHostAdd';
import { InventoriesAPI } from '@api';
jest.mock('@api');
describe('<InventoryHostAdd />', () => {
let wrapper;
let history;
const mockHostData = {
name: 'new name',
description: 'new description',
inventory: 1,
variables: '---\nfoo: bar',
};
beforeEach(async () => {
history = createMemoryHistory({
initialEntries: ['/inventories/inventory/1/hosts/add'],
});
await act(async () => {
wrapper = mountWithContexts(
<Route
path="/inventories/inventory/:id/hosts/add"
component={() => <InventoryHostAdd />}
/>,
{
context: {
router: { history, route: { location: history.location } },
},
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
afterEach(() => {
wrapper.unmount();
});
test('handleSubmit should post to api', async () => {
InventoriesAPI.createHost.mockResolvedValue({
data: { ...mockHostData },
});
const formik = wrapper.find('Formik').instance();
await act(async () => {
const changeState = new Promise(resolve => {
formik.setState(
{
values: {
...mockHostData,
},
},
() => resolve()
);
});
await changeState;
});
await act(async () => {
wrapper.find('form').simulate('submit');
});
wrapper.update();
expect(InventoriesAPI.createHost).toHaveBeenCalledWith('1', mockHostData);
});
test('handleSubmit should throw an error', async () => {
InventoriesAPI.createHost.mockImplementationOnce(() =>
Promise.reject(new Error())
);
const formik = wrapper.find('Formik').instance();
await act(async () => {
const changeState = new Promise(resolve => {
formik.setState(
{
values: {
...mockHostData,
},
},
() => resolve()
);
});
await changeState;
});
await act(async () => {
wrapper.find('form').simulate('submit');
});
wrapper.update();
expect(wrapper.find('InventoryHostAdd .formSubmitError').length).toBe(1);
});
test('should navigate to inventory hosts list when cancel is clicked', async () => {
wrapper.find('button[aria-label="Cancel"]').simulate('click');
expect(history.location.pathname).toEqual('/inventories/inventory/1/hosts');
});
});

View File

@ -23,6 +23,7 @@ import { Host } from '@types';
function InventoryHostItem(props) {
const {
detailUrl,
editUrl,
host,
i18n,
isSelected,
@ -79,7 +80,7 @@ function InventoryHostItem(props) {
<ListActionButton
variant="plain"
component={Link}
to={`/hosts/${host.id}/edit`}
to={`${editUrl}`}
>
<PencilAltIcon />
</ListActionButton>

View File

@ -178,7 +178,8 @@ function InventoryHosts({ i18n, location, match }) {
<InventoryHostItem
key={o.id}
host={o}
detailUrl={`/hosts/${o.id}/details`}
detailUrl={`/inventories/inventory/${match.params.id}/hosts/${o.id}/details`}
editUrl={`/inventories/inventory/${match.params.id}/hosts/${o.id}/edit`}
isSelected={selected.some(row => row.id === o.id)}
onSelect={() => handleSelect(o)}
toggleHost={handleToggle}

View File

@ -0,0 +1,71 @@
import React from 'react';
import { func, shape } from 'prop-types';
import { Formik } from 'formik';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Form } from '@patternfly/react-core';
import FormRow from '@components/FormRow';
import FormField from '@components/FormField';
import FormActionGroup from '@components/FormActionGroup/FormActionGroup';
import { VariablesField } from '@components/CodeMirrorInput';
import { required } from '@util/validators';
function InventoryHostForm({ handleSubmit, handleCancel, host, i18n }) {
return (
<Formik
initialValues={{
name: host.name,
description: host.description,
variables: host.variables,
}}
onSubmit={handleSubmit}
render={formik => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormRow>
<FormField
id="host-name"
name="name"
type="text"
label={i18n._(t`Name`)}
validate={required(null, i18n)}
isRequired
/>
<FormField
id="host-description"
name="description"
type="text"
label={i18n._(t`Description`)}
/>
</FormRow>
<FormRow>
<VariablesField
id="host-variables"
name="variables"
label={i18n._(t`Variables`)}
/>
</FormRow>
<FormActionGroup
onCancel={handleCancel}
onSubmit={formik.handleSubmit}
/>
</Form>
)}
/>
);
}
InventoryHostForm.propTypes = {
handleSubmit: func.isRequired,
handleCancel: func.isRequired,
host: shape({}),
};
InventoryHostForm.defaultProps = {
host: {
name: '',
description: '',
variables: '---\n',
},
};
export default withI18n()(InventoryHostForm);

View File

@ -0,0 +1,57 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import { sleep } from '@testUtils/testUtils';
import InventoryHostForm from './InventoryHostForm';
jest.mock('@api');
describe('<InventoryHostform />', () => {
let wrapper;
const handleSubmit = jest.fn();
const handleCancel = jest.fn();
const mockHostData = {
name: 'foo',
description: 'bar',
inventory: 1,
variables: '---\nfoo: bar',
};
beforeEach(async () => {
await act(async () => {
wrapper = mountWithContexts(
<InventoryHostForm
handleCancel={handleCancel}
handleSubmit={handleSubmit}
host={mockHostData}
/>
);
});
});
afterEach(() => {
wrapper.unmount();
});
test('should display form fields', () => {
expect(wrapper.find('FormGroup[label="Name"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="Description"]').length).toBe(1);
expect(wrapper.find('VariablesField').length).toBe(1);
});
test('should call handleSubmit when Submit button is clicked', async () => {
expect(handleSubmit).not.toHaveBeenCalled();
wrapper.find('button[aria-label="Save"]').simulate('click');
await sleep(1);
expect(handleSubmit).toHaveBeenCalled();
});
test('should call handleCancel when Cancel button is clicked', async () => {
expect(handleCancel).not.toHaveBeenCalled();
wrapper.find('button[aria-label="Cancel"]').simulate('click');
await sleep(1);
expect(handleCancel).toHaveBeenCalled();
});
});