Skip to content
Snippets Groups Projects
Commit 90c60138 authored by Eric Eastwood's avatar Eric Eastwood
Browse files

Move "Move to different project" to sidebar

parent a3af6830
No related branches found
No related tags found
No related merge requests found
Showing
with 184 additions and 216 deletions
Loading
Loading
@@ -3,7 +3,7 @@
Due date
- if can?(current_user, :admin_issue, @project)
= icon("spinner spin", class: "block-loading")
= link_to "Edit", "#", class: "edit-link pull-right"
= link_to "Edit", "#", class: "js-sidebar-dropdown-toggle edit-link pull-right"
.value
.value-content
%span.no-value{ "v-if" => "!issue.dueDate" }
Loading
Loading
Loading
Loading
@@ -3,7 +3,7 @@
Labels
- if can?(current_user, :admin_issue, @project)
= icon("spinner spin", class: "block-loading")
= link_to "Edit", "#", class: "edit-link pull-right"
= link_to "Edit", "#", class: "js-sidebar-dropdown-toggle edit-link pull-right"
.value.issuable-show-labels
%span.no-value{ "v-if" => "issue.labels && issue.labels.length === 0" }
None
Loading
Loading
Loading
Loading
@@ -3,7 +3,7 @@
Milestone
- if can?(current_user, :admin_issue, @project)
= icon("spinner spin", class: "block-loading")
= link_to "Edit", "#", class: "edit-link pull-right"
= link_to "Edit", "#", class: "js-sidebar-dropdown-toggle edit-link pull-right"
.value
%span.no-value{ "v-if" => "!issue.milestone" }
None
Loading
Loading
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M9 6H2a2 2 0 1 0 0 4h7v2.586a1 1 0 0 0 1.707.707l4.586-4.586a1 1 0 0 0 0-1.414l-4.586-4.586A1 1 0 0 0 9 3.414V6z"/></svg>
Loading
Loading
@@ -29,18 +29,6 @@
 
= render 'shared/issuable/form/metadata', issuable: issuable, form: form
 
- if issuable.can_move?(current_user)
%hr
.form-group
= label_tag :move_to_project_id, 'Move', class: 'control-label'
.col-sm-10
.issuable-form-select-holder
= hidden_field_tag :move_to_project_id, nil, class: 'js-move-dropdown', data: { placeholder: 'Select project', projects_url: autocomplete_projects_path(project_id: @project.id), page_size: MoveToProjectFinder::PAGE_SIZE }
&nbsp;
%span{ data: { toggle: 'tooltip', placement: 'auto top' }, style: 'cursor: default',
title: 'Moving an issue will copy the discussion to a different project and close it here. All participants will be notified of the new location.' }
= icon('question-circle')
= render 'shared/issuable/form/branch_chooser', issuable: issuable, form: form
 
= render 'shared/issuable/form/merge_params', issuable: issuable
Loading
Loading
Loading
Loading
@@ -34,7 +34,7 @@
Milestone
= icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- if can_edit_issuable
= link_to 'Edit', '#', class: 'edit-link pull-right'
= link_to 'Edit', '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right'
.value.hide-collapsed
- if issuable.milestone
= link_to issuable.milestone.title, milestone_path(issuable.milestone), class: "bold has-tooltip", title: milestone_remaining_days(issuable.milestone), data: { container: "body", html: 1 }
Loading
Loading
@@ -60,7 +60,7 @@
Due date
= icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- if can?(current_user, :"admin_#{issuable.to_ability_name}", @project)
= link_to 'Edit', '#', class: 'edit-link pull-right'
= link_to 'Edit', '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right'
.value.hide-collapsed
%span.value-content
- if issuable.due_date
Loading
Loading
@@ -95,7 +95,7 @@
Labels
= icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- if can_edit_issuable
= link_to 'Edit', '#', class: 'edit-link pull-right'
= link_to 'Edit', '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right'
.value.issuable-show-labels.hide-collapsed{ class: ("has-labels" if selected_labels.any?) }
- if selected_labels.any?
- selected_labels.each do |label|
Loading
Loading
@@ -141,5 +141,22 @@
%cite{ title: project_ref }
= project_ref
= clipboard_button(text: project_ref, title: "Copy reference to clipboard", placement: "left")
- if current_user && issuable.can_move?(current_user)
.block.js-sidebar-move-issue-block
.sidebar-collapsed-icon{ data: { toggle: 'tooltip', placement: 'left', container: 'body' }, title: 'Move issue' }
= custom_icon('icon_arrow_right')
.dropdown.sidebar-move-issue-dropdown.hide-collapsed
%button.btn.btn-default.btn-block.js-sidebar-dropdown-toggle.js-move-issue{ type: 'button',
data: { toggle: 'dropdown' } }
Move issue
.dropdown-menu.dropdown-menu-selectable
= dropdown_title('Move issue')
= dropdown_filter('Search project', search_id: 'sidebar-move-issue-dropdown-search')
= dropdown_content
= dropdown_loading
= dropdown_footer add_content_class: true do
%button.btn.btn-new.sidebar-move-issue-confirmation-button.js-move-issue-confirmation-button{ disabled: true }
Move
= icon('spinner spin', class: 'sidebar-move-issue-confirmation-loading-icon')
 
%script.js-sidebar-options{ type: "application/json" }= issuable_sidebar_options(issuable, can_edit_issuable).to_json.html_safe
Loading
Loading
@@ -13,7 +13,7 @@
Assignee
= icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- if can_edit_issuable
= link_to 'Edit', '#', class: 'edit-link pull-right'
= link_to 'Edit', '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right'
- if !signed_in
%a.gutter-toggle.pull-right.js-sidebar-toggle{ role: "button", href: "#", "aria-label" => "Toggle sidebar" }
= sidebar_gutter_toggle_icon
Loading
Loading
Loading
Loading
@@ -11,7 +11,7 @@
Assignee
= icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- if can_edit_issuable
= link_to 'Edit', '#', class: 'edit-link pull-right'
= link_to 'Edit', '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right'
.value.hide-collapsed
- if assignees.any?
- assignees.each do |assignee|
Loading
Loading
Loading
Loading
@@ -9,7 +9,7 @@
Assignee
= icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- if can_edit_issuable
= link_to 'Edit', '#', class: 'edit-link pull-right'
= link_to 'Edit', '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right'
.value.hide-collapsed
- if merge_request.assignee
= link_to_member(@project, merge_request.assignee, size: 32, extra_class: 'bold') do
Loading
Loading
Loading
Loading
@@ -21,7 +21,7 @@
.title
Start date
- if @project && can?(current_user, :admin_milestone, @project)
= link_to 'Edit', edit_project_milestone_path(@project, @milestone), class: 'edit-link pull-right'
= link_to 'Edit', edit_project_milestone_path(@project, @milestone), class: 'js-sidebar-dropdown-toggle edit-link pull-right'
.value
%span.value-content
- if milestone.start_date
Loading
Loading
@@ -51,7 +51,7 @@
.title.hide-collapsed
Due date
- if @project && can?(current_user, :admin_milestone, @project)
= link_to 'Edit', edit_project_milestone_path(@project, @milestone), class: 'edit-link pull-right'
= link_to 'Edit', edit_project_milestone_path(@project, @milestone), class: 'js-sidebar-dropdown-toggle edit-link pull-right'
.value.hide-collapsed
%span.value-content
- if milestone.due_date
Loading
Loading
---
title: Move "Move issue" controls to right-sidebar
merge_request:
author:
type: changed
Loading
Loading
@@ -303,6 +303,7 @@ constraints(ProjectUrlConstrainer.new) do
member do
post :toggle_subscription
post :mark_as_spam
post :move
get :referenced_merge_requests
get :related_branches
get :can_create_branch
Loading
Loading
doc/user/project/issues/img/sidebar_move_issue.png

53.2 KiB

Loading
Loading
@@ -86,6 +86,10 @@ Read through the [documentation on creating issues](create_new_issue.md).
 
Learn distinct ways to [close issues](closing_issues.md) in GitLab.
 
## Moving issues
Read through the [documentation on moving issues](moving_issues.md).
## Create a merge request from an issue
 
Learn more about it on the [GitLab Issues Functionalities documentation](issues_functionalities.md#18-new-merge-request).
Loading
Loading
# Moving Issues
Please read through the [GitLab Issue Documentation](index.md) for an overview on GitLab Issues.
Moving an issue will close it and duplicate it on the specified project.
There will also be a system note added to both issues indicating where it came from or went to.
You can move an issue with the "Move issue" button at the bottom of the right-sidebar when viewing the issue.
![move issue - button](img/sidebar_move_issue.png)
Loading
Loading
@@ -241,13 +241,10 @@ describe AutocompleteController do
 
it 'returns projects' do
expect(json_response).to be_kind_of(Array)
expect(json_response.size).to eq(2)
expect(json_response.first['id']).to eq(0)
expect(json_response.first['name_with_namespace']).to eq 'No project'
expect(json_response.size).to eq(1)
 
expect(json_response.last['id']).to eq authorized_project.id
expect(json_response.last['name_with_namespace']).to eq authorized_project.name_with_namespace
expect(json_response.first['id']).to eq authorized_project.id
expect(json_response.first['name_with_namespace']).to eq authorized_project.name_with_namespace
end
end
end
Loading
Loading
@@ -265,10 +262,10 @@ describe AutocompleteController do
 
it 'returns projects' do
expect(json_response).to be_kind_of(Array)
expect(json_response.size).to eq(2)
expect(json_response.size).to eq(1)
 
expect(json_response.last['id']).to eq authorized_search_project.id
expect(json_response.last['name_with_namespace']).to eq authorized_search_project.name_with_namespace
expect(json_response.first['id']).to eq authorized_search_project.id
expect(json_response.first['name_with_namespace']).to eq authorized_search_project.name_with_namespace
end
end
end
Loading
Loading
@@ -292,7 +289,7 @@ describe AutocompleteController do
 
it 'returns projects' do
expect(json_response).to be_kind_of(Array)
expect(json_response.size).to eq 3 # Of a total of 4
expect(json_response.size).to eq 2 # Of a total of 3
end
end
end
Loading
Loading
@@ -312,9 +309,9 @@ describe AutocompleteController do
get(:projects, project_id: project.id, offset_id: authorized_project.id)
end
 
it 'returns "No project"' do
expect(json_response.detect { |item| item['id'] == 0 }).to be_nil # 'No project' is not there
expect(json_response.detect { |item| item['id'] == authorized_project.id }).to be_nil # Offset project is not there either
it 'returns projects' do
expect(json_response).to be_kind_of(Array)
expect(json_response.size).to eq 2 # Of a total of 3
end
end
end
Loading
Loading
@@ -331,10 +328,9 @@ describe AutocompleteController do
get(:projects, project_id: project.id)
end
 
it 'returns a single "No project"' do
it 'returns no projects' do
expect(json_response).to be_kind_of(Array)
expect(json_response.size).to eq(1) # 'No project'
expect(json_response.first['id']).to eq 0
expect(json_response.size).to eq(0)
end
end
end
Loading
Loading
Loading
Loading
@@ -233,144 +233,119 @@ describe Projects::IssuesController do
end
end
 
context 'when moving issue to another private project' do
let(:another_project) { create(:project, :private) }
context 'when user has access to move issue' do
before do
another_project.team << [user, :reporter]
end
it 'moves issue to another project' do
move_issue
context 'Akismet is enabled' do
let(:project) { create(:project_empty_repo, :public) }
 
expect(response).to have_http_status :found
expect(another_project.issues).not_to be_empty
end
before do
stub_application_setting(recaptcha_enabled: true)
allow_any_instance_of(SpamService).to receive(:check_for_spam?).and_return(true)
end
 
context 'when user does not have access to move issue' do
it 'responds with 404' do
move_issue
context 'when an issue is not identified as spam' do
before do
allow_any_instance_of(described_class).to receive(:verify_recaptcha).and_return(false)
allow_any_instance_of(AkismetService).to receive(:spam?).and_return(false)
end
 
expect(response).to have_http_status :not_found
it 'normally updates the issue' do
expect { update_issue(title: 'Foo') }.to change { issue.reload.title }.to('Foo')
end
end
 
context 'Akismet is enabled' do
let(:project) { create(:project_empty_repo, :public) }
context 'when an issue is identified as spam' do
before do
stub_application_setting(recaptcha_enabled: true)
allow_any_instance_of(SpamService).to receive(:check_for_spam?).and_return(true)
allow_any_instance_of(AkismetService).to receive(:spam?).and_return(true)
end
 
context 'when an issue is not identified as spam' do
before do
allow_any_instance_of(described_class).to receive(:verify_recaptcha).and_return(false)
allow_any_instance_of(AkismetService).to receive(:spam?).and_return(false)
context 'when captcha is not verified' do
def update_spam_issue
update_issue(title: 'Spam Title', description: 'Spam lives here')
end
 
it 'normally updates the issue' do
expect { update_issue(title: 'Foo') }.to change { issue.reload.title }.to('Foo')
before do
allow_any_instance_of(described_class).to receive(:verify_recaptcha).and_return(false)
end
end
 
context 'when an issue is identified as spam' do
before do
allow_any_instance_of(AkismetService).to receive(:spam?).and_return(true)
it 'rejects an issue recognized as a spam' do
expect(Gitlab::Recaptcha).to receive(:load_configurations!).and_return(true)
expect { update_spam_issue }.not_to change { issue.reload.title }
end
 
context 'when captcha is not verified' do
def update_spam_issue
update_issue(title: 'Spam Title', description: 'Spam lives here')
end
it 'rejects an issue recognized as a spam when recaptcha disabled' do
stub_application_setting(recaptcha_enabled: false)
 
before do
allow_any_instance_of(described_class).to receive(:verify_recaptcha).and_return(false)
end
expect { update_spam_issue }.not_to change { issue.reload.title }
end
 
it 'rejects an issue recognized as a spam' do
expect(Gitlab::Recaptcha).to receive(:load_configurations!).and_return(true)
expect { update_spam_issue }.not_to change { issue.reload.title }
end
it 'creates a spam log' do
update_spam_issue
 
it 'rejects an issue recognized as a spam when recaptcha disabled' do
stub_application_setting(recaptcha_enabled: false)
spam_logs = SpamLog.all
 
expect { update_spam_issue }.not_to change { issue.reload.title }
end
expect(spam_logs.count).to eq(1)
expect(spam_logs.first.title).to eq('Spam Title')
expect(spam_logs.first.recaptcha_verified).to be_falsey
end
 
it 'creates a spam log' do
context 'as HTML' do
it 'renders verify template' do
update_spam_issue
 
spam_logs = SpamLog.all
expect(spam_logs.count).to eq(1)
expect(spam_logs.first.title).to eq('Spam Title')
expect(spam_logs.first.recaptcha_verified).to be_falsey
expect(response).to render_template(:verify)
end
end
 
context 'as HTML' do
it 'renders verify template' do
update_spam_issue
expect(response).to render_template(:verify)
end
context 'as JSON' do
before do
update_issue({ title: 'Spam Title', description: 'Spam lives here' }, format: :json)
end
 
context 'as JSON' do
before do
update_issue({ title: 'Spam Title', description: 'Spam lives here' }, format: :json)
end
it 'renders json errors' do
expect(json_response)
.to eql("errors" => ["Your issue has been recognized as spam. Please, change the content or solve the reCAPTCHA to proceed."])
end
it 'renders json errors' do
expect(json_response)
.to eql("errors" => ["Your issue has been recognized as spam. Please, change the content or solve the reCAPTCHA to proceed."])
end
 
it 'returns 422 status' do
expect(response).to have_http_status(422)
end
it 'returns 422 status' do
expect(response).to have_http_status(422)
end
end
end
 
context 'when captcha is verified' do
let(:spammy_title) { 'Whatever' }
let!(:spam_logs) { create_list(:spam_log, 2, user: user, title: spammy_title) }
context 'when captcha is verified' do
let(:spammy_title) { 'Whatever' }
let!(:spam_logs) { create_list(:spam_log, 2, user: user, title: spammy_title) }
 
def update_verified_issue
update_issue({ title: spammy_title },
{ spam_log_id: spam_logs.last.id,
recaptcha_verification: true })
end
def update_verified_issue
update_issue({ title: spammy_title },
{ spam_log_id: spam_logs.last.id,
recaptcha_verification: true })
end
 
before do
allow_any_instance_of(described_class).to receive(:verify_recaptcha)
.and_return(true)
end
before do
allow_any_instance_of(described_class).to receive(:verify_recaptcha)
.and_return(true)
end
 
it 'redirect to issue page' do
update_verified_issue
it 'redirect to issue page' do
update_verified_issue
 
expect(response)
.to redirect_to(project_issue_path(project, issue))
end
expect(response)
.to redirect_to(project_issue_path(project, issue))
end
 
it 'accepts an issue after recaptcha is verified' do
expect { update_verified_issue }.to change { issue.reload.title }.to(spammy_title)
end
it 'accepts an issue after recaptcha is verified' do
expect { update_verified_issue }.to change { issue.reload.title }.to(spammy_title)
end
 
it 'marks spam log as recaptcha_verified' do
expect { update_verified_issue }.to change { SpamLog.last.recaptcha_verified }.from(false).to(true)
end
it 'marks spam log as recaptcha_verified' do
expect { update_verified_issue }.to change { SpamLog.last.recaptcha_verified }.from(false).to(true)
end
 
it 'does not mark spam log as recaptcha_verified when it does not belong to current_user' do
spam_log = create(:spam_log)
it 'does not mark spam log as recaptcha_verified when it does not belong to current_user' do
spam_log = create(:spam_log)
 
expect { update_issue(spam_log_id: spam_log.id, recaptcha_verification: true) }
.not_to change { SpamLog.last.recaptcha_verified }
end
expect { update_issue(spam_log_id: spam_log.id, recaptcha_verification: true) }
.not_to change { SpamLog.last.recaptcha_verified }
end
end
end
Loading
Loading
@@ -385,13 +360,45 @@ describe Projects::IssuesController do
 
put :update, params
end
end
end
describe 'POST #move' do
before do
sign_in(user)
project.add_developer(user)
end
context 'when moving issue to another private project' do
let(:another_project) { create(:project, :private) }
context 'when user has access to move issue' do
before do
another_project.add_reporter(user)
end
it 'moves issue to another project' do
move_issue
expect(response).to have_http_status :ok
expect(another_project.issues).not_to be_empty
end
end
context 'when user does not have access to move issue' do
it 'responds with 404' do
move_issue
expect(response).to have_http_status :not_found
end
end
 
def move_issue
put :update,
post :move,
format: :json,
namespace_id: project.namespace.to_param,
project_id: project,
id: issue.iid,
issue: { title: 'New title' },
move_to_project_id: another_project.id
end
end
Loading
Loading
Loading
Loading
@@ -15,11 +15,11 @@ feature 'issue move to another project' do
background do
old_project.team << [user, :guest]
 
edit_issue(issue)
visit issue_path(issue)
end
 
scenario 'moving issue to another project not allowed' do
expect(page).to have_no_selector('#move_to_project_id')
expect(page).to have_no_selector('.js-sidebar-move-issue-block')
end
end
 
Loading
Loading
@@ -34,12 +34,14 @@ feature 'issue move to another project' do
old_project.team << [user, :reporter]
new_project.team << [user, :reporter]
 
edit_issue(issue)
visit issue_path(issue)
end
 
scenario 'moving issue to another project', js: true do
find('#issuable-move', visible: false).set(new_project.id)
click_button('Save changes')
find('.js-move-issue').trigger('click')
wait_for_requests
all('.js-move-issue-dropdown-item')[0].click
find('.js-move-issue-confirmation-button').click
 
expect(page).to have_content("Text with #{cross_reference}#{mr.to_reference}")
expect(page).to have_content("moved from #{cross_reference}#{issue.to_reference}")
Loading
Loading
@@ -50,13 +52,12 @@ feature 'issue move to another project' do
scenario 'searching project dropdown', js: true do
new_project_search.team << [user, :reporter]
 
page.within '.detail-page-description' do
first('.select2-choice').click
end
find('.js-move-issue').trigger('click')
wait_for_requests
 
fill_in('s2id_autogen1_search', with: new_project_search.name)
page.within '.js-sidebar-move-issue-block' do
fill_in('sidebar-move-issue-dropdown-search', with: new_project_search.name)
 
page.within '.select2-drop' do
expect(page).to have_content(new_project_search.name)
expect(page).not_to have_content(new_project.name)
end
Loading
Loading
@@ -68,10 +69,10 @@ feature 'issue move to another project' do
background { another_project.team << [user, :guest] }
 
scenario 'browsing projects in projects select' do
click_link 'Move to a different project'
find('.js-move-issue').trigger('click')
wait_for_requests
 
page.within '.select2-results' do
expect(page).to have_content 'No project'
page.within '.js-sidebar-move-issue-block' do
expect(page).to have_content new_project.name_with_namespace
end
end
Loading
Loading
@@ -89,11 +90,6 @@ feature 'issue move to another project' do
end
end
 
def edit_issue(issue)
visit issue_path(issue)
page.within('.issuable-actions') { first(:link, 'Edit').click }
end
def issue_path(issue)
project_issue_path(issue.project, issue)
end
Loading
Loading
Loading
Loading
@@ -34,7 +34,6 @@ describe('Issuable output', () => {
propsData: {
canUpdate: true,
canDestroy: true,
canMove: true,
endpoint: '/gitlab-org/gitlab-shell/issues/9/realtime_changes',
issuableRef: '#1',
initialTitleHtml: '',
Loading
Loading
@@ -43,7 +42,6 @@ describe('Issuable output', () => {
initialDescriptionText: '',
markdownPreviewPath: '/',
markdownDocsPath: '/',
projectsAutocompletePath: '/',
isConfidential: false,
projectNamespace: '/',
projectPath: '/',
Loading
Loading
@@ -226,7 +224,7 @@ describe('Issuable output', () => {
});
});
 
it('redirects if issue is moved', (done) => {
it('redirects if returned web_url has changed', (done) => {
spyOn(gl.utils, 'visitUrl');
spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve) => {
resolve({
Loading
Loading
@@ -250,23 +248,6 @@ describe('Issuable output', () => {
});
});
 
it('does not update issuable if project move confirm is false', (done) => {
spyOn(window, 'confirm').and.returnValue(false);
spyOn(vm.service, 'updateIssuable');
vm.store.formState.move_to_project_id = 1;
vm.updateIssuable();
setTimeout(() => {
expect(
vm.service.updateIssuable,
).not.toHaveBeenCalled();
done();
});
});
it('closes form on error', (done) => {
spyOn(window, 'Flash').and.callThrough();
spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve, reject) => {
Loading
Loading
import Vue from 'vue';
import projectMove from '~/issue_show/components/fields/project_move.vue';
describe('Project move field component', () => {
let vm;
let formState;
beforeEach((done) => {
const Component = Vue.extend(projectMove);
formState = {
move_to_project_id: 0,
};
vm = new Component({
propsData: {
formState,
projectsAutocompletePath: '/autocomplete',
},
}).$mount();
Vue.nextTick(done);
});
it('mounts select2 element', () => {
expect(
vm.$el.querySelector('.select2-container'),
).not.toBeNull();
});
it('updates formState on change', () => {
$(vm.$refs['move-dropdown']).val(2).trigger('change');
expect(
formState.move_to_project_id,
).toBe(2);
});
});
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment