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>
import { mapState, mapActions } from 'vuex';
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 FeatureFlagsTab from './feature_flags_tab.vue';
import FeatureFlagsTable from './feature_flags_table.vue';
......@@ -9,9 +10,9 @@ import UserListsTable from './user_lists_table.vue';
import { s__ } from '~/locale';
import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
import {
buildUrlWithCurrentLocation,
getParameterByName,
historyPushState,
buildUrlWithCurrentLocation,
} from '~/lib/utils/common_utils';
import ConfigureFeatureFlagsModal from './configure_feature_flags_modal.vue';
......@@ -20,13 +21,15 @@ const SCOPES = { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE };
export default {
components: {
ConfigureFeatureFlagsModal,
FeatureFlagsTab,
FeatureFlagsTable,
UserListsTable,
TablePagination,
GlAlert,
GlButton,
GlSprintf,
GlTabs,
FeatureFlagsTab,
ConfigureFeatureFlagsModal,
TablePagination,
UserListsTable,
},
directives: {
GlModal: GlModalDirective,
......@@ -44,6 +47,20 @@ export default {
type: String,
required: true,
},
featureFlagsLimit: {
type: String,
required: true,
},
featureFlagsLimitExceeded: {
type: Boolean,
required: false,
default: false,
},
rotateInstanceIdPath: {
type: String,
required: false,
default: '',
},
unleashApiUrl: {
type: String,
required: true,
......@@ -69,6 +86,7 @@ export default {
scope,
page: getParameterByName('page') || '1',
isUserListAlertDismissed: false,
shouldShowFeatureFlagsLimitWarning: this.featureFlagsLimitExceeded,
selectedTab: Object.values(SCOPES).indexOf(scope),
};
},
......@@ -184,11 +202,36 @@ export default {
dataForScope(scope) {
return this[scope];
},
onDismissFeatureFlagsLimitWarning() {
this.shouldShowFeatureFlagsLimitWarning = false;
},
onNewFeatureFlagCLick() {
if (this.featureFlagsLimitExceeded) {
this.shouldShowFeatureFlagsLimitWarning = true;
}
},
},
};
</script>
<template>
<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
v-if="canUserConfigure"
:help-client-libraries-path="featureFlagsClientLibrariesHelpPagePath"
......@@ -228,9 +271,10 @@ export default {
<gl-button
v-if="hasNewPath"
:href="newFeatureFlagPath"
:href="featureFlagsLimitExceeded ? '' : newFeatureFlagPath"
variant="success"
data-testid="ff-new-button"
@click="onNewFeatureFlagCLick"
>
{{ s__('FeatureFlags|New feature flag') }}
</gl-button>
......@@ -306,9 +350,10 @@ export default {
<gl-button
v-if="hasNewPath"
:href="newFeatureFlagPath"
:href="featureFlagsLimitExceeded ? '' : newFeatureFlagPath"
variant="success"
data-testid="ff-new-button"
@click="onNewFeatureFlagCLick"
>
{{ s__('FeatureFlags|New feature flag') }}
</gl-button>
......
......@@ -36,6 +36,8 @@ export default () => {
el.dataset.featureFlagsClientLibrariesHelpPagePath,
featureFlagsClientExampleHelpPagePath: el.dataset.featureFlagsClientExampleHelpPagePath,
unleashApiUrl: el.dataset.unleashApiUrl,
featureFlagsLimitExceeded: el.dataset.featureFlagsLimitExceeded,
featureFlagsLimit: el.dataset.featureFlagsLimit,
csrfToken: csrf.token,
canUserConfigure: el.dataset.canUserAdminFeatureFlag,
newFeatureFlagPath: el.dataset.newFeatureFlagPath,
......
......@@ -7,6 +7,8 @@
"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-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-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),
......
---
title: Feature Flags limits UX and documentation
merge_request: 44089
author:
type: added
......@@ -56,6 +56,20 @@ To create and enable a feature flag:
You can change these settings by clicking the **{pencil}** (edit) button
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
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/35555) in GitLab 13.0.
......
......@@ -11152,6 +11152,9 @@ msgstr ""
msgid "FeatureFlags|Feature flags allow you to configure your code into different flavors by dynamically toggling certain functionality."
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"
msgstr ""
......
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
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 Api from '~/api';
import createStore from '~/feature_flags/store/index';
......@@ -20,14 +20,17 @@ localVue.use(Vuex);
describe('Feature flags', () => {
const mockData = {
canUserConfigure: true,
// canUserRotateToken: true,
csrfToken: 'testToken',
featureFlagsClientLibrariesHelpPagePath: '/help/feature-flags#unleash-clients',
featureFlagsClientExampleHelpPagePath: '/help/feature-flags#client-example',
unleashApiUrl: `${TEST_HOST}/api/unleash`,
canUserConfigure: true,
canUserRotateToken: true,
featureFlagsClientLibrariesHelpPagePath: '/help/feature-flags#unleash-clients',
featureFlagsHelpPagePath: '/help/feature-flags',
featureFlagsLimit: '200',
featureFlagsLimitExceeded: false,
newFeatureFlagPath: 'feature-flags/new',
newUserListPath: '/user-list/new',
unleashApiUrl: `${TEST_HOST}/api/unleash`,
};
const mockState = {
......@@ -60,6 +63,7 @@ describe('Feature flags', () => {
const configureButton = () => wrapper.find('[data-testid="ff-configure-button"]');
const newButton = () => wrapper.find('[data-testid="ff-new-button"]');
const newUserListButton = () => wrapper.find('[data-testid="ff-new-list-button"]');
const limitAlert = () => wrapper.find(GlAlert);
beforeEach(() => {
mock = new MockAdapter(axios);
......@@ -82,28 +86,64 @@ describe('Feature flags', () => {
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', () => {
const propsData = {
csrfToken: 'testToken',
errorStateSvgPath: '/assets/illustrations/feature_flag.svg',
featureFlagsHelpPagePath: '/help/feature-flags',
...mockData,
canUserConfigure: false,
canUserRotateToken: false,
featureFlagsClientLibrariesHelpPagePath: '/help/feature-flags#unleash-clients',
featureFlagsClientExampleHelpPagePath: '/help/feature-flags#client-example',
unleashApiUrl: `${TEST_HOST}/api/unleash`,
newFeatureFlagPath: null,
newUserListPath: null,
};
beforeEach(done => {
mock
.onGet(`${TEST_HOST}/endpoint.json`, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } })
.reply(200, getRequestData, {});
factory(propsData);
setImmediate(() => {
done();
});
setImmediate(done);
});
it('does not render configure button', () => {
......@@ -197,9 +237,7 @@ describe('Feature flags', () => {
factory();
jest.spyOn(store, 'dispatch');
setImmediate(() => {
done();
});
setImmediate(done);
});
it('should render a table with feature flags', () => {
......@@ -267,10 +305,7 @@ describe('Feature flags', () => {
describe('in user lists tab', () => {
beforeEach(done => {
factory();
setImmediate(() => {
done();
});
setImmediate(done);
});
beforeEach(() => {
wrapper.find('[data-testid="user-lists-tab"]').vm.$emit('changeTab');
......@@ -295,10 +330,7 @@ describe('Feature flags', () => {
Api.fetchFeatureFlagUserLists.mockRejectedValueOnce();
factory();
setImmediate(() => {
done();
});
setImmediate(done);
});
it('should render error state', () => {
......@@ -329,10 +361,7 @@ describe('Feature flags', () => {
.onGet(`${TEST_HOST}/endpoint.json`, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } })
.reply(200, getRequestData, {});
factory();
setImmediate(() => {
done();
});
setImmediate(done);
});
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