summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--GITLAB_ELASTICSEARCH_INDEXER_VERSION2
-rw-r--r--app/assets/javascripts/boards/components/board_card.vue20
-rw-r--r--app/assets/javascripts/boards/components/board_list.vue218
-rw-r--r--app/assets/javascripts/boards/constants.js11
-rw-r--r--app/assets/javascripts/boards/index.js18
-rw-r--r--app/assets/javascripts/boards/models/list.js92
-rw-r--r--app/assets/javascripts/boards/services/board_service.js10
-rw-r--r--app/assets/javascripts/boards/stores/boards_store.js148
-rw-r--r--app/assets/javascripts/test_utils/index.js2
-rw-r--r--app/assets/stylesheets/pages/boards.scss6
-rw-r--r--app/controllers/groups/boards_controller.rb3
-rw-r--r--app/controllers/projects/boards_controller.rb3
-rw-r--r--app/models/ci/persistent_ref.rb12
-rw-r--r--app/workers/gitlab/github_import/stage/finish_import_worker.rb4
-rw-r--r--app/workers/gitlab/github_import/stage/import_repository_worker.rb4
-rw-r--r--app/workers/repository_import_worker.rb4
-rw-r--r--changelogs/unreleased/bump-elasticsearch-indexer-to-v1-4-0.yml5
-rw-r--r--config/helpers/is_ee_env.js11
-rw-r--r--doc/ci/pipelines.md12
-rw-r--r--doc/user/admin_area/settings/continuous_integration.md32
-rw-r--r--doc/user/group/index.md5
-rw-r--r--doc/user/project/img/issue_boards_multi_select.pngbin0 -> 21091 bytes
-rw-r--r--doc/user/project/issue_board.md12
-rw-r--r--doc/user/project/pipelines/settings.md5
-rw-r--r--lib/gitlab.rb20
-rw-r--r--locale/gitlab.pot9
-rw-r--r--spec/db/production/settings_spec.rb2
-rw-r--r--spec/features/boards/multi_select_spec.rb110
-rw-r--r--spec/javascripts/boards/board_card_spec.js14
-rw-r--r--spec/javascripts/boards/boards_store_spec.js132
-rw-r--r--spec/models/ci/persistent_ref_spec.rb12
-rw-r--r--spec/tasks/cache/clear/redis_spec.rb2
-rw-r--r--spec/tasks/config_lint_spec.rb2
-rw-r--r--spec/tasks/gitlab/artifacts/check_rake_spec.rb2
-rw-r--r--spec/tasks/gitlab/artifacts/migrate_rake_spec.rb2
-rw-r--r--spec/tasks/gitlab/backup_rake_spec.rb2
-rw-r--r--spec/tasks/gitlab/check_rake_spec.rb2
-rw-r--r--spec/tasks/gitlab/cleanup_rake_spec.rb2
-rw-r--r--spec/tasks/gitlab/db_rake_spec.rb2
-rw-r--r--spec/tasks/gitlab/git_rake_spec.rb2
-rw-r--r--spec/tasks/gitlab/gitaly_rake_spec.rb2
-rw-r--r--spec/tasks/gitlab/info_rake_spec.rb2
-rw-r--r--spec/tasks/gitlab/ldap_rake_spec.rb2
-rw-r--r--spec/tasks/gitlab/lfs/check_rake_spec.rb2
-rw-r--r--spec/tasks/gitlab/lfs/migrate_rake_spec.rb2
-rw-r--r--spec/tasks/gitlab/shell_rake_spec.rb2
-rw-r--r--spec/tasks/gitlab/storage_rake_spec.rb2
-rw-r--r--spec/tasks/gitlab/task_helpers_spec.rb2
-rw-r--r--spec/tasks/gitlab/uploads/check_rake_spec.rb2
-rw-r--r--spec/tasks/gitlab/uploads/migrate_rake_spec.rb2
-rw-r--r--spec/tasks/gitlab/web_hook_rake_spec.rb2
-rw-r--r--spec/tasks/gitlab/workhorse_rake_spec.rb2
-rw-r--r--spec/tasks/tokens_spec.rb2
53 files changed, 900 insertions, 82 deletions
diff --git a/GITLAB_ELASTICSEARCH_INDEXER_VERSION b/GITLAB_ELASTICSEARCH_INDEXER_VERSION
index f0bb29e7638..88c5fb891dc 100644
--- a/GITLAB_ELASTICSEARCH_INDEXER_VERSION
+++ b/GITLAB_ELASTICSEARCH_INDEXER_VERSION
@@ -1 +1 @@
-1.3.0
+1.4.0
diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue
index faf722f61af..12d68256598 100644
--- a/app/assets/javascripts/boards/components/board_card.vue
+++ b/app/assets/javascripts/boards/components/board_card.vue
@@ -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,
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue
index de41698ca04..1273fcc6a91 100644
--- a/app/assets/javascripts/boards/components/board_list.vue
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -1,12 +1,22 @@
<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>
diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js
new file mode 100644
index 00000000000..3c66c7a0660
--- /dev/null
+++ b/app/assets/javascripts/boards/constants.js
@@ -0,0 +1,11 @@
+export const ListType = {
+ assignee: 'assignee',
+ milestone: 'milestone',
+ backlog: 'backlog',
+ closed: 'closed',
+ label: 'label',
+};
+
+export default {
+ ListType,
+};
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index da2669e7cde..befca70eeae 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -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) {
diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js
index b3e56a34c28..1e213c324eb 100644
--- a/app/assets/javascripts/boards/models/list.js
+++ b/app/assets/javascripts/boards/models/list.js
@@ -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;
diff --git a/app/assets/javascripts/boards/services/board_service.js b/app/assets/javascripts/boards/services/board_service.js
index 0d11db89511..03369febb4a 100644
--- a/app/assets/javascripts/boards/services/board_service.js
+++ b/app/assets/javascripts/boards/services/board_service.js
@@ -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);
}
diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js
index 6da1cca9628..8b737d1dab0 100644
--- a/app/assets/javascripts/boards/stores/boards_store.js
+++ b/app/assets/javascripts/boards/stores/boards_store.js
@@ -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);
+ }
+ } 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) {
const issueTo = listTo.findIssue(issue.id);
const issueLists = issue.getLists();
@@ -195,6 +297,17 @@ const boardsStore = {
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') {
const filteredList = this.state.lists.filter(list => {
const byType = type
@@ -260,6 +373,10 @@ const boardsStore = {
}`;
},
+ generateMultiDragPath(boardId) {
+ return `${gon.relative_url_root}/-/boards/${boardId ? `${boardId}` : ''}/issues/bulk_move`;
+ },
+
all() {
return axios.get(this.state.endpoints.listsEndpoint);
},
@@ -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) {
return axios.post(this.generateIssuesPath(id), {
issue,
@@ -379,6 +506,25 @@ const boardsStore = {
setCurrentBoard(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);
diff --git a/app/assets/javascripts/test_utils/index.js b/app/assets/javascripts/test_utils/index.js
index 1e75ee60671..1a1f3e8d0a8 100644
--- a/app/assets/javascripts/test_utils/index.js
+++ b/app/assets/javascripts/test_utils/index.js
@@ -1,8 +1,10 @@
import 'core-js/es/map';
import 'core-js/es/set';
+import { Sortable } from 'sortablejs';
import simulateDrag from './simulate_drag';
import simulateInput from './simulate_input';
// Export to global space for rspec to use
window.simulateDrag = simulateDrag;
window.simulateInput = simulateInput;
+window.Sortable = Sortable;
diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss
index d540a347dde..2a7a53d8bd7 100644
--- a/app/assets/stylesheets/pages/boards.scss
+++ b/app/assets/stylesheets/pages/boards.scss
@@ -245,6 +245,7 @@
box-shadow: 0 1px 2px $issue-boards-card-shadow;
line-height: $gl-padding;
list-style: none;
+ position: relative;
&:not(:last-child) {
margin-bottom: $gl-padding-8;
@@ -255,6 +256,11 @@
background-color: $blue-50;
}
+ &.multi-select {
+ border-color: $blue-200;
+ background-color: $blue-50;
+ }
+
.badge {
border: 0;
outline: 0;
diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb
index 40b8d5ed72c..3c86f3108ab 100644
--- a/app/controllers/groups/boards_controller.rb
+++ b/app/controllers/groups/boards_controller.rb
@@ -5,6 +5,9 @@ class Groups::BoardsController < Groups::ApplicationController
include RecordUserLastActivity
before_action :assign_endpoint_vars
+ before_action do
+ push_frontend_feature_flag(:multi_select_board)
+ end
private
diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb
index 14b02993e6e..3b335fa4af4 100644
--- a/app/controllers/projects/boards_controller.rb
+++ b/app/controllers/projects/boards_controller.rb
@@ -7,6 +7,9 @@ class Projects::BoardsController < Projects::ApplicationController
before_action :check_issues_available!
before_action :authorize_read_board!, only: [:index, :show]
before_action :assign_endpoint_vars
+ before_action do
+ push_frontend_feature_flag(:multi_select_board)
+ end
private
diff --git a/app/models/ci/persistent_ref.rb b/app/models/ci/persistent_ref.rb
index 9bb67c88577..be3d4aa3203 100644
--- a/app/models/ci/persistent_ref.rb
+++ b/app/models/ci/persistent_ref.rb
@@ -14,15 +14,13 @@ module Ci
delegate :ref_exists?, :create_ref, :delete_refs, to: :repository
def exist?
- return unless enabled?
-
ref_exists?(path)
rescue
false
end
def create
- return unless enabled? && !exist?
+ return if exist?
create_ref(sha, path)
rescue => e
@@ -31,8 +29,6 @@ module Ci
end
def delete
- return unless enabled?
-
delete_refs(path)
rescue Gitlab::Git::Repository::NoRepository
# no-op
@@ -44,11 +40,5 @@ module Ci
def path
"refs/#{Repository::REF_PIPELINES}/#{pipeline.id}"
end
-
- private
-
- def enabled?
- Feature.enabled?(:depend_on_persistent_pipeline_ref, project)
- end
end
end
diff --git a/app/workers/gitlab/github_import/stage/finish_import_worker.rb b/app/workers/gitlab/github_import/stage/finish_import_worker.rb
index a779e631516..ee64f62637e 100644
--- a/app/workers/gitlab/github_import/stage/finish_import_worker.rb
+++ b/app/workers/gitlab/github_import/stage/finish_import_worker.rb
@@ -8,6 +8,10 @@ module Gitlab
include GithubImport::Queue
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.
def import(_, project)
project.after_import
diff --git a/app/workers/gitlab/github_import/stage/import_repository_worker.rb b/app/workers/gitlab/github_import/stage/import_repository_worker.rb
index 4d16cef1130..b5e30470070 100644
--- a/app/workers/gitlab/github_import/stage/import_repository_worker.rb
+++ b/app/workers/gitlab/github_import/stage/import_repository_worker.rb
@@ -8,6 +8,10 @@ module Gitlab
include GithubImport::Queue
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.
# project - An instance of Project.
def import(client, project)
diff --git a/app/workers/repository_import_worker.rb b/app/workers/repository_import_worker.rb
index 5be439ecbc5..85771fa8b31 100644
--- a/app/workers/repository_import_worker.rb
+++ b/app/workers/repository_import_worker.rb
@@ -6,6 +6,10 @@ class RepositoryImportWorker
include ProjectStartImport
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)
@project = Project.find(project_id)
diff --git a/changelogs/unreleased/bump-elasticsearch-indexer-to-v1-4-0.yml b/changelogs/unreleased/bump-elasticsearch-indexer-to-v1-4-0.yml
new file mode 100644
index 00000000000..561514a2dc4
--- /dev/null
+++ b/changelogs/unreleased/bump-elasticsearch-indexer-to-v1-4-0.yml
@@ -0,0 +1,5 @@
+---
+title: Bump GITLAB_ELASTICSEARCH_INDEXER_VERSION=v1.4.0
+merge_request: 18558
+author:
+type: fixed
diff --git a/config/helpers/is_ee_env.js b/config/helpers/is_ee_env.js
index 3fe9bb891eb..801cf6abc81 100644
--- a/config/helpers/is_ee_env.js
+++ b/config/helpers/is_ee_env.js
@@ -3,7 +3,12 @@ const path = require('path');
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 =
- process.env.IS_GITLAB_EE !== undefined
- ? JSON.parse(process.env.IS_GITLAB_EE)
- : fs.existsSync(path.join(ROOT_PATH, 'ee'));
+ fs.existsSync(path.join(ROOT_PATH, 'ee', 'app', 'models', 'license.rb')) &&
+ (!process.env.IS_GITLAB_EE || JSON.parse(process.env.IS_GITLAB_EE));
diff --git a/doc/ci/pipelines.md b/doc/ci/pipelines.md
index da67df3e774..0d0a40aceaa 100644
--- a/doc/ci/pipelines.md
+++ b/doc/ci/pipelines.md
@@ -436,15 +436,3 @@ To illustrate its life cycle:
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. 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.
-```
diff --git a/doc/user/admin_area/settings/continuous_integration.md b/doc/user/admin_area/settings/continuous_integration.md
index ca2a0127ede..9b07ca13eee 100644
--- a/doc/user/admin_area/settings/continuous_integration.md
+++ b/doc/user/admin_area/settings/continuous_integration.md
@@ -29,26 +29,30 @@ If you want to disable it for a specific project, you can do so in
## Maximum artifacts size **(CORE ONLY)**
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
-the default is 100MB per job; on GitLab.com it's [set to 1G](../../gitlab_com/index.md#gitlab-cicd).
+can be set at the project level, group level, and at the instance level. The value is:
-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**.
-1. Change the value of maximum artifacts size (in MB).
-1. Hit **Save changes** for the changes to take effect.
+To change it at the:
-at the group level (this will override the instance setting):
+- Instance level:
-1. Go to **Group > Settings > CI / CD > General Pipelines**.
-1. Change the value of maximum artifacts size (in MB).
-1. Hit **Save changes** for the changes to take effect.
+ 1. Go to **Admin area > Settings > Continuous Integration and Deployment**.
+ 1. Change the value of maximum artifacts size (in MB).
+ 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. Change the value of maximum artifacts size (in MB).
-1. Hit **Save changes** for the changes to take effect.
+ 1. Go to the group'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.
+
+- [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)**
diff --git a/doc/user/group/index.md b/doc/user/group/index.md
index 258f1264b48..93ad32b3e45 100644
--- a/doc/user/group/index.md
+++ b/doc/user/group/index.md
@@ -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.
+### 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)**
With [GitLab Contribution Analytics](contribution_analytics/index.md),
diff --git a/doc/user/project/img/issue_boards_multi_select.png b/doc/user/project/img/issue_boards_multi_select.png
new file mode 100644
index 00000000000..34ec0c1c58e
--- /dev/null
+++ b/doc/user/project/img/issue_boards_multi_select.png
Binary files differ
diff --git a/doc/user/project/issue_board.md b/doc/user/project/issue_board.md
index 0c0068ddd09..e9a7a15b630 100644
--- a/doc/user/project/issue_board.md
+++ b/doc/user/project/issue_board.md
@@ -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,
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)**
> Introduced in [GitLab Starter Edition 10.2](https://about.gitlab.com/2017/11/22/gitlab-10-2-released/#issue-boards-configuration).
diff --git a/doc/user/project/pipelines/settings.md b/doc/user/project/pipelines/settings.md
index 7b708a03b50..59e04907e21 100644
--- a/doc/user/project/pipelines/settings.md
+++ b/doc/user/project/pipelines/settings.md
@@ -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
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
> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/12509) in GitLab 9.4.
diff --git a/lib/gitlab.rb b/lib/gitlab.rb
index b337f5cbf2c..0cc9a6a5fb1 100644
--- a/lib/gitlab.rb
+++ b/lib/gitlab.rb
@@ -65,14 +65,18 @@ module Gitlab
def self.ee?
@is_ee ||=
- if ENV['IS_GITLAB_EE'] && !ENV['IS_GITLAB_EE'].empty?
- 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
- # this method returning `false`, even for an EE installation.
- root.join('ee/app/models/license.rb').exist?
- end
+ # We use this method when the Rails environment is not loaded. This
+ # means that checking the presence of the License class could result in
+ # this method returning `false`, even for an EE installation.
+ #
+ # 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
def self.ee
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 4a58534ba37..b5ffe1bc28a 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -14911,6 +14911,9 @@ msgid_plural "Showing %d events"
msgstr[0] ""
msgstr[1] ""
+msgid "Showing %{pageSize} of %{total} issues"
+msgstr ""
+
msgid "Showing Latest Version"
msgstr ""
@@ -15166,6 +15169,12 @@ msgstr ""
msgid "Something went wrong while merging this merge request. Please try again."
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"
msgstr ""
diff --git a/spec/db/production/settings_spec.rb b/spec/db/production/settings_spec.rb
index db19e98b851..02e25aa37e3 100644
--- a/spec/db/production/settings_spec.rb
+++ b/spec/db/production/settings_spec.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'spec_helper'
require 'rainbow/ext/string'
diff --git a/spec/features/boards/multi_select_spec.rb b/spec/features/boards/multi_select_spec.rb
new file mode 100644
index 00000000000..885dc08e38d
--- /dev/null
+++ b/spec/features/boards/multi_select_spec.rb
@@ -0,0 +1,110 @@
+# 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
diff --git a/spec/javascripts/boards/board_card_spec.js b/spec/javascripts/boards/board_card_spec.js
index 13b708a03d5..9f441ca319e 100644
--- a/spec/javascripts/boards/board_card_spec.js
+++ b/spec/javascripts/boards/board_card_spec.js
@@ -67,6 +67,16 @@ describe('Board card', () => {
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', () => {
expect(vm.$el.classList.contains('user-can-drag')).toBe(true);
});
@@ -180,7 +190,7 @@ describe('Board card', () => {
triggerEvent('mousedown');
triggerEvent('mouseup');
- expect(eventHub.$emit).toHaveBeenCalledWith('newDetailIssue', vm.issue);
+ expect(eventHub.$emit).toHaveBeenCalledWith('newDetailIssue', vm.issue, undefined);
expect(boardsStore.detail.list).toEqual(vm.list);
});
@@ -203,7 +213,7 @@ describe('Board card', () => {
triggerEvent('mousedown');
triggerEvent('mouseup');
- expect(eventHub.$emit).toHaveBeenCalledWith('clearDetailIssue');
+ expect(eventHub.$emit).toHaveBeenCalledWith('clearDetailIssue', undefined);
});
});
});
diff --git a/spec/javascripts/boards/boards_store_spec.js b/spec/javascripts/boards/boards_store_spec.js
index 11352140ba4..678fe5befa8 100644
--- a/spec/javascripts/boards/boards_store_spec.js
+++ b/spec/javascripts/boards/boards_store_spec.js
@@ -12,6 +12,7 @@ import '~/boards/services/board_service';
import boardsStore from '~/boards/stores/boards_store';
import eventHub from '~/boards/eventhub';
import { listObj, listObjDuplicate, boardsMockInterceptor, mockBoardService } from './mock_data';
+import waitForPromises from '../../frontend/helpers/wait_for_promises';
describe('Store', () => {
let mock;
@@ -29,6 +30,13 @@ describe('Store', () => {
}),
);
+ spyOn(gl.boardService, 'moveMultipleIssues').and.callFake(
+ () =>
+ new Promise(resolve => {
+ resolve();
+ }),
+ );
+
Cookies.set('issue_board_welcome_hidden', 'false', {
expires: 365 * 10,
path: '',
@@ -376,4 +384,128 @@ describe('Store', () => {
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);
+ });
+ });
});
diff --git a/spec/models/ci/persistent_ref_spec.rb b/spec/models/ci/persistent_ref_spec.rb
index 71e0b03dff9..be447476e2c 100644
--- a/spec/models/ci/persistent_ref_spec.rb
+++ b/spec/models/ci/persistent_ref_spec.rb
@@ -45,18 +45,6 @@ describe Ci::PersistentRef do
expect(pipeline.persistent_ref).to be_exist
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
let(:sha) { 'not-exist' }
diff --git a/spec/tasks/cache/clear/redis_spec.rb b/spec/tasks/cache/clear/redis_spec.rb
index 97c8c943f3a..4f597988763 100644
--- a/spec/tasks/cache/clear/redis_spec.rb
+++ b/spec/tasks/cache/clear/redis_spec.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'rake_helper'
describe 'clearing redis cache' do
diff --git a/spec/tasks/config_lint_spec.rb b/spec/tasks/config_lint_spec.rb
index 83d54259dfa..c6c11d76388 100644
--- a/spec/tasks/config_lint_spec.rb
+++ b/spec/tasks/config_lint_spec.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'rake_helper'
Rake.application.rake_require 'tasks/config_lint'
diff --git a/spec/tasks/gitlab/artifacts/check_rake_spec.rb b/spec/tasks/gitlab/artifacts/check_rake_spec.rb
index d495b08aca0..04015f0e21a 100644
--- a/spec/tasks/gitlab/artifacts/check_rake_spec.rb
+++ b/spec/tasks/gitlab/artifacts/check_rake_spec.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'rake_helper'
describe 'gitlab:artifacts rake tasks' do
diff --git a/spec/tasks/gitlab/artifacts/migrate_rake_spec.rb b/spec/tasks/gitlab/artifacts/migrate_rake_spec.rb
index afa9ff50146..55bfb7acd9d 100644
--- a/spec/tasks/gitlab/artifacts/migrate_rake_spec.rb
+++ b/spec/tasks/gitlab/artifacts/migrate_rake_spec.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'rake_helper'
describe 'gitlab:artifacts namespace rake task' do
diff --git a/spec/tasks/gitlab/backup_rake_spec.rb b/spec/tasks/gitlab/backup_rake_spec.rb
index bdbd39475b9..b307453f078 100644
--- a/spec/tasks/gitlab/backup_rake_spec.rb
+++ b/spec/tasks/gitlab/backup_rake_spec.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'spec_helper'
require 'rake'
diff --git a/spec/tasks/gitlab/check_rake_spec.rb b/spec/tasks/gitlab/check_rake_spec.rb
index 0fcb9b269f3..1469143d2ac 100644
--- a/spec/tasks/gitlab/check_rake_spec.rb
+++ b/spec/tasks/gitlab/check_rake_spec.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'rake_helper'
describe 'check.rake' do
diff --git a/spec/tasks/gitlab/cleanup_rake_spec.rb b/spec/tasks/gitlab/cleanup_rake_spec.rb
index 6c09bb5d9f9..3c3e5eea838 100644
--- a/spec/tasks/gitlab/cleanup_rake_spec.rb
+++ b/spec/tasks/gitlab/cleanup_rake_spec.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'rake_helper'
describe 'gitlab:cleanup rake tasks' do
diff --git a/spec/tasks/gitlab/db_rake_spec.rb b/spec/tasks/gitlab/db_rake_spec.rb
index 5818892d56a..49b9a572423 100644
--- a/spec/tasks/gitlab/db_rake_spec.rb
+++ b/spec/tasks/gitlab/db_rake_spec.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'spec_helper'
require 'rake'
diff --git a/spec/tasks/gitlab/git_rake_spec.rb b/spec/tasks/gitlab/git_rake_spec.rb
index 57b006e1a39..b8156e55ec7 100644
--- a/spec/tasks/gitlab/git_rake_spec.rb
+++ b/spec/tasks/gitlab/git_rake_spec.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'rake_helper'
describe 'gitlab:git rake tasks' do
diff --git a/spec/tasks/gitlab/gitaly_rake_spec.rb b/spec/tasks/gitlab/gitaly_rake_spec.rb
index 2f3fc7839c1..0cc92680582 100644
--- a/spec/tasks/gitlab/gitaly_rake_spec.rb
+++ b/spec/tasks/gitlab/gitaly_rake_spec.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'rake_helper'
describe 'gitlab:gitaly namespace rake task' do
diff --git a/spec/tasks/gitlab/info_rake_spec.rb b/spec/tasks/gitlab/info_rake_spec.rb
index ca74378a12a..8d6b3985380 100644
--- a/spec/tasks/gitlab/info_rake_spec.rb
+++ b/spec/tasks/gitlab/info_rake_spec.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'rake_helper'
describe 'gitlab:env:info' do
diff --git a/spec/tasks/gitlab/ldap_rake_spec.rb b/spec/tasks/gitlab/ldap_rake_spec.rb
index 279234f2887..bbc3f625088 100644
--- a/spec/tasks/gitlab/ldap_rake_spec.rb
+++ b/spec/tasks/gitlab/ldap_rake_spec.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'rake_helper'
describe 'gitlab:ldap:rename_provider rake task' do
diff --git a/spec/tasks/gitlab/lfs/check_rake_spec.rb b/spec/tasks/gitlab/lfs/check_rake_spec.rb
index 2610edf8bac..3d698efdcb6 100644
--- a/spec/tasks/gitlab/lfs/check_rake_spec.rb
+++ b/spec/tasks/gitlab/lfs/check_rake_spec.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'rake_helper'
describe 'gitlab:lfs rake tasks' do
diff --git a/spec/tasks/gitlab/lfs/migrate_rake_spec.rb b/spec/tasks/gitlab/lfs/migrate_rake_spec.rb
index a85a0031a6c..fc7be0eebcd 100644
--- a/spec/tasks/gitlab/lfs/migrate_rake_spec.rb
+++ b/spec/tasks/gitlab/lfs/migrate_rake_spec.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'rake_helper'
describe 'gitlab:lfs namespace rake task' do
diff --git a/spec/tasks/gitlab/shell_rake_spec.rb b/spec/tasks/gitlab/shell_rake_spec.rb
index c3e912b02c5..e3b7967bd19 100644
--- a/spec/tasks/gitlab/shell_rake_spec.rb
+++ b/spec/tasks/gitlab/shell_rake_spec.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'rake_helper'
describe 'gitlab:shell rake tasks' do
diff --git a/spec/tasks/gitlab/storage_rake_spec.rb b/spec/tasks/gitlab/storage_rake_spec.rb
index 0e47408fc72..ae11e091000 100644
--- a/spec/tasks/gitlab/storage_rake_spec.rb
+++ b/spec/tasks/gitlab/storage_rake_spec.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'rake_helper'
describe 'rake gitlab:storage:*', :sidekiq do
diff --git a/spec/tasks/gitlab/task_helpers_spec.rb b/spec/tasks/gitlab/task_helpers_spec.rb
index e9322ec4931..4b4f7d7c956 100644
--- a/spec/tasks/gitlab/task_helpers_spec.rb
+++ b/spec/tasks/gitlab/task_helpers_spec.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'spec_helper'
class TestHelpersTest
diff --git a/spec/tasks/gitlab/uploads/check_rake_spec.rb b/spec/tasks/gitlab/uploads/check_rake_spec.rb
index 5d597c66133..91f0cedb246 100644
--- a/spec/tasks/gitlab/uploads/check_rake_spec.rb
+++ b/spec/tasks/gitlab/uploads/check_rake_spec.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'rake_helper'
describe 'gitlab:uploads rake tasks' do
diff --git a/spec/tasks/gitlab/uploads/migrate_rake_spec.rb b/spec/tasks/gitlab/uploads/migrate_rake_spec.rb
index 8d1e355a7d3..2f773bdfeec 100644
--- a/spec/tasks/gitlab/uploads/migrate_rake_spec.rb
+++ b/spec/tasks/gitlab/uploads/migrate_rake_spec.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'rake_helper'
describe 'gitlab:uploads:migrate and migrate_to_local rake tasks' do
diff --git a/spec/tasks/gitlab/web_hook_rake_spec.rb b/spec/tasks/gitlab/web_hook_rake_spec.rb
index 7bdf33ff6b0..be31507000d 100644
--- a/spec/tasks/gitlab/web_hook_rake_spec.rb
+++ b/spec/tasks/gitlab/web_hook_rake_spec.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'rake_helper'
describe 'gitlab:web_hook namespace rake tasks' do
diff --git a/spec/tasks/gitlab/workhorse_rake_spec.rb b/spec/tasks/gitlab/workhorse_rake_spec.rb
index 42516d36c67..b7877a84185 100644
--- a/spec/tasks/gitlab/workhorse_rake_spec.rb
+++ b/spec/tasks/gitlab/workhorse_rake_spec.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'rake_helper'
describe 'gitlab:workhorse namespace rake task' do
diff --git a/spec/tasks/tokens_spec.rb b/spec/tasks/tokens_spec.rb
index 4188e7caccb..9c69155056a 100644
--- a/spec/tasks/tokens_spec.rb
+++ b/spec/tasks/tokens_spec.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'rake_helper'
describe 'tokens rake tasks' do