Commit 5ed837f8 authored by Mike Greiling's avatar Mike Greiling

Merge branch '7250-group-bulk-edit-issues-mrs-epics-change-label' into 'master'

Group bulk edit issues/mrs/epics - change label

See merge request gitlab-org/gitlab-ee!14827
parents dc5e1d50 61006dc0
......@@ -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) }
......
......@@ -194,6 +194,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
'!'
......
......@@ -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
{
......
- @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
......
- @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"
......
- 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)
......
# 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**.
......@@ -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:**
......
import initFilteredSearch from '~/pages/search/init_filtered_search';
import issuableInitBulkUpdateSidebar from '~/issuable_init_bulk_update_sidebar';
import FilteredSearchTokenKeysEpics from 'ee/filtered_search/filtered_search_token_keys_epics';
import initEpicCreateApp from 'ee/epic/epic_bundle';
const EPIC_BULK_UPDATE_PREFIX = 'epic_';
document.addEventListener('DOMContentLoaded', () => {
initFilteredSearch({
page: 'epics',
......@@ -12,4 +15,6 @@ document.addEventListener('DOMContentLoaded', () => {
});
initEpicCreateApp(true);
issuableInitBulkUpdateSidebar.init(EPIC_BULK_UPDATE_PREFIX);
});
......@@ -9,10 +9,11 @@ class Groups::EpicsController < Groups::ApplicationController
include EpicsActions
before_action :check_epics_available!
before_action :epic, except: [:index, :create]
before_action :epic, except: [:index, :create, :bulk_update]
before_action :set_issuables_index, only: :index
before_action :authorize_update_issuable!, only: :update
before_action :authorize_create_epic!, only: [:create]
before_action :verify_group_bulk_edit_enabled!, only: [:bulk_update]
before_action do
push_frontend_feature_flag(:epic_trees, @group)
......@@ -109,4 +110,8 @@ class Groups::EpicsController < Groups::ApplicationController
def authorize_create_epic!
return render_404 unless can?(current_user, :create_epic, group)
end
def verify_group_bulk_edit_enabled!
render_404 unless group.feature_available?(:group_bulk_edit)
end
end
......@@ -30,11 +30,21 @@ module EE
tooltip
end
def label_dropdown_data(project, opts = {})
super.merge({
scoped_labels: project&.feature_available?(:scoped_labels)&.to_s,
def label_dropdown_data(edit_context, opts = {})
scoped_labels_fields = {
scoped_labels: edit_context&.feature_available?(:scoped_labels)&.to_s,
scoped_labels_documentation_link: help_page_path('user/project/labels.md', anchor: 'scoped-labels')
})
}
return super.merge(scoped_labels_fields) unless edit_context.is_a?(Group)
{
toggle: "dropdown",
field_name: opts[:field_name] || "label_name[]",
show_no: "true",
show_any: "true",
group_id: edit_context&.try(:id)
}.merge(scoped_labels_fields, opts)
end
def sidebar_label_dropdown_data(issuable_type, issuable_sidebar)
......
......@@ -46,6 +46,7 @@ module EE
validate :validate_parent, on: :create
alias_attribute :parent_ids, :parent_id
alias_method :issuing_parent, :group
scope :in_parents, -> (parent_ids) { where(parent_id: parent_ids) }
scope :inc_group, -> { includes(:group) }
......
......@@ -12,7 +12,7 @@ module Epics
def execute(epic)
# start_date and end_date columns are no longer writable by users because those
# are composite fields managed by the system.
params.except!(:start_date, :end_date)
params.extract!(:start_date, :end_date)
update_task_event(epic) || update(epic)
......
%li
%li{ id: dom_id(epic), data: { labels: epic.label_ids, id: epic.id } }
.issue-box
- if @can_bulk_update
.issue-check.hidden
= check_box_tag dom_id(epic, "selected"), nil, false, 'data-id' => epic.id, class: "selected-issuable"
.issuable-info-container
.issuable-main-info
.issue-title.title
......
- @can_bulk_update = can?(current_user, :admin_epic, @group) && @group.feature_available?(:group_bulk_edit)
- page_title "Epics"
.top-area
= render 'shared/issuable/epic_nav', type: :epics
.nav-controls
- if @can_bulk_update
= render_if_exists 'shared/issuable/bulk_update_button', type: :epics
- if can?(current_user, :create_epic, @group)
#epic-create-root{ data: { endpoint: request.url, 'align-right' => true } }
= render 'shared/epic/search_bar', type: :epics
- if @can_bulk_update
= render_if_exists 'shared/issuable/group_bulk_update_sidebar', group: @group, type: :epics
- if @epics.to_a.any?
= render 'shared/epics'
- else
......
......@@ -25,6 +25,9 @@
= form_tag page_filter_path, method: :get, class: 'flex-fill filter-form js-filter-form' do
- if params[:search].present?
= hidden_field_tag :search, params[:search]
- if @can_bulk_update
.check-all-holder.d-none.d-sm-block.hidden
= check_box_tag "check-all-issues", nil, false, class: "check-all-issues left"
.epics-other-filters.filtered-search-wrapper.d-flex.flex-column.flex-md-row
.filtered-search-box
= dropdown_tag(custom_icon('icon_history'),
......
......@@ -8,11 +8,17 @@
.filter-item.inline.update-issues-btn.float-left
= button_tag _('Update all'), class: "btn update-selected-issues btn-info", disabled: true
= button_tag _('Cancel'), class: "btn btn-default js-bulk-update-menu-hide float-right"
- unless type == :epics
.block
.title
= _('Milestone')
.filter-item
= dropdown_tag(_('Select milestone'), options: { title: _('Assign milestone'), toggle_class: 'js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update', filter: true, dropdown_class: 'dropdown-menu-selectable dropdown-menu-milestone', placeholder: _('Search milestones'), data: { show_no: true, field_name: 'update[milestone_id]', milestones: milestones_filter_path(only_group_milestones: true, format: :json), use_id: true, default_label: _('Milestone') } })
.block
.title
= _('Milestone')
.filter-item
= dropdown_tag(_('Select milestone'), options: { title: _('Assign milestone'), toggle_class: 'js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update', filter: true, dropdown_class: 'dropdown-menu-selectable dropdown-menu-milestone', placeholder: _('Search milestones'), data: { show_no: true, field_name: 'update[milestone_id]', milestones: milestones_filter_path(only_group_milestones: true, format: :json), use_id: true, default_label: _('Milestone') } })
= _('Labels')
.filter-item.labels-filter
= render "shared/issuable/label_dropdown", classes: ["js-filter-bulk-update", "js-multiselect"], dropdown_title: _('Apply a label'), show_create: false, show_footer: false, extra_options: false, filter_submit: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true, default_label: "Labels" }, label_name: _('Select labels'), no_default_styles: true, edit_context: group
= hidden_field_tag 'update[issuable_ids]', []
= hidden_field_tag :state_event, params[:state_event]
---
title: Support for bulk editing labels at a group level
merge_request: 14827
author:
type: added
......@@ -83,6 +83,10 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
scope module: :epics do
resources :notes, only: [:index, :create, :destroy, :update], concerns: :awardable, constraints: { id: /\d+/ }
end
collection do
post :bulk_update
end
end
resources :issues, only: [] do
......
......@@ -472,5 +472,67 @@ describe Groups::EpicsController do
expect(controller).to set_flash[:notice].to(/The epic was successfully deleted\./)
end
end
describe 'POST #bulk_update' do
context 'with correct params' do
subject { post :bulk_update, params: params, format: :json }
let(:label1) { create(:group_label, group: group)}
let(:label2) { create(:group_label, group: group)}
let(:epics) { create_list(:epic, 2, group: group, labels: [label1]) }
let(:params) do
{
update: {
add_label_ids: [label2],
issuable_ids: "#{epics[0].id}, #{epics[1].id}",
remove_label_ids: [label1]
},
group_id: group
}
end
before do
sign_in(user)
group.add_reporter(user)
end
context 'when group bulk edit feature is disabled' do
before do
stub_licensed_features(group_bulk_edit: false, epics: true)
group.add_reporter(user)
end
it 'returns status 404' do
subject
expect(response.status).to eq(404)
end
it 'does not update merge requests milestone' do
subject
epics.each { |epic| expect(epic.reload.labels).to eq([label1])}
end
end
context 'when group bulk edit feature is enabled' do
before do
stub_licensed_features(group_bulk_edit: true, epics: true)
end
it 'returns status 200' do
subject
expect(response.status).to eq(200)
end
it 'updates epics labels' do
subject
epics.each {|epic| expect(epic.reload.labels).to eq([label2]) }
end
end
end
end
end
end
......@@ -32,4 +32,46 @@ describe LabelsHelper do
end
end
end
describe '#label_dropdown_data' do
subject { label_dropdown_data(edit_context, opts) }
let(:opts) { { default_label: "Labels" } }
let(:data) do
{
toggle: "dropdown",
field_name: opts[:field_name] || "label_name[]",
show_no: "true",
show_any: "true",
default_label: "Labels",
scoped_labels: "false",
scoped_labels_documentation_link: "/help/user/project/labels.md#scoped-labels"
}
end
context 'when edit_context is a project' do
let(:edit_context) { create(:project) }
let(:label) { create(:label, project: edit_context, title: 'bug') }
before do
data.merge!({
project_id: edit_context.id,
namespace_path: edit_context.namespace.full_path,
project_path: edit_context.path
})
end
it { is_expected.to eq(data) }
end
context 'when edit_context is a group' do
let(:edit_context) { create(:group) }
let(:label) { create(:group_label, group: edit_context, title: 'bug') }
before do
data.merge!(group_id: edit_context.id)
end
it { is_expected.to eq(data) }
end
end
end
......@@ -96,4 +96,12 @@ describe 'Group routing', "routing" do
expect(post('/groups/gitlabhq/-/merge_requests/bulk_update')).to route_to('groups/merge_requests#bulk_update', group_id: 'gitlabhq')
end
end
describe 'epics' do
it 'routes post to #bulk_update' do
allow(Group).to receive(:find_by_full_path).with('gitlabhq', any_args).and_return(true)
expect(post('/groups/gitlabhq/-/epics/bulk_update')).to route_to('groups/epics#bulk_update', group_id: 'gitlabhq')
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Issuable::BulkUpdateService do
let(:user) { create(:user) }
let(:group) { create(:group) }
context 'with epics' do
subject { described_class.new(user, params).execute('epic') }
let(:epic1) { create(:epic, group: group, labels: [label1]) }
let(:epic2) { create(:epic, group: group, labels: [label1]) }
let(:label1) { create(:group_label, group: group) }
describe 'updating labels' do
let(:label2) { create(:group_label, group: group, title: 'Bug') }
let(:label3) { create(:group_label, group: group, title: 'suggestion') }
let(:issuables) { [epic1, epic2] }
let(:params) do
{
issuable_ids: issuables.map(&:id).join(','),
add_label_ids: [label2.id, label3.id],
remove_label_ids: [label1.id]
}
end
context 'when epics are disabled' do
before do
group.add_reporter(user)
stub_licensed_features(epics: false)
end
it 'does not update labels' do
issuables.each do |issuable|
expect { subject }.not_to change { issuable.labels }
end
end
end
context 'when epics are enabled' do
before do
group.add_reporter(user)
stub_licensed_features(epics: true)
end
it 'updates epic labels' do
result = subject
expect(result[:success]).to be_truthy
expect(result[:count]).to eq(issuables.count)
issuables.each do |issuable|
expect(issuable.reload.labels).to eq([label2, label3])
end
end
end
end
end
end
......@@ -1579,6 +1579,9 @@ msgstr ""
msgid "Applied"
msgstr ""
msgid "Apply a label"
msgstr ""
msgid "Apply suggestion"
msgstr ""
......@@ -12870,6 +12873,9 @@ msgstr ""
msgid "Select group or project"
msgstr ""
msgid "Select labels"
msgstr ""
msgid "Select members to invite"
msgstr ""
......
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