Commit f03a645e authored by GitLab Bot's avatar GitLab Bot

Add latest changes from gitlab-org/gitlab@master

parent 0dea53d5
...@@ -42,12 +42,19 @@ export default { ...@@ -42,12 +42,19 @@ export default {
return { return {
showDetail: false, showDetail: false,
detailIssue: boardsStore.detail, detailIssue: boardsStore.detail,
multiSelect: boardsStore.multiSelect,
}; };
}, },
computed: { computed: {
issueDetailVisible() { issueDetailVisible() {
return this.detailIssue.issue && this.detailIssue.issue.id === this.issue.id; return this.detailIssue.issue && this.detailIssue.issue.id === this.issue.id;
}, },
multiSelectVisible() {
return this.multiSelect.list.findIndex(issue => issue.id === this.issue.id) > -1;
},
canMultiSelect() {
return gon.features && gon.features.multiSelectBoard;
},
}, },
methods: { methods: {
mouseDown() { mouseDown() {
...@@ -58,14 +65,20 @@ export default { ...@@ -58,14 +65,20 @@ export default {
}, },
showIssue(e) { showIssue(e) {
if (e.target.classList.contains('js-no-trigger')) return; if (e.target.classList.contains('js-no-trigger')) return;
if (this.showDetail) { if (this.showDetail) {
this.showDetail = false; this.showDetail = false;
// If CMD or CTRL is clicked
const isMultiSelect = this.canMultiSelect && (e.ctrlKey || e.metaKey);
if (boardsStore.detail.issue && boardsStore.detail.issue.id === this.issue.id) { if (boardsStore.detail.issue && boardsStore.detail.issue.id === this.issue.id) {
eventHub.$emit('clearDetailIssue'); eventHub.$emit('clearDetailIssue', isMultiSelect);
if (isMultiSelect) {
eventHub.$emit('newDetailIssue', this.issue, isMultiSelect);
}
} else { } else {
eventHub.$emit('newDetailIssue', this.issue); eventHub.$emit('newDetailIssue', this.issue, isMultiSelect);
boardsStore.setListDetail(this.list); boardsStore.setListDetail(this.list);
} }
} }
...@@ -77,6 +90,7 @@ export default { ...@@ -77,6 +90,7 @@ export default {
<template> <template>
<li <li
:class="{ :class="{
'multi-select': multiSelectVisible,
'user-can-drag': !disabled && issue.id, 'user-can-drag': !disabled && issue.id,
'is-disabled': disabled || !issue.id, 'is-disabled': disabled || !issue.id,
'is-active': issueDetailVisible, 'is-active': issueDetailVisible,
......
<script> <script>
/* eslint-disable @gitlab/vue-i18n/no-bare-strings */ import { Sortable, MultiDrag } from 'sortablejs';
import Sortable from 'sortablejs';
import { GlLoadingIcon } from '@gitlab/ui'; import { GlLoadingIcon } from '@gitlab/ui';
import _ from 'underscore';
import boardNewIssue from './board_new_issue.vue'; import boardNewIssue from './board_new_issue.vue';
import boardCard from './board_card.vue'; import boardCard from './board_card.vue';
import eventHub from '../eventhub'; import eventHub from '../eventhub';
import boardsStore from '../stores/boards_store'; import boardsStore from '../stores/boards_store';
import { getBoardSortableDefaultOptions, sortableStart } from '../mixins/sortable_default_options'; import { sprintf, __ } from '~/locale';
import createFlash from '~/flash';
import {
getBoardSortableDefaultOptions,
sortableStart,
sortableEnd,
} from '../mixins/sortable_default_options';
if (gon.features && gon.features.multiSelectBoard) {
Sortable.mount(new MultiDrag());
}
export default { export default {
name: 'BoardList', name: 'BoardList',
...@@ -54,6 +64,14 @@ export default { ...@@ -54,6 +64,14 @@ export default {
showIssueForm: false, showIssueForm: false,
}; };
}, },
computed: {
paginatedIssueText() {
return sprintf(__('Showing %{pageSize} of %{total} issues'), {
pageSize: this.list.issues.length,
total: this.list.issuesSize,
});
},
},
watch: { watch: {
filters: { filters: {
handler() { handler() {
...@@ -87,11 +105,20 @@ export default { ...@@ -87,11 +105,20 @@ export default {
eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop); eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop);
}, },
mounted() { mounted() {
const multiSelectOpts = {};
if (gon.features && gon.features.multiSelectBoard) {
multiSelectOpts.multiDrag = true;
multiSelectOpts.selectedClass = 'js-multi-select';
multiSelectOpts.animation = 500;
}
const options = getBoardSortableDefaultOptions({ const options = getBoardSortableDefaultOptions({
scroll: true, scroll: true,
disabled: this.disabled, disabled: this.disabled,
filter: '.board-list-count, .is-disabled', filter: '.board-list-count, .is-disabled',
dataIdAttr: 'data-issue-id', dataIdAttr: 'data-issue-id',
removeCloneOnHide: false,
...multiSelectOpts,
group: { group: {
name: 'issues', name: 'issues',
/** /**
...@@ -145,25 +172,66 @@ export default { ...@@ -145,25 +172,66 @@ export default {
card.showDetail = false; card.showDetail = false;
const { list } = card; const { list } = card;
const issue = list.findIssue(Number(e.item.dataset.issueId)); const issue = list.findIssue(Number(e.item.dataset.issueId));
boardsStore.startMoving(list, issue); boardsStore.startMoving(list, issue);
sortableStart(); sortableStart();
}, },
onAdd: e => { onAdd: e => {
boardsStore.moveIssueToList( const { items = [], newIndicies = [] } = e;
boardsStore.moving.list, if (items.length) {
this.list, // Not using e.newIndex here instead taking a min of all
boardsStore.moving.issue, // the newIndicies. Basically we have to find that during
e.newIndex, // a drop what is the index we're going to start putting
); // all the dropped elements from.
const newIndex = Math.min(...newIndicies.map(obj => obj.index).filter(i => i !== -1));
const issues = items.map(item =>
boardsStore.moving.list.findIssue(Number(item.dataset.issueId)),
);
this.$nextTick(() => { boardsStore.moveMultipleIssuesToList({
e.item.remove(); listFrom: boardsStore.moving.list,
}); listTo: this.list,
issues,
newIndex,
});
} else {
boardsStore.moveIssueToList(
boardsStore.moving.list,
this.list,
boardsStore.moving.issue,
e.newIndex,
);
this.$nextTick(() => {
e.item.remove();
});
}
}, },
onUpdate: e => { onUpdate: e => {
const sortedArray = this.sortable.toArray().filter(id => id !== '-1'); const sortedArray = this.sortable.toArray().filter(id => id !== '-1');
const { items = [], newIndicies = [], oldIndicies = [] } = e;
if (items.length) {
const newIndex = Math.min(...newIndicies.map(obj => obj.index));
const issues = items.map(item =>
boardsStore.moving.list.findIssue(Number(item.dataset.issueId)),
);
boardsStore.moveMultipleIssuesInList({
list: this.list,
issues,
oldIndicies: oldIndicies.map(obj => obj.index),
newIndex,
idArray: sortedArray,
});
e.items.forEach(el => {
Sortable.utils.deselect(el);
});
boardsStore.clearMultiSelect();
return;
}
boardsStore.moveIssueInList( boardsStore.moveIssueInList(
this.list, this.list,
boardsStore.moving.issue, boardsStore.moving.issue,
...@@ -172,9 +240,133 @@ export default { ...@@ -172,9 +240,133 @@ export default {
sortedArray, sortedArray,
); );
}, },
onEnd: e => {
const { items = [], clones = [], to } = e;
// This is not a multi select operation
if (!items.length && !clones.length) {
sortableEnd();
return;
}
let toList;
if (to) {
const containerEl = to.closest('.js-board-list');
toList = boardsStore.findList('id', Number(containerEl.dataset.board));
}
/**
* onEnd is called irrespective if the cards were moved in the
* same list or the other list. Don't remove items if it's same list.
*/
const isSameList = toList && toList.id === this.list.id;
if (toList && !isSameList && boardsStore.shouldRemoveIssue(this.list, toList)) {
const issues = items.map(item => this.list.findIssue(Number(item.dataset.issueId)));
if (_.compact(issues).length && !boardsStore.issuesAreContiguous(this.list, issues)) {
const indexes = [];
const ids = this.list.issues.map(i => i.id);
issues.forEach(issue => {
const index = ids.indexOf(issue.id);
if (index > -1) {
indexes.push(index);
}
});
// Descending sort because splice would cause index discrepancy otherwise
const sortedIndexes = indexes.sort((a, b) => (a < b ? 1 : -1));
sortedIndexes.forEach(i => {
/**
* **setTimeout and splice each element one-by-one in a loop
* is intended.**
*
* The problem here is all the indexes are in the list but are
* non-contiguous. Due to that, when we splice all the indexes,
* at once, Vue -- during a re-render -- is unable to find reference
* nodes and the entire app crashes.
*
* If the indexes are contiguous, this piece of code is not
* executed. If it is, this is a possible regression. Only when
* issue indexes are far apart, this logic should ever kick in.
*/
setTimeout(() => {
this.list.issues.splice(i, 1);
}, 0);
});
}
}
if (!toList) {
createFlash(__('Something went wrong while performing the action.'));
}
if (!isSameList) {
boardsStore.clearMultiSelect();
// Since Vue's list does not re-render the same keyed item, we'll
// remove `multi-select` class to express it's unselected
if (clones && clones.length) {
clones.forEach(el => el.classList.remove('multi-select'));
}
// Due to some bug which I am unable to figure out
// Sortable does not deselect some pending items from the
// source list.
// We'll just do it forcefully here.
Array.from(document.querySelectorAll('.js-multi-select') || []).forEach(item => {
Sortable.utils.deselect(item);
});
/**
* SortableJS leaves all the moving items "as is" on the DOM.
* Vue picks up and rehydrates the DOM, but we need to explicity
* remove the "trash" items from the DOM.
*
* This is in parity to the logic on single item move from a list/in
* a list. For reference, look at the implementation of onAdd method.
*/
this.$nextTick(() => {
if (items && items.length) {
items.forEach(item => {
item.remove();
});
}
});
}
sortableEnd();
},
onMove(e) { onMove(e) {
return !e.related.classList.contains('board-list-count'); return !e.related.classList.contains('board-list-count');
}, },
onSelect(e) {
const {
item: { classList },
} = e;
if (
classList &&
classList.contains('js-multi-select') &&
!classList.contains('multi-select')
) {
Sortable.utils.deselect(e.item);
}
},
onDeselect: e => {
const {
item: { dataset, classList },
} = e;
if (
classList &&
classList.contains('multi-select') &&
!classList.contains('js-multi-select')
) {
const issue = this.list.findIssue(Number(dataset.issueId));
boardsStore.toggleMultiSelect(issue);
}
},
}); });
this.sortable = Sortable.create(this.$refs.list, options); this.sortable = Sortable.create(this.$refs.list, options);
...@@ -260,7 +452,7 @@ export default { ...@@ -260,7 +452,7 @@ export default {
<li v-if="showCount" class="board-list-count text-center" data-issue-id="-1"> <li v-if="showCount" class="board-list-count text-center" data-issue-id="-1">
<gl-loading-icon v-show="list.loadingMore" label="Loading more issues" /> <gl-loading-icon v-show="list.loadingMore" label="Loading more issues" />
<span v-if="list.issues.length === list.issuesSize">{{ __('Showing all issues') }}</span> <span v-if="list.issues.length === list.issuesSize">{{ __('Showing all issues') }}</span>
<span v-else> Showing {{ list.issues.length }} of {{ list.issuesSize }} issues </span> <span v-else>{{ paginatedIssueText }}</span>
</li> </li>
</ul> </ul>
</div> </div>
......
export const ListType = {
assignee: 'assignee',
milestone: 'milestone',
backlog: 'backlog',
closed: 'closed',
label: 'label',
};
export default {
ListType,
};
...@@ -146,7 +146,7 @@ export default () => { ...@@ -146,7 +146,7 @@ export default () => {
updateTokens() { updateTokens() {
this.filterManager.updateTokens(); this.filterManager.updateTokens();
}, },
updateDetailIssue(newIssue) { updateDetailIssue(newIssue, multiSelect = false) {
const { sidebarInfoEndpoint } = newIssue; const { sidebarInfoEndpoint } = newIssue;
if (sidebarInfoEndpoint && newIssue.subscribed === undefined) { if (sidebarInfoEndpoint && newIssue.subscribed === undefined) {
newIssue.setFetchingState('subscriptions', true); newIssue.setFetchingState('subscriptions', true);
...@@ -185,9 +185,23 @@ export default () => { ...@@ -185,9 +185,23 @@ export default () => {
}); });
} }
if (multiSelect) {
boardsStore.toggleMultiSelect(newIssue);
if (boardsStore.detail.issue) {
boardsStore.clearDetailIssue();
return;
}
return;
}
boardsStore.setIssueDetail(newIssue); boardsStore.setIssueDetail(newIssue);
}, },
clearDetailIssue() { clearDetailIssue(multiSelect = false) {
if (multiSelect) {
boardsStore.clearMultiSelect();
}
boardsStore.clearDetailIssue(); boardsStore.clearDetailIssue();
}, },
toggleSubscription(id) { toggleSubscription(id) {
......
...@@ -5,6 +5,7 @@ import ListLabel from './label'; ...@@ -5,6 +5,7 @@ import ListLabel from './label';
import ListAssignee from './assignee'; import ListAssignee from './assignee';
import ListIssue from 'ee_else_ce/boards/models/issue'; import ListIssue from 'ee_else_ce/boards/models/issue';
import { urlParamsToObject } from '~/lib/utils/common_utils'; import { urlParamsToObject } from '~/lib/utils/common_utils';
import flash from '~/flash';
import boardsStore from '../stores/boards_store'; import boardsStore from '../stores/boards_store';
import ListMilestone from './milestone'; import ListMilestone from './milestone';
...@@ -176,6 +177,53 @@ class List { ...@@ -176,6 +177,53 @@ class List {
}); });
} }
addMultipleIssues(issues, listFrom, newIndex) {
let moveBeforeId = null;
let moveAfterId = null;
const listHasIssues = issues.every(issue => this.findIssue(issue.id));
if (!listHasIssues) {
if (newIndex !== undefined) {
if (this.issues[newIndex - 1]) {
moveBeforeId = this.issues[newIndex - 1].id;
}
if (this.issues[newIndex]) {
moveAfterId = this.issues[newIndex].id;
}
this.issues.splice(newIndex, 0, ...issues);
} else {
this.issues.push(...issues);
}
if (this.label) {
issues.forEach(issue => issue.addLabel(this.label));
}
if (this.assignee) {
if (listFrom && listFrom.type === 'assignee') {
issues.forEach(issue => issue.removeAssignee(listFrom.assignee));
}
issues.forEach(issue => issue.addAssignee(this.assignee));
}
if (IS_EE && this.milestone) {
if (listFrom && listFrom.type === 'milestone') {
issues.forEach(issue => issue.removeMilestone(listFrom.milestone));
}
issues.forEach(issue => issue.addMilestone(this.milestone));
}
if (listFrom) {
this.issuesSize += issues.length;
this.updateMultipleIssues(issues, listFrom, moveBeforeId, moveAfterId);
}
}
}
addIssue(issue, listFrom, newIndex) { addIssue(issue, listFrom, newIndex) {
let moveBeforeId = null; let moveBeforeId = null;
let moveAfterId = null; let moveAfterId = null;
...@@ -230,6 +278,23 @@ class List { ...@@ -230,6 +278,23 @@ class List {
}); });
} }
moveMultipleIssues({ issues, oldIndicies, newIndex, moveBeforeId, moveAfterId }) {
oldIndicies.reverse().forEach(index => {
this.issues.splice(index, 1);
});
this.issues.splice(newIndex, 0, ...issues);
gl.boardService
.moveMultipleIssues({
ids: issues.map(issue => issue.id),
fromListId: null,
toListId: null,
moveBeforeId,
moveAfterId,
})
.catch(() => flash(__('Something went wrong while moving issues.')));
}
updateIssueLabel(issue, listFrom, moveBeforeId, moveAfterId) { updateIssueLabel(issue, listFrom, moveBeforeId, moveAfterId) {
gl.boardService gl.boardService
.moveIssue(issue.id, listFrom.id, this.id, moveBeforeId, moveAfterId) .moveIssue(issue.id, listFrom.id, this.id, moveBeforeId, moveAfterId)
...@@ -238,10 +303,37 @@ class List { ...@@ -238,10 +303,37 @@ class List {
}); });
} }
updateMultipleIssues(issues, listFrom, moveBeforeId, moveAfterId) {
gl.boardService
.moveMultipleIssues({
ids: issues.map(issue => issue.id),
fromListId: listFrom.id,
toListId: this.id,
moveBeforeId,
moveAfterId,
})
.catch(() => flash(__('Something went wrong while moving issues.')));
}
findIssue(id) { findIssue(id) {
return this.issues.find(issue => issue.id === id); return this.issues.find(issue => issue.id === id);
} }
removeMultipleIssues(removeIssues) {
const ids = removeIssues.map(issue => issue.id);
this.issues = this.issues.filter(issue => {
const matchesRemove = ids.includes(issue.id);
if (matchesRemove) {
this.issuesSize -= 1;
issue.removeLabel(this.label);
}
return !matchesRemove;
});
}
removeIssue(removeIssue) { removeIssue(removeIssue) {
this.issues = this.issues.filter(issue => { this.issues = this.issues.filter(issue => {
const matchesRemove = removeIssue.id === issue.id; const matchesRemove = removeIssue.id === issue.id;
......
...@@ -48,6 +48,16 @@ export default class BoardService { ...@@ -48,6 +48,16 @@ export default class BoardService {
return boardsStore.moveIssue(id, fromListId, toListId, moveBeforeId, moveAfterId); return boardsStore.moveIssue(id, fromListId, toListId, moveBeforeId, moveAfterId);
} }
moveMultipleIssues({
ids,
fromListId = null,
toListId = null,
moveBeforeId = null,
moveAfterId = null,
}) {
return boardsStore.moveMultipleIssues({ ids, fromListId, toListId, moveBeforeId, moveAfterId });
}
newIssue(id, issue) { newIssue(id, issue) {
return boardsStore.newIssue(id, issue); return boardsStore.newIssue(id, issue);
} }
......
...@@ -11,6 +11,7 @@ import { __ } from '~/locale'; ...@@ -11,6 +11,7 @@ import { __ } from '~/locale';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { mergeUrlParams } from '~/lib/utils/url_utility'; import { mergeUrlParams } from '~/lib/utils/url_utility';
import eventHub from '../eventhub'; import eventHub from '../eventhub';
import { ListType } from '../constants';
const boardsStore = { const boardsStore = {
disabled: false, disabled: false,
...@@ -39,6 +40,7 @@ const boardsStore = { ...@@ -39,6 +40,7 @@ const boardsStore = {
issue: {}, issue: {},
list: {}, list: {},
}, },
multiSelect: { list: [] },
setEndpoints({ boardsEndpoint, listsEndpoint, bulkUpdatePath, boardId, recentBoardsEndpoint }) { setEndpoints({ boardsEndpoint, listsEndpoint, bulkUpdatePath, boardId, recentBoardsEndpoint }) {
const listsEndpointGenerate = `${listsEndpoint}/generate.json`; const listsEndpointGenerate = `${listsEndpoint}/generate.json`;
...@@ -51,7 +53,6 @@ const boardsStore = { ...@@ -51,7 +53,6 @@ const boardsStore = {
recentBoardsEndpoint: `${recentBoardsEndpoint}.json`, recentBoardsEndpoint: `${recentBoardsEndpoint}.json`,
}; };
}, },
create() { create() {
this.state.lists = []; this.state.lists = [];
this.filter.path = getUrlParamsArray().join('&'); this.filter.path = getUrlParamsArray().join('&');
...@@ -134,6 +135,107 @@ const boardsStore = { ...@@ -134,6 +135,107 @@ const boardsStore = {
Object.assign(this.moving, { list, issue }); Object.assign(this.moving, { list, issue });
}, },
moveMultipleIssuesToList({ listFrom, listTo, issues, newIndex }) {
const issueTo = issues.map(issue => listTo.findIssue(issue.id));
const issueLists = _.flatten(issues.map(issue => issue.getLists()));
const listLabels = issueLists.map(list => list.label);
const hasMoveableIssues = _.compact(issueTo).length > 0;
if (!hasMoveableIssues) {
// Check if target list assignee is already present in this issue
if (
listTo.type === ListType.assignee &&
listFrom.type === ListType.assignee &&
issues.some(issue => issue.findAssignee(listTo.assignee))
) {
const targetIssues = issues.map(issue => listTo.findIssue(issue.id));
targetIssues.forEach(targetIssue => targetIssue.removeAssignee(listFrom.assignee));
} else if (listTo.type === 'milestone') {
const currentMilestones = issues.map(issue => issue.milestone);
const currentLists = this.state.lists
.filter(list => list.type === 'milestone' && list.id !== listTo.id)
.filter(list =>
list.issues.some(listIssue => issues.some(issue => listIssue.id === issue.id)),
);
issues.forEach(issue => {
currentMilestones.forEach(milestone => {
issue.removeMilestone(milestone);
});
});
issues.forEach(issue => {
issue.addMilestone(listTo.milestone);
});
currentLists.forEach(currentList => {
issues.forEach(issue => {
currentList.removeIssue(issue);
});
});
listTo.addMultipleIssues(issues, listFrom, newIndex);
} else {
// Add to new lists issues if it doesn't already exist
listTo.addMultipleIssues(issues, listFrom, newIndex);
}
} else {
listTo.updateMultipleIssues(issues, listFrom);
issues.forEach(issue => {
issue.removeLabel(listFrom.label);
});
}
if (listTo.type === ListType.closed && listFrom.type !== ListType.backlog) {
issueLists.forEach(list => {
issues.forEach(issue => {
list.removeIssue(issue);
});
});
issues.forEach(issue => {
issue.removeLabels(listLabels);
});
} else if (listTo.type === ListType.backlog && listFrom.type === ListType.assignee) {
issues.forEach(issue => {
issue.removeAssignee(listFrom.assignee);
});
issueLists.forEach(list => {
issues.forEach(issue => {
list.removeIssue(issue);
});
});
} else if (listTo.type === ListType.backlog && listFrom.type === ListType.milestone) {
issues.forEach(issue => {
issue.removeMilestone(listFrom.milestone);
});
issueLists.forEach(list => {
issues.forEach(issue => {
list.removeIssue(issue);
});
});
} else if (
this.shouldRemoveIssue(listFrom, listTo) &&
this.issuesAreContiguous(listFrom, issues)
) {
listFrom.removeMultipleIssues(issues);
}
},
issuesAreContiguous(list, issues) {
// When there's only 1 issue selected, we can return early.
if (issues.length === 1) return true;
// Create list of ids for issues involved.
const listIssueIds = list.issues.map(issue => issue.id);
const movedIssueIds = issues.map(issue => issue.id);
// Check if moved issue IDs is sub-array
// of source list issue IDs (i.e. contiguous selection).
return listIssueIds.join('|').includes(movedIssueIds.join('|'));
},
moveIssueToList(listFrom, listTo, issue, newIndex) { moveIssueToList(listFrom, listTo, issue, newIndex) {
const issueTo = listTo.findIssue(issue.id); const issueTo = listTo.findIssue(issue.id);
const issueLists = issue.getLists(); const issueLists = issue.getLists();
...@@ -195,6 +297,17 @@ const boardsStore = { ...@@ -195,6 +297,17 @@ const boardsStore = {
list.moveIssue(issue, oldIndex, newIndex, beforeId, afterId); list.moveIssue(issue, oldIndex, newIndex, beforeId, afterId);
}, },
moveMultipleIssuesInList({ list, issues, oldIndicies, newIndex, idArray }) {
const beforeId = parseInt(idArray[newIndex - 1], 10) || null;
const afterId = parseInt(idArray[newIndex + issues.length], 10) || null;
list.moveMultipleIssues({
issues,
oldIndicies,
newIndex,
moveBeforeId: beforeId,
moveAfterId: afterId,
});
},
findList(key, val, type = 'label') { findList(key, val, type = 'label') {
const filteredList = this.state.lists.filter(list => { const filteredList = this.state.lists.filter(list => {
const byType = type const byType = type
...@@ -260,6 +373,10 @@ const boardsStore = { ...@@ -260,6 +373,10 @@ const boardsStore = {
}`; }`;
}, },
generateMultiDragPath(boardId) {
return `${gon.relative_url_root}/-/boards/${boardId ? `${boardId}` : ''}/issues/bulk_move`;
},
all() { all() {
return axios.get(this.state.endpoints.listsEndpoint); return axios.get(this.state.endpoints.listsEndpoint);
}, },
...@@ -309,6 +426,16 @@ const boardsStore = { ...@@ -309,6 +426,16 @@ const boardsStore = {
}); });
}, },
moveMultipleIssues({ ids, fromListId, toListId, moveBeforeId, moveAfterId }) {
return axios.put(this.generateMultiDragPath(this.state.endpoints.boardId), {
from_list_id: fromListId,
to_list_id: toListId,
move_before_id: moveBeforeId,
move_after_id: moveAfterId,
ids,
});
},
newIssue(id, issue) { newIssue(id, issue) {
return axios.post(this.generateIssuesPath(id), { return axios.post(this.generateIssuesPath(id), {
issue, issue,
...@@ -379,6 +506,25 @@ const boardsStore = { ...@@ -379,6 +506,25 @@ const boardsStore = {
setCurrentBoard(board) { setCurrentBoard(board) {
this.state.currentBoard = board; this.state.currentBoard = board;
}, },
toggleMultiSelect(issue) {
const selectedIssueIds = this.multiSelect.list.map(issue => issue.id);
const index = selectedIssueIds.indexOf(issue.id);
if (index === -1) {
this.multiSelect.list.push(issue);
return;
}
this.multiSelect.list = [
...this.multiSelect.list.slice(0, index),
...this.multiSelect.list.slice(index + 1),
];
},
clearMultiSelect() {
this.multiSelect.list = [];
},
}; };
BoardsStoreEE.initEESpecific(boardsStore); BoardsStoreEE.initEESpecific(boardsStore);
......
import 'core-js/es/map'; import 'core-js/es/map';
import 'core-js/es/set'; import 'core-js/es/set';
import { Sortable } from 'sortablejs';
import simulateDrag from './simulate_drag'; import simulateDrag from './simulate_drag';
import simulateInput from './simulate_input'; import simulateInput from './simulate_input';
// Export to global space for rspec to use // Export to global space for rspec to use
window.simulateDrag = simulateDrag; window.simulateDrag = simulateDrag;
window.simulateInput = simulateInput; window.simulateInput = simulateInput;
window.Sortable = Sortable;
...@@ -245,6 +245,7 @@ ...@@ -245,6 +245,7 @@
box-shadow: 0 1px 2px $issue-boards-card-shadow; box-shadow: 0 1px 2px $issue-boards-card-shadow;
line-height: $gl-padding; line-height: $gl-padding;
list-style: none; list-style: none;
position: relative;
&:not(:last-child) { &:not(:last-child) {
margin-bottom: $gl-padding-8; margin-bottom: $gl-padding-8;
...@@ -255,6 +256,11 @@ ...@@ -255,6 +256,11 @@
background-color: $blue-50; background-color: $blue-50;
} }
&.multi-select {
border-color: $blue-200;
background-color: $blue-50;
}
.badge { .badge {
border: 0; border: 0;
outline: 0; outline: 0;
......
...@@ -5,6 +5,9 @@ class Groups::BoardsController < Groups::ApplicationController ...@@ -5,6 +5,9 @@ class Groups::BoardsController < Groups::ApplicationController
include RecordUserLastActivity include RecordUserLastActivity
before_action :assign_endpoint_vars before_action :assign_endpoint_vars
before_action do
push_frontend_feature_flag(:multi_select_board)
end
private private
......
...@@ -7,6 +7,9 @@ class Projects::BoardsController < Projects::ApplicationController ...@@ -7,6 +7,9 @@ class Projects::BoardsController < Projects::ApplicationController
before_action :check_issues_available! before_action :check_issues_available!
before_action :authorize_read_board!, only: [:index, :show] before_action :authorize_read_board!, only: [:index, :show]
before_action :assign_endpoint_vars before_action :assign_endpoint_vars
before_action do
push_frontend_feature_flag(:multi_select_board)
end
private private
......
...@@ -2258,6 +2258,16 @@ class Project < ApplicationRecord ...@@ -2258,6 +2258,16 @@ class Project < ApplicationRecord
setting setting
end end
def drop_visibility_level!
if group && group.visibility_level < visibility_level
self.visibility_level = group.visibility_level
end
if Gitlab::CurrentSettings.restricted_visibility_levels.include?(visibility_level)
self.visibility_level = Gitlab::VisibilityLevel::PRIVATE
end
end
private private
def closest_namespace_setting(name) def closest_namespace_setting(name)
......
...@@ -64,7 +64,7 @@ class JiraService < IssueTrackerService ...@@ -64,7 +64,7 @@ class JiraService < IssueTrackerService
url = URI.parse(client_url) url = URI.parse(client_url)
{ {
username: username, username: username&.strip,
password: password, password: password,
site: URI.join(url, '/').to_s, # Intended to find the root site: URI.join(url, '/').to_s, # Intended to find the root
context_path: url.path, context_path: url.path,
......
---
title: 'JIRA Service: Improve username/email validation'
merge_request: 18397
author:
type: fixed
...@@ -26,10 +26,11 @@ on those issues. Please select someone with relevant experience from the ...@@ -26,10 +26,11 @@ on those issues. Please select someone with relevant experience from the
If there is nobody mentioned with that expertise look in the commit history for If there is nobody mentioned with that expertise look in the commit history for
the affected files to find someone. the affected files to find someone.
We also use [GitLab Triage](https://gitlab.com/gitlab-org/gitlab-triage) to We also use [GitLab Triage](https://gitlab.com/gitlab-org/gitlab-triage) to automate
automate some triaging policies. This is currently set up as a some triaging policies. This is currently set up as a scheduled pipeline
[scheduled pipeline](https://gitlab.com/gitlab-org/quality/triage-ops/pipeline_schedules/10512/edit) (`https://gitlab.com/gitlab-org/quality/triage-ops/pipeline_schedules/10512/editpipeline_schedules/10512/edit`,
running on [quality/triage-ops](https://gitlab.com/gitlab-org/quality/triage-ops) project. must have at least developer access to the project) running on [quality/triage-ops](https://gitlab.com/gitlab-org/quality/triage-ops)
project.
## Labels ## Labels
......
...@@ -81,7 +81,7 @@ You can improve the existing built-in templates or contribute new ones in the ...@@ -81,7 +81,7 @@ You can improve the existing built-in templates or contribute new ones in the
#### Custom project templates **(PREMIUM)** #### Custom project templates **(PREMIUM)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/6860) in > [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/6860) in
[GitLab Premium](https://about.gitlab.com/pricing) 11.2. [GitLab Premium](https://about.gitlab.com/pricing/) 11.2.
Creating new projects based on custom project templates is a convenient option for Creating new projects based on custom project templates is a convenient option for
quickly starting projects. quickly starting projects.
......
...@@ -13,9 +13,9 @@ make sure that you have created and/or signed into an account on GitLab. ...@@ -13,9 +13,9 @@ make sure that you have created and/or signed into an account on GitLab.
Depending on your operating system, you will need to use a shell of your preference. Depending on your operating system, you will need to use a shell of your preference.
Here are some suggestions: Here are some suggestions:
- [Terminal](http://blog.teamtreehouse.com/introduction-to-the-mac-os-x-command-line) on macOS - [Terminal](https://blog.teamtreehouse.com/introduction-to-the-mac-os-x-command-line) on macOS
- [GitBash](https://msysgit.github.io) on Windows - [GitBash](https://msysgit.github.io) on Windows
- [Linux Terminal](http://www.howtogeek.com/140679/beginner-geek-how-to-start-using-the-linux-terminal/) on Linux - [Linux Terminal](https://www.howtogeek.com/140679/beginner-geek-how-to-start-using-the-linux-terminal/) on Linux
## Check if Git has already been installed ## Check if Git has already been installed
......
...@@ -38,7 +38,7 @@ through the macOS App Store. ...@@ -38,7 +38,7 @@ through the macOS App Store.
### Installing Homebrew ### Installing Homebrew
Once Xcode is installed browse to the [Homebrew website](http://brew.sh/index.html) Once Xcode is installed browse to the [Homebrew website](https://brew.sh/index.html)
for the official Homebrew installation instructions. for the official Homebrew installation instructions.
### Installing Git via Homebrew ### Installing Git via Homebrew
......
...@@ -26,7 +26,7 @@ The GitLab University curriculum is composed of GitLab videos, screencasts, pres ...@@ -26,7 +26,7 @@ The GitLab University curriculum is composed of GitLab videos, screencasts, pres
### 1.1. Version Control and Git ### 1.1. Version Control and Git
1. [Version Control Systems](https://docs.google.com/presentation/d/16sX7hUrCZyOFbpvnrAFrg6tVO5_yT98IgdAqOmXwBho/edit#slide=id.g72f2e4906_2_29) 1. [Version Control Systems](https://docs.google.com/presentation/d/16sX7hUrCZyOFbpvnrAFrg6tVO5_yT98IgdAqOmXwBho/edit#slide=id.g72f2e4906_2_29)
1. [Code School: An Introduction to Git](https://www.codeschool.com/account/courses/try-git) 1. [Katakoda: Learn Git Version Control using Interactive Browser-Based Scenarios](https://www.katacoda.com/courses/git)
### 1.2. GitLab Basics ### 1.2. GitLab Basics
...@@ -34,12 +34,12 @@ The GitLab University curriculum is composed of GitLab videos, screencasts, pres ...@@ -34,12 +34,12 @@ The GitLab University curriculum is composed of GitLab videos, screencasts, pres
1. [Why Use Git and GitLab - Slides](https://docs.google.com/a/gitlab.com/presentation/d/1RcZhFmn5VPvoFu6UMxhMOy7lAsToeBZRjLRn0LIdaNc/edit?usp=drive_web) 1. [Why Use Git and GitLab - Slides](https://docs.google.com/a/gitlab.com/presentation/d/1RcZhFmn5VPvoFu6UMxhMOy7lAsToeBZRjLRn0LIdaNc/edit?usp=drive_web)
1. [GitLab Basics - Article](../gitlab-basics/README.md) 1. [GitLab Basics - Article](../gitlab-basics/README.md)
1. [Git and GitLab Basics - Video](https://www.youtube.com/watch?v=03wb9FvO4Ak&index=5&list=PLFGfElNsQthbQu_IWlNOxul0TbS_2JH-e) 1. [Git and GitLab Basics - Video](https://www.youtube.com/watch?v=03wb9FvO4Ak&index=5&list=PLFGfElNsQthbQu_IWlNOxul0TbS_2JH-e)
1. [Git and GitLab Basics - Online Course](https://courses.platzi.com/classes/git-gitlab/concepto/part-1/part-23370/material/) 1. [Git and GitLab Basics - Online Course](https://courses.platzi.com/classes/57-git-gitlab/2475-part-233-2/)
1. [Comparison of GitLab Versions](https://about.gitlab.com/features/#compare) 1. [Comparison of GitLab Versions](https://about.gitlab.com/features/#compare)
### 1.3. Your GitLab Account ### 1.3. Your GitLab Account
1. [Create a GitLab Account - Online Course](https://courses.platzi.com/classes/git-gitlab/concepto/first-steps/create-an-account-on-gitlab/material/) 1. [Create a GitLab Account - Online Course](https://courses.platzi.com/classes/57-git-gitlab/2434-create-an-account-on-gitlab/)
1. [Create and Add your SSH key to GitLab - Video](https://www.youtube.com/watch?v=54mxyLo3Mqk) 1. [Create and Add your SSH key to GitLab - Video](https://www.youtube.com/watch?v=54mxyLo3Mqk)
### 1.4. GitLab Projects ### 1.4. GitLab Projects
...@@ -59,7 +59,7 @@ The GitLab University curriculum is composed of GitLab videos, screencasts, pres ...@@ -59,7 +59,7 @@ The GitLab University curriculum is composed of GitLab videos, screencasts, pres
### 1.6. The GitLab team ### 1.6. The GitLab team
1. [About GitLab](https://about.gitlab.com/about/) 1. [About GitLab](https://about.gitlab.com/company/)
1. [GitLab Direction](https://about.gitlab.com/direction/) 1. [GitLab Direction](https://about.gitlab.com/direction/)
1. [GitLab Master Plan](https://about.gitlab.com/2016/09/13/gitlab-master-plan/) 1. [GitLab Master Plan](https://about.gitlab.com/2016/09/13/gitlab-master-plan/)
1. [Making GitLab Great for Everyone - Video](https://www.youtube.com/watch?v=GGC40y4vMx0) - Response to "Dear GitHub" letter 1. [Making GitLab Great for Everyone - Video](https://www.youtube.com/watch?v=GGC40y4vMx0) - Response to "Dear GitHub" letter
...@@ -70,7 +70,7 @@ The GitLab University curriculum is composed of GitLab videos, screencasts, pres ...@@ -70,7 +70,7 @@ The GitLab University curriculum is composed of GitLab videos, screencasts, pres
### 1.7 Community and Support ### 1.7 Community and Support
1. [Getting Help](https://about.gitlab.com/getting-help/) 1. [Getting Help](https://about.gitlab.com/get-help/)
- Proposing Features and Reporting and Tracking bugs for GitLab - Proposing Features and Reporting and Tracking bugs for GitLab
- The GitLab IRC channel, Gitter Chat Room, Community Forum and Mailing List - The GitLab IRC channel, Gitter Chat Room, Community Forum and Mailing List
- Getting Technical Support - Getting Technical Support
...@@ -107,7 +107,7 @@ The GitLab University curriculum is composed of GitLab videos, screencasts, pres ...@@ -107,7 +107,7 @@ The GitLab University curriculum is composed of GitLab videos, screencasts, pres
### 2.3. Continuous Integration ### 2.3. Continuous Integration
1. [Operating Systems, Servers, VMs, Containers and Unix - Video](https://www.youtube.com/watch?v=V61kL6IC-zY&index=8&list=PLFGfElNsQthbQu_IWlNOxul0TbS_2JH-e) 1. [Operating Systems, Servers, VMs, Containers and Unix - Video](https://www.youtube.com/watch?v=V61kL6IC-zY&index=8&list=PLFGfElNsQthbQu_IWlNOxul0TbS_2JH-e)
1. [GitLab CI - Product Page](https://about.gitlab.com/gitlab-ci/) 1. [GitLab CI - Product Page](https://about.gitlab.com/product/continuous-integration/)
1. [Getting started with GitLab and GitLab CI](https://about.gitlab.com/2015/12/14/getting-started-with-gitlab-and-gitlab-ci/) 1. [Getting started with GitLab and GitLab CI](https://about.gitlab.com/2015/12/14/getting-started-with-gitlab-and-gitlab-ci/)
1. [GitLab Container Registry](https://about.gitlab.com/2016/05/23/gitlab-container-registry/) 1. [GitLab Container Registry](https://about.gitlab.com/2016/05/23/gitlab-container-registry/)
1. [GitLab and Docker - Video](https://www.youtube.com/watch?v=ugOrCcbdHko&index=12&list=PLFGfElNsQthbQu_IWlNOxul0TbS_2JH-e) 1. [GitLab and Docker - Video](https://www.youtube.com/watch?v=ugOrCcbdHko&index=12&list=PLFGfElNsQthbQu_IWlNOxul0TbS_2JH-e)
...@@ -120,7 +120,7 @@ The GitLab University curriculum is composed of GitLab videos, screencasts, pres ...@@ -120,7 +120,7 @@ The GitLab University curriculum is composed of GitLab videos, screencasts, pres
1. [Setting up GitLab CI for iOS projects](https://about.gitlab.com/2016/03/10/setting-up-gitlab-ci-for-ios-projects/) 1. [Setting up GitLab CI for iOS projects](https://about.gitlab.com/2016/03/10/setting-up-gitlab-ci-for-ios-projects/)
1. [IBM: Continuous Delivery vs Continuous Deployment - Video](https://www.youtube.com/watch?v=igwFj8PPSnw) 1. [IBM: Continuous Delivery vs Continuous Deployment - Video](https://www.youtube.com/watch?v=igwFj8PPSnw)
1. [Amazon: Transition to Continuous Delivery - Video](https://www.youtube.com/watch?v=esEFaY0FDKc) 1. [Amazon: Transition to Continuous Delivery - Video](https://www.youtube.com/watch?v=esEFaY0FDKc)
1. [TechBeacon: Doing continuous delivery? Focus first on reducing release cycle times](https://techbeacon.com/doing-continuous-delivery-focus-first-reducing-release-cycle-times) 1. [TechBeacon: Doing continuous delivery? Focus first on reducing release cycle times](https://techbeacon.com/devops/doing-continuous-delivery-focus-first-reducing-release-cycle-times)
1. See **[Integrations](#39-integrations)** for integrations with other CI services. 1. See **[Integrations](#39-integrations)** for integrations with other CI services.
### 2.4. Workflow ### 2.4. Workflow
...@@ -133,11 +133,11 @@ The GitLab University curriculum is composed of GitLab videos, screencasts, pres ...@@ -133,11 +133,11 @@ The GitLab University curriculum is composed of GitLab videos, screencasts, pres
### 2.5. GitLab Comparisons ### 2.5. GitLab Comparisons
1. [GitLab Compared to Other Tools](https://about.gitlab.com/comparison/) 1. [GitLab Compared to Other Tools](https://about.gitlab.com/devops-tools/)
1. [Comparing GitLab Terminology](https://about.gitlab.com/2016/01/27/comparing-terms-gitlab-github-bitbucket/) 1. [Comparing GitLab Terminology](https://about.gitlab.com/2016/01/27/comparing-terms-gitlab-github-bitbucket/)
1. [GitLab Compared to Atlassian (Recording 2016-03-03)](https://youtu.be/Nbzp1t45ERo) 1. [GitLab Compared to Atlassian (Recording 2016-03-03)](https://youtu.be/Nbzp1t45ERo)
1. [GitLab Position FAQ](https://about.gitlab.com/handbook/positioning-faq) 1. [GitLab Position FAQ](https://about.gitlab.com/handbook/positioning-faq/)
1. [Customer review of GitLab with points on why they prefer GitLab](https://www.enovate.co.uk/web-design-blog/2015/11/25/gitlab-review/) 1. [Customer review of GitLab with points on why they prefer GitLab](https://www.enovate.co.uk/blog/2015/11/25/gitlab-review)
## 3. GitLab Advanced ## 3. GitLab Advanced
...@@ -145,13 +145,13 @@ The GitLab University curriculum is composed of GitLab videos, screencasts, pres ...@@ -145,13 +145,13 @@ The GitLab University curriculum is composed of GitLab videos, screencasts, pres
1. [Xebia Labs: Dev Ops Terminology](https://xebialabs.com/glossary/) 1. [Xebia Labs: Dev Ops Terminology](https://xebialabs.com/glossary/)
1. [Xebia Labs: Periodic Table of DevOps Tools](https://xebialabs.com/periodic-table-of-devops-tools/) 1. [Xebia Labs: Periodic Table of DevOps Tools](https://xebialabs.com/periodic-table-of-devops-tools/)
1. [Puppet Labs: State of Dev Ops 2016 - Book](https://puppet.com/resources/white-paper/2016-state-of-devops-report) 1. [Puppet Labs: State of Dev Ops 2016 - Book](https://puppet.com/resources/whitepaper/2016-state-of-devops-report)
### 3.2. Installing GitLab with Omnibus ### 3.2. Installing GitLab with Omnibus
1. [What is Omnibus - Video](https://www.youtube.com/watch?v=XTmpKudd-Oo) 1. [What is Omnibus - Video](https://www.youtube.com/watch?v=XTmpKudd-Oo)
1. [How to Install GitLab with Omnibus - Video](https://www.youtube.com/watch?v=Q69YaOjqNhg) 1. [How to Install GitLab with Omnibus - Video](https://www.youtube.com/watch?v=Q69YaOjqNhg)
1. [Installing GitLab - Online Course](https://courses.platzi.com/classes/git-gitlab/concepto/part-1/part-3/material/) 1. [Installing GitLab - Online Course](https://courses.platzi.com/classes/57-git-gitlab/2476-part-0/)
1. [Using a Non-Packaged PostgreSQL Database](https://gitlab.com/gitlab-org/omnibus-gitlab/blob/master/README.md#using-a-non-packaged-postgresql-database-management-server) 1. [Using a Non-Packaged PostgreSQL Database](https://gitlab.com/gitlab-org/omnibus-gitlab/blob/master/README.md#using-a-non-packaged-postgresql-database-management-server)
1. [Installing GitLab on Microsoft Azure](https://about.gitlab.com/2016/07/13/how-to-setup-a-gitlab-instance-on-microsoft-azure/) 1. [Installing GitLab on Microsoft Azure](https://about.gitlab.com/2016/07/13/how-to-setup-a-gitlab-instance-on-microsoft-azure/)
1. [Installing GitLab on Digital Ocean](https://about.gitlab.com/2016/04/27/getting-started-with-gitlab-and-digitalocean/) 1. [Installing GitLab on Digital Ocean](https://about.gitlab.com/2016/04/27/getting-started-with-gitlab-and-digitalocean/)
...@@ -176,7 +176,7 @@ The GitLab University curriculum is composed of GitLab videos, screencasts, pres ...@@ -176,7 +176,7 @@ The GitLab University curriculum is composed of GitLab videos, screencasts, pres
1. [Scalability and High Availability - Video](https://www.youtube.com/watch?v=cXRMJJb6sp4&list=PLFGfElNsQthbQu_IWlNOxul0TbS_2JH-e&index=2) 1. [Scalability and High Availability - Video](https://www.youtube.com/watch?v=cXRMJJb6sp4&list=PLFGfElNsQthbQu_IWlNOxul0TbS_2JH-e&index=2)
1. [High Availability - Video](https://www.youtube.com/watch?v=36KS808u6bE&index=15&list=PLFGfElNsQthbQu_IWlNOxul0TbS_2JH-e) 1. [High Availability - Video](https://www.youtube.com/watch?v=36KS808u6bE&index=15&list=PLFGfElNsQthbQu_IWlNOxul0TbS_2JH-e)
1. [High Availability Documentation](https://about.gitlab.com/high-availability/) 1. [High Availability Documentation](https://about.gitlab.com/solutions/high-availability/)
### 3.8 Cycle Analytics ### 3.8 Cycle Analytics
...@@ -205,7 +205,7 @@ NOTE: **Note:** ...@@ -205,7 +205,7 @@ NOTE: **Note:**
Some content can only be accessed by GitLab team members. Some content can only be accessed by GitLab team members.
1. [Support Path](support/README.md) 1. [Support Path](support/README.md)
1. [Sales Path](https://about.gitlab.com/handbook/sales-onboarding/) 1. [Sales Path](https://about.gitlab.com/handbook/sales/onboarding/)
1. [User Training](training/user_training.md) 1. [User Training](training/user_training.md)
1. [GitLab Flow Training](training/gitlab_flow.md) 1. [GitLab Flow Training](training/gitlab_flow.md)
1. [Training Topics](training/index.md) 1. [Training Topics](training/index.md)
......
...@@ -15,10 +15,10 @@ See the [book list](booklist.md) for additional recommendations. ...@@ -15,10 +15,10 @@ See the [book list](booklist.md) for additional recommendations.
1. **Remote: Office not required** 1. **Remote: Office not required**
David Heinemeier Hansson and Jason Fried, 2013 David Heinemeier Hansson and Jason Fried, 2013
([amazon](http://www.amazon.co.uk/Remote-Required-David-Heinemeier-Hansson/dp/0091954673)) ([amazon](https://www.amazon.co.uk/Remote-Required-David-Heinemeier-Hansson/dp/0091954673))
1. **The Year Without Pants** 1. **The Year Without Pants**
Scott Berkun, 2013 ([ScottBerkun.com](http://scottberkun.com/yearwithoutpants/)) Scott Berkun, 2013 ([ScottBerkun.com](https://scottberkun.com/yearwithoutpants/))
Any other books you'd like to suggest? Edit this page and add them to the queue. Any other books you'd like to suggest? Edit this page and add them to the queue.
...@@ -21,8 +21,8 @@ please submit a merge request to add an upcoming class, assign to ...@@ -21,8 +21,8 @@ please submit a merge request to add an upcoming class, assign to
people, only interns, only customers, etc.). people, only interns, only customers, etc.).
1. To allow people to contribute all content should be in Git. 1. To allow people to contribute all content should be in Git.
1. The content can go in a subdirectory under `/doc/university/`. 1. The content can go in a subdirectory under `/doc/university/`.
1. To make, view or modify the slides of the classes use [Deckset](http://www.decksetapp.com/) 1. To make, view or modify the slides of the classes use [Deckset](https://www.deckset.com)
or [RevealJS](http://lab.hakim.se/reveal-js/). Do not use Powerpoint or Google or [RevealJS](https://revealjs.com/#/). Do not use Powerpoint or Google
Slides since this prevents everyone from contributing. Slides since this prevents everyone from contributing.
1. Please upload any video recordings to our Youtube channel. We prefer them to 1. Please upload any video recordings to our Youtube channel. We prefer them to
be public, if needed they can be unlisted but if so they should be linked from be public, if needed they can be unlisted but if so they should be linked from
......
...@@ -70,7 +70,7 @@ Sometimes we need to upgrade customers from old versions of GitLab to latest, so ...@@ -70,7 +70,7 @@ Sometimes we need to upgrade customers from old versions of GitLab to latest, so
- [Perform the MySQL to PostgreSQL migration to convert your backup](../../update/mysql_to_postgresql.md) - [Perform the MySQL to PostgreSQL migration to convert your backup](../../update/mysql_to_postgresql.md)
- [Upgrade to Omnibus 7.14](https://docs.gitlab.com/omnibus/update/README.html#upgrading-from-a-non-omnibus-installation-to-an-omnibus-installation) - [Upgrade to Omnibus 7.14](https://docs.gitlab.com/omnibus/update/README.html#upgrading-from-a-non-omnibus-installation-to-an-omnibus-installation)
- [Restore backup using our Restore rake task](../../raketasks/backup_restore.md#restore) - [Restore backup using our Restore rake task](../../raketasks/backup_restore.md#restore)
- [Upgrade to latest EE](https://about.gitlab.com/downloads-ee) - [Upgrade to latest EE](https://about.gitlab.com/update/)
- (GitLab inc. only) Acquire and apply a license for the Enterprise Edition product, ask in #support - (GitLab inc. only) Acquire and apply a license for the Enterprise Edition product, ask in #support
- Perform a downgrade from [EE to CE](../../downgrade_ee_to_ce/README.md) - Perform a downgrade from [EE to CE](../../downgrade_ee_to_ce/README.md)
...@@ -147,12 +147,12 @@ Some tickets need specific knowledge or a deep understanding of a particular com ...@@ -147,12 +147,12 @@ Some tickets need specific knowledge or a deep understanding of a particular com
- Read about [Escalation](https://about.gitlab.com/handbook/support/workflows/issue_escalations.html) - Read about [Escalation](https://about.gitlab.com/handbook/support/workflows/issue_escalations.html)
- Find the macros in Zendesk for ticket escalations - Find the macros in Zendesk for ticket escalations
- Take a look at the [GitLab.com Team page](https://about.gitlab.com/team/) to find the resident experts in their fields - Take a look at the [GitLab.com Team page](https://about.gitlab.com/company/team/) to find the resident experts in their fields
### Learn about raising issues and fielding feature proposals ### Learn about raising issues and fielding feature proposals
- Understand what's in the pipeline and proposed features at GitLab: [Direction Page](https://about.gitlab.com/direction/) - Understand what's in the pipeline and proposed features at GitLab: [Direction Page](https://about.gitlab.com/direction/)
- Practice searching issues and filtering using [labels](https://gitlab.com/gitlab-org/gitlab-foss/labels) to find existing feature proposals and bugs - Practice searching issues and filtering using [labels](https://gitlab.com/gitlab-org/gitlab/-/labels) to find existing feature proposals and bugs
- If raising a new issue always provide a relevant label and a link to the relevant ticket in Zendesk - If raising a new issue always provide a relevant label and a link to the relevant ticket in Zendesk
- Add [customer labels](https://gitlab.com/gitlab-org/gitlab-foss/issues?label_name%5B%5D=customer) for those issues relevant to our subscribers - Add [customer labels](https://gitlab.com/gitlab-org/gitlab-foss/issues?label_name%5B%5D=customer) for those issues relevant to our subscribers
- Take a look at the [existing issue templates](https://gitlab.com/gitlab-org/gitlab/blob/master/CONTRIBUTING.md#issue-tracker) to see what is expected - Take a look at the [existing issue templates](https://gitlab.com/gitlab-org/gitlab/blob/master/CONTRIBUTING.md#issue-tracker) to see what is expected
......
...@@ -48,7 +48,7 @@ Workshop Time! ...@@ -48,7 +48,7 @@ Workshop Time!
### Setup ### Setup
- Windows: Install 'Git for Windows' - Windows: Install 'Git for Windows'
- <https://git-for-windows.github.io> - <https://gitforwindows.org>
- Mac: Type `git` in the Terminal application. - Mac: Type `git` in the Terminal application.
- If it's not installed, it will prompt you to install it. - If it's not installed, it will prompt you to install it.
- Linux - Linux
...@@ -253,7 +253,7 @@ git push origin conflicts_branch -f ...@@ -253,7 +253,7 @@ git push origin conflicts_branch -f
- When to use `git merge` and when to use `git rebase` - When to use `git merge` and when to use `git rebase`
- Rebase when updating your branch with master - Rebase when updating your branch with master
- Merge when bringing changes from feature to master - Merge when bringing changes from feature to master
- Reference: <https://www.atlassian.com/git/tutorials/merging-vs-rebasing/> - Reference: <https://www.atlassian.com/git/tutorials/merging-vs-rebasing>
## Revert and Unstage ## Revert and Unstage
......
...@@ -7,7 +7,7 @@ comments: false ...@@ -7,7 +7,7 @@ comments: false
## Install ## Install
- **Windows** - **Windows**
- Install 'Git for Windows' from <https://git-for-windows.github.io> - Install 'Git for Windows' from <https://gitforwindows.org>
- **Mac** - **Mac**
- Type '`git`' in the Terminal application. - Type '`git`' in the Terminal application.
......
...@@ -63,4 +63,4 @@ git push origin conflicts_branch -f ...@@ -63,4 +63,4 @@ git push origin conflicts_branch -f
- When to use `git merge` and when to use `git rebase` - When to use `git merge` and when to use `git rebase`
- Rebase when updating your branch with master - Rebase when updating your branch with master
- Merge when bringing changes from feature to master - Merge when bringing changes from feature to master
- Reference: <https://www.atlassian.com/git/tutorials/merging-vs-rebasing/> - Reference: <https://www.atlassian.com/git/tutorials/merging-vs-rebasing>
...@@ -39,7 +39,7 @@ Use the tools at your disposal when you get stuck. ...@@ -39,7 +39,7 @@ Use the tools at your disposal when you get stuck.
- Windows: Install 'Git for Windows' - Windows: Install 'Git for Windows'
> <https://git-for-windows.github.io> > <https://gitforwindows.org>
- Mac: Type '`git`' in the Terminal application. - Mac: Type '`git`' in the Terminal application.
...@@ -242,7 +242,7 @@ See GitLab merge requests for examples: <https://gitlab.com/gitlab-org/gitlab-fo ...@@ -242,7 +242,7 @@ See GitLab merge requests for examples: <https://gitlab.com/gitlab-org/gitlab-fo
1. Create an annotated tag. 1. Create an annotated tag.
1. Push the tags to the remote repository. 1. Push the tags to the remote repository.
Additional resources: <http://git-scm.com/book/en/Git-Basics-Tagging>. Additional resources: <https://git-scm.com/book/en/v2/Git-Basics-Tagging>.
## Commands (tags) ## Commands (tags)
......
...@@ -87,7 +87,7 @@ The results will be saved as a ...@@ -87,7 +87,7 @@ The results will be saved as a
that you can later download and analyze. that you can later download and analyze.
Due to implementation limitations, we always take the latest Container Scanning Due to implementation limitations, we always take the latest Container Scanning
artifact available. Behind the scenes, the artifact available. Behind the scenes, the
[GitLab Container Scanning analyzer](https://gitlab.com/gitlab-org/security-products/container-scanning) [GitLab Klar analyzer](https://gitlab.com/gitlab-org/security-products/analyzers/klar/)
is used and runs the scans. is used and runs the scans.
## Example ## Example
...@@ -145,6 +145,23 @@ container_scanning: ...@@ -145,6 +145,23 @@ container_scanning:
GIT_STRATEGY: fetch GIT_STRATEGY: fetch
``` ```
### Available variables
Container Scanning can be [configured](#overriding-the-container-scanning-template)
using environment variables.
| Environment Variable | Description | Default |
| ------ | ------ | ------ |
| `KLAR_TRACE` | Set to true to enable more verbose output from klar. | `"false"` |
| `DOCKER_USER` | Username for accessing a Docker registry requiring authentication. | `$CI_REGISTRY_USER` |
| `DOCKER_PASSWORD` | Password for accessing a Docker registry requiring authentication. | `$CI_REGISTRY_PASSWORD` |
| `CLAIR_OUTPUT` | Severity level threshold. Vulnerabilities with severity level higher than or equal to this threshold will be outputted. Supported levels are `Unknown`, `Negligible`, `Low`, `Medium`, `High`, `Critical` and `Defcon1`. | `Unknown` |
| `REGISTRY_INSECURE` | Allow [Klar](https://github.com/optiopay/klar) to access insecure registries (HTTP only). Should only be set to `true` when testing the image locally. | `"false"` |
| `CLAIR_VULNERABILITIES_DB_URL` | This variable is explicitly set in the [services section](https://gitlab.com/gitlab-org/gitlab/blob/30522ca8b901223ac8c32b633d8d67f340b159c1/lib/gitlab/ci/templates/Security/Container-Scanning.gitlab-ci.yml#L17-19) of the `Container-Scanning.gitlab-ci.yml` file and defaults to `clair-vulnerabilities-db`. This value represents the address that the [postgres server hosting the vulnerabilities definitions](https://hub.docker.com/r/arminc/clair-db) is running on and **shouldn't be changed** unless you're running the image locally as described in the [Running the scanning tool](https://gitlab.com/gitlab-org/security-products/analyzers/klar/#running-the-scanning-tool) section of the [klar readme](https://gitlab.com/gitlab-org/security-products/analyzers/klar). | `clair-vulnerabilities-db` |
| `CI_APPLICATION_REPOSITORY` | Docker repository URL for the image to be scanned. | `$CI_REGISTRY_IMAGE/$CI_COMMIT_REF_SLUG` |
| `CI_APPLICATION_TAG` | Docker respository tag for the image to be scanned. | `$CI_COMMIT_SHA` |
| `CLAIR_DB_IMAGE_TAG` | The Docker image tag for the [postgres server hosting the vulnerabilities definitions](https://hub.docker.com/r/arminc/clair-db). It can be useful to override this value with a specific version, for example, to provide a consistent set of vulnerabilities for integration testing purposes. | `latest` |
## Security Dashboard ## Security Dashboard
The Security Dashboard is a good place to get an overview of all the security The Security Dashboard is a good place to get an overview of all the security
......
...@@ -180,6 +180,18 @@ These are shortcuts to your last 4 visited boards. ...@@ -180,6 +180,18 @@ These are shortcuts to your last 4 visited boards.
When you're revisiting an issue board in a project or group with multiple boards, When you're revisiting an issue board in a project or group with multiple boards,
GitLab will automatically load the last board you visited. GitLab will automatically load the last board you visited.
### Multi-select Issue Cards
As the name suggest, multi-select issue cards allows more than one issue card
to be dragged and dropped across different lists. This becomes helpful while
moving and grooming a lot of issues at once.
You can multi-select an issue card by pressing `CTRL` + `Left mouse click` on
Windows or `CMD` + `Left mouse click` on MacOS. Once done, start by dragging one
of the issue card you have selected and drop it in the new list you want.
![Multi-select Issue Cards](img/issue_boards_multi_select.png)
### Configurable Issue Boards **(STARTER)** ### Configurable Issue Boards **(STARTER)**
> Introduced in [GitLab Starter Edition 10.2](https://about.gitlab.com/2017/11/22/gitlab-10-2-released/#issue-boards-configuration). > Introduced in [GitLab Starter Edition 10.2](https://about.gitlab.com/2017/11/22/gitlab-10-2-released/#issue-boards-configuration).
......
...@@ -27,16 +27,16 @@ to do it for you. ...@@ -27,16 +27,16 @@ to do it for you.
To help you out, we've gathered some instructions on how to do that To help you out, we've gathered some instructions on how to do that
for the most popular hosting services: for the most popular hosting services:
- [Amazon](http://docs.aws.amazon.com/gettingstarted/latest/swh/getting-started-configure-route53.html) - [Amazon](https://docs.aws.amazon.com/AmazonS3/latest/dev/website-hosting-custom-domain-walkthrough.html)
- [Bluehost](https://my.bluehost.com/cgi/help/559) - [Bluehost](https://my.bluehost.com/cgi/help/559)
- [CloudFlare](https://support.cloudflare.com/hc/en-us/articles/200169096-How-do-I-add-A-records-) - [CloudFlare](https://support.cloudflare.com/hc/en-us/articles/200169096-How-do-I-add-A-records-)
- [cPanel](https://documentation.cpanel.net/display/ALD/Edit+DNS+Zone) - [cPanel](https://documentation.cpanel.net/display/84Docs/Edit+DNS+Zone)
- [DreamHost](https://help.dreamhost.com/hc/en-us/articles/215414867-How-do-I-add-custom-DNS-records-) - [DreamHost](https://help.dreamhost.com/hc/en-us/articles/215414867-How-do-I-add-custom-DNS-records-)
- [Go Daddy](https://www.godaddy.com/help/add-an-a-record-19238) - [Go Daddy](https://www.godaddy.com/help/add-an-a-record-19238)
- [Hostgator](http://support.hostgator.com/articles/changing-dns-records) - [Hostgator](https://www.hostgator.com/help/article/changing-dns-records)
- [Inmotion hosting](https://my.bluehost.com/cgi/help/559) - [Inmotion hosting](https://my.bluehost.com/cgi/help/559)
- [Media Temple](https://mediatemple.net/community/products/dv/204403794/how-can-i-change-the-dns-records-for-my-domain) - [Media Temple](https://mediatemple.net/community/products/dv/204403794/how-can-i-change-the-dns-records-for-my-domain)
- [Microsoft](https://msdn.microsoft.com/en-us/library/bb727018.aspx) - [Microsoft](https://docs.microsoft.com/en-us/previous-versions/windows/it-pro/windows-2000-server/bb727018(v=technet.10))
If your hosting service is not listed above, you can just try to If your hosting service is not listed above, you can just try to
search the web for `how to add dns record on <my hosting service>`. search the web for `how to add dns record on <my hosting service>`.
......
...@@ -162,7 +162,7 @@ from the GitLab project. ...@@ -162,7 +162,7 @@ from the GitLab project.
> - Domain verification is **required for GitLab.com users**; > - Domain verification is **required for GitLab.com users**;
for GitLab self-managed instances, your GitLab administrator has the option for GitLab self-managed instances, your GitLab administrator has the option
to [disabled custom domain verification](../../../../administration/pages/index.md#custom-domain-verification). to [disabled custom domain verification](../../../../administration/pages/index.md#custom-domain-verification).
> - [DNS propagation may take some time (up to 24h)](http://www.inmotionhosting.com/support/domain-names/dns-nameserver-changes/domain-names-dns-changes), > - [DNS propagation may take some time (up to 24h)](https://www.inmotionhosting.com/support/domain-names/dns-nameserver-changes/domain-names-dns-changes),
although it's usually a matter of minutes to complete. Until it does, verification although it's usually a matter of minutes to complete. Until it does, verification
will fail and attempts to visit your domain will respond with a 404. will fail and attempts to visit your domain will respond with a 404.
> - Once your domain has been verified, leave the verification record > - Once your domain has been verified, leave the verification record
......
...@@ -31,7 +31,7 @@ security measure, necessary just for big companies, like banks and shoppings sit ...@@ -31,7 +31,7 @@ security measure, necessary just for big companies, like banks and shoppings sit
with financial transactions. with financial transactions.
Now we have a different picture. [According to Josh Aas](https://letsencrypt.org/2015/10/29/phishing-and-malware.html), Executive Director at [ISRG](https://en.wikipedia.org/wiki/Internet_Security_Research_Group): Now we have a different picture. [According to Josh Aas](https://letsencrypt.org/2015/10/29/phishing-and-malware.html), Executive Director at [ISRG](https://en.wikipedia.org/wiki/Internet_Security_Research_Group):
> _We’ve since come to realize that HTTPS is important for almost all websites. It’s important for any website that allows people to log in with a password, any website that [tracks its users](https://www.washingtonpost.com/news/the-switch/wp/2013/12/10/nsa-uses-google-cookies-to-pinpoint-targets-for-hacking/) in any way, any website that [doesn’t want its content altered](http://arstechnica.com/tech-policy/2014/09/why-comcasts-javascript-ad-injections-threaten-security-net-neutrality/), and for any site that offers content people might not want others to know they are consuming. We’ve also learned that any site not secured by HTTPS [can be used to attack other sites](https://krebsonsecurity.com/2015/04/dont-be-fodder-for-chinas-great-cannon/)._ > _We’ve since come to realize that HTTPS is important for almost all websites. It’s important for any website that allows people to log in with a password, any website that [tracks its users](https://www.washingtonpost.com/news/the-switch/wp/2013/12/10/nsa-uses-google-cookies-to-pinpoint-targets-for-hacking/) in any way, any website that [doesn’t want its content altered](https://arstechnica.com/tech-policy/2014/09/why-comcasts-javascript-ad-injections-threaten-security-net-neutrality/), and for any site that offers content people might not want others to know they are consuming. We’ve also learned that any site not secured by HTTPS [can be used to attack other sites](https://krebsonsecurity.com/2015/04/dont-be-fodder-for-chinas-great-cannon/)._
Therefore, the reason why certificates are so important is that they encrypt Therefore, the reason why certificates are so important is that they encrypt
the connection between the **client** (you, me, your visitors) the connection between the **client** (you, me, your visitors)
......
...@@ -38,7 +38,7 @@ Explaining [every detail of GitLab CI/CD](../../../ci/yaml/README.md) ...@@ -38,7 +38,7 @@ Explaining [every detail of GitLab CI/CD](../../../ci/yaml/README.md)
and GitLab Runner is out of the scope of this guide, but we'll and GitLab Runner is out of the scope of this guide, but we'll
need to understand just a few things to be able to write our own need to understand just a few things to be able to write our own
`.gitlab-ci.yml` or tweak an existing one. It's an `.gitlab-ci.yml` or tweak an existing one. It's an
[Yaml](http://docs.ansible.com/ansible/YAMLSyntax.html) file, [Yaml](https://docs.ansible.com/ansible/latest/reference_appendices/YAMLSyntax.html) file,
with its own syntax. You can always check your CI syntax with with its own syntax. You can always check your CI syntax with
the [GitLab CI Lint Tool](https://gitlab.com/ci/lint). the [GitLab CI Lint Tool](https://gitlab.com/ci/lint).
...@@ -127,7 +127,7 @@ pages: ...@@ -127,7 +127,7 @@ pages:
The script above would be enough to build your Jekyll The script above would be enough to build your Jekyll
site with GitLab Pages. But, from Jekyll 3.4.0 on, its default site with GitLab Pages. But, from Jekyll 3.4.0 on, its default
template originated by `jekyll new project` requires template originated by `jekyll new project` requires
[Bundler](http://bundler.io/) to install Jekyll dependencies [Bundler](https://bundler.io) to install Jekyll dependencies
and the default theme. To adjust our script to meet these new and the default theme. To adjust our script to meet these new
requirements, we only need to install and build Jekyll with Bundler: requirements, we only need to install and build Jekyll with Bundler:
......
...@@ -32,7 +32,7 @@ module Gitlab ...@@ -32,7 +32,7 @@ module Gitlab
ActiveRecord::Base.uncached do ActiveRecord::Base.uncached do
ActiveRecord::Base.no_touching do ActiveRecord::Base.no_touching do
update_project_params update_project_params!
create_relations create_relations
end end
end end
...@@ -70,7 +70,7 @@ module Gitlab ...@@ -70,7 +70,7 @@ module Gitlab
# the configuration yaml file too. # the configuration yaml file too.
# Finally, it updates each attribute in the newly imported project. # Finally, it updates each attribute in the newly imported project.
def create_relations def create_relations
project_relations_without_project_members.each do |relation_key, relation_definition| project_relations.each do |relation_key, relation_definition|
relation_key_s = relation_key.to_s relation_key_s = relation_key.to_s
if relation_definition.present? if relation_definition.present?
...@@ -124,56 +124,40 @@ module Gitlab ...@@ -124,56 +124,40 @@ module Gitlab
# no-op # no-op
end end
def project_relations_without_project_members
# We remove `project_members` as they are deserialized separately
project_relations.except(:project_members)
end
def project_relations def project_relations
reader.attributes_finder.find_relations_tree(:project) @project_relations ||= reader.attributes_finder.find_relations_tree(:project)
end end
def update_project_params def update_project_params!
Gitlab::Timeless.timeless(@project) do Gitlab::Timeless.timeless(@project) do
@project.update(project_params) project_params = @tree_hash.reject do |key, value|
end project_relations.include?(key.to_sym)
end end
def project_params project_params = project_params.merge(present_project_override_params)
@project_params ||= begin
attrs = json_params.merge(override_params).merge(visibility_level, external_label)
# Cleaning all imported and overridden params # Cleaning all imported and overridden params
Gitlab::ImportExport::AttributeCleaner.clean(relation_hash: attrs, project_params = Gitlab::ImportExport::AttributeCleaner.clean(
relation_class: Project, relation_hash: project_params,
excluded_keys: excluded_keys_for_relation(:project)) relation_class: Project,
excluded_keys: excluded_keys_for_relation(:project))
@project.assign_attributes(project_params)
@project.drop_visibility_level!
@project.save!
end end
end end
def override_params def present_project_override_params
@override_params ||= @project.import_data&.data&.fetch('override_params', nil) || {} # we filter out the empty strings from the overrides
end # keeping the default values configured
project_override_params.transform_values do |value|
def json_params value.is_a?(String) ? value.presence : value
@json_params ||= @tree_hash.reject do |key, value| end.compact
# return params that are not 1 to many or 1 to 1 relations
value.respond_to?(:each) && !Project.column_names.include?(key)
end
end
def visibility_level
level = override_params['visibility_level'] || json_params['visibility_level'] || @project.visibility_level
level = @project.group.visibility_level if @project.group && level.to_i > @project.group.visibility_level
level = Gitlab::VisibilityLevel::PRIVATE if level == Gitlab::VisibilityLevel::INTERNAL && Gitlab::CurrentSettings.restricted_visibility_levels.include?(level)
{ 'visibility_level' => level }
end end
def external_label def project_override_params
label = override_params['external_authorization_classification_label'].presence || @project_override_params ||= @project.import_data&.data&.fetch('override_params', nil) || {}
json_params['external_authorization_classification_label'].presence
{ 'external_authorization_classification_label' => label }
end end
# Given a relation hash containing one or more models and its relationships, # Given a relation hash containing one or more models and its relationships,
......
...@@ -14842,6 +14842,9 @@ msgid_plural "Showing %d events" ...@@ -14842,6 +14842,9 @@ msgid_plural "Showing %d events"
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""
msgid "Showing %{pageSize} of %{total} issues"
msgstr ""
msgid "Showing Latest Version" msgid "Showing Latest Version"
msgstr "" msgstr ""
...@@ -15097,6 +15100,12 @@ msgstr "" ...@@ -15097,6 +15100,12 @@ msgstr ""
msgid "Something went wrong while merging this merge request. Please try again." msgid "Something went wrong while merging this merge request. Please try again."
msgstr "" msgstr ""
msgid "Something went wrong while moving issues."
msgstr ""
msgid "Something went wrong while performing the action."
msgstr ""
msgid "Something went wrong while reopening the %{issuable}. Please try again later" msgid "Something went wrong while reopening the %{issuable}. Please try again later"
msgstr "" msgstr ""
......
# frozen_string_literal: true
require 'spec_helper'
describe 'Multi Select Issue', :js do
include DragTo
let(:group) { create(:group, :nested) }
let(:project) { create(:project, :public, namespace: group) }
let(:board) { create(:board, project: project) }
let(:user) { create(:user) }
def drag(selector: '.board-list', list_from_index: 1, from_index: 0, to_index: 0, list_to_index: 1, duration: 1000)
drag_to(selector: selector,
scrollable: '#board-app',
list_from_index: list_from_index,
from_index: from_index,
to_index: to_index,
list_to_index: list_to_index,
duration: duration)
end
def wait_for_board_cards(board_number, expected_cards)
page.within(find(".board:nth-child(#{board_number})")) do
expect(page.find('.board-header')).to have_content(expected_cards.to_s)
expect(page).to have_selector('.board-card', count: expected_cards)
end
end
def multi_select(selector, action = 'select')
element = page.find(selector)
script = "var el = document.querySelector('#{selector}');"
script += "var mousedown = new MouseEvent('mousedown', { button: 0, bubbles: true });"
script += "var mouseup = new MouseEvent('mouseup', { ctrlKey: true, button: 0, bubbles:true });"
script += "el.dispatchEvent(mousedown); el.dispatchEvent(mouseup);"
script += "Sortable.utils.#{action}(el);"
page.execute_script(script, element)
end
before do
project.add_maintainer(user)
sign_in(user)
end
context 'with lists' do
let(:label1) { create(:label, project: project, name: 'Label 1', description: 'Test') }
let(:label2) { create(:label, project: project, name: 'Label 2', description: 'Test') }
let!(:list1) { create(:list, board: board, label: label1, position: 0) }
let!(:list2) { create(:list, board: board, label: label2, position: 1) }
let!(:issue1) { create(:labeled_issue, project: project, title: 'Issue 1', description: '', assignees: [user], labels: [label1], relative_position: 1) }
let!(:issue2) { create(:labeled_issue, project: project, title: 'Issue 2', description: '', author: user, labels: [label1], relative_position: 2) }
let!(:issue3) { create(:labeled_issue, project: project, title: 'Issue 3', description: '', labels: [label1], relative_position: 3) }
let!(:issue4) { create(:labeled_issue, project: project, title: 'Issue 4', description: '', labels: [label1], relative_position: 4) }
let!(:issue5) { create(:labeled_issue, project: project, title: 'Issue 5', description: '', labels: [label1], relative_position: 5) }
before do
visit project_board_path(project, board)
wait_for_requests
end
it 'moves multiple issues to another list', :js do
multi_select('.board-card:nth-child(1)')
multi_select('.board-card:nth-child(2)')
drag(list_from_index: 1, list_to_index: 2)
wait_for_requests
page.within(all('.js-board-list')[2]) do
expect(find('.board-card:nth-child(1)')).to have_content(issue1.title)
expect(find('.board-card:nth-child(2)')).to have_content(issue2.title)
end
end
it 'maintains order when moved', :js do
multi_select('.board-card:nth-child(3)')
multi_select('.board-card:nth-child(2)')
multi_select('.board-card:nth-child(1)')
drag(list_from_index: 1, list_to_index: 2)
wait_for_requests
page.within(all('.js-board-list')[2]) do
expect(find('.board-card:nth-child(1)')).to have_content(issue1.title)
expect(find('.board-card:nth-child(2)')).to have_content(issue2.title)
expect(find('.board-card:nth-child(3)')).to have_content(issue3.title)
end
end
it 'move issues in the same list', :js do
multi_select('.board-card:nth-child(3)')
multi_select('.board-card:nth-child(4)')
drag(list_from_index: 1, list_to_index: 1, from_index: 2, to_index: 4)
wait_for_requests
page.within(all('.js-board-list')[1]) do
expect(find('.board-card:nth-child(1)')).to have_content(issue1.title)
expect(find('.board-card:nth-child(2)')).to have_content(issue2.title)
expect(find('.board-card:nth-child(3)')).to have_content(issue5.title)
expect(find('.board-card:nth-child(4)')).to have_content(issue3.title)
expect(find('.board-card:nth-child(5)')).to have_content(issue4.title)
end
end
it 'adds label when issues are moved to different card', :js do
page.within(all('.js-board-list')[1]) do
expect(find('.board-card:nth-child(1)')).not_to have_content(label2.title)
expect(find('.board-card:nth-child(2)')).not_to have_content(label2.title)
end
multi_select('.board-card:nth-child(1)')
multi_select('.board-card:nth-child(2)')
drag(list_from_index: 1, list_to_index: 2)
wait_for_requests
page.within(all('.js-board-list')[2]) do
expect(find('.board-card:nth-child(1)')).to have_content(label2.title)
expect(find('.board-card:nth-child(2)')).to have_content(label2.title)
end
end
end
end
...@@ -67,6 +67,16 @@ describe('Board card', () => { ...@@ -67,6 +67,16 @@ describe('Board card', () => {
expect(vm.issueDetailVisible).toBe(true); expect(vm.issueDetailVisible).toBe(true);
}); });
it("returns false when multiSelect doesn't contain issue", () => {
expect(vm.multiSelectVisible).toBe(false);
});
it('returns true when multiSelect contains issue', () => {
boardsStore.multiSelect.list = [vm.issue];
expect(vm.multiSelectVisible).toBe(true);
});
it('adds user-can-drag class if not disabled', () => { it('adds user-can-drag class if not disabled', () => {
expect(vm.$el.classList.contains('user-can-drag')).toBe(true); expect(vm.$el.classList.contains('user-can-drag')).toBe(true);
}); });
...@@ -180,7 +190,7 @@ describe('Board card', () => { ...@@ -180,7 +190,7 @@ describe('Board card', () => {
triggerEvent('mousedown'); triggerEvent('mousedown');
triggerEvent('mouseup'); triggerEvent('mouseup');
expect(eventHub.$emit).toHaveBeenCalledWith('newDetailIssue', vm.issue); expect(eventHub.$emit).toHaveBeenCalledWith('newDetailIssue', vm.issue, undefined);
expect(boardsStore.detail.list).toEqual(vm.list); expect(boardsStore.detail.list).toEqual(vm.list);
}); });
...@@ -203,7 +213,7 @@ describe('Board card', () => { ...@@ -203,7 +213,7 @@ describe('Board card', () => {
triggerEvent('mousedown'); triggerEvent('mousedown');
triggerEvent('mouseup'); triggerEvent('mouseup');
expect(eventHub.$emit).toHaveBeenCalledWith('clearDetailIssue'); expect(eventHub.$emit).toHaveBeenCalledWith('clearDetailIssue', undefined);
}); });
}); });
}); });
...@@ -12,6 +12,7 @@ import '~/boards/services/board_service'; ...@@ -12,6 +12,7 @@ import '~/boards/services/board_service';
import boardsStore from '~/boards/stores/boards_store'; import boardsStore from '~/boards/stores/boards_store';
import eventHub from '~/boards/eventhub'; import eventHub from '~/boards/eventhub';
import { listObj, listObjDuplicate, boardsMockInterceptor, mockBoardService } from './mock_data'; import { listObj, listObjDuplicate, boardsMockInterceptor, mockBoardService } from './mock_data';
import waitForPromises from '../../frontend/helpers/wait_for_promises';
describe('Store', () => { describe('Store', () => {
let mock; let mock;
...@@ -29,6 +30,13 @@ describe('Store', () => { ...@@ -29,6 +30,13 @@ describe('Store', () => {
}), }),
); );
spyOn(gl.boardService, 'moveMultipleIssues').and.callFake(
() =>
new Promise(resolve => {
resolve();
}),
);
Cookies.set('issue_board_welcome_hidden', 'false', { Cookies.set('issue_board_welcome_hidden', 'false', {
expires: 365 * 10, expires: 365 * 10,
path: '', path: '',
...@@ -376,4 +384,128 @@ describe('Store', () => { ...@@ -376,4 +384,128 @@ describe('Store', () => {
expect(state.currentBoard).toEqual(dummyBoard); expect(state.currentBoard).toEqual(dummyBoard);
}); });
}); });
describe('toggleMultiSelect', () => {
let basicIssueObj;
beforeAll(() => {
basicIssueObj = { id: 987654 };
});
afterEach(() => {
boardsStore.clearMultiSelect();
});
it('adds issue when not present', () => {
boardsStore.toggleMultiSelect(basicIssueObj);
const selectedIds = boardsStore.multiSelect.list.map(x => x.id);
expect(selectedIds.includes(basicIssueObj.id)).toEqual(true);
});
it('removes issue when issue is present', () => {
boardsStore.toggleMultiSelect(basicIssueObj);
let selectedIds = boardsStore.multiSelect.list.map(x => x.id);
expect(selectedIds.includes(basicIssueObj.id)).toEqual(true);
boardsStore.toggleMultiSelect(basicIssueObj);
selectedIds = boardsStore.multiSelect.list.map(x => x.id);
expect(selectedIds.includes(basicIssueObj.id)).toEqual(false);
});
});
describe('clearMultiSelect', () => {
it('clears all the multi selected issues', () => {
const issue1 = { id: 12345 };
const issue2 = { id: 12346 };
boardsStore.toggleMultiSelect(issue1);
boardsStore.toggleMultiSelect(issue2);
expect(boardsStore.multiSelect.list.length).toEqual(2);
boardsStore.clearMultiSelect();
expect(boardsStore.multiSelect.list.length).toEqual(0);
});
});
describe('moveMultipleIssuesToList', () => {
it('move issues on the new index', done => {
const listOne = boardsStore.addList(listObj);
const listTwo = boardsStore.addList(listObjDuplicate);
expect(boardsStore.state.lists.length).toBe(2);
setTimeout(() => {
expect(listOne.issues.length).toBe(1);
expect(listTwo.issues.length).toBe(1);
boardsStore.moveMultipleIssuesToList({
listFrom: listOne,
listTo: listTwo,
issues: listOne.issues,
newIndex: 0,
});
expect(listTwo.issues.length).toBe(1);
done();
}, 0);
});
});
describe('moveMultipleIssuesInList', () => {
it('moves multiple issues in list', done => {
const issueObj = {
title: 'Issue #1',
id: 12345,
iid: 2,
confidential: false,
labels: [],
assignees: [],
};
const issue1 = new ListIssue(issueObj);
const issue2 = new ListIssue({
...issueObj,
title: 'Issue #2',
id: 12346,
});
const list = boardsStore.addList(listObj);
waitForPromises()
.then(() => {
list.addIssue(issue1);
list.addIssue(issue2);
expect(list.issues.length).toBe(3);
expect(list.issues[0].id).not.toBe(issue2.id);
boardsStore.moveMultipleIssuesInList({
list,
issues: [issue1, issue2],
oldIndicies: [0],
newIndex: 1,
idArray: [1, 12345, 12346],
});
expect(list.issues[0].id).toBe(issue1.id);
expect(gl.boardService.moveMultipleIssues).toHaveBeenCalledWith({
ids: [issue1.id, issue2.id],
fromListId: null,
toListId: null,
moveBeforeId: 1,
moveAfterId: null,
});
done();
})
.catch(done.fail);
});
});
}); });
...@@ -19,7 +19,7 @@ describe JiraService do ...@@ -19,7 +19,7 @@ describe JiraService do
described_class.create( described_class.create(
project: create(:project), project: create(:project),
active: true, active: true,
username: 'username', username: 'username ',
password: 'test', password: 'test',
jira_issue_transition_id: 24, jira_issue_transition_id: 24,
url: 'http://jira.test.com/path/' url: 'http://jira.test.com/path/'
...@@ -35,6 +35,15 @@ describe JiraService do ...@@ -35,6 +35,15 @@ describe JiraService do
it 'leaves out trailing slashes in context' do it 'leaves out trailing slashes in context' do
expect(service.options[:context_path]).to eq('/path') expect(service.options[:context_path]).to eq('/path')
end end
it 'leaves out trailing whitespaces in username' do
expect(service.options[:username]).to eq('username')
end
it 'provides additional cookies to allow basic auth with oracle webgate' do
expect(service.options[:use_cookies]).to eq(true)
expect(service.options[:additional_cookies]).to eq(['OBBasicAuth=fromDialog'])
end
end end
describe 'Associations' do describe 'Associations' do
...@@ -68,7 +77,7 @@ describe JiraService do ...@@ -68,7 +77,7 @@ describe JiraService do
expect(subject.properties).to be_nil expect(subject.properties).to be_nil
end end
it 'sets title correctly' do it 'sets title correctly' do
service = subject service = subject
expect(service.title).to eq('custom title') expect(service.title).to eq('custom title')
...@@ -93,7 +102,7 @@ describe JiraService do ...@@ -93,7 +102,7 @@ describe JiraService do
end end
# we need to make sure we are able to read both from properties and jira_tracker_data table # we need to make sure we are able to read both from properties and jira_tracker_data table
# TODO: change this as part of #63084 # TODO: change this as part of https://gitlab.com/gitlab-org/gitlab/issues/29404
context 'overriding properties' do context 'overriding properties' do
let(:access_params) do let(:access_params) do
{ url: url, api_url: api_url, username: username, password: password, { url: url, api_url: api_url, username: username, password: password,
...@@ -604,26 +613,6 @@ describe JiraService do ...@@ -604,26 +613,6 @@ describe JiraService do
end end
end end
describe 'additional cookies' do
let(:project) { create(:project) }
context 'provides additional cookies to allow basic auth with oracle webgate' do
before do
@service = project.create_jira_service(
active: true, properties: { url: 'http://jira.com' })
end
after do
@service.destroy!
end
it 'is initialized' do
expect(@service.options[:use_cookies]).to eq(true)
expect(@service.options[:additional_cookies]).to eq(['OBBasicAuth=fromDialog'])
end
end
end
describe 'project and issue urls' do describe 'project and issue urls' do
context 'when gitlab.yml was initialized' do context 'when gitlab.yml was initialized' do
it 'is prepopulated with the settings' do it 'is prepopulated with the settings' do
......
...@@ -5179,6 +5179,61 @@ describe Project do ...@@ -5179,6 +5179,61 @@ describe Project do
end end
end end
describe '#drop_visibility_level!' do
context 'when has a group' do
let(:group) { create(:group, visibility_level: group_visibility_level) }
let(:project) { build(:project, namespace: group, visibility_level: project_visibility_level) }
context 'when the group `visibility_level` is more strict' do
let(:group_visibility_level) { Gitlab::VisibilityLevel::PRIVATE }
let(:project_visibility_level) { Gitlab::VisibilityLevel::INTERNAL }
it 'sets `visibility_level` value from the group' do
expect { project.drop_visibility_level! }
.to change { project.visibility_level }
.to(Gitlab::VisibilityLevel::PRIVATE)
end
end
context 'when the group `visibility_level` is less strict' do
let(:group_visibility_level) { Gitlab::VisibilityLevel::INTERNAL }
let(:project_visibility_level) { Gitlab::VisibilityLevel::PRIVATE }
it 'does not change the value of the `visibility_level` field' do
expect { project.drop_visibility_level! }
.not_to change { project.visibility_level }
end
end
end
context 'when `restricted_visibility_levels` of the GitLab instance exist' do
before do
stub_application_setting(restricted_visibility_levels: [Gitlab::VisibilityLevel::INTERNAL])
end
let(:project) { build(:project, visibility_level: project_visibility_level) }
context 'when `visibility_level` is included into `restricted_visibility_levels`' do
let(:project_visibility_level) { Gitlab::VisibilityLevel::INTERNAL }
it 'sets `visibility_level` value to `PRIVATE`' do
expect { project.drop_visibility_level! }
.to change { project.visibility_level }
.to(Gitlab::VisibilityLevel::PRIVATE)
end
end
context 'when `restricted_visibility_levels` does not include `visibility_level`' do
let(:project_visibility_level) { Gitlab::VisibilityLevel::PUBLIC }
it 'does not change the value of the `visibility_level` field' do
expect { project.drop_visibility_level! }
.to not_change { project.visibility_level }
end
end
end
end
def rugged_config def rugged_config
rugged_repo(project.repository).config rugged_repo(project.repository).config
end end
......
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