diff --git a/frontend/src/__mocks__/react-mdl.js b/frontend/src/__mocks__/react-mdl.js index fc109db7a9..e525a380e7 100644 --- a/frontend/src/__mocks__/react-mdl.js +++ b/frontend/src/__mocks__/react-mdl.js @@ -29,4 +29,9 @@ module.exports = { FooterDropDownSection: 'react-mdl-FooterDropDownSection', FooterSection: 'react-mdl-FooterSection', FooterLinkList: 'react-mdl-FooterLinkList', + Tooltip: 'react-mdl-Tooltip', + Dialog: 'react-mdl-Dialog', + DialogTitle: 'react-mdl-DialogTitle', + DialogContent: 'react-mdl-DialogContent', + DialogActions: 'react-mdl-DialogActions', }; diff --git a/frontend/src/component/common/util.js b/frontend/src/component/common/util.js index 429e267869..872c4f0c6f 100644 --- a/frontend/src/component/common/util.js +++ b/frontend/src/component/common/util.js @@ -20,3 +20,13 @@ export const trim = value => { return value; } }; + +export function updateWeight(variants, totalWeight) { + const size = variants.length; + const percentage = parseInt((1 / size) * totalWeight); + + variants.forEach(v => { + v.weight = percentage; + }); + return variants; +} diff --git a/frontend/src/component/feature/variant/__tests__/__snapshots__/update-variant-component-test.jsx.snap b/frontend/src/component/feature/variant/__tests__/__snapshots__/update-variant-component-test.jsx.snap index bc47c361fb..bc610fa8d0 100644 --- a/frontend/src/component/feature/variant/__tests__/__snapshots__/update-variant-component-test.jsx.snap +++ b/frontend/src/component/feature/variant/__tests__/__snapshots__/update-variant-component-test.jsx.snap @@ -4,6 +4,7 @@ exports[`renders correctly with with variants 1`] = `
getVariant() - method in the client SDK. + method in the Client SDK.

-

- The sum of variants weights needs to be a constant number to guarantee consistent hashing in the client implementations, this is why we will sometime allocate a few more percentages to the first variant if the sum is not exactly 100. In a final version of this feature it should be possible to the user to manually set the percentages for each variant. -

+ + + + Variant name + + + + Weight + + + + + + + + blue + + + + + Overrides + + + + 3.4 + % + + + + + + + + + yellow + + + + + + 3.3 + % + + + + + + + + + orange + + + + Payload + + + + + 3.3 + % + + + + + + + + +

-
- + - - - - - - - - - - - - - - - - - - - - - - - -
- Name - - Weight - -
- blue - - 34 - - - -
- yellow - - 33 - - - -
- orange - - 33 - - - -
-
-
- + - -     - Save - -   - - Cancel - -
+ +
+
+ + Passed to the variant object. +
+ Can be anything (json, value, csv) + + } + > +

+ + Payload + + +

+
+ + +
+ + +
+
+ + + +
+
+ + Add override + + + + + + Save + + + Cancel + + +
`; @@ -158,6 +320,7 @@ exports[`renders correctly with without variants 1`] = `
getVariant() - method in the client SDK. + method in the Client SDK.

-

- The sum of variants weights needs to be a constant number to guarantee consistent hashing in the client implementations, this is why we will sometime allocate a few more percentages to the first variant if the sum is not exactly 100. In a final version of this feature it should be possible to the user to manually set the percentages for each variant. -

+ + + + Variant name + + + + Weight + + + + + + +

-
-

-
-

- + + + - -     - Save - -   - - Cancel - -
+ +
+
+ + Passed to the variant object. +
+ Can be anything (json, value, csv) + + } + > +

+ + Payload + + +

+
+ + +
+ + +
+
+ + + +
+
+ + Add override + + + + + + Save + + + Cancel + + +
`; @@ -228,6 +539,7 @@ exports[`renders correctly with without variants and no permissions 1`] = `
getVariant() - method in the client SDK. + method in the Client SDK.

-

- The sum of variants weights needs to be a constant number to guarantee consistent hashing in the client implementations, this is why we will sometime allocate a few more percentages to the first variant if the sum is not exactly 100. In a final version of this feature it should be possible to the user to manually set the percentages for each variant. -

-
-

-
+ + + + Variant name + + + + Weight + + + + + + +
+ + + + +

+ +
+
+ + Passed to the variant object. +
+ Can be anything (json, value, csv) + + } + > +

+ + Payload + + +

+ + + +
+ + +
+
+ + + +
+ + + Add override + + + + + + Save + + + Cancel + + +
`; diff --git a/frontend/src/component/feature/variant/__tests__/update-variant-component-test.jsx b/frontend/src/component/feature/variant/__tests__/update-variant-component-test.jsx index ab242df171..cad73143e1 100644 --- a/frontend/src/component/feature/variant/__tests__/update-variant-component-test.jsx +++ b/frontend/src/component/feature/variant/__tests__/update-variant-component-test.jsx @@ -8,34 +8,14 @@ import { UPDATE_FEATURE } from '../../../../permissions'; jest.mock('react-mdl'); test('renders correctly with without variants', () => { - const featureToggle = { - name: 'Another', - description: "another's description", - enabled: false, - strategies: [ - { - name: 'gradualRolloutRandom', - parameters: { - percentage: 50, - }, - }, - ], - createdAt: '2018-02-04T20:27:52.127Z', - }; const tree = renderer.create( permission === UPDATE_FEATURE} /> @@ -45,34 +25,14 @@ test('renders correctly with without variants', () => { }); test('renders correctly with without variants and no permissions', () => { - const featureToggle = { - name: 'Another', - description: "another's description", - enabled: false, - strategies: [ - { - name: 'gradualRolloutRandom', - parameters: { - percentage: 50, - }, - }, - ], - createdAt: '2018-02-04T20:27:52.127Z', - }; const tree = renderer.create( false} /> @@ -124,16 +84,10 @@ test('renders correctly with with variants', () => { permission === UPDATE_FEATURE} /> diff --git a/frontend/src/component/feature/variant/add-variant.jsx b/frontend/src/component/feature/variant/add-variant.jsx new file mode 100644 index 0000000000..21da1973da --- /dev/null +++ b/frontend/src/component/feature/variant/add-variant.jsx @@ -0,0 +1,242 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { + Button, + Textfield, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Grid, + Cell, + Tooltip, + Icon, +} from 'react-mdl'; +import MySelect from '../form/select'; +import { trim } from '../form/util'; +import styles from './variant.scss'; +import OverrideConfig from './override-config'; + +const payloadOptions = [ + { key: 'string', label: 'string' }, + { key: 'json', label: 'json' }, + { key: 'csv', label: 'csv' }, +]; + +const EMPTY_PAYLOAD = { type: 'string', value: '' }; + +function AddVariant({ showDialog, closeDialog, save, validateName, editVariant, title }) { + const [data, setData] = useState({}); + const [payload, setPayload] = useState(EMPTY_PAYLOAD); + const [overrides, setOverrides] = useState([]); + const [error, setError] = useState({}); + + const clear = () => { + if (editVariant) { + setData({ name: editVariant.name }); + if (editVariant.payload) { + setPayload(editVariant.payload); + } + if (editVariant.overrides) { + setOverrides( + editVariant.overrides.map(o => ({ contextName: o.contextName, values: o.values.join(', ') })) + ); + } else { + setOverrides([]); + } + } else { + setData({}); + setPayload(EMPTY_PAYLOAD); + setOverrides([]); + } + setError({}); + }; + + useEffect(() => { + clear(); + }, [editVariant]); + + const setName = e => { + setData({ + ...data, + [e.target.name]: trim(e.target.value), + }); + }; + + const submit = async e => { + e.preventDefault(); + + const validationError = validateName(data.name); + + if (validationError) { + setError(validationError); + return; + } + + try { + const variant = { + name: data.name, + payload: payload.value ? payload : undefined, + overrides: overrides + .map(o => ({ + contextName: o.contextName, + values: o.values + .split(',') + .map(v => v.trim()) + .filter(v => v), + })) + .filter(o => o.values && o.values.length > 0), + }; + await save(variant); + clear(); + closeDialog(); + } catch (error) { + const msg = error.error || 'Could not add variant'; + setError({ general: msg }); + } + }; + + const onPayload = e => { + e.preventDefault(); + setPayload({ + ...payload, + [e.target.name]: e.target.value, + }); + }; + + const onCancel = e => { + e.preventDefault(); + clear(); + closeDialog(); + }; + + const updateOverrideOption = index => e => { + e.preventDefault(); + setOverrides( + overrides.map((o, i) => { + if (i === index) { + o[e.target.name] = e.target.value; + } + return o; + }) + ); + }; + + const removeOverrideOption = index => e => { + e.preventDefault(); + setOverrides(overrides.filter((o, i) => i !== index)); + }; + + const onAddOverride = e => { + e.preventDefault(); + setOverrides([...overrides, ...[{ contextName: 'userId', values: '' }]]); + }; + + return ( +
+ + {title} + + +

{error.general}

+ +
+
+ + Passed to the variant object.
+ Can be anything (json, value, csv) + + } + > +

+ Payload + +

+
+ + + + + + + + + {overrides.length > 0 ? ( + + Here you can specify which users that
+ should get this variant. + + } + > +

+ Overrides + +

+
+ ) : ( + undefined + )} + + + + Add override + +
+ + + + +
+
+ ); +} + +AddVariant.propTypes = { + showDialog: PropTypes.bool.isRequired, + closeDialog: PropTypes.func.isRequired, + save: PropTypes.func.isRequired, + validateName: PropTypes.func.isRequired, + editVariant: PropTypes.object, + title: PropTypes.string, +}; + +export default AddVariant; diff --git a/frontend/src/component/feature/variant/override-config.jsx b/frontend/src/component/feature/variant/override-config.jsx new file mode 100644 index 0000000000..91d45c9c8a --- /dev/null +++ b/frontend/src/component/feature/variant/override-config.jsx @@ -0,0 +1,47 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Textfield, Grid, Cell, IconButton } from 'react-mdl'; +import MySelect from '../form/select'; + +const overrideOptions = [ + { key: 'userId', label: 'userId' }, + { key: 'appName', label: 'appName' }, +]; + +function OverrideConfig({ overrides, updateOverrideOption, removeOverrideOption }) { + return overrides.map((o, i) => ( + + + + + + + + + + + + )); +} + +OverrideConfig.propTypes = { + overrides: PropTypes.array.isRequired, + updateOverrideOption: PropTypes.func.isRequired, + removeOverrideOption: PropTypes.func.isRequired, +}; + +export default OverrideConfig; diff --git a/frontend/src/component/feature/variant/update-variant-component.jsx b/frontend/src/component/feature/variant/update-variant-component.jsx index 3b61e04ea1..a777a58ed6 100644 --- a/frontend/src/component/feature/variant/update-variant-component.jsx +++ b/frontend/src/component/feature/variant/update-variant-component.jsx @@ -1,169 +1,120 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; -import { FormButtons } from '../../common'; import VariantViewComponent from './variant-view-component'; -import VariantEditComponent from './variant-edit-component'; import styles from './variant.scss'; import { UPDATE_FEATURE } from '../../../permissions'; +import AddVariant from './add-variant'; + +const initalState = { showDialog: false, editVariant: undefined, editIndex: -1 }; class UpdateVariantComponent extends Component { constructor(props) { super(props); + this.state = { ...initalState }; } - // eslint-disable-next-line camelcase - UNSAFE_componentWillMount() { - // TODO unwind this stuff - if (this.props.initCallRequired === true) { - this.props.init(this.props.input); - } - } - - updateWeight(newWeight, newSize) { - const variants = this.props.input.variants || []; - variants.forEach((v, i) => { - v.weight = newWeight; - this.props.updateVariant(i, v); - }); - - // Make sure the sum of weigths is 100. - const sum = newWeight * newSize; - if (sum !== 100) { - const first = variants[0]; - first.weight = 100 - sum + first.weight; - this.props.updateVariant(0, first); - } - } - - addVariant = (e, variants) => { - e.preventDefault(); - const size = variants.length + 1; - const percentage = parseInt((1 / size) * 100); - const variant = { - name: '', - weight: percentage, - edit: true, - }; - - this.updateWeight(percentage, size); - this.props.addVariant(variant); + closeDialog = () => { + this.setState({ ...initalState }); }; - removeVariant = (e, index) => { + openAddVariant = e => { + e.preventDefault(); + this.setState({ + showDialog: true, + editVariant: undefined, + editIndex: undefined, + title: 'Add variant', + }); + }; + + openEditVariant = (e, index, variant) => { + e.preventDefault(); + if (this.props.hasPermission(UPDATE_FEATURE)) { + this.setState({ + showDialog: true, + editVariant: variant, + editIndex: index, + title: 'Edit variant', + }); + } + }; + + validateName = name => { + if (!name) { + return { name: 'Name is required' }; + } + }; + + onRemoveVariant = (e, index) => { e.preventDefault(); - const variants = this.props.input.variants; - const size = variants.length - 1; - const percentage = parseInt((1 / size) * 100); - this.updateWeight(percentage, size); this.props.removeVariant(index); }; - editVariant = (e, index, v) => { - e.preventDefault(); - if (this.props.hasPermission(UPDATE_FEATURE)) { - v.edit = true; - this.props.updateVariant(index, v); - } - }; + renderVariant = (variant, index) => ( + this.openEditVariant(e, index, variant)} + removeVariant={e => this.onRemoveVariant(e, index)} + hasPermission={this.props.hasPermission} + /> + ); - closeVariant = (e, index, v) => { - e.preventDefault(); - v.edit = false; - this.props.updateVariant(index, v); - }; - - updateVariant = (index, newVariant) => { - this.props.updateVariant(index, newVariant); - }; - - renderVariant = (variant, index) => - variant.edit ? ( - this.removeVariant(e, index)} - closeVariant={e => this.closeVariant(e, index, variant)} - updateVariant={this.updateVariant.bind(this, index)} - /> - ) : ( - this.editVariant(e, index, variant)} - removeVariant={e => this.removeVariant(e, index)} - hasPermission={this.props.hasPermission} - /> - ); - - renderVariants = variants => { - if (variants.length > 0) { - return ( - - - - - - - - {variants.map(this.renderVariant)} -
NameWeight -
- ); - } else { - return

; - } - }; + renderVariants = variants => ( + + + + + + + + {variants.map(this.renderVariant)} +
Variant name + Weight +
+ ); render() { - const { onSubmit, onCancel, input, features } = this.props; - const variants = input.variants || []; + const { showDialog, editVariant, editIndex, title } = this.state; + const { variants, addVariant, updateVariant } = this.props; + const saveVariant = editVariant ? updateVariant.bind(null, editIndex) : addVariant; return ( -

+

Variants allows you to return a variant object if the feature toggle is considered enabled for the current request. When using variants you should use the{' '} - getVariant() method in the client SDK. -

-

- The sum of variants weights needs to be a constant number to guarantee consistent hashing in the - client implementations, this is why we will sometime allocate a few more percentages to the first - variant if the sum is not exactly 100. In a final version of this feature it should be possible to - the user to manually set the percentages for each variant. + getVariant() method in the Client SDK.

+ {this.renderVariants(variants)} +
{this.props.hasPermission(UPDATE_FEATURE) ? (

- this.addVariant(e, variants)}> + Add variant

) : null} - -
- {this.renderVariants(variants)} -
- {this.props.hasPermission(UPDATE_FEATURE) ? ( - - ) : null} - +
); } } UpdateVariantComponent.propTypes = { - input: PropTypes.object, - features: PropTypes.array, - setValue: PropTypes.func.isRequired, + variants: PropTypes.array.isRequired, addVariant: PropTypes.func.isRequired, removeVariant: PropTypes.func.isRequired, updateVariant: PropTypes.func.isRequired, - onSubmit: PropTypes.func.isRequired, - onCancel: PropTypes.func.isRequired, - initCallRequired: PropTypes.bool, - init: PropTypes.func, hasPermission: PropTypes.func.isRequired, }; diff --git a/frontend/src/component/feature/variant/update-variant-container.jsx b/frontend/src/component/feature/variant/update-variant-container.jsx index 14733bc012..9a584458a3 100644 --- a/frontend/src/component/feature/variant/update-variant-container.jsx +++ b/frontend/src/component/feature/variant/update-variant-container.jsx @@ -1,67 +1,36 @@ import { connect } from 'react-redux'; import { requestUpdateFeatureToggleVariants } from '../../../store/feature-actions'; -import { createMapper, createActions } from '../../input-helpers'; import UpdateFeatureToggleComponent from './update-variant-component'; +import { updateWeight } from '../../common/util'; -const ID = 'edit-toggle-variants'; -function getId(props) { - return [ID, props.featureToggle.name]; -} -// TODO: need to scope to the active featureToggle -// best is to emulate the "input-storage"? -const mapStateToProps = createMapper({ - id: getId, - getDefault: (state, ownProps) => ownProps.featureToggle, - prepare: props => { - props.editmode = true; - return props; +const mapStateToProps = (state, ownProps) => ({ + variants: ownProps.featureToggle.variants || [], + hasPermission: ownProps.hasPermission, +}); + +const mapDispatchToProps = (dispatch, ownProps) => ({ + addVariant: variant => { + const { featureToggle } = ownProps; + const currentVariants = featureToggle.variants || []; + const variants = [...currentVariants, variant]; + updateWeight(variants, 1000); + return requestUpdateFeatureToggleVariants(featureToggle, variants)(dispatch); + }, + removeVariant: index => { + const { featureToggle } = ownProps; + const currentVariants = featureToggle.variants || []; + const variants = currentVariants.filter((v, i) => i !== index); + updateWeight(variants, 1000); + requestUpdateFeatureToggleVariants(featureToggle, variants)(dispatch); + }, + updateVariant: (index, variant) => { + const { featureToggle } = ownProps; + const currentVariants = featureToggle.variants || []; + const variants = currentVariants.map((v, i) => (i === index ? variant : v)); + updateWeight(variants, 1000); + requestUpdateFeatureToggleVariants(featureToggle, variants)(dispatch); }, }); -const prepare = (methods, dispatch, ownProps) => { - methods.onSubmit = (input, features) => e => { - e.preventDefault(); - - const featureToggle = features.find(f => f.name === input.name); - - // Kind of a hack - featureToggle.strategies.forEach(s => (s.id = undefined)); - - const variants = input.variants.map(v => { - delete v.edit; - return v; - }); - - requestUpdateFeatureToggleVariants(featureToggle, variants)(dispatch); - variants.forEach((v, i) => methods.updateInList('variants', i, v)); - }; - - methods.onCancel = evt => { - evt.preventDefault(); - ownProps.history.push(`/features/view/${ownProps.featureToggle.name}`); - }; - - methods.addVariant = v => { - methods.pushToList('variants', v); - }; - - methods.removeVariant = index => { - methods.removeFromList('variants', index); - }; - - methods.updateVariant = (index, n) => { - methods.updateInList('variants', index, n); - }; - - methods.validateName = () => {}; - - return methods; -}; - -const actions = createActions({ - id: getId, - prepare, -}); - -export default connect(mapStateToProps, actions)(UpdateFeatureToggleComponent); +export default connect(mapStateToProps, mapDispatchToProps)(UpdateFeatureToggleComponent); diff --git a/frontend/src/component/feature/variant/variant-edit-component.jsx b/frontend/src/component/feature/variant/variant-edit-component.jsx deleted file mode 100644 index d6925e1fa5..0000000000 --- a/frontend/src/component/feature/variant/variant-edit-component.jsx +++ /dev/null @@ -1,162 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; - -import { IconButton, Cell, Grid, Textfield, Tooltip, Icon } from 'react-mdl'; -import styles from './variant.scss'; - -class VariantEditComponent extends Component { - constructor(props) { - super(props); - } - - componentDidMount() { - this.refs.name.inputRef.focus(); - } - - getUserIdOverrides(variant) { - const overrides = variant.overrides || []; - const userIdOverrides = overrides.find(o => o.contextName === 'userId') || { values: [] }; - return userIdOverrides.values.join(', '); - } - - toggleEditMode = e => { - e.preventDefault(); - this.setState({ - editmode: !this.state.editmode, - }); - }; - - updateToggleName = e => { - e.preventDefault(); - const variant = this.props.variant; - variant.name = e.target.value; - this.props.updateVariant(variant); - }; - - updatePayload = e => { - e.preventDefault(); - const variant = this.props.variant; - variant.payload = { - type: 'string', - value: e.target.value, - }; - this.props.updateVariant(variant); - }; - - updateOverrides = (contextName, e) => { - e.preventDefault(); - const values = e.target.value.split(',').map(v => v.trim()); - const variant = this.props.variant; - - // Clean empty string. (should be moved to action) - if (values.length === 1 && !values[0]) { - variant.overrides = undefined; - } else { - variant.overrides = [{ contextName, values }]; - } - this.props.updateVariant(variant); - }; - - render() { - const { variant, closeVariant, removeVariant } = this.props; - const payload = variant.payload ? variant.payload.value : ''; - const userIdOverrides = this.getUserIdOverrides(variant); - - return ( - - - - - - - - - - - - - - - Passed to the variant object.
- Can be anything (json, value, csv) - - } - > - -
-
-
- - - - - - - Here you can specify which users that
- should get this variant. - - } - > - -
-
-
- - Close - - - - {}} - /> - - - - - - - ); - } -} - -VariantEditComponent.propTypes = { - variant: PropTypes.object, - removeVariant: PropTypes.func, - updateVariant: PropTypes.func, - closeVariant: PropTypes.func, -}; - -export default VariantEditComponent; diff --git a/frontend/src/component/feature/variant/variant-view-component.jsx b/frontend/src/component/feature/variant/variant-view-component.jsx index 2d0ad8b0e1..8206261ab7 100644 --- a/frontend/src/component/feature/variant/variant-view-component.jsx +++ b/frontend/src/component/feature/variant/variant-view-component.jsx @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { IconButton } from 'react-mdl'; +import { IconButton, Chip } from 'react-mdl'; import styles from './variant.scss'; import { UPDATE_FEATURE } from '../../../permissions'; @@ -9,10 +9,18 @@ function VariantViewComponent({ variant, editVariant, removeVariant, hasPermissi return ( {variant.name} - {variant.weight} + + {variant.payload ? Payload : undefined}{' '} + {variant.overrides && variant.overrides.length > 0 ? ( + Overrides + ) : ( + undefined + )} + + {variant.weight / 10.0} % {hasPermission(UPDATE_FEATURE) ? ( - + ) : ( diff --git a/frontend/src/component/feature/variant/variant.scss b/frontend/src/component/feature/variant/variant.scss index 720301c34e..9097083a92 100644 --- a/frontend/src/component/feature/variant/variant.scss +++ b/frontend/src/component/feature/variant/variant.scss @@ -1,5 +1,6 @@ .variantTable { width: 100%; + max-width: 700px; th, td { text-align: center; @@ -14,6 +15,24 @@ } } +@media (max-width: 600px) { + th.labels { + display: none; + } + td.labels { + display: none; + } +} + +th.labels { + text-align: right; +} + +td.labels { + text-align: right; + vertical-align: top; +} + th.actions { text-align: right; } @@ -22,3 +41,21 @@ td.actions { text-align: right; vertical-align: top; } + +.modal { + max-width: 90%; + width: 600px; + position: absolute !important; +} + +@media (max-width: 600px) { + .modal { + top: 0 !important; + } +} + +.tooltip { + i { + font-size: 18px; + } +} \ No newline at end of file