Commit e3c13716 authored by Andrew Fontaine's avatar Andrew Fontaine

Merge branch 'ff-issues-widget' into 'master'

Add Feature Flag Issues Widget

See merge request gitlab-org/gitlab!33337
parents ca3f4ec7 1d3eb3c7
......@@ -35,6 +35,10 @@ export default {
type: String,
required: true,
},
featureFlagIssuesEndpoint: {
type: String,
required: true,
},
},
translations: {
legacyFlagAlert: s__(
......@@ -111,6 +115,7 @@ export default {
:cancel-path="path"
:submit-text="__('Save changes')"
:environments-endpoint="environmentsEndpoint"
:feature-flag-issues-endpoint="featureFlagIssuesEndpoint"
:active="active"
:version="version"
@handleSubmit="data => updateFeatureFlag(data)"
......
......@@ -28,6 +28,7 @@ import {
LEGACY_FLAG,
} from '../constants';
import { createNewEnvironmentScope } from '../store/modules/helpers';
import RelatedIssuesRoot from 'ee/related_issues/components/related_issues_root.vue';
export default {
components: {
......@@ -41,6 +42,7 @@ export default {
Icon,
EnvironmentsDropdown,
Strategy,
RelatedIssuesRoot,
},
directives: {
GlTooltip: GlTooltipDirective,
......@@ -83,6 +85,11 @@ export default {
type: String,
required: true,
},
featureFlagIssuesEndpoint: {
type: String,
required: false,
default: '',
},
strategies: {
type: Array,
required: false,
......@@ -146,6 +153,9 @@ export default {
canDeleteStrategy() {
return this.formStrategies.length > 1;
},
showRelatedIssues() {
return this.featureFlagIssuesEndpoint.length > 0;
},
},
mounted() {
if (this.supportsStrategies) {
......@@ -313,6 +323,13 @@ export default {
</div>
</div>
<related-issues-root
v-if="showRelatedIssues"
:endpoint="featureFlagIssuesEndpoint"
:can-admin="true"
:is-linked-issue-block="false"
/>
<template v-if="supportsStrategies">
<div class="row">
<div class="col-md-12">
......
......@@ -16,6 +16,7 @@ export default () => {
path: el.dataset.featureFlagsPath,
environmentsEndpoint: el.dataset.environmentsEndpoint,
projectId: el.dataset.projectId,
featureFlagIssuesEndpoint: el.dataset.featureFlagIssuesEndpoint,
},
});
},
......
......@@ -148,6 +148,7 @@ export default {
</template>
<related-issuable-input
ref="relatedIssuableInput"
input-id="add-related-issues-form-input"
:focus-on-mount="true"
:references="pendingReferences"
:path-id-separator="pathIdSeparator"
......
......@@ -77,6 +77,11 @@ export default {
type: String,
required: true,
},
isLinkedIssueBlock: {
type: Boolean,
required: false,
default: true,
},
},
computed: {
hasRelatedIssues() {
......@@ -169,7 +174,7 @@ export default {
class="js-add-related-issues-form-area card-body bordered-box bg-white"
>
<add-issuable-form
:is-linked-issue-block="true"
:is-linked-issue-block="isLinkedIssueBlock"
:is-submitting="isSubmitting"
:issuable-type="issuableType"
:input-value="inputValue"
......
......@@ -81,6 +81,11 @@ export default {
required: false,
default: '',
},
isLinkedIssueBlock: {
type: Boolean,
required: false,
default: true,
},
},
data() {
this.store = new RelatedIssuesStore();
......@@ -226,6 +231,7 @@ export default {
:auto-complete-sources="autoCompleteSources"
:issuable-type="issuableType"
:path-id-separator="pathIdSeparator"
:is-linked-issue-block="isLinkedIssueBlock"
@saveReorder="saveIssueOrder"
@toggleAddRelatedIssuesForm="onToggleAddRelatedIssuesForm"
@addIssuableFormInput="onInput"
......
......@@ -9,10 +9,22 @@ module Projects
private
def create_service
::FeatureFlagIssues::CreateService.new(feature_flag, current_user, create_params)
end
def list_service
::FeatureFlagIssues::ListService.new(feature_flag, current_user)
end
def destroy_service
::FeatureFlagIssues::DestroyService.new(link, current_user)
end
def link
@link ||= ::FeatureFlagIssue.find(params[:id])
end
def feature_flag
project.operations_feature_flags.find_by_iid(params[:feature_flag_iid])
end
......
......@@ -95,11 +95,11 @@ class Projects::FeatureFlagsController < Projects::ApplicationController
protected
def feature_flag
@feature_flag ||= if new_version_feature_flags_enabled?
project.operations_feature_flags.find_by_iid!(params[:iid])
else
project.operations_feature_flags.legacy_flag.find_by_iid!(params[:iid])
end
@feature_flag ||= @noteable = if new_version_feature_flags_enabled?
project.operations_feature_flags.find_by_iid!(params[:iid])
else
project.operations_feature_flags.legacy_flag.find_by_iid!(params[:iid])
end
end
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)
- breadcrumb_title @feature_flag.name
- page_title s_('FeatureFlags|Edit Feature Flag')
......@@ -5,4 +7,5 @@
#js-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)} }
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
end
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
resource :feature_flags_client, only: [] do
post :reset_token
......
......@@ -187,4 +187,135 @@ describe Projects::FeatureFlagIssuesController do
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
# 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', () => {
path: '/feature_flags',
environmentsEndpoint: 'environments.json',
projectId: '8',
featureFlagIssuesEndpoint: `${TEST_HOST}/feature_flags/5/issues`,
},
store,
provide: {
......@@ -141,6 +142,12 @@ describe('Edit feature flag form', () => {
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', () => {
......
......@@ -15,6 +15,7 @@ import {
} from 'ee/feature_flags/constants';
import ToggleButton from '~/vue_shared/components/toggle_button.vue';
import { featureFlag, userList } from '../mock_data';
import RelatedIssuesRoot from 'ee/related_issues/components/related_issues_root.vue';
jest.mock('ee/api.js');
......@@ -59,6 +60,21 @@ describe('feature flag form', () => {
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', () => {
beforeEach(() => {
factory(requiredProps);
......
......@@ -57,6 +57,10 @@ describe('New feature flag form', () => {
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', () => {
const defaultScope = {
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
find("button[data-testid='delete-strategy-button']")
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
find('.js-feature-flag-status button')
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