Commit 607b9502 authored by Andrew Fontaine's avatar Andrew Fontaine

Add Strategy Component for Feature Flags

As part of the redesign of feature flags to allow a single strategy to
apply to multiple environments, a new component that acts as a form for
a strategy is introduced.

Strategy - Handles setting the strategy type, as well as any necessary
parameters to go along with that strategy.

New Environments Dropdown - A new dropdown that searches and
autocompletes on any environment scopes for feature flags, very similar
to the currently existing Environments Dropdown.
parent b119a04f
......@@ -77,3 +77,5 @@
.gl-text-red-700 { @include gl-text-red-700; }
.gl-text-orange-700 { @include gl-text-orange-700; }
.gl-text-green-700 { @include gl-text-green-700; }
.gl-align-items-center { @include gl-align-items-center; }
<script>
import {
GlNewDropdown,
GlNewDropdownDivider,
GlNewDropdownItem,
GlIcon,
GlLoadingIcon,
GlSearchBoxByType,
} from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import { __, sprintf } from '~/locale';
import createFlash from '~/flash';
export default {
components: {
GlNewDropdown,
GlNewDropdownDivider,
GlNewDropdownItem,
GlSearchBoxByType,
GlIcon,
GlLoadingIcon,
},
props: {
endpoint: {
type: String,
required: true,
},
},
data() {
return {
environmentSearch: '',
results: [],
filter: '',
isLoading: false,
};
},
translations: {
addEnvironmentsLabel: __('Add environment'),
noResultsLabel: __('No matching results'),
},
computed: {
createEnvironmentLabel() {
return sprintf(__('Create %{environment}'), { environment: this.filter });
},
},
methods: {
addEnvironment(newEnvironment) {
this.$emit('add', newEnvironment);
this.environmentSearch = '';
this.filter = '';
},
fetchEnvironments() {
this.filter = this.environmentSearch;
this.isLoading = true;
axios
.get(this.endpoint, { params: { query: this.filter } })
.then(({ data }) => {
this.results = data;
})
.catch(() => {
createFlash(__('Something went wrong on our end. Please try again.'));
})
.finally(() => {
this.isLoading = false;
});
},
},
};
</script>
<template>
<gl-new-dropdown>
<template #button-content>
<span class="d-md-none mr-1">
{{ $options.translations.addEnvironmentsLabel }}
</span>
<gl-icon class="d-none d-md-inline-flex" name="plus" />
</template>
<gl-search-box-by-type
v-model.trim="environmentSearch"
class="m-2"
@input="fetchEnvironments"
/>
<gl-loading-icon v-if="isLoading" />
<gl-new-dropdown-item
v-for="environment in results"
v-else-if="results.length"
:key="environment"
@click="addEnvironment(environment)"
>
{{ environment }}
</gl-new-dropdown-item>
<template v-else-if="filter.length">
<span ref="noResults" class="text-secondary p-2">
{{ $options.translations.noMatchingResults }}
</span>
<gl-new-dropdown-divider />
<gl-new-dropdown-item @click="addEnvironment(filter)">
{{ createEnvironmentLabel }}
</gl-new-dropdown-item>
</template>
</gl-new-dropdown>
</template>
<script>
import {
GlFormSelect,
GlFormInput,
GlFormTextarea,
GlFormGroup,
GlToken,
GlButton,
GlIcon,
} from '@gitlab/ui';
import { s__, __ } from '~/locale';
import {
PERCENT_ROLLOUT_GROUP_ID,
ROLLOUT_STRATEGY_ALL_USERS,
ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
ROLLOUT_STRATEGY_USER_ID,
} from '../constants';
import NewEnvironmentsDropdown from './new_environments_dropdown.vue';
export default {
components: {
GlFormGroup,
GlFormInput,
GlFormTextarea,
GlFormSelect,
GlToken,
GlButton,
GlIcon,
NewEnvironmentsDropdown,
},
model: {
prop: 'strategy',
event: 'change',
},
props: {
strategy: {
type: Object,
required: true,
},
index: {
type: Number,
required: true,
},
endpoint: {
type: String,
required: false,
default: '',
},
canDelete: {
type: Boolean,
required: true,
},
},
ROLLOUT_STRATEGY_ALL_USERS,
ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
ROLLOUT_STRATEGY_USER_ID,
translations: {
allEnvironments: __('All environments'),
environmentsLabel: __('Environments'),
removeLabel: s__('FeatureFlag|Delete strategy'),
rolloutPercentageDescription: __('Enter a whole number between 0 and 100'),
rolloutPercentageInvalid: s__(
'FeatureFlags|Percent rollout must be a whole number between 0 and 100',
),
rolloutPercentageLabel: s__('FeatureFlag|Percentage'),
rolloutUserIdsDescription: __('Enter one or more user ID separated by commas'),
rolloutUserIdsLabel: s__('FeatureFlag|User IDs'),
strategyTypeDescription: __('Select strategy activation method'),
strategyTypeLabel: s__('FeatureFlag|Type'),
},
data() {
return {
environments: this.strategy.scopes || [],
formStrategy: { ...this.strategy },
formPercentage:
this.strategy.name === ROLLOUT_STRATEGY_PERCENT_ROLLOUT
? this.strategy.parameters.percentage
: '',
formUserIds:
this.strategy.name === ROLLOUT_STRATEGY_USER_ID ? this.strategy.parameters.userIds : '',
strategies: [
{
value: ROLLOUT_STRATEGY_ALL_USERS,
text: __('All users'),
},
{
value: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
text: __('Percent rollout (logged in users)'),
},
{
value: ROLLOUT_STRATEGY_USER_ID,
text: __('User IDs'),
},
],
};
},
computed: {
strategyTypeId() {
return `strategy-type-${this.index}`;
},
strategyPercentageId() {
return `strategy-percentage-${this.index}`;
},
strategyUserIdsId() {
return `strategy-user-ids-${this.index}`;
},
environmentsDropdownId() {
return `environments-dropdown-${this.index}`;
},
isPercentRollout() {
return this.isStrategyType(ROLLOUT_STRATEGY_PERCENT_ROLLOUT);
},
isUserWithId() {
return this.isStrategyType(ROLLOUT_STRATEGY_USER_ID);
},
hasNoDefinedEnvironments() {
return this.environments.length === 0;
},
},
methods: {
addEnvironment(environment) {
this.environments.push(environment);
this.onStrategyChange();
},
onStrategyChange() {
const parameters = {};
switch (this.formStrategy.name) {
case ROLLOUT_STRATEGY_PERCENT_ROLLOUT:
parameters.percentage = this.formPercentage;
parameters.groupId = PERCENT_ROLLOUT_GROUP_ID;
break;
case ROLLOUT_STRATEGY_USER_ID:
parameters.userIds = this.formUserIds;
break;
default:
break;
}
this.$emit('change', {
...this.formStrategy,
parameters,
scopes: this.environments,
});
},
removeScope(environment) {
this.environments = this.environments.filter(e => e !== environment);
this.onStrategyChange();
},
isStrategyType(type) {
return this.formStrategy.name === type;
},
},
};
</script>
<template>
<div>
<div class="flex flex-column flex-md-row flex-md-wrap">
<div class="mr-5">
<gl-form-group
:label="$options.translations.strategyTypeLabel"
:description="$options.translations.strategyTypeDescription"
:label-for="strategyTypeId"
>
<gl-form-select
:id="strategyTypeId"
v-model="formStrategy.name"
:options="strategies"
@change="onStrategyChange"
/>
</gl-form-group>
</div>
<div>
<gl-form-group
v-if="isPercentRollout"
:label="$options.translations.rolloutPercentageLabel"
:description="$options.translations.rolloutPercentageDescription"
:label-for="strategyPercentageId"
:invalid-feedback="$options.translations.rolloutPercentageInvalid"
>
<div class="flex align-items-center">
<gl-form-input
:id="strategyPercentageId"
v-model="formPercentage"
class="rollout-percentage text-right w-3rem"
type="number"
@input="onStrategyChange"
/>
<span class="ml-1">%</span>
</div>
</gl-form-group>
<gl-form-group
v-if="isUserWithId"
:label="$options.translations.rolloutUserIdsLabel"
:description="$options.translations.rolloutUserIdsDescription"
:label-for="strategyUserIdsId"
>
<gl-form-textarea
:id="strategyUserIdsId"
v-model="formUserIds"
@input="onStrategyChange"
/>
</gl-form-group>
</div>
<div class="align-self-end align-self-md-stretch order-first offset-md-0 order-md-0 ml-auto">
<gl-button v-if="canDelete" variant="danger">
<span class="d-md-none">
{{ $options.translations.removeLabel }}
</span>
<gl-icon class="d-none d-md-inline-flex" name="remove" />
</gl-button>
</div>
</div>
<div class="flex flex-column">
<label :for="environmentsDropdownId">{{ $options.translations.environmentsLabel }}</label>
<div class="flex flex-column flex-md-row align-items-start align-items-md-center">
<new-environments-dropdown
:id="environmentsDropdownId"
:endpoint="endpoint"
class="mr-2"
@add="addEnvironment"
/>
<span v-if="hasNoDefinedEnvironments" class="text-secondary mt-2 mt-md-0 ml-md-3">
{{ $options.translations.allEnvironments }}
</span>
<div v-else class="flex align-items-center">
<gl-token
v-for="environment in environments"
:key="environment"
class="mt-2 mr-2 mt-md-0 mr-md-0 ml-md-2 rounded-pill"
@close="removeScope(environment)"
>
{{ environment }}
</gl-token>
</div>
</div>
</div>
</div>
</template>
import { shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import { GlLoadingIcon, GlSearchBoxByType, GlNewDropdownItem } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import NewEnvironmentsDropdown from 'ee/feature_flags/components/new_environments_dropdown.vue';
const TEST_HOST = '/test';
const TEST_SEARCH = 'production';
describe('New Environments Dropdown', () => {
let wrapper;
let axiosMock;
beforeEach(() => {
axiosMock = new MockAdapter(axios);
wrapper = shallowMount(NewEnvironmentsDropdown, { propsData: { endpoint: TEST_HOST } });
});
afterEach(() => {
axiosMock.restore();
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
});
describe('before results', () => {
it('should show a loading icon', () => {
axiosMock.onGet(TEST_HOST).reply(() => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
});
wrapper.find(GlSearchBoxByType).vm.$emit('input', TEST_SEARCH);
return axios.waitForAll();
});
it('should not show any dropdown items', () => {
axiosMock.onGet(TEST_HOST).reply(() => {
expect(wrapper.findAll(GlNewDropdownItem)).toHaveLength(0);
});
wrapper.find(GlSearchBoxByType).vm.$emit('input', TEST_SEARCH);
return axios.waitForAll();
});
});
describe('with empty results', () => {
let item;
beforeEach(() => {
axiosMock.onGet(TEST_HOST).reply(200, []);
wrapper.find(GlSearchBoxByType).vm.$emit('input', TEST_SEARCH);
return axios
.waitForAll()
.then(() => wrapper.vm.$nextTick())
.then(() => {
item = wrapper.find(GlNewDropdownItem);
});
});
it('should display a Create item label', () => {
expect(item.text()).toBe('Create production');
});
it('should display that no matching items are found', () => {
expect(wrapper.find({ ref: 'noResults' }).exists()).toBe(true);
});
it('should emit a new scope when selected', () => {
item.vm.$emit('click');
expect(wrapper.emitted('add')).toEqual([[TEST_SEARCH]]);
});
});
describe('with results', () => {
let items;
beforeEach(() => {
axiosMock.onGet(TEST_HOST).reply(200, ['prod', 'production']);
wrapper.find(GlSearchBoxByType).vm.$emit('input', 'prod');
return axios.waitForAll().then(() => {
items = wrapper.findAll(GlNewDropdownItem);
});
});
it('should display one item per result', () => {
expect(items).toHaveLength(2);
});
it('should emit an add if an item is clicked', () => {
items.at(0).vm.$emit('click');
expect(wrapper.emitted('add')).toEqual([['prod']]);
});
it('should not display a create label', () => {
items = items.filter(i => i.text().startsWith('Create'));
expect(items).toHaveLength(0);
});
it('should not display a message about no results', () => {
expect(wrapper.find({ ref: 'noResults' }).exists()).toBe(false);
});
});
});
import { shallowMount } from '@vue/test-utils';
import { GlFormSelect, GlFormTextarea, GlFormInput, GlToken } from '@gitlab/ui';
import {
PERCENT_ROLLOUT_GROUP_ID,
ROLLOUT_STRATEGY_ALL_USERS,
ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
ROLLOUT_STRATEGY_USER_ID,
} from 'ee/feature_flags/constants';
import Strategy from 'ee/feature_flags/components/strategy.vue';
import NewEnvironmentsDropdown from 'ee/feature_flags/components/new_environments_dropdown.vue';
describe('Feature flags strategy', () => {
let wrapper;
const factory = (
opts = {
propsData: {
strategy: {},
index: 0,
endpoint: '',
canDelete: true,
},
},
) => {
wrapper = shallowMount(Strategy, opts);
};
afterEach(() => {
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
});
describe.each`
name | parameter | value | input
${ROLLOUT_STRATEGY_ALL_USERS} | ${null} | ${null} | ${null}
${ROLLOUT_STRATEGY_PERCENT_ROLLOUT} | ${'percentage'} | ${'50'} | ${GlFormInput}
${ROLLOUT_STRATEGY_USER_ID} | ${'userIds'} | ${'1,2'} | ${GlFormTextarea}
`('with strategy $name', ({ name, parameter, value, input }) => {
let propsData;
let strategy;
beforeEach(() => {
const parameters = {};
if (parameter !== null) {
parameters[parameter] = value;
}
strategy = { name, parameters };
propsData = { strategy, index: 0, endpoint: '', canDelete: true };
factory({ propsData });
});
it('should set the select to match the strategy name', () => {
expect(wrapper.find(GlFormSelect).attributes('value')).toBe(name);
});
it('should not show inputs for other paramters', () => {
[GlFormTextarea, GlFormInput]
.filter(component => component !== input)
.map(component => wrapper.findAll(component))
.forEach(inputWrapper => expect(inputWrapper).toHaveLength(0));
});
if (parameter !== null) {
it(`should show the input for ${parameter} with the correct value`, () => {
const inputWrapper = wrapper.find(input);
expect(inputWrapper.exists()).toBe(true);
expect(inputWrapper.attributes('value')).toBe(value);
});
it(`should emit a change event when altering ${parameter}`, () => {
const inputWrapper = wrapper.find(input);
inputWrapper.vm.$emit('input', '');
expect(wrapper.emitted('change')).toEqual([
[{ name, parameters: expect.objectContaining({ [parameter]: '' }), scopes: [] }],
]);
});
}
});
describe('with a strategy', () => {
beforeEach(() => {
const strategy = {
name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
parameters: { percentage: '50' },
scopes: [],
};
const propsData = { strategy, index: 0, endpoint: '', canDelete: true };
factory({ propsData });
});
it('should change the parameters if a different strategy is chosen', () => {
const select = wrapper.find(GlFormSelect);
select.vm.$emit('input', ROLLOUT_STRATEGY_ALL_USERS);
select.vm.$emit('change', ROLLOUT_STRATEGY_ALL_USERS);
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.find(GlFormInput).exists()).toBe(false);
expect(wrapper.emitted('change')).toEqual([
[{ name: ROLLOUT_STRATEGY_ALL_USERS, parameters: {}, scopes: [] }],
]);
});
});
it('should display selected scopes', () => {
const dropdown = wrapper.find(NewEnvironmentsDropdown);
dropdown.vm.$emit('add', 'production');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.findAll(GlToken)).toHaveLength(1);
expect(wrapper.find(GlToken).text()).toBe('production');
});
});
it('should display all selected scopes', () => {
const dropdown = wrapper.find(NewEnvironmentsDropdown);
dropdown.vm.$emit('add', 'production');
dropdown.vm.$emit('add', 'staging');
return wrapper.vm.$nextTick().then(() => {
const tokens = wrapper.findAll(GlToken);
expect(tokens).toHaveLength(2);
expect(tokens.at(0).text()).toBe('production');
expect(tokens.at(1).text()).toBe('staging');
});
});
it('should emit selected scopes', () => {
const dropdown = wrapper.find(NewEnvironmentsDropdown);
dropdown.vm.$emit('add', 'production');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.emitted('change')).toEqual([
[
{
name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
parameters: { percentage: '50', groupId: PERCENT_ROLLOUT_GROUP_ID },
scopes: ['production'],
},
],
]);
});
});
});
});
......@@ -1137,6 +1137,9 @@ msgstr ""
msgid "Add email address"
msgstr ""
msgid "Add environment"
msgstr ""
msgid "Add header and footer to emails. Please note that color settings will only be applied within the application interface"
msgstr ""
......@@ -5601,6 +5604,9 @@ msgstr ""
msgid "Create"
msgstr ""
msgid "Create %{environment}"
msgstr ""
msgid "Create %{type} token"
msgstr ""
......@@ -7418,6 +7424,9 @@ msgstr ""
msgid "Enter a number"
msgstr ""
msgid "Enter a whole number between 0 and 100"
msgstr ""
msgid "Enter at least three characters to search"
msgstr ""
......@@ -7442,6 +7451,9 @@ msgstr ""
msgid "Enter number of issues"
msgstr ""
msgid "Enter one or more user ID separated by commas"
msgstr ""
msgid "Enter the issue description"
msgstr ""
......@@ -8537,6 +8549,18 @@ msgstr ""
msgid "FeatureFlags|User IDs"
msgstr ""
msgid "FeatureFlag|Delete strategy"
msgstr ""
msgid "FeatureFlag|Percentage"
msgstr ""
msgid "FeatureFlag|Type"
msgstr ""
msgid "FeatureFlag|User IDs"
msgstr ""
msgid "Feb"
msgstr ""
......@@ -13884,6 +13908,9 @@ msgstr ""
msgid "People without permission will never get a notification."
msgstr ""
msgid "Percent rollout (logged in users)"
msgstr ""
msgid "Percentage"
msgstr ""
......@@ -17411,6 +17438,9 @@ msgstr ""
msgid "Select source branch"
msgstr ""
msgid "Select strategy activation method"
msgstr ""
msgid "Select target branch"
msgstr ""
......@@ -21366,6 +21396,9 @@ msgstr ""
msgid "User Cohorts are only shown when the %{usage_ping_link_start}usage ping%{usage_ping_link_end} is enabled."
msgstr ""
msgid "User IDs"
msgstr ""
msgid "User OAuth applications"
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