Commit f579ddc6 authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch 'feature-flag-toggle-on-table-poc' into 'master'

Add Toggle on Feature Flags Table

See merge request gitlab-org/gitlab!21564
parents ecac5802 d33ce3fc
...@@ -174,6 +174,7 @@ export default { ...@@ -174,6 +174,7 @@ export default {
'setInstanceIdEndpoint', 'setInstanceIdEndpoint',
'setInstanceId', 'setInstanceId',
'rotateInstanceId', 'rotateInstanceId',
'toggleFeatureFlag',
]), ]),
onChangeTab(scope) { onChangeTab(scope) {
this.scope = scope; this.scope = scope;
...@@ -280,6 +281,7 @@ export default { ...@@ -280,6 +281,7 @@ export default {
v-else-if="shouldRenderTable" v-else-if="shouldRenderTable"
:csrf-token="csrfToken" :csrf-token="csrfToken"
:feature-flags="featureFlags" :feature-flags="featureFlags"
@toggle-flag="toggleFeatureFlag"
/> />
<table-pagination v-if="shouldRenderPagination" :change="onChangePage" :page-info="pageInfo" /> <table-pagination v-if="shouldRenderPagination" :change="onChangePage" :page-info="pageInfo" />
......
<script> <script>
import _ from 'underscore'; import _ from 'underscore';
import { GlButton, GlLink, GlTooltipDirective, GlModalDirective, GlModal } from '@gitlab/ui'; import {
GlButton,
GlLink,
GlTooltipDirective,
GlModalDirective,
GlModal,
GlToggle,
} 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 glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
...@@ -12,6 +19,7 @@ export default { ...@@ -12,6 +19,7 @@ export default {
GlLink, GlLink,
Icon, Icon,
GlModal, GlModal,
GlToggle,
}, },
directives: { directives: {
GlModalDirective, GlModalDirective,
...@@ -62,6 +70,9 @@ export default { ...@@ -62,6 +70,9 @@ export default {
modalId() { modalId() {
return 'delete-feature-flag'; return 'delete-feature-flag';
}, },
hasFeatureFlagToggle() {
return this.glFeatures.featureFlagToggle;
},
}, },
methods: { methods: {
scopeTooltipText(scope) { scopeTooltipText(scope) {
...@@ -94,6 +105,12 @@ export default { ...@@ -94,6 +105,12 @@ export default {
onSubmit() { onSubmit() {
this.$refs.form.submit(); this.$refs.form.submit();
}, },
toggleFeatureFlag(flag) {
this.$emit('toggle-flag', {
...flag,
active: !flag.active,
});
},
}, },
}; };
</script> </script>
...@@ -123,7 +140,12 @@ export default { ...@@ -123,7 +140,12 @@ export default {
<div class="table-section section-10" role="gridcell"> <div class="table-section section-10" role="gridcell">
<div class="table-mobile-header" role="rowheader">{{ s__('FeatureFlags|Status') }}</div> <div class="table-mobile-header" role="rowheader">{{ s__('FeatureFlags|Status') }}</div>
<div class="table-mobile-content js-feature-flag-status"> <div class="table-mobile-content js-feature-flag-status">
<span v-if="featureFlag.active" class="badge badge-success"> <gl-toggle
v-if="hasFeatureFlagToggle && featureFlag.update_path"
:value="featureFlag.active"
@change="toggleFeatureFlag(featureFlag)"
/>
<span v-else-if="featureFlag.active" class="badge badge-success">
{{ s__('FeatureFlags|Active') }} {{ s__('FeatureFlags|Active') }}
</span> </span>
<span v-else class="badge badge-danger">{{ s__('FeatureFlags|Inactive') }}</span> <span v-else class="badge badge-danger">{{ s__('FeatureFlags|Inactive') }}</span>
......
...@@ -33,6 +33,24 @@ export const receiveFeatureFlagsSuccess = ({ commit }, response) => ...@@ -33,6 +33,24 @@ export const receiveFeatureFlagsSuccess = ({ commit }, response) =>
commit(types.RECEIVE_FEATURE_FLAGS_SUCCESS, response); commit(types.RECEIVE_FEATURE_FLAGS_SUCCESS, response);
export const receiveFeatureFlagsError = ({ commit }) => commit(types.RECEIVE_FEATURE_FLAGS_ERROR); export const receiveFeatureFlagsError = ({ commit }) => commit(types.RECEIVE_FEATURE_FLAGS_ERROR);
export const toggleFeatureFlag = ({ dispatch }, flag) => {
dispatch('updateFeatureFlag', flag);
axios
.put(flag.update_path, {
operations_feature_flag: flag,
})
.then(response => dispatch('receiveUpdateFeatureFlagSuccess', response.data))
.catch(() => dispatch('receiveUpdateFeatureFlagError', flag.id));
};
export const updateFeatureFlag = ({ commit }, flag) => commit(types.UPDATE_FEATURE_FLAG, flag);
export const receiveUpdateFeatureFlagSuccess = ({ commit }, data) =>
commit(types.RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS, data);
export const receiveUpdateFeatureFlagError = ({ commit }, id) =>
commit(types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR, id);
export const rotateInstanceId = ({ state, dispatch }) => { export const rotateInstanceId = ({ state, dispatch }) => {
dispatch('requestRotateInstanceId'); dispatch('requestRotateInstanceId');
......
...@@ -7,6 +7,10 @@ export const REQUEST_FEATURE_FLAGS = 'REQUEST_FEATURE_FLAGS'; ...@@ -7,6 +7,10 @@ export const REQUEST_FEATURE_FLAGS = 'REQUEST_FEATURE_FLAGS';
export const RECEIVE_FEATURE_FLAGS_SUCCESS = 'RECEIVE_FEATURE_FLAGS_SUCCESS'; export const RECEIVE_FEATURE_FLAGS_SUCCESS = 'RECEIVE_FEATURE_FLAGS_SUCCESS';
export const RECEIVE_FEATURE_FLAGS_ERROR = 'RECEIVE_FEATURE_FLAGS_ERROR'; export const RECEIVE_FEATURE_FLAGS_ERROR = 'RECEIVE_FEATURE_FLAGS_ERROR';
export const UPDATE_FEATURE_FLAG = 'UPDATE_FEATURE_FLAG';
export const RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS = 'RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS';
export const RECEIVE_UPDATE_FEATURE_FLAG_ERROR = 'RECEIVE_UPDATE_FEATURE_FLAG_ERROR';
export const REQUEST_ROTATE_INSTANCE_ID = 'REQUEST_ROTATE_INSTANCE_ID'; export const REQUEST_ROTATE_INSTANCE_ID = 'REQUEST_ROTATE_INSTANCE_ID';
export const RECEIVE_ROTATE_INSTANCE_ID_SUCCESS = 'RECEIVE_ROTATE_INSTANCE_ID_SUCCESS'; export const RECEIVE_ROTATE_INSTANCE_ID_SUCCESS = 'RECEIVE_ROTATE_INSTANCE_ID_SUCCESS';
export const RECEIVE_ROTATE_INSTANCE_ID_ERROR = 'RECEIVE_ROTATE_INSTANCE_ID_ERROR'; export const RECEIVE_ROTATE_INSTANCE_ID_ERROR = 'RECEIVE_ROTATE_INSTANCE_ID_ERROR';
import Vue from 'vue';
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'; import { mapToScopesViewModel } from '../helpers';
const mapFlag = flag => ({ ...flag, scopes: mapToScopesViewModel(flag.scopes || []) });
const updateFlag = (state, flag) => {
const i = state.featureFlags.findIndex(({ id }) => id === flag.id);
Vue.set(state.featureFlags, i, flag);
Vue.set(state.count, 'enabled', state.featureFlags.filter(({ active }) => active).length);
Vue.set(state.count, 'disabled', state.featureFlags.filter(({ active }) => !active).length);
};
export default { export default {
[types.SET_FEATURE_FLAGS_ENDPOINT](state, endpoint) { [types.SET_FEATURE_FLAGS_ENDPOINT](state, endpoint) {
state.endpoint = endpoint; state.endpoint = endpoint;
...@@ -22,10 +33,7 @@ export default { ...@@ -22,10 +33,7 @@ export default {
state.isLoading = false; state.isLoading = false;
state.hasError = false; state.hasError = false;
state.count = response.data.count; state.count = response.data.count;
state.featureFlags = (response.data.feature_flags || []).map(f => ({ state.featureFlags = (response.data.feature_flags || []).map(mapFlag);
...f,
scopes: mapToScopesViewModel(f.scopes || []),
}));
let paginationInfo; let paginationInfo;
if (Object.keys(response.headers).length) { if (Object.keys(response.headers).length) {
...@@ -58,4 +66,14 @@ export default { ...@@ -58,4 +66,14 @@ export default {
state.isRotating = false; state.isRotating = false;
state.hasRotateError = true; state.hasRotateError = true;
}, },
[types.UPDATE_FEATURE_FLAG](state, flag) {
updateFlag(state, mapFlag(flag));
},
[types.RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS](state, data) {
updateFlag(state, mapFlag(data));
},
[types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR](state, i) {
const flag = state.featureFlags.find(({ id }) => i === id);
updateFlag(state, { ...flag, active: !flag.active });
},
}; };
import { createLocalVue, shallowMount } from '@vue/test-utils'; import { createLocalVue, shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui'; import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
import store from 'ee/feature_flags/store';
import FeatureFlagsComponent from 'ee/feature_flags/components/feature_flags.vue'; import FeatureFlagsComponent from 'ee/feature_flags/components/feature_flags.vue';
import FeatureFlagsTable from 'ee/feature_flags/components/feature_flags_table.vue'; import FeatureFlagsTable from 'ee/feature_flags/components/feature_flags_table.vue';
import ConfigureFeatureFlagsModal from 'ee/feature_flags/components/configure_feature_flags_modal.vue'; import ConfigureFeatureFlagsModal from 'ee/feature_flags/components/configure_feature_flags_modal.vue';
...@@ -42,6 +43,7 @@ describe('Feature flags', () => { ...@@ -42,6 +43,7 @@ describe('Feature flags', () => {
beforeEach(() => { beforeEach(() => {
mock = new MockAdapter(axios); mock = new MockAdapter(axios);
jest.spyOn(store, 'dispatch');
}); });
afterEach(() => { afterEach(() => {
...@@ -184,7 +186,7 @@ describe('Feature flags', () => { ...@@ -184,7 +186,7 @@ describe('Feature flags', () => {
it('should render a table with feature flags', () => { it('should render a table with feature flags', () => {
const table = wrapper.find(FeatureFlagsTable); const table = wrapper.find(FeatureFlagsTable);
expect(wrapper.find(FeatureFlagsTable).exists()).toBe(true); expect(table.exists()).toBe(true);
expect(table.props('featureFlags')).toEqual( expect(table.props('featureFlags')).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
...@@ -195,6 +197,15 @@ describe('Feature flags', () => { ...@@ -195,6 +197,15 @@ describe('Feature flags', () => {
); );
}); });
it('should toggle a flag when receiving the toggle-flag event', () => {
const table = wrapper.find(FeatureFlagsTable);
const [flag] = table.props('featureFlags');
table.vm.$emit('toggle-flag', flag);
expect(store.dispatch).toHaveBeenCalledWith('index/toggleFeatureFlag', flag);
});
it('renders configure button', () => { it('renders configure button', () => {
expect(configureButton().exists()).toBe(true); expect(configureButton().exists()).toBe(true);
}); });
......
import FeatureFlagsTable from 'ee/feature_flags/components/feature_flags_table.vue'; import FeatureFlagsTable from 'ee/feature_flags/components/feature_flags_table.vue';
import { createLocalVue, shallowMount } from '@vue/test-utils'; import { createLocalVue, shallowMount } from '@vue/test-utils';
import { GlToggle } from '@gitlab/ui';
import { trimText } from 'helpers/text_helper'; import { trimText } from 'helpers/text_helper';
import { import {
ROLLOUT_STRATEGY_ALL_USERS, ROLLOUT_STRATEGY_ALL_USERS,
...@@ -113,6 +114,28 @@ describe('Feature flag table', () => { ...@@ -113,6 +114,28 @@ describe('Feature flag table', () => {
}); });
}); });
describe('when active and with an update toggle', () => {
let toggle;
beforeEach(() => {
props.featureFlags[0].update_path = props.featureFlags[0].destroy_path;
createWrapper(props, { provide: { glFeatures: { featureFlagToggle: true } } });
toggle = wrapper.find(GlToggle);
});
it('should have a toggle', () => {
expect(toggle.exists()).toBe(true);
expect(toggle.props('value')).toBe(true);
});
it('should trigger a toggle event', () => {
toggle.vm.$emit('change');
const flag = { ...props.featureFlags[0], active: !props.featureFlags[0].active };
expect(wrapper.emitted('toggle-flag')).toEqual([[flag]]);
});
});
describe('with an active scope and a percentage rollout strategy', () => { describe('with an active scope and a percentage rollout strategy', () => {
beforeEach(() => { beforeEach(() => {
props.featureFlags[0].scopes[0].rolloutStrategy = ROLLOUT_STRATEGY_PERCENT_ROLLOUT; props.featureFlags[0].scopes[0].rolloutStrategy = ROLLOUT_STRATEGY_PERCENT_ROLLOUT;
......
...@@ -11,6 +11,7 @@ export const featureFlag = { ...@@ -11,6 +11,7 @@ export const featureFlag = {
name: 'test flag', name: 'test flag',
description: 'flag for tests', description: 'flag for tests',
destroy_path: 'feature_flags/1', destroy_path: 'feature_flags/1',
update_path: 'feature_flags/1',
edit_path: 'feature_flags/1/edit', edit_path: 'feature_flags/1/edit',
scopes: [ scopes: [
{ {
......
...@@ -12,13 +12,18 @@ import { ...@@ -12,13 +12,18 @@ import {
requestRotateInstanceId, requestRotateInstanceId,
receiveRotateInstanceIdSuccess, receiveRotateInstanceIdSuccess,
receiveRotateInstanceIdError, receiveRotateInstanceIdError,
toggleFeatureFlag,
updateFeatureFlag,
receiveUpdateFeatureFlagSuccess,
receiveUpdateFeatureFlagError,
} from 'ee/feature_flags/store/modules/index/actions'; } from 'ee/feature_flags/store/modules/index/actions';
import { mapToScopesViewModel } from 'ee/feature_flags/store/modules/helpers';
import state from 'ee/feature_flags/store/modules/index/state'; import state from 'ee/feature_flags/store/modules/index/state';
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 testAction from 'helpers/vuex_action_helper'; import testAction from 'helpers/vuex_action_helper';
import { TEST_HOST } from 'spec/test_constants'; import { TEST_HOST } from 'spec/test_constants';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { getRequestData, rotateData } from '../../mock_data'; import { getRequestData, rotateData, featureFlag } from '../../mock_data';
describe('Feature flags actions', () => { describe('Feature flags actions', () => {
let mockedState; let mockedState;
...@@ -282,4 +287,138 @@ describe('Feature flags actions', () => { ...@@ -282,4 +287,138 @@ describe('Feature flags actions', () => {
); );
}); });
}); });
describe('toggleFeatureFlag', () => {
let mock;
beforeEach(() => {
mockedState.featureFlags = getRequestData.feature_flags.map(flag => ({
...flag,
scopes: mapToScopesViewModel(flag.scopes || []),
}));
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
});
describe('success', () => {
it('dispatches updateFeatureFlag and receiveUpdateFeatureFlagSuccess', done => {
mock.onPut(featureFlag.update_path).replyOnce(200, featureFlag, {});
testAction(
toggleFeatureFlag,
featureFlag,
mockedState,
[],
[
{
type: 'updateFeatureFlag',
payload: featureFlag,
},
{
payload: featureFlag,
type: 'receiveUpdateFeatureFlagSuccess',
},
],
done,
);
});
});
describe('error', () => {
it('dispatches updateFeatureFlag and receiveUpdateFeatureFlagSuccess', done => {
mock.onPut(featureFlag.update_path).replyOnce(500);
testAction(
toggleFeatureFlag,
featureFlag,
mockedState,
[],
[
{
type: 'updateFeatureFlag',
payload: featureFlag,
},
{
payload: featureFlag.id,
type: 'receiveUpdateFeatureFlagError',
},
],
done,
);
});
});
});
describe('updateFeatureFlag', () => {
beforeEach(() => {
mockedState.featureFlags = getRequestData.feature_flags.map(f => ({
...f,
scopes: mapToScopesViewModel(f.scopes || []),
}));
});
it('commits UPDATE_FEATURE_FLAG with the given flag', done => {
testAction(
updateFeatureFlag,
featureFlag,
mockedState,
[
{
type: 'UPDATE_FEATURE_FLAG',
payload: featureFlag,
},
],
[],
done,
);
});
});
describe('receiveUpdateFeatureFlagSuccess', () => {
beforeEach(() => {
mockedState.featureFlags = getRequestData.feature_flags.map(f => ({
...f,
scopes: mapToScopesViewModel(f.scopes || []),
}));
});
it('commits RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS with the given flag', done => {
testAction(
receiveUpdateFeatureFlagSuccess,
featureFlag,
mockedState,
[
{
type: 'RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS',
payload: featureFlag,
},
],
[],
done,
);
});
});
describe('receiveUpdateFeatureFlagError', () => {
beforeEach(() => {
mockedState.featureFlags = getRequestData.feature_flags.map(f => ({
...f,
scopes: mapToScopesViewModel(f.scopes || []),
}));
});
it('commits RECEIVE_UPDATE_FEATURE_FLAG_ERROR with the given flag id', done => {
testAction(
receiveUpdateFeatureFlagError,
featureFlag.id,
mockedState,
[
{
type: 'RECEIVE_UPDATE_FEATURE_FLAG_ERROR',
payload: featureFlag.id,
},
],
[],
done,
);
});
});
}); });
...@@ -3,7 +3,7 @@ import mutations from 'ee/feature_flags/store/modules/index/mutations'; ...@@ -3,7 +3,7 @@ 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 { mapToScopesViewModel } from 'ee/feature_flags/store/modules/helpers'; import { mapToScopesViewModel } from 'ee/feature_flags/store/modules/helpers';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import { getRequestData, rotateData } from '../../mock_data'; import { getRequestData, rotateData, featureFlag } from '../../mock_data';
describe('Feature flags store Mutations', () => { describe('Feature flags store Mutations', () => {
let stateCopy; let stateCopy;
...@@ -75,9 +75,9 @@ describe('Feature flags store Mutations', () => { ...@@ -75,9 +75,9 @@ describe('Feature flags store Mutations', () => {
}); });
it('should set featureFlags with the transformed data', () => { it('should set featureFlags with the transformed data', () => {
const expected = getRequestData.feature_flags.map(f => ({ const expected = getRequestData.feature_flags.map(flag => ({
...f, ...flag,
scopes: mapToScopesViewModel(f.scopes || []), scopes: mapToScopesViewModel(flag.scopes || []),
})); }));
expect(stateCopy.featureFlags).toEqual(expected); expect(stateCopy.featureFlags).toEqual(expected);
...@@ -151,4 +151,92 @@ describe('Feature flags store Mutations', () => { ...@@ -151,4 +151,92 @@ describe('Feature flags store Mutations', () => {
expect(stateCopy.hasRotateError).toBe(true); expect(stateCopy.hasRotateError).toBe(true);
}); });
}); });
describe('UPDATE_FEATURE_FLAG', () => {
beforeEach(() => {
stateCopy.featureFlags = getRequestData.feature_flags.map(flag => ({
...flag,
scopes: mapToScopesViewModel(flag.scopes || []),
}));
stateCopy.count = { enabled: 1, disabled: 0 };
mutations[types.UPDATE_FEATURE_FLAG](stateCopy, {
...featureFlag,
active: false,
});
});
it('should update the flag with the matching ID', () => {
expect(stateCopy.featureFlags).toEqual([
{
...featureFlag,
scopes: mapToScopesViewModel(featureFlag.scopes || []),
active: false,
},
]);
});
it('should update the enabled count', () => {
expect(stateCopy.count.enabled).toBe(0);
});
it('should update the disabled count', () => {
expect(stateCopy.count.disabled).toBe(1);
});
});
describe('RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS', () => {
beforeEach(() => {
stateCopy.featureFlags = getRequestData.feature_flags.map(flag => ({
...flag,
scopes: mapToScopesViewModel(flag.scopes || []),
}));
stateCopy.count = { enabled: 1, disabled: 0 };
mutations[types.RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS](stateCopy, {
...featureFlag,
active: false,
});
});
it('should update the flag with the matching ID', () => {
expect(stateCopy.featureFlags).toEqual([
{
...featureFlag,
scopes: mapToScopesViewModel(featureFlag.scopes || []),
active: false,
},
]);
});
it('should update the enabled count', () => {
expect(stateCopy.count.enabled).toBe(0);
});
it('should update the disabled count', () => {
expect(stateCopy.count.disabled).toBe(1);
});
});
describe('RECEIVE_UPDATE_FEATURE_FLAG_ERROR', () => {
beforeEach(() => {
stateCopy.featureFlags = getRequestData.feature_flags.map(flag => ({
...flag,
scopes: mapToScopesViewModel(flag.scopes || []),
}));
stateCopy.count = { enabled: 1, disabled: 0 };
mutations[types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR](stateCopy, featureFlag.id);
});
it('should update the flag with the matching ID, toggling active', () => {
expect(stateCopy.featureFlags).toEqual([
{
...featureFlag,
scopes: mapToScopesViewModel(featureFlag.scopes || []),
active: false,
},
]);
});
it('should update the enabled count', () => {
expect(stateCopy.count.enabled).toBe(0);
});
it('should update the disabled count', () => {
expect(stateCopy.count.disabled).toBe(1);
});
});
}); });
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