Merge pull request #5127 from marshmalien/project-add-form

Project add form

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot] 2019-11-01 15:13:51 +00:00 committed by GitHub
commit 8cb32045f0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 1423 additions and 14 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

@ -0,0 +1,68 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { bool, func, number, string, oneOfType } from 'prop-types';
import { CredentialsAPI } from '@api';
import { Credential } from '@types';
import { mergeParams } from '@util/qs';
import { FormGroup } from '@patternfly/react-core';
import Lookup from '@components/Lookup';
function CredentialLookup({
helperTextInvalid,
label,
isValid,
onBlur,
onChange,
required,
credentialTypeId,
value,
}) {
const getCredentials = async params =>
CredentialsAPI.read(
mergeParams(params, { credential_type: credentialTypeId })
);
return (
<FormGroup
fieldId="credential"
isRequired={required}
isValid={isValid}
label={label}
helperTextInvalid={helperTextInvalid}
>
<Lookup
id="credential"
lookupHeader={label}
name="credential"
value={value}
onBlur={onBlur}
onLookupSave={onChange}
getItems={getCredentials}
required={required}
sortedColumnKey="name"
/>
</FormGroup>
);
}
CredentialLookup.propTypes = {
credentialTypeId: oneOfType([number, string]).isRequired,
helperTextInvalid: string,
isValid: bool,
label: string.isRequired,
onBlur: func,
onChange: func.isRequired,
required: bool,
value: Credential,
};
CredentialLookup.defaultProps = {
helperTextInvalid: '',
isValid: true,
onBlur: () => {},
required: false,
value: null,
};
export { CredentialLookup as _CredentialLookup };
export default withI18n()(CredentialLookup);

View File

@ -0,0 +1,41 @@
import React from 'react';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import CredentialLookup, { _CredentialLookup } from './CredentialLookup';
import { CredentialsAPI } from '@api';
jest.mock('@api');
describe('CredentialLookup', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(
<CredentialLookup credentialTypeId={1} label="Foo" onChange={() => {}} />
);
});
afterEach(() => {
jest.clearAllMocks();
});
test('initially renders successfully', () => {
expect(wrapper.find('CredentialLookup')).toHaveLength(1);
});
test('should fetch credentials', () => {
expect(CredentialsAPI.read).toHaveBeenCalledTimes(1);
expect(CredentialsAPI.read).toHaveBeenCalledWith({
credential_type: 1,
order_by: 'name',
page: 1,
page_size: 5,
});
});
test('should display label', () => {
const title = wrapper.find('FormGroup .pf-c-form__label-text');
expect(title.text()).toEqual('Foo');
});
test('should define default value for function props', () => {
expect(_CredentialLookup.defaultProps.onBlur).toBeInstanceOf(Function);
expect(_CredentialLookup.defaultProps.onBlur).not.toThrow();
});
});

View File

@ -101,7 +101,7 @@ class MultiCredentialsLookup extends React.Component {
const { selectedCredentialType, credentialTypes } = this.state;
const { tooltip, i18n, credentials } = this.props;
return (
<FormGroup label={i18n._(t`Credentials`)} fieldId="org-credentials">
<FormGroup label={i18n._(t`Credentials`)} fieldId="multiCredential">
{tooltip && (
<Tooltip position="right" content={tooltip}>
<QuestionCircleIcon />
@ -114,7 +114,7 @@ class MultiCredentialsLookup extends React.Component {
selectedCategory={selectedCredentialType}
onToggleItem={this.toggleCredentialSelection}
onloadCategories={this.loadCredentialTypes}
id="org-credentials"
id="multiCredential"
lookupHeader={i18n._(t`Credentials`)}
name="credentials"
value={credentials}

View File

@ -0,0 +1,62 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { string, func, bool } from 'prop-types';
import { OrganizationsAPI } from '@api';
import { Organization } from '@types';
import { FormGroup } from '@patternfly/react-core';
import Lookup from '@components/Lookup';
const getOrganizations = async params => OrganizationsAPI.read(params);
function OrganizationLookup({
helperTextInvalid,
i18n,
isValid,
onBlur,
onChange,
required,
value,
}) {
return (
<FormGroup
fieldId="organization"
helperTextInvalid={helperTextInvalid}
isRequired={required}
isValid={isValid}
label={i18n._(t`Organization`)}
>
<Lookup
id="organization"
lookupHeader={i18n._(t`Organization`)}
name="organization"
value={value}
onBlur={onBlur}
onLookupSave={onChange}
getItems={getOrganizations}
required={required}
sortedColumnKey="name"
/>
</FormGroup>
);
}
OrganizationLookup.propTypes = {
helperTextInvalid: string,
isValid: bool,
onBlur: func,
onChange: func.isRequired,
required: bool,
value: Organization,
};
OrganizationLookup.defaultProps = {
helperTextInvalid: '',
isValid: true,
onBlur: () => {},
required: false,
value: null,
};
export default withI18n()(OrganizationLookup);
export { OrganizationLookup as _OrganizationLookup };

View File

@ -0,0 +1,38 @@
import React from 'react';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import OrganizationLookup, { _OrganizationLookup } from './OrganizationLookup';
import { OrganizationsAPI } from '@api';
jest.mock('@api');
describe('OrganizationLookup', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<OrganizationLookup onChange={() => {}} />);
});
afterEach(() => {
jest.clearAllMocks();
});
test('initially renders successfully', () => {
expect(wrapper).toHaveLength(1);
});
test('should fetch organizations', () => {
expect(OrganizationsAPI.read).toHaveBeenCalledTimes(1);
expect(OrganizationsAPI.read).toHaveBeenCalledWith({
order_by: 'name',
page: 1,
page_size: 5,
});
});
test('should display "Organization" label', () => {
const title = wrapper.find('FormGroup .pf-c-form__label-text');
expect(title.text()).toEqual('Organization');
});
test('should define default value for function props', () => {
expect(_OrganizationLookup.defaultProps.onBlur).toBeInstanceOf(Function);
expect(_OrganizationLookup.defaultProps.onBlur).not.toThrow();
});
});

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,161 @@
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, CredentialTypesAPI } from '@api';
jest.mock('@api');
describe('<ProjectAdd />', () => {
let wrapper;
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',
};
const projectOptionsResolve = {
data: {
actions: {
GET: {
scm_type: {
choices: [
['', 'Manual'],
['git', 'Git'],
['hg', 'Mercurial'],
['svn', 'Subversion'],
['insights', 'Red Hat Insights'],
],
},
},
},
},
};
const scmCredentialResolve = {
data: {
results: [
{
id: 4,
name: 'Source Control',
kind: 'scm',
},
],
},
};
const insightsCredentialResolve = {
data: {
results: [
{
id: 5,
name: 'Insights',
kind: 'insights',
},
],
},
};
beforeEach(async () => {
await ProjectsAPI.readOptions.mockImplementation(
() => projectOptionsResolve
);
await CredentialTypesAPI.read.mockImplementationOnce(
() => scmCredentialResolve
);
await CredentialTypesAPI.read.mockImplementationOnce(
() => insightsCredentialResolve
);
});
afterEach(() => {
jest.clearAllMocks();
});
test('initially renders successfully', async () => {
await act(async () => {
wrapper = mountWithContexts(<ProjectAdd />);
});
expect(wrapper.length).toBe(1);
});
test('handleSubmit should post to the api', async () => {
ProjectsAPI.create.mockResolvedValueOnce({
data: { ...projectData },
});
await act(async () => {
wrapper = mountWithContexts(<ProjectAdd />);
});
await waitForElement(wrapper, 'ContentLoading', 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()));
await act(async () => {
wrapper = mountWithContexts(<ProjectAdd />);
});
await waitForElement(wrapper, 'ContentLoading', 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', async () => {
const history = createMemoryHistory();
await act(async () => {
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', async () => {
const history = createMemoryHistory();
await act(async () => {
wrapper = mountWithContexts(<ProjectAdd />, {
context: { router: { history } },
});
});
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
wrapper.find('ProjectAdd button[aria-label="Cancel"]').simulate('click');
expect(history.location.pathname).toEqual('/projects');
});
});

View File

@ -30,6 +30,7 @@ function ProjectDetail({ project, i18n }) {
scm_branch,
scm_clean,
scm_delete_on_update,
scm_refspec,
scm_type,
scm_update_on_launch,
scm_update_cache_timeout,
@ -98,6 +99,7 @@ function ProjectDetail({ project, i18n }) {
<Detail label={i18n._(t`SCM Type`)} value={scm_type} />
<Detail label={i18n._(t`SCM URL`)} value={scm_url} />
<Detail label={i18n._(t`SCM Branch`)} value={scm_branch} />
<Detail label={i18n._(t`SCM Refspec`)} value={scm_refspec} />
{summary_fields.credential && (
<Detail
label={i18n._(t`SCM Credential`)}

View File

@ -88,6 +88,7 @@ describe('<ProjectDetail />', () => {
assertDetail('SCM Type', mockProject.scm_type);
assertDetail('SCM URL', mockProject.scm_url);
assertDetail('SCM Branch', mockProject.scm_branch);
assertDetail('SCM Refspec', mockProject.scm_refspec);
assertDetail(
'SCM Credential',
`Scm: ${mockProject.summary_fields.credential.name}`

View File

@ -91,7 +91,7 @@ class ProjectListItem extends React.Component {
<Link
id={labelId}
to={`${detailUrl}`}
style={{ marginLeft: '10px' }}
css={{ marginLeft: '10px' }}
>
<b>{project.name}</b>
</Link>

View File

@ -0,0 +1,319 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Formik, Field } from 'formik';
import { Config } from '@contexts/Config';
import { Form, FormGroup } from '@patternfly/react-core';
import AnsibleSelect from '@components/AnsibleSelect';
import ContentError from '@components/ContentError';
import ContentLoading from '@components/ContentLoading';
import FormActionGroup from '@components/FormActionGroup/FormActionGroup';
import FormField, { FieldTooltip } from '@components/FormField';
import FormRow from '@components/FormRow';
import OrganizationLookup from '@components/Lookup/OrganizationLookup';
import { CredentialTypesAPI, ProjectsAPI } from '@api';
import { required } from '@util/validators';
import styled from 'styled-components';
import {
GitSubForm,
HgSubForm,
SvnSubForm,
InsightsSubForm,
SubFormTitle,
} from './ProjectSubForms';
const ScmTypeFormRow = styled(FormRow)`
background-color: #f5f5f5;
grid-column: 1 / -1;
margin: 0 -24px;
padding: 24px;
`;
function ProjectForm(props) {
const { project, handleCancel, handleSubmit, i18n } = props;
const [contentError, setContentError] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [organization, setOrganization] = useState(null);
const [scmTypeOptions, setScmTypeOptions] = useState(null);
const [scmCredential, setScmCredential] = useState({
typeId: null,
value: null,
});
const [insightsCredential, setInsightsCredential] = useState({
typeId: null,
value: null,
});
useEffect(() => {
async function fetchData() {
try {
const [
{
data: {
results: [scmCredentialType],
},
},
{
data: {
results: [insightsCredentialType],
},
},
{
data: {
actions: {
GET: {
scm_type: { choices },
},
},
},
},
] = await Promise.all([
CredentialTypesAPI.read({ kind: 'scm' }),
CredentialTypesAPI.read({ name: 'Insights' }),
ProjectsAPI.readOptions(),
]);
setScmCredential({ typeId: scmCredentialType.id });
setInsightsCredential({ typeId: insightsCredentialType.id });
setScmTypeOptions(choices);
} catch (error) {
setContentError(error);
} finally {
setIsLoading(false);
}
}
fetchData();
}, []);
const resetScmTypeFields = form => {
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);
});
};
if (isLoading) {
return <ContentLoading />;
}
if (contentError) {
return <ContentError error={contentError} />;
}
return (
<Formik
initialValues={{
allow_override: project.allow_override || false,
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_cache_timeout: project.scm_update_cache_timeout || 0,
scm_update_on_launch: project.scm_update_on_launch || false,
scm_url: project.scm_url || '',
}}
onSubmit={handleSubmit}
render={formik => (
<Form
autoComplete="off"
onSubmit={formik.handleSubmit}
css="padding: 0 24px"
>
<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 }) => (
<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={[
{
value: '',
key: '',
label: i18n._(t`Choose an SCM Type`),
isDisabled: true,
},
...scmTypeOptions.map(([value, label]) => {
if (label === 'Manual') {
value = 'manual';
}
return {
label,
value,
key: value,
};
}),
]}
onChange={(event, value) => {
form.setFieldValue('scm_type', value);
resetScmTypeFields(form);
}}
/>
</FormGroup>
)}
/>
{formik.values.scm_type !== '' && (
<ScmTypeFormRow>
<SubFormTitle size="md">{i18n._(t`Type Details`)}</SubFormTitle>
{
{
git: (
<GitSubForm
setScmCredential={setScmCredential}
scmCredential={scmCredential}
scmUpdateOnLaunch={formik.values.scm_update_on_launch}
/>
),
hg: (
<HgSubForm
setScmCredential={setScmCredential}
scmCredential={scmCredential}
scmUpdateOnLaunch={formik.values.scm_update_on_launch}
/>
),
svn: (
<SvnSubForm
setScmCredential={setScmCredential}
scmCredential={scmCredential}
scmUpdateOnLaunch={formik.values.scm_update_on_launch}
/>
),
insights: (
<InsightsSubForm
setInsightsCredential={setInsightsCredential}
insightsCredential={insightsCredential}
scmUpdateOnLaunch={formik.values.scm_update_on_launch}
/>
),
}[formik.values.scm_type]
}
</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={formik.handleSubmit}
/>
</Form>
)}
/>
);
}
ProjectForm.propTypes = {
handleCancel: PropTypes.func.isRequired,
handleSubmit: PropTypes.func.isRequired,
project: PropTypes.shape({}),
};
ProjectForm.defaultProps = {
project: {},
};
export default withI18n()(ProjectForm);

View File

@ -0,0 +1,297 @@
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';
import { CredentialTypesAPI, ProjectsAPI } from '@api';
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',
};
const projectOptionsResolve = {
data: {
actions: {
GET: {
scm_type: {
choices: [
['', 'Manual'],
['git', 'Git'],
['hg', 'Mercurial'],
['svn', 'Subversion'],
['insights', 'Red Hat Insights'],
],
},
},
},
},
};
const scmCredentialResolve = {
data: {
results: [
{
id: 4,
name: 'Source Control',
kind: 'scm',
},
],
},
};
const insightsCredentialResolve = {
data: {
results: [
{
id: 5,
name: 'Insights',
kind: 'insights',
},
],
},
};
beforeEach(async () => {
await ProjectsAPI.readOptions.mockImplementation(
() => projectOptionsResolve
);
await CredentialTypesAPI.read.mockImplementationOnce(
() => scmCredentialResolve
);
await CredentialTypesAPI.read.mockImplementationOnce(
() => insightsCredentialResolve
);
});
afterEach(() => {
wrapper.unmount();
jest.clearAllMocks();
});
test('initially renders successfully', async () => {
await act(async () => {
wrapper = mountWithContexts(
<ProjectForm handleSubmit={jest.fn()} handleCancel={jest.fn()} />
);
});
expect(wrapper.find('ProjectForm').length).toBe(1);
});
test('new form displays primary form fields', async () => {
const config = {
custom_virtualenvs: ['venv/foo', 'venv/bar'],
};
await act(async () => {
wrapper = mountWithContexts(
<ProjectForm handleSubmit={jest.fn()} handleCancel={jest.fn()} />,
{
context: { config },
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
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 () => {
await act(async () => {
wrapper = mountWithContexts(
<ProjectForm handleSubmit={jest.fn()} handleCancel={jest.fn()} />
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
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}
/>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
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 () => {
await act(async () => {
wrapper = mountWithContexts(
<ProjectForm handleSubmit={jest.fn()} handleCancel={jest.fn()} />
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
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' }}
/>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
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('svn', { target: { name: 'Subversion' } });
});
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, 'ContentLoading', 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, 'ContentLoading', el => el.length === 0);
expect(handleCancel).not.toHaveBeenCalled();
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
expect(handleCancel).toBeCalled();
});
test('should display ContentError on throw', async () => {
CredentialTypesAPI.read = () => Promise.reject(new Error());
await act(async () => {
wrapper = mountWithContexts(
<ProjectForm handleSubmit={jest.fn()} handleCancel={jest.fn()} />
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('ContentError').length).toBe(1);
});
});

View File

@ -0,0 +1,84 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import FormField from '@components/FormField';
import {
UrlFormField,
BranchFormField,
ScmCredentialFormField,
ScmTypeOptions,
} from './SharedFields';
const GitSubForm = ({
i18n,
scmCredential,
setScmCredential,
scmUpdateOnLaunch,
}) => (
<>
<UrlFormField
i18n={i18n}
tooltip={
<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>
}
/>
<BranchFormField i18n={i18n} label={i18n._(t`SCM Branch/Tag/Commit`)} />
<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 css={{ 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`)}{' '}
<a
target="_blank"
rel="noopener noreferrer"
href="https://docs.ansible.com/ansible-tower/latest/html/userguide/projects.html#manage-playbooks-using-source-control"
>
{i18n._(t`Ansible Tower Documentation.`)}
</a>
</span>
}
/>
<ScmCredentialFormField
setScmCredential={setScmCredential}
scmCredential={scmCredential}
/>
<ScmTypeOptions scmUpdateOnLaunch={scmUpdateOnLaunch} />
</>
);
export default withI18n()(GitSubForm);

View File

@ -0,0 +1,44 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
UrlFormField,
BranchFormField,
ScmCredentialFormField,
ScmTypeOptions,
} from './SharedFields';
const HgSubForm = ({
i18n,
scmCredential,
setScmCredential,
scmUpdateOnLaunch,
}) => (
<>
<UrlFormField
i18n={i18n}
tooltip={
<span>
{i18n._(t`Example URLs for Mercurial SCM include:`)}
<ul css={{ 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>
}
/>
<BranchFormField i18n={i18n} label={i18n._(t`SCM Branch/Tag/Revision`)} />
<ScmCredentialFormField
setScmCredential={setScmCredential}
scmCredential={scmCredential}
/>
<ScmTypeOptions scmUpdateOnLaunch={scmUpdateOnLaunch} />
</>
);
export default withI18n()(HgSubForm);

View File

@ -0,0 +1,42 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Field } from 'formik';
import CredentialLookup from '@components/Lookup/CredentialLookup';
import { required } from '@util/validators';
import { ScmTypeOptions } from './SharedFields';
const InsightsSubForm = ({
i18n,
setInsightsCredential,
insightsCredential,
scmUpdateOnLaunch,
}) => (
<>
<Field
name="credential"
validate={required(i18n._(t`Select a value for this field`), i18n)}
render={({ form }) => (
<CredentialLookup
credentialTypeId={insightsCredential.typeId}
label={i18n._(t`Insights Credential`)}
helperTextInvalid={form.errors.credential}
isValid={!form.touched.credential || !form.errors.credential}
onBlur={() => form.setFieldTouched('credential')}
onChange={credential => {
form.setFieldValue('credential', credential.id);
setInsightsCredential({
...insightsCredential,
value: credential,
});
}}
value={insightsCredential.value}
required
/>
)}
/>
<ScmTypeOptions hideAllowOverride scmUpdateOnLaunch={scmUpdateOnLaunch} />
</>
);
export default withI18n()(InsightsSubForm);

View File

@ -0,0 +1,135 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Field } from 'formik';
import CredentialLookup from '@components/Lookup/CredentialLookup';
import FormField, { CheckboxField } from '@components/FormField';
import { required } from '@util/validators';
import FormRow from '@components/FormRow';
import { FormGroup, Title } from '@patternfly/react-core';
import styled from 'styled-components';
export const SubFormTitle = styled(Title)`
--pf-c-title--m-md--FontWeight: 700;
grid-column: 1 / -1;
`;
export const UrlFormField = withI18n()(({ i18n, tooltip }) => (
<FormField
id="project-scm-url"
isRequired
label={i18n._(t`SCM URL`)}
name="scm_url"
tooltip={tooltip}
tooltipMaxWidth="350px"
type="text"
validate={required(null, i18n)}
/>
));
export const BranchFormField = withI18n()(({ i18n, label }) => (
<FormField
id="project-scm-branch"
name="scm_branch"
type="text"
label={label}
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.`)}
/>
));
export const ScmCredentialFormField = withI18n()(
({ i18n, setScmCredential, scmCredential }) => (
<Field
name="credential"
render={({ form }) => (
<CredentialLookup
credentialTypeId={scmCredential.typeId}
label={i18n._(t`SCM Credential`)}
value={scmCredential.value}
onChange={credential => {
form.setFieldValue('credential', credential.id);
setScmCredential({
...scmCredential,
value: credential,
});
}}
/>
)}
/>
)
);
export const ScmTypeOptions = withI18n()(
({ i18n, scmUpdateOnLaunch, hideAllowOverride }) => (
<>
<FormGroup
css="grid-column: 1/-1"
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.`
)}
/>
{!hideAllowOverride && (
<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>
{scmUpdateOnLaunch && (
<>
<SubFormTitle size="md">{i18n._(t`Option Details`)}</SubFormTitle>
<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.`)}
/>
</>
)}
</>
)
);

View File

@ -0,0 +1,40 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
UrlFormField,
BranchFormField,
ScmCredentialFormField,
ScmTypeOptions,
} from './SharedFields';
const SvnSubForm = ({
i18n,
scmCredential,
setScmCredential,
scmUpdateOnLaunch,
}) => (
<>
<UrlFormField
i18n={i18n}
tooltip={
<span>
{i18n._(t`Example URLs for Subversion SCM include:`)}
<ul css={{ 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>
}
/>
<BranchFormField i18n={i18n} label={i18n._(t`Revision #`)} />
<ScmCredentialFormField
setScmCredential={setScmCredential}
scmCredential={scmCredential}
/>
<ScmTypeOptions scmUpdateOnLaunch={scmUpdateOnLaunch} />
</>
);
export default withI18n()(SvnSubForm);

View File

@ -0,0 +1,5 @@
export { default as GitSubForm } from './GitSubForm';
export { default as HgSubForm } from './HgSubForm';
export { default as SvnSubForm } from './SvnSubForm';
export { default as InsightsSubForm } from './InsightsSubForm';
export { SubFormTitle } from './SharedFields';