Commit 10cb4e6f authored by Igor Drozdov's avatar Igor Drozdov

Merge branch 'afontaine/search-for-feature-flags' into 'master'

Link to a search for feature flag name in project

See merge request gitlab-org/gitlab!70417
parents 16085c2d 8d1c153c
<script> <script>
import { GlAlert, GlLoadingIcon, GlToggle } from '@gitlab/ui'; import { GlAlert, GlLoadingIcon, GlToggle } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex';
import { sprintf, s__ } from '~/locale'; import { sprintf, __ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import FeatureFlagForm from './form.vue'; import FeatureFlagForm from './form.vue';
...@@ -10,6 +10,7 @@ export default { ...@@ -10,6 +10,7 @@ export default {
GlAlert, GlAlert,
GlLoadingIcon, GlLoadingIcon,
GlToggle, GlToggle,
FeatureFlagActions: () => import('ee_component/feature_flags/components/actions.vue'),
FeatureFlagForm, FeatureFlagForm,
}, },
mixins: [glFeatureFlagMixin()], mixins: [glFeatureFlagMixin()],
...@@ -28,7 +29,7 @@ export default { ...@@ -28,7 +29,7 @@ export default {
title() { title() {
return this.iid return this.iid
? `^${this.iid} ${this.name}` ? `^${this.iid} ${this.name}`
: sprintf(s__('Edit %{name}'), { name: this.name }); : sprintf(this.$options.i18n.editTitle, { name: this.name });
}, },
}, },
created() { created() {
...@@ -37,6 +38,11 @@ export default { ...@@ -37,6 +38,11 @@ export default {
methods: { methods: {
...mapActions(['updateFeatureFlag', 'fetchFeatureFlag', 'toggleActive']), ...mapActions(['updateFeatureFlag', 'fetchFeatureFlag', 'toggleActive']),
}, },
i18n: {
editTitle: __('Edit %{name}'),
toggleLabel: __('Feature flag status'),
submit: __('Save changes'),
},
}; };
</script> </script>
<template> <template>
...@@ -51,11 +57,13 @@ export default { ...@@ -51,11 +57,13 @@ export default {
data-track-action="click_button" data-track-action="click_button"
data-track-label="feature_flag_toggle" data-track-label="feature_flag_toggle"
class="gl-mr-4" class="gl-mr-4"
:label="__('Feature flag status')" :label="$options.i18n.toggleLabel"
label-position="hidden" label-position="hidden"
@change="toggleActive" @change="toggleActive"
/> />
<h3 class="page-title gl-m-0">{{ title }}</h3> <h3 class="page-title gl-m-0">{{ title }}</h3>
<feature-flag-actions class="gl-ml-auto" />
</div> </div>
<gl-alert v-if="error.length" variant="warning" class="gl-mb-5" :dismissible="false"> <gl-alert v-if="error.length" variant="warning" class="gl-mb-5" :dismissible="false">
...@@ -67,7 +75,7 @@ export default { ...@@ -67,7 +75,7 @@ export default {
:description="description" :description="description"
:strategies="strategies" :strategies="strategies"
:cancel-path="path" :cancel-path="path"
:submit-text="__('Save changes')" :submit-text="$options.i18n.submit"
:active="active" :active="active"
@handleSubmit="(data) => updateFeatureFlag(data)" @handleSubmit="(data) => updateFeatureFlag(data)"
/> />
......
...@@ -95,7 +95,7 @@ export default { ...@@ -95,7 +95,7 @@ export default {
return this.formStrategies.filter((s) => !s.shouldBeDestroyed); return this.formStrategies.filter((s) => !s.shouldBeDestroyed);
}, },
showRelatedIssues() { showRelatedIssues() {
return this.featureFlagIssuesEndpoint.length > 0; return Boolean(this.featureFlagIssuesEndpoint);
}, },
}, },
methods: { methods: {
......
...@@ -15,6 +15,7 @@ export default () => { ...@@ -15,6 +15,7 @@ export default () => {
environmentsEndpoint, environmentsEndpoint,
projectId, projectId,
featureFlagIssuesEndpoint, featureFlagIssuesEndpoint,
searchPath,
} = el.dataset; } = el.dataset;
return new Vue({ return new Vue({
...@@ -26,6 +27,7 @@ export default () => { ...@@ -26,6 +27,7 @@ export default () => {
environmentsEndpoint, environmentsEndpoint,
projectId, projectId,
featureFlagIssuesEndpoint, featureFlagIssuesEndpoint,
searchPath,
}, },
render(createElement) { render(createElement) {
return createElement(EditFeatureFlag); return createElement(EditFeatureFlag);
......
...@@ -11,8 +11,15 @@ module FeatureFlagsHelper ...@@ -11,8 +11,15 @@ module FeatureFlagsHelper
project.feature_flags_client_token project.feature_flags_client_token
end end
def feature_flag_issues_links_endpoint(_project, _feature_flag, _user) def edit_feature_flag_data
'' {
endpoint: project_feature_flag_path(@project, @feature_flag),
project_id: @project.id,
feature_flags_path: project_feature_flags_path(@project),
environments_endpoint: search_project_environments_path(@project, format: :json),
strategy_type_docs_page_path: help_page_path('operations/feature_flags', anchor: 'feature-flag-strategies'),
environments_scope_docs_path: help_page_path('ci/environments/index.md', anchor: 'scope-environments-with-specs')
}
end end
end end
......
...@@ -4,10 +4,4 @@ ...@@ -4,10 +4,4 @@
- breadcrumb_title @feature_flag.name - breadcrumb_title @feature_flag.name
- page_title s_('FeatureFlags|Edit Feature Flag') - page_title s_('FeatureFlags|Edit Feature Flag')
#js-edit-feature-flag{ data: { endpoint: project_feature_flag_path(@project, @feature_flag), #js-edit-feature-flag{ data: edit_feature_flag_data }
project_id: @project.id,
feature_flags_path: project_feature_flags_path(@project),
environments_endpoint: search_project_environments_path(@project, format: :json),
strategy_type_docs_page_path: help_page_path('operations/feature_flags', anchor: 'feature-flag-strategies'),
environments_scope_docs_path: help_page_path('ci/environments/index.md', anchor: 'scope-environments-with-specs'),
feature_flag_issues_endpoint: feature_flag_issues_links_endpoint(@project, @feature_flag, current_user) } }
...@@ -166,6 +166,21 @@ WARNING: ...@@ -166,6 +166,21 @@ WARNING:
The Unleash client **must** be given a user ID for the feature to be enabled for The Unleash client **must** be given a user ID for the feature to be enabled for
target users. See the [Ruby example](#ruby-application-example) below. target users. See the [Ruby example](#ruby-application-example) below.
## Search for Code References **(PREMIUM)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/300299) in GitLab 14.4.
Search your project and find any references of a feature flag in your
code so that you and clean it up when it's time to remove the feature flag.
To search for code references of a feature flag:
1. On the top bar, select **Menu > Projects** and find your project.
1. On the left sidebar, select **Deployments > Feature Flags**.
1. Edit the feature flag you want to remove.
1. Select **More actions** (**{ellipsis_v}**).
1. Select **Search code references**.
### User List ### User List
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/35930) in GitLab 13.1. > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/35930) in GitLab 13.1.
......
<script>
import { GlDropdown, GlDropdownItem, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
import { __, s__ } from '~/locale';
export default {
components: {
GlDropdown,
GlDropdownItem,
},
directives: { GlTooltip },
inject: { searchPath: { default: '' } },
i18n: {
moreActions: __('More actions'),
searchLabel: s__('FeatureFlags|Search code references'),
},
};
</script>
<template>
<gl-dropdown
v-if="searchPath"
v-gl-tooltip
icon="ellipsis_v"
text-sr-only
:title="$options.i18n.moreActions"
:text="$options.i18n.moreActions"
category="secondary"
no-caret
right
>
<gl-dropdown-item :href="searchPath" target="_blank">
{{ $options.i18n.searchLabel }}
</gl-dropdown-item>
</gl-dropdown>
</template>
...@@ -4,11 +4,24 @@ module EE ...@@ -4,11 +4,24 @@ module EE
module FeatureFlagsHelper module FeatureFlagsHelper
extend ::Gitlab::Utils::Override extend ::Gitlab::Utils::Override
override :feature_flag_issues_links_endpoint override :edit_feature_flag_data
def edit_feature_flag_data
super.merge(feature_flag_issues_endpoint: feature_flag_issues_links_endpoint(@project, @feature_flag, current_user),
search_path: feature_flags_search_path(@project, @feature_flag, current_user))
end
private
def feature_flag_issues_links_endpoint(project, feature_flag, user) def feature_flag_issues_links_endpoint(project, feature_flag, user)
return '' unless can?(user, :admin_feature_flags_issue_links, project) return '' unless can?(user, :admin_feature_flags_issue_links, project)
project_feature_flag_issues_path(project, feature_flag) project_feature_flag_issues_path(project, feature_flag)
end end
def feature_flags_search_path(project, feature_flag, user)
return '' unless project.licensed_feature_available?(:feature_flags_code_references, user)
search_path(project_id: project.id, search: feature_flag.name, scope: :blobs)
end
end end
end end
...@@ -89,6 +89,7 @@ class License < ApplicationRecord ...@@ -89,6 +89,7 @@ class License < ApplicationRecord
extended_audit_events extended_audit_events
external_authorization_service_api_management external_authorization_service_api_management
feature_flags_related_issues feature_flags_related_issues
feature_flags_code_references
file_locks file_locks
geo geo
generic_alert_fingerprinting generic_alert_fingerprinting
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'User updates feature flag', :js do
include FeatureFlagHelpers
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, namespace: user.namespace) }
let_it_be(:feature_flag) do
create_flag(project, 'test_flag', false, version: Operations::FeatureFlag.versions['new_version_flag'],
description: 'For testing')
end
let_it_be(:strategy) do
create(:operations_strategy, feature_flag: feature_flag,
name: 'default', parameters: {})
end
let_it_be(:scope) do
create(:operations_scope, strategy: strategy, environment_scope: '*')
end
before_all do
project.add_developer(user)
end
before do
stub_licensed_features(feature_flags_code_references: premium)
sign_in(user)
end
context 'with a premium license' do
let(:premium) { true }
it 'links to a search page for code references' do
visit(edit_project_feature_flag_path(project, feature_flag))
click_button _('More actions')
expect(page).to have_link s_('FeatureFlags|Search code references'), href: search_path(project_id: project.id, search: feature_flag.name, scope: :blobs)
end
end
context 'without a premium license' do
let(:premium) { false }
it 'does not link to a search page for code references' do
visit(edit_project_feature_flag_path(project, feature_flag))
expect(page).not_to have_button _('More actions')
expect(page).not_to have_link s_('FeatureFlags|Search code references'), href: search_path(project_id: project.id, search: feature_flag.name, scope: :blobs)
end
end
end
import { GlDropdown } from '@gitlab/ui';
import FeatureFlagsActions from 'ee/feature_flags/components/actions.vue';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { s__ } from '~/locale';
describe('ee/feature_flags/components/actions.vue', () => {
let wrapper;
afterEach(() => {
wrapper.destroy();
});
const createWrapper = (provide = { searchPath: '/search' }) =>
mountExtended(FeatureFlagsActions, { provide });
it('shows a link to search for code references if provided', () => {
wrapper = createWrapper();
const link = wrapper.findByRole('menuitem', {
name: s__('FeatureFlags|Search code references'),
});
expect(link.exists()).toBe(true);
expect(link.attributes('href')).toBe('/search');
});
it('shows nothing if no path is provided', () => {
wrapper = createWrapper({ searchPath: null });
expect(wrapper.findComponent(GlDropdown).exists()).toBe(false);
});
});
import { shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import Vue from 'vue';
import Vuex from 'vuex';
import FeatureFlagActions from 'ee/feature_flags/components/actions.vue';
import waitForPromises from 'helpers/wait_for_promises';
import { TEST_HOST } from 'spec/test_constants';
import EditFeatureFlag from '~/feature_flags/components/edit_feature_flag.vue';
import createStore from '~/feature_flags/store/edit';
import axios from '~/lib/utils/axios_utils';
Vue.use(Vuex);
const endpoint = `${TEST_HOST}/feature_flags.json`;
describe('Edit feature flag form', () => {
let wrapper;
let mock;
const store = createStore({
path: '/feature_flags',
endpoint,
});
const factory = (provide = { searchPath: '/search' }) => {
wrapper = shallowMount(EditFeatureFlag, {
store,
provide,
});
};
beforeEach(() => {
mock = new MockAdapter(axios);
mock.onGet(endpoint).replyOnce(200, {
id: 21,
iid: 5,
active: true,
created_at: '2019-01-17T17:27:39.778Z',
updated_at: '2019-01-17T17:27:39.778Z',
name: 'feature_flag',
description: '',
edit_path: '/h5bp/html5-boilerplate/-/feature_flags/21/edit',
destroy_path: '/h5bp/html5-boilerplate/-/feature_flags/21',
});
factory();
return waitForPromises();
});
afterEach(() => {
wrapper.destroy();
mock.restore();
});
describe('without error', () => {
it('shows a dropdown to search for code references', () => {
expect(wrapper.findComponent(FeatureFlagActions).exists()).toBe(true);
});
});
});
...@@ -3,23 +3,48 @@ ...@@ -3,23 +3,48 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe EE::FeatureFlagsHelper do RSpec.describe EE::FeatureFlagsHelper do
include Devise::Test::ControllerHelpers
let_it_be(:project) { create(:project) } let_it_be(:project) { create(:project) }
let_it_be(:feature_flag) { create(:operations_feature_flag, project: project) } let_it_be(:feature_flag) { create(:operations_feature_flag, project: project) }
let_it_be(:user) { create(:user) } let_it_be(:user) { create(:user) }
describe '#feature_flag_issues_links_endpoint' do before do
subject { helper.feature_flag_issues_links_endpoint(project, feature_flag, user) } stub_licensed_features(feature_flags_code_references: feature_flags_code_references?)
allow(helper).to receive(:can?).with(user, :admin_feature_flags_issue_links, project).and_return(admin_feature_flags_issue_links?)
allow(helper).to receive(:current_user).and_return(user)
self.instance_variable_set(:@project, project)
self.instance_variable_set(:@feature_flag, feature_flag)
end
describe "#edit_feature_flags_data" do
subject { helper.edit_feature_flag_data }
context 'with permissions' do
let(:admin_feature_flags_issue_links?) { true }
let(:feature_flags_code_references?) { true }
it 'returns an empty string when the user is not allowed' do it 'adds the search path' do
allow(helper).to receive(:can?).with(user, :admin_feature_flags_issue_links, project).and_return(false) is_expected.to include(search_path: "/search?project_id=#{project.id}&scope=blobs&search=#{feature_flag.name}")
end
is_expected.to be_empty it 'adds the issue links path' do
is_expected.to include(feature_flag_issues_endpoint: "/#{project.full_path}/-/feature_flags/#{feature_flag.iid}/issues")
end
end end
it 'returns the issue endpoint when the user is allowed' do context 'without permissions' do
allow(helper).to receive(:can?).with(user, :admin_feature_flags_issue_links, project).and_return(true) let(:admin_feature_flags_issue_links?) { false }
let(:feature_flags_code_references?) { false }
it 'adds a blank search path' do
is_expected.to include(search_path: '')
end
is_expected.to eq("/#{project.full_path}/-/feature_flags/#{feature_flag.iid}/issues") it 'adds a blank issue links path' do
is_expected.to include(feature_flag_issues_endpoint: '')
end
end end
end end
end end
...@@ -14402,6 +14402,9 @@ msgstr "" ...@@ -14402,6 +14402,9 @@ msgstr ""
msgid "FeatureFlags|Remove" msgid "FeatureFlags|Remove"
msgstr "" msgstr ""
msgid "FeatureFlags|Search code references"
msgstr ""
msgid "FeatureFlags|Set the Unleash client application name to the name of the environment your application runs in. This value is used to match environment scopes. See the %{linkStart}example client configuration%{linkEnd}." msgid "FeatureFlags|Set the Unleash client application name to the name of the environment your application runs in. This value is used to match environment scopes. See the %{linkStart}example client configuration%{linkEnd}."
msgstr "" msgstr ""
......
...@@ -4,6 +4,7 @@ import MockAdapter from 'axios-mock-adapter'; ...@@ -4,6 +4,7 @@ import MockAdapter from 'axios-mock-adapter';
import Vue from 'vue'; import Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import { mockTracking } from 'helpers/tracking_helper'; import { mockTracking } from 'helpers/tracking_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { TEST_HOST } from 'spec/test_constants'; import { TEST_HOST } from 'spec/test_constants';
import EditFeatureFlag from '~/feature_flags/components/edit_feature_flag.vue'; import EditFeatureFlag from '~/feature_flags/components/edit_feature_flag.vue';
import Form from '~/feature_flags/components/form.vue'; import Form from '~/feature_flags/components/form.vue';
...@@ -20,7 +21,7 @@ describe('Edit feature flag form', () => { ...@@ -20,7 +21,7 @@ describe('Edit feature flag form', () => {
endpoint: `${TEST_HOST}/feature_flags.json`, endpoint: `${TEST_HOST}/feature_flags.json`,
}); });
const factory = (provide = {}) => { const factory = (provide = { searchPath: '/search' }) => {
if (wrapper) { if (wrapper) {
wrapper.destroy(); wrapper.destroy();
wrapper = null; wrapper = null;
...@@ -31,7 +32,7 @@ describe('Edit feature flag form', () => { ...@@ -31,7 +32,7 @@ describe('Edit feature flag form', () => {
}); });
}; };
beforeEach((done) => { beforeEach(() => {
mock = new MockAdapter(axios); mock = new MockAdapter(axios);
mock.onGet(`${TEST_HOST}/feature_flags.json`).replyOnce(200, { mock.onGet(`${TEST_HOST}/feature_flags.json`).replyOnce(200, {
id: 21, id: 21,
...@@ -45,7 +46,8 @@ describe('Edit feature flag form', () => { ...@@ -45,7 +46,8 @@ describe('Edit feature flag form', () => {
destroy_path: '/h5bp/html5-boilerplate/-/feature_flags/21', destroy_path: '/h5bp/html5-boilerplate/-/feature_flags/21',
}); });
factory(); factory();
setImmediate(() => done());
return waitForPromises();
}); });
afterEach(() => { afterEach(() => {
...@@ -60,7 +62,7 @@ describe('Edit feature flag form', () => { ...@@ -60,7 +62,7 @@ describe('Edit feature flag form', () => {
}); });
it('should render the toggle', () => { it('should render the toggle', () => {
expect(wrapper.find(GlToggle).exists()).toBe(true); expect(wrapper.findComponent(GlToggle).exists()).toBe(true);
}); });
describe('with error', () => { describe('with error', () => {
...@@ -80,11 +82,11 @@ describe('Edit feature flag form', () => { ...@@ -80,11 +82,11 @@ describe('Edit feature flag form', () => {
}); });
it('should render feature flag form', () => { it('should render feature flag form', () => {
expect(wrapper.find(Form).exists()).toEqual(true); expect(wrapper.findComponent(Form).exists()).toEqual(true);
}); });
it('should track when the toggle is clicked', () => { it('should track when the toggle is clicked', () => {
const toggle = wrapper.find(GlToggle); const toggle = wrapper.findComponent(GlToggle);
const spy = mockTracking('_category_', toggle.element, jest.spyOn); const spy = mockTracking('_category_', toggle.element, jest.spyOn);
toggle.trigger('click'); toggle.trigger('click');
...@@ -95,7 +97,7 @@ describe('Edit feature flag form', () => { ...@@ -95,7 +97,7 @@ describe('Edit feature flag form', () => {
}); });
it('should render the toggle with a visually hidden label', () => { it('should render the toggle with a visually hidden label', () => {
expect(wrapper.find(GlToggle).props()).toMatchObject({ expect(wrapper.findComponent(GlToggle).props()).toMatchObject({
label: 'Feature flag status', label: 'Feature flag status',
labelPosition: 'hidden', labelPosition: 'hidden',
}); });
......
...@@ -3,10 +3,20 @@ ...@@ -3,10 +3,20 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe FeatureFlagsHelper do RSpec.describe FeatureFlagsHelper do
include Devise::Test::ControllerHelpers
let_it_be(:project) { create(:project) } let_it_be(:project) { create(:project) }
let_it_be(:feature_flag) { create(:operations_feature_flag, project: project) } let_it_be(:feature_flag) { create(:operations_feature_flag, project: project) }
let_it_be(:user) { create(:user) } let_it_be(:user) { create(:user) }
before do
allow(helper).to receive(:can?).and_return(true)
allow(helper).to receive(:current_user).and_return(user)
self.instance_variable_set(:@project, project)
self.instance_variable_set(:@feature_flag, feature_flag)
end
describe '#unleash_api_url' do describe '#unleash_api_url' do
subject { helper.unleash_api_url(project) } subject { helper.unleash_api_url(project) }
...@@ -18,4 +28,17 @@ RSpec.describe FeatureFlagsHelper do ...@@ -18,4 +28,17 @@ RSpec.describe FeatureFlagsHelper do
it { is_expected.not_to be_empty } it { is_expected.not_to be_empty }
end end
describe '#edit_feature_flag_data' do
subject { helper.edit_feature_flag_data }
it 'contains all the data needed to edit feature flags' do
is_expected.to include(endpoint: "/#{project.full_path}/-/feature_flags/#{feature_flag.iid}",
project_id: project.id,
feature_flags_path: "/#{project.full_path}/-/feature_flags",
environments_endpoint: "/#{project.full_path}/-/environments/search.json",
strategy_type_docs_page_path: "/help/operations/feature_flags#feature-flag-strategies",
environments_scope_docs_path: "/help/ci/environments/index.md#scope-environments-with-specs")
end
end
end end
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