Commit ab650e66 authored by Sean McGivern's avatar Sean McGivern

Merge branch '7249-group-bulk-edit-issues-milestone' into 'master'

Allow bulk update for group issues - milestones

See merge request gitlab-org/gitlab-ee!14141
parents d78a88bd 6d10d7c2
/* eslint-disable consistent-return, func-names, array-callback-return, prefer-arrow-callback, no-unused-vars */ /* eslint-disable consistent-return, func-names, array-callback-return, prefer-arrow-callback */
import $ from 'jquery'; import $ from 'jquery';
import _ from 'underscore'; import _ from 'underscore';
...@@ -7,7 +7,7 @@ import Flash from './flash'; ...@@ -7,7 +7,7 @@ import Flash from './flash';
import { __ } from './locale'; import { __ } from './locale';
export default { export default {
init({ container, form, issues, prefixId } = {}) { init({ form, issues, prefixId } = {}) {
this.prefixId = prefixId || 'issue_'; this.prefixId = prefixId || 'issue_';
this.form = form || this.getElement('.bulk-update'); this.form = form || this.getElement('.bulk-update');
this.$labelDropdown = this.form.find('.js-label-select'); this.$labelDropdown = this.form.find('.js-label-select');
......
...@@ -2,26 +2,13 @@ import $ from 'jquery'; ...@@ -2,26 +2,13 @@ import $ from 'jquery';
import axios from './lib/utils/axios_utils'; import axios from './lib/utils/axios_utils';
import flash from './flash'; import flash from './flash';
import { s__, __ } from './locale'; import { s__, __ } from './locale';
import IssuableBulkUpdateSidebar from './issuable_bulk_update_sidebar'; import issuableInitBulkUpdateSidebar from './issuable_init_bulk_update_sidebar';
import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
export default class IssuableIndex { export default class IssuableIndex {
constructor(pagePrefix) { constructor(pagePrefix) {
this.initBulkUpdate(pagePrefix); issuableInitBulkUpdateSidebar.init(pagePrefix);
IssuableIndex.resetIncomingEmailToken(); IssuableIndex.resetIncomingEmailToken();
} }
initBulkUpdate(pagePrefix) {
const userCanBulkUpdate = $('.issues-bulk-update').length > 0;
const alreadyInitialized = Boolean(this.bulkUpdateSidebar);
if (userCanBulkUpdate && !alreadyInitialized) {
IssuableBulkUpdateActions.init({
prefixId: pagePrefix,
});
this.bulkUpdateSidebar = new IssuableBulkUpdateSidebar();
}
}
static resetIncomingEmailToken() { static resetIncomingEmailToken() {
const $resetToken = $('.incoming-email-token-reset'); const $resetToken = $('.incoming-email-token-reset');
......
import IssuableBulkUpdateSidebar from './issuable_bulk_update_sidebar';
import issuableBulkUpdateActions from './issuable_bulk_update_actions';
export default {
bulkUpdateSidebar: null,
init(prefixId) {
const bulkUpdateEl = document.querySelector('.issues-bulk-update');
const alreadyInitialized = Boolean(this.bulkUpdateSidebar);
if (bulkUpdateEl && !alreadyInitialized) {
issuableBulkUpdateActions.init({ prefixId });
this.bulkUpdateSidebar = new IssuableBulkUpdateSidebar();
}
return this.bulkUpdateSidebar;
},
};
import projectSelect from '~/project_select'; import projectSelect from '~/project_select';
import initFilteredSearch from '~/pages/search/init_filtered_search'; import initFilteredSearch from '~/pages/search/init_filtered_search';
import issuableInitBulkUpdateSidebar from '~/issuable_init_bulk_update_sidebar';
import { FILTERED_SEARCH } from '~/pages/constants'; import { FILTERED_SEARCH } from '~/pages/constants';
import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys'; import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys';
import initManualOrdering from '~/manual_ordering'; import initManualOrdering from '~/manual_ordering';
const ISSUE_BULK_UPDATE_PREFIX = 'issue_';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
IssuableFilteredSearchTokenKeys.addExtraTokensForIssues(); IssuableFilteredSearchTokenKeys.addExtraTokensForIssues();
issuableInitBulkUpdateSidebar.init(ISSUE_BULK_UPDATE_PREFIX);
initFilteredSearch({ initFilteredSearch({
page: FILTERED_SEARCH.ISSUES, page: FILTERED_SEARCH.ISSUES,
......
...@@ -92,7 +92,7 @@ module IssuableActions ...@@ -92,7 +92,7 @@ module IssuableActions
end end
def bulk_update def bulk_update
result = Issuable::BulkUpdateService.new(project, current_user, bulk_update_params).execute(resource_name) result = Issuable::BulkUpdateService.new(current_user, bulk_update_params).execute(resource_name)
quantity = result[:count] quantity = result[:count]
render json: { notice: "#{quantity} #{resource_name.pluralize(quantity)} updated" } render json: { notice: "#{quantity} #{resource_name.pluralize(quantity)} updated" }
...@@ -181,7 +181,7 @@ module IssuableActions ...@@ -181,7 +181,7 @@ module IssuableActions
end end
def authorize_admin_issuable! def authorize_admin_issuable!
unless can?(current_user, :"admin_#{resource_name}", @project) # rubocop:disable Gitlab/ModuleWithInstanceVariables unless can?(current_user, :"admin_#{resource_name}", parent)
return access_denied! return access_denied!
end end
end end
......
# frozen_string_literal: true # frozen_string_literal: true
module Issuable module Issuable
class BulkUpdateService < IssuableBaseService class BulkUpdateService
include Gitlab::Allowable
attr_accessor :current_user, :params
def initialize(user = nil, params = {})
@current_user, @params = user, params.dup
end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
def execute(type) def execute(type)
model_class = type.classify.constantize model_class = type.classify.constantize
......
- @can_bulk_update = can?(current_user, :admin_issue, @group)
- page_title "Issues" - page_title "Issues"
= content_for :meta_tags do = content_for :meta_tags do
= auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{@group.name} issues") = auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{@group.name} issues")
...@@ -9,8 +11,15 @@ ...@@ -9,8 +11,15 @@
= render 'shared/issuable/nav', type: :issues = render 'shared/issuable/nav', type: :issues
.nav-controls .nav-controls
= render 'shared/issuable/feed_buttons' = render 'shared/issuable/feed_buttons'
- if @can_bulk_update
= render_if_exists 'shared/issuable/bulk_update_button'
= render 'shared/new_project_item_select', path: 'issues/new', label: "New issue", type: :issues, with_feature_enabled: 'issues', with_shared: false, include_projects_in_subgroups: true = render 'shared/new_project_item_select', path: 'issues/new', label: "New issue", type: :issues, with_feature_enabled: 'issues', with_shared: false, include_projects_in_subgroups: true
= render 'shared/issuable/search_bar', type: :issues = render 'shared/issuable/search_bar', type: :issues
- if @can_bulk_update
= render_if_exists 'shared/issuable/group_bulk_update_sidebar', group: @group, type: :issues
= render 'shared/issues' = render 'shared/issues'
# frozen_string_literal: true
class Groups::BulkUpdateController < Groups::ApplicationController
include IssuableActions
before_action :authorize_admin_group!
before_action :verify_group_bulk_edit_enabled!, only: [:bulk_update]
private
def verify_group_bulk_edit_enabled!
render_404 unless group.feature_available?(:group_bulk_edit)
end
end
# frozen_string_literal: true
class Groups::IssuesController < Groups::BulkUpdateController
end
...@@ -15,6 +15,7 @@ class License < ApplicationRecord ...@@ -15,6 +15,7 @@ class License < ApplicationRecord
contribution_analytics contribution_analytics
elastic_search elastic_search
export_issues export_issues
group_bulk_edit
group_burndown_charts group_burndown_charts
group_webhooks group_webhooks
issuable_default_templates issuable_default_templates
......
= button_tag _('Edit issues'), class: 'btn btn-default append-right-10 js-bulk-update-toggle'
- group = local_assigns.fetch(:group)
- type = local_assigns.fetch(:type)
%aside.issues-bulk-update.js-right-sidebar.right-sidebar{ 'aria-live' => 'polite', data: { 'signed-in': current_user.present? } }
.issuable-sidebar.hidden
= form_tag [:bulk_update, group, type], method: :post, class: "bulk-update" do
.block.issuable-sidebar-header
.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"
.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') } })
= hidden_field_tag 'update[issuable_ids]', []
= hidden_field_tag :state_event, params[:state_event]
---
title: Allow bulk editing group issues
merge_request: 14141
author:
type: added
...@@ -72,6 +72,12 @@ constraints(::Constraints::GroupUrlConstrainer.new) do ...@@ -72,6 +72,12 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
end end
end end
resources :issues, only: [] do
collection do
post :bulk_update
end
end
resources :todos, only: [:create] resources :todos, only: [:create]
resources :boards, only: [:create, :update, :destroy] do resources :boards, only: [:create, :update, :destroy] do
collection do collection do
......
...@@ -80,4 +80,12 @@ describe 'Group routing', "routing" do ...@@ -80,4 +80,12 @@ describe 'Group routing', "routing" do
expect(get('/groups/gitlabhq/-/packages')).to route_to('groups/packages#index', group_id: 'gitlabhq') expect(get('/groups/gitlabhq/-/packages')).to route_to('groups/packages#index', group_id: 'gitlabhq')
end end
end end
describe 'issues' 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/-/issues/bulk_update')).to route_to('groups/issues#bulk_update', group_id: 'gitlabhq')
end
end
end end
...@@ -12226,6 +12226,9 @@ msgstr "" ...@@ -12226,6 +12226,9 @@ msgstr ""
msgid "Select merge moment" msgid "Select merge moment"
msgstr "" msgstr ""
msgid "Select milestone"
msgstr ""
msgid "Select private project" msgid "Select private project"
msgstr "" msgstr ""
...@@ -14946,6 +14949,9 @@ msgstr "" ...@@ -14946,6 +14949,9 @@ msgstr ""
msgid "Update" msgid "Update"
msgstr "" msgstr ""
msgid "Update all"
msgstr ""
msgid "Update approvers" msgid "Update approvers"
msgstr "" msgstr ""
......
...@@ -2,14 +2,14 @@ import $ from 'jquery'; ...@@ -2,14 +2,14 @@ import $ from 'jquery';
import MockAdaptor from 'axios-mock-adapter'; import MockAdaptor from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import IssuableIndex from '~/issuable_index'; import IssuableIndex from '~/issuable_index';
import issuableInitBulkUpdateSidebar from '~/issuable_init_bulk_update_sidebar';
describe('Issuable', () => { describe('Issuable', () => {
let Issuable;
describe('initBulkUpdate', () => { describe('initBulkUpdate', () => {
it('should not set bulkUpdateSidebar', () => { it('should not set bulkUpdateSidebar', () => {
Issuable = new IssuableIndex('issue_'); new IssuableIndex('issue_'); // eslint-disable-line no-new
expect(Issuable.bulkUpdateSidebar).not.toBeDefined(); expect(issuableInitBulkUpdateSidebar.bulkUpdateSidebar).toBeNull();
}); });
it('should set bulkUpdateSidebar', () => { it('should set bulkUpdateSidebar', () => {
...@@ -17,9 +17,9 @@ describe('Issuable', () => { ...@@ -17,9 +17,9 @@ describe('Issuable', () => {
element.classList.add('issues-bulk-update'); element.classList.add('issues-bulk-update');
document.body.appendChild(element); document.body.appendChild(element);
Issuable = new IssuableIndex('issue_'); new IssuableIndex('issue_'); // eslint-disable-line no-new
expect(Issuable.bulkUpdateSidebar).toBeDefined(); expect(issuableInitBulkUpdateSidebar.bulkUpdateSidebar).toBeDefined();
}); });
}); });
...@@ -36,7 +36,7 @@ describe('Issuable', () => { ...@@ -36,7 +36,7 @@ describe('Issuable', () => {
input.setAttribute('id', 'issuable_email'); input.setAttribute('id', 'issuable_email');
document.body.appendChild(input); document.body.appendChild(input);
Issuable = new IssuableIndex('issue_'); new IssuableIndex('issue_'); // eslint-disable-line no-new
mock = new MockAdaptor(axios); mock = new MockAdaptor(axios);
......
...@@ -11,9 +11,27 @@ describe Issuable::BulkUpdateService do ...@@ -11,9 +11,27 @@ describe Issuable::BulkUpdateService do
.reverse_merge(issuable_ids: Array(issuables).map(&:id).join(',')) .reverse_merge(issuable_ids: Array(issuables).map(&:id).join(','))
type = Array(issuables).first.model_name.param_key type = Array(issuables).first.model_name.param_key
Issuable::BulkUpdateService.new(project, user, bulk_update_params).execute(type) Issuable::BulkUpdateService.new(user, bulk_update_params).execute(type)
end end
shared_examples 'updates milestones' do
it 'succeeds' do
result = bulk_update(issues, milestone_id: milestone.id)
expect(result[:success]).to be_truthy
expect(result[:count]).to eq(issues.count)
end
it 'updates the issues milestone' do
bulk_update(issues, milestone_id: milestone.id)
issues.each do |issue|
expect(issue.reload.milestone).to eq(milestone)
end
end
end
context 'with project issues' do
describe 'close issues' do describe 'close issues' do
let(:issues) { create_list(:issue, 2, project: project) } let(:issues) { create_list(:issue, 2, project: project) }
...@@ -153,20 +171,10 @@ describe Issuable::BulkUpdateService do ...@@ -153,20 +171,10 @@ describe Issuable::BulkUpdateService do
end end
describe 'updating milestones' do describe 'updating milestones' do
let(:issue) { create(:issue, project: project) } let(:issues) { [create(:issue, project: project)] }
let(:milestone) { create(:milestone, project: project) } let(:milestone) { create(:milestone, project: project) }
it 'succeeds' do it_behaves_like 'updates milestones'
result = bulk_update(issue, milestone_id: milestone.id)
expect(result[:success]).to be_truthy
expect(result[:count]).to eq(1)
end
it 'updates the issue milestone' do
expect { bulk_update(issue, milestone_id: milestone.id) }
.to change { issue.reload.milestone }.from(nil).to(milestone)
end
end end
describe 'updating labels' do describe 'updating labels' do
...@@ -350,4 +358,24 @@ describe Issuable::BulkUpdateService do ...@@ -350,4 +358,24 @@ describe Issuable::BulkUpdateService do
end end
end end
end end
end
context 'with group issues' do
let(:group) { create(:group) }
context 'updating milestone' do
let(:milestone) { create(:milestone, group: group) }
let(:project1) { create(:project, :repository, group: group) }
let(:project2) { create(:project, :repository, group: group) }
let(:issue1) { create(:issue, project: project1) }
let(:issue2) { create(:issue, project: project2) }
let(:issues) { [issue1, issue2] }
before do
group.add_maintainer(user)
end
it_behaves_like 'updates milestones'
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