Read about our upcoming Code of Conduct on this issue

Commit fe82fbe5 authored by GitLab Bot's avatar GitLab Bot
Browse files

Add latest changes from gitlab-org/gitlab@master

parent 962ebb55fefa
......@@ -42,12 +42,19 @@ export default {
return {
showDetail: false,
detailIssue: boardsStore.detail,
multiSelect: boardsStore.multiSelect,
};
},
computed: {
issueDetailVisible() {
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: {
mouseDown() {
......@@ -58,14 +65,20 @@ export default {
},
showIssue(e) {
if (e.target.classList.contains('js-no-trigger')) return;
if (this.showDetail) {
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) {
eventHub.$emit('clearDetailIssue');
eventHub.$emit('clearDetailIssue', isMultiSelect);
if (isMultiSelect) {
eventHub.$emit('newDetailIssue', this.issue, isMultiSelect);
}
} else {
eventHub.$emit('newDetailIssue', this.issue);
eventHub.$emit('newDetailIssue', this.issue, isMultiSelect);
boardsStore.setListDetail(this.list);
}
}
......@@ -77,6 +90,7 @@ export default {
<template>
<li
:class="{
'multi-select': multiSelectVisible,
'user-can-drag': !disabled && issue.id,
'is-disabled': disabled || !issue.id,
'is-active': issueDetailVisible,
......
<script>
/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
import Sortable from 'sortablejs';
import { Sortable, MultiDrag } from 'sortablejs';
import { GlLoadingIcon } from '@gitlab/ui';
import _ from 'underscore';
import boardNewIssue from './board_new_issue.vue';
import boardCard from './board_card.vue';
import eventHub from '../eventhub';
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 {
name: 'BoardList',
......@@ -54,6 +64,14 @@ export default {
showIssueForm: false,
};
},
computed: {
paginatedIssueText() {
return sprintf(__('Showing %{pageSize} of %{total} issues'), {
pageSize: this.list.issues.length,
total: this.list.issuesSize,
});
},
},
watch: {
filters: {
handler() {
......@@ -87,11 +105,20 @@ export default {
eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop);
},
mounted() {
const multiSelectOpts = {};
if (gon.features && gon.features.multiSelectBoard) {
multiSelectOpts.multiDrag = true;
multiSelectOpts.selectedClass = 'js-multi-select';
multiSelectOpts.animation = 500;
}
const options = getBoardSortableDefaultOptions({
scroll: true,
disabled: this.disabled,
filter: '.board-list-count, .is-disabled',
dataIdAttr: 'data-issue-id',
removeCloneOnHide: false,
...multiSelectOpts,
group: {
name: 'issues',
/**
......@@ -145,25 +172,66 @@ export default {
card.showDetail = false;
const { list } = card;
const issue = list.findIssue(Number(e.item.dataset.issueId));
boardsStore.startMoving(list, issue);
sortableStart();
},
onAdd: e => {
boardsStore.moveIssueToList(
boardsStore.moving.list,
this.list,
boardsStore.moving.issue,
e.newIndex,
);
const { items = [], newIndicies = [] } = e;
if (items.length) {
// Not using e.newIndex here instead taking a min of all
// the newIndicies. Basically we have to find that during
// 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(() => {
e.item.remove();
});
boardsStore.moveMultipleIssuesToList({
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 => {
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(
this.list,
boardsStore.moving.issue,
......@@ -172,9 +240,133 @@ export default {
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) {
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);
......@@ -260,7 +452,7 @@ export default {
<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" />
<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>
</ul>
</div>
......
export const ListType = {
assignee: 'assignee',
milestone: 'milestone',
backlog: 'backlog',
closed: 'closed',
label: 'label',
};
export default {
ListType,
};
......@@ -146,7 +146,7 @@ export default () => {
updateTokens() {
this.filterManager.updateTokens();
},
updateDetailIssue(newIssue) {
updateDetailIssue(newIssue, multiSelect = false) {
const { sidebarInfoEndpoint } = newIssue;
if (sidebarInfoEndpoint && newIssue.subscribed === undefined) {
newIssue.setFetchingState('subscriptions', true);
......@@ -185,9 +185,23 @@ export default () => {
});
}
if (multiSelect) {
boardsStore.toggleMultiSelect(newIssue);
if (boardsStore.detail.issue) {
boardsStore.clearDetailIssue();
return;
}
return;
}
boardsStore.setIssueDetail(newIssue);
},
clearDetailIssue() {
clearDetailIssue(multiSelect = false) {
if (multiSelect) {
boardsStore.clearMultiSelect();
}
boardsStore.clearDetailIssue();
},
toggleSubscription(id) {
......
......@@ -5,6 +5,7 @@ import ListLabel from './label';
import ListAssignee from './assignee';
import ListIssue from 'ee_else_ce/boards/models/issue';
import { urlParamsToObject } from '~/lib/utils/common_utils';
import flash from '~/flash';
import boardsStore from '../stores/boards_store';
import ListMilestone from './milestone';
......@@ -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) {
let moveBeforeId = null;
let moveAfterId = null;
......@@ -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) {
gl.boardService
.moveIssue(issue.id, listFrom.id, this.id, moveBeforeId, moveAfterId)
......@@ -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) {
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) {
this.issues = this.issues.filter(issue => {
const matchesRemove = removeIssue.id === issue.id;
......
......@@ -48,6 +48,16 @@ export default class BoardService {
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) {
return boardsStore.newIssue(id, issue);
}
......
......@@ -11,6 +11,7 @@ import { __ } from '~/locale';
import axios from '~/lib/utils/axios_utils';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import eventHub from '../eventhub';
import { ListType } from '../constants';
const boardsStore = {
disabled: false,
......@@ -39,6 +40,7 @@ const boardsStore = {
issue: {},
list: {},
},
multiSelect: { list: [] },
setEndpoints({ boardsEndpoint, listsEndpoint, bulkUpdatePath, boardId, recentBoardsEndpoint }) {
const listsEndpointGenerate = `${listsEndpoint}/generate.json`;
......@@ -51,7 +53,6 @@ const boardsStore = {
recentBoardsEndpoint: `${recentBoardsEndpoint}.json`,
};
},
create() {
this.state.lists = [];
this.filter.path = getUrlParamsArray().join('&');
......@@ -134,6 +135,107 @@ const boardsStore = {
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);