Commit e81ca7d6 authored by Rajat Jain's avatar Rajat Jain

Multi select drag/drop in Issue Board

This adds ability to drag and drop multiple issues from
one list to another, while keeping the parity with single
drag and drop features.
parent 7c6e27fa
...@@ -42,12 +42,19 @@ export default { ...@@ -42,12 +42,19 @@ export default {
return { return {
showDetail: false, showDetail: false,
detailIssue: boardsStore.detail, detailIssue: boardsStore.detail,
multiSelect: boardsStore.multiSelect,
}; };
}, },
computed: { computed: {
issueDetailVisible() { issueDetailVisible() {
return this.detailIssue.issue && this.detailIssue.issue.id === this.issue.id; return this.detailIssue.issue && this.detailIssue.issue.id === this.issue.id;
}, },
multiSelectVisible() {
return this.multiSelect.list.findIndex(issue => issue.id === this.issue.id) > -1;
},
canMultiSelect() {
return gon.features && gon.features.multiSelectBoard;
},
}, },
methods: { methods: {
mouseDown() { mouseDown() {
...@@ -58,14 +65,20 @@ export default { ...@@ -58,14 +65,20 @@ export default {
}, },
showIssue(e) { showIssue(e) {
if (e.target.classList.contains('js-no-trigger')) return; if (e.target.classList.contains('js-no-trigger')) return;
if (this.showDetail) { if (this.showDetail) {
this.showDetail = false; this.showDetail = false;
// If CMD or CTRL is clicked
const isMultiSelect = this.canMultiSelect && (e.ctrlKey || e.metaKey);
if (boardsStore.detail.issue && boardsStore.detail.issue.id === this.issue.id) { if (boardsStore.detail.issue && boardsStore.detail.issue.id === this.issue.id) {
eventHub.$emit('clearDetailIssue'); eventHub.$emit('clearDetailIssue', isMultiSelect);
if (isMultiSelect) {
eventHub.$emit('newDetailIssue', this.issue, isMultiSelect);
}
} else { } else {
eventHub.$emit('newDetailIssue', this.issue); eventHub.$emit('newDetailIssue', this.issue, isMultiSelect);
boardsStore.setListDetail(this.list); boardsStore.setListDetail(this.list);
} }
} }
...@@ -77,6 +90,7 @@ export default { ...@@ -77,6 +90,7 @@ export default {
<template> <template>
<li <li
:class="{ :class="{
'multi-select': multiSelectVisible,
'user-can-drag': !disabled && issue.id, 'user-can-drag': !disabled && issue.id,
'is-disabled': disabled || !issue.id, 'is-disabled': disabled || !issue.id,
'is-active': issueDetailVisible, 'is-active': issueDetailVisible,
......
<script> <script>
/* eslint-disable @gitlab/vue-i18n/no-bare-strings */ import { Sortable, MultiDrag } from 'sortablejs';
import Sortable from 'sortablejs';
import { GlLoadingIcon } from '@gitlab/ui'; import { GlLoadingIcon } from '@gitlab/ui';
import _ from 'underscore';
import boardNewIssue from './board_new_issue.vue'; import boardNewIssue from './board_new_issue.vue';
import boardCard from './board_card.vue'; import boardCard from './board_card.vue';
import eventHub from '../eventhub'; import eventHub from '../eventhub';
import boardsStore from '../stores/boards_store'; import boardsStore from '../stores/boards_store';
import { getBoardSortableDefaultOptions, sortableStart } from '../mixins/sortable_default_options'; import { sprintf, __ } from '~/locale';
import createFlash from '~/flash';
import {
getBoardSortableDefaultOptions,
sortableStart,
sortableEnd,
} from '../mixins/sortable_default_options';
if (gon.features && gon.features.multiSelectBoard) {
Sortable.mount(new MultiDrag());
}
export default { export default {
name: 'BoardList', name: 'BoardList',
...@@ -54,6 +64,14 @@ export default { ...@@ -54,6 +64,14 @@ export default {
showIssueForm: false, showIssueForm: false,
}; };
}, },
computed: {
paginatedIssueText() {
return sprintf(__('Showing %{pageSize} of %{total} issues'), {
pageSize: this.list.issues.length,
total: this.list.issuesSize,
});
},
},
watch: { watch: {
filters: { filters: {
handler() { handler() {
...@@ -87,11 +105,20 @@ export default { ...@@ -87,11 +105,20 @@ export default {
eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop); eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop);
}, },
mounted() { mounted() {
const multiSelectOpts = {};
if (gon.features && gon.features.multiSelectBoard) {
multiSelectOpts.multiDrag = true;
multiSelectOpts.selectedClass = 'js-multi-select';
multiSelectOpts.animation = 500;
}
const options = getBoardSortableDefaultOptions({ const options = getBoardSortableDefaultOptions({
scroll: true, scroll: true,
disabled: this.disabled, disabled: this.disabled,
filter: '.board-list-count, .is-disabled', filter: '.board-list-count, .is-disabled',
dataIdAttr: 'data-issue-id', dataIdAttr: 'data-issue-id',
removeCloneOnHide: false,
...multiSelectOpts,
group: { group: {
name: 'issues', name: 'issues',
/** /**
...@@ -145,25 +172,66 @@ export default { ...@@ -145,25 +172,66 @@ export default {
card.showDetail = false; card.showDetail = false;
const { list } = card; const { list } = card;
const issue = list.findIssue(Number(e.item.dataset.issueId)); const issue = list.findIssue(Number(e.item.dataset.issueId));
boardsStore.startMoving(list, issue); boardsStore.startMoving(list, issue);
sortableStart(); sortableStart();
}, },
onAdd: e => { onAdd: e => {
boardsStore.moveIssueToList( const { items = [], newIndicies = [] } = e;
boardsStore.moving.list, if (items.length) {
this.list, // Not using e.newIndex here instead taking a min of all
boardsStore.moving.issue, // the newIndicies. Basically we have to find that during
e.newIndex, // a drop what is the index we're going to start putting
); // all the dropped elements from.
const newIndex = Math.min(...newIndicies.map(obj => obj.index).filter(i => i !== -1));
const issues = items.map(item =>
boardsStore.moving.list.findIssue(Number(item.dataset.issueId)),
);
this.$nextTick(() => { boardsStore.moveMultipleIssuesToList({
e.item.remove(); listFrom: boardsStore.moving.list,
}); listTo: this.list,
issues,
newIndex,
});
} else {
boardsStore.moveIssueToList(
boardsStore.moving.list,
this.list,
boardsStore.moving.issue,
e.newIndex,
);
this.$nextTick(() => {
e.item.remove();
});
}
}, },
onUpdate: e => { onUpdate: e => {
const sortedArray = this.sortable.toArray().filter(id => id !== '-1'); const sortedArray = this.sortable.toArray().filter(id => id !== '-1');
const { items = [], newIndicies = [], oldIndicies = [] } = e;
if (items.length) {
const newIndex = Math.min(...newIndicies.map(obj => obj.index));
const issues = items.map(item =>
boardsStore.moving.list.findIssue(Number(item.dataset.issueId)),
);
boardsStore.moveMultipleIssuesInList({
list: this.list,
issues,
oldIndicies: oldIndicies.map(obj => obj.index),
newIndex,
idArray: sortedArray,
});
e.items.forEach(el => {
Sortable.utils.deselect(el);
});
boardsStore.clearMultiSelect();
return;
}
boardsStore.moveIssueInList( boardsStore.moveIssueInList(
this.list, this.list,
boardsStore.moving.issue, boardsStore.moving.issue,
...@@ -172,9 +240,133 @@ export default { ...@@ -172,9 +240,133 @@ export default {
sortedArray, sortedArray,
); );
}, },
onEnd: e => {
const { items = [], clones = [], to } = e;
// This is not a multi select operation
if (!items.length && !clones.length) {
sortableEnd();
return;
}
let toList;
if (to) {
const containerEl = to.closest('.js-board-list');
toList = boardsStore.findList('id', Number(containerEl.dataset.board));
}
/**
* onEnd is called irrespective if the cards were moved in the
* same list or the other list. Don't remove items if it's same list.
*/
const isSameList = toList && toList.id === this.list.id;
if (toList && !isSameList && boardsStore.shouldRemoveIssue(this.list, toList)) {
const issues = items.map(item => this.list.findIssue(Number(item.dataset.issueId)));
if (_.compact(issues).length && !boardsStore.issuesAreContiguous(this.list, issues)) {
const indexes = [];
const ids = this.list.issues.map(i => i.id);
issues.forEach(issue => {
const index = ids.indexOf(issue.id);
if (index > -1) {
indexes.push(index);
}
});
// Descending sort because splice would cause index discrepancy otherwise
const sortedIndexes = indexes.sort((a, b) => (a < b ? 1 : -1));
sortedIndexes.forEach(i => {
/**
* **setTimeout and splice each element one-by-one in a loop
* is intended.**
*
* The problem here is all the indexes are in the list but are
* non-contiguous. Due to that, when we splice all the indexes,
* at once, Vue -- during a re-render -- is unable to find reference
* nodes and the entire app crashes.
*
* If the indexes are contiguous, this piece of code is not
* executed. If it is, this is a possible regression. Only when
* issue indexes are far apart, this logic should ever kick in.
*/
setTimeout(() => {
this.list.issues.splice(i, 1);
}, 0);
});
}
}
if (!toList) {
createFlash(__('Something went wrong while performing the action.'));
}
if (!isSameList) {
boardsStore.clearMultiSelect();
// Since Vue's list does not re-render the same keyed item, we'll
// remove `multi-select` class to express it's unselected
if (clones && clones.length) {
clones.forEach(el => el.classList.remove('multi-select'));
}
// Due to some bug which I am unable to figure out
// Sortable does not deselect some pending items from the
// source list.
// We'll just do it forcefully here.
Array.from(document.querySelectorAll('.js-multi-select') || []).forEach(item => {
Sortable.utils.deselect(item);
});
/**
* SortableJS leaves all the moving items "as is" on the DOM.
* Vue picks up and rehydrates the DOM, but we need to explicity
* remove the "trash" items from the DOM.
*
* This is in parity to the logic on single item move from a list/in
* a list. For reference, look at the implementation of onAdd method.
*/
this.$nextTick(() => {
if (items && items.length) {
items.forEach(item => {
item.remove();
});
}
});
}
sortableEnd();
},
onMove(e) { onMove(e) {
return !e.related.classList.contains('board-list-count'); return !e.related.classList.contains('board-list-count');
}, },
onSelect(e) {
const {
item: { classList },
} = e;
if (
classList &&
classList.contains('js-multi-select') &&
!classList.contains('multi-select')
) {
Sortable.utils.deselect(e.item);
}
},
onDeselect: e => {
const {
item: { dataset, classList },
} = e;
if (
classList &&
classList.contains('multi-select') &&
!classList.contains('js-multi-select')
) {
const issue = this.list.findIssue(Number(dataset.issueId));
boardsStore.toggleMultiSelect(issue);
}
},
}); });
this.sortable = Sortable.create(this.$refs.list, options); this.sortable = Sortable.create(this.$refs.list, options);
...@@ -260,7 +452,7 @@ export default { ...@@ -260,7 +452,7 @@ export default {
<li v-if="showCount" class="board-list-count text-center" data-issue-id="-1"> <li v-if="showCount" class="board-list-count text-center" data-issue-id="-1">
<gl-loading-icon v-show="list.loadingMore" label="Loading more issues" /> <gl-loading-icon v-show="list.loadingMore" label="Loading more issues" />
<span v-if="list.issues.length === list.issuesSize">{{ __('Showing all issues') }}</span> <span v-if="list.issues.length === list.issuesSize">{{ __('Showing all issues') }}</span>
<span v-else> Showing {{ list.issues.length }} of {{ list.issuesSize }} issues </span> <span v-else>{{ paginatedIssueText }}</span>
</li> </li>
</ul> </ul>
</div> </div>
......
export const ListType = {
assignee: 'assignee',
milestone: 'milestone',
backlog: 'backlog',
closed: 'closed',
label: 'label',
};
export default {
ListType,
};
...@@ -146,7 +146,7 @@ export default () => { ...@@ -146,7 +146,7 @@ export default () => {
updateTokens() { updateTokens() {
this.filterManager.updateTokens(); this.filterManager.updateTokens();
}, },
updateDetailIssue(newIssue) { updateDetailIssue(newIssue, multiSelect = false) {
const { sidebarInfoEndpoint } = newIssue; const { sidebarInfoEndpoint } = newIssue;
if (sidebarInfoEndpoint && newIssue.subscribed === undefined) { if (sidebarInfoEndpoint && newIssue.subscribed === undefined) {
newIssue.setFetchingState('subscriptions', true); newIssue.setFetchingState('subscriptions', true);
...@@ -185,9 +185,23 @@ export default () => { ...@@ -185,9 +185,23 @@ export default () => {
}); });
} }
if (multiSelect) {
boardsStore.toggleMultiSelect(newIssue);
if (boardsStore.detail.issue) {
boardsStore.clearDetailIssue();
return;
}
return;
}
boardsStore.setIssueDetail(newIssue); boardsStore.setIssueDetail(newIssue);
}, },
clearDetailIssue() { clearDetailIssue(multiSelect = false) {
if (multiSelect) {
boardsStore.clearMultiSelect();
}
boardsStore.clearDetailIssue(); boardsStore.clearDetailIssue();
}, },
toggleSubscription(id) { toggleSubscription(id) {
......
...@@ -5,6 +5,7 @@ import ListLabel from './label'; ...@@ -5,6 +5,7 @@ import ListLabel from './label';
import ListAssignee from './assignee'; import ListAssignee from './assignee';
import ListIssue from 'ee_else_ce/boards/models/issue'; import ListIssue from 'ee_else_ce/boards/models/issue';
import { urlParamsToObject } from '~/lib/utils/common_utils'; import { urlParamsToObject } from '~/lib/utils/common_utils';
import flash from '~/flash';
import boardsStore from '../stores/boards_store'; import boardsStore from '../stores/boards_store';
import ListMilestone from './milestone'; import ListMilestone from './milestone';
...@@ -176,6 +177,53 @@ class List { ...@@ -176,6 +177,53 @@ class List {
}); });
} }
addMultipleIssues(issues, listFrom, newIndex) {
let moveBeforeId = null;
let moveAfterId = null;
const listHasIssues = issues.every(issue => this.findIssue(issue.id));
if (!listHasIssues) {
if (newIndex !== undefined) {
if (this.issues[newIndex - 1]) {
moveBeforeId = this.issues[newIndex - 1].id;
}
if (this.issues[newIndex]) {
moveAfterId = this.issues[newIndex].id;
}
this.issues.splice(newIndex, 0, ...issues);
} else {
this.issues.push(...issues);
}
if (this.label) {
issues.forEach(issue => issue.addLabel(this.label));
}
if (this.assignee) {
if (listFrom && listFrom.type === 'assignee') {
issues.forEach(issue => issue.removeAssignee(listFrom.assignee));
}
issues.forEach(issue => issue.addAssignee(this.assignee));
}
if (IS_EE && this.milestone) {
if (listFrom && listFrom.type === 'milestone') {
issues.forEach(issue => issue.removeMilestone(listFrom.milestone));
}
issues.forEach(issue => issue.addMilestone(this.milestone));
}
if (listFrom) {
this.issuesSize += issues.length;
this.updateMultipleIssues(issues, listFrom, moveBeforeId, moveAfterId);
}
}
}
addIssue(issue, listFrom, newIndex) { addIssue(issue, listFrom, newIndex) {
let moveBeforeId = null; let moveBeforeId = null;
let moveAfterId = null; let moveAfterId = null;
...@@ -230,6 +278,23 @@ class List { ...@@ -230,6 +278,23 @@ class List {
}); });
} }
moveMultipleIssues({ issues, oldIndicies, newIndex, moveBeforeId, moveAfterId }) {
oldIndicies.reverse().forEach(index => {
this.issues.splice(index, 1);
});
this.issues.splice(newIndex, 0, ...issues);
gl.boardService
.moveMultipleIssues({
ids: issues.map(issue => issue.id),
fromListId: null,
toListId: null,
moveBeforeId,
moveAfterId,
})
.catch(() => flash(__('Something went wrong while moving issues.')));
}
updateIssueLabel(issue, listFrom, moveBeforeId, moveAfterId) { updateIssueLabel(issue, listFrom, moveBeforeId, moveAfterId) {
gl.boardService gl.boardService
.moveIssue(issue.id, listFrom.id, this.id, moveBeforeId, moveAfterId) .moveIssue(issue.id, listFrom.id, this.id, moveBeforeId, moveAfterId)
...@@ -238,10 +303,37 @@ class List { ...@@ -238,10 +303,37 @@ class List {
}); });
} }
updateMultipleIssues(issues, listFrom, moveBeforeId, moveAfterId) {
gl.boardService
.moveMultipleIssues({
ids: issues.map(issue => issue.id),
fromListId: listFrom.id,
toListId: this.id,
moveBeforeId,
moveAfterId,
})
.catch(() => flash(__('Something went wrong while moving issues.')));
}
findIssue(id) { findIssue(id) {
return this.issues.find(issue => issue.id === id); return this.issues.find(issue => issue.id === id);
} }
removeMultipleIssues(removeIssues) {
const ids = removeIssues.map(issue => issue.id);
this.issues = this.issues.filter(issue => {
const matchesRemove = ids.includes(issue.id);
if (matchesRemove) {
this.issuesSize -= 1;
issue.removeLabel(this.label);
}
return !matchesRemove;
});
}
removeIssue(removeIssue) { removeIssue(removeIssue) {
this.issues = this.issues.filter(issue => { this.issues = this.issues.filter(issue => {
const matchesRemove = removeIssue.id === issue.id; const matchesRemove = removeIssue.id === issue.id;
......
...@@ -48,6 +48,16 @@ export default class BoardService { ...@@ -48,6 +48,16 @@ export default class BoardService {
return boardsStore.moveIssue(id, fromListId, toListId, moveBeforeId, moveAfterId); return boardsStore.moveIssue(id, fromListId, toListId, moveBeforeId, moveAfterId);
} }
moveMultipleIssues({
ids,
fromListId = null,
toListId = null,
moveBeforeId = null,
moveAfterId = null,
}) {
return boardsStore.moveMultipleIssues({ ids, fromListId, toListId, moveBeforeId, moveAfterId });
}
newIssue(id, issue) { newIssue(id, issue) {
return boardsStore.newIssue(id, issue); return boardsStore.newIssue(id, issue);
} }
......
...@@ -11,6 +11,7 @@ import { __ } from '~/locale'; ...@@ -11,6 +11,7 @@ import { __ } from '~/locale';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { mergeUrlParams } from '~/lib/utils/url_utility'; import { mergeUrlParams } from '~/lib/utils/url_utility';
import eventHub from '../eventhub'; import eventHub from '../eventhub';
import { ListType } from '../constants';
const boardsStore = { const boardsStore = {
disabled: false, disabled: false,
...@@ -39,6 +40,7 @@ const boardsStore = { ...@@ -39,6 +40,7 @@ const boardsStore = {
issue: {}, issue: {},
list: {}, list: {},
}, },
multiSelect: { list: [] },
setEndpoints({ boardsEndpoint, listsEndpoint, bulkUpdatePath, boardId, recentBoardsEndpoint }) { setEndpoints({ boardsEndpoint, listsEndpoint, bulkUpdatePath, boardId, recentBoardsEndpoint }) {
const listsEndpointGenerate = `${listsEndpoint}/generate.json`; const listsEndpointGenerate = `${listsEndpoint}/generate.json`;
...@@ -51,7 +53,6 @@ const boardsStore = { ...@@ -51,7 +53,6 @@ const boardsStore = {
recentBoardsEndpoint: `${recentBoardsEndpoint}.json`, recentBoardsEndpoint: `${recentBoardsEndpoint}.json`,
}; };
}, },
create() { create() {
this.state.lists = []; this.state.lists = [];
this.filter.path = getUrlParamsArray().join('&'); this.filter.path = getUrlParamsArray().join('&');
...@@ -134,6 +135,107 @@ const boardsStore = { ...@@ -134,6 +135,107 @@ const boardsStore = {
Object.assign(this.moving, { list, issue }); Object.assign(this.moving, { list, issue });
}, },
moveMultipleIssuesToList({ listFrom, listTo, issues, newIndex }) {
const issueTo = issues.map(issue => listTo.findIssue(issue.id));
const issueLists = _.flatten(issues.map(issue => issue.getLists()));
const listLabels = issueLists.map(list => list.label);
const hasMoveableIssues = _.compact(issueTo).length > 0;
if (!hasMoveableIssues) {
// Check if target list assignee is already present in this issue
if (
listTo.type === ListType.assignee &&
listFrom.type === ListType.assignee &&
issues.some(issue => issue.findAssignee(listTo.assignee))
) {
const targetIssues = issues.map(issue => listTo.findIssue(issue.id));
targetIssues.forEach(targetIssue => targetIssue.removeAssignee(listFrom.assignee));
} else if (listTo.type === 'milestone') {
const currentMilestones = issues.map(issue => issue.milestone);
const currentLists = this.state.lists
.filter(list => list.type === 'milestone' && list.id !== listTo.id)
.filter(list =>
list.issues.some(listIssue => issues.some(issue => listIssue.id === issue.id)),
);
issues.forEach(issue => {
currentMilestones.forEach(milestone => {
issue.removeMilestone(milestone);
});
});
issues.forEach(issue => {
issue.addMilestone(listTo.milestone);
});
currentLists.forEach(currentList => {
issues.forEach(issue => {
currentList.removeIssue(issue);
});
});
listTo.addMultipleIssues(issues, listFrom, newIndex);
} else {
// Add to new lists issues if it doesn't already exist
listTo.addMultipleIssues(issues, listFrom, newIndex);
}
} else {
listTo.updateMultipleIssues(issues, listFrom);
issues.forEach(issue => {
issue.removeLabel(listFrom.label);
});
}
if (listTo.type === ListType.closed && listFrom.type !== ListType.backlog) {
issueLists.forEach(list => {
issues.forEach(issue => {
list.removeIssue(issue);
});
});
issues.forEach(issue => {
issue.removeLabels(listLabels);
});
} else if (listTo.type === ListType.backlog && listFrom.type === ListType.assignee) {
issues.forEach(issue => {
issue.removeAssignee(listFrom.assignee);
});
issueLists.forEach(list => {
issues.forEach(issue => {
list.removeIssue(issue);
});
});
} else if (listTo.type === ListType.backlog && listFrom.type === ListType.milestone) {
issues.forEach(issue => {
issue.removeMilestone(listFrom.milestone);
});
issueLists.forEach(list => {
issues.forEach(issue => {
list.removeIssue(issue);
});
});
} else if (
this.shouldRemoveIssue(listFrom, listTo) &&
this.issuesAreContiguous(listFrom, issues)
) {
listFrom.removeMultipleIssues(issues);
}
},
issuesAreContiguous(list, issues) {
// When there's only 1 issue selected, we can return early.
if (issues.length === 1) return true;
// Create list of ids for issues involved.
const listIssueIds = list.issues.map(issue => issue.id);
const movedIssueIds = issues.map(issue => issue.id);
// Check if moved issue IDs is sub-array
// of source list issue IDs (i.e. contiguous selection).
return listIssueIds.join('|').includes(movedIssueIds.join('|'));
},
moveIssueToList(listFrom, listTo, issue, newIndex) { moveIssueToList(listFrom, listTo, issue, newIndex) {
const issueTo = listTo.findIssue(issue.id); const issueTo = listTo.findIssue(issue.id);
const issueLists = issue.getLists(); const issueLists = issue.getLists();
...@@ -195,6 +297,17 @@ const boardsStore = { ...@@ -195,6 +297,17 @@ const boardsStore = {
list.moveIssue(issue, oldIndex, newIndex, beforeId, afterId); list.moveIssue(issue, oldIndex, newIndex, beforeId, afterId);
}, },
moveMultipleIssuesInList({ list, issues, oldIndicies, newIndex, idArray }) {
const beforeId = parseInt(idArray[newIndex - 1], 10) || null;
const afterId = parseInt(idArray[newIndex + issues.length], 10) || null;
list.moveMultipleIssues({
issues,
oldIndicies,
newIndex,
moveBeforeId: beforeId,
moveAfterId: afterId,
});
},
findList(key, val, type = 'label') { findList(key, val, type = 'label') {
const filteredList = this.state.lists.filter(list => { const filteredList = this.state.lists.filter(list => {
const byType = type const byType = type
...@@ -260,6 +373,10 @@ const boardsStore = { ...@@ -260,6 +373,10 @@ const boardsStore = {
}`; }`;
}, },
generateMultiDragPath(boardId) {
return `${gon.relative_url_root}/-/boards/${boardId ? `${boardId}` : ''}/issues/bulk_move`;
},
all() { all() {
return axios.get(this.state.endpoints.listsEndpoint); return axios.get(this.state.endpoints.listsEndpoint);
}, },
...@@ -309,6 +426,16 @@ const boardsStore = { ...@@ -309,6 +426,16 @@ const boardsStore = {
}); });
}, },
moveMultipleIssues({ ids, fromListId, toListId, moveBeforeId, moveAfterId }) {
return axios.put(this.generateMultiDragPath(this.state.endpoints.boardId), {
from_list_id: fromListId,
to_list_id: toListId,
move_before_id: moveBeforeId,
move_after_id: moveAfterId,
ids,
});
},
newIssue(id, issue) { newIssue(id, issue) {
return axios.post(this.generateIssuesPath(id), { return axios.post(this.generateIssuesPath(id), {
issue, issue,
...@@ -379,6 +506,25 @@ const boardsStore = { ...@@ -379,6 +506,25 @@ const boardsStore = {
setCurrentBoard(board) { setCurrentBoard(board) {
this.state.currentBoard = board; this.state.currentBoard = board;
}, },
toggleMultiSelect(issue) {
const selectedIssueIds = this.multiSelect.list.map(issue => issue.id);
const index = selectedIssueIds.indexOf(issue.id);
if (index === -1) {
this.multiSelect.list.push(issue);
return;
}
this.multiSelect.list = [
...this.multiSelect.list.slice(0, index),
...this.multiSelect.list.slice(index + 1),
];
},
clearMultiSelect() {
this.multiSelect.list = [];
},
}; };
BoardsStoreEE.initEESpecific(boardsStore); BoardsStoreEE.initEESpecific(boardsStore);
......
import 'core-js/es/map'; import 'core-js/es/map';
import 'core-js/es/set'; import 'core-js/es/set';
import { Sortable } from 'sortablejs';
import simulateDrag from './simulate_drag'; import simulateDrag from './simulate_drag';
import simulateInput from './simulate_input'; import simulateInput from './simulate_input';
// Export to global space for rspec to use // Export to global space for rspec to use
window.simulateDrag = simulateDrag; window.simulateDrag = simulateDrag;
window.simulateInput = simulateInput; window.simulateInput = simulateInput;
window.Sortable = Sortable;
...@@ -245,6 +245,7 @@ ...@@ -245,6 +245,7 @@
box-shadow: 0 1px 2px $issue-boards-card-shadow; box-shadow: 0 1px 2px $issue-boards-card-shadow;
line-height: $gl-padding; line-height: $gl-padding;
list-style: none; list-style: none;
position: relative;
&:not(:last-child) { &:not(:last-child) {
margin-bottom: $gl-padding-8; margin-bottom: $gl-padding-8;
...@@ -255,6 +256,11 @@ ...@@ -255,6 +256,11 @@
background-color: $blue-50; background-color: $blue-50;
} }
&.multi-select {
border-color: $blue-200;
background-color: $blue-50;
}
.badge { .badge {
border: 0; border: 0;
outline: 0; outline: 0;
......
...@@ -5,6 +5,9 @@ class Groups::BoardsController < Groups::ApplicationController ...@@ -5,6 +5,9 @@ class Groups::BoardsController < Groups::ApplicationController
include RecordUserLastActivity include RecordUserLastActivity
before_action :assign_endpoint_vars before_action :assign_endpoint_vars
before_action do
push_frontend_feature_flag(:multi_select_board)
end
private private
......
...@@ -7,6 +7,9 @@ class Projects::BoardsController < Projects::ApplicationController ...@@ -7,6 +7,9 @@ class Projects::BoardsController < Projects::ApplicationController
before_action :check_issues_available! before_action :check_issues_available!
before_action :authorize_read_board!, only: [:index, :show] before_action :authorize_read_board!, only: [:index, :show]
before_action :assign_endpoint_vars before_action :assign_endpoint_vars
before_action do
push_frontend_feature_flag(:multi_select_board)
end
private private
......
...@@ -180,6 +180,18 @@ These are shortcuts to your last 4 visited boards. ...@@ -180,6 +180,18 @@ These are shortcuts to your last 4 visited boards.
When you're revisiting an issue board in a project or group with multiple boards, When you're revisiting an issue board in a project or group with multiple boards,
GitLab will automatically load the last board you visited. GitLab will automatically load the last board you visited.
### Multi-select Issue Cards
As the name suggest, multi-select issue cards allows more than one issue card
to be dragged and dropped across different lists. This becomes helpful while
moving and grooming a lot of issues at once.
You can multi-select an issue card by pressing `CTRL` + `Left mouse click` on
Windows or `CMD` + `Left mouse click` on MacOS. Once done, start by dragging one
of the issue card you have selected and drop it in the new list you want.
![Multi-select Issue Cards](img/issue_boards_multi_select.png)
### Configurable Issue Boards **(STARTER)** ### Configurable Issue Boards **(STARTER)**
> Introduced in [GitLab Starter Edition 10.2](https://about.gitlab.com/2017/11/22/gitlab-10-2-released/#issue-boards-configuration). > Introduced in [GitLab Starter Edition 10.2](https://about.gitlab.com/2017/11/22/gitlab-10-2-released/#issue-boards-configuration).
......
---
title: Add ability to multi select issue board cards
merge_request: 16317
author:
type: added
...@@ -14608,6 +14608,9 @@ msgid_plural "Showing %d events" ...@@ -14608,6 +14608,9 @@ msgid_plural "Showing %d events"
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""
msgid "Showing %{pageSize} of %{total} issues"
msgstr ""
msgid "Showing Latest Version" msgid "Showing Latest Version"
msgstr "" msgstr ""
...@@ -14866,6 +14869,12 @@ msgstr "" ...@@ -14866,6 +14869,12 @@ msgstr ""
msgid "Something went wrong while merging this merge request. Please try again." msgid "Something went wrong while merging this merge request. Please try again."
msgstr "" msgstr ""
msgid "Something went wrong while moving issues."
msgstr ""
msgid "Something went wrong while performing the action."
msgstr ""
msgid "Something went wrong while reopening the %{issuable}. Please try again later" msgid "Something went wrong while reopening the %{issuable}. Please try again later"
msgstr "" msgstr ""
......
# frozen_string_literal: true
require 'spec_helper'
describe 'Multi Select Issue', :js do
include DragTo
let(:group) { create(:group, :nested) }
let(:project) { create(:project, :public, namespace: group) }
let(:board) { create(:board, project: project) }
let(:user) { create(:user) }
def drag(selector: '.board-list', list_from_index: 1, from_index: 0, to_index: 0, list_to_index: 1, duration: 1000)
drag_to(selector: selector,
scrollable: '#board-app',
list_from_index: list_from_index,
from_index: from_index,
to_index: to_index,
list_to_index: list_to_index,
duration: duration)
end
def wait_for_board_cards(board_number, expected_cards)
page.within(find(".board:nth-child(#{board_number})")) do
expect(page.find('.board-header')).to have_content(expected_cards.to_s)
expect(page).to have_selector('.board-card', count: expected_cards)
end
end
def multi_select(selector, action = 'select')
element = page.find(selector)
script = "var el = document.querySelector('#{selector}');"
script += "var mousedown = new MouseEvent('mousedown', { button: 0, bubbles: true });"
script += "var mouseup = new MouseEvent('mouseup', { ctrlKey: true, button: 0, bubbles:true });"
script += "el.dispatchEvent(mousedown); el.dispatchEvent(mouseup);"
script += "Sortable.utils.#{action}(el);"
page.execute_script(script, element)
end
before do
project.add_maintainer(user)
sign_in(user)
end
context 'with lists' do
let(:label1) { create(:label, project: project, name: 'Label 1', description: 'Test') }
let(:label2) { create(:label, project: project, name: 'Label 2', description: 'Test') }
let!(:list1) { create(:list, board: board, label: label1, position: 0) }
let!(:list2) { create(:list, board: board, label: label2, position: 1) }
let!(:issue1) { create(:labeled_issue, project: project, title: 'Issue 1', description: '', assignees: [user], labels: [label1], relative_position: 1) }
let!(:issue2) { create(:labeled_issue, project: project, title: 'Issue 2', description: '', author: user, labels: [label1], relative_position: 2) }
let!(:issue3) { create(:labeled_issue, project: project, title: 'Issue 3', description: '', labels: [label1], relative_position: 3) }
let!(:issue4) { create(:labeled_issue, project: project, title: 'Issue 4', description: '', labels: [label1], relative_position: 4) }
let!(:issue5) { create(:labeled_issue, project: project, title: 'Issue 5', description: '', labels: [label1], relative_position: 5) }
before do
visit project_board_path(project, board)
wait_for_requests
end
it 'moves multiple issues to another list', :js do
multi_select('.board-card:nth-child(1)')
multi_select('.board-card:nth-child(2)')
drag(list_from_index: 1, list_to_index: 2)
wait_for_requests
page.within(all('.js-board-list')[2]) do
expect(find('.board-card:nth-child(1)')).to have_content(issue1.title)
expect(find('.board-card:nth-child(2)')).to have_content(issue2.title)
end
end
it 'maintains order when moved', :js do
multi_select('.board-card:nth-child(3)')
multi_select('.board-card:nth-child(2)')
multi_select('.board-card:nth-child(1)')
drag(list_from_index: 1, list_to_index: 2)
wait_for_requests
page.within(all('.js-board-list')[2]) do
expect(find('.board-card:nth-child(1)')).to have_content(issue1.title)
expect(find('.board-card:nth-child(2)')).to have_content(issue2.title)
expect(find('.board-card:nth-child(3)')).to have_content(issue3.title)
end
end
it 'move issues in the same list', :js do
multi_select('.board-card:nth-child(3)')
multi_select('.board-card:nth-child(4)')
drag(list_from_index: 1, list_to_index: 1, from_index: 2, to_index: 4)
wait_for_requests
page.within(all('.js-board-list')[1]) do
expect(find('.board-card:nth-child(1)')).to have_content(issue1.title)
expect(find('.board-card:nth-child(2)')).to have_content(issue2.title)
expect(find('.board-card:nth-child(3)')).to have_content(issue5.title)
expect(find('.board-card:nth-child(4)')).to have_content(issue3.title)
expect(find('.board-card:nth-child(5)')).to have_content(issue4.title)
end
end
it 'adds label when issues are moved to different card', :js do
page.within(all('.js-board-list')[1]) do
expect(find('.board-card:nth-child(1)')).not_to have_content(label2.title)
expect(find('.board-card:nth-child(2)')).not_to have_content(label2.title)
end
multi_select('.board-card:nth-child(1)')
multi_select('.board-card:nth-child(2)')
drag(list_from_index: 1, list_to_index: 2)
wait_for_requests
page.within(all('.js-board-list')[2]) do
expect(find('.board-card:nth-child(1)')).to have_content(label2.title)
expect(find('.board-card:nth-child(2)')).to have_content(label2.title)
end
end
end
end
...@@ -67,6 +67,16 @@ describe('Board card', () => { ...@@ -67,6 +67,16 @@ describe('Board card', () => {
expect(vm.issueDetailVisible).toBe(true); expect(vm.issueDetailVisible).toBe(true);
}); });
it("returns false when multiSelect doesn't contain issue", () => {
expect(vm.multiSelectVisible).toBe(false);
});
it('returns true when multiSelect contains issue', () => {
boardsStore.multiSelect.list = [vm.issue];
expect(vm.multiSelectVisible).toBe(true);
});
it('adds user-can-drag class if not disabled', () => { it('adds user-can-drag class if not disabled', () => {
expect(vm.$el.classList.contains('user-can-drag')).toBe(true); expect(vm.$el.classList.contains('user-can-drag')).toBe(true);
}); });
...@@ -180,7 +190,7 @@ describe('Board card', () => { ...@@ -180,7 +190,7 @@ describe('Board card', () => {
triggerEvent('mousedown'); triggerEvent('mousedown');
triggerEvent('mouseup'); triggerEvent('mouseup');
expect(eventHub.$emit).toHaveBeenCalledWith('newDetailIssue', vm.issue); expect(eventHub.$emit).toHaveBeenCalledWith('newDetailIssue', vm.issue, undefined);
expect(boardsStore.detail.list).toEqual(vm.list); expect(boardsStore.detail.list).toEqual(vm.list);
}); });
...@@ -203,7 +213,7 @@ describe('Board card', () => { ...@@ -203,7 +213,7 @@ describe('Board card', () => {
triggerEvent('mousedown'); triggerEvent('mousedown');
triggerEvent('mouseup'); triggerEvent('mouseup');
expect(eventHub.$emit).toHaveBeenCalledWith('clearDetailIssue'); expect(eventHub.$emit).toHaveBeenCalledWith('clearDetailIssue', undefined);
}); });
}); });
}); });
...@@ -12,6 +12,7 @@ import '~/boards/services/board_service'; ...@@ -12,6 +12,7 @@ import '~/boards/services/board_service';
import boardsStore from '~/boards/stores/boards_store'; import boardsStore from '~/boards/stores/boards_store';
import eventHub from '~/boards/eventhub'; import eventHub from '~/boards/eventhub';
import { listObj, listObjDuplicate, boardsMockInterceptor, mockBoardService } from './mock_data'; import { listObj, listObjDuplicate, boardsMockInterceptor, mockBoardService } from './mock_data';
import waitForPromises from '../../frontend/helpers/wait_for_promises';
describe('Store', () => { describe('Store', () => {
let mock; let mock;
...@@ -29,6 +30,13 @@ describe('Store', () => { ...@@ -29,6 +30,13 @@ describe('Store', () => {
}), }),
); );
spyOn(gl.boardService, 'moveMultipleIssues').and.callFake(
() =>
new Promise(resolve => {
resolve();
}),
);
Cookies.set('issue_board_welcome_hidden', 'false', { Cookies.set('issue_board_welcome_hidden', 'false', {
expires: 365 * 10, expires: 365 * 10,
path: '', path: '',
...@@ -376,4 +384,128 @@ describe('Store', () => { ...@@ -376,4 +384,128 @@ describe('Store', () => {
expect(state.currentBoard).toEqual(dummyBoard); expect(state.currentBoard).toEqual(dummyBoard);
}); });
}); });
describe('toggleMultiSelect', () => {
let basicIssueObj;
beforeAll(() => {
basicIssueObj = { id: 987654 };
});
afterEach(() => {
boardsStore.clearMultiSelect();
});
it('adds issue when not present', () => {
boardsStore.toggleMultiSelect(basicIssueObj);
const selectedIds = boardsStore.multiSelect.list.map(x => x.id);
expect(selectedIds.includes(basicIssueObj.id)).toEqual(true);
});
it('removes issue when issue is present', () => {
boardsStore.toggleMultiSelect(basicIssueObj);
let selectedIds = boardsStore.multiSelect.list.map(x => x.id);
expect(selectedIds.includes(basicIssueObj.id)).toEqual(true);
boardsStore.toggleMultiSelect(basicIssueObj);
selectedIds = boardsStore.multiSelect.list.map(x => x.id);
expect(selectedIds.includes(basicIssueObj.id)).toEqual(false);
});
});
describe('clearMultiSelect', () => {
it('clears all the multi selected issues', () => {
const issue1 = { id: 12345 };
const issue2 = { id: 12346 };
boardsStore.toggleMultiSelect(issue1);
boardsStore.toggleMultiSelect(issue2);
expect(boardsStore.multiSelect.list.length).toEqual(2);
boardsStore.clearMultiSelect();
expect(boardsStore.multiSelect.list.length).toEqual(0);
});
});
describe('moveMultipleIssuesToList', () => {
it('move issues on the new index', done => {
const listOne = boardsStore.addList(listObj);
const listTwo = boardsStore.addList(listObjDuplicate);
expect(boardsStore.state.lists.length).toBe(2);
setTimeout(() => {
expect(listOne.issues.length).toBe(1);
expect(listTwo.issues.length).toBe(1);
boardsStore.moveMultipleIssuesToList({
listFrom: listOne,
listTo: listTwo,
issues: listOne.issues,
newIndex: 0,
});
expect(listTwo.issues.length).toBe(1);
done();
}, 0);
});
});
describe('moveMultipleIssuesInList', () => {
it('moves multiple issues in list', done => {
const issueObj = {
title: 'Issue #1',
id: 12345,
iid: 2,
confidential: false,
labels: [],
assignees: [],
};
const issue1 = new ListIssue(issueObj);
const issue2 = new ListIssue({
...issueObj,
title: 'Issue #2',
id: 12346,
});
const list = boardsStore.addList(listObj);
waitForPromises()
.then(() => {
list.addIssue(issue1);
list.addIssue(issue2);
expect(list.issues.length).toBe(3);
expect(list.issues[0].id).not.toBe(issue2.id);
boardsStore.moveMultipleIssuesInList({
list,
issues: [issue1, issue2],
oldIndicies: [0],
newIndex: 1,
idArray: [1, 12345, 12346],
});
expect(list.issues[0].id).toBe(issue1.id);
expect(gl.boardService.moveMultipleIssues).toHaveBeenCalledWith({
ids: [issue1.id, issue2.id],
fromListId: null,
toListId: null,
moveBeforeId: 1,
moveAfterId: null,
});
done();
})
.catch(done.fail);
});
});
}); });
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