Commit e618f5e8 authored by Andrew Fontaine's avatar Andrew Fontaine

Integrate New Feature Flag Form with New API

This allows the creation of new-style feature flags with strategies,
hidden behind the `:feature_flags_new_version`
parent 919fd2dc
<script> <script>
import Vue from 'vue'; import Vue from 'vue';
import { memoize, isString } from 'lodash'; import { memoize, isString, cloneDeep } from 'lodash';
import { import {
GlButton, GlButton,
GlBadge, GlBadge,
...@@ -8,18 +8,22 @@ import { ...@@ -8,18 +8,22 @@ import {
GlTooltipDirective, GlTooltipDirective,
GlFormTextarea, GlFormTextarea,
GlFormCheckbox, GlFormCheckbox,
GlSprintf,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { s__, sprintf } from '~/locale'; import { s__ } from '~/locale';
import featureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import featureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import ToggleButton from '~/vue_shared/components/toggle_button.vue'; import ToggleButton from '~/vue_shared/components/toggle_button.vue';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import EnvironmentsDropdown from './environments_dropdown.vue'; import EnvironmentsDropdown from './environments_dropdown.vue';
import Strategy from './strategy.vue';
import { import {
ROLLOUT_STRATEGY_ALL_USERS, ROLLOUT_STRATEGY_ALL_USERS,
ROLLOUT_STRATEGY_PERCENT_ROLLOUT, ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
ROLLOUT_STRATEGY_USER_ID, ROLLOUT_STRATEGY_USER_ID,
ALL_ENVIRONMENTS_NAME, ALL_ENVIRONMENTS_NAME,
INTERNAL_ID_PREFIX, INTERNAL_ID_PREFIX,
NEW_VERSION_FLAG,
LEGACY_FLAG,
} from '../constants'; } from '../constants';
import { createNewEnvironmentScope } from '../store/modules/helpers'; import { createNewEnvironmentScope } from '../store/modules/helpers';
...@@ -30,9 +34,11 @@ export default { ...@@ -30,9 +34,11 @@ export default {
GlFormTextarea, GlFormTextarea,
GlFormCheckbox, GlFormCheckbox,
GlTooltip, GlTooltip,
GlSprintf,
ToggleButton, ToggleButton,
Icon, Icon,
EnvironmentsDropdown, EnvironmentsDropdown,
Strategy,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
...@@ -71,22 +77,29 @@ export default { ...@@ -71,22 +77,29 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
strategies: {
type: Array,
required: false,
default: () => [],
},
version: {
type: String,
required: false,
default: LEGACY_FLAG,
},
}, },
translations: {
allEnvironmentsText: s__('FeatureFlags|* (All Environments)'),
allEnvironmentsText: s__('FeatureFlags|* (All Environments)'), helpText: s__(
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}.', '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}.',
), ),
{
codeStart: '<code>', newHelpText: s__(
codeEnd: '</code>', 'FeatureFlags|Enable features for specific users and specific environments by defining feature flag strategies. By default, features are available to all users in all environments.',
boldStart: '<b>', ),
boldEnd: '</b>', noStrategiesText: s__('FeatureFlags|Feature Flag has no strategies'),
}, },
false,
),
ROLLOUT_STRATEGY_ALL_USERS, ROLLOUT_STRATEGY_ALL_USERS,
ROLLOUT_STRATEGY_PERCENT_ROLLOUT, ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
...@@ -102,6 +115,7 @@ export default { ...@@ -102,6 +115,7 @@ export default {
// operate on a clone to avoid mutating props // operate on a clone to avoid mutating props
formScopes: this.scopes.map(s => ({ ...s })), formScopes: this.scopes.map(s => ({ ...s })),
formStrategies: cloneDeep(this.strategies),
newScope: '', newScope: '',
}; };
...@@ -110,14 +124,36 @@ export default { ...@@ -110,14 +124,36 @@ export default {
filteredScopes() { filteredScopes() {
return this.formScopes.filter(scope => !scope.shouldBeDestroyed); return this.formScopes.filter(scope => !scope.shouldBeDestroyed);
}, },
filteredStrategies() {
return this.formStrategies.filter(s => !s.shouldBeDestroyed);
},
canUpdateFlag() { canUpdateFlag() {
return !this.permissionsFlag || (this.formScopes || []).every(scope => scope.canUpdate); return !this.permissionsFlag || (this.formScopes || []).every(scope => scope.canUpdate);
}, },
permissionsFlag() { permissionsFlag() {
return this.glFeatures.featureFlagPermissions; return this.glFeatures.featureFlagPermissions;
}, },
supportsStrategies() {
return this.glFeatures.featureFlagsNewVersion && this.version === NEW_VERSION_FLAG;
},
canDeleteStrategy() {
return this.formStrategies.length > 1;
},
}, },
methods: { methods: {
addStrategy() {
this.formStrategies.push({ name: '', parameters: {}, scopes: [] });
},
deleteStrategy(s) {
if (s.id) {
Vue.set(s, 'shouldBeDestroyed', true);
} else {
this.formStrategies = this.formStrategies.filter(strategy => strategy !== s);
}
},
isAllEnvironment(name) { isAllEnvironment(name) {
return name === ALL_ENVIRONMENTS_NAME; return name === ALL_ENVIRONMENTS_NAME;
}, },
...@@ -157,12 +193,20 @@ export default { ...@@ -157,12 +193,20 @@ export default {
* it triggers an event with the form data * it triggers an event with the form data
*/ */
handleSubmit() { handleSubmit() {
this.$emit('handleSubmit', { const flag = {
name: this.formName, name: this.formName,
description: this.formDescription, description: this.formDescription,
scopes: this.formScopes,
active: this.active, active: this.active,
}); version: this.version,
};
if (this.version === LEGACY_FLAG) {
flag.scopes = this.formScopes;
} else {
flag.strategies = this.formStrategies;
}
this.$emit('handleSubmit', flag);
}, },
canUpdateScope(scope) { canUpdateScope(scope) {
...@@ -208,6 +252,14 @@ export default { ...@@ -208,6 +252,14 @@ export default {
scope.rolloutUserIds.length > 0 && scope.rolloutUserIds.length > 0 &&
scope.rolloutStrategy === ROLLOUT_STRATEGY_PERCENT_ROLLOUT; scope.rolloutStrategy === ROLLOUT_STRATEGY_PERCENT_ROLLOUT;
}, },
onFormStrategyChange({ id, name, parameters, scopes }, index) {
Object.assign(this.filteredStrategies[index], {
id,
name,
parameters,
scopes,
});
},
}, },
}; };
</script> </script>
...@@ -241,10 +293,46 @@ export default { ...@@ -241,10 +293,46 @@ export default {
</div> </div>
</div> </div>
<div class="row"> <template v-if="supportsStrategies">
<div class="row">
<div class="col-md-12">
<h4>{{ s__('FeatureFlags|Strategies') }}</h4>
<div class="flex align-items-baseline justify-content-between">
<p class="mr-3">{{ $options.translations.newHelpText }}</p>
<gl-button variant="success" category="secondary" @click="addStrategy">
{{ s__('FeatureFlags|Add strategy') }}
</gl-button>
</div>
</div>
</div>
<template v-if="filteredStrategies.length > 0">
<strategy
v-for="(strategy, index) in filteredStrategies"
:key="strategy.id"
:strategy="strategy"
:index="index"
:endpoint="environmentsEndpoint"
:can-delete="canDeleteStrategy"
@change="onFormStrategyChange($event, index)"
@delete="deleteStrategy(strategy)"
/>
</template>
<div v-else class="flex justify-content-center border-top py-4 w-100">
<span>{{ $options.translations.noStrategiesText }}</span>
</div>
</template>
<div v-else class="row">
<div class="form-group col-md-12"> <div class="form-group col-md-12">
<h4>{{ s__('FeatureFlags|Target environments') }}</h4> <h4>{{ s__('FeatureFlags|Target environments') }}</h4>
<div v-html="$options.helpText"></div> <gl-sprintf :message="$options.translations.helpText">
<template #code="{ content }">
<code>{{ content }}</code>
</template>
<template #bold="{ content }">
<b>{{ content }}</b>
</template>
</gl-sprintf>
<div class="js-scopes-table prepend-top-default"> <div class="js-scopes-table prepend-top-default">
<div class="gl-responsive-table-row table-row-header" role="row"> <div class="gl-responsive-table-row table-row-header" role="row">
...@@ -274,7 +362,7 @@ export default { ...@@ -274,7 +362,7 @@ export default {
class="table-mobile-content js-feature-flag-status d-flex align-items-center justify-content-start" class="table-mobile-content js-feature-flag-status d-flex align-items-center justify-content-start"
> >
<p v-if="isAllEnvironment(scope.environmentScope)" class="js-scope-all pl-3"> <p v-if="isAllEnvironment(scope.environmentScope)" class="js-scope-all pl-3">
{{ $options.allEnvironmentsText }} {{ $options.translations.allEnvironmentsText }}
</p> </p>
<environments-dropdown <environments-dropdown
......
...@@ -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 { LEGACY_FLAG, NEW_VERSION_FLAG } from '../constants';
import { createNewEnvironmentScope } from '../store/modules/helpers'; import { createNewEnvironmentScope } from '../store/modules/helpers';
import featureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import featureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
...@@ -41,6 +42,12 @@ export default { ...@@ -41,6 +42,12 @@ export default {
), ),
]; ];
}, },
version() {
return this.glFeatures.featureFlagsNewVersion ? NEW_VERSION_FLAG : LEGACY_FLAG;
},
strategies() {
return [{ name: '', parameters: {}, scopes: [] }];
},
}, },
created() { created() {
this.setEndpoint(this.endpoint); this.setEndpoint(this.endpoint);
...@@ -63,7 +70,9 @@ export default { ...@@ -63,7 +70,9 @@ export default {
:cancel-path="path" :cancel-path="path"
:submit-text="s__('FeatureFlags|Create feature flag')" :submit-text="s__('FeatureFlags|Create feature flag')"
:scopes="scopes" :scopes="scopes"
:strategies="strategies"
:environments-endpoint="environmentsEndpoint" :environments-endpoint="environmentsEndpoint"
:version="version"
@handleSubmit="data => createFeatureFlag(data)" @handleSubmit="data => createFeatureFlag(data)"
/> />
</div> </div>
......
...@@ -12,6 +12,7 @@ class Projects::FeatureFlagsController < Projects::ApplicationController ...@@ -12,6 +12,7 @@ class Projects::FeatureFlagsController < Projects::ApplicationController
before_action do before_action do
push_frontend_feature_flag(:feature_flag_permissions) push_frontend_feature_flag(:feature_flag_permissions)
push_frontend_feature_flag(:feature_flags_new_version, project)
end end
def index def index
......
...@@ -12,6 +12,7 @@ describe 'User creates feature flag', :js do ...@@ -12,6 +12,7 @@ describe 'User creates feature flag', :js do
project.add_developer(user) project.add_developer(user)
stub_licensed_features(feature_flags: true) stub_licensed_features(feature_flags: true)
stub_feature_flags(feature_flag_permissions: false) stub_feature_flags(feature_flag_permissions: false)
stub_feature_flags(feature_flags_new_version: false)
sign_in(user) sign_in(user)
end end
......
import _ from 'underscore'; import _ from 'underscore';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { GlFormTextarea, GlFormCheckbox } from '@gitlab/ui'; import { GlFormTextarea, GlFormCheckbox, GlButton } from '@gitlab/ui';
import Form from 'ee/feature_flags/components/form.vue'; import Form from 'ee/feature_flags/components/form.vue';
import EnvironmentsDropdown from 'ee/feature_flags/components/environments_dropdown.vue'; import EnvironmentsDropdown from 'ee/feature_flags/components/environments_dropdown.vue';
import Strategy from 'ee/feature_flags/components/strategy.vue';
import { import {
ROLLOUT_STRATEGY_ALL_USERS, ROLLOUT_STRATEGY_ALL_USERS,
ROLLOUT_STRATEGY_PERCENT_ROLLOUT, ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
INTERNAL_ID_PREFIX, INTERNAL_ID_PREFIX,
DEFAULT_PERCENT_ROLLOUT, DEFAULT_PERCENT_ROLLOUT,
LEGACY_FLAG,
NEW_VERSION_FLAG,
} from 'ee/feature_flags/constants'; } from 'ee/feature_flags/constants';
import ToggleButton from '~/vue_shared/components/toggle_button.vue'; import ToggleButton from '~/vue_shared/components/toggle_button.vue';
import { featureFlag } from '../mock_data'; import { featureFlag } from '../mock_data';
...@@ -26,6 +29,7 @@ describe('feature flag form', () => { ...@@ -26,6 +29,7 @@ describe('feature flag form', () => {
provide: { provide: {
glFeatures: { glFeatures: {
featureFlagPermissions: true, featureFlagPermissions: true,
featureFlagsNewVersion: true,
}, },
}, },
}); });
...@@ -100,6 +104,7 @@ describe('feature flag form', () => { ...@@ -100,6 +104,7 @@ describe('feature flag form', () => {
name: featureFlag.name, name: featureFlag.name,
description: featureFlag.description, description: featureFlag.description,
active: true, active: true,
version: LEGACY_FLAG,
scopes: [ scopes: [
{ {
id: 1, id: 1,
...@@ -380,4 +385,60 @@ describe('feature flag form', () => { ...@@ -380,4 +385,60 @@ describe('feature flag form', () => {
}); });
}); });
}); });
describe('with strategies', () => {
beforeEach(() => {
factory({
...requiredProps,
name: featureFlag.name,
description: featureFlag.description,
active: true,
version: NEW_VERSION_FLAG,
strategies: [
{
type: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
paramters: { percentage: '30' },
scopes: [],
},
{
type: ROLLOUT_STRATEGY_ALL_USERS,
paramters: {},
scopes: [{ environment_scope: 'review/*' }],
},
],
});
});
it('should show the strategy component', () => {
const strategy = wrapper.find(Strategy);
expect(strategy.exists()).toBe(true);
expect(strategy.props('strategy')).toEqual({
type: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
paramters: { percentage: '30' },
scopes: [],
});
});
it('should show one strategy component per strategy', () => {
expect(wrapper.findAll(Strategy)).toHaveLength(2);
});
it('should add a strategy when clicking the Add button', () => {
wrapper.find(GlButton).vm.$emit('click');
return wrapper.vm.$nextTick().then(() => expect(wrapper.findAll(Strategy)).toHaveLength(3));
});
it('should remove a strategy on delete', () => {
const strategy = {
type: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
paramters: { percentage: '30' },
scopes: [],
};
wrapper.find(Strategy).vm.$emit('delete');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.findAll(Strategy)).toHaveLength(1);
expect(wrapper.find(Strategy).props('strategy')).not.toEqual(strategy);
});
});
});
}); });
...@@ -8675,6 +8675,9 @@ msgstr "" ...@@ -8675,6 +8675,9 @@ msgstr ""
msgid "FeatureFlags|Active" msgid "FeatureFlags|Active"
msgstr "" msgstr ""
msgid "FeatureFlags|Add strategy"
msgstr ""
msgid "FeatureFlags|All users" msgid "FeatureFlags|All users"
msgstr "" msgstr ""
...@@ -8699,6 +8702,9 @@ msgstr "" ...@@ -8699,6 +8702,9 @@ msgstr ""
msgid "FeatureFlags|Edit Feature Flag" msgid "FeatureFlags|Edit Feature Flag"
msgstr "" msgstr ""
msgid "FeatureFlags|Enable features for specific users and specific environments by defining feature flag strategies. By default, features are available to all users in all environments."
msgstr ""
msgid "FeatureFlags|Environment Spec" msgid "FeatureFlags|Environment Spec"
msgstr "" msgstr ""
...@@ -8711,6 +8717,9 @@ msgstr "" ...@@ -8711,6 +8717,9 @@ msgstr ""
msgid "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}." msgid "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}."
msgstr "" msgstr ""
msgid "FeatureFlags|Feature Flag has no strategies"
msgstr ""
msgid "FeatureFlags|Feature Flags" msgid "FeatureFlags|Feature Flags"
msgstr "" msgstr ""
...@@ -8780,6 +8789,9 @@ msgstr "" ...@@ -8780,6 +8789,9 @@ msgstr ""
msgid "FeatureFlags|Status" msgid "FeatureFlags|Status"
msgstr "" msgstr ""
msgid "FeatureFlags|Strategies"
msgstr ""
msgid "FeatureFlags|Target environments" msgid "FeatureFlags|Target environments"
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