Commit d8654744 authored by Connor Shea's avatar Connor Shea

Merge branch 'master' into diff-line-comment-vuejs

parents 4a13aa9f 46dc0063
......@@ -9,6 +9,7 @@ v 8.11.0 (unreleased)
- Fix the title of the toggle dropdown button. !5515 (herminiotorres)
- Rename `markdown_preview` routes to `preview_markdown`. (Christopher Bartz)
- Update to Ruby 2.3.1. !4948
- Add Issues Board !5548
- Improve diff performance by eliminating redundant checks for text blobs
- Ensure that branch names containing escapable characters (e.g. %20) aren't unescaped indiscriminately. !5770 (ewiltshi)
- Convert switch icon into icon font (ClemMakesApps)
......@@ -16,6 +17,7 @@ v 8.11.0 (unreleased)
- API: List access requests, request access, approve, and deny access requests to a project or a group. !4833
- Use long options for curl examples in documentation !5703 (winniehell)
- Remove magic comments (`# encoding: UTF-8`) from Ruby files. !5456 (winniehell)
- GitLab Performance Monitoring can now track custom events such as the number of tags pushed to a repository
- Add support for relative links starting with ./ or / to RelativeLinkFilter (winniehell)
- Ignore URLs starting with // in Markdown links !5677 (winniehell)
- Fix CI status icon link underline (ClemMakesApps)
......@@ -90,6 +92,7 @@ v 8.11.0 (unreleased)
- Allow branch names ending with .json for graph and network page !5579 (winniehell)
- Add the `sprockets-es6` gem
- Improve OAuth2 client documentation (muteor)
- Fix diff comments inverted toggle bug (ClemMakesApps)
- Multiple trigger variables show in separate lines (Katarzyna Kobierska Ula Budziszewska)
- Profile requests when a header is passed
- Avoid calculation of line_code and position for _line partial when showing diff notes on discussion tab.
......@@ -120,6 +123,7 @@ v 8.11.0 (unreleased)
- Fix a memory leak caused by Banzai::Filter::SanitizationFilter
- Speed up todos queries by limiting the projects set we join with
- Ensure file editing in UI does not overwrite commited changes without warning user
- Eliminate unneeded calls to Repository#blob_at when listing commits with no path
v 8.10.6
- Upgrade Rails to 4.2.7.1 for security fixes. !5781
......
......@@ -314,6 +314,7 @@ end
group :test do
gem 'shoulda-matchers', '~> 2.8.0', require: false
gem 'email_spec', '~> 1.6.0'
gem 'json-schema', '~> 2.6.2'
gem 'webmock', '~> 1.21.0'
gem 'test_after_commit', '~> 0.4.2'
gem 'sham_rack', '~> 1.3.6'
......
......@@ -356,6 +356,8 @@ GEM
jquery-ui-rails (5.0.5)
railties (>= 3.2.16)
json (1.8.3)
json-schema (2.6.2)
addressable (~> 2.3.8)
jwt (1.5.4)
kaminari (0.17.0)
actionpack (>= 3.0.0)
......@@ -873,6 +875,7 @@ DEPENDENCIES
jquery-rails (~> 4.1.0)
jquery-turbolinks (~> 2.1.0)
jquery-ui-rails (~> 5.0.0)
json-schema (~> 2.6.2)
jwt
kaminari (~> 0.17.0)
knapsack (~> 1.11.0)
......
//= require vue
//= require vue-resource
//= require Sortable
//= require_tree ./models
//= require_tree ./stores
//= require_tree ./services
//= require_tree ./mixins
//= require ./components/board
//= require ./components/new_list_dropdown
//= require ./vue_resource_interceptor
$(() => {
const $boardApp = document.getElementById('board-app'),
Store = gl.issueBoards.BoardsStore;
window.gl = window.gl || {};
if (gl.IssueBoardsApp) {
gl.IssueBoardsApp.$destroy(true);
}
gl.IssueBoardsApp = new Vue({
el: $boardApp,
components: {
'board': gl.issueBoards.Board
},
data: {
state: Store.state,
loading: true,
endpoint: $boardApp.dataset.endpoint,
disabled: $boardApp.dataset.disabled === 'true',
issueLinkBase: $boardApp.dataset.issueLinkBase
},
init: Store.create.bind(Store),
created () {
gl.boardService = new BoardService(this.endpoint);
},
ready () {
Store.disabled = this.disabled;
gl.boardService.all()
.then((resp) => {
resp.json().forEach((board) => {
const list = Store.addList(board);
if (list.type === 'done') {
list.position = Infinity;
} else if (list.type === 'backlog') {
list.position = -1;
}
});
Store.addBlankState();
this.loading = false;
});
}
});
});
//= require ./board_blank_state
//= require ./board_delete
//= require ./board_list
(() => {
const Store = gl.issueBoards.BoardsStore;
window.gl = window.gl || {};
window.gl.issueBoards = window.gl.issueBoards || {};
gl.issueBoards.Board = Vue.extend({
components: {
'board-list': gl.issueBoards.BoardList,
'board-delete': gl.issueBoards.BoardDelete,
'board-blank-state': gl.issueBoards.BoardBlankState
},
props: {
list: Object,
disabled: Boolean,
issueLinkBase: String
},
data () {
return {
query: '',
filters: Store.state.filters
};
},
watch: {
query () {
this.list.filters = this.getFilterData();
this.list.getIssues(true);
},
filters: {
handler () {
this.list.page = 1;
this.list.getIssues(true);
},
deep: true
}
},
methods: {
getFilterData () {
const filters = this.filters;
let queryData = { search: this.query };
Object.keys(filters).forEach((key) => { queryData[key] = filters[key]; });
return queryData;
}
},
ready () {
const options = gl.issueBoards.getBoardSortableDefaultOptions({
disabled: this.disabled,
group: 'boards',
draggable: '.is-draggable',
handle: '.js-board-handle',
onEnd: (e) => {
document.body.classList.remove('is-dragging');
if (e.newIndex !== undefined && e.oldIndex !== e.newIndex) {
const order = this.sortable.toArray(),
$board = this.$parent.$refs.board[e.oldIndex + 1],
list = $board.list;
$board.$destroy(true);
this.$nextTick(() => {
Store.state.lists.splice(e.newIndex, 0, list);
Store.moveList(list, order);
});
}
}
});
if (bp.getBreakpointSize() === 'xs') {
options.handle = '.js-board-drag-handle';
}
this.sortable = Sortable.create(this.$el.parentNode, options);
},
beforeDestroy () {
Store.state.lists.$remove(this.list);
}
});
})();
(() => {
const Store = gl.issueBoards.BoardsStore;
window.gl = window.gl || {};
window.gl.issueBoards = window.gl.issueBoards || {};
gl.issueBoards.BoardBlankState = Vue.extend({
data () {
return {
predefinedLabels: [
new ListLabel({ title: 'Development', color: '#5CB85C' }),
new ListLabel({ title: 'Testing', color: '#F0AD4E' }),
new ListLabel({ title: 'Production', color: '#FF5F00' }),
new ListLabel({ title: 'Ready', color: '#FF0000' })
]
}
},
methods: {
addDefaultLists () {
this.clearBlankState();
this.predefinedLabels.forEach((label, i) => {
Store.addList({
title: label.title,
position: i,
list_type: 'label',
label: {
title: label.title,
color: label.color
}
});
});
// Save the labels
gl.boardService.generateDefaultLists()
.then((resp) => {
resp.json().forEach((listObj) => {
const list = Store.findList('title', listObj.title);
list.id = listObj.id;
list.label.id = listObj.label.id;
list.getIssues();
});
});
},
clearBlankState: Store.removeBlankState.bind(Store)
}
});
})();
(() => {
const Store = gl.issueBoards.BoardsStore;
window.gl = window.gl || {};
window.gl.issueBoards = window.gl.issueBoards || {};
gl.issueBoards.BoardCard = Vue.extend({
props: {
list: Object,
issue: Object,
issueLinkBase: String,
disabled: Boolean,
index: Number
},
methods: {
filterByLabel (label, e) {
let labelToggleText = label.title;
const labelIndex = Store.state.filters['label_name'].indexOf(label.title);
$(e.target).tooltip('hide');
if (labelIndex === -1) {
Store.state.filters['label_name'].push(label.title);
$('.labels-filter').prepend(`<input type="hidden" name="label_name[]" value="${label.title}" />`);
} else {
Store.state.filters['label_name'].splice(labelIndex, 1);
labelToggleText = Store.state.filters['label_name'][0];
$(`.labels-filter input[name="label_name[]"][value="${label.title}"]`).remove();
}
const selectedLabels = Store.state.filters['label_name'];
if (selectedLabels.length === 0) {
labelToggleText = 'Label';
} else if (selectedLabels.length > 1) {
labelToggleText = `${selectedLabels[0]} + ${selectedLabels.length - 1} more`;
}
$('.labels-filter .dropdown-toggle-text').text(labelToggleText);
Store.updateFiltersUrl();
}
}
});
})();
(() => {
window.gl = window.gl || {};
window.gl.issueBoards = window.gl.issueBoards || {};
gl.issueBoards.BoardDelete = Vue.extend({
props: {
list: Object
},
methods: {
deleteBoard () {
$(this.$el).tooltip('hide');
if (confirm('Are you sure you want to delete this list?')) {
this.list.destroy();
}
}
}
});
})();
//= require ./board_card
(() => {
const Store = gl.issueBoards.BoardsStore;
window.gl = window.gl || {};
window.gl.issueBoards = window.gl.issueBoards || {};
gl.issueBoards.BoardList = Vue.extend({
components: {
'board-card': gl.issueBoards.BoardCard
},
props: {
disabled: Boolean,
list: Object,
issues: Array,
loading: Boolean,
issueLinkBase: String
},
data () {
return {
scrollOffset: 250,
filters: Store.state.filters
};
},
watch: {
filters: {
handler () {
this.list.loadingMore = false;
this.$els.list.scrollTop = 0;
},
deep: true
}
},
methods: {
listHeight () {
return this.$els.list.getBoundingClientRect().height;
},
scrollHeight () {
return this.$els.list.scrollHeight;
},
scrollTop () {
return this.$els.list.scrollTop + this.listHeight();
},
loadNextPage () {
const getIssues = this.list.nextPage();
if (getIssues) {
this.list.loadingMore = true;
getIssues.then(() => {
this.list.loadingMore = false;
});
}
},
},
ready () {
const options = gl.issueBoards.getBoardSortableDefaultOptions({
group: 'issues',
sort: false,
disabled: this.disabled,
onStart: (e) => {
const card = this.$refs.issue[e.oldIndex];
Store.moving.issue = card.issue;
Store.moving.list = card.list;
},
onAdd: (e) => {
gl.issueBoards.BoardsStore.moveIssueToList(Store.moving.list, this.list, Store.moving.issue);
},
onRemove: (e) => {
this.$refs.issue[e.oldIndex].$destroy(true);
}
});
if (bp.getBreakpointSize() === 'xs') {
options.handle = '.js-card-drag-handle';
}
this.sortable = Sortable.create(this.$els.list, options);
// Scroll event on list to load more
this.$els.list.onscroll = () => {
if ((this.scrollTop() > this.scrollHeight() - this.scrollOffset) && !this.list.loadingMore) {
this.loadNextPage();
}
};
}
});
})();
$(() => {
const Store = gl.issueBoards.BoardsStore;
$('.js-new-board-list').each(function () {
const $this = $(this);
new gl.CreateLabelDropdown($this.closest('.dropdown').find('.dropdown-new-label'), $this.data('project-id'));
$this.glDropdown({
data(term, callback) {
$.get($this.attr('data-labels'))
.then((resp) => {
callback(resp);
});
},
renderRow (label) {
const active = Store.findList('title', label.title),
$li = $('<li />'),
$a = $('<a />', {
class: (active ? `is-active js-board-list-${active.id}` : ''),
text: label.title,
href: '#'
}),
$labelColor = $('<span />', {
class: 'dropdown-label-box',
style: `background-color: ${label.color}`
});
return $li.append($a.prepend($labelColor));
},
search: {
fields: ['title']
},
filterable: true,
selectable: true,
clicked (label, $el, e) {
e.preventDefault();
if (!Store.findList('title', label.title)) {
Store.new({
title: label.title,
position: Store.state.lists.length - 2,
list_type: 'label',
label: {
id: label.id,
title: label.title,
color: label.color
}
});
}
}
});
});
});
((w) => {
window.gl = window.gl || {};
window.gl.issueBoards = window.gl.issueBoards || {};
gl.issueBoards.getBoardSortableDefaultOptions = (obj) => {
let defaultSortOptions = {
forceFallback: true,
fallbackClass: 'is-dragging',
fallbackOnBody: true,
ghostClass: 'is-ghost',
filter: '.has-tooltip',
scrollSensitivity: 100,
scrollSpeed: 20,
onStart () {
document.body.classList.add('is-dragging');
},
onEnd () {
document.body.classList.remove('is-dragging');
}
}
Object.keys(obj).forEach((key) => { defaultSortOptions[key] = obj[key]; });
return defaultSortOptions;
};
})(window);
class ListIssue {
constructor (obj) {
this.id = obj.iid;
this.title = obj.title;
this.confidential = obj.confidential;
this.labels = [];
if (obj.assignee) {
this.assignee = new ListUser(obj.assignee);
}
obj.labels.forEach((label) => {
this.labels.push(new ListLabel(label));
});
this.priority = this.labels.reduce((max, label) => {
return (label.priority < max) ? label.priority : max;
}, Infinity);
}
addLabel (label) {
if (!this.findLabel(label)) {
this.labels.push(new ListLabel(label));
}
}
findLabel (findLabel) {
return this.labels.filter( label => label.title === findLabel.title )[0];
}
removeLabel (removeLabel) {
if (removeLabel) {
this.labels = this.labels.filter( label => removeLabel.title !== label.title );
}
}
removeLabels (labels) {
labels.forEach(this.removeLabel.bind(this));
}
getLists () {
return gl.issueBoards.BoardsStore.state.lists.filter( list => list.findIssue(this.id) );
}
}
class ListLabel {
constructor (obj) {
this.id = obj.id;
this.title = obj.title;
this.color = obj.color;
this.description = obj.description;
this.priority = (obj.priority !== null) ? obj.priority : Infinity;
}
}
class List {
constructor (obj) {
this.id = obj.id;
this._uid = this.guid();
this.position = obj.position;
this.title = obj.title;
this.type = obj.list_type;
this.preset = ['backlog', 'done', 'blank'].indexOf(this.type) > -1;
this.filters = gl.issueBoards.BoardsStore.state.filters;
this.page = 1;
this.loading = true;
this.loadingMore = false;
this.issues = [];
if (obj.label) {
this.label = new ListLabel(obj.label);
}
if (this.type !== 'blank' && this.id) {
this.getIssues();
}
}
guid() {
const s4 = () => Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1);
return `${s4()}${s4()}-${s4()}-${s4()}-${s4()}-${s4()}${s4()}${s4()}`;
}
save () {
return gl.boardService.createList(this.label.id)
.then((resp) => {
const data = resp.json();
this.id = data.id;
this.type = data.list_type;
this.position = data.position;
return this.getIssues();
});
}
destroy () {
gl.issueBoards.BoardsStore.state.lists.$remove(this);
gl.issueBoards.BoardsStore.updateNewListDropdown(this.id);
gl.boardService.destroyList(this.id);
}
update () {
gl.boardService.updateList(this.id, this.position);
}
nextPage () {
if (Math.floor(this.issues.length / 20) === this.page) {
this.page++;
return this.getIssues(false);
}
}
canSearch () {
return this.type === 'backlog';
}
getIssues (emptyIssues = true) {
const filters = this.filters;
let data = { page: this.page };
Object.keys(filters).forEach((key) => { data[key] = filters[key]; });
if (this.label) {
data.label_name = data.label_name.filter( label => label !== this.label.title );
}
if (emptyIssues) {
this.loading = true;
}
return gl.boardService.getIssuesForList(this.id, data)
.then((resp) => {
const data = resp.json();
this.loading = false;
if (emptyIssues) {
this.issues = [];
}
this.createIssues(data);
});
}
createIssues (data) {
data.forEach((issueObj) => {
this.addIssue(new ListIssue(issueObj));
});
}
addIssue (issue, listFrom) {
this.issues.push(issue);
if (this.label) {
issue.addLabel(this.label);
}
if (listFrom) {
gl.boardService.moveIssue(issue.id, listFrom.id, this.id);
}
}
findIssue (id) {
return this.issues.filter( issue => issue.id === id )[0];
}
removeIssue (removeIssue) {
this.issues = this.issues.filter((issue) => {
const matchesRemove = removeIssue.id === issue.id;
if (matchesRemove) {
issue.removeLabel(this.label);
}
return !matchesRemove;
});
}
}
class ListUser {
constructor (user) {
this.id = user.id;
this.name = user.name;
this.username = user.username;
this.avatar = user.avatar_url;
}
}
class BoardService {
constructor (root) {
Vue.http.options.root = root;
this.lists = Vue.resource(`${root}/lists{/id}`, {}, {
generate: {
method: 'POST',
url: `${root}/lists/generate.json`
}
});
this.issue = Vue.resource(`${root}/issues{/id}`, {});
this.issues = Vue.resource(`${root}/lists{/id}/issues`, {});
Vue.http.interceptors.push((request, next) => {
request.headers['X-CSRF-Token'] = $.rails.csrfToken();
next();
});
}
all () {
return this.lists.get();
}
generateDefaultLists () {
return this.lists.generate({});
}
createList (label_id) {
return this.lists.save({}, {
list: {
label_id
}
});
}
updateList (id, position) {
return this.lists.update({ id }, {
list: {
position
}
});
}
destroyList (id) {
return this.lists.delete({ id });
}
getIssuesForList (id, filter = {}) {
let data = { id };
Object.keys(filter).forEach((key) => { data[key] = filter[key]; });
return this.issues.get(data);
}
moveIssue (id, from_list_id, to_list_id) {
return this.issue.update({ id }, {
from_list_id,
to_list_id
});
}
};
(() => {
window.gl = window.gl || {};
window.gl.issueBoards = window.gl.issueBoards || {};
gl.issueBoards.BoardsStore = {
disabled: false,
state: {},
moving: {
issue: {},
list: {}
},
create () {
this.state.lists = [];
this.state.filters = {
author_id: gl.utils.getParameterValues('author_id')[0],
assignee_id: gl.utils.getParameterValues('assignee_id')[0],
milestone_title: gl.utils.getParameterValues('milestone_title')[0],
label_name: gl.utils.getParameterValues('label_name[]')
};
},
addList (listObj) {
const list = new List(listObj);
this.state.lists.push(list);
return list;
},
new (listObj) {
const list = this.addList(listObj),
backlogList = this.findList('type', 'backlog', 'backlog');
list
.save()
.then(() => {
// Remove any new issues from the backlog
// as they will be visible in the new list
list.issues.forEach(backlogList.removeIssue.bind(backlogList));
});
this.removeBlankState();
},
updateNewListDropdown (listId) {
$(`.js-board-list-${listId}`).removeClass('is-active');
},
shouldAddBlankState () {
// Decide whether to add the blank state
return !(this.state.lists.filter( list => list.type !== 'backlog' && list.type !== 'done' )[0]);
},
addBlankState () {
if (!this.shouldAddBlankState() || this.welcomeIsHidden() || this.disabled) return;
this.addList({
id: 'blank',
list_type: 'blank',
title: 'Welcome to your Issue Board!',
position: 0
});
},
removeBlankState () {
this.removeList('blank');
$.cookie('issue_board_welcome_hidden', 'true', {
expires: 365 * 10
});
},
welcomeIsHidden () {
return $.cookie('issue_board_welcome_hidden') === 'true';
},
removeList (id, type = 'blank') {
const list = this.findList('id', id, type);
if (!list) return;
this.state.lists = this.state.lists.filter( list => list.id !== id );
},
moveList (listFrom, orderLists) {
orderLists.forEach((id, i) => {
const list = this.findList('id', parseInt(id));
list.position = i;
});
listFrom.update();
},
moveIssueToList (listFrom, listTo, issue) {
const issueTo = listTo.findIssue(issue.id),
issueLists = issue.getLists(),
listLabels = issueLists.map( listIssue => listIssue.label );
// Add to new lists issues if it doesn't already exist
if (!issueTo) {
listTo.addIssue(issue, listFrom);
}
if (listTo.type === 'done' && listFrom.type !== 'backlog') {
issueLists.forEach((list) => {
list.removeIssue(issue);
})
issue.removeLabels(listLabels);
} else {
listFrom.removeIssue(issue);
}
},
findList (key, val, type = 'label') {
return this.state.lists.filter((list) => {
const byType = type ? list['type'] === type : true;
return list[key] === val && byType;
})[0];
},
updateFiltersUrl () {
history.pushState(null, null, `?${$.param(this.state.filters)}`);
}
};
})();
(function () {
'use strict';
function simulateEvent(el, type, options) {
var event;
if (!el) return;
var ownerDocument = el.ownerDocument;
options = options || {};
if (/^mouse/.test(type)) {
event = ownerDocument.createEvent('MouseEvents');
event.initMouseEvent(type, true, true, ownerDocument.defaultView,
options.button, options.screenX, options.screenY, options.clientX, options.clientY,
options.ctrlKey, options.altKey, options.shiftKey, options.metaKey, options.button, el);
} else {
event = ownerDocument.createEvent('CustomEvent');
event.initCustomEvent(type, true, true, ownerDocument.defaultView,
options.button, options.screenX, options.screenY, options.clientX, options.clientY,
options.ctrlKey, options.altKey, options.shiftKey, options.metaKey, options.button, el);
event.dataTransfer = {
data: {},
setData: function (type, val) {
this.data[type] = val;
},
getData: function (type) {
return this.data[type];
}
};
}
if (el.dispatchEvent) {
el.dispatchEvent(event);
} else if (el.fireEvent) {
el.fireEvent('on' + type, event);
}
return event;
}
function getTraget(target) {
var el = typeof target.el === 'string' ? document.getElementById(target.el.substr(1)) : target.el;
var children = el.children;
return (
children[target.index] ||
children[target.index === 'first' ? 0 : -1] ||
children[target.index === 'last' ? children.length - 1 : -1]
);
}
function getRect(el) {
var rect = el.getBoundingClientRect();
var width = rect.right - rect.left;
var height = rect.bottom - rect.top;
return {
x: rect.left,
y: rect.top,
cx: rect.left + width / 2,
cy: rect.top + height / 2,
w: width,
h: height,
hw: width / 2,
wh: height / 2
};
}
function simulateDrag(options, callback) {
options.to.el = options.to.el || options.from.el;
var fromEl = getTraget(options.from);
var toEl = getTraget(options.to);
var scrollable = options.scrollable;
var fromRect = getRect(fromEl);
var toRect = getRect(toEl);
var startTime = new Date().getTime();
var duration = options.duration || 1000;
simulateEvent(fromEl, 'mousedown', {button: 0});
options.ontap && options.ontap();
window.SIMULATE_DRAG_ACTIVE = 1;
var dragInterval = setInterval(function loop() {
var progress = (new Date().getTime() - startTime) / duration;
var x = (fromRect.cx + (toRect.cx - fromRect.cx) * progress) - scrollable.scrollLeft;
var y = (fromRect.cy + (toRect.cy - fromRect.cy) * progress) - scrollable.scrollTop;
var overEl = fromEl.ownerDocument.elementFromPoint(x, y);
simulateEvent(overEl, 'mousemove', {
clientX: x,
clientY: y
});
if (progress >= 1) {
options.ondragend && options.ondragend();
simulateEvent(toEl, 'mouseup');
clearInterval(dragInterval);
window.SIMULATE_DRAG_ACTIVE = 0;
}
}, 100);
return {
target: fromEl,
fromList: fromEl.parentNode,
toList: toEl.parentNode
};
}
// Export
window.simulateEvent = simulateEvent;
window.simulateDrag = simulateDrag;
})();
Vue.http.interceptors.push((request, next) => {
Vue.activeResources = Vue.activeResources ? Vue.activeResources + 1 : 1;
setTimeout(() => {
Vue.activeResources--;
}, 500);
next();
});
......@@ -34,6 +34,7 @@
$(function() {
var clipboard;
clipboard = new Clipboard('[data-clipboard-target], [data-clipboard-text]');
clipboard.on('success', genericSuccess);
return clipboard.on('error', genericError);
......
(function (w) {
class CreateLabelDropdown {
constructor ($el, projectId) {
this.$el = $el;
this.projectId = projectId;
this.$dropdownBack = $('.dropdown-menu-back', this.$el.closest('.dropdown'));
this.$cancelButton = $('.js-cancel-label-btn', this.$el);
this.$newLabelField = $('#new_label_name', this.$el);
this.$newColorField = $('#new_label_color', this.$el);
this.$colorPreview = $('.js-dropdown-label-color-preview', this.$el);
this.$newLabelError = $('.js-label-error', this.$el);
this.$newLabelCreateButton = $('.js-new-label-btn', this.$el);
this.$colorSuggestions = $('.suggest-colors-dropdown a', this.$el);
this.$newLabelError.hide();
this.$newLabelCreateButton.disable();
this.cleanBinding();
this.addBinding();
}
cleanBinding () {
this.$colorSuggestions.off('click');
this.$newLabelField.off('keyup change');
this.$newColorField.off('keyup change');
this.$dropdownBack.off('click');
this.$cancelButton.off('click');
this.$newLabelCreateButton.off('click');
}
addBinding () {
const self = this;
this.$colorSuggestions.on('click', function (e) {
const $this = $(this);
self.addColorValue(e, $this);
});
this.$newLabelField.on('keyup change', this.enableLabelCreateButton.bind(this));
this.$newColorField.on('keyup change', this.enableLabelCreateButton.bind(this));
this.$dropdownBack.on('click', this.resetForm.bind(this));
this.$cancelButton.on('click', function(e) {
e.preventDefault();
e.stopPropagation();
self.resetForm();
self.$dropdownBack.trigger('click');
});
this.$newLabelCreateButton.on('click', this.saveLabel.bind(this));
}
addColorValue (e, $this) {
e.preventDefault();
e.stopPropagation();
this.$newColorField.val($this.data('color')).trigger('change');
this.$colorPreview
.css('background-color', $this.data('color'))
.parent()
.addClass('is-active');
}
enableLabelCreateButton () {
if (this.$newLabelField.val() !== '' && this.$newColorField.val() !== '') {
this.$newLabelError.hide();
this.$newLabelCreateButton.enable();
} else {
this.$newLabelCreateButton.disable();
}
}
resetForm () {
this.$newLabelField
.val('')
.trigger('change');
this.$newColorField
.val('')
.trigger('change');
this.$colorPreview
.css('background-color', '')
.parent()
.removeClass('is-active');
}
saveLabel (e) {
e.preventDefault();
e.stopPropagation();
Api.newLabel(this.projectId, {
name: this.$newLabelField.val(),
color: this.$newColorField.val()
}, (label) => {
this.$newLabelCreateButton.enable();
if (label.message) {
let errors;
if (typeof label.message === 'string') {
errors = label.message;
} else {
errors = label.message.map(function (value, key) {
return key + " " + value[0];
}).join("<br/>");
}
this.$newLabelError
.html(errors)
.show();
} else {
this.$dropdownBack.trigger('click');
}
});
}
}
if (!w.gl) {
w.gl = {};
}
gl.CreateLabelDropdown = CreateLabelDropdown;
})(window);
......@@ -88,6 +88,8 @@
new ZenMode();
new MergedButtons();
break;
case "projects:merge_requests:conflicts":
window.mcui = new MergeConflictResolver()
case 'projects:merge_requests:index':
shortcut_handler = new ShortcutsNavigation();
Issuable.init();
......
......@@ -4,7 +4,7 @@
var _this;
_this = this;
$('.js-label-select').each(function(i, dropdown) {
var $block, $colorPreview, $dropdown, $form, $loading, $newLabelCreateButton, $newLabelError, $selectbox, $sidebarCollapsedValue, $value, abilityName, defaultLabel, enableLabelCreateButton, issueURLSplit, issueUpdateURL, labelHTMLTemplate, labelNoneHTMLTemplate, labelUrl, newColorField, newLabelField, projectId, resetForm, saveLabel, saveLabelData, selectedLabel, showAny, showNo;
var $block, $colorPreview, $dropdown, $form, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, defaultLabel, enableLabelCreateButton, issueURLSplit, issueUpdateURL, labelHTMLTemplate, labelNoneHTMLTemplate, labelUrl, projectId, saveLabelData, selectedLabel, showAny, showNo;
$dropdown = $(dropdown);
projectId = $dropdown.data('project-id');
labelUrl = $dropdown.data('labels');
......@@ -13,8 +13,6 @@
if ((selectedLabel != null) && !$dropdown.hasClass('js-multiselect')) {
selectedLabel = selectedLabel.split(',');
}
newLabelField = $('#new_label_name');
newColorField = $('#new_label_color');
showNo = $dropdown.data('show-no');
showAny = $dropdown.data('show-any');
defaultLabel = $dropdown.data('default-label');
......@@ -24,10 +22,6 @@
$form = $dropdown.closest('form');
$sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon span');
$value = $block.find('.value');
$newLabelError = $('.js-label-error');
$colorPreview = $('.js-dropdown-label-color-preview');
$newLabelCreateButton = $('.js-new-label-btn');
$newLabelError.hide();
$loading = $block.find('.block-loading').fadeOut();
if (issueUpdateURL != null) {
issueURLSplit = issueUpdateURL.split('/');
......@@ -36,62 +30,9 @@
labelHTMLTemplate = _.template('<% _.each(labels, function(label){ %> <a href="<%- ["",issueURLSplit[1], issueURLSplit[2],""].join("/") %>issues?label_name[]=<%- encodeURIComponent(label.title) %>"> <span class="label has-tooltip color-label" title="<%- label.description %>" style="background-color: <%- label.color %>; color: <%- label.text_color %>;"> <%- label.title %> </span> </a> <% }); %>');
labelNoneHTMLTemplate = '<span class="no-value">None</span>';
}
if (newLabelField.length) {
$('.suggest-colors-dropdown a').on("click", function(e) {
e.preventDefault();
e.stopPropagation();
newColorField.val($(this).data('color')).trigger('change');
return $colorPreview.css('background-color', $(this).data('color')).parent().addClass('is-active');
});
resetForm = function() {
newLabelField.val('').trigger('change');
newColorField.val('').trigger('change');
return $colorPreview.css('background-color', '').parent().removeClass('is-active');
};
$('.dropdown-menu-back').on('click', function() {
return resetForm();
});
$('.js-cancel-label-btn').on('click', function(e) {
e.preventDefault();
e.stopPropagation();
resetForm();
return $('.dropdown-menu-back', $dropdown.parent()).trigger('click');
});
enableLabelCreateButton = function() {
if (newLabelField.val() !== '' && newColorField.val() !== '') {
$newLabelError.hide();
return $newLabelCreateButton.enable();
} else {
return $newLabelCreateButton.disable();
}
};
saveLabel = function() {
return Api.newLabel(projectId, {
name: newLabelField.val(),
color: newColorField.val()
}, function(label) {
$newLabelCreateButton.enable();
if (label.message != null) {
var errorText = label.message;
if (_.isObject(label.message)) {
errorText = _.map(label.message, function(value, key) {
return key + " " + value[0];
}).join('<br/>');
}
return $newLabelError.html(errorText).show();
} else {
return $('.dropdown-menu-back', $dropdown.parent()).trigger('click');
}
});
};
newLabelField.on('keyup change', enableLabelCreateButton);
newColorField.on('keyup change', enableLabelCreateButton);
$newLabelCreateButton.disable().on('click', function(e) {
e.preventDefault();
e.stopPropagation();
return saveLabel();
});
}
new gl.CreateLabelDropdown($dropdown.closest('.dropdown').find('.dropdown-new-label'), projectId);
saveLabelData = function() {
var data, selected;
selected = $dropdown.closest('.selectbox').find("input[name='" + ($dropdown.data('field-name')) + "']").map(function() {
......@@ -272,6 +213,9 @@
isMRIndex = page === 'projects:merge_requests:index';
$selectbox.hide();
$value.removeAttr('style');
if (page === 'projects:boards:show') {
return;
}
if ($dropdown.hasClass('js-multiselect')) {
if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
selectedLabels = $dropdown.closest('form').find("input:hidden[name='" + ($dropdown.data('fieldName')) + "']");
......@@ -291,7 +235,7 @@
}
},
multiSelect: $dropdown.hasClass('js-multiselect'),
clicked: function(label) {
clicked: function(label, $el, e) {
var isIssueIndex, isMRIndex, page;
_this.enableBulkLabelDropdown();
if ($dropdown.hasClass('js-filter-bulk-update')) {
......@@ -300,7 +244,23 @@
page = $('body').data('page');
isIssueIndex = page === 'projects:issues:index';
isMRIndex = page === 'projects:merge_requests:index';
if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
if (page === 'projects:boards:show') {
if (label.isAny) {
gl.issueBoards.BoardsStore.state.filters['label_name'] = [];
} else if (label.title) {
gl.issueBoards.BoardsStore.state.filters['label_name'].push(label.title);
} else {
var filters = gl.issueBoards.BoardsStore.state.filters['label_name'];
filters = filters.filter(function (label) {
return label !== $el.text().trim();
});
gl.issueBoards.BoardsStore.state.filters['label_name'] = filters;
}
gl.issueBoards.BoardsStore.updateFiltersUrl();
e.preventDefault();
return;
} else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
if (!$dropdown.hasClass('js-multiselect')) {
selectedLabel = label.title;
return Issuable.filterResults($dropdown.closest('form'));
......
const HEAD_HEADER_TEXT = 'HEAD//our changes';
const ORIGIN_HEADER_TEXT = 'origin//their changes';
const HEAD_BUTTON_TITLE = 'Use ours';
const ORIGIN_BUTTON_TITLE = 'Use theirs';
class MergeConflictDataProvider {
getInitialData() {
const diffViewType = $.cookie('diff_view');
return {
isLoading : true,
hasError : false,
isParallel : diffViewType === 'parallel',
diffViewType : diffViewType,
isSubmitting : false,
conflictsData : {},
resolutionData : {}
}
}
decorateData(vueInstance, data) {
this.vueInstance = vueInstance;
if (data.type === 'error') {
vueInstance.hasError = true;
data.errorMessage = data.message;
}
else {
data.shortCommitSha = data.commit_sha.slice(0, 7);
data.commitMessage = data.commit_message;
this.setParallelLines(data);
this.setInlineLines(data);
this.updateResolutionsData(data);
}
vueInstance.conflictsData = data;
vueInstance.isSubmitting = false;
const conflictsText = this.getConflictsCount() > 1 ? 'conflicts' : 'conflict';
vueInstance.conflictsData.conflictsText = conflictsText;
}
updateResolutionsData(data) {
const vi = this.vueInstance;
data.files.forEach( (file) => {
file.sections.forEach( (section) => {
if (section.conflict) {
vi.$set(`resolutionData['${section.id}']`, false);
}
});
});
}
setParallelLines(data) {
data.files.forEach( (file) => {
file.filePath = this.getFilePath(file);
file.iconClass = `fa-${file.blob_icon}`;
file.blobPath = file.blob_path;
file.parallelLines = [];
const linesObj = { left: [], right: [] };
file.sections.forEach( (section) => {
const { conflict, lines, id } = section;
if (conflict) {
linesObj.left.push(this.getOriginHeaderLine(id));
linesObj.right.push(this.getHeadHeaderLine(id));
}
lines.forEach( (line) => {
const { type } = line;
if (conflict) {
if (type === 'old') {
linesObj.left.push(this.getLineForParallelView(line, id, 'conflict'));
}
else if (type === 'new') {
linesObj.right.push(this.getLineForParallelView(line, id, 'conflict', true));
}
}
else {
const lineType = type || 'context';
linesObj.left.push (this.getLineForParallelView(line, id, lineType));
linesObj.right.push(this.getLineForParallelView(line, id, lineType, true));
}
});
this.checkLineLengths(linesObj);
});
for (let i = 0, len = linesObj.left.length; i < len; i++) {
file.parallelLines.push([
linesObj.right[i],
linesObj.left[i]
]);
}
});
}
checkLineLengths(linesObj) {
let { left, right } = linesObj;
if (left.length !== right.length) {
if (left.length > right.length) {
const diff = left.length - right.length;
for (let i = 0; i < diff; i++) {
right.push({ lineType: 'emptyLine', richText: '' });
}
}
else {
const diff = right.length - left.length;
for (let i = 0; i < diff; i++) {
left.push({ lineType: 'emptyLine', richText: '' });
}
}
}
}
setInlineLines(data) {
data.files.forEach( (file) => {
file.iconClass = `fa-${file.blob_icon}`;
file.blobPath = file.blob_path;
file.filePath = this.getFilePath(file);
file.inlineLines = []
file.sections.forEach( (section) => {
let currentLineType = 'new';
const { conflict, lines, id } = section;
if (conflict) {
file.inlineLines.push(this.getHeadHeaderLine(id));
}
lines.forEach( (line) => {
const { type } = line;
if ((type === 'new' || type === 'old') && currentLineType !== type) {
currentLineType = type;
file.inlineLines.push({ lineType: 'emptyLine', richText: '' });
}
this.decorateLineForInlineView(line, id, conflict);
file.inlineLines.push(line);
})
if (conflict) {
file.inlineLines.push(this.getOriginHeaderLine(id));
}
});
});
}
handleSelected(sectionId, selection) {
const vi = this.vueInstance;
vi.resolutionData[sectionId] = selection;
vi.conflictsData.files.forEach( (file) => {
file.inlineLines.forEach( (line) => {
if (line.id === sectionId && (line.hasConflict || line.isHeader)) {
this.markLine(line, selection);
}
});
file.parallelLines.forEach( (lines) => {
const left = lines[0];
const right = lines[1];
const hasSameId = right.id === sectionId || left.id === sectionId;
const isLeftMatch = left.hasConflict || left.isHeader;
const isRightMatch = right.hasConflict || right.isHeader;
if (hasSameId && (isLeftMatch || isRightMatch)) {
this.markLine(left, selection);
this.markLine(right, selection);
}
})
});
}
updateViewType(newType) {
const vi = this.vueInstance;
if (newType === vi.diffView || !(newType === 'parallel' || newType === 'inline')) {
return;
}
vi.diffView = newType;
vi.isParallel = newType === 'parallel';
$.cookie('diff_view', newType); // TODO: Make sure that cookie path added.
$('.content-wrapper .container-fluid').toggleClass('container-limited');
}
markLine(line, selection) {
if (selection === 'head' && line.isHead) {
line.isSelected = true;
line.isUnselected = false;
}
else if (selection === 'origin' && line.isOrigin) {
line.isSelected = true;
line.isUnselected = false;
}
else {
line.isSelected = false;
line.isUnselected = true;
}
}
getConflictsCount() {
return Object.keys(this.vueInstance.resolutionData).length;
}
getResolvedCount() {
let count = 0;
const data = this.vueInstance.resolutionData;
for (const id in data) {
const resolution = data[id];
if (resolution) {
count++;
}
}
return count;
}
isReadyToCommit() {
const { conflictsData, isSubmitting } = this.vueInstance
const allResolved = this.getConflictsCount() === this.getResolvedCount();
const hasCommitMessage = $.trim(conflictsData.commitMessage).length;
return !isSubmitting && hasCommitMessage && allResolved;
}
getCommitButtonText() {
const initial = 'Commit conflict resolution';
const inProgress = 'Committing...';
const vue = this.vueInstance;
return vue ? vue.isSubmitting ? inProgress : initial : initial;
}
decorateLineForInlineView(line, id, conflict) {
const { type } = line;
line.id = id;
line.hasConflict = conflict;
line.isHead = type === 'new';
line.isOrigin = type === 'old';
line.hasMatch = type === 'match';
line.richText = line.rich_text;
line.isSelected = false;
line.isUnselected = false;
}
getLineForParallelView(line, id, lineType, isHead) {
const { old_line, new_line, rich_text } = line;
const hasConflict = lineType === 'conflict';
return {
id,
lineType,
hasConflict,
isHead : hasConflict && isHead,
isOrigin : hasConflict && !isHead,
hasMatch : lineType === 'match',
lineNumber : isHead ? new_line : old_line,
section : isHead ? 'head' : 'origin',
richText : rich_text,
isSelected : false,
isUnselected : false
}
}
getHeadHeaderLine(id) {
return {
id : id,
richText : HEAD_HEADER_TEXT,
buttonTitle : HEAD_BUTTON_TITLE,
type : 'new',
section : 'head',
isHeader : true,
isHead : true,
isSelected : false,
isUnselected: false
}
}
getOriginHeaderLine(id) {
return {
id : id,
richText : ORIGIN_HEADER_TEXT,
buttonTitle : ORIGIN_BUTTON_TITLE,
type : 'old',
section : 'origin',
isHeader : true,
isOrigin : true,
isSelected : false,
isUnselected: false
}
}
handleFailedRequest(vueInstance, data) {
vueInstance.hasError = true;
vueInstance.conflictsData.errorMessage = 'Something went wrong!';
}
getCommitData() {
return {
commit_message: this.vueInstance.conflictsData.commitMessage,
sections: this.vueInstance.resolutionData
}
}
getFilePath(file) {
const { old_path, new_path } = file;
return old_path === new_path ? new_path : `${old_path} → ${new_path}`;
}
}
//= require vue
class MergeConflictResolver {
constructor() {
this.dataProvider = new MergeConflictDataProvider()
this.initVue()
}
initVue() {
const that = this;
this.vue = new Vue({
el : '#conflicts',
name : 'MergeConflictResolver',
data : this.dataProvider.getInitialData(),
created : this.fetchData(),
computed : this.setComputedProperties(),
methods : {
handleSelected(sectionId, selection) {
that.dataProvider.handleSelected(sectionId, selection);
},
handleViewTypeChange(newType) {
that.dataProvider.updateViewType(newType);
},
commit() {
that.commit();
}
}
})
}
setComputedProperties() {
const dp = this.dataProvider;
return {
conflictsCount() { return dp.getConflictsCount() },
resolvedCount() { return dp.getResolvedCount() },
readyToCommit() { return dp.isReadyToCommit() },
commitButtonText() { return dp.getCommitButtonText() }
}
}
fetchData() {
const dp = this.dataProvider;
$.get($('#conflicts').data('conflictsPath'))
.done((data) => {
dp.decorateData(this.vue, data);
})
.error((data) => {
dp.handleFailedRequest(this.vue, data);
})
.always(() => {
this.vue.isLoading = false;
this.vue.$nextTick(() => {
$('#conflicts .js-syntax-highlight').syntaxHighlight();
});
if (this.vue.diffViewType === 'parallel') {
$('.content-wrapper .container-fluid').removeClass('container-limited');
}
})
}
commit() {
this.vue.isSubmitting = true;
$.post($('#conflicts').data('resolveConflictsPath'), this.dataProvider.getCommitData())
.done((data) => {
window.location.href = data.redirect_to;
})
.error(() => {
new Flash('Something went wrong!');
})
.always(() => {
this.vue.isSubmitting = false;
});
}
}
......@@ -9,6 +9,8 @@
MergeRequestTabs.prototype.buildsLoaded = false;
MergeRequestTabs.prototype.pipelinesLoaded = false;
MergeRequestTabs.prototype.commitsLoaded = false;
function MergeRequestTabs(opts) {
......@@ -50,6 +52,9 @@
} else if (action === 'builds') {
this.loadBuilds($target.attr('href'));
this.expandView();
} else if (action === 'pipelines') {
this.loadPipelines($target.attr('href'));
this.expandView();
} else {
this.expandView();
}
......@@ -82,7 +87,7 @@
action = 'notes';
}
this.currentAction = action;
new_state = this._location.pathname.replace(/\/(commits|diffs|builds)(\.html)?\/?$/, '');
new_state = this._location.pathname.replace(/\/(commits|diffs|builds|pipelines)(\.html)?\/?$/, '');
if (action !== 'notes') {
new_state += "/" + action;
}
......@@ -183,6 +188,21 @@
});
};
MergeRequestTabs.prototype.loadPipelines = function(source) {
if (this.pipelinesLoaded) {
return;
}
return this._get({
url: source + ".json",
success: function(data) {
$('#pipelines').html(data.html);
gl.utils.localTimeAgo($('.js-timeago', '#pipelines'));
this.pipelinesLoaded = true;
return this.scrollToElement("#pipelines");
}.bind(this)
});
};
MergeRequestTabs.prototype.toggleLoading = function(status) {
return $('.mr-loading-status .loading').toggle(status);
};
......
......@@ -28,7 +28,7 @@
MergeRequestWidget.prototype.addEventListeners = function() {
var allowedPages;
allowedPages = ['show', 'commits', 'builds', 'changes'];
allowedPages = ['show', 'commits', 'builds', 'pipelines', 'changes'];
return $(document).on('page:change.merge_request', (function(_this) {
return function() {
var page;
......@@ -53,7 +53,7 @@
return function(data) {
var callback, urlSuffix;
if (data.state === "merged") {
urlSuffix = deleteSourceBranch ? '?delete_source=true' : '';
urlSuffix = deleteSourceBranch ? '?deleted_source_branch=true' : '';
return window.location.href = window.location.pathname + urlSuffix;
} else if (data.merge_error) {
return $('.mr-widget-body').html("<h4>" + data.merge_error + "</h4>");
......
......@@ -94,7 +94,7 @@
$selectbox.hide();
return $value.css('display', '');
},
clicked: function(selected) {
clicked: function(selected, $el, e) {
var data, isIssueIndex, isMRIndex, page;
page = $('body').data('page');
isIssueIndex = page === 'projects:issues:index';
......@@ -102,7 +102,11 @@
if ($dropdown.hasClass('js-filter-bulk-update')) {
return;
}
if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
if (page === 'projects:boards:show') {
gl.issueBoards.BoardsStore.state.filters[$dropdown.data('field-name')] = selected.name;
gl.issueBoards.BoardsStore.updateFiltersUrl();
e.preventDefault();
} else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
if (selected.name != null) {
selectedMilestone = selected.name;
} else {
......
......@@ -141,7 +141,7 @@
$selectbox.hide();
return $value.css('display', '');
},
clicked: function(user) {
clicked: function(user, $el, e) {
var isIssueIndex, isMRIndex, page, selected;
page = $('body').data('page');
isIssueIndex = page === 'projects:issues:index';
......@@ -149,7 +149,12 @@
if ($dropdown.hasClass('js-filter-bulk-update')) {
return;
}
if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
if (page === 'projects:boards:show') {
selectedId = user.id;
gl.issueBoards.BoardsStore.state.filters[$dropdown.data('field-name')] = user.id;
gl.issueBoards.BoardsStore.updateFiltersUrl();
e.preventDefault();
} else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
selectedId = user.id;
return Issuable.filterResults($dropdown.closest('form'));
} else if ($dropdown.hasClass('js-filter-submit')) {
......
......@@ -124,3 +124,8 @@
}
}
}
@mixin dark-diff-match-line {
color: rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.1);
}
......@@ -276,3 +276,5 @@ $personal-access-tokens-disabled-label-color: #bbb;
$ci-output-bg: #1d1f21;
$ci-text-color: #c5c8c6;
$issue-boards-font-size: 15px;
......@@ -21,6 +21,10 @@
// Diff line
.line_holder {
&.match .line_content {
@include dark-diff-match-line;
}
td.diff-line-num.hll:not(.empty-cell),
td.line_content.hll:not(.empty-cell) {
background-color: #557;
......@@ -36,8 +40,7 @@
}
.line_content.match {
color: rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.1);
@include dark-diff-match-line;
}
}
......
......@@ -21,6 +21,10 @@
// Diff line
.line_holder {
&.match .line_content {
@include dark-diff-match-line;
}
td.diff-line-num.hll:not(.empty-cell),
td.line_content.hll:not(.empty-cell) {
background-color: #49483e;
......@@ -36,8 +40,7 @@
}
.line_content.match {
color: rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.1);
@include dark-diff-match-line;
}
}
......
......@@ -21,6 +21,10 @@
// Diff line
.line_holder {
&.match .line_content {
@include dark-diff-match-line;
}
td.diff-line-num.hll:not(.empty-cell),
td.line_content.hll:not(.empty-cell) {
background-color: #174652;
......@@ -36,8 +40,7 @@
}
.line_content.match {
color: rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.1);
@include dark-diff-match-line;
}
}
......
/* https://gist.github.com/qguv/7936275 */
@mixin matchLine {
color: $black-transparent;
background: rgba(255, 255, 255, 0.4);
}
.code.solarized-light {
// Line numbers
.line-numbers, .diff-line-num {
......@@ -21,6 +27,10 @@
// Diff line
.line_holder {
&.match .line_content {
@include matchLine;
}
td.diff-line-num.hll:not(.empty-cell),
td.line_content.hll:not(.empty-cell) {
background-color: #ddd8c5;
......@@ -36,8 +46,7 @@
}
.line_content.match {
color: $black-transparent;
background: rgba(255, 255, 255, 0.4);
@include matchLine;
}
}
......
/* https://github.com/aahan/pygments-github-style */
@mixin matchLine {
color: $black-transparent;
background-color: $match-line;
}
.code.white {
// Line numbers
.line-numbers, .diff-line-num {
......@@ -22,6 +28,10 @@
// Diff line
.line_holder {
&.match .line_content {
@include matchLine;
}
.diff-line-num {
&.old {
background-color: $line-number-old;
......@@ -57,8 +67,7 @@
}
&.match {
color: $black-transparent;
background-color: $match-line;
@include matchLine;
}
&.hll:not(.empty-cell) {
......
[v-cloak] {
display: none;
}
.user-can-drag {
cursor: -webkit-grab;
cursor: grab;
}
.is-dragging {
* {
cursor: -webkit-grabbing;
cursor: grabbing;
}
}
.dropdown-menu-issues-board-new {
width: 320px;
.dropdown-content {
max-height: 150px;
}
}
.issue-board-dropdown-content {
margin: 0 8px 10px;
padding-bottom: 10px;
border-bottom: 1px solid $dropdown-divider-color;
> p {
margin: 0;
font-size: 14px;
color: #9c9c9c;
}
}
.issue-boards-page {
.content-wrapper {
display: -webkit-flex;
display: flex;
-webkit-flex-direction: column;
flex-direction: column;
}
.sub-nav,
.issues-filters {
-webkit-flex: none;
flex: none;
}
.page-with-sidebar {
display: -webkit-flex;
display: flex;
min-height: 100vh;
max-height: 100vh;
padding-bottom: 0;
}
.issue-boards-content {
display: -webkit-flex;
display: flex;
-webkit-flex: 1;
flex: 1;
width: 100%;
.content {
display: -webkit-flex;
display: flex;
-webkit-flex-direction: column;
flex-direction: column;
width: 100%;
}
}
}
.boards-app-loading {
width: 100%;
font-size: 34px;
}
.boards-list {
display: -webkit-flex;
display: flex;
-webkit-flex: 1;
flex: 1;
-webkit-flex-basis: 0;
flex-basis: 0;
min-height: calc(100vh - 152px);
max-height: calc(100vh - 152px);
padding-top: 25px;
padding-right: ($gl-padding / 2);
padding-left: ($gl-padding / 2);
overflow-x: scroll;
@media (min-width: $screen-sm-min) {
min-height: 475px;
max-height: none;
}
}
.board {
display: -webkit-flex;
display: flex;
min-width: calc(100vw - 15px);
max-width: calc(100vw - 15px);
margin-bottom: 25px;
padding-right: ($gl-padding / 2);
padding-left: ($gl-padding / 2);
@media (min-width: $screen-sm-min) {
min-width: 400px;
max-width: 400px;
}
}
.board-inner {
display: -webkit-flex;
display: flex;
-webkit-flex-direction: column;
flex-direction: column;
width: 100%;
font-size: $issue-boards-font-size;
background: $background-color;
border: 1px solid $border-color;
border-radius: $border-radius-default;
}
.board-header {
border-top-left-radius: $border-radius-default;
border-top-right-radius: $border-radius-default;
&.has-border {
border-top: 3px solid;
.board-title {
padding-top: ($gl-padding - 3px);
}
}
}
.board-header-loading-spinner {
margin-right: 10px;
color: $gray-darkest;
}
.board-inner-container {
border-bottom: 1px solid $border-color;
padding: $gl-padding;
}
.board-title {
position: relative;
margin: 0;
padding: $gl-padding;
font-size: 1em;
border-bottom: 1px solid $border-color;
.board-mobile-handle {
position: relative;
left: 0;
top: 1px;
margin-top: 0;
margin-right: 5px;
}
}
.board-search-container {
position: relative;
background-color: #fff;
.form-control {
padding-right: 30px;
}
}
.board-search-icon,
.board-search-clear-btn {
position: absolute;
right: $gl-padding + 10px;
top: 50%;
margin-top: -7px;
font-size: 14px;
}
.board-search-icon {
color: $gl-placeholder-color;
}
.board-search-clear-btn {
padding: 0;
line-height: 1;
background: transparent;
border: 0;
outline: 0;
&:hover {
color: $gl-link-color;
}
}
.board-delete {
margin-right: 10px;
padding: 0;
color: $gray-darkest;
background-color: transparent;
border: 0;
outline: 0;
&:hover {
color: $gl-link-color;
}
}
.board-blank-state {
height: 100%;
padding: $gl-padding;
background-color: #fff;
}
.board-blank-state-list {
list-style: none;
> li:not(:last-child) {
margin-bottom: 8px;
}
.label-color {
position: relative;
top: 2px;
display: inline-block;
width: 16px;
height: 16px;
margin-right: 3px;
border-radius: $border-radius-default;
}
}
.board-list {
-webkit-flex: 1;
flex: 1;
height: 400px;
margin-bottom: 0;
padding: 5px;
overflow-y: scroll;
overflow-x: hidden;
}
.board-list-loading {
margin-top: 10px;
font-size: 26px;
}
.is-ghost {
opacity: 0.3;
}
.is-dragging {
// Important because plugin sets inline CSS
opacity: 1!important;
}
.card {
position: relative;
width: 100%;
padding: 10px $gl-padding;
background: #fff;
border-radius: $border-radius-default;
box-shadow: 0 1px 2px rgba(186, 186, 186, 0.5);
list-style: none;
&.user-can-drag {
padding-left: ($gl-padding * 2);
@media (min-width: $screen-sm-min) {
padding-left: $gl-padding;
}
}
&:not(:last-child) {
margin-bottom: 5px;
}
a {
cursor: pointer;
}
.label {
border: 0;
outline: 0;
}
.confidential-icon {
margin-right: 5px;
}
}
.board-mobile-handle {
position: absolute;
left: 10px;
top: 50%;
margin-top: (-15px / 2);
@media (min-width: $screen-sm-min) {
display: none;
}
}
.card-title {
margin: 0;
font-size: 1em;
a {
color: inherit;
}
}
.card-footer {
margin-top: 5px;
.label {
margin-right: 4px;
font-size: (14px / $issue-boards-font-size) * 1em;
}
}
.card-number {
margin-right: 8px;
font-weight: 500;
}
$colors: (
white_header_head_neutral : #e1fad7,
white_line_head_neutral : #effdec,
white_button_head_neutral : #9adb84,
white_header_head_chosen : #baf0a8,
white_line_head_chosen : #e1fad7,
white_button_head_chosen : #52c22d,
white_header_origin_neutral : #e0f0ff,
white_line_origin_neutral : #f2f9ff,
white_button_origin_neutral : #87c2fa,
white_header_origin_chosen : #add8ff,
white_line_origin_chosen : #e0f0ff,
white_button_origin_chosen : #268ced,
white_header_not_chosen : #f0f0f0,
white_line_not_chosen : #f9f9f9,
dark_header_head_neutral : rgba(#3f3, .2),
dark_line_head_neutral : rgba(#3f3, .1),
dark_button_head_neutral : #40874f,
dark_header_head_chosen : rgba(#3f3, .33),
dark_line_head_chosen : rgba(#3f3, .2),
dark_button_head_chosen : #258537,
dark_header_origin_neutral : rgba(#2878c9, .4),
dark_line_origin_neutral : rgba(#2878c9, .3),
dark_button_origin_neutral : #2a5c8c,
dark_header_origin_chosen : rgba(#2878c9, .6),
dark_line_origin_chosen : rgba(#2878c9, .4),
dark_button_origin_chosen : #1d6cbf,
dark_header_not_chosen : rgba(#fff, .25),
dark_line_not_chosen : rgba(#fff, .1),
monokai_header_head_neutral : rgba(#a6e22e, .25),
monokai_line_head_neutral : rgba(#a6e22e, .1),
monokai_button_head_neutral : #376b20,
monokai_header_head_chosen : rgba(#a6e22e, .4),
monokai_line_head_chosen : rgba(#a6e22e, .25),
monokai_button_head_chosen : #39800d,
monokai_header_origin_neutral : rgba(#60d9f1, .35),
monokai_line_origin_neutral : rgba(#60d9f1, .15),
monokai_button_origin_neutral : #38848c,
monokai_header_origin_chosen : rgba(#60d9f1, .5),
monokai_line_origin_chosen : rgba(#60d9f1, .35),
monokai_button_origin_chosen : #3ea4b2,
monokai_header_not_chosen : rgba(#76715d, .24),
monokai_line_not_chosen : rgba(#76715d, .1),
solarized_light_header_head_neutral : rgba(#859900, .37),
solarized_light_line_head_neutral : rgba(#859900, .2),
solarized_light_button_head_neutral : #afb262,
solarized_light_header_head_chosen : rgba(#859900, .5),
solarized_light_line_head_chosen : rgba(#859900, .37),
solarized_light_button_head_chosen : #94993d,
solarized_light_header_origin_neutral : rgba(#2878c9, .37),
solarized_light_line_origin_neutral : rgba(#2878c9, .15),
solarized_light_button_origin_neutral : #60a1bf,
solarized_light_header_origin_chosen : rgba(#2878c9, .6),
solarized_light_line_origin_chosen : rgba(#2878c9, .37),
solarized_light_button_origin_chosen : #2482b2,
solarized_light_header_not_chosen : rgba(#839496, .37),
solarized_light_line_not_chosen : rgba(#839496, .2),
solarized_dark_header_head_neutral : rgba(#859900, .35),
solarized_dark_line_head_neutral : rgba(#859900, .15),
solarized_dark_button_head_neutral : #376b20,
solarized_dark_header_head_chosen : rgba(#859900, .5),
solarized_dark_line_head_chosen : rgba(#859900, .35),
solarized_dark_button_head_chosen : #39800d,
solarized_dark_header_origin_neutral : rgba(#2878c9, .35),
solarized_dark_line_origin_neutral : rgba(#2878c9, .15),
solarized_dark_button_origin_neutral : #086799,
solarized_dark_header_origin_chosen : rgba(#2878c9, .6),
solarized_dark_line_origin_chosen : rgba(#2878c9, .35),
solarized_dark_button_origin_chosen : #0082cc,
solarized_dark_header_not_chosen : rgba(#839496, .25),
solarized_dark_line_not_chosen : rgba(#839496, .15)
);
@mixin color-scheme($color) {
.header.line_content, .diff-line-num {
&.origin {
background-color: map-get($colors, #{$color}_header_origin_neutral);
border-color: map-get($colors, #{$color}_header_origin_neutral);
button {
background-color: map-get($colors, #{$color}_button_origin_neutral);
border-color: darken(map-get($colors, #{$color}_button_origin_neutral), 15);
}
&.selected {
background-color: map-get($colors, #{$color}_header_origin_chosen);
border-color: map-get($colors, #{$color}_header_origin_chosen);
button {
background-color: map-get($colors, #{$color}_button_origin_chosen);
border-color: darken(map-get($colors, #{$color}_button_origin_chosen), 15);
}
}
&.unselected {
background-color: map-get($colors, #{$color}_header_not_chosen);
border-color: map-get($colors, #{$color}_header_not_chosen);
button {
background-color: lighten(map-get($colors, #{$color}_button_origin_neutral), 15);
border-color: map-get($colors, #{$color}_button_origin_neutral);
}
}
}
&.head {
background-color: map-get($colors, #{$color}_header_head_neutral);
border-color: map-get($colors, #{$color}_header_head_neutral);
button {
background-color: map-get($colors, #{$color}_button_head_neutral);
border-color: darken(map-get($colors, #{$color}_button_head_neutral), 15);
}
&.selected {
background-color: map-get($colors, #{$color}_header_head_chosen);
border-color: map-get($colors, #{$color}_header_head_chosen);
button {
background-color: map-get($colors, #{$color}_button_head_chosen);
border-color: darken(map-get($colors, #{$color}_button_head_chosen), 15);
}
}
&.unselected {
background-color: map-get($colors, #{$color}_header_not_chosen);
border-color: map-get($colors, #{$color}_header_not_chosen);
button {
background-color: lighten(map-get($colors, #{$color}_button_head_neutral), 15);
border-color: map-get($colors, #{$color}_button_head_neutral);
}
}
}
}
.line_content {
&.origin {
background-color: map-get($colors, #{$color}_line_origin_neutral);
&.selected {
background-color: map-get($colors, #{$color}_line_origin_chosen);
}
&.unselected {
background-color: map-get($colors, #{$color}_line_not_chosen);
}
}
&.head {
background-color: map-get($colors, #{$color}_line_head_neutral);
&.selected {
background-color: map-get($colors, #{$color}_line_head_chosen);
}
&.unselected {
background-color: map-get($colors, #{$color}_line_not_chosen);
}
}
}
}
#conflicts {
.white {
@include color-scheme('white')
}
.dark {
@include color-scheme('dark')
}
.monokai {
@include color-scheme('monokai')
}
.solarized-light {
@include color-scheme('solarized_light')
}
.solarized-dark {
@include color-scheme('solarized_dark')
}
.diff-wrap-lines .line_content {
white-space: normal;
min-height: 19px;
}
.line_content.header {
position: relative;
button {
border-radius: 2px;
font-size: 10px;
position: absolute;
right: 10px;
padding: 0;
outline: none;
color: #fff;
width: 75px; // static width to make 2 buttons have same width
height: 19px;
}
}
.btn-success .fa-spinner {
color: #fff;
}
}
......@@ -229,3 +229,15 @@
box-shadow: none;
}
}
.pipelines.tab-pane {
.content-list.pipelines {
overflow: scroll;
}
.stage {
max-width: 60px;
width: 60px;
}
}
class Projects::BoardListsController < Projects::ApplicationController
respond_to :json
before_action :authorize_admin_list!
rescue_from ActiveRecord::RecordNotFound, with: :record_not_found
def create
list = Boards::Lists::CreateService.new(project, current_user, list_params).execute
if list.valid?
render json: list.as_json(only: [:id, :list_type, :position], methods: [:title], include: { label: { only: [:id, :title, :description, :color, :priority] } })
else
render json: list.errors, status: :unprocessable_entity
end
end
def update
service = Boards::Lists::MoveService.new(project, current_user, move_params)
if service.execute
head :ok
else
head :unprocessable_entity
end
end
def destroy
service = Boards::Lists::DestroyService.new(project, current_user, params)
if service.execute
head :ok
else
head :unprocessable_entity
end
end
def generate
service = Boards::Lists::GenerateService.new(project, current_user)
if service.execute
render json: project.board.lists.label.as_json(only: [:id, :list_type, :position], methods: [:title], include: { label: { only: [:id, :title, :description, :color, :priority] } })
else
head :unprocessable_entity
end
end
private
def authorize_admin_list!
return render_403 unless can?(current_user, :admin_list, project)
end
def list_params
params.require(:list).permit(:label_id)
end
def move_params
params.require(:list).permit(:position).merge(id: params[:id])
end
def record_not_found(exception)
render json: { error: exception.message }, status: :not_found
end
end
module Projects
module Boards
class ApplicationController < Projects::ApplicationController
respond_to :json
rescue_from ActiveRecord::RecordNotFound, with: :record_not_found
private
def record_not_found(exception)
render json: { error: exception.message }, status: :not_found
end
end
end
end
module Projects
module Boards
class IssuesController < Boards::ApplicationController
before_action :authorize_read_issue!, only: [:index]
before_action :authorize_update_issue!, only: [:update]
def index
issues = ::Boards::Issues::ListService.new(project, current_user, filter_params).execute
issues = issues.page(params[:page])
render json: issues.as_json(
only: [:iid, :title, :confidential],
include: {
assignee: { only: [:id, :name, :username], methods: [:avatar_url] },
labels: { only: [:id, :title, :description, :color, :priority] }
})
end
def update
service = ::Boards::Issues::MoveService.new(project, current_user, move_params)
if service.execute(issue)
head :ok
else
head :unprocessable_entity
end
end
private
def issue
@issue ||=
IssuesFinder.new(current_user, project_id: project.id, state: 'all')
.execute
.where(iid: params[:id])
.first!
end
def authorize_read_issue!
return render_403 unless can?(current_user, :read_issue, project)
end
def authorize_update_issue!
return render_403 unless can?(current_user, :update_issue, issue)
end
def filter_params
params.merge(id: params[:list_id])
end
def move_params
params.permit(:id, :from_list_id, :to_list_id)
end
end
end
end
module Projects
module Boards
class ListsController < Boards::ApplicationController
before_action :authorize_admin_list!, only: [:create, :update, :destroy, :generate]
before_action :authorize_read_list!, only: [:index]
def index
render json: serialize_as_json(project.board.lists)
end
def create
list = ::Boards::Lists::CreateService.new(project, current_user, list_params).execute
if list.valid?
render json: serialize_as_json(list)
else
render json: list.errors, status: :unprocessable_entity
end
end
def update
list = project.board.lists.movable.find(params[:id])
service = ::Boards::Lists::MoveService.new(project, current_user, move_params)
if service.execute(list)
head :ok
else
head :unprocessable_entity
end
end
def destroy
list = project.board.lists.destroyable.find(params[:id])
service = ::Boards::Lists::DestroyService.new(project, current_user, params)
if service.execute(list)
head :ok
else
head :unprocessable_entity
end
end
def generate
service = ::Boards::Lists::GenerateService.new(project, current_user)
if service.execute
render json: serialize_as_json(project.board.lists.movable)
else
head :unprocessable_entity
end
end
private
def authorize_admin_list!
return render_403 unless can?(current_user, :admin_list, project)
end
def authorize_read_list!
return render_403 unless can?(current_user, :read_list, project)
end
def list_params
params.require(:list).permit(:label_id)
end
def move_params
params.require(:list).permit(:position)
end
def serialize_as_json(resource)
resource.as_json(
only: [:id, :list_type, :position],
methods: [:title],
include: {
label: { only: [:id, :title, :description, :color, :priority] }
})
end
end
end
end
class Projects::BoardsController < Projects::ApplicationController
respond_to :html
before_action :authorize_read_board!, only: [:show]
def show
::Boards::CreateService.new(project, current_user).execute
end
private
def authorize_read_board!
return access_denied! unless can?(current_user, :read_board, project)
end
end
......@@ -9,15 +9,15 @@ class Projects::MergeRequestsController < Projects::ApplicationController
before_action :module_enabled
before_action :merge_request, only: [
:edit, :update, :show, :diffs, :commits, :builds, :merge, :merge_check,
:ci_status, :toggle_subscription, :cancel_merge_when_build_succeeds, :remove_wip
:edit, :update, :show, :diffs, :commits, :conflicts, :builds, :pipelines, :merge, :merge_check,
:ci_status, :toggle_subscription, :cancel_merge_when_build_succeeds, :remove_wip, :resolve_conflicts
]
before_action :validates_merge_request, only: [:show, :diffs, :commits, :builds]
before_action :define_show_vars, only: [:show, :diffs, :commits, :builds]
before_action :validates_merge_request, only: [:show, :diffs, :commits, :builds, :pipelines]
before_action :define_show_vars, only: [:show, :diffs, :commits, :conflicts, :builds, :pipelines]
before_action :define_widget_vars, only: [:merge, :cancel_merge_when_build_succeeds, :merge_check]
before_action :define_commit_vars, only: [:diffs]
before_action :define_diff_comment_vars, only: [:diffs]
before_action :ensure_ref_fetched, only: [:show, :diffs, :commits, :builds]
before_action :ensure_ref_fetched, only: [:show, :diffs, :commits, :builds, :conflicts, :pipelines]
# Allow read any merge_request
before_action :authorize_read_merge_request!
......@@ -28,6 +28,8 @@ class Projects::MergeRequestsController < Projects::ApplicationController
# Allow modify merge_request
before_action :authorize_update_merge_request!, only: [:close, :edit, :update, :remove_wip, :sort]
before_action :authorize_can_resolve_conflicts!, only: [:conflicts, :resolve_conflicts]
def index
terms = params['issue_search']
@merge_requests = merge_requests_collection
......@@ -130,6 +132,47 @@ class Projects::MergeRequestsController < Projects::ApplicationController
end
end
def conflicts
respond_to do |format|
format.html { define_discussion_vars }
format.json do
if @merge_request.conflicts_can_be_resolved_in_ui?
render json: @merge_request.conflicts
elsif @merge_request.can_be_merged?
render json: {
message: 'The merge conflicts for this merge request have already been resolved. Please return to the merge request.',
type: 'error'
}
else
render json: {
message: 'The merge conflicts for this merge request cannot be resolved through GitLab. Please try to resolve them locally.',
type: 'error'
}
end
end
end
end
def resolve_conflicts
return render_404 unless @merge_request.conflicts_can_be_resolved_in_ui?
if @merge_request.can_be_merged?
render status: :bad_request, json: { message: 'The merge conflicts for this merge request have already been resolved.' }
return
end
begin
MergeRequests::ResolveService.new(@merge_request.source_project, current_user, params).execute(@merge_request)
flash[:notice] = 'All merge conflicts were resolved. The merge request can now be merged.'
render json: { redirect_to: namespace_project_merge_request_url(@project.namespace, @project, @merge_request, resolved_conflicts: true) }
rescue Gitlab::Conflict::File::MissingResolution => e
render status: :bad_request, json: { message: e.message }
end
end
def builds
respond_to do |format|
format.html do
......@@ -141,6 +184,19 @@ class Projects::MergeRequestsController < Projects::ApplicationController
end
end
def pipelines
@pipelines = @merge_request.all_pipelines
respond_to do |format|
format.html do
define_discussion_vars
render 'show'
end
format.json { render json: { html: view_to_html_string('projects/merge_requests/show/_pipelines') } }
end
end
def new
build_merge_request
@noteable = @merge_request
......@@ -338,6 +394,10 @@ class Projects::MergeRequestsController < Projects::ApplicationController
return render_404 unless can?(current_user, :admin_merge_request, @merge_request)
end
def authorize_can_resolve_conflicts!
return render_404 unless @merge_request.conflicts_can_be_resolved_by?(current_user)
end
def module_enabled
return render_404 unless @project.merge_requests_enabled
end
......@@ -409,7 +469,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
noteable_id: @merge_request.id
}
@use_legacy_diff_notes = !@merge_request.support_new_diff_notes?
@use_legacy_diff_notes = !@merge_request.has_complete_diff_refs?
@grouped_diff_discussions = @merge_request.notes.inc_author_project_award_emoji.grouped_diff_discussions
Banzai::NoteRenderer.render(
......
......@@ -320,4 +320,8 @@ module ApplicationHelper
capture(&block)
end
end
def page_class
"issue-boards-page" if current_controller?(:boards)
end
end
......@@ -98,7 +98,16 @@ module CommitsHelper
end
def link_to_browse_code(project, commit)
if current_controller?(:projects, :commits)
if @path.blank?
return link_to(
"Browse Files",
namespace_project_tree_path(project.namespace, project, commit),
class: "btn btn-default"
)
end
return unless current_controller?(:projects, :commits)
if @repo.blob_at(commit.id, @path)
return link_to(
"Browse File",
......@@ -115,12 +124,6 @@ module CommitsHelper
)
end
end
link_to(
"Browse Files",
namespace_project_tree_path(project.namespace, project, commit),
class: "btn btn-default"
)
end
def revert_commit_link(commit, continue_to_path, btn_class: nil, has_tooltip: true)
return unless current_user
......
......@@ -24,6 +24,7 @@ module NavHelper
current_path?('merge_requests#diffs') ||
current_path?('merge_requests#commits') ||
current_path?('merge_requests#builds') ||
current_path?('merge_requests#conflicts') ||
current_path?('issues#show')
if cookies[:collapsed_gutter] == 'true'
"page-gutter right-sidebar-collapsed"
......
......@@ -90,6 +90,8 @@ class Ability
if project && project.public?
rules = [
:read_project,
:read_board,
:read_list,
:read_wiki,
:read_label,
:read_milestone,
......@@ -228,6 +230,8 @@ class Ability
:read_project,
:read_wiki,
:read_issue,
:read_board,
:read_list,
:read_label,
:read_milestone,
:read_project_snippet,
......@@ -249,6 +253,7 @@ class Ability
:update_issue,
:admin_issue,
:admin_label,
:admin_list,
:read_commit_status,
:read_build,
:read_container_image,
......
class Board < ActiveRecord::Base
belongs_to :project
has_many :lists, -> { order(:list_type, :position) }, dependent: :delete_all
validates :project, presence: true
end
......@@ -109,7 +109,11 @@ class DiffNote < Note
private
def supported?
<<<<<<< HEAD
for_commit? || self.noteable.support_new_diff_notes?
=======
!self.for_merge_request? || self.noteable.has_complete_diff_refs?
>>>>>>> master
end
def noteable_diff_refs
......
......@@ -13,6 +13,8 @@ class Label < ActiveRecord::Base
default_value_for :color, DEFAULT_COLOR
belongs_to :project
has_many :lists, dependent: :destroy
has_many :label_links, dependent: :destroy
has_many :issues, through: :label_links, source: :target, source_type: 'Issue'
has_many :merge_requests, through: :label_links, source: :target, source_type: 'MergeRequest'
......
class List < ActiveRecord::Base
belongs_to :board
belongs_to :label
enum list_type: { backlog: 0, label: 1, done: 2 }
validates :board, :list_type, presence: true
validates :label, :position, presence: true, if: :label?
validates :label_id, uniqueness: { scope: :board_id }, if: :label?
validates :position, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, if: :label?
before_destroy :can_be_destroyed
scope :destroyable, -> { where(list_type: list_types[:label]) }
scope :movable, -> { where(list_type: list_types[:label]) }
def destroyable?
label?
end
def movable?
label?
end
def title
label? ? label.name : list_type.humanize
end
private
def can_be_destroyed
destroyable?
end
end
......@@ -700,10 +700,21 @@ class MergeRequest < ActiveRecord::Base
diverged_commits_count > 0
end
def commits_sha
commits.map(&:sha)
end
def pipeline
@pipeline ||= source_project.pipeline(diff_head_sha, source_branch) if diff_head_sha && source_project
end
def all_pipelines
@all_pipelines ||=
if diff_head_sha && source_project
source_project.pipelines.order(id: :desc).where(sha: commits_sha, ref: source_branch)
end
end
def merge_commit
@merge_commit ||= project.commit(merge_commit_sha) if merge_commit_sha
end
......@@ -716,12 +727,12 @@ class MergeRequest < ActiveRecord::Base
merge_commit
end
def support_new_diff_notes?
def has_complete_diff_refs?
diff_sha_refs && diff_sha_refs.complete?
end
def update_diff_notes_positions(old_diff_refs:, new_diff_refs:)
return unless support_new_diff_notes?
return unless has_complete_diff_refs?
return if new_diff_refs == old_diff_refs
active_diff_notes = self.notes.diff_notes.select do |note|
......@@ -749,4 +760,26 @@ class MergeRequest < ActiveRecord::Base
def keep_around_commit
project.repository.keep_around(self.merge_commit_sha)
end
def conflicts
@conflicts ||= Gitlab::Conflict::FileCollection.new(self)
end
def conflicts_can_be_resolved_by?(user)
access = ::Gitlab::UserAccess.new(user, project: source_project)
access.can_push_to_branch?(source_branch)
end
def conflicts_can_be_resolved_in_ui?
return @conflicts_can_be_resolved_in_ui if defined?(@conflicts_can_be_resolved_in_ui)
return @conflicts_can_be_resolved_in_ui = false unless cannot_be_merged?
return @conflicts_can_be_resolved_in_ui = false unless has_complete_diff_refs?
begin
@conflicts_can_be_resolved_in_ui = conflicts.files.each(&:lines)
rescue Gitlab::Conflict::Parser::ParserError, Gitlab::Conflict::FileCollection::ConflictSideMissing
@conflicts_can_be_resolved_in_ui = false
end
end
end
......@@ -62,6 +62,8 @@ class Project < ActiveRecord::Base
belongs_to :group, -> { where(type: Group) }, foreign_key: 'namespace_id'
belongs_to :namespace
has_one :board, dependent: :destroy
has_one :last_event, -> {order 'events.created_at DESC'}, class_name: 'Event', foreign_key: 'project_id'
# Project services
......
......@@ -391,6 +391,8 @@ class Repository
expire_exists_cache
expire_root_ref_cache
expire_emptiness_caches
repository_event(:create_repository)
end
# Runs code just before a repository is deleted.
......@@ -407,6 +409,8 @@ class Repository
expire_root_ref_cache
expire_emptiness_caches
expire_exists_cache
repository_event(:remove_repository)
end
# Runs code just before the HEAD of a repository is changed.
......@@ -414,6 +418,8 @@ class Repository
# Cached divergent commit counts are based on repository head
expire_branch_cache
expire_root_ref_cache
repository_event(:change_default_branch)
end
# Runs code before pushing (= creating or removing) a tag.
......@@ -421,12 +427,16 @@ class Repository
expire_cache
expire_tags_cache
expire_tag_count_cache
repository_event(:push_tag)
end
# Runs code before removing a tag.
def before_remove_tag
expire_tags_cache
expire_tag_count_cache
repository_event(:remove_tag)
end
def before_import
......@@ -443,6 +453,8 @@ class Repository
# Runs code after a new commit has been pushed.
def after_push_commit(branch_name, revision)
expire_cache(branch_name, revision)
repository_event(:push_commit, branch: branch_name)
end
# Runs code after a new branch has been created.
......@@ -450,11 +462,15 @@ class Repository
expire_branches_cache
expire_has_visible_content_cache
expire_branch_count_cache
repository_event(:push_branch)
end
# Runs code before removing an existing branch.
def before_remove_branch
expire_branches_cache
repository_event(:remove_branch)
end
# Runs code after an existing branch has been removed.
......@@ -869,6 +885,14 @@ class Repository
end
end
def resolve_conflicts(user, branch, params)
commit_with_hooks(user, branch) do
committer = user_to_committer(user)
Rugged::Commit.create(rugged, params.merge(author: committer, committer: committer))
end
end
def check_revert_content(commit, base_branch)
source_sha = find_branch(base_branch).target.sha
args = [commit.id, source_sha]
......@@ -1059,4 +1083,8 @@ class Repository
def keep_around_ref_name(sha)
"refs/keep-around/#{sha}"
end
def repository_event(event, tags = {})
Gitlab::Metrics.add_event(event, { path: path_with_namespace }.merge(tags))
end
end
module Boards
class BaseService < ::BaseService
delegate :board, to: :project
end
end
module Boards
class CreateService < Boards::BaseService
def execute
create_board! unless project.board.present?
project.board
end
private
def create_board!
project.create_board
project.board.lists.create(list_type: :backlog)
project.board.lists.create(list_type: :done)
end
end
end
module Boards
module Issues
class ListService < Boards::BaseService
def execute
issues = IssuesFinder.new(current_user, filter_params).execute
issues = without_board_labels(issues) unless list.movable?
issues = with_list_label(issues) if list.movable?
issues
end
private
def list
@list ||= board.lists.find(params[:id])
end
def filter_params
set_default_scope
set_default_sort
set_project
set_state
params
end
def set_default_scope
params[:scope] = 'all'
end
def set_default_sort
params[:sort] = 'priority'
end
def set_project
params[:project_id] = project.id
end
def set_state
params[:state] = list.done? ? 'closed' : 'opened'
end
def board_label_ids
@board_label_ids ||= board.lists.movable.pluck(:label_id)
end
def without_board_labels(issues)
return issues unless board_label_ids.any?
issues.where.not(
LabelLink.where("label_links.target_type = 'Issue' AND label_links.target_id = issues.id")
.where(label_id: board_label_ids).limit(1).arel.exists
)
end
def with_list_label(issues)
issues.where(
LabelLink.where("label_links.target_type = 'Issue' AND label_links.target_id = issues.id")
.where("label_links.label_id = ?", list.label_id).limit(1).arel.exists
)
end
end
end
end
module Boards
module Issues
class MoveService < Boards::BaseService
def execute(issue)
return false unless can?(current_user, :update_issue, issue)
return false unless valid_move?
update_service.execute(issue)
end
private
def valid_move?
moving_from_list.present? && moving_to_list.present? &&
moving_from_list != moving_to_list
end
def moving_from_list
@moving_from_list ||= board.lists.find_by(id: params[:from_list_id])
end
def moving_to_list
@moving_to_list ||= board.lists.find_by(id: params[:to_list_id])
end
def update_service
::Issues::UpdateService.new(project, current_user, issue_params)
end
def issue_params
{
add_label_ids: add_label_ids,
remove_label_ids: remove_label_ids,
state_event: issue_state
}
end
def issue_state
return 'reopen' if moving_from_list.done?
return 'close' if moving_to_list.done?
end
def add_label_ids
[moving_to_list.label_id].compact
end
def remove_label_ids
label_ids =
if moving_to_list.movable?
moving_from_list.label_id
else
board.lists.movable.pluck(:label_id)
end
Array(label_ids).compact
end
end
end
end
module Boards
module Lists
class CreateService < Boards::BaseService
def execute
List.transaction do
create_list_at(next_position)
end
end
private
def next_position
max_position = board.lists.movable.maximum(:position)
max_position.nil? ? 0 : max_position.succ
end
def create_list_at(position)
board.lists.create(params.merge(list_type: :label, position: position))
end
end
end
end
module Boards
module Lists
class DestroyService < Boards::BaseService
def execute(list)
return false unless list.destroyable?
list.with_lock do
decrement_higher_lists(list)
remove_list(list)
end
end
private
def decrement_higher_lists(list)
board.lists.movable.where('position > ?', list.position)
.update_all('position = position - 1')
end
def remove_list(list)
list.destroy
end
end
end
end
module Boards
module Lists
class GenerateService < Boards::BaseService
def execute
return false unless board.lists.movable.empty?
List.transaction do
label_params.each { |params| create_list(params) }
end
true
end
private
def create_list(params)
label = find_or_create_label(params)
Lists::CreateService.new(project, current_user, label_id: label.id).execute
end
def find_or_create_label(params)
project.labels.create_with(color: params[:color])
.find_or_create_by(name: params[:name])
end
def label_params
[
{ name: 'Development', color: '#5CB85C' },
{ name: 'Testing', color: '#F0AD4E' },
{ name: 'Production', color: '#FF5F00' },
{ name: 'Ready', color: '#FF0000' }
]
end
end
end
end
module Boards
module Lists
class MoveService < Boards::BaseService
def execute(list)
@old_position = list.position
@new_position = params[:position]
return false unless list.movable?
return false unless valid_move?
list.with_lock do
reorder_intermediate_lists
update_list_position(list)
end
end
private
attr_reader :old_position, :new_position
def valid_move?
new_position.present? && new_position != old_position &&
new_position >= 0 && new_position < board.lists.movable.size
end
def reorder_intermediate_lists
if old_position < new_position
decrement_intermediate_lists
else
increment_intermediate_lists
end
end
def decrement_intermediate_lists
board.lists.movable.where('position > ?', old_position)
.where('position <= ?', new_position)
.update_all('position = position - 1')
end
def increment_intermediate_lists
board.lists.movable.where('position >= ?', new_position)
.where('position < ?', old_position)
.update_all('position = position + 1')
end
def update_list_position(list)
list.update_attribute(:position, new_position)
end
end
end
end
module MergeRequests
class ResolveService < MergeRequests::BaseService
attr_accessor :conflicts, :rugged, :merge_index
def execute(merge_request)
@conflicts = merge_request.conflicts
@rugged = project.repository.rugged
@merge_index = conflicts.merge_index
conflicts.files.each do |file|
write_resolved_file_to_index(file, params[:sections])
end
commit_params = {
message: params[:commit_message] || conflicts.default_commit_message,
parents: [conflicts.our_commit, conflicts.their_commit].map(&:oid),
tree: merge_index.write_tree(rugged)
}
project.repository.resolve_conflicts(current_user, merge_request.source_branch, commit_params)
end
def write_resolved_file_to_index(file, resolutions)
new_file = file.resolve_lines(resolutions).map(&:text).join("\n")
our_path = file.our_path
merge_index.add(path: our_path, oid: rugged.write(new_file, :blob), mode: file.our_mode)
merge_index.conflict_remove(our_path)
end
end
end
......@@ -23,7 +23,6 @@
= render "layouts/broadcast"
= render "layouts/flash"
= yield :flash_message
%div{ class: (container_class unless @no_container) }
%div{ class: "#{(container_class unless @no_container)} #{@content_class}" }
.content
.clearfix
= yield
!!! 5
%html{ lang: "en"}
%html{ lang: "en", class: "#{page_class}" }
= render "layouts/head"
%body{class: "#{user_application_theme}", data: {page: body_data_page, project: "#{@project.path if @project}", group: "#{@group.path if @group}"}}
= Gon::Base.render_data
......
%board-blank-state{ "inline-template" => true,
"v-if" => "list.id == 'blank'" }
.board-blank-state
%p
Add the following default lists to your Issue Board with one click:
%ul.board-blank-state-list
%li{ "v-for" => "label in predefinedLabels" }
%span.label-color{ ":style" => "{ backgroundColor: label.color } " }
{{ label.title }}
%p
Starting out with the default set of lists will get you right on the way to making the most of your board.
%button.btn.btn-create.btn-inverted.btn-block{ type: "button", "@click.stop" => "addDefaultLists" }
Add default lists
%button.btn.btn-default.btn-block{ type: "button", "@click.stop" => "clearBlankState" }
Nevermind, I'll use my own
%board{ "inline-template" => true,
"v-cloak" => true,
"v-for" => "list in state.lists | orderBy 'position'",
"v-ref:board" => true,
":list" => "list",
":disabled" => "disabled",
":issue-link-base" => "issueLinkBase",
"track-by" => "_uid" }
.board{ ":class" => "{ 'is-draggable': !list.preset }",
":data-id" => "list.id" }
.board-inner
%header.board-header{ ":class" => "{ 'has-border': list.label }", ":style" => "{ borderTopColor: (list.label ? list.label.color : null) }" }
%h3.board-title.js-board-handle{ ":class" => "{ 'user-can-drag': (!disabled && !list.preset) }" }
= icon("align-justify", class: "board-mobile-handle js-board-drag-handle", "v-if" => "(!disabled && !list.preset)")
{{ list.title }}
%span.pull-right{ "v-if" => "list.type !== 'blank'" }
{{ list.issues.length }}
- if can?(current_user, :admin_list, @project)
%board-delete{ "inline-template" => true,
":list" => "list",
"v-if" => "!list.preset && list.id" }
%button.board-delete.has-tooltip.pull-right{ type: "button", title: "Delete list", "aria-label" => "Delete list", data: { placement: "bottom" }, "@click.stop" => "deleteBoard" }
= icon("trash")
= icon("spinner spin", class: "board-header-loading-spinner pull-right", "v-show" => "list.loadingMore")
.board-inner-container.board-search-container{ "v-if" => "list.canSearch()" }
%input.form-control{ type: "text", placeholder: "Search issues", "v-model" => "query", "debounce" => "250" }
= icon("search", class: "board-search-icon", "v-show" => "!query")
%button.board-search-clear-btn{ type: "button", role: "button", "aria-label" => "Clear search", "@click" => "query = ''", "v-show" => "query" }
= icon("times", class: "board-search-clear")
%board-list{ "inline-template" => true,
"v-if" => "list.type !== 'blank'",
":list" => "list",
":issues" => "list.issues",
":loading" => "list.loading",
":disabled" => "disabled",
":issue-link-base" => "issueLinkBase" }
.board-list-loading.text-center{ "v-if" => "loading" }
= icon("spinner spin")
%ul.board-list{ "v-el:list" => true,
"v-show" => "!loading",
":data-board" => "list.id" }
= render "projects/boards/components/card"
- if can?(current_user, :admin_list, @project)
= render "projects/boards/components/blank_state"
%board-card{ "inline-template" => true,
"v-for" => "issue in issues | orderBy 'priority'",
"v-ref:issue" => true,
":index" => "$index",
":list" => "list",
":issue" => "issue",
":issue-link-base" => "issueLinkBase",
":disabled" => "disabled",
"track-by" => "id" }
%li.card{ ":class" => "{ 'user-can-drag': !disabled }",
":index" => "index" }
= icon("align-justify", class: "board-mobile-handle js-card-drag-handle", "v-if" => "!disabled")
%h4.card-title
= icon("eye-slash", class: "confidential-icon", "v-if" => "issue.confidential")
%a{ ":href" => "issueLinkBase + '/' + issue.id",
":title" => "issue.title" }
{{ issue.title }}
.card-footer
%span.card-number
= precede '#' do
{{ issue.id }}
%button.label.color-label.has-tooltip{ "v-for" => "label in issue.labels",
type: "button",
"v-if" => "(!list.label || label.id !== list.label.id)",
"@click" => "filterByLabel(label, $event)",
":style" => "{ backgroundColor: label.color, color: label.textColor }",
":title" => "label.description",
data: { container: 'body' } }
{{ label.title }}
%a.has-tooltip{ ":href" => "'/u/' + issue.assignee.username",
":title" => "'Assigned to ' + issue.assignee.name",
"v-if" => "issue.assignee",
data: { container: 'body' } }
%img.avatar.avatar-inline.s20{ ":src" => "issue.assignee.avatar", width: 20, height: 20 }
- @no_container = true
- @content_class = "issue-boards-content"
- page_title "Boards"
- content_for :page_specific_javascripts do
= page_specific_javascript_tag('boards/boards_bundle.js')
= page_specific_javascript_tag('boards/test_utils/simulate_drag.js') if Rails.env.test?
= render "projects/issues/head"
= render 'shared/issuable/filter', type: :boards
.boards-list#board-app{ "v-cloak" => true,
"data-endpoint" => "#{namespace_project_board_path(@project.namespace, @project)}",
"data-disabled" => "#{!can?(current_user, :admin_list, @project)}",
"data-issue-link-base" => "#{namespace_project_issues_path(@project.namespace, @project)}" }
.boards-app-loading.text-center{ "v-if" => "loading" }
= icon("spinner spin")
= render "projects/boards/components/board"
......@@ -2,14 +2,16 @@
%tr.commit
%td.commit-link
= link_to namespace_project_pipeline_path(@project.namespace, @project, pipeline.id) do
- if defined?(status_icon_only) && status_icon_only
= ci_icon_for_status(status)
- else
= ci_status_with_icon(status)
%td
.branch-commit
= link_to namespace_project_pipeline_path(@project.namespace, @project, pipeline.id) do
%span ##{pipeline.id}
- if pipeline.ref
- unless defined?(hide_branch) && hide_branch
.icon-container
= pipeline.tag? ? icon('tag') : icon('code-fork')
= link_to pipeline.ref, namespace_project_commits_path(@project.namespace, @project, pipeline.ref), class: "monospace branch-name"
......@@ -53,7 +55,7 @@
- if pipeline.finished_at
%p.finished-at
= icon("calendar")
#{time_ago_with_tooltip(pipeline.finished_at, short_format: true, skip_js: true)}
#{time_ago_with_tooltip(pipeline.finished_at, short_format: false, skip_js: true)}
%td.pipeline-actions
.controls.hidden-xs.pull-right
......
%ul.content-list.pipelines
- if pipelines.blank?
%li
.nothing-here-block No pipelines to show
- else
.table-holder
%table.table.builds
%tbody
%th Status
%th Commit
- pipelines.stages.each do |stage|
%th.stage
%span.has-tooltip{ title: "#{stage.titleize}" }
= stage.titleize
%th
%th
= render pipelines, commit_sha: true, stage: true, allow_retry: true, stages: pipelines.stages, status_icon_only: true, hide_branch: true
......@@ -6,6 +6,11 @@
%span
Issues
= nav_link(controller: :boards) do
= link_to namespace_project_board_path(@project.namespace, @project), title: 'Board' do
%span
Board
- if project_nav_tab?(:merge_requests) && current_controller?(:merge_requests)
= nav_link(controller: :merge_requests) do
= link_to namespace_project_merge_requests_path(@project.namespace, @project), title: 'Merge Requests' do
......
......@@ -47,20 +47,24 @@
- if @commits_count.nonzero?
%ul.merge-request-tabs.nav-links.no-top.no-bottom
%li.notes-tab
= link_to namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: {target: 'div#notes', action: 'notes', toggle: 'tab'} do
= link_to namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: 'div#notes', action: 'notes', toggle: 'tab' } do
Discussion
%span.badge= @merge_request.mr_and_commit_notes.user.count
%li.commits-tab
= link_to commits_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: {target: 'div#commits', action: 'commits', toggle: 'tab'} do
= link_to commits_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: 'div#commits', action: 'commits', toggle: 'tab' } do
Commits
%span.badge= @commits_count
- if @pipeline
%li.pipelines-tab
= link_to pipelines_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: '#pipelines', action: 'pipelines', toggle: 'tab' } do
Pipelines
%span.badge= @merge_request.all_pipelines.size
%li.builds-tab
= link_to builds_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: {target: '#builds', action: 'builds', toggle: 'tab'} do
= link_to builds_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: '#builds', action: 'builds', toggle: 'tab' } do
Builds
%span.badge= @statuses.size
%li.diffs-tab
= link_to diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: {target: 'div#diffs', action: 'diffs', toggle: 'tab'} do
= link_to diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: 'div#diffs', action: 'diffs', toggle: 'tab' } do
Changes
%span.badge= @merge_request.diff_size
%li#resolve-count-app.line-resolve-all-container.pull-right.prepend-top-10.hidden-xs{ "v-cloak" => true }
......@@ -88,6 +92,8 @@
- # This tab is always loaded via AJAX
#builds.builds.tab-pane
- # This tab is always loaded via AJAX
#pipelines.pipelines.tab-pane
- # This tab is always loaded via AJAX
#diffs.diffs.tab-pane
- # This tab is always loaded via AJAX
......
- class_bindings = "{ |
'head': line.isHead, |
'origin': line.isOrigin, |
'match': line.hasMatch, |
'selected': line.isSelected, |
'unselected': line.isUnselected }"
- page_title "Merge Conflicts", "#{@merge_request.title} (#{@merge_request.to_reference}", "Merge Requests"
= render "projects/merge_requests/show/mr_title"
.merge-request-details.issuable-details
= render "projects/merge_requests/show/mr_box"
= render 'shared/issuable/sidebar', issuable: @merge_request
#conflicts{"v-cloak" => "true", data: { conflicts_path: conflicts_namespace_project_merge_request_path(@merge_request.project.namespace, @merge_request.project, @merge_request, format: :json),
resolve_conflicts_path: resolve_conflicts_namespace_project_merge_request_path(@merge_request.project.namespace, @merge_request.project, @merge_request) } }
.loading{"v-if" => "isLoading"}
%i.fa.fa-spinner.fa-spin
.nothing-here-block{"v-if" => "hasError"}
{{conflictsData.errorMessage}}
= render partial: "projects/merge_requests/conflicts/commit_stats"
.files-wrapper{"v-if" => "!isLoading && !hasError"}
= render partial: "projects/merge_requests/conflicts/parallel_view", locals: { class_bindings: class_bindings }
= render partial: "projects/merge_requests/conflicts/inline_view", locals: { class_bindings: class_bindings }
= render partial: "projects/merge_requests/conflicts/submit_form"
.content-block.oneline-block.files-changed{"v-if" => "!isLoading && !hasError"}
.inline-parallel-buttons
.btn-group
%a.btn{ |
":class" => "{'active': !isParallel}", |
"@click" => "handleViewTypeChange('inline')"}
Inline
%a.btn{ |
":class" => "{'active': isParallel}", |
"@click" => "handleViewTypeChange('parallel')"}
Side-by-side
.js-toggle-container
.commit-stat-summary
Showing
%strong.cred {{conflictsCount}} {{conflictsData.conflictsText}}
between
%strong {{conflictsData.source_branch}}
and
%strong {{conflictsData.target_branch}}
.files{"v-show" => "!isParallel"}
.diff-file.file-holder.conflict.inline-view{"v-for" => "file in conflictsData.files"}
.file-title
%i.fa.fa-fw{":class" => "file.iconClass"}
%strong {{file.filePath}}
.file-actions
%a.btn.view-file.btn-file-option{":href" => "file.blobPath"}
View file @{{conflictsData.shortCommitSha}}
.diff-content.diff-wrap-lines
.diff-wrap-lines.code.file-content.js-syntax-highlight
%table
%tr.line_holder.diff-inline{"v-for" => "line in file.inlineLines"}
%template{"v-if" => "!line.isHeader"}
%td.diff-line-num.new_line{":class" => class_bindings}
%a {{line.new_line}}
%td.diff-line-num.old_line{":class" => class_bindings}
%a {{line.old_line}}
%td.line_content{":class" => class_bindings}
{{{line.richText}}}
%template{"v-if" => "line.isHeader"}
%td.diff-line-num.header{":class" => class_bindings}
%td.diff-line-num.header{":class" => class_bindings}
%td.line_content.header{":class" => class_bindings}
%strong {{{line.richText}}}
%button.btn{"@click" => "handleSelected(line.id, line.section)"}
{{line.buttonTitle}}
.files{"v-show" => "isParallel"}
.diff-file.file-holder.conflict.parallel-view{"v-for" => "file in conflictsData.files"}
.file-title
%i.fa.fa-fw{":class" => "file.iconClass"}
%strong {{file.filePath}}
.file-actions
%a.btn.view-file.btn-file-option{":href" => "file.blobPath"}
View file @{{conflictsData.shortCommitSha}}
.diff-content.diff-wrap-lines
.diff-wrap-lines.code.file-content.js-syntax-highlight
%table
%tr.line_holder.parallel{"v-for" => "section in file.parallelLines"}
%template{"v-for" => "line in section"}
%template{"v-if" => "line.isHeader"}
%td.diff-line-num.header{":class" => class_bindings}
%td.line_content.header{":class" => class_bindings}
%strong {{line.richText}}
%button.btn{"@click" => "handleSelected(line.id, line.section)"}
{{line.buttonTitle}}
%template{"v-if" => "!line.isHeader"}
%td.diff-line-num.old_line{":class" => class_bindings}
{{line.lineNumber}}
%td.line_content.parallel{":class" => class_bindings}
{{{line.richText}}}
.content-block.oneline-block.files-changed
%strong.resolved-count {{resolvedCount}}
of
%strong.total-count {{conflictsCount}}
conflicts have been resolved
.commit-message-container.form-group
.max-width-marker
%textarea.form-control.js-commit-message{"v-model" => "conflictsData.commitMessage"}
{{{conflictsData.commitMessage}}}
%button{type: "button", class: "btn btn-success js-submit-button", ":disabled" => "!readyToCommit", "@click" => "commit()"}
%span {{commitButtonText}}
= link_to "Cancel", namespace_project_merge_request_path(@merge_request.project.namespace, @merge_request.project, @merge_request), class: "btn btn-cancel"
= render "projects/commit/pipeline", pipeline: @pipeline, link_to_commit: true
= render "projects/commit/pipelines_list", pipelines: @pipelines, link_to_commit: true
......@@ -6,7 +6,7 @@
- if @merge_request.merge_event
by #{link_to_member(@project, @merge_request.merge_event.author, avatar: true)}
#{time_ago_with_tooltip(@merge_request.merge_event.created_at)}
- if !@merge_request.source_branch_exists? || (params[:delete_source] == 'true')
- if !@merge_request.source_branch_exists? || params[:deleted_source_branch]
%p
The changes were merged into
#{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"}.
......
.mr-state-widget
= render 'projects/merge_requests/widget/heading'
.mr-widget-body
-# After conflicts are resolved, the user is redirected back to the MR page.
-# There is a short window before background workers run and GitLab processes
-# the new push and commits, during which it will think the conflicts still exist.
-# We send this param to get the widget to treat the MR as having no more conflicts.
- resolved_conflicts = params[:resolved_conflicts]
- if @project.archived?
= render 'projects/merge_requests/widget/open/archived'
- elsif @merge_request.commits.blank?
......@@ -9,7 +15,7 @@
= render 'projects/merge_requests/widget/open/missing_branch'
- elsif @merge_request.unchecked?
= render 'projects/merge_requests/widget/open/check'
- elsif @merge_request.cannot_be_merged?
- elsif @merge_request.cannot_be_merged? && !resolved_conflicts
= render 'projects/merge_requests/widget/open/conflicts'
- elsif @merge_request.work_in_progress?
= render 'projects/merge_requests/widget/open/wip'
......@@ -19,7 +25,7 @@
= render 'projects/merge_requests/widget/open/not_allowed'
- elsif !@merge_request.mergeable_ci_state? && @pipeline && @pipeline.failed?
= render 'projects/merge_requests/widget/open/build_failed'
- elsif @merge_request.can_be_merged?
- elsif @merge_request.can_be_merged? || resolved_conflicts
= render 'projects/merge_requests/widget/open/accept'
- if mr_closes_issues.present?
......
......@@ -23,7 +23,8 @@
preparing: "{{status}} build",
normal: "Build {{status}}"
},
builds_path: "#{builds_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}"
builds_path: "#{builds_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}",
pipelines_path: "#{pipelines_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}"
};
if (typeof merge_request_widget !== 'undefined') {
......
......@@ -3,7 +3,18 @@
This merge request contains merge conflicts
%p
Please resolve these conflicts or
Please
- if @merge_request.conflicts_can_be_resolved_by?(current_user)
- if @merge_request.conflicts_can_be_resolved_in_ui?
= link_to "resolve these conflicts", conflicts_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)
- else
%span.has-tooltip{title: "These conflicts cannot be resolved through GitLab"}
resolve these conflicts locally
- else
resolve these conflicts
or
- if @merge_request.can_be_merged_via_command_line_by?(current_user)
#{link_to "merge this request manually", "#modal_merge_info", class: "how_to_merge_link vlink", "data-toggle" => "modal"}.
- else
......
......@@ -27,7 +27,17 @@
= render "shared/issuable/label_dropdown"
.pull-right
- if controller.controller_name != 'boards'
= render 'shared/sort_dropdown'
- if can?(current_user, :admin_list, @project)
.dropdown
%button.btn.btn-create.js-new-board-list{ type: "button", data: { toggle: "dropdown", labels: labels_filter_path, project_id: @project.try(:id) } }
Create new list
.dropdown-menu.dropdown-menu-paging.dropdown-menu-align-right.dropdown-menu-issues-board-new.dropdown-menu-selectable
= render partial: "shared/issuable/label_page_default", locals: { show_footer: true, show_create: true, show_boards_content: true, title: "Create a new list" }
- if can?(current_user, :admin_label, @project)
= render partial: "shared/issuable/label_page_create"
= dropdown_loading
- if controller.controller_name == 'issues'
.issues_bulk_update.hide
......
......@@ -2,8 +2,13 @@
- show_create = local_assigns.fetch(:show_create, true)
- show_footer = local_assigns.fetch(:show_footer, true)
- filter_placeholder = local_assigns.fetch(:filter_placeholder, 'Search labels')
- show_boards_content = local_assigns.fetch(:show_boards_content, false)
.dropdown-page-one
= dropdown_title(title)
- if show_boards_content
.issue-board-dropdown-content
%p
Each label that exists in your issue tracker can have its own dedicated list. Select a label below to add a list to your Board and it will automatically be populated with issues that have that label. To create a list for a label that doesn't exist yet, simply create the label below.
= dropdown_filter(filter_placeholder)
= dropdown_content
- if @project && show_footer
......@@ -12,7 +17,7 @@
- if can?(current_user, :admin_label, @project)
%li
%a.dropdown-toggle-page{href: "#"}
Create new
Create new label
%li
= link_to namespace_project_labels_path(@project.namespace, @project), :"data-is-link" => true do
- if show_create && @project && can?(current_user, :admin_label, @project)
......
......@@ -5,6 +5,10 @@ class RepositoryForkWorker
sidekiq_options queue: :gitlab_shell
def perform(project_id, forked_from_repository_storage_path, source_path, target_path)
Gitlab::Metrics.add_event(:fork_repository,
source_path: source_path,
target_path: target_path)
project = Project.find_by_id(project_id)
unless project.present?
......
......@@ -10,6 +10,10 @@ class RepositoryImportWorker
@project = Project.find(project_id)
@current_user = @project.creator
Gitlab::Metrics.add_event(:import_repository,
import_url: @project.import_url,
path: @project.path_with_namespace)
result = Projects::ImportService.new(project, current_user).execute
if result[:status] == :error
......
......@@ -86,6 +86,8 @@ module Gitlab
config.assets.precompile << "network/network_bundle.js"
config.assets.precompile << "profile/profile_bundle.js"
config.assets.precompile << "diff_notes/diff_notes_bundle.js"
config.assets.precompile << "boards/boards_bundle.js"
config.assets.precompile << "boards/test_utils/simulate_drag.js"
config.assets.precompile << "lib/utils/*.js"
config.assets.precompile << "lib/*.js"
config.assets.precompile << "u2f.js"
......
......@@ -727,7 +727,9 @@ Rails.application.routes.draw do
member do
get :commits
get :diffs
get :conflicts
get :builds
get :pipelines
get :merge_check
post :merge
post :cancel_merge_when_build_succeeds
......@@ -736,6 +738,7 @@ Rails.application.routes.draw do
post :toggle_award_emoji
post :remove_wip
get :diff_for_path
post :resolve_conflicts
end
collection do
......@@ -865,6 +868,20 @@ Rails.application.routes.draw do
end
end
resource :board, only: [:show] do
scope module: :boards do
resources :issues, only: [:update]
resources :lists, only: [:index, :create, :update, :destroy] do
collection do
post :generate
end
resources :issues, only: [:index]
end
end
end
resources :todos, only: [:create]
resources :uploads, only: [:create] do
......
......@@ -26,24 +26,44 @@ class Gitlab::Seeder::Builds
begin
BUILDS.each { |opts| build_create!(pipeline, opts) }
commit_status_create!(pipeline, name: 'jenkins', status: :success)
print '.'
rescue ActiveRecord::RecordInvalid
print 'F'
ensure
pipeline.build_updated
end
end
end
def pipelines
commits = @project.repository.commits('master', limit: 5)
commits_sha = commits.map { |commit| commit.raw.id }
commits_sha.map do |sha|
@project.ensure_pipeline(sha, 'master')
master_pipelines + merge_request_pipelines
end
def master_pipelines
create_pipelines_for(@project, 'master')
rescue
[]
end
def merge_request_pipelines
@project.merge_requests.last(5).map do |merge_request|
create_pipelines(merge_request.source_project, merge_request.source_branch, merge_request.commits.last(5))
end.flatten
rescue
[]
end
def create_pipelines_for(project, ref)
commits = project.repository.commits(ref, limit: 5)
create_pipelines(project, ref, commits)
end
def create_pipelines(project, ref, commits)
commits.map do |commit|
project.pipelines.create(sha: commit.id, ref: ref)
end
end
def build_create!(pipeline, opts = {})
attributes = build_attributes_for(pipeline, opts)
......
class CreateBoards < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def change
create_table :boards do |t|
t.references :project, index: true, foreign_key: true, null: false
t.timestamps null: false
end
end
end
class CreateLists < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def change
create_table :lists do |t|
t.references :board, index: true, foreign_key: true, null: false
t.references :label, index: true, foreign_key: true
t.integer :list_type, null: false, default: 1
t.integer :position
t.timestamps null: false
end
end
end
class AddUniqueIndexToListsLabelId < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_concurrent_index :lists, [:board_id, :label_id], unique: true
end
def down
remove_index :lists, column: [:board_id, :label_id] if index_exists?(:lists, [:board_id, :label_id], unique: true)
end
end
......@@ -117,6 +117,14 @@ ActiveRecord::Schema.define(version: 20160817154936) do
add_index "award_emoji", ["user_id", "name"], name: "index_award_emoji_on_user_id_and_name", using: :btree
add_index "award_emoji", ["user_id"], name: "index_award_emoji_on_user_id", using: :btree
create_table "boards", force: :cascade do |t|
t.integer "project_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
add_index "boards", ["project_id"], name: "index_boards_on_project_id", using: :btree
create_table "broadcast_messages", force: :cascade do |t|
t.text "message", null: false
t.datetime "starts_at"
......@@ -533,6 +541,19 @@ ActiveRecord::Schema.define(version: 20160817154936) do
add_index "lfs_objects_projects", ["project_id"], name: "index_lfs_objects_projects_on_project_id", using: :btree
create_table "lists", force: :cascade do |t|
t.integer "board_id", null: false
t.integer "label_id"
t.integer "list_type", default: 1, null: false
t.integer "position"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
add_index "lists", ["board_id", "label_id"], name: "index_lists_on_board_id_and_label_id", unique: true, using: :btree
add_index "lists", ["board_id"], name: "index_lists_on_board_id", using: :btree
add_index "lists", ["label_id"], name: "index_lists_on_label_id", using: :btree
create_table "members", force: :cascade do |t|
t.integer "access_level", null: false
t.integer "source_id", null: false
......@@ -1120,6 +1141,9 @@ ActiveRecord::Schema.define(version: 20160817154936) do
add_index "web_hooks", ["project_id"], name: "index_web_hooks_on_project_id", using: :btree
add_foreign_key "boards", "projects"
add_foreign_key "lists", "boards"
add_foreign_key "lists", "labels"
add_foreign_key "personal_access_tokens", "users"
add_foreign_key "protected_branch_merge_access_levels", "protected_branches"
add_foreign_key "protected_branch_push_access_levels", "protected_branches"
......
......@@ -9,6 +9,7 @@ The following measurements are currently stored in InfluxDB:
- `PROCESS_object_counts`
- `PROCESS_transactions`
- `PROCESS_views`
- `events`
Here, `PROCESS` is replaced with either `rails` or `sidekiq` depending on the
process type. In all series, any form of duration is stored in milliseconds.
......@@ -78,6 +79,14 @@ following value fields are available:
The `action` tag contains the action name of the transaction that rendered the
view.
## events
This measurement is used to store generic events such as the number of Git
pushes, Emails sent, etc. Each point in this measurement has a single value
field called `count`. The value of this field is simply set to `1`. Each point
also has at least one tag: `event`. This tag's value is set to the event name.
Depending on the event type additional tags may be available as well.
---
Read more on:
......
# Merge conflict resolution
> [Introduced][ce-5479] in GitLab 8.11.
When a merge request has conflicts, GitLab may provide the option to resolve
those conflicts in the GitLab UI. (See
[conflicts available for resolution](#conflicts-available-for-resolution) for
more information on when this is available.) If this is an option, you will see
a **resolve these conflicts** link in the merge request widget:
![Merge request widget](img/merge_request_widget.png)
Clicking this will show a list of files with conflicts, with conflict sections
highlighted:
![Conflict section](img/conflict_section.png)
Once all conflicts have been marked as using 'ours' or 'theirs', the conflict
can be resolved. This will perform a merge of the target branch of the merge
request into the source branch, resolving the conflicts using the options
chosen. If the source branch is `feature` and the target branch is `master`,
this is similar to performing `git checkout feature; git merge master` locally.
## Conflicts available for resolution
GitLab allows resolving conflicts in a file where all of the below are true:
- The file is text, not binary
- The file does not already contain conflict markers
- The file, with conflict markers added, is not over 200 KB in size
- The file exists under the same path in both branches
If any file with conflicts in that merge request does not meet all of these
criteria, the conflicts for that merge request cannot be resolved in the UI.
Additionally, GitLab does not detect conflicts in renames away from a path. For
example, this will not create a conflict: on branch `a`, doing `git mv file1
file2`; on branch `b`, doing `git mv file1 file3`. Instead, both files will be
present in the branch after the merge request is merged.
[ce-5479]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5479
......@@ -20,8 +20,13 @@ module Ci
build = Ci::RegisterBuildService.new.execute(current_runner)
if build
Gitlab::Metrics.add_event(:build_found,
project: build.project.path_with_namespace)
present build, with: Entities::BuildDetails
else
Gitlab::Metrics.add_event(:build_not_found)
not_found!
end
end
......@@ -42,6 +47,9 @@ module Ci
build.update_attributes(trace: params[:trace]) if params[:trace]
Gitlab::Metrics.add_event(:update_build,
project: build.project.path_with_namespace)
case params[:state].to_s
when 'success'
build.success
......
module Gitlab
module Conflict
class File
include Gitlab::Routing.url_helpers
include IconsHelper
class MissingResolution < StandardError
end
CONTEXT_LINES = 3
attr_reader :merge_file_result, :their_path, :our_path, :our_mode, :merge_request, :repository
def initialize(merge_file_result, conflict, merge_request:)
@merge_file_result = merge_file_result
@their_path = conflict[:theirs][:path]
@our_path = conflict[:ours][:path]
@our_mode = conflict[:ours][:mode]
@merge_request = merge_request
@repository = merge_request.project.repository
@match_line_headers = {}
end
# Array of Gitlab::Diff::Line objects
def lines
@lines ||= Gitlab::Conflict::Parser.new.parse(merge_file_result[:data],
our_path: our_path,
their_path: their_path,
parent_file: self)
end
def resolve_lines(resolution)
section_id = nil
lines.map do |line|
unless line.type
section_id = nil
next line
end
section_id ||= line_code(line)
case resolution[section_id]
when 'head'
next unless line.type == 'new'
when 'origin'
next unless line.type == 'old'
else
raise MissingResolution, "Missing resolution for section ID: #{section_id}"
end
line
end.compact
end
def highlight_lines!
their_file = lines.reject { |line| line.type == 'new' }.map(&:text).join("\n")
our_file = lines.reject { |line| line.type == 'old' }.map(&:text).join("\n")
their_highlight = Gitlab::Highlight.highlight(their_path, their_file, repository: repository).lines
our_highlight = Gitlab::Highlight.highlight(our_path, our_file, repository: repository).lines
lines.each do |line|
if line.type == 'old'
line.rich_text = their_highlight[line.old_line - 1].try(:html_safe)
else
line.rich_text = our_highlight[line.new_line - 1].try(:html_safe)
end
end
end
def sections
return @sections if @sections
chunked_lines = lines.chunk { |line| line.type.nil? }.to_a
match_line = nil
sections_count = chunked_lines.size
@sections = chunked_lines.flat_map.with_index do |(no_conflict, lines), i|
section = nil
# We need to reduce context sections to CONTEXT_LINES. Conflict sections are
# always shown in full.
if no_conflict
conflict_before = i > 0
conflict_after = (sections_count - i) > 1
if conflict_before && conflict_after
# Create a gap in a long context section.
if lines.length > CONTEXT_LINES * 2
head_lines = lines.first(CONTEXT_LINES)
tail_lines = lines.last(CONTEXT_LINES)
# Ensure any existing match line has text for all lines up to the last
# line of its context.
update_match_line_text(match_line, head_lines.last)
# Insert a new match line after the created gap.
match_line = create_match_line(tail_lines.first)
section = [
{ conflict: false, lines: head_lines },
{ conflict: false, lines: tail_lines.unshift(match_line) }
]
end
elsif conflict_after
tail_lines = lines.last(CONTEXT_LINES)
# Create a gap and insert a match line at the start.
if lines.length > tail_lines.length
match_line = create_match_line(tail_lines.first)
tail_lines.unshift(match_line)
end
lines = tail_lines
elsif conflict_before
# We're at the end of the file (no conflicts after), so just remove extra
# trailing lines.
lines = lines.first(CONTEXT_LINES)
end
end
# We want to update the match line's text every time unless we've already
# created a gap and its corresponding match line.
update_match_line_text(match_line, lines.last) unless section
section ||= { conflict: !no_conflict, lines: lines }
section[:id] = line_code(lines.first) unless no_conflict
section
end
end
def line_code(line)
Gitlab::Diff::LineCode.generate(our_path, line.new_pos, line.old_pos)
end
def create_match_line(line)
Gitlab::Diff::Line.new('', 'match', line.index, line.old_pos, line.new_pos)
end
# Any line beginning with a letter, an underscore, or a dollar can be used in a
# match line header. Only context sections can contain match lines, as match lines
# have to exist in both versions of the file.
def find_match_line_header(index)
return @match_line_headers[index] if @match_line_headers.key?(index)
@match_line_headers[index] = begin
if index >= 0
line = lines[index]
if line.type.nil? && line.text.match(/\A[A-Za-z$_]/)
" #{line.text}"
else
find_match_line_header(index - 1)
end
end
end
end
# Set the match line's text for the current line. A match line takes its start
# position and context header (where present) from itself, and its end position from
# the line passed in.
def update_match_line_text(match_line, line)
return unless match_line
header = find_match_line_header(match_line.index - 1)
match_line.text = "@@ -#{match_line.old_pos},#{line.old_pos} +#{match_line.new_pos},#{line.new_pos} @@#{header}"
end
def as_json(opts = nil)
{
old_path: their_path,
new_path: our_path,
blob_icon: file_type_icon_class('file', our_mode, our_path),
blob_path: namespace_project_blob_path(merge_request.project.namespace,
merge_request.project,
::File.join(merge_request.diff_refs.head_sha, our_path)),
sections: sections
}
end
end
end
end
module Gitlab
module Conflict
class FileCollection
class ConflictSideMissing < StandardError
end
attr_reader :merge_request, :our_commit, :their_commit
def initialize(merge_request)
@merge_request = merge_request
@our_commit = merge_request.source_branch_head.raw.raw_commit
@their_commit = merge_request.target_branch_head.raw.raw_commit
end
def repository
merge_request.project.repository
end
def merge_index
@merge_index ||= repository.rugged.merge_commits(our_commit, their_commit)
end
def files
@files ||= merge_index.conflicts.map do |conflict|
raise ConflictSideMissing unless conflict[:theirs] && conflict[:ours]
Gitlab::Conflict::File.new(merge_index.merge_file(conflict[:ours][:path]),
conflict,
merge_request: merge_request)
end
end
def as_json(opts = nil)
{
target_branch: merge_request.target_branch,
source_branch: merge_request.source_branch,
commit_sha: merge_request.diff_head_sha,
commit_message: default_commit_message,
files: files
}
end
def default_commit_message
conflict_filenames = merge_index.conflicts.map do |conflict|
"# #{conflict[:ours][:path]}"
end
<<EOM.chomp
Merge branch '#{merge_request.target_branch}' into '#{merge_request.source_branch}'
# Conflicts:
#{conflict_filenames.join("\n")}
EOM
end
end
end
end
module Gitlab
module Conflict
class Parser
class ParserError < StandardError
end
class UnexpectedDelimiter < ParserError
end
class MissingEndDelimiter < ParserError
end
class UnmergeableFile < ParserError
end
def parse(text, our_path:, their_path:, parent_file: nil)
raise UnmergeableFile if text.blank? # Typically a binary file
raise UnmergeableFile if text.length > 102400
line_obj_index = 0
line_old = 1
line_new = 1
type = nil
lines = []
conflict_start = "<<<<<<< #{our_path}"
conflict_middle = '======='
conflict_end = ">>>>>>> #{their_path}"
text.each_line.map do |line|
full_line = line.delete("\n")
if full_line == conflict_start
raise UnexpectedDelimiter unless type.nil?
type = 'new'
elsif full_line == conflict_middle
raise UnexpectedDelimiter unless type == 'new'
type = 'old'
elsif full_line == conflict_end
raise UnexpectedDelimiter unless type == 'old'
type = nil
elsif line[0] == '\\'
type = 'nonewline'
lines << Gitlab::Diff::Line.new(full_line, type, line_obj_index, line_old, line_new, parent_file: parent_file)
else
lines << Gitlab::Diff::Line.new(full_line, type, line_obj_index, line_old, line_new, parent_file: parent_file)
line_old += 1 if type != 'new'
line_new += 1 if type != 'old'
line_obj_index += 1
end
end
raise MissingEndDelimiter unless type.nil?
lines
end
end
end
end
......@@ -2,11 +2,13 @@ module Gitlab
module Diff
class Line
attr_reader :type, :index, :old_pos, :new_pos
attr_writer :rich_text
attr_accessor :text
def initialize(text, type, index, old_pos, new_pos)
def initialize(text, type, index, old_pos, new_pos, parent_file: nil)
@text, @type, @index = text, type, index
@old_pos, @new_pos = old_pos, new_pos
@parent_file = parent_file
end
def self.init_from_hash(hash)
......@@ -43,9 +45,25 @@ module Gitlab
type == 'old'
end
def rich_text
@parent_file.highlight_lines! if @parent_file && !@rich_text
@rich_text
end
def meta?
type == 'match' || type == 'nonewline'
end
def as_json(opts = nil)
{
type: type,
old_line: old_line,
new_line: new_line,
text: text,
rich_text: rich_text || text
}
end
end
end
end
......@@ -124,6 +124,15 @@ module Gitlab
trans.action = action if trans
end
# Tracks an event.
#
# See `Gitlab::Metrics::Transaction#add_event` for more details.
def self.add_event(*args)
trans = current_transaction
trans.add_event(*args) if trans
end
# Returns the prefix to use for the name of a series.
def self.series_prefix
@series_prefix ||= Sidekiq.server? ? 'sidekiq_' : 'rails_'
......
......@@ -4,15 +4,20 @@ module Gitlab
class Metric
JITTER_RANGE = 0.000001..0.001
attr_reader :series, :values, :tags
attr_reader :series, :values, :tags, :type
# series - The name of the series (as a String) to store the metric in.
# values - A Hash containing the values to store.
# tags - A Hash containing extra tags to add to the metrics.
def initialize(series, values, tags = {})
def initialize(series, values, tags = {}, type = :metric)
@values = values
@series = series
@tags = tags
@type = type
end
def event?
type == :event
end
# Returns a Hash in a format that can be directly written to InfluxDB.
......
......@@ -17,6 +17,10 @@ module Gitlab
begin
retval = trans.run { @app.call(env) }
rescue Exception => error # rubocop: disable Lint/RescueException
trans.add_event(:rails_exception)
raise error
# Even in the event of an error we want to submit any metrics we
# might've gathered up to this point.
ensure
......
......@@ -11,6 +11,10 @@ module Gitlab
# Old gitlad-shell messages don't provide enqueued_at/created_at attributes
trans.set(:sidekiq_queue_duration, Time.now.to_f - (message['enqueued_at'] || message['created_at'] || 0))
trans.run { yield }
rescue Exception => error # rubocop: disable Lint/RescueException
trans.add_event(:sidekiq_exception)
raise error
ensure
trans.finish
end
......
......@@ -4,7 +4,10 @@ module Gitlab
class Transaction
THREAD_KEY = :_gitlab_metrics_transaction
attr_reader :tags, :values, :methods
# The series to store events (e.g. Git pushes) in.
EVENT_SERIES = 'events'
attr_reader :tags, :values, :method, :metrics
attr_accessor :action
......@@ -55,6 +58,20 @@ module Gitlab
@metrics << Metric.new("#{Metrics.series_prefix}#{series}", values, tags)
end
# Tracks a business level event
#
# Business level events including events such as Git pushes, Emails being
# sent, etc.
#
# event_name - The name of the event (e.g. "git_push").
# tags - A set of tags to attach to the event.
def add_event(event_name, tags = {})
@metrics << Metric.new(EVENT_SERIES,
{ count: 1 },
{ event: event_name }.merge(tags),
:event)
end
# Returns a MethodCall object for the given name.
def method_call_for(name)
unless method = @methods[name]
......@@ -101,7 +118,7 @@ module Gitlab
submit_hashes = submit.map do |metric|
hash = metric.to_hash
hash[:tags][:action] ||= @action if @action
hash[:tags][:action] ||= @action if @action && !metric.event?
hash
end
......
require 'spec_helper'
describe Projects::Boards::IssuesController do
let(:project) { create(:project_with_board) }
let(:user) { create(:user) }
let(:planning) { create(:label, project: project, name: 'Planning') }
let(:development) { create(:label, project: project, name: 'Development') }
let!(:list1) { create(:list, board: project.board, label: planning, position: 0) }
let!(:list2) { create(:list, board: project.board, label: development, position: 1) }
before do
project.team << [user, :master]
end
describe 'GET index' do
context 'with valid list id' do
it 'returns issues that have the list label applied' do
johndoe = create(:user, avatar: fixture_file_upload(File.join(Rails.root, 'spec/fixtures/dk.png')))
create(:labeled_issue, project: project, labels: [planning])
create(:labeled_issue, project: project, labels: [development])
create(:labeled_issue, project: project, labels: [development], assignee: johndoe)
list_issues user: user, list_id: list2
parsed_response = JSON.parse(response.body)
expect(response).to match_response_schema('issues')
expect(parsed_response.length).to eq 2
end
end
context 'with invalid list id' do
it 'returns a not found 404 response' do
list_issues user: user, list_id: 999
expect(response).to have_http_status(404)
end
end
context 'with unauthorized user' do
before do
allow(Ability.abilities).to receive(:allowed?).with(user, :read_project, project).and_return(true)
allow(Ability.abilities).to receive(:allowed?).with(user, :read_issue, project).and_return(false)
end
it 'returns a successful 403 response' do
list_issues user: user, list_id: list2
expect(response).to have_http_status(403)
end
end
def list_issues(user:, list_id:)
sign_in(user)
get :index, namespace_id: project.namespace.to_param,
project_id: project.to_param,
list_id: list_id.to_param
end
end
describe 'PATCH update' do
let(:issue) { create(:labeled_issue, project: project, labels: [planning]) }
context 'with valid params' do
it 'returns a successful 200 response' do
move user: user, issue: issue, from_list_id: list1.id, to_list_id: list2.id
expect(response).to have_http_status(200)
end
it 'moves issue to the desired list' do
move user: user, issue: issue, from_list_id: list1.id, to_list_id: list2.id
expect(issue.reload.labels).to contain_exactly(development)
end
end
context 'with invalid params' do
it 'returns a unprocessable entity 422 response for invalid lists' do
move user: user, issue: issue, from_list_id: nil, to_list_id: nil
expect(response).to have_http_status(422)
end
it 'returns a not found 404 response for invalid issue id' do
move user: user, issue: 999, from_list_id: list1.id, to_list_id: list2.id
expect(response).to have_http_status(404)
end
end
context 'with unauthorized user' do
let(:guest) { create(:user) }
before do
project.team << [guest, :guest]
end
it 'returns a successful 403 response' do
move user: guest, issue: issue, from_list_id: list1.id, to_list_id: list2.id
expect(response).to have_http_status(403)
end
end
def move(user:, issue:, from_list_id:, to_list_id:)
sign_in(user)
patch :update, namespace_id: project.namespace.to_param,
project_id: project.to_param,
id: issue.to_param,
from_list_id: from_list_id,
to_list_id: to_list_id,
format: :json
end
end
end
require 'spec_helper'
describe Projects::Boards::ListsController do
let(:project) { create(:project_with_board) }
let(:board) { project.board }
let(:user) { create(:user) }
let(:guest) { create(:user) }
before do
project.team << [user, :master]
project.team << [guest, :guest]
end
describe 'GET index' do
it 'returns a successful 200 response' do
read_board_list user: user
expect(response).to have_http_status(200)
expect(response.content_type).to eq 'application/json'
end
it 'returns a list of board lists' do
board = project.create_board
create(:backlog_list, board: board)
create(:list, board: board)
create(:done_list, board: board)
read_board_list user: user
parsed_response = JSON.parse(response.body)
expect(response).to match_response_schema('lists')
expect(parsed_response.length).to eq 3
end
context 'with unauthorized user' do
before do
allow(Ability.abilities).to receive(:allowed?).with(user, :read_project, project).and_return(true)
allow(Ability.abilities).to receive(:allowed?).with(user, :read_list, project).and_return(false)
end
it 'returns a successful 403 response' do
read_board_list user: user
expect(response).to have_http_status(403)
end
end
def read_board_list(user:)
sign_in(user)
get :index, namespace_id: project.namespace.to_param,
project_id: project.to_param,
format: :json
end
end
describe 'POST create' do
let(:label) { create(:label, project: project, name: 'Development') }
context 'with valid params' do
it 'returns a successful 200 response' do
create_board_list user: user, label_id: label.id
expect(response).to have_http_status(200)
end
it 'returns the created list' do
create_board_list user: user, label_id: label.id
expect(response).to match_response_schema('list')
end
end
context 'with invalid params' do
it 'returns an error' do
create_board_list user: user, label_id: nil
parsed_response = JSON.parse(response.body)
expect(parsed_response['label']).to contain_exactly "can't be blank"
expect(response).to have_http_status(422)
end
end
context 'with unauthorized user' do
let(:label) { create(:label, project: project, name: 'Development') }
it 'returns a successful 403 response' do
create_board_list user: guest, label_id: label.id
expect(response).to have_http_status(403)
end
end
def create_board_list(user:, label_id:)
sign_in(user)
post :create, namespace_id: project.namespace.to_param,
project_id: project.to_param,
list: { label_id: label_id },
format: :json
end
end
describe 'PATCH update' do
let!(:planning) { create(:list, board: board, position: 0) }
let!(:development) { create(:list, board: board, position: 1) }
context 'with valid position' do
it 'returns a successful 200 response' do
move user: user, list: planning, position: 1
expect(response).to have_http_status(200)
end
it 'moves the list to the desired position' do
move user: user, list: planning, position: 1
expect(planning.reload.position).to eq 1
end
end
context 'with invalid position' do
it 'returns a unprocessable entity 422 response' do
move user: user, list: planning, position: 6
expect(response).to have_http_status(422)
end
end
context 'with invalid list id' do
it 'returns a not found 404 response' do
move user: user, list: 999, position: 1
expect(response).to have_http_status(404)
end
end
context 'with unauthorized user' do
it 'returns a successful 403 response' do
move user: guest, list: planning, position: 6
expect(response).to have_http_status(403)
end
end
def move(user:, list:, position:)
sign_in(user)
patch :update, namespace_id: project.namespace.to_param,
project_id: project.to_param,
id: list.to_param,
list: { position: position },
format: :json
end
end
describe 'DELETE destroy' do
let!(:planning) { create(:list, board: board, position: 0) }
context 'with valid list id' do
it 'returns a successful 200 response' do
remove_board_list user: user, list: planning
expect(response).to have_http_status(200)
end
it 'removes list from board' do
expect { remove_board_list user: user, list: planning }.to change(board.lists, :size).by(-1)
end
end
context 'with invalid list id' do
it 'returns a not found 404 response' do
remove_board_list user: user, list: 999
expect(response).to have_http_status(404)
end
end
context 'with unauthorized user' do
it 'returns a successful 403 response' do
remove_board_list user: guest, list: planning
expect(response).to have_http_status(403)
end
end
def remove_board_list(user:, list:)
sign_in(user)
delete :destroy, namespace_id: project.namespace.to_param,
project_id: project.to_param,
id: list.to_param,
format: :json
end
end
describe 'POST generate' do
context 'when board lists is empty' do
it 'returns a successful 200 response' do
generate_default_board_lists user: user
expect(response).to have_http_status(200)
end
it 'returns the defaults lists' do
generate_default_board_lists user: user
expect(response).to match_response_schema('lists')
end
end
context 'when board lists is not empty' do
it 'returns a unprocessable entity 422 response' do
create(:list, board: board)
generate_default_board_lists user: user
expect(response).to have_http_status(422)
end
end
context 'with unauthorized user' do
it 'returns a successful 403 response' do
generate_default_board_lists user: guest
expect(response).to have_http_status(403)
end
end
def generate_default_board_lists(user:)
sign_in(user)
post :generate, namespace_id: project.namespace.to_param,
project_id: project.to_param,
format: :json
end
end
end
require 'spec_helper'
describe Projects::BoardsController do
let(:project) { create(:empty_project) }
let(:user) { create(:user) }
before do
project.team << [user, :master]
sign_in(user)
end
describe 'GET show' do
it 'creates a new board when project does not have one' do
expect { read_board }.to change(Board, :count).by(1)
end
it 'renders HTML template' do
read_board
expect(response).to render_template :show
expect(response.content_type).to eq 'text/html'
end
context 'with unauthorized user' do
before do
allow(Ability.abilities).to receive(:allowed?).with(user, :read_project, project).and_return(true)
allow(Ability.abilities).to receive(:allowed?).with(user, :read_board, project).and_return(false)
end
it 'returns a successful 404 response' do
read_board
expect(response).to have_http_status(404)
end
end
def read_board(format: :html)
get :show, namespace_id: project.namespace.to_param,
project_id: project.to_param,
format: format
end
end
end
......@@ -4,6 +4,11 @@ describe Projects::MergeRequestsController do
let(:project) { create(:project) }
let(:user) { create(:user) }
let(:merge_request) { create(:merge_request_with_diffs, target_project: project, source_project: project) }
let(:merge_request_with_conflicts) do
create(:merge_request, source_branch: 'conflict-resolvable', target_branch: 'conflict-start', source_project: project) do |mr|
mr.mark_as_unmergeable
end
end
before do
sign_in(user)
......@@ -523,4 +528,135 @@ describe Projects::MergeRequestsController do
end
end
end
describe 'GET conflicts' do
let(:json_response) { JSON.parse(response.body) }
context 'when the conflicts cannot be resolved in the UI' do
before do
allow_any_instance_of(Gitlab::Conflict::Parser).
to receive(:parse).and_raise(Gitlab::Conflict::Parser::UnexpectedDelimiter)
get :conflicts,
namespace_id: merge_request_with_conflicts.project.namespace.to_param,
project_id: merge_request_with_conflicts.project.to_param,
id: merge_request_with_conflicts.iid,
format: 'json'
end
it 'returns a 200 status code' do
expect(response).to have_http_status(:ok)
end
it 'returns JSON with a message' do
expect(json_response.keys).to contain_exactly('message', 'type')
end
end
context 'with valid conflicts' do
before do
get :conflicts,
namespace_id: merge_request_with_conflicts.project.namespace.to_param,
project_id: merge_request_with_conflicts.project.to_param,
id: merge_request_with_conflicts.iid,
format: 'json'
end
it 'includes meta info about the MR' do
expect(json_response['commit_message']).to include('Merge branch')
expect(json_response['commit_sha']).to match(/\h{40}/)
expect(json_response['source_branch']).to eq(merge_request_with_conflicts.source_branch)
expect(json_response['target_branch']).to eq(merge_request_with_conflicts.target_branch)
end
it 'includes each file that has conflicts' do
filenames = json_response['files'].map { |file| file['new_path'] }
expect(filenames).to contain_exactly('files/ruby/popen.rb', 'files/ruby/regex.rb')
end
it 'splits files into sections with lines' do
json_response['files'].each do |file|
file['sections'].each do |section|
expect(section).to include('conflict', 'lines')
section['lines'].each do |line|
if section['conflict']
expect(line['type']).to be_in(['old', 'new'])
expect(line.values_at('old_line', 'new_line')).to contain_exactly(nil, a_kind_of(Integer))
else
if line['type'].nil?
expect(line['old_line']).not_to eq(nil)
expect(line['new_line']).not_to eq(nil)
else
expect(line['type']).to eq('match')
expect(line['old_line']).to eq(nil)
expect(line['new_line']).to eq(nil)
end
end
end
end
end
end
it 'has unique section IDs across files' do
section_ids = json_response['files'].flat_map do |file|
file['sections'].map { |section| section['id'] }.compact
end
expect(section_ids.uniq).to eq(section_ids)
end
end
end
context 'POST resolve_conflicts' do
let(:json_response) { JSON.parse(response.body) }
let!(:original_head_sha) { merge_request_with_conflicts.diff_head_sha }
def resolve_conflicts(sections)
post :resolve_conflicts,
namespace_id: merge_request_with_conflicts.project.namespace.to_param,
project_id: merge_request_with_conflicts.project.to_param,
id: merge_request_with_conflicts.iid,
format: 'json',
sections: sections,
commit_message: 'Commit message'
end
context 'with valid params' do
before do
resolve_conflicts('2f6fcd96b88b36ce98c38da085c795a27d92a3dd_14_14' => 'head',
'6eb14e00385d2fb284765eb1cd8d420d33d63fc9_9_9' => 'head',
'6eb14e00385d2fb284765eb1cd8d420d33d63fc9_21_21' => 'origin',
'6eb14e00385d2fb284765eb1cd8d420d33d63fc9_49_49' => 'origin')
end
it 'creates a new commit on the branch' do
expect(original_head_sha).not_to eq(merge_request_with_conflicts.source_branch_head.sha)
expect(merge_request_with_conflicts.source_branch_head.message).to include('Commit message')
end
it 'returns an OK response' do
expect(response).to have_http_status(:ok)
end
end
context 'when sections are missing' do
before do
resolve_conflicts('2f6fcd96b88b36ce98c38da085c795a27d92a3dd_14_14' => 'head')
end
it 'returns a 400 error' do
expect(response).to have_http_status(:bad_request)
end
it 'has a message with the name of the first missing section' do
expect(json_response['message']).to include('6eb14e00385d2fb284765eb1cd8d420d33d63fc9_9_9')
end
it 'does not create a new commit' do
expect(original_head_sha).to eq(merge_request_with_conflicts.source_branch_head.sha)
end
end
end
end
FactoryGirl.define do
factory :board do
project factory: :empty_project
end
end
FactoryGirl.define do
factory :list do
board
label
list_type :label
sequence(:position)
end
factory :backlog_list, parent: :list do
list_type :backlog
label nil
position nil
end
factory :done_list, parent: :list do
list_type :done
label nil
position nil
end
end
......@@ -83,4 +83,10 @@ FactoryGirl.define do
)
end
end
factory :project_with_board, parent: :empty_project do
after(:create) do |project|
project.create_board
end
end
end
require 'rails_helper'
describe 'Issue Boards', feature: true, js: true do
include WaitForAjax
let(:project) { create(:empty_project, :public) }
let(:user) { create(:user) }
let!(:user2) { create(:user) }
before do
project.create_board
project.board.lists.create(list_type: :backlog)
project.board.lists.create(list_type: :done)
project.team << [user, :master]
project.team << [user2, :master]
login_as(user)
end
context 'no lists' do
before do
visit namespace_project_board_path(project.namespace, project)
wait_for_vue_resource
expect(page).to have_selector('.board', count: 3)
end
it 'shows blank state' do
expect(page).to have_content('Welcome to your Issue Board!')
end
it 'hides the blank state when clicking nevermind button' do
page.within(find('.board-blank-state')) do
click_button("Nevermind, I'll use my own")
end
expect(page).to have_selector('.board', count: 2)
end
it 'creates default lists' do
lists = ['Backlog', 'Development', 'Testing', 'Production', 'Ready', 'Done']
page.within(find('.board-blank-state')) do
click_button('Add default lists')
end
wait_for_vue_resource
expect(page).to have_selector('.board', count: 6)
page.all('.board').each_with_index do |list, i|
expect(list.find('.board-title')).to have_content(lists[i])
end
end
end
context 'with lists' do
let(:milestone) { create(:milestone, project: project) }
let(:planning) { create(:label, project: project, name: 'Planning') }
let(:development) { create(:label, project: project, name: 'Development') }
let(:testing) { create(:label, project: project, name: 'Testing') }
let(:bug) { create(:label, project: project, name: 'Bug') }
let!(:backlog) { create(:label, project: project, name: 'Backlog') }
let!(:done) { create(:label, project: project, name: 'Done') }
let!(:list1) { create(:list, board: project.board, label: planning, position: 0) }
let!(:list2) { create(:list, board: project.board, label: development, position: 1) }
let!(:confidential_issue) { create(:issue, :confidential, project: project, author: user) }
let!(:issue1) { create(:issue, project: project, assignee: user) }
let!(:issue2) { create(:issue, project: project, author: user2) }
let!(:issue3) { create(:issue, project: project) }
let!(:issue4) { create(:issue, project: project) }
let!(:issue5) { create(:labeled_issue, project: project, labels: [planning], milestone: milestone) }
let!(:issue6) { create(:labeled_issue, project: project, labels: [planning, development]) }
let!(:issue7) { create(:labeled_issue, project: project, labels: [development]) }
let!(:issue8) { create(:closed_issue, project: project) }
let!(:issue9) { create(:labeled_issue, project: project, labels: [testing, bug]) }
before do
visit namespace_project_board_path(project.namespace, project)
wait_for_vue_resource
expect(page).to have_selector('.board', count: 4)
expect(find('.board:nth-child(1)')).to have_selector('.card')
expect(find('.board:nth-child(2)')).to have_selector('.card')
expect(find('.board:nth-child(3)')).to have_selector('.card')
expect(find('.board:nth-child(4)')).to have_selector('.card')
end
it 'shows lists' do
expect(page).to have_selector('.board', count: 4)
end
it 'shows issues in lists' do
page.within(find('.board:nth-child(2)')) do
expect(page.find('.board-header')).to have_content('2')
expect(page).to have_selector('.card', count: 2)
end
page.within(find('.board:nth-child(3)')) do
expect(page.find('.board-header')).to have_content('2')
expect(page).to have_selector('.card', count: 2)
end
end
it 'shows confidential issues with icon' do
page.within(find('.board', match: :first)) do
expect(page).to have_selector('.confidential-icon', count: 1)
end
end
it 'allows user to delete board' do
page.within(find('.board:nth-child(2)')) do
find('.board-delete').click
end
wait_for_vue_resource
expect(page).to have_selector('.board', count: 3)
end
it 'removes checkmark in new list dropdown after deleting' do
click_button 'Create new list'
wait_for_ajax
page.within(find('.board:nth-child(2)')) do
find('.board-delete').click
end
wait_for_vue_resource
expect(page).to have_selector('.board', count: 3)
expect(find(".js-board-list-#{planning.id}", visible: false)).not_to have_css('.is-active')
end
it 'infinite scrolls list' do
50.times do
create(:issue, project: project)
end
visit namespace_project_board_path(project.namespace, project)
wait_for_vue_resource
page.within(find('.board', match: :first)) do
expect(page.find('.board-header')).to have_content('20')
expect(page).to have_selector('.card', count: 20)
evaluate_script("document.querySelectorAll('.board .board-list')[0].scrollTop = document.querySelectorAll('.board .board-list')[0].scrollHeight")
expect(page.find('.board-header')).to have_content('40')
expect(page).to have_selector('.card', count: 40)
end
end
context 'backlog' do
it 'shows issues in backlog with no labels' do
page.within(find('.board', match: :first)) do
expect(page.find('.board-header')).to have_content('6')
expect(page).to have_selector('.card', count: 6)
end
end
it 'is searchable' do
page.within(find('.board', match: :first)) do
find('.form-control').set issue1.title
expect(page).to have_selector('.card', count: 1)
end
end
it 'clears search' do
page.within(find('.board', match: :first)) do
find('.form-control').set issue1.title
expect(page).to have_selector('.card', count: 1)
find('.board-search-clear-btn').click
expect(page).to have_selector('.card', count: 6)
end
end
it 'moves issue from backlog into list' do
drag_to(list_to_index: 1)
page.within(find('.board', match: :first)) do
expect(page.find('.board-header')).to have_content('5')
expect(page).to have_selector('.card', count: 5)
end
page.within(find('.board:nth-child(2)')) do
expect(page.find('.board-header')).to have_content('3')
expect(page).to have_selector('.card', count: 3)
end
end
end
context 'done' do
it 'shows list of done issues' do
expect(find('.board:nth-child(4)')).to have_selector('.card', count: 1)
end
it 'moves issue to done' do
drag_to(list_from_index: 0, list_to_index: 3)
expect(find('.board:nth-child(4)')).to have_selector('.card', count: 2)
expect(find('.board:nth-child(4)')).to have_content(issue9.title)
expect(find('.board:nth-child(4)')).not_to have_content(planning.title)
end
it 'removes all of the same issue to done' do
drag_to(list_from_index: 1, list_to_index: 3)
expect(find('.board:nth-child(2)')).to have_selector('.card', count: 1)
expect(find('.board:nth-child(3)')).to have_selector('.card', count: 1)
expect(find('.board:nth-child(4)')).to have_content(issue6.title)
expect(find('.board:nth-child(4)')).not_to have_content(planning.title)
end
end
context 'lists' do
it 'changes position of list' do
drag_to(list_from_index: 1, list_to_index: 2, selector: '.board-header')
expect(find('.board:nth-child(2)')).to have_content(development.title)
expect(find('.board:nth-child(2)')).to have_content(planning.title)
end
it 'issue moves between lists' do
drag_to(list_from_index: 1, card_index: 1, list_to_index: 2)
expect(find('.board:nth-child(2)')).to have_selector('.card', count: 1)
expect(find('.board:nth-child(3)')).to have_selector('.card', count: 3)
expect(find('.board:nth-child(3)')).to have_content(issue6.title)
expect(find('.board:nth-child(3)').all('.card').last).not_to have_content(development.title)
end
it 'issue moves between lists' do
drag_to(list_from_index: 2, list_to_index: 1)
expect(find('.board:nth-child(2)')).to have_selector('.card', count: 3)
expect(find('.board:nth-child(3)')).to have_selector('.card', count: 1)
expect(find('.board:nth-child(2)')).to have_content(issue7.title)
expect(find('.board:nth-child(2)').all('.card').first).not_to have_content(planning.title)
end
it 'issue moves from done' do
drag_to(list_from_index: 3, list_to_index: 1)
expect(find('.board:nth-child(2)')).to have_selector('.card', count: 3)
expect(find('.board:nth-child(2)')).to have_content(issue8.title)
end
context 'issue card' do
it 'shows assignee' do
page.within(find('.board', match: :first)) do
expect(page).to have_selector('.avatar', count: 1)
end
end
end
context 'new list' do
it 'shows all labels in new list dropdown' do
click_button 'Create new list'
page.within('.dropdown-menu-issues-board-new') do
expect(page).to have_content(planning.title)
expect(page).to have_content(development.title)
expect(page).to have_content(testing.title)
end
end
it 'creates new list for label' do
click_button 'Create new list'
page.within('.dropdown-menu-issues-board-new') do
click_link testing.title
end
wait_for_vue_resource
expect(page).to have_selector('.board', count: 5)
end
it 'creates new list for Backlog label' do
click_button 'Create new list'
page.within('.dropdown-menu-issues-board-new') do
click_link backlog.title
end
wait_for_vue_resource
expect(page).to have_selector('.board', count: 5)
end
it 'creates new list for Done label' do
click_button 'Create new list'
page.within('.dropdown-menu-issues-board-new') do
click_link done.title
end
wait_for_vue_resource
expect(page).to have_selector('.board', count: 5)
end
it 'moves issues from backlog into new list' do
page.within(find('.board', match: :first)) do
expect(page.find('.board-header')).to have_content('6')
expect(page).to have_selector('.card', count: 6)
end
click_button 'Create new list'
page.within('.dropdown-menu-issues-board-new') do
click_link testing.title
end
wait_for_vue_resource
page.within(find('.board', match: :first)) do
expect(page.find('.board-header')).to have_content('5')
expect(page).to have_selector('.card', count: 5)
end
end
end
end
context 'filtering' do
it 'filters by author' do
page.within '.issues-filters' do
click_button('Author')
page.within '.dropdown-menu-author' do
click_link(user2.name)
end
wait_for_vue_resource(spinner: false)
expect(find('.js-author-search')).to have_content(user2.name)
end
wait_for_vue_resource
page.within(find('.board', match: :first)) do
expect(page.find('.board-header')).to have_content('1')
expect(page).to have_selector('.card', count: 1)
end
page.within(find('.board:nth-child(2)')) do
expect(page.find('.board-header')).to have_content('0')
expect(page).to have_selector('.card', count: 0)
end
end
it 'filters by assignee' do
page.within '.issues-filters' do
click_button('Assignee')
page.within '.dropdown-menu-assignee' do
click_link(user.name)
end
wait_for_vue_resource(spinner: false)
expect(find('.js-assignee-search')).to have_content(user.name)
end
wait_for_vue_resource
page.within(find('.board', match: :first)) do
expect(page.find('.board-header')).to have_content('1')
expect(page).to have_selector('.card', count: 1)
end
page.within(find('.board:nth-child(2)')) do
expect(page.find('.board-header')).to have_content('0')
expect(page).to have_selector('.card', count: 0)
end
end
it 'filters by milestone' do
page.within '.issues-filters' do
click_button('Milestone')
page.within '.milestone-filter' do
click_link(milestone.title)
end
wait_for_vue_resource(spinner: false)
expect(find('.js-milestone-select')).to have_content(milestone.title)
end
wait_for_vue_resource
page.within(find('.board', match: :first)) do
expect(page.find('.board-header')).to have_content('0')
expect(page).to have_selector('.card', count: 0)
end
page.within(find('.board:nth-child(2)')) do
expect(page.find('.board-header')).to have_content('1')
expect(page).to have_selector('.card', count: 1)
end
end
it 'filters by label' do
page.within '.issues-filters' do
click_button('Label')
page.within '.dropdown-menu-labels' do
click_link(testing.title)
wait_for_vue_resource(spinner: false)
find('.dropdown-menu-close').click
end
end
wait_for_vue_resource
page.within(find('.board', match: :first)) do
expect(page.find('.board-header')).to have_content('1')
expect(page).to have_selector('.card', count: 1)
end
page.within(find('.board:nth-child(2)')) do
expect(page.find('.board-header')).to have_content('0')
expect(page).to have_selector('.card', count: 0)
end
end
it 'infinite scrolls list with label filter' do
50.times do
create(:labeled_issue, project: project, labels: [testing])
end
page.within '.issues-filters' do
click_button('Label')
page.within '.dropdown-menu-labels' do
click_link(testing.title)
wait_for_vue_resource(spinner: false)
find('.dropdown-menu-close').click
end
end
wait_for_vue_resource
page.within(find('.board', match: :first)) do
expect(page.find('.board-header')).to have_content('20')
expect(page).to have_selector('.card', count: 20)
evaluate_script("document.querySelectorAll('.board .board-list')[0].scrollTop = document.querySelectorAll('.board .board-list')[0].scrollHeight")
expect(page.find('.board-header')).to have_content('40')
expect(page).to have_selector('.card', count: 40)
end
end
it 'filters by multiple labels' do
page.within '.issues-filters' do
click_button('Label')
page.within '.dropdown-menu-labels' do
click_link(testing.title)
wait_for_vue_resource(spinner: false)
click_link(bug.title)
wait_for_vue_resource(spinner: false)
find('.dropdown-menu-close').click
end
end
wait_for_vue_resource
page.within(find('.board', match: :first)) do
expect(page.find('.board-header')).to have_content('1')
expect(page).to have_selector('.card', count: 1)
end
page.within(find('.board:nth-child(2)')) do
expect(page.find('.board-header')).to have_content('0')
expect(page).to have_selector('.card', count: 0)
end
end
it 'filters by no label' do
page.within '.issues-filters' do
click_button('Label')
page.within '.dropdown-menu-labels' do
click_link("No Label")
wait_for_vue_resource(spinner: false)
find('.dropdown-menu-close').click
end
end
wait_for_vue_resource
page.within(find('.board', match: :first)) do
expect(page.find('.board-header')).to have_content('5')
expect(page).to have_selector('.card', count: 5)
end
page.within(find('.board:nth-child(2)')) do
expect(page.find('.board-header')).to have_content('0')
expect(page).to have_selector('.card', count: 0)
end
end
it 'filters by clicking label button on issue' do
page.within(find('.board', match: :first)) do
expect(page).to have_selector('.card', count: 6)
click_button(bug.title)
wait_for_vue_resource(spinner: false)
end
page.within(find('.board', match: :first)) do
expect(page.find('.board-header')).to have_content('1')
expect(page).to have_selector('.card', count: 1)
end
page.within(find('.board:nth-child(2)')) do
expect(page.find('.board-header')).to have_content('0')
expect(page).to have_selector('.card', count: 0)
end
page.within('.labels-filter') do
expect(find('.dropdown-toggle-text')).to have_content(bug.title)
end
end
it 'removes label filter by clicking label button on issue' do
page.within(find('.board', match: :first)) do
page.within(find('.card', match: :first)) do
click_button(bug.title)
end
wait_for_vue_resource(spinner: false)
expect(page).to have_selector('.card', count: 1)
end
wait_for_vue_resource
page.within('.labels-filter') do
expect(find('.dropdown-toggle-text')).to have_content(bug.title)
end
end
end
end
context 'signed out user' do
before do
logout
visit namespace_project_board_path(project.namespace, project)
wait_for_vue_resource
end
it 'does not show create new list' do
expect(page).not_to have_selector('.js-new-board-list')
end
end
context 'as guest user' do
let(:user_guest) { create(:user) }
before do
project.team << [user_guest, :guest]
logout
login_as(user_guest)
visit namespace_project_board_path(project.namespace, project)
wait_for_vue_resource
end
it 'does not show create new list' do
expect(page).not_to have_selector('.js-new-board-list')
end
end
def drag_to(list_from_index: 0, card_index: 0, to_index: 0, list_to_index: 0, selector: '.board-list')
evaluate_script("simulateDrag({scrollable: document.getElementById('board-app'), from: {el: $('#{selector}').eq(#{list_from_index}).get(0), index: #{card_index}}, to: {el: $('.board-list').eq(#{list_to_index}).get(0), index: #{to_index}}});")
Timeout.timeout(Capybara.default_max_wait_time) do
loop until page.evaluate_script('window.SIMULATE_DRAG_ACTIVE').zero?
end
wait_for_vue_resource
end
def wait_for_vue_resource(spinner: true)
Timeout.timeout(Capybara.default_max_wait_time) do
loop until page.evaluate_script('Vue.activeResources').zero?
end
if spinner
expect(find('.boards-list')).not_to have_selector('.fa-spinner')
end
end
end
require 'spec_helper'
feature 'Merge request conflict resolution', js: true, feature: true do
include WaitForAjax
let(:user) { create(:user) }
let(:project) { create(:project) }
def create_merge_request(source_branch)
create(:merge_request, source_branch: source_branch, target_branch: 'conflict-start', source_project: project) do |mr|
mr.mark_as_unmergeable
end
end
context 'when a merge request can be resolved in the UI' do
let(:merge_request) { create_merge_request('conflict-resolvable') }
before do
project.team << [user, :developer]
login_as(user)
visit namespace_project_merge_request_path(project.namespace, project, merge_request)
end
it 'shows a link to the conflict resolution page' do
expect(page).to have_link('conflicts', href: /\/conflicts\Z/)
end
context 'visiting the conflicts resolution page' do
before { click_link('conflicts', href: /\/conflicts\Z/) }
it 'shows the conflicts' do
begin
expect(find('#conflicts')).to have_content('popen.rb')
rescue Capybara::Poltergeist::JavascriptError
retry
end
end
end
end
UNRESOLVABLE_CONFLICTS = {
'conflict-too-large' => 'when the conflicts contain a large file',
'conflict-binary-file' => 'when the conflicts contain a binary file',
'conflict-contains-conflict-markers' => 'when the conflicts contain a file with ambiguous conflict markers',
'conflict-missing-side' => 'when the conflicts contain a file edited in one branch and deleted in another'
}
UNRESOLVABLE_CONFLICTS.each do |source_branch, description|
context description do
let(:merge_request) { create_merge_request(source_branch) }
before do
project.team << [user, :developer]
login_as(user)
visit namespace_project_merge_request_path(project.namespace, project, merge_request)
end
it 'does not show a link to the conflict resolution page' do
expect(page).not_to have_link('conflicts', href: /\/conflicts\Z/)
end
it 'shows an error if the conflicts page is visited directly' do
visit current_url + '/conflicts'
wait_for_ajax
expect(find('#conflicts')).to have_content('Please try to resolve them locally.')
end
end
end
end
require 'spec_helper'
feature 'Pipelines for Merge Requests', feature: true, js: true do
include WaitForAjax
given(:user) { create(:user) }
given(:merge_request) { create(:merge_request) }
given(:project) { merge_request.target_project }
before do
project.team << [user, :master]
login_as user
end
context 'with pipelines' do
let!(:pipeline) do
create(:ci_empty_pipeline,
project: merge_request.source_project,
ref: merge_request.source_branch,
sha: merge_request.diff_head_sha)
end
before do
visit namespace_project_merge_request_path(project.namespace, project, merge_request)
end
scenario 'user visits merge request pipelines tab' do
page.within('.merge-request-tabs') do
click_link('Pipelines')
end
wait_for_ajax
expect(page).to have_selector('.pipeline-actions')
end
end
context 'without pipelines' do
before do
visit namespace_project_merge_request_path(project.namespace, project, merge_request)
end
scenario 'user visits merge request page' do
page.within('.merge-request-tabs') do
expect(page).to have_no_link('Pipelines')
end
end
end
end
{
"type": "object",
"required" : [
"iid",
"title",
"confidential"
],
"properties" : {
"iid": { "type": "integer" },
"title": { "type": "string" },
"confidential": { "type": "boolean" },
"labels": {
"type": ["array"],
"required": [
"id",
"color",
"description",
"title",
"priority"
],
"properties": {
"id": { "type": "integer" },
"color": {
"type": "string",
"pattern": "^#[0-9A-Fa-f]{3}{1,2}+$"
},
"description": { "type": ["string", "null"] },
"title": { "type": "string" },
"priority": { "type": ["integer", "null"] }
}
},
"assignee": {
"id": { "type": "integet" },
"name": { "type": "string" },
"username": { "type": "string" },
"avatar_url": { "type": "uri" }
}
},
"additionalProperties": false
}
{
"type": "array",
"items": { "$ref": "issue.json" }
}
{
"type": "object",
"required" : [
"id",
"list_type",
"title",
"position"
],
"properties" : {
"id": { "type": "integer" },
"list_type": {
"type": "string",
"enum": ["backlog", "label", "done"]
},
"label": {
"type": ["object"],
"required": [
"id",
"color",
"description",
"title",
"priority"
],
"properties": {
"id": { "type": "integer" },
"color": {
"type": "string",
"pattern": "^#[0-9A-Fa-f]{3}{1,2}+$"
},
"description": { "type": ["string", "null"] },
"title": { "type": "string" },
"priority": { "type": ["integer", "null"] }
}
},
"title": { "type": "string" },
"position": { "type": ["integer", "null"] }
},
"additionalProperties": false
}
{
"type": "array",
"items": { "$ref": "list.json" }
}
//= require jquery
//= require jquery_ujs
//= require jquery.cookie
//= require vue
//= require vue-resource
//= require lib/utils/url_utility
//= require boards/models/issue
//= require boards/models/label
//= require boards/models/list
//= require boards/models/user
//= require boards/services/board_service
//= require boards/stores/boards_store
//= require ./mock_data
(() => {
beforeEach(() => {
gl.boardService = new BoardService('/test/issue-boards/board');
gl.issueBoards.BoardsStore.create();
$.cookie('issue_board_welcome_hidden', 'false');
});
describe('Store', () => {
it('starts with a blank state', () => {
expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(0);
});
describe('lists', () => {
it('creates new list without persisting to DB', () => {
gl.issueBoards.BoardsStore.addList(listObj);
expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(1);
});
it('finds list by ID', () => {
gl.issueBoards.BoardsStore.addList(listObj);
const list = gl.issueBoards.BoardsStore.findList('id', 1);
expect(list.id).toBe(1);
});
it('finds list by type', () => {
gl.issueBoards.BoardsStore.addList(listObj);
const list = gl.issueBoards.BoardsStore.findList('type', 'label');
expect(list).toBeDefined();
});
it('finds list limited by type', () => {
gl.issueBoards.BoardsStore.addList({
id: 1,
position: 0,
title: 'Test',
list_type: 'backlog'
});
const list = gl.issueBoards.BoardsStore.findList('id', 1, 'backlog');
expect(list).toBeDefined();
});
it('gets issue when new list added', (done) => {
gl.issueBoards.BoardsStore.addList(listObj);
const list = gl.issueBoards.BoardsStore.findList('id', 1);
expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(1);
setTimeout(() => {
expect(list.issues.length).toBe(1);
expect(list.issues[0].id).toBe(1);
done();
}, 0);
});
it('persists new list', (done) => {
gl.issueBoards.BoardsStore.new({
title: 'Test',
type: 'label',
label: {
id: 1,
title: 'Testing',
color: 'red',
description: 'testing;'
}
});
expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(1);
setTimeout(() => {
const list = gl.issueBoards.BoardsStore.findList('id', 1);
expect(list).toBeDefined();
expect(list.id).toBe(1);
expect(list.position).toBe(0);
done();
}, 0);
});
it('check for blank state adding', () => {
expect(gl.issueBoards.BoardsStore.shouldAddBlankState()).toBe(true);
});
it('check for blank state not adding', () => {
gl.issueBoards.BoardsStore.addList(listObj);
expect(gl.issueBoards.BoardsStore.shouldAddBlankState()).toBe(false);
});
it('check for blank state adding when backlog & done list exist', () => {
gl.issueBoards.BoardsStore.addList({
list_type: 'backlog'
});
gl.issueBoards.BoardsStore.addList({
list_type: 'done'
});
expect(gl.issueBoards.BoardsStore.shouldAddBlankState()).toBe(true);
});
it('adds the blank state', () => {
gl.issueBoards.BoardsStore.addBlankState();
const list = gl.issueBoards.BoardsStore.findList('type', 'blank', 'blank');
expect(list).toBeDefined();
});
it('removes list from state', () => {
gl.issueBoards.BoardsStore.addList(listObj);
expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(1);
gl.issueBoards.BoardsStore.removeList(1, 'label');
expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(0);
});
it('moves the position of lists', () => {
const listOne = gl.issueBoards.BoardsStore.addList(listObj),
listTwo = gl.issueBoards.BoardsStore.addList(listObjDuplicate);
expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(2);
gl.issueBoards.BoardsStore.moveList(listOne, ['2', '1']);
expect(listOne.position).toBe(1);
});
it('moves an issue from one list to another', (done) => {
const listOne = gl.issueBoards.BoardsStore.addList(listObj),
listTwo = gl.issueBoards.BoardsStore.addList(listObjDuplicate);
expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(2);
setTimeout(() => {
expect(listOne.issues.length).toBe(1);
expect(listTwo.issues.length).toBe(1);
gl.issueBoards.BoardsStore.moveIssueToList(listOne, listTwo, listOne.findIssue(1));
expect(listOne.issues.length).toBe(0);
expect(listTwo.issues.length).toBe(1);
done();
}, 0);
});
});
});
})();
//= require jquery
//= require jquery_ujs
//= require jquery.cookie
//= require vue
//= require vue-resource
//= require lib/utils/url_utility
//= require boards/models/issue
//= require boards/models/label
//= require boards/models/list
//= require boards/models/user
//= require boards/services/board_service
//= require boards/stores/boards_store
//= require ./mock_data
describe('Issue model', () => {
let issue;
beforeEach(() => {
gl.boardService = new BoardService('/test/issue-boards/board');
gl.issueBoards.BoardsStore.create();
issue = new ListIssue({
title: 'Testing',
iid: 1,
confidential: false,
labels: [{
id: 1,
title: 'test',
color: 'red',
description: 'testing'
}]
});
});
it('has label', () => {
expect(issue.labels.length).toBe(1);
});
it('add new label', () => {
issue.addLabel({
id: 2,
title: 'bug',
color: 'blue',
description: 'bugs!'
});
expect(issue.labels.length).toBe(2);
});
it('does not add existing label', () => {
issue.addLabel({
id: 2,
title: 'test',
color: 'blue',
description: 'bugs!'
});
expect(issue.labels.length).toBe(1);
});
it('finds label', () => {
const label = issue.findLabel(issue.labels[0]);
expect(label).toBeDefined();
});
it('removes label', () => {
const label = issue.findLabel(issue.labels[0]);
issue.removeLabel(label);
expect(issue.labels.length).toBe(0);
});
it('removes multiple labels', () => {
issue.addLabel({
id: 2,
title: 'bug',
color: 'blue',
description: 'bugs!'
});
expect(issue.labels.length).toBe(2);
issue.removeLabels([issue.labels[0], issue.labels[1]]);
expect(issue.labels.length).toBe(0);
});
});
//= require jquery
//= require jquery_ujs
//= require jquery.cookie
//= require vue
//= require vue-resource
//= require lib/utils/url_utility
//= require boards/models/issue
//= require boards/models/label
//= require boards/models/list
//= require boards/models/user
//= require boards/services/board_service
//= require boards/stores/boards_store
//= require ./mock_data
describe('List model', () => {
let list;
beforeEach(() => {
gl.boardService = new BoardService('/test/issue-boards/board');
gl.issueBoards.BoardsStore.create();
list = new List(listObj);
});
it('gets issues when created', (done) => {
setTimeout(() => {
expect(list.issues.length).toBe(1);
done();
}, 0);
});
it('saves list and returns ID', (done) => {
list = new List({
title: 'test',
label: {
id: 1,
title: 'test',
color: 'red'
}
});
list.save();
setTimeout(() => {
expect(list.id).toBe(1);
expect(list.type).toBe('label');
expect(list.position).toBe(0);
done();
}, 0);
});
it('destroys the list', (done) => {
gl.issueBoards.BoardsStore.addList(listObj);
list = gl.issueBoards.BoardsStore.findList('id', 1);
expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(1);
list.destroy();
setTimeout(() => {
expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(0);
done();
}, 0);
});
it('can\'t search when not backlog', () => {
expect(list.canSearch()).toBe(false);
});
it('can search when backlog', () => {
list.type = 'backlog';
expect(list.canSearch()).toBe(true);
});
it('gets issue from list', (done) => {
setTimeout(() => {
const issue = list.findIssue(1);
expect(issue).toBeDefined();
done();
}, 0);
});
it('removes issue', (done) => {
setTimeout(() => {
const issue = list.findIssue(1);
expect(list.issues.length).toBe(1);
list.removeIssue(issue);
expect(list.issues.length).toBe(0);
done();
}, 0);
});
});
const listObj = {
id: 1,
position: 0,
title: 'Test',
list_type: 'label',
label: {
id: 1,
title: 'Testing',
color: 'red',
description: 'testing;'
}
};
const listObjDuplicate = {
id: 2,
position: 1,
title: 'Test',
list_type: 'label',
label: {
id: 2,
title: 'Testing',
color: 'red',
description: 'testing;'
}
};
const BoardsMockData = {
'GET': {
'/test/issue-boards/board/lists{/id}/issues': [{
title: 'Testing',
iid: 1,
confidential: false,
labels: []
}]
},
'POST': {
'/test/issue-boards/board/lists{/id}': listObj
},
'PUT': {
'/test/issue-boards/board/lists{/id}': {}
},
'DELETE': {
'/test/issue-boards/board/lists{/id}': {}
}
};
Vue.http.interceptors.push((request, next) => {
const body = BoardsMockData[request.method][request.url];
next(request.respondWith(JSON.stringify(body), {
status: 200
}));
});
require 'spec_helper'
describe Gitlab::Conflict::FileCollection, lib: true do
let(:merge_request) { create(:merge_request, source_branch: 'conflict-resolvable', target_branch: 'conflict-start') }
let(:file_collection) { Gitlab::Conflict::FileCollection.new(merge_request) }
describe '#files' do
it 'returns an array of Conflict::Files' do
expect(file_collection.files).to all(be_an_instance_of(Gitlab::Conflict::File))
end
end
describe '#default_commit_message' do
it 'matches the format of the git CLI commit message' do
expect(file_collection.default_commit_message).to eq(<<EOM.chomp)
Merge branch 'conflict-start' into 'conflict-resolvable'
# Conflicts:
# files/ruby/popen.rb
# files/ruby/regex.rb
EOM
end
end
end
require 'spec_helper'
describe Gitlab::Conflict::File, lib: true do
let(:project) { create(:project) }
let(:repository) { project.repository }
let(:rugged) { repository.rugged }
let(:their_commit) { rugged.branches['conflict-start'].target }
let(:our_commit) { rugged.branches['conflict-resolvable'].target }
let(:merge_request) { create(:merge_request, source_branch: 'conflict-resolvable', target_branch: 'conflict-start', source_project: project) }
let(:index) { rugged.merge_commits(our_commit, their_commit) }
let(:conflict) { index.conflicts.last }
let(:merge_file_result) { index.merge_file('files/ruby/regex.rb') }
let(:conflict_file) { Gitlab::Conflict::File.new(merge_file_result, conflict, merge_request: merge_request) }
describe '#resolve_lines' do
let(:section_keys) { conflict_file.sections.map { |section| section[:id] }.compact }
context 'when resolving everything to the same side' do
let(:resolution_hash) { section_keys.map { |key| [key, 'head'] }.to_h }
let(:resolved_lines) { conflict_file.resolve_lines(resolution_hash) }
let(:expected_lines) { conflict_file.lines.reject { |line| line.type == 'old' } }
it 'has the correct number of lines' do
expect(resolved_lines.length).to eq(expected_lines.length)
end
it 'has content matching the chosen lines' do
expect(resolved_lines.map(&:text)).to eq(expected_lines.map(&:text))
end
end
context 'with mixed resolutions' do
let(:resolution_hash) do
section_keys.map.with_index { |key, i| [key, i.even? ? 'head' : 'origin'] }.to_h
end
let(:resolved_lines) { conflict_file.resolve_lines(resolution_hash) }
it 'has the correct number of lines' do
file_lines = conflict_file.lines.reject { |line| line.type == 'new' }
expect(resolved_lines.length).to eq(file_lines.length)
end
it 'returns a file containing only the chosen parts of the resolved sections' do
expect(resolved_lines.chunk { |line| line.type || 'both' }.map(&:first)).
to eq(['both', 'new', 'both', 'old', 'both', 'new', 'both'])
end
end
it 'raises MissingResolution when passed a hash without resolutions for all sections' do
empty_hash = section_keys.map { |key| [key, nil] }.to_h
invalid_hash = section_keys.map { |key| [key, 'invalid'] }.to_h
expect { conflict_file.resolve_lines({}) }.
to raise_error(Gitlab::Conflict::File::MissingResolution)
expect { conflict_file.resolve_lines(empty_hash) }.
to raise_error(Gitlab::Conflict::File::MissingResolution)
expect { conflict_file.resolve_lines(invalid_hash) }.
to raise_error(Gitlab::Conflict::File::MissingResolution)
end
end
describe '#highlight_lines!' do
def html_to_text(html)
CGI.unescapeHTML(ActionView::Base.full_sanitizer.sanitize(html)).delete("\n")
end
it 'modifies the existing lines' do
expect { conflict_file.highlight_lines! }.to change { conflict_file.lines.map(&:instance_variables) }
end
it 'is called implicitly when rich_text is accessed on a line' do
expect(conflict_file).to receive(:highlight_lines!).once.and_call_original
conflict_file.lines.each(&:rich_text)
end
it 'sets the rich_text of the lines matching the text content' do
conflict_file.lines.each do |line|
expect(line.text).to eq(html_to_text(line.rich_text))
end
end
end
describe '#sections' do
it 'only inserts match lines when there is a gap between sections' do
conflict_file.sections.each_with_index do |section, i|
previous_line_number = 0
current_line_number = section[:lines].map(&:old_line).compact.min
if i > 0
previous_line_number = conflict_file.sections[i - 1][:lines].map(&:old_line).compact.last
end
if current_line_number == previous_line_number + 1
expect(section[:lines].first.type).not_to eq('match')
else
expect(section[:lines].first.type).to eq('match')
expect(section[:lines].first.text).to match(/\A@@ -#{current_line_number},\d+ \+\d+,\d+ @@ module Gitlab\Z/)
end
end
end
it 'sets conflict to false for sections with only unchanged lines' do
conflict_file.sections.reject { |section| section[:conflict] }.each do |section|
without_match = section[:lines].reject { |line| line.type == 'match' }
expect(without_match).to all(have_attributes(type: nil))
end
end
it 'only includes a maximum of CONTEXT_LINES (plus an optional match line) in context sections' do
conflict_file.sections.reject { |section| section[:conflict] }.each do |section|
without_match = section[:lines].reject { |line| line.type == 'match' }
expect(without_match.length).to be <= Gitlab::Conflict::File::CONTEXT_LINES * 2
end
end
it 'sets conflict to true for sections with only changed lines' do
conflict_file.sections.select { |section| section[:conflict] }.each do |section|
section[:lines].each do |line|
expect(line.type).to be_in(['new', 'old'])
end
end
end
it 'adds unique IDs to conflict sections, and not to other sections' do
section_ids = []
conflict_file.sections.each do |section|
if section[:conflict]
expect(section).to have_key(:id)
section_ids << section[:id]
else
expect(section).not_to have_key(:id)
end
end
expect(section_ids.uniq).to eq(section_ids)
end
context 'with an example file' do
let(:file) do
<<FILE
# Ensure there is no match line header here
def username_regexp
default_regexp
end
<<<<<<< files/ruby/regex.rb
def project_name_regexp
/\A[a-zA-Z0-9][a-zA-Z0-9_\-\. ]*\z/
end
def name_regexp
/\A[a-zA-Z0-9_\-\. ]*\z/
=======
def project_name_regex
%r{\A[a-zA-Z0-9][a-zA-Z0-9_\-\. ]*\z}
end
def name_regex
%r{\A[a-zA-Z0-9_\-\. ]*\z}
>>>>>>> files/ruby/regex.rb
end
# Some extra lines
# To force a match line
# To be created
def path_regexp
default_regexp
end
<<<<<<< files/ruby/regex.rb
def archive_formats_regexp
/(zip|tar|7z|tar\.gz|tgz|gz|tar\.bz2|tbz|tbz2|tb2|bz2)/
=======
def archive_formats_regex
%r{(zip|tar|7z|tar\.gz|tgz|gz|tar\.bz2|tbz|tbz2|tb2|bz2)}
>>>>>>> files/ruby/regex.rb
end
def git_reference_regexp
# Valid git ref regexp, see:
# https://www.kernel.org/pub/software/scm/git/docs/git-check-ref-format.html
%r{
(?!
(?# doesn't begins with)
\/| (?# rule #6)
(?# doesn't contain)
.*(?:
[\/.]\.| (?# rule #1,3)
\/\/| (?# rule #6)
@\{| (?# rule #8)
\\ (?# rule #9)
)
)
[^\000-\040\177~^:?*\[]+ (?# rule #4-5)
(?# doesn't end with)
(?<!\.lock) (?# rule #1)
(?<![\/.]) (?# rule #6-7)
}x
end
protected
<<<<<<< files/ruby/regex.rb
def default_regexp
/\A[.?]?[a-zA-Z0-9][a-zA-Z0-9_\-\.]*(?<!\.git)\z/
=======
def default_regex
%r{\A[.?]?[a-zA-Z0-9][a-zA-Z0-9_\-\.]*(?<!\.git)\z}
>>>>>>> files/ruby/regex.rb
end
FILE
end
let(:conflict_file) { Gitlab::Conflict::File.new({ data: file }, conflict, merge_request: merge_request) }
let(:sections) { conflict_file.sections }
it 'sets the correct match line headers' do
expect(sections[0][:lines].first).to have_attributes(type: 'match', text: '@@ -3,14 +3,14 @@')
expect(sections[3][:lines].first).to have_attributes(type: 'match', text: '@@ -19,26 +19,26 @@ def path_regexp')
expect(sections[6][:lines].first).to have_attributes(type: 'match', text: '@@ -47,52 +47,52 @@ end')
end
it 'does not add match lines where they are not needed' do
expect(sections[1][:lines].first.type).not_to eq('match')
expect(sections[2][:lines].first.type).not_to eq('match')
expect(sections[4][:lines].first.type).not_to eq('match')
expect(sections[5][:lines].first.type).not_to eq('match')
expect(sections[7][:lines].first.type).not_to eq('match')
end
it 'creates context sections of the correct length' do
expect(sections[0][:lines].reject(&:type).length).to eq(3)
expect(sections[2][:lines].reject(&:type).length).to eq(3)
expect(sections[3][:lines].reject(&:type).length).to eq(3)
expect(sections[5][:lines].reject(&:type).length).to eq(3)
expect(sections[6][:lines].reject(&:type).length).to eq(3)
expect(sections[8][:lines].reject(&:type).length).to eq(1)
end
end
end
describe '#as_json' do
it 'includes the blob path for the file' do
expect(conflict_file.as_json[:blob_path]).
to eq("/#{project.namespace.to_param}/#{merge_request.project.to_param}/blob/#{our_commit.oid}/files/ruby/regex.rb")
end
it 'includes the blob icon for the file' do
expect(conflict_file.as_json[:blob_icon]).to eq('file-text-o')
end
end
end
require 'spec_helper'
describe Gitlab::Conflict::Parser, lib: true do
let(:parser) { Gitlab::Conflict::Parser.new }
describe '#parse' do
def parse_text(text)
parser.parse(text, our_path: 'README.md', their_path: 'README.md')
end
context 'when the file has valid conflicts' do
let(:text) do
<<CONFLICT
module Gitlab
module Regexp
extend self
def username_regexp
default_regexp
end
<<<<<<< files/ruby/regex.rb
def project_name_regexp
/\A[a-zA-Z0-9][a-zA-Z0-9_\-\. ]*\z/
end
def name_regexp
/\A[a-zA-Z0-9_\-\. ]*\z/
=======
def project_name_regex
%r{\A[a-zA-Z0-9][a-zA-Z0-9_\-\. ]*\z}
end
def name_regex
%r{\A[a-zA-Z0-9_\-\. ]*\z}
>>>>>>> files/ruby/regex.rb
end
def path_regexp
default_regexp
end
<<<<<<< files/ruby/regex.rb
def archive_formats_regexp
/(zip|tar|7z|tar\.gz|tgz|gz|tar\.bz2|tbz|tbz2|tb2|bz2)/
=======
def archive_formats_regex
%r{(zip|tar|7z|tar\.gz|tgz|gz|tar\.bz2|tbz|tbz2|tb2|bz2)}
>>>>>>> files/ruby/regex.rb
end
def git_reference_regexp
# Valid git ref regexp, see:
# https://www.kernel.org/pub/software/scm/git/docs/git-check-ref-format.html
%r{
(?!
(?# doesn't begins with)
\/| (?# rule #6)
(?# doesn't contain)
.*(?:
[\/.]\.| (?# rule #1,3)
\/\/| (?# rule #6)
@\{| (?# rule #8)
\\ (?# rule #9)
)
)
[^\000-\040\177~^:?*\[]+ (?# rule #4-5)
(?# doesn't end with)
(?<!\.lock) (?# rule #1)
(?<![\/.]) (?# rule #6-7)
}x
end
protected
<<<<<<< files/ruby/regex.rb
def default_regexp
/\A[.?]?[a-zA-Z0-9][a-zA-Z0-9_\-\.]*(?<!\.git)\z/
=======
def default_regex
%r{\A[.?]?[a-zA-Z0-9][a-zA-Z0-9_\-\.]*(?<!\.git)\z}
>>>>>>> files/ruby/regex.rb
end
end
end
CONFLICT
end
let(:lines) do
parser.parse(text, our_path: 'files/ruby/regex.rb', their_path: 'files/ruby/regex.rb')
end
it 'sets our lines as new lines' do
expect(lines[8..13]).to all(have_attributes(type: 'new'))
expect(lines[26..27]).to all(have_attributes(type: 'new'))
expect(lines[56..57]).to all(have_attributes(type: 'new'))
end
it 'sets their lines as old lines' do
expect(lines[14..19]).to all(have_attributes(type: 'old'))
expect(lines[28..29]).to all(have_attributes(type: 'old'))
expect(lines[58..59]).to all(have_attributes(type: 'old'))
end
it 'sets non-conflicted lines as both' do
expect(lines[0..7]).to all(have_attributes(type: nil))
expect(lines[20..25]).to all(have_attributes(type: nil))
expect(lines[30..55]).to all(have_attributes(type: nil))
expect(lines[60..62]).to all(have_attributes(type: nil))
end
it 'sets consecutive line numbers for index, old_pos, and new_pos' do
old_line_numbers = lines.select { |line| line.type != 'new' }.map(&:old_pos)
new_line_numbers = lines.select { |line| line.type != 'old' }.map(&:new_pos)
expect(lines.map(&:index)).to eq(0.upto(62).to_a)
expect(old_line_numbers).to eq(1.upto(53).to_a)
expect(new_line_numbers).to eq(1.upto(53).to_a)
end
end
context 'when the file contents include conflict delimiters' do
it 'raises UnexpectedDelimiter when there is a non-start delimiter first' do
expect { parse_text('=======') }.
to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter)
expect { parse_text('>>>>>>> README.md') }.
to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter)
expect { parse_text('>>>>>>> some-other-path.md') }.
not_to raise_error
end
it 'raises UnexpectedDelimiter when a start delimiter is followed by a non-middle delimiter' do
start_text = "<<<<<<< README.md\n"
end_text = "\n=======\n>>>>>>> README.md"
expect { parse_text(start_text + '>>>>>>> README.md' + end_text) }.
to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter)
expect { parse_text(start_text + start_text + end_text) }.
to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter)
expect { parse_text(start_text + '>>>>>>> some-other-path.md' + end_text) }.
not_to raise_error
end
it 'raises UnexpectedDelimiter when a middle delimiter is followed by a non-end delimiter' do
start_text = "<<<<<<< README.md\n=======\n"
end_text = "\n>>>>>>> README.md"
expect { parse_text(start_text + '=======' + end_text) }.
to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter)
expect { parse_text(start_text + start_text + end_text) }.
to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter)
expect { parse_text(start_text + '>>>>>>> some-other-path.md' + end_text) }.
not_to raise_error
end
it 'raises MissingEndDelimiter when there is no end delimiter at the end' do
start_text = "<<<<<<< README.md\n=======\n"
expect { parse_text(start_text) }.
to raise_error(Gitlab::Conflict::Parser::MissingEndDelimiter)
expect { parse_text(start_text + '>>>>>>> some-other-path.md') }.
to raise_error(Gitlab::Conflict::Parser::MissingEndDelimiter)
end
end
context 'other file types' do
it 'raises UnmergeableFile when lines is blank, indicating a binary file' do
expect { parse_text('') }.
to raise_error(Gitlab::Conflict::Parser::UnmergeableFile)
expect { parse_text(nil) }.
to raise_error(Gitlab::Conflict::Parser::UnmergeableFile)
end
it 'raises UnmergeableFile when the file is over 100 KB' do
expect { parse_text('a' * 102401) }.
to raise_error(Gitlab::Conflict::Parser::UnmergeableFile)
end
end
end
end
......@@ -23,6 +23,24 @@ describe Gitlab::Metrics::Metric do
it { is_expected.to eq({ host: 'localtoast' }) }
end
describe '#type' do
subject { metric.type }
it { is_expected.to eq(:metric) }
end
describe '#event?' do
it 'returns false for a regular metric' do
expect(metric.event?).to eq(false)
end
it 'returns true for an event metric' do
expect(metric).to receive(:type).and_return(:event)
expect(metric.event?).to eq(true)
end
end
describe '#to_hash' do
it 'returns a Hash' do
expect(metric.to_hash).to be_an_instance_of(Hash)
......
......@@ -45,6 +45,15 @@ describe Gitlab::Metrics::RackMiddleware do
middleware.call(env)
end
it 'tracks any raised exceptions' do
expect(app).to receive(:call).with(env).and_raise(RuntimeError)
expect_any_instance_of(Gitlab::Metrics::Transaction).
to receive(:add_event).with(:rails_exception)
expect { middleware.call(env) }.to raise_error(RuntimeError)
end
end
describe '#transaction_from_env' do
......
......@@ -12,7 +12,9 @@ describe Gitlab::Metrics::SidekiqMiddleware do
with('TestWorker#perform').
and_call_original
expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:set).with(:sidekiq_queue_duration, instance_of(Float))
expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:set).
with(:sidekiq_queue_duration, instance_of(Float))
expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:finish)
middleware.call(worker, message, :test) { nil }
......@@ -25,10 +27,28 @@ describe Gitlab::Metrics::SidekiqMiddleware do
with('TestWorker#perform').
and_call_original
expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:set).with(:sidekiq_queue_duration, instance_of(Float))
expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:set).
with(:sidekiq_queue_duration, instance_of(Float))
expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:finish)
middleware.call(worker, {}, :test) { nil }
end
it 'tracks any raised exceptions' do
worker = double(:worker, class: double(:class, name: 'TestWorker'))
expect_any_instance_of(Gitlab::Metrics::Transaction).
to receive(:run).and_raise(RuntimeError)
expect_any_instance_of(Gitlab::Metrics::Transaction).
to receive(:add_event).with(:sidekiq_exception)
expect_any_instance_of(Gitlab::Metrics::Transaction).
to receive(:finish)
expect { middleware.call(worker, message, :test) }.
to raise_error(RuntimeError)
end
end
end
......@@ -142,5 +142,62 @@ describe Gitlab::Metrics::Transaction do
transaction.submit
end
it 'does not add an action tag for events' do
transaction.action = 'Foo#bar'
transaction.add_event(:meow)
hash = {
series: 'events',
tags: { event: :meow },
values: { count: 1 },
timestamp: an_instance_of(Fixnum)
}
expect(Gitlab::Metrics).to receive(:submit_metrics).
with([hash])
transaction.submit
end
end
describe '#add_event' do
it 'adds a metric' do
transaction.add_event(:meow)
expect(transaction.metrics[0]).to be_an_instance_of(Gitlab::Metrics::Metric)
end
it "does not prefix the metric's series name" do
transaction.add_event(:meow)
metric = transaction.metrics[0]
expect(metric.series).to eq(described_class::EVENT_SERIES)
end
it 'tracks a counter for every event' do
transaction.add_event(:meow)
metric = transaction.metrics[0]
expect(metric.values).to eq(count: 1)
end
it 'tracks the event name' do
transaction.add_event(:meow)
metric = transaction.metrics[0]
expect(metric.tags).to eq(event: :meow)
end
it 'allows tracking of custom tags' do
transaction.add_event(:meow, animal: 'cat')
metric = transaction.metrics[0]
expect(metric.tags).to eq(event: :meow, animal: 'cat')
end
end
end
......@@ -153,4 +153,28 @@ describe Gitlab::Metrics do
expect(described_class.series_prefix).to be_an_instance_of(String)
end
end
describe '.add_event' do
context 'without a transaction' do
it 'does nothing' do
expect_any_instance_of(Gitlab::Metrics::Transaction).
not_to receive(:add_event)
Gitlab::Metrics.add_event(:meow)
end
end
context 'with a transaction' do
it 'adds an event' do
transaction = Gitlab::Metrics::Transaction.new
expect(transaction).to receive(:add_event).with(:meow)
expect(Gitlab::Metrics).to receive(:current_transaction).
and_return(transaction)
Gitlab::Metrics.add_event(:meow)
end
end
end
end
require 'rails_helper'
describe Board do
describe 'relationships' do
it { is_expected.to belong_to(:project) }
it { is_expected.to have_many(:lists).order(list_type: :asc, position: :asc).dependent(:delete_all) }
end
describe 'validations' do
it { is_expected.to validate_presence_of(:project) }
end
end
......@@ -5,8 +5,10 @@ describe Label, models: true do
describe 'associations' do
it { is_expected.to belong_to(:project) }
it { is_expected.to have_many(:label_links).dependent(:destroy) }
it { is_expected.to have_many(:issues).through(:label_links).source(:target) }
it { is_expected.to have_many(:lists).dependent(:destroy) }
end
describe 'modules' do
......
require 'rails_helper'
describe List do
describe 'relationships' do
it { is_expected.to belong_to(:board) }
it { is_expected.to belong_to(:label) }
end
describe 'validations' do
it { is_expected.to validate_presence_of(:board) }
it { is_expected.to validate_presence_of(:label) }
it { is_expected.to validate_presence_of(:list_type) }
it { is_expected.to validate_presence_of(:position) }
it { is_expected.to validate_numericality_of(:position).only_integer.is_greater_than_or_equal_to(0) }
it 'validates uniqueness of label scoped to board_id' do
create(:list)
expect(subject).to validate_uniqueness_of(:label_id).scoped_to(:board_id)
end
context 'when list_type is set to backlog' do
subject { described_class.new(list_type: :backlog) }
it { is_expected.not_to validate_presence_of(:label) }
it { is_expected.not_to validate_presence_of(:position) }
end
context 'when list_type is set to done' do
subject { described_class.new(list_type: :done) }
it { is_expected.not_to validate_presence_of(:label) }
it { is_expected.not_to validate_presence_of(:position) }
end
end
describe '#destroy' do
it 'can be destroyed when when list_type is set to label' do
subject = create(:list)
expect(subject.destroy).to be_truthy
end
it 'can not be destroyed when list_type is set to backlog' do
subject = create(:backlog_list)
expect(subject.destroy).to be_falsey
end
it 'can not be destroyed when when list_type is set to done' do
subject = create(:done_list)
expect(subject.destroy).to be_falsey
end
end
describe '#destroyable?' do
it 'retruns true when list_type is set to label' do
subject.list_type = :label
expect(subject).to be_destroyable
end
it 'retruns false when list_type is set to backlog' do
subject.list_type = :backlog
expect(subject).not_to be_destroyable
end
it 'retruns false when list_type is set to done' do
subject.list_type = :done
expect(subject).not_to be_destroyable
end
end
describe '#movable?' do
it 'retruns true when list_type is set to label' do
subject.list_type = :label
expect(subject).to be_movable
end
it 'retruns false when list_type is set to backlog' do
subject.list_type = :backlog
expect(subject).not_to be_movable
end
it 'retruns false when list_type is set to done' do
subject.list_type = :done
expect(subject).not_to be_movable
end
end
describe '#title' do
it 'returns label name when list_type is set to label' do
subject.list_type = :label
subject.label = Label.new(name: 'Development')
expect(subject.title).to eq 'Development'
end
it 'returns Backlog when list_type is set to backlog' do
subject.list_type = :backlog
expect(subject.title).to eq 'Backlog'
end
it 'returns Done when list_type is set to done' do
subject.list_type = :done
expect(subject.title).to eq 'Done'
end
end
end
......@@ -456,6 +456,20 @@ describe MergeRequest, models: true do
subject { create :merge_request, :simple }
end
describe '#commits_sha' do
let(:commit0) { double('commit0', sha: 'sha1') }
let(:commit1) { double('commit1', sha: 'sha2') }
let(:commit2) { double('commit2', sha: 'sha3') }
before do
allow(subject.merge_request_diff).to receive(:commits).and_return([commit0, commit1, commit2])
end
it 'returns sha of commits' do
expect(subject.commits_sha).to contain_exactly('sha1', 'sha2', 'sha3')
end
end
describe '#pipeline' do
describe 'when the source project exists' do
it 'returns the latest pipeline' do
......@@ -480,6 +494,19 @@ describe MergeRequest, models: true do
end
end
describe '#all_pipelines' do
let!(:pipelines) do
subject.merge_request_diff.commits.map do |commit|
create(:ci_empty_pipeline, project: subject.source_project, sha: commit.id, ref: subject.source_branch)
end
end
it 'returns a pipelines from source projects with proper ordering' do
expect(subject.all_pipelines).not_to be_empty
expect(subject.all_pipelines).to eq(pipelines.reverse)
end
end
describe '#participants' do
let(:project) { create(:project, :public) }
......@@ -848,4 +875,56 @@ describe MergeRequest, models: true do
end
end
end
describe '#conflicts_can_be_resolved_in_ui?' do
def create_merge_request(source_branch)
create(:merge_request, source_branch: source_branch, target_branch: 'conflict-start') do |mr|
mr.mark_as_unmergeable
end
end
it 'returns a falsey value when the MR can be merged without conflicts' do
merge_request = create_merge_request('master')
merge_request.mark_as_mergeable
expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey
end
it 'returns a falsey value when the MR does not support new diff notes' do
merge_request = create_merge_request('conflict-resolvable')
merge_request.merge_request_diff.update_attributes(start_commit_sha: nil)
expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey
end
it 'returns a falsey value when the conflicts contain a large file' do
merge_request = create_merge_request('conflict-too-large')
expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey
end
it 'returns a falsey value when the conflicts contain a binary file' do
merge_request = create_merge_request('conflict-binary-file')
expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey
end
it 'returns a falsey value when the conflicts contain a file with ambiguous conflict markers' do
merge_request = create_merge_request('conflict-contains-conflict-markers')
expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey
end
it 'returns a falsey value when the conflicts contain a file edited in one branch and deleted in another' do
merge_request = create_merge_request('conflict-missing-side')
expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey
end
it 'returns a truthy value when the conflicts are resolvable in the UI' do
merge_request = create_merge_request('conflict-resolvable')
expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_truthy
end
end
end
......@@ -23,6 +23,7 @@ describe Project, models: true do
it { is_expected.to have_one(:slack_service).dependent(:destroy) }
it { is_expected.to have_one(:pushover_service).dependent(:destroy) }
it { is_expected.to have_one(:asana_service).dependent(:destroy) }
it { is_expected.to have_one(:board).dependent(:destroy) }
it { is_expected.to have_many(:commit_statuses) }
it { is_expected.to have_many(:pipelines) }
it { is_expected.to have_many(:builds) }
......
require 'spec_helper'
describe Boards::CreateService, services: true do
describe '#execute' do
subject(:service) { described_class.new(project, double) }
context 'when project does not have a board' do
let(:project) { create(:empty_project, board: nil) }
it 'creates a new board' do
expect { service.execute }.to change(Board, :count).by(1)
end
it 'creates default lists' do
service.execute
expect(project.board.lists.size).to eq 2
expect(project.board.lists.first).to be_backlog
expect(project.board.lists.last).to be_done
end
end
context 'when project has a board' do
let!(:project) { create(:project_with_board) }
it 'does not create a new board' do
expect { service.execute }.not_to change(Board, :count)
end
it 'does not create board lists' do
expect { service.execute }.not_to change(project.board.lists, :count)
end
end
end
end
require 'spec_helper'
describe Boards::Issues::ListService, services: true do
describe '#execute' do
let(:user) { create(:user) }
let(:project) { create(:project_with_board) }
let(:board) { project.board }
let(:bug) { create(:label, project: project, name: 'Bug') }
let(:development) { create(:label, project: project, name: 'Development') }
let(:testing) { create(:label, project: project, name: 'Testing') }
let(:p1) { create(:label, title: 'P1', project: project, priority: 1) }
let(:p2) { create(:label, title: 'P2', project: project, priority: 2) }
let(:p3) { create(:label, title: 'P3', project: project, priority: 3) }
let!(:backlog) { create(:backlog_list, board: board) }
let!(:list1) { create(:list, board: board, label: development, position: 0) }
let!(:list2) { create(:list, board: board, label: testing, position: 1) }
let!(:done) { create(:done_list, board: board) }
let!(:opened_issue1) { create(:labeled_issue, project: project, labels: [bug]) }
let!(:opened_issue2) { create(:labeled_issue, project: project, labels: [p2]) }
let!(:reopened_issue1) { create(:issue, :reopened, project: project) }
let!(:list1_issue1) { create(:labeled_issue, project: project, labels: [p2, development]) }
let!(:list1_issue2) { create(:labeled_issue, project: project, labels: [development]) }
let!(:list1_issue3) { create(:labeled_issue, project: project, labels: [development, p1]) }
let!(:list2_issue1) { create(:labeled_issue, project: project, labels: [testing]) }
let!(:closed_issue1) { create(:labeled_issue, :closed, project: project, labels: [bug]) }
let!(:closed_issue2) { create(:labeled_issue, :closed, project: project, labels: [p3]) }
let!(:closed_issue3) { create(:issue, :closed, project: project) }
let!(:closed_issue4) { create(:labeled_issue, :closed, project: project, labels: [p1]) }
before do
project.team << [user, :developer]
end
it 'delegates search to IssuesFinder' do
params = { id: list1.id }
expect_any_instance_of(IssuesFinder).to receive(:execute).once.and_call_original
described_class.new(project, user, params).execute
end
context 'sets default order to priority' do
it 'returns opened issues when listing issues from Backlog' do
params = { id: backlog.id }
issues = described_class.new(project, user, params).execute
expect(issues).to eq [opened_issue2, reopened_issue1, opened_issue1]
end
it 'returns closed issues when listing issues from Done' do
params = { id: done.id }
issues = described_class.new(project, user, params).execute
expect(issues).to eq [closed_issue4, closed_issue2, closed_issue3, closed_issue1]
end
it 'returns opened issues that have label list applied when listing issues from a label list' do
params = { id: list1.id }
issues = described_class.new(project, user, params).execute
expect(issues).to eq [list1_issue3, list1_issue1, list1_issue2]
end
end
end
end
require 'spec_helper'
describe Boards::Issues::MoveService, services: true do
describe '#execute' do
let(:user) { create(:user) }
let(:project) { create(:project_with_board) }
let(:board) { project.board }
let(:bug) { create(:label, project: project, name: 'Bug') }
let(:development) { create(:label, project: project, name: 'Development') }
let(:testing) { create(:label, project: project, name: 'Testing') }
let!(:backlog) { create(:backlog_list, board: board) }
let!(:list1) { create(:list, board: board, label: development, position: 0) }
let!(:list2) { create(:list, board: board, label: testing, position: 1) }
let!(:done) { create(:done_list, board: board) }
before do
project.team << [user, :developer]
end
context 'when moving from backlog' do
it 'adds the label of the list it goes to' do
issue = create(:labeled_issue, project: project, labels: [bug])
params = { from_list_id: backlog.id, to_list_id: list1.id }
described_class.new(project, user, params).execute(issue)
expect(issue.reload.labels).to contain_exactly(bug, development)
end
end
context 'when moving to backlog' do
it 'removes all list-labels' do
issue = create(:labeled_issue, project: project, labels: [bug, development, testing])
params = { from_list_id: list1.id, to_list_id: backlog.id }
described_class.new(project, user, params).execute(issue)
expect(issue.reload.labels).to contain_exactly(bug)
end
end
context 'when moving from backlog to done' do
it 'closes the issue' do
issue = create(:labeled_issue, project: project, labels: [bug])
params = { from_list_id: backlog.id, to_list_id: done.id }
described_class.new(project, user, params).execute(issue)
issue.reload
expect(issue.labels).to contain_exactly(bug)
expect(issue).to be_closed
end
end
context 'when moving an issue between lists' do
let(:issue) { create(:labeled_issue, project: project, labels: [bug, development]) }
let(:params) { { from_list_id: list1.id, to_list_id: list2.id } }
it 'delegates the label changes to Issues::UpdateService' do
expect_any_instance_of(Issues::UpdateService).to receive(:execute).with(issue).once
described_class.new(project, user, params).execute(issue)
end
it 'removes the label from the list it came from and adds the label of the list it goes to' do
described_class.new(project, user, params).execute(issue)
expect(issue.reload.labels).to contain_exactly(bug, testing)
end
end
context 'when moving to done' do
let(:issue) { create(:labeled_issue, project: project, labels: [bug, development, testing]) }
let(:params) { { from_list_id: list2.id, to_list_id: done.id } }
it 'delegates the close proceedings to Issues::CloseService' do
expect_any_instance_of(Issues::CloseService).to receive(:execute).with(issue).once
described_class.new(project, user, params).execute(issue)
end
it 'removes all list-labels and close the issue' do
described_class.new(project, user, params).execute(issue)
issue.reload
expect(issue.labels).to contain_exactly(bug)
expect(issue).to be_closed
end
end
context 'when moving from done' do
let(:issue) { create(:labeled_issue, :closed, project: project, labels: [bug]) }
let(:params) { { from_list_id: done.id, to_list_id: list2.id } }
it 'delegates the re-open proceedings to Issues::ReopenService' do
expect_any_instance_of(Issues::ReopenService).to receive(:execute).with(issue).once
described_class.new(project, user, params).execute(issue)
end
it 'adds the label of the list it goes to and reopen the issue' do
described_class.new(project, user, params).execute(issue)
issue.reload
expect(issue.labels).to contain_exactly(bug, testing)
expect(issue).to be_reopened
end
end
context 'when moving from done to backlog' do
it 'reopens the issue' do
issue = create(:labeled_issue, :closed, project: project, labels: [bug])
params = { from_list_id: done.id, to_list_id: backlog.id }
described_class.new(project, user, params).execute(issue)
issue.reload
expect(issue.labels).to contain_exactly(bug)
expect(issue).to be_reopened
end
end
context 'when moving to same list' do
let(:issue) { create(:labeled_issue, project: project, labels: [bug, development]) }
let(:params) { { from_list_id: list1.id, to_list_id: list1.id } }
it 'returns false' do
expect(described_class.new(project, user, params).execute(issue)).to eq false
end
it 'keeps issues labels' do
described_class.new(project, user, params).execute(issue)
expect(issue.reload.labels).to contain_exactly(bug, development)
end
end
end
end
require 'spec_helper'
describe Boards::Lists::CreateService, services: true do
describe '#execute' do
let(:project) { create(:project_with_board) }
let(:board) { project.board }
let(:user) { create(:user) }
let(:label) { create(:label, name: 'in-progress') }
subject(:service) { described_class.new(project, user, label_id: label.id) }
context 'when board lists is empty' do
it 'creates a new list at beginning of the list' do
list = service.execute
expect(list.position).to eq 0
end
end
context 'when board lists has only a backlog list' do
it 'creates a new list at beginning of the list' do
create(:backlog_list, board: board)
list = service.execute
expect(list.position).to eq 0
end
end
context 'when board lists has only labels lists' do
it 'creates a new list at end of the lists' do
create(:list, board: board, position: 0)
create(:list, board: board, position: 1)
list = service.execute
expect(list.position).to eq 2
end
end
context 'when board lists has backlog, label and done lists' do
it 'creates a new list at end of the label lists' do
create(:backlog_list, board: board)
create(:done_list, board: board)
list1 = create(:list, board: board, position: 0)
list2 = service.execute
expect(list1.reload.position).to eq 0
expect(list2.reload.position).to eq 1
end
end
end
end
require 'spec_helper'
describe Boards::Lists::DestroyService, services: true do
describe '#execute' do
let(:project) { create(:project_with_board) }
let(:board) { project.board }
let(:user) { create(:user) }
context 'when list type is label' do
it 'removes list from board' do
list = create(:list, board: board)
service = described_class.new(project, user)
expect { service.execute(list) }.to change(board.lists, :count).by(-1)
end
it 'decrements position of higher lists' do
backlog = create(:backlog_list, board: board)
development = create(:list, board: board, position: 0)
review = create(:list, board: board, position: 1)
staging = create(:list, board: board, position: 2)
done = create(:done_list, board: board)
described_class.new(project, user).execute(development)
expect(backlog.reload.position).to be_nil
expect(review.reload.position).to eq 0
expect(staging.reload.position).to eq 1
expect(done.reload.position).to be_nil
end
end
it 'does not remove list from board when list type is backlog' do
list = create(:backlog_list, board: board)
service = described_class.new(project, user)
expect { service.execute(list) }.not_to change(board.lists, :count)
end
it 'does not remove list from board when list type is done' do
list = create(:done_list, board: board)
service = described_class.new(project, user)
expect { service.execute(list) }.not_to change(board.lists, :count)
end
end
end
require 'spec_helper'
describe Boards::Lists::GenerateService, services: true do
describe '#execute' do
let(:project) { create(:project_with_board) }
let(:board) { project.board }
let(:user) { create(:user) }
subject(:service) { described_class.new(project, user) }
context 'when board lists is empty' do
it 'creates the default lists' do
expect { service.execute }.to change(board.lists, :count).by(4)
end
end
context 'when board lists is not empty' do
it 'does not creates the default lists' do
create(:list, board: board)
expect { service.execute }.not_to change(board.lists, :count)
end
end
context 'when project labels does not contains any list label' do
it 'creates labels' do
expect { service.execute }.to change(project.labels, :count).by(4)
end
end
context 'when project labels contains some of list label' do
it 'creates the missing labels' do
create(:label, project: project, name: 'Development')
create(:label, project: project, name: 'Ready')
expect { service.execute }.to change(project.labels, :count).by(2)
end
end
end
end
require 'spec_helper'
describe Boards::Lists::MoveService, services: true do
describe '#execute' do
let(:project) { create(:project_with_board) }
let(:board) { project.board }
let(:user) { create(:user) }
let!(:backlog) { create(:backlog_list, board: board) }
let!(:planning) { create(:list, board: board, position: 0) }
let!(:development) { create(:list, board: board, position: 1) }
let!(:review) { create(:list, board: board, position: 2) }
let!(:staging) { create(:list, board: board, position: 3) }
let!(:done) { create(:done_list, board: board) }
context 'when list type is set to label' do
it 'keeps position of lists when new position is nil' do
service = described_class.new(project, user, position: nil)
service.execute(planning)
expect(current_list_positions).to eq [0, 1, 2, 3]
end
it 'keeps position of lists when new positon is equal to old position' do
service = described_class.new(project, user, position: planning.position)
service.execute(planning)
expect(current_list_positions).to eq [0, 1, 2, 3]
end
it 'keeps position of lists when new positon is negative' do
service = described_class.new(project, user, position: -1)
service.execute(planning)
expect(current_list_positions).to eq [0, 1, 2, 3]
end
it 'keeps position of lists when new positon is equal to number of labels lists' do
service = described_class.new(project, user, position: board.lists.label.size)
service.execute(planning)
expect(current_list_positions).to eq [0, 1, 2, 3]
end
it 'keeps position of lists when new positon is greater than number of labels lists' do
service = described_class.new(project, user, position: board.lists.label.size + 1)
service.execute(planning)
expect(current_list_positions).to eq [0, 1, 2, 3]
end
it 'increments position of intermediate lists when new positon is equal to first position' do
service = described_class.new(project, user, position: 0)
service.execute(staging)
expect(current_list_positions).to eq [1, 2, 3, 0]
end
it 'decrements position of intermediate lists when new positon is equal to last position' do
service = described_class.new(project, user, position: board.lists.label.last.position)
service.execute(planning)
expect(current_list_positions).to eq [3, 0, 1, 2]
end
it 'decrements position of intermediate lists when new position is greater than old position' do
service = described_class.new(project, user, position: 2)
service.execute(planning)
expect(current_list_positions).to eq [2, 0, 1, 3]
end
it 'increments position of intermediate lists when new position is lower than old position' do
service = described_class.new(project, user, position: 1)
service.execute(staging)
expect(current_list_positions).to eq [0, 2, 3, 1]
end
end
it 'keeps position of lists when list type is backlog' do
service = described_class.new(project, user, position: 2)
service.execute(backlog)
expect(current_list_positions).to eq [0, 1, 2, 3]
end
it 'keeps position of lists when list type is done' do
service = described_class.new(project, user, position: 2)
service.execute(done)
expect(current_list_positions).to eq [0, 1, 2, 3]
end
end
def current_list_positions
[planning, development, review, staging].map { |list| list.reload.position }
end
end
RSpec::Matchers.define :match_response_schema do |schema, **options|
match do |response|
schema_directory = "#{Dir.pwd}/spec/fixtures/api/schemas"
schema_path = "#{schema_directory}/#{schema}.json"
JSON::Validator.validate!(schema_path, response.body, options)
end
end
......@@ -23,7 +23,13 @@ module TestEnv
'expand-collapse-files' => '025db92',
'expand-collapse-lines' => '238e82d',
'video' => '8879059',
'crlf-diff' => '5938907'
'crlf-diff' => '5938907',
'conflict-start' => '14fa46b',
'conflict-resolvable' => '1450cd6',
'conflict-binary-file' => '259a6fb',
'conflict-contains-conflict-markers' => '5e0964c',
'conflict-missing-side' => 'eb227b3',
'conflict-too-large' => '39fa04f',
}
# gitlab-test-fork is a fork of gitlab-fork, but we don't necessarily
......
/**!
* Sortable
* @author RubaXa <trash@rubaxa.org>
* @license MIT
*/
(function (factory) {
"use strict";
if (typeof define === "function" && define.amd) {
define(factory);
}
else if (typeof module != "undefined" && typeof module.exports != "undefined") {
module.exports = factory();
}
else if (typeof Package !== "undefined") {
Sortable = factory(); // export for Meteor.js
}
else {
/* jshint sub:true */
window["Sortable"] = factory();
}
})(function () {
"use strict";
var dragEl,
parentEl,
ghostEl,
cloneEl,
rootEl,
nextEl,
scrollEl,
scrollParentEl,
lastEl,
lastCSS,
lastParentCSS,
oldIndex,
newIndex,
activeGroup,
autoScroll = {},
tapEvt,
touchEvt,
moved,
/** @const */
RSPACE = /\s+/g,
expando = 'Sortable' + (new Date).getTime(),
win = window,
document = win.document,
parseInt = win.parseInt,
supportDraggable = !!('draggable' in document.createElement('div')),
supportCssPointerEvents = (function (el) {
el = document.createElement('x');
el.style.cssText = 'pointer-events:auto';
return el.style.pointerEvents === 'auto';
})(),
_silent = false,
abs = Math.abs,
min = Math.min,
slice = [].slice,
touchDragOverListeners = [],
_autoScroll = _throttle(function (/**Event*/evt, /**Object*/options, /**HTMLElement*/rootEl) {
// Bug: https://bugzilla.mozilla.org/show_bug.cgi?id=505521
if (rootEl && options.scroll) {
var el,
rect,
sens = options.scrollSensitivity,
speed = options.scrollSpeed,
x = evt.clientX,
y = evt.clientY,
winWidth = window.innerWidth,
winHeight = window.innerHeight,
vx,
vy
;
// Delect scrollEl
if (scrollParentEl !== rootEl) {
scrollEl = options.scroll;
scrollParentEl = rootEl;
if (scrollEl === true) {
scrollEl = rootEl;
do {
if ((scrollEl.offsetWidth < scrollEl.scrollWidth) ||
(scrollEl.offsetHeight < scrollEl.scrollHeight)
) {
break;
}
/* jshint boss:true */
} while (scrollEl = scrollEl.parentNode);
}
}
if (scrollEl) {
el = scrollEl;
rect = scrollEl.getBoundingClientRect();
vx = (abs(rect.right - x) <= sens) - (abs(rect.left - x) <= sens);
vy = (abs(rect.bottom - y) <= sens) - (abs(rect.top - y) <= sens);
}
if (!(vx || vy)) {
vx = (winWidth - x <= sens) - (x <= sens);
vy = (winHeight - y <= sens) - (y <= sens);
/* jshint expr:true */
(vx || vy) && (el = win);
}
if (autoScroll.vx !== vx || autoScroll.vy !== vy || autoScroll.el !== el) {
autoScroll.el = el;
autoScroll.vx = vx;
autoScroll.vy = vy;
clearInterval(autoScroll.pid);
if (el) {
autoScroll.pid = setInterval(function () {
if (el === win) {
win.scrollTo(win.pageXOffset + vx * speed, win.pageYOffset + vy * speed);
} else {
vy && (el.scrollTop += vy * speed);
vx && (el.scrollLeft += vx * speed);
}
}, 24);
}
}
}
}, 30),
_prepareGroup = function (options) {
var group = options.group;
if (!group || typeof group != 'object') {
group = options.group = {name: group};
}
['pull', 'put'].forEach(function (key) {
if (!(key in group)) {
group[key] = true;
}
});
options.groups = ' ' + group.name + (group.put.join ? ' ' + group.put.join(' ') : '') + ' ';
}
;
/**
* @class Sortable
* @param {HTMLElement} el
* @param {Object} [options]
*/
function Sortable(el, options) {
if (!(el && el.nodeType && el.nodeType === 1)) {
throw 'Sortable: `el` must be HTMLElement, and not ' + {}.toString.call(el);
}
this.el = el; // root element
this.options = options = _extend({}, options);
// Export instance
el[expando] = this;
// Default options
var defaults = {
group: Math.random(),
sort: true,
disabled: false,
store: null,
handle: null,
scroll: true,
scrollSensitivity: 30,
scrollSpeed: 10,
draggable: /[uo]l/i.test(el.nodeName) ? 'li' : '>*',
ghostClass: 'sortable-ghost',
chosenClass: 'sortable-chosen',
ignore: 'a, img',
filter: null,
animation: 0,
setData: function (dataTransfer, dragEl) {
dataTransfer.setData('Text', dragEl.textContent);
},
dropBubble: false,
dragoverBubble: false,
dataIdAttr: 'data-id',
delay: 0,
forceFallback: false,
fallbackClass: 'sortable-fallback',
fallbackOnBody: false,
fallbackTolerance: 0
};
// Set default options
for (var name in defaults) {
!(name in options) && (options[name] = defaults[name]);
}
_prepareGroup(options);
// Bind all private methods
for (var fn in this) {
if (fn.charAt(0) === '_') {
this[fn] = this[fn].bind(this);
}
}
// Setup drag mode
this.nativeDraggable = options.forceFallback ? false : supportDraggable;
// Bind events
_on(el, 'mousedown', this._onTapStart);
_on(el, 'touchstart', this._onTapStart);
if (this.nativeDraggable) {
_on(el, 'dragover', this);
_on(el, 'dragenter', this);
}
touchDragOverListeners.push(this._onDragOver);
// Restore sorting
options.store && this.sort(options.store.get(this));
}
Sortable.prototype = /** @lends Sortable.prototype */ {
constructor: Sortable,
_onTapStart: function (/** Event|TouchEvent */evt) {
var _this = this,
el = this.el,
options = this.options,
type = evt.type,
touch = evt.touches && evt.touches[0],
target = (touch || evt).target,
originalTarget = target,
filter = options.filter,
startIndex;
// Don't trigger start event when an element is been dragged, otherwise the evt.oldindex always wrong when set option.group.
if (dragEl) {
return;
}
if (type === 'mousedown' && evt.button !== 0 || options.disabled) {
return; // only left button or enabled
}
target = _closest(target, options.draggable, el);
if (!target) {
return;
}
if (options.handle && !_closest(originalTarget, options.handle, el)) {
return;
}
// Get the index of the dragged element within its parent
startIndex = _index(target, options.draggable);
// Check filter
if (typeof filter === 'function') {
if (filter.call(this, evt, target, this)) {
_dispatchEvent(_this, originalTarget, 'filter', target, el, startIndex);
evt.preventDefault();
return; // cancel dnd
}
}
else if (filter) {
filter = filter.split(',').some(function (criteria) {
criteria = _closest(originalTarget, criteria.trim(), el);
if (criteria) {
_dispatchEvent(_this, criteria, 'filter', target, el, startIndex);
return true;
}
});
if (filter) {
evt.preventDefault();
return; // cancel dnd
}
}
// Prepare `dragstart`
this._prepareDragStart(evt, touch, target, startIndex);
},
_prepareDragStart: function (/** Event */evt, /** Touch */touch, /** HTMLElement */target, /** Number */startIndex) {
var _this = this,
el = _this.el,
options = _this.options,
ownerDocument = el.ownerDocument,
dragStartFn;
if (target && !dragEl && (target.parentNode === el)) {
tapEvt = evt;
rootEl = el;
dragEl = target;
parentEl = dragEl.parentNode;
nextEl = dragEl.nextSibling;
activeGroup = options.group;
oldIndex = startIndex;
this._lastX = (touch || evt).clientX;
this._lastY = (touch || evt).clientY;
dragStartFn = function () {
// Delayed drag has been triggered
// we can re-enable the events: touchmove/mousemove
_this._disableDelayedDrag();
// Make the element draggable
dragEl.draggable = true;
// Chosen item
_toggleClass(dragEl, _this.options.chosenClass, true);
// Bind the events: dragstart/dragend
_this._triggerDragStart(touch);
// Drag start event
_dispatchEvent(_this, rootEl, 'choose', dragEl, rootEl, oldIndex);
};
// Disable "draggable"
options.ignore.split(',').forEach(function (criteria) {
_find(dragEl, criteria.trim(), _disableDraggable);
});
_on(ownerDocument, 'mouseup', _this._onDrop);
_on(ownerDocument, 'touchend', _this._onDrop);
_on(ownerDocument, 'touchcancel', _this._onDrop);
if (options.delay) {
// If the user moves the pointer or let go the click or touch
// before the delay has been reached:
// disable the delayed drag
_on(ownerDocument, 'mouseup', _this._disableDelayedDrag);
_on(ownerDocument, 'touchend', _this._disableDelayedDrag);
_on(ownerDocument, 'touchcancel', _this._disableDelayedDrag);
_on(ownerDocument, 'mousemove', _this._disableDelayedDrag);
_on(ownerDocument, 'touchmove', _this._disableDelayedDrag);
_this._dragStartTimer = setTimeout(dragStartFn, options.delay);
} else {
dragStartFn();
}
}
},
_disableDelayedDrag: function () {
var ownerDocument = this.el.ownerDocument;
clearTimeout(this._dragStartTimer);
_off(ownerDocument, 'mouseup', this._disableDelayedDrag);
_off(ownerDocument, 'touchend', this._disableDelayedDrag);
_off(ownerDocument, 'touchcancel', this._disableDelayedDrag);
_off(ownerDocument, 'mousemove', this._disableDelayedDrag);
_off(ownerDocument, 'touchmove', this._disableDelayedDrag);
},
_triggerDragStart: function (/** Touch */touch) {
if (touch) {
// Touch device support
tapEvt = {
target: dragEl,
clientX: touch.clientX,
clientY: touch.clientY
};
this._onDragStart(tapEvt, 'touch');
}
else if (!this.nativeDraggable) {
this._onDragStart(tapEvt, true);
}
else {
_on(dragEl, 'dragend', this);
_on(rootEl, 'dragstart', this._onDragStart);
}
try {
if (document.selection) {
document.selection.empty();
} else {
window.getSelection().removeAllRanges();
}
} catch (err) {
}
},
_dragStarted: function () {
if (rootEl && dragEl) {
// Apply effect
_toggleClass(dragEl, this.options.ghostClass, true);
Sortable.active = this;
// Drag start event
_dispatchEvent(this, rootEl, 'start', dragEl, rootEl, oldIndex);
}
},
_emulateDragOver: function () {
if (touchEvt) {
if (this._lastX === touchEvt.clientX && this._lastY === touchEvt.clientY) {
return;
}
this._lastX = touchEvt.clientX;
this._lastY = touchEvt.clientY;
if (!supportCssPointerEvents) {
_css(ghostEl, 'display', 'none');
}
var target = document.elementFromPoint(touchEvt.clientX, touchEvt.clientY),
parent = target,
groupName = ' ' + this.options.group.name + '',
i = touchDragOverListeners.length;
if (parent) {
do {
if (parent[expando] && parent[expando].options.groups.indexOf(groupName) > -1) {
while (i--) {
touchDragOverListeners[i]({
clientX: touchEvt.clientX,
clientY: touchEvt.clientY,
target: target,
rootEl: parent
});
}
break;
}
target = parent; // store last element
}
/* jshint boss:true */
while (parent = parent.parentNode);
}
if (!supportCssPointerEvents) {
_css(ghostEl, 'display', '');
}
}
},
_onTouchMove: function (/**TouchEvent*/evt) {
if (tapEvt) {
var options = this.options,
fallbackTolerance = options.fallbackTolerance,
touch = evt.touches ? evt.touches[0] : evt,
dx = touch.clientX - tapEvt.clientX,
dy = touch.clientY - tapEvt.clientY,
translate3d = evt.touches ? 'translate3d(' + dx + 'px,' + dy + 'px,0)' : 'translate(' + dx + 'px,' + dy + 'px)';
// only set the status to dragging, when we are actually dragging
if (!Sortable.active) {
if (fallbackTolerance &&
min(abs(touch.clientX - this._lastX), abs(touch.clientY - this._lastY)) < fallbackTolerance
) {
return;
}
this._dragStarted();
}
// as well as creating the ghost element on the document body
this._appendGhost();
moved = true;
touchEvt = touch;
_css(ghostEl, 'webkitTransform', translate3d);
_css(ghostEl, 'mozTransform', translate3d);
_css(ghostEl, 'msTransform', translate3d);
_css(ghostEl, 'transform', translate3d);
evt.preventDefault();
}
},
_appendGhost: function () {
if (!ghostEl) {
var rect = dragEl.getBoundingClientRect(),
css = _css(dragEl),
options = this.options,
ghostRect;
ghostEl = dragEl.cloneNode(true);
_toggleClass(ghostEl, options.ghostClass, false);
_toggleClass(ghostEl, options.fallbackClass, true);
_css(ghostEl, 'top', rect.top - parseInt(css.marginTop, 10));
_css(ghostEl, 'left', rect.left - parseInt(css.marginLeft, 10));
_css(ghostEl, 'width', rect.width);
_css(ghostEl, 'height', rect.height);
_css(ghostEl, 'opacity', '0.8');
_css(ghostEl, 'position', 'fixed');
_css(ghostEl, 'zIndex', '100000');
_css(ghostEl, 'pointerEvents', 'none');
options.fallbackOnBody && document.body.appendChild(ghostEl) || rootEl.appendChild(ghostEl);
// Fixing dimensions.
ghostRect = ghostEl.getBoundingClientRect();
_css(ghostEl, 'width', rect.width * 2 - ghostRect.width);
_css(ghostEl, 'height', rect.height * 2 - ghostRect.height);
}
},
_onDragStart: function (/**Event*/evt, /**boolean*/useFallback) {
var dataTransfer = evt.dataTransfer,
options = this.options;
this._offUpEvents();
if (activeGroup.pull == 'clone') {
cloneEl = dragEl.cloneNode(true);
_css(cloneEl, 'display', 'none');
rootEl.insertBefore(cloneEl, dragEl);
_dispatchEvent(this, rootEl, 'clone', dragEl);
}
if (useFallback) {
if (useFallback === 'touch') {
// Bind touch events
_on(document, 'touchmove', this._onTouchMove);
_on(document, 'touchend', this._onDrop);
_on(document, 'touchcancel', this._onDrop);
} else {
// Old brwoser
_on(document, 'mousemove', this._onTouchMove);
_on(document, 'mouseup', this._onDrop);
}
this._loopId = setInterval(this._emulateDragOver, 50);
}
else {
if (dataTransfer) {
dataTransfer.effectAllowed = 'move';
options.setData && options.setData.call(this, dataTransfer, dragEl);
}
_on(document, 'drop', this);
setTimeout(this._dragStarted, 0);
}
},
_onDragOver: function (/**Event*/evt) {
var el = this.el,
target,
dragRect,
revert,
options = this.options,
group = options.group,
groupPut = group.put,
isOwner = (activeGroup === group),
canSort = options.sort;
if (evt.preventDefault !== void 0) {
evt.preventDefault();
!options.dragoverBubble && evt.stopPropagation();
}
moved = true;
if (activeGroup && !options.disabled &&
(isOwner
? canSort || (revert = !rootEl.contains(dragEl)) // Reverting item into the original list
: activeGroup.pull && groupPut && (
(activeGroup.name === group.name) || // by Name
(groupPut.indexOf && ~groupPut.indexOf(activeGroup.name)) // by Array
)
) &&
(evt.rootEl === void 0 || evt.rootEl === this.el) // touch fallback
) {
// Smart auto-scrolling
_autoScroll(evt, options, this.el);
if (_silent) {
return;
}
target = _closest(evt.target, options.draggable, el);
dragRect = dragEl.getBoundingClientRect();
if (revert) {
_cloneHide(true);
parentEl = rootEl; // actualization
if (cloneEl || nextEl) {
rootEl.insertBefore(dragEl, cloneEl || nextEl);
}
else if (!canSort) {
rootEl.appendChild(dragEl);
}
return;
}
if ((el.children.length === 0) || (el.children[0] === ghostEl) ||
(el === evt.target) && (target = _ghostIsLast(el, evt))
) {
if (target) {
if (target.animated) {
return;
}
targetRect = target.getBoundingClientRect();
}
_cloneHide(isOwner);
if (_onMove(rootEl, el, dragEl, dragRect, target, targetRect) !== false) {
if (!dragEl.contains(el)) {
el.appendChild(dragEl);
parentEl = el; // actualization
}
this._animate(dragRect, dragEl);
target && this._animate(targetRect, target);
}
}
else if (target && !target.animated && target !== dragEl && (target.parentNode[expando] !== void 0)) {
if (lastEl !== target) {
lastEl = target;
lastCSS = _css(target);
lastParentCSS = _css(target.parentNode);
}
var targetRect = target.getBoundingClientRect(),
width = targetRect.right - targetRect.left,
height = targetRect.bottom - targetRect.top,
floating = /left|right|inline/.test(lastCSS.cssFloat + lastCSS.display)
|| (lastParentCSS.display == 'flex' && lastParentCSS['flex-direction'].indexOf('row') === 0),
isWide = (target.offsetWidth > dragEl.offsetWidth),
isLong = (target.offsetHeight > dragEl.offsetHeight),
halfway = (floating ? (evt.clientX - targetRect.left) / width : (evt.clientY - targetRect.top) / height) > 0.5,
nextSibling = target.nextElementSibling,
moveVector = _onMove(rootEl, el, dragEl, dragRect, target, targetRect),
after
;
if (moveVector !== false) {
_silent = true;
setTimeout(_unsilent, 30);
_cloneHide(isOwner);
if (moveVector === 1 || moveVector === -1) {
after = (moveVector === 1);
}
else if (floating) {
var elTop = dragEl.offsetTop,
tgTop = target.offsetTop;
if (elTop === tgTop) {
after = (target.previousElementSibling === dragEl) && !isWide || halfway && isWide;
}
else if (target.previousElementSibling === dragEl || dragEl.previousElementSibling === target) {
after = (evt.clientY - targetRect.top) / height > 0.5;
} else {
after = tgTop > elTop;
}
} else {
after = (nextSibling !== dragEl) && !isLong || halfway && isLong;
}
if (!dragEl.contains(el)) {
if (after && !nextSibling) {
el.appendChild(dragEl);
} else {
target.parentNode.insertBefore(dragEl, after ? nextSibling : target);
}
}
parentEl = dragEl.parentNode; // actualization
this._animate(dragRect, dragEl);
this._animate(targetRect, target);
}
}
}
},
_animate: function (prevRect, target) {
var ms = this.options.animation;
if (ms) {
var currentRect = target.getBoundingClientRect();
_css(target, 'transition', 'none');
_css(target, 'transform', 'translate3d('
+ (prevRect.left - currentRect.left) + 'px,'
+ (prevRect.top - currentRect.top) + 'px,0)'
);
target.offsetWidth; // repaint
_css(target, 'transition', 'all ' + ms + 'ms');
_css(target, 'transform', 'translate3d(0,0,0)');
clearTimeout(target.animated);
target.animated = setTimeout(function () {
_css(target, 'transition', '');
_css(target, 'transform', '');
target.animated = false;
}, ms);
}
},
_offUpEvents: function () {
var ownerDocument = this.el.ownerDocument;
_off(document, 'touchmove', this._onTouchMove);
_off(ownerDocument, 'mouseup', this._onDrop);
_off(ownerDocument, 'touchend', this._onDrop);
_off(ownerDocument, 'touchcancel', this._onDrop);
},
_onDrop: function (/**Event*/evt) {
var el = this.el,
options = this.options;
clearInterval(this._loopId);
clearInterval(autoScroll.pid);
clearTimeout(this._dragStartTimer);
// Unbind events
_off(document, 'mousemove', this._onTouchMove);
if (this.nativeDraggable) {
_off(document, 'drop', this);
_off(el, 'dragstart', this._onDragStart);
}
this._offUpEvents();
if (evt) {
if (moved) {
evt.preventDefault();
!options.dropBubble && evt.stopPropagation();
}
ghostEl && ghostEl.parentNode.removeChild(ghostEl);
if (dragEl) {
if (this.nativeDraggable) {
_off(dragEl, 'dragend', this);
}
_disableDraggable(dragEl);
// Remove class's
_toggleClass(dragEl, this.options.ghostClass, false);
_toggleClass(dragEl, this.options.chosenClass, false);
if (rootEl !== parentEl) {
newIndex = _index(dragEl, options.draggable);
if (newIndex >= 0) {
// drag from one list and drop into another
_dispatchEvent(null, parentEl, 'sort', dragEl, rootEl, oldIndex, newIndex);
_dispatchEvent(this, rootEl, 'sort', dragEl, rootEl, oldIndex, newIndex);
// Add event
_dispatchEvent(null, parentEl, 'add', dragEl, rootEl, oldIndex, newIndex);
// Remove event
_dispatchEvent(this, rootEl, 'remove', dragEl, rootEl, oldIndex, newIndex);
}
}
else {
// Remove clone
cloneEl && cloneEl.parentNode.removeChild(cloneEl);
if (dragEl.nextSibling !== nextEl) {
// Get the index of the dragged element within its parent
newIndex = _index(dragEl, options.draggable);
if (newIndex >= 0) {
// drag & drop within the same list
_dispatchEvent(this, rootEl, 'update', dragEl, rootEl, oldIndex, newIndex);
_dispatchEvent(this, rootEl, 'sort', dragEl, rootEl, oldIndex, newIndex);
}
}
}
if (Sortable.active) {
if (newIndex === null || newIndex === -1) {
newIndex = oldIndex;
}
_dispatchEvent(this, rootEl, 'end', dragEl, rootEl, oldIndex, newIndex);
// Save sorting
this.save();
}
}
}
this._nulling();
},
_nulling: function () {
rootEl =
dragEl =
parentEl =
ghostEl =
nextEl =
cloneEl =
scrollEl =
scrollParentEl =
tapEvt =
touchEvt =
moved =
newIndex =
lastEl =
lastCSS =
activeGroup =
Sortable.active = null;
},
handleEvent: function (/**Event*/evt) {
var type = evt.type;
if (type === 'dragover' || type === 'dragenter') {
if (dragEl) {
this._onDragOver(evt);
_globalDragOver(evt);
}
}
else if (type === 'drop' || type === 'dragend') {
this._onDrop(evt);
}
},
/**
* Serializes the item into an array of string.
* @returns {String[]}
*/
toArray: function () {
var order = [],
el,
children = this.el.children,
i = 0,
n = children.length,
options = this.options;
for (; i < n; i++) {
el = children[i];
if (_closest(el, options.draggable, this.el)) {
order.push(el.getAttribute(options.dataIdAttr) || _generateId(el));
}
}
return order;
},
/**
* Sorts the elements according to the array.
* @param {String[]} order order of the items
*/
sort: function (order) {
var items = {}, rootEl = this.el;
this.toArray().forEach(function (id, i) {
var el = rootEl.children[i];
if (_closest(el, this.options.draggable, rootEl)) {
items[id] = el;
}
}, this);
order.forEach(function (id) {
if (items[id]) {
rootEl.removeChild(items[id]);
rootEl.appendChild(items[id]);
}
});
},
/**
* Save the current sorting
*/
save: function () {
var store = this.options.store;
store && store.set(this);
},
/**
* For each element in the set, get the first element that matches the selector by testing the element itself and traversing up through its ancestors in the DOM tree.
* @param {HTMLElement} el
* @param {String} [selector] default: `options.draggable`
* @returns {HTMLElement|null}
*/
closest: function (el, selector) {
return _closest(el, selector || this.options.draggable, this.el);
},
/**
* Set/get option
* @param {string} name
* @param {*} [value]
* @returns {*}
*/
option: function (name, value) {
var options = this.options;
if (value === void 0) {
return options[name];
} else {
options[name] = value;
if (name === 'group') {
_prepareGroup(options);
}
}
},
/**
* Destroy
*/
destroy: function () {
var el = this.el;
el[expando] = null;
_off(el, 'mousedown', this._onTapStart);
_off(el, 'touchstart', this._onTapStart);
if (this.nativeDraggable) {
_off(el, 'dragover', this);
_off(el, 'dragenter', this);
}
// Remove draggable attributes
Array.prototype.forEach.call(el.querySelectorAll('[draggable]'), function (el) {
el.removeAttribute('draggable');
});
touchDragOverListeners.splice(touchDragOverListeners.indexOf(this._onDragOver), 1);
this._onDrop();
this.el = el = null;
}
};
function _cloneHide(state) {
if (cloneEl && (cloneEl.state !== state)) {
_css(cloneEl, 'display', state ? 'none' : '');
!state && cloneEl.state && rootEl.insertBefore(cloneEl, dragEl);
cloneEl.state = state;
}
}
function _closest(/**HTMLElement*/el, /**String*/selector, /**HTMLElement*/ctx) {
if (el) {
ctx = ctx || document;
do {
if ((selector === '>*' && el.parentNode === ctx) || _matches(el, selector)) {
return el;
}
}
while (el !== ctx && (el = el.parentNode));
}
return null;
}
function _globalDragOver(/**Event*/evt) {
if (evt.dataTransfer) {
evt.dataTransfer.dropEffect = 'move';
}
evt.preventDefault();
}
function _on(el, event, fn) {
el.addEventListener(event, fn, false);
}
function _off(el, event, fn) {
el.removeEventListener(event, fn, false);
}
function _toggleClass(el, name, state) {
if (el) {
if (el.classList) {
el.classList[state ? 'add' : 'remove'](name);
}
else {
var className = (' ' + el.className + ' ').replace(RSPACE, ' ').replace(' ' + name + ' ', ' ');
el.className = (className + (state ? ' ' + name : '')).replace(RSPACE, ' ');
}
}
}
function _css(el, prop, val) {
var style = el && el.style;
if (style) {
if (val === void 0) {
if (document.defaultView && document.defaultView.getComputedStyle) {
val = document.defaultView.getComputedStyle(el, '');
}
else if (el.currentStyle) {
val = el.currentStyle;
}
return prop === void 0 ? val : val[prop];
}
else {
if (!(prop in style)) {
prop = '-webkit-' + prop;
}
style[prop] = val + (typeof val === 'string' ? '' : 'px');
}
}
}
function _find(ctx, tagName, iterator) {
if (ctx) {
var list = ctx.getElementsByTagName(tagName), i = 0, n = list.length;
if (iterator) {
for (; i < n; i++) {
iterator(list[i], i);
}
}
return list;
}
return [];
}
function _dispatchEvent(sortable, rootEl, name, targetEl, fromEl, startIndex, newIndex) {
var evt = document.createEvent('Event'),
options = (sortable || rootEl[expando]).options,
onName = 'on' + name.charAt(0).toUpperCase() + name.substr(1);
evt.initEvent(name, true, true);
evt.to = rootEl;
evt.from = fromEl || rootEl;
evt.item = targetEl || rootEl;
evt.clone = cloneEl;
evt.oldIndex = startIndex;
evt.newIndex = newIndex;
rootEl.dispatchEvent(evt);
if (options[onName]) {
options[onName].call(sortable, evt);
}
}
function _onMove(fromEl, toEl, dragEl, dragRect, targetEl, targetRect) {
var evt,
sortable = fromEl[expando],
onMoveFn = sortable.options.onMove,
retVal;
evt = document.createEvent('Event');
evt.initEvent('move', true, true);
evt.to = toEl;
evt.from = fromEl;
evt.dragged = dragEl;
evt.draggedRect = dragRect;
evt.related = targetEl || toEl;
evt.relatedRect = targetRect || toEl.getBoundingClientRect();
fromEl.dispatchEvent(evt);
if (onMoveFn) {
retVal = onMoveFn.call(sortable, evt);
}
return retVal;
}
function _disableDraggable(el) {
el.draggable = false;
}
function _unsilent() {
_silent = false;
}
/** @returns {HTMLElement|false} */
function _ghostIsLast(el, evt) {
var lastEl = el.lastElementChild,
rect = lastEl.getBoundingClientRect();
return ((evt.clientY - (rect.top + rect.height) > 5) || (evt.clientX - (rect.right + rect.width) > 5)) && lastEl; // min delta
}
/**
* Generate id
* @param {HTMLElement} el
* @returns {String}
* @private
*/
function _generateId(el) {
var str = el.tagName + el.className + el.src + el.href + el.textContent,
i = str.length,
sum = 0;
while (i--) {
sum += str.charCodeAt(i);
}
return sum.toString(36);
}
/**
* Returns the index of an element within its parent for a selected set of
* elements
* @param {HTMLElement} el
* @param {selector} selector
* @return {number}
*/
function _index(el, selector) {
var index = 0;
if (!el || !el.parentNode) {
return -1;
}
while (el && (el = el.previousElementSibling)) {
if ((el.nodeName.toUpperCase() !== 'TEMPLATE') && (selector === '>*' || _matches(el, selector))) {
index++;
}
}
return index;
}
function _matches(/**HTMLElement*/el, /**String*/selector) {
if (el) {
selector = selector.split('.');
var tag = selector.shift().toUpperCase(),
re = new RegExp('\\s(' + selector.join('|') + ')(?=\\s)', 'g');
return (
(tag === '' || el.nodeName.toUpperCase() == tag) &&
(!selector.length || ((' ' + el.className + ' ').match(re) || []).length == selector.length)
);
}
return false;
}
function _throttle(callback, ms) {
var args, _this;
return function () {
if (args === void 0) {
args = arguments;
_this = this;
setTimeout(function () {
if (args.length === 1) {
callback.call(_this, args[0]);
} else {
callback.apply(_this, args);
}
args = void 0;
}, ms);
}
};
}
function _extend(dst, src) {
if (dst && src) {
for (var key in src) {
if (src.hasOwnProperty(key)) {
dst[key] = src[key];
}
}
}
return dst;
}
// Export utils
Sortable.utils = {
on: _on,
off: _off,
css: _css,
find: _find,
is: function (el, selector) {
return !!_closest(el, selector, el);
},
extend: _extend,
throttle: _throttle,
closest: _closest,
toggleClass: _toggleClass,
index: _index
};
/**
* Create sortable instance
* @param {HTMLElement} el
* @param {Object} [options]
*/
Sortable.create = function (el, options) {
return new Sortable(el, options);
};
// Export
Sortable.version = '1.4.2';
return Sortable;
});
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