Commit d33ce3fc authored by Andrew Fontaine's avatar Andrew Fontaine

Add Toggle on Feature Flags Table

This is a WIP
parent 13e60a2b
......@@ -174,6 +174,7 @@ export default {
'setInstanceIdEndpoint',
'setInstanceId',
'rotateInstanceId',
'toggleFeatureFlag',
]),
onChangeTab(scope) {
this.scope = scope;
......@@ -280,6 +281,7 @@ export default {
v-else-if="shouldRenderTable"
:csrf-token="csrfToken"
:feature-flags="featureFlags"
@toggle-flag="toggleFeatureFlag"
/>
<table-pagination v-if="shouldRenderPagination" :change="onChangePage" :page-info="pageInfo" />
......
<script>
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 Icon from '~/vue_shared/components/icon.vue';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
......@@ -12,6 +19,7 @@ export default {
GlLink,
Icon,
GlModal,
GlToggle,
},
directives: {
GlModalDirective,
......@@ -62,6 +70,9 @@ export default {
modalId() {
return 'delete-feature-flag';
},
hasFeatureFlagToggle() {
return this.glFeatures.featureFlagToggle;
},
},
methods: {
scopeTooltipText(scope) {
......@@ -94,6 +105,12 @@ export default {
onSubmit() {
this.$refs.form.submit();
},
toggleFeatureFlag(flag) {
this.$emit('toggle-flag', {
...flag,
active: !flag.active,
});
},
},
};
</script>
......@@ -123,7 +140,12 @@ export default {
<div class="table-section section-10" role="gridcell">
<div class="table-mobile-header" role="rowheader">{{ s__('FeatureFlags|Status') }}</div>
<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') }}
</span>
<span v-else class="badge badge-danger">{{ s__('FeatureFlags|Inactive') }}</span>
......
......@@ -33,6 +33,24 @@ export const receiveFeatureFlagsSuccess = ({ commit }, response) =>
commit(types.RECEIVE_FEATURE_FLAGS_SUCCESS, response);
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 }) => {
dispatch('requestRotateInstanceId');
......
......@@ -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_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 RECEIVE_ROTATE_INSTANCE_ID_SUCCESS = 'RECEIVE_ROTATE_INSTANCE_ID_SUCCESS';
export const RECEIVE_ROTATE_INSTANCE_ID_ERROR = 'RECEIVE_ROTATE_INSTANCE_ID_ERROR';
import Vue from 'vue';
import * as types from './mutation_types';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
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 {
[types.SET_FEATURE_FLAGS_ENDPOINT](state, endpoint) {
state.endpoint = endpoint;
......@@ -22,10 +33,7 @@ export default {
state.isLoading = false;
state.hasError = false;
state.count = response.data.count;
state.featureFlags = (response.data.feature_flags || []).map(f => ({
...f,
scopes: mapToScopesViewModel(f.scopes || []),
}));
state.featureFlags = (response.data.feature_flags || []).map(mapFlag);
let paginationInfo;
if (Object.keys(response.headers).length) {
......@@ -58,4 +66,14 @@ export default {
state.isRotating = false;
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 MockAdapter from 'axios-mock-adapter';
import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
import store from 'ee/feature_flags/store';
import FeatureFlagsComponent from 'ee/feature_flags/components/feature_flags.vue';
import FeatureFlagsTable from 'ee/feature_flags/components/feature_flags_table.vue';
import ConfigureFeatureFlagsModal from 'ee/feature_flags/components/configure_feature_flags_modal.vue';
......@@ -42,6 +43,7 @@ describe('Feature flags', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
jest.spyOn(store, 'dispatch');
});
afterEach(() => {
......@@ -184,7 +186,7 @@ describe('Feature flags', () => {
it('should render a table with feature flags', () => {
const table = wrapper.find(FeatureFlagsTable);
expect(wrapper.find(FeatureFlagsTable).exists()).toBe(true);
expect(table.exists()).toBe(true);
expect(table.props('featureFlags')).toEqual(
expect.arrayContaining([
expect.objectContaining({
......@@ -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', () => {
expect(configureButton().exists()).toBe(true);
});
......
import FeatureFlagsTable from 'ee/feature_flags/components/feature_flags_table.vue';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { GlToggle } from '@gitlab/ui';
import { trimText } from 'helpers/text_helper';
import {
ROLLOUT_STRATEGY_ALL_USERS,
......@@ -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', () => {
beforeEach(() => {
props.featureFlags[0].scopes[0].rolloutStrategy = ROLLOUT_STRATEGY_PERCENT_ROLLOUT;
......
......@@ -11,6 +11,7 @@ export const featureFlag = {
name: 'test flag',
description: 'flag for tests',
destroy_path: 'feature_flags/1',
update_path: 'feature_flags/1',
edit_path: 'feature_flags/1/edit',
scopes: [
{
......
......@@ -12,13 +12,18 @@ import {
requestRotateInstanceId,
receiveRotateInstanceIdSuccess,
receiveRotateInstanceIdError,
toggleFeatureFlag,
updateFeatureFlag,
receiveUpdateFeatureFlagSuccess,
receiveUpdateFeatureFlagError,
} 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 * as types from 'ee/feature_flags/store/modules/index/mutation_types';
import testAction from 'helpers/vuex_action_helper';
import { TEST_HOST } from 'spec/test_constants';
import axios from '~/lib/utils/axios_utils';
import { getRequestData, rotateData } from '../../mock_data';
import { getRequestData, rotateData, featureFlag } from '../../mock_data';
describe('Feature flags actions', () => {
let mockedState;
......@@ -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';
import * as types from 'ee/feature_flags/store/modules/index/mutation_types';
import { mapToScopesViewModel } from 'ee/feature_flags/store/modules/helpers';
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', () => {
let stateCopy;
......@@ -75,9 +75,9 @@ describe('Feature flags store Mutations', () => {
});
it('should set featureFlags with the transformed data', () => {
const expected = getRequestData.feature_flags.map(f => ({
...f,
scopes: mapToScopesViewModel(f.scopes || []),
const expected = getRequestData.feature_flags.map(flag => ({
...flag,
scopes: mapToScopesViewModel(flag.scopes || []),
}));
expect(stateCopy.featureFlags).toEqual(expected);
......@@ -151,4 +151,92 @@ describe('Feature flags store Mutations', () => {
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