Commit 6971beed authored by Rajat Jain's avatar Rajat Jain

Add epic bulk edit

Add ability to bulk assign multiple issues with an epic. Works
for both issues in groups as well as in projects.
parent 5b5246fa
...@@ -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: this.form.find('input[name="update[epic_id]"]').val(),
add_label_ids: [], add_label_ids: [],
remove_label_ids: [], remove_label_ids: [],
}, },
......
...@@ -8,6 +8,7 @@ import issueStatusSelect from './issue_status_select'; ...@@ -8,6 +8,7 @@ import issueStatusSelect from './issue_status_select';
import subscriptionSelect from './subscription_select'; import subscriptionSelect from './subscription_select';
import LabelsSelect from './labels_select'; import LabelsSelect from './labels_select';
import issueableEventHub from './issuables_list/eventhub'; import issueableEventHub from './issuables_list/eventhub';
import EpicSelect from 'ee_else_ce/vue_shared/components/sidebar/epics_select/epics_select_bundle';
const HIDDEN_CLASS = 'hidden'; const HIDDEN_CLASS = 'hidden';
const DISABLED_CONTENT_CLASS = 'disabled-content'; const DISABLED_CONTENT_CLASS = 'disabled-content';
...@@ -71,6 +72,9 @@ export default class IssuableBulkUpdateSidebar { ...@@ -71,6 +72,9 @@ export default class IssuableBulkUpdateSidebar {
}) })
.catch(() => {}); .catch(() => {});
} }
if (EpicSelect) {
EpicSelect();
}
} }
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;
}
}
}
- 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
...@@ -44,6 +45,13 @@ ...@@ -44,6 +45,13 @@
.filter-item.health-status.health-status-filter .filter-item.health-status.health-status-filter
#js-bulk-update-health-status-root #js-bulk-update-health-status-root
%input{ id: 'issue_health_status_value', type: 'hidden', name: 'update[health_status]' } %input{ id: 'issue_health_status_value', type: 'hidden', name: 'update[health_status]' }
- 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
= _('Subscriptions') = _('Subscriptions')
...@@ -57,5 +65,6 @@ ...@@ -57,5 +65,6 @@
%a{ href: "#", data: { id: "unsubscribe" } } %a{ href: "#", data: { id: "unsubscribe" } }
= _('Unsubscribe') = _('Unsubscribe')
= hidden_field_tag "update[issuable_ids]", [] = hidden_field_tag "update[issuable_ids]", []
= hidden_field_tag :state_event, params[:state_event] = hidden_field_tag :state_event, params[:state_event]
...@@ -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
].freeze ].freeze
private private
......
...@@ -56,7 +56,7 @@ module EE ...@@ -56,7 +56,7 @@ module EE
epic_param = params.delete(:epic) epic_param = params.delete(:epic)
if epic_param if epic_param
EpicIssues::CreateService.new(epic_param, current_user, { target_issuable: issue }).execute EpicIssues::CreateService.new(::Epic.find(epic_param), current_user, { target_issuable: issue }).execute
else else
link = EpicIssue.find_by(issue_id: issue.id) # rubocop: disable CodeReuse/ActiveRecord link = EpicIssue.find_by(issue_id: issue.id) # rubocop: disable CodeReuse/ActiveRecord
......
- 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 = 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
...@@ -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
# frozen_string_literal: true
require 'spec_helper'
describe 'Issues > Epic bulk assignment' 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) }
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];
...@@ -20471,6 +20471,9 @@ msgstr "" ...@@ -20471,6 +20471,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