Commit 1d3eb3c7 authored by Jason Goodman's avatar Jason Goodman Committed by Andrew Fontaine

Add linked issues widget to feature flag details page

Put widget behind a feature flag
parent 4065584b
...@@ -35,6 +35,10 @@ export default { ...@@ -35,6 +35,10 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
featureFlagIssuesEndpoint: {
type: String,
required: true,
},
}, },
translations: { translations: {
legacyFlagAlert: s__( legacyFlagAlert: s__(
...@@ -111,6 +115,7 @@ export default { ...@@ -111,6 +115,7 @@ export default {
:cancel-path="path" :cancel-path="path"
:submit-text="__('Save changes')" :submit-text="__('Save changes')"
:environments-endpoint="environmentsEndpoint" :environments-endpoint="environmentsEndpoint"
:feature-flag-issues-endpoint="featureFlagIssuesEndpoint"
:active="active" :active="active"
:version="version" :version="version"
@handleSubmit="data => updateFeatureFlag(data)" @handleSubmit="data => updateFeatureFlag(data)"
......
...@@ -28,6 +28,7 @@ import { ...@@ -28,6 +28,7 @@ import {
LEGACY_FLAG, LEGACY_FLAG,
} from '../constants'; } from '../constants';
import { createNewEnvironmentScope } from '../store/modules/helpers'; import { createNewEnvironmentScope } from '../store/modules/helpers';
import RelatedIssuesRoot from 'ee/related_issues/components/related_issues_root.vue';
export default { export default {
components: { components: {
...@@ -41,6 +42,7 @@ export default { ...@@ -41,6 +42,7 @@ export default {
Icon, Icon,
EnvironmentsDropdown, EnvironmentsDropdown,
Strategy, Strategy,
RelatedIssuesRoot,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
...@@ -83,6 +85,11 @@ export default { ...@@ -83,6 +85,11 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
featureFlagIssuesEndpoint: {
type: String,
required: false,
default: '',
},
strategies: { strategies: {
type: Array, type: Array,
required: false, required: false,
...@@ -146,6 +153,9 @@ export default { ...@@ -146,6 +153,9 @@ export default {
canDeleteStrategy() { canDeleteStrategy() {
return this.formStrategies.length > 1; return this.formStrategies.length > 1;
}, },
showRelatedIssues() {
return this.featureFlagIssuesEndpoint.length > 0;
},
}, },
mounted() { mounted() {
if (this.supportsStrategies) { if (this.supportsStrategies) {
...@@ -313,6 +323,13 @@ export default { ...@@ -313,6 +323,13 @@ export default {
</div> </div>
</div> </div>
<related-issues-root
v-if="showRelatedIssues"
:endpoint="featureFlagIssuesEndpoint"
:can-admin="true"
:is-linked-issue-block="false"
/>
<template v-if="supportsStrategies"> <template v-if="supportsStrategies">
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
......
...@@ -16,6 +16,7 @@ export default () => { ...@@ -16,6 +16,7 @@ export default () => {
path: el.dataset.featureFlagsPath, path: el.dataset.featureFlagsPath,
environmentsEndpoint: el.dataset.environmentsEndpoint, environmentsEndpoint: el.dataset.environmentsEndpoint,
projectId: el.dataset.projectId, projectId: el.dataset.projectId,
featureFlagIssuesEndpoint: el.dataset.featureFlagIssuesEndpoint,
}, },
}); });
}, },
......
...@@ -148,6 +148,7 @@ export default { ...@@ -148,6 +148,7 @@ export default {
</template> </template>
<related-issuable-input <related-issuable-input
ref="relatedIssuableInput" ref="relatedIssuableInput"
input-id="add-related-issues-form-input"
:focus-on-mount="true" :focus-on-mount="true"
:references="pendingReferences" :references="pendingReferences"
:path-id-separator="pathIdSeparator" :path-id-separator="pathIdSeparator"
......
...@@ -77,6 +77,11 @@ export default { ...@@ -77,6 +77,11 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
isLinkedIssueBlock: {
type: Boolean,
required: false,
default: true,
},
}, },
computed: { computed: {
hasRelatedIssues() { hasRelatedIssues() {
...@@ -169,7 +174,7 @@ export default { ...@@ -169,7 +174,7 @@ export default {
class="js-add-related-issues-form-area card-body bordered-box bg-white" class="js-add-related-issues-form-area card-body bordered-box bg-white"
> >
<add-issuable-form <add-issuable-form
:is-linked-issue-block="true" :is-linked-issue-block="isLinkedIssueBlock"
:is-submitting="isSubmitting" :is-submitting="isSubmitting"
:issuable-type="issuableType" :issuable-type="issuableType"
:input-value="inputValue" :input-value="inputValue"
......
...@@ -81,6 +81,11 @@ export default { ...@@ -81,6 +81,11 @@ export default {
required: false, required: false,
default: '', default: '',
}, },
isLinkedIssueBlock: {
type: Boolean,
required: false,
default: true,
},
}, },
data() { data() {
this.store = new RelatedIssuesStore(); this.store = new RelatedIssuesStore();
...@@ -226,6 +231,7 @@ export default { ...@@ -226,6 +231,7 @@ export default {
:auto-complete-sources="autoCompleteSources" :auto-complete-sources="autoCompleteSources"
:issuable-type="issuableType" :issuable-type="issuableType"
:path-id-separator="pathIdSeparator" :path-id-separator="pathIdSeparator"
:is-linked-issue-block="isLinkedIssueBlock"
@saveReorder="saveIssueOrder" @saveReorder="saveIssueOrder"
@toggleAddRelatedIssuesForm="onToggleAddRelatedIssuesForm" @toggleAddRelatedIssuesForm="onToggleAddRelatedIssuesForm"
@addIssuableFormInput="onInput" @addIssuableFormInput="onInput"
......
...@@ -9,10 +9,22 @@ module Projects ...@@ -9,10 +9,22 @@ module Projects
private private
def create_service
::FeatureFlagIssues::CreateService.new(feature_flag, current_user, create_params)
end
def list_service def list_service
::FeatureFlagIssues::ListService.new(feature_flag, current_user) ::FeatureFlagIssues::ListService.new(feature_flag, current_user)
end end
def destroy_service
::FeatureFlagIssues::DestroyService.new(link, current_user)
end
def link
@link ||= ::FeatureFlagIssue.find(params[:id])
end
def feature_flag def feature_flag
project.operations_feature_flags.find_by_iid(params[:feature_flag_iid]) project.operations_feature_flags.find_by_iid(params[:feature_flag_iid])
end end
......
...@@ -95,11 +95,11 @@ class Projects::FeatureFlagsController < Projects::ApplicationController ...@@ -95,11 +95,11 @@ class Projects::FeatureFlagsController < Projects::ApplicationController
protected protected
def feature_flag def feature_flag
@feature_flag ||= if new_version_feature_flags_enabled? @feature_flag ||= @noteable = if new_version_feature_flags_enabled?
project.operations_feature_flags.find_by_iid!(params[:iid]) project.operations_feature_flags.find_by_iid!(params[:iid])
else else
project.operations_feature_flags.legacy_flag.find_by_iid!(params[:iid]) project.operations_feature_flags.legacy_flag.find_by_iid!(params[:iid])
end end
end end
def new_version_feature_flags_enabled? def new_version_feature_flags_enabled?
......
# frozen_string_literal: true
module FeatureFlagIssues
class CreateService < IssuableLinks::CreateService
def previous_related_issuables
@related_issues ||= issuable.issues.to_a
end
def linkable_issuables(issues)
issues
end
def relate_issuables(referenced_issue)
attrs = { feature_flag_id: issuable.id, issue: referenced_issue }
::FeatureFlagIssue.create(attrs)
end
end
end
# frozen_string_literal: true
module FeatureFlagIssues
class DestroyService < IssuableLinks::DestroyService
def permission_to_remove_relation?
can?(current_user, :admin_feature_flag, link.feature_flag)
end
def create_notes
end
end
end
- @gfm_form = Feature.enabled?(:feature_flags_issue_links, @project)
- add_to_breadcrumbs s_('FeatureFlags|Feature Flags'), project_feature_flags_path(@project) - add_to_breadcrumbs s_('FeatureFlags|Feature Flags'), project_feature_flags_path(@project)
- breadcrumb_title @feature_flag.name - breadcrumb_title @feature_flag.name
- page_title s_('FeatureFlags|Edit Feature Flag') - page_title s_('FeatureFlags|Edit Feature Flag')
...@@ -5,4 +7,5 @@ ...@@ -5,4 +7,5 @@
#js-edit-feature-flag{ data: { endpoint: project_feature_flag_path(@project, @feature_flag), #js-edit-feature-flag{ data: { endpoint: project_feature_flag_path(@project, @feature_flag),
project_id: @project.id, project_id: @project.id,
feature_flags_path: project_feature_flags_path(@project), feature_flags_path: project_feature_flags_path(@project),
environments_endpoint: search_project_environments_path(@project, format: :json)} } environments_endpoint: search_project_environments_path(@project, format: :json),
feature_flag_issues_endpoint: Feature.enabled?(:feature_flags_issue_links, @project) ? project_feature_flag_issues_path(@project, @feature_flag) : ''} }
...@@ -23,7 +23,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do ...@@ -23,7 +23,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
end end
resources :feature_flags, param: :iid do resources :feature_flags, param: :iid do
resources :feature_flag_issues, only: [:index, :destroy], as: 'issues', path: 'issues' resources :feature_flag_issues, only: [:index, :create, :destroy], as: 'issues', path: 'issues'
end end
resource :feature_flags_client, only: [] do resource :feature_flags_client, only: [] do
post :reset_token post :reset_token
......
...@@ -187,4 +187,135 @@ describe Projects::FeatureFlagIssuesController do ...@@ -187,4 +187,135 @@ describe Projects::FeatureFlagIssuesController do
end end
end end
end end
describe 'POST #create' do
def setup
feature_flag = create(:operations_feature_flag, project: project)
issue = create(:issue, project: project)
[feature_flag, issue]
end
def post_request(project, feature_flag, issue)
post_params = {
namespace_id: project.namespace,
project_id: project,
feature_flag_iid: feature_flag,
issuable_references: [issue.to_reference],
link_type: 'relates_to'
}
post :create, params: post_params, format: :json
end
it 'creates a link between the feature flag and the issue' do
feature_flag, issue = setup
sign_in(developer)
post_request(project, feature_flag, issue)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to match(a_hash_including({
'issuables' => [a_hash_including({
'id' => issue.id,
'link_type' => 'relates_to'
})]
}))
end
it 'creates a link for the correct feature flag when there are multiple feature flags and projects' do
other_project = create(:project)
other_project.add_developer(developer)
create(:issue, project: other_project)
create(:operations_feature_flag, project: other_project)
feature_flag, issue = setup
sign_in(developer)
post_request(project, feature_flag, issue)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to match(a_hash_including({
'issuables' => [a_hash_including({
'id' => issue.id
})]
}))
end
it 'does not create a link for a reporter' do
feature_flag, issue = setup
sign_in(reporter)
post_request(project, feature_flag, issue)
expect(response).to have_gitlab_http_status(:not_found)
end
it 'does not create a cross project link' do
other_project = create(:project)
other_project.add_developer(developer)
feature_flag = create(:operations_feature_flag, project: project)
issue = create(:issue, project: other_project)
sign_in(developer)
post_request(project, feature_flag, issue)
expect(response).to have_gitlab_http_status(:not_found)
end
context 'when feature flags are unlicensed' do
before do
stub_licensed_features(feature_flags: false)
end
it 'does not create a link between the feature flag and the issue when feature flags are unlicensed' do
feature_flag, issue = setup
sign_in(developer)
post_request(project, feature_flag, issue)
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
describe 'DELETE #destroy' do
def setup
feature_flag = create(:operations_feature_flag, project: project)
issue = create(:issue, project: project)
link = create(:feature_flag_issue, feature_flag: feature_flag, issue: issue)
[feature_flag, issue, link]
end
def delete_request(project, feature_flag, feature_flag_issue)
params = {
namespace_id: project.namespace,
project_id: project,
feature_flag_iid: feature_flag,
id: feature_flag_issue
}
delete :destroy, params: params, format: :json
end
it 'unlinks the issue from the feature flag' do
feature_flag, _issue, link = setup
sign_in(developer)
delete_request(project, feature_flag, link)
expect(response).to have_gitlab_http_status(:ok)
expect(feature_flag.reload.issues).to eq([])
end
it 'does not unlink the issue for a reporter' do
feature_flag, issue, link = setup
sign_in(reporter)
delete_request(project, feature_flag, link)
expect(response).to have_gitlab_http_status(:not_found)
expect(feature_flag.reload.issues).to eq([issue])
end
end
end end
# frozen_string_literal: true
require 'spec_helper'
describe 'Feature flag issue links', :js do
include FeatureFlagHelpers
let_it_be(:developer) { create(:user) }
let_it_be(:project) { create(:project, namespace: developer.namespace) }
before_all do
project.add_developer(developer)
end
before do
stub_licensed_features(feature_flags: true)
sign_in(developer)
end
describe 'linking a feature flag to an issue' do
let!(:issue) do
create(:issue, project: project, title: 'My Cool Linked Issue')
end
let!(:other_issue) do
create(:issue, project: project, title: 'Another Issue')
end
let!(:feature_flag) do
create(:operations_feature_flag, :new_version_flag, project: project)
end
it 'user can link a feature flag to an issue' do
visit(edit_project_feature_flag_path(project, feature_flag))
add_linked_issue_button.click
fill_in 'add-related-issues-form-input', with: issue.to_reference
click_button 'Add'
expect(page).to have_text 'My Cool Linked Issue'
end
it 'user sees simple form without relates to / blocks / is blocked by radio buttons' do
visit(edit_project_feature_flag_path(project, feature_flag))
add_linked_issue_button.click
within '.js-add-related-issues-form-area' do
expect(page).to have_selector "#add-related-issues-form-input"
expect(page).not_to have_selector "#linked-issue-type-radio"
end
end
it 'autocompletes issues' do
visit(edit_project_feature_flag_path(project, feature_flag))
add_linked_issue_button.click
fill_in 'add-related-issues-form-input', with: '#'
within '#at-view-issues' do
expect(page).to have_text 'My Cool Linked Issue'
expect(page).to have_text 'Another Issue'
end
end
context 'when the feature is disabled' do
before do
stub_feature_flags(feature_flags_issue_links: false)
end
it 'does not show the related issues widget' do
visit(edit_project_feature_flag_path(project, feature_flag))
expect(page).to have_text 'Strategies'
expect(page).not_to have_selector '#related-issues'
end
end
end
describe 'unlinking a feature flag from an issue' do
let!(:issue) do
create(:issue, project: project, title: 'Remove This Issue')
end
let!(:feature_flag) do
create(:operations_feature_flag, :new_version_flag, project: project, issues: [issue])
end
it 'user can unlink a feature flag from an issue' do
visit(edit_project_feature_flag_path(project, feature_flag))
expect(page).to have_text 'Remove This Issue'
remove_linked_issue_button.click
expect(page).not_to have_text 'Remove This Issue'
end
end
end
...@@ -34,6 +34,7 @@ describe('Edit feature flag form', () => { ...@@ -34,6 +34,7 @@ describe('Edit feature flag form', () => {
path: '/feature_flags', path: '/feature_flags',
environmentsEndpoint: 'environments.json', environmentsEndpoint: 'environments.json',
projectId: '8', projectId: '8',
featureFlagIssuesEndpoint: `${TEST_HOST}/feature_flags/5/issues`,
}, },
store, store,
provide: { provide: {
...@@ -141,6 +142,12 @@ describe('Edit feature flag form', () => { ...@@ -141,6 +142,12 @@ describe('Edit feature flag form', () => {
expect(wrapper.find(Form).props('version')).toBe(NEW_VERSION_FLAG); expect(wrapper.find(Form).props('version')).toBe(NEW_VERSION_FLAG);
}); });
}); });
it('renders the related issues widget', () => {
const expected = `${TEST_HOST}/feature_flags/5/issues`;
expect(wrapper.find(Form).props('featureFlagIssuesEndpoint')).toBe(expected);
});
}); });
describe('without new version flags', () => { describe('without new version flags', () => {
......
...@@ -15,6 +15,7 @@ import { ...@@ -15,6 +15,7 @@ import {
} from 'ee/feature_flags/constants'; } from 'ee/feature_flags/constants';
import ToggleButton from '~/vue_shared/components/toggle_button.vue'; import ToggleButton from '~/vue_shared/components/toggle_button.vue';
import { featureFlag, userList } from '../mock_data'; import { featureFlag, userList } from '../mock_data';
import RelatedIssuesRoot from 'ee/related_issues/components/related_issues_root.vue';
jest.mock('ee/api.js'); jest.mock('ee/api.js');
...@@ -59,6 +60,21 @@ describe('feature flag form', () => { ...@@ -59,6 +60,21 @@ describe('feature flag form', () => {
expect(wrapper.find('.js-ff-cancel').attributes('href')).toEqual(requiredProps.cancelPath); expect(wrapper.find('.js-ff-cancel').attributes('href')).toEqual(requiredProps.cancelPath);
}); });
it('does not render the related issues widget without the featureFlagIssuesEndpoint', () => {
factory(requiredProps);
expect(wrapper.find(RelatedIssuesRoot).exists()).toBe(false);
});
it('renders the related issues widget when the featureFlagIssuesEndpoint is provided', () => {
factory({
...requiredProps,
featureFlagIssuesEndpoint: '/some/endpoint',
});
expect(wrapper.find(RelatedIssuesRoot).exists()).toBe(true);
});
describe('without provided data', () => { describe('without provided data', () => {
beforeEach(() => { beforeEach(() => {
factory(requiredProps); factory(requiredProps);
......
...@@ -57,6 +57,10 @@ describe('New feature flag form', () => { ...@@ -57,6 +57,10 @@ describe('New feature flag form', () => {
expect(wrapper.find(Form).exists()).toEqual(true); expect(wrapper.find(Form).exists()).toEqual(true);
}); });
it('does not render the related issues widget', () => {
expect(wrapper.find(Form).props('featureFlagIssuesEndpoint')).toBe('');
});
it('should render default * row', () => { it('should render default * row', () => {
const defaultScope = { const defaultScope = {
id: expect.any(String), id: expect.any(String),
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe FeatureFlagIssues::DestroyService do
let_it_be(:project) { create(:project) }
let_it_be(:developer) { create(:user) }
let_it_be(:reporter) { create(:user) }
before_all do
project.add_developer(developer)
project.add_reporter(reporter)
end
before do
stub_licensed_features(feature_flags: true)
end
def setup
feature_flag = create(:operations_feature_flag, project: project)
issue = create(:issue, project: project)
feature_flag_issue = create(:feature_flag_issue, feature_flag: feature_flag, issue: issue)
feature_flag_issue
end
describe '#execute' do
it 'unlinks the feature flag and the issue' do
feature_flag_issue = setup
described_class.new(feature_flag_issue, developer).execute
expect(::FeatureFlagIssue.count).to eq(0)
end
it 'does not unlink the feature flag and the issue when the user cannot admin the feature flag' do
feature_flag_issue = setup
described_class.new(feature_flag_issue, reporter).execute
expect(::FeatureFlagIssue.count).to eq(1)
end
end
end
...@@ -64,6 +64,14 @@ module FeatureFlagHelpers ...@@ -64,6 +64,14 @@ module FeatureFlagHelpers
find("button[data-testid='delete-strategy-button']") find("button[data-testid='delete-strategy-button']")
end end
def add_linked_issue_button
find('.js-issue-count-badge-add-button')
end
def remove_linked_issue_button
find('.js-issue-item-remove-button')
end
def status_toggle_button def status_toggle_button
find('.js-feature-flag-status button') find('.js-feature-flag-status button')
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