diff options
-rw-r--r-- | app/models/issue.rb | 1 | ||||
-rw-r--r-- | app/models/merge_request.rb | 1 | ||||
-rw-r--r-- | app/services/issuable/bulk_update_service.rb | 2 | ||||
-rw-r--r-- | app/views/groups/issues.html.haml | 2 | ||||
-rw-r--r-- | app/views/groups/merge_requests.html.haml | 2 | ||||
-rw-r--r-- | app/views/shared/issuable/_label_dropdown.html.haml | 3 | ||||
-rw-r--r-- | doc/user/group/bulk_editing/img/bulk-editing.png | bin | 98926 -> 99844 bytes | |||
-rw-r--r-- | doc/user/group/bulk_editing/index.md | 42 | ||||
-rw-r--r-- | doc/user/group/epics/img/bulk_editing.png | bin | 0 -> 72912 bytes | |||
-rw-r--r-- | doc/user/group/epics/index.md | 16 | ||||
-rw-r--r-- | spec/services/issuable/bulk_update_service_spec.rb | 319 |
11 files changed, 215 insertions, 173 deletions
diff --git a/app/models/issue.rb b/app/models/issue.rb index 164858dc432..bc5ec94081b 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -43,6 +43,7 @@ class Issue < ApplicationRecord validates :project, presence: true alias_attribute :parent_ids, :project_id + alias_method :issuing_parent, :project scope :in_projects, ->(project_ids) { where(project_id: project_ids) } diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 5e8a6a7d5e5..4c4883fc022 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -192,6 +192,7 @@ class MergeRequest < ApplicationRecord alias_attribute :project, :target_project alias_attribute :project_id, :target_project_id alias_attribute :auto_merge_enabled, :merge_when_pipeline_succeeds + alias_method :issuing_parent, :target_project def self.reference_prefix '!' diff --git a/app/services/issuable/bulk_update_service.rb b/app/services/issuable/bulk_update_service.rb index 6d215d7a3b9..273a12f386a 100644 --- a/app/services/issuable/bulk_update_service.rb +++ b/app/services/issuable/bulk_update_service.rb @@ -29,7 +29,7 @@ module Issuable items.each do |issuable| next unless can?(current_user, :"update_#{type}", issuable) - update_class.new(issuable.project, current_user, params).execute(issuable) + update_class.new(issuable.issuing_parent, current_user, params).execute(issuable) end { diff --git a/app/views/groups/issues.html.haml b/app/views/groups/issues.html.haml index 2163446425c..bf077eb09d2 100644 --- a/app/views/groups/issues.html.haml +++ b/app/views/groups/issues.html.haml @@ -1,4 +1,4 @@ -- @can_bulk_update = can?(current_user, :admin_issue, @group) +- @can_bulk_update = can?(current_user, :admin_issue, @group) && @group.feature_available?(:group_bulk_edit) - page_title "Issues" = content_for :meta_tags do diff --git a/app/views/groups/merge_requests.html.haml b/app/views/groups/merge_requests.html.haml index b5a2bab4799..0780fab513b 100644 --- a/app/views/groups/merge_requests.html.haml +++ b/app/views/groups/merge_requests.html.haml @@ -1,4 +1,4 @@ -- @can_bulk_update = can?(current_user, :admin_merge_request, @group) +- @can_bulk_update = can?(current_user, :admin_merge_request, @group) && @group.feature_available?(:group_bulk_edit) - page_title "Merge Requests" diff --git a/app/views/shared/issuable/_label_dropdown.html.haml b/app/views/shared/issuable/_label_dropdown.html.haml index 483652852b6..bca5db16bd3 100644 --- a/app/views/shared/issuable/_label_dropdown.html.haml +++ b/app/views/shared/issuable/_label_dropdown.html.haml @@ -1,4 +1,5 @@ - project = @target_project || @project +- edit_context = local_assigns.fetch(:edit_context, nil) || project - show_create = local_assigns.fetch(:show_create, true) - extra_options = local_assigns.fetch(:extra_options, true) - filter_submit = local_assigns.fetch(:filter_submit, true) @@ -8,7 +9,7 @@ - classes = local_assigns.fetch(:classes, []) - selected = local_assigns.fetch(:selected, nil) - dropdown_title = local_assigns.fetch(:dropdown_title, "Filter by label") -- dropdown_data = label_dropdown_data(@project, labels: labels_filter_path_with_defaults, default_label: "Labels") +- dropdown_data = label_dropdown_data(edit_context, labels: labels_filter_path_with_defaults(only_group_labels: edit_context.is_a?(Group)), default_label: "Labels") - dropdown_data.merge!(data_options) - label_name = local_assigns.fetch(:label_name, "Labels") - no_default_styles = local_assigns.fetch(:no_default_styles, false) diff --git a/doc/user/group/bulk_editing/img/bulk-editing.png b/doc/user/group/bulk_editing/img/bulk-editing.png Binary files differindex 8e2bd265054..ca1662a781b 100644 --- a/doc/user/group/bulk_editing/img/bulk-editing.png +++ b/doc/user/group/bulk_editing/img/bulk-editing.png diff --git a/doc/user/group/bulk_editing/index.md b/doc/user/group/bulk_editing/index.md index c8715577eb2..ea48b0b9fef 100644 --- a/doc/user/group/bulk_editing/index.md +++ b/doc/user/group/bulk_editing/index.md @@ -1,25 +1,31 @@ -# Bulk editing issue and merge request milestones **(PREMIUM)** +# Bulk editing issues, merge requests, and epics at the group level **(PREMIUM)** -> - [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/issues/7249) for issues in - [GitLab Premium](https://about.gitlab.com/pricing/) 12.1. -> - [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/issues/12719) for merge - requests in GitLab [GitLab Premium](https://about.gitlab.com/pricing/) 12.2. +> - [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/issues/7249) for issues in [GitLab Premium](https://about.gitlab.com/pricing/) 12.1. +> - [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/issues/12719) for merge requests in [GitLab Premium](https://about.gitlab.com/pricing/) 12.2. +> - [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/issues/7250) for epics in [GitLab Premium](https://about.gitlab.com/pricing/) 12.2. -Milestones can be updated simultaneously across multiple issues or merge requests by using the bulk editing feature. +## Editing milestones and labels -![Bulk editing](img/bulk-editing.png) +> **Notes:** +> +> - A permission level of `Reporter` or higher is required in order to manage issues. +> - A permission level of `Developer` or higher is required in order to manage merge requests. +> - A permission level of `Reporter` or higher is required in order to manage epics. + +By using the bulk editing feature: -NOTE: **Note:** -A permission level of `Reporter` or higher is required in order to manage issues, and -a permission level of `Developer` or higher is required in order to manage merge requests. +- Milestones can be updated simultaneously across multiple issues or merge requests. +- Labels can be updated simultaneously across multiple issues, merge requests, or epics. + +![Bulk editing](img/bulk-editing.png) -To bulk update group issue or merge request milestones: +To bulk update group issues, merge requests, or epics: -1. Navigate to the issues or merge requests list. -1. Click the **Edit issues** or **Edit merge requests** button. - - This will open a sidebar on the right-hand side of your screen where an editable field - for milestones will be displayed. - - Checkboxes will also appear beside each issue or merge request. -1. Check the checkbox beside each issue to be edited. -1. Select the desired milestone from the sidebar. +1. Navigate to the issues, merge requests, or epics list. +1. Click **Edit issues**, **Edit merge requests**, or **Edit epics**. + - This will open a sidebar on the right-hand side where editable fields + for milestones and labels will be displayed. + - Checkboxes will also appear beside each issue, merge request, or epic. +1. Check the checkbox beside each issue, merge request, or epic to be edited. +1. Select the desired new values from the sidebar. 1. Click **Update all**. diff --git a/doc/user/group/epics/img/bulk_editing.png b/doc/user/group/epics/img/bulk_editing.png Binary files differnew file mode 100644 index 00000000000..85610bef83e --- /dev/null +++ b/doc/user/group/epics/img/bulk_editing.png diff --git a/doc/user/group/epics/index.md b/doc/user/group/epics/index.md index 4ab562b655f..5968b91c9b7 100644 --- a/doc/user/group/epics/index.md +++ b/doc/user/group/epics/index.md @@ -97,6 +97,22 @@ have a [start or due date](#start-date-and-due-date), then you can see a Drag and drop to reorder issues and child epics. New issues and child epics added to an epic appear at the top of the list. +## Updating epics + +### Using bulk editing + +To apply labels across multiple epics: + +1. Go to the Epics list. +1. Click **Edit epics**. + - Checkboxes will appear beside each epic. + - A sidebar on the right-hand side will appear, with an editable field for labels. +1. Check the checkbox beside each epic to be edited. +1. Select the desired labels. +1. Click **Update all**. + +![bulk editing](img/bulk_editing.png) + ## Deleting an epic NOTE: **Note:** diff --git a/spec/services/issuable/bulk_update_service_spec.rb b/spec/services/issuable/bulk_update_service_spec.rb index fb12877fa05..e3a728f2566 100644 --- a/spec/services/issuable/bulk_update_service_spec.rb +++ b/spec/services/issuable/bulk_update_service_spec.rb @@ -31,7 +31,159 @@ describe Issuable::BulkUpdateService do end end - context 'with project issuables' do + shared_examples 'updating labels' do + def create_issue_with_labels(labels) + create(:labeled_issue, project: project, labels: labels) + end + + let(:issue_all_labels) { create_issue_with_labels([bug, regression, merge_requests]) } + let(:issue_bug_and_regression) { create_issue_with_labels([bug, regression]) } + let(:issue_bug_and_merge_requests) { create_issue_with_labels([bug, merge_requests]) } + let(:issue_no_labels) { create(:issue, project: project) } + let(:issues) { [issue_all_labels, issue_bug_and_regression, issue_bug_and_merge_requests, issue_no_labels] } + + let(:labels) { [] } + let(:add_labels) { [] } + let(:remove_labels) { [] } + + let(:bulk_update_params) do + { + label_ids: labels.map(&:id), + add_label_ids: add_labels.map(&:id), + remove_label_ids: remove_labels.map(&:id) + } + end + + before do + bulk_update(issues, bulk_update_params) + end + + context 'when label_ids are passed' do + let(:issues) { [issue_all_labels, issue_no_labels] } + let(:labels) { [bug, regression] } + + it 'updates the labels of all issues passed to the labels passed' do + expect(issues.map(&:reload).map(&:label_ids)).to all(match_array(labels.map(&:id))) + end + + it 'does not update issues not passed in' do + expect(issue_bug_and_regression.label_ids).to contain_exactly(bug.id, regression.id) + end + + context 'when those label IDs are empty' do + let(:labels) { [] } + + it 'updates the issues passed to have no labels' do + expect(issues.map(&:reload).map(&:label_ids)).to all(be_empty) + end + end + end + + context 'when add_label_ids are passed' do + let(:issues) { [issue_all_labels, issue_bug_and_merge_requests, issue_no_labels] } + let(:add_labels) { [bug, regression, merge_requests] } + + it 'adds those label IDs to all issues passed' do + expect(issues.map(&:reload).map(&:label_ids)).to all(include(*add_labels.map(&:id))) + end + + it 'does not update issues not passed in' do + expect(issue_bug_and_regression.label_ids).to contain_exactly(bug.id, regression.id) + end + end + + context 'when remove_label_ids are passed' do + let(:issues) { [issue_all_labels, issue_bug_and_merge_requests, issue_no_labels] } + let(:remove_labels) { [bug, regression, merge_requests] } + + it 'removes those label IDs from all issues passed' do + expect(issues.map(&:reload).map(&:label_ids)).to all(be_empty) + end + + it 'does not update issues not passed in' do + expect(issue_bug_and_regression.label_ids).to contain_exactly(bug.id, regression.id) + end + end + + context 'when add_label_ids and remove_label_ids are passed' do + let(:issues) { [issue_all_labels, issue_bug_and_merge_requests, issue_no_labels] } + let(:add_labels) { [bug] } + let(:remove_labels) { [merge_requests] } + + it 'adds the label IDs to all issues passed' do + expect(issues.map(&:reload).map(&:label_ids)).to all(include(bug.id)) + end + + it 'removes the label IDs from all issues passed' do + expect(issues.map(&:reload).flat_map(&:label_ids)).not_to include(merge_requests.id) + end + + it 'does not update issues not passed in' do + expect(issue_bug_and_regression.label_ids).to contain_exactly(bug.id, regression.id) + end + end + + context 'when add_label_ids and label_ids are passed' do + let(:issues) { [issue_all_labels, issue_bug_and_regression, issue_bug_and_merge_requests] } + let(:labels) { [merge_requests] } + let(:add_labels) { [regression] } + + it 'adds the label IDs to all issues passed' do + expect(issues.map(&:reload).map(&:label_ids)).to all(include(regression.id)) + end + + it 'ignores the label IDs parameter' do + expect(issues.map(&:reload).map(&:label_ids)).to all(include(bug.id)) + end + + it 'does not update issues not passed in' do + expect(issue_no_labels.label_ids).to be_empty + end + end + + context 'when remove_label_ids and label_ids are passed' do + let(:issues) { [issue_no_labels, issue_bug_and_regression] } + let(:labels) { [merge_requests] } + let(:remove_labels) { [regression] } + + it 'removes the label IDs from all issues passed' do + expect(issues.map(&:reload).flat_map(&:label_ids)).not_to include(regression.id) + end + + it 'ignores the label IDs parameter' do + expect(issues.map(&:reload).flat_map(&:label_ids)).not_to include(merge_requests.id) + end + + it 'does not update issues not passed in' do + expect(issue_all_labels.label_ids).to contain_exactly(bug.id, regression.id, merge_requests.id) + end + end + + context 'when add_label_ids, remove_label_ids, and label_ids are passed' do + let(:issues) { [issue_bug_and_merge_requests, issue_no_labels] } + let(:labels) { [regression] } + let(:add_labels) { [bug] } + let(:remove_labels) { [merge_requests] } + + it 'adds the label IDs to all issues passed' do + expect(issues.map(&:reload).map(&:label_ids)).to all(include(bug.id)) + end + + it 'removes the label IDs from all issues passed' do + expect(issues.map(&:reload).flat_map(&:label_ids)).not_to include(merge_requests.id) + end + + it 'ignores the label IDs parameter' do + expect(issues.map(&:reload).flat_map(&:label_ids)).not_to include(regression.id) + end + + it 'does not update issues not passed in' do + expect(issue_bug_and_regression.label_ids).to contain_exactly(bug.id, regression.id) + end + end + end + + context 'with issuables at a project level' do describe 'close issues' do let(:issues) { create_list(:issue, 2, project: project) } @@ -178,159 +330,11 @@ describe Issuable::BulkUpdateService do end describe 'updating labels' do - def create_issue_with_labels(labels) - create(:labeled_issue, project: project, labels: labels) - end - let(:bug) { create(:label, project: project) } let(:regression) { create(:label, project: project) } let(:merge_requests) { create(:label, project: project) } - let(:issue_all_labels) { create_issue_with_labels([bug, regression, merge_requests]) } - let(:issue_bug_and_regression) { create_issue_with_labels([bug, regression]) } - let(:issue_bug_and_merge_requests) { create_issue_with_labels([bug, merge_requests]) } - let(:issue_no_labels) { create(:issue, project: project) } - let(:issues) { [issue_all_labels, issue_bug_and_regression, issue_bug_and_merge_requests, issue_no_labels] } - - let(:labels) { [] } - let(:add_labels) { [] } - let(:remove_labels) { [] } - - let(:bulk_update_params) do - { - label_ids: labels.map(&:id), - add_label_ids: add_labels.map(&:id), - remove_label_ids: remove_labels.map(&:id) - } - end - - before do - bulk_update(issues, bulk_update_params) - end - - context 'when label_ids are passed' do - let(:issues) { [issue_all_labels, issue_no_labels] } - let(:labels) { [bug, regression] } - - it 'updates the labels of all issues passed to the labels passed' do - expect(issues.map(&:reload).map(&:label_ids)).to all(match_array(labels.map(&:id))) - end - - it 'does not update issues not passed in' do - expect(issue_bug_and_regression.label_ids).to contain_exactly(bug.id, regression.id) - end - - context 'when those label IDs are empty' do - let(:labels) { [] } - - it 'updates the issues passed to have no labels' do - expect(issues.map(&:reload).map(&:label_ids)).to all(be_empty) - end - end - end - - context 'when add_label_ids are passed' do - let(:issues) { [issue_all_labels, issue_bug_and_merge_requests, issue_no_labels] } - let(:add_labels) { [bug, regression, merge_requests] } - - it 'adds those label IDs to all issues passed' do - expect(issues.map(&:reload).map(&:label_ids)).to all(include(*add_labels.map(&:id))) - end - - it 'does not update issues not passed in' do - expect(issue_bug_and_regression.label_ids).to contain_exactly(bug.id, regression.id) - end - end - - context 'when remove_label_ids are passed' do - let(:issues) { [issue_all_labels, issue_bug_and_merge_requests, issue_no_labels] } - let(:remove_labels) { [bug, regression, merge_requests] } - - it 'removes those label IDs from all issues passed' do - expect(issues.map(&:reload).map(&:label_ids)).to all(be_empty) - end - - it 'does not update issues not passed in' do - expect(issue_bug_and_regression.label_ids).to contain_exactly(bug.id, regression.id) - end - end - - context 'when add_label_ids and remove_label_ids are passed' do - let(:issues) { [issue_all_labels, issue_bug_and_merge_requests, issue_no_labels] } - let(:add_labels) { [bug] } - let(:remove_labels) { [merge_requests] } - - it 'adds the label IDs to all issues passed' do - expect(issues.map(&:reload).map(&:label_ids)).to all(include(bug.id)) - end - - it 'removes the label IDs from all issues passed' do - expect(issues.map(&:reload).flat_map(&:label_ids)).not_to include(merge_requests.id) - end - - it 'does not update issues not passed in' do - expect(issue_bug_and_regression.label_ids).to contain_exactly(bug.id, regression.id) - end - end - - context 'when add_label_ids and label_ids are passed' do - let(:issues) { [issue_all_labels, issue_bug_and_regression, issue_bug_and_merge_requests] } - let(:labels) { [merge_requests] } - let(:add_labels) { [regression] } - - it 'adds the label IDs to all issues passed' do - expect(issues.map(&:reload).map(&:label_ids)).to all(include(regression.id)) - end - - it 'ignores the label IDs parameter' do - expect(issues.map(&:reload).map(&:label_ids)).to all(include(bug.id)) - end - - it 'does not update issues not passed in' do - expect(issue_no_labels.label_ids).to be_empty - end - end - - context 'when remove_label_ids and label_ids are passed' do - let(:issues) { [issue_no_labels, issue_bug_and_regression] } - let(:labels) { [merge_requests] } - let(:remove_labels) { [regression] } - - it 'removes the label IDs from all issues passed' do - expect(issues.map(&:reload).flat_map(&:label_ids)).not_to include(regression.id) - end - - it 'ignores the label IDs parameter' do - expect(issues.map(&:reload).flat_map(&:label_ids)).not_to include(merge_requests.id) - end - - it 'does not update issues not passed in' do - expect(issue_all_labels.label_ids).to contain_exactly(bug.id, regression.id, merge_requests.id) - end - end - - context 'when add_label_ids, remove_label_ids, and label_ids are passed' do - let(:issues) { [issue_bug_and_merge_requests, issue_no_labels] } - let(:labels) { [regression] } - let(:add_labels) { [bug] } - let(:remove_labels) { [merge_requests] } - - it 'adds the label IDs to all issues passed' do - expect(issues.map(&:reload).map(&:label_ids)).to all(include(bug.id)) - end - - it 'removes the label IDs from all issues passed' do - expect(issues.map(&:reload).flat_map(&:label_ids)).not_to include(merge_requests.id) - end - - it 'ignores the label IDs parameter' do - expect(issues.map(&:reload).flat_map(&:label_ids)).not_to include(regression.id) - end - - it 'does not update issues not passed in' do - expect(issue_bug_and_regression.label_ids).to contain_exactly(bug.id, regression.id) - end - end + it_behaves_like 'updating labels' end describe 'subscribe to issues' do @@ -360,7 +364,7 @@ describe Issuable::BulkUpdateService do end end - context 'with group issuables ' do + context 'with issuables at a group level' do let(:group) { create(:group) } describe 'updating milestones' do @@ -387,5 +391,18 @@ describe Issuable::BulkUpdateService do it_behaves_like 'updates milestones' end end + + describe 'updating labels' do + let(:project) { create(:project, :repository, group: group) } + let(:bug) { create(:group_label, group: group) } + let(:regression) { create(:group_label, group: group) } + let(:merge_requests) { create(:group_label, group: group) } + + before do + group.add_reporter(user) + end + + it_behaves_like 'updating labels' + end end end |