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>
......
...@@ -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
* 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 || [];
export const parseFeatureFlagsParams = params => ({ 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: { operations_feature_flag: {
name: params.name, name: params.name,
description: params.description, description: params.description,
scopes_attributes: params.scopes.map(scope => { scopes_attributes: scopes,
const scopeCopy = Object.assign({}, scope);
if (_.isString(scopeCopy.id) && scopeCopy.id.indexOf(internalKeyID) !== -1) {
delete scopeCopy.id;
}
return scopeCopy;
}),
}, },
}); };
};
/**
* 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,
environment_scope: 'environment_scope',
active: true, active: true,
created_at: '2019-01-17T17:22:07.625Z', can_update: true,
environment_scope: '*', protected: true,
id: 2, strategies: [
updated_at: '2019-01-17T17:22:07.625Z', {
name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
parameters: {
percentage: '56',
},
},
],
_destroy: true,
}, },
];
const expected = [
{ {
id: 3,
environmentScope: 'environment_scope',
active: true, active: true,
created_at: '2019-03-11T11:18:42.709Z', canUpdate: true,
environment_scope: 'review', protected: true,
id: 29, rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
updated_at: '2019-03-11T11:18:42.709Z', 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 = [
{ {
active: true, id: 3,
created_at: '2019-03-11T11:18:42.709Z', environment_scope: 'environment_scope',
environment_scope: 'review',
id: _.uniqueId(internalKeyID),
updated_at: '2019-03-11T11:18:42.709Z',
}, },
]; ];
const parsedScopes = parseFeatureFlagsParams({ const [result] = mapToScopesViewModel(input);
name: 'review',
scopes, expect(result).toEqual(
description: 'feature flag', expect.objectContaining({
active: false,
canUpdate: false,
protected: false,
shouldBeDestroyed: false,
}),
);
}); });
expect(parsedScopes.operations_feature_flag.scopes_attributes[2].id).toEqual(undefined); 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,
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 actual = createNewEnvironmentScope(overrides);
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', () => {
beforeEach(() => {
Component = Vue.extend(featureFlagsTableComponent);
vm = mountComponent(Component, { vm = mountComponent(Component, {
featureFlags: [featureFlag], 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', csrfToken: 'fakeToken',
}); });
});
it('Should render a table', () => {
expect(vm.$el.getAttribute('class')).toContain('table-holder'); expect(vm.$el.getAttribute('class')).toContain('table-holder');
}); });
...@@ -29,50 +56,116 @@ describe('Feature Flag table', () => { ...@@ -29,50 +56,116 @@ describe('Feature Flag table', () => {
}); });
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(vm.$el.querySelector('.js-feature-flag-status')).not.toBeNull();
expect(vm.$el.querySelector('.js-feature-flag-status').textContent.trim()).toEqual(status); expect(trimText(vm.$el.querySelector('.js-feature-flag-status').textContent)).toEqual(
'Active',
);
}); });
it('Should render a feature flag column', () => { it('Should render a feature flag column', () => {
expect(vm.$el.querySelector('.js-feature-flag-title')).not.toBeNull(); expect(vm.$el.querySelector('.js-feature-flag-title')).not.toBeNull();
expect(vm.$el.querySelector('.feature-flag-name').textContent.trim()).toEqual(featureFlag.name); expect(trimText(vm.$el.querySelector('.feature-flag-name').textContent)).toEqual('flag name');
expect(vm.$el.querySelector('.feature-flag-description').textContent.trim()).toEqual( expect(trimText(vm.$el.querySelector('.feature-flag-description').textContent)).toEqual(
featureFlag.description, 'flag description',
); );
}); });
it('should render a environments specs column', () => { it('should render an environments specs column', () => {
const envColumn = vm.$el.querySelector('.js-feature-flag-environments'); const envColumn = vm.$el.querySelector('.js-feature-flag-environments');
expect(envColumn).not.toBeNull(); expect(envColumn).toBeDefined();
expect(envColumn.textContent.trim()).toContain(featureFlag.scopes[0].environment_scope); expect(trimText(envColumn.textContent)).toBe('scope');
expect(envColumn.textContent.trim()).toContain(featureFlag.scopes[1].environment_scope);
}); });
it('should render a environments specs badge with inactive class', () => { it('should render an environments specs badge with active class', () => {
const envColumn = vm.$el.querySelector('.js-feature-flag-environments'); const envColumn = vm.$el.querySelector('.js-feature-flag-environments');
expect(envColumn.querySelector('.badge-inactive').textContent.trim()).toContain( expect(trimText(envColumn.querySelector('.badge-active').textContent)).toBe('scope');
featureFlag.scopes[1].environment_scope,
);
});
it('should render a environments specs badge with active class', () => {
const envColumn = vm.$el.querySelector('.js-feature-flag-environments');
expect(envColumn.querySelector('.badge-active').textContent.trim()).toContain(
featureFlag.scopes[0].environment_scope,
);
}); });
it('Should render an actions column', () => { it('should render an actions column', () => {
expect(vm.$el.querySelector('.table-action-buttons')).not.toBeNull(); 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-delete-button')).not.toBeNull();
expect(vm.$el.querySelector('.js-feature-flag-edit-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( expect(vm.$el.querySelector('.js-feature-flag-edit-button').getAttribute('href')).toEqual(
featureFlag.edit_path, 'edit/path',
); );
}); });
});
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(trimText(envColumn.querySelector('.badge').textContent)).toBe('scope: 54%');
});
});
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');
});
});
}); });
...@@ -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, name: ROLLOUT_STRATEGY_ALL_USERS,
active: true, parameters: {},
environment_scope: '*',
created_at: '2019-01-14T06:41:40.987Z',
updated_at: '2019-01-14T06:41:40.987Z',
}, },
{ ],
id: 2,
active: false,
environment_scope: 'production',
created_at: '2019-01-14T06:41:40.987Z',
updated_at: '2019-01-14T06:41:40.987Z',
}, },
{ {
id: 3, id: 4,
active: false, active: true,
environment_scope: 'review/*', environment_scope: 'development',
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_PERCENT_ROLLOUT,
parameters: {
percentage: '86',
},
}, },
], ],
}, },
], ],
};
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