Commit dc0cb770 authored by Jarka Kadlecova's avatar Jarka Kadlecova Committed by Jacob Schatz

Add create epic

parent a4308d4d
...@@ -1029,3 +1029,32 @@ header.header-content .dropdown-menu.projects-dropdown-menu { ...@@ -1029,3 +1029,32 @@ header.header-content .dropdown-menu.projects-dropdown-menu {
} }
} }
} }
.new-epic-dropdown {
.dropdown-menu {
padding-left: $gl-padding-top;
padding-right: $gl-padding-top;
}
.form-control {
width: 100%;
}
.btn-save {
display: flex;
margin-top: $gl-btn-padding;
}
}
.empty-state .new-epic-dropdown {
display: inline-flex;
.btn-save {
margin-left: 0;
margin-bottom: 0;
}
.btn-new {
margin: 0;
}
}
---
title: Add ability to create new epics
merge_request:
author:
type: added
...@@ -41,6 +41,7 @@ var config = { ...@@ -41,6 +41,7 @@ var config = {
environments: './environments/environments_bundle.js', environments: './environments/environments_bundle.js',
environments_folder: './environments/folder/environments_folder_bundle.js', environments_folder: './environments/folder/environments_folder_bundle.js',
epic_show: 'ee/epics/epic_show/epic_show_bundle.js', epic_show: 'ee/epics/epic_show/epic_show_bundle.js',
new_epic: 'ee/epics/new_epic/new_epic_bundle.js',
filtered_search: './filtered_search/filtered_search_bundle.js', filtered_search: './filtered_search/filtered_search_bundle.js',
graphs: './graphs/graphs_bundle.js', graphs: './graphs/graphs_bundle.js',
graphs_charts: './graphs/graphs_charts.js', graphs_charts: './graphs/graphs_charts.js',
......
<script>
import Flash from '~/flash';
import { visitUrl } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import loadingButton from '~/vue_shared/components/loading_button.vue';
import NewEpicService from '../services/new_epic_service';
export default {
name: 'newEpic',
props: {
endpoint: {
type: String,
required: true,
},
alignRight: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
service: new NewEpicService(this.endpoint),
creating: false,
title: '',
};
},
components: {
loadingButton,
},
computed: {
buttonLabel() {
return this.creating ? s__('Creating epic') : s__('Create epic');
},
isCreatingDisabled() {
return this.title.length === 0;
},
},
methods: {
createEpic() {
this.creating = true;
this.service.createEpic(this.title)
.then(res => res.json())
.then((data) => {
visitUrl(data.web_url);
})
.catch(() => {
this.creating = false;
Flash(s__('Error creating epic'));
});
},
focusInput() {
// Wait for dropdown to appear because of transition CSS
setTimeout(() => {
this.$refs.title.focus();
}, 25);
},
},
};
</script>
<template>
<div class="dropdown new-epic-dropdown">
<button
class="btn btn-new"
type="button"
data-toggle="dropdown"
@click="focusInput"
>
{{ s__('New epic') }}
</button>
<div
class="dropdown-menu"
:class="{ 'dropdown-menu-align-right' : alignRight }"
>
<input
ref="title"
type="text"
class="form-control"
:placeholder="s__('Title')"
v-model="title"
/>
<loading-button
container-class="btn btn-save btn-inverted"
:disabled="isCreatingDisabled"
:loading="creating"
:label="buttonLabel"
@click.stop="createEpic"
/>
</div>
</div>
</template>
import Vue from 'vue';
import NewEpicApp from './components/new_epic.vue';
document.addEventListener('DOMContentLoaded', () => {
const el = document.querySelector('#new-epic-app');
const props = el.dataset;
return new Vue({
el,
components: {
'new-epic-app': NewEpicApp,
},
render: createElement => createElement('new-epic-app', {
props,
}),
});
});
import Vue from 'vue';
import VueResource from 'vue-resource';
Vue.use(VueResource);
export default class NewEpicService {
constructor(endpoint) {
this.endpoint = endpoint;
this.resource = Vue.resource(this.endpoint, {});
}
createEpic(title) {
return this.resource.save({
title,
});
}
}
class Groups::EpicIssuesController < Groups::EpicsController class Groups::EpicIssuesController < Groups::EpicsController
include IssuableLinks include IssuableLinks
before_action :check_epics_available!
skip_before_action :authorize_destroy_issuable! skip_before_action :authorize_destroy_issuable!
skip_before_action :authorize_create_epic!
before_action :authorize_admin_epic!, only: [:create, :destroy] before_action :authorize_admin_epic!, only: [:create, :destroy]
before_action :authorize_issue_link_association!, only: :destroy before_action :authorize_issue_link_association!, only: :destroy
......
...@@ -3,9 +3,10 @@ class Groups::EpicsController < Groups::ApplicationController ...@@ -3,9 +3,10 @@ class Groups::EpicsController < Groups::ApplicationController
include IssuableCollections include IssuableCollections
before_action :check_epics_available! before_action :check_epics_available!
before_action :epic, except: :index before_action :epic, except: [:index, :create]
before_action :set_issuables_index, only: :index before_action :set_issuables_index, only: :index
before_action :authorize_update_issuable!, only: :update before_action :authorize_update_issuable!, only: :update
before_action :authorize_create_epic!, only: [:create]
skip_before_action :labels skip_before_action :labels
...@@ -23,6 +24,18 @@ class Groups::EpicsController < Groups::ApplicationController ...@@ -23,6 +24,18 @@ class Groups::EpicsController < Groups::ApplicationController
end end
end end
def create
@epic = Epics::CreateService.new(@group, current_user, epic_params).execute
if @epic.persisted?
render json: {
web_url: group_epic_path(@group, @epic)
}
else
head :unprocessable_entity
end
end
private private
def epic def epic
...@@ -73,4 +86,8 @@ class Groups::EpicsController < Groups::ApplicationController ...@@ -73,4 +86,8 @@ class Groups::EpicsController < Groups::ApplicationController
def set_default_state def set_default_state
params[:state] = 'all' params[:state] = 'all'
end end
def authorize_create_epic!
return render_404 unless can?(current_user, :create_epic, group)
end
end end
...@@ -27,6 +27,10 @@ module EE ...@@ -27,6 +27,10 @@ module EE
false false
end end
# we don't support project epics for epics yet, planned in the future #4019
def update_project_counter_caches
end
def issues(current_user) def issues(current_user)
related_issues = ::Issue.select('issues.*, epic_issues.id as epic_issue_id') related_issues = ::Issue.select('issues.*, epic_issues.id as epic_issue_id')
.joins(:epic_issue) .joins(:epic_issue)
......
module Epics
class CreateService < IssuableBaseService
attr_reader :group
def initialize(group, current_user, params)
@group, @current_user, @params = group, current_user, params
end
def execute
@epic = group.epics.new(whitelisted_epic_params)
create(@epic)
end
private
def whitelisted_epic_params
params.slice(:title, :description, :start_date, :end_date)
end
end
end
.top-area .top-area
= render 'shared/issuable/nav', type: :epics = render 'shared/issuable/nav', type: :epics
.nav-controls
- if can?(current_user, :create_epic, @group)
- content_for :page_specific_javascripts do
= webpack_bundle_tag 'common_vue'
= webpack_bundle_tag 'new_epic'
#new-epic-app{ data: { endpoint: request.url, 'align-right' => true } }
%ul.content-list.issuable-list %ul.content-list.issuable-list
= render partial: 'groups/epics/epic', collection: @epics = render partial: 'groups/epics/epic', collection: @epics
......
...@@ -8,5 +8,8 @@ ...@@ -8,5 +8,8 @@
= _('Epics let you manage your portfolio of projects more efficiently and with less effort') = _('Epics let you manage your portfolio of projects more efficiently and with less effort')
%p %p
= _('Track groups of issues that share a theme, across projects and milestones') = _('Track groups of issues that share a theme, across projects and milestones')
%button.btn.btn-new{ type: 'button' } - if can?(current_user, :create_epic, @group)
New epic - content_for :page_specific_javascripts do
= webpack_bundle_tag 'common_vue'
= webpack_bundle_tag 'new_epic'
#new-epic-app{ data: { endpoint: request.url } }
...@@ -8,143 +8,139 @@ describe Groups::EpicIssuesController do ...@@ -8,143 +8,139 @@ describe Groups::EpicIssuesController do
let(:user) { create(:user) } let(:user) { create(:user) }
before do before do
stub_licensed_features(epics: true)
sign_in(user) sign_in(user)
end end
context 'when epics feature is enabled' do describe 'GET #index' do
let!(:epic_issues) { create(:epic_issue, epic: epic, issue: issue) }
before do before do
stub_licensed_features(epics: true) group.add_developer(user)
get :index, group_id: group, epic_id: epic.to_param
end
it 'returns status 200' do
expect(response.status).to eq(200)
end end
describe 'GET #index' do it 'returns the correct json' do
let!(:epic_issues) { create(:epic_issue, epic: epic, issue: issue) } expected_result = [
{
'id' => issue.id,
'title' => issue.title,
'state' => issue.state,
'reference' => "#{project.full_path}##{issue.iid}",
'path' => "/#{project.full_path}/issues/#{issue.iid}",
'destroy_relation_path' => "/groups/#{group.full_path}/-/epics/#{epic.iid}/issues/#{epic_issues.id}"
}
]
expect(JSON.parse(response.body)).to eq(expected_result)
end
end
describe 'POST #create' do
subject do
reference = [issue.to_reference(full: true)]
post :create, group_id: group, epic_id: epic.to_param, issue_references: reference
end
context 'when user has permissions to create requested association' do
before do before do
group.add_developer(user) group.add_developer(user)
get :index, group_id: group, epic_id: epic.to_param
end end
it 'returns status 200' do it 'returns correct response for the correct issue reference' do
expect(response.status).to eq(200) subject
list_service_response = EpicIssues::ListService.new(epic, user).execute
expect(response).to have_gitlab_http_status(200)
expect(json_response).to eq('message' => nil, 'issues' => list_service_response.as_json)
end end
it 'returns the correct json' do it 'creates a new EpicIssue record' do
expected_result = [ expect { subject }.to change { EpicIssue.count }.from(0).to(1)
{
'id' => issue.id,
'title' => issue.title,
'state' => issue.state,
'reference' => "#{project.full_path}##{issue.iid}",
'path' => "/#{project.full_path}/issues/#{issue.iid}",
'destroy_relation_path' => "/groups/#{group.full_path}/-/epics/#{epic.iid}/issues/#{epic_issues.id}"
}
]
expect(JSON.parse(response.body)).to eq(expected_result)
end end
end end
describe 'POST #create' do context 'when user does not have permissions to create requested association' do
subject do it 'returns correct response for the correct issue reference' do
reference = [issue.to_reference(full: true)] subject
post :create, group_id: group, epic_id: epic.to_param, issue_references: reference expect(response).to have_gitlab_http_status(403)
end end
context 'when user has permissions to create requested association' do it 'does not create a new EpicIssue record' do
before do expect { subject }.not_to change { EpicIssue.count }.from(0)
group.add_developer(user) end
end end
end
it 'returns correct response for the correct issue reference' do describe 'DELETE #destroy' do
subject let!(:epic_issue) { create(:epic_issue, epic: epic, issue: issue) }
list_service_response = EpicIssues::ListService.new(epic, user).execute
expect(response).to have_gitlab_http_status(200) subject do
expect(json_response).to eq('message' => nil, 'issues' => list_service_response.as_json) delete :destroy, group_id: group, epic_id: epic.to_param, id: epic_issue.id
end end
it 'creates a new EpicIssue record' do context 'when user has permissions to detele the link' do
expect { subject }.to change { EpicIssue.count }.from(0).to(1) before do
end group.add_developer(user)
end end
context 'when user does not have permissions to create requested association' do it 'returns status 200' do
it 'returns correct response for the correct issue reference' do subject
subject
expect(response).to have_gitlab_http_status(403) expect(response.status).to eq(200)
end end
it 'does not create a new EpicIssue record' do it 'destroys the link' do
expect { subject }.not_to change { EpicIssue.count }.from(0) expect { subject }.to change { EpicIssue.count }.from(1).to(0)
end
end end
end end
describe 'DELETE #destroy' do context 'when user does not have permissions to delete the link' do
let!(:epic_issue) { create(:epic_issue, epic: epic, issue: issue) } it 'returns status 404' do
subject
subject do expect(response.status).to eq(403)
delete :destroy, group_id: group, epic_id: epic.to_param, id: epic_issue.id
end end
context 'when user has permissions to detele the link' do it 'does not destroy the link' do
before do expect { subject }.not_to change { EpicIssue.count }.from(1)
group.add_developer(user)
end
it 'returns status 200' do
subject
expect(response.status).to eq(200)
end
it 'destroys the link' do
expect { subject }.to change { EpicIssue.count }.from(1).to(0)
end
end end
end
context 'when user does not have permissions to delete the link' do context 'when the epic from the association does not equal epic from the path' do
it 'returns status 404' do subject do
subject delete :destroy, group_id: group, epic_id: another_epic.to_param, id: epic_issue.id
expect(response.status).to eq(403)
end
it 'does not destroy the link' do
expect { subject }.not_to change { EpicIssue.count }.from(1)
end
end end
context 'when the epic from the association does not equal epic from the path' do let(:another_epic) { create(:epic, group: group) }
subject do
delete :destroy, group_id: group, epic_id: another_epic.to_param, id: epic_issue.id
end
let(:another_epic) { create(:epic, group: group) } before do
group.add_developer(user)
before do end
group.add_developer(user)
end
it 'returns status 404' do it 'returns status 404' do
subject subject
expect(response.status).to eq(404) expect(response.status).to eq(404)
end end
it 'does not destroy the link' do it 'does not destroy the link' do
expect { subject }.not_to change { EpicIssue.count }.from(1) expect { subject }.not_to change { EpicIssue.count }.from(1)
end
end end
end
context 'when the epic_issue record does not exists' do context 'when the epic_issue record does not exists' do
it 'returns status 404' do it 'returns status 404' do
delete :destroy, group_id: group, epic_id: epic.to_param, id: 9999 delete :destroy, group_id: group, epic_id: epic.to_param, id: 9999
expect(response.status).to eq(403) expect(response.status).to eq(403)
end
end end
end end
end end
......
...@@ -139,6 +139,7 @@ describe Groups::EpicsController do ...@@ -139,6 +139,7 @@ describe Groups::EpicsController do
describe 'GET #realtime_changes' do describe 'GET #realtime_changes' do
subject { get :realtime_changes, group_id: group, id: epic.to_param } subject { get :realtime_changes, group_id: group, id: epic.to_param }
it 'returns epic' do it 'returns epic' do
group.add_developer(user) group.add_developer(user)
subject subject
...@@ -156,6 +157,59 @@ describe Groups::EpicsController do ...@@ -156,6 +157,59 @@ describe Groups::EpicsController do
end end
end end
describe '#create' do
subject do
post :create, group_id: group, epic: { title: 'new epic', description: 'some descripition' }
end
context 'when user has permissions to create an epic' do
before do
group.add_developer(user)
end
context 'when all required parameters are passed' do
it 'returns 200 response' do
subject
expect(response).to have_http_status(200)
end
it 'creates a new epic' do
expect { subject }.to change { Epic.count }.from(0).to(1)
end
it 'returns the correct json' do
subject
expect(JSON.parse(response.body)).to eq({ 'web_url' => group_epic_path(group, Epic.last) })
end
end
context 'when required parameter is missing' do
before do
post :create, group_id: group, epic: { description: 'some descripition' }
end
it 'returns 422 response' do
expect(response).to have_gitlab_http_status(422)
end
it 'does not create a new epic' do
expect(Epic.count).to eq(0)
end
end
end
context 'with unauthorized user' do
it 'returns a not found 404 response' do
group.add_guest(user)
subject
expect(response).to have_http_status(404)
end
end
end
describe "DELETE #destroy" do describe "DELETE #destroy" do
before do before do
sign_in(user) sign_in(user)
......
require 'spec_helper'
feature 'New Epic', :js do
let(:user) { create(:user) }
let(:group) { create(:group, :public) }
before do
stub_licensed_features(epics: true)
sign_in(user)
end
context 'empty epic list' do
context 'when user who is not a group member views the epic list' do
it 'does not show the create button' do
visit group_epics_path(group)
expect(page).not_to have_selector('.new-epic-dropdown .btn-new')
end
end
context 'when user with owner views the epic list' do
before do
group.add_owner(user)
visit group_epics_path(group)
end
it 'does show the create button' do
expect(page).to have_selector('.new-epic-dropdown .btn-new')
end
end
end
context 'has epics in list' do
let!(:epics) { create_list(:epic, 2, group: group) }
context 'when user who is not a group member views the epic list' do
before do
visit group_epics_path(group)
end
it 'does not show the create button' do
expect(page).not_to have_selector('.new-epic-dropdown .btn-new')
end
end
context 'when user with owner views the epic list' do
before do
group.add_owner(user)
visit group_epics_path(group)
end
it 'does show the create button' do
expect(page).to have_selector('.new-epic-dropdown .btn-new')
end
it 'can create epic' do
find('.new-epic-dropdown .btn-new').click
find('.new-epic-dropdown input').set('test epic title')
find('.new-epic-dropdown .btn-save').click
wait_for_requests
expect(find('.issuable-details h2.title')).to have_content('test epic title')
end
end
end
end
require 'spec_helper'
describe Epics::CreateService do
let(:group) { create(:group, :internal)}
let(:user) { create(:user) }
let(:params) { { title: 'new epic', description: 'epic description' } }
subject { described_class.new(group, user, params).execute }
describe '#execute' do
it 'creates one issue correctly' do
expect { subject }.to change { Epic.count }.from(0).to(1)
epic = Epic.last
expect(epic).to be_persisted
expect(epic.title).to eq('new epic')
expect(epic.description).to eq('epic description')
end
end
end
import Vue from 'vue';
import newEpic from 'ee/epics/new_epic/components/new_epic.vue';
import * as urlUtility from '~/lib/utils/url_utility';
import mountComponent from '../../../helpers/vue_mount_component_helper';
describe('newEpic', () => {
let vm;
const interceptor = (request, next) => {
next(request.respondWith(JSON.stringify({
web_url: gl.TEST_HOST,
}), {
status: 200,
}));
};
beforeEach(() => {
Vue.http.interceptors.push(interceptor);
const NewEpic = Vue.extend(newEpic);
vm = mountComponent(NewEpic, {
endpoint: gl.TEST_HOST,
});
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
});
describe('alignRight', () => {
it('should not add dropdown-menu-align-right by default', () => {
expect(vm.$el.querySelector('.dropdown-menu').classList.contains('dropdown-menu-align-right')).toEqual(false);
});
it('should add dropdown-menu-align-right when alignRight', (done) => {
vm.alignRight = true;
Vue.nextTick(() => {
expect(vm.$el.querySelector('.dropdown-menu').classList.contains('dropdown-menu-align-right')).toEqual(true);
done();
});
});
});
describe('creating epic', () => {
it('should call createEpic service', (done) => {
spyOn(urlUtility, 'visitUrl').and.callFake(() => {});
spyOn(vm.service, 'createEpic').and.callThrough();
vm.title = 'test';
Vue.nextTick(() => {
vm.$el.querySelector('.btn-save').click();
expect(vm.service.createEpic).toHaveBeenCalled();
done();
});
});
it('should redirect to epic url after epic creation', (done) => {
spyOn(urlUtility, 'visitUrl').and.callFake((url) => {
expect(url).toEqual(gl.TEST_HOST);
done();
});
vm.title = 'test';
Vue.nextTick(() => {
vm.$el.querySelector('.btn-save').click();
});
});
it('should toggle loading button while creating', (done) => {
spyOn(urlUtility, 'visitUrl').and.callFake(() => {});
vm.title = 'test';
Vue.nextTick(() => {
const btnSave = vm.$el.querySelector('.btn-save');
const loadingIcon = btnSave.querySelector('.js-loading-button-icon');
expect(loadingIcon).toBeNull();
btnSave.click();
expect(loadingIcon).toBeDefined();
done();
});
});
});
});
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