Commit f1ecdad1 authored by Kushal Pandya's avatar Kushal Pandya

Add Epic create support within Epic app

Adds support for launching Epic create UI through refactored Epic app.
Feature is currently behind same cookie flag
which is used for refactored Epic app.
parent 517a288b
<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'; ...@@ -7,12 +7,36 @@ import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import createStore from './store'; import createStore from './store';
import EpicApp from './components/epic_app.vue'; 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 epicMeta = convertObjectPropsToCamelCase(JSON.parse(el.dataset.meta), { deep: true });
const epicData = JSON.parse(el.dataset.initial); const epicData = JSON.parse(el.dataset.initial);
const store = createStore();
// Collapse the sidebar on mobile screens by default // Collapse the sidebar on mobile screens by default
const bpBreakpoint = bp.getBreakpointSize(); const bpBreakpoint = bp.getBreakpointSize();
......
...@@ -2,6 +2,7 @@ import flash from '~/flash'; ...@@ -2,6 +2,7 @@ import flash from '~/flash';
import { __, s__, sprintf } from '~/locale'; import { __, s__, sprintf } from '~/locale';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { visitUrl } from '~/lib/utils/url_utility';
import epicUtils from '../utils/epic_utils'; import epicUtils from '../utils/epic_utils';
import { statusType, statusEvent, dateTypes } from '../constants'; import { statusType, statusEvent, dateTypes } from '../constants';
...@@ -179,5 +180,29 @@ export const toggleEpicSubscription = ({ state, dispatch }) => { ...@@ -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 // prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {}; export default () => {};
...@@ -21,3 +21,7 @@ export const REQUEST_EPIC_DATE_SAVE_FAILURE = 'REQUEST_EPIC_DATE_SAVE_FAILURE'; ...@@ -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 = 'REQUEST_EPIC_SUBSCRIPTION_TOGGLE';
export const REQUEST_EPIC_SUBSCRIPTION_TOGGLE_SUCCESS = 'REQUEST_EPIC_SUBSCRIPTION_TOGGLE_SUCCESS'; 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 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 { ...@@ -84,4 +84,14 @@ export default {
[types.REQUEST_EPIC_SUBSCRIPTION_TOGGLE_FAILURE](state) { [types.REQUEST_EPIC_SUBSCRIPTION_TOGGLE_FAILURE](state) {
state.epicSubscriptionToggleInProgress = false; 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 () => ({ ...@@ -58,6 +58,9 @@ export default () => ({
participants: [], participants: [],
subscribed: false, subscribed: false,
// Create Epic Props
newEpicTitle: '',
// UI status flags // UI status flags
epicStatusChangeInProgress: false, epicStatusChangeInProgress: false,
epicDeleteInProgress: false, epicDeleteInProgress: false,
...@@ -65,5 +68,6 @@ export default () => ({ ...@@ -65,5 +68,6 @@ export default () => ({
epicStartDateSaveInProgress: false, epicStartDateSaveInProgress: false,
epicDueDateSaveInProgress: false, epicDueDateSaveInProgress: false,
epicSubscriptionToggleInProgress: false, epicSubscriptionToggleInProgress: false,
epicCreateInProgress: false,
sidebarCollapsed: false, sidebarCollapsed: false,
}); });
import Cookies from 'js-cookie';
import { parseBoolean } from '~/lib/utils/common_utils';
import initFilteredSearch from '~/pages/search/init_filtered_search'; import initFilteredSearch from '~/pages/search/init_filtered_search';
import FilteredSearchTokenKeysEpics from 'ee/filtered_search/filtered_search_token_keys_epics'; import FilteredSearchTokenKeysEpics from 'ee/filtered_search/filtered_search_token_keys_epics';
import initNewEpic from 'ee/epics/new_epic/new_epic_bundle'; import initNewEpic from 'ee/epics/new_epic/new_epic_bundle';
import initEpicCreateApp from 'ee/epic/epic_bundle';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
initFilteredSearch({ initFilteredSearch({
...@@ -10,5 +13,10 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -10,5 +13,10 @@ document.addEventListener('DOMContentLoaded', () => {
filteredSearchTokenKeys: FilteredSearchTokenKeysEpics, filteredSearchTokenKeys: FilteredSearchTokenKeysEpics,
stateFiltersSelector: '.epics-state-filters', stateFiltersSelector: '.epics-state-filters',
}); });
if (parseBoolean(Cookies.get('load_new_epic_app'))) {
initEpicCreateApp(true);
} else {
initNewEpic(); initNewEpic();
}
}); });
.new-epic-dropdown { .new-epic-dropdown,
.epic-create-dropdown {
.dropdown-menu { .dropdown-menu {
padding-left: $gl-padding-top; padding-left: $gl-padding-top;
padding-right: $gl-padding-top; padding-right: $gl-padding-top;
...@@ -13,10 +14,13 @@ ...@@ -13,10 +14,13 @@
} }
} }
.empty-state .new-epic-dropdown { .empty-state {
.new-epic-dropdown,
.epic-create-dropdown {
display: inline-flex; display: inline-flex;
.btn-success { .btn-success {
margin: 0; margin: 0;
} }
}
} }
...@@ -4,6 +4,9 @@ ...@@ -4,6 +4,9 @@
= render 'shared/issuable/epic_nav', type: :epics = render 'shared/issuable/epic_nav', type: :epics
.nav-controls .nav-controls
- if can?(current_user, :create_epic, @group) - if can?(current_user, :create_epic, @group)
- 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 } } #new-epic-app{ data: { endpoint: request.url, 'align-right' => true } }
= render 'shared/epic/search_bar', type: :epics = 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', () => { ...@@ -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', () => { ...@@ -275,4 +275,42 @@ describe('Epic Store Mutations', () => {
expect(state.epicSubscriptionToggleInProgress).toBe(false); 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