Commit f6b1ba84 authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch 'winh-epics-tree-create-issue-button' into 'master'

Add split button to create issue from epics tree

See merge request gitlab-org/gitlab!17948
parents 01c33f95 d2bc316c
<script>
import _ from 'underscore';
import { GlDropdown, GlDropdownDivider, GlDropdownItem } from '@gitlab/ui';
const isValidItem = item =>
_.isString(item.eventName) && _.isString(item.title) && _.isString(item.description);
export default {
components: {
GlDropdown,
GlDropdownDivider,
GlDropdownItem,
},
props: {
actionItems: {
type: Array,
required: true,
validator(value) {
return value.length > 1 && value.every(isValidItem);
},
},
menuClass: {
type: String,
required: false,
default: '',
},
},
data() {
return {
selectedItem: this.actionItems[0],
};
},
computed: {
dropdownToggleText() {
return this.selectedItem.title;
},
},
methods: {
triggerEvent() {
this.$emit(this.selectedItem.eventName);
},
},
};
</script>
<template>
<gl-dropdown
:menu-class="`dropdown-menu-selectable ${menuClass}`"
split
:text="dropdownToggleText"
v-bind="$attrs"
@click="triggerEvent"
>
<template v-for="(item, itemIndex) in actionItems">
<gl-dropdown-item
:key="item.eventName"
:active="selectedItem === item"
active-class="is-active"
@click="selectedItem = item"
>
<strong>{{ item.title }}</strong>
<div>{{ item.description }}</div>
</gl-dropdown-item>
<gl-dropdown-divider
v-if="itemIndex < actionItems.length - 1"
:key="`${item.eventName}-divider`"
/>
</template>
</gl-dropdown>
</template>
...@@ -506,7 +506,8 @@ ...@@ -506,7 +506,8 @@
.dropdown-menu-selectable { .dropdown-menu-selectable {
li { li {
a, a,
button { button,
.dropdown-item {
padding: 8px 40px; padding: 8px 40px;
position: relative; position: relative;
......
<template>
<div>
<!-- eslint-disable @gitlab/vue-i18n/no-bare-strings -->
<p>
This is a placeholder for
<a href="https://gitlab.com/gitlab-org/gitlab/issues/5419">#5419</a>.
</p>
<button class="btn btn-secondary" type="button" @click="$emit('cancel')">Cancel</button>
</div>
</template>
<script>
import SplitButton from '~/vue_shared/components/split_button.vue';
import { __ } from '~/locale';
const actionItems = [
{
title: __('Add an issue'),
description: __('Add an existing issue to the epic.'),
eventName: 'showAddIssueForm',
},
{
title: __('Create an issue'),
description: __('Create a new issue and add it to the epic.'),
eventName: 'showCreateIssueForm',
},
];
export default {
actionItems,
components: {
SplitButton,
},
};
</script>
<template>
<split-button
:action-items="$options.actionItems"
class="js-issue-actions-split-button"
menu-class="dropdown-menu-large"
right
size="sm"
v-on="$listeners"
/>
</template>
...@@ -3,8 +3,12 @@ import { mapState, mapActions, mapGetters } from 'vuex'; ...@@ -3,8 +3,12 @@ import { mapState, mapActions, mapGetters } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui'; import { GlLoadingIcon } from '@gitlab/ui';
import { issuableTypesMap } from 'ee/related_issues/constants';
import AddItemForm from 'ee/related_issues/components/add_issuable_form.vue'; import AddItemForm from 'ee/related_issues/components/add_issuable_form.vue';
import CreateEpicForm from './create_epic_form.vue'; import CreateEpicForm from './create_epic_form.vue';
import CreateIssueForm from './create_issue_form.vue';
import IssueActionsSplitButton from './issue_actions_split_button.vue';
import TreeItemRemoveModal from './tree_item_remove_modal.vue'; import TreeItemRemoveModal from './tree_item_remove_modal.vue';
import RelatedItemsTreeHeader from './related_items_tree_header.vue'; import RelatedItemsTreeHeader from './related_items_tree_header.vue';
...@@ -22,6 +26,13 @@ export default { ...@@ -22,6 +26,13 @@ export default {
AddItemForm, AddItemForm,
CreateEpicForm, CreateEpicForm,
TreeItemRemoveModal, TreeItemRemoveModal,
CreateIssueForm,
IssueActionsSplitButton,
},
data() {
return {
isCreateIssueFormVisible: false,
};
}, },
computed: { computed: {
...mapState([ ...mapState([
...@@ -44,6 +55,9 @@ export default { ...@@ -44,6 +55,9 @@ export default {
disableContents() { disableContents() {
return this.itemAddInProgress || this.itemCreateInProgress; return this.itemAddInProgress || this.itemCreateInProgress;
}, },
createIssueEnabled() {
return gon.features && gon.features.epicNewIssue;
},
}, },
mounted() { mounted() {
this.fetchItems({ this.fetchItems({
...@@ -97,6 +111,14 @@ export default { ...@@ -97,6 +111,14 @@ export default {
this.toggleCreateEpicForm({ toggleState: false }); this.toggleCreateEpicForm({ toggleState: false });
this.setItemInputValue(''); this.setItemInputValue('');
}, },
showAddIssueForm() {
this.toggleAddItemForm({ toggleState: true, issuableType: issuableTypesMap.ISSUE });
},
showCreateIssueForm() {
this.toggleAddItemForm({ toggleState: false });
this.toggleCreateEpicForm({ toggleState: false });
this.isCreateIssueFormVisible = true;
},
}, },
}; };
</script> </script>
...@@ -114,9 +136,17 @@ export default { ...@@ -114,9 +136,17 @@ export default {
'overflow-auto': directChildren.length > $options.OVERFLOW_AFTER, 'overflow-auto': directChildren.length > $options.OVERFLOW_AFTER,
}" }"
> >
<related-items-tree-header :class="{ 'border-bottom-0': itemsFetchResultEmpty }" /> <related-items-tree-header :class="{ 'border-bottom-0': itemsFetchResultEmpty }">
<issue-actions-split-button
v-if="createIssueEnabled"
slot="issueActions"
class="ml-1"
@showAddIssueForm="showAddIssueForm"
@showCreateIssueForm="showCreateIssueForm"
/>
</related-items-tree-header>
<div <div
v-if="showAddItemForm || showCreateEpicForm" v-if="showAddItemForm || showCreateEpicForm || isCreateIssueFormVisible"
class="card-body add-item-form-container" class="card-body add-item-form-container"
:class="{ 'border-bottom-0': itemsFetchResultEmpty }" :class="{ 'border-bottom-0': itemsFetchResultEmpty }"
> >
...@@ -140,6 +170,10 @@ export default { ...@@ -140,6 +170,10 @@ export default {
@createEpicFormSubmit="handleCreateEpicFormSubmit" @createEpicFormSubmit="handleCreateEpicFormSubmit"
@createEpicFormCancel="handleCreateEpicFormCancel" @createEpicFormCancel="handleCreateEpicFormCancel"
/> />
<create-issue-form
v-if="isCreateIssueFormVisible && !showAddItemForm && !showCreateEpicForm"
@cancel="isCreateIssueFormVisible = false"
/>
</div> </div>
<related-items-tree-body <related-items-tree-body
v-if="!itemsFetchResultEmpty" v-if="!itemsFetchResultEmpty"
......
...@@ -75,13 +75,16 @@ export default { ...@@ -75,13 +75,16 @@ export default {
size="sm" size="sm"
@onActionClick="handleActionClick" @onActionClick="handleActionClick"
/> />
<gl-button
:class="headerItems[1].qaClass" <slot name="issueActions">
class="ml-1 js-add-issues-button" <gl-button
size="sm" :class="headerItems[1].qaClass"
@click="handleActionClick({ id: 0, issuableType: 'issue' })" class="ml-1 js-add-issues-button"
>{{ __('Add an issue') }}</gl-button size="sm"
> @click="handleActionClick({ id: 0, issuableType: 'issue' })"
>{{ __('Add an issue') }}</gl-button
>
</slot>
</template> </template>
</div> </div>
</div> </div>
......
...@@ -19,6 +19,7 @@ class Groups::EpicsController < Groups::ApplicationController ...@@ -19,6 +19,7 @@ class Groups::EpicsController < Groups::ApplicationController
push_frontend_feature_flag(:epic_trees, @group) push_frontend_feature_flag(:epic_trees, @group)
push_frontend_feature_flag(:roadmap_graphql, @group) push_frontend_feature_flag(:roadmap_graphql, @group)
push_frontend_feature_flag(:vue_issuable_epic_sidebar, @group) push_frontend_feature_flag(:vue_issuable_epic_sidebar, @group)
push_frontend_feature_flag(:epic_new_issue, @group)
end end
def index def index
......
...@@ -40,6 +40,10 @@ describe 'Epic Issues', :js do ...@@ -40,6 +40,10 @@ describe 'Epic Issues', :js do
wait_for_requests wait_for_requests
end end
before do
stub_feature_flags(epic_new_issue: false)
end
context 'when user is not a group member of a public group' do context 'when user is not a group member of a public group' do
before do before do
visit_epic visit_epic
...@@ -67,8 +71,8 @@ describe 'Epic Issues', :js do ...@@ -67,8 +71,8 @@ describe 'Epic Issues', :js do
let(:issue_invalid) { create(:issue) } let(:issue_invalid) { create(:issue) }
let(:epic_to_add) { create(:epic, group: group) } let(:epic_to_add) { create(:epic, group: group) }
def add_issues(references) def add_issues(references, button_selector: '.js-add-issues-button')
find('.related-items-tree-container .js-add-issues-button').click find(".related-items-tree-container #{button_selector}").click
find('.related-items-tree-container .js-add-issuable-form-input').set(references) find('.related-items-tree-container .js-add-issuable-form-input').set(references)
# When adding long references, for some reason the input gets stuck # When adding long references, for some reason the input gets stuck
# waiting for more text. Send a keystroke before clicking the button to # waiting for more text. Send a keystroke before clicking the button to
...@@ -148,6 +152,26 @@ describe 'Epic Issues', :js do ...@@ -148,6 +152,26 @@ describe 'Epic Issues', :js do
end end
end end
context 'with epic_new_issue feature flag enabled' do
before do
stub_feature_flags(epic_new_issue: true)
visit_epic
end
it 'user can add new issues to the epic' do
references = "#{issue_to_add.to_reference(full: true)}"
add_issues(references, button_selector: '.js-issue-actions-split-button')
expect(page).not_to have_selector('.content-wrapper .flash-text')
expect(page).not_to have_content("We can't find an issue that matches what you are looking for.")
within('.related-items-tree-container ul.related-items-list') do
expect(page).to have_selector('li.js-item-type-issue', count: 3)
end
end
end
it 'user can add new epics to the epic' do it 'user can add new epics to the epic' do
references = "#{epic_to_add.to_reference(full: true)}" references = "#{epic_to_add.to_reference(full: true)}"
add_epics(references) add_epics(references)
......
...@@ -5,6 +5,9 @@ import RelatedItemsTreeApp from 'ee/related_items_tree/components/related_items_ ...@@ -5,6 +5,9 @@ import RelatedItemsTreeApp from 'ee/related_items_tree/components/related_items_
import RelatedItemsTreeHeader from 'ee/related_items_tree/components/related_items_tree_header.vue'; import RelatedItemsTreeHeader from 'ee/related_items_tree/components/related_items_tree_header.vue';
import createDefaultStore from 'ee/related_items_tree/store'; import createDefaultStore from 'ee/related_items_tree/store';
import { issuableTypesMap } from 'ee/related_issues/constants'; import { issuableTypesMap } from 'ee/related_issues/constants';
import AddItemForm from 'ee/related_issues/components/add_issuable_form.vue';
import CreateIssueForm from 'ee/related_items_tree/components/create_issue_form.vue';
import IssueActionsSplitButton from 'ee/related_items_tree/components/issue_actions_split_button.vue';
import { mockInitialConfig, mockParentItem } from '../mock_data'; import { mockInitialConfig, mockParentItem } from '../mock_data';
...@@ -24,15 +27,19 @@ const createComponent = () => { ...@@ -24,15 +27,19 @@ const createComponent = () => {
describe('RelatedItemsTreeApp', () => { describe('RelatedItemsTreeApp', () => {
let wrapper; let wrapper;
beforeEach(() => { const findAddItemForm = () => wrapper.find(AddItemForm);
wrapper = createComponent(); const findCreateIssueForm = () => wrapper.find(CreateIssueForm);
}); const findIssueActionsSplitButton = () => wrapper.find(IssueActionsSplitButton);
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
}); });
describe('methods', () => { describe('methods', () => {
beforeEach(() => {
wrapper = createComponent();
});
describe('getRawRefs', () => { describe('getRawRefs', () => {
it('returns array of references from provided string with spaces', () => { it('returns array of references from provided string with spaces', () => {
const value = '&1 &2 &3'; const value = '&1 &2 &3';
...@@ -165,6 +172,7 @@ describe('RelatedItemsTreeApp', () => { ...@@ -165,6 +172,7 @@ describe('RelatedItemsTreeApp', () => {
describe('template', () => { describe('template', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createComponent();
wrapper.vm.$store.dispatch('receiveItemsSuccess', { wrapper.vm.$store.dispatch('receiveItemsSuccess', {
parentItem: mockParentItem, parentItem: mockParentItem,
children: [], children: [],
...@@ -218,5 +226,90 @@ describe('RelatedItemsTreeApp', () => { ...@@ -218,5 +226,90 @@ describe('RelatedItemsTreeApp', () => {
done(); done();
}); });
}); });
it('does not render issue actions split button', () => {
expect(findIssueActionsSplitButton().exists()).toBe(false);
});
it('does not render create issue form', () => {
expect(findCreateIssueForm().exists()).toBe(false);
});
});
describe('with epicNewIssue feature flag enabled', () => {
beforeEach(done => {
window.gon.features = { epicNewIssue: true };
wrapper = createComponent();
wrapper.vm.$store.state.itemsFetchInProgress = false;
wrapper.vm
.$nextTick()
.then(done)
.catch(done.fail);
});
afterEach(() => {
window.gon.features = {};
});
it('renders issue actions split button', () => {
expect(findIssueActionsSplitButton().exists()).toBe(true);
});
describe('after split button emitted showAddIssueForm event', () => {
it('shows add item form', done => {
expect(findAddItemForm().exists()).toBe(false);
findIssueActionsSplitButton().vm.$emit('showAddIssueForm');
wrapper.vm
.$nextTick()
.then(() => {
expect(findAddItemForm().exists()).toBe(true);
})
.then(done)
.catch(done.fail);
});
});
describe('after split button emitted showCreateIssueForm event', () => {
it('shows create item form', done => {
expect(findCreateIssueForm().exists()).toBe(false);
findIssueActionsSplitButton().vm.$emit('showCreateIssueForm');
wrapper.vm
.$nextTick()
.then(() => {
expect(findCreateIssueForm().exists()).toBe(true);
})
.then(done)
.catch(done.fail);
});
});
describe('after create issue form emitted cancel event', () => {
beforeEach(done => {
findIssueActionsSplitButton().vm.$emit('showCreateIssueForm');
wrapper.vm
.$nextTick()
.then(done)
.catch(done.fail);
});
it('hides the form', done => {
expect(findCreateIssueForm().exists()).toBe(true);
findCreateIssueForm().vm.$emit('cancel');
wrapper.vm
.$nextTick()
.then(() => {
expect(findCreateIssueForm().exists()).toBe(false);
})
.then(done)
.catch(done.fail);
});
});
}); });
}); });
...@@ -10,7 +10,7 @@ import { issuableTypesMap } from 'ee/related_issues/constants'; ...@@ -10,7 +10,7 @@ import { issuableTypesMap } from 'ee/related_issues/constants';
import { mockParentItem, mockQueryResponse } from '../mock_data'; import { mockParentItem, mockQueryResponse } from '../mock_data';
const createComponent = () => { const createComponent = ({ slots } = {}) => {
const store = createDefaultStore(); const store = createDefaultStore();
const localVue = createLocalVue(); const localVue = createLocalVue();
const children = epicUtils.processQueryResponse(mockQueryResponse.data.group); const children = epicUtils.processQueryResponse(mockQueryResponse.data.group);
...@@ -29,6 +29,7 @@ const createComponent = () => { ...@@ -29,6 +29,7 @@ const createComponent = () => {
return shallowMount(RelatedItemsTreeHeader, { return shallowMount(RelatedItemsTreeHeader, {
localVue, localVue,
store, store,
slots,
}); });
}; };
...@@ -36,15 +37,15 @@ describe('RelatedItemsTree', () => { ...@@ -36,15 +37,15 @@ describe('RelatedItemsTree', () => {
describe('RelatedItemsTreeHeader', () => { describe('RelatedItemsTreeHeader', () => {
let wrapper; let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
}); });
describe('computed', () => { describe('computed', () => {
beforeEach(() => {
wrapper = createComponent();
});
describe('badgeTooltip', () => { describe('badgeTooltip', () => {
it('returns string containing epic count and issues count based on available direct children within state', () => { it('returns string containing epic count and issues count based on available direct children within state', () => {
expect(wrapper.vm.badgeTooltip).toBe('2 epics and 2 issues'); expect(wrapper.vm.badgeTooltip).toBe('2 epics and 2 issues');
...@@ -53,6 +54,10 @@ describe('RelatedItemsTree', () => { ...@@ -53,6 +54,10 @@ describe('RelatedItemsTree', () => {
}); });
describe('methods', () => { describe('methods', () => {
beforeEach(() => {
wrapper = createComponent();
});
describe('handleActionClick', () => { describe('handleActionClick', () => {
const issuableType = issuableTypesMap.Epic; const issuableType = issuableTypesMap.Epic;
...@@ -81,6 +86,10 @@ describe('RelatedItemsTree', () => { ...@@ -81,6 +86,10 @@ describe('RelatedItemsTree', () => {
}); });
describe('template', () => { describe('template', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('renders item badges container', () => { it('renders item badges container', () => {
const badgesContainerEl = wrapper.find('.issue-count-badge'); const badgesContainerEl = wrapper.find('.issue-count-badge');
...@@ -116,5 +125,30 @@ describe('RelatedItemsTree', () => { ...@@ -116,5 +125,30 @@ describe('RelatedItemsTree', () => {
expect(addIssueBtn.text()).toBe('Add an issue'); expect(addIssueBtn.text()).toBe('Add an issue');
}); });
}); });
describe('slots', () => {
describe('issueActions', () => {
it('defaults to button', () => {
wrapper = createComponent();
expect(wrapper.find(GlButton).exists()).toBe(true);
});
it('uses provided slot content', () => {
const issueActions = {
template: '<p>custom content</p>',
};
wrapper = createComponent({
slots: {
issueActions,
},
});
expect(wrapper.find(GlButton).exists()).toBe(false);
expect(wrapper.find(issueActions).exists()).toBe(true);
});
});
});
}); });
}); });
...@@ -925,6 +925,9 @@ msgstr "" ...@@ -925,6 +925,9 @@ msgstr ""
msgid "Add an SSH key" msgid "Add an SSH key"
msgstr "" msgstr ""
msgid "Add an existing issue to the epic."
msgstr ""
msgid "Add an issue" msgid "Add an issue"
msgstr "" msgstr ""
...@@ -4672,12 +4675,18 @@ msgstr "" ...@@ -4672,12 +4675,18 @@ msgstr ""
msgid "Create a new issue" msgid "Create a new issue"
msgstr "" msgstr ""
msgid "Create a new issue and add it to the epic."
msgstr ""
msgid "Create a new repository" msgid "Create a new repository"
msgstr "" msgstr ""
msgid "Create a personal access token on your account to pull or push via %{protocol}." msgid "Create a personal access token on your account to pull or push via %{protocol}."
msgstr "" msgstr ""
msgid "Create an issue"
msgstr ""
msgid "Create an issue. Issues are created for each alert triggered." msgid "Create an issue. Issues are created for each alert triggered."
msgstr "" msgstr ""
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SplitButton renders actionItems 1`] = `
<gldropdown-stub
menu-class="dropdown-menu-selectable "
split="true"
text="professor"
>
<gldropdownitem-stub
active="true"
active-class="is-active"
>
<strong>
professor
</strong>
<div>
very symphonic
</div>
</gldropdownitem-stub>
<gldropdowndivider-stub />
<gldropdownitem-stub
active-class="is-active"
>
<strong>
captain
</strong>
<div>
warp drive
</div>
</gldropdownitem-stub>
<!---->
</gldropdown-stub>
`;
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import SplitButton from '~/vue_shared/components/split_button.vue';
const mockActionItems = [
{
eventName: 'concert',
title: 'professor',
description: 'very symphonic',
},
{
eventName: 'apocalypse',
title: 'captain',
description: 'warp drive',
},
];
describe('SplitButton', () => {
let wrapper;
const createComponent = propsData => {
wrapper = shallowMount(SplitButton, {
propsData,
sync: false,
});
};
const findDropdown = () => wrapper.find(GlDropdown);
const findDropdownItem = (index = 0) =>
findDropdown()
.findAll(GlDropdownItem)
.at(index);
const selectItem = index => {
findDropdownItem(index).vm.$emit('click');
return wrapper.vm.$nextTick();
};
const clickToggleButton = () => {
findDropdown().vm.$emit('click');
return wrapper.vm.$nextTick();
};
it('fails for empty actionItems', () => {
const actionItems = [];
expect(() => createComponent({ actionItems })).toThrow();
});
it('fails for single actionItems', () => {
const actionItems = [mockActionItems[0]];
expect(() => createComponent({ actionItems })).toThrow();
});
it('renders actionItems', () => {
createComponent({ actionItems: mockActionItems });
expect(wrapper.element).toMatchSnapshot();
});
describe('toggle button text', () => {
beforeEach(() => {
createComponent({ actionItems: mockActionItems });
});
it('defaults to first actionItems title', () => {
expect(findDropdown().props().text).toBe(mockActionItems[0].title);
});
it('changes to selected actionItems title', () =>
selectItem(1).then(() => {
expect(findDropdown().props().text).toBe(mockActionItems[1].title);
}));
});
describe('emitted event', () => {
let eventHandler;
beforeEach(() => {
createComponent({ actionItems: mockActionItems });
});
const addEventHandler = ({ eventName }) => {
eventHandler = jest.fn();
wrapper.vm.$once(eventName, () => eventHandler());
};
it('defaults to first actionItems event', () => {
addEventHandler(mockActionItems[0]);
return clickToggleButton().then(() => {
expect(eventHandler).toHaveBeenCalled();
});
});
it('changes to selected actionItems event', () =>
selectItem(1)
.then(() => addEventHandler(mockActionItems[1]))
.then(clickToggleButton)
.then(() => {
expect(eventHandler).toHaveBeenCalled();
}));
});
});
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