Commit 653ddceb authored by Phil Hughes's avatar Phil Hughes

Merge branch '7752-add-epic-create-support' into 'master'

Add support for launching Epic create UI from Epic app

Closes #7752

See merge request gitlab-org/gitlab-ee!9331
parents 1b639ccf f1ecdad1
<script>
import { mapState, mapActions } from 'vuex';
import { __ } from '~/locale';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
export default {
components: {
LoadingButton,
},
props: {
alignRight: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
...mapState(['newEpicTitle', 'epicCreateInProgress']),
buttonLabel() {
return this.epicCreateInProgress ? __('Creating epic') : __('Create epic');
},
isEpicCreateDisabled() {
return !this.newEpicTitle.length;
},
epicTitle: {
set(value) {
this.setEpicCreateTitle({
newEpicTitle: value,
});
},
get() {
return this.newEpicTitle;
},
},
},
methods: {
...mapActions(['setEpicCreateTitle', 'createEpic']),
focusInput() {
this.$nextTick(() => this.$refs.epicTitleInput.focus());
},
},
};
</script>
<template>
<div class="dropdown epic-create-dropdown">
<button
class="btn btn-success qa-new-epic-button"
type="button"
data-toggle="dropdown"
@click="focusInput"
>
{{ __('New epic') }}
</button>
<div :class="{ 'dropdown-menu-right': alignRight }" class="dropdown-menu">
<input
ref="epicTitleInput"
v-model="epicTitle"
:disabled="epicCreateInProgress"
:placeholder="__('Title')"
type="text"
class="form-control qa-epic-title"
@keyup.enter.exact="createEpic"
/>
<loading-button
:disabled="isEpicCreateDisabled"
:loading="epicCreateInProgress"
:label="buttonLabel"
container-class="btn btn-success btn-inverted prepend-top-10 qa-create-epic-button"
@click.stop="createEpic"
/>
</div>
</div>
</template>
......@@ -7,12 +7,36 @@ import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import createStore from './store';
import EpicApp from './components/epic_app.vue';
import EpicCreateApp from './components/epic_create.vue';
export default (epicCreate = false) => {
const el = document.getElementById(epicCreate ? 'epic-create-root' : 'epic-app-root');
const store = createStore();
if (epicCreate) {
return new Vue({
el,
store,
components: { EpicCreateApp },
created() {
this.setEpicMeta({
endpoint: el.dataset.endpoint,
});
},
methods: {
...mapActions(['setEpicMeta']),
},
render: createElement =>
createElement('epic-create-app', {
props: {
alignRight: el.dataset.alignRight,
},
}),
});
}
export default () => {
const el = document.getElementById('epic-app-root');
const epicMeta = convertObjectPropsToCamelCase(JSON.parse(el.dataset.meta), { deep: true });
const epicData = JSON.parse(el.dataset.initial);
const store = createStore();
// Collapse the sidebar on mobile screens by default
const bpBreakpoint = bp.getBreakpointSize();
......
......@@ -2,6 +2,7 @@ import flash from '~/flash';
import { __, s__, sprintf } from '~/locale';
import axios from '~/lib/utils/axios_utils';
import { visitUrl } from '~/lib/utils/url_utility';
import epicUtils from '../utils/epic_utils';
import { statusType, statusEvent, dateTypes } from '../constants';
......@@ -179,5 +180,29 @@ export const toggleEpicSubscription = ({ state, dispatch }) => {
});
};
/**
* Methods to handle Epic create from Epics index page
*/
export const setEpicCreateTitle = ({ commit }, data) => commit(types.SET_EPIC_CREATE_TITLE, data);
export const requestEpicCreate = ({ commit }) => commit(types.REQUEST_EPIC_CREATE);
export const requestEpicCreateSuccess = (_, webUrl) => visitUrl(webUrl);
export const requestEpicCreateFailure = ({ commit }) => {
commit(types.REQUEST_EPIC_CREATE_FAILURE);
flash(s__('Error creating epic'));
};
export const createEpic = ({ state, dispatch }) => {
dispatch('requestEpicCreate');
axios
.post(state.endpoint, {
title: state.newEpicTitle,
})
.then(({ data }) => {
dispatch('requestEpicCreateSuccess', data.web_url);
})
.catch(() => {
dispatch('requestEpicCreateFailure');
});
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
......@@ -21,3 +21,7 @@ export const REQUEST_EPIC_DATE_SAVE_FAILURE = 'REQUEST_EPIC_DATE_SAVE_FAILURE';
export const REQUEST_EPIC_SUBSCRIPTION_TOGGLE = 'REQUEST_EPIC_SUBSCRIPTION_TOGGLE';
export const REQUEST_EPIC_SUBSCRIPTION_TOGGLE_SUCCESS = 'REQUEST_EPIC_SUBSCRIPTION_TOGGLE_SUCCESS';
export const REQUEST_EPIC_SUBSCRIPTION_TOGGLE_FAILURE = 'REQUEST_EPIC_SUBSCRIPTION_TOGGLE_FAILURE';
export const SET_EPIC_CREATE_TITLE = 'SET_EPIC_CREATE_TITLE';
export const REQUEST_EPIC_CREATE = 'REQUEST_EPIC_CREATE';
export const REQUEST_EPIC_CREATE_FAILURE = 'REQUEST_EPIC_CREATE_FAILURE';
......@@ -84,4 +84,14 @@ export default {
[types.REQUEST_EPIC_SUBSCRIPTION_TOGGLE_FAILURE](state) {
state.epicSubscriptionToggleInProgress = false;
},
[types.SET_EPIC_CREATE_TITLE](state, { newEpicTitle }) {
state.newEpicTitle = newEpicTitle;
},
[types.REQUEST_EPIC_CREATE](state) {
state.epicCreateInProgress = true;
},
[types.REQUEST_EPIC_CREATE_FAILURE](state) {
state.epicCreateInProgress = false;
},
};
......@@ -58,6 +58,9 @@ export default () => ({
participants: [],
subscribed: false,
// Create Epic Props
newEpicTitle: '',
// UI status flags
epicStatusChangeInProgress: false,
epicDeleteInProgress: false,
......@@ -65,5 +68,6 @@ export default () => ({
epicStartDateSaveInProgress: false,
epicDueDateSaveInProgress: false,
epicSubscriptionToggleInProgress: false,
epicCreateInProgress: false,
sidebarCollapsed: false,
});
import Cookies from 'js-cookie';
import { parseBoolean } from '~/lib/utils/common_utils';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import FilteredSearchTokenKeysEpics from 'ee/filtered_search/filtered_search_token_keys_epics';
import initNewEpic from 'ee/epics/new_epic/new_epic_bundle';
import initEpicCreateApp from 'ee/epic/epic_bundle';
document.addEventListener('DOMContentLoaded', () => {
initFilteredSearch({
......@@ -10,5 +13,10 @@ document.addEventListener('DOMContentLoaded', () => {
filteredSearchTokenKeys: FilteredSearchTokenKeysEpics,
stateFiltersSelector: '.epics-state-filters',
});
initNewEpic();
if (parseBoolean(Cookies.get('load_new_epic_app'))) {
initEpicCreateApp(true);
} else {
initNewEpic();
}
});
.new-epic-dropdown {
.new-epic-dropdown,
.epic-create-dropdown {
.dropdown-menu {
padding-left: $gl-padding-top;
padding-right: $gl-padding-top;
......@@ -13,10 +14,13 @@
}
}
.empty-state .new-epic-dropdown {
display: inline-flex;
.empty-state {
.new-epic-dropdown,
.epic-create-dropdown {
display: inline-flex;
.btn-success {
margin: 0;
.btn-success {
margin: 0;
}
}
}
......@@ -4,7 +4,10 @@
= render 'shared/issuable/epic_nav', type: :epics
.nav-controls
- if can?(current_user, :create_epic, @group)
#new-epic-app{ data: { endpoint: request.url, 'align-right' => true } }
- if cookies[:load_new_epic_app] == 'true'
#epic-create-root{ data: { endpoint: request.url, 'align-right' => true } }
- else
#new-epic-app{ data: { endpoint: request.url, 'align-right' => true } }
= render 'shared/epic/search_bar', type: :epics
......
import Vue from 'vue';
import EpicCreate from 'ee/epic/components/epic_create.vue';
import createStore from 'ee/epic/store';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { mockEpicMeta } from '../mock_data';
describe('EpicCreateComponent', () => {
let vm;
let store;
beforeEach(done => {
const Component = Vue.extend(EpicCreate);
store = createStore();
store.dispatch('setEpicMeta', mockEpicMeta);
vm = mountComponentWithStore(Component, {
store,
});
setTimeout(done);
});
afterEach(() => {
vm.$destroy();
});
describe('computed', () => {
describe('buttonLabel', () => {
it('returns string `Create epic` when `epicCreateInProgress` is false', () => {
vm.$store.state.epicCreateInProgress = false;
expect(vm.buttonLabel).toBe('Create epic');
});
it('returns string `Creating epic` when `epicCreateInProgress` is true', () => {
vm.$store.state.epicCreateInProgress = true;
expect(vm.buttonLabel).toBe('Creating epic');
});
});
describe('isEpicCreateDisabled', () => {
it('returns `true` when `newEpicTitle` is an empty string', () => {
vm.$store.state.newEpicTitle = '';
expect(vm.isEpicCreateDisabled).toBe(true);
});
it('returns `false` when `newEpicTitle` is not empty', () => {
vm.$store.state.newEpicTitle = 'foobar';
expect(vm.isEpicCreateDisabled).toBe(false);
});
});
describe('epicTitle', () => {
describe('set', () => {
it('calls `setEpicCreateTitle` with param `value`', () => {
spyOn(vm, 'setEpicCreateTitle');
const newEpicTitle = 'foobar';
vm.epicTitle = newEpicTitle;
expect(vm.setEpicCreateTitle).toHaveBeenCalledWith(
jasmine.objectContaining({
newEpicTitle,
}),
);
});
});
describe('get', () => {
it('returns value of `newEpicTitle` from state', () => {
const newEpicTitle = 'foobar';
vm.$store.state.newEpicTitle = newEpicTitle;
expect(vm.epicTitle).toBe(newEpicTitle);
});
});
});
});
describe('template', () => {
it('renders component container element with classes `dropdown` & `epic-create-dropdown`', () => {
expect(vm.$el.classList.contains('dropdown')).toBe(true);
expect(vm.$el.classList.contains('epic-create-dropdown')).toBe(true);
});
it('renders new epic button element', () => {
const newEpicButtonEl = vm.$el.querySelector('button.btn-success');
expect(newEpicButtonEl).not.toBeNull();
expect(newEpicButtonEl.innerText.trim()).toBe('New epic');
});
it('renders new epic dropdown menu element', () => {
const dropdownMenuEl = vm.$el.querySelector('.dropdown-menu');
expect(dropdownMenuEl).not.toBeNull();
});
it('renders epic input textbox element', () => {
const inputEl = vm.$el.querySelector('.dropdown-menu input.form-control');
expect(inputEl).not.toBeNull();
expect(inputEl.placeholder).toBe('Title');
});
it('renders create epic button element', () => {
const createEpicButtonEl = vm.$el.querySelector('.dropdown-menu button.btn-success');
expect(createEpicButtonEl).not.toBeNull();
expect(createEpicButtonEl.innerText.trim()).toBe('Create epic');
});
});
});
......@@ -851,4 +851,120 @@ describe('Epic Store Actions', () => {
});
});
});
describe('setEpicCreateTitle', () => {
it('should set `state.newEpicTitle` value to the value of `newEpicTitle` param', done => {
const data = {
newEpicTitle: 'foobar',
};
testAction(
actions.setEpicCreateTitle,
data,
{ newEpicTitle: '' },
[{ type: 'SET_EPIC_CREATE_TITLE', payload: { ...data } }],
[],
done,
);
});
});
describe('requestEpicCreate', () => {
it('should set `state.epicCreateInProgress` flag to `true`', done => {
testAction(
actions.requestEpicCreate,
{},
{ epicCreateInProgress: false },
[{ type: 'REQUEST_EPIC_CREATE' }],
[],
done,
);
});
});
describe('requestEpicCreateFailure', () => {
beforeEach(() => {
setFixtures('<div class="flash-container"></div>');
});
it('should set `state.epicCreateInProgress` flag to `false`', done => {
testAction(
actions.requestEpicCreateFailure,
{},
{ epicCreateInProgress: true },
[{ type: 'REQUEST_EPIC_CREATE_FAILURE' }],
[],
done,
);
});
it('should show flash error with message "Error creating epic."', () => {
actions.requestEpicCreateFailure({
commit: () => {},
});
expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
'Error creating epic',
);
});
});
describe('createEpic', () => {
let mock;
const stateCreateEpic = {
newEpicTitle: 'foobar',
};
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
});
describe('success', () => {
it('dispatches requestEpicCreate when request is complete', done => {
mock.onPost(/(.*)/).replyOnce(200, {});
testAction(
actions.createEpic,
{ ...stateCreateEpic },
stateCreateEpic,
[],
[
{
type: 'requestEpicCreate',
},
{
type: 'requestEpicCreateSuccess',
},
],
done,
);
});
});
describe('failure', () => {
it('dispatches requestEpicCreate and requestEpicCreateFailure when request fails', done => {
mock.onPost(/(.*)/).replyOnce(500, {});
testAction(
actions.createEpic,
{ ...stateCreateEpic },
stateCreateEpic,
[],
[
{
type: 'requestEpicCreate',
},
{
type: 'requestEpicCreateFailure',
},
],
done,
);
});
});
});
});
......@@ -275,4 +275,42 @@ describe('Epic Store Mutations', () => {
expect(state.epicSubscriptionToggleInProgress).toBe(false);
});
});
describe('SET_EPIC_CREATE_TITLE', () => {
it('Should set `newEpicTitle` prop on state as with the value of provided `newEpicTitle` param', () => {
const state = {
newEpicTitle: '',
};
mutations[types.SET_EPIC_CREATE_TITLE](state, {
newEpicTitle: 'foobar',
});
expect(state.newEpicTitle).toBe('foobar');
});
});
describe('REQUEST_EPIC_CREATE', () => {
it('Should set `epicCreateInProgress` flag on state as `true`', () => {
const state = {
epicCreateInProgress: false,
};
mutations[types.REQUEST_EPIC_CREATE](state);
expect(state.epicCreateInProgress).toBe(true);
});
});
describe('REQUEST_EPIC_CREATE_FAILURE', () => {
it('Should set `epicCreateInProgress` flag on state as `false`', () => {
const state = {
epicCreateInProgress: true,
};
mutations[types.REQUEST_EPIC_CREATE_FAILURE](state);
expect(state.epicCreateInProgress).toBe(false);
});
});
});
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