mirror of
https://github.com/ansible/awx.git
synced 2026-03-13 23:17:32 -02:30
Add project add form and tests
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
101
awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.test.jsx
Normal file
101
awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.test.jsx
Normal 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');
|
||||
});
|
||||
});
|
||||
485
awx/ui_next/src/screens/Project/shared/ProjectForm.jsx
Normal file
485
awx/ui_next/src/screens/Project/shared/ProjectForm.jsx
Normal 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));
|
||||
209
awx/ui_next/src/screens/Project/shared/ProjectForm.test.jsx
Normal file
209
awx/ui_next/src/screens/Project/shared/ProjectForm.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user