mirror of
https://github.com/Unleash/unleash.git
synced 2025-07-31 13:47:02 +02:00
feat: Add support for flexible rollout strategy. (#193)
UI part of https://github.com/Unleash/unleash/issues/516
This commit is contained in:
parent
d1e0972401
commit
8ad6f3dc35
@ -12,6 +12,7 @@ exports[`render the create feature page 1`] = `
|
|||||||
<StrategiesSection
|
<StrategiesSection
|
||||||
addStrategy={[MockFunction]}
|
addStrategy={[MockFunction]}
|
||||||
configuredStrategies={Array []}
|
configuredStrategies={Array []}
|
||||||
|
featureToggleName="feature"
|
||||||
moveStrategy={[MockFunction]}
|
moveStrategy={[MockFunction]}
|
||||||
removeStrategy={[MockFunction]}
|
removeStrategy={[MockFunction]}
|
||||||
updateStrategy={[MockFunction]}
|
updateStrategy={[MockFunction]}
|
||||||
|
@ -0,0 +1,86 @@
|
|||||||
|
import React, { Component } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { Textfield } from 'react-mdl';
|
||||||
|
import Select from './select';
|
||||||
|
|
||||||
|
import StrategyInputPercentage from './strategy-input-percentage';
|
||||||
|
|
||||||
|
const stickinessOptions = [
|
||||||
|
{ key: 'default', label: 'default' },
|
||||||
|
{ key: 'userId', label: 'userId' },
|
||||||
|
{ key: 'sessionId', label: 'sessionId' },
|
||||||
|
{ key: 'random', label: 'random' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default class FlexibleRolloutStrategy extends Component {
|
||||||
|
static propTypes = {
|
||||||
|
strategy: PropTypes.object.isRequired,
|
||||||
|
featureToggleName: PropTypes.string.isRequired,
|
||||||
|
updateStrategy: PropTypes.func.isRequired,
|
||||||
|
handleConfigChange: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
componentWillMount() {
|
||||||
|
const { strategy, featureToggleName } = this.props;
|
||||||
|
if (!strategy.parameters.rollout) {
|
||||||
|
this.setConfig('rollout', 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!strategy.parameters.stickiness) {
|
||||||
|
this.setConfig('stickiness', 'default');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!strategy.parameters.groupId) {
|
||||||
|
this.setConfig('groupId', featureToggleName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setConfig = (key, value) => {
|
||||||
|
const parameters = this.props.strategy.parameters || {};
|
||||||
|
parameters[key] = value;
|
||||||
|
|
||||||
|
const updatedStrategy = Object.assign({}, this.props.strategy, {
|
||||||
|
parameters,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.props.updateStrategy(updatedStrategy);
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { strategy, handleConfigChange } = this.props;
|
||||||
|
|
||||||
|
const rollout = strategy.parameters.rollout;
|
||||||
|
const stickiness = strategy.parameters.stickiness;
|
||||||
|
const groupId = strategy.parameters.groupId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<br />
|
||||||
|
<h5>Rollout</h5>
|
||||||
|
<StrategyInputPercentage
|
||||||
|
name="percentage"
|
||||||
|
value={1 * rollout}
|
||||||
|
minLabel="off"
|
||||||
|
maxLabel="on"
|
||||||
|
onChange={evt => handleConfigChange('rollout', evt)}
|
||||||
|
/>
|
||||||
|
<div style={{ margin: '0 17px' }}>
|
||||||
|
<Select
|
||||||
|
name="stickiness"
|
||||||
|
label="Stickiness"
|
||||||
|
options={stickinessOptions}
|
||||||
|
value={stickiness}
|
||||||
|
onChange={evt => handleConfigChange('stickiness', evt)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Textfield
|
||||||
|
floatingLabel
|
||||||
|
label="groupId"
|
||||||
|
value={groupId}
|
||||||
|
onChange={evt => handleConfigChange('groupId', evt)}
|
||||||
|
/>{' '}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -34,6 +34,7 @@ class UpdateFeatureComponent extends Component {
|
|||||||
<section style={{ padding: '16px' }}>
|
<section style={{ padding: '16px' }}>
|
||||||
<StrategiesSection
|
<StrategiesSection
|
||||||
configuredStrategies={configuredStrategies}
|
configuredStrategies={configuredStrategies}
|
||||||
|
featureToggleName={input.name}
|
||||||
addStrategy={addStrategy}
|
addStrategy={addStrategy}
|
||||||
updateStrategy={updateStrategy}
|
updateStrategy={updateStrategy}
|
||||||
moveStrategy={moveStrategy}
|
moveStrategy={moveStrategy}
|
||||||
|
40
frontend/src/component/feature/form/select.jsx
Normal file
40
frontend/src/component/feature/form/select.jsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
const Select = ({ name, value, label, options, style, onChange }) => {
|
||||||
|
const wrapper = Object.assign({ width: 'auto' }, style);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="mdl-textfield mdl-js-textfield mdl-textfield--floating-label is-dirty is-upgraded"
|
||||||
|
style={wrapper}
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
className="mdl-textfield__input"
|
||||||
|
name={name}
|
||||||
|
onChange={onChange}
|
||||||
|
value={value}
|
||||||
|
style={{ width: 'auto' }}
|
||||||
|
>
|
||||||
|
{options.map(o => (
|
||||||
|
<option key={o.key} value={o.key}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<label className="mdl-textfield__label" htmlFor="textfield-conextName">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
Select.propTypes = {
|
||||||
|
name: PropTypes.string,
|
||||||
|
value: PropTypes.string,
|
||||||
|
label: PropTypes.string,
|
||||||
|
options: PropTypes.array,
|
||||||
|
style: PropTypes.object,
|
||||||
|
onChange: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Select;
|
@ -9,13 +9,21 @@ class StrategiesList extends React.Component {
|
|||||||
static propTypes = {
|
static propTypes = {
|
||||||
strategies: PropTypes.array.isRequired,
|
strategies: PropTypes.array.isRequired,
|
||||||
configuredStrategies: PropTypes.array.isRequired,
|
configuredStrategies: PropTypes.array.isRequired,
|
||||||
|
featureToggleName: PropTypes.string.isRequired,
|
||||||
updateStrategy: PropTypes.func,
|
updateStrategy: PropTypes.func,
|
||||||
removeStrategy: PropTypes.func,
|
removeStrategy: PropTypes.func,
|
||||||
moveStrategy: PropTypes.func,
|
moveStrategy: PropTypes.func,
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { strategies, configuredStrategies, moveStrategy, removeStrategy, updateStrategy } = this.props;
|
const {
|
||||||
|
strategies,
|
||||||
|
configuredStrategies,
|
||||||
|
moveStrategy,
|
||||||
|
removeStrategy,
|
||||||
|
updateStrategy,
|
||||||
|
featureToggleName,
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
if (!configuredStrategies || configuredStrategies.length === 0) {
|
if (!configuredStrategies || configuredStrategies.length === 0) {
|
||||||
return (
|
return (
|
||||||
@ -29,6 +37,7 @@ class StrategiesList extends React.Component {
|
|||||||
<ConfigureStrategy
|
<ConfigureStrategy
|
||||||
index={i}
|
index={i}
|
||||||
key={`${strategy.id}-${i}`}
|
key={`${strategy.id}-${i}`}
|
||||||
|
featureToggleName={featureToggleName}
|
||||||
strategy={strategy}
|
strategy={strategy}
|
||||||
moveStrategy={moveStrategy}
|
moveStrategy={moveStrategy}
|
||||||
removeStrategy={removeStrategy ? removeStrategy.bind(null, i) : null}
|
removeStrategy={removeStrategy ? removeStrategy.bind(null, i) : null}
|
||||||
|
@ -8,6 +8,7 @@ import { HeaderTitle } from '../../common';
|
|||||||
class StrategiesSectionComponent extends React.Component {
|
class StrategiesSectionComponent extends React.Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
strategies: PropTypes.array.isRequired,
|
strategies: PropTypes.array.isRequired,
|
||||||
|
featureToggleName: PropTypes.string.isRequired,
|
||||||
addStrategy: PropTypes.func,
|
addStrategy: PropTypes.func,
|
||||||
removeStrategy: PropTypes.func,
|
removeStrategy: PropTypes.func,
|
||||||
updateStrategy: PropTypes.func,
|
updateStrategy: PropTypes.func,
|
||||||
|
@ -16,6 +16,7 @@ import {
|
|||||||
import { DragSource, DropTarget } from 'react-dnd';
|
import { DragSource, DropTarget } from 'react-dnd';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import StrategyInputPercentage from './strategy-input-percentage';
|
import StrategyInputPercentage from './strategy-input-percentage';
|
||||||
|
import FlexibleRolloutStrategyInput from './flexible-rollout-strategy-input';
|
||||||
import StrategyInputList from './strategy-input-list';
|
import StrategyInputList from './strategy-input-list';
|
||||||
import styles from './strategy.scss';
|
import styles from './strategy.scss';
|
||||||
|
|
||||||
@ -58,6 +59,7 @@ class StrategyConfigure extends React.Component {
|
|||||||
/* eslint-enable */
|
/* eslint-enable */
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
strategy: PropTypes.object.isRequired,
|
strategy: PropTypes.object.isRequired,
|
||||||
|
featureToggleName: PropTypes.string.isRequired,
|
||||||
strategyDefinition: PropTypes.object,
|
strategyDefinition: PropTypes.object,
|
||||||
updateStrategy: PropTypes.func,
|
updateStrategy: PropTypes.func,
|
||||||
removeStrategy: PropTypes.func,
|
removeStrategy: PropTypes.func,
|
||||||
@ -93,6 +95,21 @@ class StrategyConfigure extends React.Component {
|
|||||||
this.props.removeStrategy();
|
this.props.removeStrategy();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
renderStrategContent(strategyDefinition) {
|
||||||
|
if (strategyDefinition.name === 'flexibleRollout') {
|
||||||
|
return (
|
||||||
|
<FlexibleRolloutStrategyInput
|
||||||
|
strategy={this.props.strategy}
|
||||||
|
featureToggleName={this.props.featureToggleName}
|
||||||
|
updateStrategy={this.props.updateStrategy}
|
||||||
|
handleConfigChange={this.handleConfigChange.bind(this)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return this.renderInputFields(strategyDefinition);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
renderInputFields({ parameters }) {
|
renderInputFields({ parameters }) {
|
||||||
if (parameters && parameters.length > 0) {
|
if (parameters && parameters.length > 0) {
|
||||||
return parameters.map(({ name, type, description, required }) => {
|
return parameters.map(({ name, type, description, required }) => {
|
||||||
@ -193,7 +210,7 @@ class StrategyConfigure extends React.Component {
|
|||||||
|
|
||||||
let item;
|
let item;
|
||||||
if (this.props.strategyDefinition) {
|
if (this.props.strategyDefinition) {
|
||||||
const inputFields = this.renderInputFields(this.props.strategyDefinition);
|
const strategyContent = this.renderStrategContent(this.props.strategyDefinition);
|
||||||
const { name } = this.props.strategy;
|
const { name } = this.props.strategy;
|
||||||
item = (
|
item = (
|
||||||
<Card shadow={0} className={styles.card} style={{ opacity: isDragging ? '0.1' : '1' }}>
|
<Card shadow={0} className={styles.card} style={{ opacity: isDragging ? '0.1' : '1' }}>
|
||||||
@ -203,11 +220,7 @@ class StrategyConfigure extends React.Component {
|
|||||||
{name}
|
{name}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardText>{this.props.strategyDefinition.description}</CardText>
|
<CardText>{this.props.strategyDefinition.description}</CardText>
|
||||||
{inputFields && (
|
{strategyContent && <CardActions border>{strategyContent}</CardActions>}
|
||||||
<CardActions border style={{ padding: '20px' }}>
|
|
||||||
{inputFields}
|
|
||||||
</CardActions>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<CardMenu className="mdl-color-text--white">
|
<CardMenu className="mdl-color-text--white">
|
||||||
<Link title="View strategy" to={`/strategies/view/${name}`} className={styles.editLink}>
|
<Link title="View strategy" to={`/strategies/view/${name}`} className={styles.editLink}>
|
||||||
|
@ -1,25 +1,39 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { Slider } from 'react-mdl';
|
import { Slider, Grid, Cell } from 'react-mdl';
|
||||||
|
|
||||||
const labelStyle = {
|
const labelStyle = {
|
||||||
margin: '20px 0',
|
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
color: '#3f51b5',
|
color: '#3f51b5',
|
||||||
fontSize: '12px',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const InputPercentage = ({ name, value, onChange }) => (
|
const infoLabelStyle = {
|
||||||
|
fontSize: '0.8em',
|
||||||
|
color: 'gray',
|
||||||
|
paddingBottom: '-3px',
|
||||||
|
};
|
||||||
|
|
||||||
|
const InputPercentage = ({ name, minLabel, maxLabel, value, onChange }) => (
|
||||||
<div style={{ marginBottom: '20px' }}>
|
<div style={{ marginBottom: '20px' }}>
|
||||||
<div style={labelStyle}>
|
<Grid noSpacing style={{ margin: '0 15px' }}>
|
||||||
{name}: {value}%
|
<Cell col={1}>
|
||||||
</div>
|
<span style={infoLabelStyle}>{minLabel}</span>
|
||||||
|
</Cell>
|
||||||
|
<Cell col={10} style={labelStyle}>
|
||||||
|
{name}: {value}%
|
||||||
|
</Cell>
|
||||||
|
<Cell col={1} style={{ textAlign: 'right' }}>
|
||||||
|
<span style={infoLabelStyle}>{maxLabel}</span>
|
||||||
|
</Cell>
|
||||||
|
</Grid>
|
||||||
<Slider min={0} max={100} defaultValue={value} value={value} onChange={onChange} label={name} />
|
<Slider min={0} max={100} defaultValue={value} value={value} onChange={onChange} label={name} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
InputPercentage.propTypes = {
|
InputPercentage.propTypes = {
|
||||||
name: PropTypes.string,
|
name: PropTypes.string,
|
||||||
|
minLabel: PropTypes.string,
|
||||||
|
maxLabel: PropTypes.string,
|
||||||
value: PropTypes.number,
|
value: PropTypes.number,
|
||||||
onChange: PropTypes.func.isRequired,
|
onChange: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
.item {
|
.item {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 300px;
|
min-width: 400px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
margin: 5px 0px 15px 35px;
|
margin: 5px 0 5px 5px;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
};
|
};
|
||||||
@ -17,6 +17,7 @@
|
|||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
.item:not(:first-child):after {
|
.item:not(:first-child):after {
|
||||||
content: " OR ";
|
content: " OR ";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@ -30,6 +31,7 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
.cardTitle {
|
.cardTitle {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
@ -7,16 +7,19 @@ import { DrawerMenu } from './drawer';
|
|||||||
import Breadcrum from './breadcrumb';
|
import Breadcrum from './breadcrumb';
|
||||||
import ShowUserContainer from '../user/show-user-container';
|
import ShowUserContainer from '../user/show-user-container';
|
||||||
import { fetchUIConfig } from './../../store/ui-config/actions';
|
import { fetchUIConfig } from './../../store/ui-config/actions';
|
||||||
|
import { fetchContext } from './../../store/context/actions';
|
||||||
|
|
||||||
class HeaderComponent extends PureComponent {
|
class HeaderComponent extends PureComponent {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
uiConfig: PropTypes.object.isRequired,
|
uiConfig: PropTypes.object.isRequired,
|
||||||
fetchUIConfig: PropTypes.func.isRequired,
|
fetchUIConfig: PropTypes.func.isRequired,
|
||||||
|
fetchContext: PropTypes.func.isRequired,
|
||||||
location: PropTypes.object.isRequired,
|
location: PropTypes.object.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this.props.fetchUIConfig();
|
this.props.fetchUIConfig();
|
||||||
|
this.props.fetchContext();
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillReceiveProps(nextProps) {
|
componentWillReceiveProps(nextProps) {
|
||||||
@ -51,5 +54,5 @@ class HeaderComponent extends PureComponent {
|
|||||||
|
|
||||||
export default connect(
|
export default connect(
|
||||||
state => ({ uiConfig: state.uiConfig.toJS() }),
|
state => ({ uiConfig: state.uiConfig.toJS() }),
|
||||||
{ fetchUIConfig }
|
{ fetchUIConfig, fetchContext }
|
||||||
)(HeaderComponent);
|
)(HeaderComponent);
|
||||||
|
13
frontend/src/data/context-api.js
Normal file
13
frontend/src/data/context-api.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { throwIfNotSuccess } from './helper';
|
||||||
|
|
||||||
|
const URI = 'api/admin/context';
|
||||||
|
|
||||||
|
function fetchContext() {
|
||||||
|
return fetch(URI, { credentials: 'include' })
|
||||||
|
.then(throwIfNotSuccess)
|
||||||
|
.then(response => response.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
fetchContext,
|
||||||
|
};
|
18
frontend/src/store/context/actions.js
Normal file
18
frontend/src/store/context/actions.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import api from '../../data/context-api';
|
||||||
|
import { dispatchAndThrow } from '../util';
|
||||||
|
|
||||||
|
export const RECEIVE_CONTEXT = 'RECEIVE_CONTEXT';
|
||||||
|
export const ERROR_RECEIVE_CONTEXT = 'ERROR_RECEIVE_CONTEXT';
|
||||||
|
|
||||||
|
export const receiveContext = json => ({
|
||||||
|
type: RECEIVE_CONTEXT,
|
||||||
|
value: json,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function fetchContext() {
|
||||||
|
return dispatch =>
|
||||||
|
api
|
||||||
|
.fetchContext()
|
||||||
|
.then(json => dispatch(receiveContext(json)))
|
||||||
|
.catch(dispatchAndThrow(dispatch, ERROR_RECEIVE_CONTEXT));
|
||||||
|
}
|
18
frontend/src/store/context/index.js
Normal file
18
frontend/src/store/context/index.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { RECEIVE_CONTEXT } from './actions';
|
||||||
|
|
||||||
|
const DEFAULT_CONTEXT_FIELDS = [{ name: 'environment' }, { name: 'userId' }, { name: 'appName' }];
|
||||||
|
|
||||||
|
function getInitState() {
|
||||||
|
return DEFAULT_CONTEXT_FIELDS;
|
||||||
|
}
|
||||||
|
|
||||||
|
const strategies = (state = getInitState(), action) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case RECEIVE_CONTEXT:
|
||||||
|
return action.value;
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default strategies;
|
@ -12,6 +12,7 @@ import user from './user';
|
|||||||
import api from './api';
|
import api from './api';
|
||||||
import applications from './application';
|
import applications from './application';
|
||||||
import uiConfig from './ui-config';
|
import uiConfig from './ui-config';
|
||||||
|
import context from './context';
|
||||||
|
|
||||||
const unleashStore = combineReducers({
|
const unleashStore = combineReducers({
|
||||||
features,
|
features,
|
||||||
@ -27,6 +28,7 @@ const unleashStore = combineReducers({
|
|||||||
applications,
|
applications,
|
||||||
uiConfig,
|
uiConfig,
|
||||||
api,
|
api,
|
||||||
|
context,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default unleashStore;
|
export default unleashStore;
|
||||||
|
Loading…
Reference in New Issue
Block a user