Commit 69e02904 authored by Patrick Derichs's avatar Patrick Derichs

Add endpoint to move multiple issues

Add specs for new endpoint to move multiple issues.
Add changelog entry

Just check the first issue for the ability to move / update

Add specs for exceeding limits and malformed requests

Changed name of shared examples

Change title of changelog entry

Use %i instead of %w

Check permission to update issue on project instead of board

Use admin_issue permission to check for issue move ability

Changed variable name to avoid shadow issue_params method

Rename route to bulk_move

Change route definition

Check permissions for each issue

Combine methods for parameters permit check

Remove extra context

Change description of context

Check param for type Array

Add unit tests to MoveService

Use before_action for permission check

Use set instead of let!

Use let's instead of set
parent e6f8e120
...@@ -2,16 +2,24 @@ ...@@ -2,16 +2,24 @@
module Boards module Boards
class IssuesController < Boards::ApplicationController class IssuesController < Boards::ApplicationController
# This is the maximum amount of issues which can be moved by one request to
# bulk_move for now. This is temporary and might be removed in future by
# introducing an alternative (async?) approach.
# (related: https://gitlab.com/groups/gitlab-org/-/epics/382)
MAX_MOVE_ISSUES_COUNT = 50
include BoardsResponses include BoardsResponses
include ControllerWithCrossProjectAccessCheck include ControllerWithCrossProjectAccessCheck
requires_cross_project_access if: -> { board&.group_board? } requires_cross_project_access if: -> { board&.group_board? }
before_action :whitelist_query_limiting, only: [:index, :update] before_action :whitelist_query_limiting, only: [:index, :update, :bulk_move]
before_action :authorize_read_issue, only: [:index] before_action :authorize_read_issue, only: [:index]
before_action :authorize_create_issue, only: [:create] before_action :authorize_create_issue, only: [:create]
before_action :authorize_update_issue, only: [:update] before_action :authorize_update_issue, only: [:update]
skip_before_action :authenticate_user!, only: [:index] skip_before_action :authenticate_user!, only: [:index]
before_action :validate_id_list, only: [:bulk_move]
before_action :can_move_issues?, only: [:bulk_move]
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
def index def index
...@@ -46,6 +54,17 @@ module Boards ...@@ -46,6 +54,17 @@ module Boards
end end
end end
def bulk_move
service = Boards::Issues::MoveService.new(board_parent, current_user, move_params(true))
issues = Issue.find(params[:ids])
if service.execute_multiple(issues)
head :ok
else
head :unprocessable_entity
end
end
def update def update
service = Boards::Issues::MoveService.new(board_parent, current_user, move_params) service = Boards::Issues::MoveService.new(board_parent, current_user, move_params)
...@@ -58,6 +77,10 @@ module Boards ...@@ -58,6 +77,10 @@ module Boards
private private
def can_move_issues?
head(:forbidden) unless can?(current_user, :admin_issue, board)
end
def render_issues(issues, metadata) def render_issues(issues, metadata)
data = { issues: serialize_as_json(issues) } data = { issues: serialize_as_json(issues) }
data.merge!(metadata) data.merge!(metadata)
...@@ -90,8 +113,9 @@ module Boards ...@@ -90,8 +113,9 @@ module Boards
end end
end end
def move_params def move_params(multiple = false)
params.permit(:board_id, :id, :from_list_id, :to_list_id, :move_before_id, :move_after_id) id_param = multiple ? :ids : :id
params.permit(id_param, :board_id, :from_list_id, :to_list_id, :move_before_id, :move_after_id)
end end
def issue_params def issue_params
...@@ -112,5 +136,10 @@ module Boards ...@@ -112,5 +136,10 @@ module Boards
# Also see https://gitlab.com/gitlab-org/gitlab-ce/issues/42439 # Also see https://gitlab.com/gitlab-org/gitlab-ce/issues/42439
Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42428') Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42428')
end end
def validate_id_list
head(:bad_request) unless params[:ids].is_a?(Array)
head(:unprocessable_entity) if params[:ids].size > MAX_MOVE_ISSUES_COUNT
end
end end
end end
...@@ -4,14 +4,37 @@ module Boards ...@@ -4,14 +4,37 @@ module Boards
module Issues module Issues
class MoveService < Boards::BaseService class MoveService < Boards::BaseService
def execute(issue) def execute(issue)
return false unless can?(current_user, :update_issue, issue) issue_modification_params = issue_params(issue)
return false if issue_params(issue).empty? return false if issue_modification_params.empty?
update(issue) move_single_issue(issue, issue_modification_params)
end
def execute_multiple(issues)
return false if issues.empty?
last_inserted_issue_id = nil
issues.map do |issue|
issue_modification_params = issue_params(issue)
next if issue_modification_params.empty?
if last_inserted_issue_id
issue_modification_params[:move_between_ids] = move_between_ids({ move_after_id: nil, move_before_id: last_inserted_issue_id })
end
last_inserted_issue_id = issue.id
move_single_issue(issue, issue_modification_params)
end.all?
end end
private private
def move_single_issue(issue, issue_modification_params)
return false unless can?(current_user, :update_issue, issue)
update(issue, issue_modification_params)
end
def board def board
@board ||= parent.boards.find(params[:board_id]) @board ||= parent.boards.find(params[:board_id])
end end
...@@ -33,8 +56,8 @@ module Boards ...@@ -33,8 +56,8 @@ module Boards
end end
# rubocop: enable CodeReuse/ActiveRecord # rubocop: enable CodeReuse/ActiveRecord
def update(issue) def update(issue, issue_modification_params)
::Issues::UpdateService.new(issue.project, current_user, issue_params(issue)).execute(issue) ::Issues::UpdateService.new(issue.project, current_user, issue_modification_params).execute(issue)
end end
def issue_params(issue) def issue_params(issue)
...@@ -48,6 +71,7 @@ module Boards ...@@ -48,6 +71,7 @@ module Boards
) )
end end
move_between_ids = move_between_ids(params)
if move_between_ids if move_between_ids
attrs[:move_between_ids] = move_between_ids attrs[:move_between_ids] = move_between_ids
attrs[:board_group_id] = board.group&.id attrs[:board_group_id] = board.group&.id
...@@ -78,8 +102,8 @@ module Boards ...@@ -78,8 +102,8 @@ module Boards
end end
# rubocop: enable CodeReuse/ActiveRecord # rubocop: enable CodeReuse/ActiveRecord
def move_between_ids def move_between_ids(move_params)
ids = [params[:move_after_id], params[:move_before_id]] ids = [move_params[:move_after_id], move_params[:move_before_id]]
.map(&:to_i) .map(&:to_i)
.map { |m| m.positive? ? m : nil } .map { |m| m.positive? ? m : nil }
......
---
title: Add endpoint to move multiple issues in boards
merge_request: 30216
author:
type: added
...@@ -82,7 +82,11 @@ Rails.application.routes.draw do ...@@ -82,7 +82,11 @@ Rails.application.routes.draw do
resources :issues, only: [:index, :create, :update] resources :issues, only: [:index, :create, :update]
end end
resources :issues, module: :boards, only: [:index, :update] resources :issues, module: :boards, only: [:index, :update] do
collection do
put :bulk_move, format: :json
end
end
Gitlab.ee do Gitlab.ee do
resources :users, module: :boards, only: [:index] resources :users, module: :boards, only: [:index]
......
...@@ -164,6 +164,201 @@ describe Boards::IssuesController do ...@@ -164,6 +164,201 @@ describe Boards::IssuesController do
end end
end end
describe 'PUT move_multiple' do
let(:todo) { create(:group_label, group: group, name: 'Todo') }
let(:development) { create(:group_label, group: group, name: 'Development') }
let(:user) { create(:group_member, :maintainer, user: create(:user), group: group ).user }
let(:guest) { create(:group_member, :guest, user: create(:user), group: group ).user }
let(:project) { create(:project, group: group) }
let(:group) { create(:group) }
let(:board) { create(:board, project: project) }
let(:list1) { create(:list, board: board, label: todo, position: 0) }
let(:list2) { create(:list, board: board, label: development, position: 1) }
let(:issue1) { create(:labeled_issue, project: project, labels: [todo], author: user, relative_position: 10) }
let(:issue2) { create(:labeled_issue, project: project, labels: [todo], author: user, relative_position: 20) }
let(:issue3) { create(:labeled_issue, project: project, labels: [todo], author: user, relative_position: 30) }
let(:issue4) { create(:labeled_issue, project: project, labels: [development], author: user, relative_position: 100) }
let(:move_params) do
{
board_id: board.id,
ids: [issue1.id, issue2.id, issue3.id],
from_list_id: list1.id,
to_list_id: list2.id,
move_before_id: issue4.id,
move_after_id: nil
}
end
before do
project.add_maintainer(user)
project.add_guest(guest)
end
shared_examples 'move issues endpoint provider' do
before do
sign_in(signed_in_user)
end
it 'moves issues as expected' do
put :bulk_move, params: move_issues_params
expect(response).to have_gitlab_http_status(expected_status)
list_issues user: requesting_user, board: board, list: list2
expect(response).to have_gitlab_http_status(200)
expect(response).to match_response_schema('entities/issue_boards')
responded_issues = json_response['issues']
expect(responded_issues.length).to eq expected_issue_count
ids_in_order = responded_issues.pluck('id')
expect(ids_in_order).to eq(expected_issue_ids_in_order)
end
end
context 'when items are moved to another list' do
it_behaves_like 'move issues endpoint provider' do
let(:signed_in_user) { user }
let(:move_issues_params) { move_params }
let(:requesting_user) { user }
let(:expected_status) { 200 }
let(:expected_issue_count) { 4 }
let(:expected_issue_ids_in_order) { [issue4.id, issue1.id, issue2.id, issue3.id] }
end
end
context 'when moving just one issue' do
it_behaves_like 'move issues endpoint provider' do
let(:signed_in_user) { user }
let(:move_issues_params) do
move_params.dup.tap do |hash|
hash[:ids] = [issue2.id]
end
end
let(:requesting_user) { user }
let(:expected_status) { 200 }
let(:expected_issue_count) { 2 }
let(:expected_issue_ids_in_order) { [issue4.id, issue2.id] }
end
end
context 'when user is not allowed to move issue' do
it_behaves_like 'move issues endpoint provider' do
let(:signed_in_user) { guest }
let(:move_issues_params) do
move_params.dup.tap do |hash|
hash[:ids] = [issue2.id]
end
end
let(:requesting_user) { user }
let(:expected_status) { 403 }
let(:expected_issue_count) { 1 }
let(:expected_issue_ids_in_order) { [issue4.id] }
end
end
context 'when issues should be moved visually above existing issue in list' do
it_behaves_like 'move issues endpoint provider' do
let(:signed_in_user) { user }
let(:move_issues_params) do
move_params.dup.tap do |hash|
hash[:move_after_id] = issue4.id
hash[:move_before_id] = nil
end
end
let(:requesting_user) { user }
let(:expected_status) { 200 }
let(:expected_issue_count) { 4 }
let(:expected_issue_ids_in_order) { [issue1.id, issue2.id, issue3.id, issue4.id] }
end
end
context 'when destination list is empty' do
before do
# Remove issue from list
issue4.labels -= [development]
issue4.save!
end
it_behaves_like 'move issues endpoint provider' do
let(:signed_in_user) { user }
let(:move_issues_params) do
move_params.dup.tap do |hash|
hash[:move_before_id] = nil
end
end
let(:requesting_user) { user }
let(:expected_status) { 200 }
let(:expected_issue_count) { 3 }
let(:expected_issue_ids_in_order) { [issue1.id, issue2.id, issue3.id] }
end
end
context 'when no position arguments are given' do
it_behaves_like 'move issues endpoint provider' do
let(:signed_in_user) { user }
let(:move_issues_params) do
move_params.dup.tap do |hash|
hash[:move_before_id] = nil
end
end
let(:requesting_user) { user }
let(:expected_status) { 200 }
let(:expected_issue_count) { 4 }
let(:expected_issue_ids_in_order) { [issue1.id, issue2.id, issue3.id, issue4.id] }
end
end
context 'when move_before_id and move_after_id are given' do
let(:issue5) { create(:labeled_issue, project: project, labels: [development], author: user, relative_position: 90) }
it_behaves_like 'move issues endpoint provider' do
let(:signed_in_user) { user }
let(:move_issues_params) do
move_params.dup.tap do |hash|
hash[:move_before_id] = issue5.id
hash[:move_after_id] = issue4.id
end
end
let(:requesting_user) { user }
let(:expected_status) { 200 }
let(:expected_issue_count) { 5 }
let(:expected_issue_ids_in_order) { [issue5.id, issue1.id, issue2.id, issue3.id, issue4.id] }
end
end
context 'when request contains too many issues' do
it_behaves_like 'move issues endpoint provider' do
let(:signed_in_user) { user }
let(:move_issues_params) do
move_params.dup.tap do |hash|
hash[:ids] = (0..51).to_a
end
end
let(:requesting_user) { user }
let(:expected_status) { 422 }
let(:expected_issue_count) { 1 }
let(:expected_issue_ids_in_order) { [issue4.id] }
end
end
context 'when request is malformed' do
it_behaves_like 'move issues endpoint provider' do
let(:signed_in_user) { user }
let(:move_issues_params) do
move_params.dup.tap do |hash|
hash[:ids] = 'foobar'
end
end
let(:requesting_user) { user }
let(:expected_status) { 400 }
let(:expected_issue_count) { 1 }
let(:expected_issue_ids_in_order) { [issue4.id] }
end
end
end
def list_issues(user:, board:, list: nil) def list_issues(user:, board:, list: nil)
sign_in(user) sign_in(user)
......
...@@ -52,5 +52,91 @@ describe Boards::Issues::MoveService do ...@@ -52,5 +52,91 @@ describe Boards::Issues::MoveService do
it_behaves_like 'issues move service', true it_behaves_like 'issues move service', true
end end
describe '#execute_multiple' do
set(:group) { create(:group) }
set(:user) { create(:user) }
set(:project) { create(:project, namespace: group) }
set(:board1) { create(:board, group: group) }
set(:development) { create(:group_label, group: group, name: 'Development') }
set(:testing) { create(:group_label, group: group, name: 'Testing') }
set(:list1) { create(:list, board: board1, label: development, position: 0) }
set(:list2) { create(:list, board: board1, label: testing, position: 1) }
let(:params) { { board_id: board1.id, from_list_id: list1.id, to_list_id: list2.id } }
before do
project.add_developer(user)
end
it 'returns false if list of issues is empty' do
expect(described_class.new(group, user, params).execute_multiple([])).to eq(false)
end
context 'moving multiple issues' do
let(:issue1) { create(:labeled_issue, project: project, labels: [development]) }
let(:issue2) { create(:labeled_issue, project: project, labels: [development]) }
it 'moves multiple issues from one list to another' do
expect(described_class.new(group, user, params).execute_multiple([issue1, issue2])).to be_truthy
expect(issue1.labels).to eq([testing])
expect(issue2.labels).to eq([testing])
end
end
context 'moving a single issue' do
let(:issue1) { create(:labeled_issue, project: project, labels: [development]) }
it 'moves one issue' do
expect(described_class.new(group, user, params).execute_multiple([issue1])).to be_truthy
expect(issue1.labels).to eq([testing])
end
end
context 'moving issues visually after an existing issue' do
let(:existing_issue) { create(:labeled_issue, project: project, labels: [testing], relative_position: 10) }
let(:issue1) { create(:labeled_issue, project: project, labels: [development]) }
let(:issue2) { create(:labeled_issue, project: project, labels: [development]) }
let(:move_params) do
params.dup.tap do |hash|
hash[:move_before_id] = existing_issue.id
end
end
it 'moves one issue' do
expect(described_class.new(group, user, move_params).execute_multiple([issue1, issue2])).to be_truthy
expect(issue1.labels).to eq([testing])
expect(issue2.labels).to eq([testing])
expect(issue1.relative_position > existing_issue.relative_position).to eq(true)
expect(issue2.relative_position > issue1.relative_position).to eq(true)
end
end
context 'moving issues visually before an existing issue' do
let(:existing_issue) { create(:labeled_issue, project: project, labels: [testing], relative_position: 10) }
let(:issue1) { create(:labeled_issue, project: project, labels: [development]) }
let(:issue2) { create(:labeled_issue, project: project, labels: [development]) }
let(:move_params) do
params.dup.tap do |hash|
hash[:move_after_id] = existing_issue.id
end
end
it 'moves one issue' do
expect(described_class.new(group, user, move_params).execute_multiple([issue1, issue2])).to be_truthy
expect(issue1.labels).to eq([testing])
expect(issue2.labels).to eq([testing])
expect(issue2.relative_position < existing_issue.relative_position).to eq(true)
expect(issue1.relative_position < issue2.relative_position).to eq(true)
end
end
end
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