diff --git a/frontend/CHANGELOG.md b/frontend/CHANGELOG.md index 0583e275d0..0f5cabc4d1 100644 --- a/frontend/CHANGELOG.md +++ b/frontend/CHANGELOG.md @@ -8,7 +8,8 @@ The latest version of this document is always available in [releases][releases-url]. -## [Unreleased] +## [3.2.0] +- feat: Initial beta support for variants - feature: Show tooltips and featuretoggle names in event view ## [3.1.4] diff --git a/frontend/package.json b/frontend/package.json index 8d2002c4ed..c9d7fc02cb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "unleash-frontend", "description": "unleash your features", - "version": "3.1.4", + "version": "3.2.0-beta.5", "keywords": [ "unleash", "feature toggle", diff --git a/frontend/src/component/feature/__tests__/view-component-test.jsx b/frontend/src/component/feature/__tests__/view-component-test.jsx index 921549ce29..b672e5e08d 100644 --- a/frontend/src/component/feature/__tests__/view-component-test.jsx +++ b/frontend/src/component/feature/__tests__/view-component-test.jsx @@ -32,6 +32,7 @@ test('renders correctly with one feature', () => { activeTab={'strategies'} featureToggleName="another" features={[feature]} + betaFlags={[]} featureToggle={feature} fetchFeatureToggles={jest.fn()} history={{}} diff --git a/frontend/src/component/feature/variant/__tests__/__snapshots__/update-variant-component-test.jsx.snap b/frontend/src/component/feature/variant/__tests__/__snapshots__/update-variant-component-test.jsx.snap new file mode 100644 index 0000000000..4a2bc0ae5a --- /dev/null +++ b/frontend/src/component/feature/variant/__tests__/__snapshots__/update-variant-component-test.jsx.snap @@ -0,0 +1,244 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders correctly with with variants 1`] = ` +
+

+ Variants is a new + + beta feature + + and the implementation is subject to change at any time until it is made in to a permanent feature. In order to use variants you will have use a Client SDK which supports variants. You should read more about variants in the  + + user documentation + + . +

+

+ The sum of variants weights needs to be a constant number to guarantee consistent hashing in the client implementations, this is why we will sometime allocate a few more percentages to the first variant if the sum is not exactly 100. In a final version of this feature it should be possible to the user to manually set the percentages for each variant. +

+

+ + Add variant + +

+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ Name + + Weight + +
+ blue + + 34 + + + +
+ yellow + + 33 + + + +
+ orange + + 33 + + + +
+
+
+ + +     + Save + +   + + +     Cancel + +
+
+
+`; + +exports[`renders correctly with without variants 1`] = ` +
+

+ Variants is a new + + beta feature + + and the implementation is subject to change at any time until it is made in to a permanent feature. In order to use variants you will have use a Client SDK which supports variants. You should read more about variants in the  + + user documentation + + . +

+

+ The sum of variants weights needs to be a constant number to guarantee consistent hashing in the client implementations, this is why we will sometime allocate a few more percentages to the first variant if the sum is not exactly 100. In a final version of this feature it should be possible to the user to manually set the percentages for each variant. +

+

+ + Add variant + +

+
+`; + +exports[`renders correctly with without variants and no permissions 1`] = ` +
+

+ Variants is a new + + beta feature + + and the implementation is subject to change at any time until it is made in to a permanent feature. In order to use variants you will have use a Client SDK which supports variants. You should read more about variants in the  + + user documentation + + . +

+

+ The sum of variants weights needs to be a constant number to guarantee consistent hashing in the client implementations, this is why we will sometime allocate a few more percentages to the first variant if the sum is not exactly 100. In a final version of this feature it should be possible to the user to manually set the percentages for each variant. +

+
+`; diff --git a/frontend/src/component/feature/variant/__tests__/update-variant-component-test.jsx b/frontend/src/component/feature/variant/__tests__/update-variant-component-test.jsx new file mode 100644 index 0000000000..ab242df171 --- /dev/null +++ b/frontend/src/component/feature/variant/__tests__/update-variant-component-test.jsx @@ -0,0 +1,143 @@ +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; + +import UpdateVariant from './../update-variant-component'; +import renderer from 'react-test-renderer'; +import { UPDATE_FEATURE } from '../../../../permissions'; + +jest.mock('react-mdl'); + +test('renders correctly with without variants', () => { + const featureToggle = { + name: 'Another', + description: "another's description", + enabled: false, + strategies: [ + { + name: 'gradualRolloutRandom', + parameters: { + percentage: 50, + }, + }, + ], + createdAt: '2018-02-04T20:27:52.127Z', + }; + const tree = renderer.create( + + permission === UPDATE_FEATURE} + /> + + ); + + expect(tree).toMatchSnapshot(); +}); + +test('renders correctly with without variants and no permissions', () => { + const featureToggle = { + name: 'Another', + description: "another's description", + enabled: false, + strategies: [ + { + name: 'gradualRolloutRandom', + parameters: { + percentage: 50, + }, + }, + ], + createdAt: '2018-02-04T20:27:52.127Z', + }; + const tree = renderer.create( + + false} + /> + + ); + + expect(tree).toMatchSnapshot(); +}); + +test('renders correctly with with variants', () => { + const featureToggle = { + name: 'toggle.variants', + description: 'description', + enabled: false, + strategies: [ + { + name: 'gradualRolloutRandom', + parameters: { + percentage: 50, + }, + }, + ], + variants: [ + { + name: 'blue', + weight: 34, + overrides: [ + { + field: 'userId', + values: ['1337', '123'], + }, + ], + }, + { + name: 'yellow', + weight: 33, + }, + { + name: 'orange', + weight: 33, + payload: { + type: 'string', + value: '{"color": "blue", "animated": false}', + }, + }, + ], + createdAt: '2018-02-04T20:27:52.127Z', + }; + const tree = renderer.create( + + permission === UPDATE_FEATURE} + /> + + ); + + expect(tree).toMatchSnapshot(); +}); diff --git a/frontend/src/component/feature/variant/update-variant-component.jsx b/frontend/src/component/feature/variant/update-variant-component.jsx new file mode 100644 index 0000000000..cc9ed390bd --- /dev/null +++ b/frontend/src/component/feature/variant/update-variant-component.jsx @@ -0,0 +1,163 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; + +import { FormButtons } from '../../common'; +import VariantViewComponent from './variant-view-component'; +import VariantEditComponent from './variant-edit-component'; +import styles from './variant.scss'; +import { UPDATE_FEATURE } from '../../../permissions'; + +class UpdateVariantComponent extends Component { + constructor(props) { + super(props); + } + + componentWillMount() { + // TODO unwind this stuff + if (this.props.initCallRequired === true) { + this.props.init(this.props.input); + } + } + + updateWeight(newWeight, newSize) { + const variants = this.props.input.variants || []; + variants.forEach((v, i) => { + v.weight = newWeight; + this.props.updateVariant(i, v); + }); + + // Make sure the sum of weigths is 100. + const sum = newWeight * newSize; + if (sum !== 100) { + const first = variants[0]; + first.weight = 100 - sum + first.weight; + this.props.updateVariant(0, first); + } + } + + addVariant = (e, variants) => { + e.preventDefault(); + const size = variants.length + 1; + const percentage = parseInt(1 / size * 100); + const variant = { + name: '', + weight: percentage, + edit: true, + }; + + this.updateWeight(percentage, size); + this.props.addVariant(variant); + }; + + removeVariant = (e, index) => { + e.preventDefault(); + const variants = this.props.input.variants; + const size = variants.length - 1; + const percentage = parseInt(1 / size * 100); + this.updateWeight(percentage, size); + this.props.removeVariant(index); + }; + + editVariant = (e, index, v) => { + e.preventDefault(); + if (this.props.hasPermission(UPDATE_FEATURE)) { + v.edit = true; + this.props.updateVariant(index, v); + } + }; + + closeVariant = (e, index, v) => { + e.preventDefault(); + v.edit = false; + this.props.updateVariant(index, v); + }; + + updateVariant = (index, newVariant) => { + this.props.updateVariant(index, newVariant); + }; + + renderVariant = (variant, index) => + variant.edit ? ( + this.removeVariant(e, index)} + closeVariant={e => this.closeVariant(e, index, variant)} + updateVariant={this.updateVariant.bind(this, index)} + /> + ) : ( + this.editVariant(e, index, variant)} + removeVariant={e => this.removeVariant(e, index)} + hasPermission={this.props.hasPermission} + /> + ); + + render() { + const { onSubmit, onCancel, input, features } = this.props; + const variants = input.variants || []; + return ( +
+

+ Variants is a new beta feature and the implementation is subject to change at any time until + it is made in to a permanent feature. In order to use variants you will have use a Client SDK which + supports variants. You should read more about variants in the  + + user documentation + . +

+

+ The sum of variants weights needs to be a constant number to guarantee consistent hashing in the + client implementations, this is why we will sometime allocate a few more percentages to the first + variant if the sum is not exactly 100. In a final version of this feature it should be possible to + the user to manually set the percentages for each variant. +

+ + {this.props.hasPermission(UPDATE_FEATURE) ? ( +

+ this.addVariant(e, variants)}> + Add variant + +

+ ) : null} + + {variants.length > 0 ? ( +
+ + + + + + + + {variants.map(this.renderVariant)} +
NameWeight +
+
+ {this.props.hasPermission(UPDATE_FEATURE) ? ( + + ) : null} + + ) : null} +
+ ); + } +} + +UpdateVariantComponent.propTypes = { + input: PropTypes.object, + features: PropTypes.array, + setValue: PropTypes.func.isRequired, + addVariant: PropTypes.func.isRequired, + removeVariant: PropTypes.func.isRequired, + updateVariant: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired, + initCallRequired: PropTypes.bool, + init: PropTypes.func, + hasPermission: PropTypes.func.isRequired, +}; + +export default UpdateVariantComponent; diff --git a/frontend/src/component/feature/variant/update-variant-container.jsx b/frontend/src/component/feature/variant/update-variant-container.jsx new file mode 100644 index 0000000000..14733bc012 --- /dev/null +++ b/frontend/src/component/feature/variant/update-variant-container.jsx @@ -0,0 +1,67 @@ +import { connect } from 'react-redux'; + +import { requestUpdateFeatureToggleVariants } from '../../../store/feature-actions'; +import { createMapper, createActions } from '../../input-helpers'; +import UpdateFeatureToggleComponent from './update-variant-component'; + +const ID = 'edit-toggle-variants'; +function getId(props) { + return [ID, props.featureToggle.name]; +} +// TODO: need to scope to the active featureToggle +// best is to emulate the "input-storage"? +const mapStateToProps = createMapper({ + id: getId, + getDefault: (state, ownProps) => ownProps.featureToggle, + prepare: props => { + props.editmode = true; + return props; + }, +}); + +const prepare = (methods, dispatch, ownProps) => { + methods.onSubmit = (input, features) => e => { + e.preventDefault(); + + const featureToggle = features.find(f => f.name === input.name); + + // Kind of a hack + featureToggle.strategies.forEach(s => (s.id = undefined)); + + const variants = input.variants.map(v => { + delete v.edit; + return v; + }); + + requestUpdateFeatureToggleVariants(featureToggle, variants)(dispatch); + variants.forEach((v, i) => methods.updateInList('variants', i, v)); + }; + + methods.onCancel = evt => { + evt.preventDefault(); + ownProps.history.push(`/features/view/${ownProps.featureToggle.name}`); + }; + + methods.addVariant = v => { + methods.pushToList('variants', v); + }; + + methods.removeVariant = index => { + methods.removeFromList('variants', index); + }; + + methods.updateVariant = (index, n) => { + methods.updateInList('variants', index, n); + }; + + methods.validateName = () => {}; + + return methods; +}; + +const actions = createActions({ + id: getId, + prepare, +}); + +export default connect(mapStateToProps, actions)(UpdateFeatureToggleComponent); diff --git a/frontend/src/component/feature/variant/variant-edit-component.jsx b/frontend/src/component/feature/variant/variant-edit-component.jsx new file mode 100644 index 0000000000..da9d29c123 --- /dev/null +++ b/frontend/src/component/feature/variant/variant-edit-component.jsx @@ -0,0 +1,162 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; + +import { IconButton, Cell, Grid, Textfield, Tooltip, Icon } from 'react-mdl'; +import styles from './variant.scss'; + +class VariantEditComponent extends Component { + constructor(props) { + super(props); + } + + componentDidMount() { + this.refs.name.inputRef.focus(); + } + + getUserIdOverrides(variant) { + const overrides = variant.overrides || []; + const userIdOverrides = overrides.find(o => o.contextName === 'userId') || { values: [] }; + return userIdOverrides.values.join(', '); + } + + toggleEditMode = e => { + e.preventDefault(); + this.setState({ + editmode: !this.state.editmode, + }); + }; + + updateToggleName = e => { + e.preventDefault(); + const variant = this.props.variant; + variant.name = e.target.value; + this.props.updateVariant(variant); + }; + + updatePayload = e => { + e.preventDefault(); + const variant = this.props.variant; + variant.payload = { + type: 'string', + value: e.target.value, + }; + this.props.updateVariant(variant); + }; + + updateOverrides = (contextName, e) => { + e.preventDefault(); + const values = e.target.value.split(',').map(v => v.trim()); + const variant = this.props.variant; + + // Clean empty string. (should be moved to action) + if (values.length === 1 && !values[0]) { + variant.overrides = undefined; + } else { + variant.overrides = [{ contextName, values }]; + } + this.props.updateVariant(variant); + }; + + render() { + const { variant, closeVariant, removeVariant } = this.props; + const payload = variant.payload ? variant.payload.value : ''; + const userIdOverrides = this.getUserIdOverrides(variant); + + return ( + + + + + + + + + + + + + + + Passed to the variant object.
+ Can be anything (json, value, csv) + + } + > + +
+
+
+ + + + + + + Here you can specify which users that
+ should get this variant. + + } + > + +
+
+
+ + Close + + + + {}} + /> + + + + + + + ); + } +} + +VariantEditComponent.propTypes = { + variant: PropTypes.object, + removeVariant: PropTypes.func, + updateVariant: PropTypes.func, + closeVariant: PropTypes.func, +}; + +export default VariantEditComponent; diff --git a/frontend/src/component/feature/variant/variant-view-component.jsx b/frontend/src/component/feature/variant/variant-view-component.jsx new file mode 100644 index 0000000000..66004a3636 --- /dev/null +++ b/frontend/src/component/feature/variant/variant-view-component.jsx @@ -0,0 +1,35 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; + +import { IconButton } from 'react-mdl'; +import styles from './variant.scss'; +import { UPDATE_FEATURE } from '../../../permissions'; + +class VariantViewComponent extends Component { + render() { + const { variant, editVariant, removeVariant, hasPermission } = this.props; + return ( + + {variant.name} + {variant.weight} + {hasPermission(UPDATE_FEATURE) ? ( + + + + + ) : ( + + )} + + ); + } +} + +VariantViewComponent.propTypes = { + variant: PropTypes.object, + removeVariant: PropTypes.func, + editVariant: PropTypes.func, + hasPermission: PropTypes.func.isRequired, +}; + +export default VariantViewComponent; diff --git a/frontend/src/component/feature/variant/variant.scss b/frontend/src/component/feature/variant/variant.scss new file mode 100644 index 0000000000..edc77c039c --- /dev/null +++ b/frontend/src/component/feature/variant/variant.scss @@ -0,0 +1,22 @@ +.variantTable { + width: 100%; + + th, td { + text-align: center; + width: 100px; + } + th:first-of-type, td:first-of-type { + text-align: left; + width: 100%; + } +} + +th.actions { + text-align: right; +} + +td.actions { + text-align: right; + vertical-align: top; +} + diff --git a/frontend/src/component/feature/view-component.jsx b/frontend/src/component/feature/view-component.jsx index 86cbc4799f..d50cc836b4 100644 --- a/frontend/src/component/feature/view-component.jsx +++ b/frontend/src/component/feature/view-component.jsx @@ -1,11 +1,24 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { Tabs, Tab, ProgressBar, Button, Card, CardText, CardTitle, CardActions, Textfield, Switch } from 'react-mdl'; +import { + Tabs, + Tab, + ProgressBar, + Button, + Card, + CardText, + CardTitle, + CardActions, + Textfield, + Switch, + Badge, +} from 'react-mdl'; import { Link } from 'react-router-dom'; import HistoryComponent from '../history/history-list-toggle-container'; import MetricComponent from './metric-container'; import EditFeatureToggle from './form/form-update-feature-container'; +import EditVariants from './variant/update-variant-container'; import ViewFeatureToggle from './form/form-view-feature-container'; import { styles as commonStyles } from '../common'; import { CREATE_FEATURE, DELETE_FEATURE, UPDATE_FEATURE } from '../../permissions'; @@ -13,7 +26,8 @@ import { CREATE_FEATURE, DELETE_FEATURE, UPDATE_FEATURE } from '../../permission const TABS = { strategies: 0, view: 1, - history: 2, + variants: 2, + history: 3, }; export default class ViewFeatureToggleComponent extends React.Component { @@ -27,6 +41,7 @@ export default class ViewFeatureToggleComponent extends React.Component { activeTab: PropTypes.string.isRequired, featureToggleName: PropTypes.string.isRequired, features: PropTypes.array.isRequired, + betaFlags: PropTypes.array.isRequired, toggleFeature: PropTypes.func, removeFeatureToggle: PropTypes.func, revive: PropTypes.func, @@ -60,6 +75,15 @@ export default class ViewFeatureToggleComponent extends React.Component { ); } return ; + } else if (TABS[activeTab] === TABS.variants) { + return ( + + ); } else { return ; } @@ -74,6 +98,7 @@ export default class ViewFeatureToggleComponent extends React.Component { const { featureToggle, features, + betaFlags, activeTab, revive, // setValue, @@ -83,6 +108,9 @@ export default class ViewFeatureToggleComponent extends React.Component { hasPermission, } = this.props; + // TODO: Find better solution for this + const showVariants = betaFlags.includes('unleash.beta.variants'); + if (!featureToggle) { if (features.length === 0) { return ; @@ -217,6 +245,15 @@ export default class ViewFeatureToggleComponent extends React.Component { > this.goToTab('strategies', featureToggleName)}>Strategies this.goToTab('view', featureToggleName)}>Metrics + {showVariants ? ( + this.goToTab('variants', featureToggleName)}> + + Variants + + + ) : ( + [] + )} this.goToTab('history', featureToggleName)}>History {tabContent} diff --git a/frontend/src/component/feature/view-container.jsx b/frontend/src/component/feature/view-container.jsx index 4ed95e6309..6fcd0e4734 100644 --- a/frontend/src/component/feature/view-container.jsx +++ b/frontend/src/component/feature/view-container.jsx @@ -13,6 +13,11 @@ import { hasPermission } from '../../permissions'; export default connect( (state, props) => ({ features: state.features.toJS(), + betaFlags: state.features + .toJS() + .filter(t => t.enabled) + .filter(t => t.name.startsWith('unleash.beta')) + .map(t => t.name), featureToggle: state.features.toJS().find(toggle => toggle.name === props.featureToggleName), activeTab: props.activeTab, hasPermission: hasPermission.bind(null, state.user.get('profile')), diff --git a/frontend/src/store/feature-actions.js b/frontend/src/store/feature-actions.js index 03d2ba6275..69fc6b9b62 100644 --- a/frontend/src/store/feature-actions.js +++ b/frontend/src/store/feature-actions.js @@ -102,6 +102,22 @@ export function requestUpdateFeatureToggleStrategies(featureToggle, newStrategie }; } +export function requestUpdateFeatureToggleVariants(featureToggle, newVariants) { + return dispatch => { + featureToggle.variants = newVariants; + dispatch({ type: START_UPDATE_FEATURE_TOGGLE }); + + return api + .update(featureToggle) + .then(() => { + const info = `${featureToggle.name} successfully updated!`; + setTimeout(() => dispatch({ type: MUTE_ERROR, error: info }), 1000); + return dispatch({ type: UPDATE_FEATURE_TOGGLE_STRATEGIES, featureToggle, info }); + }) + .catch(dispatchAndThrow(dispatch, ERROR_UPDATE_FEATURE_TOGGLE)); + }; +} + export function removeFeatureToggle(featureToggleName) { return dispatch => { dispatch({ type: START_REMOVE_FEATURE_TOGGLE }); diff --git a/frontend/src/store/input-store.js b/frontend/src/store/input-store.js index f93cabcad7..08ab8c8d87 100644 --- a/frontend/src/store/input-store.js +++ b/frontend/src/store/input-store.js @@ -18,7 +18,7 @@ function assertId(state, id) { } function assertList(state, id, key) { - if (!state.getIn(id).has(key)) { + if (!state.getIn(id).has(key) || state.getIn(id).get(key) == null) { return state.setIn(id.concat([key]), new List()); } return state;