mirror of
https://github.com/Unleash/unleash.git
synced 2025-01-31 00:16:47 +01: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
|
||||
addStrategy={[MockFunction]}
|
||||
configuredStrategies={Array []}
|
||||
featureToggleName="feature"
|
||||
moveStrategy={[MockFunction]}
|
||||
removeStrategy={[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' }}>
|
||||
<StrategiesSection
|
||||
configuredStrategies={configuredStrategies}
|
||||
featureToggleName={input.name}
|
||||
addStrategy={addStrategy}
|
||||
updateStrategy={updateStrategy}
|
||||
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 = {
|
||||
strategies: PropTypes.array.isRequired,
|
||||
configuredStrategies: PropTypes.array.isRequired,
|
||||
featureToggleName: PropTypes.string.isRequired,
|
||||
updateStrategy: PropTypes.func,
|
||||
removeStrategy: PropTypes.func,
|
||||
moveStrategy: PropTypes.func,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { strategies, configuredStrategies, moveStrategy, removeStrategy, updateStrategy } = this.props;
|
||||
const {
|
||||
strategies,
|
||||
configuredStrategies,
|
||||
moveStrategy,
|
||||
removeStrategy,
|
||||
updateStrategy,
|
||||
featureToggleName,
|
||||
} = this.props;
|
||||
|
||||
if (!configuredStrategies || configuredStrategies.length === 0) {
|
||||
return (
|
||||
@ -29,6 +37,7 @@ class StrategiesList extends React.Component {
|
||||
<ConfigureStrategy
|
||||
index={i}
|
||||
key={`${strategy.id}-${i}`}
|
||||
featureToggleName={featureToggleName}
|
||||
strategy={strategy}
|
||||
moveStrategy={moveStrategy}
|
||||
removeStrategy={removeStrategy ? removeStrategy.bind(null, i) : null}
|
||||
|
@ -8,6 +8,7 @@ import { HeaderTitle } from '../../common';
|
||||
class StrategiesSectionComponent extends React.Component {
|
||||
static propTypes = {
|
||||
strategies: PropTypes.array.isRequired,
|
||||
featureToggleName: PropTypes.string.isRequired,
|
||||
addStrategy: PropTypes.func,
|
||||
removeStrategy: PropTypes.func,
|
||||
updateStrategy: PropTypes.func,
|
||||
|
@ -16,6 +16,7 @@ import {
|
||||
import { DragSource, DropTarget } from 'react-dnd';
|
||||
import { Link } from 'react-router-dom';
|
||||
import StrategyInputPercentage from './strategy-input-percentage';
|
||||
import FlexibleRolloutStrategyInput from './flexible-rollout-strategy-input';
|
||||
import StrategyInputList from './strategy-input-list';
|
||||
import styles from './strategy.scss';
|
||||
|
||||
@ -58,6 +59,7 @@ class StrategyConfigure extends React.Component {
|
||||
/* eslint-enable */
|
||||
static propTypes = {
|
||||
strategy: PropTypes.object.isRequired,
|
||||
featureToggleName: PropTypes.string.isRequired,
|
||||
strategyDefinition: PropTypes.object,
|
||||
updateStrategy: PropTypes.func,
|
||||
removeStrategy: PropTypes.func,
|
||||
@ -93,6 +95,21 @@ class StrategyConfigure extends React.Component {
|
||||
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 }) {
|
||||
if (parameters && parameters.length > 0) {
|
||||
return parameters.map(({ name, type, description, required }) => {
|
||||
@ -193,7 +210,7 @@ class StrategyConfigure extends React.Component {
|
||||
|
||||
let item;
|
||||
if (this.props.strategyDefinition) {
|
||||
const inputFields = this.renderInputFields(this.props.strategyDefinition);
|
||||
const strategyContent = this.renderStrategContent(this.props.strategyDefinition);
|
||||
const { name } = this.props.strategy;
|
||||
item = (
|
||||
<Card shadow={0} className={styles.card} style={{ opacity: isDragging ? '0.1' : '1' }}>
|
||||
@ -203,11 +220,7 @@ class StrategyConfigure extends React.Component {
|
||||
{name}
|
||||
</CardTitle>
|
||||
<CardText>{this.props.strategyDefinition.description}</CardText>
|
||||
{inputFields && (
|
||||
<CardActions border style={{ padding: '20px' }}>
|
||||
{inputFields}
|
||||
</CardActions>
|
||||
)}
|
||||
{strategyContent && <CardActions border>{strategyContent}</CardActions>}
|
||||
|
||||
<CardMenu className="mdl-color-text--white">
|
||||
<Link title="View strategy" to={`/strategies/view/${name}`} className={styles.editLink}>
|
||||
|
@ -1,25 +1,39 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Slider } from 'react-mdl';
|
||||
import { Slider, Grid, Cell } from 'react-mdl';
|
||||
|
||||
const labelStyle = {
|
||||
margin: '20px 0',
|
||||
textAlign: 'center',
|
||||
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={labelStyle}>
|
||||
{name}: {value}%
|
||||
</div>
|
||||
<Grid noSpacing style={{ margin: '0 15px' }}>
|
||||
<Cell col={1}>
|
||||
<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} />
|
||||
</div>
|
||||
);
|
||||
|
||||
InputPercentage.propTypes = {
|
||||
name: PropTypes.string,
|
||||
minLabel: PropTypes.string,
|
||||
maxLabel: PropTypes.string,
|
||||
value: PropTypes.number,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
@ -1,8 +1,8 @@
|
||||
.item {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
min-width: 400px;
|
||||
max-width: 100%;
|
||||
margin: 5px 0px 15px 35px;
|
||||
margin: 5px 0 5px 5px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
};
|
||||
@ -17,6 +17,7 @@
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
.item:not(:first-child):after {
|
||||
content: " OR ";
|
||||
position: absolute;
|
||||
@ -30,6 +31,7 @@
|
||||
height: 100%;
|
||||
z-index: 2;
|
||||
}
|
||||
*/
|
||||
|
||||
.cardTitle {
|
||||
color: #fff;
|
||||
|
@ -7,16 +7,19 @@ import { DrawerMenu } from './drawer';
|
||||
import Breadcrum from './breadcrumb';
|
||||
import ShowUserContainer from '../user/show-user-container';
|
||||
import { fetchUIConfig } from './../../store/ui-config/actions';
|
||||
import { fetchContext } from './../../store/context/actions';
|
||||
|
||||
class HeaderComponent extends PureComponent {
|
||||
static propTypes = {
|
||||
uiConfig: PropTypes.object.isRequired,
|
||||
fetchUIConfig: PropTypes.func.isRequired,
|
||||
fetchContext: PropTypes.func.isRequired,
|
||||
location: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.props.fetchUIConfig();
|
||||
this.props.fetchContext();
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
@ -51,5 +54,5 @@ class HeaderComponent extends PureComponent {
|
||||
|
||||
export default connect(
|
||||
state => ({ uiConfig: state.uiConfig.toJS() }),
|
||||
{ fetchUIConfig }
|
||||
{ fetchUIConfig, fetchContext }
|
||||
)(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 applications from './application';
|
||||
import uiConfig from './ui-config';
|
||||
import context from './context';
|
||||
|
||||
const unleashStore = combineReducers({
|
||||
features,
|
||||
@ -27,6 +28,7 @@ const unleashStore = combineReducers({
|
||||
applications,
|
||||
uiConfig,
|
||||
api,
|
||||
context,
|
||||
});
|
||||
|
||||
export default unleashStore;
|
||||
|
Loading…
Reference in New Issue
Block a user