Merge pull request #291 from marshmalien/skeleton-template-edit-form

Skeleton template edit form
This commit is contained in:
Marliana Lara 2019-06-26 10:33:22 -04:00 committed by GitHub
commit a503529d05
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 523 additions and 65 deletions

View File

@ -20,7 +20,8 @@ class AnsibleSelect extends React.Component {
}
render () {
const { label, value, data, defaultSelected, i18n } = this.props;
const { value, data, i18n } = this.props;
return (
<FormSelect
value={value}
@ -28,15 +29,12 @@ class AnsibleSelect extends React.Component {
aria-label={i18n._(t`Select Input`)}
>
{data.map((datum) => (
datum === defaultSelected ? (
<FormSelectOption
key=""
value=""
label={i18n._(t`Use Default ${label}`)}
/>
) : (
<FormSelectOption key={datum} value={datum} label={datum} />
)
<FormSelectOption
key={datum.key}
value={datum.value}
label={datum.label}
isDisabled={datum.isDisabled}
/>
))}
</FormSelect>
);
@ -45,15 +43,10 @@ class AnsibleSelect extends React.Component {
AnsibleSelect.defaultProps = {
data: [],
label: 'Ansible Select',
defaultSelected: null,
};
AnsibleSelect.propTypes = {
data: PropTypes.arrayOf(PropTypes.string),
defaultSelected: PropTypes.string,
label: PropTypes.string,
name: PropTypes.string.isRequired,
data: PropTypes.arrayOf(PropTypes.object),
onChange: PropTypes.func.isRequired,
value: PropTypes.string.isRequired,
};

View File

@ -2,8 +2,17 @@ import React from 'react';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import AnsibleSelect, { _AnsibleSelect } from './AnsibleSelect';
const label = 'test select';
const mockData = ['/venv/baz/', '/venv/ansible/'];
const mockData = [
{
label: 'Baz',
value: '/venv/baz/'
},
{
label: 'Default',
value: '/venv/ansible/'
}
];
describe('<AnsibleSelect />', () => {
test('initially renders succesfully', async () => {
mountWithContexts(
@ -11,7 +20,6 @@ describe('<AnsibleSelect />', () => {
value="foo"
name="bar"
onChange={() => { }}
label={label}
data={mockData}
/>
);
@ -24,7 +32,6 @@ describe('<AnsibleSelect />', () => {
value="foo"
name="bar"
onChange={() => { }}
label={label}
data={mockData}
/>
);
@ -33,17 +40,17 @@ describe('<AnsibleSelect />', () => {
expect(spy).toHaveBeenCalled();
});
test('Returns correct select options if defaultSelected props is passed', () => {
test('Returns correct select options', () => {
const wrapper = mountWithContexts(
<AnsibleSelect
value="foo"
name="bar"
onChange={() => { }}
label={label}
data={mockData}
defaultSelected={mockData[1]}
/>
);
expect(wrapper.find('FormSelect')).toHaveLength(1);
expect(wrapper.find('FormSelectOption')).toHaveLength(2);
});
});

View File

@ -4,29 +4,31 @@ import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
ActionGroup as PFActionGroup,
Toolbar,
ToolbarGroup,
Button
} from '@patternfly/react-core';
import styled from 'styled-components';
const ActionGroup = styled(PFActionGroup)`
display: flex;
flex-direction: row;
justify-content: flex-end;
--pf-c-form__group--m-action--MarginTop: 0;
display: flex;
justify-content: flex-end;
--pf-c-form__group--m-action--MarginTop: 0;
.pf-c-form__actions {
display: grid;
gap: 24px;
grid-template-columns: auto auto;
margin: 0;
& > button {
margin: 0;
}
}
`;
const FormActionGroup = ({ onSubmit, submitDisabled, onCancel, i18n }) => (
<ActionGroup>
<Toolbar>
<ToolbarGroup css="margin-right: 20px">
<Button aria-label={i18n._(t`Save`)} variant="primary" type="submit" onClick={onSubmit} isDisabled={submitDisabled}>{i18n._(t`Save`)}</Button>
</ToolbarGroup>
<ToolbarGroup>
<Button aria-label={i18n._(t`Cancel`)} variant="secondary" type="button" onClick={onCancel}>{i18n._(t`Cancel`)}</Button>
</ToolbarGroup>
</Toolbar>
<Button aria-label={i18n._(t`Save`)} variant="primary" type="submit" onClick={onSubmit} isDisabled={submitDisabled}>{i18n._(t`Save`)}</Button>
<Button aria-label={i18n._(t`Cancel`)} variant="secondary" type="button" onClick={onCancel}>{i18n._(t`Cancel`)}</Button>
</ActionGroup>
);

View File

@ -1,10 +1,16 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Field } from 'formik';
import { FormGroup, TextInput } from '@patternfly/react-core';
import { FormGroup, TextInput, Tooltip } from '@patternfly/react-core';
import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons';
import styled from 'styled-components';
const QuestionCircleIcon = styled(PFQuestionCircleIcon)`
margin-left: 10px;
`;
function FormField (props) {
const { id, name, label, validate, isRequired, ...rest } = props;
const { id, name, label, tooltip, validate, isRequired, ...rest } = props;
return (
<Field
@ -21,6 +27,15 @@ function FormField (props) {
isValid={isValid}
label={label}
>
{tooltip && (
<Tooltip
position="right"
content={tooltip}
>
<QuestionCircleIcon />
</Tooltip>
)}
<TextInput
id={id}
isRequired={isRequired}
@ -45,12 +60,14 @@ FormField.propTypes = {
type: PropTypes.string,
validate: PropTypes.func,
isRequired: PropTypes.bool,
tooltip: PropTypes.string,
};
FormField.defaultProps = {
type: 'text',
validate: () => {},
isRequired: false,
tooltip: null
};
export default FormField;

View File

@ -105,7 +105,8 @@ describe('<OrganizationAdd />', () => {
{ context: { config } }
).find('AnsibleSelect');
expect(wrapper.find('FormSelect')).toHaveLength(1);
expect(wrapper.find('FormSelectOption')).toHaveLength(2);
expect(wrapper.find('FormSelectOption')).toHaveLength(3);
expect(wrapper.find('FormSelectOption').first().prop('value')).toEqual('/venv/ansible/');
});
test('AnsibleSelect component does not render if there are 0 virtual environments', () => {

View File

@ -93,7 +93,11 @@ class OrganizationForm extends Component {
render () {
const { organization, handleCancel, i18n, me } = this.props;
const { instanceGroups, formIsValid, error } = this.state;
const defaultVenv = '/venv/ansible/';
const defaultVenv = {
label: i18n._(t`Use Default Ansible Environment`),
value: '/venv/ansible/',
key: 'default'
};
return (
<Formik
@ -133,7 +137,10 @@ class OrganizationForm extends Component {
{(
<Tooltip
position="right"
content="The maximum number of hosts allowed to be managed by this organization. Value defaults to 0 which means no limit. Refer to the Ansible documentation for more details."
content={i18n._(t`The maximum number of hosts allowed
to be managed by this organization. Value defaults to
0 which means no limit. Refer to the Ansible
documentation for more details.`)}
>
<QuestionCircleIcon />
</Tooltip>
@ -156,9 +163,10 @@ class OrganizationForm extends Component {
label={i18n._(t`Ansible Environment`)}
>
<AnsibleSelect
data={custom_virtualenvs}
defaultSelected={defaultVenv}
label={i18n._(t`Ansible Environment`)}
data={[defaultVenv, ...custom_virtualenvs
.filter(datum => datum !== defaultVenv.value)
.map(datum => ({ label: datum, value: datum, key: datum }))
]}
{...field}
/>
</FormGroup>

View File

@ -144,7 +144,8 @@ describe('<OrganizationForm />', () => {
}
);
expect(wrapper.find('FormSelect')).toHaveLength(1);
expect(wrapper.find('FormSelectOption')).toHaveLength(2);
expect(wrapper.find('FormSelectOption')).toHaveLength(3);
expect(wrapper.find('FormSelectOption').first().prop('value')).toEqual('/venv/ansible/');
});
test('calls handleSubmit when form submitted', async () => {

View File

@ -75,6 +75,7 @@ class JobTemplateDetail extends Component {
},
hasTemplateLoading,
i18n,
match,
} = this.props;
const { instanceGroups, hasContentLoading, contentError } = this.state;
const verbosityOptions = [
@ -307,7 +308,7 @@ class JobTemplateDetail extends Component {
{summary_fields.user_capabilities.edit && (
<Button
component={Link}
to="/home"
to={`/templates/${match.params.templateType}/${match.params.id}/edit`}
aria-label={i18n._(t`Edit`)}
>

View File

@ -1,37 +1,58 @@
import React, { Component } from 'react';
import { t } from '@lingui/macro';
import { withI18n } from '@lingui/react';
import { Card, CardHeader, PageSection } from '@patternfly/react-core';
import { Switch, Route, Redirect, withRouter } from 'react-router-dom';
import {
Card,
CardHeader,
PageSection,
} from '@patternfly/react-core';
import {
Switch,
Route,
Redirect,
withRouter,
} from 'react-router-dom';
import CardCloseButton from '@components/CardCloseButton';
import ContentError from '@components/ContentError';
import RoutedTabs from '@components/RoutedTabs';
import JobTemplateDetail from './JobTemplateDetail';
import { JobTemplatesAPI } from '@api';
import TemplateEdit from './TemplateEdit';
class Template extends Component {
constructor (props) {
super(props);
this.state = {
hasContentError: false,
hasContentLoading: true,
template: {}
template: null,
};
this.readTemplate = this.readTemplate.bind(this);
this.loadTemplate = this.loadTemplate.bind(this);
}
componentDidMount () {
this.readTemplate();
async componentDidMount () {
await this.loadTemplate();
}
async readTemplate () {
async componentDidUpdate (prevProps) {
const { location } = this.props;
if (location !== prevProps.location) {
await this.loadTemplate();
}
}
async loadTemplate () {
const { setBreadcrumb, match } = this.props;
const { id } = match.params;
this.setState({ hasContentError: false, hasContentLoading: true });
try {
const { data } = await JobTemplatesAPI.readDetail(id);
setBreadcrumb(data);
this.setState({ template: data });
this.setState({
template: data,
});
} catch {
this.setState({ hasContentError: true });
} finally {
@ -40,8 +61,17 @@ class Template extends Component {
}
render () {
const { match, i18n, history } = this.props;
const { hasContentLoading, template, hasContentError } = this.state;
const {
history,
i18n,
location,
match,
} = this.props;
const {
hasContentError,
hasContentLoading,
template
} = this.state;
const tabsArray = [
{ name: i18n._(t`Details`), link: `${match.url}/details`, id: 0 },
@ -51,7 +81,8 @@ class Template extends Component {
{ name: i18n._(t`Completed Jobs`), link: '/home', id: 4 },
{ name: i18n._(t`Survey`), link: '/home', id: 5 }
];
const cardHeader = (hasContentLoading ? null
let cardHeader = (hasContentLoading ? null
: (
<CardHeader style={{ padding: 0 }}>
<RoutedTabs
@ -63,6 +94,10 @@ class Template extends Component {
)
);
if (location.pathname.endsWith('edit')) {
cardHeader = null;
}
if (!hasContentLoading && hasContentError) {
return (
<PageSection>
@ -94,11 +129,22 @@ class Template extends Component {
)}
/>
)}
{template && (
<Route
path="/templates/:templateType/:id/edit"
render={() => (
<TemplateEdit
template={template}
/>
)}
/>
)}
</Switch>
</Card>
</PageSection>
);
}
}
export { Template as _Template };
export default withI18n()(withRouter(Template));

View File

@ -7,10 +7,10 @@ describe('<Template />', () => {
mountWithContexts(<Template />);
});
test('When component mounts API is called and the response is put in state', async (done) => {
const readTemplate = jest.spyOn(_Template.prototype, 'readTemplate');
const loadTemplate = jest.spyOn(_Template.prototype, 'loadTemplate');
const wrapper = mountWithContexts(<Template />);
await waitForElement(wrapper, 'Template', (el) => el.state('hasContentLoading') === true);
expect(readTemplate).toHaveBeenCalled();
expect(loadTemplate).toHaveBeenCalled();
await waitForElement(wrapper, 'Template', (el) => el.state('hasContentLoading') === true);
done();
});

View File

@ -0,0 +1,63 @@
import React, { Component } from 'react';
import { withRouter, Redirect } from 'react-router-dom';
import { CardBody } from '@patternfly/react-core';
import TemplateForm from '../shared/TemplateForm';
import { JobTemplatesAPI } from '@api';
import { JobTemplate } from '@types';
class TemplateEdit extends Component {
static propTypes = {
template: JobTemplate.isRequired,
};
constructor (props) {
super(props);
this.state = {
error: ''
};
this.handleCancel = this.handleCancel.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
async handleSubmit (values) {
const { template: { id, type }, history } = this.props;
try {
await JobTemplatesAPI.update(id, { ...values });
history.push(`/templates/${type}/${id}/details`);
} catch (error) {
this.setState({ error });
}
}
handleCancel () {
const { template: { id, type }, history } = this.props;
history.push(`/templates/${type}/${id}/details`);
}
render () {
const { template } = this.props;
const { error } = this.state;
const canEdit = template.summary_fields.user_capabilities.edit;
if (!canEdit) {
const { template: { id, type } } = this.props;
return <Redirect to={`/templates/${type}/${id}/details`} />;
}
return (
<CardBody>
<TemplateForm
template={template}
handleCancel={this.handleCancel}
handleSubmit={this.handleSubmit}
/>
{error ? <div> error </div> : null}
</CardBody>
);
}
}
export default withRouter(TemplateEdit);

View File

@ -0,0 +1,64 @@
import React from 'react';
import { JobTemplatesAPI } from '@api';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import TemplateEdit from './TemplateEdit';
jest.mock('@api');
describe('<TemplateEdit />', () => {
const mockData = {
id: 1,
name: 'Foo',
description: 'Bar',
job_type: 'run',
inventory: 2,
project: 3,
playbook: 'Baz',
type: 'job_template',
summary_fields: {
user_capabilities: {
edit: true
}
}
};
test('initially renders successfully', () => {
mountWithContexts(
<TemplateEdit
template={mockData}
/>
);
});
test('handleSubmit should call api update', () => {
const wrapper = mountWithContexts(
<TemplateEdit
template={mockData}
/>
);
const updatedTemplateData = {
name: 'new name',
description: 'new description',
job_type: 'check',
};
wrapper.find('TemplateForm').prop('handleSubmit')(updatedTemplateData);
expect(JobTemplatesAPI.update).toHaveBeenCalledWith(1, updatedTemplateData);
});
test('should navigate to job template detail when cancel is clicked', () => {
const history = {
push: jest.fn(),
};
const wrapper = mountWithContexts(
<TemplateEdit
template={mockData}
/>,
{ context: { router: { history } } }
);
expect(history.push).not.toHaveBeenCalled();
wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
expect(history.push).toHaveBeenCalledWith('/templates/job_template/1/details');
});
});

View File

@ -0,0 +1 @@
export { default } from './TemplateEdit';

View File

@ -80,7 +80,7 @@ describe('<TemplatesList />', () => {
jest.clearAllMocks();
});
test('initially renders succesfully', () => {
test('initially renders successfully', () => {
mountWithContexts(
<TemplatesList
match={{ path: '/templates', url: '/templates' }}

View File

@ -28,7 +28,8 @@ class Templates extends Component {
}
const breadcrumbConfig = {
'/templates': i18n._(t`Templates`),
[`/templates/${template.type}/${template.id}/details`]: i18n._(t`${template.name} Details`)
[`/templates/${template.type}/${template.id}/details`]: i18n._(t`${template.name} Details`),
[`/templates/${template.type}/${template.id}/edit`]: i18n._(t`${template.name} Edit`)
};
this.setState({ breadcrumbConfig });
}

View File

@ -0,0 +1,141 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Formik, Field } from 'formik';
import {
Form,
FormGroup,
Tooltip,
} from '@patternfly/react-core';
import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons';
import AnsibleSelect from '@components/AnsibleSelect';
import FormActionGroup from '@components/FormActionGroup';
import FormField from '@components/FormField';
import FormRow from '@components/FormRow';
import { required } from '@util/validators';
import styled from 'styled-components';
import { JobTemplate } from '@types';
const QuestionCircleIcon = styled(PFQuestionCircleIcon)`
margin-left: 10px;
`;
class TemplateForm extends Component {
static propTypes = {
template: JobTemplate.isRequired,
handleCancel: PropTypes.func.isRequired,
handleSubmit: PropTypes.func.isRequired,
};
render () {
const {
handleCancel,
handleSubmit,
i18n,
template
} = this.props;
const jobTypeOptions = [
{ value: '', key: '', label: i18n._(t`Choose a job type`), isDisabled: true },
{ value: 'run', key: 'run', label: i18n._(t`Run`), isDisabled: false },
{ value: 'check', key: 'check', label: i18n._(t`Check`), isDisabled: false }
];
return (
<Formik
initialValues={{
name: template.name,
description: template.description,
job_type: template.job_type,
inventory: template.inventory,
project: template.project,
playbook: template.playbook
}}
onSubmit={handleSubmit}
render={formik => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormRow>
<FormField
id="template-name"
name="name"
type="text"
label={i18n._(t`Name`)}
validate={required(null, i18n)}
isRequired
/>
<FormField
id="template-description"
name="description"
type="text"
label={i18n._(t`Description`)}
/>
<Field
name="job_type"
validate={required(null, i18n)}
render={({ field }) => (
<FormGroup
fieldId="template-job-type"
isRequired
label={i18n._(t`Job Type`)}
>
<Tooltip
position="right"
content={i18n._(t`For job templates, select run to execute
the playbook. Select check to only check playbook syntax,
test environment setup, and report problems without
executing the playbook.`)}
>
<QuestionCircleIcon />
</Tooltip>
<AnsibleSelect
data={jobTypeOptions}
{...field}
/>
</FormGroup>
)}
/>
<FormField
id="template-inventory"
name="inventory"
type="number"
label={i18n._(t`Inventory`)}
tooltip={i18n._(t`Select the inventory containing the hosts
you want this job to manage.`)}
isRequired
validate={required(null, i18n)}
/>
<FormField
id="template-project"
name="project"
type="number"
label={i18n._(t`Project`)}
tooltip={i18n._(t`Select the project containing the playbook
you want this job to execute.`)}
isRequired
validate={required(null, i18n)}
/>
<FormField
id="template-playbook"
name="playbook"
type="text"
label={i18n._(t`Playbook`)}
tooltip={i18n._(t`Select the playbook to be executed by this job.`)}
isRequired
validate={required(null, i18n)}
/>
</FormRow>
<FormActionGroup
onCancel={handleCancel}
onSubmit={formik.handleSubmit}
/>
</Form>
)}
/>
);
}
}
export default withI18n()(withRouter(TemplateForm));

View File

@ -0,0 +1,99 @@
import React from 'react';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import { sleep } from '@testUtils/testUtils';
import TemplateForm from './TemplateForm';
jest.mock('@api');
describe('<TemplateForm />', () => {
const mockData = {
id: 1,
name: 'Foo',
description: 'Bar',
job_type: 'run',
inventory: 2,
project: 3,
playbook: 'Baz',
type: 'job_template'
};
afterEach(() => {
jest.clearAllMocks();
});
test('initially renders successfully', () => {
mountWithContexts(
<TemplateForm
template={mockData}
handleSubmit={jest.fn()}
handleCancel={jest.fn()}
/>
);
});
test('should update form values on input changes', () => {
const wrapper = mountWithContexts(
<TemplateForm
template={mockData}
handleSubmit={jest.fn()}
handleCancel={jest.fn()}
/>
);
const form = wrapper.find('Formik');
wrapper.find('input#template-name').simulate('change', {
target: { value: 'new foo', name: 'name' }
});
expect(form.state('values').name).toEqual('new foo');
wrapper.find('input#template-description').simulate('change', {
target: { value: 'new bar', name: 'description' }
});
expect(form.state('values').description).toEqual('new bar');
wrapper.find('AnsibleSelect[name="job_type"]').simulate('change', {
target: { value: 'new job type', name: 'job_type' }
});
expect(form.state('values').job_type).toEqual('new job type');
wrapper.find('input#template-inventory').simulate('change', {
target: { value: 3, name: 'inventory' }
});
expect(form.state('values').inventory).toEqual(3);
wrapper.find('input#template-project').simulate('change', {
target: { value: 4, name: 'project' }
});
expect(form.state('values').project).toEqual(4);
wrapper.find('input#template-playbook').simulate('change', {
target: { value: 'new baz type', name: 'playbook' }
});
expect(form.state('values').playbook).toEqual('new baz type');
});
test('should call handleSubmit when Submit button is clicked', async () => {
const handleSubmit = jest.fn();
const wrapper = mountWithContexts(
<TemplateForm
template={mockData}
handleSubmit={handleSubmit}
handleCancel={jest.fn()}
/>
);
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', () => {
const handleCancel = jest.fn();
const wrapper = mountWithContexts(
<TemplateForm
template={mockData}
handleSubmit={jest.fn()}
handleCancel={handleCancel}
/>
);
expect(handleCancel).not.toHaveBeenCalled();
wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
expect(handleCancel).toBeCalled();
});
});

View File

@ -0,0 +1 @@
export { default } from './TemplateForm';

View File

@ -1,4 +1,4 @@
import { shape, arrayOf, number, string, bool } from 'prop-types';
import { shape, arrayOf, number, string, bool, oneOf } from 'prop-types';
export const Role = shape({
descendent_roles: arrayOf(string),
@ -53,3 +53,15 @@ export const QSConfig = shape({
namespace: string,
integerFields: arrayOf(string).isRequired,
});
export const JobTemplate = shape({
name: string.isRequired,
description: string,
inventory: number.isRequired,
job_type: oneOf([
'run',
'check'
]),
playbook: string.isRequired,
project: number.isRequired,
});

View File

@ -2,7 +2,7 @@ import { t } from '@lingui/macro';
export function required (message, i18n) {
return value => {
if (!value.trim()) {
if (typeof value === 'string' && !value.trim()) {
return message || i18n._(t`This field must not be blank`);
}
return undefined;