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