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
- if controller.controller_name != 'boards'
= render 'shared/sort_dropdown' = 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"
......
This diff is collapsed.
//= 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
}));
});
This diff is collapsed.
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