1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-03-23 00:16:25 +01:00

feat: add support for toggle type

This commit is contained in:
Ivar Conradi Østhus 2020-08-05 21:36:28 +02:00
parent 60705d3993
commit 6395568d55
22 changed files with 270 additions and 93 deletions

View File

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
const Select = ({ name, value, label, options, style, onChange }) => { const Select = ({ name, value, label, options, style, onChange, filled }) => {
const wrapper = Object.assign({ width: 'auto' }, style); const wrapper = Object.assign({ width: 'auto' }, style);
return ( return (
<div <div
@ -13,10 +13,10 @@ const Select = ({ name, value, label, options, style, onChange }) => {
name={name} name={name}
onChange={onChange} onChange={onChange}
value={value} value={value}
style={{ width: 'auto' }} style={{ width: 'auto', background: filled ? '#f5f5f5' : 'none' }}
> >
{options.map(o => ( {options.map(o => (
<option key={o.key} value={o.key}> <option key={o.key} value={o.key} title={o.title}>
{o.label} {o.label}
</option> </option>
))} ))}

View File

@ -47,10 +47,8 @@ exports[`renders correctly with one feature 1`] = `
className="listItemStrategies hideLt920" className="listItemStrategies hideLt920"
> >
<react-mdl-Chip <react-mdl-Chip
className="strategyChip" className="mdl-color--blue-grey-100"
> />
gradualRolloutRandom
</react-mdl-Chip>
</span> </span>
<span /> <span />
</react-mdl-ListItem> </react-mdl-ListItem>
@ -102,10 +100,8 @@ exports[`renders correctly with one feature without permission 1`] = `
className="listItemStrategies hideLt920" className="listItemStrategies hideLt920"
> >
<react-mdl-Chip <react-mdl-Chip
className="strategyChip" className="mdl-color--blue-grey-100"
> />
gradualRolloutRandom
</react-mdl-Chip>
</span> </span>
<span /> <span />
</react-mdl-ListItem> </react-mdl-ListItem>

View File

@ -124,6 +124,12 @@ exports[`renders correctly with one feature 1`] = `
> >
Name Name
</react-mdl-MenuItem> </react-mdl-MenuItem>
<react-mdl-MenuItem
data-target="type"
disabled={false}
>
Type
</react-mdl-MenuItem>
<react-mdl-MenuItem <react-mdl-MenuItem
data-target="enabled" data-target="enabled"
disabled={false} disabled={false}
@ -283,6 +289,12 @@ exports[`renders correctly with one feature without permissions 1`] = `
> >
Name Name
</react-mdl-MenuItem> </react-mdl-MenuItem>
<react-mdl-MenuItem
data-target="type"
disabled={false}
>
Type
</react-mdl-MenuItem>
<react-mdl-MenuItem <react-mdl-MenuItem
data-target="enabled" data-target="enabled"
disabled={false} disabled={false}

View File

@ -22,14 +22,29 @@ exports[`renders correctly with one feature 1`] = `
</react-mdl-CardTitle> </react-mdl-CardTitle>
<react-mdl-CardText> <react-mdl-CardText>
another's description <div>
  another's description
<a  
href="#edit" <a
onClick={[Function]} href="#edit"
> onClick={[Function]}
edit >
</a> edit
</a>
</div>
</react-mdl-CardText>
<react-mdl-CardText
style={
Object {
"paddingTop": 0,
}
}
>
<FeatureTypeSelect
filled={true}
onChange={[Function]}
value="release"
/>
</react-mdl-CardText> </react-mdl-CardText>
<react-mdl-CardActions <react-mdl-CardActions
border={true} border={true}
@ -137,6 +152,7 @@ exports[`renders correctly with one feature 1`] = `
}, },
}, },
], ],
"type": "release",
} }
} }
features={ features={
@ -154,6 +170,7 @@ exports[`renders correctly with one feature 1`] = `
}, },
}, },
], ],
"type": "release",
}, },
] ]
} }

View File

@ -10,12 +10,14 @@ jest.mock('../form/form-update-feature-container', () => ({
__esModule: true, __esModule: true,
default: 'UpdateFeatureToggleComponent', default: 'UpdateFeatureToggleComponent',
})); }));
jest.mock('../form/feature-type-select-container', () => 'FeatureTypeSelect');
test('renders correctly with one feature', () => { test('renders correctly with one feature', () => {
const feature = { const feature = {
name: 'Another', name: 'Another',
description: "another's description", description: "another's description",
enabled: false, enabled: false,
type: 'release',
strategies: [ strategies: [
{ {
name: 'gradualRolloutRandom', name: 'gradualRolloutRandom',

View File

@ -17,7 +17,7 @@ const Feature = ({
revive, revive,
hasPermission, hasPermission,
}) => { }) => {
const { name, description, enabled, strategies } = feature; const { name, description, enabled, type } = feature;
const { showLastHour = false } = settings; const { showLastHour = false } = settings;
const isStale = showLastHour ? metricsLastHour.isFallback : metricsLastMinute.isFallback; const isStale = showLastHour ? metricsLastHour.isFallback : metricsLastMinute.isFallback;
const percent = const percent =
@ -25,17 +25,7 @@ const Feature = ({
(showLastHour (showLastHour
? calc(metricsLastHour.yes, metricsLastHour.yes + metricsLastHour.no, 0) ? calc(metricsLastHour.yes, metricsLastHour.yes + metricsLastHour.no, 0)
: calc(metricsLastMinute.yes, metricsLastMinute.yes + metricsLastMinute.no, 0)); : calc(metricsLastMinute.yes, metricsLastMinute.yes + metricsLastMinute.no, 0));
const typeChip = <Chip className="mdl-color--blue-grey-100">{type}</Chip>;
const strategiesToShow = Math.min(strategies.length, 3);
const remainingStrategies = strategies.length - strategiesToShow;
const strategyChips =
strategies &&
strategies.slice(0, strategiesToShow).map((s, i) => (
<Chip className={styles.strategyChip} key={i}>
{s.name}
</Chip>
));
const summaryChip = remainingStrategies > 0 && <Chip className={styles.strategyChip}>+{remainingStrategies}</Chip>;
const featureUrl = toggleFeature === undefined ? `/archive/strategies/${name}` : `/features/strategies/${name}`; const featureUrl = toggleFeature === undefined ? `/archive/strategies/${name}` : `/features/strategies/${name}`;
return ( return (
<ListItem twoLine> <ListItem twoLine>
@ -61,10 +51,7 @@ const Feature = ({
<span className={['mdl-list__item-sub-title', commonStyles.truncate].join(' ')}>{description}</span> <span className={['mdl-list__item-sub-title', commonStyles.truncate].join(' ')}>{description}</span>
</Link> </Link>
</span> </span>
<span className={[styles.listItemStrategies, commonStyles.hideLt920].join(' ')}> <span className={[styles.listItemStrategies, commonStyles.hideLt920].join(' ')}>{typeChip}</span>
{strategyChips}
{summaryChip}
</span>
{revive && hasPermission(UPDATE_FEATURE) ? ( {revive && hasPermission(UPDATE_FEATURE) ? (
<ListItemAction onClick={() => revive(feature.name)}> <ListItemAction onClick={() => revive(feature.name)}>
<Icon name="undo" /> <Icon name="undo" />

View File

@ -33,3 +33,8 @@
.strategyChip { .strategyChip {
margin-left: 8px !important; margin-left: 8px !important;
} }
.typeChip {
margin-left: 8px !important;
background: #d3c1ff;
}

View File

@ -23,21 +23,54 @@ exports[`render the create feature page 1`] = `
<form <form
onSubmit={[MockFunction]} onSubmit={[MockFunction]}
> >
<react-mdl-Grid>
<react-mdl-Cell
col={4}
>
<react-mdl-Textfield
floatingLabel={true}
label="Name"
name="name"
onBlur={[Function]}
onChange={[Function]}
style={
Object {
"width": "100%",
}
}
value="feature"
/>
</react-mdl-Cell>
<react-mdl-Cell
col={2}
>
<Connect(FeatureTypeSelectComponent)
onChange={[Function]}
/>
</react-mdl-Cell>
<react-mdl-Cell
col={2}
style={
Object {
"paddingTop": "14px",
}
}
>
<react-mdl-Switch
checked={false}
onChange={[Function]}
>
Disabled
</react-mdl-Switch>
</react-mdl-Cell>
</react-mdl-Grid>
<section <section
style={ style={
Object { Object {
"padding": "16px", "padding": "0 16px",
} }
} }
> >
<react-mdl-Textfield
floatingLabel={true}
label="Name"
name="name"
onBlur={[Function]}
onChange={[Function]}
value="feature"
/>
<react-mdl-Textfield <react-mdl-Textfield
floatingLabel={true} floatingLabel={true}
label="Description" label="Description"
@ -50,17 +83,6 @@ exports[`render the create feature page 1`] = `
} }
value="Description" value="Description"
/> />
<div>
<br />
<react-mdl-Switch
checked={false}
onChange={[Function]}
>
Enabled
</react-mdl-Switch>
<br />
<br />
</div>
<StrategiesSection <StrategiesSection
addStrategy={[MockFunction]} addStrategy={[MockFunction]}
configuredStrategies={Array []} configuredStrategies={Array []}

View File

@ -0,0 +1,33 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import MySelect from '../../common/select';
class FeatureTypeSelectComponent extends Component {
componentDidMount() {
if (this.props.fetchFeatureTypes) {
this.props.fetchFeatureTypes();
}
}
render() {
const { value, types, onChange, filled } = this.props;
const options = types.map(t => ({ key: t.id, label: t.name, title: t.description }));
if (!options.find(o => o.key === value)) {
options.push({ key: value, label: value });
}
return <MySelect label="Toggle type" options={options} value={value} onChange={onChange} filled={filled} />;
}
}
FeatureTypeSelectComponent.propTypes = {
value: PropTypes.string,
filled: PropTypes.bool,
types: PropTypes.array.isRequired,
fetchFeatureTypes: PropTypes.func,
onChange: PropTypes.func.isRequired,
};
export default FeatureTypeSelectComponent;

View File

@ -0,0 +1,11 @@
import { connect } from 'react-redux';
import FeatureTypeSelectComponent from './feature-type-select-component';
import { fetchFeatureTypes } from './../../../store/feature-type/actions';
const mapStateToProps = state => ({
types: state.featureTypes.toJS(),
});
const FormAddContainer = connect(mapStateToProps, { fetchFeatureTypes })(FeatureTypeSelectComponent);
export default FormAddContainer;

View File

@ -1,7 +1,8 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Textfield, Switch, Card, CardTitle, CardActions } from 'react-mdl'; import { Textfield, Switch, Card, CardTitle, CardActions, Grid, Cell } from 'react-mdl';
import StrategiesSection from './strategies-section-container'; import StrategiesSection from './strategies-section-container';
import FeatureTypeSelect from './feature-type-select-container';
import { FormButtons } from './../../common'; import { FormButtons } from './../../common';
import { styles as commonStyles } from '../../common'; import { styles as commonStyles } from '../../common';
@ -37,16 +38,34 @@ class AddFeatureComponent extends Component {
<Card shadow={0} className={commonStyles.fullwidth} style={{ overflow: 'visible' }}> <Card shadow={0} className={commonStyles.fullwidth} style={{ overflow: 'visible' }}>
<CardTitle style={{ paddingTop: '24px', wordBreak: 'break-all' }}>Create new feature toggle</CardTitle> <CardTitle style={{ paddingTop: '24px', wordBreak: 'break-all' }}>Create new feature toggle</CardTitle>
<form onSubmit={onSubmit}> <form onSubmit={onSubmit}>
<section style={{ padding: '16px' }}> <Grid>
<Textfield <Cell col={4}>
floatingLabel <Textfield
label="Name" floatingLabel
name="name" style={{ width: '100%' }}
value={input.name} label="Name"
error={errors.name} name="name"
onBlur={v => validateName(v.target.value)} value={input.name}
onChange={v => setValue('name', trim(v.target.value))} error={errors.name}
/> onBlur={v => validateName(v.target.value)}
onChange={v => setValue('name', trim(v.target.value))}
/>
</Cell>
<Cell col={2}>
<FeatureTypeSelect value={input.type} onChange={v => setValue('type', v.target.value)} />
</Cell>
<Cell col={2} style={{ paddingTop: '14px' }}>
<Switch
checked={input.enabled}
onChange={() => {
setValue('enabled', !input.enabled);
}}
>
{input.enabled ? 'Enabled' : 'Disabled'}
</Switch>
</Cell>
</Grid>
<section style={{ padding: '0 16px' }}>
<Textfield <Textfield
floatingLabel floatingLabel
style={{ width: '100%' }} style={{ width: '100%' }}
@ -56,19 +75,6 @@ class AddFeatureComponent extends Component {
value={input.description} value={input.description}
onChange={v => setValue('description', v.target.value)} onChange={v => setValue('description', v.target.value)}
/> />
<div>
<br />
<Switch
checked={input.enabled}
onChange={() => {
setValue('enabled', !input.enabled);
}}
>
Enabled
</Switch>
<br />
<br />
</div>
{input.name ? ( {input.name ? (
<StrategiesSection <StrategiesSection

View File

@ -13,7 +13,7 @@ class WrapperComponent extends Component {
super(props); super(props);
const name = loadNameFromHash(); const name = loadNameFromHash();
this.state = { this.state = {
featureToggle: { name, description: '', strategies: [], enabled: true }, featureToggle: { name, description: '', type: 'release', strategies: [], enabled: true },
errors: {}, errors: {},
dirty: false, dirty: false,
}; };

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Button, CardText, Textfield } from 'react-mdl'; import { Button, Textfield } from 'react-mdl';
import { UPDATE_FEATURE } from '../../../permissions'; import { UPDATE_FEATURE } from '../../../permissions';
@ -40,21 +40,21 @@ export default class UpdateDescriptionComponent extends React.Component {
renderRead({ description, isFeatureView, hasPermission }) { renderRead({ description, isFeatureView, hasPermission }) {
return ( return (
<CardText> <div>
{description}&nbsp; {description}&nbsp;
{isFeatureView && hasPermission(UPDATE_FEATURE) ? ( {isFeatureView && hasPermission(UPDATE_FEATURE) ? (
<a href="#edit" onClick={this.onEditMode.bind(this, description)}> <a href="#edit" onClick={this.onEditMode.bind(this, description)}>
edit edit
</a> </a>
) : null} ) : null}
</CardText> </div>
); );
} }
renderEdit() { renderEdit() {
const { description } = this.state; const { description } = this.state;
return ( return (
<CardText> <div>
<Textfield <Textfield
floatingLabel floatingLabel
style={{ width: '100%' }} style={{ width: '100%' }}
@ -72,7 +72,7 @@ export default class UpdateDescriptionComponent extends React.Component {
Cancel Cancel
</Button> </Button>
</div> </div>
</CardText> </div>
); );
} }

View File

@ -105,6 +105,9 @@ export default class FeatureListComponent extends React.Component {
<MenuItem disabled={settings.sort === 'name'} data-target="name"> <MenuItem disabled={settings.sort === 'name'} data-target="name">
Name Name
</MenuItem> </MenuItem>
<MenuItem disabled={settings.sort === 'type'} data-target="type">
Type
</MenuItem>
<MenuItem disabled={settings.sort === 'enabled'} data-target="enabled"> <MenuItem disabled={settings.sort === 'enabled'} data-target="enabled">
Enabled Enabled
</MenuItem> </MenuItem>

View File

@ -47,6 +47,16 @@ export const mapStateToPropsConfigurable = isFeature => state => {
}); });
} else if (settings.sort === 'strategies') { } else if (settings.sort === 'strategies') {
features = features.sort((a, b) => (a.strategies.length > b.strategies.length ? -1 : 1)); features = features.sort((a, b) => (a.strategies.length > b.strategies.length ? -1 : 1));
} else if (settings.sort === 'type') {
features = features.sort((a, b) => {
if (a.type < b.type) {
return -1;
}
if (a.type > b.type) {
return 1;
}
return 0;
});
} else if (settings.sort === 'metrics') { } else if (settings.sort === 'metrics') {
const target = settings.showLastHour ? featureMetrics.lastHour : featureMetrics.lastMinute; const target = settings.showLastHour ? featureMetrics.lastHour : featureMetrics.lastMinute;

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Tabs, Tab, ProgressBar, Button, Card, CardTitle, CardActions, Switch } from 'react-mdl'; import { Tabs, Tab, ProgressBar, Button, Card, CardTitle, CardActions, Switch, CardText } from 'react-mdl';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import HistoryComponent from '../history/history-list-toggle-container'; import HistoryComponent from '../history/history-list-toggle-container';
@ -8,6 +8,7 @@ import MetricComponent from './metric-container';
import EditFeatureToggle from './form/form-update-feature-container'; import EditFeatureToggle from './form/form-update-feature-container';
import EditVariants from './variant/update-variant-container'; import EditVariants from './variant/update-variant-container';
import ViewFeatureToggle from './form/form-view-feature-container'; import ViewFeatureToggle from './form/form-view-feature-container';
import FeatureTypeSelect from './form/feature-type-select-container';
import UpdateDescriptionComponent from './form/update-description-component'; import UpdateDescriptionComponent from './form/update-description-component';
import { styles as commonStyles } from '../common'; import { styles as commonStyles } from '../common';
import { CREATE_FEATURE, DELETE_FEATURE, UPDATE_FEATURE } from '../../permissions'; import { CREATE_FEATURE, DELETE_FEATURE, UPDATE_FEATURE } from '../../permissions';
@ -145,16 +146,33 @@ export default class ViewFeatureToggleComponent extends React.Component {
this.props.editFeatureToggle(feature); this.props.editFeatureToggle(feature);
}; };
const updateType = evt => {
evt.preventDefault();
const type = evt.target.value;
let feature = { ...featureToggle, type };
if (Array.isArray(feature.strategies)) {
feature.strategies.forEach(s => {
delete s.id;
});
}
this.props.editFeatureToggle(feature);
};
return ( return (
<Card shadow={0} className={commonStyles.fullwidth} style={{ overflow: 'visible' }}> <Card shadow={0} className={commonStyles.fullwidth} style={{ overflow: 'visible' }}>
<CardTitle style={{ wordBreak: 'break-all', paddingBottom: 0 }}>{featureToggle.name} </CardTitle> <CardTitle style={{ wordBreak: 'break-all', paddingBottom: 0 }}>{featureToggle.name} </CardTitle>
<UpdateDescriptionComponent <CardText>
isFeatureView={this.isFeatureView} <UpdateDescriptionComponent
description={featureToggle.description} isFeatureView={this.isFeatureView}
update={updateDescription} description={featureToggle.description}
hasPermission={hasPermission} update={updateDescription}
/> hasPermission={hasPermission}
/>
</CardText>
<CardText style={{ paddingTop: 0 }}>
<FeatureTypeSelect value={featureToggle.type} onChange={updateType} filled />
</CardText>
<CardActions <CardActions
border border

View File

@ -0,0 +1,13 @@
import { throwIfNotSuccess } from './helper';
const URI = 'api/admin/feature-types';
function fetchAll() {
return fetch(URI, { credentials: 'include' })
.then(throwIfNotSuccess)
.then(response => response.json());
}
export default {
fetchAll,
};

View File

@ -6,6 +6,7 @@ import {
ERROR_REMOVE_FEATURE_TOGGLE, ERROR_REMOVE_FEATURE_TOGGLE,
ERROR_UPDATE_FEATURE_TOGGLE, ERROR_UPDATE_FEATURE_TOGGLE,
UPDATE_FEATURE_TOGGLE_STRATEGIES, UPDATE_FEATURE_TOGGLE_STRATEGIES,
UPDATE_FEATURE_TOGGLE,
} from './feature-actions'; } from './feature-actions';
import { ERROR_UPDATING_STRATEGY, ERROR_CREATING_STRATEGY, ERROR_RECEIVE_STRATEGIES } from './strategy/actions'; import { ERROR_UPDATING_STRATEGY, ERROR_CREATING_STRATEGY, ERROR_RECEIVE_STRATEGIES } from './strategy/actions';
@ -46,6 +47,7 @@ const strategies = (state = getInitState(), action) => {
return addErrorIfNotAlreadyInList(state, action.error.message || '403 Forbidden'); return addErrorIfNotAlreadyInList(state, action.error.message || '403 Forbidden');
case MUTE_ERROR: case MUTE_ERROR:
return state.update('list', list => list.remove(list.indexOf(action.error))); return state.update('list', list => list.remove(list.indexOf(action.error)));
case UPDATE_FEATURE_TOGGLE:
case UPDATE_FEATURE_TOGGLE_STRATEGIES: case UPDATE_FEATURE_TOGGLE_STRATEGIES:
return addErrorIfNotAlreadyInList(state, action.info); return addErrorIfNotAlreadyInList(state, action.info);
default: default:

View File

@ -82,7 +82,11 @@ export function requestUpdateFeatureToggle(featureToggle) {
return api return api
.update(featureToggle) .update(featureToggle)
.then(() => dispatch({ type: UPDATE_FEATURE_TOGGLE, featureToggle })) .then(() => {
const info = `${featureToggle.name} successfully updated!`;
setTimeout(() => dispatch({ type: MUTE_ERROR, error: info }), 1000);
dispatch({ type: UPDATE_FEATURE_TOGGLE, featureToggle, info });
})
.catch(dispatchAndThrow(dispatch, ERROR_UPDATE_FEATURE_TOGGLE)); .catch(dispatchAndThrow(dispatch, ERROR_UPDATE_FEATURE_TOGGLE));
}; };
} }

View File

@ -0,0 +1,15 @@
import api from '../../data/feature-type-api';
import { dispatchAndThrow } from '../util';
export const RECEIVE_FEATURE_TYPES = 'RECEIVE_FEATURE_TYPES';
export const ERROR_RECEIVE_FEATURE_TYPES = 'ERROR_RECEIVE_FEATURE_TYPES';
const receiveFeatureTypes = value => ({ type: RECEIVE_FEATURE_TYPES, value });
export function fetchFeatureTypes() {
return dispatch =>
api
.fetchAll()
.then(json => dispatch(receiveFeatureTypes(json)))
.catch(dispatchAndThrow(dispatch, ERROR_RECEIVE_FEATURE_TYPES));
}

View File

@ -0,0 +1,19 @@
import { List } from 'immutable';
import { RECEIVE_FEATURE_TYPES } from './actions';
const DEFAULT_FEATURE_TYPES = [{ id: 'release', name: 'Release', inital: true }];
function getInitState() {
return new List(DEFAULT_FEATURE_TYPES);
}
const strategies = (state = getInitState(), action) => {
switch (action.type) {
case RECEIVE_FEATURE_TYPES:
return new List(action.value.types);
default:
return state;
}
};
export default strategies;

View File

@ -1,5 +1,6 @@
import { combineReducers } from 'redux'; import { combineReducers } from 'redux';
import features from './feature-store'; import features from './feature-store';
import featureTypes from './feature-type';
import featureMetrics from './feature-metrics-store'; import featureMetrics from './feature-metrics-store';
import strategies from './strategy'; import strategies from './strategy';
import input from './input-store'; import input from './input-store';
@ -15,6 +16,7 @@ import context from './context';
const unleashStore = combineReducers({ const unleashStore = combineReducers({
features, features,
featureTypes,
featureMetrics, featureMetrics,
strategies, strategies,
input, input,