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 {
/** COMMON SIZING CLASSES **/
.w-0 { width: 0; }
.w-8em { width: 8em; }
.w-3rem { width: 3rem; }
.h-12em { height: 12em; }
......
......@@ -3,6 +3,7 @@ import _ from 'underscore';
import { GlButton, GlLink, GlTooltipDirective, GlModalDirective, GlModal } from '@gitlab/ui';
import { sprintf, s__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import { ROLLOUT_STRATEGY_PERCENT_ROLLOUT } from '../constants';
export default {
components: {
......@@ -61,12 +62,22 @@ export default {
scopeTooltipText(scope) {
return !scope.active
? sprintf(s__('FeatureFlags|Inactive flag for %{scope}'), {
scope: scope.environment_scope,
scope: scope.environmentScope,
})
: '';
},
scopeName(name) {
return name === '*' ? s__('FeatureFlags|* (All environments)') : name;
badgeText(scope) {
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) {
return !this.permissions || (flag.scopes || []).every(scope => scope.can_update);
......@@ -131,8 +142,11 @@ export default {
:key="scope.id"
v-gl-tooltip.hover="scopeTooltipText(scope)"
class="badge append-right-8 prepend-top-2"
:class="{ 'badge-active': scope.active, 'badge-inactive': !scope.active }"
>{{ scopeName(scope.environment_scope) }}</span
:class="{
'badge-active': scope.active,
'badge-inactive': !scope.active,
}"
>{{ badgeText(scope) }}</span
>
</div>
</div>
......
<script>
import Vue from 'vue';
import _ from 'underscore';
import { GlButton, GlBadge } from '@gitlab/ui';
import { GlButton, GlBadge, GlTooltip, GlTooltipDirective } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
import ToggleButton from '~/vue_shared/components/toggle_button.vue';
import Icon from '~/vue_shared/components/icon.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 {
components: {
GlButton,
GlBadge,
GlTooltip,
ToggleButton,
Icon,
EnvironmentsDropdown,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
name: {
type: String,
......@@ -44,15 +55,9 @@ export default {
required: true,
},
},
data() {
return {
formName: this.name,
formDescription: this.description,
formScopes: this.scopes || [],
newScope: '',
};
},
allEnvironmentsText: s__('FeatureFlags|* (All Environments)'),
helpText: sprintf(
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}.',
......@@ -65,16 +70,30 @@ export default {
},
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: {
filteredScopes() {
// eslint-disable-next-line no-underscore-dangle
return this.formScopes.filter(scope => !scope._destroy);
return this.formScopes.filter(scope => !scope.shouldBeDestroyed);
},
canUpdateFlag() {
return !this.permissionsFlag || (this.scopes || []).every(scope => scope.can_update);
return !this.permissionsFlag || (this.formScopes || []).every(scope => scope.canUpdate);
},
permissionsFlag() {
return gon && gon.features && gon.features.featureFlagPermissions;
......@@ -82,88 +101,39 @@ export default {
},
methods: {
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
*
* If the scope has an ID, we need to add the `_destroy` flag
* otherwise we can just remove it.
* Backend needs the destroy flag only in the PUT request.
* If the scope has an ID, we need to add the `shouldBeDestroyed` flag.
* If the scope does *not* have an ID, we can just remove it.
*
* 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
*/
removeScope(scope) {
const index = this.formScopes.findIndex(el => el.id === scope.id);
if (_.isString(scope.id) && scope.id.indexOf(internalKeyID) !== -1) {
this.formScopes.splice(index, 1);
if (_.isString(scope.id) && scope.id.startsWith(INTERNAL_ID_PREFIX)) {
this.formScopes = this.formScopes.filter(s => s !== scope);
} 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
* dropdown in the creation row, we push a new entry with the selected value.
* Creates a new scope and adds it to the list of scopes
*
* @param {String}
* @param overrides An object whose properties will
* be used override the default scope options
*/
createNewEnvironment(name) {
this.formScopes.push({
environment_scope: name,
active: false,
id: _.uniqueId(internalKeyID),
});
createNewScope(overrides) {
this.formScopes.push(createNewEnvironmentScope(overrides));
this.newScope = '';
},
/**
* When the user clicks the submit button
* it triggers an event with the form data
......@@ -177,13 +147,35 @@ export default {
},
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>
<template>
<form>
<form class="feature-flags-form">
<fieldset>
<div class="row">
<div class="form-group col-md-4">
......@@ -219,12 +211,15 @@ export default {
<div class="js-scopes-table prepend-top-default">
<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') }}
</div>
<div class="table-section section-20" role="columnheader">
<div class="table-section section-20 text-center" role="columnheader">
{{ s__('FeatureFlags|Status') }}
</div>
<div class="table-section section-40" role="columnheader">
{{ s__('FeatureFlags|Rollout Strategy') }}
</div>
</div>
<div
......@@ -233,26 +228,26 @@ export default {
class="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">
{{ s__('FeatureFlags|Environment Spec') }}
</div>
<div
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">
{{ $options.allEnvironments }}
<p v-if="isAllEnvironment(scope.environmentScope)" class="js-scope-all pl-3">
{{ $options.allEnvironmentsText }}
</p>
<environments-dropdown
v-else
class="col-md-6"
:value="scope.environment_scope"
class="col-12"
:value="scope.environmentScope"
:endpoint="environmentsEndpoint"
:disabled="!canUpdateScope(scope)"
@selectEnvironment="env => updateScope(env, scope, index)"
@createClicked="env => updateScope(env, scope, index)"
@clearInput="updateScope('', scope, index)"
@selectEnvironment="env => (scope.environmentScope = env)"
@createClicked="env => (scope.environmentScope = env)"
@clearInput="env => (scope.environmentScope = '')"
/>
<gl-badge v-if="permissionsFlag && scope.protected" variant="success">{{
......@@ -261,7 +256,7 @@ export default {
</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">
{{ s__('FeatureFlags|Status') }}
</div>
......@@ -269,19 +264,81 @@ export default {
<toggle-button
:value="scope.active"
:disabled-input="!canUpdateScope(scope)"
@change="status => onUpdateScopeStatus(scope, status)"
@change="status => (scope.active = status)"
/>
</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">
{{ 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 class="table-mobile-content js-feature-flag-delete">
<gl-button
v-if="!isAllEnvironment(scope.environment_scope) && canUpdateScope(scope)"
class="js-delete-scope btn-transparent"
v-if="!isAllEnvironment(scope.environmentScope) && canUpdateScope(scope)"
v-gl-tooltip
:title="s__('FeatureFlags|Remove')"
class="js-delete-scope btn-transparent pr-3 pl-3"
@click="removeScope(scope)"
>
<icon name="clear" />
......@@ -291,27 +348,48 @@ export default {
</div>
<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">
{{ s__('FeatureFlags|Environment Spec') }}
</div>
<div class="table-mobile-content js-feature-flag-status">
<environments-dropdown
class="js-new-scope-name col-md-6"
class="js-new-scope-name col-12"
:endpoint="environmentsEndpoint"
:value="newScope"
@selectEnvironment="env => createNewEnvironment(env)"
@createClicked="env => createNewEnvironment(env)"
@selectEnvironment="env => createNewScope({ environmentScope: env })"
@createClicked="env => createNewScope({ environmentScope: env })"
/>
</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">
{{ s__('FeatureFlags|Status') }}
</div>
<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>
......@@ -322,6 +400,7 @@ export default {
<div class="form-actions">
<gl-button
ref="submitButton"
type="button"
variant="success"
class="js-ff-submit col-xs-12"
......
......@@ -2,6 +2,7 @@
import { createNamespacedHelpers } from 'vuex';
import store from '../store/index';
import FeatureFlagForm from './form.vue';
import { createNewEnvironmentScope } from '../store/modules/helpers';
const { mapState, mapActions } = createNamespacedHelpers('new');
......@@ -28,10 +29,10 @@ export default {
...mapState(['error']),
scopes() {
return [
{
environment_scope: '*',
createNewEnvironmentScope({
environmentScope: '*',
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';
import { visitUrl } from '~/lib/utils/url_utility';
import createFlash from '~/flash';
import { __ } from '~/locale';
import { parseFeatureFlagsParams } from '../helpers';
import { mapFromScopesViewModel } from '../helpers';
/**
* Commits mutation to set the main endpoint
......@@ -32,7 +32,7 @@ export const updateFeatureFlag = ({ state, dispatch }, params) => {
dispatch('requestUpdateFeatureFlag');
axios
.put(state.endpoint, parseFeatureFlagsParams(params))
.put(state.endpoint, mapFromScopesViewModel(params))
.then(() => {
dispatch('receiveUpdateFeatureFlagSuccess');
visitUrl(state.path);
......
import * as types from './mutation_types';
import { mapToScopesViewModel } from '../helpers';
export default {
[types.SET_ENDPOINT](state, endpoint) {
......@@ -16,9 +17,7 @@ export default {
state.name = response.name;
state.description = response.description;
// When there aren't scopes BE sends `null`
state.scopes = response.scopes || [];
state.scopes = mapToScopesViewModel(response.scopes);
},
[types.RECEIVE_FEATURE_FLAG_ERROR](state) {
state.isLoading = false;
......
......@@ -6,7 +6,7 @@ export default () => ({
name: null,
description: null,
scopes: null,
scopes: [],
isLoading: false,
hasError: false,
});
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_';
export const parseFeatureFlagsParams = params => ({
operations_feature_flag: {
name: params.name,
description: params.description,
scopes_attributes: params.scopes.map(scope => {
const scopeCopy = Object.assign({}, scope);
if (_.isString(scopeCopy.id) && scopeCopy.id.indexOf(internalKeyID) !== -1) {
delete scopeCopy.id;
}
return scopeCopy;
}),
},
});
/**
* Converts raw scope objects fetched from the API into an array of scope
* objects that is easier/nicer to bind to in Vue.
* @param {Array} scopesFromRails An array of scope objects fetched from the API
*/
export const mapToScopesViewModel = scopesFromRails =>
(scopesFromRails || []).map(s => {
const [strategy] = s.strategies || [];
const rolloutStrategy = strategy ? strategy.name : ROLLOUT_STRATEGY_ALL_USERS;
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 { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import { mapToScopesViewModel } from '../helpers';
export default {
[types.SET_FEATURE_FLAGS_ENDPOINT](state, endpoint) {
......@@ -20,8 +21,11 @@ export default {
[types.RECEIVE_FEATURE_FLAGS_SUCCESS](state, response) {
state.isLoading = false;
state.hasError = false;
state.featureFlags = response.data.feature_flags;
state.count = response.data.count;
state.featureFlags = (response.data.feature_flags || []).map(f => ({
...f,
scopes: mapToScopesViewModel(f.scopes || []),
}));
let paginationInfo;
if (Object.keys(response.headers).length) {
......
import * as types from './mutation_types';
import axios from '~/lib/utils/axios_utils';
import { visitUrl } from '~/lib/utils/url_utility';
import { parseFeatureFlagsParams } from '../helpers';
import { mapFromScopesViewModel } from '../helpers';
/**
* Commits mutation to set the main endpoint
......@@ -30,7 +30,7 @@ export const createFeatureFlag = ({ state, dispatch }, params) => {
dispatch('requestCreateFeatureFlag');
axios
.post(state.endpoint, parseFeatureFlagsParams(params))
.post(state.endpoint, mapFromScopesViewModel(params))
.then(() => {
dispatch('receiveCreateFeatureFlagSuccess');
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 { 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('parseFeatureFlagsParams', () => {
describe('with internalKeyId', () => {
it('removes id', () => {
const scopes = [
{
active: true,
created_at: '2019-01-17T17:22:07.625Z',
environment_scope: '*',
id: 2,
updated_at: '2019-01-17T17:22:07.625Z',
},
{
active: true,
created_at: '2019-03-11T11:18:42.709Z',
environment_scope: 'review',
id: 29,
updated_at: '2019-03-11T11:18:42.709Z',
},
describe('mapToScopesViewModel', () => {
it('converts the data object from the Rails API into something more usable by Vue', () => {
const input = [
{
id: 3,
environment_scope: 'environment_scope',
active: true,
can_update: true,
protected: true,
strategies: [
{
name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
parameters: {
percentage: '56',
},
},
],
_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,
created_at: '2019-03-11T11:18:42.709Z',
environment_scope: 'review',
id: _.uniqueId(internalKeyID),
updated_at: '2019-03-11T11:18:42.709Z',
canUpdate: true,
protected: true,
shouldBeDestroyed: true,
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({
name: 'review',
scopes,
description: 'feature flag',
});
const actual = createNewEnvironmentScope(overrides);
expect(parsedScopes.operations_feature_flag.scopes_attributes[2].id).toEqual(undefined);
});
expect(actual).toEqual(expected);
});
});
});
import Vue from 'vue';
import featureFlagsTableComponent from 'ee/feature_flags/components/feature_flags_table.vue';
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', () => {
let Component;
let vm;
beforeEach(() => {
Component = Vue.extend(featureFlagsTableComponent);
});
afterEach(() => {
vm.$destroy();
});
it('Should render a table', () => {
vm = mountComponent(Component, {
featureFlags: [featureFlag],
csrfToken: 'fakeToken',
describe('with an active scope and a standard rollout strategy', () => {
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_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', () => {
expect(vm.$el.querySelector('.gl-responsive-table-row')).not.toBeNull();
});
it('Should render rows', () => {
expect(vm.$el.querySelector('.gl-responsive-table-row')).not.toBeNull();
});
it('Should render a status column', () => {
const status = featureFlag.active ? 'Active' : 'Inactive';
it('Should render a status column', () => {
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();
expect(vm.$el.querySelector('.js-feature-flag-status').textContent.trim()).toEqual(status);
});
it('Should render a feature flag column', () => {
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', () => {
expect(vm.$el.querySelector('.js-feature-flag-title')).not.toBeNull();
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 an environments specs column', () => {
const envColumn = vm.$el.querySelector('.js-feature-flag-environments');
it('should render a environments specs column', () => {
const envColumn = vm.$el.querySelector('.js-feature-flag-environments');
expect(envColumn).toBeDefined();
expect(trimText(envColumn.textContent)).toBe('scope');
});
expect(envColumn).not.toBeNull();
expect(envColumn.textContent.trim()).toContain(featureFlag.scopes[0].environment_scope);
expect(envColumn.textContent.trim()).toContain(featureFlag.scopes[1].environment_scope);
});
it('should render an environments specs badge with active class', () => {
const envColumn = vm.$el.querySelector('.js-feature-flag-environments');
it('should render a environments specs badge with inactive class', () => {
const envColumn = vm.$el.querySelector('.js-feature-flag-environments');
expect(trimText(envColumn.querySelector('.badge-active').textContent)).toBe('scope');
});
expect(envColumn.querySelector('.badge-inactive').textContent.trim()).toContain(
featureFlag.scopes[1].environment_scope,
);
it('should render an actions column', () => {
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', () => {
const envColumn = vm.$el.querySelector('.js-feature-flag-environments');
describe('with an active scope and a percentage rollout strategy', () => {
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(
featureFlag.scopes[0].environment_scope,
);
expect(trimText(envColumn.querySelector('.badge').textContent)).toBe('scope: 54%');
});
});
it('Should render an actions column', () => {
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(
featureFlag.edit_path,
);
describe('with an inactive scope', () => {
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: 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';
import Form from 'ee/feature_flags/components/form.vue';
import ToggleButton from '~/vue_shared/components/toggle_button.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', () => {
let wrapper;
......@@ -76,7 +82,7 @@ describe('feature flag form', () => {
expect(wrapper.vm.formScopes.length).toEqual(1);
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('');
});
......@@ -89,29 +95,26 @@ describe('feature flag form', () => {
beforeEach(() => {
factory({
...requiredProps,
name: 'feature_flag_1',
description: 'this is a feature flag',
name: featureFlag.name,
description: featureFlag.description,
scopes: [
{
environment_scope: 'production',
active: false,
can_update: true,
protected: true,
id: 2,
},
{
environment_scope: 'review',
id: 1,
active: true,
can_update: true,
environmentScope: 'scope',
canUpdate: true,
protected: false,
id: 4,
rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
rolloutPercentage: '54',
},
{
environment_scope: 'staging',
id: 2,
active: true,
can_update: false,
environmentScope: 'scope',
canUpdate: false,
protected: true,
id: 5,
rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
rolloutPercentage: '54',
},
],
});
......@@ -129,33 +132,9 @@ describe('feature flag form', () => {
describe('update scope', () => {
describe('on click on toggle', () => {
it('should update the scope', () => {
wrapper.find(ToggleButton).vm.$emit('change', true);
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,
},
]);
wrapper.find(ToggleButton).vm.$emit('change', false);
expect(wrapper.vm.newScope).toEqual('');
expect(_.first(wrapper.vm.formScopes).active).toBe(false);
});
});
});
......@@ -165,50 +144,12 @@ describe('feature flag form', () => {
wrapper.find('.js-delete-scope').trigger('click');
});
it('should add `_destroy` key the clicked scope', () => {
expect(wrapper.vm.formScopes).toEqual([
{
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 add `shouldBeDestroyed` key the clicked scope', () => {
expect(_.first(wrapper.vm.formScopes).shouldBeDestroyed).toBe(true);
});
it('should not render deleted scopes', () => {
expect(wrapper.vm.filteredScopes).toEqual([
{
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.filteredScopes).toEqual([jasmine.objectContaining({ id: 2 })]);
});
});
......@@ -220,11 +161,17 @@ describe('feature flag form', () => {
description: 'this is a feature flag',
scopes: [
{
environment_scope: 'new_scope',
environmentScope: 'new_scope',
active: false,
id: _.uniqueId(internalKeyID),
can_update: true,
id: _.uniqueId(INTERNAL_ID_PREFIX),
canUpdate: true,
protected: false,
strategies: [
{
name: ROLLOUT_STRATEGY_ALL_USERS,
parameters: {},
},
],
},
],
});
......@@ -243,8 +190,11 @@ describe('feature flag form', () => {
description: 'this is a feature flag',
scopes: [
{
environment_scope: '*',
environmentScope: '*',
active: false,
canUpdate: false,
rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS,
rolloutPercentage: DEFAULT_PERCENT_ROLLOUT,
},
],
});
......@@ -269,7 +219,7 @@ describe('feature flag form', () => {
});
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(ToggleButton).vm.disabledInput).toBe(true);
......@@ -279,23 +229,37 @@ describe('feature flag form', () => {
});
describe('on submit', () => {
beforeEach(() => {
const selectFirstRolloutStrategyOption = dropdownIndex => {
wrapper
.findAll('select.js-rollout-strategy')
.at(dropdownIndex)
.findAll('option')
.at(1)
.setSelected();
};
beforeEach(done => {
factory({
...requiredProps,
name: 'feature_flag_1',
description: 'this is a feature flag',
scopes: [
{
environment_scope: 'production',
can_update: true,
id: 1,
environmentScope: 'production',
canUpdate: true,
protected: true,
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
......@@ -308,19 +272,62 @@ describe('feature flag form', () => {
.find(ToggleButton)
.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');
expect(data.description).toEqual('this is a feature flag');
expect(data.scopes.length).toEqual(3);
expect(data.scopes[0]).toEqual({
active: false,
environment_scope: 'production',
can_update: true,
protected: true,
});
.then(() => {
selectFirstRolloutStrategyOption(0);
selectFirstRolloutStrategyOption(2);
return wrapper.vm.$nextTick();
})
.then(() => {
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';
import Form from 'ee/feature_flags/components/form.vue';
import newModule from 'ee/feature_flags/store/modules/new';
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();
localVue.use(Vuex);
......@@ -60,8 +61,11 @@ describe('New feature flag form', () => {
it('should render default * row', () => {
expect(wrapper.vm.scopes).toEqual([
{
environment_scope: '*',
id: jasmine.any(String),
environmentScope: '*',
active: true,
rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS,
rolloutPercentage: DEFAULT_PERCENT_ROLLOUT,
},
]);
......
export const featureFlagsList = [
{
id: 1,
active: true,
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',
},
];
import {
ROLLOUT_STRATEGY_ALL_USERS,
ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
} from 'ee/feature_flags/constants';
export const featureFlag = {
id: 1,
......@@ -25,55 +17,69 @@ export const featureFlag = {
id: 1,
active: true,
environment_scope: '*',
can_update: true,
protected: false,
created_at: '2019-01-14T06:41:40.987Z',
updated_at: '2019-01-14T06:41:40.987Z',
strategies: [
{
name: ROLLOUT_STRATEGY_ALL_USERS,
parameters: {},
},
],
},
{
id: 2,
active: false,
environment_scope: 'production',
can_update: true,
protected: false,
created_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,
active: true,
active: false,
environment_scope: 'review/*',
can_update: true,
protected: false,
created_at: '2019-01-14T06:41:40.987Z',
updated_at: '2019-01-14T06:41:40.987Z',
name: 'ci_live_trace',
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',
},
strategies: [
{
id: 2,
active: false,
environment_scope: 'production',
created_at: '2019-01-14T06:41:40.987Z',
updated_at: '2019-01-14T06:41:40.987Z',
name: ROLLOUT_STRATEGY_ALL_USERS,
parameters: {},
},
],
},
{
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,
active: false,
environment_scope: 'review/*',
created_at: '2019-01-14T06:41:40.987Z',
updated_at: '2019-01-14T06:41:40.987Z',
name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
parameters: {
percentage: '86',
},
},
],
},
],
};
export const getRequestData = {
feature_flags: [featureFlag],
count: {
all: 1,
disabled: 1,
......
......@@ -65,22 +65,14 @@ describe('Feature flags Edit Module actions', () => {
describe('success', () => {
it('dispatches requestUpdateFeatureFlag and receiveUpdateFeatureFlagSuccess ', done => {
mock
.onPut(mockedState.endpoint, {
operations_feature_flag: {
name: 'feature_flag',
description: 'feature flag',
scopes_attributes: [{ environment_scope: '*', active: true }],
},
})
.replyOnce(200);
mock.onPut(mockedState.endpoint).replyOnce(200);
testAction(
updateFeatureFlag,
{
name: 'feature_flag',
description: 'feature flag',
scopes: [{ environment_scope: '*', active: true }],
scopes: [{ environmentScope: '*', active: true }],
},
mockedState,
[],
......@@ -99,15 +91,7 @@ describe('Feature flags Edit Module actions', () => {
describe('error', () => {
it('dispatches requestUpdateFeatureFlag and receiveUpdateFeatureFlagError ', done => {
mock
.onPut(`${TEST_HOST}/endpoint.json`, {
operations_feature_flag: {
name: 'feature_flag',
description: 'feature flag',
scopes_attributes: [{ environment_scope: '*', active: true }],
},
})
.replyOnce(500, { message: [] });
mock.onPut(`${TEST_HOST}/endpoint.json`).replyOnce(500, { message: [] });
testAction(
updateFeatureFlag,
......
......@@ -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 * as types from 'ee/feature_flags/store/modules/index/mutation_types';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import { mapToScopesViewModel } from 'ee/feature_flags/store/modules/helpers';
import { getRequestData, rotateData } from '../../mock_data';
describe('Feature flags store Mutations', () => {
......@@ -73,8 +74,13 @@ describe('Feature flags store Mutations', () => {
expect(stateCopy.hasError).toEqual(false);
});
it('should set featureFlags with the given data', () => {
expect(stateCopy.featureFlags).toEqual(getRequestData.feature_flags);
it('should set featureFlags with the transformed data', () => {
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', () => {
......
......@@ -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 testAction from 'spec/helpers/vuex_action_helper';
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', () => {
let mockedState;
......@@ -49,6 +54,23 @@ describe('Feature flags New Module Actions', () => {
describe('createFeatureFlag', () => {
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(() => {
mockedState.endpoint = `${TEST_HOST}/endpoint.json`;
mock = new MockAdapter(axios);
......@@ -61,23 +83,13 @@ describe('Feature flags New Module Actions', () => {
describe('success', () => {
it('dispatches requestCreateFeatureFlag and receiveCreateFeatureFlagSuccess ', done => {
mock
.onPost(`${TEST_HOST}/endpoint.json`, {
operations_feature_flag: {
name: 'feature_flag',
description: 'feature flag',
scopes_attributes: [{ environment_scope: '*', active: true }],
},
})
.replyOnce(200);
const convertedActionParams = mapFromScopesViewModel(actionParams);
mock.onPost(`${TEST_HOST}/endpoint.json`, convertedActionParams).replyOnce(200);
testAction(
createFeatureFlag,
{
name: 'feature_flag',
description: 'feature flag',
scopes: [{ environment_scope: '*', active: true }],
},
actionParams,
mockedState,
[],
[
......@@ -95,23 +107,15 @@ describe('Feature flags New Module Actions', () => {
describe('error', () => {
it('dispatches requestCreateFeatureFlag and receiveCreateFeatureFlagError ', done => {
const convertedActionParams = mapFromScopesViewModel(actionParams);
mock
.onPost(`${TEST_HOST}/endpoint.json`, {
operations_feature_flag: {
name: 'feature_flag',
description: 'feature flag',
scopes_attributes: [{ environment_scope: '*', active: true }],
},
})
.onPost(`${TEST_HOST}/endpoint.json`, convertedActionParams)
.replyOnce(500, { message: [] });
testAction(
createFeatureFlag,
{
name: 'feature_flag',
description: 'feature flag',
scopes: [{ environment_scope: '*', active: true }],
},
actionParams,
mockedState,
[],
[
......
......@@ -45,7 +45,7 @@ module FeatureFlagHelpers
end
def within_delete
within '.table-section:nth-child(3)' do
within '.table-section:nth-child(4)' do
yield
end
end
......
......@@ -6024,6 +6024,9 @@ msgstr ""
msgid "FeatureFlags|Active"
msgstr ""
msgid "FeatureFlags|All users"
msgstr ""
msgid "FeatureFlags|Configure"
msgstr ""
......@@ -6096,9 +6099,24 @@ msgstr ""
msgid "FeatureFlags|New Feature Flag"
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"
msgstr ""
msgid "FeatureFlags|Remove"
msgstr ""
msgid "FeatureFlags|Rollout Percentage"
msgstr ""
msgid "FeatureFlags|Rollout Strategy"
msgstr ""
msgid "FeatureFlags|Status"
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