Commit 179cf7c7 authored by Sean McGivern's avatar Sean McGivern

Merge branch 'feature/multiple-issue-boards' into 'master'

Add ability to have multiple issue boards for a single project.

https://gitlab.com/gitlab-org/gitlab-ee/issues/929

https://gitlab.com/gitlab-org/gitlab-ee/issues/1084

See merge request !782
parents 22da02ab 8a69afda
...@@ -5,6 +5,7 @@ Please view this file on the master branch, on stable branches it's out of date. ...@@ -5,6 +5,7 @@ Please view this file on the master branch, on stable branches it's out of date.
- Add user activity table and service to query for active users - Add user activity table and service to query for active users
- Fix 500 error updating mirror URLs for projects - Fix 500 error updating mirror URLs for projects
- Fix validations related to mirroring settings form. !773 - Fix validations related to mirroring settings form. !773
- Add multiple issue boards. !782
- Fix Git access panel for Wikis when Kerberos authentication is enabled (Borja Aparicio) - Fix Git access panel for Wikis when Kerberos authentication is enabled (Borja Aparicio)
- Decrease maximum time that GitLab waits for a mirror to finish !791 (Borja Aparicio) - Decrease maximum time that GitLab waits for a mirror to finish !791 (Borja Aparicio)
- User groups (that can be assigned as approvers) - User groups (that can be assigned as approvers)
......
...@@ -6,6 +6,7 @@ ...@@ -6,6 +6,7 @@
//= require_tree ./services //= require_tree ./services
//= require_tree ./mixins //= require_tree ./mixins
//= require ./components/board //= require ./components/board
//= require ./components/boards_selector
//= require ./components/new_list_dropdown //= require ./components/new_list_dropdown
//= require ./vue_resource_interceptor //= require ./vue_resource_interceptor
...@@ -22,7 +23,8 @@ $(() => { ...@@ -22,7 +23,8 @@ $(() => {
gl.IssueBoardsApp = new Vue({ gl.IssueBoardsApp = new Vue({
el: $boardApp, el: $boardApp,
components: { components: {
'board': gl.issueBoards.Board 'board': gl.issueBoards.Board,
'boards-selector': gl.issueBoards.BoardsSelector
}, },
data: { data: {
state: Store.state, state: Store.state,
......
(() => {
window.gl = window.gl || {};
window.gl.issueBoards = window.gl.issueBoards || {};
gl.issueBoards.BoardSelectorForm = Vue.extend({
props: {
type: String,
currentBoard: Object,
currentPage: String,
reload: Boolean
},
data () {
return {
board: {
id: false,
name: ''
}
};
},
ready () {
if (this.currentBoard && Object.keys(this.currentBoard).length) {
this.board = Vue.util.extend({}, this.currentBoard);
}
},
computed: {
buttonText () {
if (this.type === 'new') {
return 'Create';
} else {
return 'Save';
}
}
},
methods: {
submit () {
gl.boardService.createBoard(this.board)
.then(() => {
if (this.currentBoard) {
this.currentBoard.name = this.board.name;
}
// Enable the button thanks to our jQuery disabling it
$(this.$els.submitBtn).enable();
// Reset the selectors current page
this.currentPage = '';
this.reload = true;
});
}
}
});
})();
//= require ./board_new_form
(() => {
window.gl = window.gl || {};
window.gl.issueBoards = window.gl.issueBoards || {};
gl.issueBoards.BoardsSelector = Vue.extend({
components: {
'board-selector-form': gl.issueBoards.BoardSelectorForm
},
props: {
currentBoard: Object,
endpoint: String
},
data () {
return {
open: false,
loading: true,
boards: [],
currentPage: '',
reload: false
};
},
watch: {
reload () {
if (this.reload) {
this.boards = [];
this.loading = true;
this.reload = false;
this.loadBoards(false);
}
}
},
computed: {
showDelete () {
return this.boards.length > 1;
},
title () {
if (this.currentPage === 'edit') {
return 'Edit board';
} else if (this.currentPage === 'new') {
return 'Create new board';
} else if (this.currentPage === 'delete') {
return 'Delete board';
} else {
return 'Go to a board';
}
}
},
methods: {
showPage (page) {
this.currentPage = page;
},
toggleDropdown () {
this.open = !this.open;
},
loadBoards (toggleDropdown = true) {
if (toggleDropdown) {
this.toggleDropdown();
}
if (this.open && !this.boards.length) {
gl.boardService.allBoards().then((resp) => {
this.loading = false;
this.boards = resp.json();
});
}
}
}
});
})();
...@@ -2,6 +2,7 @@ class BoardService { ...@@ -2,6 +2,7 @@ class BoardService {
constructor (root, boardId) { constructor (root, boardId) {
Vue.http.options.root = root; Vue.http.options.root = root;
this.boards = Vue.resource(`${root}{/id}.json`);
this.lists = Vue.resource(`${root}/${boardId}/lists{/id}`, {}, { this.lists = Vue.resource(`${root}/${boardId}/lists{/id}`, {}, {
generate: { generate: {
method: 'POST', method: 'POST',
...@@ -17,6 +18,18 @@ class BoardService { ...@@ -17,6 +18,18 @@ class BoardService {
}); });
} }
allBoards () {
return this.boards.get();
}
createBoard (board) {
if (board.id) {
return this.boards.update({ id: board.id }, board);
} else {
return this.boards.save({}, board);
}
}
all () { all () {
return this.lists.get(); return this.lists.get();
} }
......
...@@ -151,6 +151,10 @@ ...@@ -151,6 +151,10 @@
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
&.text-danger {
color: $gl-danger;
}
&:hover, &:hover,
&:focus, &:focus,
&.is-focused { &.is-focused {
......
lex
[v-cloak] { [v-cloak] {
display: none; display: none;
} }
...@@ -56,7 +55,6 @@ lex ...@@ -56,7 +55,6 @@ lex
.boards-list { .boards-list {
height: calc(100vh - 152px); height: calc(100vh - 152px);
width: 100%; width: 100%;
padding-top: 25px;
padding-bottom: 25px; padding-bottom: 25px;
padding-right: ($gl-padding / 2); padding-right: ($gl-padding / 2);
padding-left: ($gl-padding / 2); padding-left: ($gl-padding / 2);
...@@ -64,9 +62,9 @@ lex ...@@ -64,9 +62,9 @@ lex
white-space: nowrap; white-space: nowrap;
@media (min-width: $screen-sm-min) { @media (min-width: $screen-sm-min) {
height: 475px; // Needed for PhantomJS height: 409px; // Needed for PhantomJS
height: calc(100vh - 220px); height: calc(100vh - 290px);
min-height: 475px; min-height: 409px;
} }
} }
...@@ -265,3 +263,38 @@ lex ...@@ -265,3 +263,38 @@ lex
border-width: 1px 0 1px 1px; border-width: 1px 0 1px 1px;
} }
} }
.boards-title-holder {
padding: 25px 13px $gl-padding;
.dropdown-menu {
margin-top: -15px;
margin-left: 8px;
}
}
.boards-switcher {
padding: 0;
border: 0;
outline: 0;
background: none;
font-size: 19px;
font-weight: 600;
> .fa {
position: relative;
top: 2px;
margin-left: 5px;
}
}
.board-selector-page-two {
padding-left: 10px;
padding-right: 10px;
white-space: normal;
}
.board-delete-btns {
padding-top: 12px;
border-top: 1px solid $border-color;
}
...@@ -2,6 +2,8 @@ class Projects::BoardsController < Projects::ApplicationController ...@@ -2,6 +2,8 @@ class Projects::BoardsController < Projects::ApplicationController
include IssuableCollections include IssuableCollections
before_action :authorize_read_board!, only: [:index, :show] before_action :authorize_read_board!, only: [:index, :show]
before_action :authorize_admin_board!, only: [:create, :update, :destroy]
before_action :find_board, only: [:show, :update, :destroy]
def index def index
@boards = ::Boards::ListService.new(project, current_user).execute @boards = ::Boards::ListService.new(project, current_user).execute
...@@ -15,8 +17,6 @@ class Projects::BoardsController < Projects::ApplicationController ...@@ -15,8 +17,6 @@ class Projects::BoardsController < Projects::ApplicationController
end end
def show def show
@board = project.boards.find(params[:id])
respond_to do |format| respond_to do |format|
format.html format.html
format.json do format.json do
...@@ -25,13 +25,64 @@ class Projects::BoardsController < Projects::ApplicationController ...@@ -25,13 +25,64 @@ class Projects::BoardsController < Projects::ApplicationController
end end
end end
def create
board = ::Boards::CreateService.new(project, current_user, board_params).execute
respond_to do |format|
format.json do
if board.valid?
render json: serialize_as_json(board)
else
render json: board.errors, status: :unprocessable_entity
end
end
end
end
def update
service = ::Boards::UpdateService.new(project, current_user, board_params)
service.execute(@board)
respond_to do |format|
format.json do
if @board.valid?
render json: serialize_as_json(@board)
else
render json: @board.errors, status: :unprocessable_entity
end
end
end
end
def destroy
service = ::Boards::DestroyService.new(project, current_user)
service.execute(@board)
respond_to do |format|
format.html { redirect_to namespace_project_boards_path(@project.namespace, @project) }
end
end
private private
def authorize_admin_board!
return render_404 unless can?(current_user, :admin_board, project)
end
def authorize_read_board! def authorize_read_board!
return access_denied! unless can?(current_user, :read_board, project) return render_404 unless can?(current_user, :read_board, project)
end
def board_params
params.require(:board).permit(:name)
end
def find_board
@board = project.boards.find(params[:id])
end end
def serialize_as_json(resource) def serialize_as_json(resource)
resource.as_json(only: [:id]) resource.as_json(only: [:id, :name])
end end
end end
...@@ -3,7 +3,7 @@ class Board < ActiveRecord::Base ...@@ -3,7 +3,7 @@ class Board < ActiveRecord::Base
has_many :lists, -> { order(:list_type, :position) }, dependent: :delete_all has_many :lists, -> { order(:list_type, :position) }, dependent: :delete_all
validates :project, presence: true validates :name, :project, presence: true
def backlog_list def backlog_list
lists.merge(List.backlog).take lists.merge(List.backlog).take
......
...@@ -17,9 +17,6 @@ class Project < ActiveRecord::Base ...@@ -17,9 +17,6 @@ class Project < ActiveRecord::Base
extend Gitlab::ConfigHelper extend Gitlab::ConfigHelper
class BoardLimitExceeded < StandardError; end
NUMBER_OF_PERMITTED_BOARDS = 1
UNKNOWN_IMPORT_URL = 'http://unknown.git' UNKNOWN_IMPORT_URL = 'http://unknown.git'
cache_markdown_field :description, pipeline: :description cache_markdown_field :description, pipeline: :description
...@@ -64,7 +61,7 @@ class Project < ActiveRecord::Base ...@@ -64,7 +61,7 @@ class Project < ActiveRecord::Base
has_one :push_rule, dependent: :destroy has_one :push_rule, dependent: :destroy
has_one :last_event, -> {order 'events.created_at DESC'}, class_name: 'Event', foreign_key: 'project_id' has_one :last_event, -> {order 'events.created_at DESC'}, class_name: 'Event', foreign_key: 'project_id'
has_many :boards, before_add: :validate_board_limit, dependent: :destroy has_many :boards, dependent: :destroy
# Project services # Project services
has_many :services has_many :services
...@@ -1613,8 +1610,4 @@ class Project < ActiveRecord::Base ...@@ -1613,8 +1610,4 @@ class Project < ActiveRecord::Base
shared_projects.any? shared_projects.any?
end end
def validate_board_limit(board)
raise BoardLimitExceeded, 'Number of permitted boards exceeded' if boards.size >= NUMBER_OF_PERMITTED_BOARDS
end
end end
...@@ -91,6 +91,7 @@ class ProjectPolicy < BasePolicy ...@@ -91,6 +91,7 @@ class ProjectPolicy < BasePolicy
can! :update_container_image can! :update_container_image
can! :create_environment can! :create_environment
can! :create_deployment can! :create_deployment
can! :admin_board
end end
def master_access! def master_access!
......
module Boards module Boards
class CreateService < BaseService class CreateService < BaseService
def execute def execute
if project.boards.empty? board = project.boards.create(params)
create_board!
else
project.boards.first
end
end
private if board.persisted?
def create_board!
board = project.boards.create
board.lists.create(list_type: :backlog) board.lists.create(list_type: :backlog)
board.lists.create(list_type: :done) board.lists.create(list_type: :done)
end
board board
end end
......
module Boards
class DestroyService < BaseService
def execute(board)
return false if project.boards.size == 1
board.destroy
end
end
end
module Boards
class UpdateService < BaseService
def execute(board)
board.update(name: params[:name])
end
end
end
%boards-selector{ "inline-template" => true,
":current-board" => board.to_json }
.boards-title-holder.dropdown
%button.boards-switcher{ "@click" => "loadBoards",
data: { toggle: "dropdown" } }
{{ currentBoard.name }}
= icon("caret-down")
.dropdown-menu{ ":class" => "{ 'is-loading': loading }" }
.dropdown-title
%button.dropdown-title-button.dropdown-menu-back{ type: "button",
aria: { label: "Go back" },
"@click.stop.prevent" => "showPage('')",
"v-if" => "currentPage !== ''" }
= icon("arrow-left")
{{ title }}
%button.dropdown-title-button.dropdown-menu-close{ type: "button",
aria: { label: "Close" } }
= icon("times", class: "dropdown-menu-close-icon")
.dropdown-content{ "v-if" => "currentPage === ''" }
%ul{ "v-if" => "!loading" }
%li{ "v-for" => "board in boards" }
%a{ ":href" => "'#{namespace_project_boards_path(@project.namespace, @project)}/' + board.id" }
{{ board.name }}
.dropdown-loading{ "v-if" => "loading" }
= icon("spin spinner")
%board-selector-form{ "inline-template" => true,
"v-if" => "currentPage === 'edit'",
"type" => "edit",
":current-board.sync" => "currentBoard",
":current-page.sync" => "currentPage",
":reload.sync" => "reload" }
= render "projects/boards/components/form"
%board-selector-form{ "inline-template" => true,
"v-if" => "currentPage === 'new'",
"type" => "new",
":current-page.sync" => "currentPage",
":reload.sync" => "reload" }
= render "projects/boards/components/form"
.dropdown-content.board-selector-page-two{ "v-if" => "currentPage === 'delete'" }
%p
Are you sure you want to delete this board?
.board-delete-btns.clearfix
= link_to "",
class: "btn btn-danger pull-left",
method: :delete,
":href" => "'#{namespace_project_boards_path(@project.namespace, @project)}/' + currentBoard.id" do
Delete
%button.btn.btn-default.pull-right{ type: "button",
"@click.stop.prevent" => "currentPage = ''" }
Cancel
.dropdown-footer{ "v-if" => "currentPage === ''" }
%ul.dropdown-footer-list
%li
%a{ "href": "#", "@click.stop.prevent" => "showPage('new')" }
Create new board
%li
%a{ "href": "#", "@click.stop.prevent" => "showPage('edit')" }
Edit board name
%li{ "v-if" => "showDelete" }
%a.text-danger{ "href": "#", "@click.stop.prevent" => "showPage('delete')" }
Delete board
.dropdown-content.board-selector-page-two
%form{ "@submit.prevent" => "submit" }
%label.label-light{ for: "board-new-name" }
Board name
%input.form-control{ type: "text",
id: "board-new-name",
"v-model" => "board.name" }
.clearfix.prepend-top-10
%button.btn.btn-primary.pull-left{ type: "submit",
":disabled" => "board.name === ''",
"v-el:submit-btn" => true }
{{ buttonText }}
%button.btn.btn-default.pull-right{ type: "button",
"@click.stop.prevent" => "currentPage = ''" }
Cancel
...@@ -10,7 +10,9 @@ ...@@ -10,7 +10,9 @@
= render 'shared/issuable/filter', type: :boards = render 'shared/issuable/filter', type: :boards
.boards-list#board-app{ "v-cloak" => true, data: board_data } #board-app{ "v-cloak" => true, data: board_data }
= render "title", board: @boards.first
.boards-app-loading.text-center{ "v-if" => "loading" } .boards-app-loading.text-center{ "v-if" => "loading" }
= icon("spinner spin") = icon("spinner spin")
.boards-list
= render "projects/boards/components/board" = render "projects/boards/components/board"
...@@ -10,7 +10,9 @@ ...@@ -10,7 +10,9 @@
= render 'shared/issuable/filter', type: :boards = render 'shared/issuable/filter', type: :boards
.boards-list#board-app{ "v-cloak" => true, data: board_data } #board-app{ "v-cloak" => true, data: board_data }
= render "title", board: @board
.boards-app-loading.text-center{ "v-if" => "loading" } .boards-app-loading.text-center{ "v-if" => "loading" }
= icon("spinner spin") = icon("spinner spin")
.boards-list
= render "projects/boards/components/board" = render "projects/boards/components/board"
...@@ -459,7 +459,7 @@ resources :namespaces, path: '/', constraints: { id: /[a-zA-Z.0-9_\-]+/ }, only: ...@@ -459,7 +459,7 @@ resources :namespaces, path: '/', constraints: { id: /[a-zA-Z.0-9_\-]+/ }, only:
end end
end end
resources :boards, only: [:index, :show] do resources :boards, only: [:index, :show, :create, :update, :destroy] do
scope module: :boards do scope module: :boards do
resources :issues, only: [:update] resources :issues, only: [:update]
......
class AddNameToBoards < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_column_with_default :boards, :name, :string, default: 'Development'
end
def down
remove_column :boards, :name
end
end
...@@ -167,6 +167,7 @@ ActiveRecord::Schema.define(version: 20161007133303) do ...@@ -167,6 +167,7 @@ ActiveRecord::Schema.define(version: 20161007133303) do
t.integer "project_id", null: false t.integer "project_id", null: false
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
end end
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
......
...@@ -22,13 +22,7 @@ module API ...@@ -22,13 +22,7 @@ module API
segment ':id/boards/:board_id' do segment ':id/boards/:board_id' do
helpers do helpers do
def project_board def project_board
board = user_project.boards.first user_project.boards.find(params[:board_id])
if params[:board_id] == board.id
board
else
not_found!('Board')
end
end end
def board_lists def board_lists
...@@ -92,12 +86,12 @@ module API ...@@ -92,12 +86,12 @@ module API
requires :position, type: Integer, desc: 'The position of the list' requires :position, type: Integer, desc: 'The position of the list'
end end
put '/lists/:list_id' do put '/lists/:list_id' do
list = project_board.lists.movable.find(params[:list_id]) list = board_lists.find(params[:list_id])
authorize!(:admin_list, user_project) authorize!(:admin_list, user_project)
service = ::Boards::Lists::MoveService.new(user_project, current_user, service = ::Boards::Lists::MoveService.new(user_project, current_user,
{ position: params[:position] }) { position: params[:position].to_i })
if service.execute(list) if service.execute(list)
present list, with: Entities::List present list, with: Entities::List
......
...@@ -106,6 +106,22 @@ describe Projects::Boards::IssuesController do ...@@ -106,6 +106,22 @@ describe Projects::Boards::IssuesController do
expect(response).to have_http_status(404) expect(response).to have_http_status(404)
end end
end end
context 'with invalid board id' do
it 'returns a not found 404 response' do
create_issue user: user, board: 999, list: list1, title: 'New issue'
expect(response).to have_http_status(404)
end
end
context 'with invalid list id' do
it 'returns a not found 404 response' do
create_issue user: user, board: board, list: 999, title: 'New issue'
expect(response).to have_http_status(404)
end
end
end end
context 'with unauthorized user' do context 'with unauthorized user' do
......
...@@ -21,6 +21,20 @@ describe Projects::BoardsController do ...@@ -21,6 +21,20 @@ describe Projects::BoardsController do
expect(response).to render_template :index expect(response).to render_template :index
expect(response.content_type).to eq 'text/html' expect(response.content_type).to eq 'text/html'
end end
context 'with unauthorized user' do
before do
allow(Ability).to receive(:allowed?).with(user, :read_project, project).and_return(true)
allow(Ability).to receive(:allowed?).with(user, :read_board, project).and_return(false)
end
it 'returns a not found 404 response' do
list_boards
expect(response).to have_http_status(404)
expect(response.content_type).to eq 'text/html'
end
end
end end
context 'when format is JSON' do context 'when format is JSON' do
...@@ -34,7 +48,6 @@ describe Projects::BoardsController do ...@@ -34,7 +48,6 @@ describe Projects::BoardsController do
expect(response).to match_response_schema('boards') expect(response).to match_response_schema('boards')
expect(parsed_response.length).to eq 2 expect(parsed_response.length).to eq 2
end end
end
context 'with unauthorized user' do context 'with unauthorized user' do
before do before do
...@@ -43,9 +56,11 @@ describe Projects::BoardsController do ...@@ -43,9 +56,11 @@ describe Projects::BoardsController do
end end
it 'returns a not found 404 response' do it 'returns a not found 404 response' do
list_boards list_boards format: :json
expect(response).to have_http_status(404) expect(response).to have_http_status(404)
expect(response.content_type).to eq 'application/json'
end
end end
end end
...@@ -66,6 +81,20 @@ describe Projects::BoardsController do ...@@ -66,6 +81,20 @@ describe Projects::BoardsController do
expect(response).to render_template :show expect(response).to render_template :show
expect(response.content_type).to eq 'text/html' expect(response.content_type).to eq 'text/html'
end end
context 'with unauthorized user' do
before do
allow(Ability).to receive(:allowed?).with(user, :read_project, project).and_return(true)
allow(Ability).to receive(:allowed?).with(user, :read_board, project).and_return(false)
end
it 'returns a not found 404 response' do
read_board board: board
expect(response).to have_http_status(404)
expect(response.content_type).to eq 'text/html'
end
end
end end
context 'when format is JSON' do context 'when format is JSON' do
...@@ -74,7 +103,6 @@ describe Projects::BoardsController do ...@@ -74,7 +103,6 @@ describe Projects::BoardsController do
expect(response).to match_response_schema('board') expect(response).to match_response_schema('board')
end end
end
context 'with unauthorized user' do context 'with unauthorized user' do
before do before do
...@@ -83,9 +111,11 @@ describe Projects::BoardsController do ...@@ -83,9 +111,11 @@ describe Projects::BoardsController do
end end
it 'returns a not found 404 response' do it 'returns a not found 404 response' do
read_board board: board read_board board: board, format: :json
expect(response).to have_http_status(404) expect(response).to have_http_status(404)
expect(response.content_type).to eq 'application/json'
end
end end
end end
...@@ -106,4 +136,150 @@ describe Projects::BoardsController do ...@@ -106,4 +136,150 @@ describe Projects::BoardsController do
format: format format: format
end end
end end
describe 'POST create' do
context 'with valid params' do
it 'returns a successful 200 response' do
create_board name: 'Backend'
expect(response).to have_http_status(200)
end
it 'returns the created board' do
create_board name: 'Backend'
expect(response).to match_response_schema('board')
end
end
context 'with invalid params' do
it 'returns an unprocessable entity 422 response' do
create_board name: nil
expect(response).to have_http_status(422)
end
end
context 'with unauthorized user' do
before do
allow(Ability).to receive(:allowed?).with(user, :read_project, project).and_return(true)
allow(Ability).to receive(:allowed?).with(user, :admin_board, project).and_return(false)
end
it 'returns a not found 404 response' do
create_board name: 'Backend'
expect(response.content_type).to eq 'application/json'
expect(response).to have_http_status(404)
end
end
def create_board(name:)
post :create, namespace_id: project.namespace.to_param,
project_id: project.to_param,
board: { name: name },
format: :json
end
end
describe 'PATCH update' do
let(:board) { create(:board, project: project, name: 'Backend') }
context 'with valid params' do
it 'returns a successful 200 response' do
update_board board: board, name: 'Frontend'
expect(response).to have_http_status(200)
end
it 'returns the updated board' do
update_board board: board, name: 'Frontend'
expect(response).to match_response_schema('board')
end
end
context 'with invalid params' do
it 'returns an unprocessable entity 422 response' do
update_board board: board, name: nil
expect(response).to have_http_status(422)
end
end
context 'with invalid board id' do
it 'returns a not found 404 response' do
update_board board: 999, name: 'Frontend'
expect(response).to have_http_status(404)
end
end
context 'with unauthorized user' do
before do
allow(Ability).to receive(:allowed?).with(user, :read_project, project).and_return(true)
allow(Ability).to receive(:allowed?).with(user, :admin_board, project).and_return(false)
end
it 'returns a not found 404 response' do
update_board board: board, name: 'Backend'
expect(response.content_type).to eq 'application/json'
expect(response).to have_http_status(404)
end
end
def update_board(board:, name:)
patch :update, namespace_id: project.namespace.to_param,
project_id: project.to_param,
id: board.to_param,
board: { name: name },
format: :json
end
end
describe 'DELETE destroy' do
let!(:boards) { create_pair(:board, project: project) }
let(:board) { project.boards.first }
context 'with valid board id' do
it 'redirects to the issue boards page' do
remove_board board: board
expect(response).to redirect_to(namespace_project_boards_path(project.namespace, project))
end
it 'removes board from project' do
expect { remove_board board: board }.to change(project.boards, :size).by(-1)
end
end
context 'with invalid board id' do
it 'returns a not found 404 response' do
remove_board board: 999
expect(response).to have_http_status(404)
end
end
context 'with unauthorized user' do
before do
allow(Ability).to receive(:allowed?).with(user, :read_project, project).and_return(true)
allow(Ability).to receive(:allowed?).with(user, :admin_board, project).and_return(false)
end
it 'returns a not found 404 response' do
remove_board board: board
expect(response).to have_http_status(404)
end
end
def remove_board(board:)
delete :destroy, namespace_id: project.namespace.to_param,
project_id: project.to_param,
id: board.to_param,
format: :html
end
end
end end
FactoryGirl.define do FactoryGirl.define do
factory :board do factory :board do
sequence(:name) { |n| "board#{n}" }
project factory: :empty_project project factory: :empty_project
after(:create) do |board| after(:create) do |board|
......
...@@ -5,7 +5,7 @@ describe 'Issue Boards', feature: true, js: true do ...@@ -5,7 +5,7 @@ describe 'Issue Boards', feature: true, js: true do
include WaitForVueResource include WaitForVueResource
let(:project) { create(:empty_project, :public) } let(:project) { create(:empty_project, :public) }
let(:board) { create(:board, project: project) } let!(:board) { create(:board, project: project) }
let(:user) { create(:user) } let(:user) { create(:user) }
let!(:user2) { create(:user) } let!(:user2) { create(:user) }
...@@ -18,7 +18,7 @@ describe 'Issue Boards', feature: true, js: true do ...@@ -18,7 +18,7 @@ describe 'Issue Boards', feature: true, js: true do
context 'no lists' do context 'no lists' do
before do before do
visit namespace_project_board_path(project.namespace, project, board) visit namespace_project_boards_path(project.namespace, project)
wait_for_vue_resource wait_for_vue_resource
expect(page).to have_selector('.board', count: 3) expect(page).to have_selector('.board', count: 3)
end end
...@@ -76,7 +76,7 @@ describe 'Issue Boards', feature: true, js: true do ...@@ -76,7 +76,7 @@ describe 'Issue Boards', feature: true, js: true do
let!(:issue9) { create(:labeled_issue, project: project, labels: [testing, bug, accepting]) } let!(:issue9) { create(:labeled_issue, project: project, labels: [testing, bug, accepting]) }
before do before do
visit namespace_project_board_path(project.namespace, project, board) visit namespace_project_boards_path(project.namespace, project)
wait_for_vue_resource wait_for_vue_resource
...@@ -170,7 +170,7 @@ describe 'Issue Boards', feature: true, js: true do ...@@ -170,7 +170,7 @@ describe 'Issue Boards', feature: true, js: true do
create(:issue, project: project) create(:issue, project: project)
end end
visit namespace_project_board_path(project.namespace, project, board) visit namespace_project_boards_path(project.namespace, project)
wait_for_vue_resource wait_for_vue_resource
page.within(find('.board', match: :first)) do page.within(find('.board', match: :first)) do
...@@ -604,7 +604,7 @@ describe 'Issue Boards', feature: true, js: true do ...@@ -604,7 +604,7 @@ describe 'Issue Boards', feature: true, js: true do
context 'keyboard shortcuts' do context 'keyboard shortcuts' do
before do before do
visit namespace_project_board_path(project.namespace, project, board) visit namespace_project_boards_path(project.namespace, project)
wait_for_vue_resource wait_for_vue_resource
end end
...@@ -617,7 +617,7 @@ describe 'Issue Boards', feature: true, js: true do ...@@ -617,7 +617,7 @@ describe 'Issue Boards', feature: true, js: true do
context 'signed out user' do context 'signed out user' do
before do before do
logout logout
visit namespace_project_board_path(project.namespace, project, board) visit namespace_project_boards_path(project.namespace, project)
wait_for_vue_resource wait_for_vue_resource
end end
...@@ -633,7 +633,7 @@ describe 'Issue Boards', feature: true, js: true do ...@@ -633,7 +633,7 @@ describe 'Issue Boards', feature: true, js: true do
project.team << [user_guest, :guest] project.team << [user_guest, :guest]
logout logout
login_as(user_guest) login_as(user_guest)
visit namespace_project_board_path(project.namespace, project, board) visit namespace_project_boards_path(project.namespace, project)
wait_for_vue_resource wait_for_vue_resource
end end
......
require 'rails_helper'
describe 'Multiple Issue Boards', feature: true, js: true do
include WaitForAjax
include WaitForVueResource
let(:user) { create(:user) }
let(:project) { create(:empty_project, :public) }
let!(:planning) { create(:label, project: project, name: 'Planning') }
let!(:board) { create(:board, project: project) }
let!(:board2) { create(:board, project: project) }
before do
project.team << [user, :master]
login_as(user)
visit namespace_project_boards_path(project.namespace, project)
wait_for_vue_resource
end
it 'shows current board name' do
page.within('.boards-switcher') do
expect(page).to have_content(board.name)
end
end
it 'shows a list of boards' do
click_button board.name
page.within('.boards-title-holder .dropdown-menu') do
expect(page).to have_content(board.name)
expect(page).to have_content(board2.name)
end
end
it 'switches current board' do
click_button board.name
page.within('.boards-title-holder .dropdown-menu') do
click_link board2.name
end
wait_for_vue_resource
page.within('.boards-switcher') do
expect(page).to have_content(board2.name)
end
end
it 'creates new board' do
click_button board.name
page.within('.boards-title-holder .dropdown-menu') do
click_link 'Edit board name'
fill_in 'board-new-name', with: 'Testing'
click_button 'Save'
end
wait_for_vue_resource
page.within('.boards-title-holder .dropdown-menu') do
expect(page).to have_content('Testing')
end
end
it 'edits board name' do
click_button board.name
page.within('.boards-title-holder .dropdown-menu') do
click_link 'Edit board name'
fill_in 'board-new-name', with: 'Testing'
click_button 'Save'
end
wait_for_vue_resource
page.within('.boards-title-holder .dropdown-menu') do
expect(page).to have_content('Testing')
end
end
it 'deletes board' do
click_button board.name
wait_for_vue_resource
page.within('.boards-title-holder .dropdown-menu') do
click_link 'Delete board'
page.within('.dropdown-title') do
expect(page).to have_content('Delete board')
end
click_link 'Delete'
end
click_button board2.name
page.within('.boards-title-holder .dropdown-menu') do
expect(page).not_to have_content(board.name)
expect(page).to have_content(board2.name)
end
end
it 'adds a list to the none default board' do
click_button board.name
page.within('.boards-title-holder .dropdown-menu') do
click_link board2.name
end
wait_for_vue_resource
page.within('.boards-switcher') do
expect(page).to have_content(board2.name)
end
click_button 'Create new list'
wait_for_ajax
page.within '.dropdown-menu-issues-board-new' do
click_link planning.title
end
wait_for_vue_resource
expect(page).to have_selector('.board', count: 3)
click_button board2.name
page.within('.boards-title-holder .dropdown-menu') do
click_link board.name
end
wait_for_vue_resource
expect(page).to have_selector('.board', count: 2)
end
end
{ {
"type": "object", "type": "object",
"required" : [ "required" : [
"id" "id",
"name"
], ],
"properties" : { "properties" : {
"id": { "type": "integer" }, "id": { "type": "integer" },
......
...@@ -7,6 +7,7 @@ describe Board do ...@@ -7,6 +7,7 @@ describe Board do
end end
describe 'validations' do describe 'validations' do
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
end end
...@@ -97,15 +97,6 @@ describe Project, models: true do ...@@ -97,15 +97,6 @@ describe Project, models: true do
end end
end end
end end
describe '#boards' do
it 'raises an error when attempting to add more than one board to the project' do
subject.boards.build
expect { subject.boards.build }.to raise_error(Project::BoardLimitExceeded, 'Number of permitted boards exceeded')
expect(subject.boards.size).to eq 1
end
end
end end
describe 'modules' do describe 'modules' do
......
...@@ -51,7 +51,7 @@ describe API::API, api: true do ...@@ -51,7 +51,7 @@ describe API::API, api: true do
end end
context "when authenticated" do context "when authenticated" do
it "returns the project issue board" do it "returns the project issue boards" do
get api(base_url, user) get api(base_url, user)
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
......
...@@ -4,14 +4,14 @@ describe Boards::CreateService, services: true do ...@@ -4,14 +4,14 @@ describe Boards::CreateService, services: true do
describe '#execute' do describe '#execute' do
let(:project) { create(:empty_project) } let(:project) { create(:empty_project) }
subject(:service) { described_class.new(project, double) } context 'with valid params' do
subject(:service) { described_class.new(project, double, name: 'Backend') }
context 'when project does not have a board' do it 'creates a new project board' do
it 'creates a new board' do expect { service.execute }.to change(project.boards, :count).by(1)
expect { service.execute }.to change(Board, :count).by(1)
end end
it 'creates default lists' do it "creates board's default lists" do
board = service.execute board = service.execute
expect(board.lists.size).to eq 2 expect(board.lists.size).to eq 2
...@@ -20,14 +20,34 @@ describe Boards::CreateService, services: true do ...@@ -20,14 +20,34 @@ describe Boards::CreateService, services: true do
end end
end end
context 'when project has a board' do context 'with invalid params' do
before do subject(:service) { described_class.new(project, double, name: nil) }
create(:board, project: project)
end
it 'does not create a new board' do it 'does not create a new project board' do
expect { service.execute }.not_to change(project.boards, :count) expect { service.execute }.not_to change(project.boards, :count)
end end
it "does not create board's default lists" do
board = service.execute
expect(board.lists.size).to eq 0
end
end
context 'without params' do
subject(:service) { described_class.new(project, double) }
it 'creates a new project board' do
expect { service.execute }.to change(project.boards, :count).by(1)
end
it "creates board's default lists" do
board = service.execute
expect(board.lists.size).to eq 2
expect(board.lists.first).to be_backlog
expect(board.lists.last).to be_done
end
end end
end end
end end
require 'spec_helper'
describe Boards::DestroyService, services: true do
describe '#execute' do
let(:project) { create(:empty_project) }
let!(:board) { create(:board, project: project) }
subject(:service) { described_class.new(project, double) }
context 'when project have more than one board' do
it 'removes board from project' do
create(:board, project: project)
expect { service.execute(board) }.to change(project.boards, :count).by(-1)
end
end
context 'when project have one board' do
it 'does not remove board from project' do
expect { service.execute(board) }.not_to change(project.boards, :count)
end
end
end
end
...@@ -13,10 +13,10 @@ describe Boards::Issues::ListService, services: true do ...@@ -13,10 +13,10 @@ describe Boards::Issues::ListService, services: true do
let(:p2) { create(:label, title: 'P2', project: project, priority: 2) } let(:p2) { create(:label, title: 'P2', project: project, priority: 2) }
let(:p3) { create(:label, title: 'P3', project: project, priority: 3) } let(:p3) { create(:label, title: 'P3', project: project, priority: 3) }
let!(:backlog) { create(:backlog_list, board: board) } let!(:backlog) { board.backlog_list }
let!(:list1) { create(:list, board: board, label: development, position: 0) } let!(:list1) { create(:list, board: board, label: development, position: 0) }
let!(:list2) { create(:list, board: board, label: testing, position: 1) } let!(:list2) { create(:list, board: board, label: testing, position: 1) }
let!(:done) { create(:done_list, board: board) } let!(:done) { board.done_list }
let!(:opened_issue1) { create(:labeled_issue, project: project, labels: [bug]) } let!(:opened_issue1) { create(:labeled_issue, project: project, labels: [bug]) }
let!(:opened_issue2) { create(:labeled_issue, project: project, labels: [p2]) } let!(:opened_issue2) { create(:labeled_issue, project: project, labels: [p2]) }
......
...@@ -10,10 +10,10 @@ describe Boards::Issues::MoveService, services: true do ...@@ -10,10 +10,10 @@ describe Boards::Issues::MoveService, services: true do
let(:development) { create(:label, project: project, name: 'Development') } let(:development) { create(:label, project: project, name: 'Development') }
let(:testing) { create(:label, project: project, name: 'Testing') } let(:testing) { create(:label, project: project, name: 'Testing') }
let!(:backlog) { create(:backlog_list, board: board1) } let!(:backlog) { board1.backlog_list }
let!(:list1) { create(:list, board: board1, label: development, position: 0) } let!(:list1) { create(:list, board: board1, label: development, position: 0) }
let!(:list2) { create(:list, board: board1, label: testing, position: 1) } let!(:list2) { create(:list, board: board1, label: testing, position: 1) }
let!(:done) { create(:done_list, board: board1) } let!(:done) { board1.done_list }
before do before do
project.team << [user, :developer] project.team << [user, :developer]
......
...@@ -29,9 +29,10 @@ describe Boards::ListService, services: true do ...@@ -29,9 +29,10 @@ describe Boards::ListService, services: true do
end end
it 'returns project boards' do it 'returns project boards' do
board = create(:board, project: project) board1 = create(:board, project: project)
board2 = create(:board, project: project)
expect(service.execute).to match_array [board] expect(service.execute).to match_array [board1, board2]
end end
end end
end end
...@@ -6,12 +6,12 @@ describe Boards::Lists::MoveService, services: true do ...@@ -6,12 +6,12 @@ describe Boards::Lists::MoveService, services: true do
let(:board) { create(:board, project: project) } let(:board) { create(:board, project: project) }
let(:user) { create(:user) } let(:user) { create(:user) }
let!(:backlog) { create(:backlog_list, board: board) } let!(:backlog) { board.backlog_list }
let!(:planning) { create(:list, board: board, position: 0) } let!(:planning) { create(:list, board: board, position: 0) }
let!(:development) { create(:list, board: board, position: 1) } let!(:development) { create(:list, board: board, position: 1) }
let!(:review) { create(:list, board: board, position: 2) } let!(:review) { create(:list, board: board, position: 2) }
let!(:staging) { create(:list, board: board, position: 3) } let!(:staging) { create(:list, board: board, position: 3) }
let!(:done) { create(:done_list, board: board) } let!(:done) { board.done_list }
context 'when list type is set to label' do context 'when list type is set to label' do
it 'keeps position of lists when new position is nil' do it 'keeps position of lists when new position is nil' do
......
require 'spec_helper'
describe Boards::UpdateService, services: true do
describe '#execute' do
let(:project) { create(:empty_project) }
let!(:board) { create(:board, project: project, name: 'Backend') }
it "updates board's name" do
service = described_class.new(project, double, name: 'Engineering')
service.execute(board)
expect(board).to have_attributes(name: 'Engineering')
end
it 'returns true with valid params' do
service = described_class.new(project, double, name: 'Engineering')
expect(service.execute(board)).to eq true
end
it 'returns false with invalid params' do
service = described_class.new(project, double, name: nil)
expect(service.execute(board)).to eq false
end
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