Add project add form and tests

This commit is contained in:
Marliana Lara
2019-10-28 10:24:22 -04:00
parent 9c019e1cc0
commit e4bde24f38
6 changed files with 876 additions and 11 deletions

View File

@@ -10,7 +10,16 @@ const QuestionCircleIcon = styled(PFQuestionCircleIcon)`
`;
function FormField(props) {
const { id, name, label, tooltip, validate, isRequired, ...rest } = props;
const {
id,
name,
label,
tooltip,
tooltipMaxWidth,
validate,
isRequired,
...rest
} = props;
return (
<Field
@@ -29,7 +38,11 @@ function FormField(props) {
label={label}
>
{tooltip && (
<Tooltip position="right" content={tooltip}>
<Tooltip
content={tooltip}
maxWidth={tooltipMaxWidth}
position="right"
>
<QuestionCircleIcon />
</Tooltip>
)}
@@ -58,6 +71,7 @@ FormField.propTypes = {
validate: PropTypes.func,
isRequired: PropTypes.bool,
tooltip: PropTypes.node,
tooltipMaxWidth: PropTypes.string,
};
FormField.defaultProps = {
@@ -65,6 +79,7 @@ FormField.defaultProps = {
validate: () => {},
isRequired: false,
tooltip: null,
tooltipMaxWidth: '',
};
export default FormField;

View File

@@ -6,6 +6,6 @@ const Row = styled.div`
grid-gap: 20px;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
`;
export default function FormRow({ children }) {
return <Row>{children}</Row>;
export default function FormRow({ children, className }) {
return <Row className={className}>{children}</Row>;
}

View File

@@ -1,10 +1,65 @@
import React, { Component } from 'react';
import { PageSection } from '@patternfly/react-core';
import React, { useState } from 'react';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import {
Card as _Card,
CardBody,
CardHeader,
PageSection,
Tooltip,
} from '@patternfly/react-core';
import CardCloseButton from '@components/CardCloseButton';
import ProjectForm from '../shared/ProjectForm';
import { ProjectsAPI } from '@api';
class ProjectAdd extends Component {
render() {
return <PageSection>Coming soon :)</PageSection>;
}
const Card = styled(_Card)`
--pf-c-card--child--PaddingLeft: 0;
--pf-c-card--child--PaddingRight: 0;
`;
function ProjectAdd({ history, i18n }) {
const [formSubmitError, setFormSubmitError] = useState(null);
const handleSubmit = async values => {
setFormSubmitError(null);
try {
const {
data: { id },
} = await ProjectsAPI.create(values);
history.push(`/projects/${id}/details`);
} catch (error) {
setFormSubmitError(error);
}
};
const handleCancel = () => {
history.push(`/projects`);
};
return (
<PageSection>
<Card>
<CardHeader css="text-align: right">
<Tooltip content={i18n._(t`Close`)} position="top">
<CardCloseButton onClick={handleCancel} />
</Tooltip>
</CardHeader>
<CardBody>
<ProjectForm
handleCancel={handleCancel}
handleSubmit={handleSubmit}
/>
</CardBody>
{formSubmitError ? (
<div className="formSubmitError">formSubmitError</div>
) : (
''
)}
</Card>
</PageSection>
);
}
export default ProjectAdd;
export default withI18n()(withRouter(ProjectAdd));

View File

@@ -0,0 +1,101 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import ProjectAdd from './ProjectAdd';
import { ProjectsAPI } from '@api';
jest.mock('@api');
describe('<ProjectAdd />', () => {
const projectData = {
name: 'foo',
description: 'bar',
scm_type: 'git',
scm_url: 'https://foo.bar',
scm_clean: true,
credential: 100,
organization: 2,
scm_update_on_launch: true,
scm_update_cache_timeout: 3,
allow_override: false,
custom_virtualenv: '/venv/custom-env',
};
afterEach(() => {
jest.clearAllMocks();
});
test('initially renders successfully', () => {
const wrapper = mountWithContexts(<ProjectAdd />);
expect(wrapper.length).toBe(1);
});
test('handleSubmit should post to the api', async () => {
ProjectsAPI.create.mockResolvedValueOnce({
data: { ...projectData },
});
let wrapper;
await act(async () => {
wrapper = mountWithContexts(<ProjectAdd />);
});
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
const formik = wrapper.find('Formik').instance();
const changeState = new Promise(resolve => {
formik.setState(
{
values: {
...projectData,
},
},
() => resolve()
);
});
await changeState;
wrapper.find('form').simulate('submit');
});
test('handleSubmit should throw an error', async () => {
ProjectsAPI.create.mockImplementation(() => Promise.reject(new Error()));
let wrapper;
await act(async () => {
wrapper = mountWithContexts(<ProjectAdd />);
});
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
const formik = wrapper.find('Formik').instance();
const changeState = new Promise(resolve => {
formik.setState(
{
values: {
...projectData,
},
},
() => resolve()
);
});
await changeState;
await act(async () => {
wrapper.find('form').simulate('submit');
});
wrapper.update();
expect(wrapper.find('ProjectAdd .formSubmitError').length).toBe(1);
});
test('CardHeader close button should navigate to projects list', () => {
const history = createMemoryHistory();
const wrapper = mountWithContexts(<ProjectAdd />, {
context: { router: { history } },
}).find('ProjectAdd CardHeader');
wrapper.find('CardCloseButton').simulate('click');
expect(history.location.pathname).toEqual('/projects');
});
test('CardBody cancel button should navigate to projects list', () => {
const history = createMemoryHistory();
const wrapper = mountWithContexts(<ProjectAdd />, {
context: { router: { history } },
}).find('ProjectAdd CardBody');
wrapper.find('button[aria-label="Cancel"]').simulate('click');
expect(history.location.pathname).toEqual('/projects');
});
});

View File

@@ -0,0 +1,485 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { withRouter, Link } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { withFormik, Field } from 'formik';
import { Config } from '@contexts/Config';
import {
Form as _Form,
FormGroup,
Title as _Title,
} from '@patternfly/react-core';
import AnsibleSelect from '@components/AnsibleSelect';
import FormActionGroup from '@components/FormActionGroup/FormActionGroup';
import FormField, { CheckboxField, FieldTooltip } from '@components/FormField';
import FormRow from '@components/FormRow';
import OrganizationLookup from '@components/Lookup/OrganizationLookup';
import CredentialLookup from '@components/Lookup/CredentialLookup';
import { required } from '@util/validators';
import styled from 'styled-components';
const Form = styled(_Form)`
padding: 0 24px;
`;
const ScmTypeFormRow = styled(FormRow)`
background-color: #f5f5f5;
grid-column: 1 / -1;
margin: 0 -24px;
padding: 24px;
`;
const OptionsFormGroup = styled.div`
grid-column: 1/-1;
`;
const Title = styled(_Title)`
--pf-c-title--m-md--FontWeight: 700;
grid-column: 1 / -1;
`;
function ProjectForm(props) {
const { values, handleCancel, handleSubmit, i18n } = props;
const [organization, setOrganization] = useState(null);
const [scmCredential, setScmCredential] = useState(null);
const [insightsCredential, setInsightsCredential] = useState(null);
const resetScmTypeFields = (value, form) => {
if (form.initialValues.scm_type === value) {
return;
}
const scmFormFields = [
'scm_url',
'scm_branch',
'scm_refspec',
'credential',
'scm_clean',
'scm_delete_on_update',
'scm_update_on_launch',
'allow_override',
'scm_update_cache_timeout',
];
scmFormFields.forEach(field => {
form.setFieldValue(field, form.initialValues[field]);
form.setFieldTouched(field, false);
});
};
const scmTypeOptions = [
{
value: '',
key: '',
label: i18n._(t`Choose a SCM Type`),
isDisabled: true,
},
{ value: 'manual', key: 'manual', label: i18n._(t`Manual`) },
{
value: 'git',
key: 'git',
label: i18n._(t`Git`),
},
{
value: 'hg',
key: 'hg',
label: i18n._(t`Mercurial`),
},
{
value: 'svn',
key: 'svn',
label: i18n._(t`Subversion`),
},
{
value: 'insights',
key: 'insights',
label: i18n._(t`Red Hat Insights`),
},
];
const gitScmTooltip = (
<span>
{i18n._(t`Example URLs for GIT SCM include:`)}
<ul css="margin: 10px 0 10px 20px">
<li>https://github.com/ansible/ansible.git</li>
<li>git@github.com:ansible/ansible.git</li>
<li>git://servername.example.com/ansible.git</li>
</ul>
{i18n._(t`Note: When using SSH protocol for GitHub or
Bitbucket, enter an SSH key only, do not enter a username
(other than git). Additionally, GitHub and Bitbucket do
not support password authentication when using SSH. GIT
read only protocol (git://) does not use username or
password information.`)}
</span>
);
const hgScmTooltip = (
<span>
{i18n._(t`Example URLs for Mercurial SCM include:`)}
<ul style={{ margin: '10px 0 10px 20px' }}>
<li>https://bitbucket.org/username/project</li>
<li>ssh://hg@bitbucket.org/username/project</li>
<li>ssh://server.example.com/path</li>
</ul>
{i18n._(t`Note: Mercurial does not support password authentication
for SSH. Do not put the username and key in the URL. If using
Bitbucket and SSH, do not supply your Bitbucket username.
`)}
</span>
);
const svnScmTooltip = (
<span>
{i18n._(t`Example URLs for Subversion SCM include:`)}
<ul style={{ margin: '10px 0 10px 20px' }}>
<li>https://github.com/ansible/ansible</li>
<li>svn://servername.example.com/path</li>
<li>svn+ssh://servername.example.com/path</li>
</ul>
</span>
);
const scmUrlTooltips = {
git: gitScmTooltip,
hg: hgScmTooltip,
svn: svnScmTooltip,
};
const scmBranchLabels = {
git: i18n._(t`SCM Branch/Tag/Commit`),
hg: i18n._(t`SCM Branch/Tag/Revision`),
svn: i18n._(t`Revision #`),
};
return (
<Form autoComplete="off" onSubmit={handleSubmit}>
<FormRow>
<FormField
id="project-name"
label={i18n._(t`Name`)}
name="name"
type="text"
validate={required(null, i18n)}
isRequired
/>
<FormField
id="project-description"
label={i18n._(t`Description`)}
name="description"
type="text"
/>
<Field
name="organization"
validate={required(i18n._(t`Select a value for this field`), i18n)}
render={({ form }) => {
return (
<OrganizationLookup
helperTextInvalid={form.errors.organization}
isValid={
!form.touched.organization || !form.errors.organization
}
onBlur={() => form.setFieldTouched('organization')}
onChange={value => {
form.setFieldValue('organization', value.id);
setOrganization(value);
}}
value={organization}
required
/>
);
}}
/>
<Field
name="scm_type"
validate={required(i18n._(t`Select a value for this field`), i18n)}
render={({ field, form }) => (
<FormGroup
fieldId="project-scm-type"
helperTextInvalid={form.errors.scm_type}
isRequired
isValid={!form.touched.scm_type || !form.errors.scm_type}
label={i18n._(t`SCM Type`)}
>
<AnsibleSelect
{...field}
id="scm_type"
data={scmTypeOptions}
onChange={(event, value) => {
form.setFieldValue('scm_type', value);
resetScmTypeFields(value, form);
}}
/>
</FormGroup>
)}
/>
{values.scm_type !== '' && (
<ScmTypeFormRow>
<Title size="md">{i18n._(t`Type Details`)}</Title>
{(values.scm_type === 'git' ||
values.scm_type === 'hg' ||
values.scm_type === 'svn') && (
<FormField
id="project-scm-url"
isRequired
label={i18n._(t`SCM URL`)}
name="scm_url"
type="text"
validate={required(null, i18n)}
tooltipMaxWidth="350px"
tooltip={scmUrlTooltips[values.scm_type]}
/>
)}
{(values.scm_type === 'git' ||
values.scm_type === 'hg' ||
values.scm_type === 'svn') && (
<FormField
id="project-scm-branch"
name="scm_branch"
type="text"
label={scmBranchLabels[values.scm_type]}
tooltip={i18n._(t`Branch to checkout. In addition to branches,
you can input tags, commit hashes, and arbitrary refs. Some
commit hashes and refs may not be availble unless you also
provide a custom refspec.`)}
/>
)}
{values.scm_type === 'git' && (
<FormField
id="project-scm-refspec"
label={i18n._(t`SCM Refspec`)}
name="scm_refspec"
type="text"
tooltipMaxWidth="400px"
tooltip={
<span>
{i18n._(t`A refspec to fetch (passed to the Ansible git
module). This parameter allows access to references via
the branch field not otherwise available.`)}
<br />
<br />
{i18n._(
t`Note: This field assumes the remote name is "origin".`
)}
<br />
<br />
{i18n._(t`Examples include:`)}
<ul style={{ margin: '10px 0 10px 20px' }}>
<li>refs/*:refs/remotes/origin/*</li>
<li>
refs/pull/62/head:refs/remotes/origin/pull/62/head
</li>
</ul>
{i18n._(t`The first fetches all references. The second
fetches the Github pull request number 62, in this example
the branch needs to be "pull/62/head".`)}
<br />
<br />
{i18n._(t`For more information, refer to the`)}{' '}
<Link to="https://docs.ansible.com/ansible-tower/latest/html/userguide/projects.html#manage-playbooks-using-source-control">
{i18n._(t`Ansible Tower Documentation.`)}
</Link>
</span>
}
/>
)}
{(values.scm_type === 'git' ||
values.scm_type === 'hg' ||
values.scm_type === 'svn') && (
<Field
name="credential"
render={({ form }) => (
<CredentialLookup
credentialTypeId={2}
label={i18n._(t`SCM Credential`)}
value={scmCredential}
onChange={value => {
form.setFieldValue('credential', value.id);
setScmCredential(value);
}}
/>
)}
/>
)}
{values.scm_type === 'insights' && (
<Field
name="credential"
validate={required(
i18n._(t`Select a value for this field`),
i18n
)}
render={({ form }) => (
<CredentialLookup
credentialTypeId={14}
label={i18n._(t`Insights Credential`)}
helperTextInvalid={form.errors.credential}
isValid={
!form.touched.credential || !form.errors.credential
}
onBlur={() => form.setFieldTouched('credential')}
onChange={value => {
form.setFieldValue('credential', value.id);
setInsightsCredential(value);
}}
value={insightsCredential}
required
/>
)}
/>
)}
{/*
PF Bug: FormGroup doesn't pass down className
Workaround is to wrap FormGroup with an extra div
Cleanup when upgraded to @patternfly/react-core@3.103.4
*/}
{values.scm_type !== 'manual' && (
<OptionsFormGroup>
<FormGroup
fieldId="project-option-checkboxes"
label={i18n._(t`Options`)}
>
<FormRow>
<CheckboxField
id="option-scm-clean"
name="scm_clean"
label={i18n._(t`Clean`)}
tooltip={i18n._(
t`Remove any local modifications prior to performing an update.`
)}
/>
<CheckboxField
id="option-scm-delete-on-update"
name="scm_delete_on_update"
label={i18n._(t`Delete`)}
tooltip={i18n._(
t`Delete the local repository in its entirety prior to
performing an update. Depending on the size of the
repository this may significantly increase the amount
of time required to complete an update.`
)}
/>
<CheckboxField
id="option-scm-update-on-launch"
name="scm_update_on_launch"
label={i18n._(t`Update Revision on Launch`)}
tooltip={i18n._(
t`Each time a job runs using this project, update the
revision of the project prior to starting the job.`
)}
/>
{values.scm_type !== 'insights' && (
<CheckboxField
id="option-allow-override"
name="allow_override"
label={i18n._(t`Allow Branch Override`)}
tooltip={i18n._(
t`Allow changing the SCM branch or revision in a job
template that uses this project.`
)}
/>
)}
</FormRow>
</FormGroup>
</OptionsFormGroup>
)}
{values.scm_type !== 'manual' && values.scm_update_on_launch && (
<>
<Title size="md">{i18n._(t`Option Details`)}</Title>
<FormField
id="project-cache-timeout"
name="scm_update_cache_timeout"
type="number"
min="0"
label={i18n._(t`Cache Timeout`)}
tooltip={i18n._(t`Time in seconds to consider a project
to be current. During job runs and callbacks the task
system will evaluate the timestamp of the latest project
update. If it is older than Cache Timeout, it is not
considered current, and a new project update will be
performed.`)}
/>
</>
)}
</ScmTypeFormRow>
)}
<Config>
{({ custom_virtualenvs }) =>
custom_virtualenvs &&
custom_virtualenvs.length > 1 && (
<Field
name="custom_virtualenv"
render={({ field }) => (
<FormGroup
fieldId="project-custom-virtualenv"
label={i18n._(t`Ansible Environment`)}
>
<FieldTooltip
content={i18n._(t`Select the playbook to be executed by
this job.`)}
/>
<AnsibleSelect
id="project-custom-virtualenv"
data={[
{
label: i18n._(t`Use Default Ansible Environment`),
value: '/venv/ansible/',
key: 'default',
},
...custom_virtualenvs
.filter(datum => datum !== '/venv/ansible/')
.map(datum => ({
label: datum,
value: datum,
key: datum,
})),
]}
{...field}
/>
</FormGroup>
)}
/>
)
}
</Config>
</FormRow>
<FormActionGroup onCancel={handleCancel} onSubmit={handleSubmit} />
</Form>
);
}
const FormikApp = withFormik({
mapPropsToValues(props) {
const { project = {} } = props;
return {
credential: project.credential || '',
custom_virtualenv: project.custom_virtualenv || '',
description: project.description || '',
name: project.name || '',
organization: project.organization || '',
scm_branch: project.scm_branch || '',
scm_clean: project.scm_clean || false,
scm_delete_on_update: project.scm_delete_on_update || false,
scm_refspec: project.scm_refspec || '',
scm_type: project.scm_type || '',
scm_update_on_launch: project.scm_update_on_launch || false,
scm_url: project.scm_url || '',
scm_update_cache_timeout: project.scm_update_cache_timeout || 0,
allow_override: project.allow_override || false,
};
},
handleSubmit: (values, { props }) => props.handleSubmit(values),
})(ProjectForm);
ProjectForm.propTypes = {
handleCancel: PropTypes.func.isRequired,
handleSubmit: PropTypes.func.isRequired,
project: PropTypes.shape({}),
};
ProjectForm.defaultProps = {
project: {},
};
export default withI18n()(withRouter(FormikApp));

View File

@@ -0,0 +1,209 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import { sleep } from '@testUtils/testUtils';
import ProjectForm from './ProjectForm';
jest.mock('@api');
describe('<ProjectAdd />', () => {
let wrapper;
const mockData = {
name: 'foo',
description: 'bar',
scm_type: 'git',
scm_url: 'https://foo.bar',
scm_clean: true,
credential: 100,
organization: 2,
scm_update_on_launch: true,
scm_update_cache_timeout: 3,
allow_override: false,
custom_virtualenv: '/venv/custom-env',
};
beforeEach(() => {
const config = {
custom_virtualenvs: ['venv/foo', 'venv/bar'],
};
wrapper = mountWithContexts(
<ProjectForm handleSubmit={jest.fn()} handleCancel={jest.fn()} />,
{
context: { config },
}
);
});
afterEach(() => {
jest.clearAllMocks();
});
test('initially renders successfully', () => {
expect(wrapper.find('ProjectForm').length).toBe(1);
});
test('new form displays primary form fields', () => {
expect(wrapper.find('FormGroup[label="Name"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="Description"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="Organization"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="SCM Type"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="Ansible Environment"]').length).toBe(
1
);
expect(wrapper.find('FormGroup[label="Options"]').length).toBe(0);
});
test('should display scm subform when scm type select has a value', async () => {
const formik = wrapper.find('Formik').instance();
const changeState = new Promise(resolve => {
formik.setState(
{
values: {
...mockData,
},
},
() => resolve()
);
});
await changeState;
wrapper.update();
expect(wrapper.find('FormGroup[label="SCM URL"]').length).toBe(1);
expect(
wrapper.find('FormGroup[label="SCM Branch/Tag/Commit"]').length
).toBe(1);
expect(wrapper.find('FormGroup[label="SCM Refspec"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="SCM Credential"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="Options"]').length).toBe(1);
});
test('inputs should update form value on change', async () => {
const project = { ...mockData };
await act(async () => {
wrapper = mountWithContexts(
<ProjectForm
handleSubmit={jest.fn()}
handleCancel={jest.fn()}
project={project}
/>
);
});
const form = wrapper.find('Formik');
act(() => {
wrapper.find('OrganizationLookup').invoke('onBlur')();
wrapper.find('OrganizationLookup').invoke('onChange')({
id: 1,
name: 'organization',
});
});
expect(form.state('values').organization).toEqual(1);
act(() => {
wrapper.find('CredentialLookup').invoke('onBlur')();
wrapper.find('CredentialLookup').invoke('onChange')({
id: 10,
name: 'credential',
});
});
expect(form.state('values').credential).toEqual(10);
});
test('should display insights credential lookup when scm type is "Insights"', async () => {
const formik = wrapper.find('Formik').instance();
const changeState = new Promise(resolve => {
formik.setState(
{
values: {
...mockData,
scm_type: 'insights',
},
},
() => resolve()
);
});
await changeState;
wrapper.update();
expect(wrapper.find('FormGroup[label="Insights Credential"]').length).toBe(
1
);
act(() => {
wrapper.find('CredentialLookup').invoke('onBlur')();
wrapper.find('CredentialLookup').invoke('onChange')({
id: 123,
name: 'credential',
});
});
expect(formik.state.values.credential).toEqual(123);
});
test('should reset scm subform values when scm type changes', async () => {
await act(async () => {
wrapper = mountWithContexts(
<ProjectForm
handleSubmit={jest.fn()}
handleCancel={jest.fn()}
project={{ scm_type: 'insights' }}
/>
);
});
const scmTypeSelect = wrapper.find(
'FormGroup[label="SCM Type"] FormSelect'
);
const formik = wrapper.find('Formik').instance();
expect(formik.state.values.scm_url).toEqual('');
await act(async () => {
scmTypeSelect.props().onChange('hg', { target: { name: 'Mercurial' } });
});
wrapper.update();
await act(async () => {
wrapper.find('FormGroup[label="SCM URL"] input').simulate('change', {
target: { value: 'baz', name: 'scm_url' },
});
});
expect(formik.state.values.scm_url).toEqual('baz');
await act(async () => {
scmTypeSelect
.props()
.onChange('insights', { target: { name: 'insights' } });
});
wrapper.update();
await act(async () => {
scmTypeSelect.props().onChange('git', { target: { name: 'insights' } });
});
wrapper.update();
expect(formik.state.values.scm_url).toEqual('');
});
test('should call handleSubmit when Submit button is clicked', async () => {
const handleSubmit = jest.fn();
await act(async () => {
wrapper = mountWithContexts(
<ProjectForm
project={mockData}
handleSubmit={handleSubmit}
handleCancel={jest.fn()}
/>
);
});
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
expect(handleSubmit).not.toHaveBeenCalled();
wrapper.find('button[aria-label="Save"]').simulate('click');
await sleep(1);
expect(handleSubmit).toBeCalled();
});
test('should call handleCancel when Cancel button is clicked', async () => {
const handleCancel = jest.fn();
await act(async () => {
wrapper = mountWithContexts(
<ProjectForm
project={mockData}
handleSubmit={jest.fn()}
handleCancel={handleCancel}
/>
);
});
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
expect(handleCancel).not.toHaveBeenCalled();
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
expect(handleCancel).toBeCalled();
});
});