Commit a66a3c74 authored by Fatih Acet's avatar Fatih Acet

Merge branch 'issue-board-sidebar' into 'master'

Issue board sidebar

## What does this MR do?

Adds a sidebar when clicking an issue in the issue boards lists. This allows user to easily update other parts of the issue details without having to visit the issue itself. Same functionality as on issue page.

When creating a new issue the sidebar automatically opens.

## Screenshots (if relevant)

![Screen_Shot_2016-10-07_at_13.10.16](/uploads/ad08785f407d8ac3fe9cb078868a7839/Screen_Shot_2016-10-07_at_13.10.16.png)

## What are the relevant issue numbers?

Closes #21219

See merge request !6690
parents b0e6fc12 a2eff1a8
......@@ -5,7 +5,9 @@
//= require_tree ./stores
//= require_tree ./services
//= require_tree ./mixins
//= require_tree ./filters
//= require ./components/board
//= require ./components/board_sidebar
//= require ./components/new_list_dropdown
//= require ./vue_resource_interceptor
......@@ -22,7 +24,8 @@ $(() => {
gl.IssueBoardsApp = new Vue({
el: $boardApp,
components: {
'board': gl.issueBoards.Board
'board': gl.issueBoards.Board,
'board-sidebar': gl.issueBoards.BoardSidebar
},
data: {
state: Store.state,
......@@ -30,9 +33,15 @@ $(() => {
endpoint: $boardApp.dataset.endpoint,
boardId: $boardApp.dataset.boardId,
disabled: $boardApp.dataset.disabled === 'true',
issueLinkBase: $boardApp.dataset.issueLinkBase
issueLinkBase: $boardApp.dataset.issueLinkBase,
detailIssue: Store.detail
},
init: Store.create.bind(Store),
computed: {
detailIssueVisible () {
return Object.keys(this.detailIssue.issue).length;
}
},
created () {
gl.boardService = new BoardService(this.endpoint, this.boardId);
},
......
......@@ -21,6 +21,7 @@
},
data () {
return {
detailIssue: Store.detail,
filters: Store.state.filters,
showIssueForm: false
};
......@@ -32,6 +33,26 @@
this.list.getIssues(true);
},
deep: true
},
detailIssue: {
handler () {
if (!Object.keys(this.detailIssue.issue).length) return;
const issue = this.list.findIssue(this.detailIssue.issue.id);
if (issue) {
const boardsList = document.querySelectorAll('.boards-list')[0];
const right = (this.$el.offsetLeft + this.$el.offsetWidth) - boardsList.offsetWidth;
const left = boardsList.scrollLeft - this.$el.offsetLeft;
if (right - boardsList.scrollLeft > 0) {
boardsList.scrollLeft = right;
} else if (left > 0) {
boardsList.scrollLeft = this.$el.offsetLeft;
}
}
},
deep: true
}
},
methods: {
......
......@@ -12,6 +12,17 @@
disabled: Boolean,
index: Number
},
data () {
return {
showDetail: false,
detailIssue: Store.detail
};
},
computed: {
issueDetailVisible () {
return this.detailIssue.issue && this.detailIssue.issue.id === this.issue.id;
}
},
methods: {
filterByLabel (label, e) {
let labelToggleText = label.title;
......@@ -37,6 +48,29 @@
$('.labels-filter .dropdown-toggle-text').text(labelToggleText);
Store.updateFiltersUrl();
},
mouseDown () {
this.showDetail = true;
},
mouseMove () {
if (this.showDetail) {
this.showDetail = false;
}
},
showIssue (e) {
const targetTagName = e.target.tagName.toLowerCase();
if (targetTagName === 'a' || targetTagName === 'button') return;
if (this.showDetail) {
this.showDetail = false;
if (Store.detail.issue && Store.detail.issue.id === this.issue.id) {
Store.detail.issue = {};
} else {
Store.detail.issue = this.issue;
}
}
}
}
});
......
(() => {
const Store = gl.issueBoards.BoardsStore;
window.gl = window.gl || {};
gl.issueBoards.BoardNewIssue = Vue.extend({
......@@ -27,13 +29,16 @@
const labels = this.list.label ? [this.list.label] : [];
const issue = new ListIssue({
title: this.title,
labels
labels,
subscribed: true
});
this.list.newIssue(issue)
.then((data) => {
// Need this because our jQuery very kindly disables buttons on ALL form submissions
$(this.$els.submitButton).enable();
Store.detail.issue = issue;
})
.catch(() => {
// Need this because our jQuery very kindly disables buttons on ALL form submissions
......
(() => {
const Store = gl.issueBoards.BoardsStore;
window.gl = window.gl || {};
window.gl.issueBoards = window.gl.issueBoards || {};
gl.issueBoards.BoardSidebar = Vue.extend({
props: {
currentUser: Object
},
data() {
return {
detail: Store.detail,
issue: {}
};
},
computed: {
showSidebar () {
return Object.keys(this.issue).length;
}
},
watch: {
detail: {
handler () {
this.issue = this.detail.issue;
},
deep: true
},
issue () {
if (this.showSidebar) {
this.$nextTick(() => {
$('.right-sidebar').getNiceScroll(0).doScrollTop(0, 0);
$('.right-sidebar').getNiceScroll().resize();
});
}
}
},
methods: {
closeSidebar () {
this.detail.issue = {};
}
},
ready () {
new IssuableContext(this.currentUser);
new MilestoneSelect();
new gl.DueDateSelectors();
new LabelsSelect();
new Sidebar();
new Subscription('.subscription');
}
});
})();
Vue.filter('due-date', (value) => {
const date = new Date(value);
return $.datepicker.formatDate('M d, yy', date);
});
......@@ -22,7 +22,7 @@
fallbackOnBody: true,
ghostClass: 'is-ghost',
filter: '.has-tooltip, .btn',
delay: gl.issueBoards.touchEnabled ? 100 : 0,
delay: gl.issueBoards.touchEnabled ? 100 : 50,
scrollSensitivity: gl.issueBoards.touchEnabled ? 60 : 100,
scrollSpeed: 20,
onStart: gl.issueBoards.onStart,
......
......@@ -3,12 +3,18 @@ class ListIssue {
this.id = obj.iid;
this.title = obj.title;
this.confidential = obj.confidential;
this.dueDate = obj.due_date;
this.subscribed = obj.subscribed;
this.labels = [];
if (obj.assignee) {
this.assignee = new ListUser(obj.assignee);
}
if (obj.milestone) {
this.milestone = new ListMilestone(obj.milestone);
}
obj.labels.forEach((label) => {
this.labels.push(new ListLabel(label));
});
......@@ -41,4 +47,21 @@ class ListIssue {
getLists () {
return gl.issueBoards.BoardsStore.state.lists.filter( list => list.findIssue(this.id) );
}
update (url) {
const data = {
issue: {
milestone_id: this.milestone ? this.milestone.id : null,
due_date: this.dueDate,
assignee_id: this.assignee ? this.assignee.id : null,
label_ids: this.labels.map( (label) => label.id )
}
};
if (!data.issue.label_ids.length) {
data.issue.label_ids = [''];
}
return Vue.http.patch(url, data);
}
}
class ListMilestone {
constructor (obj) {
this.id = obj.id;
this.title = obj.title;
}
}
class BoardService {
constructor (root, boardId) {
Vue.http.options.root = root;
this.lists = Vue.resource(`${root}/${boardId}/lists{/id}`, {}, {
generate: {
method: 'POST',
......
......@@ -5,6 +5,9 @@
gl.issueBoards.BoardsStore = {
disabled: false,
state: {},
detail: {
issue: {}
},
moving: {
issue: {},
list: {}
......
......@@ -41,7 +41,12 @@
defaultDate: $("input[name='" + this.fieldName + "']").val(),
altField: "input[name='" + this.fieldName + "']",
onSelect: () => {
return this.saveDueDate(true);
if (this.$dropdown.hasClass('js-issue-boards-due-date')) {
gl.issueBoards.BoardsStore.detail.issue.dueDate = $(`input[name='${this.fieldName}']`).val();
this.updateIssueBoardIssue();
} else {
return this.saveDueDate(true);
}
}
});
}
......@@ -49,8 +54,14 @@
initRemoveDueDate() {
this.$block.on('click', '.js-remove-due-date', (e) => {
e.preventDefault();
$("input[name='" + this.fieldName + "']").val('');
return this.saveDueDate(false);
if (this.$dropdown.hasClass('js-issue-boards-due-date')) {
gl.issueBoards.BoardsStore.detail.issue.dueDate = '';
this.updateIssueBoardIssue();
} else {
$("input[name='" + this.fieldName + "']").val('');
return this.saveDueDate(false);
}
});
}
......@@ -83,6 +94,18 @@
this.datePayload = datePayload;
}
updateIssueBoardIssue () {
this.$loading.fadeIn();
this.$dropdown.trigger('loading.gl.dropdown');
this.$selectbox.hide();
this.$value.css('display', '');
gl.issueBoards.BoardsStore.detail.issue.update(this.$dropdown.attr('data-issue-update'))
.then(() => {
this.$loading.fadeOut();
});
}
submitSelectedDate(isDropdown) {
return $.ajax({
type: 'PUT',
......
......@@ -622,6 +622,17 @@
selectedObject = this.renderedData[selectedIndex];
}
}
if (this.options.vue) {
if (el.hasClass(ACTIVE_CLASS)) {
el.removeClass(ACTIVE_CLASS);
} else {
el.addClass(ACTIVE_CLASS);
}
return selectedObject;
}
field = [];
value = this.options.id ? this.options.id(selectedObject, el) : selectedObject.id;
if (isInput) {
......
......@@ -22,7 +22,7 @@
abilityName = $dropdown.data('ability-name');
$selectbox = $dropdown.closest('.selectbox');
$block = $selectbox.closest('.block');
$form = $dropdown.closest('form');
$form = $dropdown.closest('form, .js-issuable-update');
$sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon span');
$sidebarLabelTooltip = $block.find('.js-sidebar-labels-tooltip');
$value = $block.find('.value');
......@@ -317,6 +317,7 @@
}
},
multiSelect: $dropdown.hasClass('js-multiselect'),
vue: $dropdown.hasClass('js-issue-board-sidebar'),
clicked: function(label, $el, e) {
var isIssueIndex, isMRIndex, page;
_this.enableBulkLabelDropdown();
......@@ -334,7 +335,7 @@
page = $('body').data('page');
isIssueIndex = page === 'projects:issues:index';
isMRIndex = page === 'projects:merge_requests:index';
if ($('html').hasClass('issue-boards-page')) {
if ($('html').hasClass('issue-boards-page') && !$dropdown.hasClass('js-issue-board-sidebar')) {
if (label.isAny) {
gl.issueBoards.BoardsStore.state.filters['label_name'] = [];
}
......@@ -362,6 +363,30 @@
else if ($dropdown.hasClass('js-filter-submit')) {
return $dropdown.closest('form').submit();
}
else if ($dropdown.hasClass('js-issue-board-sidebar')) {
if ($el.hasClass('is-active')) {
gl.issueBoards.BoardsStore.detail.issue.labels.push(new ListLabel({
id: label.id,
title: label.title,
color: label.color[0],
textColor: '#fff'
}));
}
else {
var labels = gl.issueBoards.BoardsStore.detail.issue.labels;
labels = labels.filter(function (selectedLabel) {
return selectedLabel.id !== label.id;
});
gl.issueBoards.BoardsStore.detail.issue.labels = labels;
}
$loading.fadeIn();
gl.issueBoards.BoardsStore.detail.issue.update($dropdown.attr('data-issue-update'))
.then(function () {
$loading.fadeOut();
});
}
else {
if ($dropdown.hasClass('js-multiselect')) {
......
......@@ -101,6 +101,7 @@
// display:block overrides the hide-collapse rule
return $value.css('display', '');
},
vue: $dropdown.hasClass('js-issue-board-sidebar'),
clicked: function(selected, $el, e) {
var data, isIssueIndex, isMRIndex, page;
page = $('body').data('page');
......@@ -110,7 +111,7 @@
e.preventDefault();
return;
}
if ($('html').hasClass('issue-boards-page')) {
if ($('html').hasClass('issue-boards-page') && !$dropdown.hasClass('js-issue-board-sidebar')) {
gl.issueBoards.BoardsStore.state.filters[$dropdown.data('field-name')] = selected.name;
gl.issueBoards.BoardsStore.updateFiltersUrl();
e.preventDefault();
......@@ -123,6 +124,24 @@
return Issuable.filterResults($dropdown.closest('form'));
} else if ($dropdown.hasClass('js-filter-submit')) {
return $dropdown.closest('form').submit();
} else if ($dropdown.hasClass('js-issue-board-sidebar')) {
if (selected.id !== -1) {
Vue.set(gl.issueBoards.BoardsStore.detail.issue, 'milestone', new ListMilestone({
id: selected.id,
title: selected.name
}));
} else {
Vue.delete(gl.issueBoards.BoardsStore.detail.issue, 'milestone');
}
$dropdown.trigger('loading.gl.dropdown');
$loading.fadeIn();
gl.issueBoards.BoardsStore.detail.issue.update($dropdown.attr('data-issue-update'))
.then(function () {
$dropdown.trigger('loaded.gl.dropdown');
$loading.fadeOut();
});
} else {
selected = $selectbox.find('input[type="hidden"]').val();
data = {};
......
......@@ -5,15 +5,24 @@
function Sidebar(currentUser) {
this.toggleTodo = bind(this.toggleTodo, this);
this.sidebar = $('aside');
this.removeListeners();
this.addEventListeners();
}
Sidebar.prototype.removeListeners = function () {
this.sidebar.off('click', '.sidebar-collapsed-icon');
$('.dropdown').off('hidden.gl.dropdown');
$('.dropdown').off('loading.gl.dropdown');
$('.dropdown').off('loaded.gl.dropdown');
$(document).off('click', '.js-sidebar-toggle');
}
Sidebar.prototype.addEventListeners = function() {
this.sidebar.on('click', '.sidebar-collapsed-icon', this, this.sidebarCollapseClicked);
$('.dropdown').on('hidden.gl.dropdown', this, this.onSidebarDropdownHidden);
$('.dropdown').on('loading.gl.dropdown', this.sidebarDropdownLoading);
$('.dropdown').on('loaded.gl.dropdown', this.sidebarDropdownLoaded);
$(document).off('click', '.js-sidebar-toggle').on('click', '.js-sidebar-toggle', function(e, triggered) {
$(document).on('click', '.js-sidebar-toggle', function(e, triggered) {
var $allGutterToggleIcons, $this, $thisIcon;
e.preventDefault();
$this = $(this);
......
......@@ -5,10 +5,10 @@
function Subscription(container) {
this.toggleSubscription = bind(this.toggleSubscription, this);
var $container;
$container = $(container);
this.url = $container.attr('data-url');
this.subscribe_button = $container.find('.js-subscribe-button');
this.subscription_status = $container.find('.subscription-status');
this.$container = $(container);
this.url = this.$container.attr('data-url');
this.subscribe_button = this.$container.find('.js-subscribe-button');
this.subscription_status = this.$container.find('.subscription-status');
this.subscribe_button.unbind('click').click(this.toggleSubscription);
}
......@@ -18,17 +18,27 @@
action = btn.find('span').text();
current_status = this.subscription_status.attr('data-status');
btn.addClass('disabled');
if ($('html').hasClass('issue-boards-page')) {
this.url = this.$container.attr('data-url');
}
return $.post(this.url, (function(_this) {
return function() {
var status;
btn.removeClass('disabled');
status = current_status === 'subscribed' ? 'unsubscribed' : 'subscribed';
_this.subscription_status.attr('data-status', status);
action = status === 'subscribed' ? 'Unsubscribe' : 'Subscribe';
btn.find('span').text(action);
_this.subscription_status.find('>div').toggleClass('hidden');
if (btn.attr('data-original-title')) {
return btn.tooltip('hide').attr('data-original-title', action).tooltip('fixTitle');
if ($('html').hasClass('issue-boards-page')) {
Vue.set(gl.issueBoards.BoardsStore.detail.issue, 'subscribed', !gl.issueBoards.BoardsStore.detail.issue.subscribed);
} else {
status = current_status === 'subscribed' ? 'unsubscribed' : 'subscribed';
_this.subscription_status.attr('data-status', status);
action = status === 'subscribed' ? 'Unsubscribe' : 'Subscribe';
btn.find('span').text(action);
_this.subscription_status.find('>div').toggleClass('hidden');
if (btn.attr('data-original-title')) {
return btn.tooltip('hide').attr('data-original-title', action).tooltip('fixTitle');
}
}
};
})(this));
......
......@@ -9,7 +9,11 @@
this.usersPath = "/autocomplete/users.json";
this.userPath = "/autocomplete/users/:id.json";
if (currentUser != null) {
this.currentUser = JSON.parse(currentUser);
if (typeof currentUser === 'object') {
this.currentUser = currentUser;
} else {
this.currentUser = JSON.parse(currentUser);
}
}
$('.js-user-search').each((function(_this) {
return function(i, dropdown) {
......@@ -32,9 +36,30 @@
$value = $block.find('.value');
$collapsedSidebar = $block.find('.sidebar-collapsed-user');
$loading = $block.find('.block-loading').fadeOut();
var updateIssueBoardsIssue = function () {
$loading.fadeIn();
gl.issueBoards.BoardsStore.detail.issue.update($dropdown.attr('data-issue-update'))
.then(function () {
$loading.fadeOut();
});
};
$block.on('click', '.js-assign-yourself', function(e) {
e.preventDefault();
return assignTo(_this.currentUser.id);
if ($dropdown.hasClass('js-issue-board-sidebar')) {
Vue.set(gl.issueBoards.BoardsStore.detail.issue, 'assignee', new ListUser({
id: _this.currentUser.id,
username: _this.currentUser.username,
name: _this.currentUser.name,
avatar_url: _this.currentUser.avatar_url
}));
updateIssueBoardsIssue();
} else {
return assignTo(_this.currentUser.id);
}
});
assignTo = function(selected) {
var data;
......@@ -150,6 +175,7 @@
// display:block overrides the hide-collapse rule
return $value.css('display', '');
},
vue: $dropdown.hasClass('js-issue-board-sidebar'),
clicked: function(user, $el, e) {
var isIssueIndex, isMRIndex, page, selected;
page = $('body').data('page');
......@@ -160,7 +186,7 @@
selectedId = user.id;
return;
}
if ($('html').hasClass('issue-boards-page')) {
if ($('html').hasClass('issue-boards-page') && !$dropdown.hasClass('js-issue-board-sidebar')) {
selectedId = user.id;
gl.issueBoards.BoardsStore.state.filters[$dropdown.data('field-name')] = user.id;
gl.issueBoards.BoardsStore.updateFiltersUrl();
......@@ -170,6 +196,19 @@
return Issuable.filterResults($dropdown.closest('form'));
} else if ($dropdown.hasClass('js-filter-submit')) {
return $dropdown.closest('form').submit();
} else if ($dropdown.hasClass('js-issue-board-sidebar')) {
if (user.id) {
Vue.set(gl.issueBoards.BoardsStore.detail.issue, 'assignee', new ListUser({
id: user.id,
username: user.username,
name: user.name,
avatar_url: user.avatar_url
}));
} else {
Vue.delete(gl.issueBoards.BoardsStore.detail.issue, 'assignee');
}
updateIssueBoardsIssue();
} else {
selected = $dropdown.closest('.selectbox').find("input[name='" + ($dropdown.data('field-name')) + "']").val();
return assignTo(selected);
......
......@@ -45,6 +45,15 @@
.page-with-sidebar {
padding-bottom: 0;
}
.issues-filters {
position: relative;
z-index: 999999;
}
}
.boards-app {
position: relative;
}
.boards-app-loading {
......@@ -66,6 +75,10 @@
height: 475px; // Needed for PhantomJS
height: calc(100vh - 220px);
min-height: 475px;
&.is-compact {
width: calc(100% - 290px);
}
}
}
......@@ -184,6 +197,10 @@
margin-bottom: 5px;
}
&.is-active {
background-color: $row-hover;
}
.label {
border: 0;
outline: 0;
......@@ -212,6 +229,10 @@
margin-right: 5px;
font-size: (14px / $issue-boards-font-size) * 1em;
}
.avatar {
margin-left: 0;
}
}
.card-number {
......@@ -264,3 +285,48 @@
border-width: 1px 0 1px 1px;
}
}
.issue-boards-sidebar {
&.right-sidebar {
top: 153px;
bottom: 0;
@media (min-width: $screen-sm-min) {
top: 220px;
}
}
.issuable-sidebar-header {
position: relative;
}
.gutter-toggle {
position: absolute;
top: 0;
bottom: 15px;
right: 0;
width: 22px;
color: $gray-darkest;
svg {
position: absolute;
top: 50%;
margin-top: (-11px / 2);
}
&:hover {
path {
fill: $gray-darkest;
}
}
}
.issuable-header-text {
width: 100%;
padding-right: 35px;
> strong {
font-weight: 600;
}
}
}
......@@ -73,10 +73,13 @@ module Projects
def serialize_as_json(resource)
resource.as_json(
labels: true,
only: [:iid, :title, :confidential],
only: [:iid, :title, :confidential, :due_date],
include: {
assignee: { only: [:id, :name, :username], methods: [:avatar_url] }
})
assignee: { only: [:id, :name, :username], methods: [:avatar_url] },
milestone: { only: [:id, :title] }
},
user: current_user
)
end
end
end
......
......@@ -287,10 +287,12 @@ class Issue < ActiveRecord::Base
def as_json(options = {})
super(options).tap do |json|
json[:subscribed] = subscribed?(options[:user]) if options.has_key?(:user)
if options.has_key?(:labels)
json[:labels] = labels.as_json(
project: project,
only: [:id, :title, :description, :color],
only: [:id, :title, :description, :color, :priority],
methods: [:text_color]
)
end
......
......@@ -7,8 +7,11 @@
":issue-link-base" => "issueLinkBase",
":disabled" => "disabled",
"track-by" => "id" }
%li.card{ ":class" => "{ 'user-can-drag': !disabled && issue.id, 'is-disabled': disabled || !issue.id }",
":index" => "index" }
%li.card{ ":class" => "{ 'user-can-drag': !disabled && issue.id, 'is-disabled': disabled || !issue.id, 'is-active': issueDetailVisible }",
":index" => "index",
"@mousedown" => "mouseDown",
"@mouseMove" => "mouseMove",
"@mouseup" => "showIssue($event)" }
%h4.card-title
= icon("eye-slash", class: "confidential-icon", "v-if" => "issue.confidential")
%a{ ":href" => "issueLinkBase + '/' + issue.id",
......@@ -18,6 +21,11 @@
%span.card-number{ "v-if" => "issue.id" }
= precede '#' do
{{ issue.id }}
%a.has-tooltip{ ":href" => "'#{root_path}' + 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 }
%button.label.color-label.has-tooltip{ "v-for" => "label in issue.labels",
type: "button",
"v-if" => "(!list.label || label.id !== list.label.id)",
......@@ -26,8 +34,3 @@
":title" => "label.description",
data: { container: 'body' } }
{{ label.title }}
%a.has-tooltip{ ":href" => "'#{root_path}' + 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 }
%board-sidebar{ "inline-template" => true,
":current-user" => "#{current_user.to_json(only: [:username, :id, :name], methods: [:avatar_url]) if current_user}" }
%aside.right-sidebar.right-sidebar-expanded.issue-boards-sidebar{ "v-show" => "showSidebar" }
.issuable-sidebar
.block.issuable-sidebar-header
%span.issuable-header-text.hide-collapsed.pull-left
%strong
{{ issue.title }}
%br/
%span
= precede "#" do
{{ issue.id }}
%a.gutter-toggle.pull-right{ role: "button",
href: "#",
"@click.prevent" => "closeSidebar",
"aria-label" => "Toggle sidebar" }
= custom_icon("icon_close", size: 15)
.js-issuable-update
= render "projects/boards/components/sidebar/assignee"
= render "projects/boards/components/sidebar/milestone"
= render "projects/boards/components/sidebar/due_date"
= render "projects/boards/components/sidebar/labels"
= render "projects/boards/components/sidebar/notifications"
.block.assignee
.title.hide-collapsed
Assignee
= icon("spinner spin", class: "block-loading")
- if can?(current_user, :admin_issue, @project)
= link_to "Edit", "#", class: "edit-link pull-right"
.value.hide-collapsed
%span.assign-yourself.no-value{ "v-if" => "!issue.assignee" }
No assignee
- if can?(current_user, :admin_issue, @project)
\-
%a.js-assign-yourself{ href: "#" }
assign yourself
%a.author_link.bold{ ":href" => "'#{root_url}' + issue.assignee.username",
"v-if" => "issue.assignee" }
%img.avatar.avatar-inline.s32{ ":src" => "issue.assignee.avatar",
width: "32" }
%span.author
{{ issue.assignee.name }}
%span.username
= precede "@" do
{{ issue.assignee.username }}
- if can?(current_user, :admin_issue, @project)
.selectbox.hide-collapsed
%input{ type: "hidden",
name: "issue[assignee_id]",
id: "issue_assignee_id",
":value" => "issue.assignee.id",
"v-if" => "issue.assignee" }
.dropdown
%button.dropdown-menu-toggle.js-user-search.js-author-search.js-issue-board-sidebar{ type: "button", data: { toggle: "dropdown", field_name: "issue[assignee_id]", first_user: (current_user.username if current_user), current_user: "true", project_id: @project.id, null_user: "true" },
":data-issuable-id" => "issue.id",
":data-issue-update" => "'#{namespace_project_issues_path(@project.namespace, @project)}/' + issue.id + '.json'" }
Select assignee
= icon("chevron-down")
.dropdown-menu.dropdown-menu-user.dropdown-menu-selectable.dropdown-menu-author
= dropdown_title("Assign to")
= dropdown_filter("Search users")
= dropdown_content
= dropdown_loading
.block.due_date
.title
Due date
= icon("spinner spin", class: "block-loading")
- if can?(current_user, :admin_issue, @project)
= link_to "Edit", "#", class: "edit-link pull-right"
.value
.value-content
%span.no-value{ "v-if" => "!issue.dueDate" }
No due date
%span.bold{ "v-if" => "issue.dueDate" }
{{ issue.dueDate | due-date }}
- if can?(current_user, :admin_issue, @project)
%span.no-value.js-remove-due-date-holder{ "v-if" => "issue.dueDate" }
\-
%a.js-remove-due-date{ href: "#", role: "button" }
remove due date
- if can?(current_user, :admin_issue, @project)
.selectbox
%input{ type: "hidden",
name: "issue[due_date]",
":value" => "issue.dueDate" }
.dropdown
%button.dropdown-menu-toggle.js-due-date-select.js-issue-boards-due-date{ type: 'button',
data: { toggle: 'dropdown', field_name: "issue[due_date]", ability_name: "issue" },
":data-issue-update" => "'#{namespace_project_issues_path(@project.namespace, @project)}/' + issue.id + '.json'" }
%span.dropdown-toggle-text Due date
= icon('chevron-down')
.dropdown-menu.dropdown-menu-due-date
= dropdown_title('Due date')
= dropdown_content do
.js-due-date-calendar
.block.labels
.title
Labels
= icon("spinner spin", class: "block-loading")
- if can?(current_user, :admin_issue, @project)
= link_to "Edit", "#", class: "edit-link pull-right"
.value.issuable-show-labels
%span.no-value{ "v-if" => "issue.labels && issue.labels.length === 0" }
None
%a{ href: "#",
"v-for" => "label in issue.labels" }
%span.label.color-label.has-tooltip{ ":style" => "{ backgroundColor: label.color, color: label.textColor }" }
{{ label.title }}
- if can?(current_user, :admin_issue, @project)
.selectbox
%input{ type: "hidden",
name: "issue[label_names][]",
"v-for" => "label in issue.labels",
":value" => "label.id" }
.dropdown
%button.dropdown-menu-toggle.js-label-select.js-multiselect.js-issue-board-sidebar{ type: "button",
data: { toggle: "dropdown", field_name: "issue[label_names][]", show_no: "true", show_any: "true", project_id: @project.id, labels: namespace_project_labels_path(@project.namespace, @project, :json), namespace_path: @project.try(:namespace).try(:path), project_path: @project.try(:path) },
":data-issue-update" => "'#{namespace_project_issues_path(@project.namespace, @project)}/' + issue.id + '.json'" }
%span.dropdown-toggle-text
Label
= icon('chevron-down')
.dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable
= render partial: "shared/issuable/label_page_default"
- if can? current_user, :admin_label, @project and @project
= render partial: "shared/issuable/label_page_create"
.block.milestone
.title
Milestone
= icon("spinner spin", class: "block-loading")
- if can?(current_user, :admin_issue, @project)
= link_to "Edit", "#", class: "edit-link pull-right"
.value
%span.no-value{ "v-if" => "!issue.milestone" }
None
%span.bold.has-tooltip{ "v-if" => "issue.milestone" }
{{ issue.milestone.title }}
- if can?(current_user, :admin_issue, @project)
.selectbox
%input{ type: "hidden",
":value" => "issue.milestone.id",
name: "issue[milestone_id]",
"v-if" => "issue.milestone" }
.dropdown
%button.dropdown-menu-toggle.js-milestone-select.js-issue-board-sidebar{ type: "button", data: { toggle: "dropdown", show_no: "true", field_name: "issue[milestone_id]", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), ability_name: "issue", use_id: "true" },
":data-issuable-id" => "issue.id",
":data-issue-update" => "'#{namespace_project_issues_path(@project.namespace, @project)}/' + issue.id + '.json'" }
Milestone
= icon("chevron-down")
.dropdown-menu.dropdown-select.dropdown-menu-selectable
= dropdown_title("Assignee milestone")
= dropdown_filter("Search milestones")
= dropdown_content
= dropdown_loading
- if current_user
.block.light.subscription{ ":data-url" => "'#{namespace_project_issues_path(@project.namespace, @project)}/' + issue.id + '/toggle_subscription'" }
.title
Notifications
%button.btn.btn-block.btn-default.js-subscribe-button.issuable-subscribe-button.hide-collapsed{ type: "button" }
{{ issue.subscribed ? 'Unsubscribe' : 'Subscribe' }}
.subscription-status{ ":data-status" => "issue.subscribed ? 'subscribed' : 'unsubscribed'" }
.unsubscribed{ "v-show" => "!issue.subscribed" }
You're not receiving notifications from this thread.
.subscribed{ "v-show" => "issue.subscribed" }
You're receiving notifications because you're subscribed to this thread.
......@@ -10,7 +10,9 @@
= render 'shared/issuable/filter', type: :boards
.boards-list#board-app{ "v-cloak" => true, data: board_data }
.boards-app-loading.text-center{ "v-if" => "loading" }
= icon("spinner spin")
= render "projects/boards/components/board"
#board-app.boards-app{ "v-cloak" => true, data: board_data }
.boards-list{ ":class" => "{ 'is-compact': detailIssueVisible }" }
.boards-app-loading.text-center{ "v-if" => "loading" }
= icon("spinner spin")
= render "projects/boards/components/board"
= render "projects/boards/components/sidebar"
......@@ -10,7 +10,9 @@
= render 'shared/issuable/filter', type: :boards
.boards-list#board-app{ "v-cloak" => true, data: board_data }
.boards-app-loading.text-center{ "v-if" => "loading" }
= icon("spinner spin")
= render "projects/boards/components/board"
#board-app.boards-app{ "v-cloak" => true, data: board_data }
.boards-list{ ":class" => "{ 'is-compact': detailIssueVisible }" }
.boards-app-loading.text-center{ "v-if" => "loading" }
= icon("spinner spin")
= render "projects/boards/components/board"
= render "projects/boards/components/sidebar"
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15"><path d="M9,7.5l5.83-5.91a.48.48,0,0,0,0-.69L14.11.15a.46.46,0,0,0-.68,0l-5.93,6L1.57.15a.46.46,0,0,0-.68,0L.15.9a.48.48,0,0,0,0,.69L6,7.5.15,13.41a.48.48,0,0,0,0,.69l.74.75a.46.46,0,0,0,.68,0l5.93-6,5.93,6a.46.46,0,0,0,.68,0l.74-.75a.48.48,0,0,0,0-.69Z"/></svg>
\ No newline at end of file
......@@ -21,9 +21,11 @@ describe Projects::Boards::IssuesController do
context 'with valid list id' do
it 'returns issues that have the list label applied' do
johndoe = create(:user, avatar: fixture_file_upload(File.join(Rails.root, 'spec/fixtures/dk.png')))
issue = create(:labeled_issue, project: project, labels: [planning])
create(:labeled_issue, project: project, labels: [planning])
create(:labeled_issue, project: project, labels: [development])
create(:labeled_issue, project: project, labels: [development], due_date: Date.tomorrow)
create(:labeled_issue, project: project, labels: [development], assignee: johndoe)
issue.subscribe(johndoe)
list_issues user: user, board: board, list: list2
......
......@@ -66,6 +66,21 @@ describe 'Issue Boards new issue', feature: true, js: true do
expect(page).to have_content('1')
end
end
it 'shows sidebar when creating new issue' do
page.within(first('.board')) do
find('.board-issue-count-holder .btn').click
end
page.within(first('.board-new-issue-form')) do
find('.form-control').set('bug')
click_button 'Submit issue'
end
wait_for_vue_resource
expect(page).to have_selector('.issue-boards-sidebar')
end
end
context 'unauthorized user' do
......
require 'rails_helper'
describe 'Issue Boards', feature: true, js: true do
include WaitForAjax
include WaitForVueResource
let(:project) { create(:empty_project, :public) }
let(:board) { create(:board, project: project) }
let(:user) { create(:user) }
let!(:label) { create(:label, project: project) }
let!(:label2) { create(:label, project: project) }
let!(:milestone) { create(:milestone, project: project) }
let!(:issue2) { create(:labeled_issue, project: project, assignee: user, milestone: milestone, labels: [label]) }
let!(:issue) { create(:issue, project: project) }
before do
project.team << [user, :master]
login_as(user)
visit namespace_project_board_path(project.namespace, project, board)
wait_for_vue_resource
end
it 'shows sidebar when clicking issue' do
page.within(first('.board')) do
first('.card').click
end
expect(page).to have_selector('.issue-boards-sidebar')
end
it 'closes sidebar when clicking issue' do
page.within(first('.board')) do
first('.card').click
end
expect(page).to have_selector('.issue-boards-sidebar')
page.within(first('.board')) do
first('.card').click
end
expect(page).not_to have_selector('.issue-boards-sidebar')
end
it 'closes sidebar when clicking close button' do
page.within(first('.board')) do
first('.card').click
end
expect(page).to have_selector('.issue-boards-sidebar')
find('.gutter-toggle').click
expect(page).not_to have_selector('.issue-boards-sidebar')
end
it 'shows issue details when sidebar is open' do
page.within(first('.board')) do
first('.card').click
end
page.within('.issue-boards-sidebar') do
expect(page).to have_content(issue.title)
expect(page).to have_content(issue.to_reference)
end
end
context 'assignee' do
it 'updates the issues assignee' do
page.within(first('.board')) do
first('.card').click
end
page.within('.assignee') do
click_link 'Edit'
wait_for_ajax
page.within('.dropdown-menu-user') do
click_link user.name
wait_for_vue_resource
end
expect(page).to have_content(user.name)
end
page.within(first('.board')) do
page.within(first('.card')) do
expect(page).to have_selector('.avatar')
end
end
end
it 'removes the assignee' do
page.within(first('.board')) do
find('.card:nth-child(2)').click
end
page.within('.assignee') do
click_link 'Edit'
wait_for_ajax
page.within('.dropdown-menu-user') do
click_link 'Unassigned'
wait_for_vue_resource
end
expect(page).to have_content('No assignee')
end
page.within(first('.board')) do
page.within(find('.card:nth-child(2)')) do
expect(page).not_to have_selector('.avatar')
end
end
end
it 'assignees to current user' do
page.within(first('.board')) do
first('.card').click
end
page.within('.assignee') do
click_link 'assign yourself'
wait_for_vue_resource
expect(page).to have_content(user.name)
end
page.within(first('.board')) do
page.within(first('.card')) do
expect(page).to have_selector('.avatar')
end
end
end
end
context 'milestone' do
it 'adds a milestone' do
page.within(first('.board')) do
first('.card').click
end
page.within('.milestone') do
click_link 'Edit'
wait_for_ajax
click_link milestone.title
wait_for_vue_resource
page.within('.value') do
expect(page).to have_content(milestone.title)
end
end
end
it 'removes a milestone' do
page.within(first('.board')) do
find('.card:nth-child(2)').click
end
page.within('.milestone') do
click_link 'Edit'
wait_for_ajax
click_link "No Milestone"
wait_for_vue_resource
page.within('.value') do
expect(page).not_to have_content(milestone.title)
end
end
end
end
context 'due date' do
it 'updates due date' do
page.within(first('.board')) do
first('.card').click
end
page.within('.due_date') do
click_link 'Edit'
click_link Date.today.day
wait_for_vue_resource
expect(page).to have_content(Date.today.to_s(:medium))
end
end
end
context 'labels' do
it 'adds a single label' do
page.within(first('.board')) do
first('.card').click
end
page.within('.labels') do
click_link 'Edit'
wait_for_ajax
click_link label.title
wait_for_vue_resource
find('.dropdown-menu-close-icon').click
page.within('.value') do
expect(page).to have_selector('.label', count: 1)
expect(page).to have_content(label.title)
end
end
page.within(first('.board')) do
page.within(first('.card')) do
expect(page).to have_selector('.label', count: 1)
expect(page).to have_content(label.title)
end
end
end
it 'adds a multiple labels' do
page.within(first('.board')) do
first('.card').click
end
page.within('.labels') do
click_link 'Edit'
wait_for_ajax
click_link label.title
click_link label2.title
wait_for_vue_resource
find('.dropdown-menu-close-icon').click
page.within('.value') do
expect(page).to have_selector('.label', count: 2)
expect(page).to have_content(label.title)
expect(page).to have_content(label2.title)
end
end
page.within(first('.board')) do
page.within(first('.card')) do
expect(page).to have_selector('.label', count: 2)
expect(page).to have_content(label.title)
expect(page).to have_content(label2.title)
end
end
end
it 'removes a label' do
page.within(first('.board')) do
find('.card:nth-child(2)').click
end
page.within('.labels') do
click_link 'Edit'
wait_for_ajax
click_link label.title
wait_for_vue_resource
find('.dropdown-menu-close-icon').click
page.within('.value') do
expect(page).to have_selector('.label', count: 0)
expect(page).not_to have_content(label.title)
end
end
page.within(first('.board')) do
page.within(find('.card:nth-child(2)')) do
expect(page).not_to have_selector('.label', count: 1)
expect(page).not_to have_content(label.title)
end
end
end
end
context 'subscription' do
it 'changes issue subscription' do
page.within(first('.board')) do
first('.card').click
end
page.within('.subscription') do
click_button 'Subscribe'
expect(page).to have_content("You're receiving notifications because you're subscribed to this thread.")
end
end
end
end
......@@ -9,6 +9,7 @@
"iid": { "type": "integer" },
"title": { "type": "string" },
"confidential": { "type": "boolean" },
"due_date": { "type": ["date", "null"] },
"labels": {
"type": "array",
"items": {
......@@ -42,7 +43,8 @@
"name": { "type": "string" },
"username": { "type": "string" },
"avatar_url": { "type": "uri" }
}
},
"subscribed": { "type": ["boolean", "null"] }
},
"additionalProperties": false
}
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