Add project manual scm type subform

This commit is contained in:
Marliana Lara 2019-11-06 10:51:36 -05:00
parent 922723cf39
commit e4721d7722
No known key found for this signature in database
GPG Key ID: 38C73B40DFA809EE
13 changed files with 456 additions and 204 deletions

View File

@ -97,7 +97,13 @@ class App extends Component {
MeAPI.read(),
]);
const {
data: { ansible_version, custom_virtualenvs, version },
data: {
ansible_version,
custom_virtualenvs,
project_base_dir,
project_local_paths,
version,
},
} = configRes;
const {
data: {
@ -105,7 +111,14 @@ class App extends Component {
},
} = meRes;
this.setState({ ansible_version, custom_virtualenvs, version, me });
this.setState({
ansible_version,
custom_virtualenvs,
project_base_dir,
project_local_paths,
version,
me,
});
} catch (err) {
this.setState({ configError: err });
}
@ -115,6 +128,8 @@ class App extends Component {
const {
ansible_version,
custom_virtualenvs,
project_base_dir,
project_local_paths,
isAboutModalOpen,
isNavOpen,
me,
@ -169,7 +184,14 @@ class App extends Component {
<Fragment>
<Page usecondensed="True" header={header} sidebar={sidebar}>
<ConfigProvider
value={{ ansible_version, custom_virtualenvs, me, version }}
value={{
ansible_version,
custom_virtualenvs,
project_base_dir,
project_local_paths,
me,
version,
}}
>
{render({ routeGroups })}
</ConfigProvider>

View File

@ -189,10 +189,6 @@
z-index: 20;
}
.pf-c-alert__icon {
--pf-c-alert__icon--Color: white;
}
.at-u-textRight {
text-align: right;
}

View File

@ -23,6 +23,9 @@ function ProjectAdd({ history, i18n }) {
const [formSubmitError, setFormSubmitError] = useState(null);
const handleSubmit = async values => {
if (values.scm_type === 'manual') {
values.scm_type = '';
}
setFormSubmitError(null);
try {
const {

View File

@ -16,6 +16,7 @@ describe('<ProjectAdd />', () => {
scm_url: 'https://foo.bar',
scm_clean: true,
credential: 100,
local_path: '',
organization: 2,
scm_update_on_launch: true,
scm_update_cache_timeout: 3,
@ -116,9 +117,15 @@ describe('<ProjectAdd />', () => {
});
test('handleSubmit should throw an error', async () => {
const config = {
project_local_paths: ['foobar', 'qux'],
project_base_dir: 'dir/foo/bar',
};
ProjectsAPI.create.mockImplementation(() => Promise.reject(new Error()));
await act(async () => {
wrapper = mountWithContexts(<ProjectAdd />);
wrapper = mountWithContexts(<ProjectAdd />, {
context: { config },
});
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
const formik = wrapper.find('Formik').instance();
@ -127,6 +134,7 @@ describe('<ProjectAdd />', () => {
{
values: {
...projectData,
scm_type: 'manual',
},
},
() => resolve()

View File

@ -5,9 +5,11 @@ import { t } from '@lingui/macro';
import styled from 'styled-components';
import { Project } from '@types';
import { formatDateString } from '@util/dates';
import { Config } from '@contexts/Config';
import { Button, CardBody, List, ListItem } from '@patternfly/react-core';
import { DetailList, Detail } from '@components/DetailList';
import { CredentialChip } from '@components/Chip';
import { toTitleCase } from '@util/strings';
const ActionButtonWrapper = styled.div`
display: flex;
@ -25,6 +27,7 @@ function ProjectDetail({ project, i18n }) {
custom_virtualenv,
description,
id,
local_path,
modified,
name,
scm_branch,
@ -93,10 +96,21 @@ function ProjectDetail({ project, i18n }) {
{summary_fields.organization && (
<Detail
label={i18n._(t`Organization`)}
value={summary_fields.organization.name}
value={
<Link
to={`/organizations/${summary_fields.organization.id}/details`}
>
{summary_fields.organization.name}
</Link>
}
/>
)}
<Detail label={i18n._(t`SCM Type`)} value={scm_type} />
<Detail
label={i18n._(t`SCM Type`)}
value={
scm_type === '' ? i18n._(t`Manual`) : toTitleCase(project.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} />
@ -123,6 +137,15 @@ function ProjectDetail({ project, i18n }) {
label={i18n._(t`Ansible Environment`)}
value={custom_virtualenv}
/>
<Config>
{({ project_base_dir }) => (
<Detail
label={i18n._(t`Project Base Path`)}
value={project_base_dir}
/>
)}
</Config>
<Detail label={i18n._(t`Playbook Directory`)} value={local_path} />
{/* TODO: Link to user in users */}
<Detail label={i18n._(t`Created`)} value={createdBy} />
{/* TODO: Link to user in users */}

View File

@ -85,7 +85,7 @@ describe('<ProjectDetail />', () => {
assertDetail('Name', mockProject.name);
assertDetail('Description', mockProject.description);
assertDetail('Organization', mockProject.summary_fields.organization.name);
assertDetail('SCM Type', mockProject.scm_type);
assertDetail('SCM Type', 'Git');
assertDetail('SCM URL', mockProject.scm_url);
assertDetail('SCM Branch', mockProject.scm_branch);
assertDetail('SCM Refspec', mockProject.scm_refspec);

View File

@ -22,6 +22,9 @@ function ProjectEdit({ project, history, i18n }) {
const [formSubmitError, setFormSubmitError] = useState(null);
const handleSubmit = async values => {
if (values.scm_type === 'manual') {
values.scm_type = '';
}
try {
const {
data: { id },

View File

@ -17,6 +17,7 @@ describe('<ProjectEdit />', () => {
scm_url: 'https://foo.bar',
scm_clean: true,
credential: 100,
local_path: '',
organization: 2,
scm_update_on_launch: true,
scm_update_cache_timeout: 3,
@ -115,9 +116,18 @@ describe('<ProjectEdit />', () => {
});
test('handleSubmit should throw an error', async () => {
const config = {
project_local_paths: [],
project_base_dir: 'foo/bar',
};
ProjectsAPI.update.mockImplementation(() => Promise.reject(new Error()));
await act(async () => {
wrapper = mountWithContexts(<ProjectEdit project={projectData} />);
wrapper = mountWithContexts(
<ProjectEdit project={{ ...projectData, scm_type: 'manual' }} />,
{
context: { config },
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
await act(async () => {

View File

@ -19,6 +19,7 @@ import ListActionButton from '@components/ListActionButton';
import ProjectSyncButton from '../shared/ProjectSyncButton';
import { StatusIcon } from '@components/Sparkline';
import VerticalSeparator from '@components/VerticalSeparator';
import { toTitleCase } from '@util/strings';
import { Project } from '@types';
class ProjectListItem extends React.Component {
@ -97,7 +98,9 @@ class ProjectListItem extends React.Component {
</Link>
</DataListCell>,
<DataListCell key="type">
{project.scm_type.toUpperCase()}
{project.scm_type === ''
? i18n._(t`Manual`)
: toTitleCase(project.scm_type)}
</DataListCell>,
<DataListCell key="revision">
{project.scm_revision.substring(0, 7)}

View File

@ -22,6 +22,7 @@ import {
SvnSubForm,
InsightsSubForm,
SubFormTitle,
ManualSubForm,
} from './ProjectSubForms';
const ScmTypeFormRow = styled(FormRow)`
@ -173,199 +174,224 @@ function ProjectForm({ project, ...props }) {
}
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 === ''
? 'manual'
: project.scm_type === undefined
? ''
: 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}
<Config>
{({ project_base_dir, project_local_paths }) => (
<Formik
initialValues={{
allow_override: project.allow_override || false,
base_dir: project_base_dir || '',
credential: project.credential || '',
custom_virtualenv: project.custom_virtualenv || '',
description: project.description || '',
local_path: project.local_path || null,
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 === ''
? 'manual'
: project.scm_type === undefined
? ''
: 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
isValid={!form.touched.scm_type || !form.errors.scm_type}
label={i18n._(t`SCM Type`)}
>
<AnsibleSelect
{...field}
id="scm_type"
data={[
/>
<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(value, form);
}}
/>
</FormGroup>
)}
/>
{formik.values.scm_type !== '' && (
<ScmTypeFormRow>
<SubFormTitle size="md">
{i18n._(t`Type Details`)}
</SubFormTitle>
{
{
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(value, form);
}}
/>
</FormGroup>
)}
/>
{formik.values.scm_type !== '' && (
<ScmTypeFormRow>
<SubFormTitle size="md">{i18n._(t`Type Details`)}</SubFormTitle>
{
{
git: (
<GitSubForm
credential={credentials.scm}
onCredentialSelection={handleCredentialSelection}
scmUpdateOnLaunch={formik.values.scm_update_on_launch}
manual: (
<ManualSubForm
localPath={formik.initialValues.local_path}
project_base_dir={project_base_dir}
project_local_paths={project_local_paths}
/>
),
git: (
<GitSubForm
credential={credentials.scm}
onCredentialSelection={handleCredentialSelection}
scmUpdateOnLaunch={
formik.values.scm_update_on_launch
}
/>
),
hg: (
<HgSubForm
credential={credentials.scm}
onCredentialSelection={handleCredentialSelection}
scmUpdateOnLaunch={
formik.values.scm_update_on_launch
}
/>
),
svn: (
<SvnSubForm
credential={credentials.scm}
onCredentialSelection={handleCredentialSelection}
scmUpdateOnLaunch={
formik.values.scm_update_on_launch
}
/>
),
insights: (
<InsightsSubForm
credential={credentials.insights}
onCredentialSelection={handleCredentialSelection}
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>
)}
/>
),
hg: (
<HgSubForm
credential={credentials.scm}
onCredentialSelection={handleCredentialSelection}
scmUpdateOnLaunch={formik.values.scm_update_on_launch}
/>
),
svn: (
<SvnSubForm
credential={credentials.scm}
onCredentialSelection={handleCredentialSelection}
scmUpdateOnLaunch={formik.values.scm_update_on_launch}
/>
),
insights: (
<InsightsSubForm
credential={credentials.insights}
onCredentialSelection={handleCredentialSelection}
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>
)
}
</Config>
</FormRow>
<FormActionGroup
onCancel={handleCancel}
onSubmit={formik.handleSubmit}
/>
</Form>
)}
/>
)}
/>
</Config>
);
}

View File

@ -26,6 +26,7 @@ describe('<ProjectAdd />', () => {
id: 100,
credential_type_id: 4,
kind: 'scm',
name: 'alpha',
},
},
};
@ -216,6 +217,59 @@ describe('<ProjectAdd />', () => {
expect(formik.state.values.credential).toEqual(123);
});
test('manual subform should display expected fields', async () => {
const config = {
project_local_paths: ['foobar', 'qux'],
project_base_dir: 'dir/foo/bar',
};
await act(async () => {
wrapper = mountWithContexts(
<ProjectForm
handleSubmit={jest.fn()}
handleCancel={jest.fn()}
project={{ scm_type: '', local_path: '/_foo__bar' }}
/>,
{
context: { config },
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
const playbookDirectorySelect = wrapper.find(
'FormGroup[label="Playbook Directory"] FormSelect'
);
await act(async () => {
playbookDirectorySelect
.props()
.onChange('foobar', { target: { name: 'foobar' } });
});
expect(wrapper.find('FormGroup[label="Project Base Path"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="Playbook Directory"]').length).toBe(
1
);
});
test('manual subform should display warning message when playbook directory is empty', async () => {
const config = {
project_local_paths: [],
project_base_dir: 'dir/foo/bar',
};
await act(async () => {
wrapper = mountWithContexts(
<ProjectForm
handleSubmit={jest.fn()}
handleCancel={jest.fn()}
project={{ scm_type: '', local_path: '' }}
/>,
{
context: { config },
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('ManualSubForm Alert').length).toBe(1);
});
test('should reset scm subform values when scm type changes', async () => {
await act(async () => {
wrapper = mountWithContexts(

View File

@ -0,0 +1,103 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Field } from 'formik';
import AnsibleSelect from '@components/AnsibleSelect';
import FormField, { FieldTooltip } from '@components/FormField';
import { FormGroup, Alert } from '@patternfly/react-core';
import { BrandName } from '../../../../variables';
// Setting BrandName to a variable here is necessary to get the jest tests
// passing. Attempting to use BrandName in the template literal results
// in failing tests.
const brandName = BrandName;
const ManualSubForm = ({
i18n,
localPath,
project_base_dir,
project_local_paths,
}) => {
const localPaths = [...new Set([...project_local_paths, localPath])];
const options = [
{
value: '',
key: '',
label: i18n._(t`Choose a Playbook Directory`),
},
...localPaths
.filter(path => path)
.map(path => ({
value: path,
key: path,
label: path,
})),
];
return (
<>
{options.length === 1 && (
<Alert
title={i18n._(t`WARNING: `)}
css="grid-column: 1/-1"
variant="warning"
isInline
>
{i18n._(t`
There are no available playbook directories in ${project_base_dir}.
Either that directory is empty, or all of the contents are already
assigned to other projects. Create a new directory there and make
sure the playbook files can be read by the "awx" system user,
or have ${brandName} directly retrieve your playbooks from
source control using the SCM Type option above.`)}
</Alert>
)}
<FormField
id="project-base-dir"
label={i18n._(t`Project Base Path`)}
name="base_dir"
type="text"
isReadOnly
tooltip={
<span>
{i18n._(t`Base path used for locating playbooks. Directories
found inside this path will be listed in the playbook directory drop-down.
Together the base path and selected playbook directory provide the full
path used to locate playbooks.`)}
<br />
<br />
{i18n._(t`Change PROJECTS_ROOT when deploying
${brandName} to change this location.`)}
</span>
}
/>
{options.length !== 1 && (
<Field
name="local_path"
render={({ field, form }) => (
<FormGroup
fieldId="project-local-path"
label={i18n._(t`Playbook Directory`)}
>
<FieldTooltip
content={i18n._(t`Select from the list of directories found in
the Project Base Path. Together the base path and the playbook
directory provide the full path used to locate playbooks.`)}
/>
<AnsibleSelect
{...field}
id="local_path"
data={options}
onChange={(event, value) => {
form.setFieldValue('local_path', value);
}}
/>
</FormGroup>
)}
/>
)}
</>
);
};
export default withI18n()(ManualSubForm);

View File

@ -1,5 +1,6 @@
export { SubFormTitle } from './SharedFields';
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';
export { default as ManualSubForm } from './ManualSubForm';
export { default as SvnSubForm } from './SvnSubForm';