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 { ...@@ -87,6 +87,7 @@ export default {
issuable_ids: this.form.find('input[name="update[issuable_ids]"]').val(), issuable_ids: this.form.find('input[name="update[issuable_ids]"]').val(),
subscription_event: this.form.find('input[name="update[subscription_event]"]').val(), subscription_event: this.form.find('input[name="update[subscription_event]"]').val(),
health_status: this.form.find('input[name="update[health_status]"]').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: [], add_label_ids: [],
remove_label_ids: [], remove_label_ids: [],
}, },
......
...@@ -71,6 +71,14 @@ export default class IssuableBulkUpdateSidebar { ...@@ -71,6 +71,14 @@ export default class IssuableBulkUpdateSidebar {
}) })
.catch(() => {}); .catch(() => {});
} }
if (IS_EE) {
import('ee/vue_shared/components/sidebar/epics_select/epics_select_bundle')
.then(({ default: EpicSelect }) => {
EpicSelect();
})
.catch(() => {});
}
} }
setupBulkUpdateActions() { setupBulkUpdateActions() {
......
...@@ -1089,3 +1089,11 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu { ...@@ -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 @@ ...@@ -26,6 +26,12 @@
margin-right: 6px; margin-right: 6px;
} }
.bulk-update {
.filter-item {
margin-right: 0;
}
}
.sort-filter { .sort-filter {
display: inline-block; display: inline-block;
float: right; float: right;
......
- type = local_assigns.fetch(:type) - 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) - 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? } } %aside.issues-bulk-update.js-right-sidebar.right-sidebar{ "aria-live" => "polite", data: { 'signed-in': current_user.present? } }
.issuable-sidebar.hidden .issuable-sidebar.hidden
...@@ -27,6 +28,13 @@ ...@@ -27,6 +28,13 @@
- field_name = "update[assignee_ids][]" - 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", = 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 } }) 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 .block
.title .title
= _('Milestone') = _('Milestone')
......
...@@ -67,6 +67,11 @@ export default { ...@@ -67,6 +67,11 @@ export default {
required: false, required: false,
default: DropdownVariant.Sidebar, default: DropdownVariant.Sidebar,
}, },
showHeader: {
type: Boolean,
required: false,
default: false,
},
}, },
data() { data() {
return { return {
...@@ -132,7 +137,7 @@ export default { ...@@ -132,7 +137,7 @@ export default {
variant: this.variant, variant: this.variant,
groupId: this.groupId, groupId: this.groupId,
issueId: this.issueId, issueId: this.issueId,
selectedEpic: this.selectedEpic, selectedEpic: this.initialEpic,
selectedEpicIssueId: this.epicIssueId, selectedEpicIssueId: this.epicIssueId,
}); });
$(this.$refs.dropdown).on('shown.bs.dropdown', () => this.fetchEpics()); $(this.$refs.dropdown).on('shown.bs.dropdown', () => this.fetchEpics());
...@@ -205,7 +210,7 @@ export default { ...@@ -205,7 +210,7 @@ export default {
:toggle-text-class="dropdownButtonTextClass" :toggle-text-class="dropdownButtonTextClass"
/> />
<div class="dropdown-menu dropdown-select dropdown-menu-epics dropdown-menu-selectable"> <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-search-input @onSearchInput="setSearchQuery" />
<dropdown-contents <dropdown-contents
v-if="!epicsFetchInProgress" v-if="!epicsFetchInProgress"
......
import Vue from 'vue'; import Vue from 'vue';
import EpicsSelect from 'ee/vue_shared/components/sidebar/epics_select/base.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'; import { DropdownVariant } from 'ee/vue_shared/components/sidebar/epics_select/constants';
export default () => { export default () => {
...@@ -19,7 +19,7 @@ export default () => { ...@@ -19,7 +19,7 @@ export default () => {
}, },
data() { data() {
return { return {
selectedEpic: noneEpic, selectedEpic: placeholderEpic,
}; };
}, },
methods: { methods: {
...@@ -38,6 +38,7 @@ export default () => { ...@@ -38,6 +38,7 @@ export default () => {
initialEpic: this.selectedEpic, initialEpic: this.selectedEpic,
initialEpicLoading: false, initialEpicLoading: false,
variant: DropdownVariant.Standalone, variant: DropdownVariant.Standalone,
showHeader: Boolean(el.dataset.showHeader),
}, },
on: { on: {
onEpicSelect: this.handleEpicSelect.bind(this), onEpicSelect: this.handleEpicSelect.bind(this),
......
// eslint-disable-next-line import/prefer-default-export import { __ } from '~/locale';
export const noneEpic = { export const noneEpic = {
id: 0, id: 0,
title: 'none', title: __('No Epic'),
};
export const placeholderEpic = {
id: -1,
title: __('Select epic'),
}; };
...@@ -8,6 +8,7 @@ module EE ...@@ -8,6 +8,7 @@ module EE
EE_PERMITTED_KEYS = %w[ EE_PERMITTED_KEYS = %w[
weight weight
health_status health_status
epic_id
].freeze ].freeze
private private
......
...@@ -26,6 +26,7 @@ module EE ...@@ -26,6 +26,7 @@ module EE
super super
set_health_status set_health_status
set_epic_param
end end
def set_health_status def set_health_status
...@@ -33,6 +34,33 @@ module EE ...@@ -33,6 +34,33 @@ module EE
params[:health_status] = nil if params[:health_status] == IssuableFinder::Params::NONE.to_s params[:health_status] = nil if params[:health_status] == IssuableFinder::Params::NONE.to_s
end 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 end
end end
- group = local_assigns.fetch(:group) - group = local_assigns.fetch(:group)
- type = local_assigns.fetch(:type) - 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) - 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? } } %aside.issues-bulk-update.js-right-sidebar.right-sidebar{ 'aria-live' => 'polite', data: { 'signed-in': current_user.present? } }
.issuable-sidebar.hidden .issuable-sidebar.hidden
...@@ -9,6 +10,13 @@ ...@@ -9,6 +10,13 @@
.filter-item.inline.update-issues-btn.float-left .filter-item.inline.update-issues-btn.float-left
= button_tag _('Update all'), class: "btn update-selected-issues btn-info", disabled: true = 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" = 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 - unless type == :epics
.block .block
.title .title
......
---
title: Epic bulk edit
merge_request: 34256
author:
type: added
...@@ -3,10 +3,11 @@ ...@@ -3,10 +3,11 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe Groups::IssuesController do RSpec.describe Groups::IssuesController do
let(:user) { create(:user) }
let(:group) { create(:group, :public) } let(:group) { create(:group, :public) }
let(:project) { create(:project_empty_repo, :public, namespace: group) } let(:project) { create(:project_empty_repo, :public, namespace: group) }
let(:milestone) { create(:milestone, group: group) } let(:milestone) { create(:milestone, group: group) }
let(:user) { create(:user) }
let(:epic) { create(:epic, group: group) }
let(:issue1) { create(:issue, project: project) } let(:issue1) { create(:issue, project: project) }
let(:issue2) { create(:issue, project: project) } let(:issue2) { create(:issue, project: project) }
...@@ -17,7 +18,8 @@ RSpec.describe Groups::IssuesController do ...@@ -17,7 +18,8 @@ RSpec.describe Groups::IssuesController do
{ {
update: { update: {
milestone_id: milestone.id, milestone_id: milestone.id,
issuable_ids: "#{issue1.id}, #{issue2.id}" issuable_ids: "#{issue1.id}, #{issue2.id}",
epic_id: epic.id
}, },
group_id: group group_id: group
} }
...@@ -25,8 +27,8 @@ RSpec.describe Groups::IssuesController do ...@@ -25,8 +27,8 @@ RSpec.describe Groups::IssuesController do
context 'when group bulk edit feature is not enabled' do context 'when group bulk edit feature is not enabled' do
before do before do
stub_licensed_features(epics: true, group_bulk_edit: false)
sign_in(user) sign_in(user)
stub_licensed_features(group_bulk_edit: false)
end end
it 'returns 404 status' do it 'returns 404 status' do
...@@ -37,8 +39,8 @@ RSpec.describe Groups::IssuesController do ...@@ -37,8 +39,8 @@ RSpec.describe Groups::IssuesController do
context 'when group bulk edit feature is enabled' do context 'when group bulk edit feature is enabled' do
before do before do
stub_licensed_features(epics: true, group_bulk_edit: true)
sign_in(user) sign_in(user)
stub_licensed_features(group_bulk_edit: true)
end end
context 'when user has permissions to bulk update issues' do context 'when user has permissions to bulk update issues' do
...@@ -52,10 +54,29 @@ RSpec.describe Groups::IssuesController do ...@@ -52,10 +54,29 @@ RSpec.describe Groups::IssuesController do
expect(response).to have_gitlab_http_status(:ok) expect(response).to have_gitlab_http_status(:ok)
end end
it 'updates issues milestone' do it 'updates issues milestone and epic' do
expect { subject } expect { subject }
.to change { issue1.reload.milestone }.from(nil).to(milestone) .to change { issue1.reload.milestone }.from(nil).to(milestone)
.and change { issue2.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
end end
...@@ -70,10 +91,12 @@ RSpec.describe Groups::IssuesController do ...@@ -70,10 +91,12 @@ RSpec.describe Groups::IssuesController do
expect(response).to have_gitlab_http_status(:not_found) expect(response).to have_gitlab_http_status(:not_found)
end end
it 'does not update issues milestone' do it 'does not update issues milestone or epic' do
expect { subject } expect { subject }
.to not_change { issue1.reload.milestone } .to not_change { issue1.reload.milestone }
.and not_change { issue2.reload.milestone } .and not_change { issue2.reload.milestone }
.and not_change { issue1.epic }
.and not_change { issue2.epic }
end end
end 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', () => { ...@@ -89,7 +89,7 @@ describe('SidebarItemEpicsSelect', () => {
expect(wrapper.vm.getEpic()).toEqual( expect(wrapper.vm.getEpic()).toEqual(
expect.objectContaining({ expect.objectContaining({
id: 0, id: 0,
title: 'none', title: 'No Epic',
}), }),
); );
}); });
......
...@@ -107,9 +107,11 @@ describe('EpicsSelect', () => { ...@@ -107,9 +107,11 @@ describe('EpicsSelect', () => {
initialEpic: mockEpic2, initialEpic: mockEpic2,
}); });
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.$store.state.selectedEpic).toBe(mockEpic2); expect(wrapper.vm.$store.state.selectedEpic).toBe(mockEpic2);
}); });
}); });
});
describe('searchQuery', () => { describe('searchQuery', () => {
beforeEach(() => { beforeEach(() => {
......
...@@ -39,7 +39,12 @@ export const mockAssignRemoveRes = { ...@@ -39,7 +39,12 @@ export const mockAssignRemoveRes = {
export const noneEpic = { export const noneEpic = {
id: 0, id: 0,
title: 'none', title: 'No Epic',
};
export const placeholderEpic = {
id: -1,
title: 'Select epic',
}; };
export const mockEpics = [mockEpic1, mockEpic2]; export const mockEpics = [mockEpic1, mockEpic2];
...@@ -21,7 +21,7 @@ RSpec.describe Issuable::BulkUpdateService do ...@@ -21,7 +21,7 @@ RSpec.describe Issuable::BulkUpdateService do
end end
shared_examples 'does not update issuables attribute' do |attribute| 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| issuables.each do |issuable|
expect { subject }.not_to change { issuable.send(attribute) } expect { subject }.not_to change { issuable.send(attribute) }
end end
...@@ -34,34 +34,38 @@ RSpec.describe Issuable::BulkUpdateService do ...@@ -34,34 +34,38 @@ RSpec.describe Issuable::BulkUpdateService do
let(:issue1) { create(:issue, project: project1, health_status: :at_risk) } let(:issue1) { create(:issue, project: project1, health_status: :at_risk) }
let(:issue2) { create(:issue, project: project2, health_status: :at_risk) } let(:issue2) { create(:issue, project: project2, health_status: :at_risk) }
let(:issuables) { [issue1, issue2] } let(:issuables) { [issue1, issue2] }
let(:epic) { create(:epic, group: group) }
before do before do
group.add_reporter(user) group.add_reporter(user)
end end
context 'updating health status' do context 'updating health status and epic' do
let(:params) do let(:params) do
{ {
issuable_ids: issuables.map(&:id), issuable_ids: issuables.map(&:id),
health_status: :on_track health_status: :on_track,
epic_id: epic.id
} }
end end
context 'when features are enabled' do context 'when features are enabled' do
before do before do
stub_licensed_features(issuable_health_status: true) stub_licensed_features(epics: true, issuable_health_status: true)
end end
it 'succeeds and returns the correct number of issuables updated' do it 'succeeds and returns the correct number of issuables updated' do
expect(subject.success?).to be_truthy expect(subject.success?).to be_truthy
expect(subject.payload[:count]).to eq(issuables.count) expect(subject.payload[:count]).to eq(issuables.count)
issuables.each do |issuable| 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
end end
context "when params value is '0'" do 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 it 'succeeds and remove values' do
expect(subject.success?).to be_truthy expect(subject.success?).to be_truthy
...@@ -69,9 +73,26 @@ RSpec.describe Issuable::BulkUpdateService do ...@@ -69,9 +73,26 @@ RSpec.describe Issuable::BulkUpdateService do
issuables.each do |issuable| issuables.each do |issuable|
issuable.reload issuable.reload
expect(issuable.health_status).to be_nil expect(issuable.health_status).to be_nil
expect(issuable.epic).to be_nil
end end
end 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 end
context 'when feature issuable_health_status is disabled' do context 'when feature issuable_health_status is disabled' do
...@@ -88,6 +109,14 @@ RSpec.describe Issuable::BulkUpdateService do ...@@ -88,6 +109,14 @@ RSpec.describe Issuable::BulkUpdateService do
end end
it_behaves_like 'does not update issuables attribute', :health_status 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 end
end end
......
...@@ -8986,6 +8986,9 @@ msgstr "" ...@@ -8986,6 +8986,9 @@ msgstr ""
msgid "Epic events" msgid "Epic events"
msgstr "" msgstr ""
msgid "Epic not found for given params"
msgstr ""
msgid "Epics" msgid "Epics"
msgstr "" msgstr ""
...@@ -20519,6 +20522,9 @@ msgstr "" ...@@ -20519,6 +20522,9 @@ msgstr ""
msgid "Select due date" msgid "Select due date"
msgstr "" msgstr ""
msgid "Select epic"
msgstr ""
msgid "Select file" msgid "Select file"
msgstr "" 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