Commit 3d13802b authored by GitLab Bot's avatar GitLab Bot

Add latest changes from gitlab-org/gitlab@master

parent 00050519
...@@ -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 => {
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)),
);
boardsStore.moveMultipleIssuesToList({
listFrom: boardsStore.moving.list,
listTo: this.list,
issues,
newIndex,
});
} else {
boardsStore.moveIssueToList( boardsStore.moveIssueToList(
boardsStore.moving.list, boardsStore.moving.list,
this.list, this.list,
boardsStore.moving.issue, boardsStore.moving.issue,
e.newIndex, e.newIndex,
); );
this.$nextTick(() => { this.$nextTick(() => {
e.item.remove(); 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
......
...@@ -14,15 +14,13 @@ module Ci ...@@ -14,15 +14,13 @@ module Ci
delegate :ref_exists?, :create_ref, :delete_refs, to: :repository delegate :ref_exists?, :create_ref, :delete_refs, to: :repository
def exist? def exist?
return unless enabled?
ref_exists?(path) ref_exists?(path)
rescue rescue
false false
end end
def create def create
return unless enabled? && !exist? return if exist?
create_ref(sha, path) create_ref(sha, path)
rescue => e rescue => e
...@@ -31,8 +29,6 @@ module Ci ...@@ -31,8 +29,6 @@ module Ci
end end
def delete def delete
return unless enabled?
delete_refs(path) delete_refs(path)
rescue Gitlab::Git::Repository::NoRepository rescue Gitlab::Git::Repository::NoRepository
# no-op # no-op
...@@ -44,11 +40,5 @@ module Ci ...@@ -44,11 +40,5 @@ module Ci
def path def path
"refs/#{Repository::REF_PIPELINES}/#{pipeline.id}" "refs/#{Repository::REF_PIPELINES}/#{pipeline.id}"
end end
private
def enabled?
Feature.enabled?(:depend_on_persistent_pipeline_ref, project)
end
end end
end end
...@@ -8,6 +8,10 @@ module Gitlab ...@@ -8,6 +8,10 @@ module Gitlab
include GithubImport::Queue include GithubImport::Queue
include StageMethods include StageMethods
# technical debt: https://gitlab.com/gitlab-org/gitlab/issues/33991
sidekiq_options memory_killer_memory_growth_kb: ENV.fetch('MEMORY_KILLER_FINISH_IMPORT_WORKER_MEMORY_GROWTH_KB', 50).to_i
sidekiq_options memory_killer_max_memory_growth_kb: ENV.fetch('MEMORY_KILLER_FINISH_IMPORT_WORKER_MAX_MEMORY_GROWTH_KB', 200_000).to_i
# project - An instance of Project. # project - An instance of Project.
def import(_, project) def import(_, project)
project.after_import project.after_import
......
...@@ -8,6 +8,10 @@ module Gitlab ...@@ -8,6 +8,10 @@ module Gitlab
include GithubImport::Queue include GithubImport::Queue
include StageMethods include StageMethods
# technical debt: https://gitlab.com/gitlab-org/gitlab/issues/33991
sidekiq_options memory_killer_memory_growth_kb: ENV.fetch('MEMORY_KILLER_IMPORT_REPOSITORY_WORKER_MEMORY_GROWTH_KB', 50).to_i
sidekiq_options memory_killer_max_memory_growth_kb: ENV.fetch('MEMORY_KILLER_IMPORT_REPOSITORY_WORKER_MAX_MEMORY_GROWTH_KB', 300_000).to_i
# client - An instance of Gitlab::GithubImport::Client. # client - An instance of Gitlab::GithubImport::Client.
# project - An instance of Project. # project - An instance of Project.
def import(client, project) def import(client, project)
......
...@@ -6,6 +6,10 @@ class RepositoryImportWorker ...@@ -6,6 +6,10 @@ class RepositoryImportWorker
include ProjectStartImport include ProjectStartImport
include ProjectImportOptions include ProjectImportOptions
# technical debt: https://gitlab.com/gitlab-org/gitlab/issues/33991
sidekiq_options memory_killer_memory_growth_kb: ENV.fetch('MEMORY_KILLER_REPOSITORY_IMPORT_WORKER_MEMORY_GROWTH_KB', 50).to_i
sidekiq_options memory_killer_max_memory_growth_kb: ENV.fetch('MEMORY_KILLER_REPOSITORY_IMPORT_WORKER_MAX_MEMORY_GROWTH_KB', 300_000).to_i
def perform(project_id) def perform(project_id)
@project = Project.find(project_id) @project = Project.find(project_id)
......
---
title: Bump GITLAB_ELASTICSEARCH_INDEXER_VERSION=v1.4.0
merge_request: 18558
author:
type: fixed
...@@ -3,7 +3,12 @@ const path = require('path'); ...@@ -3,7 +3,12 @@ const path = require('path');
const ROOT_PATH = path.resolve(__dirname, '../..'); const ROOT_PATH = path.resolve(__dirname, '../..');
// The `IS_GITLAB_EE` is always `string` or `nil`
// Thus the nil or empty string will result
// in using default value: true
//
// The behavior needs to be synchronised with
// lib/gitlab.rb: Gitlab.ee?
module.exports = module.exports =
process.env.IS_GITLAB_EE !== undefined fs.existsSync(path.join(ROOT_PATH, 'ee', 'app', 'models', 'license.rb')) &&
? JSON.parse(process.env.IS_GITLAB_EE) (!process.env.IS_GITLAB_EE || JSON.parse(process.env.IS_GITLAB_EE));
: fs.existsSync(path.join(ROOT_PATH, 'ee'));
...@@ -436,15 +436,3 @@ To illustrate its life cycle: ...@@ -436,15 +436,3 @@ To illustrate its life cycle:
even if the commit history of the `example` branch has been overwritten by force-push. even if the commit history of the `example` branch has been overwritten by force-push.
1. GitLab Runner fetches the persistent pipeline ref and gets source code from the checkout-SHA. 1. GitLab Runner fetches the persistent pipeline ref and gets source code from the checkout-SHA.
1. When the pipeline finished, its persistent ref is cleaned up in a background process. 1. When the pipeline finished, its persistent ref is cleaned up in a background process.
NOTE: **NOTE**: At this moment, this feature is off dy default and can be manually enabled
by enabling `depend_on_persistent_pipeline_ref` feature flag, however, we'd remove this
feature flag and make it enabled by deafult by the day we release 12.4 _if we don't find any issues_.
If you'd be interested in manually turning on this behavior, please ask the administrator
to execute the following commands in rails console.
```shell
> sudo gitlab-rails console # Login to Rails console of GitLab instance.
> project = Project.find_by_full_path('namespace/project-name') # Get the project instance.
> Feature.enable(:depend_on_persistent_pipeline_ref, project) # Enable the feature flag.
```
...@@ -29,26 +29,30 @@ If you want to disable it for a specific project, you can do so in ...@@ -29,26 +29,30 @@ If you want to disable it for a specific project, you can do so in
## Maximum artifacts size **(CORE ONLY)** ## Maximum artifacts size **(CORE ONLY)**
The maximum size of the [job artifacts](../../../administration/job_artifacts.md) The maximum size of the [job artifacts](../../../administration/job_artifacts.md)
can be set at the project level, group level, and at the instance level. The value is in *MB* and can be set at the project level, group level, and at the instance level. The value is:
the default is 100MB per job; on GitLab.com it's [set to 1G](../../gitlab_com/index.md#gitlab-cicd).
To change it at the instance level: - In *MB* and the default is 100MB per job.
- [Set to 1G](../../gitlab_com/index.md#gitlab-cicd) on GitLab.com.
1. Go to **Admin area > Settings > Continuous Integration and Deployment**. To change it at the:
1. Change the value of maximum artifacts size (in MB).
1. Hit **Save changes** for the changes to take effect.
at the group level (this will override the instance setting): - Instance level:
1. Go to **Group > Settings > CI / CD > General Pipelines**. 1. Go to **Admin area > Settings > Continuous Integration and Deployment**.
1. Change the value of maximum artifacts size (in MB). 1. Change the value of maximum artifacts size (in MB).
1. Hit **Save changes** for the changes to take effect. 1. Hit **Save changes** for the changes to take effect.
at the project level (this will override the instance and group settings): - [Group level](../../group/index.md#group-settings) (this will override the instance setting):
1. Go to **Project > Settings > CI / CD > General Pipelines**. 1. Go to the group's **Settings > CI / CD > General Pipelines**.
1. Change the value of maximum artifacts size (in MB). 1. Change the value of **maximum artifacts size (in MB)**.
1. Hit **Save changes** for the changes to take effect. 1. Press **Save changes** for the changes to take effect.
- [Project level](../../project/pipelines/settings.md) (this will override the instance and group settings):
1. Go to the project's **Settings > CI / CD > General Pipelines**.
1. Change the value of **maximum artifacts size (in MB)**.
1. Press **Save changes** for the changes to take effect.
## Default artifacts expiration **(CORE ONLY)** ## Default artifacts expiration **(CORE ONLY)**
......
...@@ -451,6 +451,11 @@ For performance reasons, we may delay the update up to 1 hour and 30 minutes. ...@@ -451,6 +451,11 @@ For performance reasons, we may delay the update up to 1 hour and 30 minutes.
If your namespace shows `N/A` as the total storage usage, you can trigger a recalculation by pushing a commit to any project in that namespace. If your namespace shows `N/A` as the total storage usage, you can trigger a recalculation by pushing a commit to any project in that namespace.
### Maximum artifacts size **(CORE ONLY)**
For information about setting a maximum artifact size for a group, see
[Maximum artifacts size](../admin_area/settings/continuous_integration.md#maximum-artifacts-size-core-only).
## User contribution analysis **(STARTER)** ## User contribution analysis **(STARTER)**
With [GitLab Contribution Analytics](contribution_analytics/index.md), With [GitLab Contribution Analytics](contribution_analytics/index.md),
......
...@@ -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).
......
...@@ -60,6 +60,11 @@ if the job surpasses the threshold, it is marked as failed. ...@@ -60,6 +60,11 @@ if the job surpasses the threshold, it is marked as failed.
Project defined timeout (either specific timeout set by user or the default Project defined timeout (either specific timeout set by user or the default
60 minutes timeout) may be [overridden on Runner level](../../../ci/runners/README.html#setting-maximum-job-timeout-for-a-runner). 60 minutes timeout) may be [overridden on Runner level](../../../ci/runners/README.html#setting-maximum-job-timeout-for-a-runner).
## Maximum artifacts size **(CORE ONLY)**
For information about setting a maximum artifact size for a project, see
[Maximum artifacts size](../../admin_area/settings/continuous_integration.md#maximum-artifacts-size-core-only).
## Custom CI config path ## Custom CI config path
> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/12509) in GitLab 9.4. > [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/12509) in GitLab 9.4.
......
...@@ -65,14 +65,18 @@ module Gitlab ...@@ -65,14 +65,18 @@ module Gitlab
def self.ee? def self.ee?
@is_ee ||= @is_ee ||=
if ENV['IS_GITLAB_EE'] && !ENV['IS_GITLAB_EE'].empty? # We use this method when the Rails environment is not loaded. This
Gitlab::Utils.to_boolean(ENV['IS_GITLAB_EE'])
else
# We may use this method when the Rails environment is not loaded. This
# means that checking the presence of the License class could result in # means that checking the presence of the License class could result in
# this method returning `false`, even for an EE installation. # this method returning `false`, even for an EE installation.
root.join('ee/app/models/license.rb').exist? #
end # The `IS_GITLAB_EE` is always `string` or `nil`
# Thus the nil or empty string will result
# in using default value: true
#
# The behavior needs to be synchronised with
# config/helpers/is_ee_env.js
root.join('ee/app/models/license.rb').exist? &&
(ENV['IS_GITLAB_EE'].to_s.empty? || Gitlab::Utils.to_boolean(ENV['IS_GITLAB_EE']))
end end
def self.ee def self.ee
......
...@@ -14911,6 +14911,9 @@ msgid_plural "Showing %d events" ...@@ -14911,6 +14911,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 ""
...@@ -15166,6 +15169,12 @@ msgstr "" ...@@ -15166,6 +15169,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' require 'spec_helper'
require 'rainbow/ext/string' require 'rainbow/ext/string'
......
# 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
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);
});
});
}); });
...@@ -45,18 +45,6 @@ describe Ci::PersistentRef do ...@@ -45,18 +45,6 @@ describe Ci::PersistentRef do
expect(pipeline.persistent_ref).to be_exist expect(pipeline.persistent_ref).to be_exist
end end
context 'when depend_on_persistent_pipeline_ref feature flag is disabled' do
before do
stub_feature_flags(depend_on_persistent_pipeline_ref: false)
end
it 'does not create a persistent ref' do
expect(project.repository).not_to receive(:create_ref)
subject
end
end
context 'when sha does not exist in the repository' do context 'when sha does not exist in the repository' do
let(:sha) { 'not-exist' } let(:sha) { 'not-exist' }
......
# frozen_string_literal: true
require 'rake_helper' require 'rake_helper'
describe 'clearing redis cache' do describe 'clearing redis cache' do
......
# frozen_string_literal: true
require 'rake_helper' require 'rake_helper'
Rake.application.rake_require 'tasks/config_lint' Rake.application.rake_require 'tasks/config_lint'
......
# frozen_string_literal: true
require 'rake_helper' require 'rake_helper'
describe 'gitlab:artifacts rake tasks' do describe 'gitlab:artifacts rake tasks' do
......
# frozen_string_literal: true
require 'rake_helper' require 'rake_helper'
describe 'gitlab:artifacts namespace rake task' do describe 'gitlab:artifacts namespace rake task' do
......
# frozen_string_literal: true
require 'spec_helper' require 'spec_helper'
require 'rake' require 'rake'
......
# frozen_string_literal: true
require 'rake_helper' require 'rake_helper'
describe 'check.rake' do describe 'check.rake' do
......
# frozen_string_literal: true
require 'rake_helper' require 'rake_helper'
describe 'gitlab:cleanup rake tasks' do describe 'gitlab:cleanup rake tasks' do
......
# frozen_string_literal: true
require 'spec_helper' require 'spec_helper'
require 'rake' require 'rake'
......
# frozen_string_literal: true
require 'rake_helper' require 'rake_helper'
describe 'gitlab:git rake tasks' do describe 'gitlab:git rake tasks' do
......
# frozen_string_literal: true
require 'rake_helper' require 'rake_helper'
describe 'gitlab:gitaly namespace rake task' do describe 'gitlab:gitaly namespace rake task' do
......
# frozen_string_literal: true
require 'rake_helper' require 'rake_helper'
describe 'gitlab:env:info' do describe 'gitlab:env:info' do
......
# frozen_string_literal: true
require 'rake_helper' require 'rake_helper'
describe 'gitlab:ldap:rename_provider rake task' do describe 'gitlab:ldap:rename_provider rake task' do
......
# frozen_string_literal: true
require 'rake_helper' require 'rake_helper'
describe 'gitlab:lfs rake tasks' do describe 'gitlab:lfs rake tasks' do
......
# frozen_string_literal: true
require 'rake_helper' require 'rake_helper'
describe 'gitlab:lfs namespace rake task' do describe 'gitlab:lfs namespace rake task' do
......
# frozen_string_literal: true
require 'rake_helper' require 'rake_helper'
describe 'gitlab:shell rake tasks' do describe 'gitlab:shell rake tasks' do
......
# frozen_string_literal: true
require 'rake_helper' require 'rake_helper'
describe 'rake gitlab:storage:*', :sidekiq do describe 'rake gitlab:storage:*', :sidekiq do
......
# frozen_string_literal: true
require 'spec_helper' require 'spec_helper'
class TestHelpersTest class TestHelpersTest
......
# frozen_string_literal: true
require 'rake_helper' require 'rake_helper'
describe 'gitlab:uploads rake tasks' do describe 'gitlab:uploads rake tasks' do
......
# frozen_string_literal: true
require 'rake_helper' require 'rake_helper'
describe 'gitlab:uploads:migrate and migrate_to_local rake tasks' do describe 'gitlab:uploads:migrate and migrate_to_local rake tasks' do
......
# frozen_string_literal: true
require 'rake_helper' require 'rake_helper'
describe 'gitlab:web_hook namespace rake tasks' do describe 'gitlab:web_hook namespace rake tasks' do
......
# frozen_string_literal: true
require 'rake_helper' require 'rake_helper'
describe 'gitlab:workhorse namespace rake task' do describe 'gitlab:workhorse namespace rake task' do
......
# frozen_string_literal: true
require 'rake_helper' require 'rake_helper'
describe 'tokens rake tasks' do describe 'tokens rake tasks' do
......
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