summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG1
-rw-r--r--app/assets/javascripts/boards/components/board.js.es68
-rw-r--r--app/assets/javascripts/boards/components/board_list.js.es69
-rw-r--r--app/assets/javascripts/boards/components/board_new_issue.js.es658
-rw-r--r--app/assets/javascripts/boards/mixins/sortable_default_options.js.es62
-rw-r--r--app/assets/javascripts/boards/models/list.js.es611
-rw-r--r--app/assets/javascripts/boards/services/board_service.js.es66
-rw-r--r--app/assets/stylesheets/pages/boards.scss32
-rw-r--r--app/controllers/projects/boards/issues_controller.rb37
-rw-r--r--app/services/boards/issues/create_service.rb16
-rw-r--r--app/views/projects/boards/components/_board.html.haml41
-rw-r--r--app/views/projects/boards/components/_card.html.haml4
-rw-r--r--config/routes/project.rb2
-rw-r--r--spec/controllers/projects/boards/issues_controller_spec.rb64
-rw-r--r--spec/features/boards/new_issue_spec.rb80
-rw-r--r--spec/services/boards/issues/create_service_spec.rb33
16 files changed, 380 insertions, 24 deletions
diff --git a/CHANGELOG b/CHANGELOG
index 59c1d066d90..3bbf580b67e 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -23,6 +23,7 @@ v 8.13.0 (unreleased)
- Fix issue with page scrolling to top when closing or pinning sidebar (lukehowell)
- Add Issue Board API support (andrebsguedes)
- Allow the Koding integration to be configured through the API
+ - Add new issue button to each list on Issues Board
- Added soft wrap button to repository file/blob editor
- Add word-wrap to issue title on issue and milestone boards (ClemMakesApps)
- Fix todos page mobile viewport layout (ClemMakesApps)
diff --git a/app/assets/javascripts/boards/components/board.js.es6 b/app/assets/javascripts/boards/components/board.js.es6
index 7e86f001f44..cacb36a897f 100644
--- a/app/assets/javascripts/boards/components/board.js.es6
+++ b/app/assets/javascripts/boards/components/board.js.es6
@@ -21,7 +21,8 @@
},
data () {
return {
- filters: Store.state.filters
+ filters: Store.state.filters,
+ showIssueForm: false
};
},
watch: {
@@ -33,6 +34,11 @@
deep: true
}
},
+ methods: {
+ showNewIssueForm() {
+ this.showIssueForm = !this.showIssueForm;
+ }
+ },
ready () {
const options = gl.issueBoards.getBoardSortableDefaultOptions({
disabled: this.disabled,
diff --git a/app/assets/javascripts/boards/components/board_list.js.es6 b/app/assets/javascripts/boards/components/board_list.js.es6
index 474805c1437..7022a29e818 100644
--- a/app/assets/javascripts/boards/components/board_list.js.es6
+++ b/app/assets/javascripts/boards/components/board_list.js.es6
@@ -1,4 +1,5 @@
//= require ./board_card
+//= require ./board_new_issue
(() => {
const Store = gl.issueBoards.BoardsStore;
@@ -8,14 +9,16 @@
gl.issueBoards.BoardList = Vue.extend({
components: {
- 'board-card': gl.issueBoards.BoardCard
+ 'board-card': gl.issueBoards.BoardCard,
+ 'board-new-issue': gl.issueBoards.BoardNewIssue
},
props: {
disabled: Boolean,
list: Object,
issues: Array,
loading: Boolean,
- issueLinkBase: String
+ issueLinkBase: String,
+ showIssueForm: Boolean
},
data () {
return {
@@ -73,7 +76,7 @@
group: 'issues',
sort: false,
disabled: this.disabled,
- filter: '.board-list-count',
+ filter: '.board-list-count, .is-disabled',
onStart: (e) => {
const card = this.$refs.issue[e.oldIndex];
diff --git a/app/assets/javascripts/boards/components/board_new_issue.js.es6 b/app/assets/javascripts/boards/components/board_new_issue.js.es6
new file mode 100644
index 00000000000..a4fad422eca
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_new_issue.js.es6
@@ -0,0 +1,58 @@
+(() => {
+ window.gl = window.gl || {};
+
+ gl.issueBoards.BoardNewIssue = Vue.extend({
+ props: {
+ list: Object,
+ showIssueForm: Boolean
+ },
+ data() {
+ return {
+ title: '',
+ error: false
+ };
+ },
+ watch: {
+ showIssueForm () {
+ this.$els.input.focus();
+ }
+ },
+ methods: {
+ submit(e) {
+ e.preventDefault();
+ if (this.title.trim() === '') return;
+
+ this.error = false;
+
+ const labels = this.list.label ? [this.list.label] : [];
+ const issue = new ListIssue({
+ title: this.title,
+ labels
+ });
+
+ this.list.newIssue(issue)
+ .then((data) => {
+ // Need this because our jQuery very kindly disables buttons on ALL form submissions
+ $(this.$els.submitButton).enable();
+ })
+ .catch(() => {
+ // Need this because our jQuery very kindly disables buttons on ALL form submissions
+ $(this.$els.submitButton).enable();
+
+ // Remove the issue
+ this.list.removeIssue(issue);
+
+ // Show error message
+ this.error = true;
+ this.showIssueForm = true;
+ });
+
+ this.cancel();
+ },
+ cancel() {
+ this.showIssueForm = false;
+ this.title = '';
+ }
+ }
+ });
+})();
diff --git a/app/assets/javascripts/boards/mixins/sortable_default_options.js.es6 b/app/assets/javascripts/boards/mixins/sortable_default_options.js.es6
index 44addb3ea98..f629d45c587 100644
--- a/app/assets/javascripts/boards/mixins/sortable_default_options.js.es6
+++ b/app/assets/javascripts/boards/mixins/sortable_default_options.js.es6
@@ -21,7 +21,7 @@
fallbackClass: 'is-dragging',
fallbackOnBody: true,
ghostClass: 'is-ghost',
- filter: '.has-tooltip',
+ filter: '.has-tooltip, .btn',
delay: gl.issueBoards.touchEnabled ? 100 : 0,
scrollSensitivity: gl.issueBoards.touchEnabled ? 60 : 100,
scrollSpeed: 20,
diff --git a/app/assets/javascripts/boards/models/list.js.es6 b/app/assets/javascripts/boards/models/list.js.es6
index 91fd620fdb3..5d0a561cdba 100644
--- a/app/assets/javascripts/boards/models/list.js.es6
+++ b/app/assets/javascripts/boards/models/list.js.es6
@@ -87,6 +87,17 @@ class List {
});
}
+ newIssue (issue) {
+ this.addIssue(issue);
+ this.issuesSize++;
+
+ return gl.boardService.newIssue(this.id, issue)
+ .then((resp) => {
+ const data = resp.json();
+ issue.id = data.iid;
+ });
+ }
+
createIssues (data) {
data.forEach((issueObj) => {
this.addIssue(new ListIssue(issueObj));
diff --git a/app/assets/javascripts/boards/services/board_service.js.es6 b/app/assets/javascripts/boards/services/board_service.js.es6
index 9b80fb2e99f..2b825c3949f 100644
--- a/app/assets/javascripts/boards/services/board_service.js.es6
+++ b/app/assets/javascripts/boards/services/board_service.js.es6
@@ -58,4 +58,10 @@ class BoardService {
to_list_id
});
}
+
+ newIssue (id, issue) {
+ return this.issues.save({ id }, {
+ issue
+ });
+ }
};
diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss
index ecc5b24e360..6e81c12aa55 100644
--- a/app/assets/stylesheets/pages/boards.scss
+++ b/app/assets/stylesheets/pages/boards.scss
@@ -162,6 +162,10 @@ lex
list-style: none;
overflow-y: scroll;
overflow-x: hidden;
+
+ &.is-smaller {
+ height: calc(100% - 185px);
+ }
}
.board-list-loading {
@@ -233,3 +237,31 @@ lex
margin-right: 5px;
}
}
+
+.board-new-issue-form {
+ margin: 5px;
+}
+
+.board-issue-count-holder {
+ margin-top: -3px;
+
+ .btn {
+ line-height: 12px;
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+ }
+}
+
+.board-issue-count {
+ padding-right: 10px;
+ padding-left: 10px;
+ line-height: 21px;
+ border-radius: $border-radius-base;
+ border: 1px solid $border-color;
+
+ &.has-btn {
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+ border-width: 1px 0 1px 1px;
+ }
+}
diff --git a/app/controllers/projects/boards/issues_controller.rb b/app/controllers/projects/boards/issues_controller.rb
index 4aa7982eab4..095af6c35eb 100644
--- a/app/controllers/projects/boards/issues_controller.rb
+++ b/app/controllers/projects/boards/issues_controller.rb
@@ -2,6 +2,7 @@ module Projects
module Boards
class IssuesController < Boards::ApplicationController
before_action :authorize_read_issue!, only: [:index]
+ before_action :authorize_create_issue!, only: [:create]
before_action :authorize_update_issue!, only: [:update]
def index
@@ -9,16 +10,23 @@ module Projects
issues = issues.page(params[:page])
render json: {
- issues: issues.as_json(
- only: [:iid, :title, :confidential],
- include: {
- assignee: { only: [:id, :name, :username], methods: [:avatar_url] },
- labels: { only: [:id, :title, :description, :color, :priority], methods: [:text_color] }
- }),
+ issues: serialize_as_json(issues),
size: issues.total_count
}
end
+ def create
+ list = project.board.lists.find(params[:list_id])
+ service = ::Boards::Issues::CreateService.new(project, current_user, issue_params)
+ issue = service.execute(list)
+
+ if issue.valid?
+ render json: serialize_as_json(issue)
+ else
+ render json: issue.errors, status: :unprocessable_entity
+ end
+ end
+
def update
service = ::Boards::Issues::MoveService.new(project, current_user, move_params)
@@ -43,6 +51,10 @@ module Projects
return render_403 unless can?(current_user, :read_issue, project)
end
+ def authorize_create_issue!
+ return render_403 unless can?(current_user, :admin_issue, project)
+ end
+
def authorize_update_issue!
return render_403 unless can?(current_user, :update_issue, issue)
end
@@ -54,6 +66,19 @@ module Projects
def move_params
params.permit(:id, :from_list_id, :to_list_id)
end
+
+ def issue_params
+ params.require(:issue).permit(:title).merge(request: request)
+ end
+
+ def serialize_as_json(resource)
+ resource.as_json(
+ only: [:iid, :title, :confidential],
+ include: {
+ assignee: { only: [:id, :name, :username], methods: [:avatar_url] },
+ labels: { only: [:id, :title, :description, :color, :priority], methods: [:text_color] }
+ })
+ end
end
end
end
diff --git a/app/services/boards/issues/create_service.rb b/app/services/boards/issues/create_service.rb
new file mode 100644
index 00000000000..3701afd441f
--- /dev/null
+++ b/app/services/boards/issues/create_service.rb
@@ -0,0 +1,16 @@
+module Boards
+ module Issues
+ class CreateService < Boards::BaseService
+ def execute(list)
+ params.merge!(label_ids: [list.label_id])
+ create_issue
+ end
+
+ private
+
+ def create_issue
+ ::Issues::CreateService.new(project, current_user, params).execute
+ end
+ end
+ end
+end
diff --git a/app/views/projects/boards/components/_board.html.haml b/app/views/projects/boards/components/_board.html.haml
index 73066150fb3..ba1502c97b6 100644
--- a/app/views/projects/boards/components/_board.html.haml
+++ b/app/views/projects/boards/components/_board.html.haml
@@ -12,8 +12,17 @@
%header.board-header{ ":class" => "{ 'has-border': list.label }", ":style" => "{ borderTopColor: (list.label ? list.label.color : null) }" }
%h3.board-title.js-board-handle{ ":class" => "{ 'user-can-drag': (!disabled && !list.preset) }" }
{{ list.title }}
- %span.pull-right{ "v-if" => "list.type !== 'blank'" }
- {{ list.issuesSize }}
+ .board-issue-count-holder.pull-right.clearfix{ "v-if" => "list.type !== 'blank'" }
+ %span.board-issue-count.pull-left{ ":class" => "{ 'has-btn': list.type !== 'done' }" }
+ {{ list.issuesSize }}
+ - if can?(current_user, :admin_issue, @project)
+ %button.btn.btn-small.btn-default.pull-right.has-tooltip{ type: "button",
+ "@click" => "showNewIssueForm",
+ "v-if" => "list.type !== 'done'",
+ "aria-label" => "Add an issue",
+ "title" => "Add an issue",
+ data: { placement: "top", container: "body" } }
+ = icon("plus")
- if can?(current_user, :admin_list, @project)
%board-delete{ "inline-template" => true,
":list" => "list",
@@ -26,12 +35,38 @@
":issues" => "list.issues",
":loading" => "list.loading",
":disabled" => "disabled",
+ ":show-issue-form.sync" => "showIssueForm",
":issue-link-base" => "issueLinkBase" }
.board-list-loading.text-center{ "v-if" => "loading" }
= icon("spinner spin")
+ - if can? current_user, :create_issue, @project
+ %board-new-issue{ "inline-template" => true,
+ ":list" => "list",
+ ":show-issue-form.sync" => "showIssueForm",
+ "v-show" => "list.type !== 'done' && showIssueForm" }
+ .card.board-new-issue-form
+ %form{ "@submit" => "submit($event)" }
+ .flash-container{ "v-if" => "error" }
+ .flash-alert
+ An error occured. Please try again.
+ %label.label-light{ ":for" => "list.id + '-title'" }
+ Title
+ %input.form-control{ type: "text",
+ "v-model" => "title",
+ "v-el:input" => true,
+ ":id" => "list.id + '-title'" }
+ .clearfix.prepend-top-10
+ %button.btn.btn-success.pull-left{ type: "submit",
+ ":disabled" => "title === ''",
+ "v-el:submit-button" => true }
+ Submit issue
+ %button.btn.btn-default.pull-right{ type: "button",
+ "@click" => "cancel" }
+ Cancel
%ul.board-list{ "v-el:list" => true,
"v-show" => "!loading",
- ":data-board" => "list.id" }
+ ":data-board" => "list.id",
+ ":class" => "{ 'is-smaller': showIssueForm }" }
= render "projects/boards/components/card"
%li.board-list-count.text-center{ "v-if" => "showCount" }
= icon("spinner spin", "v-show" => "list.loadingMore" )
diff --git a/app/views/projects/boards/components/_card.html.haml b/app/views/projects/boards/components/_card.html.haml
index e8b60b54d80..f15c87c8185 100644
--- a/app/views/projects/boards/components/_card.html.haml
+++ b/app/views/projects/boards/components/_card.html.haml
@@ -7,7 +7,7 @@
":issue-link-base" => "issueLinkBase",
":disabled" => "disabled",
"track-by" => "id" }
- %li.card{ ":class" => "{ 'user-can-drag': !disabled }",
+ %li.card{ ":class" => "{ 'user-can-drag': !disabled && issue.id, 'is-disabled': disabled || !issue.id }",
":index" => "index" }
%h4.card-title
= icon("eye-slash", class: "confidential-icon", "v-if" => "issue.confidential")
@@ -15,7 +15,7 @@
":title" => "issue.title" }
{{ issue.title }}
.card-footer
- %span.card-number
+ %span.card-number{ "v-if" => "issue.id" }
= precede '#' do
{{ issue.id }}
%button.label.color-label.has-tooltip{ "v-for" => "label in issue.labels",
diff --git a/config/routes/project.rb b/config/routes/project.rb
index 0c188478ed1..e8807ef06a7 100644
--- a/config/routes/project.rb
+++ b/config/routes/project.rb
@@ -425,7 +425,7 @@ resources :namespaces, path: '/', constraints: { id: /[a-zA-Z.0-9_\-]+/ }, only:
post :generate
end
- resources :issues, only: [:index]
+ resources :issues, only: [:index, :create]
end
end
end
diff --git a/spec/controllers/projects/boards/issues_controller_spec.rb b/spec/controllers/projects/boards/issues_controller_spec.rb
index 2896636db5a..566658b508d 100644
--- a/spec/controllers/projects/boards/issues_controller_spec.rb
+++ b/spec/controllers/projects/boards/issues_controller_spec.rb
@@ -3,6 +3,7 @@ require 'spec_helper'
describe Projects::Boards::IssuesController do
let(:project) { create(:project_with_board) }
let(:user) { create(:user) }
+ let(:guest) { create(:user) }
let(:planning) { create(:label, project: project, name: 'Planning') }
let(:development) { create(:label, project: project, name: 'Development') }
@@ -12,6 +13,7 @@ describe Projects::Boards::IssuesController do
before do
project.team << [user, :master]
+ project.team << [guest, :guest]
end
describe 'GET index' do
@@ -61,6 +63,60 @@ describe Projects::Boards::IssuesController do
end
end
+ describe 'POST create' do
+ context 'with valid params' do
+ it 'returns a successful 200 response' do
+ create_issue user: user, list: list1, title: 'New issue'
+
+ expect(response).to have_http_status(200)
+ end
+
+ it 'returns the created issue' do
+ create_issue user: user, list: list1, title: 'New issue'
+
+ expect(response).to match_response_schema('issue')
+ end
+ end
+
+ context 'with invalid params' do
+ context 'when title is nil' do
+ it 'returns an unprocessable entity 422 response' do
+ create_issue user: user, list: list1, title: nil
+
+ expect(response).to have_http_status(422)
+ end
+ end
+
+ context 'when list does not belongs to project board' do
+ it 'returns a not found 404 response' do
+ list = create(:list)
+
+ create_issue user: user, list: list, title: 'New issue'
+
+ expect(response).to have_http_status(404)
+ end
+ end
+ end
+
+ context 'with unauthorized user' do
+ it 'returns a forbidden 403 response' do
+ create_issue user: guest, list: list1, title: 'New issue'
+
+ expect(response).to have_http_status(403)
+ end
+ end
+
+ def create_issue(user:, list:, title:)
+ sign_in(user)
+
+ post :create, namespace_id: project.namespace.to_param,
+ project_id: project.to_param,
+ list_id: list.to_param,
+ issue: { title: title },
+ format: :json
+ end
+ end
+
describe 'PATCH update' do
let(:issue) { create(:labeled_issue, project: project, labels: [planning]) }
@@ -93,13 +149,7 @@ describe Projects::Boards::IssuesController do
end
context 'with unauthorized user' do
- let(:guest) { create(:user) }
-
- before do
- project.team << [guest, :guest]
- end
-
- it 'returns a successful 403 response' do
+ it 'returns a forbidden 403 response' do
move user: guest, issue: issue, from_list_id: list1.id, to_list_id: list2.id
expect(response).to have_http_status(403)
diff --git a/spec/features/boards/new_issue_spec.rb b/spec/features/boards/new_issue_spec.rb
new file mode 100644
index 00000000000..c046e6b8d79
--- /dev/null
+++ b/spec/features/boards/new_issue_spec.rb
@@ -0,0 +1,80 @@
+require 'rails_helper'
+
+describe 'Issue Boards new issue', feature: true, js: true do
+ include WaitForAjax
+ include WaitForVueResource
+
+ let(:project) { create(:project_with_board, :public) }
+ let(:user) { create(:user) }
+
+ context 'authorized user' do
+ before do
+ project.team << [user, :master]
+
+ login_as(user)
+
+ visit namespace_project_board_path(project.namespace, project)
+ wait_for_vue_resource
+
+ expect(page).to have_selector('.board', count: 3)
+ end
+
+ it 'displays new issue button' do
+ expect(page).to have_selector('.board-issue-count-holder .btn', count: 1)
+ end
+
+ it 'does not display new issue button in done list' do
+ page.within('.board:nth-child(3)') do
+ expect(page).not_to have_selector('.board-issue-count-holder .btn')
+ end
+ end
+
+ it 'shows form when clicking button' do
+ page.within(first('.board')) do
+ find('.board-issue-count-holder .btn').click
+
+ expect(page).to have_selector('.board-new-issue-form')
+ end
+ end
+
+ it 'hides form when clicking cancel' do
+ page.within(first('.board')) do
+ find('.board-issue-count-holder .btn').click
+
+ expect(page).to have_selector('.board-new-issue-form')
+
+ click_button 'Cancel'
+
+ expect(page).to have_selector('.board-new-issue-form', visible: false)
+ end
+ end
+
+ it 'creates 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
+
+ page.within(first('.board .board-issue-count')) do
+ expect(page).to have_content('1')
+ end
+ end
+ end
+
+ context 'unauthorized user' do
+ before do
+ visit namespace_project_board_path(project.namespace, project)
+ wait_for_vue_resource
+ end
+
+ it 'does not display new issue button' do
+ expect(page).to have_selector('.board-issue-count-holder .btn', count: 0)
+ end
+ end
+end
diff --git a/spec/services/boards/issues/create_service_spec.rb b/spec/services/boards/issues/create_service_spec.rb
new file mode 100644
index 00000000000..33e10e79f6d
--- /dev/null
+++ b/spec/services/boards/issues/create_service_spec.rb
@@ -0,0 +1,33 @@
+require 'spec_helper'
+
+describe Boards::Issues::CreateService, services: true do
+ describe '#execute' do
+ let(:project) { create(:project_with_board) }
+ let(:board) { project.board }
+ let(:user) { create(:user) }
+ let(:label) { create(:label, project: project, name: 'in-progress') }
+ let!(:list) { create(:list, board: board, label: label, position: 0) }
+
+ subject(:service) { described_class.new(project, user, title: 'New issue') }
+
+ before do
+ project.team << [user, :developer]
+ end
+
+ it 'delegates the create proceedings to Issues::CreateService' do
+ expect_any_instance_of(Issues::CreateService).to receive(:execute).once
+
+ service.execute(list)
+ end
+
+ it 'creates a new issue' do
+ expect { service.execute(list) }.to change(project.issues, :count).by(1)
+ end
+
+ it 'adds the label of the list to the issue' do
+ issue = service.execute(list)
+
+ expect(issue.labels).to eq [label]
+ end
+ end
+end