Merge branch 'issue-boards-new-issue' into 'master'

Issue boards new issue form

## What does this MR do?

Adds a new issue form into the issue boards lists.

## Screenshots (if relevant)

![Screen_Shot_2016-10-03_at_14.57.30](/uploads/17fe6cd37bd020a2ee1688e0b496c18f/Screen_Shot_2016-10-03_at_14.57.30.png)

![Screen_Shot_2016-10-03_at_14.57.32](/uploads/c3f12bcb9ff9a0e7ce5b0bb06dfb0dd7/Screen_Shot_2016-10-03_at_14.57.32.png)

## What are the relevant issue numbers?

Part of #21219

See merge request !6653
This commit is contained in:
Fatih Acet 2016-10-06 14:49:59 +00:00
commit 9a13f885a9
16 changed files with 380 additions and 24 deletions

View file

@ -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)

View file

@ -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,

View file

@ -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];

View file

@ -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 = '';
}
}
});
})();

View file

@ -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,

View file

@ -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));

View file

@ -58,4 +58,10 @@ class BoardService {
to_list_id
});
}
newIssue (id, issue) {
return this.issues.save({ id }, {
issue
});
}
};

View file

@ -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;
}
}

View file

@ -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

View file

@ -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

View file

@ -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" )

View file

@ -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",

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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