Merge pull request #5225 from marshmalien/project-edit

Add Project Edit form

Reviewed-by: Marliana Lara <marliana.lara@gmail.com>
             https://github.com/marshmalien
This commit is contained in:
softwarefactory-project-zuul[bot]
2019-11-05 18:21:55 +00:00
committed by GitHub
10 changed files with 371 additions and 98 deletions

View File

@@ -108,7 +108,11 @@ describe('<ProjectAdd />', () => {
); );
}); });
await changeState; await changeState;
wrapper.find('form').simulate('submit'); await act(async () => {
wrapper.find('form').simulate('submit');
});
wrapper.update();
expect(ProjectsAPI.create).toHaveBeenCalledTimes(1);
}); });
test('handleSubmit should throw an error', async () => { test('handleSubmit should throw an error', async () => {

View File

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

View File

@@ -0,0 +1,153 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import ProjectEdit from './ProjectEdit';
import { ProjectsAPI, CredentialTypesAPI } from '@api';
jest.mock('@api');
describe('<ProjectEdit />', () => {
let wrapper;
const projectData = {
id: 123,
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',
summary_fields: {
credential: {
id: 100,
credential_type_id: 5,
kind: 'insights',
},
},
};
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(<ProjectEdit project={projectData} />);
});
expect(wrapper.length).toBe(1);
});
test('handleSubmit should post to the api', async () => {
const history = createMemoryHistory();
ProjectsAPI.update.mockResolvedValueOnce({
data: { ...projectData },
});
await act(async () => {
wrapper = mountWithContexts(<ProjectEdit project={projectData} />, {
context: { router: { history } },
});
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
await act(async () => {
wrapper.find('form').simulate('submit');
});
wrapper.update();
expect(ProjectsAPI.update).toHaveBeenCalledTimes(1);
});
test('handleSubmit should throw an error', async () => {
ProjectsAPI.update.mockImplementation(() => Promise.reject(new Error()));
await act(async () => {
wrapper = mountWithContexts(<ProjectEdit project={projectData} />);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
await act(async () => {
wrapper.find('form').simulate('submit');
});
wrapper.update();
expect(ProjectsAPI.update).toHaveBeenCalledTimes(1);
expect(wrapper.find('ProjectEdit .formSubmitError').length).toBe(1);
});
test('CardHeader close button should navigate to project details', async () => {
const history = createMemoryHistory();
await act(async () => {
wrapper = mountWithContexts(<ProjectEdit project={projectData} />, {
context: { router: { history } },
});
});
wrapper.find('CardCloseButton').simulate('click');
expect(history.location.pathname).toEqual('/projects/123/details');
});
test('CardBody cancel button should navigate to project details', async () => {
const history = createMemoryHistory();
await act(async () => {
wrapper = mountWithContexts(<ProjectEdit project={projectData} />, {
context: { router: { history } },
});
});
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
wrapper.find('ProjectEdit button[aria-label="Cancel"]').simulate('click');
expect(history.location.pathname).toEqual('/projects/123/details');
});
});

View File

@@ -1,3 +1,4 @@
/* eslint no-nested-ternary: 0 */
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
@@ -30,52 +31,74 @@ const ScmTypeFormRow = styled(FormRow)`
padding: 24px; padding: 24px;
`; `;
function ProjectForm(props) { const fetchCredentials = async credential => {
const { project, handleCancel, handleSubmit, i18n } = props; const [
{
data: {
results: [scmCredentialType],
},
},
{
data: {
results: [insightsCredentialType],
},
},
] = await Promise.all([
CredentialTypesAPI.read({ kind: 'scm' }),
CredentialTypesAPI.read({ name: 'Insights' }),
]);
if (!credential) {
return {
scm: { typeId: scmCredentialType.id },
insights: { typeId: insightsCredentialType.id },
};
}
const { credential_type_id } = credential;
return {
scm: {
typeId: scmCredentialType.id,
value: credential_type_id === scmCredentialType.id ? credential : null,
},
insights: {
typeId: insightsCredentialType.id,
value:
credential_type_id === insightsCredentialType.id ? credential : null,
},
};
};
function ProjectForm({ project, ...props }) {
const { i18n, handleCancel, handleSubmit } = props;
const { summary_fields = {} } = project;
const [contentError, setContentError] = useState(null); const [contentError, setContentError] = useState(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [organization, setOrganization] = useState(null); const [organization, setOrganization] = useState(
summary_fields.organization || null
);
const [scmSubFormState, setScmSubFormState] = useState(null);
const [scmTypeOptions, setScmTypeOptions] = useState(null); const [scmTypeOptions, setScmTypeOptions] = useState(null);
const [scmCredential, setScmCredential] = useState({ const [credentials, setCredentials] = useState({
typeId: null, scm: { typeId: null, value: null },
value: null, insights: { typeId: null, value: null },
});
const [insightsCredential, setInsightsCredential] = useState({
typeId: null,
value: null,
}); });
useEffect(() => { useEffect(() => {
async function fetchData() { async function fetchData() {
try { try {
const [ const credentialResponse = fetchCredentials(summary_fields.credential);
{ const {
data: { data: {
results: [scmCredentialType], actions: {
}, GET: {
}, scm_type: { choices },
{
data: {
results: [insightsCredentialType],
},
},
{
data: {
actions: {
GET: {
scm_type: { choices },
},
}, },
}, },
}, },
] = await Promise.all([ } = await ProjectsAPI.readOptions();
CredentialTypesAPI.read({ kind: 'scm' }),
CredentialTypesAPI.read({ name: 'Insights' }),
ProjectsAPI.readOptions(),
]);
setScmCredential({ typeId: scmCredentialType.id }); setCredentials(await credentialResponse);
setInsightsCredential({ typeId: insightsCredentialType.id });
setScmTypeOptions(choices); setScmTypeOptions(choices);
} catch (error) { } catch (error) {
setContentError(error); setContentError(error);
@@ -87,22 +110,57 @@ function ProjectForm(props) {
fetchData(); fetchData();
}, []); }, []);
const resetScmTypeFields = form => { const scmFormFields = {
const scmFormFields = [ scm_url: '',
'scm_url', scm_branch: '',
'scm_branch', scm_refspec: '',
'scm_refspec', credential: '',
'credential', scm_clean: false,
'scm_clean', scm_delete_on_update: false,
'scm_delete_on_update', scm_update_on_launch: false,
'scm_update_on_launch', allow_override: false,
'allow_override', scm_update_cache_timeout: 0,
'scm_update_cache_timeout', };
];
scmFormFields.forEach(field => { /* Save current scm subform field values to state */
form.setFieldValue(field, form.initialValues[field]); const saveSubFormState = form => {
form.setFieldTouched(field, false); const currentScmFormFields = { ...scmFormFields };
Object.keys(currentScmFormFields).forEach(label => {
currentScmFormFields[label] = form.values[label];
});
setScmSubFormState(currentScmFormFields);
};
/**
* If scm type is !== the initial scm type value,
* reset scm subform field values to defaults.
* If scm type is === the initial scm type value,
* reset scm subform field values to scmSubFormState.
*/
const resetScmTypeFields = (value, form) => {
if (form.values.scm_type === form.initialValues.scm_type) {
saveSubFormState(form);
}
Object.keys(scmFormFields).forEach(label => {
if (value === form.initialValues.scm_type) {
form.setFieldValue(label, scmSubFormState[label]);
} else {
form.setFieldValue(label, scmFormFields[label]);
}
form.setFieldTouched(label, false);
});
};
const handleCredentialSelection = (type, value) => {
setCredentials({
...credentials,
[type]: {
...credentials[type],
value,
},
}); });
}; };
@@ -127,7 +185,12 @@ function ProjectForm(props) {
scm_clean: project.scm_clean || false, scm_clean: project.scm_clean || false,
scm_delete_on_update: project.scm_delete_on_update || false, scm_delete_on_update: project.scm_delete_on_update || false,
scm_refspec: project.scm_refspec || '', scm_refspec: project.scm_refspec || '',
scm_type: project.scm_type || '', 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_cache_timeout: project.scm_update_cache_timeout || 0,
scm_update_on_launch: project.scm_update_on_launch || false, scm_update_on_launch: project.scm_update_on_launch || false,
scm_url: project.scm_url || '', scm_url: project.scm_url || '',
@@ -213,7 +276,7 @@ function ProjectForm(props) {
]} ]}
onChange={(event, value) => { onChange={(event, value) => {
form.setFieldValue('scm_type', value); form.setFieldValue('scm_type', value);
resetScmTypeFields(form); resetScmTypeFields(value, form);
}} }}
/> />
</FormGroup> </FormGroup>
@@ -226,29 +289,29 @@ function ProjectForm(props) {
{ {
git: ( git: (
<GitSubForm <GitSubForm
setScmCredential={setScmCredential} credential={credentials.scm}
scmCredential={scmCredential} onCredentialSelection={handleCredentialSelection}
scmUpdateOnLaunch={formik.values.scm_update_on_launch} scmUpdateOnLaunch={formik.values.scm_update_on_launch}
/> />
), ),
hg: ( hg: (
<HgSubForm <HgSubForm
setScmCredential={setScmCredential} credential={credentials.scm}
scmCredential={scmCredential} onCredentialSelection={handleCredentialSelection}
scmUpdateOnLaunch={formik.values.scm_update_on_launch} scmUpdateOnLaunch={formik.values.scm_update_on_launch}
/> />
), ),
svn: ( svn: (
<SvnSubForm <SvnSubForm
setScmCredential={setScmCredential} credential={credentials.scm}
scmCredential={scmCredential} onCredentialSelection={handleCredentialSelection}
scmUpdateOnLaunch={formik.values.scm_update_on_launch} scmUpdateOnLaunch={formik.values.scm_update_on_launch}
/> />
), ),
insights: ( insights: (
<InsightsSubForm <InsightsSubForm
setInsightsCredential={setInsightsCredential} credential={credentials.insights}
insightsCredential={insightsCredential} onCredentialSelection={handleCredentialSelection}
scmUpdateOnLaunch={formik.values.scm_update_on_launch} scmUpdateOnLaunch={formik.values.scm_update_on_launch}
/> />
), ),

View File

@@ -21,6 +21,13 @@ describe('<ProjectAdd />', () => {
scm_update_cache_timeout: 3, scm_update_cache_timeout: 3,
allow_override: false, allow_override: false,
custom_virtualenv: '/venv/custom-env', custom_virtualenv: '/venv/custom-env',
summary_fields: {
credential: {
id: 100,
credential_type_id: 4,
kind: 'scm',
},
},
}; };
const projectOptionsResolve = { const projectOptionsResolve = {

View File

@@ -11,8 +11,8 @@ import {
const GitSubForm = ({ const GitSubForm = ({
i18n, i18n,
scmCredential, credential,
setScmCredential, onCredentialSelection,
scmUpdateOnLaunch, scmUpdateOnLaunch,
}) => ( }) => (
<> <>
@@ -74,8 +74,8 @@ const GitSubForm = ({
} }
/> />
<ScmCredentialFormField <ScmCredentialFormField
setScmCredential={setScmCredential} credential={credential}
scmCredential={scmCredential} onCredentialSelection={onCredentialSelection}
/> />
<ScmTypeOptions scmUpdateOnLaunch={scmUpdateOnLaunch} /> <ScmTypeOptions scmUpdateOnLaunch={scmUpdateOnLaunch} />
</> </>

View File

@@ -10,8 +10,8 @@ import {
const HgSubForm = ({ const HgSubForm = ({
i18n, i18n,
scmCredential, credential,
setScmCredential, onCredentialSelection,
scmUpdateOnLaunch, scmUpdateOnLaunch,
}) => ( }) => (
<> <>
@@ -34,8 +34,8 @@ const HgSubForm = ({
/> />
<BranchFormField i18n={i18n} label={i18n._(t`SCM Branch/Tag/Revision`)} /> <BranchFormField i18n={i18n} label={i18n._(t`SCM Branch/Tag/Revision`)} />
<ScmCredentialFormField <ScmCredentialFormField
setScmCredential={setScmCredential} credential={credential}
scmCredential={scmCredential} onCredentialSelection={onCredentialSelection}
/> />
<ScmTypeOptions scmUpdateOnLaunch={scmUpdateOnLaunch} /> <ScmTypeOptions scmUpdateOnLaunch={scmUpdateOnLaunch} />
</> </>

View File

@@ -8,8 +8,8 @@ import { ScmTypeOptions } from './SharedFields';
const InsightsSubForm = ({ const InsightsSubForm = ({
i18n, i18n,
setInsightsCredential, credential,
insightsCredential, onCredentialSelection,
scmUpdateOnLaunch, scmUpdateOnLaunch,
}) => ( }) => (
<> <>
@@ -18,19 +18,16 @@ const InsightsSubForm = ({
validate={required(i18n._(t`Select a value for this field`), i18n)} validate={required(i18n._(t`Select a value for this field`), i18n)}
render={({ form }) => ( render={({ form }) => (
<CredentialLookup <CredentialLookup
credentialTypeId={insightsCredential.typeId} credentialTypeId={credential.typeId}
label={i18n._(t`Insights Credential`)} label={i18n._(t`Insights Credential`)}
helperTextInvalid={form.errors.credential} helperTextInvalid={form.errors.credential}
isValid={!form.touched.credential || !form.errors.credential} isValid={!form.touched.credential || !form.errors.credential}
onBlur={() => form.setFieldTouched('credential')} onBlur={() => form.setFieldTouched('credential')}
onChange={credential => { onChange={value => {
form.setFieldValue('credential', credential.id); onCredentialSelection('insights', value);
setInsightsCredential({ form.setFieldValue('credential', value.id);
...insightsCredential,
value: credential,
});
}} }}
value={insightsCredential.value} value={credential.value}
required required
/> />
)} )}

View File

@@ -41,20 +41,17 @@ export const BranchFormField = withI18n()(({ i18n, label }) => (
)); ));
export const ScmCredentialFormField = withI18n()( export const ScmCredentialFormField = withI18n()(
({ i18n, setScmCredential, scmCredential }) => ( ({ i18n, credential, onCredentialSelection }) => (
<Field <Field
name="credential" name="credential"
render={({ form }) => ( render={({ form }) => (
<CredentialLookup <CredentialLookup
credentialTypeId={scmCredential.typeId} credentialTypeId={credential.typeId}
label={i18n._(t`SCM Credential`)} label={i18n._(t`SCM Credential`)}
value={scmCredential.value} value={credential.value}
onChange={credential => { onChange={value => {
form.setFieldValue('credential', credential.id); onCredentialSelection('scm', value);
setScmCredential({ form.setFieldValue('credential', value.id);
...scmCredential,
value: credential,
});
}} }}
/> />
)} )}

View File

@@ -10,8 +10,8 @@ import {
const SvnSubForm = ({ const SvnSubForm = ({
i18n, i18n,
scmCredential, credential,
setScmCredential, onCredentialSelection,
scmUpdateOnLaunch, scmUpdateOnLaunch,
}) => ( }) => (
<> <>
@@ -30,8 +30,8 @@ const SvnSubForm = ({
/> />
<BranchFormField i18n={i18n} label={i18n._(t`Revision #`)} /> <BranchFormField i18n={i18n} label={i18n._(t`Revision #`)} />
<ScmCredentialFormField <ScmCredentialFormField
setScmCredential={setScmCredential} credential={credential}
scmCredential={scmCredential} onCredentialSelection={onCredentialSelection}
/> />
<ScmTypeOptions scmUpdateOnLaunch={scmUpdateOnLaunch} /> <ScmTypeOptions scmUpdateOnLaunch={scmUpdateOnLaunch} />
</> </>