Commit 8d1c153c authored by Andrew Fontaine's avatar Andrew Fontaine

Link to a search for feature flag name in project

We can utilize the power of search to find references to the feature
flag name in the code base to help make cleaning up the feature flag
easier.

The simple search for the name can be expanded on later by checking if
advanced search is enabled and utilizing some of the powers of elastic
search to refine our searching of the codebase.

Changelog: added
EE: true
parent ced6a706
...@@ -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()],
...@@ -61,6 +62,8 @@ export default { ...@@ -61,6 +62,8 @@ export default {
@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">
......
...@@ -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);
......
...@@ -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>
...@@ -6,7 +6,8 @@ module EE ...@@ -6,7 +6,8 @@ module EE
override :edit_feature_flag_data override :edit_feature_flag_data
def 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)) 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 end
private private
...@@ -16,5 +17,11 @@ module EE ...@@ -16,5 +17,11 @@ module EE
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
...@@ -84,6 +84,7 @@ class License < ApplicationRecord ...@@ -84,6 +84,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);
});
});
});
...@@ -10,6 +10,7 @@ RSpec.describe EE::FeatureFlagsHelper do ...@@ -10,6 +10,7 @@ RSpec.describe EE::FeatureFlagsHelper do
let_it_be(:user) { create(:user) } let_it_be(:user) { create(:user) }
before do before do
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(: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) allow(helper).to receive(:current_user).and_return(user)
...@@ -22,6 +23,11 @@ RSpec.describe EE::FeatureFlagsHelper do ...@@ -22,6 +23,11 @@ RSpec.describe EE::FeatureFlagsHelper do
context 'with permissions' do context 'with permissions' do
let(:admin_feature_flags_issue_links?) { true } let(:admin_feature_flags_issue_links?) { true }
let(:feature_flags_code_references?) { true }
it 'adds the search path' do
is_expected.to include(search_path: "/search?project_id=#{project.id}&scope=blobs&search=#{feature_flag.name}")
end
it 'adds the issue links path' do it 'adds the issue links path' do
is_expected.to include(feature_flag_issues_endpoint: "/#{project.full_path}/-/feature_flags/#{feature_flag.iid}/issues") is_expected.to include(feature_flag_issues_endpoint: "/#{project.full_path}/-/feature_flags/#{feature_flag.iid}/issues")
...@@ -30,6 +36,11 @@ RSpec.describe EE::FeatureFlagsHelper do ...@@ -30,6 +36,11 @@ RSpec.describe EE::FeatureFlagsHelper do
context 'without permissions' do context 'without permissions' do
let(:admin_feature_flags_issue_links?) { false } 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
it 'adds a blank issue links path' do it 'adds a blank issue links path' do
is_expected.to include(feature_flag_issues_endpoint: '') is_expected.to include(feature_flag_issues_endpoint: '')
......
...@@ -14270,6 +14270,9 @@ msgstr "" ...@@ -14270,6 +14270,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',
}); });
......
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