Commit 35caa356 authored by Jacob Schatz's avatar Jacob Schatz

Merge branch 'issue-boards-ui' into 'issue-boards'

Issue boards UI

## What does this MR do?

UI for issue boards.

## Are there points in the code the reviewer needs to double check?

## Why was this MR needed?

## What are the relevant issue numbers?

-  Issue: #17907
-  Backend MR: !5548
-  Frontend MR: !5554
- Documentation !5713 

## Screenshots (if relevant)

![Screen_Shot_2016-08-17_at_14.06.06](/uploads/e2abed987d2c2d280e8b8034c8949024/Screen_Shot_2016-08-17_at_14.06.06.png)


See merge request !5554
parents ec106b28 dc550ace
//= 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 @@ ...@@ -34,6 +34,7 @@
$(function() { $(function() {
var clipboard; var clipboard;
clipboard = new Clipboard('[data-clipboard-target], [data-clipboard-text]'); clipboard = new Clipboard('[data-clipboard-target], [data-clipboard-text]');
clipboard.on('success', genericSuccess); clipboard.on('success', genericSuccess);
return clipboard.on('error', genericError); 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);
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
var _this; var _this;
_this = this; _this = this;
$('.js-label-select').each(function(i, dropdown) { $('.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); $dropdown = $(dropdown);
projectId = $dropdown.data('project-id'); projectId = $dropdown.data('project-id');
labelUrl = $dropdown.data('labels'); labelUrl = $dropdown.data('labels');
...@@ -13,8 +13,6 @@ ...@@ -13,8 +13,6 @@
if ((selectedLabel != null) && !$dropdown.hasClass('js-multiselect')) { if ((selectedLabel != null) && !$dropdown.hasClass('js-multiselect')) {
selectedLabel = selectedLabel.split(','); selectedLabel = selectedLabel.split(',');
} }
newLabelField = $('#new_label_name');
newColorField = $('#new_label_color');
showNo = $dropdown.data('show-no'); showNo = $dropdown.data('show-no');
showAny = $dropdown.data('show-any'); showAny = $dropdown.data('show-any');
defaultLabel = $dropdown.data('default-label'); defaultLabel = $dropdown.data('default-label');
...@@ -24,10 +22,6 @@ ...@@ -24,10 +22,6 @@
$form = $dropdown.closest('form'); $form = $dropdown.closest('form');
$sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon span'); $sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon span');
$value = $block.find('.value'); $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(); $loading = $block.find('.block-loading').fadeOut();
if (issueUpdateURL != null) { if (issueUpdateURL != null) {
issueURLSplit = issueUpdateURL.split('/'); issueURLSplit = issueUpdateURL.split('/');
...@@ -36,62 +30,9 @@ ...@@ -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> <% }); %>'); 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>'; labelNoneHTMLTemplate = '<span class="no-value">None</span>';
} }
if (newLabelField.length) {
$('.suggest-colors-dropdown a').on("click", function(e) { new gl.CreateLabelDropdown($dropdown.closest('.dropdown').find('.dropdown-new-label'), projectId);
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();
});
}
saveLabelData = function() { saveLabelData = function() {
var data, selected; var data, selected;
selected = $dropdown.closest('.selectbox').find("input[name='" + ($dropdown.data('field-name')) + "']").map(function() { selected = $dropdown.closest('.selectbox').find("input[name='" + ($dropdown.data('field-name')) + "']").map(function() {
...@@ -272,6 +213,9 @@ ...@@ -272,6 +213,9 @@
isMRIndex = page === 'projects:merge_requests:index'; isMRIndex = page === 'projects:merge_requests:index';
$selectbox.hide(); $selectbox.hide();
$value.removeAttr('style'); $value.removeAttr('style');
if (page === 'projects:boards:show') {
return;
}
if ($dropdown.hasClass('js-multiselect')) { if ($dropdown.hasClass('js-multiselect')) {
if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
selectedLabels = $dropdown.closest('form').find("input:hidden[name='" + ($dropdown.data('fieldName')) + "']"); selectedLabels = $dropdown.closest('form').find("input:hidden[name='" + ($dropdown.data('fieldName')) + "']");
...@@ -291,7 +235,7 @@ ...@@ -291,7 +235,7 @@
} }
}, },
multiSelect: $dropdown.hasClass('js-multiselect'), multiSelect: $dropdown.hasClass('js-multiselect'),
clicked: function(label) { clicked: function(label, $el, e) {
var isIssueIndex, isMRIndex, page; var isIssueIndex, isMRIndex, page;
_this.enableBulkLabelDropdown(); _this.enableBulkLabelDropdown();
if ($dropdown.hasClass('js-filter-bulk-update')) { if ($dropdown.hasClass('js-filter-bulk-update')) {
...@@ -300,7 +244,23 @@ ...@@ -300,7 +244,23 @@
page = $('body').data('page'); page = $('body').data('page');
isIssueIndex = page === 'projects:issues:index'; isIssueIndex = page === 'projects:issues:index';
isMRIndex = page === 'projects:merge_requests: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')) { if (!$dropdown.hasClass('js-multiselect')) {
selectedLabel = label.title; selectedLabel = label.title;
return Issuable.filterResults($dropdown.closest('form')); return Issuable.filterResults($dropdown.closest('form'));
......
...@@ -94,7 +94,7 @@ ...@@ -94,7 +94,7 @@
$selectbox.hide(); $selectbox.hide();
return $value.css('display', ''); return $value.css('display', '');
}, },
clicked: function(selected) { clicked: function(selected, $el, e) {
var data, isIssueIndex, isMRIndex, page; var data, isIssueIndex, isMRIndex, page;
page = $('body').data('page'); page = $('body').data('page');
isIssueIndex = page === 'projects:issues:index'; isIssueIndex = page === 'projects:issues:index';
...@@ -102,7 +102,11 @@ ...@@ -102,7 +102,11 @@
if ($dropdown.hasClass('js-filter-bulk-update')) { if ($dropdown.hasClass('js-filter-bulk-update')) {
return; 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) { if (selected.name != null) {
selectedMilestone = selected.name; selectedMilestone = selected.name;
} else { } else {
......
...@@ -141,7 +141,7 @@ ...@@ -141,7 +141,7 @@
$selectbox.hide(); $selectbox.hide();
return $value.css('display', ''); return $value.css('display', '');
}, },
clicked: function(user) { clicked: function(user, $el, e) {
var isIssueIndex, isMRIndex, page, selected; var isIssueIndex, isMRIndex, page, selected;
page = $('body').data('page'); page = $('body').data('page');
isIssueIndex = page === 'projects:issues:index'; isIssueIndex = page === 'projects:issues:index';
...@@ -149,7 +149,12 @@ ...@@ -149,7 +149,12 @@
if ($dropdown.hasClass('js-filter-bulk-update')) { if ($dropdown.hasClass('js-filter-bulk-update')) {
return; 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; selectedId = user.id;
return Issuable.filterResults($dropdown.closest('form')); return Issuable.filterResults($dropdown.closest('form'));
} else if ($dropdown.hasClass('js-filter-submit')) { } else if ($dropdown.hasClass('js-filter-submit')) {
......
...@@ -276,3 +276,5 @@ $personal-access-tokens-disabled-label-color: #bbb; ...@@ -276,3 +276,5 @@ $personal-access-tokens-disabled-label-color: #bbb;
$ci-output-bg: #1d1f21; $ci-output-bg: #1d1f21;
$ci-text-color: #c5c8c6; $ci-text-color: #c5c8c6;
$issue-boards-font-size: 15px;
[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;
}
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
...@@ -320,4 +320,8 @@ module ApplicationHelper ...@@ -320,4 +320,8 @@ module ApplicationHelper
capture(&block) capture(&block)
end end
end end
def page_class
"issue-boards-page" if current_controller?(:boards)
end
end end
...@@ -23,7 +23,6 @@ ...@@ -23,7 +23,6 @@
= render "layouts/broadcast" = render "layouts/broadcast"
= render "layouts/flash" = render "layouts/flash"
= yield :flash_message = yield :flash_message
%div{ class: (container_class unless @no_container) } %div{ class: "#{(container_class unless @no_container)} #{@content_class}" }
.content .content
.clearfix = yield
= yield
!!! 5 !!! 5
%html{ lang: "en"} %html{ lang: "en", class: "#{page_class}" }
= render "layouts/head" = render "layouts/head"
%body{class: "#{user_application_theme}", data: {page: body_data_page, project: "#{@project.path if @project}", group: "#{@group.path if @group}"}} %body{class: "#{user_application_theme}", data: {page: body_data_page, project: "#{@project.path if @project}", group: "#{@group.path if @group}"}}
= Gon::Base.render_data = 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 - @no_container = true
- @content_class = 'issues-board-content' - @content_class = "issue-boards-content"
- page_title 'Board' - 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 'projects/issues/head'
= render 'shared/issuable/filter', type: :boards = render 'shared/issuable/filter', type: :boards
.board-lists .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"
...@@ -27,7 +27,17 @@ ...@@ -27,7 +27,17 @@
= render "shared/issuable/label_dropdown" = render "shared/issuable/label_dropdown"
.pull-right .pull-right
= render 'shared/sort_dropdown' - 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' - if controller.controller_name == 'issues'
.issues_bulk_update.hide .issues_bulk_update.hide
......
...@@ -2,8 +2,13 @@ ...@@ -2,8 +2,13 @@
- show_create = local_assigns.fetch(:show_create, true) - show_create = local_assigns.fetch(:show_create, true)
- show_footer = local_assigns.fetch(:show_footer, true) - show_footer = local_assigns.fetch(:show_footer, true)
- filter_placeholder = local_assigns.fetch(:filter_placeholder, 'Search labels') - filter_placeholder = local_assigns.fetch(:filter_placeholder, 'Search labels')
- show_boards_content = local_assigns.fetch(:show_boards_content, false)
.dropdown-page-one .dropdown-page-one
= dropdown_title(title) = 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_filter(filter_placeholder)
= dropdown_content = dropdown_content
- if @project && show_footer - if @project && show_footer
...@@ -12,7 +17,7 @@ ...@@ -12,7 +17,7 @@
- if can?(current_user, :admin_label, @project) - if can?(current_user, :admin_label, @project)
%li %li
%a.dropdown-toggle-page{href: "#"} %a.dropdown-toggle-page{href: "#"}
Create new Create new label
%li %li
= link_to namespace_project_labels_path(@project.namespace, @project), :"data-is-link" => true do = link_to namespace_project_labels_path(@project.namespace, @project), :"data-is-link" => true do
- if show_create && @project && can?(current_user, :admin_label, @project) - if show_create && @project && can?(current_user, :admin_label, @project)
......
...@@ -85,6 +85,8 @@ module Gitlab ...@@ -85,6 +85,8 @@ module Gitlab
config.assets.precompile << "users/users_bundle.js" config.assets.precompile << "users/users_bundle.js"
config.assets.precompile << "network/network_bundle.js" config.assets.precompile << "network/network_bundle.js"
config.assets.precompile << "profile/profile_bundle.js" config.assets.precompile << "profile/profile_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/utils/*.js"
config.assets.precompile << "lib/*.js" config.assets.precompile << "lib/*.js"
config.assets.precompile << "u2f.js" config.assets.precompile << "u2f.js"
......
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 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
}));
});
/**!
* 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;
});
...@@ -154,12 +154,12 @@ function E () { ...@@ -154,12 +154,12 @@ function E () {
E.prototype = { E.prototype = {
on: function (name, callback, ctx) { on: function (name, callback, ctx) {
var e = this.e || (this.e = {}); var e = this.e || (this.e = {});
(e[name] || (e[name] = [])).push({ (e[name] || (e[name] = [])).push({
fn: callback, fn: callback,
ctx: ctx ctx: ctx
}); });
return this; return this;
}, },
...@@ -169,7 +169,7 @@ E.prototype = { ...@@ -169,7 +169,7 @@ E.prototype = {
self.off(name, fn); self.off(name, fn);
callback.apply(ctx, arguments); callback.apply(ctx, arguments);
}; };
return this.on(name, fn, ctx); return this.on(name, fn, ctx);
}, },
...@@ -178,11 +178,11 @@ E.prototype = { ...@@ -178,11 +178,11 @@ E.prototype = {
var evtArr = ((this.e || (this.e = {}))[name] || []).slice(); var evtArr = ((this.e || (this.e = {}))[name] || []).slice();
var i = 0; var i = 0;
var len = evtArr.length; var len = evtArr.length;
for (i; i < len; i++) { for (i; i < len; i++) {
evtArr[i].fn.apply(evtArr[i].ctx, data); evtArr[i].fn.apply(evtArr[i].ctx, data);
} }
return this; return this;
}, },
...@@ -190,21 +190,21 @@ E.prototype = { ...@@ -190,21 +190,21 @@ E.prototype = {
var e = this.e || (this.e = {}); var e = this.e || (this.e = {});
var evts = e[name]; var evts = e[name];
var liveEvents = []; var liveEvents = [];
if (evts && callback) { if (evts && callback) {
for (var i = 0, len = evts.length; i < len; i++) { for (var i = 0, len = evts.length; i < len; i++) {
if (evts[i].fn !== callback) liveEvents.push(evts[i]); if (evts[i].fn !== callback) liveEvents.push(evts[i]);
} }
} }
// Remove event from queue to prevent memory leak // Remove event from queue to prevent memory leak
// Suggested by https://github.com/lazd // Suggested by https://github.com/lazd
// Ref: https://github.com/scottcorgan/tiny-emitter/commit/c6ebfaa9bc973b33d110a84a307742b7cf94c953#commitcomment-5024910 // Ref: https://github.com/scottcorgan/tiny-emitter/commit/c6ebfaa9bc973b33d110a84a307742b7cf94c953#commitcomment-5024910
(liveEvents.length) (liveEvents.length)
? e[name] = liveEvents ? e[name] = liveEvents
: delete e[name]; : delete e[name];
return this; return this;
} }
}; };
...@@ -618,4 +618,4 @@ exports['default'] = Clipboard; ...@@ -618,4 +618,4 @@ exports['default'] = Clipboard;
module.exports = exports['default']; module.exports = exports['default'];
},{"./clipboard-action":6,"delegate-events":1,"tiny-emitter":5}]},{},[7])(7) },{"./clipboard-action":6,"delegate-events":1,"tiny-emitter":5}]},{},[7])(7)
}); });
\ No newline at end of file
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