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 } }
...@@ -7,13 +7,10 @@ describe Groups::EpicIssuesController do ...@@ -7,13 +7,10 @@ describe Groups::EpicIssuesController do
let(:issue) { create(:issue, project: project) } let(:issue) { create(:issue, project: project) }
let(:user) { create(:user) } let(:user) { create(:user) }
before do
sign_in(user)
end
context 'when epics feature is enabled' do
before do before do
stub_licensed_features(epics: true) stub_licensed_features(epics: true)
sign_in(user)
end end
describe 'GET #index' do describe 'GET #index' do
...@@ -147,5 +144,4 @@ describe Groups::EpicIssuesController do ...@@ -147,5 +144,4 @@ describe Groups::EpicIssuesController do
end end
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