Commit d2bc316c authored by Winnie Hellmann's avatar Winnie Hellmann Committed by Natalia Tepluhina

Add split button to create issue from epics tree

parent 01c33f95
<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 @@
.dropdown-menu-selectable {
li {
a,
button {
button,
.dropdown-item {
padding: 8px 40px;
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';
import { GlLoadingIcon } from '@gitlab/ui';
import { issuableTypesMap } from 'ee/related_issues/constants';
import AddItemForm from 'ee/related_issues/components/add_issuable_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 RelatedItemsTreeHeader from './related_items_tree_header.vue';
......@@ -22,6 +26,13 @@ export default {
AddItemForm,
CreateEpicForm,
TreeItemRemoveModal,
CreateIssueForm,
IssueActionsSplitButton,
},
data() {
return {
isCreateIssueFormVisible: false,
};
},
computed: {
...mapState([
......@@ -44,6 +55,9 @@ export default {
disableContents() {
return this.itemAddInProgress || this.itemCreateInProgress;
},
createIssueEnabled() {
return gon.features && gon.features.epicNewIssue;
},
},
mounted() {
this.fetchItems({
......@@ -97,6 +111,14 @@ export default {
this.toggleCreateEpicForm({ toggleState: false });
this.setItemInputValue('');
},
showAddIssueForm() {
this.toggleAddItemForm({ toggleState: true, issuableType: issuableTypesMap.ISSUE });
},
showCreateIssueForm() {
this.toggleAddItemForm({ toggleState: false });
this.toggleCreateEpicForm({ toggleState: false });
this.isCreateIssueFormVisible = true;
},
},
};
</script>
......@@ -114,9 +136,17 @@ export default {
'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
v-if="showAddItemForm || showCreateEpicForm"
v-if="showAddItemForm || showCreateEpicForm || isCreateIssueFormVisible"
class="card-body add-item-form-container"
:class="{ 'border-bottom-0': itemsFetchResultEmpty }"
>
......@@ -140,6 +170,10 @@ export default {
@createEpicFormSubmit="handleCreateEpicFormSubmit"
@createEpicFormCancel="handleCreateEpicFormCancel"
/>
<create-issue-form
v-if="isCreateIssueFormVisible && !showAddItemForm && !showCreateEpicForm"
@cancel="isCreateIssueFormVisible = false"
/>
</div>
<related-items-tree-body
v-if="!itemsFetchResultEmpty"
......
......@@ -75,6 +75,8 @@ export default {
size="sm"
@onActionClick="handleActionClick"
/>
<slot name="issueActions">
<gl-button
:class="headerItems[1].qaClass"
class="ml-1 js-add-issues-button"
......@@ -82,6 +84,7 @@ export default {
@click="handleActionClick({ id: 0, issuableType: 'issue' })"
>{{ __('Add an issue') }}</gl-button
>
</slot>
</template>
</div>
</div>
......
......@@ -19,6 +19,7 @@ class Groups::EpicsController < Groups::ApplicationController
push_frontend_feature_flag(:epic_trees, @group)
push_frontend_feature_flag(:roadmap_graphql, @group)
push_frontend_feature_flag(:vue_issuable_epic_sidebar, @group)
push_frontend_feature_flag(:epic_new_issue, @group)
end
def index
......
......@@ -40,6 +40,10 @@ describe 'Epic Issues', :js do
wait_for_requests
end
before do
stub_feature_flags(epic_new_issue: false)
end
context 'when user is not a group member of a public group' do
before do
visit_epic
......@@ -67,8 +71,8 @@ describe 'Epic Issues', :js do
let(:issue_invalid) { create(:issue) }
let(:epic_to_add) { create(:epic, group: group) }
def add_issues(references)
find('.related-items-tree-container .js-add-issues-button').click
def add_issues(references, button_selector: '.js-add-issues-button')
find(".related-items-tree-container #{button_selector}").click
find('.related-items-tree-container .js-add-issuable-form-input').set(references)
# When adding long references, for some reason the input gets stuck
# waiting for more text. Send a keystroke before clicking the button to
......@@ -148,6 +152,26 @@ describe 'Epic Issues', :js do
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
references = "#{epic_to_add.to_reference(full: true)}"
add_epics(references)
......
......@@ -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 createDefaultStore from 'ee/related_items_tree/store';
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';
......@@ -24,15 +27,19 @@ const createComponent = () => {
describe('RelatedItemsTreeApp', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
const findAddItemForm = () => wrapper.find(AddItemForm);
const findCreateIssueForm = () => wrapper.find(CreateIssueForm);
const findIssueActionsSplitButton = () => wrapper.find(IssueActionsSplitButton);
afterEach(() => {
wrapper.destroy();
});
describe('methods', () => {
beforeEach(() => {
wrapper = createComponent();
});
describe('getRawRefs', () => {
it('returns array of references from provided string with spaces', () => {
const value = '&1 &2 &3';
......@@ -165,6 +172,7 @@ describe('RelatedItemsTreeApp', () => {
describe('template', () => {
beforeEach(() => {
wrapper = createComponent();
wrapper.vm.$store.dispatch('receiveItemsSuccess', {
parentItem: mockParentItem,
children: [],
......@@ -218,5 +226,90 @@ describe('RelatedItemsTreeApp', () => {
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';
import { mockParentItem, mockQueryResponse } from '../mock_data';
const createComponent = () => {
const createComponent = ({ slots } = {}) => {
const store = createDefaultStore();
const localVue = createLocalVue();
const children = epicUtils.processQueryResponse(mockQueryResponse.data.group);
......@@ -29,6 +29,7 @@ const createComponent = () => {
return shallowMount(RelatedItemsTreeHeader, {
localVue,
store,
slots,
});
};
......@@ -36,15 +37,15 @@ describe('RelatedItemsTree', () => {
describe('RelatedItemsTreeHeader', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('computed', () => {
beforeEach(() => {
wrapper = createComponent();
});
describe('badgeTooltip', () => {
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');
......@@ -53,6 +54,10 @@ describe('RelatedItemsTree', () => {
});
describe('methods', () => {
beforeEach(() => {
wrapper = createComponent();
});
describe('handleActionClick', () => {
const issuableType = issuableTypesMap.Epic;
......@@ -81,6 +86,10 @@ describe('RelatedItemsTree', () => {
});
describe('template', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('renders item badges container', () => {
const badgesContainerEl = wrapper.find('.issue-count-badge');
......@@ -116,5 +125,30 @@ describe('RelatedItemsTree', () => {
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 ""
msgid "Add an SSH key"
msgstr ""
msgid "Add an existing issue to the epic."
msgstr ""
msgid "Add an issue"
msgstr ""
......@@ -4672,12 +4675,18 @@ msgstr ""
msgid "Create a new issue"
msgstr ""
msgid "Create a new issue and add it to the epic."
msgstr ""
msgid "Create a new repository"
msgstr ""
msgid "Create a personal access token on your account to pull or push via %{protocol}."
msgstr ""
msgid "Create an issue"
msgstr ""
msgid "Create an issue. Issues are created for each alert triggered."
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