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

fix: UX should not eagerly store strategy updates! (#240)

Co-authored-by: Christopher Kolstad <chriswk@getunleash.ai>
This commit is contained in:
Ivar Conradi Østhus 2021-02-09 10:14:04 +01:00 committed by GitHub
parent d0a54d6859
commit 00f411d9d2
26 changed files with 450 additions and 238 deletions

View File

@ -67,7 +67,7 @@
"identity-obj-proxy": "^3.0.0", "identity-obj-proxy": "^3.0.0",
"immutable": "^3.8.1", "immutable": "^3.8.1",
"jest": "^26.6.3", "jest": "^26.6.3",
"lodash": "^4.17.15", "lodash": "^4.17.20",
"mini-css-extract-plugin": "^0.9.0", "mini-css-extract-plugin": "^0.9.0",
"node-fetch": "^2.6.1", "node-fetch": "^2.6.1",
"node-sass": "^4.5.3", "node-sass": "^4.5.3",

View File

@ -8,6 +8,10 @@
width: 100%; width: 100%;
} }
.sectionPadding {
padding: 0 16px;
}
.horisontalScroll { .horisontalScroll {
overflow-x: scroll; overflow-x: scroll;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;

View File

@ -34,9 +34,9 @@ export const HeaderTitle = ({ title, actions, subtitle }) => (
<div <div
style={{ style={{
display: 'flex', display: 'flex',
borderBottom: '1px solid #f1f1f1', borderBottom: '1px solid #f9f9f9',
marginBottom: '10px', marginBottom: '10px',
padding: '16px 20px ', padding: '16px',
}} }}
> >
<div style={{ flex: '2' }}> <div style={{ flex: '2' }}>

View File

@ -104,3 +104,5 @@ export const modalStyles = {
transform: 'translate(-50%, -50%)', transform: 'translate(-50%, -50%)',
}, },
}; };
export const updateIndexInArray = (array, index, newValue) => array.map((v, i) => (i === index ? newValue : v));

View File

@ -28,75 +28,72 @@ exports[`render the create feature page 1`] = `
col={4} col={4}
> >
<react-mdl-Textfield <react-mdl-Textfield
className="fullwidth"
floatingLabel={true} floatingLabel={true}
label="Name" label="Name"
name="name" name="name"
onBlur={[Function]} onBlur={[Function]}
onChange={[Function]} onChange={[Function]}
placeholder="Unique-name" placeholder="Unique-name"
style={
Object {
"width": "100%",
}
}
value="feature" value="feature"
/> />
</react-mdl-Cell> </react-mdl-Cell>
<react-mdl-Cell <react-mdl-Cell
col={2} col={3}
> >
<Connect(FeatureTypeSelectComponent) <Connect(FeatureTypeSelectComponent)
onChange={[Function]} onChange={[Function]}
/> />
</react-mdl-Cell> </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> </react-mdl-Grid>
<section <section
style={ className="sectionPadding"
Object {
"padding": "0 16px",
}
}
> >
<Connect(ProjectSelectComponent) <Connect(ProjectSelectComponent)
onChange={[Function]} onChange={[Function]}
/> />
</section> </section>
<section <section
style={ className="sectionPadding"
Object {
"padding": "0 16px",
}
}
> >
<react-mdl-Textfield <react-mdl-Textfield
className="fullwidth"
floatingLabel={true} floatingLabel={true}
label="Description" label="Description"
onChange={[Function]} onChange={[Function]}
placeholder="A short description of the feature toggle" placeholder="A short description of the feature toggle"
rows={1} rows={1}
style={
Object {
"width": "100%",
}
}
value="Description" value="Description"
/> />
</section> </section>
<section
style={
Object {
"padding": "10px 16px",
}
}
>
<react-mdl-Switch
checked={false}
onChange={[Function]}
>
Disabled
feature toggle
</react-mdl-Switch>
</section>
<section
style={
Object {
"margin": "40px 0",
}
}
>
<Connect(StrategiesList)
editable={true}
featureToggleName="feature"
saveStrategies={[Function]}
/>
</section>
<react-mdl-CardActions> <react-mdl-CardActions>
<FormButtons <FormButtons
onCancel={[MockFunction]} onCancel={[MockFunction]}

View File

@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import { Textfield, Switch, Card, CardTitle, CardActions, Grid, Cell } from 'react-mdl'; import { Textfield, Switch, Card, CardTitle, CardActions, Grid, Cell } from 'react-mdl';
import FeatureTypeSelect from '../feature-type-select-container'; import FeatureTypeSelect from '../feature-type-select-container';
import ProjectSelect from '../project-select-container'; import ProjectSelect from '../project-select-container';
import StrategiesList from '../strategy/strategies-list-add-container';
import { FormButtons, styles as commonStyles } from '../../common'; import { FormButtons, styles as commonStyles } from '../../common';
import { trim } from '../../common/util'; import { trim } from '../../common/util';
@ -28,7 +29,7 @@ class AddFeatureComponent extends Component {
<Cell col={4}> <Cell col={4}>
<Textfield <Textfield
floatingLabel floatingLabel
style={{ width: '100%' }} className={commonStyles.fullwidth}
label="Name" label="Name"
placeholder="Unique-name" placeholder="Unique-name"
name="name" name="name"
@ -38,27 +39,17 @@ class AddFeatureComponent extends Component {
onChange={v => setValue('name', trim(v.target.value))} onChange={v => setValue('name', trim(v.target.value))}
/> />
</Cell> </Cell>
<Cell col={2}> <Cell col={3}>
<FeatureTypeSelect value={input.type} onChange={v => setValue('type', v.target.value)} /> <FeatureTypeSelect value={input.type} onChange={v => setValue('type', v.target.value)} />
</Cell> </Cell>
<Cell col={2} style={{ paddingTop: '14px' }}>
<Switch
checked={input.enabled}
onChange={() => {
setValue('enabled', !input.enabled);
}}
>
{input.enabled ? 'Enabled' : 'Disabled'}
</Switch>
</Cell>
</Grid> </Grid>
<section style={{ padding: '0 16px' }}> <section className={commonStyles.sectionPadding}>
<ProjectSelect value={input.project} onChange={v => setValue('project', v.target.value)} /> <ProjectSelect value={input.project} onChange={v => setValue('project', v.target.value)} />
</section> </section>
<section style={{ padding: '0 16px' }}> <section className={commonStyles.sectionPadding}>
<Textfield <Textfield
floatingLabel floatingLabel
style={{ width: '100%' }} className={commonStyles.fullwidth}
rows={1} rows={1}
label="Description" label="Description"
placeholder="A short description of the feature toggle" placeholder="A short description of the feature toggle"
@ -67,6 +58,24 @@ class AddFeatureComponent extends Component {
onChange={v => setValue('description', v.target.value)} onChange={v => setValue('description', v.target.value)}
/> />
</section> </section>
<section style={{ padding: '10px 16px' }}>
<Switch
checked={input.enabled}
onChange={() => {
setValue('enabled', !input.enabled);
}}
>
{input.enabled ? 'Enabled' : 'Disabled'} feature toggle
</Switch>
</section>
<section style={{ margin: '40px 0' }}>
<StrategiesList
configuredStrategies={input.strategies}
featureToggleName={input.name}
saveStrategies={s => setValue('strategies', s)}
editable
/>
</section>
<CardActions> <CardActions>
<FormButtons submitText={'Create'} onCancel={onCancel} /> <FormButtons submitText={'Create'} onCancel={onCancel} />
</CardActions> </CardActions>

View File

@ -61,7 +61,10 @@ class WrapperComponent extends Component {
evt.preventDefault(); evt.preventDefault();
const { createFeatureToggles, history } = this.props; const { createFeatureToggles, history } = this.props;
const { featureToggle } = this.state; const { featureToggle } = this.state;
featureToggle.strategies = [defaultStrategy];
if (featureToggle.strategies < 1) {
featureToggle.strategies = [defaultStrategy];
}
createFeatureToggles(featureToggle).then(() => history.push(`/features/strategies/${featureToggle.name}`)); createFeatureToggles(featureToggle).then(() => history.push(`/features/strategies/${featureToggle.name}`));
}; };
@ -78,6 +81,7 @@ class WrapperComponent extends Component {
onCancel={this.onCancel} onCancel={this.onCancel}
validateName={this.validateName} validateName={this.validateName}
setValue={this.setValue} setValue={this.setValue}
setStrategies={this.setStrategies}
input={this.state.featureToggle} input={this.state.featureToggle}
errors={this.state.errors} errors={this.state.errors}
/> />

View File

@ -31,7 +31,7 @@ export default class InputList extends Component {
const newValues = value.split(/,\s*/).filter(a => !list.includes(a)); const newValues = value.split(/,\s*/).filter(a => !list.includes(a));
if (newValues.length > 0) { if (newValues.length > 0) {
const newList = list.concat(newValues).filter(a => a); const newList = list.concat(newValues).filter(a => a);
setConfig(name, newList.join(','), true); setConfig(name, newList.join(','));
} }
this.textInput.inputRef.value = ''; this.textInput.inputRef.value = '';
} }
@ -40,7 +40,7 @@ export default class InputList extends Component {
onClose(index) { onClose(index) {
const { name, list, setConfig } = this.props; const { name, list, setConfig } = this.props;
list[index] = null; list[index] = null;
setConfig(name, list.length === 1 ? '' : list.filter(Boolean).join(','), true); setConfig(name, list.length === 1 ? '' : list.filter(Boolean).join(','));
} }
render() { render() {

View File

@ -0,0 +1,9 @@
import React from 'react';
export default function LoadingStrategy() {
return (
<div>
<p>Loading definition...</p>
</div>
);
}

View File

@ -21,6 +21,7 @@ class AddStrategy extends React.Component {
strategies: PropTypes.array.isRequired, strategies: PropTypes.array.isRequired,
addStrategy: PropTypes.func, addStrategy: PropTypes.func,
featureToggleName: PropTypes.string.isRequired, featureToggleName: PropTypes.string.isRequired,
disabled: PropTypes.bool,
}; };
addStrategy(strategyName) { addStrategy(strategyName) {
@ -45,10 +46,11 @@ class AddStrategy extends React.Component {
render() { render() {
const menuStyle = { const menuStyle = {
maxHeight: '300px', maxHeight: '400px',
overflowY: 'auto', overflowY: 'auto',
backgroundColor: 'rgb(247, 248, 255)', backgroundColor: 'rgb(247, 248, 255)',
}; };
const { disabled = false } = this.props;
return ( return (
<div style={{ position: 'relative', width: '25px', height: '25px', display: 'inline-block' }}> <div style={{ position: 'relative', width: '25px', height: '25px', display: 'inline-block' }}>
<IconButton <IconButton
@ -56,6 +58,7 @@ class AddStrategy extends React.Component {
id="strategies-add" id="strategies-add"
raised raised
accent accent
disabled={disabled}
title="Add Strategy" title="Add Strategy"
onClick={this.stopPropagation} onClick={this.stopPropagation}
/> />

View File

@ -0,0 +1,96 @@
import React from 'react';
import PropTypes from 'prop-types';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import arrayMove from 'array-move';
import ConfigureStrategy from './strategy-configure-container';
import AddStrategy from './strategies-add';
import { HeaderTitle } from '../../common';
import { updateIndexInArray } from '../../common/util';
import styles from './strategy.module.scss';
const StrategiesList = props => {
const updateStrategy = index => strategy => {
const newStrategy = { ...strategy };
const newStrategies = updateIndexInArray(props.configuredStrategies, index, newStrategy);
props.saveStrategies(newStrategies);
};
const saveStrategy = () => () => {
// not needed for create flow
};
const addStrategy = strategy => {
const strategies = [...props.configuredStrategies];
strategies.push({ ...strategy });
props.saveStrategies(strategies);
};
const moveStrategy = async (index, toIndex) => {
const strategies = arrayMove(props.configuredStrategies, index, toIndex);
await props.saveStrategies(strategies);
};
const removeStrategy = index => async () => {
props.saveStrategies(props.configuredStrategies.filter((_, i) => i !== index));
};
const { strategies, configuredStrategies, featureToggleName } = props;
const hasName = featureToggleName && featureToggleName.length > 1;
const blocks = configuredStrategies.map((strategy, i) => (
<ConfigureStrategy
index={i}
key={i}
featureToggleName={featureToggleName}
strategy={strategy}
moveStrategy={moveStrategy}
removeStrategy={removeStrategy(i)}
updateStrategy={updateStrategy(i)}
saveStrategy={saveStrategy(i)}
strategyDefinition={strategies.find(s => s.name === strategy.name)}
editable
movable
/>
));
return (
<DndProvider backend={HTML5Backend}>
<div className={styles.strategyListAdd}>
<HeaderTitle
title="Activation strategies"
actions={
<AddStrategy
strategies={strategies}
addStrategy={addStrategy}
disabled={!hasName}
featureToggleName={featureToggleName}
/>
}
/>
<div className={styles.strategyList}>
{blocks.length > 0 ? (
blocks
) : (
<p style={{ maxWidth: '800px' }}>
An activation strategy allows you to control how a feature toggle is enabled in your
applications. If you do not specify any activation strategies you will get the "default"
strategy.
</p>
)}
</div>
</div>
</DndProvider>
);
};
StrategiesList.propTypes = {
strategies: PropTypes.array.isRequired,
configuredStrategies: PropTypes.array.isRequired,
featureToggleName: PropTypes.string.isRequired,
saveStrategies: PropTypes.func,
};
export default StrategiesList;

View File

@ -0,0 +1,8 @@
import { connect } from 'react-redux';
import StrategiesList from './strategies-list-add-component';
const mapStateToProps = state => ({
strategies: state.strategies.get('list').toArray(),
});
export default connect(mapStateToProps, undefined)(StrategiesList);

View File

@ -0,0 +1,162 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { cloneDeep } from 'lodash';
import arrayMove from 'array-move';
import { Button, Icon } from 'react-mdl';
import ConfigureStrategy from './strategy-configure-container';
import AddStrategy from './strategies-add';
import { HeaderTitle } from '../../common';
import { updateIndexInArray } from '../../common/util';
import styles from './strategy.module.scss';
const cleanStrategy = strategy => ({
name: strategy.name,
parameters: cloneDeep(strategy.parameters),
constraints: cloneDeep(strategy.constraints || []),
});
const StrategiesList = props => {
const [editableStrategies, updateEditableStrategies] = useState(cloneDeep(props.configuredStrategies));
const dirty = editableStrategies.some(p => p.dirty);
useEffect(() => {
if (!dirty) {
updateEditableStrategies(cloneDeep(props.configuredStrategies));
}
}, [props.configuredStrategies]);
const updateStrategy = index => (strategy, dirty = true) => {
const newStrategy = { ...strategy, dirty };
const newStrategies = updateIndexInArray(editableStrategies, index, newStrategy);
updateEditableStrategies(newStrategies);
};
const saveStrategy = index => async () => {
const strategies = [...props.configuredStrategies];
const strategy = editableStrategies[index];
const cleanedStrategy = cleanStrategy(strategy);
if (strategy.new) {
strategies.push(cleanedStrategy);
} else {
strategies[index] = cleanedStrategy;
}
// store in server
await props.saveStrategies(strategies);
// update local state
updateStrategy(index)(cleanedStrategy, false);
};
const addStrategy = strategy => {
const strategies = [...editableStrategies];
strategies.push({ ...strategy, dirty: true, new: true });
updateEditableStrategies(strategies);
};
const moveStrategy = async (index, toIndex) => {
if (!dirty) {
// console.log(`move strategy from ${index} to ${toIndex}`);
const strategies = arrayMove(editableStrategies, index, toIndex);
await props.saveStrategies(strategies);
updateEditableStrategies(strategies);
}
};
const removeStrategy = index => async () => {
// eslint-disable-next-line no-alert
if (window.confirm('Are you sure you want to remove this activation strategy?')) {
const strategy = editableStrategies[index];
if (!strategy.new) {
await props.saveStrategies(props.configuredStrategies.filter((_, i) => i !== index));
}
updateEditableStrategies(editableStrategies.filter((_, i) => i !== index));
}
};
const clearAll = () => {
updateEditableStrategies(cloneDeep(props.configuredStrategies));
};
const saveAll = async () => {
const cleanedStrategies = editableStrategies.map(cleanStrategy);
await props.saveStrategies(cleanedStrategies);
updateEditableStrategies(cleanedStrategies);
};
const { strategies, configuredStrategies, featureToggleName, editable } = props;
if (!configuredStrategies || configuredStrategies.length === 0) {
return (
<p style={{ padding: '0 16px' }}>
<i>No activation strategies selected.</i>
</p>
);
}
const resolveStrategyDefinition = strategyName => {
if (!strategies || strategies.length === 0) {
return { name: 'Loading' };
}
return strategies.find(s => s.name === strategyName);
};
const blocks = editableStrategies.map((strategy, i) => (
<ConfigureStrategy
index={i}
key={i}
featureToggleName={featureToggleName}
strategy={strategy}
moveStrategy={moveStrategy}
removeStrategy={removeStrategy(i)}
updateStrategy={updateStrategy(i)}
saveStrategy={saveStrategy(i)}
strategyDefinition={resolveStrategyDefinition(strategy.name)}
editable={editable}
movable={!dirty}
/>
));
return (
<DndProvider backend={HTML5Backend}>
{editable && (
<HeaderTitle
title="Activation strategies"
actions={
<AddStrategy
strategies={strategies}
addStrategy={addStrategy}
featureToggleName={featureToggleName}
/>
}
/>
)}
<div className={styles.strategyList}>{blocks}</div>
<div style={{ visibility: dirty ? 'visible' : 'hidden', padding: '10px' }}>
<Button type="submit" ripple raised primary icon="add" onClick={saveAll}>
<Icon name="save" />
&nbsp;&nbsp;&nbsp; Save all
</Button>
&nbsp;
<Button accent type="cancel" onClick={clearAll}>
Clear all
</Button>
</div>
</DndProvider>
);
};
StrategiesList.propTypes = {
strategies: PropTypes.array.isRequired,
configuredStrategies: PropTypes.array.isRequired,
featureToggleName: PropTypes.string.isRequired,
saveStrategies: PropTypes.func,
editable: PropTypes.bool,
};
export default StrategiesList;

View File

@ -0,0 +1,8 @@
import { connect } from 'react-redux';
import StrategiesList from './strategies-list-component';
const mapStateToProps = state => ({
strategies: state.strategies.get('list').toArray(),
});
export default connect(mapStateToProps, undefined)(StrategiesList);

View File

@ -1,76 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import ConfigureStrategy from './strategy-configure-container';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
const randomKeys = length => Array.from({ length }, () => Math.random());
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,
editable: PropTypes.bool,
};
constructor(props) {
super();
// temporal hack, until strategies get UIDs
this.state = { keys: randomKeys(props.configuredStrategies.length) };
}
moveStrategy = async (index, toIndex) => {
await this.props.moveStrategy(index, toIndex);
this.setState({ keys: randomKeys(this.props.configuredStrategies.length) });
};
removeStrategy = async index => {
await this.props.removeStrategy(index);
this.setState({ keys: randomKeys(this.props.configuredStrategies.length) });
};
componentDidUpdate(props) {
const { keys } = this.state;
if (keys.length < props.configuredStrategies.length) {
// eslint-disable-next-line react/no-did-update-set-state
this.setState({ keys: randomKeys(props.configuredStrategies.length) });
}
}
render() {
const { strategies, configuredStrategies, updateStrategy, featureToggleName, editable } = this.props;
const { keys } = this.state;
if (!configuredStrategies || configuredStrategies.length === 0) {
return (
<p style={{ padding: '0 16px' }}>
<i>No activation strategies selected.</i>
</p>
);
}
const blocks = configuredStrategies.map((strategy, i) => (
<ConfigureStrategy
index={i}
key={`${keys[i]}}`}
featureToggleName={featureToggleName}
strategy={strategy}
moveStrategy={this.moveStrategy}
removeStrategy={this.removeStrategy.bind(this, i)}
updateStrategy={updateStrategy ? updateStrategy.bind(null, i) : null}
strategyDefinition={strategies.find(s => s.name === strategy.name)}
editable={editable}
/>
));
return (
<DndProvider backend={HTML5Backend}>
<div style={{ display: 'flex', flexWrap: 'wrap' }}>{blocks}</div>
</DndProvider>
);
}
}
export default StrategiesList;

View File

@ -8,6 +8,7 @@ import DefaultStrategy from './default-strategy';
import GeneralStrategy from './general-strategy'; import GeneralStrategy from './general-strategy';
import UserWithIdStrategy from './user-with-id-strategy'; import UserWithIdStrategy from './user-with-id-strategy';
import UnknownStrategy from './unknown-strategy'; import UnknownStrategy from './unknown-strategy';
import LoadingStrategy from './loading-strategy';
import styles from './strategy.module.scss'; import styles from './strategy.module.scss';
@ -18,61 +19,35 @@ export default class StrategyConfigureComponent extends React.Component {
index: PropTypes.number.isRequired, index: PropTypes.number.isRequired,
strategyDefinition: PropTypes.object, strategyDefinition: PropTypes.object,
updateStrategy: PropTypes.func, updateStrategy: PropTypes.func,
saveStrategy: PropTypes.func,
removeStrategy: PropTypes.func, removeStrategy: PropTypes.func,
moveStrategy: PropTypes.func, moveStrategy: PropTypes.func,
isDragging: PropTypes.bool.isRequired, isDragging: PropTypes.bool.isRequired,
hovered: PropTypes.bool, hovered: PropTypes.bool,
movable: PropTypes.bool,
connectDragPreview: PropTypes.func.isRequired, connectDragPreview: PropTypes.func.isRequired,
connectDragSource: PropTypes.func.isRequired, connectDragSource: PropTypes.func.isRequired,
connectDropTarget: PropTypes.func.isRequired, connectDropTarget: PropTypes.func.isRequired,
editable: PropTypes.bool, editable: PropTypes.bool,
}; };
constructor(props) {
super();
this.state = {
constraints: props.strategy.constraints ? [...props.strategy.constraints] : [],
parameters: { ...props.strategy.parameters },
edit: false,
dirty: false,
index: props.index,
};
}
updateParameters = parameters => { updateParameters = parameters => {
const { constraints } = this.state; const { strategy } = this.props;
const updatedStrategy = Object.assign({}, this.props.strategy, { const updatedStrategy = { ...strategy, parameters };
parameters,
constraints,
});
this.props.updateStrategy(updatedStrategy); this.props.updateStrategy(updatedStrategy);
}; };
updateConstraints = constraints => { updateConstraints = constraints => {
this.setState({ constraints, dirty: true }); const { strategy } = this.props;
const updatedStrategy = { ...strategy, constraints };
this.props.updateStrategy(updatedStrategy);
}; };
updateParameter = async (field, value, forceUp = false) => { updateParameter = async (field, value) => {
const { parameters } = this.state; const { strategy } = this.props;
const parameters = { ...strategy.parameters };
parameters[field] = value; parameters[field] = value;
if (forceUp) {
await this.updateParameters(parameters);
this.setState({ parameters, dirty: false });
} else {
this.setState({ parameters, dirty: true });
}
};
onSave = evt => {
evt.preventDefault();
const { parameters } = this.state;
this.updateParameters(parameters); this.updateParameters(parameters);
this.setState({ edit: false, dirty: false });
};
handleRemove = evt => {
evt.preventDefault();
this.props.removeStrategy();
}; };
resolveInputType() { resolveInputType() {
@ -81,6 +56,8 @@ export default class StrategyConfigureComponent extends React.Component {
return UnknownStrategy; return UnknownStrategy;
} }
switch (strategyDefinition.name) { switch (strategyDefinition.name) {
case 'Loading':
return LoadingStrategy;
case 'default': case 'default':
return DefaultStrategy; return DefaultStrategy;
case 'flexibleRollout': case 'flexibleRollout':
@ -93,7 +70,6 @@ export default class StrategyConfigureComponent extends React.Component {
} }
render() { render() {
const { dirty, parameters } = this.state;
const { const {
isDragging, isDragging,
hovered, hovered,
@ -104,11 +80,14 @@ export default class StrategyConfigureComponent extends React.Component {
strategyDefinition, strategyDefinition,
strategy, strategy,
index, index,
removeStrategy,
saveStrategy,
movable,
} = this.props; } = this.props;
const { name } = strategy; const { name, dirty, parameters } = strategy;
const description = strategyDefinition ? strategyDefinition.description : 'Uknown'; const description = strategyDefinition ? strategyDefinition.description : 'Unknown';
const InputType = this.resolveInputType(name); const InputType = this.resolveInputType(name);
const cardClasses = [styles.card]; const cardClasses = [styles.card];
@ -143,7 +122,7 @@ export default class StrategyConfigureComponent extends React.Component {
editable={editable} editable={editable}
/> />
<Button <Button
onClick={this.onSave} onClick={saveStrategy}
accent accent
raised raised
ripple ripple
@ -164,17 +143,23 @@ export default class StrategyConfigureComponent extends React.Component {
</Link> </Link>
{editable && ( {editable && (
<IconButton <IconButton
title="Remove strategy from toggle" title="Remove this activation strategy"
name="delete" name="delete"
onClick={this.handleRemove} onClick={removeStrategy}
/> />
)} )}
{editable && {editable &&
movable &&
connectDragSource( connectDragSource(
<span className={styles.reorderIcon}> <span className={styles.reorderIcon}>
<Icon name="reorder" /> <Icon name="reorder" />
</span> </span>
)} )}
{editable && !movable && (
<span className={[styles.reorderIcon, styles.disabled].join(' ')}>
<Icon name="reorder" title="You can not reorder while editing." />
</span>
)}
</CardMenu> </CardMenu>
</Card> </Card>
</div> </div>

View File

@ -2,6 +2,7 @@ import PropTypes from 'prop-types';
export default { export default {
strategyDefinition: PropTypes.shape({ strategyDefinition: PropTypes.shape({
name: PropTypes.string,
parameters: PropTypes.array, parameters: PropTypes.array,
}).isRequired, }).isRequired,
parameters: PropTypes.object.isRequired, parameters: PropTypes.object.isRequired,

View File

@ -1,7 +1,7 @@
.item { .item {
flex: 1; flex: 1;
min-width: 400px; min-width: 400px;
max-width: 100%; max-width: 537px;
margin: 5px 0 5px 5px; margin: 5px 0 5px 5px;
position: relative; position: relative;
z-index: 1; z-index: 1;
@ -85,15 +85,25 @@
cursor: pointer; cursor: pointer;
display: inline-block; display: inline-block;
vertical-align: bottom; vertical-align: bottom;
&.disabled {
cursor: default;
color: silver;
}
} }
.strategyList {
.paddingDesktop { display: flex;
flex-wrap: wrap;
padding: 10px; padding: 10px;
} }
.strategyListAdd {
background-color: #f9f9f9;
}
@media (max-width: 500px) { @media (max-width: 500px) {
.paddingDesktop { .strategyList {
padding: 0; padding: 0;
} }
} }

View File

@ -1,9 +1,9 @@
import React from 'react'; import React from 'react';
import strategyInputProps from './strategy-input-props'; import PropTypes from 'prop-types';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
export default function UknownStrategy({ strategy }) { export default function UnknownStrategy({ strategy }) {
const { name } = strategy; const { name } = strategy;
return ( return (
<div> <div>
@ -13,4 +13,8 @@ export default function UknownStrategy({ strategy }) {
); );
} }
UknownStrategy.propTypes = strategyInputProps; UnknownStrategy.propTypes = {
strategy: PropTypes.shape({
name: PropTypes.string,
}).isRequired,
};

View File

@ -1,3 +1,27 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`render the create feature page 1`] = `""`; exports[`render the create feature page 1`] = `
<section>
<Connect(StrategiesList)
addStrategy={[MockFunction]}
configuredStrategies={
Array [
Object {
"name": "default",
},
]
}
editable={true}
featureToggleName="some-toggle"
initCallRequired={false}
moveStrategy={[MockFunction]}
onCancel={[MockFunction]}
onSubmit={[MockFunction]}
removeStrategy={[MockFunction]}
setValue={[MockFunction]}
title="title"
updateStrategy={[MockFunction]}
validateName={[MockFunction]}
/>
</section>
`;

View File

@ -1,19 +1,14 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import StrategiesList from '../strategy/strategies-list'; import StrategiesList from '../strategy/strategies-list-container';
import AddStrategy from '../strategy/strategies-add';
import { HeaderTitle } from '../../common';
import styles from '../strategy/strategy.module.scss';
// TODO: do we still need this wrapper?
function UpdateStrategiesComponent(props) { function UpdateStrategiesComponent(props) {
const { editable, configuredStrategies, strategies } = props; const { configuredStrategies } = props;
if (!configuredStrategies || configuredStrategies.length === 0) return null; if (!configuredStrategies || configuredStrategies.length === 0) return null;
if (!strategies || strategies.length === 0) return null;
return ( return (
<section className={styles.paddingDesktop}> <section>
{editable && <HeaderTitle title="Activation strategies" actions={<AddStrategy {...props} />} />}
<StrategiesList {...props} /> <StrategiesList {...props} />
</section> </section>
); );
@ -23,10 +18,6 @@ UpdateStrategiesComponent.propTypes = {
featureToggleName: PropTypes.string.isRequired, featureToggleName: PropTypes.string.isRequired,
strategies: PropTypes.array, strategies: PropTypes.array,
configuredStrategies: PropTypes.array.isRequired, configuredStrategies: PropTypes.array.isRequired,
addStrategy: PropTypes.func.isRequired,
removeStrategy: PropTypes.func.isRequired,
moveStrategy: PropTypes.func.isRequired,
updateStrategy: PropTypes.func.isRequired,
editable: PropTypes.bool, editable: PropTypes.bool,
}; };

View File

@ -1,6 +1,5 @@
/* eslint-disable no-console */ /* eslint-disable no-console */
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import arrayMove from 'array-move';
import { requestUpdateFeatureToggleStrategies } from '../../../store/feature-toggle/actions'; import { requestUpdateFeatureToggleStrategies } from '../../../store/feature-toggle/actions';
import UpdateStrategiesComponent from './update-strategies-component'; import UpdateStrategiesComponent from './update-strategies-component';
@ -8,39 +7,11 @@ import UpdateStrategiesComponent from './update-strategies-component';
const mapStateToProps = (state, ownProps) => ({ const mapStateToProps = (state, ownProps) => ({
featureToggleName: ownProps.featureToggle.name, featureToggleName: ownProps.featureToggle.name,
configuredStrategies: ownProps.featureToggle.strategies, configuredStrategies: ownProps.featureToggle.strategies,
strategies: state.strategies.get('list').toArray(),
}); });
const mapDispatchToProps = (dispatch, ownProps) => ({ const mapDispatchToProps = (dispatch, ownProps) => ({
addStrategy: s => { saveStrategies: strategies => {
console.log(`add ${s}`);
const featureToggle = ownProps.featureToggle; const featureToggle = ownProps.featureToggle;
const strategies = featureToggle.strategies.concat(s);
return requestUpdateFeatureToggleStrategies(featureToggle, strategies)(dispatch);
},
removeStrategy: index => {
console.log(`remove ${index}`);
const featureToggle = ownProps.featureToggle;
const strategies = featureToggle.strategies.filter((_, i) => i !== index);
return requestUpdateFeatureToggleStrategies(featureToggle, strategies)(dispatch);
},
moveStrategy: (index, toIndex) => {
// methods.moveItem('strategies', index, toIndex);
console.log(`move strategy from ${index} to ${toIndex}`);
console.log(ownProps.featureToggle);
const featureToggle = ownProps.featureToggle;
const strategies = arrayMove(featureToggle.strategies, index, toIndex);
return requestUpdateFeatureToggleStrategies(featureToggle, strategies)(dispatch);
},
updateStrategy: (index, s) => {
// methods.updateInList('strategies', index, n);
console.log(`update strtegy at index ${index} with ${JSON.stringify(s)}`);
const featureToggle = ownProps.featureToggle;
const strategies = featureToggle.strategies.concat();
strategies[index] = s;
return requestUpdateFeatureToggleStrategies(featureToggle, strategies)(dispatch); return requestUpdateFeatureToggleStrategies(featureToggle, strategies)(dispatch);
}, },
}); });

View File

@ -13,10 +13,10 @@ exports[`renders correctly with one strategy 1`] = `
<div <div
style={ style={
Object { Object {
"borderBottom": "1px solid #f1f1f1", "borderBottom": "1px solid #f9f9f9",
"display": "flex", "display": "flex",
"marginBottom": "10px", "marginBottom": "10px",
"padding": "16px 20px ", "padding": "16px",
} }
} }
> >
@ -104,10 +104,10 @@ exports[`renders correctly with one strategy without permissions 1`] = `
<div <div
style={ style={
Object { Object {
"borderBottom": "1px solid #f1f1f1", "borderBottom": "1px solid #f9f9f9",
"display": "flex", "display": "flex",
"marginBottom": "10px", "marginBottom": "10px",
"padding": "16px 20px ", "padding": "16px",
} }
} }
> >

View File

@ -10,10 +10,10 @@ exports[`renders correctly with one strategy 1`] = `
<div <div
style={ style={
Object { Object {
"borderBottom": "1px solid #f1f1f1", "borderBottom": "1px solid #f9f9f9",
"display": "flex", "display": "flex",
"marginBottom": "10px", "marginBottom": "10px",
"padding": "16px 20px ", "padding": "16px",
} }
} }
> >

View File

@ -13,10 +13,10 @@ exports[`renders a list with elements correctly 1`] = `
<div <div
style={ style={
Object { Object {
"borderBottom": "1px solid #f1f1f1", "borderBottom": "1px solid #f9f9f9",
"display": "flex", "display": "flex",
"marginBottom": "10px", "marginBottom": "10px",
"padding": "16px 20px ", "padding": "16px",
} }
} }
> >
@ -92,10 +92,10 @@ exports[`renders an empty list correctly 1`] = `
<div <div
style={ style={
Object { Object {
"borderBottom": "1px solid #f1f1f1", "borderBottom": "1px solid #f9f9f9",
"display": "flex", "display": "flex",
"marginBottom": "10px", "marginBottom": "10px",
"padding": "16px 20px ", "padding": "16px",
} }
} }
> >

View File

@ -6533,7 +6533,7 @@ lodash@^4.0.0, lodash@^4.15.0, lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.14
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.19.tgz#e48ddedbe30b3321783c5b4301fbd353bc1e4a4b" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.19.tgz#e48ddedbe30b3321783c5b4301fbd353bc1e4a4b"
integrity sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ== integrity sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ==
lodash@^4.17.19: lodash@^4.17.19, lodash@^4.17.20:
version "4.17.20" version "4.17.20"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52"
integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==