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 {
return {
showDetail: false,
detailIssue: boardsStore.detail,
multiSelect: boardsStore.multiSelect,
};
},
computed: {
issueDetailVisible() {
return this.detailIssue.issue && this.detailIssue.issue.id === this.issue.id;
},
multiSelectVisible() {
return this.multiSelect.list.findIndex(issue => issue.id === this.issue.id) > -1;
},
canMultiSelect() {
return gon.features && gon.features.multiSelectBoard;
},
},
methods: {
mouseDown() {
......@@ -58,14 +65,20 @@ export default {
},
showIssue(e) {
if (e.target.classList.contains('js-no-trigger')) return;
if (this.showDetail) {
this.showDetail = false;
// If CMD or CTRL is clicked
const isMultiSelect = this.canMultiSelect && (e.ctrlKey || e.metaKey);
if (boardsStore.detail.issue && boardsStore.detail.issue.id === this.issue.id) {
eventHub.$emit('clearDetailIssue');
eventHub.$emit('clearDetailIssue', isMultiSelect);
if (isMultiSelect) {
eventHub.$emit('newDetailIssue', this.issue, isMultiSelect);
}
} else {
eventHub.$emit('newDetailIssue', this.issue);
eventHub.$emit('newDetailIssue', this.issue, isMultiSelect);
boardsStore.setListDetail(this.list);
}
}
......@@ -77,6 +90,7 @@ export default {
<template>
<li
:class="{
'multi-select': multiSelectVisible,
'user-can-drag': !disabled && issue.id,
'is-disabled': disabled || !issue.id,
'is-active': issueDetailVisible,
......
<script>
/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
import Sortable from 'sortablejs';
import { Sortable, MultiDrag } from 'sortablejs';
import { GlLoadingIcon } from '@gitlab/ui';
import _ from 'underscore';
import boardNewIssue from './board_new_issue.vue';
import boardCard from './board_card.vue';
import eventHub from '../eventhub';
import boardsStore from '../stores/boards_store';
import { getBoardSortableDefaultOptions, sortableStart } from '../mixins/sortable_default_options';
import { sprintf, __ } from '~/locale';
import createFlash from '~/flash';
import {
getBoardSortableDefaultOptions,
sortableStart,
sortableEnd,
} from '../mixins/sortable_default_options';
if (gon.features && gon.features.multiSelectBoard) {
Sortable.mount(new MultiDrag());
}
export default {
name: 'BoardList',
......@@ -54,6 +64,14 @@ export default {
showIssueForm: false,
};
},
computed: {
paginatedIssueText() {
return sprintf(__('Showing %{pageSize} of %{total} issues'), {
pageSize: this.list.issues.length,
total: this.list.issuesSize,
});
},
},
watch: {
filters: {
handler() {
......@@ -87,11 +105,20 @@ export default {
eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop);
},
mounted() {
const multiSelectOpts = {};
if (gon.features && gon.features.multiSelectBoard) {
multiSelectOpts.multiDrag = true;
multiSelectOpts.selectedClass = 'js-multi-select';
multiSelectOpts.animation = 500;
}
const options = getBoardSortableDefaultOptions({
scroll: true,
disabled: this.disabled,
filter: '.board-list-count, .is-disabled',
dataIdAttr: 'data-issue-id',
removeCloneOnHide: false,
...multiSelectOpts,
group: {
name: 'issues',
/**
......@@ -145,25 +172,66 @@ export default {
card.showDetail = false;
const { list } = card;
const issue = list.findIssue(Number(e.item.dataset.issueId));
boardsStore.startMoving(list, issue);
sortableStart();
},
onAdd: e => {
boardsStore.moveIssueToList(
boardsStore.moving.list,
this.list,
boardsStore.moving.issue,
e.newIndex,
);
const { items = [], newIndicies = [] } = e;
if (items.length) {
// Not using e.newIndex here instead taking a min of all
// the newIndicies. Basically we have to find that during
// a drop what is the index we're going to start putting
// all the dropped elements from.
const newIndex = Math.min(...newIndicies.map(obj => obj.index).filter(i => i !== -1));
const issues = items.map(item =>
boardsStore.moving.list.findIssue(Number(item.dataset.issueId)),
);
this.$nextTick(() => {
e.item.remove();
});
boardsStore.moveMultipleIssuesToList({
listFrom: boardsStore.moving.list,
listTo: this.list,
issues,
newIndex,
});
} else {
boardsStore.moveIssueToList(
boardsStore.moving.list,
this.list,
boardsStore.moving.issue,
e.newIndex,
);
this.$nextTick(() => {
e.item.remove();
});
}
},
onUpdate: e => {
const sortedArray = this.sortable.toArray().filter(id => id !== '-1');
const { items = [], newIndicies = [], oldIndicies = [] } = e;
if (items.length) {
const newIndex = Math.min(...newIndicies.map(obj => obj.index));
const issues = items.map(item =>
boardsStore.moving.list.findIssue(Number(item.dataset.issueId)),
);
boardsStore.moveMultipleIssuesInList({
list: this.list,
issues,
oldIndicies: oldIndicies.map(obj => obj.index),
newIndex,
idArray: sortedArray,
});
e.items.forEach(el => {
Sortable.utils.deselect(el);
});
boardsStore.clearMultiSelect();
return;
}
boardsStore.moveIssueInList(
this.list,
boardsStore.moving.issue,
......@@ -172,9 +240,133 @@ export default {
sortedArray,
);
},
onEnd: e => {
const { items = [], clones = [], to } = e;
// This is not a multi select operation
if (!items.length && !clones.length) {
sortableEnd();
return;
}
let toList;
if (to) {
const containerEl = to.closest('.js-board-list');
toList = boardsStore.findList('id', Number(containerEl.dataset.board));
}
/**
* onEnd is called irrespective if the cards were moved in the
* same list or the other list. Don't remove items if it's same list.
*/
const isSameList = toList && toList.id === this.list.id;
if (toList && !isSameList && boardsStore.shouldRemoveIssue(this.list, toList)) {
const issues = items.map(item => this.list.findIssue(Number(item.dataset.issueId)));
if (_.compact(issues).length && !boardsStore.issuesAreContiguous(this.list, issues)) {
const indexes = [];
const ids = this.list.issues.map(i => i.id);
issues.forEach(issue => {
const index = ids.indexOf(issue.id);
if (index > -1) {
indexes.push(index);
}
});
// Descending sort because splice would cause index discrepancy otherwise
const sortedIndexes = indexes.sort((a, b) => (a < b ? 1 : -1));
sortedIndexes.forEach(i => {
/**
* **setTimeout and splice each element one-by-one in a loop
* is intended.**
*
* The problem here is all the indexes are in the list but are
* non-contiguous. Due to that, when we splice all the indexes,
* at once, Vue -- during a re-render -- is unable to find reference
* nodes and the entire app crashes.
*
* If the indexes are contiguous, this piece of code is not
* executed. If it is, this is a possible regression. Only when
* issue indexes are far apart, this logic should ever kick in.
*/
setTimeout(() => {
this.list.issues.splice(i, 1);
}, 0);
});
}
}
if (!toList) {
createFlash(__('Something went wrong while performing the action.'));
}
if (!isSameList) {
boardsStore.clearMultiSelect();
// Since Vue's list does not re-render the same keyed item, we'll
// remove `multi-select` class to express it's unselected
if (clones && clones.length) {
clones.forEach(el => el.classList.remove('multi-select'));
}
// Due to some bug which I am unable to figure out
// Sortable does not deselect some pending items from the
// source list.
// We'll just do it forcefully here.
Array.from(document.querySelectorAll('.js-multi-select') || []).forEach(item => {
Sortable.utils.deselect(item);
});
/**
* SortableJS leaves all the moving items "as is" on the DOM.
* Vue picks up and rehydrates the DOM, but we need to explicity
* remove the "trash" items from the DOM.
*
* This is in parity to the logic on single item move from a list/in
* a list. For reference, look at the implementation of onAdd method.
*/
this.$nextTick(() => {
if (items && items.length) {
items.forEach(item => {
item.remove();
});
}
});
}
sortableEnd();
},
onMove(e) {
return !e.related.classList.contains('board-list-count');
},
onSelect(e) {
const {
item: { classList },
} = e;
if (
classList &&
classList.contains('js-multi-select') &&
!classList.contains('multi-select')
) {
Sortable.utils.deselect(e.item);
}
},
onDeselect: e => {
const {
item: { dataset, classList },
} = e;
if (
classList &&
classList.contains('multi-select') &&
!classList.contains('js-multi-select')
) {
const issue = this.list.findIssue(Number(dataset.issueId));
boardsStore.toggleMultiSelect(issue);
}
},
});
this.sortable = Sortable.create(this.$refs.list, options);
......@@ -260,7 +452,7 @@ export default {
<li v-if="showCount" class="board-list-count text-center" data-issue-id="-1">
<gl-loading-icon v-show="list.loadingMore" label="Loading more issues" />
<span v-if="list.issues.length === list.issuesSize">{{ __('Showing all issues') }}</span>
<span v-else> Showing {{ list.issues.length }} of {{ list.issuesSize }} issues </span>
<span v-else>{{ paginatedIssueText }}</span>
</li>
</ul>
</div>
......
export const ListType = {
assignee: 'assignee',
milestone: 'milestone',
backlog: 'backlog',
closed: 'closed',
label: 'label',
};
export default {
ListType,
};
......@@ -146,7 +146,7 @@ export default () => {
updateTokens() {
this.filterManager.updateTokens();
},
updateDetailIssue(newIssue) {
updateDetailIssue(newIssue, multiSelect = false) {
const { sidebarInfoEndpoint } = newIssue;
if (sidebarInfoEndpoint && newIssue.subscribed === undefined) {
newIssue.setFetchingState('subscriptions', true);
......@@ -185,9 +185,23 @@ export default () => {
});
}
if (multiSelect) {
boardsStore.toggleMultiSelect(newIssue);
if (boardsStore.detail.issue) {
boardsStore.clearDetailIssue();
return;
}
return;
}
boardsStore.setIssueDetail(newIssue);
},
clearDetailIssue() {
clearDetailIssue(multiSelect = false) {
if (multiSelect) {
boardsStore.clearMultiSelect();
}
boardsStore.clearDetailIssue();
},
toggleSubscription(id) {
......
......@@ -5,6 +5,7 @@ import ListLabel from './label';
import ListAssignee from './assignee';
import ListIssue from 'ee_else_ce/boards/models/issue';
import { urlParamsToObject } from '~/lib/utils/common_utils';
import flash from '~/flash';
import boardsStore from '../stores/boards_store';
import ListMilestone from './milestone';
......@@ -176,6 +177,53 @@ class List {
});
}
addMultipleIssues(issues, listFrom, newIndex) {
let moveBeforeId = null;
let moveAfterId = null;
const listHasIssues = issues.every(issue => this.findIssue(issue.id));
if (!listHasIssues) {
if (newIndex !== undefined) {
if (this.issues[newIndex - 1]) {
moveBeforeId = this.issues[newIndex - 1].id;
}
if (this.issues[newIndex]) {
moveAfterId = this.issues[newIndex].id;
}
this.issues.splice(newIndex, 0, ...issues);
} else {
this.issues.push(...issues);
}
if (this.label) {
issues.forEach(issue => issue.addLabel(this.label));
}
if (this.assignee) {
if (listFrom && listFrom.type === 'assignee') {
issues.forEach(issue => issue.removeAssignee(listFrom.assignee));
}
issues.forEach(issue => issue.addAssignee(this.assignee));
}
if (IS_EE && this.milestone) {
if (listFrom && listFrom.type === 'milestone') {
issues.forEach(issue => issue.removeMilestone(listFrom.milestone));
}
issues.forEach(issue => issue.addMilestone(this.milestone));
}
if (listFrom) {
this.issuesSize += issues.length;
this.updateMultipleIssues(issues, listFrom, moveBeforeId, moveAfterId);
}
}
}
addIssue(issue, listFrom, newIndex) {
let moveBeforeId = null;
let moveAfterId = null;
......@@ -230,6 +278,23 @@ class List {
});
}
moveMultipleIssues({ issues, oldIndicies, newIndex, moveBeforeId, moveAfterId }) {
oldIndicies.reverse().forEach(index => {
this.issues.splice(index, 1);
});
this.issues.splice(newIndex, 0, ...issues);
gl.boardService
.moveMultipleIssues({
ids: issues.map(issue => issue.id),
fromListId: null,
toListId: null,
moveBeforeId,
moveAfterId,
})
.catch(() => flash(__('Something went wrong while moving issues.')));
}
updateIssueLabel(issue, listFrom, moveBeforeId, moveAfterId) {
gl.boardService
.moveIssue(issue.id, listFrom.id, this.id, moveBeforeId, moveAfterId)
......@@ -238,10 +303,37 @@ class List {
});
}
updateMultipleIssues(issues, listFrom, moveBeforeId, moveAfterId) {
gl.boardService
.moveMultipleIssues({
ids: issues.map(issue => issue.id),
fromListId: listFrom.id,
toListId: this.id,
moveBeforeId,
moveAfterId,
})
.catch(() => flash(__('Something went wrong while moving issues.')));
}
findIssue(id) {
return this.issues.find(issue => issue.id === id);
}
removeMultipleIssues(removeIssues) {
const ids = removeIssues.map(issue => issue.id);
this.issues = this.issues.filter(issue => {
const matchesRemove = ids.includes(issue.id);
if (matchesRemove) {
this.issuesSize -= 1;
issue.removeLabel(this.label);
}
return !matchesRemove;
});
}
removeIssue(removeIssue) {
this.issues = this.issues.filter(issue => {
const matchesRemove = removeIssue.id === issue.id;
......
......@@ -48,6 +48,16 @@ export default class BoardService {
return boardsStore.moveIssue(id, fromListId, toListId, moveBeforeId, moveAfterId);
}
moveMultipleIssues({
ids,
fromListId = null,
toListId = null,
moveBeforeId = null,
moveAfterId = null,
}) {
return boardsStore.moveMultipleIssues({ ids, fromListId, toListId, moveBeforeId, moveAfterId });
}
newIssue(id, issue) {
return boardsStore.newIssue(id, issue);
}
......
......@@ -11,6 +11,7 @@ import { __ } from '~/locale';
import axios from '~/lib/utils/axios_utils';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import eventHub from '../eventhub';
import { ListType } from '../constants';
const boardsStore = {
disabled: false,
......@@ -39,6 +40,7 @@ const boardsStore = {
issue: {},
list: {},
},
multiSelect: { list: [] },
setEndpoints({ boardsEndpoint, listsEndpoint, bulkUpdatePath, boardId, recentBoardsEndpoint }) {
const listsEndpointGenerate = `${listsEndpoint}/generate.json`;
......@@ -51,7 +53,6 @@ const boardsStore = {
recentBoardsEndpoint: `${recentBoardsEndpoint}.json`,
};
},
create() {
this.state.lists = [];
this.filter.path = getUrlParamsArray().join('&');
......@@ -134,6 +135,107 @@ const boardsStore = {
Object.assign(this.moving, { list, issue });
},
moveMultipleIssuesToList({ listFrom, listTo, issues, newIndex }) {
const issueTo = issues.map(issue => listTo.findIssue(issue.id));
const issueLists = _.flatten(issues.map(issue => issue.getLists()));
const listLabels = issueLists.map(list => list.label);
const hasMoveableIssues = _.compact(issueTo).length > 0;
if (!hasMoveableIssues) {
// Check if target list assignee is already present in this issue
if (
listTo.type === ListType.assignee &&
listFrom.type === ListType.assignee &&
issues.some(issue => issue.findAssignee(listTo.assignee))
) {
const targetIssues = issues.map(issue => listTo.findIssue(issue.id));
targetIssues.forEach(targetIssue => targetIssue.removeAssignee(listFrom.assignee));
} else if (listTo.type === 'milestone') {
const currentMilestones = issues.map(issue => issue.milestone);
const currentLists = this.state.lists
.filter(list => list.type === 'milestone' && list.id !== listTo.id)
.filter(list =>
list.issues.some(listIssue => issues.some(issue => listIssue.id === issue.id)),
);
issues.forEach(issue => {
currentMilestones.forEach(milestone => {
issue.removeMilestone(milestone);
});
});
issues.forEach(issue => {
issue.addMilestone(listTo.milestone);
});
currentLists.forEach(currentList => {
issues.forEach(issue => {
currentList.removeIssue(issue);
});
});
listTo.addMultipleIssues(issues, listFrom, newIndex);
} else {
// Add to new lists issues if it doesn't already exist
listTo.addMultipleIssues(issues, listFrom, newIndex);
}
} 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);
......
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;
......@@ -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;
......
......@@ -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
......
......@@ -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
......
......@@ -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
......@@ -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
......
......@@ -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)
......
......@@ -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)
......
---
title: Bump GITLAB_ELASTICSEARCH_INDEXER_VERSION=v1.4.0
merge_request: 18558
author:
type: fixed
......@@ -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));
......@@ -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.
```
......@@ -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)**
......
......@@ -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),
......
......@@ -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).
......
......@@ -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.
......
......@@ -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
......
......@@ -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 ""
......
# frozen_string_literal: true
require 'spec_helper'
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', () => {
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);
});
});
});
......@@ -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);
});
});
});
......@@ -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' }
......
# frozen_string_literal: true
require 'rake_helper'
describe 'clearing redis cache' do
......
# frozen_string_literal: true
require 'rake_helper'
Rake.application.rake_require 'tasks/config_lint'
......
# frozen_string_literal: true
require 'rake_helper'
describe 'gitlab:artifacts rake tasks' do
......
# frozen_string_literal: true
require 'rake_helper'
describe 'gitlab:artifacts namespace rake task' do
......
# frozen_string_literal: true
require 'spec_helper'
require 'rake'
......
# frozen_string_literal: true
require 'rake_helper'
describe 'check.rake' do
......
# frozen_string_literal: true
require 'rake_helper'
describe 'gitlab:cleanup rake tasks' do
......
# frozen_string_literal: true
require 'spec_helper'
require 'rake'
......
# frozen_string_literal: true
require 'rake_helper'
describe 'gitlab:git rake tasks' do
......
# frozen_string_literal: true
require 'rake_helper'
describe 'gitlab:gitaly namespace rake task' do
......
# frozen_string_literal: true
require 'rake_helper'
describe 'gitlab:env:info' do
......
# frozen_string_literal: true
require 'rake_helper'
describe 'gitlab:ldap:rename_provider rake task' do
......
# frozen_string_literal: true
require 'rake_helper'
describe 'gitlab:lfs rake tasks' do
......
# frozen_string_literal: true
require 'rake_helper'
describe 'gitlab:lfs namespace rake task' do
......
# frozen_string_literal: true
require 'rake_helper'
describe 'gitlab:shell rake tasks' do
......
# frozen_string_literal: true
require 'rake_helper'
describe 'rake gitlab:storage:*', :sidekiq do
......
# frozen_string_literal: true
require 'spec_helper'
class TestHelpersTest
......
# frozen_string_literal: true
require 'rake_helper'
describe 'gitlab:uploads rake tasks' do
......
# frozen_string_literal: true
require 'rake_helper'
describe 'gitlab:uploads:migrate and migrate_to_local rake tasks' do
......
# frozen_string_literal: true
require 'rake_helper'
describe 'gitlab:web_hook namespace rake tasks' do
......
# frozen_string_literal: true
require 'rake_helper'
describe 'gitlab:workhorse namespace rake task' do
......
# frozen_string_literal: true
require 'rake_helper'
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