Merge pull request #144 from keithjgrant/120-add-org-form-cleanup

Refactor org add/edit forms with Formik
This commit is contained in:
Keith Grant
2019-04-03 09:15:03 -04:00
committed by GitHub
17 changed files with 1021 additions and 756 deletions

View File

@@ -0,0 +1,84 @@
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { I18n, i18nMark } from '@lingui/react';
import { FormGroup, Tooltip } from '@patternfly/react-core';
import { QuestionCircleIcon } from '@patternfly/react-icons';
import { t } from '@lingui/macro';
import Lookup from '../../../components/Lookup';
const INSTANCE_GROUPS_LOOKUP_COLUMNS = [
{ name: i18nMark('Name'), key: 'name', isSortable: true },
{ name: i18nMark('Modified'), key: 'modified', isSortable: false, isNumeric: true },
{ name: i18nMark('Created'), key: 'created', isSortable: false, isNumeric: true }
];
class InstanceGroupsLookup extends React.Component {
constructor (props) {
super(props);
this.getInstanceGroups = this.getInstanceGroups.bind(this);
}
async getInstanceGroups (params) {
const { api } = this.props;
const data = await api.getInstanceGroups(params);
return data;
}
render () {
const { value, tooltip, onChange } = this.props;
return (
<I18n>
{({ i18n }) => (
<FormGroup
label={(
<Fragment>
{i18n._(t`Instance Groups`)}
{' '}
{
tooltip && (
<Tooltip
position="right"
content={tooltip}
>
<QuestionCircleIcon />
</Tooltip>
)
}
</Fragment>
)}
fieldId="org-instance-groups"
>
<Lookup
id="org-instance-groups"
lookupHeader={i18n._(t`Instance Groups`)}
name="instanceGroups"
value={value}
onLookupSave={onChange}
getItems={this.getInstanceGroups}
columns={INSTANCE_GROUPS_LOOKUP_COLUMNS}
sortedColumnKey="name"
/>
</FormGroup>
)}
</I18n>
);
}
}
InstanceGroupsLookup.propTypes = {
api: PropTypes.shape({
getInstanceGroups: PropTypes.func,
}).isRequired,
value: PropTypes.arrayOf(PropTypes.object).isRequired,
tooltip: PropTypes.string,
onChange: PropTypes.func.isRequired,
};
InstanceGroupsLookup.defaultProps = {
tooltip: '',
};
export default InstanceGroupsLookup;

View File

@@ -0,0 +1,178 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { withRouter } from 'react-router-dom';
import { Formik, Field } from 'formik';
import { I18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
Form,
FormGroup,
} from '@patternfly/react-core';
import { ConfigContext } from '../../../context';
import FormRow from '../../../components/FormRow';
import FormField from '../../../components/FormField';
import FormActionGroup from '../../../components/FormActionGroup';
import AnsibleSelect from '../../../components/AnsibleSelect';
import InstanceGroupsLookup from './InstanceGroupsLookup';
import { required } from '../../../util/validators';
class OrganizationForm extends Component {
constructor (props) {
super(props);
this.getRelatedInstanceGroups = this.getRelatedInstanceGroups.bind(this);
this.handleInstanceGroupsChange = this.handleInstanceGroupsChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.state = {
instanceGroups: [],
formIsValid: true,
};
}
async componentDidMount () {
let instanceGroups = [];
if (!this.isEditingNewOrganization()) {
try {
instanceGroups = await this.getRelatedInstanceGroups();
} catch (err) {
this.setState({ error: err });
}
}
this.setState({
instanceGroups,
initialInstanceGroups: [...instanceGroups],
});
}
async getRelatedInstanceGroups () {
const {
api,
organization: { id }
} = this.props;
const { data } = await api.getOrganizationInstanceGroups(id);
return data.results;
}
isEditingNewOrganization () {
const { organization } = this.props;
return !organization.id;
}
handleInstanceGroupsChange (instanceGroups) {
this.setState({ instanceGroups });
}
handleSubmit (values) {
const { handleSubmit } = this.props;
const { instanceGroups, initialInstanceGroups } = this.state;
const initialIds = initialInstanceGroups.map(ig => ig.id);
const updatedIds = instanceGroups.map(ig => ig.id);
const groupsToAssociate = [...updatedIds]
.filter(x => !initialIds.includes(x));
const groupsToDisassociate = [...initialIds]
.filter(x => !updatedIds.includes(x));
handleSubmit(values, groupsToAssociate, groupsToDisassociate);
}
render () {
const { api, organization, handleCancel } = this.props;
const { instanceGroups, formIsValid, error } = this.state;
const defaultVenv = '/venv/ansible/';
return (
<I18n>
{({ i18n }) => (
<Formik
initialValues={{
name: organization.name,
description: organization.description,
custom_virtualenv: organization.custom_virtualenv || '',
}}
onSubmit={this.handleSubmit}
render={formik => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormRow>
<FormField
id="org-name"
name="name"
type="text"
label={i18n._(t`Name`)}
validate={required()}
isRequired
/>
<FormField
id="org-description"
name="description"
type="text"
label={i18n._(t`Description`)}
/>
<ConfigContext.Consumer>
{({ custom_virtualenvs }) => (
custom_virtualenvs && custom_virtualenvs.length > 1 && (
<Field
name="custom_virtualenv"
render={({ field }) => (
<FormGroup
fieldId="org-custom-virtualenv"
label={i18n._(t`Ansible Environment`)}
>
<AnsibleSelect
data={custom_virtualenvs}
defaultSelected={defaultVenv}
label={i18n._(t`Ansible Environment`)}
{...field}
/>
</FormGroup>
)}
/>
)
)}
</ConfigContext.Consumer>
</FormRow>
<InstanceGroupsLookup
api={api}
value={instanceGroups}
onChange={this.handleInstanceGroupsChange}
tooltip={i18n._(t`Select the Instance Groups for this Organization to run on.`)}
/>
<FormActionGroup
onCancel={handleCancel}
onSubmit={formik.handleSubmit}
submitDisabled={!formIsValid}
/>
{error ? <div>error</div> : null}
</Form>
)}
/>
)}
</I18n>
);
}
}
OrganizationForm.propTypes = {
api: PropTypes.shape().isRequired,
organization: PropTypes.shape(),
handleSubmit: PropTypes.func.isRequired,
handleCancel: PropTypes.func.isRequired,
};
OrganizationForm.defaultProps = {
organization: {
name: '',
description: '',
custom_virtualenv: '',
}
};
OrganizationForm.contextTypes = {
custom_virtualenvs: PropTypes.arrayOf(PropTypes.string)
};
export default withRouter(OrganizationForm);

View File

@@ -1,171 +1,50 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { withRouter } from 'react-router-dom';
import { I18n, i18nMark } from '@lingui/react';
import { t } from '@lingui/macro';
import {
CardBody,
Form,
FormGroup,
TextInput,
} from '@patternfly/react-core';
import { CardBody } from '@patternfly/react-core';
import { ConfigContext } from '../../../../context';
import Lookup from '../../../../components/Lookup';
import FormActionGroup from '../../../../components/FormActionGroup';
import AnsibleSelect from '../../../../components/AnsibleSelect';
import OrganizationForm from '../../components/OrganizationForm';
class OrganizationEdit extends Component {
constructor (props) {
super(props);
this.getInstanceGroups = this.getInstanceGroups.bind(this);
this.getRelatedInstanceGroups = this.getRelatedInstanceGroups.bind(this);
this.checkValidity = this.checkValidity.bind(this);
this.onFieldChange = this.onFieldChange.bind(this);
this.onLookupSave = this.onLookupSave.bind(this);
this.onSubmit = this.onSubmit.bind(this);
this.postInstanceGroups = this.postInstanceGroups.bind(this);
this.onCancel = this.onCancel.bind(this);
this.onSuccess = this.onSuccess.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.submitInstanceGroups = this.submitInstanceGroups.bind(this);
this.handleCancel = this.handleCancel.bind(this);
this.handleSuccess = this.handleSuccess.bind(this);
this.state = {
form: {
name: {
value: '',
isValid: true,
validation: {
required: true
},
helperTextInvalid: i18nMark('This field must not be blank')
},
description: {
value: ''
},
instanceGroups: {
value: [],
initialValue: []
},
custom_virtualenv: {
value: '',
defaultValue: '/venv/ansible/'
}
},
error: '',
formIsValid: true
};
}
async componentDidMount () {
const { organization } = this.props;
const { form: formData } = this.state;
formData.name.value = organization.name;
formData.description.value = organization.description;
formData.custom_virtualenv.value = organization.custom_virtualenv;
try {
formData.instanceGroups.value = await this.getRelatedInstanceGroups();
formData.instanceGroups.initialValue = [...formData.instanceGroups.value];
} catch (err) {
this.setState({ error: err });
}
this.setState({ form: formData });
}
onFieldChange (val, evt) {
const targetName = evt.target.name;
const value = val;
const { form: updatedForm } = this.state;
const updatedFormEl = { ...updatedForm[targetName] };
updatedFormEl.value = value;
updatedForm[targetName] = updatedFormEl;
updatedFormEl.isValid = (updatedFormEl.validation)
? this.checkValidity(updatedFormEl.value, updatedFormEl.validation) : true;
const formIsValid = (updatedFormEl.validation) ? updatedFormEl.isValid : true;
this.setState({ form: updatedForm, formIsValid });
}
onLookupSave (val, targetName) {
const { form: updatedForm } = this.state;
updatedForm[targetName].value = val;
this.setState({ form: updatedForm });
}
async onSubmit () {
async handleSubmit (values, groupsToAssociate, groupsToDisassociate) {
const { api, organization } = this.props;
const { form: { name, description, custom_virtualenv } } = this.state;
const formData = { name, description, custom_virtualenv };
const updatedData = {};
Object.keys(formData)
.forEach(formId => {
updatedData[formId] = formData[formId].value;
});
try {
await api.updateOrganizationDetails(organization.id, updatedData);
await this.postInstanceGroups();
await api.updateOrganizationDetails(organization.id, values);
await this.submitInstanceGroups(groupsToAssociate, groupsToDisassociate);
} catch (err) {
this.setState({ error: err });
} finally {
this.onSuccess();
this.handleSuccess();
}
}
onCancel () {
handleCancel () {
const { organization: { id }, history } = this.props;
history.push(`/organizations/${id}`);
}
onSuccess () {
handleSuccess () {
const { organization: { id }, history } = this.props;
history.push(`/organizations/${id}`);
}
async getInstanceGroups (params) {
const { api } = this.props;
const data = await api.getInstanceGroups(params);
return data;
}
async getRelatedInstanceGroups () {
const {
api,
organization: { id }
} = this.props;
const { data } = await api.getOrganizationInstanceGroups(id);
const { results } = data;
return results;
}
checkValidity = (value, validation) => {
const isValid = (validation.required)
? (value.trim() !== '') : true;
return isValid;
}
async postInstanceGroups () {
async submitInstanceGroups (groupsToAssociate, groupsToDisassociate) {
const { api, organization } = this.props;
const { form: { instanceGroups } } = this.state;
const url = organization.related.instance_groups;
const initialInstanceGroups = instanceGroups.initialValue.map(ig => ig.id);
const updatedInstanceGroups = instanceGroups.value.map(ig => ig.id);
const groupsToAssociate = [...updatedInstanceGroups]
.filter(x => !initialInstanceGroups.includes(x));
const groupsToDisassociate = [...initialInstanceGroups]
.filter(x => !updatedInstanceGroups.includes(x));
try {
await Promise.all(groupsToAssociate.map(async id => {
await api.associateInstanceGroup(url, id);
@@ -179,106 +58,31 @@ class OrganizationEdit extends Component {
}
render () {
const {
form: {
name,
description,
instanceGroups,
custom_virtualenv
},
formIsValid,
error
} = this.state;
const instanceGroupsLookupColumns = [
{ name: i18nMark('Name'), key: 'name', isSortable: true },
{ name: i18nMark('Modified'), key: 'modified', isSortable: false, isNumeric: true },
{ name: i18nMark('Created'), key: 'created', isSortable: false, isNumeric: true }
];
const { api, organization } = this.props;
const { error } = this.state;
return (
<CardBody>
<I18n>
{({ i18n }) => (
<Form autoComplete="off">
<div style={{ display: 'grid', gridGap: '20px', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))' }}>
<FormGroup
fieldId="edit-org-form-name"
helperTextInvalid={name.helperTextInvalid}
isRequired
isValid={name.isValid}
label={i18n._(t`Name`)}
>
<TextInput
id="edit-org-form-name"
isRequired
isValid={name.isValid}
name="name"
onChange={this.onFieldChange}
value={name.value || ''}
/>
</FormGroup>
<FormGroup
fieldId="edit-org-form-description"
label={i18n._(t`Description`)}
>
<TextInput
id="edit-org-form-description"
name="description"
onChange={this.onFieldChange}
value={description.value || ''}
/>
</FormGroup>
<ConfigContext.Consumer>
{({ custom_virtualenvs }) => (
custom_virtualenvs && custom_virtualenvs.length > 1 && (
<FormGroup
fieldId="edit-org-custom-virtualenv"
label={i18n._(t`Ansible Environment`)}
>
<AnsibleSelect
data={custom_virtualenvs}
defaultSelected={custom_virtualenv.defaultEnv}
label={i18n._(t`Ansible Environment`)}
name="custom_virtualenv"
onChange={this.onFieldChange}
value={custom_virtualenv.value || ''}
/>
</FormGroup>
)
)}
</ConfigContext.Consumer>
</div>
<FormGroup
fieldId="edit-org-form-instance-groups"
label={i18n._(t`Instance Groups`)}
>
<Lookup
columns={instanceGroupsLookupColumns}
getItems={this.getInstanceGroups}
lookupHeader={i18n._(t`Instance Groups`)}
name="instanceGroups"
onLookupSave={this.onLookupSave}
sortedColumnKey="name"
value={instanceGroups.value}
/>
</FormGroup>
<FormActionGroup
onCancel={this.onCancel}
onSubmit={this.onSubmit}
submitDisabled={!formIsValid}
/>
{ error ? <div>error</div> : '' }
</Form>
)}
</I18n>
<OrganizationForm
api={api}
organization={organization}
handleSubmit={this.handleSubmit}
handleCancel={this.handleCancel}
/>
{error ? <div>error</div> : null}
</CardBody>
);
}
}
OrganizationEdit.propTypes = {
api: PropTypes.shape().isRequired,
organization: PropTypes.shape().isRequired,
};
OrganizationEdit.contextTypes = {
custom_virtualenvs: PropTypes.arrayOf(PropTypes.string)
};
export { OrganizationEdit as _OrganizationEdit };
export default withRouter(OrganizationEdit);

View File

@@ -1,114 +1,65 @@
import React, { Fragment } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import { withRouter } from 'react-router-dom';
import { I18n, i18nMark } from '@lingui/react';
import { I18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
PageSection,
Form,
FormGroup,
TextInput,
Gallery,
Card,
CardHeader,
CardBody,
Button,
Tooltip,
} from '@patternfly/react-core';
import { QuestionCircleIcon, TimesIcon } from '@patternfly/react-icons';
import { TimesIcon } from '@patternfly/react-icons';
import { ConfigContext } from '../../../context';
import Lookup from '../../../components/Lookup';
import AnsibleSelect from '../../../components/AnsibleSelect';
import FormActionGroup from '../../../components/FormActionGroup';
import OrganizationForm from '../components/OrganizationForm';
class OrganizationAdd extends React.Component {
constructor (props) {
super(props);
this.getInstanceGroups = this.getInstanceGroups.bind(this);
this.onFieldChange = this.onFieldChange.bind(this);
this.onLookupSave = this.onLookupSave.bind(this);
this.onSubmit = this.onSubmit.bind(this);
this.onCancel = this.onCancel.bind(this);
this.onSuccess = this.onSuccess.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleCancel = this.handleCancel.bind(this);
this.handleSuccess = this.handleSuccess.bind(this);
this.state = {
name: '',
description: '',
custom_virtualenv: '',
instanceGroups: [],
error: '',
defaultEnv: '/venv/ansible/',
};
}
onFieldChange (val, evt) {
this.setState({ [evt.target.name]: val || evt.target.value });
}
onLookupSave (val, targetName) {
this.setState({ [targetName]: val });
}
async onSubmit () {
async handleSubmit (values, groupsToAssociate) {
const { api } = this.props;
const { name, description, custom_virtualenv, instanceGroups } = this.state;
const data = {
name,
description,
custom_virtualenv
};
try {
const { data: response } = await api.createOrganization(data);
const { data: response } = await api.createOrganization(values);
const instanceGroupsUrl = response.related.instance_groups;
try {
if (instanceGroups.length > 0) {
instanceGroups.forEach(async (select) => {
await api.associateInstanceGroup(instanceGroupsUrl, select.id);
});
}
await Promise.all(groupsToAssociate.map(async id => {
await api.associateInstanceGroup(instanceGroupsUrl, id);
}));
} catch (err) {
this.setState({ error: err });
} finally {
this.onSuccess(response.id);
this.handleSuccess(response.id);
}
} catch (err) {
this.setState({ error: err });
}
}
onCancel () {
handleCancel () {
const { history } = this.props;
history.push('/organizations');
}
onSuccess (id) {
handleSuccess (id) {
const { history } = this.props;
history.push(`/organizations/${id}`);
}
async getInstanceGroups (params) {
const { api } = this.props;
const data = await api.getInstanceGroups(params);
return data;
}
render () {
const {
name,
description,
custom_virtualenv,
defaultEnv,
instanceGroups,
error
} = this.state;
const enabled = name.length > 0; // TODO: add better form validation
const instanceGroupsLookupColumns = [
{ name: i18nMark('Name'), key: 'name', isSortable: true },
{ name: i18nMark('Modified'), key: 'modified', isSortable: false, isNumeric: true },
{ name: i18nMark('Created'), key: 'created', isSortable: false, isNumeric: true }
];
const { api } = this.props;
const { error } = this.state;
return (
<PageSection>
@@ -123,99 +74,19 @@ class OrganizationAdd extends React.Component {
<Button
variant="plain"
aria-label={i18n._(t`Close`)}
onClick={this.onCancel}
onClick={this.handleCancel}
>
<TimesIcon />
</Button>
</Tooltip>
</CardHeader>
<CardBody>
<Form autoComplete="off">
<Gallery gutter="md">
<FormGroup
label={i18n._(t`Name`)}
isRequired
fieldId="add-org-form-name"
>
<TextInput
isRequired
id="add-org-form-name"
name="name"
value={name}
onChange={this.onFieldChange}
/>
</FormGroup>
<FormGroup label={i18n._(t`Description`)} fieldId="add-org-form-description">
<TextInput
id="add-org-form-description"
name="description"
value={description}
onChange={this.onFieldChange}
/>
</FormGroup>
<FormGroup
label={(
<Fragment>
{i18n._(t`Instance Groups`)}
{' '}
<Tooltip
position="right"
content={i18n._(t`Select the Instance Groups for this Organization to run on.`)}
>
<QuestionCircleIcon />
</Tooltip>
</Fragment>
)}
fieldId="add-org-form-instance-groups"
>
<Lookup
lookupHeader={i18n._(t`Instance Groups`)}
name="instanceGroups"
value={instanceGroups}
onLookupSave={this.onLookupSave}
getItems={this.getInstanceGroups}
columns={instanceGroupsLookupColumns}
sortedColumnKey="name"
/>
</FormGroup>
<ConfigContext.Consumer>
{({ custom_virtualenvs }) => (
custom_virtualenvs && custom_virtualenvs.length > 1 && (
<FormGroup
label={(
<Fragment>
{i18n._(t`Ansible Environment`)}
{' '}
<Tooltip
position="right"
content={i18n._(t`Select the custom Python virtual environment for this organization to run on.`)}
>
<QuestionCircleIcon />
</Tooltip>
</Fragment>
)}
fieldId="add-org-custom-virtualenv"
>
<AnsibleSelect
label={i18n._(t`Ansible Environment`)}
name="custom_virtualenv"
value={custom_virtualenv}
onChange={this.onFieldChange}
data={custom_virtualenvs}
defaultSelected={defaultEnv}
/>
</FormGroup>
)
)}
</ConfigContext.Consumer>
</Gallery>
<FormActionGroup
onSubmit={this.onSubmit}
submitDisabled={!enabled}
onCancel={this.onCancel}
/>
{error ? <div>error</div> : ''}
</Form>
<OrganizationForm
api={api}
handleSubmit={this.handleSubmit}
handleCancel={this.handleCancel}
/>
{error ? <div>error</div> : ''}
</CardBody>
</Card>
)}
@@ -225,8 +96,13 @@ class OrganizationAdd extends React.Component {
}
}
OrganizationAdd.propTypes = {
api: PropTypes.shape().isRequired,
};
OrganizationAdd.contextTypes = {
custom_virtualenvs: PropTypes.arrayOf(PropTypes.string)
};
export { OrganizationAdd as _OrganizationAdd };
export default withRouter(OrganizationAdd);