mirror of
https://github.com/ansible/awx.git
synced 2026-04-05 10:09:20 -02:30
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:
@@ -10,7 +10,16 @@ const QuestionCircleIcon = styled(PFQuestionCircleIcon)`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
function FormField(props) {
|
function FormField(props) {
|
||||||
const { id, name, label, tooltip, validate, isRequired, ...rest } = props;
|
const {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
tooltip,
|
||||||
|
tooltipMaxWidth,
|
||||||
|
validate,
|
||||||
|
isRequired,
|
||||||
|
...rest
|
||||||
|
} = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Field
|
<Field
|
||||||
@@ -29,7 +38,11 @@ function FormField(props) {
|
|||||||
label={label}
|
label={label}
|
||||||
>
|
>
|
||||||
{tooltip && (
|
{tooltip && (
|
||||||
<Tooltip position="right" content={tooltip}>
|
<Tooltip
|
||||||
|
content={tooltip}
|
||||||
|
maxWidth={tooltipMaxWidth}
|
||||||
|
position="right"
|
||||||
|
>
|
||||||
<QuestionCircleIcon />
|
<QuestionCircleIcon />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
@@ -58,6 +71,7 @@ FormField.propTypes = {
|
|||||||
validate: PropTypes.func,
|
validate: PropTypes.func,
|
||||||
isRequired: PropTypes.bool,
|
isRequired: PropTypes.bool,
|
||||||
tooltip: PropTypes.node,
|
tooltip: PropTypes.node,
|
||||||
|
tooltipMaxWidth: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|
||||||
FormField.defaultProps = {
|
FormField.defaultProps = {
|
||||||
@@ -65,6 +79,7 @@ FormField.defaultProps = {
|
|||||||
validate: () => {},
|
validate: () => {},
|
||||||
isRequired: false,
|
isRequired: false,
|
||||||
tooltip: null,
|
tooltip: null,
|
||||||
|
tooltipMaxWidth: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default FormField;
|
export default FormField;
|
||||||
|
|||||||
@@ -6,6 +6,6 @@ const Row = styled.div`
|
|||||||
grid-gap: 20px;
|
grid-gap: 20px;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
`;
|
`;
|
||||||
export default function FormRow({ children }) {
|
export default function FormRow({ children, className }) {
|
||||||
return <Row>{children}</Row>;
|
return <Row className={className}>{children}</Row>;
|
||||||
}
|
}
|
||||||
|
|||||||
68
awx/ui_next/src/components/Lookup/CredentialLookup.jsx
Normal file
68
awx/ui_next/src/components/Lookup/CredentialLookup.jsx
Normal 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);
|
||||||
41
awx/ui_next/src/components/Lookup/CredentialLookup.test.jsx
Normal file
41
awx/ui_next/src/components/Lookup/CredentialLookup.test.jsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -101,7 +101,7 @@ class MultiCredentialsLookup extends React.Component {
|
|||||||
const { selectedCredentialType, credentialTypes } = this.state;
|
const { selectedCredentialType, credentialTypes } = this.state;
|
||||||
const { tooltip, i18n, credentials } = this.props;
|
const { tooltip, i18n, credentials } = this.props;
|
||||||
return (
|
return (
|
||||||
<FormGroup label={i18n._(t`Credentials`)} fieldId="org-credentials">
|
<FormGroup label={i18n._(t`Credentials`)} fieldId="multiCredential">
|
||||||
{tooltip && (
|
{tooltip && (
|
||||||
<Tooltip position="right" content={tooltip}>
|
<Tooltip position="right" content={tooltip}>
|
||||||
<QuestionCircleIcon />
|
<QuestionCircleIcon />
|
||||||
@@ -114,7 +114,7 @@ class MultiCredentialsLookup extends React.Component {
|
|||||||
selectedCategory={selectedCredentialType}
|
selectedCategory={selectedCredentialType}
|
||||||
onToggleItem={this.toggleCredentialSelection}
|
onToggleItem={this.toggleCredentialSelection}
|
||||||
onloadCategories={this.loadCredentialTypes}
|
onloadCategories={this.loadCredentialTypes}
|
||||||
id="org-credentials"
|
id="multiCredential"
|
||||||
lookupHeader={i18n._(t`Credentials`)}
|
lookupHeader={i18n._(t`Credentials`)}
|
||||||
name="credentials"
|
name="credentials"
|
||||||
value={credentials}
|
value={credentials}
|
||||||
|
|||||||
62
awx/ui_next/src/components/Lookup/OrganizationLookup.jsx
Normal file
62
awx/ui_next/src/components/Lookup/OrganizationLookup.jsx
Normal 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 };
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,65 @@
|
|||||||
import React, { Component } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { PageSection } from '@patternfly/react-core';
|
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 {
|
const Card = styled(_Card)`
|
||||||
render() {
|
--pf-c-card--child--PaddingLeft: 0;
|
||||||
return <PageSection>Coming soon :)</PageSection>;
|
--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));
|
||||||
|
|||||||
161
awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.test.jsx
Normal file
161
awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.test.jsx
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -30,6 +30,7 @@ function ProjectDetail({ project, i18n }) {
|
|||||||
scm_branch,
|
scm_branch,
|
||||||
scm_clean,
|
scm_clean,
|
||||||
scm_delete_on_update,
|
scm_delete_on_update,
|
||||||
|
scm_refspec,
|
||||||
scm_type,
|
scm_type,
|
||||||
scm_update_on_launch,
|
scm_update_on_launch,
|
||||||
scm_update_cache_timeout,
|
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 Type`)} value={scm_type} />
|
||||||
<Detail label={i18n._(t`SCM URL`)} value={scm_url} />
|
<Detail label={i18n._(t`SCM URL`)} value={scm_url} />
|
||||||
<Detail label={i18n._(t`SCM Branch`)} value={scm_branch} />
|
<Detail label={i18n._(t`SCM Branch`)} value={scm_branch} />
|
||||||
|
<Detail label={i18n._(t`SCM Refspec`)} value={scm_refspec} />
|
||||||
{summary_fields.credential && (
|
{summary_fields.credential && (
|
||||||
<Detail
|
<Detail
|
||||||
label={i18n._(t`SCM Credential`)}
|
label={i18n._(t`SCM Credential`)}
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ describe('<ProjectDetail />', () => {
|
|||||||
assertDetail('SCM Type', mockProject.scm_type);
|
assertDetail('SCM Type', mockProject.scm_type);
|
||||||
assertDetail('SCM URL', mockProject.scm_url);
|
assertDetail('SCM URL', mockProject.scm_url);
|
||||||
assertDetail('SCM Branch', mockProject.scm_branch);
|
assertDetail('SCM Branch', mockProject.scm_branch);
|
||||||
|
assertDetail('SCM Refspec', mockProject.scm_refspec);
|
||||||
assertDetail(
|
assertDetail(
|
||||||
'SCM Credential',
|
'SCM Credential',
|
||||||
`Scm: ${mockProject.summary_fields.credential.name}`
|
`Scm: ${mockProject.summary_fields.credential.name}`
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ class ProjectListItem extends React.Component {
|
|||||||
<Link
|
<Link
|
||||||
id={labelId}
|
id={labelId}
|
||||||
to={`${detailUrl}`}
|
to={`${detailUrl}`}
|
||||||
style={{ marginLeft: '10px' }}
|
css={{ marginLeft: '10px' }}
|
||||||
>
|
>
|
||||||
<b>{project.name}</b>
|
<b>{project.name}</b>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
319
awx/ui_next/src/screens/Project/shared/ProjectForm.jsx
Normal file
319
awx/ui_next/src/screens/Project/shared/ProjectForm.jsx
Normal 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);
|
||||||
297
awx/ui_next/src/screens/Project/shared/ProjectForm.test.jsx
Normal file
297
awx/ui_next/src/screens/Project/shared/ProjectForm.test.jsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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.`)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -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);
|
||||||
@@ -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';
|
||||||
Reference in New Issue
Block a user