Commit 4e6cc417 authored by Nathan Friend's avatar Nathan Friend Committed by Paul Slaughter

Add percentage rollout support to feature flag UI (EE)

This commit updates the feature flags pages to includes support for the
percentage rollout strategy.
parent 0e84283b
...@@ -435,6 +435,7 @@ img.emoji { ...@@ -435,6 +435,7 @@ img.emoji {
/** COMMON SIZING CLASSES **/ /** COMMON SIZING CLASSES **/
.w-0 { width: 0; } .w-0 { width: 0; }
.w-8em { width: 8em; } .w-8em { width: 8em; }
.w-3rem { width: 3rem; }
.h-12em { height: 12em; } .h-12em { height: 12em; }
......
...@@ -3,6 +3,7 @@ import _ from 'underscore'; ...@@ -3,6 +3,7 @@ import _ from 'underscore';
import { GlButton, GlLink, GlTooltipDirective, GlModalDirective, GlModal } from '@gitlab/ui'; import { GlButton, GlLink, GlTooltipDirective, GlModalDirective, GlModal } from '@gitlab/ui';
import { sprintf, s__ } from '~/locale'; import { sprintf, s__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import { ROLLOUT_STRATEGY_PERCENT_ROLLOUT } from '../constants';
export default { export default {
components: { components: {
...@@ -61,12 +62,22 @@ export default { ...@@ -61,12 +62,22 @@ export default {
scopeTooltipText(scope) { scopeTooltipText(scope) {
return !scope.active return !scope.active
? sprintf(s__('FeatureFlags|Inactive flag for %{scope}'), { ? sprintf(s__('FeatureFlags|Inactive flag for %{scope}'), {
scope: scope.environment_scope, scope: scope.environmentScope,
}) })
: ''; : '';
}, },
scopeName(name) { badgeText(scope) {
return name === '*' ? s__('FeatureFlags|* (All environments)') : name; const displayName =
scope.environmentScope === '*'
? s__('FeatureFlags|* (All environments)')
: scope.environmentScope;
const displayPercentage =
scope.rolloutStrategy === ROLLOUT_STRATEGY_PERCENT_ROLLOUT
? `: ${scope.rolloutPercentage}%`
: '';
return `${displayName}${displayPercentage}`;
}, },
canDeleteFlag(flag) { canDeleteFlag(flag) {
return !this.permissions || (flag.scopes || []).every(scope => scope.can_update); return !this.permissions || (flag.scopes || []).every(scope => scope.can_update);
...@@ -131,8 +142,11 @@ export default { ...@@ -131,8 +142,11 @@ export default {
:key="scope.id" :key="scope.id"
v-gl-tooltip.hover="scopeTooltipText(scope)" v-gl-tooltip.hover="scopeTooltipText(scope)"
class="badge append-right-8 prepend-top-2" class="badge append-right-8 prepend-top-2"
:class="{ 'badge-active': scope.active, 'badge-inactive': !scope.active }" :class="{
>{{ scopeName(scope.environment_scope) }}</span 'badge-active': scope.active,
'badge-inactive': !scope.active,
}"
>{{ badgeText(scope) }}</span
> >
</div> </div>
</div> </div>
......
<script> <script>
import Vue from 'vue';
import _ from 'underscore'; import _ from 'underscore';
import { GlButton, GlBadge } from '@gitlab/ui'; import { GlButton, GlBadge, GlTooltip, GlTooltipDirective } from '@gitlab/ui';
import { s__, sprintf } from '~/locale'; import { s__, sprintf } from '~/locale';
import ToggleButton from '~/vue_shared/components/toggle_button.vue'; import ToggleButton from '~/vue_shared/components/toggle_button.vue';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import EnvironmentsDropdown from './environments_dropdown.vue'; import EnvironmentsDropdown from './environments_dropdown.vue';
import { internalKeyID } from '../store/modules/helpers'; import {
ROLLOUT_STRATEGY_ALL_USERS,
ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
ALL_ENVIRONMENTS_NAME,
INTERNAL_ID_PREFIX,
} from '../constants';
import { createNewEnvironmentScope } from '../store/modules/helpers';
export default { export default {
components: { components: {
GlButton, GlButton,
GlBadge, GlBadge,
GlTooltip,
ToggleButton, ToggleButton,
Icon, Icon,
EnvironmentsDropdown, EnvironmentsDropdown,
}, },
directives: {
GlTooltip: GlTooltipDirective,
},
props: { props: {
name: { name: {
type: String, type: String,
...@@ -44,15 +55,9 @@ export default { ...@@ -44,15 +55,9 @@ export default {
required: true, required: true,
}, },
}, },
data() {
return {
formName: this.name,
formDescription: this.description,
formScopes: this.scopes || [],
newScope: '', allEnvironmentsText: s__('FeatureFlags|* (All Environments)'),
};
},
helpText: sprintf( helpText: sprintf(
s__( s__(
'FeatureFlags|Feature Flag behavior is built up by creating a set of rules to define the status of target environments. A default wildcard rule %{codeStart}*%{codeEnd} for %{boldStart}All Environments%{boldEnd} is set, and you are able to add as many rules as you need by choosing environment specs below. You can toggle the behavior for each of your rules to set them %{boldStart}Active%{boldEnd} or %{boldStart}Inactive%{boldEnd}.', 'FeatureFlags|Feature Flag behavior is built up by creating a set of rules to define the status of target environments. A default wildcard rule %{codeStart}*%{codeEnd} for %{boldStart}All Environments%{boldEnd} is set, and you are able to add as many rules as you need by choosing environment specs below. You can toggle the behavior for each of your rules to set them %{boldStart}Active%{boldEnd} or %{boldStart}Inactive%{boldEnd}.',
...@@ -65,16 +70,30 @@ export default { ...@@ -65,16 +70,30 @@ export default {
}, },
false, false,
), ),
allEnvironments: s__('FeatureFlags|* (All Environments)'),
all: '*', ROLLOUT_STRATEGY_ALL_USERS,
ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
// Matches numbers 0 through 100
rolloutPercentageRegex: /^[0-9]$|^[1-9][0-9]$|^100$/,
data() {
return {
formName: this.name,
formDescription: this.description,
// operate on a clone to avoid mutating props
formScopes: this.scopes.map(s => ({ ...s })),
newScope: '',
};
},
computed: { computed: {
filteredScopes() { filteredScopes() {
// eslint-disable-next-line no-underscore-dangle return this.formScopes.filter(scope => !scope.shouldBeDestroyed);
return this.formScopes.filter(scope => !scope._destroy);
}, },
canUpdateFlag() { canUpdateFlag() {
return !this.permissionsFlag || (this.scopes || []).every(scope => scope.can_update); return !this.permissionsFlag || (this.formScopes || []).every(scope => scope.canUpdate);
}, },
permissionsFlag() { permissionsFlag() {
return gon && gon.features && gon.features.featureFlagPermissions; return gon && gon.features && gon.features.featureFlagPermissions;
...@@ -82,88 +101,39 @@ export default { ...@@ -82,88 +101,39 @@ export default {
}, },
methods: { methods: {
isAllEnvironment(name) { isAllEnvironment(name) {
return name === this.$options.all; return name === ALL_ENVIRONMENTS_NAME;
}, },
/**
* When the user updates the status of
* an existing scope we toggle the status for
* the `formScopes`
*
* @param {Object} scope
* @param {Number} index
* @param {Boolean} status
*/
onUpdateScopeStatus(scope, status) {
const index = this.formScopes.findIndex(el => el.id === scope.id);
this.formScopes.splice(index, 1, Object.assign({}, scope, { active: status }));
},
/**
* When the user selects or creates a new scope in the environemnts dropdoown
* we update the selected value.
*
* @param {String} name
* @param {Object} scope
* @param {Number} index
*/
updateScope(name, scope) {
const index = this.formScopes.findIndex(el => el.id === scope.id);
this.formScopes.splice(index, 1, Object.assign({}, scope, { environment_scope: name }));
},
/**
* When the user clicks the toggle button in the new row,
* we automatically add it as a new scope
*
* @param {Boolean} value the toggle value
*/
onChangeNewScopeStatus(value) {
const newScope = {
active: value,
environment_scope: this.newScope,
id: _.uniqueId(internalKeyID),
};
if (this.permissionsFlag) {
newScope.can_update = true;
newScope.protected = false;
}
this.formScopes.push(newScope);
this.newScope = '';
},
/** /**
* When the user clicks the remove button we delete the scope * When the user clicks the remove button we delete the scope
* *
* If the scope has an ID, we need to add the `_destroy` flag * If the scope has an ID, we need to add the `shouldBeDestroyed` flag.
* otherwise we can just remove it. * If the scope does *not* have an ID, we can just remove it.
* Backend needs the destroy flag only in the PUT request. *
* This flag will be used when submitting the data to the backend
* to determine which records to delete (via a "_destroy" property).
* *
* @param {Number} index
* @param {Object} scope * @param {Object} scope
*/ */
removeScope(scope) { removeScope(scope) {
const index = this.formScopes.findIndex(el => el.id === scope.id); if (_.isString(scope.id) && scope.id.startsWith(INTERNAL_ID_PREFIX)) {
if (_.isString(scope.id) && scope.id.indexOf(internalKeyID) !== -1) { this.formScopes = this.formScopes.filter(s => s !== scope);
this.formScopes.splice(index, 1);
} else { } else {
this.formScopes.splice(index, 1, Object.assign({}, scope, { _destroy: true })); Vue.set(scope, 'shouldBeDestroyed', true);
} }
}, },
/** /**
* When the user selects a value or creates a new value in the environments * Creates a new scope and adds it to the list of scopes
* dropdown in the creation row, we push a new entry with the selected value.
* *
* @param {String} * @param overrides An object whose properties will
* be used override the default scope options
*/ */
createNewEnvironment(name) { createNewScope(overrides) {
this.formScopes.push({ this.formScopes.push(createNewEnvironmentScope(overrides));
environment_scope: name,
active: false,
id: _.uniqueId(internalKeyID),
});
this.newScope = ''; this.newScope = '';
}, },
/** /**
* When the user clicks the submit button * When the user clicks the submit button
* it triggers an event with the form data * it triggers an event with the form data
...@@ -177,13 +147,35 @@ export default { ...@@ -177,13 +147,35 @@ export default {
}, },
canUpdateScope(scope) { canUpdateScope(scope) {
return !this.permissionsFlag || scope.can_update; return !this.permissionsFlag || scope.canUpdate;
},
isRolloutPercentageInvalid: _.memoize(function isRolloutPercentageInvalid(percentage) {
return !this.$options.rolloutPercentageRegex.test(percentage);
}),
/**
* Generates a unique ID for the strategy based on the v-for index
*
* @param index The index of the strategy
*/
rolloutStrategyId(index) {
return `rollout-strategy-${index}`;
},
/**
* Generates a unique ID for the percentage based on the v-for index
*
* @param index The index of the percentage
*/
rolloutPercentageId(index) {
return `rollout-percentage-${index}`;
}, },
}, },
}; };
</script> </script>
<template> <template>
<form> <form class="feature-flags-form">
<fieldset> <fieldset>
<div class="row"> <div class="row">
<div class="form-group col-md-4"> <div class="form-group col-md-4">
...@@ -219,12 +211,15 @@ export default { ...@@ -219,12 +211,15 @@ export default {
<div class="js-scopes-table prepend-top-default"> <div class="js-scopes-table prepend-top-default">
<div class="gl-responsive-table-row table-row-header" role="row"> <div class="gl-responsive-table-row table-row-header" role="row">
<div class="table-section section-60" role="columnheader"> <div class="table-section section-30" role="columnheader">
{{ s__('FeatureFlags|Environment Spec') }} {{ s__('FeatureFlags|Environment Spec') }}
</div> </div>
<div class="table-section section-20" role="columnheader"> <div class="table-section section-20 text-center" role="columnheader">
{{ s__('FeatureFlags|Status') }} {{ s__('FeatureFlags|Status') }}
</div> </div>
<div class="table-section section-40" role="columnheader">
{{ s__('FeatureFlags|Rollout Strategy') }}
</div>
</div> </div>
<div <div
...@@ -233,26 +228,26 @@ export default { ...@@ -233,26 +228,26 @@ export default {
class="gl-responsive-table-row" class="gl-responsive-table-row"
role="row" role="row"
> >
<div class="table-section section-60" role="gridcell"> <div class="table-section section-30" role="gridcell">
<div class="table-mobile-header" role="rowheader"> <div class="table-mobile-header" role="rowheader">
{{ s__('FeatureFlags|Environment Spec') }} {{ s__('FeatureFlags|Environment Spec') }}
</div> </div>
<div <div
class="table-mobile-content js-feature-flag-status d-flex align-items-center justify-content-start" class="table-mobile-content js-feature-flag-status d-flex align-items-center justify-content-start"
> >
<p v-if="isAllEnvironment(scope.environment_scope)" class="js-scope-all pl-3"> <p v-if="isAllEnvironment(scope.environmentScope)" class="js-scope-all pl-3">
{{ $options.allEnvironments }} {{ $options.allEnvironmentsText }}
</p> </p>
<environments-dropdown <environments-dropdown
v-else v-else
class="col-md-6" class="col-12"
:value="scope.environment_scope" :value="scope.environmentScope"
:endpoint="environmentsEndpoint" :endpoint="environmentsEndpoint"
:disabled="!canUpdateScope(scope)" :disabled="!canUpdateScope(scope)"
@selectEnvironment="env => updateScope(env, scope, index)" @selectEnvironment="env => (scope.environmentScope = env)"
@createClicked="env => updateScope(env, scope, index)" @createClicked="env => (scope.environmentScope = env)"
@clearInput="updateScope('', scope, index)" @clearInput="env => (scope.environmentScope = '')"
/> />
<gl-badge v-if="permissionsFlag && scope.protected" variant="success">{{ <gl-badge v-if="permissionsFlag && scope.protected" variant="success">{{
...@@ -261,7 +256,7 @@ export default { ...@@ -261,7 +256,7 @@ export default {
</div> </div>
</div> </div>
<div class="table-section section-20" role="gridcell"> <div class="table-section section-20 text-center" role="gridcell">
<div class="table-mobile-header" role="rowheader"> <div class="table-mobile-header" role="rowheader">
{{ s__('FeatureFlags|Status') }} {{ s__('FeatureFlags|Status') }}
</div> </div>
...@@ -269,19 +264,81 @@ export default { ...@@ -269,19 +264,81 @@ export default {
<toggle-button <toggle-button
:value="scope.active" :value="scope.active"
:disabled-input="!canUpdateScope(scope)" :disabled-input="!canUpdateScope(scope)"
@change="status => onUpdateScopeStatus(scope, status)" @change="status => (scope.active = status)"
/> />
</div> </div>
</div> </div>
<div class="table-section section-20" role="gridcell"> <div class="table-section section-40" role="gridcell">
<div class="table-mobile-header" role="rowheader"> <div class="table-mobile-header" role="rowheader">
{{ s__('FeatureFlags|Status') }} {{ s__('FeatureFlags|Rollout Strategy') }}
</div>
<div class="table-mobile-content js-rollout-strategy form-inline">
<label class="sr-only" :for="rolloutStrategyId(index)">
{{ s__('FeatureFlags|Rollout Strategy') }}
</label>
<div class="select-wrapper col-12 col-md-8 p-0">
<select
:id="rolloutStrategyId(index)"
v-model="scope.rolloutStrategy"
:disabled="!scope.active"
class="form-control select-control w-100 js-rollout-strategy"
>
<option :value="$options.ROLLOUT_STRATEGY_ALL_USERS">{{
s__('FeatureFlags|All users')
}}</option>
<option :value="$options.ROLLOUT_STRATEGY_PERCENT_ROLLOUT">{{
s__('FeatureFlags|Percent rollout (logged in users)')
}}</option>
</select>
<i aria-hidden="true" data-hidden="true" class="fa fa-chevron-down"></i>
</div>
<div
v-if="scope.rolloutStrategy === $options.ROLLOUT_STRATEGY_PERCENT_ROLLOUT"
class="d-flex-center mt-2 mt-md-0 ml-md-2"
>
<label class="sr-only" :for="rolloutPercentageId(index)">
{{ s__('FeatureFlags|Rollout Percentage') }}
</label>
<div class="w-3rem">
<input
:id="rolloutPercentageId(index)"
v-model="scope.rolloutPercentage"
:disabled="!scope.active"
:class="{
'is-invalid': isRolloutPercentageInvalid(scope.rolloutPercentage),
}"
type="number"
min="0"
max="100"
:pattern="$options.rolloutPercentageRegex.source"
class="rollout-percentage js-rollout-percentage form-control text-right w-100"
/>
</div>
<gl-tooltip
v-if="isRolloutPercentageInvalid(scope.rolloutPercentage)"
:target="rolloutPercentageId(index)"
>
{{
s__('FeatureFlags|Percent rollout must be a whole number between 0 and 100')
}}
</gl-tooltip>
<span class="ml-1">%</span>
</div>
</div>
</div>
<div class="table-section section-10 text-right" role="gridcell">
<div class="table-mobile-header" role="rowheader">
{{ s__('FeatureFlags|Remove') }}
</div> </div>
<div class="table-mobile-content js-feature-flag-delete"> <div class="table-mobile-content js-feature-flag-delete">
<gl-button <gl-button
v-if="!isAllEnvironment(scope.environment_scope) && canUpdateScope(scope)" v-if="!isAllEnvironment(scope.environmentScope) && canUpdateScope(scope)"
class="js-delete-scope btn-transparent" v-gl-tooltip
:title="s__('FeatureFlags|Remove')"
class="js-delete-scope btn-transparent pr-3 pl-3"
@click="removeScope(scope)" @click="removeScope(scope)"
> >
<icon name="clear" /> <icon name="clear" />
...@@ -291,27 +348,48 @@ export default { ...@@ -291,27 +348,48 @@ export default {
</div> </div>
<div class="js-add-new-scope gl-responsive-table-row" role="row"> <div class="js-add-new-scope gl-responsive-table-row" role="row">
<div class="table-section section-60" role="gridcell"> <div class="table-section section-30" role="gridcell">
<div class="table-mobile-header" role="rowheader"> <div class="table-mobile-header" role="rowheader">
{{ s__('FeatureFlags|Environment Spec') }} {{ s__('FeatureFlags|Environment Spec') }}
</div> </div>
<div class="table-mobile-content js-feature-flag-status"> <div class="table-mobile-content js-feature-flag-status">
<environments-dropdown <environments-dropdown
class="js-new-scope-name col-md-6" class="js-new-scope-name col-12"
:endpoint="environmentsEndpoint" :endpoint="environmentsEndpoint"
:value="newScope" :value="newScope"
@selectEnvironment="env => createNewEnvironment(env)" @selectEnvironment="env => createNewScope({ environmentScope: env })"
@createClicked="env => createNewEnvironment(env)" @createClicked="env => createNewScope({ environmentScope: env })"
/> />
</div> </div>
</div> </div>
<div class="table-section section-20" role="gridcell"> <div class="table-section section-20 text-center" role="gridcell">
<div class="table-mobile-header" role="rowheader"> <div class="table-mobile-header" role="rowheader">
{{ s__('FeatureFlags|Status') }} {{ s__('FeatureFlags|Status') }}
</div> </div>
<div class="table-mobile-content js-feature-flag-status"> <div class="table-mobile-content js-feature-flag-status">
<toggle-button :value="false" @change="onChangeNewScopeStatus" /> <toggle-button :value="false" @change="createNewScope({ active: true })" />
</div>
</div>
<div class="table-section section-40" role="gridcell">
<div class="table-mobile-header" role="rowheader">
{{ s__('FeatureFlags|Rollout Strategy') }}
</div>
<div class="table-mobile-content js-rollout-strategy form-inline">
<label class="sr-only" for="new-rollout-strategy-placeholder">
{{ s__('FeatureFlags|Rollout Strategy') }}
</label>
<div class="select-wrapper col-12 col-md-8 p-0">
<select
id="new-rollout-strategy-placeholder"
disabled
class="form-control select-control w-100"
>
<option>{{ s__('FeatureFlags|All users') }}</option>
</select>
<i aria-hidden="true" data-hidden="true" class="fa fa-chevron-down"></i>
</div>
</div> </div>
</div> </div>
</div> </div>
...@@ -322,6 +400,7 @@ export default { ...@@ -322,6 +400,7 @@ export default {
<div class="form-actions"> <div class="form-actions">
<gl-button <gl-button
ref="submitButton"
type="button" type="button"
variant="success" variant="success"
class="js-ff-submit col-xs-12" class="js-ff-submit col-xs-12"
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
import { createNamespacedHelpers } from 'vuex'; import { createNamespacedHelpers } from 'vuex';
import store from '../store/index'; import store from '../store/index';
import FeatureFlagForm from './form.vue'; import FeatureFlagForm from './form.vue';
import { createNewEnvironmentScope } from '../store/modules/helpers';
const { mapState, mapActions } = createNamespacedHelpers('new'); const { mapState, mapActions } = createNamespacedHelpers('new');
...@@ -28,10 +29,10 @@ export default { ...@@ -28,10 +29,10 @@ export default {
...mapState(['error']), ...mapState(['error']),
scopes() { scopes() {
return [ return [
{ createNewEnvironmentScope({
environment_scope: '*', environmentScope: '*',
active: true, active: true,
}, }),
]; ];
}, },
}, },
......
export const ROLLOUT_STRATEGY_ALL_USERS = 'default';
export const ROLLOUT_STRATEGY_PERCENT_ROLLOUT = 'gradualRolloutUserId';
export const PERCENT_ROLLOUT_GROUP_ID = 'default';
export const DEFAULT_PERCENT_ROLLOUT = '100';
export const ALL_ENVIRONMENTS_NAME = '*';
export const INTERNAL_ID_PREFIX = 'internal_';
...@@ -3,7 +3,7 @@ import axios from '~/lib/utils/axios_utils'; ...@@ -3,7 +3,7 @@ import axios from '~/lib/utils/axios_utils';
import { visitUrl } from '~/lib/utils/url_utility'; import { visitUrl } from '~/lib/utils/url_utility';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { parseFeatureFlagsParams } from '../helpers'; import { mapFromScopesViewModel } from '../helpers';
/** /**
* Commits mutation to set the main endpoint * Commits mutation to set the main endpoint
...@@ -32,7 +32,7 @@ export const updateFeatureFlag = ({ state, dispatch }, params) => { ...@@ -32,7 +32,7 @@ export const updateFeatureFlag = ({ state, dispatch }, params) => {
dispatch('requestUpdateFeatureFlag'); dispatch('requestUpdateFeatureFlag');
axios axios
.put(state.endpoint, parseFeatureFlagsParams(params)) .put(state.endpoint, mapFromScopesViewModel(params))
.then(() => { .then(() => {
dispatch('receiveUpdateFeatureFlagSuccess'); dispatch('receiveUpdateFeatureFlagSuccess');
visitUrl(state.path); visitUrl(state.path);
......
import * as types from './mutation_types'; import * as types from './mutation_types';
import { mapToScopesViewModel } from '../helpers';
export default { export default {
[types.SET_ENDPOINT](state, endpoint) { [types.SET_ENDPOINT](state, endpoint) {
...@@ -16,9 +17,7 @@ export default { ...@@ -16,9 +17,7 @@ export default {
state.name = response.name; state.name = response.name;
state.description = response.description; state.description = response.description;
state.scopes = mapToScopesViewModel(response.scopes);
// When there aren't scopes BE sends `null`
state.scopes = response.scopes || [];
}, },
[types.RECEIVE_FEATURE_FLAG_ERROR](state) { [types.RECEIVE_FEATURE_FLAG_ERROR](state) {
state.isLoading = false; state.isLoading = false;
......
...@@ -6,7 +6,7 @@ export default () => ({ ...@@ -6,7 +6,7 @@ export default () => ({
name: null, name: null,
description: null, description: null,
scopes: null, scopes: [],
isLoading: false, isLoading: false,
hasError: false, hasError: false,
}); });
import _ from 'underscore'; import _ from 'underscore';
import {
ROLLOUT_STRATEGY_ALL_USERS,
ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
INTERNAL_ID_PREFIX,
DEFAULT_PERCENT_ROLLOUT,
PERCENT_ROLLOUT_GROUP_ID,
} from '../../constants';
export const internalKeyID = 'internal_'; /**
* Converts raw scope objects fetched from the API into an array of scope
export const parseFeatureFlagsParams = params => ({ * objects that is easier/nicer to bind to in Vue.
operations_feature_flag: { * @param {Array} scopesFromRails An array of scope objects fetched from the API
name: params.name, */
description: params.description, export const mapToScopesViewModel = scopesFromRails =>
scopes_attributes: params.scopes.map(scope => { (scopesFromRails || []).map(s => {
const scopeCopy = Object.assign({}, scope); const [strategy] = s.strategies || [];
if (_.isString(scopeCopy.id) && scopeCopy.id.indexOf(internalKeyID) !== -1) {
delete scopeCopy.id; const rolloutStrategy = strategy ? strategy.name : ROLLOUT_STRATEGY_ALL_USERS;
}
return scopeCopy; let rolloutPercentage = DEFAULT_PERCENT_ROLLOUT;
}), if (strategy && strategy.parameters && strategy.parameters.percentage) {
}, rolloutPercentage = strategy.parameters.percentage;
}); }
return {
id: s.id,
environmentScope: s.environment_scope,
active: Boolean(s.active),
canUpdate: Boolean(s.can_update),
protected: Boolean(s.protected),
rolloutStrategy,
rolloutPercentage,
// eslint-disable-next-line no-underscore-dangle
shouldBeDestroyed: Boolean(s._destroy),
};
});
/**
* Converts the parameters emitted by the Vue component into
* the shape that the Rails API expects.
* @param {Array} scopesFromVue An array of scope objects from the Vue component
*/
export const mapFromScopesViewModel = params => {
const scopes = (params.scopes || []).map(s => {
const parameters = {};
if (s.rolloutStrategy === ROLLOUT_STRATEGY_PERCENT_ROLLOUT) {
parameters.groupId = PERCENT_ROLLOUT_GROUP_ID;
parameters.percentage = s.rolloutPercentage;
}
// Strip out any internal IDs
const id = _.isString(s.id) && s.id.startsWith(INTERNAL_ID_PREFIX) ? undefined : s.id;
return {
id,
environment_scope: s.environmentScope,
active: s.active,
can_update: s.canUpdate,
protected: s.protected,
_destroy: s.shouldBeDestroyed,
strategies: [
{
name: s.rolloutStrategy,
parameters,
},
],
};
});
return {
operations_feature_flag: {
name: params.name,
description: params.description,
scopes_attributes: scopes,
},
};
};
/**
* Creates a new feature flag environment scope object for use
* in a Vue component. An optional parameter can be passed to
* override the property values that are created by default.
*
* @param {Object} overrides An optional object whose
* property values will be used to override the default values.
*
*/
export const createNewEnvironmentScope = (overrides = {}) => {
const defaultScope = {
environmentScope: '',
active: false,
id: _.uniqueId(INTERNAL_ID_PREFIX),
rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS,
rolloutPercentage: DEFAULT_PERCENT_ROLLOUT,
};
const newScope = {
...defaultScope,
...overrides,
};
if (gon && gon.features && gon.features.featureFlagPermissions) {
newScope.canUpdate = true;
newScope.protected = false;
}
return newScope;
};
import * as types from './mutation_types'; import * as types from './mutation_types';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import { mapToScopesViewModel } from '../helpers';
export default { export default {
[types.SET_FEATURE_FLAGS_ENDPOINT](state, endpoint) { [types.SET_FEATURE_FLAGS_ENDPOINT](state, endpoint) {
...@@ -20,8 +21,11 @@ export default { ...@@ -20,8 +21,11 @@ export default {
[types.RECEIVE_FEATURE_FLAGS_SUCCESS](state, response) { [types.RECEIVE_FEATURE_FLAGS_SUCCESS](state, response) {
state.isLoading = false; state.isLoading = false;
state.hasError = false; state.hasError = false;
state.featureFlags = response.data.feature_flags;
state.count = response.data.count; state.count = response.data.count;
state.featureFlags = (response.data.feature_flags || []).map(f => ({
...f,
scopes: mapToScopesViewModel(f.scopes || []),
}));
let paginationInfo; let paginationInfo;
if (Object.keys(response.headers).length) { if (Object.keys(response.headers).length) {
......
import * as types from './mutation_types'; import * as types from './mutation_types';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { visitUrl } from '~/lib/utils/url_utility'; import { visitUrl } from '~/lib/utils/url_utility';
import { parseFeatureFlagsParams } from '../helpers'; import { mapFromScopesViewModel } from '../helpers';
/** /**
* Commits mutation to set the main endpoint * Commits mutation to set the main endpoint
...@@ -30,7 +30,7 @@ export const createFeatureFlag = ({ state, dispatch }, params) => { ...@@ -30,7 +30,7 @@ export const createFeatureFlag = ({ state, dispatch }, params) => {
dispatch('requestCreateFeatureFlag'); dispatch('requestCreateFeatureFlag');
axios axios
.post(state.endpoint, parseFeatureFlagsParams(params)) .post(state.endpoint, mapFromScopesViewModel(params))
.then(() => { .then(() => {
dispatch('receiveCreateFeatureFlagSuccess'); dispatch('receiveCreateFeatureFlagSuccess');
visitUrl(state.path); visitUrl(state.path);
......
.feature-flags-form {
input.rollout-percentage {
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
-moz-appearance: textfield;
}
}
---
title: Add percentage rollout support to feature flag UI
merge_request: 14538
author:
type: added
import _ from 'underscore'; import _ from 'underscore';
import { parseFeatureFlagsParams, internalKeyID } from 'ee/feature_flags/store/modules/helpers'; import {
mapToScopesViewModel,
mapFromScopesViewModel,
createNewEnvironmentScope,
} from 'ee/feature_flags/store/modules/helpers';
import {
ROLLOUT_STRATEGY_ALL_USERS,
ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
PERCENT_ROLLOUT_GROUP_ID,
INTERNAL_ID_PREFIX,
DEFAULT_PERCENT_ROLLOUT,
} from 'ee/feature_flags/constants';
describe('feature flags helpers spec', () => { describe('feature flags helpers spec', () => {
describe('parseFeatureFlagsParams', () => { describe('mapToScopesViewModel', () => {
describe('with internalKeyId', () => { it('converts the data object from the Rails API into something more usable by Vue', () => {
it('removes id', () => { const input = [
const scopes = [ {
{ id: 3,
active: true, environment_scope: 'environment_scope',
created_at: '2019-01-17T17:22:07.625Z', active: true,
environment_scope: '*', can_update: true,
id: 2, protected: true,
updated_at: '2019-01-17T17:22:07.625Z', strategies: [
}, {
{ name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
active: true, parameters: {
created_at: '2019-03-11T11:18:42.709Z', percentage: '56',
environment_scope: 'review', },
id: 29, },
updated_at: '2019-03-11T11:18:42.709Z', ],
},
_destroy: true,
},
];
const expected = [
{
id: 3,
environmentScope: 'environment_scope',
active: true,
canUpdate: true,
protected: true,
rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
rolloutPercentage: '56',
shouldBeDestroyed: true,
},
];
const actual = mapToScopesViewModel(input);
expect(actual).toEqual(expected);
});
it('returns Boolean properties even when their Rails counterparts were not provided (are `undefined`)', () => {
const input = [
{
id: 3,
environment_scope: 'environment_scope',
},
];
const [result] = mapToScopesViewModel(input);
expect(result).toEqual(
expect.objectContaining({
active: false,
canUpdate: false,
protected: false,
shouldBeDestroyed: false,
}),
);
});
it('returns an empty array if null or undefined is provided as a parameter', () => {
expect(mapToScopesViewModel(null)).toEqual([]);
expect(mapToScopesViewModel(undefined)).toEqual([]);
});
});
describe('mapFromScopesViewModel', () => {
it('converts the object emitted from the Vue component into an object than is in the right format to be submitted to the Rails API', () => {
const input = {
name: 'name',
description: 'description',
scopes: [
{ {
id: 4,
environmentScope: 'environmentScope',
active: true, active: true,
created_at: '2019-03-11T11:18:42.709Z', canUpdate: true,
environment_scope: 'review', protected: true,
id: _.uniqueId(internalKeyID), shouldBeDestroyed: true,
updated_at: '2019-03-11T11:18:42.709Z', rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
rolloutPercentage: '48',
}, },
]; ],
};
const expected = {
operations_feature_flag: {
name: 'name',
description: 'description',
scopes_attributes: [
{
id: 4,
environment_scope: 'environmentScope',
active: true,
can_update: true,
protected: true,
_destroy: true,
strategies: [
{
name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
parameters: {
groupId: PERCENT_ROLLOUT_GROUP_ID,
percentage: '48',
},
},
],
},
],
},
};
const actual = mapFromScopesViewModel(input);
expect(actual).toEqual(expected);
});
it('should strip out internal IDs', () => {
const input = {
scopes: [{ id: 3 }, { id: _.uniqueId(INTERNAL_ID_PREFIX) }],
};
const result = mapFromScopesViewModel(input);
const [realId, internalId] = result.operations_feature_flag.scopes_attributes;
expect(realId.id).toBe(3);
expect(internalId.id).toBeUndefined();
});
it('returns scopes_attributes as [] if param.scopes is null or undefined', () => {
let {
operations_feature_flag: { scopes_attributes: actualScopes },
} = mapFromScopesViewModel({ scopes: null });
expect(actualScopes).toEqual([]);
({
operations_feature_flag: { scopes_attributes: actualScopes },
} = mapFromScopesViewModel({ scopes: undefined }));
expect(actualScopes).toEqual([]);
});
});
describe('createNewEnvironmentScope', () => {
it('should return a new environment scope object populated with the default options', () => {
const expected = {
environmentScope: '',
active: false,
id: expect.stringContaining(INTERNAL_ID_PREFIX),
rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS,
rolloutPercentage: DEFAULT_PERCENT_ROLLOUT,
};
const actual = createNewEnvironmentScope();
expect(actual).toEqual(expected);
});
it('should return a new environment scope object with overrides applied', () => {
const overrides = {
environmentScope: 'environmentScope',
active: true,
};
const expected = {
environmentScope: 'environmentScope',
active: true,
id: expect.stringContaining(INTERNAL_ID_PREFIX),
rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS,
rolloutPercentage: DEFAULT_PERCENT_ROLLOUT,
};
const parsedScopes = parseFeatureFlagsParams({ const actual = createNewEnvironmentScope(overrides);
name: 'review',
scopes,
description: 'feature flag',
});
expect(parsedScopes.operations_feature_flag.scopes_attributes[2].id).toEqual(undefined); expect(actual).toEqual(expected);
});
}); });
}); });
}); });
import Vue from 'vue'; import Vue from 'vue';
import featureFlagsTableComponent from 'ee/feature_flags/components/feature_flags_table.vue'; import featureFlagsTableComponent from 'ee/feature_flags/components/feature_flags_table.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { featureFlag } from '../mock_data'; import { trimText } from 'spec/helpers/text_helper';
import {
ROLLOUT_STRATEGY_ALL_USERS,
ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
DEFAULT_PERCENT_ROLLOUT,
} from 'ee/feature_flags/constants';
describe('Feature Flag table', () => { describe('Feature Flag table', () => {
let Component; let Component;
let vm; let vm;
beforeEach(() => {
Component = Vue.extend(featureFlagsTableComponent);
});
afterEach(() => { afterEach(() => {
vm.$destroy(); vm.$destroy();
}); });
it('Should render a table', () => { describe('with an active scope and a standard rollout strategy', () => {
vm = mountComponent(Component, { beforeEach(() => {
featureFlags: [featureFlag], Component = Vue.extend(featureFlagsTableComponent);
csrfToken: 'fakeToken',
vm = mountComponent(Component, {
featureFlags: [
{
id: 1,
active: true,
name: 'flag name',
description: 'flag description',
destroy_path: 'destroy/path',
edit_path: 'edit/path',
scopes: [
{
id: 1,
active: true,
environmentScope: 'scope',
canUpdate: true,
protected: false,
rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS,
rolloutPercentage: DEFAULT_PERCENT_ROLLOUT,
shouldBeDestroyed: false,
},
],
},
],
csrfToken: 'fakeToken',
});
}); });
expect(vm.$el.getAttribute('class')).toContain('table-holder'); it('Should render a table', () => {
}); expect(vm.$el.getAttribute('class')).toContain('table-holder');
});
it('Should render rows', () => { it('Should render rows', () => {
expect(vm.$el.querySelector('.gl-responsive-table-row')).not.toBeNull(); expect(vm.$el.querySelector('.gl-responsive-table-row')).not.toBeNull();
}); });
it('Should render a status column', () => { it('Should render a status column', () => {
const status = featureFlag.active ? 'Active' : 'Inactive'; expect(vm.$el.querySelector('.js-feature-flag-status')).not.toBeNull();
expect(trimText(vm.$el.querySelector('.js-feature-flag-status').textContent)).toEqual(
'Active',
);
});
expect(vm.$el.querySelector('.js-feature-flag-status')).not.toBeNull(); it('Should render a feature flag column', () => {
expect(vm.$el.querySelector('.js-feature-flag-status').textContent.trim()).toEqual(status); expect(vm.$el.querySelector('.js-feature-flag-title')).not.toBeNull();
}); expect(trimText(vm.$el.querySelector('.feature-flag-name').textContent)).toEqual('flag name');
expect(trimText(vm.$el.querySelector('.feature-flag-description').textContent)).toEqual(
'flag description',
);
});
it('Should render a feature flag column', () => { it('should render an environments specs column', () => {
expect(vm.$el.querySelector('.js-feature-flag-title')).not.toBeNull(); const envColumn = vm.$el.querySelector('.js-feature-flag-environments');
expect(vm.$el.querySelector('.feature-flag-name').textContent.trim()).toEqual(featureFlag.name);
expect(vm.$el.querySelector('.feature-flag-description').textContent.trim()).toEqual(
featureFlag.description,
);
});
it('should render a environments specs column', () => { expect(envColumn).toBeDefined();
const envColumn = vm.$el.querySelector('.js-feature-flag-environments'); expect(trimText(envColumn.textContent)).toBe('scope');
});
expect(envColumn).not.toBeNull(); it('should render an environments specs badge with active class', () => {
expect(envColumn.textContent.trim()).toContain(featureFlag.scopes[0].environment_scope); const envColumn = vm.$el.querySelector('.js-feature-flag-environments');
expect(envColumn.textContent.trim()).toContain(featureFlag.scopes[1].environment_scope);
});
it('should render a environments specs badge with inactive class', () => { expect(trimText(envColumn.querySelector('.badge-active').textContent)).toBe('scope');
const envColumn = vm.$el.querySelector('.js-feature-flag-environments'); });
expect(envColumn.querySelector('.badge-inactive').textContent.trim()).toContain( it('should render an actions column', () => {
featureFlag.scopes[1].environment_scope, expect(vm.$el.querySelector('.table-action-buttons')).not.toBeNull();
); expect(vm.$el.querySelector('.js-feature-flag-delete-button')).not.toBeNull();
expect(vm.$el.querySelector('.js-feature-flag-edit-button')).not.toBeNull();
expect(vm.$el.querySelector('.js-feature-flag-edit-button').getAttribute('href')).toEqual(
'edit/path',
);
});
}); });
it('should render a environments specs badge with active class', () => { describe('with an active scope and a percentage rollout strategy', () => {
const envColumn = vm.$el.querySelector('.js-feature-flag-environments'); beforeEach(() => {
Component = Vue.extend(featureFlagsTableComponent);
vm = mountComponent(Component, {
featureFlags: [
{
id: 1,
active: true,
name: 'flag name',
description: 'flag description',
destroy_path: 'destroy/path',
edit_path: 'edit/path',
scopes: [
{
id: 1,
active: true,
environmentScope: 'scope',
canUpdate: true,
protected: false,
rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
rolloutPercentage: '54',
shouldBeDestroyed: false,
},
],
},
],
csrfToken: 'fakeToken',
});
});
it('should render an environments specs badge with percentage', () => {
const envColumn = vm.$el.querySelector('.js-feature-flag-environments');
expect(envColumn.querySelector('.badge-active').textContent.trim()).toContain( expect(trimText(envColumn.querySelector('.badge').textContent)).toBe('scope: 54%');
featureFlag.scopes[0].environment_scope, });
);
}); });
it('Should render an actions column', () => { describe('with an inactive scope', () => {
expect(vm.$el.querySelector('.table-action-buttons')).not.toBeNull(); beforeEach(() => {
expect(vm.$el.querySelector('.js-feature-flag-delete-button')).not.toBeNull(); Component = Vue.extend(featureFlagsTableComponent);
expect(vm.$el.querySelector('.js-feature-flag-edit-button')).not.toBeNull();
expect(vm.$el.querySelector('.js-feature-flag-edit-button').getAttribute('href')).toEqual( vm = mountComponent(Component, {
featureFlag.edit_path, featureFlags: [
); {
id: 1,
active: true,
name: 'flag name',
description: 'flag description',
destroy_path: 'destroy/path',
edit_path: 'edit/path',
scopes: [
{
id: 1,
active: false,
environmentScope: 'scope',
canUpdate: true,
protected: false,
rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS,
rolloutPercentage: DEFAULT_PERCENT_ROLLOUT,
shouldBeDestroyed: false,
},
],
},
],
csrfToken: 'fakeToken',
});
});
it('should render an environments specs badge with inactive class', () => {
const envColumn = vm.$el.querySelector('.js-feature-flag-environments');
expect(trimText(envColumn.querySelector('.badge-inactive').textContent)).toBe('scope');
});
}); });
}); });
...@@ -3,7 +3,13 @@ import { createLocalVue, mount } from '@vue/test-utils'; ...@@ -3,7 +3,13 @@ import { createLocalVue, mount } from '@vue/test-utils';
import Form from 'ee/feature_flags/components/form.vue'; import Form from 'ee/feature_flags/components/form.vue';
import ToggleButton from '~/vue_shared/components/toggle_button.vue'; import ToggleButton from '~/vue_shared/components/toggle_button.vue';
import EnvironmentsDropdown from 'ee/feature_flags/components/environments_dropdown.vue'; import EnvironmentsDropdown from 'ee/feature_flags/components/environments_dropdown.vue';
import { internalKeyID } from 'ee/feature_flags/store/modules/helpers'; import {
ROLLOUT_STRATEGY_ALL_USERS,
ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
INTERNAL_ID_PREFIX,
DEFAULT_PERCENT_ROLLOUT,
} from 'ee/feature_flags/constants';
import { featureFlag } from '../mock_data';
describe('feature flag form', () => { describe('feature flag form', () => {
let wrapper; let wrapper;
...@@ -76,7 +82,7 @@ describe('feature flag form', () => { ...@@ -76,7 +82,7 @@ describe('feature flag form', () => {
expect(wrapper.vm.formScopes.length).toEqual(1); expect(wrapper.vm.formScopes.length).toEqual(1);
expect(wrapper.vm.formScopes[0].active).toEqual(true); expect(wrapper.vm.formScopes[0].active).toEqual(true);
expect(wrapper.vm.formScopes[0].environment_scope).toEqual(''); expect(wrapper.vm.formScopes[0].environmentScope).toEqual('');
expect(wrapper.vm.newScope).toEqual(''); expect(wrapper.vm.newScope).toEqual('');
}); });
...@@ -89,29 +95,26 @@ describe('feature flag form', () => { ...@@ -89,29 +95,26 @@ describe('feature flag form', () => {
beforeEach(() => { beforeEach(() => {
factory({ factory({
...requiredProps, ...requiredProps,
name: 'feature_flag_1', name: featureFlag.name,
description: 'this is a feature flag', description: featureFlag.description,
scopes: [ scopes: [
{ {
environment_scope: 'production', id: 1,
active: false,
can_update: true,
protected: true,
id: 2,
},
{
environment_scope: 'review',
active: true, active: true,
can_update: true, environmentScope: 'scope',
canUpdate: true,
protected: false, protected: false,
id: 4, rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
rolloutPercentage: '54',
}, },
{ {
environment_scope: 'staging', id: 2,
active: true, active: true,
can_update: false, environmentScope: 'scope',
canUpdate: false,
protected: true, protected: true,
id: 5, rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
rolloutPercentage: '54',
}, },
], ],
}); });
...@@ -129,33 +132,9 @@ describe('feature flag form', () => { ...@@ -129,33 +132,9 @@ describe('feature flag form', () => {
describe('update scope', () => { describe('update scope', () => {
describe('on click on toggle', () => { describe('on click on toggle', () => {
it('should update the scope', () => { it('should update the scope', () => {
wrapper.find(ToggleButton).vm.$emit('change', true); wrapper.find(ToggleButton).vm.$emit('change', false);
expect(wrapper.vm.formScopes).toEqual([
{
active: true,
environment_scope: 'production',
id: 2,
can_update: true,
protected: true,
},
{
active: true,
environment_scope: 'review',
id: 4,
can_update: true,
protected: false,
},
{
environment_scope: 'staging',
active: true,
can_update: false,
protected: true,
id: 5,
},
]);
expect(wrapper.vm.newScope).toEqual(''); expect(_.first(wrapper.vm.formScopes).active).toBe(false);
}); });
}); });
}); });
...@@ -165,50 +144,12 @@ describe('feature flag form', () => { ...@@ -165,50 +144,12 @@ describe('feature flag form', () => {
wrapper.find('.js-delete-scope').trigger('click'); wrapper.find('.js-delete-scope').trigger('click');
}); });
it('should add `_destroy` key the clicked scope', () => { it('should add `shouldBeDestroyed` key the clicked scope', () => {
expect(wrapper.vm.formScopes).toEqual([ expect(_.first(wrapper.vm.formScopes).shouldBeDestroyed).toBe(true);
{
environment_scope: 'production',
active: false,
_destroy: true,
id: 2,
can_update: true,
protected: true,
},
{
active: true,
environment_scope: 'review',
id: 4,
can_update: true,
protected: false,
},
{
environment_scope: 'staging',
active: true,
can_update: false,
protected: true,
id: 5,
},
]);
}); });
it('should not render deleted scopes', () => { it('should not render deleted scopes', () => {
expect(wrapper.vm.filteredScopes).toEqual([ expect(wrapper.vm.filteredScopes).toEqual([jasmine.objectContaining({ id: 2 })]);
{
active: true,
environment_scope: 'review',
id: 4,
can_update: true,
protected: false,
},
{
environment_scope: 'staging',
active: true,
can_update: false,
protected: true,
id: 5,
},
]);
}); });
}); });
...@@ -220,11 +161,17 @@ describe('feature flag form', () => { ...@@ -220,11 +161,17 @@ describe('feature flag form', () => {
description: 'this is a feature flag', description: 'this is a feature flag',
scopes: [ scopes: [
{ {
environment_scope: 'new_scope', environmentScope: 'new_scope',
active: false, active: false,
id: _.uniqueId(internalKeyID), id: _.uniqueId(INTERNAL_ID_PREFIX),
can_update: true, canUpdate: true,
protected: false, protected: false,
strategies: [
{
name: ROLLOUT_STRATEGY_ALL_USERS,
parameters: {},
},
],
}, },
], ],
}); });
...@@ -243,8 +190,11 @@ describe('feature flag form', () => { ...@@ -243,8 +190,11 @@ describe('feature flag form', () => {
description: 'this is a feature flag', description: 'this is a feature flag',
scopes: [ scopes: [
{ {
environment_scope: '*', environmentScope: '*',
active: false, active: false,
canUpdate: false,
rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS,
rolloutPercentage: DEFAULT_PERCENT_ROLLOUT,
}, },
], ],
}); });
...@@ -269,7 +219,7 @@ describe('feature flag form', () => { ...@@ -269,7 +219,7 @@ describe('feature flag form', () => {
}); });
it('should have the scope that cannot be updated be disabled', () => { it('should have the scope that cannot be updated be disabled', () => {
const row = wrapper.findAll('.gl-responsive-table-row').wrappers[3]; const row = wrapper.findAll('.gl-responsive-table-row').at(2);
expect(row.find(EnvironmentsDropdown).vm.disabled).toBe(true); expect(row.find(EnvironmentsDropdown).vm.disabled).toBe(true);
expect(row.find(ToggleButton).vm.disabledInput).toBe(true); expect(row.find(ToggleButton).vm.disabledInput).toBe(true);
...@@ -279,23 +229,37 @@ describe('feature flag form', () => { ...@@ -279,23 +229,37 @@ describe('feature flag form', () => {
}); });
describe('on submit', () => { describe('on submit', () => {
beforeEach(() => { const selectFirstRolloutStrategyOption = dropdownIndex => {
wrapper
.findAll('select.js-rollout-strategy')
.at(dropdownIndex)
.findAll('option')
.at(1)
.setSelected();
};
beforeEach(done => {
factory({ factory({
...requiredProps, ...requiredProps,
name: 'feature_flag_1', name: 'feature_flag_1',
description: 'this is a feature flag', description: 'this is a feature flag',
scopes: [ scopes: [
{ {
environment_scope: 'production', id: 1,
can_update: true, environmentScope: 'production',
canUpdate: true,
protected: true, protected: true,
active: false, active: false,
rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS,
rolloutPercentage: DEFAULT_PERCENT_ROLLOUT,
}, },
], ],
}); });
wrapper.vm.$nextTick(done, done.fail);
}); });
it('should emit handleSubmit with the updated data', () => { it('should emit handleSubmit with the updated data', done => {
wrapper.find('#feature-flag-name').setValue('feature_flag_2'); wrapper.find('#feature-flag-name').setValue('feature_flag_2');
wrapper wrapper
...@@ -308,19 +272,62 @@ describe('feature flag form', () => { ...@@ -308,19 +272,62 @@ describe('feature flag form', () => {
.find(ToggleButton) .find(ToggleButton)
.vm.$emit('change', true); .vm.$emit('change', true);
wrapper.vm.handleSubmit(); wrapper.find(ToggleButton).vm.$emit('change', true);
const data = wrapper.emitted().handleSubmit[0][0]; wrapper.vm
.$nextTick()
expect(data.name).toEqual('feature_flag_2'); .then(() => {
expect(data.description).toEqual('this is a feature flag'); selectFirstRolloutStrategyOption(0);
expect(data.scopes.length).toEqual(3); selectFirstRolloutStrategyOption(2);
expect(data.scopes[0]).toEqual({
active: false, return wrapper.vm.$nextTick();
environment_scope: 'production', })
can_update: true, .then(() => {
protected: true, wrapper.find('.js-rollout-percentage').setValue('55');
});
return wrapper.vm.$nextTick();
})
.then(() => {
wrapper.find({ ref: 'submitButton' }).trigger('click');
const data = wrapper.emitted().handleSubmit[0][0];
expect(data.name).toEqual('feature_flag_2');
expect(data.description).toEqual('this is a feature flag');
expect(data.scopes).toEqual([
{
id: 1,
active: true,
environmentScope: 'production',
canUpdate: true,
protected: true,
rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
rolloutPercentage: '55',
},
{
id: jasmine.any(String),
active: false,
environmentScope: 'review',
canUpdate: true,
protected: false,
rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS,
rolloutPercentage: DEFAULT_PERCENT_ROLLOUT,
},
{
id: jasmine.any(String),
active: true,
environmentScope: '',
canUpdate: true,
protected: false,
rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
rolloutPercentage: DEFAULT_PERCENT_ROLLOUT,
},
]);
})
.then(done)
.catch(done.fail);
}); });
}); });
}); });
......
...@@ -4,6 +4,7 @@ import { createLocalVue, mount } from '@vue/test-utils'; ...@@ -4,6 +4,7 @@ import { createLocalVue, mount } from '@vue/test-utils';
import Form from 'ee/feature_flags/components/form.vue'; import Form from 'ee/feature_flags/components/form.vue';
import newModule from 'ee/feature_flags/store/modules/new'; import newModule from 'ee/feature_flags/store/modules/new';
import NewFeatureFlag from 'ee/feature_flags/components/new_feature_flag.vue'; import NewFeatureFlag from 'ee/feature_flags/components/new_feature_flag.vue';
import { ROLLOUT_STRATEGY_ALL_USERS, DEFAULT_PERCENT_ROLLOUT } from 'ee/feature_flags/constants';
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(Vuex); localVue.use(Vuex);
...@@ -60,8 +61,11 @@ describe('New feature flag form', () => { ...@@ -60,8 +61,11 @@ describe('New feature flag form', () => {
it('should render default * row', () => { it('should render default * row', () => {
expect(wrapper.vm.scopes).toEqual([ expect(wrapper.vm.scopes).toEqual([
{ {
environment_scope: '*', id: jasmine.any(String),
environmentScope: '*',
active: true, active: true,
rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS,
rolloutPercentage: DEFAULT_PERCENT_ROLLOUT,
}, },
]); ]);
......
export const featureFlagsList = [ import {
{ ROLLOUT_STRATEGY_ALL_USERS,
id: 1, ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
active: true, } from 'ee/feature_flags/constants';
created_at: '2018-12-12T22:07:31.401Z',
updated_at: '2018-12-12T22:07:31.401Z',
name: 'test flag',
description: 'flag for tests',
destroy_path: 'feature_flags/1',
edit_path: 'feature_flags/1/edit',
},
];
export const featureFlag = { export const featureFlag = {
id: 1, id: 1,
...@@ -25,55 +17,69 @@ export const featureFlag = { ...@@ -25,55 +17,69 @@ export const featureFlag = {
id: 1, id: 1,
active: true, active: true,
environment_scope: '*', environment_scope: '*',
can_update: true,
protected: false,
created_at: '2019-01-14T06:41:40.987Z', created_at: '2019-01-14T06:41:40.987Z',
updated_at: '2019-01-14T06:41:40.987Z', updated_at: '2019-01-14T06:41:40.987Z',
strategies: [
{
name: ROLLOUT_STRATEGY_ALL_USERS,
parameters: {},
},
],
}, },
{ {
id: 2, id: 2,
active: false, active: false,
environment_scope: 'production', environment_scope: 'production',
can_update: true,
protected: false,
created_at: '2019-01-14T06:41:40.987Z', created_at: '2019-01-14T06:41:40.987Z',
updated_at: '2019-01-14T06:41:40.987Z', updated_at: '2019-01-14T06:41:40.987Z',
strategies: [
{
name: ROLLOUT_STRATEGY_ALL_USERS,
parameters: {},
},
],
}, },
],
};
export const getRequestData = {
feature_flags: [
{ {
id: 3, id: 3,
active: true, active: false,
environment_scope: 'review/*',
can_update: true,
protected: false,
created_at: '2019-01-14T06:41:40.987Z', created_at: '2019-01-14T06:41:40.987Z',
updated_at: '2019-01-14T06:41:40.987Z', updated_at: '2019-01-14T06:41:40.987Z',
name: 'ci_live_trace', strategies: [
description: 'For the new live trace architecture',
edit_path: '/root/per-environment-feature-flags/-/feature_flags/3/edit',
destroy_path: '/root/per-environment-feature-flags/-/feature_flags/3',
scopes: [
{
id: 1,
active: true,
environment_scope: '*',
created_at: '2019-01-14T06:41:40.987Z',
updated_at: '2019-01-14T06:41:40.987Z',
},
{ {
id: 2, name: ROLLOUT_STRATEGY_ALL_USERS,
active: false, parameters: {},
environment_scope: 'production',
created_at: '2019-01-14T06:41:40.987Z',
updated_at: '2019-01-14T06:41:40.987Z',
}, },
],
},
{
id: 4,
active: true,
environment_scope: 'development',
can_update: true,
protected: false,
created_at: '2019-01-14T06:41:40.987Z',
updated_at: '2019-01-14T06:41:40.987Z',
strategies: [
{ {
id: 3, name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
active: false, parameters: {
environment_scope: 'review/*', percentage: '86',
created_at: '2019-01-14T06:41:40.987Z', },
updated_at: '2019-01-14T06:41:40.987Z',
}, },
], ],
}, },
], ],
};
export const getRequestData = {
feature_flags: [featureFlag],
count: { count: {
all: 1, all: 1,
disabled: 1, disabled: 1,
......
...@@ -65,22 +65,14 @@ describe('Feature flags Edit Module actions', () => { ...@@ -65,22 +65,14 @@ describe('Feature flags Edit Module actions', () => {
describe('success', () => { describe('success', () => {
it('dispatches requestUpdateFeatureFlag and receiveUpdateFeatureFlagSuccess ', done => { it('dispatches requestUpdateFeatureFlag and receiveUpdateFeatureFlagSuccess ', done => {
mock mock.onPut(mockedState.endpoint).replyOnce(200);
.onPut(mockedState.endpoint, {
operations_feature_flag: {
name: 'feature_flag',
description: 'feature flag',
scopes_attributes: [{ environment_scope: '*', active: true }],
},
})
.replyOnce(200);
testAction( testAction(
updateFeatureFlag, updateFeatureFlag,
{ {
name: 'feature_flag', name: 'feature_flag',
description: 'feature flag', description: 'feature flag',
scopes: [{ environment_scope: '*', active: true }], scopes: [{ environmentScope: '*', active: true }],
}, },
mockedState, mockedState,
[], [],
...@@ -99,15 +91,7 @@ describe('Feature flags Edit Module actions', () => { ...@@ -99,15 +91,7 @@ describe('Feature flags Edit Module actions', () => {
describe('error', () => { describe('error', () => {
it('dispatches requestUpdateFeatureFlag and receiveUpdateFeatureFlagError ', done => { it('dispatches requestUpdateFeatureFlag and receiveUpdateFeatureFlagError ', done => {
mock mock.onPut(`${TEST_HOST}/endpoint.json`).replyOnce(500, { message: [] });
.onPut(`${TEST_HOST}/endpoint.json`, {
operations_feature_flag: {
name: 'feature_flag',
description: 'feature flag',
scopes_attributes: [{ environment_scope: '*', active: true }],
},
})
.replyOnce(500, { message: [] });
testAction( testAction(
updateFeatureFlag, updateFeatureFlag,
......
...@@ -2,6 +2,7 @@ import state from 'ee/feature_flags/store/modules/index/state'; ...@@ -2,6 +2,7 @@ import state from 'ee/feature_flags/store/modules/index/state';
import mutations from 'ee/feature_flags/store/modules/index/mutations'; import mutations from 'ee/feature_flags/store/modules/index/mutations';
import * as types from 'ee/feature_flags/store/modules/index/mutation_types'; import * as types from 'ee/feature_flags/store/modules/index/mutation_types';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import { mapToScopesViewModel } from 'ee/feature_flags/store/modules/helpers';
import { getRequestData, rotateData } from '../../mock_data'; import { getRequestData, rotateData } from '../../mock_data';
describe('Feature flags store Mutations', () => { describe('Feature flags store Mutations', () => {
...@@ -73,8 +74,13 @@ describe('Feature flags store Mutations', () => { ...@@ -73,8 +74,13 @@ describe('Feature flags store Mutations', () => {
expect(stateCopy.hasError).toEqual(false); expect(stateCopy.hasError).toEqual(false);
}); });
it('should set featureFlags with the given data', () => { it('should set featureFlags with the transformed data', () => {
expect(stateCopy.featureFlags).toEqual(getRequestData.feature_flags); const expected = getRequestData.feature_flags.map(f => ({
...f,
scopes: mapToScopesViewModel(f.scopes || []),
}));
expect(stateCopy.featureFlags).toEqual(expected);
}); });
it('should set count with the given data', () => { it('should set count with the given data', () => {
......
...@@ -12,6 +12,11 @@ import state from 'ee/feature_flags/store/modules/new/state'; ...@@ -12,6 +12,11 @@ import state from 'ee/feature_flags/store/modules/new/state';
import * as types from 'ee/feature_flags/store/modules/new/mutation_types'; import * as types from 'ee/feature_flags/store/modules/new/mutation_types';
import testAction from 'spec/helpers/vuex_action_helper'; import testAction from 'spec/helpers/vuex_action_helper';
import { TEST_HOST } from 'spec/test_constants'; import { TEST_HOST } from 'spec/test_constants';
import {
ROLLOUT_STRATEGY_ALL_USERS,
ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
} from 'ee/feature_flags/constants';
import { mapFromScopesViewModel } from 'ee/feature_flags/store/modules/helpers';
describe('Feature flags New Module Actions', () => { describe('Feature flags New Module Actions', () => {
let mockedState; let mockedState;
...@@ -49,6 +54,23 @@ describe('Feature flags New Module Actions', () => { ...@@ -49,6 +54,23 @@ describe('Feature flags New Module Actions', () => {
describe('createFeatureFlag', () => { describe('createFeatureFlag', () => {
let mock; let mock;
const actionParams = {
name: 'name',
description: 'description',
scopes: [
{
id: 1,
environmentScope: 'environmentScope',
active: true,
canUpdate: true,
protected: true,
shouldBeDestroyed: false,
rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS,
rolloutPercentage: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
},
],
};
beforeEach(() => { beforeEach(() => {
mockedState.endpoint = `${TEST_HOST}/endpoint.json`; mockedState.endpoint = `${TEST_HOST}/endpoint.json`;
mock = new MockAdapter(axios); mock = new MockAdapter(axios);
...@@ -61,23 +83,13 @@ describe('Feature flags New Module Actions', () => { ...@@ -61,23 +83,13 @@ describe('Feature flags New Module Actions', () => {
describe('success', () => { describe('success', () => {
it('dispatches requestCreateFeatureFlag and receiveCreateFeatureFlagSuccess ', done => { it('dispatches requestCreateFeatureFlag and receiveCreateFeatureFlagSuccess ', done => {
mock const convertedActionParams = mapFromScopesViewModel(actionParams);
.onPost(`${TEST_HOST}/endpoint.json`, {
operations_feature_flag: { mock.onPost(`${TEST_HOST}/endpoint.json`, convertedActionParams).replyOnce(200);
name: 'feature_flag',
description: 'feature flag',
scopes_attributes: [{ environment_scope: '*', active: true }],
},
})
.replyOnce(200);
testAction( testAction(
createFeatureFlag, createFeatureFlag,
{ actionParams,
name: 'feature_flag',
description: 'feature flag',
scopes: [{ environment_scope: '*', active: true }],
},
mockedState, mockedState,
[], [],
[ [
...@@ -95,23 +107,15 @@ describe('Feature flags New Module Actions', () => { ...@@ -95,23 +107,15 @@ describe('Feature flags New Module Actions', () => {
describe('error', () => { describe('error', () => {
it('dispatches requestCreateFeatureFlag and receiveCreateFeatureFlagError ', done => { it('dispatches requestCreateFeatureFlag and receiveCreateFeatureFlagError ', done => {
const convertedActionParams = mapFromScopesViewModel(actionParams);
mock mock
.onPost(`${TEST_HOST}/endpoint.json`, { .onPost(`${TEST_HOST}/endpoint.json`, convertedActionParams)
operations_feature_flag: {
name: 'feature_flag',
description: 'feature flag',
scopes_attributes: [{ environment_scope: '*', active: true }],
},
})
.replyOnce(500, { message: [] }); .replyOnce(500, { message: [] });
testAction( testAction(
createFeatureFlag, createFeatureFlag,
{ actionParams,
name: 'feature_flag',
description: 'feature flag',
scopes: [{ environment_scope: '*', active: true }],
},
mockedState, mockedState,
[], [],
[ [
......
...@@ -45,7 +45,7 @@ module FeatureFlagHelpers ...@@ -45,7 +45,7 @@ module FeatureFlagHelpers
end end
def within_delete def within_delete
within '.table-section:nth-child(3)' do within '.table-section:nth-child(4)' do
yield yield
end end
end end
......
...@@ -6024,6 +6024,9 @@ msgstr "" ...@@ -6024,6 +6024,9 @@ msgstr ""
msgid "FeatureFlags|Active" msgid "FeatureFlags|Active"
msgstr "" msgstr ""
msgid "FeatureFlags|All users"
msgstr ""
msgid "FeatureFlags|Configure" msgid "FeatureFlags|Configure"
msgstr "" msgstr ""
...@@ -6096,9 +6099,24 @@ msgstr "" ...@@ -6096,9 +6099,24 @@ msgstr ""
msgid "FeatureFlags|New Feature Flag" msgid "FeatureFlags|New Feature Flag"
msgstr "" msgstr ""
msgid "FeatureFlags|Percent rollout (logged in users)"
msgstr ""
msgid "FeatureFlags|Percent rollout must be a whole number between 0 and 100"
msgstr ""
msgid "FeatureFlags|Protected" msgid "FeatureFlags|Protected"
msgstr "" msgstr ""
msgid "FeatureFlags|Remove"
msgstr ""
msgid "FeatureFlags|Rollout Percentage"
msgstr ""
msgid "FeatureFlags|Rollout Strategy"
msgstr ""
msgid "FeatureFlags|Status" msgid "FeatureFlags|Status"
msgstr "" msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment