diff --git a/frontend/.babelrc b/frontend/.babelrc index 799d0247a0..1a1487ba8f 100644 --- a/frontend/.babelrc +++ b/frontend/.babelrc @@ -2,7 +2,8 @@ "presets": ["@babel/preset-env", "@babel/preset-react"], "plugins": [ ["@babel/plugin-proposal-decorators", { "legacy": true }], - "@babel/plugin-proposal-class-properties" + "@babel/plugin-proposal-class-properties", + "@babel/plugin-transform-runtime" ], "env": { "test": { diff --git a/frontend/package.json b/frontend/package.json index c926f13c5a..9d067c8657 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -44,8 +44,10 @@ "@babel/plugin-proposal-class-properties": "^7.5.5", "@babel/plugin-proposal-decorators": "^7.6.0", "@babel/plugin-transform-modules-commonjs": "^7.6.0", + "@babel/plugin-transform-runtime": "^7.7.6", "@babel/preset-env": "^7.6.2", "@babel/preset-react": "^7.0.0", + "array-move": "^2.2.1", "babel-eslint": "^10.0.3", "babel-jest": "^24.9.0", "babel-loader": "^8.0.6", diff --git a/frontend/src/component/feature/__tests__/__snapshots__/view-component-test.jsx.snap b/frontend/src/component/feature/__tests__/__snapshots__/view-component-test.jsx.snap index f30374cbbd..9774fd2edd 100644 --- a/frontend/src/component/feature/__tests__/__snapshots__/view-component-test.jsx.snap +++ b/frontend/src/component/feature/__tests__/__snapshots__/view-component-test.jsx.snap @@ -19,6 +19,7 @@ exports[`renders correctly with one feature 1`] = ` } > Another + another's description @@ -56,17 +57,36 @@ exports[`renders correctly with one feature 1`] = ` Disabled - + + + Clone + + + - Archive - + title="Archive feature toggle" + > + Archive + +
- Create feature toggle + Create new feature toggle -
+
Enabled -
+
+
'StrategiesSection'); it('render the create feature page', () => { let input = { name: 'feature', - nameError: {}, description: 'Description', enabled: false, }; + let errors = {}; const tree = shallow( { let input = { name: 'feature', - nameError: {}, description: 'Description', enabled: false, }; +let errors = {}; let validateName = jest.fn(); let setValue = jest.fn(); @@ -56,6 +57,7 @@ let eventMock = { const buildComponent = (setValue, validateName) => ( 'StrategiesSection'); it('render the create feature page', () => { let input = { name: 'feature', - nameError: {}, + errors: {}, description: 'Description', enabled: false, }; diff --git a/frontend/src/component/feature/form/form-add-feature-component.jsx b/frontend/src/component/feature/form/form-add-feature-component.jsx index f5a9284dbc..fc2c575b15 100644 --- a/frontend/src/component/feature/form/form-add-feature-component.jsx +++ b/frontend/src/component/feature/form/form-add-feature-component.jsx @@ -5,27 +5,18 @@ import StrategiesSection from './strategies-section-container'; import { FormButtons } from './../../common'; import { styles as commonStyles } from '../../common'; - -const trim = value => { - if (value && value.trim) { - return value.trim(); - } else { - return value; - } -}; +import { trim } from './util'; class AddFeatureComponent extends Component { // static displayName = `AddFeatureComponent-${getDisplayName(Component)}`; - componentWillMount() { - // TODO unwind this stuff - if (this.props.initCallRequired === true) { - this.props.init(this.props.input); - } + componentDidMount() { + window.onbeforeunload = () => 'Data will be lost if you leave the page, are you sure?'; } render() { const { input, + errors, setValue, validateName, addStrategy, @@ -36,26 +27,19 @@ class AddFeatureComponent extends Component { onCancel, } = this.props; - const { - name, // eslint-disable-line - nameError, - description, - enabled, - } = input; const configuredStrategies = input.strategies || []; return ( - Create feature toggle - + Create new feature toggle +
validateName(v.target.value)} onChange={v => setValue('name', trim(v.target.value))} /> @@ -64,30 +48,34 @@ class AddFeatureComponent extends Component { style={{ width: '100%' }} rows={1} label="Description" - required - value={description} + error={errors.description} + value={input.description} onChange={v => setValue('description', v.target.value)} />

{ - setValue('enabled', !enabled); + setValue('enabled', !input.enabled); }} > Enabled -
+
+
- + {input.name ? ( + + ) : null}
@@ -102,6 +90,7 @@ class AddFeatureComponent extends Component { AddFeatureComponent.propTypes = { input: PropTypes.object, + errors: PropTypes.object, setValue: PropTypes.func.isRequired, addStrategy: PropTypes.func.isRequired, removeStrategy: PropTypes.func.isRequired, diff --git a/frontend/src/component/feature/form/form-add-feature-container.jsx b/frontend/src/component/feature/form/form-add-feature-container.jsx index 0980e8ce40..1943315150 100644 --- a/frontend/src/component/feature/form/form-add-feature-container.jsx +++ b/frontend/src/component/feature/form/form-add-feature-container.jsx @@ -1,78 +1,122 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; import { connect } from 'react-redux'; +import arrayMove from 'array-move'; import { createFeatureToggles, validateName } from './../../../store/feature-actions'; -import { createMapper, createActions } from './../../input-helpers'; import AddFeatureComponent from './form-add-feature-component'; const defaultStrategy = { name: 'default' }; -const ID = 'add-feature-toggle'; -const mapStateToProps = createMapper({ - id: ID, - getDefault() { - let name; +class WrapperComponent extends Component { + constructor() { + super(); + this.state = { + featureToggle: { strategies: [], enabled: true }, + errors: {}, + dirty: false, + }; + } + + setValue = (field, value) => { + const { featureToggle } = this.state; + featureToggle[field] = value; + this.setState({ featureToggle, dirty: true }); + }; + + validateName = async featureToggleName => { + const { errors } = this.state; try { - [, name] = document.location.hash.match(/name=([a-z0-9-_.]+)/i); - } catch (e) { - // hide error + await validateName(featureToggleName); + errors.name = undefined; + } catch (err) { + errors.name = err.message; } - return { name }; - }, -}); -const prepare = (methods, dispatch, ownProps) => { - methods.onSubmit = input => e => { - e.preventDefault(); - input.createdAt = new Date(); + this.setState({ errors }); + }; - if (Array.isArray(input.strategies)) { - input.strategies.forEach(s => { + addStrategy = strat => { + strat.id = Math.round(Math.random() * 10000000); + const { featureToggle } = this.state; + const strategies = featureToggle.strategies.concat(strat); + featureToggle.strategies = strategies; + this.setState({ featureToggle, dirty: true }); + }; + + moveStrategy = (index, toIndex) => { + const { featureToggle } = this.state; + const strategies = arrayMove(featureToggle.strategies, index, toIndex); + featureToggle.strategies = strategies; + this.setState({ featureToggle, dirty: true }); + }; + + removeStrategy = index => { + const { featureToggle } = this.state; + const strategies = featureToggle.strategies.filter((_, i) => i !== index); + featureToggle.strategies = strategies; + this.setState({ featureToggle, dirty: true }); + }; + + updateStrategy = (index, strat) => { + const { featureToggle } = this.state; + const strategies = featureToggle.strategies.concat(); + strategies[index] = strat; + featureToggle.strategies = strategies; + this.setState({ featureToggle, dirty: true }); + }; + + onSubmit = evt => { + evt.preventDefault(); + const { createFeatureToggles, history } = this.props; + const { featureToggle } = this.state; + featureToggle.createdAt = new Date(); + + if (Array.isArray(featureToggle.strategies)) { + featureToggle.strategies.forEach(s => { delete s.id; }); } else { - input.strategies = [defaultStrategy]; + featureToggle.strategies = [defaultStrategy]; } - createFeatureToggles(input)(dispatch) - .then(() => methods.clear()) - .then(() => ownProps.history.push(`/features/strategies/${input.name}`)); + createFeatureToggles(featureToggle).then(() => history.push(`/features/strategies/${featureToggle.name}`)); }; - methods.onCancel = evt => { + onCancel = evt => { evt.preventDefault(); - methods.clear(); - ownProps.history.push('/features'); + this.props.history.push('/features'); }; - methods.addStrategy = v => { - v.id = Math.round(Math.random() * 10000000); - methods.pushToList('strategies', v); - }; - - methods.updateStrategy = (index, n) => { - methods.updateInList('strategies', index, n); - }; - - methods.moveStrategy = (index, toIndex) => { - methods.moveItem('strategies', index, toIndex); - }; - - methods.removeStrategy = index => { - methods.removeFromList('strategies', index); - }; - - methods.validateName = featureToggleName => { - validateName(featureToggleName) - .then(() => methods.setValue('nameError', undefined)) - .catch(err => methods.setValue('nameError', err.message)); - }; - - return methods; + render() { + return ( + + ); + } +} +WrapperComponent.propTypes = { + history: PropTypes.object.isRequired, + createFeatureToggles: PropTypes.func.isRequired, }; -const actions = createActions({ id: ID, prepare }); + +const mapDispatchToProps = dispatch => ({ + validateName: name => validateName(name)(dispatch), + createFeatureToggles: featureToggle => createFeatureToggles(featureToggle)(dispatch), +}); const FormAddContainer = connect( - mapStateToProps, - actions -)(AddFeatureComponent); + () => {}, + mapDispatchToProps +)(WrapperComponent); export default FormAddContainer; diff --git a/frontend/src/component/feature/form/form-copy-feature-component.jsx b/frontend/src/component/feature/form/form-copy-feature-component.jsx new file mode 100644 index 0000000000..a27534658a --- /dev/null +++ b/frontend/src/component/feature/form/form-copy-feature-component.jsx @@ -0,0 +1,139 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; + +import { Link } from 'react-router-dom'; + +import { Button, Icon, Textfield, Checkbox, Card, CardTitle, CardActions } from 'react-mdl'; + +import { styles as commonStyles } from '../../common'; + +import { trim } from './util'; + +class CopyFeatureComponent extends Component { + // static displayName = `AddFeatureComponent-${getDisplayName(Component)}`; + + constructor() { + super(); + this.state = { newToggleName: '', replaceGroupId: true }; + } + + componentWillMount() { + // TODO unwind this stuff + if (this.props.copyToggle) { + this.setState({ featureToggle: this.props.copyToggle }); + } + } + + componentDidMount() { + if (this.props.copyToggle) { + this.refs.name.inputRef.focus(); + } else { + this.props.fetchFeatureToggles(); + } + } + + setValue = evt => { + const value = trim(evt.target.value); + this.setState({ newToggleName: value }); + }; + + toggleReplaceGroupId = () => { + const { replaceGroupId } = !!this.state; + this.setState({ replaceGroupId }); + }; + + onValidateName = async () => { + const { newToggleName } = this.state; + try { + await this.props.validateName(newToggleName); + this.setState({ nameError: undefined }); + } catch (err) { + this.setState({ nameError: err.message }); + } + }; + + onSubmit = evt => { + evt.preventDefault(); + + const { nameError, newToggleName, replaceGroupId } = this.state; + if (nameError) { + return; + } + + const { copyToggle, history } = this.props; + + copyToggle.name = newToggleName; + + if (replaceGroupId) { + copyToggle.strategies.forEach(s => { + if (s.parameters.groupId) { + s.parameters.groupId = newToggleName; + } + }); + } + + this.props.createFeatureToggle(copyToggle).then(() => history.push(`/features/strategies/${copyToggle.name}`)); + }; + + render() { + const { copyToggle } = this.props; + + if (!copyToggle) return Toggle not found; + + const { newToggleName, nameError, replaceGroupId } = this.state; + + return ( + + + Copy {copyToggle.name} + + + +
+

+ You are about to create a new feature toggle by cloning the configuration of feature + toggle  + {copyToggle.name}. You must give + the new feature toggle a unique name before you can proceed. +

+ +
+
+ +
+
+ + +
+
+ +
+ ); + } +} + +CopyFeatureComponent.propTypes = { + copyToggle: PropTypes.object, + history: PropTypes.object.isRequired, + createFeatureToggle: PropTypes.func.isRequired, + fetchFeatureToggles: PropTypes.func.isRequired, + validateName: PropTypes.func.isRequired, +}; + +export default CopyFeatureComponent; diff --git a/frontend/src/component/feature/form/form-copy-feature-container.jsx b/frontend/src/component/feature/form/form-copy-feature-container.jsx new file mode 100644 index 0000000000..99f847ab4a --- /dev/null +++ b/frontend/src/component/feature/form/form-copy-feature-container.jsx @@ -0,0 +1,21 @@ +import { connect } from 'react-redux'; +import CopyFeatureComponent from './form-copy-feature-component'; +import { createFeatureToggles, validateName, fetchFeatureToggles } from './../../../store/feature-actions'; + +const mapStateToProps = (state, props) => ({ + history: props.history, + copyToggle: state.features.toJS().find(toggle => toggle.name === props.copyToggleName), +}); + +const mapDispatchToProps = dispatch => ({ + validateName, + createFeatureToggle: featureToggle => createFeatureToggles(featureToggle)(dispatch), + fetchFeatureToggles: () => fetchFeatureToggles()(dispatch), +}); + +const FormAddContainer = connect( + mapStateToProps, + mapDispatchToProps +)(CopyFeatureComponent); + +export default FormAddContainer; diff --git a/frontend/src/component/feature/form/strategies-section-container.jsx b/frontend/src/component/feature/form/strategies-section-container.jsx index ac71741718..dc6e6a2d89 100644 --- a/frontend/src/component/feature/form/strategies-section-container.jsx +++ b/frontend/src/component/feature/form/strategies-section-container.jsx @@ -3,8 +3,9 @@ import StrategiesSectionComponent from './strategies-section'; import { fetchStrategies } from '../../../store/strategy/actions'; const StrategiesSection = connect( - state => ({ + (state, ownProps) => ({ strategies: state.strategies.get('list').toArray(), + configuredStrategies: ownProps.configuredStrategies, }), { fetchStrategies } )(StrategiesSectionComponent); diff --git a/frontend/src/component/feature/form/util.js b/frontend/src/component/feature/form/util.js new file mode 100644 index 0000000000..8864343b74 --- /dev/null +++ b/frontend/src/component/feature/form/util.js @@ -0,0 +1,7 @@ +export const trim = value => { + if (value && value.trim) { + return value.trim(); + } else { + return value; + } +}; diff --git a/frontend/src/component/feature/view-component.jsx b/frontend/src/component/feature/view-component.jsx index 5072f924a0..3935937a15 100644 --- a/frontend/src/component/feature/view-component.jsx +++ b/frontend/src/component/feature/view-component.jsx @@ -147,7 +147,7 @@ export default class ViewFeatureToggleComponent extends React.Component { return ( - {featureToggle.name} + {featureToggle.name} {this.isFeatureView ? ( - +
+ + + + +
) : (