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
+
+
+
+
+`;
+
+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 ? (
+
+ ) : 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;