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 {
}
}
}
.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 = {
environments: './environments/environments_bundle.js',
environments_folder: './environments/folder/environments_folder_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',
graphs: './graphs/graphs_bundle.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
include IssuableLinks
before_action :check_epics_available!
skip_before_action :authorize_destroy_issuable!
skip_before_action :authorize_create_epic!
before_action :authorize_admin_epic!, only: [:create, :destroy]
before_action :authorize_issue_link_association!, only: :destroy
......
......@@ -3,9 +3,10 @@ class Groups::EpicsController < Groups::ApplicationController
include IssuableCollections
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 :authorize_update_issuable!, only: :update
before_action :authorize_create_epic!, only: [:create]
skip_before_action :labels
......@@ -23,6 +24,18 @@ class Groups::EpicsController < Groups::ApplicationController
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
def epic
......@@ -73,4 +86,8 @@ class Groups::EpicsController < Groups::ApplicationController
def set_default_state
params[:state] = 'all'
end
def authorize_create_epic!
return render_404 unless can?(current_user, :create_epic, group)
end
end
......@@ -27,6 +27,10 @@ module EE
false
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)
related_issues = ::Issue.select('issues.*, epic_issues.id as epic_issue_id')
.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
= 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
= render partial: 'groups/epics/epic', collection: @epics
......
......@@ -8,5 +8,8 @@
= _('Epics let you manage your portfolio of projects more efficiently and with less effort')
%p
= _('Track groups of issues that share a theme, across projects and milestones')
%button.btn.btn-new{ type: 'button' }
New epic
- 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 } }
......@@ -8,143 +8,139 @@ describe Groups::EpicIssuesController do
let(:user) { create(:user) }
before do
stub_licensed_features(epics: true)
sign_in(user)
end
context 'when epics feature is enabled' do
describe 'GET #index' do
let!(:epic_issues) { create(:epic_issue, epic: epic, issue: issue) }
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
describe 'GET #index' do
let!(:epic_issues) { create(:epic_issue, epic: epic, issue: issue) }
it 'returns the correct json' do
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
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)
it 'returns correct response for the correct issue reference' do
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
it 'returns the correct json' do
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)
it 'creates a new EpicIssue record' do
expect { subject }.to change { EpicIssue.count }.from(0).to(1)
end
end
describe 'POST #create' do
subject do
reference = [issue.to_reference(full: true)]
context 'when user does not have permissions to create requested association' do
it 'returns correct response for the correct issue reference' do
subject
post :create, group_id: group, epic_id: epic.to_param, issue_references: reference
expect(response).to have_gitlab_http_status(403)
end
context 'when user has permissions to create requested association' do
before do
group.add_developer(user)
end
it 'does not create a new EpicIssue record' do
expect { subject }.not_to change { EpicIssue.count }.from(0)
end
end
end
it 'returns correct response for the correct issue reference' do
subject
list_service_response = EpicIssues::ListService.new(epic, user).execute
describe 'DELETE #destroy' do
let!(:epic_issue) { create(:epic_issue, epic: epic, issue: issue) }
expect(response).to have_gitlab_http_status(200)
expect(json_response).to eq('message' => nil, 'issues' => list_service_response.as_json)
end
subject do
delete :destroy, group_id: group, epic_id: epic.to_param, id: epic_issue.id
end
it 'creates a new EpicIssue record' do
expect { subject }.to change { EpicIssue.count }.from(0).to(1)
end
context 'when user has permissions to detele the link' do
before do
group.add_developer(user)
end
context 'when user does not have permissions to create requested association' do
it 'returns correct response for the correct issue reference' do
subject
it 'returns status 200' do
subject
expect(response).to have_gitlab_http_status(403)
end
expect(response.status).to eq(200)
end
it 'does not create a new EpicIssue record' do
expect { subject }.not_to change { EpicIssue.count }.from(0)
end
it 'destroys the link' do
expect { subject }.to change { EpicIssue.count }.from(1).to(0)
end
end
describe 'DELETE #destroy' do
let!(:epic_issue) { create(:epic_issue, epic: epic, issue: issue) }
context 'when user does not have permissions to delete the link' do
it 'returns status 404' do
subject
subject do
delete :destroy, group_id: group, epic_id: epic.to_param, id: epic_issue.id
expect(response.status).to eq(403)
end
context 'when user has permissions to detele the link' do
before do
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
it 'does not destroy the link' do
expect { subject }.not_to change { EpicIssue.count }.from(1)
end
end
context 'when user does not have permissions to delete the link' do
it 'returns status 404' do
subject
expect(response.status).to eq(403)
end
it 'does not destroy the link' do
expect { subject }.not_to change { EpicIssue.count }.from(1)
end
context 'when the epic from the association does not equal epic from the path' do
subject do
delete :destroy, group_id: group, epic_id: another_epic.to_param, id: epic_issue.id
end
context 'when the epic from the association does not equal epic from the path' do
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) }
let(:another_epic) { create(:epic, group: group) }
before do
group.add_developer(user)
end
before do
group.add_developer(user)
end
it 'returns status 404' do
subject
it 'returns status 404' do
subject
expect(response.status).to eq(404)
end
expect(response.status).to eq(404)
end
it 'does not destroy the link' do
expect { subject }.not_to change { EpicIssue.count }.from(1)
end
it 'does not destroy the link' do
expect { subject }.not_to change { EpicIssue.count }.from(1)
end
end
context 'when the epic_issue record does not exists' do
it 'returns status 404' do
delete :destroy, group_id: group, epic_id: epic.to_param, id: 9999
context 'when the epic_issue record does not exists' do
it 'returns status 404' do
delete :destroy, group_id: group, epic_id: epic.to_param, id: 9999
expect(response.status).to eq(403)
end
expect(response.status).to eq(403)
end
end
end
......
......@@ -139,6 +139,7 @@ describe Groups::EpicsController do
describe 'GET #realtime_changes' do
subject { get :realtime_changes, group_id: group, id: epic.to_param }
it 'returns epic' do
group.add_developer(user)
subject
......@@ -156,6 +157,59 @@ describe Groups::EpicsController do
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
before do
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