1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-20 00:08:02 +01:00

Fix/feedback on create (#292)

* fix: copy feature toggle instead of setting newVariants on the reference

* fix: remove console log

* fix: update messages

* fix: give feedback on strategy actions

* fix: do not allow feature toggle to be created with empty name

* fix: disable delete if only one strategy is applied

* fix: archive view

* fix: set name field on add variant required

* fix: set required on feature toggle name
This commit is contained in:
Fredrik Strand Oseberg 2021-05-10 13:22:22 +02:00 committed by GitHub
parent 06d7f9b609
commit 2f1848f6fd
22 changed files with 347 additions and 132 deletions

View File

@ -23,7 +23,6 @@ Now you should be able to review rendering information in the console. If you do
You need to first start the unleash-api on port 4242 You need to first start the unleash-api on port 4242
before you can start working on unleash-frontend. before you can start working on unleash-frontend.
Start webpack-dev-server with hot-reload: Start webpack-dev-server with hot-reload:
```bash ```bash

View File

@ -26,8 +26,6 @@ const ContextList = ({ removeContextField, history, contextFields }) => {
const [showDelDialogue, setShowDelDialogue] = useState(false); const [showDelDialogue, setShowDelDialogue] = useState(false);
const [name, setName] = useState(); const [name, setName] = useState();
console.log(contextFields);
const styles = useStyles(); const styles = useStyles();
const contextList = () => const contextList = () =>
contextFields.map(field => ( contextFields.map(field => (

View File

@ -36,6 +36,7 @@ const FeatureToggleList = ({
updateSetting, updateSetting,
featureMetrics, featureMetrics,
toggleFeature, toggleFeature,
archive,
loading, loading,
}) => { }) => {
const { hasAccess } = useContext(AccessContext); const { hasAccess } = useContext(AccessContext);
@ -93,12 +94,24 @@ const FeatureToggleList = ({
/> />
))} ))}
elseShow={ elseShow={
<ConditionallyRender
condition={archive}
show={
<ListItem className={styles.emptyStateListItem}> <ListItem className={styles.emptyStateListItem}>
No features available. Get started by adding a new No archived features.
feature toggle.
<Link to="/features/create">Add your first toggle</Link>
</ListItem> </ListItem>
} }
elseShow={
<ListItem className={styles.emptyStateListItem}>
No features available. Get started by adding a
new feature toggle.
<Link to="/features/create">
Add your first toggle
</Link>
</ListItem>
}
/>
}
/> />
); );
}; };
@ -134,7 +147,9 @@ const FeatureToggleList = ({
/> />
} }
/> />
<ConditionallyRender
condition={!archive}
show={
<ConditionallyRender <ConditionallyRender
condition={smallScreen} condition={smallScreen}
show={ show={
@ -175,6 +190,8 @@ const FeatureToggleList = ({
</Button> </Button>
} }
/> />
}
/>
</div> </div>
} }
/> />

View File

@ -1,5 +1,8 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { toggleFeature, fetchFeatureToggles } from '../../../store/feature-toggle/actions'; import {
toggleFeature,
fetchFeatureToggles,
} from '../../../store/feature-toggle/actions';
import { updateSettingForGroup } from '../../../store/settings/actions'; import { updateSettingForGroup } from '../../../store/settings/actions';
import FeatureToggleList from './FeatureToggleList'; import FeatureToggleList from './FeatureToggleList';
@ -11,18 +14,23 @@ function checkConstraints(strategy, regex) {
} }
function resolveCurrentProjectId(settings) { function resolveCurrentProjectId(settings) {
if(!settings.currentProjectId || settings.currentProjectId === '*') { if (!settings.currentProjectId || settings.currentProjectId === '*') {
return 'default'; return 'default';
} return settings.currentProjectId; }
return settings.currentProjectId;
} }
export const mapStateToPropsConfigurable = isFeature => state => { export const mapStateToPropsConfigurable = isFeature => state => {
const featureMetrics = state.featureMetrics.toJS(); const featureMetrics = state.featureMetrics.toJS();
const settings = state.settings.toJS().feature || {}; const settings = state.settings.toJS().feature || {};
let features = isFeature ? state.features.toJS() : state.archive.get('list').toArray(); let features = isFeature
? state.features.toJS()
: state.archive.get('list').toArray();
if (settings.currentProjectId && settings.currentProjectId !== '*') { if (settings.currentProjectId && settings.currentProjectId !== '*') {
features = features.filter(f => f.project === settings.currentProjectId); features = features.filter(
f => f.project === settings.currentProjectId
);
} }
if (settings.filter) { if (settings.filter) {
@ -33,8 +41,11 @@ export const mapStateToPropsConfigurable = isFeature => state => {
feature.strategies.some(s => checkConstraints(s, regex)) || feature.strategies.some(s => checkConstraints(s, regex)) ||
regex.test(feature.name) || regex.test(feature.name) ||
regex.test(feature.description) || regex.test(feature.description) ||
feature.strategies.some(s => s && s.name && regex.test(s.name)) || feature.strategies.some(
(settings.filter.length > 1 && regex.test(JSON.stringify(feature))) s => s && s.name && regex.test(s.name)
) ||
(settings.filter.length > 1 &&
regex.test(JSON.stringify(feature)))
); );
} catch (e) { } catch (e) {
// Invalid filter regex // Invalid filter regex
@ -56,9 +67,13 @@ export const mapStateToPropsConfigurable = isFeature => state => {
a.stale === b.stale ? 0 : a.stale ? -1 : 1 a.stale === b.stale ? 0 : a.stale ? -1 : 1
); );
} else if (settings.sort === 'created') { } else if (settings.sort === 'created') {
features = features.sort((a, b) => (new Date(a.createdAt) > new Date(b.createdAt) ? -1 : 1)); features = features.sort((a, b) =>
new Date(a.createdAt) > new Date(b.createdAt) ? -1 : 1
);
} else if (settings.sort === 'Last seen') { } else if (settings.sort === 'Last seen') {
features = features.sort((a, b) => (new Date(a.lastSeenAt) > new Date(b.lastSeenAt) ? -1 : 1)); features = features.sort((a, b) =>
new Date(a.lastSeenAt) > new Date(b.lastSeenAt) ? -1 : 1
);
} else if (settings.sort === 'name') { } else if (settings.sort === 'name') {
features = features.sort((a, b) => { features = features.sort((a, b) => {
if (a.name < b.name) { if (a.name < b.name) {
@ -70,7 +85,9 @@ export const mapStateToPropsConfigurable = isFeature => state => {
return 0; return 0;
}); });
} 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') { } else if (settings.sort === 'type') {
features = features.sort((a, b) => { features = features.sort((a, b) => {
if (a.type < b.type) { if (a.type < b.type) {
@ -82,7 +99,9 @@ export const mapStateToPropsConfigurable = isFeature => state => {
return 0; 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;
features = features.sort((a, b) => { features = features.sort((a, b) => {
if (!target[a.name]) { if (!target[a.name]) {
@ -102,6 +121,7 @@ export const mapStateToPropsConfigurable = isFeature => state => {
features, features,
currentProjectId: resolveCurrentProjectId(settings), currentProjectId: resolveCurrentProjectId(settings),
featureMetrics, featureMetrics,
archive: !isFeature,
settings, settings,
loading: state.apiCalls.fetchTogglesState.loading, loading: state.apiCalls.fetchTogglesState.loading,
}; };
@ -113,6 +133,9 @@ const mapDispatchToProps = {
updateSetting: updateSettingForGroup('feature'), updateSetting: updateSettingForGroup('feature'),
}; };
const FeatureToggleListContainer = connect(mapStateToProps, mapDispatchToProps)(FeatureToggleList); const FeatureToggleListContainer = connect(
mapStateToProps,
mapDispatchToProps
)(FeatureToggleList);
export default FeatureToggleListContainer; export default FeatureToggleListContainer;

View File

@ -45,6 +45,7 @@ const CreateFeature = ({
size="small" size="small"
variant="outlined" variant="outlined"
label="Name" label="Name"
required
placeholder="Unique-name" placeholder="Unique-name"
className={styles.nameInput} className={styles.nameInput}
name="name" name="name"

View File

@ -50,7 +50,7 @@ class WrapperComponent extends Component {
}; };
validateName = async featureToggleName => { validateName = async featureToggleName => {
const { errors } = {...this.state}; const { errors } = { ...this.state };
try { try {
await validateName(featureToggleName); await validateName(featureToggleName);
errors.name = undefined; errors.name = undefined;
@ -61,7 +61,7 @@ class WrapperComponent extends Component {
this.setState({ errors }); this.setState({ errors });
}; };
onSubmit = evt => { onSubmit = async evt => {
evt.preventDefault(); evt.preventDefault();
const { createFeatureToggles, history } = this.props; const { createFeatureToggles, history } = this.props;
const { featureToggle } = this.state; const { featureToggle } = this.state;
@ -76,10 +76,17 @@ class WrapperComponent extends Component {
featureToggle.strategies = [defaultStrategy]; featureToggle.strategies = [defaultStrategy];
} }
try {
createFeatureToggles(featureToggle).then(() => await createFeatureToggles(featureToggle).then(() =>
history.push(`/features/strategies/${featureToggle.name}`) history.push(`/features/strategies/${featureToggle.name}`)
); );
} catch (e) {
if (e.toString().includes('not allowed to be empty')) {
this.setState({
errors: { name: 'Name is not allowed to be empty' },
});
}
}
}; };
onCancel = evt => { onCancel = evt => {

View File

@ -16,6 +16,8 @@ import { styles as commonStyles } from '../../common';
import styles from './copy-feature-component.module.scss'; import styles from './copy-feature-component.module.scss';
import { trim } from '../../common/util'; import { trim } from '../../common/util';
import ConditionallyRender from '../../common/ConditionallyRender';
import { Alert } from '@material-ui/lab';
class CopyFeatureComponent extends Component { class CopyFeatureComponent extends Component {
// static displayName = `AddFeatureComponent-${getDisplayName(Component)}`; // static displayName = `AddFeatureComponent-${getDisplayName(Component)}`;
@ -62,7 +64,7 @@ class CopyFeatureComponent extends Component {
} }
}; };
onSubmit = evt => { onSubmit = async evt => {
evt.preventDefault(); evt.preventDefault();
const { nameError, newToggleName, replaceGroupId } = this.state; const { nameError, newToggleName, replaceGroupId } = this.state;
@ -82,11 +84,15 @@ class CopyFeatureComponent extends Component {
}); });
} }
try {
this.props this.props
.createFeatureToggle(copyToggle) .createFeatureToggle(copyToggle)
.then(() => .then(() =>
history.push(`/features/strategies/${copyToggle.name}`) history.push(`/features/strategies/${copyToggle.name}`)
); );
} catch (e) {
this.setState({ apiError: e });
}
}; };
render() { render() {
@ -104,7 +110,10 @@ class CopyFeatureComponent extends Component {
<div className={styles.header}> <div className={styles.header}>
<h1>Copy&nbsp;{copyToggle.name}</h1> <h1>Copy&nbsp;{copyToggle.name}</h1>
</div> </div>
<ConditionallyRender
condition={this.state.apiError}
show={<Alert severity="error">{this.state.apiError}</Alert>}
/>
<section className={styles.content}> <section className={styles.content}>
<p className={styles.text}> <p className={styles.text}>
You are about to create a new feature toggle by cloning You are about to create a new feature toggle by cloning

View File

@ -14,6 +14,7 @@ const StrategyCard = ({
connectDragPreview, connectDragPreview,
connectDragSource, connectDragSource,
removeStrategy, removeStrategy,
disableDelete,
editStrategy, editStrategy,
connectDropTarget, connectDropTarget,
}) => { }) => {
@ -28,9 +29,13 @@ const StrategyCard = ({
connectDragSource={connectDragSource} connectDragSource={connectDragSource}
removeStrategy={removeStrategy} removeStrategy={removeStrategy}
editStrategy={editStrategy} editStrategy={editStrategy}
disableDelete={disableDelete}
/> />
<CardContent> <CardContent>
<StrategyCardContent strategy={strategy} strategyDefinition={strategyDefinition} /> <StrategyCardContent
strategy={strategy}
strategyDefinition={strategyDefinition}
/>
</CardContent> </CardContent>
</Card> </Card>
</span> </span>

View File

@ -11,12 +11,14 @@ import {
import { useStyles } from './StrategyCardHeader.styles.js'; import { useStyles } from './StrategyCardHeader.styles.js';
import { ReactComponent as ReorderIcon } from '../../../../../assets/icons/reorder.svg'; import { ReactComponent as ReorderIcon } from '../../../../../assets/icons/reorder.svg';
import ConditionallyRender from '../../../../common/ConditionallyRender/ConditionallyRender';
const StrategyCardHeader = ({ const StrategyCardHeader = ({
name, name,
connectDragSource, connectDragSource,
removeStrategy, removeStrategy,
editStrategy, editStrategy,
disableDelete,
}) => { }) => {
const styles = useStyles(); const styles = useStyles();
@ -51,13 +53,40 @@ const StrategyCardHeader = ({
</Tooltip> </Tooltip>
</span> </span>
)} )}
<ConditionallyRender
condition={disableDelete}
show={
<Tooltip title="One strategy must always be applied. You can not delete this strategy.">
<span>
<IconButton
onClick={removeStrategy}
disabled={disableDelete}
>
<Icon
className={
styles.strateyCardHeaderIcon
}
>
delete
</Icon>
</IconButton>
</span>
</Tooltip>
}
elseShow={
<Tooltip title="Delete strategy"> <Tooltip title="Delete strategy">
<IconButton onClick={removeStrategy}> <IconButton onClick={removeStrategy}>
<Icon className={styles.strateyCardHeaderIcon}> <Icon
className={
styles.strateyCardHeaderIcon
}
>
delete delete
</Icon> </Icon>
</IconButton> </IconButton>
</Tooltip> </Tooltip>
}
/>
</div> </div>
</> </>
} }

View File

@ -124,6 +124,8 @@ const StrategiesList = props => {
return strategies.find(s => s.name === strategyName); return strategies.find(s => s.name === strategyName);
}; };
const disableDelete = editableStrategies.length === 1;
const cards = editableStrategies.map((strategy, i) => ( const cards = editableStrategies.map((strategy, i) => (
<StrategyCard <StrategyCard
key={i} key={i}
@ -133,6 +135,7 @@ const StrategiesList = props => {
moveStrategy={moveStrategy} moveStrategy={moveStrategy}
editStrategy={() => setEditStrategyIndex(i)} editStrategy={() => setEditStrategyIndex(i)}
index={i} index={i}
disableDelete={disableDelete}
movable movable
/> />
)); ));

View File

@ -86,10 +86,10 @@ const AddVariant = ({
}; };
const submit = async e => { const submit = async e => {
setError({});
e.preventDefault(); e.preventDefault();
const validationError = validateName(data.name); const validationError = validateName(data.name);
if (validationError) { if (validationError) {
setError(validationError); setError(validationError);
return; return;
@ -112,9 +112,13 @@ const AddVariant = ({
clear(); clear();
closeDialog(); closeDialog();
} catch (error) { } catch (error) {
if (error.message.includes('duplicate value')) {
setError({ name: 'A variant with that name already exists.' });
} else {
const msg = error.message || 'Could not add variant'; const msg = error.message || 'Could not add variant';
setError({ general: msg }); setError({ general: msg });
} }
}
}; };
const onPayload = e => { const onPayload = e => {
@ -188,9 +192,11 @@ const AddVariant = ({
name="name" name="name"
placeholder="" placeholder=""
className={commonStyles.fullWidth} className={commonStyles.fullWidth}
helperText={error.name}
value={data.name || ''} value={data.name || ''}
error={error.name} error={Boolean(error.name)}
variant="outlined" variant="outlined"
required
size="small" size="small"
type="name" type="name"
onChange={setVariantValue} onChange={setVariantValue}
@ -214,7 +220,7 @@ const AddVariant = ({
}} }}
style={{ marginRight: '0.8rem' }} style={{ marginRight: '0.8rem' }}
value={data.weight || ''} value={data.weight || ''}
error={error.weight} error={Boolean(error.weight)}
type="number" type="number"
disabled={!isFixWeight} disabled={!isFixWeight}
onChange={setVariantValue} onChange={setVariantValue}

View File

@ -1,4 +1,4 @@
import React, { Component } from 'react'; import { Component } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classnames from 'classnames'; import classnames from 'classnames';
@ -63,12 +63,16 @@ class UpdateVariantComponent extends Component {
onRemoveVariant = (e, index) => { onRemoveVariant = (e, index) => {
e.preventDefault(); e.preventDefault();
try {
this.props.removeVariant(index); this.props.removeVariant(index);
} catch (e) {
console.log('An exception was caught.');
}
}; };
renderVariant = (variant, index) => ( renderVariant = (variant, index) => (
<VariantViewComponent <VariantViewComponent
key={index} key={variant.name}
variant={variant} variant={variant}
editVariant={e => this.openEditVariant(e, index, variant)} editVariant={e => this.openEditVariant(e, index, variant)}
removeVariant={e => this.onRemoveVariant(e, index)} removeVariant={e => this.onRemoveVariant(e, index)}

View File

@ -6,7 +6,10 @@ import { updateWeight } from '../../common/util';
const mapStateToProps = (state, ownProps) => ({ const mapStateToProps = (state, ownProps) => ({
variants: ownProps.featureToggle.variants || [], variants: ownProps.featureToggle.variants || [],
stickinessOptions: ['default', ...state.context.filter(c => c.stickiness).map(c => c.name)], stickinessOptions: [
'default',
...state.context.filter(c => c.stickiness).map(c => c.name),
],
}); });
const mapDispatchToProps = (dispatch, ownProps) => ({ const mapDispatchToProps = (dispatch, ownProps) => ({
@ -15,11 +18,15 @@ const mapDispatchToProps = (dispatch, ownProps) => ({
const currentVariants = featureToggle.variants || []; const currentVariants = featureToggle.variants || [];
const variants = [...currentVariants, variant]; const variants = [...currentVariants, variant];
updateWeight(variants, 1000); updateWeight(variants, 1000);
return requestUpdateFeatureToggleVariants(featureToggle, variants)(dispatch); return requestUpdateFeatureToggleVariants(
featureToggle,
variants
)(dispatch);
}, },
removeVariant: index => { removeVariant: index => {
const { featureToggle } = ownProps; const { featureToggle } = ownProps;
const currentVariants = featureToggle.variants || []; const currentVariants = featureToggle.variants || [];
const variants = currentVariants.filter((v, i) => i !== index); const variants = currentVariants.filter((v, i) => i !== index);
if (variants.length > 0) { if (variants.length > 0) {
updateWeight(variants, 1000); updateWeight(variants, 1000);
@ -29,7 +36,9 @@ const mapDispatchToProps = (dispatch, ownProps) => ({
updateVariant: (index, variant) => { updateVariant: (index, variant) => {
const { featureToggle } = ownProps; const { featureToggle } = ownProps;
const currentVariants = featureToggle.variants || []; const currentVariants = featureToggle.variants || [];
const variants = currentVariants.map((v, i) => (i === index ? variant : v)); const variants = currentVariants.map((v, i) =>
i === index ? variant : v
);
updateWeight(variants, 1000); updateWeight(variants, 1000);
requestUpdateFeatureToggleVariants(featureToggle, variants)(dispatch); requestUpdateFeatureToggleVariants(featureToggle, variants)(dispatch);
}, },
@ -41,4 +50,7 @@ const mapDispatchToProps = (dispatch, ownProps) => ({
}, },
}); });
export default connect(mapStateToProps, mapDispatchToProps)(UpdateFeatureToggleComponent); export default connect(
mapStateToProps,
mapDispatchToProps
)(UpdateFeatureToggleComponent);

View File

@ -88,10 +88,25 @@ const CreateStrategy = ({
Add parameter Add parameter
</Button> </Button>
<ConditionallyRender
condition={editMode}
show={
<Button
type="submit"
variant="contained"
color="primary"
style={{ display: 'block' }}
>
Update
</Button>
}
elseShow={
<FormButtons <FormButtons
submitText={editMode ? 'Update' : 'Create'} submitText={'Create'}
onCancel={onCancel} onCancel={onCancel}
/> />
}
/>
</form> </form>
</PageContent> </PageContent>
); );

View File

@ -1,4 +1,4 @@
import React, { useContext, useEffect } from 'react'; import { useContext, useEffect, useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classnames from 'classnames'; import classnames from 'classnames';
import { Link, useHistory } from 'react-router-dom'; import { Link, useHistory } from 'react-router-dom';
@ -25,6 +25,7 @@ import HeaderTitle from '../../common/HeaderTitle';
import { useStyles } from './styles'; import { useStyles } from './styles';
import AccessContext from '../../../contexts/AccessContext'; import AccessContext from '../../../contexts/AccessContext';
import Dialogue from '../../common/Dialogue';
const StrategiesList = ({ const StrategiesList = ({
strategies, strategies,
@ -37,6 +38,7 @@ const StrategiesList = ({
const styles = useStyles(); const styles = useStyles();
const smallScreen = useMediaQuery('(max-width:700px)'); const smallScreen = useMediaQuery('(max-width:700px)');
const { hasAccess } = useContext(AccessContext); const { hasAccess } = useContext(AccessContext);
const [dialogueMetaData, setDialogueMetaData] = useState({ show: false });
useEffect(() => { useEffect(() => {
fetchStrategies(); fetchStrategies();
@ -86,7 +88,15 @@ const StrategiesList = ({
const reactivateButton = strategy => ( const reactivateButton = strategy => (
<Tooltip title="Reactivate activation strategy"> <Tooltip title="Reactivate activation strategy">
<IconButton onClick={() => reactivateStrategy(strategy)}> <IconButton
onClick={() =>
setDialogueMetaData({
show: true,
title: 'Really reactivate strategy?',
onConfirm: () => reactivateStrategy(strategy),
})
}
>
<Icon>visibility</Icon> <Icon>visibility</Icon>
</IconButton> </IconButton>
</Tooltip> </Tooltip>
@ -107,7 +117,16 @@ const StrategiesList = ({
elseShow={ elseShow={
<Tooltip title="Deprecate activation strategy"> <Tooltip title="Deprecate activation strategy">
<div> <div>
<IconButton onClick={() => deprecateStrategy(strategy)}> <IconButton
onClick={() =>
setDialogueMetaData({
show: true,
title: 'Really deprecate strategy?',
onConfirm: () =>
deprecateStrategy(strategy),
})
}
>
<Icon>visibility_off</Icon> <Icon>visibility_off</Icon>
</IconButton> </IconButton>
</div> </div>
@ -121,7 +140,15 @@ const StrategiesList = ({
condition={strategy.editable} condition={strategy.editable}
show={ show={
<Tooltip title="Delete strategy"> <Tooltip title="Delete strategy">
<IconButton onClick={() => removeStrategy(strategy)}> <IconButton
onClick={() =>
setDialogueMetaData({
show: true,
title: 'Really delete strategy?',
onConfirm: () => removeStrategy(strategy),
})
}
>
<Icon>delete</Icon> <Icon>delete</Icon>
</IconButton> </IconButton>
</Tooltip> </Tooltip>
@ -167,12 +194,25 @@ const StrategiesList = ({
</ListItem> </ListItem>
)); ));
const onDialogConfirm = () => {
dialogueMetaData?.onConfirm();
setDialogueMetaData(prev => ({ ...prev, show: false }));
};
return ( return (
<PageContent <PageContent
headerContent={ headerContent={
<HeaderTitle title="Strategies" actions={headerButton()} /> <HeaderTitle title="Strategies" actions={headerButton()} />
} }
> >
<Dialogue
open={dialogueMetaData.show}
onClick={onDialogConfirm}
title={dialogueMetaData?.title}
onClose={() =>
setDialogueMetaData(prev => ({ ...prev, show: false }))
}
/>
<List> <List>
<ConditionallyRender <ConditionallyRender
condition={strategies.length > 0} condition={strategies.length > 0}

View File

@ -17,26 +17,20 @@ const mapStateToProps = state => {
const mapDispatchToProps = dispatch => ({ const mapDispatchToProps = dispatch => ({
removeStrategy: strategy => { removeStrategy: strategy => {
// eslint-disable-next-line no-alert
if (window.confirm('Are you sure you want to remove this strategy?')) {
removeStrategy(strategy)(dispatch); removeStrategy(strategy)(dispatch);
}
}, },
deprecateStrategy: strategy => { deprecateStrategy: strategy => {
// eslint-disable-next-line no-alert
if (window.confirm('Are you sure you want to deprecate this strategy?')) {
deprecateStrategy(strategy)(dispatch); deprecateStrategy(strategy)(dispatch);
}
}, },
reactivateStrategy: strategy => { reactivateStrategy: strategy => {
// eslint-disable-next-line no-alert
if (window.confirm('Are you sure you want to reactivate this strategy?')) {
reactivateStrategy(strategy)(dispatch); reactivateStrategy(strategy)(dispatch);
}
}, },
fetchStrategies: () => fetchStrategies()(dispatch), fetchStrategies: () => fetchStrategies()(dispatch),
}); });
const StrategiesListContainer = connect(mapStateToProps, mapDispatchToProps)(StrategiesList); const StrategiesListContainer = connect(
mapStateToProps,
mapDispatchToProps
)(StrategiesList);
export default StrategiesListContainer; export default StrategiesListContainer;

View File

@ -14,9 +14,7 @@ class AuthenticationCustomComponent extends React.Component {
<p>{authDetails.message}</p> <p>{authDetails.message}</p>
<CardActions style={{ textAlign: 'center' }}> <CardActions style={{ textAlign: 'center' }}>
<a href={authDetails.path}> <a href={authDetails.path}>
<Button raised colored> <Button>Sign In</Button>
Sign In
</Button>
</a> </a>
</CardActions> </CardActions>
</div> </div>

View File

@ -1,7 +1,9 @@
const defaultErrorMessage = 'Unexpected exception when talking to unleash-api'; const defaultErrorMessage = 'Unexpected exception when talking to unleash-api';
function extractJoiMsg(body) { function extractJoiMsg(body) {
return body.details.length > 0 ? body.details[0].message : defaultErrorMessage; return body.details.length > 0
? body.details[0].message
: defaultErrorMessage;
} }
function extractLegacyMsg(body) { function extractLegacyMsg(body) {
return body && body.length > 0 ? body[0].msg : defaultErrorMessage; return body && body.length > 0 ? body[0].msg : defaultErrorMessage;
@ -35,7 +37,9 @@ export class ForbiddenError extends Error {
export class NotFoundError extends Error { export class NotFoundError extends Error {
constructor(statusCode) { constructor(statusCode) {
super('The requested resource could not be found but may be available in the future'); super(
'The requested resource could not be found but may be available in the future'
);
this.name = 'NotFoundError'; this.name = 'NotFoundError';
this.statusCode = statusCode; this.statusCode = statusCode;
} }
@ -45,11 +49,19 @@ export function throwIfNotSuccess(response) {
if (!response.ok) { if (!response.ok) {
if (response.status === 401) { if (response.status === 401) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
response.json().then(body => reject(new AuthenticationError(response.status, body))); response
.json()
.then(body =>
reject(new AuthenticationError(response.status, body))
);
}); });
} else if (response.status === 403) { } else if (response.status === 403) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
response.json().then(body => reject(new ForbiddenError(response.status, body))); response
.json()
.then(body =>
reject(new ForbiddenError(response.status, body))
);
}); });
} else if (response.status === 404) { } else if (response.status === 404) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -57,12 +69,18 @@ export function throwIfNotSuccess(response) {
}); });
} else if (response.status > 399 && response.status < 501) { } else if (response.status > 399 && response.status < 501) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
response.json().then(body => { response
const errorMsg = body && body.isJoi ? extractJoiMsg(body) : extractLegacyMsg(body); .json()
.then(body => {
const errorMsg =
body && body.isJoi
? extractJoiMsg(body)
: extractLegacyMsg(body);
let error = new Error(errorMsg); let error = new Error(errorMsg);
error.statusCode = response.status; error.statusCode = response.status;
reject(error); reject(error);
}).catch(() => reject(new Error(defaultErrorMessage))) })
.catch(() => reject(new Error(defaultErrorMessage)));
}); });
} else { } else {
return Promise.reject(new ServiceError(response.status)); return Promise.reject(new ServiceError(response.status));

View File

@ -13,6 +13,7 @@ import {
ERROR_UPDATING_STRATEGY, ERROR_UPDATING_STRATEGY,
ERROR_CREATING_STRATEGY, ERROR_CREATING_STRATEGY,
ERROR_RECEIVE_STRATEGIES, ERROR_RECEIVE_STRATEGIES,
UPDATE_STRATEGY_SUCCESS,
} from '../strategy/actions'; } from '../strategy/actions';
import { import {
@ -79,9 +80,13 @@ const strategies = (state = getInitState(), action) => {
return state.update('list', list => return state.update('list', list =>
list.remove(list.indexOf(action.error)) list.remove(list.indexOf(action.error))
); );
// This reducer controls not only errors, but general information and success
// messages. This can be a little misleading, given it's naming. We should
// revise how this works in a future update.
case UPDATE_FEATURE_TOGGLE: case UPDATE_FEATURE_TOGGLE:
case UPDATE_FEATURE_TOGGLE_STRATEGIES: case UPDATE_FEATURE_TOGGLE_STRATEGIES:
case UPDATE_APPLICATION_FIELD: case UPDATE_APPLICATION_FIELD:
case UPDATE_STRATEGY_SUCCESS:
return addErrorIfNotAlreadyInList(state, action.info); return addErrorIfNotAlreadyInList(state, action.info);
default: default:
return state; return state;

View File

@ -110,7 +110,10 @@ export function createFeatureToggles(featureToggle) {
featureToggle: createdFeature, featureToggle: createdFeature,
}); });
}) })
.catch(dispatchError(dispatch, ERROR_CREATING_FEATURE_TOGGLE)); .catch(e => {
dispatchError(dispatch, ERROR_CREATING_FEATURE_TOGGLE);
throw e;
});
}; };
} }
@ -189,24 +192,28 @@ export function requestUpdateFeatureToggleStrategies(
export function requestUpdateFeatureToggleVariants(featureToggle, newVariants) { export function requestUpdateFeatureToggleVariants(featureToggle, newVariants) {
return dispatch => { return dispatch => {
featureToggle.variants = newVariants; const newFeature = { ...featureToggle };
newFeature.variants = newVariants;
dispatch({ type: START_UPDATE_FEATURE_TOGGLE }); dispatch({ type: START_UPDATE_FEATURE_TOGGLE });
return api return api
.update(featureToggle) .update(newFeature)
.then(() => { .then(() => {
const info = `${featureToggle.name} successfully updated!`; const info = `${newFeature.name} successfully updated!`;
setTimeout( setTimeout(
() => dispatch({ type: MUTE_ERROR, error: info }), () => dispatch({ type: MUTE_ERROR, error: info }),
1000 1000
); );
return dispatch({ return dispatch({
type: UPDATE_FEATURE_TOGGLE_STRATEGIES, type: UPDATE_FEATURE_TOGGLE_STRATEGIES,
featureToggle, featureToggle: newFeature,
info, info,
}); });
}) })
.catch(dispatchError(dispatch, ERROR_UPDATE_FEATURE_TOGGLE)); .catch(e => {
dispatchError(dispatch, ERROR_UPDATE_FEATURE_TOGGLE);
throw e;
});
}; };
} }

View File

@ -50,14 +50,14 @@ function validate(featureToggle) {
function update(featureToggle) { function update(featureToggle) {
return validateToggle(featureToggle) return validateToggle(featureToggle)
.then(() => .then(() => {
fetch(`${URI}/${featureToggle.name}`, { return fetch(`${URI}/${featureToggle.name}`, {
method: 'PUT', method: 'PUT',
headers, headers,
credentials: 'include', credentials: 'include',
body: JSON.stringify(featureToggle), body: JSON.stringify(featureToggle),
});
}) })
)
.then(throwIfNotSuccess); .then(throwIfNotSuccess);
} }

View File

@ -1,6 +1,7 @@
import api from './api'; import api from './api';
import applicationApi from '../application/api'; import applicationApi from '../application/api';
import { dispatchError } from '../util'; import { dispatchError } from '../util';
import { MUTE_ERROR } from '../error/actions';
export const ADD_STRATEGY = 'ADD_STRATEGY'; export const ADD_STRATEGY = 'ADD_STRATEGY';
export const UPDATE_STRATEGY = 'UPDATE_STRATEGY'; export const UPDATE_STRATEGY = 'UPDATE_STRATEGY';
@ -19,6 +20,7 @@ export const ERROR_UPDATING_STRATEGY = 'ERROR_UPDATING_STRATEGY';
export const ERROR_REMOVING_STRATEGY = 'ERROR_REMOVING_STRATEGY'; export const ERROR_REMOVING_STRATEGY = 'ERROR_REMOVING_STRATEGY';
export const ERROR_DEPRECATING_STRATEGY = 'ERROR_DEPRECATING_STRATEGY'; export const ERROR_DEPRECATING_STRATEGY = 'ERROR_DEPRECATING_STRATEGY';
export const ERROR_REACTIVATING_STRATEGY = 'ERROR_REACTIVATING_STRATEGY'; export const ERROR_REACTIVATING_STRATEGY = 'ERROR_REACTIVATING_STRATEGY';
export const UPDATE_STRATEGY_SUCCESS = 'UPDATE_STRATEGY_SUCCESS';
export const receiveStrategies = json => ({ export const receiveStrategies = json => ({
type: RECEIVE_STRATEGIES, type: RECEIVE_STRATEGIES,
@ -46,6 +48,14 @@ const reactivateStrategyEvent = strategy => ({
strategy, strategy,
}); });
const setInfoMessage = (info, dispatch) => {
dispatch({
type: UPDATE_STRATEGY_SUCCESS,
info: info,
});
setTimeout(() => dispatch({ type: MUTE_ERROR, error: info }), 1500);
};
export function fetchStrategies() { export function fetchStrategies() {
return dispatch => { return dispatch => {
dispatch(startRequest()); dispatch(startRequest());
@ -64,6 +74,9 @@ export function createStrategy(strategy) {
return api return api
.create(strategy) .create(strategy)
.then(() => dispatch(addStrategy(strategy))) .then(() => dispatch(addStrategy(strategy)))
.then(() => {
setInfoMessage('Strategy successfully created.', dispatch);
})
.catch(e => { .catch(e => {
dispatchError(dispatch, ERROR_CREATING_STRATEGY); dispatchError(dispatch, ERROR_CREATING_STRATEGY);
throw e; throw e;
@ -78,6 +91,9 @@ export function updateStrategy(strategy) {
return api return api
.update(strategy) .update(strategy)
.then(() => dispatch(updatedStrategy(strategy))) .then(() => dispatch(updatedStrategy(strategy)))
.then(() => {
setInfoMessage('Strategy successfully updated.', dispatch);
})
.catch(dispatchError(dispatch, ERROR_UPDATING_STRATEGY)); .catch(dispatchError(dispatch, ERROR_UPDATING_STRATEGY));
}; };
} }
@ -87,6 +103,9 @@ export function removeStrategy(strategy) {
api api
.remove(strategy) .remove(strategy)
.then(() => dispatch(createRemoveStrategy(strategy))) .then(() => dispatch(createRemoveStrategy(strategy)))
.then(() => {
setInfoMessage('Strategy successfully deleted.', dispatch);
})
.catch(dispatchError(dispatch, ERROR_REMOVING_STRATEGY)); .catch(dispatchError(dispatch, ERROR_REMOVING_STRATEGY));
} }
@ -99,6 +118,9 @@ export function deprecateStrategy(strategy) {
dispatch(startDeprecate()); dispatch(startDeprecate());
api.deprecate(strategy) api.deprecate(strategy)
.then(() => dispatch(deprecateStrategyEvent(strategy))) .then(() => dispatch(deprecateStrategyEvent(strategy)))
.then(() =>
setInfoMessage('Strategy successfully deprecated', dispatch)
)
.catch(dispatchError(dispatch, ERROR_DEPRECATING_STRATEGY)); .catch(dispatchError(dispatch, ERROR_DEPRECATING_STRATEGY));
}; };
} }
@ -108,6 +130,9 @@ export function reactivateStrategy(strategy) {
dispatch(startReactivate()); dispatch(startReactivate());
api.reactivate(strategy) api.reactivate(strategy)
.then(() => dispatch(reactivateStrategyEvent(strategy))) .then(() => dispatch(reactivateStrategyEvent(strategy)))
.then(() =>
setInfoMessage('Strategy successfully reactivated', dispatch)
)
.catch(dispatchError(dispatch, ERROR_REACTIVATING_STRATEGY)); .catch(dispatchError(dispatch, ERROR_REACTIVATING_STRATEGY));
}; };
} }