Commit 9b187ddd authored by Sean McGivern's avatar Sean McGivern

Merge branch 'multiple-issue-boards-milestone' into 'master'

Multiple issue boards milestone

Closes #1587 and #1156

See merge request !1150
parents fce2070c 5a72cc5a
...@@ -51,7 +51,8 @@ $(() => { ...@@ -51,7 +51,8 @@ $(() => {
issueLinkBase: $boardApp.dataset.issueLinkBase, issueLinkBase: $boardApp.dataset.issueLinkBase,
rootPath: $boardApp.dataset.rootPath, rootPath: $boardApp.dataset.rootPath,
bulkUpdatePath: $boardApp.dataset.bulkUpdatePath, bulkUpdatePath: $boardApp.dataset.bulkUpdatePath,
detailIssue: Store.detail detailIssue: Store.detail,
milestoneTitle: $boardApp.dataset.boardMilestoneTitle,
}, },
computed: { computed: {
detailIssueVisible () { detailIssueVisible () {
...@@ -59,6 +60,10 @@ $(() => { ...@@ -59,6 +60,10 @@ $(() => {
}, },
}, },
created () { created () {
if (this.milestoneTitle) {
this.state.filters.milestone_title = this.milestoneTitle;
}
gl.boardService = new BoardService(this.endpoint, this.bulkUpdatePath, this.boardId); gl.boardService = new BoardService(this.endpoint, this.bulkUpdatePath, this.boardId);
}, },
mounted () { mounted () {
...@@ -84,7 +89,8 @@ $(() => { ...@@ -84,7 +89,8 @@ $(() => {
gl.IssueBoardsSearch = new Vue({ gl.IssueBoardsSearch = new Vue({
el: document.getElementById('js-boards-search'), el: document.getElementById('js-boards-search'),
data: { data: {
filters: Store.state.filters filters: Store.state.filters,
milestoneTitle: $boardApp.dataset.boardMilestoneTitle,
}, },
mounted () { mounted () {
gl.issueBoards.newListDropdownInit(); gl.issueBoards.newListDropdownInit();
......
/* global Vue */ /* global Vue */
/* global BoardService */
const boardMilestoneSelect = require('./milestone_select');
const extraMilestones = require('../mixins/extra_milestones');
(() => { (() => {
window.gl = window.gl || {}; window.gl = window.gl || {};
...@@ -7,18 +10,32 @@ ...@@ -7,18 +10,32 @@
const Store = gl.issueBoards.BoardsStore; const Store = gl.issueBoards.BoardsStore;
gl.issueBoards.BoardSelectorForm = Vue.extend({ gl.issueBoards.BoardSelectorForm = Vue.extend({
props: {
milestonePath: {
type: String,
required: true,
},
},
data() { data() {
return { return {
board: { board: {
id: false, id: false,
name: '', name: '',
milestone: extraMilestones[0],
milestone_id: extraMilestones[0].id,
}, },
currentBoard: Store.state.currentBoard, currentBoard: Store.state.currentBoard,
currentPage: Store.state.currentPage, currentPage: Store.state.currentPage,
milestones: [],
milestoneDropdownOpen: false,
extraMilestones,
}; };
}, },
components: {
boardMilestoneSelect,
},
mounted() { mounted() {
if (this.currentBoard && Object.keys(this.currentBoard).length && this.currentPage === 'edit') { if (this.currentBoard && Object.keys(this.currentBoard).length && this.currentPage !== 'new') {
this.board = Vue.util.extend({}, this.currentBoard); this.board = Vue.util.extend({}, this.currentBoard);
} }
}, },
...@@ -30,13 +47,35 @@ ...@@ -30,13 +47,35 @@
return 'Save'; return 'Save';
}, },
milestoneToggleText() {
return this.board.milestone.title || 'Milestone';
},
submitDisabled() {
if (this.currentPage !== 'milestone') {
return this.board.name === '';
}
return false;
},
}, },
methods: { methods: {
loadMilestones() {
this.milestoneDropdownOpen = !this.milestoneDropdownOpen;
BoardService.loadMilestones.call(this);
},
submit() { submit() {
gl.boardService.createBoard(this.board) gl.boardService.createBoard(this.board)
.then(() => { .then(() => {
if (this.currentBoard && this.currentPage === 'edit') { if (this.currentBoard && this.currentPage !== 'new') {
this.currentBoard.name = this.board.name; this.currentBoard.name = this.board.name;
if (this.board.milestone) {
this.currentBoard.milestone_id = this.board.milestone_id;
this.currentBoard.milestone = this.board.milestone;
Store.state.filters.milestone_title = this.currentBoard.milestone_id ?
this.currentBoard.milestone.title : null;
}
} }
// Enable the button thanks to our jQuery disabling it // Enable the button thanks to our jQuery disabling it
...@@ -50,6 +89,13 @@ ...@@ -50,6 +89,13 @@
cancel() { cancel() {
Store.state.currentPage = ''; Store.state.currentPage = '';
}, },
selectMilestone(milestone) {
this.milestoneDropdownOpen = false;
this.board.milestone_id = milestone.id;
this.board.milestone = {
title: milestone.title,
};
},
}, },
}); });
})(); })();
...@@ -15,8 +15,14 @@ require('./board_new_form'); ...@@ -15,8 +15,14 @@ require('./board_new_form');
'board-selector-form': gl.issueBoards.BoardSelectorForm, 'board-selector-form': gl.issueBoards.BoardSelectorForm,
}, },
props: { props: {
currentBoard: Object, currentBoard: {
endpoint: String, type: Object,
required: true,
},
milestonePath: {
type: String,
required: true,
},
}, },
data() { data() {
return { return {
...@@ -36,6 +42,12 @@ require('./board_new_form'); ...@@ -36,6 +42,12 @@ require('./board_new_form');
this.loadBoards(false); this.loadBoards(false);
} }
}, },
board: {
handler() {
this.updateMilestoneFilterDropdown();
},
deep: true,
},
}, },
computed: { computed: {
currentPage() { currentPage() {
...@@ -51,7 +63,7 @@ require('./board_new_form'); ...@@ -51,7 +63,7 @@ require('./board_new_form');
return this.boards.length > 1; return this.boards.length > 1;
}, },
title() { title() {
if (this.currentPage === 'edit') { if (this.currentPage === 'edit' || this.currentPage === 'milestone') {
return 'Edit board'; return 'Edit board';
} else if (this.currentPage === 'new') { } else if (this.currentPage === 'new') {
return 'Create new board'; return 'Create new board';
...@@ -82,9 +94,33 @@ require('./board_new_form'); ...@@ -82,9 +94,33 @@ require('./board_new_form');
}); });
} }
}, },
updateMilestoneFilterDropdown() {
const $milestoneDropdownToggle = $('.js-milestone-select');
const glDropdown = $milestoneDropdownToggle.data('glDropdown');
const $milestoneDropdown = $('.dropdown-menu-milestone');
const hideElements = this.board.milestone === undefined || this.board.milestone_id === null;
$('#milestone_title').val(this.board.milestone ? this.board.milestone.title : '');
if (glDropdown.fullData) {
glDropdown.parseData(glDropdown.fullData);
}
$milestoneDropdown.find('.dropdown-input, .dropdown-footer-list')
.toggle(hideElements);
$milestoneDropdown.find('.js-milestone-footer-content').toggle(!hideElements);
$milestoneDropdown.find('.dropdown-content li').show()
.filter((i, el) => $(el).find('.is-active').length === 0)
.toggle(hideElements);
$('.js-milestone-select .dropdown-toggle-text')
.text(hideElements ? 'Milestone' : this.board.milestone.title)
.toggleClass('is-default', hideElements);
},
}, },
created() { created() {
this.state.currentBoard = this.currentBoard; this.state.currentBoard = this.currentBoard;
this.updateMilestoneFilterDropdown();
}, },
}); });
})(); })();
/* global BoardService */
/* global Vue */
const extraMilestones = require('../mixins/extra_milestones');
module.exports = {
props: {
board: {
type: Object,
required: true,
},
milestonePath: {
type: String,
required: true,
},
selectMilestone: {
type: Function,
required: true,
},
},
data() {
return {
loading: false,
milestones: [],
extraMilestones,
};
},
mounted() {
BoardService.loadMilestones.call(this);
},
template: `
<div>
<div class="text-center">
<i
v-if="loading"
class="fa fa-spinner fa-spin"></i>
</div>
<ul
class="board-milestone-list"
v-if="!loading">
<li v-for="milestone in extraMilestones">
<a
href="#"
@click.prevent.stop="selectMilestone(milestone)">
<i
class="fa fa-check"
v-if="board.milestone_id === milestone.id"></i>
{{ milestone.title }}
</a>
</li>
<li class="divider"></li>
<li v-for="milestone in milestones">
<a
href="#"
@click.prevent.stop="selectMilestone(milestone)">
<i
class="fa fa-check"
v-if="board.milestone_id === milestone.id"></i>
{{ milestone.title }}
</a>
</li>
</ul>
</div>
`,
};
module.exports = [
{
id: null,
title: 'Any Milestone',
},
{
id: -2,
title: 'Upcoming',
},
];
...@@ -102,6 +102,16 @@ class BoardService { ...@@ -102,6 +102,16 @@ class BoardService {
return this.issues.bulkUpdate(data); return this.issues.bulkUpdate(data);
} }
static loadMilestones(path) {
this.loading = true;
return this.$http.get(this.milestonePath)
.then((res) => {
this.milestones = res.json();
this.loading = false;
});
}
} }
window.BoardService = BoardService; window.BoardService = BoardService;
...@@ -532,7 +532,20 @@ ...@@ -532,7 +532,20 @@
}; };
GitLabDropdown.prototype.renderItem = function(data, group, index) { GitLabDropdown.prototype.renderItem = function(data, group, index) {
var field, fieldName, html, selected, text, url, value; var field, fieldName, html, selected, text, url, value, rowHidden;
if (!this.options.renderRow) {
value = this.options.id ? this.options.id(data) : data.id;
if (value) {
value = value.toString().replace(/'/g, '\\\'');
}
}
// Hide element
if (this.options.hideRow && this.options.hideRow(value)) {
rowHidden = true;
}
if (group == null) { if (group == null) {
group = false; group = false;
} }
...@@ -541,7 +554,12 @@ ...@@ -541,7 +554,12 @@
index = false; index = false;
} }
html = document.createElement('li'); html = document.createElement('li');
if (data === 'divider' || data === 'separator') {
if (rowHidden) {
html.style.display = 'none';
}
if ((data === 'divider' || data === 'separator')) {
html.className = data; html.className = data;
return html; return html;
} }
...@@ -556,11 +574,8 @@ ...@@ -556,11 +574,8 @@
html = this.options.renderRow.call(this.options, data, this); html = this.options.renderRow.call(this.options, data, this);
} else { } else {
if (!selected) { if (!selected) {
value = this.options.id ? this.options.id(data) : data.id;
fieldName = this.options.fieldName; fieldName = this.options.fieldName;
if (value) { value = value.toString().replace(/'/g, '\\\''); }
field = this.dropdown.parent().find("input[name='" + fieldName + "'][value='" + value + "']"); field = this.dropdown.parent().find("input[name='" + fieldName + "'][value='" + value + "']");
if (field.length) { if (field.length) {
selected = true; selected = true;
......
...@@ -87,6 +87,11 @@ ...@@ -87,6 +87,11 @@
}, },
selectable: true, selectable: true,
toggleLabel: function(selected, el, e) { toggleLabel: function(selected, el, e) {
if ($('html').hasClass('issue-boards-page') && !$dropdown.hasClass('js-issue-board-sidebar') &&
!$dropdown.closest('.add-issues-modal').length && !selected) {
return gl.issueBoards.BoardsStore.state.currentBoard.milestone.title;
}
if (selected && 'id' in selected && $(el).hasClass('is-active')) { if (selected && 'id' in selected && $(el).hasClass('is-active')) {
return selected.title; return selected.title;
} else { } else {
...@@ -114,8 +119,25 @@ ...@@ -114,8 +119,25 @@
return $value.css('display', ''); return $value.css('display', '');
}, },
vue: $dropdown.hasClass('js-issue-board-sidebar'), vue: $dropdown.hasClass('js-issue-board-sidebar'),
hideRow: function(milestone) {
if ($('html').hasClass('issue-boards-page') && !$dropdown.hasClass('js-issue-board-sidebar') &&
!$dropdown.closest('.add-issues-modal').length && gl.issueBoards.BoardsStore.state.currentBoard.milestone) {
return milestone !== gl.issueBoards.BoardsStore.state.currentBoard.milestone.title;
}
return false;
},
isSelectable: function() {
if ($('html').hasClass('issue-boards-page') && !$dropdown.hasClass('js-issue-board-sidebar') &&
!$dropdown.closest('.add-issues-modal').length && gl.issueBoards.BoardsStore.state.currentBoard.milestone_id) {
return false;
}
return true;
},
clicked: function(selected, $el, e) { clicked: function(selected, $el, e) {
var data, isIssueIndex, isMRIndex, page, boardsStore; var data, isIssueIndex, isMRIndex, page, boardsStore;
if (!selected) return;
page = $('body').data('page'); page = $('body').data('page');
isIssueIndex = page === 'projects:issues:index'; isIssueIndex = page === 'projects:issues:index';
isMRIndex = (page === page && page === 'projects:merge_requests:index'); isMRIndex = (page === page && page === 'projects:merge_requests:index');
......
...@@ -10,7 +10,7 @@ ...@@ -10,7 +10,7 @@
@media (min-width: $screen-sm-min) { @media (min-width: $screen-sm-min) {
.issues-filters, .issues-filters,
.issues_bulk_update { .issues_bulk_update {
.dropdown-menu-toggle { .dropdown-menu-toggle:not(.wide) {
width: 132px; width: 132px;
} }
} }
......
...@@ -528,12 +528,6 @@ ...@@ -528,12 +528,6 @@
} }
.boards-switcher {
padding-right: 10px;
margin-right: 10px;
border-right: 1px solid $white-dark;
}
.modal-filters { .modal-filters {
display: flex; display: flex;
...@@ -554,3 +548,39 @@ ...@@ -554,3 +548,39 @@
} }
} }
} }
.boards-switcher {
padding-right: 10px;
margin-right: 10px;
border-right: 1px solid $white-dark;
}
.board-milestone-list {
> li {
padding-left: 0;
padding-right: 0;
}
a {
padding-left: 25px;
}
.fa-check {
margin-left: -18px;
}
}
.board-inner-milestone-dropdown {
margin-top: 10px;
.dropdown-menu {
top: 60px;
min-width: 100%;
}
}
.board-milestone-footer-content {
padding-left: 12px;
padding-right: 12px;
color: $gl-gray-dark;
}
...@@ -75,7 +75,7 @@ class Projects::BoardsController < Projects::ApplicationController ...@@ -75,7 +75,7 @@ class Projects::BoardsController < Projects::ApplicationController
end end
def board_params def board_params
params.require(:board).permit(:name) params.require(:board).permit(:name, :milestone_id)
end end
def find_board def find_board
...@@ -83,6 +83,11 @@ class Projects::BoardsController < Projects::ApplicationController ...@@ -83,6 +83,11 @@ class Projects::BoardsController < Projects::ApplicationController
end end
def serialize_as_json(resource) def serialize_as_json(resource)
resource.as_json(only: [:id, :name]) resource.as_json(
only: [:id, :name],
include: {
milestone: { only: [:id, :title] }
}
)
end end
end end
...@@ -5,10 +5,22 @@ module BoardsHelper ...@@ -5,10 +5,22 @@ module BoardsHelper
{ {
endpoint: namespace_project_boards_path(@project.namespace, @project), endpoint: namespace_project_boards_path(@project.namespace, @project),
board_id: board.id, board_id: board.id,
board_milestone_title: board&.milestone&.title,
disabled: "#{!can?(current_user, :admin_list, @project)}", disabled: "#{!can?(current_user, :admin_list, @project)}",
issue_link_base: namespace_project_issues_path(@project.namespace, @project), issue_link_base: namespace_project_issues_path(@project.namespace, @project),
root_path: root_path, root_path: root_path,
bulk_update_path: bulk_update_namespace_project_issues_path(@project.namespace, @project), bulk_update_path: bulk_update_namespace_project_issues_path(@project.namespace, @project),
} }
end end
def current_board_json
board = @board || @boards.first
board.to_json(
only: [:id, :name, :milestone_id],
include: {
milestone: { only: [:title] }
}
)
end
end end
class Board < ActiveRecord::Base class Board < ActiveRecord::Base
belongs_to :project belongs_to :project
belongs_to :milestone
has_many :lists, -> { order(:list_type, :position) }, dependent: :delete_all has_many :lists, -> { order(:list_type, :position) }, dependent: :delete_all
...@@ -8,4 +9,26 @@ class Board < ActiveRecord::Base ...@@ -8,4 +9,26 @@ class Board < ActiveRecord::Base
def done_list def done_list
lists.merge(List.done).take lists.merge(List.done).take
end end
def milestone
if milestone_id == Milestone::Upcoming.id
Milestone::Upcoming
else
super
end
end
def as_json(options = {})
milestone_attrs = options.fetch(:include, {})
.extract!(:milestone)
.dig(:milestone, :only)
super(options).tap do |json|
if milestone.present? && milestone_attrs.present?
json[:milestone] = milestone_attrs.each_with_object({}) do |attr, json|
json[attr] = milestone.public_send(attr)
end
end
end
end
end end
...@@ -18,6 +18,7 @@ class Milestone < ActiveRecord::Base ...@@ -18,6 +18,7 @@ class Milestone < ActiveRecord::Base
cache_markdown_field :description cache_markdown_field :description
belongs_to :project belongs_to :project
has_many :boards
has_many :issues has_many :issues
has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues
has_many :merge_requests has_many :merge_requests
......
module Boards module Boards
class UpdateService < BaseService class UpdateService < BaseService
def execute(board) def execute(board)
board.update(name: params[:name]) board.update(name: params[:name], milestone_id: params[:milestone_id])
end end
end end
end end
%boards-selector{ "inline-template" => true, %boards-selector{ "inline-template" => true,
":current-board" => board.to_json } ":current-board" => current_board_json,
"milestone-path" => namespace_project_milestones_path(board.project.namespace, board.project, :json) }
.dropdown .dropdown
%button.dropdown-menu-toggle{ "@click" => "loadBoards", %button.dropdown-menu-toggle{ "@click" => "loadBoards",
data: { toggle: "dropdown" } } data: { toggle: "dropdown" } }
...@@ -25,7 +26,8 @@ ...@@ -25,7 +26,8 @@
= icon("spin spinner") = icon("spin spinner")
- if can?(current_user, :admin_board, @project) - if can?(current_user, :admin_board, @project)
%board-selector-form{ "inline-template" => true, %board-selector-form{ "inline-template" => true,
"v-if" => "currentPage === 'new' || currentPage === 'edit'" } ":milestone-path" => "milestonePath",
"v-if" => "currentPage === 'new' || currentPage === 'edit' || currentPage === 'milestone'" }
= render "projects/boards/components/form" = render "projects/boards/components/form"
.dropdown-content.board-selector-page-two{ "v-if" => "currentPage === 'delete'" } .dropdown-content.board-selector-page-two{ "v-if" => "currentPage === 'delete'" }
%p %p
...@@ -47,6 +49,9 @@ ...@@ -47,6 +49,9 @@
%li %li
%a{ "href" => "#", "@click.stop.prevent" => "showPage('edit')" } %a{ "href" => "#", "@click.stop.prevent" => "showPage('edit')" }
Edit board name Edit board name
%li
%a{ "href" => "#", "@click.stop.prevent" => "showPage('milestone')" }
Edit board milestone
%li{ "v-if" => "showDelete" } %li{ "v-if" => "showDelete" }
%a.text-danger{ "href" => "#", "@click.stop.prevent" => "showPage('delete')" } %a.text-danger{ "href" => "#", "@click.stop.prevent" => "showPage('delete')" }
Delete board Delete board
.dropdown-content.board-selector-page-two .dropdown-content.board-selector-page-two
%form{ "@submit.prevent" => "submit" } %form{ "@submit.prevent" => "submit" }
%label.label-light{ for: "board-new-name" } %input{ type: "hidden",
Board name id: "board-milestone",
%input.form-control{ type: "text", "v-model.number" => "board.milestone_id" }
id: "board-new-name", %div{ "v-if" => "currentPage !== 'milestone'" }
"v-model" => "board.name" } %label.label-light{ for: "board-new-name" }
Board name
%input.form-control{ type: "text",
id: "board-new-name",
"v-model" => "board.name" }
.dropdown.board-inner-milestone-dropdown{ ":class" => "{ open: milestoneDropdownOpen }",
"v-if" => "currentPage === 'new'" }
%label.label-light{ for: "board-milestone" }
Board milestone
%button.dropdown-menu-toggle.wide{ type: "button",
"@click.stop.prevent" => "loadMilestones" }
{{ milestoneToggleText }}
= icon("chevron-down")
.dropdown-menu.dropdown-menu-selectable{ "v-if" => "milestoneDropdownOpen" }
.dropdown-content
%ul
%li{ "v-for" => "milestone in extraMilestones" }
%a{ href: "#",
":class" => "{ 'is-active': milestone.id === board.milestone_id }",
"@click.stop.prevent" => "selectMilestone(milestone)" }
{{ milestone.title }}
%li.divider
%li{ "v-for" => "milestone in milestones" }
%a{ href: "#",
":class" => "{ 'is-active': milestone.id === board.milestone_id }",
"@click.stop.prevent" => "selectMilestone(milestone)" }
{{ milestone.title }}
= dropdown_loading
%span
Only show issues scheduled for the selected milestone
%board-milestone-select{ "v-if" => "currentPage == 'milestone'",
":milestone-path" => "milestonePath",
":select-milestone" => "selectMilestone",
":board" => "board" }
.clearfix.prepend-top-10 .clearfix.prepend-top-10
%button.btn.btn-primary.pull-left{ type: "submit", %button.btn.btn-primary.pull-left{ type: "submit",
":disabled" => "board.name === ''", ":disabled" => "submitDisabled",
"ref" => "'submit-btn'" } "ref" => "'submit-btn'" }
{{ buttonText }} {{ buttonText }}
%button.btn.btn-default.pull-right{ type: "button", %button.btn.btn-default.pull-right{ type: "button",
......
- finder = controller.controller_name == 'issues' || controller.controller_name == 'boards' ? issues_finder : merge_requests_finder - finder = controller.controller_name == 'issues' || controller.controller_name == 'boards' ? issues_finder : merge_requests_finder
- boards_page = controller.controller_name == 'boards' - boards_page = controller.controller_name == 'boards'
- board = local_assigns.fetch(:board, nil) - board = local_assigns[:board]
.issues-filters .issues-filters
.issues-details-filters.row-content-block.second-block .issues-details-filters.row-content-block.second-block
...@@ -28,7 +28,7 @@ ...@@ -28,7 +28,7 @@
placeholder: "Search assignee", data: { any_user: "Any Assignee", first_user: current_user.try(:username), null_user: true, current_user: true, project_id: @project.try(:id), selected: params[:assignee_id], field_name: "assignee_id", default_label: "Assignee" } }) placeholder: "Search assignee", data: { any_user: "Any Assignee", first_user: current_user.try(:username), null_user: true, current_user: true, project_id: @project.try(:id), selected: params[:assignee_id], field_name: "assignee_id", default_label: "Assignee" } })
.filter-item.inline.milestone-filter .filter-item.inline.milestone-filter
= render "shared/issuable/milestone_dropdown", selected: finder.milestones.try(:first), name: :milestone_title, show_any: true, show_upcoming: true = render "shared/issuable/milestone_dropdown", selected: finder.milestones.try(:first), name: :milestone_title, show_any: true, show_upcoming: true, board: board
.filter-item.inline.labels-filter .filter-item.inline.labels-filter
= render "shared/issuable/label_dropdown", selected: finder.labels.select(:title).uniq, use_id: false, selected_toggle: params[:label_name], data_options: { field_name: "label_name[]" } = render "shared/issuable/label_dropdown", selected: finder.labels.select(:title).uniq, use_id: false, selected_toggle: params[:label_name], data_options: { field_name: "label_name[]" }
......
- project = @target_project || @project - project = @target_project || @project
- extra_class = extra_class || '' - extra_class = extra_class || ''
- show_menu_above = show_menu_above || false - show_menu_above = show_menu_above || false
- board = local_assigns[:board]
- selected = board.try(:milestone) || local_assigns[:selected]
- selected_text = selected.try(:title) || params[:milestone_title] - selected_text = selected.try(:title) || params[:milestone_title]
- dropdown_title = local_assigns.fetch(:dropdown_title, "Filter by milestone") - dropdown_title = local_assigns.fetch(:dropdown_title, "Filter by milestone")
- if selected.present? || params[:milestone_title].present? - if selected.present? || params[:milestone_title].present?
...@@ -19,3 +22,7 @@ ...@@ -19,3 +22,7 @@
Manage milestones Manage milestones
- else - else
View milestones View milestones
- if board
%p.board-milestone-footer-content.js-milestone-footer-content{ style: "display: none;" }
This board's milestone has been set in its configuration.
Edit this value under the board menu.
class AddMilestoneIdToBoards < ActiveRecord::Migration
DOWNTIME = false
def change
add_column :boards, :milestone_id, :integer, null: true
end
end
class AddIndexToMilestoneIdOnBoards < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_concurrent_index(:boards, :milestone_id)
end
def down
remove_index(:boards, :milestone_id)
end
end
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20170224075132) do ActiveRecord::Schema.define(version: 20170306180725) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
...@@ -185,8 +185,10 @@ ActiveRecord::Schema.define(version: 20170224075132) do ...@@ -185,8 +185,10 @@ ActiveRecord::Schema.define(version: 20170224075132) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.string "name", default: "Development", null: false t.string "name", default: "Development", null: false
t.integer "milestone_id"
end end
add_index "boards", ["milestone_id"], name: "index_boards_on_milestone_id", using: :btree
add_index "boards", ["project_id"], name: "index_boards_on_project_id", using: :btree add_index "boards", ["project_id"], name: "index_boards_on_project_id", using: :btree
create_table "broadcast_messages", force: :cascade do |t| create_table "broadcast_messages", force: :cascade do |t|
......
...@@ -39,7 +39,8 @@ describe Projects::BoardsController do ...@@ -39,7 +39,8 @@ describe Projects::BoardsController do
context 'when format is JSON' do context 'when format is JSON' do
it 'returns a list of project boards' do it 'returns a list of project boards' do
create_list(:board, 2, project: project) create(:board, project: project, milestone: create(:milestone, project: project))
create(:board, project: project, milestone_id: Milestone::Upcoming.id)
list_boards format: :json list_boards format: :json
......
require 'rails_helper'
describe 'Board with milestone', :feature, :js do
include WaitForAjax
include WaitForVueResource
let(:user) { create(:user) }
let(:project) { create(:empty_project, :public) }
let!(:milestone) { create(:milestone, project: project) }
let!(:issue) { create(:closed_issue, project: project) }
let!(:issue_milestone) { create(:closed_issue, project: project, milestone: milestone) }
before do
project.team << [user, :master]
login_as(user)
end
context 'new board' do
before do
visit namespace_project_boards_path(project.namespace, project)
end
it 'creates board with milestone' do
create_board_with_milestone
click_link 'test'
expect(find('.js-milestone-select')).to have_content(milestone.title)
expect(all('.board')[1]).to have_selector('.card', count: 1)
end
end
context 'update board' do
let!(:milestone_two) { create(:milestone, project: project) }
let!(:board) { create(:board, project: project, milestone: milestone) }
before do
visit namespace_project_boards_path(project.namespace, project)
end
it 'defaults milestone filter' do
page.within '#js-multiple-boards-switcher' do
find('.dropdown-menu-toggle').click
wait_for_vue_resource
click_link board.name
end
expect(find('.js-milestone-select')).to have_content(milestone.title)
expect(all('.board')[1]).to have_selector('.card', count: 1)
end
it 'sets board to any milestone' do
update_board_milestone('Any Milestone')
expect(find('.js-milestone-select')).not_to have_content(milestone.title)
expect(all('.board')[1]).to have_selector('.card', count: 2)
end
it 'sets board to upcoming milestone' do
update_board_milestone('Upcoming')
expect(find('.js-milestone-select')).not_to have_content(milestone.title)
expect(all('.board')[1]).to have_selector('.card', count: 0)
end
end
def create_board_with_milestone
page.within '#js-multiple-boards-switcher' do
find('.dropdown-menu-toggle').click
click_link 'Create new board'
find('#board-new-name').set 'test'
click_button 'Milestone'
click_link milestone.title
click_button 'Create'
end
end
def update_board_milestone(milestone_title)
page.within '#js-multiple-boards-switcher' do
find('.dropdown-menu-toggle').click
click_link 'Edit board milestone'
click_link milestone_title
click_button 'Save'
end
end
end
...@@ -6,7 +6,19 @@ ...@@ -6,7 +6,19 @@
], ],
"properties" : { "properties" : {
"id": { "type": "integer" }, "id": { "type": "integer" },
"name": { "type": "string" } "name": { "type": "string" },
"milestone": {
"type": ["object", "null"],
"required": [
"id",
"title"
],
"properties": {
"id": { "type": "integer" },
"title": { "type": "string" }
},
"additionalProperties": false
}
}, },
"additionalProperties": false "additionalProperties": false
} }
...@@ -40,10 +40,20 @@ ...@@ -40,10 +40,20 @@
} }
}, },
"assignee": { "assignee": {
"id": { "type": "integet" }, "type": ["object", "null"],
"name": { "type": "string" }, "required": [
"username": { "type": "string" }, "id",
"avatar_url": { "type": "uri" } "name",
"username",
"avatar_url"
],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"username": { "type": "string" },
"avatar_url": { "type": "uri" }
},
"additionalProperties": false
}, },
"subscribed": { "type": ["boolean", "null"] } "subscribed": { "type": ["boolean", "null"] }
}, },
......
/* global Vue */
/* global boardsMockInterceptor */
/* global boardObj */
/* global BoardService */
const milestoneSelect = require('~/boards/components/milestone_select');
require('~/boards/services/board_service');
require('~/boards/stores/boards_store');
require('./mock_data');
describe('Milestone select component', () => {
let selectMilestoneSpy;
let vm;
beforeEach(() => {
const MilestoneComp = Vue.extend(milestoneSelect);
Vue.http.interceptors.push(boardsMockInterceptor);
gl.boardService = new BoardService('/test/issue-boards/board', '', '1');
gl.issueBoards.BoardsStore.create();
selectMilestoneSpy = jasmine.createSpy('selectMilestone').and.callFake((milestone) => {
vm.board.milestone_id = milestone.id;
});
vm = new MilestoneComp({
propsData: {
board: boardObj,
milestonePath: '/test/issue-boards/milestones.json',
selectMilestone: selectMilestoneSpy,
},
});
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, boardsMockInterceptor);
});
describe('before mount', () => {
it('sets default data', () => {
expect(vm.loading).toBe(false);
expect(vm.milestones.length).toBe(0);
expect(vm.extraMilestones.length).toBe(2);
expect(vm.extraMilestones[0].title).toBe('Any Milestone');
expect(vm.extraMilestones[1].title).toBe('Upcoming');
});
});
describe('mounted', () => {
describe('without board milestone', () => {
beforeEach((done) => {
vm.$mount();
setTimeout(() => {
done();
});
});
it('loads data', () => {
expect(vm.milestones.length).toBe(1);
});
it('renders the milestone list', () => {
expect(vm.$el.querySelector('.fa-spinner')).toBeNull();
expect(vm.$el.querySelectorAll('.board-milestone-list li').length).toBe(4);
expect(
vm.$el.querySelectorAll('.board-milestone-list li')[3].textContent,
).toContain('test');
});
it('selects any milestone', () => {
vm.$el.querySelectorAll('.board-milestone-list a')[0].click();
expect(selectMilestoneSpy).toHaveBeenCalledWith({
id: null,
title: 'Any Milestone',
});
});
it('selects upcoming milestone', () => {
vm.$el.querySelectorAll('.board-milestone-list a')[1].click();
expect(selectMilestoneSpy).toHaveBeenCalledWith({
id: -2,
title: 'Upcoming',
});
});
it('selects fetched milestone', () => {
vm.$el.querySelectorAll('.board-milestone-list a')[2].click();
expect(selectMilestoneSpy).toHaveBeenCalledWith({
id: 1,
title: 'test',
});
});
it('changes selected milestone', (done) => {
const firstLink = vm.$el.querySelectorAll('.board-milestone-list a')[0];
firstLink.click();
Vue.nextTick(() => {
expect(firstLink.querySelector('.fa-check')).toBeDefined();
done();
});
});
});
describe('with board milestone', () => {
beforeEach((done) => {
vm.board.milestone_id = 1;
vm.$mount();
setTimeout(() => {
done();
});
});
it('renders the selected milestone', () => {
expect(vm.$el.querySelector('.board-milestone-list .fa-check')).not.toBeNull();
expect(vm.$el.querySelectorAll('.board-milestone-list .fa-check').length).toBe(1);
});
});
});
});
/* eslint-disable comma-dangle, no-unused-vars, quote-props */ /* eslint-disable comma-dangle, no-unused-vars, quote-props */
const boardObj = {
id: 1,
name: 'test',
milestone_id: null,
};
const listObj = { const listObj = {
id: 1, id: 1,
...@@ -36,7 +41,11 @@ const BoardsMockData = { ...@@ -36,7 +41,11 @@ const BoardsMockData = {
labels: [] labels: []
}], }],
size: 1 size: 1
} },
'/test/issue-boards/milestones.json': [{
id: 1,
title: 'test',
}],
}, },
'POST': { 'POST': {
'/test/issue-boards/board/1/lists{/id}': listObj '/test/issue-boards/board/1/lists{/id}': listObj
...@@ -50,13 +59,14 @@ const BoardsMockData = { ...@@ -50,13 +59,14 @@ const BoardsMockData = {
}; };
const boardsMockInterceptor = (request, next) => { const boardsMockInterceptor = (request, next) => {
const body = BoardsMockData[request.method][request.url]; const body = BoardsMockData[request.method.toUpperCase()][request.url];
next(request.respondWith(JSON.stringify(body), { next(request.respondWith(JSON.stringify(body), {
status: 200 status: 200
})); }));
}; };
window.boardObj = boardObj;
window.listObj = listObj; window.listObj = listObj;
window.listObjDuplicate = listObjDuplicate; window.listObjDuplicate = listObjDuplicate;
window.BoardsMockData = BoardsMockData; window.BoardsMockData = BoardsMockData;
......
...@@ -47,6 +47,7 @@ milestone: ...@@ -47,6 +47,7 @@ milestone:
- merge_requests - merge_requests
- participants - participants
- events - events
- boards
snippets: snippets:
- author - author
- project - project
......
...@@ -3,6 +3,8 @@ require 'rails_helper' ...@@ -3,6 +3,8 @@ require 'rails_helper'
describe Board do describe Board do
describe 'relationships' do describe 'relationships' do
it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:project) }
it { is_expected.to belong_to(:milestone) }
it { is_expected.to have_many(:lists).order(list_type: :asc, position: :asc).dependent(:delete_all) } it { is_expected.to have_many(:lists).order(list_type: :asc, position: :asc).dependent(:delete_all) }
end end
...@@ -10,4 +12,27 @@ describe Board do ...@@ -10,4 +12,27 @@ describe Board do
it { is_expected.to validate_presence_of(:name) } it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_presence_of(:project) } it { is_expected.to validate_presence_of(:project) }
end end
describe 'milestone' do
subject { described_class.new }
it 'returns Milestone::Upcoming for upcoming milestone id' do
subject.milestone_id = Milestone::Upcoming.id
expect(subject.milestone).to eq Milestone::Upcoming
end
it 'returns milestone for valid milestone id' do
milestone = create(:milestone)
subject.milestone_id = milestone.id
expect(subject.milestone).to eq milestone
end
it 'returns nil for invalid milestone id' do
subject.milestone_id = -1
expect(subject.milestone).to be_nil
end
end
end end
...@@ -21,6 +21,8 @@ describe Milestone, models: true do ...@@ -21,6 +21,8 @@ describe Milestone, models: true do
describe "Associations" do describe "Associations" do
it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:project) }
it { is_expected.to have_many(:boards) }
it { is_expected.to have_many(:issues) } it { is_expected.to have_many(:issues) }
end end
......
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