Add issues to boards list

This removes the backlog list & instead creates a modal window that will
list all issues that are not part of a list for easy adding onto the
board

Closes #26205
This commit is contained in:
Phil Hughes 2017-01-24 10:27:41 +00:00 committed by Fatih Acet
parent 52ea505126
commit a132b7d8ce
18 changed files with 438 additions and 52 deletions

View file

@ -13,6 +13,7 @@
//= require ./components/board
//= require ./components/board_sidebar
//= require ./components/new_list_dropdown
//= require ./components/modal
//= require ./vue_resource_interceptor
$(() => {
@ -31,7 +32,8 @@ $(() => {
el: $boardApp,
components: {
'board': gl.issueBoards.Board,
'board-sidebar': gl.issueBoards.BoardSidebar
'board-sidebar': gl.issueBoards.BoardSidebar,
'board-add-issues-modal': gl.issueBoards.IssuesModal,
},
data: {
state: Store.state,
@ -55,12 +57,12 @@ $(() => {
gl.boardService.all()
.then((resp) => {
resp.json().forEach((board) => {
if (board.list_type === 'backlog') return;
const list = Store.addList(board);
if (list.type === 'done') {
list.position = Infinity;
} else if (list.type === 'backlog') {
list.position = -1;
}
});
@ -81,4 +83,13 @@ $(() => {
gl.issueBoards.newListDropdownInit();
}
});
// This element is outside the Vue app
$(document)
.off('click', '.js-show-add-issues')
.on('click', '.js-show-add-issues', (e) => {
e.preventDefault();
Store.modal.showAddIssuesModal = true;
});
});

View file

@ -1,4 +1,5 @@
/* eslint-disable comma-dangle, space-before-function-paren, dot-notation */
//= require ./issue_card_inner
/* global Vue */
(() => {
@ -9,6 +10,9 @@
gl.issueBoards.BoardCard = Vue.extend({
template: '#js-board-list-card',
components: {
'issue-card-inner': gl.issueBoards.IssueCardInner,
},
props: {
list: Object,
issue: Object,
@ -28,31 +32,6 @@
}
},
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();
},
mouseDown () {
this.showDetail = true;
},

View file

@ -0,0 +1,89 @@
/* global Vue */
(() => {
const Store = gl.issueBoards.BoardsStore;
window.gl = window.gl || {};
window.gl.issueBoards = window.gl.issueBoards || {};
gl.issueBoards.IssueCardInner = Vue.extend({
props: [
'issue', 'issueLinkBase', 'list',
],
methods: {
showLabel(label) {
if (!this.list) return true;
return !this.list.label || label.id !== this.list.label.id;
},
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();
},
},
template: `
<div>
<h4 class="card-title">
<i
class="fa fa-eye-flash"
v-if="issue.confidential"></i>
<a
:href="issueLinkBase + '/' + issue.id"
:title="issue.title">
{{ issue.title }}
</a>
</h4>
<div class="card-footer">
<span
class="card-number"
v-if="issue.id">
#{{issue.id}}
</span>
<a
class="has-tooltip"
:href="issue.assignee.username"
:title="'Assigned to ' + issue.assignee.name"
v-if="issue.assignee"
data-container="body">
<img
class="avatar avatar-inline s20"
:src="issue.assignee.avatar"
width="20"
height="20" />
</a>
<button
class="label color-label has-tooltip"
v-for="label in issue.labels"
type="button"
v-if="showLabel(label)"
@click="filterByLabel(label, $event)"
:style="{ backgroundColor: label.color, color: label.textColor }"
:title="label.description"
data-container="body">
{{ label.title }}
</button>
</div>
</div>
`,
});
})();

View file

@ -0,0 +1,28 @@
/* global Vue */
(() => {
const Store = gl.issueBoards.BoardsStore;
window.gl = window.gl || {};
window.gl.issueBoards = window.gl.issueBoards || {};
gl.issueBoards.DismissModal = Vue.extend({
data() {
return Store.modal;
},
methods: {
toggleModal(toggle) {
this.showAddIssuesModal = toggle;
},
},
template: `
<button
type="button"
class="close"
data-dismiss="modal"
aria-label="Close"
@click="toggleModal(false)">
<span aria-hidden="true">×</span>
</button>
`,
});
})();

View file

@ -0,0 +1,33 @@
/* global Vue */
(() => {
const Store = gl.issueBoards.BoardsStore;
window.gl = window.gl || {};
window.gl.issueBoards = window.gl.issueBoards || {};
gl.issueBoards.ModalFooter = Vue.extend({
data() {
return Store.modal;
},
methods: {
hideModal() {
this.showAddIssuesModal = false;
},
},
template: `
<footer class="form-actions add-issues-footer">
<button
class="btn btn-success pull-left"
type="button">
Add issues
</button>
<button
class="btn btn-default pull-right"
type="button"
@click="hideModal">
Cancel
</button>
</footer>
`,
});
})();

View file

@ -0,0 +1,31 @@
//= require ./dismiss
//= require ./tabs
//= require ./search
/* global Vue */
(() => {
const Store = gl.issueBoards.BoardsStore;
window.gl = window.gl || {};
window.gl.issueBoards = window.gl.issueBoards || {};
gl.issueBoards.IssuesModalHeader = Vue.extend({
data() {
return Store.modal;
},
components: {
'modal-dismiss': gl.issueBoards.DismissModal,
'modal-tabs': gl.issueBoards.ModalTabs,
'modal-search': gl.issueBoards.ModalSearch,
},
template: `
<header class="add-issues-header">
<h2>
Add issues to board
<modal-dismiss></modal-dismiss>
</h2>
<modal-tabs></modal-tabs>
<modal-search></modal-search>
</header>
`,
});
})();

View file

@ -0,0 +1,32 @@
//= require ./header
//= require ./list
//= require ./footer
/* global Vue */
(() => {
const Store = gl.issueBoards.BoardsStore;
window.gl = window.gl || {};
window.gl.issueBoards = window.gl.issueBoards || {};
gl.issueBoards.IssuesModal = Vue.extend({
data() {
return Store.modal;
},
components: {
'modal-header': gl.issueBoards.IssuesModalHeader,
'modal-list': gl.issueBoards.ModalList,
'modal-footer': gl.issueBoards.ModalFooter,
},
template: `
<div
class="add-issues-modal"
v-if="showAddIssuesModal">
<div class="add-issues-container">
<modal-header></modal-header>
<modal-list></modal-list>
<modal-footer></modal-footer>
</div>
</div>
`,
});
})();

View file

@ -0,0 +1,51 @@
/* global Vue */
/* global ListIssue */
(() => {
const Store = gl.issueBoards.BoardsStore;
window.gl = window.gl || {};
window.gl.issueBoards = window.gl.issueBoards || {};
gl.issueBoards.ModalList = Vue.extend({
data() {
return Store.modal;
},
computed: {
loading() {
return this.issues.length === 0;
},
},
mounted() {
gl.boardService.getBacklog()
.then((res) => {
const data = res.json();
data.forEach((issueObj) => {
this.issues.push(new ListIssue(issueObj));
});
});
},
components: {
'issue-card-inner': gl.issueBoards.IssueCardInner,
},
template: `
<section class="add-issues-list">
<i
class="fa fa-spinner fa-spin"
v-if="loading"></i>
<ul
class="list-unstyled"
v-if="!loading">
<li
class="card"
v-for="issue in issues">
<issue-card-inner
:issue="issue"
:issue-link-base="'/'">
</issue-card-inner>
</li>
</ul>
</section>
`,
});
})();

View file

@ -0,0 +1,14 @@
/* global Vue */
(() => {
window.gl = window.gl || {};
window.gl.issueBoards = window.gl.issueBoards || {};
gl.issueBoards.ModalSearch = Vue.extend({
template: `
<input
placeholder="Search issues..."
class="form-control"
type="search" />
`,
});
})();

View file

@ -0,0 +1,46 @@
/* global Vue */
(() => {
const Store = gl.issueBoards.BoardsStore;
window.gl = window.gl || {};
window.gl.issueBoards = window.gl.issueBoards || {};
gl.issueBoards.ModalTabs = Vue.extend({
data() {
return Store.modal;
},
methods: {
changeTab(tab) {
this.activeTab = tab;
},
},
template: `
<div class="top-area">
<ul class="nav-links issues-state-filters">
<li :class="{ 'active': activeTab == 'all' }">
<a
href="#"
role="button"
@click.prevent="changeTab('all')">
<span>All issues</span>
<span class="badge">
{{ issues.length }}
</span>
</a>
</li>
<li :class="{ 'active': activeTab == 'selected' }">
<a
href="#"
role="button"
@click.prevent="changeTab('selected')">
<span>Selected issues</span>
<span class="badge">
0
</span>
</a>
</li>
</ul>
</div>
`,
});
})();

View file

@ -3,6 +3,12 @@
class BoardService {
constructor (root, boardId) {
this.boards = Vue.resource(`${root}{/id}.json`, {}, {
backlog: {
method: 'GET',
url: `${root}/${boardId}/backlog.json`
}
});
this.lists = Vue.resource(`${root}/${boardId}/lists{/id}`, {}, {
generate: {
method: 'POST',
@ -65,6 +71,10 @@ class BoardService {
issue
});
}
getBacklog() {
return this.boards.backlog();
}
}
window.BoardService = BoardService;

View file

@ -12,6 +12,11 @@
detail: {
issue: {}
},
modal: {
issues: [],
showAddIssuesModal: false,
activeTab: 'all',
},
moving: {
issue: {},
list: {}

View file

@ -250,11 +250,12 @@
}
.issue-boards-search {
width: 290px;
width: 395px;
.form-control {
display: inline-block;
width: 210px;
margin-right: 10px
}
}
@ -354,3 +355,52 @@
padding-right: 0;
}
}
.add-issues-modal {
display: flex;
align-items: center;
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-color: rgba($black, .3);
z-index: 9999;
}
.add-issues-container {
display: flex;
flex-direction: column;
width: 90vw;
height: 85vh;
margin-left: auto;
margin-right: auto;
padding: 25px 15px 0;
background-color: $white-light;
border-radius: $border-radius-default;
box-shadow: 0 2px 12px rgba($black, .5);
}
.add-issues-header {
> h2 {
margin: 0;
font-size: 18px;
}
.top-area {
margin-bottom: 10px;
}
}
.add-issues-list {
flex: 1;
overflow-y: scroll;
}
.add-issues-footer {
margin-top: auto;
margin-left: -15px;
margin-right: -15px;
padding-left: 15px;
padding-right: 15px;
}

View file

@ -1,7 +1,7 @@
class Projects::BoardsController < Projects::ApplicationController
include IssuableCollections
before_action :authorize_read_board!, only: [:index, :show]
# before_action :authorize_read_board!, only: [:index, :show, :backlog]
def index
@boards = ::Boards::ListService.new(project, current_user).execute
@ -25,6 +25,27 @@ class Projects::BoardsController < Projects::ApplicationController
end
end
def backlog
board = project.boards.find(params[:id])
@issues = issues_collection
@issues = @issues.where.not(
LabelLink.where("label_links.target_type = 'Issue' AND label_links.target_id = issues.id")
.where(label_id: board.lists.movable.pluck(:label_id)).limit(1).arel.exists
)
@issues = @issues.page(params[:page])
render json: @issues.as_json(
labels: true,
only: [:iid, :title, :confidential, :due_date],
include: {
assignee: { only: [:id, :name, :username], methods: [:avatar_url] },
milestone: { only: [:id, :title] }
},
user: current_user
)
end
private
def authorize_read_board!

View file

@ -26,3 +26,4 @@
":issue-link-base" => "issueLinkBase",
":key" => "_uid" }
= render "projects/boards/components/sidebar"
%board-add-issues-modal

View file

@ -4,25 +4,6 @@
"@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',
":title" => "issue.title" }
{{ issue.title }}
.card-footer
%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, alt: "Avatar" }
%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 }}
%issue-card-inner{ ":list" => "list",
":issue" => "issue",
":issue-link-base" => "issueLinkBase" }

View file

@ -38,6 +38,8 @@
#js-boards-search.issue-boards-search
%input.pull-left.form-control{ type: "search", placeholder: "Filter by name...", "v-model" => "filters.search", "debounce" => "250" }
- if can?(current_user, :admin_list, @project)
%button.btn.btn-create.btn-inverted.js-show-add-issues{ type: "button" }
Add issues
.dropdown.pull-right
%button.btn.btn-create.js-new-board-list{ type: "button", data: { toggle: "dropdown", labels: labels_filter_path, namespace_path: @project.try(:namespace).try(:path), project_path: @project.try(:path) } }
Add list

View file

@ -266,6 +266,8 @@ constraints(ProjectUrlConstrainer.new) do
end
resources :boards, only: [:index, :show] do
get :backlog, on: :member
scope module: :boards do
resources :issues, only: [:update]