Convert Parameters form to use redux-form

Using Formsy in large forms causes performance problems

* Introduce ParametersForm and ParametersSidebar
* Add NewParameterInputList and NewParameterInput as the old ones are
  still used in role forms. After those are converted too, we'll rename
  these components
* Validate json parameter inputs
* Handle submitting of ParametersForm
* Separate updateParameters action to 3 actions: updateParameters,
  updateNodesAssignment, updateRoleParameters as those 3 dialogs all
  update parameters using the same mistral action but they handle it
  differently

Implements: blueprint ui-redux-form-migration
Closes-Bug: 1745408
Change-Id: I527f3587f8adce89be649c487d9a0df5ce4939d5
This commit is contained in:
Jiri Tomasek 2018-01-19 11:46:25 +01:00
parent 8ee595b019
commit 0e7d65228a
14 changed files with 698 additions and 306 deletions

View File

@ -0,0 +1,8 @@
---
features:
- |
Improved perfomance in 'Deployment configuration' -> 'Parameters' section,
a general error is shown when sub-section contains validation error
fixes:
- Fixes `bug 1745408 <https://bugs.launchpad.net/tripleo/+bug/1745408>`__
Parameters form does not load for environments with large amount of parameters

View File

@ -115,20 +115,13 @@ describe('ParametersActions', () => {
}
);
expect(store.getActions()).toEqual([
ReduxFormActions.startSubmit('nodesAssignment'),
ParametersActions.updateParametersPending(),
ReduxFormActions.stopSubmit('nodesAssignment', {
ReduxFormActions.startSubmit('parametersForm'),
ReduxFormActions.stopSubmit('parametersForm', {
_error: {
title: 'Parameters could not be updated',
message: error.message
}
}),
ParametersActions.updateParametersFailed([
{
title: 'Parameters could not be updated',
message: error.message
}
])
})
]);
});
});

View File

@ -28,6 +28,10 @@ const messages = defineMessages({
id: 'ParametersActions.parametersUpdatedNotficationTitle',
defaultMessage: 'Parameters updated'
},
updateParametersFailed: {
id: 'ParametersActions.updateParametersFailed',
defaultMessage: 'Parameters could not be updated'
},
parametersUpdatedNotficationMessage: {
id: 'ParametersActions.parametersUpdatedNotficationMessage',
defaultMessage: 'The Deployment parameters have been successfully updated.'
@ -108,7 +112,45 @@ export default {
};
},
updateParameters(planName, data, inputFieldNames, redirect) {
updateRoleParameters(planName, data, inputFieldNames, redirect) {
return (dispatch, getState, { getIntl }) => {
const { formatMessage } = getIntl(getState());
dispatch(this.updateParametersPending());
return dispatch(
MistralApiService.runAction(MistralConstants.PARAMETERS_UPDATE, {
container: planName,
parameters: data
})
)
.then(response => {
dispatch(this.updateParametersSuccess(data));
dispatch(
NotificationActions.notify({
title: formatMessage(messages.parametersUpdatedNotficationTitle),
message: formatMessage(
messages.parametersUpdatedNotficationMessage
),
type: 'success'
})
);
if (redirect) {
redirect();
}
})
.catch(error => {
dispatch(
this.updateParametersFailed([
{
title: formatMessage(messages.updateParametersFailed),
message: error.message
}
])
);
});
};
},
updateNodesAssignment(planName, data) {
return (dispatch, getState, { getIntl }) => {
const { formatMessage } = getIntl(getState());
dispatch(startSubmit('nodesAssignment'));
@ -131,22 +173,19 @@ export default {
type: 'success'
})
);
if (redirect) {
redirect();
}
})
.catch(error => {
dispatch(
handleErrors(
error,
'Deployment parameters could not be updated',
formatMessage(messages.updateParametersFailed),
false
)
);
dispatch(
stopSubmit('nodesAssignment', {
_error: {
title: 'Parameters could not be updated',
title: formatMessage(messages.updateParametersFailed),
message: error.message
}
})
@ -154,12 +193,42 @@ export default {
dispatch(
this.updateParametersFailed([
{
title: 'Parameters could not be updated',
title: formatMessage(messages.updateParametersFailed),
message: error.message
}
])
);
});
};
},
updateParameters(planName, data, redirect) {
return (dispatch, getState, { getIntl }) => {
const { formatMessage } = getIntl(getState());
dispatch(startSubmit('parametersForm'));
return dispatch(
MistralApiService.runAction(MistralConstants.PARAMETERS_UPDATE, {
container: planName,
parameters: data
})
)
.then(response => {
dispatch(this.updateParametersSuccess(data));
dispatch(stopSubmit('parametersForm'));
if (redirect) {
redirect();
}
})
.catch(error => {
dispatch(
stopSubmit('parametersForm', {
_error: {
title: formatMessage(messages.updateParametersFailed),
message: error.message
}
})
);
});
};
}
};

View File

@ -41,59 +41,50 @@ const messages = defineMessages({
}
});
class DeploymentConfiguration extends React.Component {
constructor() {
super();
this.state = { show: true };
}
render() {
const { location, match } = this.props;
return (
<RoutedModal
id="DeploymentConfiguration__ModalDialog"
bsSize="xl"
redirectPath={`/plans/${match.params.planName}`}
const DeploymentConfiguration = ({ location, match }) => (
<RoutedModal
id="DeploymentConfiguration__ModalDialog"
bsSize="xl"
redirectPath={`/plans/${match.params.planName}`}
>
<ModalHeader>
<CloseModalXButton />
<ModalTitle>
<FormattedMessage {...messages.deploymentConfiguration} />
</ModalTitle>
</ModalHeader>
<ul className="nav nav-tabs">
<NavTab
id="DeploymentConfiguration__OverallSettingsTab"
to={`${match.url}/environment`}
>
<ModalHeader>
<CloseModalXButton />
<ModalTitle>
<FormattedMessage {...messages.deploymentConfiguration} />
</ModalTitle>
</ModalHeader>
<FormattedMessage {...messages.overallSettings} />
</NavTab>
<NavTab
id="DeploymentConfiguration__ParametersTab"
to={`${match.url}/parameters`}
>
<FormattedMessage {...messages.parameters} />
</NavTab>
</ul>
<ul className="nav nav-tabs">
<NavTab
id="DeploymentConfiguration__OverallSettingsTab"
to={`${match.url}/environment`}
>
<FormattedMessage {...messages.overallSettings} />
</NavTab>
<NavTab
id="DeploymentConfiguration__ParametersTab"
to={`${match.url}/parameters`}
>
<FormattedMessage {...messages.parameters} />
</NavTab>
</ul>
<Switch location={location}>
<Route
path="/plans/:planName/configuration/environment"
component={EnvironmentConfiguration}
/>
<Route
path="/plans/:planName/configuration/parameters"
component={Parameters}
/>
<Redirect
from="/plans/:planName/configuration"
to={`${match.url}/environment`}
/>
</Switch>
</RoutedModal>
);
}
}
<Switch location={location}>
<Route
path="/plans/:planName/configuration/environment"
component={EnvironmentConfiguration}
/>
<Route
path="/plans/:planName/configuration/parameters"
component={Parameters}
/>
<Redirect
from="/plans/:planName/configuration"
to={`${match.url}/environment`}
/>
</Switch>
</RoutedModal>
);
DeploymentConfiguration.propTypes = {
location: PropTypes.object.isRequired,
match: PropTypes.object.isRequired

View File

@ -25,7 +25,7 @@ import { getEnvironmentParameters } from '../../selectors/parameters';
import { getEnvironment } from '../../selectors/environmentConfiguration';
import InlineNotification from '../ui/InlineNotification';
import { Loader } from '../ui/Loader';
import ParameterInputList from './ParameterInputList';
import ParameterInputList from './NewParameterInputList';
class EnvironmentParameters extends React.Component {
componentDidMount() {
@ -41,7 +41,7 @@ class EnvironmentParameters extends React.Component {
<Loader
height={120}
loaded={!isFetchingEnvironment}
content="Fetching Parameters..."
content="Loading configuration..."
>
{environmentError ? (
<fieldset>
@ -70,7 +70,6 @@ function mapStateToProps(state, ownProps) {
currentPlanName: getCurrentPlanName(state),
environmentError: getEnvironment(state, ownProps.environment).error,
parameters: getEnvironmentParameters(state, ownProps.environment),
parametersLoaded: state.parameters.loaded,
isFetchingEnvironment: getEnvironment(state, ownProps.environment)
.isFetching
};

View File

@ -0,0 +1,121 @@
/**
* 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 { Field } from 'redux-form';
import PropTypes from 'prop-types';
import React from 'react';
import { isObjectLike } from 'lodash';
import HorizontalInput from '../ui/reduxForm/HorizontalInput';
import HorizontalTextarea from '../ui/reduxForm/HorizontalTextarea';
import HorizontalCheckBox from '../ui/reduxForm/HorizontalCheckBox';
import HorizontalStaticText from '../ui/forms/HorizontalStaticText';
// TODO(jtomasek): rename this when original Formsy based ParameterInputList and
// ParameterInput are not used
class NewParameterInput extends React.Component {
render() {
const { name, label, description, defaultValue, value, type } = this.props;
const commonProps = {
component: HorizontalInput,
name,
id: name,
label,
description,
labelColumns: 4,
inputColumns: 8
};
if (value) {
return (
<HorizontalStaticText
text={isObjectLike(value) ? JSON.stringify(value) : value}
title={label}
labelColumnClasses="col-sm-4"
inputColumnClasses="col-sm-8"
/>
);
} else if (type.toLowerCase() === 'commadelimitedlist') {
return (
<Field
{...commonProps}
parse={value => value.split(',')}
component={HorizontalTextarea}
/>
);
} else if (type.toLowerCase() === 'json' || isObjectLike(defaultValue)) {
return <Field {...commonProps} component={HorizontalTextarea} />;
} else if (
type.toLowerCase() === 'string' &&
/^.*(Key|Cert|Certificate)$/.test(name)
) {
return <Field {...commonProps} component={HorizontalTextarea} />;
} else if (type.toLowerCase() === 'number') {
return (
<Field
{...commonProps}
type="number"
parse={value =>
isNaN(parseInt(value)) ? undefined : parseInt(value)
}
/>
);
} else if (type.toLowerCase() === 'boolean') {
return (
<Field
{...commonProps}
type="checkbox"
component={HorizontalCheckBox}
/>
);
} else {
return (
<Field
{...commonProps}
component={HorizontalInput}
description={description}
labelColumns={4}
inputColumns={8}
/>
);
}
}
}
NewParameterInput.propTypes = {
defaultValue: PropTypes.oneOfType([
PropTypes.object,
PropTypes.array,
PropTypes.bool,
PropTypes.number,
PropTypes.string
]),
description: PropTypes.string.isRequired,
intl: PropTypes.object,
label: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
value: PropTypes.oneOfType([
PropTypes.object,
PropTypes.array,
PropTypes.bool,
PropTypes.number,
PropTypes.string
])
};
NewParameterInput.defaultProps = {
defaultValue: ''
};
export default NewParameterInput;

View File

@ -0,0 +1,75 @@
/**
* 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, injectIntl } from 'react-intl';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import InlineNotification from '../ui/InlineNotification';
import ParameterInput from './NewParameterInput';
const messages = defineMessages({
noParameters: {
id: 'ParameterInputList.noParameters',
defaultMessage:
'There are currently no parameters to configure in this section.'
}
});
// TODO(jtomasek): rename this when original Formsy based ParameterInputList and
// ParameterInput are not used
class NewParameterInputList extends React.Component {
render() {
const emptyParametersMessage =
this.props.emptyParametersMessage ||
this.props.intl.formatMessage(messages.noParameters);
const parameters = this.props.parameters.map(parameter => {
return (
<ParameterInput
key={parameter.name}
name={parameter.name}
label={parameter.label}
description={parameter.description}
defaultValue={parameter.default}
value={parameter.value}
type={parameter.type}
/>
);
});
if (parameters.isEmpty()) {
return (
<fieldset>
<InlineNotification type="info">
{emptyParametersMessage}
</InlineNotification>
</fieldset>
);
} else {
return <fieldset>{parameters}</fieldset>;
}
}
}
NewParameterInputList.propTypes = {
emptyParametersMessage: PropTypes.string,
intl: PropTypes.object,
mistralParameters: ImmutablePropTypes.map,
parameters: ImmutablePropTypes.list.isRequired
};
export default injectIntl(NewParameterInputList);

View File

@ -15,53 +15,28 @@
*/
import { connect } from 'react-redux';
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
import Formsy from 'formsy-react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { isObjectLike, mapValues } from 'lodash';
import { fromJS, is } from 'immutable';
import { pickBy, isEqual, mapValues } from 'lodash';
import PropTypes from 'prop-types';
import React from 'react';
import { CloseModalButton } from '../ui/Modals';
import EnvironmentConfigurationActions from '../../actions/EnvironmentConfigurationActions';
import EnvironmentParameters from './EnvironmentParameters';
import { getCurrentPlanName } from '../../selectors/plans';
import { getRootParameters } from '../../selectors/parameters';
import { getEnabledEnvironments } from '../../selectors/environmentConfiguration';
import { Loader } from '../ui/Loader';
import ModalFormErrorList from '../ui/forms/ModalFormErrorList';
import ParametersActions from '../../actions/ParametersActions';
import ParameterInputList from './ParameterInputList';
import Tab from '../ui/Tab';
import ParametersForm from './ParametersForm';
import ParameterInputList from './NewParameterInputList';
import ParametersSidebar from './ParametersSidebar';
import TabPane from '../ui/TabPane';
const messages = defineMessages({
saveChanges: {
id: 'Parameters.saveChanges',
defaultMessage: 'Save Changes'
},
saveAndClose: {
id: 'Parameters.saveAndClose',
defaultMessage: 'Save And Close'
},
cancel: {
id: 'Parameters.cancel',
defaultMessage: 'Cancel'
},
general: {
id: 'Parameters.general',
defaultMessage: 'General'
}
});
class Parameters extends React.Component {
constructor() {
super();
this.state = {
canSubmit: false,
closeOnSubmit: false,
selectedTab: 'general'
activeTab: 'general'
};
}
@ -70,116 +45,23 @@ class Parameters extends React.Component {
currentPlanName,
fetchEnvironmentConfiguration,
fetchParameters,
history,
isFetchingParameters
} = this.props;
fetchEnvironmentConfiguration(currentPlanName);
fetchEnvironmentConfiguration(currentPlanName, () =>
history.push(`/plans/${currentPlanName}`)
);
!isFetchingParameters && fetchParameters(currentPlanName);
}
componentDidUpdate() {
this.invalidateForm(this.props.formFieldErrors.toJS());
}
enableButton() {
this.setState({ canSubmit: true });
}
disableButton() {
this.setState({ canSubmit: false });
}
/**
* Filter out non updated parameters, so only parameters which have been actually changed
* get sent to updateparameters
*/
_filterFormData(formData) {
return fromJS(formData)
.filterNot((value, key) => {
return is(fromJS(this.props.allParameters.get(key).default), value);
})
.toJS();
}
/**
* Json parameter values are sent as string, this function parses them and checks if they're object
* or array. Also, parameters with undefined value are set to null
*/
_jsonParseFormData(formData) {
return mapValues(formData, value => {
try {
const parsedValue = JSON.parse(value);
if (isObjectLike(parsedValue)) {
return parsedValue;
}
return value;
} catch (e) {
return value === undefined ? null : value;
}
});
}
invalidateForm(formFieldErrors) {
this.refs.parameterConfigurationForm.updateInputsWithError(formFieldErrors);
}
handleSubmit(formData, resetForm, invalidateForm) {
this.disableButton();
this.props.updateParameters(
this.props.currentPlanName,
this._filterFormData(this._jsonParseFormData(formData)),
Object.keys(this.refs.parameterConfigurationForm.inputs)
);
if (this.state.closeOnSubmit) {
this.setState({
closeOnSubmit: false
});
this.props.history.push(`/plans/${this.props.currentPlanName}`);
}
}
onSubmitAndClose() {
this.setState(
{
closeOnSubmit: true
},
this.refs.parameterConfigurationForm.submit
);
}
selectTab(tabName) {
this.setState({
selectedTab: tabName
});
}
renderTabs() {
return this.props.enabledEnvironments.toList().map(environment => {
return (
<Tab
key={environment.file}
title={environment.description}
isActive={environment.file === this.state.selectedTab}
>
<a
className="link"
onClick={this.selectTab.bind(this, environment.file)}
>
{environment.title}
</a>
</Tab>
);
});
}
isTabActive = tabName => tabName === this.state.activeTab;
renderTabPanes() {
if (this.state.selectedTab === 'general') {
if (this.state.activeTab === 'general') {
return (
<TabPane isActive>
<ParameterInputList
parameters={this.props.parameters.toList()}
parameters={this.props.rootParameters.toList()}
mistralParameters={this.props.mistralParameters}
/>
</TabPane>
@ -188,7 +70,7 @@ class Parameters extends React.Component {
return this.props.enabledEnvironments.toList().map(environment => {
return (
<TabPane
isActive={this.state.selectedTab === environment.file}
isActive={this.state.activeTab === environment.file}
key={environment.file}
renderOnlyActive
>
@ -199,75 +81,99 @@ class Parameters extends React.Component {
}
}
/**
* Filter out non updated parameters, so only parameters which have been actually changed
* get sent to updateparameters
*/
_filterFormData(values, initialValues) {
return pickBy(values, (value, key) => !isEqual(value, initialValues[key]));
}
/**
* Json parameter values are sent as string, this function parses them and checks if they're object
* or array. Also, parameters with undefined value are set to null
*/
_parseJsonTypeValues(values, parameters) {
return mapValues(values, (value, key) => {
if (parameters.get(key).type.toLowerCase() === 'json') {
try {
return JSON.parse(value);
} catch (e) {
return value === undefined ? null : value;
}
}
return value === undefined ? null : value;
});
}
handleSubmit = ({ saveAndClose, ...values }, dispatch, { initialValues }) => {
const {
currentPlanName,
history,
parameters,
updateParameters
} = this.props;
const updatedValues = this._parseJsonTypeValues(
this._filterFormData(values, initialValues),
parameters
);
updateParameters(
currentPlanName,
updatedValues,
saveAndClose && history.push.bind(this, `/plans/${currentPlanName}`)
);
};
_convertJsonTypeParameterValueToString(value) {
// Heat defaults empty values to empty string also some JSON type parameters
// accept empty string as valid value
return ['', undefined].includes(value) ? '' : JSON.stringify(value);
}
getFormInitialValues() {
return this.props.parameters
.map(p => {
const value = p.value === undefined ? p.default : p.value;
if (p.type.toLowerCase() === 'json') {
return this._convertJsonTypeParameterValueToString(value);
} else {
return value;
}
})
.toJS();
}
render() {
const { enabledEnvironments, parameters, parametersLoaded } = this.props;
return (
<Formsy
ref="parameterConfigurationForm"
role="form"
className="form form-horizontal"
onSubmit={this.handleSubmit.bind(this)}
onValid={this.enableButton.bind(this)}
onInvalid={this.disableButton.bind(this)}
<Loader
height={120}
content="Fetching Parameters..."
loaded={parametersLoaded}
>
<div className="container-fluid">
<div className="row row-eq-height">
<div className="col-sm-4 sidebar-pf sidebar-pf-left">
<ul className="nav nav-pills nav-stacked nav-arrows">
<Tab
key="general"
title={this.props.intl.formatMessage(messages.general)}
isActive={'general' === this.state.selectedTab}
>
<a
className="link"
onClick={this.selectTab.bind(this, 'general')}
>
<FormattedMessage {...messages.general} />
</a>
</Tab>
<li className="spacer" />
{this.renderTabs()}
</ul>
</div>
<div className="col-sm-8">
<Loader
height={120}
content="Fetching Parameters..."
loaded={this.props.parametersLoaded}
>
<ModalFormErrorList errors={this.props.formErrors.toJS()} />
<ParametersForm
onSubmit={this.handleSubmit}
parameters={parameters}
initialValues={this.getFormInitialValues()}
>
<div className="container-fluid">
<div className="row row-eq-height">
<ParametersSidebar
activateTab={tabName => this.setState({ activeTab: tabName })}
enabledEnvironments={enabledEnvironments.toList()}
isTabActive={this.isTabActive}
/>
<div className="col-sm-8">
<div className="tab-content">{this.renderTabPanes()}</div>
</Loader>
</div>
</div>
</div>
</div>
<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>
</ParametersForm>
</Loader>
);
}
}
Parameters.propTypes = {
allParameters: ImmutablePropTypes.map.isRequired,
currentPlanName: PropTypes.string,
enabledEnvironments: ImmutablePropTypes.map.isRequired,
fetchEnvironmentConfiguration: PropTypes.func.isRequired,
@ -280,12 +186,13 @@ Parameters.propTypes = {
mistralParameters: ImmutablePropTypes.map.isRequired,
parameters: ImmutablePropTypes.map.isRequired,
parametersLoaded: PropTypes.bool,
rootParameters: ImmutablePropTypes.map.isRequired,
updateParameters: PropTypes.func
};
function mapStateToProps(state, ownProps) {
return {
allParameters: state.parameters.parameters,
parameters: state.parameters.parameters,
enabledEnvironments: getEnabledEnvironments(state),
form: state.parameters.form,
formErrors: state.parameters.form.get('formErrors'),
@ -293,7 +200,7 @@ function mapStateToProps(state, ownProps) {
currentPlanName: getCurrentPlanName(state),
isFetchingParameters: state.parameters.isFetching,
mistralParameters: state.parameters.mistralParameters,
parameters: getRootParameters(state),
rootParameters: getRootParameters(state),
parametersLoaded: state.parameters.loaded
};
}
@ -315,19 +222,12 @@ function mapDispatchToProps(dispatch, ownProps) {
)
);
},
updateParameters: (currentPlanName, data, inputFields, redirect) => {
updateParameters: (currentPlanName, data, redirect) => {
dispatch(
ParametersActions.updateParameters(
currentPlanName,
data,
inputFields,
redirect
)
ParametersActions.updateParameters(currentPlanName, data, redirect)
);
}
};
}
export default injectIntl(
connect(mapStateToProps, mapDispatchToProps)(Parameters)
);
export default connect(mapStateToProps, mapDispatchToProps)(Parameters);

View File

@ -0,0 +1,166 @@
/**
* 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 { Button, Form, ModalFooter } from 'react-bootstrap';
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { isEqual, pickBy } from 'lodash';
import PropTypes from 'prop-types';
import React from 'react';
import { reduxForm } from 'redux-form';
import { CloseModalButton } from '../ui/Modals';
import ModalFormErrorList from '../ui/forms/ModalFormErrorList';
import { OverlayLoader } from '../ui/Loader';
const messages = defineMessages({
cancel: {
id: 'ParametersForm.cancel',
defaultMessage: 'Cancel'
},
saveChanges: {
id: 'ParametersForm.saveChanges',
defaultMessage: 'Save Changes'
},
saveAndClose: {
id: 'ParametersForm.saveAndClose',
defaultMessage: 'Save And Close'
},
updatingParameters: {
id: 'ParametersForm.updatingParameters',
defaultMessage: 'Updating parameters'
},
enterValidJson: {
id: 'ParametersForm.enterValidJson',
defaultMessage: 'Please enter a valid JSON.'
},
invalidParameters: {
id: 'ParametersForm.invalidParameters',
defaultMessage: 'Some parameter values are invalid:',
description: 'Form error notification title'
},
invalidParametersList: {
id: 'ParametersForm.invalidParametersList',
defaultMessage: '{parameters}',
description: 'A list of invalid parameters in form error message'
}
});
const ParametersForm = ({
dispatch,
error,
children,
onSubmit,
handleSubmit,
intl: { formatMessage },
invalid,
pristine,
submitting,
initialValues
}) => (
<Form onSubmit={handleSubmit} horizontal>
<OverlayLoader
loaded={!submitting}
content={formatMessage(messages.updatingParameters)}
>
<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 }, dispatch, {
initialValues
})
)}
>
<FormattedMessage {...messages.saveAndClose} />
</Button>
<CloseModalButton>
<FormattedMessage {...messages.cancel} />
</CloseModalButton>
</ModalFooter>
</Form>
);
ParametersForm.propTypes = {
children: PropTypes.node,
dispatch: PropTypes.func.isRequired,
error: PropTypes.object,
handleSubmit: PropTypes.func.isRequired,
initialValues: PropTypes.object.isRequired,
intl: PropTypes.object.isRequired,
invalid: PropTypes.bool.isRequired,
onSubmit: PropTypes.func.isRequired,
parameters: ImmutablePropTypes.map.isRequired,
pristine: PropTypes.bool.isRequired,
submitting: PropTypes.bool.isRequired
};
const isJSON = value => {
try {
// empty string is considered valid JSON type parameter value
return value === '' ? true : !!JSON.parse(value);
} catch (e) {
return false;
}
};
const validate = (
values,
{ initialValues, parameters, intl: { formatMessage } }
) => {
const errors = {};
// Validate only parameters which were updated by User
const updatedValues = pickBy(
values,
(value, key) => !isEqual(value, initialValues[key])
);
Object.keys(updatedValues).map(key => {
if (parameters.getIn([key, 'type']).toLowerCase() === 'json') {
if (!isJSON(values[key])) {
errors[key] = formatMessage(messages.enterValidJson);
}
}
});
// Add global error message
if (Object.keys(errors).length > 0) {
errors['_error'] = {
title: formatMessage(messages.invalidParameters),
message: formatMessage(messages.invalidParametersList, {
parameters: Object.keys(errors).join(', ')
})
};
}
return errors;
};
const form = reduxForm({
form: 'parametersForm',
validate
});
export default injectIntl(form(ParametersForm));

View File

@ -0,0 +1,74 @@
/**
* 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 ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import React from 'react';
import Tab from '../ui/Tab';
const messages = defineMessages({
general: {
id: 'Parameters.general',
defaultMessage: 'General'
}
});
const ParametersSidebar = ({
activateTab,
enabledEnvironments,
intl: { formatMessage },
isTabActive
}) => (
<div className="col-sm-4 sidebar-pf sidebar-pf-left">
<ul
id="Parameters__EnvironmentTabsList"
className="nav nav-pills nav-stacked nav-arrows"
>
<Tab
key="general"
title={formatMessage(messages.general)}
isActive={isTabActive('general')}
>
<a className="link" onClick={() => activateTab('general')}>
<FormattedMessage {...messages.general} />
</a>
</Tab>
<li className="spacer" />
{enabledEnvironments.map(environment => {
return (
<Tab key={environment.file} isActive={isTabActive(environment.file)}>
<a
className="link"
onClick={() => activateTab(environment.file)}
title={environment.file}
>
{environment.title}
</a>
</Tab>
);
})}
</ul>
</div>
);
ParametersSidebar.propTypes = {
activateTab: PropTypes.func.isRequired,
enabledEnvironments: ImmutablePropTypes.list.isRequired,
intl: PropTypes.object.isRequired,
isTabActive: PropTypes.func.isRequired
};
export default injectIntl(ParametersSidebar);

View File

@ -100,15 +100,8 @@ const mapStateToProps = state => ({
const mapDispatchToProps = dispatch => {
return {
updateParameters: (currentPlanName, data, inputFields, redirectPath) => {
dispatch(
ParametersActions.updateParameters(
currentPlanName,
data,
inputFields,
redirectPath
)
);
updateParameters: (currentPlanName, data) => {
dispatch(ParametersActions.updateNodesAssignment(currentPlanName, data));
}
};
};

View File

@ -294,7 +294,7 @@ function mapDispatchToProps(dispatch) {
},
updateParameters: (currentPlanName, data, inputFields, redirectPath) => {
dispatch(
ParametersActions.updateParameters(
ParametersActions.updateRoleParameters(
currentPlanName,
data,
inputFields,

View File

@ -17,7 +17,7 @@
import cx from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import { Checkbox, Col, FormGroup } from 'react-bootstrap';
import { Checkbox, Col, ControlLabel, FormGroup } from 'react-bootstrap';
import { getValidationState, InputDescription, InputMessage } from './utils';
@ -35,10 +35,15 @@ const HorizontalCheckBox = ({
}) => {
return (
<FormGroup controlId={id} validationState={getValidationState(meta)}>
<Col smOffset={labelColumns} sm={inputColumns}>
<Checkbox {...input} {...rest}>
<span className={cx({ 'required-pf': required })}>{label}</span>
</Checkbox>
<Col
componentClass={ControlLabel}
sm={labelColumns}
className={cx({ 'required-pf': required })}
>
{label}
</Col>
<Col sm={inputColumns}>
<Checkbox {...input} {...rest} />
<InputMessage {...meta} />
<InputDescription description={description} />
</Col>

View File

@ -31,24 +31,22 @@ const HorizontalTextarea = ({
meta,
required,
...rest
}) => {
return (
<FormGroup controlId={id} validationState={getValidationState(meta)}>
<Col
componentClass={ControlLabel}
sm={labelColumns}
className={cx({ 'required-pf': required })}
>
{label}
</Col>
<Col sm={inputColumns}>
<FormControl componentClass="textarea" {...input} {...rest} />
<InputMessage {...meta} />
<InputDescription description={description} />
</Col>
</FormGroup>
);
};
}) => (
<FormGroup controlId={id} validationState={getValidationState(meta)}>
<Col
componentClass={ControlLabel}
sm={labelColumns}
className={cx({ 'required-pf': required })}
>
{label}
</Col>
<Col sm={inputColumns}>
<FormControl componentClass="textarea" {...input} {...rest} />
<InputMessage {...meta} />
<InputDescription description={description} />
</Col>
</FormGroup>
);
HorizontalTextarea.propTypes = {
description: PropTypes.node,
id: PropTypes.string.isRequired,