Commit 8384aeab authored by Jan Provaznik's avatar Jan Provaznik

Merge branch 'epic-bulk-edit' into 'master'

RUN AS-IF-FOSS Epic bulk edit

See merge request gitlab-org/gitlab!34256
parents ca12428c a932042a
......@@ -87,6 +87,7 @@ export default {
issuable_ids: this.form.find('input[name="update[issuable_ids]"]').val(),
subscription_event: this.form.find('input[name="update[subscription_event]"]').val(),
health_status: this.form.find('input[name="update[health_status]"]').val(),
epic_id: this.form.find('input[name="update[epic_id]"]').val(),
add_label_ids: [],
remove_label_ids: [],
},
......
......@@ -71,6 +71,14 @@ export default class IssuableBulkUpdateSidebar {
})
.catch(() => {});
}
if (IS_EE) {
import('ee/vue_shared/components/sidebar/epics_select/epics_select_bundle')
.then(({ default: EpicSelect }) => {
EpicSelect();
})
.catch(() => {});
}
}
setupBulkUpdateActions() {
......
......@@ -1089,3 +1089,11 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu {
}
}
}
.bulk-update {
.dropdown-toggle-text {
&.is-default {
color: $gl-text-color;
}
}
}
......@@ -26,6 +26,12 @@
margin-right: 6px;
}
.bulk-update {
.filter-item {
margin-right: 0;
}
}
.sort-filter {
display: inline-block;
float: right;
......
- type = local_assigns.fetch(:type)
- bulk_issue_health_status_flag = Feature.enabled?(:bulk_update_health_status, @project&.group) && type == :issues && @project&.group&.feature_available?(:issuable_health_status)
- epic_bulk_edit_flag = @project&.group&.feature_available?(:epics) && type == :issues
%aside.issues-bulk-update.js-right-sidebar.right-sidebar{ "aria-live" => "polite", data: { 'signed-in': current_user.present? } }
.issuable-sidebar.hidden
......@@ -27,6 +28,13 @@
- field_name = "update[assignee_ids][]"
= dropdown_tag(_("Select assignee"), options: { toggle_class: "js-user-search js-update-assignee js-filter-submit js-filter-bulk-update", title: _("Assign to"), filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable",
placeholder: _("Search authors"), data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: field_name } })
- if epic_bulk_edit_flag
.block
.title
= _('Epic')
.filter-item.epic-bulk-edit
#js-epic-select-root{ data: { group_id: @project&.group&.id, show_header: "true" } }
%input{ id: 'issue_epic_id', type: 'hidden', name: 'update[epic_id]' }
.block
.title
= _('Milestone')
......
......@@ -67,6 +67,11 @@ export default {
required: false,
default: DropdownVariant.Sidebar,
},
showHeader: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
......@@ -132,7 +137,7 @@ export default {
variant: this.variant,
groupId: this.groupId,
issueId: this.issueId,
selectedEpic: this.selectedEpic,
selectedEpic: this.initialEpic,
selectedEpicIssueId: this.epicIssueId,
});
$(this.$refs.dropdown).on('shown.bs.dropdown', () => this.fetchEpics());
......@@ -205,7 +210,7 @@ export default {
:toggle-text-class="dropdownButtonTextClass"
/>
<div class="dropdown-menu dropdown-select dropdown-menu-epics dropdown-menu-selectable">
<dropdown-header v-if="isDropdownVariantSidebar" />
<dropdown-header v-if="isDropdownVariantSidebar || showHeader" />
<dropdown-search-input @onSearchInput="setSearchQuery" />
<dropdown-contents
v-if="!epicsFetchInProgress"
......
import Vue from 'vue';
import EpicsSelect from 'ee/vue_shared/components/sidebar/epics_select/base.vue';
import { noneEpic } from 'ee/vue_shared/constants';
import { placeholderEpic } from 'ee/vue_shared/constants';
import { DropdownVariant } from 'ee/vue_shared/components/sidebar/epics_select/constants';
export default () => {
......@@ -19,7 +19,7 @@ export default () => {
},
data() {
return {
selectedEpic: noneEpic,
selectedEpic: placeholderEpic,
};
},
methods: {
......@@ -38,6 +38,7 @@ export default () => {
initialEpic: this.selectedEpic,
initialEpicLoading: false,
variant: DropdownVariant.Standalone,
showHeader: Boolean(el.dataset.showHeader),
},
on: {
onEpicSelect: this.handleEpicSelect.bind(this),
......
// eslint-disable-next-line import/prefer-default-export
import { __ } from '~/locale';
export const noneEpic = {
id: 0,
title: 'none',
title: __('No Epic'),
};
export const placeholderEpic = {
id: -1,
title: __('Select epic'),
};
......@@ -8,6 +8,7 @@ module EE
EE_PERMITTED_KEYS = %w[
weight
health_status
epic_id
].freeze
private
......
......@@ -26,6 +26,7 @@ module EE
super
set_health_status
set_epic_param
end
def set_health_status
......@@ -33,6 +34,33 @@ module EE
params[:health_status] = nil if params[:health_status] == IssuableFinder::Params::NONE.to_s
end
def set_epic_param
return unless params[:epic_id].present?
epic_id = params.delete(:epic_id)
params[:epic] = find_epic(epic_id)
end
def find_epic(epic_id)
return if remove_epic?(epic_id)
EpicsFinder.new(current_user, group_id: group&.id, include_ancestor_groups: true).find(epic_id)
rescue ActiveRecord::RecordNotFound
raise ArgumentError, _('Epic not found for given params')
end
def remove_epic?(epic_id)
epic_id == IssuableFinder::Params::NONE.to_s
end
def epics_available?
group&.feature_available?(:epics)
end
def group
parent.is_a?(Group) ? parent : parent.group
end
end
end
end
- group = local_assigns.fetch(:group)
- type = local_assigns.fetch(:type)
- bulk_issue_health_status_flag = type == :issues && Feature.enabled?(:bulk_update_health_status, group) && group&.feature_available?(:issuable_health_status)
- epic_bulk_edit_flag = type == :issues && group&.feature_available?(:epics)
%aside.issues-bulk-update.js-right-sidebar.right-sidebar{ 'aria-live' => 'polite', data: { 'signed-in': current_user.present? } }
.issuable-sidebar.hidden
......@@ -9,6 +10,13 @@
.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"
- if epic_bulk_edit_flag
.block
.title
= _('Epic')
.filter-item.epic-bulk-edit
#js-epic-select-root{ data: { group_id: group.id, show_header: "true" } }
%input{ id: 'issue_epic_id', type: 'hidden', name: 'update[epic_id]' }
- unless type == :epics
.block
.title
......
---
title: Epic bulk edit
merge_request: 34256
author:
type: added
......@@ -3,10 +3,11 @@
require 'spec_helper'
RSpec.describe Groups::IssuesController do
let(:user) { create(:user) }
let(:group) { create(:group, :public) }
let(:project) { create(:project_empty_repo, :public, namespace: group) }
let(:milestone) { create(:milestone, group: group) }
let(:user) { create(:user) }
let(:epic) { create(:epic, group: group) }
let(:issue1) { create(:issue, project: project) }
let(:issue2) { create(:issue, project: project) }
......@@ -17,7 +18,8 @@ RSpec.describe Groups::IssuesController do
{
update: {
milestone_id: milestone.id,
issuable_ids: "#{issue1.id}, #{issue2.id}"
issuable_ids: "#{issue1.id}, #{issue2.id}",
epic_id: epic.id
},
group_id: group
}
......@@ -25,8 +27,8 @@ RSpec.describe Groups::IssuesController do
context 'when group bulk edit feature is not enabled' do
before do
stub_licensed_features(epics: true, group_bulk_edit: false)
sign_in(user)
stub_licensed_features(group_bulk_edit: false)
end
it 'returns 404 status' do
......@@ -37,8 +39,8 @@ RSpec.describe Groups::IssuesController do
context 'when group bulk edit feature is enabled' do
before do
stub_licensed_features(epics: true, group_bulk_edit: true)
sign_in(user)
stub_licensed_features(group_bulk_edit: true)
end
context 'when user has permissions to bulk update issues' do
......@@ -52,10 +54,29 @@ RSpec.describe Groups::IssuesController do
expect(response).to have_gitlab_http_status(:ok)
end
it 'updates issues milestone' do
it 'updates issues milestone and epic' do
expect { subject }
.to change { issue1.reload.milestone }.from(nil).to(milestone)
.and change { issue2.reload.milestone }.from(nil).to(milestone)
.and change { issue1.epic }.from(nil).to(epic)
.and change { issue2.epic }.from(nil).to(epic)
end
context 'when params are incorrect' do
let(:external_epic) { create(:epic, group: create(:group, :private)) }
let(:params) do
{
update: { issuable_ids: "#{issue1.id}, #{issue2.id}", epic_id: external_epic.id },
group_id: group
}
end
it 'returns 422 status' do
subject
expect(response).to have_gitlab_http_status(:unprocessable_entity)
expect(response.body).to include('Epic not found for given params')
end
end
end
......@@ -70,10 +91,12 @@ RSpec.describe Groups::IssuesController do
expect(response).to have_gitlab_http_status(:not_found)
end
it 'does not update issues milestone' do
it 'does not update issues milestone or epic' do
expect { subject }
.to not_change { issue1.reload.milestone }
.and not_change { issue2.reload.milestone }
.and not_change { issue1.epic }
.and not_change { issue2.epic }
end
end
end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Issues > Epic bulk assignment', :js do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group, :public) }
let_it_be(:project) { create(:project, :public, group: group) }
let_it_be(:issue1) { create(:issue, project: project, title: "Issue 1") }
let_it_be(:issue2) { create(:issue, project: project, title: "Issue 2") }
let!(:epic1) { create(:epic, group: group) }
before do
stub_feature_flags(vue_issuables_list: false)
end
context 'as an allowed user', :js do
before do
allow(group).to receive(:feature_enabled?).and_return(true)
stub_licensed_features(epics: true)
group.add_maintainer(user)
sign_in user
end
context 'sidebar' do
before do
enable_bulk_update
end
it 'is present when bulk edit is enabled' do
expect(page).to have_css('.issuable-sidebar')
end
it 'is not present when bulk edit is disabled' do
disable_bulk_update
expect(page).not_to have_css('.issuable-sidebar')
end
end
context 'can bulk assign' do
before do
enable_bulk_update
end
context 'epic' do
context 'to all issues' do
before do
check 'check-all-issues'
open_epic_dropdown [epic1.title]
update_issues
end
it do
expect(issue1.reload.epic.title).to eq epic1.title
expect(issue2.reload.epic.title).to eq epic1.title
end
end
context 'to a issue' do
before do
check "selected_issue_#{issue1.id}"
open_epic_dropdown [epic1.title]
update_issues
end
it do
expect(issue1.reload.epic.title).to eq epic1.title
expect(issue2.reload.epic).to eq nil
end
end
end
end
end
context 'as a guest' do
before do
sign_in user
allow(group).to receive(:feature_enabled?).and_return(true)
stub_licensed_features(epics: true)
visit project_issues_path(project)
end
context 'cannot bulk assign epic' do
it do
expect(page).not_to have_button 'Edit issues'
expect(page).not_to have_css '.check-all-issues'
expect(page).not_to have_css '.issue-check'
end
end
end
def open_epic_dropdown(items = [])
page.within('.issues-bulk-update') do
click_button 'Select epic'
items.map do |item|
find('.gl-link', { text: item }).click
end
end
end
def check_issue(issue, uncheck = false)
page.within('.issues-list') do
if uncheck
uncheck "selected_issue_#{issue.id}"
else
check "selected_issue_#{issue.id}"
end
end
end
def uncheck_issue(issue)
check_issue(issue, true)
end
def update_issues
find('.update-selected-issues').click
wait_for_requests
end
def enable_bulk_update
visit project_issues_path(project)
click_button 'Edit issues'
end
def disable_bulk_update
click_button 'Cancel'
end
end
......@@ -89,7 +89,7 @@ describe('SidebarItemEpicsSelect', () => {
expect(wrapper.vm.getEpic()).toEqual(
expect.objectContaining({
id: 0,
title: 'none',
title: 'No Epic',
}),
);
});
......
......@@ -107,7 +107,9 @@ describe('EpicsSelect', () => {
initialEpic: mockEpic2,
});
expect(wrapper.vm.$store.state.selectedEpic).toBe(mockEpic2);
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.$store.state.selectedEpic).toBe(mockEpic2);
});
});
});
......
......@@ -39,7 +39,12 @@ export const mockAssignRemoveRes = {
export const noneEpic = {
id: 0,
title: 'none',
title: 'No Epic',
};
export const placeholderEpic = {
id: -1,
title: 'Select epic',
};
export const mockEpics = [mockEpic1, mockEpic2];
......@@ -21,7 +21,7 @@ RSpec.describe Issuable::BulkUpdateService do
end
shared_examples 'does not update issuables attribute' do |attribute|
it 'does not update issuables' do
it 'does not update attribute' do
issuables.each do |issuable|
expect { subject }.not_to change { issuable.send(attribute) }
end
......@@ -34,34 +34,38 @@ RSpec.describe Issuable::BulkUpdateService do
let(:issue1) { create(:issue, project: project1, health_status: :at_risk) }
let(:issue2) { create(:issue, project: project2, health_status: :at_risk) }
let(:issuables) { [issue1, issue2] }
let(:epic) { create(:epic, group: group) }
before do
group.add_reporter(user)
end
context 'updating health status' do
context 'updating health status and epic' do
let(:params) do
{
issuable_ids: issuables.map(&:id),
health_status: :on_track
health_status: :on_track,
epic_id: epic.id
}
end
context 'when features are enabled' do
before do
stub_licensed_features(issuable_health_status: true)
stub_licensed_features(epics: true, issuable_health_status: true)
end
it 'succeeds and returns the correct number of issuables updated' do
expect(subject.success?).to be_truthy
expect(subject.payload[:count]).to eq(issuables.count)
issuables.each do |issuable|
expect(issuable.reload.health_status).to eq('on_track')
issuable.reload
expect(issuable.health_status).to eq('on_track')
expect(issuable.epic).to eq(epic)
end
end
context "when params value is '0'" do
let(:params) { { issuable_ids: issuables.map(&:id), health_status: '0' } }
let(:params) { { issuable_ids: issuables.map(&:id), health_status: '0', epic_id: '0' } }
it 'succeeds and remove values' do
expect(subject.success?).to be_truthy
......@@ -69,9 +73,26 @@ RSpec.describe Issuable::BulkUpdateService do
issuables.each do |issuable|
issuable.reload
expect(issuable.health_status).to be_nil
expect(issuable.epic).to be_nil
end
end
end
context 'when epic param is incorrect' do
let(:external_epic) { create(:epic, group: create(:group, :private))}
let(:params) do
{
issuable_ids: issuables.map(&:id),
epic_id: external_epic.id
}
end
it 'returns error' do
expect(subject.message).to eq('Epic not found for given params')
expect(subject.status).to eq(:error)
expect(subject.http_status).to eq(422)
end
end
end
context 'when feature issuable_health_status is disabled' do
......@@ -88,6 +109,14 @@ RSpec.describe Issuable::BulkUpdateService do
end
it_behaves_like 'does not update issuables attribute', :health_status
it_behaves_like 'does not update issuables attribute', :epic
end
context 'when user can not admin epic' do
let(:epic3) { create(:epic, group: create(:group)) }
let(:params) { { issuable_ids: issuables.map(&:id), epic_id: epic3.id } }
it_behaves_like 'does not update issuables attribute', :epic
end
end
end
......
......@@ -8986,6 +8986,9 @@ msgstr ""
msgid "Epic events"
msgstr ""
msgid "Epic not found for given params"
msgstr ""
msgid "Epics"
msgstr ""
......@@ -20519,6 +20522,9 @@ msgstr ""
msgid "Select due date"
msgstr ""
msgid "Select epic"
msgstr ""
msgid "Select file"
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