Fix EnvironmentConfiguration dialog

* convert Environment Configuration form to redux-form
* add required environments validation
* add HorizontalCheckBox and EnvironmentCheckBox
* Fix the bug where error is not visible when it is happening in
  other topic tab by adding general error message
* actions and reducer update

Partial-Bug: 1743722
Change-Id: Ia056621a847b56913b1dfff57f6e8237f62bd8d7
This commit is contained in:
Jiri Tomasek 2018-01-02 09:15:09 +01:00
parent 4e776a6291
commit 8ee595b019
18 changed files with 603 additions and 421 deletions

View File

@ -0,0 +1,6 @@
---
features:
- |
Improvements in Deployment configuration -> Overal Settings, Improved
performance, form now shows general error in case when some sub section
contains error

View File

@ -15,6 +15,7 @@
*/
import configureMockStore from 'redux-mock-store';
import { startSubmit, stopSubmit } from 'redux-form';
import thunkMiddleware from 'redux-thunk';
import EnvironmentConfigurationActions from '../../js/actions/EnvironmentConfigurationActions';
@ -128,7 +129,7 @@ describe('updateEnvironmentConfiguration', () => {
.then(() => {
expect(MistralApiService.runAction).toHaveBeenCalled();
expect(store.getActions()).toEqual([
EnvironmentConfigurationActions.updateEnvironmentConfigurationPending(),
startSubmit('environmentConfigurationForm'),
EnvironmentConfigurationActions.updateEnvironmentConfigurationSuccess(
[
'overcloud-resource-registry-puppet.yaml',
@ -136,17 +137,9 @@ describe('updateEnvironmentConfiguration', () => {
'environments/network-isolation.yaml'
]
),
stopSubmit('environmentConfigurationForm'),
NotificationActions.notify({ type: 'NOTIFY' })
]);
});
// const result = EnvironmentConfigurationActions.updateEnvironmentConfiguration(
// 'myPlan'
// );
// const mockDispatch = jest.fn();
// result(mockDispatch, jest.fn(), mockGetIntl).then(() => {
// console.log(mockDispatch);
// expect(mockDispatch.mock.calls).toEqual(true);
// });
});
});

View File

@ -16,6 +16,7 @@
import { defineMessages } from 'react-intl';
import { normalize } from 'normalizr';
import { startSubmit, stopSubmit } from 'redux-form';
import yaml from 'js-yaml';
import EnvironmentConfigurationConstants from '../constants/EnvironmentConfigurationConstants';
@ -35,6 +36,10 @@ const messages = defineMessages({
envConfigUpdatedNotificationTitle: {
id: 'EnvironmentConfigurationActions.envConfigUpdatedNotificationTitle',
defaultMessage: 'Environment Configuration updated'
},
configurationNotUpdatedError: {
id: 'EnvironmentConfigurationActions.configurationNotUpdatedError',
defaultMessage: 'Deployment configuration could not be updated'
}
});
@ -85,10 +90,10 @@ export default {
};
},
updateEnvironmentConfiguration(planName, data, formFields) {
updateEnvironmentConfiguration(planName, data, redirect) {
return (dispatch, getState, { getIntl }) => {
const { formatMessage } = getIntl(getState());
dispatch(this.updateEnvironmentConfigurationPending());
dispatch(startSubmit('environmentConfigurationForm'));
return dispatch(
MistralApiService.runAction(MistralConstants.CAPABILITIES_UPDATE, {
environments: data,
@ -98,6 +103,7 @@ export default {
.then(response => {
const enabledEnvs = response.environments.map(env => env.path);
dispatch(this.updateEnvironmentConfigurationSuccess(enabledEnvs));
dispatch(stopSubmit('environmentConfigurationForm'));
dispatch(
NotificationActions.notify({
title: formatMessage(messages.envConfigUpdatedNotificationTitle),
@ -107,34 +113,21 @@ export default {
type: 'success'
})
);
redirect && redirect();
})
.catch(error => {
dispatch(
handleErrors(
error,
'Deployment configuration could not be updated',
false
)
);
dispatch(
this.updateEnvironmentConfigurationFailed([
{
title: 'Configuration could not be updated',
stopSubmit('environmentConfigurationForm', {
_error: {
title: formatMessage(messages.configurationNotUpdatedError),
message: error.message
}
])
})
);
});
};
},
updateEnvironmentConfigurationPending() {
return {
type:
EnvironmentConfigurationConstants.UPDATE_ENVIRONMENT_CONFIGURATION_PENDING
};
},
updateEnvironmentConfigurationSuccess(enabledEnvironments) {
return {
type:
@ -143,17 +136,6 @@ export default {
};
},
updateEnvironmentConfigurationFailed(formErrors = [], formFieldErrors = {}) {
return {
type:
EnvironmentConfigurationConstants.UPDATE_ENVIRONMENT_CONFIGURATION_FAILED,
payload: {
formErrors,
formFieldErrors
}
};
},
fetchEnvironment(planName, environmentPath) {
return dispatch => {
dispatch(this.fetchEnvironmentPending(environmentPath));

View File

@ -0,0 +1,76 @@
/**
* Copyright 2017 Red Hat Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License. You may obtain
* a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import cx from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import { Checkbox, Col, FormGroup } from 'react-bootstrap';
import {
getValidationState,
InputDescription,
InputMessage
} from '../ui/reduxForm/utils';
/**
* EnvironmentCheckBox differs from HorizontalCheckBox in being considered as
* always touched
*/
const EnvironmentCheckBox = ({
id,
label,
labelColumns,
inputColumns,
description,
type,
input,
meta,
required,
...rest
}) => {
return (
<FormGroup
controlId={id}
validationState={getValidationState({ ...meta, touched: true })}
>
<Col smOffset={labelColumns} sm={inputColumns}>
<Checkbox {...input} {...rest}>
<span className={cx({ 'required-pf': required })}>{label}</span>
</Checkbox>
<InputMessage {...meta} touched />
<InputDescription description={description} />
</Col>
</FormGroup>
);
};
EnvironmentCheckBox.propTypes = {
description: PropTypes.node,
id: PropTypes.string.isRequired,
input: PropTypes.object.isRequired,
inputColumns: PropTypes.number.isRequired,
label: PropTypes.node,
labelColumns: PropTypes.number.isRequired,
meta: PropTypes.object.isRequired,
required: PropTypes.bool.isRequired,
type: PropTypes.string.isRequired
};
EnvironmentCheckBox.defaultProps = {
labelColumns: 5,
inputColumns: 7,
required: false,
type: 'text'
};
export default EnvironmentCheckBox;

View File

@ -14,40 +14,26 @@
* under the License.
*/
import * as _ from 'lodash';
import { camelCase, mapKeys } from 'lodash';
import { connect } from 'react-redux';
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
import Formsy, { addValidationRule } from 'formsy-react';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import React from 'react';
import { CloseModalButton } from '../ui/Modals';
import EnvironmentConfigurationActions from '../../actions/EnvironmentConfigurationActions';
import EnvironmentConfigurationForm from './EnvironmentConfigurationForm';
import EnvironmentConfigurationSidebar from './EnvironmentConfigurationSidebar';
import EnvironmentConfigurationTopic from './EnvironmentConfigurationTopic';
import { getCurrentPlanName } from '../../selectors/plans';
import ModalFormErrorList from '../ui/forms/ModalFormErrorList';
import {
getEnvironments,
getTopicsTree
} from '../../selectors/environmentConfiguration';
import { Loader } from '../ui/Loader';
import Tab from '../ui/Tab';
import TabPane from '../ui/TabPane';
const messages = defineMessages({
cancel: {
id: 'EnvironmentConfiguration.cancel',
defaultMessage: 'Cancel'
},
saveChanges: {
id: 'EnvironmentConfiguration.saveChanges',
defaultMessage: 'Save Changes'
},
saveAndClose: {
id: 'EnvironmentConfiguration.saveAndClose',
defaultMessage: 'Save And Close'
},
loadingEnvironmentConfiguration: {
id: 'EnvironmentConfiguration.loadingEnvironmentConfiguration',
defaultMessage: 'Loading Deployment Configuration...'
@ -58,8 +44,6 @@ class EnvironmentConfiguration extends React.Component {
constructor() {
super();
this.state = {
canSubmit: false,
closeOnSubmit: false,
activeTab: undefined
};
}
@ -75,165 +59,95 @@ class EnvironmentConfiguration extends React.Component {
);
}
componentDidUpdate() {
this.invalidateForm(this.props.formFieldErrors.toJS());
}
enableButton() {
this.setState({ canSubmit: true });
}
disableButton() {
this.setState({ canSubmit: false });
}
invalidateForm(formFieldErrors) {
this.refs.environmentConfigurationForm.updateInputsWithError(
formFieldErrors
);
}
/*
* Formsy splits data into objects by '.', file names include '.'
* so we need to convert data back to e.g. { filename.yaml: true, ... }
*/
_convertFormData(formData) {
return _.mapValues(
_.mapKeys(formData, (value, key) => {
return key + '.yaml';
}),
value => {
return value.yaml;
}
);
}
handleSubmit(formData, resetForm, invalidateForm) {
const data = this._convertFormData(formData);
this.disableButton();
this.props.updateEnvironmentConfiguration(
this.props.currentPlanName,
data,
Object.keys(this.refs.environmentConfigurationForm.inputs)
);
if (this.state.closeOnSubmit) {
this.setState({
closeOnSubmit: false
});
this.props.history.push(`/plans/${this.props.currentPlanName}`);
}
}
onSubmitAndClose() {
this.setState(
{
closeOnSubmit: true
},
this.refs.environmentConfigurationForm.submit
);
}
activateTab(tabName, e) {
e.preventDefault();
this.setState({ activeTab: tabName });
}
isTabActive(tabName) {
let firstTabName = _.camelCase(
isTabActive = tabName => {
const firstTabName = camelCase(
this.props.environmentConfigurationTopics.first().get('title')
);
let currentTab = this.state.activeTab || firstTabName;
const currentTab = this.state.activeTab || firstTabName;
return currentTab === tabName;
};
renderTopics = () => {
const { environmentConfigurationTopics } = this.props;
return environmentConfigurationTopics.toList().map((topic, index) => {
const tabName = camelCase(topic.get('title'));
return (
<TabPane
isActive={this.isTabActive(tabName)}
key={index}
renderOnlyActive
>
<EnvironmentConfigurationTopic
title={topic.get('title')}
description={topic.get('description')}
environmentGroups={topic.get('environment_groups')}
/>
</TabPane>
);
});
};
handleSubmit = ({ saveAndClose, ...values }, dispatch, props) => {
const data = mapKeys(values, (_, k) => k.replace(':', '.'));
const {
currentPlanName,
history,
updateEnvironmentConfiguration
} = this.props;
if (saveAndClose) {
updateEnvironmentConfiguration(currentPlanName, data, () =>
history.push(`/plans/${currentPlanName}`)
);
} else {
updateEnvironmentConfiguration(currentPlanName, data);
}
};
/**
* Initial values are all enabled environments, keys are changed as dots
* in input names cause unwanted splitting into nested objects
*/
getFormInitialValues() {
return this.props.allEnvironments
.mapKeys(k => k.replace('.', ':'))
.filter(e => e.enabled)
.map(e => e.enabled)
.toJS();
}
render() {
let topics = this.props.environmentConfigurationTopics
.toList()
.map((topic, index) => {
let tabName = _.camelCase(topic.get('title'));
return (
<TabPane isActive={this.isTabActive(tabName)} key={index}>
<EnvironmentConfigurationTopic
key={index}
title={topic.get('title')}
description={topic.get('description')}
allEnvironments={this.props.allEnvironments}
environmentGroups={topic.get('environment_groups')}
/>
</TabPane>
);
});
let topicTabs = this.props.environmentConfigurationTopics
.toList()
.map((topic, index) => {
let tabName = _.camelCase(topic.get('title'));
return (
<Tab key={index} isActive={this.isTabActive(tabName)}>
<a href="" onClick={this.activateTab.bind(this, tabName)}>
{topic.get('title')}
</a>
</Tab>
);
});
const {
allEnvironments,
environmentConfigurationTopics,
isFetching,
intl: { formatMessage }
} = this.props;
return (
<Formsy
ref="environmentConfigurationForm"
role="form"
className="form"
onSubmit={this.handleSubmit.bind(this)}
onValid={this.enableButton.bind(this)}
onInvalid={this.disableButton.bind(this)}
<Loader
height={60}
loaded={!isFetching}
content={formatMessage(messages.loadingEnvironmentConfiguration)}
>
<Loader
height={60}
loaded={!this.props.isFetching}
content={this.props.intl.formatMessage(
messages.loadingEnvironmentConfiguration
)}
<EnvironmentConfigurationForm
allEnvironments={allEnvironments}
onSubmit={this.handleSubmit}
initialValues={this.getFormInitialValues()}
>
<ModalFormErrorList errors={this.props.formErrors.toJS()} />
<div className="container-fluid">
<div className="row row-eq-height">
<div className="col-sm-4 sidebar-pf sidebar-pf-left">
<ul
id="DeploymentConfiguration__CategoriesList"
className="nav nav-pills nav-stacked nav-arrows"
>
{topicTabs}
</ul>
</div>
<EnvironmentConfigurationSidebar
activateTab={tabName => this.setState({ activeTab: tabName })}
categories={environmentConfigurationTopics.toList().toJS()}
isTabActive={this.isTabActive}
/>
<div className="col-sm-8">
<div className="tab-content">{topics}</div>
<div className="tab-content">{this.renderTopics()}</div>
</div>
</div>
</div>
</Loader>
<div className="modal-footer">
<button
type="submit"
disabled={!this.state.canSubmit}
className="btn btn-primary"
>
<FormattedMessage {...messages.saveChanges} />
</button>
<button
type="button"
disabled={!this.state.canSubmit}
onClick={this.onSubmitAndClose.bind(this)}
className="btn btn-default"
>
<FormattedMessage {...messages.saveAndClose} />
</button>
<CloseModalButton>
<FormattedMessage {...messages.cancel} />
</CloseModalButton>
</div>
</Formsy>
</EnvironmentConfigurationForm>
</Loader>
);
}
}
@ -252,60 +166,35 @@ EnvironmentConfiguration.propTypes = {
updateEnvironmentConfiguration: PropTypes.func
};
function mapStateToProps(state) {
return {
currentPlanName: getCurrentPlanName(state),
allEnvironments: getEnvironments(state),
environmentConfigurationTopics: getTopicsTree(state),
formErrors: state.environmentConfiguration.getIn(['form', 'formErrors']),
formFieldErrors: state.environmentConfiguration.getIn([
'form',
'formFieldErrors'
]),
isFetching: state.environmentConfiguration.isFetching
};
}
const mapStateToProps = state => ({
currentPlanName: getCurrentPlanName(state),
allEnvironments: getEnvironments(state),
environmentConfigurationTopics: getTopicsTree(state),
formErrors: state.environmentConfiguration.getIn(['form', 'formErrors']),
formFieldErrors: state.environmentConfiguration.getIn([
'form',
'formFieldErrors'
]),
isFetching: state.environmentConfiguration.isFetching
});
function mapDispatchToProps(dispatch) {
return {
fetchEnvironmentConfiguration: planName => {
dispatch(
EnvironmentConfigurationActions.fetchEnvironmentConfiguration(planName)
);
},
updateEnvironmentConfiguration: (planName, data, inputFields) => {
dispatch(
EnvironmentConfigurationActions.updateEnvironmentConfiguration(
planName,
data,
inputFields
)
);
}
};
}
const mapDispatchToProps = dispatch => ({
fetchEnvironmentConfiguration: planName => {
dispatch(
EnvironmentConfigurationActions.fetchEnvironmentConfiguration(planName)
);
},
updateEnvironmentConfiguration: (planName, data, inputFields) => {
dispatch(
EnvironmentConfigurationActions.updateEnvironmentConfiguration(
planName,
data,
inputFields
)
);
}
});
export default injectIntl(
connect(mapStateToProps, mapDispatchToProps)(EnvironmentConfiguration)
);
/**
* requiresEnvironments validation
* Invalidates input if it is selected and environment it requires is not.
* example: validations="requiredEnvironments:['some_environment.yaml']"
*/
addValidationRule('requiredEnvironments', function(
values,
value,
requiredEnvironmentFieldNames
) {
if (value) {
return !_.filter(
_.values(_.pick(values, requiredEnvironmentFieldNames)),
function(val) {
return val === false;
}
).length;
}
return true;
});

View File

@ -0,0 +1,164 @@
/**
* Copyright 2018 Red Hat Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License. You may obtain
* a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
import { Button, Form, ModalFooter } from 'react-bootstrap';
import PropTypes from 'prop-types';
import React from 'react';
import { reduxForm } from 'redux-form';
import { pickBy } from 'lodash';
import { CloseModalButton } from '../ui/Modals';
import ModalFormErrorList from '../ui/forms/ModalFormErrorList';
import { OverlayLoader } from '../ui/Loader';
const messages = defineMessages({
cancel: {
id: 'EnvironmentConfigurationForm.cancel',
defaultMessage: 'Cancel'
},
saveChanges: {
id: 'EnvironmentConfigurationForm.saveChanges',
defaultMessage: 'Save Changes'
},
saveAndClose: {
id: 'EnvironmentConfigurationForm.saveAndClose',
defaultMessage: 'Save And Close'
},
requiredEnvironments: {
id: 'EnvironmentConfigurationForm.requiredEnvironments',
defaultMessage: 'This option requires {requiredEnvironments} to be enabled.'
},
missingConfiguration: {
id: 'EnvironmentConfigurationForm.missingConfiguration',
defaultMessage: 'Missing configuration',
description:
'Title for general error message describing dependent environments need to be enabled'
},
requiredEnvironmentsGlobalError: {
id: 'EnvironmentConfigurationForm.requiredEnvironmentGlobalError',
defaultMessage:
'Selected options depend on other options which are not enabled',
description:
'General error message describing dependent environments need to be enabled'
},
updatingEnvironmentConfiguration: {
id: 'EnvironmentConfigurationForm.updatingEnvironmentConfiguration',
defaultMessage: 'Updating Environment configuration'
}
});
const EnvironmentConfigurationForm = ({
error,
children,
onSubmit,
handleSubmit,
intl: { formatMessage },
invalid,
pristine,
submitting,
initialValues
}) => (
<Form onSubmit={handleSubmit} horizontal>
<OverlayLoader
loaded={!submitting}
content={formatMessage(messages.updatingEnvironmentConfiguration)}
>
<ModalFormErrorList errors={error ? [error] : []} />
{children}
</OverlayLoader>
<ModalFooter>
<Button
type="submit"
disabled={invalid || pristine || submitting}
bsStyle="primary"
>
<FormattedMessage {...messages.saveChanges} />
</Button>
<Button
disabled={invalid || pristine || submitting}
onClick={handleSubmit(values =>
onSubmit({ ...values, saveAndClose: true })
)}
>
<FormattedMessage {...messages.saveAndClose} />
</Button>
<CloseModalButton>
<FormattedMessage {...messages.cancel} />
</CloseModalButton>
</ModalFooter>
</Form>
);
EnvironmentConfigurationForm.propTypes = {
children: PropTypes.node,
error: PropTypes.object,
handleSubmit: PropTypes.func.isRequired,
initialValues: PropTypes.object.isRequired,
intl: PropTypes.object.isRequired,
invalid: PropTypes.bool.isRequired,
onSubmit: PropTypes.func.isRequired,
pristine: PropTypes.bool.isRequired,
submitting: PropTypes.bool.isRequired
};
const validate = (values, { allEnvironments, intl: { formatMessage } }) => {
const errors = {};
// Get array of environment files currently enabled in the form
const enabledValues = Object.keys(pickBy(values)).map(v =>
v.replace(':', '.')
);
// For each enabled environment, get its list of required environments. Add
// error if some of them are not enabled
enabledValues.map(e => {
const requires = allEnvironments.getIn([e, 'requires']);
if (
!requires
.toSet()
.subtract(enabledValues)
.isEmpty()
) {
const requiredEnvironmentNames = requires
.map(env => allEnvironments.getIn([env, 'title'], env))
.toArray();
errors[e.replace('.', ':')] = formatMessage(
messages.requiredEnvironments,
{
requiredEnvironments: requiredEnvironmentNames
}
);
}
});
// Add global error message
if (Object.keys(errors).length > 0) {
errors['_error'] = {
title: formatMessage(messages.missingConfiguration),
message: formatMessage(messages.requiredEnvironmentsGlobalError)
};
}
return errors;
};
const form = reduxForm({
form: 'environmentConfigurationForm',
validate
});
export default injectIntl(form(EnvironmentConfigurationForm));

View File

@ -0,0 +1,51 @@
/**
* Copyright 2018 Red Hat Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License. You may obtain
* a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { camelCase } from 'lodash';
import PropTypes from 'prop-types';
import React from 'react';
import Tab from '../ui/Tab';
const EnvironmentConfigurationSidebar = ({
activateTab,
categories,
isTabActive
}) => (
<div className="col-sm-4 sidebar-pf sidebar-pf-left">
<ul
id="DeploymentConfiguration__CategoriesList"
className="nav nav-pills nav-stacked nav-arrows"
>
{categories.map(({ title }, index) => {
const tabName = camelCase(title);
return (
<Tab key={index} isActive={isTabActive(tabName)}>
<a className="link" onClick={() => activateTab(tabName)}>
{title}
</a>
</Tab>
);
})}
</ul>
</div>
);
EnvironmentConfigurationSidebar.propTypes = {
activateTab: PropTypes.func.isRequired,
categories: PropTypes.array.isRequired,
isTabActive: PropTypes.func.isRequired
};
export default EnvironmentConfigurationSidebar;

View File

@ -20,39 +20,30 @@ import React from 'react';
import EnvironmentGroup from './EnvironmentGroup';
export default class EnvironmentConfigurationTopic extends React.Component {
render() {
let environmentGroups = this.props.environmentGroups
const EnvironmentConfigurationTopic = ({ description, environmentGroups }) => (
<fieldset className="environment-topic">
{description && (
<p>
<i>{description}</i>
</p>
)}
{environmentGroups
.toList()
.map((envGroup, index) => {
return (
<EnvironmentGroup
key={index}
title={envGroup.get('title')}
description={envGroup.get('description')}
allEnvironments={this.props.allEnvironments}
environments={envGroup.get('environments')}
mutuallyExclusive={envGroup.get('mutually_exclusive')}
/>
);
});
const { description } = this.props;
return (
<fieldset className="environment-topic">
{description && (
<p>
<i>{description}</i>
</p>
)}
{environmentGroups}
</fieldset>
);
}
}
.map((envGroup, index) => (
<EnvironmentGroup
key={index}
title={envGroup.get('title')}
description={envGroup.get('description')}
environments={envGroup.get('environments')}
mutuallyExclusive={envGroup.get('mutually_exclusive')}
/>
))}
</fieldset>
);
EnvironmentConfigurationTopic.propTypes = {
allEnvironments: ImmutablePropTypes.map.isRequired,
description: PropTypes.string,
environmentGroups: ImmutablePropTypes.list,
title: PropTypes.string.isRequired
};
export default EnvironmentConfigurationTopic;

View File

@ -14,117 +14,61 @@
* under the License.
*/
import { defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import React from 'react';
import { change, Field } from 'redux-form';
import GenericCheckBox from '../ui/forms/GenericCheckBox';
import GroupedCheckBox from '../ui/forms/GroupedCheckBox';
const messages = defineMessages({
requiredEnvironments: {
id: 'EnvironmentGroup.requiredEnvironments',
defaultMessage: 'This option requires {requiredEnvironments} to be enabled.'
}
});
import EnvironmentGroupHeading from './EnvironmentGroupHeading';
import EnvironmentCheckBox from './EnvironmentCheckBox';
class EnvironmentGroup extends React.Component {
constructor(props) {
super(props);
this.state = {
checkedEnvironment: null
};
}
componentWillMount() {
const firstCheckedEnvironment = this.props.environments
.filter(env => env.get('enabled') === true)
.first();
this.setState({
checkedEnvironment: firstCheckedEnvironment
? firstCheckedEnvironment.get('file')
: null
});
}
onGroupedCheckBoxChange(checked, environmentFile) {
this.setState({ checkedEnvironment: checked ? environmentFile : null });
}
getRequiredEnvironmentsNames(environment) {
return environment.requires
.map(env => this.props.allEnvironments.getIn([env, 'title'], env))
.toArray();
}
generateInputs() {
const {
environments,
intl: { formatMessage },
mutuallyExclusive
} = this.props;
return environments.toList().map((environment, index) => {
const requiredEnvironments = environment.requires.toArray();
const requiredEnvironmentNames = this.getRequiredEnvironmentsNames(
environment
);
if (mutuallyExclusive) {
let checkBoxValue = this.state.checkedEnvironment === environment.file;
return (
<GroupedCheckBox
key={environment.file}
name={environment.file}
id={environment.file}
title={environment.title}
value={checkBoxValue}
validations={{ requiredEnvironments: requiredEnvironments }}
validationError={formatMessage(messages.requiredEnvironments, {
requiredEnvironments: requiredEnvironmentNames
})}
onChange={this.onGroupedCheckBoxChange.bind(this)}
description={environment.description}
/>
);
} else {
return (
<GenericCheckBox
key={environment.file}
name={environment.file}
id={environment.file}
title={environment.title}
value={environment.get('enabled', false)}
validations={{ requiredEnvironments: requiredEnvironments }}
validationError={formatMessage(messages.requiredEnvironments, {
requiredEnvironments: requiredEnvironmentNames
})}
description={environment.description}
/>
);
}
});
}
/**
* When enabling environment in mutually exclusive group disable other
* environments in the group
*/
handleEnablingEnvironment = (event, newValue, previousValue) => {
const { changeValue, environments } = this.props;
if (newValue) {
environments
.delete(event.target.name.replace(':', '.'))
.map(env => changeValue(env.file.replace('.', ':'), false));
}
};
render() {
let environments = this.generateInputs();
const { title, description, environments, mutuallyExclusive } = this.props;
return (
<div className="environment-group">
<EnvironmentGroupHeading
title={this.props.title}
description={this.props.description}
/>
{environments}
<EnvironmentGroupHeading title={title} description={description} />
{environments
.toList()
.map((environment, index) => (
<Field
labelColumns={0}
inputColumns={12}
id={environment.file}
key={environment.file}
label={environment.title}
title={environment.file}
description={environment.description}
name={environment.file.replace('.', ':')}
component={EnvironmentCheckBox}
type="checkbox"
onChange={
mutuallyExclusive ? this.handleEnablingEnvironment : null
}
/>
))}
</div>
);
}
}
EnvironmentGroup.propTypes = {
allEnvironments: ImmutablePropTypes.map.isRequired,
changeValue: PropTypes.func.isRequired,
description: PropTypes.string,
environments: ImmutablePropTypes.map,
intl: PropTypes.object,
mutuallyExclusive: PropTypes.bool.isRequired,
title: PropTypes.string
};
@ -132,26 +76,9 @@ EnvironmentGroup.defaultProps = {
mutuallyExclusive: false
};
export default injectIntl(EnvironmentGroup);
const mapDispatchToProps = dispatch => ({
changeValue: (field, value) =>
dispatch(change('environmentConfigurationForm', field, value))
});
class EnvironmentGroupHeading extends React.Component {
render() {
if (this.props.title) {
return (
<h4>
{this.props.title}
<br />
<small>{this.props.description}</small>
</h4>
);
} else if (this.props.description) {
return <p>{this.props.description}</p>;
} else {
return false;
}
}
}
EnvironmentGroupHeading.propTypes = {
description: PropTypes.string,
title: PropTypes.string
};
export default connect(null, mapDispatchToProps)(EnvironmentGroup);

View File

@ -0,0 +1,40 @@
/**
* Copyright 2017 Red Hat Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License. You may obtain
* a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import PropTypes from 'prop-types';
import React from 'react';
const EnvironmentGroupHeading = ({ title, description }) => {
if (title) {
return (
<h4>
{title}
<br />
<small>{description}</small>
</h4>
);
} else if (description) {
return <p>{description}</p>;
} else {
return false;
}
};
EnvironmentGroupHeading.propTypes = {
description: PropTypes.string,
title: PropTypes.string
};
export default EnvironmentGroupHeading;

View File

@ -22,7 +22,11 @@ import React from 'react';
import InputDescription from './InputDescription';
import InputErrorMessage from './InputErrorMessage';
class GenericCheckBox extends React.Component {
class GenericCheckBox extends React.PureComponent {
constructor() {
super();
this.changeValue = this.changeValue.bind(this);
}
changeValue(event) {
this.props.setValue(event.target.checked);
}
@ -43,7 +47,7 @@ class GenericCheckBox extends React.Component {
name={this.props.name}
ref={this.props.id}
id={this.props.id}
onChange={this.changeValue.bind(this)}
onChange={this.changeValue}
checked={!!this.props.getValue()}
value={this.props.getValue()}
/>

View File

@ -22,7 +22,11 @@ import React from 'react';
import InputDescription from './InputDescription';
import InputErrorMessage from './InputErrorMessage';
class GroupedCheckBox extends React.Component {
class GroupedCheckBox extends React.PureComponent {
constructor() {
super();
this.changeValue = this.changeValue.bind(this);
}
changeValue(event) {
this.props.onChange(event.target.checked, this.props.name);
this.props.setValue(event.target.checked);
@ -44,7 +48,7 @@ class GroupedCheckBox extends React.Component {
name={this.props.name}
ref={this.props.id}
id={this.props.id}
onChange={this.changeValue.bind(this)}
onChange={this.changeValue}
checked={!!this.props.getValue()}
value={this.props.getValue()}
/>

View File

@ -0,0 +1,65 @@
/**
* Copyright 2017 Red Hat Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License. You may obtain
* a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import cx from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import { Checkbox, Col, FormGroup } from 'react-bootstrap';
import { getValidationState, InputDescription, InputMessage } from './utils';
const HorizontalCheckBox = ({
id,
label,
labelColumns,
inputColumns,
description,
type,
input,
meta,
required,
...rest
}) => {
return (
<FormGroup controlId={id} validationState={getValidationState(meta)}>
<Col smOffset={labelColumns} sm={inputColumns}>
<Checkbox {...input} {...rest}>
<span className={cx({ 'required-pf': required })}>{label}</span>
</Checkbox>
<InputMessage {...meta} />
<InputDescription description={description} />
</Col>
</FormGroup>
);
};
HorizontalCheckBox.propTypes = {
description: PropTypes.node,
id: PropTypes.string.isRequired,
input: PropTypes.object.isRequired,
inputColumns: PropTypes.number.isRequired,
label: PropTypes.node,
labelColumns: PropTypes.number.isRequired,
meta: PropTypes.object.isRequired,
required: PropTypes.bool.isRequired,
type: PropTypes.string.isRequired
};
HorizontalCheckBox.defaultProps = {
labelColumns: 5,
inputColumns: 7,
required: false,
type: 'text'
};
export default HorizontalCheckBox;

View File

@ -44,7 +44,7 @@ const HorizontalInput = ({
</Col>
<Col sm={inputColumns}>
<FormControl type={type} {...input} {...rest} />
<InputMessage fieldMeta={meta} />
<InputMessage {...meta} />
<InputDescription description={description} />
</Col>
</FormGroup>

View File

@ -52,7 +52,7 @@ const HorizontalSelect = ({
>
{children}
</FormControl>
<InputMessage fieldMeta={meta} />
<InputMessage {...meta} />
<InputDescription description={description} />
</Col>
</FormGroup>

View File

@ -43,7 +43,7 @@ const HorizontalTextarea = ({
</Col>
<Col sm={inputColumns}>
<FormControl componentClass="textarea" {...input} {...rest} />
<InputMessage fieldMeta={meta} />
<InputMessage {...meta} />
<InputDescription description={description} />
</Col>
</FormGroup>

View File

@ -35,12 +35,11 @@ InputDescription.propTypes = {
description: PropTypes.node
};
export const InputMessage = ({ fieldMeta: { touched, error, warning } }) =>
export const InputMessage = ({ touched, error, warning }) =>
touched
? (error ? <HelpBlock>{error}</HelpBlock> : null) ||
(warning ? <HelpBlock>{warning}</HelpBlock> : null)
: null;
InputMessage.propTypes = {
error: PropTypes.node,
touched: PropTypes.bool,

View File

@ -48,9 +48,6 @@ export default function environmentConfigurationReducer(
case EnvironmentConfigurationConstants.FETCH_ENVIRONMENT_CONFIGURATION_FAILED:
return state.set('isFetching', false).set('loaded', true);
case EnvironmentConfigurationConstants.UPDATE_ENVIRONMENT_CONFIGURATION_PENDING:
return state.set('isFetching', true);
case EnvironmentConfigurationConstants.UPDATE_ENVIRONMENT_CONFIGURATION_SUCCESS: {
const enabledEnvs = fromJS(action.payload);
const updatedEnvs = state.environments.map(environment => {
@ -59,15 +56,9 @@ export default function environmentConfigurationReducer(
enabledEnvs.includes(environment.get('file'))
);
});
return state.set('environments', updatedEnvs).set('isFetching', false);
return state.set('environments', updatedEnvs);
}
case EnvironmentConfigurationConstants.UPDATE_ENVIRONMENT_CONFIGURATION_FAILED:
return state
.set('isFetching', false)
.set('loaded', true)
.set('form', fromJS(action.payload));
case EnvironmentConfigurationConstants.FETCH_ENVIRONMENT_PENDING:
return state.setIn(['environments', action.payload, 'isFetching'], true);