Commit acf57529 authored by Amy Troschinetz's avatar Amy Troschinetz

Adds UX for plan limits for feature flags

**app/assets/javascripts/feature_flags/components/feature_flags.vue:**
**spec/frontend/feature_flags/components/feature_flags_spec.js:**

Implement disabling of add button with tool tip when limit reached.

**locale/gitlab.pot:**
**app/assets/javascripts/feature_flags/index.js:**
**app/views/projects/feature_flags/index.html.haml:**
**changelogs/unreleased/feature-flag-limits-ux.yml:**

Boilerplate.

**doc/operations/feature_flags.md:**

Documentation.
parent 1f89ccc1
<script> <script>
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex';
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
import { GlButton, GlModalDirective, GlTabs } from '@gitlab/ui'; import { GlAlert, GlButton, GlModalDirective, GlSprintf, GlTabs } from '@gitlab/ui';
import { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE } from '../constants'; import { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE } from '../constants';
import FeatureFlagsTab from './feature_flags_tab.vue'; import FeatureFlagsTab from './feature_flags_tab.vue';
import FeatureFlagsTable from './feature_flags_table.vue'; import FeatureFlagsTable from './feature_flags_table.vue';
...@@ -9,9 +10,9 @@ import UserListsTable from './user_lists_table.vue'; ...@@ -9,9 +10,9 @@ import UserListsTable from './user_lists_table.vue';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
import { import {
buildUrlWithCurrentLocation,
getParameterByName, getParameterByName,
historyPushState, historyPushState,
buildUrlWithCurrentLocation,
} from '~/lib/utils/common_utils'; } from '~/lib/utils/common_utils';
import ConfigureFeatureFlagsModal from './configure_feature_flags_modal.vue'; import ConfigureFeatureFlagsModal from './configure_feature_flags_modal.vue';
...@@ -20,13 +21,15 @@ const SCOPES = { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE }; ...@@ -20,13 +21,15 @@ const SCOPES = { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE };
export default { export default {
components: { components: {
ConfigureFeatureFlagsModal,
FeatureFlagsTab,
FeatureFlagsTable, FeatureFlagsTable,
UserListsTable, GlAlert,
TablePagination,
GlButton, GlButton,
GlSprintf,
GlTabs, GlTabs,
FeatureFlagsTab, TablePagination,
ConfigureFeatureFlagsModal, UserListsTable,
}, },
directives: { directives: {
GlModal: GlModalDirective, GlModal: GlModalDirective,
...@@ -44,6 +47,20 @@ export default { ...@@ -44,6 +47,20 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
featureFlagsLimit: {
type: String,
required: true,
},
featureFlagsLimitExceeded: {
type: Boolean,
required: false,
default: false,
},
rotateInstanceIdPath: {
type: String,
required: false,
default: '',
},
unleashApiUrl: { unleashApiUrl: {
type: String, type: String,
required: true, required: true,
...@@ -69,6 +86,7 @@ export default { ...@@ -69,6 +86,7 @@ export default {
scope, scope,
page: getParameterByName('page') || '1', page: getParameterByName('page') || '1',
isUserListAlertDismissed: false, isUserListAlertDismissed: false,
shouldShowFeatureFlagsLimitWarning: this.featureFlagsLimitExceeded,
selectedTab: Object.values(SCOPES).indexOf(scope), selectedTab: Object.values(SCOPES).indexOf(scope),
}; };
}, },
...@@ -184,11 +202,36 @@ export default { ...@@ -184,11 +202,36 @@ export default {
dataForScope(scope) { dataForScope(scope) {
return this[scope]; return this[scope];
}, },
onDismissFeatureFlagsLimitWarning() {
this.shouldShowFeatureFlagsLimitWarning = false;
},
onNewFeatureFlagCLick() {
if (this.featureFlagsLimitExceeded) {
this.shouldShowFeatureFlagsLimitWarning = true;
}
},
}, },
}; };
</script> </script>
<template> <template>
<div> <div>
<gl-alert
v-if="shouldShowFeatureFlagsLimitWarning"
variant="warning"
@dismiss="onDismissFeatureFlagsLimitWarning"
>
<gl-sprintf
:message="
s__(
'FeatureFlags|Feature flags limit reached (%{featureFlagsLimit}). Delete one or more feature flags before adding new ones.',
)
"
>
<template #featureFlagsLimit>
<span>{{ featureFlagsLimit }}</span>
</template>
</gl-sprintf>
</gl-alert>
<configure-feature-flags-modal <configure-feature-flags-modal
v-if="canUserConfigure" v-if="canUserConfigure"
:help-client-libraries-path="featureFlagsClientLibrariesHelpPagePath" :help-client-libraries-path="featureFlagsClientLibrariesHelpPagePath"
...@@ -228,9 +271,10 @@ export default { ...@@ -228,9 +271,10 @@ export default {
<gl-button <gl-button
v-if="hasNewPath" v-if="hasNewPath"
:href="newFeatureFlagPath" :href="featureFlagsLimitExceeded ? '' : newFeatureFlagPath"
variant="success" variant="success"
data-testid="ff-new-button" data-testid="ff-new-button"
@click="onNewFeatureFlagCLick"
> >
{{ s__('FeatureFlags|New feature flag') }} {{ s__('FeatureFlags|New feature flag') }}
</gl-button> </gl-button>
...@@ -306,9 +350,10 @@ export default { ...@@ -306,9 +350,10 @@ export default {
<gl-button <gl-button
v-if="hasNewPath" v-if="hasNewPath"
:href="newFeatureFlagPath" :href="featureFlagsLimitExceeded ? '' : newFeatureFlagPath"
variant="success" variant="success"
data-testid="ff-new-button" data-testid="ff-new-button"
@click="onNewFeatureFlagCLick"
> >
{{ s__('FeatureFlags|New feature flag') }} {{ s__('FeatureFlags|New feature flag') }}
</gl-button> </gl-button>
......
...@@ -36,6 +36,8 @@ export default () => { ...@@ -36,6 +36,8 @@ export default () => {
el.dataset.featureFlagsClientLibrariesHelpPagePath, el.dataset.featureFlagsClientLibrariesHelpPagePath,
featureFlagsClientExampleHelpPagePath: el.dataset.featureFlagsClientExampleHelpPagePath, featureFlagsClientExampleHelpPagePath: el.dataset.featureFlagsClientExampleHelpPagePath,
unleashApiUrl: el.dataset.unleashApiUrl, unleashApiUrl: el.dataset.unleashApiUrl,
featureFlagsLimitExceeded: el.dataset.featureFlagsLimitExceeded,
featureFlagsLimit: el.dataset.featureFlagsLimit,
csrfToken: csrf.token, csrfToken: csrf.token,
canUserConfigure: el.dataset.canUserAdminFeatureFlag, canUserConfigure: el.dataset.canUserAdminFeatureFlag,
newFeatureFlagPath: el.dataset.newFeatureFlagPath, newFeatureFlagPath: el.dataset.newFeatureFlagPath,
......
...@@ -7,6 +7,8 @@ ...@@ -7,6 +7,8 @@
"feature-flags-help-page-path" => help_page_path("operations/feature_flags"), "feature-flags-help-page-path" => help_page_path("operations/feature_flags"),
"feature-flags-client-libraries-help-page-path" => help_page_path("operations/feature_flags", anchor: "choose-a-client-library"), "feature-flags-client-libraries-help-page-path" => help_page_path("operations/feature_flags", anchor: "choose-a-client-library"),
"feature-flags-client-example-help-page-path" => help_page_path("operations/feature_flags", anchor: "golang-application-example"), "feature-flags-client-example-help-page-path" => help_page_path("operations/feature_flags", anchor: "golang-application-example"),
"feature-flags-limit-exceeded" => @project.actual_limits.exceeded?(:project_feature_flags, @project.operations_feature_flags.count),
"feature-flags-limit" => @project.actual_limits.project_feature_flags,
"unleash-api-url" => (unleash_api_url(@project) if can?(current_user, :admin_feature_flag, @project)), "unleash-api-url" => (unleash_api_url(@project) if can?(current_user, :admin_feature_flag, @project)),
"unleash-api-instance-id" => (unleash_api_instance_id(@project) if can?(current_user, :admin_feature_flag, @project)), "unleash-api-instance-id" => (unleash_api_instance_id(@project) if can?(current_user, :admin_feature_flag, @project)),
"can-user-admin-feature-flag" => can?(current_user, :admin_feature_flag, @project), "can-user-admin-feature-flag" => can?(current_user, :admin_feature_flag, @project),
......
---
title: Feature Flags limits UX and documentation
merge_request: 44089
author:
type: added
...@@ -56,6 +56,20 @@ To create and enable a feature flag: ...@@ -56,6 +56,20 @@ To create and enable a feature flag:
You can change these settings by clicking the **{pencil}** (edit) button You can change these settings by clicking the **{pencil}** (edit) button
next to any feature flag in the list. next to any feature flag in the list.
## Maximum number of feature flags
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/254379) in GitLab 13.5.
The maximum number of feature flags per project on self-managed GitLab instances
is 200. On GitLab.com, the maximum number is determined by [GitLab.com tier](https://about.gitlab.com/pricing/):
| Tier | Number of feature flags per project |
|----------|-------------------------------------|
| Free | 50 |
| Bronze | 100 |
| Silver | 150 |
| Gold | 200 |
## Feature flag strategies ## Feature flag strategies
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/35555) in GitLab 13.0. > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/35555) in GitLab 13.0.
......
...@@ -11152,6 +11152,9 @@ msgstr "" ...@@ -11152,6 +11152,9 @@ msgstr ""
msgid "FeatureFlags|Feature flags allow you to configure your code into different flavors by dynamically toggling certain functionality." msgid "FeatureFlags|Feature flags allow you to configure your code into different flavors by dynamically toggling certain functionality."
msgstr "" msgstr ""
msgid "FeatureFlags|Feature flags limit reached (%{featureFlagsLimit}). Delete one or more feature flags before adding new ones."
msgstr ""
msgid "FeatureFlags|Flag becomes read only soon" msgid "FeatureFlags|Flag becomes read only soon"
msgstr "" msgstr ""
......
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex'; import Vuex from 'vuex';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui'; import { GlAlert, GlEmptyState, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
import { TEST_HOST } from 'spec/test_constants'; import { TEST_HOST } from 'spec/test_constants';
import Api from '~/api'; import Api from '~/api';
import createStore from '~/feature_flags/store/index'; import createStore from '~/feature_flags/store/index';
...@@ -20,14 +20,17 @@ localVue.use(Vuex); ...@@ -20,14 +20,17 @@ localVue.use(Vuex);
describe('Feature flags', () => { describe('Feature flags', () => {
const mockData = { const mockData = {
canUserConfigure: true,
// canUserRotateToken: true,
csrfToken: 'testToken', csrfToken: 'testToken',
featureFlagsClientLibrariesHelpPagePath: '/help/feature-flags#unleash-clients',
featureFlagsClientExampleHelpPagePath: '/help/feature-flags#client-example', featureFlagsClientExampleHelpPagePath: '/help/feature-flags#client-example',
unleashApiUrl: `${TEST_HOST}/api/unleash`, featureFlagsClientLibrariesHelpPagePath: '/help/feature-flags#unleash-clients',
canUserConfigure: true, featureFlagsHelpPagePath: '/help/feature-flags',
canUserRotateToken: true, featureFlagsLimit: '200',
featureFlagsLimitExceeded: false,
newFeatureFlagPath: 'feature-flags/new', newFeatureFlagPath: 'feature-flags/new',
newUserListPath: '/user-list/new', newUserListPath: '/user-list/new',
unleashApiUrl: `${TEST_HOST}/api/unleash`,
}; };
const mockState = { const mockState = {
...@@ -60,6 +63,7 @@ describe('Feature flags', () => { ...@@ -60,6 +63,7 @@ describe('Feature flags', () => {
const configureButton = () => wrapper.find('[data-testid="ff-configure-button"]'); const configureButton = () => wrapper.find('[data-testid="ff-configure-button"]');
const newButton = () => wrapper.find('[data-testid="ff-new-button"]'); const newButton = () => wrapper.find('[data-testid="ff-new-button"]');
const newUserListButton = () => wrapper.find('[data-testid="ff-new-list-button"]'); const newUserListButton = () => wrapper.find('[data-testid="ff-new-list-button"]');
const limitAlert = () => wrapper.find(GlAlert);
beforeEach(() => { beforeEach(() => {
mock = new MockAdapter(axios); mock = new MockAdapter(axios);
...@@ -82,28 +86,64 @@ describe('Feature flags', () => { ...@@ -82,28 +86,64 @@ describe('Feature flags', () => {
wrapper = null; wrapper = null;
}); });
describe('when limit exceeded', () => {
const propsData = { ...mockData, featureFlagsLimitExceeded: true };
beforeEach(done => {
mock
.onGet(`${TEST_HOST}/endpoint.json`, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } })
.reply(200, getRequestData, {});
factory(propsData);
setImmediate(done);
});
it('makes the new feature flag button do nothing if clicked', () => {
expect(newButton().exists()).toBe(true);
expect(newButton().props('disabled')).toBe(false);
expect(newButton().props('href')).toBe(undefined);
});
it('shows a feature flags limit reached alert', () => {
expect(limitAlert().exists()).toBe(true);
expect(
limitAlert()
.find(GlSprintf)
.attributes('message'),
).toContain('Feature flags limit reached');
});
describe('when the alert is dismissed', () => {
beforeEach(async () => {
await limitAlert().vm.$emit('dismiss');
});
it('hides the alert', async () => {
expect(limitAlert().exists()).toBe(false);
});
it('re-shows the alert if the new feature flag button is clicked', async () => {
await newButton().vm.$emit('click');
expect(limitAlert().exists()).toBe(true);
});
});
});
describe('without permissions', () => { describe('without permissions', () => {
const propsData = { const propsData = {
csrfToken: 'testToken', ...mockData,
errorStateSvgPath: '/assets/illustrations/feature_flag.svg',
featureFlagsHelpPagePath: '/help/feature-flags',
canUserConfigure: false, canUserConfigure: false,
canUserRotateToken: false, canUserRotateToken: false,
featureFlagsClientLibrariesHelpPagePath: '/help/feature-flags#unleash-clients', newFeatureFlagPath: null,
featureFlagsClientExampleHelpPagePath: '/help/feature-flags#client-example', newUserListPath: null,
unleashApiUrl: `${TEST_HOST}/api/unleash`,
}; };
beforeEach(done => { beforeEach(done => {
mock mock
.onGet(`${TEST_HOST}/endpoint.json`, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } }) .onGet(`${TEST_HOST}/endpoint.json`, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } })
.reply(200, getRequestData, {}); .reply(200, getRequestData, {});
factory(propsData); factory(propsData);
setImmediate(done);
setImmediate(() => {
done();
});
}); });
it('does not render configure button', () => { it('does not render configure button', () => {
...@@ -197,9 +237,7 @@ describe('Feature flags', () => { ...@@ -197,9 +237,7 @@ describe('Feature flags', () => {
factory(); factory();
jest.spyOn(store, 'dispatch'); jest.spyOn(store, 'dispatch');
setImmediate(() => { setImmediate(done);
done();
});
}); });
it('should render a table with feature flags', () => { it('should render a table with feature flags', () => {
...@@ -267,10 +305,7 @@ describe('Feature flags', () => { ...@@ -267,10 +305,7 @@ describe('Feature flags', () => {
describe('in user lists tab', () => { describe('in user lists tab', () => {
beforeEach(done => { beforeEach(done => {
factory(); factory();
setImmediate(done);
setImmediate(() => {
done();
});
}); });
beforeEach(() => { beforeEach(() => {
wrapper.find('[data-testid="user-lists-tab"]').vm.$emit('changeTab'); wrapper.find('[data-testid="user-lists-tab"]').vm.$emit('changeTab');
...@@ -295,10 +330,7 @@ describe('Feature flags', () => { ...@@ -295,10 +330,7 @@ describe('Feature flags', () => {
Api.fetchFeatureFlagUserLists.mockRejectedValueOnce(); Api.fetchFeatureFlagUserLists.mockRejectedValueOnce();
factory(); factory();
setImmediate(done);
setImmediate(() => {
done();
});
}); });
it('should render error state', () => { it('should render error state', () => {
...@@ -329,10 +361,7 @@ describe('Feature flags', () => { ...@@ -329,10 +361,7 @@ describe('Feature flags', () => {
.onGet(`${TEST_HOST}/endpoint.json`, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } }) .onGet(`${TEST_HOST}/endpoint.json`, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } })
.reply(200, getRequestData, {}); .reply(200, getRequestData, {});
factory(); factory();
setImmediate(done);
setImmediate(() => {
done();
});
}); });
it('should fire the rotate action when a `token` event is received', () => { it('should fire the rotate action when a `token` event is received', () => {
......
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