Commit b38c9bd2 authored by Florie Guibert's avatar Florie Guibert

Consolidate epic tree buttons

Merge Add issue and epic buttons on epic tree
parent 72ec0a48
......@@ -58,7 +58,7 @@ An epic's page contains the following tabs:
- Hover over the total counts to see a breakdown of open and closed items.
- **Roadmap**: a roadmap view of child epics which have start and due dates.
![epic view](img/epic_view_v12.3.png)
![epic view](img/epic_view_v13.0.png)
## Adding an issue to an epic
......@@ -75,6 +75,7 @@ the issue is automatically unlinked from its current parent.
To add an issue to an epic:
1. Click the **Add** dropdown button.
1. Click **Add an issue**.
1. Identify the issue to be added, using either of the following methods:
- Paste the link of the issue.
......@@ -91,7 +92,7 @@ Creating an issue from an epic enables you to maintain focus on the broader cont
To create an issue from an epic:
1. On the epic's page, under **Epics and Issues**, click the arrow next to **Add an issue** and select **Create new issue**.
1. On the epic's page, under **Epics and Issues**, click the **Add** dropdown button and select **Create new issue**.
1. Under **Title**, enter the title for the new issue.
1. From the **Project** dropdown, select the project in which the issue should be created.
1. Click **Create issue**.
......@@ -128,6 +129,7 @@ the maximum depth being 5.
To add a child epic to an epic:
1. Click the **Add** dropdown button.
1. Click **Add an epic**.
1. Identify the epic to be added, using either of the following methods:
- Paste the link of the epic.
......
<script>
import SplitButton from '~/vue_shared/components/split_button.vue';
import { s__ } from '~/locale';
const actionItems = [
{
title: s__('Epics|Add an epic'),
description: s__('Epics|Add an existing epic as a child epic.'),
eventName: 'showAddEpicForm',
},
{
title: s__('Epics|Create new epic'),
description: s__('Epics|Create an epic within this group and add it as a child epic.'),
eventName: 'showCreateEpicForm',
},
];
export default {
actionItems,
components: {
SplitButton,
},
methods: {
change(item) {
this.$emit(item.eventName);
},
},
};
</script>
<template>
<split-button
:action-items="$options.actionItems"
class="js-add-epics-button"
menu-class="dropdown-menu-large"
right
size="sm"
v-on="$listeners"
@change="change"
/>
</template>
<script>
import { GlDropdown, GlDropdownDivider, GlDropdownHeader, GlDropdownItem } from '@gitlab/ui';
import { s__ } from '~/locale';
const epicActionItems = [
{
title: s__('Epics|Add an epic'),
description: s__('Epics|Add an existing epic as a child epic.'),
eventName: 'showAddEpicForm',
},
{
title: s__('Epics|Create new epic'),
description: s__('Epics|Create an epic within this group and add it as a child epic.'),
eventName: 'showCreateEpicForm',
},
];
const issueActionItems = [
{
title: s__('Add an issue'),
description: s__('Add an existing issue to the epic.'),
eventName: 'showAddIssueForm',
},
{
title: s__('Create an issue'),
description: s__('Create a new issue and add it to the epic.'),
eventName: 'showCreateIssueForm',
},
];
export default {
epicActionItems,
issueActionItems,
components: {
GlDropdown,
GlDropdownDivider,
GlDropdownHeader,
GlDropdownItem,
},
props: {
allowSubEpics: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
actionItems() {
return this.allowSubEpics ? [...epicActionItems, ...issueActionItems] : issueActionItems;
},
},
methods: {
change(item) {
this.$emit(item.eventName);
},
},
};
</script>
<template>
<gl-dropdown
:menu-class="`dropdown-menu-selectable`"
:text="s__('Add')"
variant="secondary"
data-qa-selector="epic_issue_actions_split_button"
v-on="$listeners"
>
<gl-dropdown-header>{{ s__('Issue') }}</gl-dropdown-header>
<template v-for="item in $options.issueActionItems">
<gl-dropdown-item :key="item.eventName" active-class="is-active" @click="change(item)">
{{ item.title }}
</gl-dropdown-item>
</template>
<template v-if="allowSubEpics">
<gl-dropdown-divider />
<gl-dropdown-header>{{ s__('Epic') }}</gl-dropdown-header>
<template v-for="item in $options.epicActionItems">
<gl-dropdown-item :key="item.eventName" active-class="is-active" @click="change(item)">
{{ item.title }}
</gl-dropdown-item>
</template>
</template>
</gl-dropdown>
</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,
},
methods: {
change(item) {
this.$emit(item.eventName);
},
},
};
</script>
<template>
<split-button
:action-items="$options.actionItems"
class="js-issue-actions-split-button"
data-qa-selector="issue_actions_split_button"
menu-class="dropdown-menu-large"
right
size="sm"
v-on="$listeners"
@change="change"
/>
</template>
......@@ -3,13 +3,10 @@ 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 SlotSwitch from '~/vue_shared/components/slot_switch.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';
......@@ -34,7 +31,6 @@ export default {
CreateEpicForm,
TreeItemRemoveModal,
CreateIssueForm,
IssueActionsSplitButton,
SlotSwitch,
},
computed: {
......@@ -133,12 +129,6 @@ export default {
this.toggleCreateEpicForm({ toggleState: false });
this.setItemInputValue('');
},
handleShowAddIssueForm() {
this.toggleAddItemForm({ toggleState: true, issuableType: issuableTypesMap.ISSUE });
},
handleShowCreateIssueForm() {
this.toggleCreateIssueForm({ toggleState: true });
},
},
};
</script>
......@@ -156,14 +146,7 @@ export default {
'overflow-auto': directChildren.length > $options.OVERFLOW_AFTER,
}"
>
<related-items-tree-header :class="{ 'border-bottom-0': itemsFetchResultEmpty }">
<issue-actions-split-button
slot="issueActions"
class="ml-0 ml-sm-1"
@showAddIssueForm="handleShowAddIssueForm"
@showCreateIssueForm="handleShowCreateIssueForm"
/>
</related-items-tree-header>
<related-items-tree-header :class="{ 'border-bottom-0': itemsFetchResultEmpty }" />
<slot-switch
v-if="visibleForm"
:active-slot-names="[visibleForm]"
......
<script>
import { mapState, mapActions } from 'vuex';
import { GlDeprecatedButton, GlTooltip, GlIcon } from '@gitlab/ui';
import { GlTooltip, GlIcon } from '@gitlab/ui';
import { issuableTypesMap } from 'ee/related_issues/constants';
import EpicActionsSplitButton from './epic_actions_split_button.vue';
import EpicActionsSplitButton from './epic_issue_actions_split_button.vue';
import EpicHealthStatus from './epic_health_status.vue';
export default {
components: {
GlDeprecatedButton,
GlTooltip,
GlIcon,
EpicHealthStatus,
......@@ -26,17 +25,25 @@ export default {
},
},
methods: {
...mapActions(['toggleAddItemForm', 'toggleCreateEpicForm', 'setItemInputValue']),
showAddEpicForm() {
...mapActions([
'toggleCreateIssueForm',
'toggleAddItemForm',
'toggleCreateEpicForm',
'setItemInputValue',
]),
showAddIssueForm() {
this.setItemInputValue('');
this.toggleAddItemForm({
issuableType: issuableTypesMap.EPIC,
issuableType: issuableTypesMap.ISSUE,
toggleState: true,
});
},
showAddIssueForm() {
this.setItemInputValue('');
showCreateIssueForm() {
this.toggleCreateIssueForm({ toggleState: true });
},
showAddEpicForm() {
this.toggleAddItemForm({
issuableType: issuableTypesMap.ISSUE,
issuableType: issuableTypesMap.EPIC,
toggleState: true,
});
},
......@@ -91,20 +98,13 @@ export default {
<div class="d-inline-flex flex-column flex-sm-row js-button-container">
<template v-if="parentItem.userPermissions.adminEpic">
<epic-actions-split-button
v-if="allowSubEpics"
class="qa-add-epics-button mb-2 mb-sm-0"
:allow-sub-epics="allowSubEpics"
class="js-add-epics-issues-button qa-add-epics-button mb-2 mb-sm-0"
@showAddIssueForm="showAddIssueForm"
@showCreateIssueForm="showCreateIssueForm"
@showAddEpicForm="showAddEpicForm"
@showCreateEpicForm="showCreateEpicForm"
/>
<slot name="issueActions">
<gl-deprecated-button
class="ml-1 js-add-issues-button qa-add-issues-button"
size="sm"
@click="showAddIssueForm"
>{{ __('Add an issue') }}</gl-deprecated-button
>
</slot>
</template>
</div>
</div>
......
---
title: Consolidate epic tree buttons
merge_request: 30816
author:
type: changed
......@@ -60,7 +60,7 @@ describe 'Epic Issues', :js do
end
it 'user cannot add new epics to the epic' do
expect(page).not_to have_selector('.related-items-tree-container .js-add-epics-button')
expect(page).not_to have_selector('.related-items-tree-container .js-add-epics-issues-button')
end
end
......@@ -69,8 +69,9 @@ describe 'Epic Issues', :js do
let(:issue_invalid) { create(:issue) }
let(:epic_to_add) { create(:epic, group: group) }
def add_issues(references, button_selector: '.js-issue-actions-split-button > button:first-child')
find(".related-items-tree-container #{button_selector}").click
def add_issues(references)
find(".related-items-tree-container .js-add-epics-issues-button").click
find('.related-items-tree-container .js-add-epics-issues-button .dropdown-item', text: 'Add an issue').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
......@@ -82,7 +83,8 @@ describe 'Epic Issues', :js do
end
def add_epics(references)
find('.related-items-tree-container .js-add-epics-button').click
find('.related-items-tree-container .js-add-epics-issues-button').click
find('.related-items-tree-container .js-add-epics-issues-button .dropdown-item', text: 'Add an epic').click
find('.related-items-tree-container .js-add-issuable-form-input').set(references)
find('.related-items-tree-container .js-add-issuable-form-input').send_keys(:tab)
......@@ -100,8 +102,8 @@ describe 'Epic Issues', :js do
it 'user can display create new epic form by clicking the dropdown item' do
expect(page).not_to have_selector('input[placeholder="New epic title"]')
find('.related-items-tree-container .js-add-epics-button .dropdown-toggle').click
find('.related-items-tree-container .js-add-epics-button .dropdown-item', text: 'Create new epic').click
find('.related-items-tree-container .js-add-epics-issues-button .dropdown-toggle').click
find('.related-items-tree-container .js-add-epics-issues-button .dropdown-item', text: 'Create new epic').click
expect(page).to have_selector('input[placeholder="New epic title"]')
end
......@@ -222,8 +224,10 @@ describe 'Epic Issues', :js do
stub_licensed_features(epics: true, subepics: false)
visit_epic
find('.related-items-tree-container .js-add-epics-issues-button').click
expect(page).not_to have_selector('.related-items-tree-container .js-add-epics-button')
expect(page).not_to have_selector('.related-items-tree-container .js-add-epics-issues-button .dropdown-item', text: 'Add an epic')
expect(page).not_to have_selector('.related-items-tree-container .js-add-epics-issues-button .dropdown-item', text: 'Create new epic')
end
end
end
......@@ -231,7 +235,7 @@ describe 'Epic Issues', :js do
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')
add_issues(references)
expect(page).not_to have_selector('.gl-field-error')
expect(page).not_to have_content("Issue cannot be found.")
......
......@@ -6,10 +6,7 @@ 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 { TEST_HOST } from 'spec/test_constants';
import AxiosMockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import { getJSONFixture } from 'helpers/fixtures';
......@@ -41,12 +38,7 @@ describe('RelatedItemsTreeApp', () => {
let axiosMock;
let wrapper;
const findAddItemForm = () => wrapper.find(AddItemForm);
const findCreateIssueForm = () => wrapper.find(CreateIssueForm);
const findIssueActionsSplitButton = () => wrapper.find(IssueActionsSplitButton);
const showCreateIssueForm = () => {
findIssueActionsSplitButton().vm.$emit('showCreateIssueForm');
};
beforeEach(() => {
axiosMock = new AxiosMockAdapter(axios);
......@@ -249,73 +241,4 @@ describe('RelatedItemsTreeApp', () => {
expect(findCreateIssueForm().exists()).toBe(false);
});
});
describe('issue actions split button', () => {
beforeEach(() => {
wrapper = createComponent();
wrapper.vm.$store.state.itemsFetchInProgress = false;
return wrapper.vm.$nextTick();
});
it('renders issue actions split button', () => {
expect(findIssueActionsSplitButton().exists()).toBe(true);
});
describe('after split button emitted showAddIssueForm event', () => {
it('shows add item form', () => {
expect(findAddItemForm().exists()).toBe(false);
findIssueActionsSplitButton().vm.$emit('showAddIssueForm');
return wrapper.vm.$nextTick().then(() => {
expect(findAddItemForm().exists()).toBe(true);
});
});
});
describe('after split button emitted showCreateIssueForm event', () => {
it('shows create item form', () => {
expect(findCreateIssueForm().exists()).toBe(false);
showCreateIssueForm();
return wrapper.vm.$nextTick(() => {
expect(findCreateIssueForm().exists()).toBe(true);
});
});
});
describe('after create issue form emitted cancel event', () => {
beforeEach(() => showCreateIssueForm());
it('hides the form', () => {
expect(findCreateIssueForm().exists()).toBe(true);
findCreateIssueForm().vm.$emit('cancel');
return wrapper.vm.$nextTick().then(() => {
expect(findCreateIssueForm().exists()).toBe(false);
});
});
});
describe('after create issue form emitted submit event', () => {
beforeEach(() => showCreateIssueForm());
it('dispatches createNewIssue action', () => {
const issuesEndpoint = `${TEST_HOST}/issues`;
axiosMock.onPost(issuesEndpoint).replyOnce(200, {});
const params = {
issuesEndpoint,
title: 'some new issue',
};
findCreateIssueForm().vm.$emit('submit', params);
return axios.waitFor(issuesEndpoint).then(({ data }) => {
expect(JSON.parse(data).title).toBe(params.title);
});
});
});
});
});
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { GlDeprecatedButton, GlTooltip, GlIcon } from '@gitlab/ui';
import { GlTooltip, GlIcon } from '@gitlab/ui';
import RelatedItemsTreeHeader from 'ee/related_items_tree/components/related_items_tree_header.vue';
import createDefaultStore from 'ee/related_items_tree/store';
import * as epicUtils from 'ee/related_items_tree/utils/epic_utils';
import { issuableTypesMap } from 'ee/related_issues/constants';
import EpicActionsSplitButton from 'ee/related_items_tree/components/epic_actions_split_button.vue';
import EpicActionsSplitButton from 'ee/related_items_tree/components/epic_issue_actions_split_button.vue';
import { mockInitialConfig, mockParentItem, mockQueryResponse } from '../mock_data';
......@@ -41,8 +41,7 @@ describe('RelatedItemsTree', () => {
describe('RelatedItemsTreeHeader', () => {
let wrapper;
const findAddIssuesButton = () => wrapper.find(GlDeprecatedButton);
const findEpicsSplitButton = () => wrapper.find(EpicActionsSplitButton);
const findEpicsIssuesSplitButton = () => wrapper.find(EpicActionsSplitButton);
afterEach(() => {
wrapper.destroy();
......@@ -64,7 +63,7 @@ describe('RelatedItemsTree', () => {
});
});
describe('epic actions split button', () => {
describe('epic issue actions split button', () => {
beforeEach(() => {
wrapper = createComponent();
});
......@@ -82,7 +81,7 @@ describe('RelatedItemsTree', () => {
});
it('dispatches toggleAddItemForm action', () => {
findEpicsSplitButton().vm.$emit('showAddEpicForm');
findEpicsIssuesSplitButton().vm.$emit('showAddEpicForm');
expect(toggleAddItemForm).toHaveBeenCalled();
......@@ -108,7 +107,7 @@ describe('RelatedItemsTree', () => {
});
it('dispatches toggleCreateEpicForm action', () => {
findEpicsSplitButton().vm.$emit('showCreateEpicForm');
findEpicsIssuesSplitButton().vm.$emit('showCreateEpicForm');
expect(toggleCreateEpicForm).toHaveBeenCalled();
......@@ -118,38 +117,28 @@ describe('RelatedItemsTree', () => {
expect(payload).toEqual({ toggleState: true });
});
});
});
describe('add issues button', () => {
beforeEach(() => {
wrapper = createComponent();
});
describe('click event', () => {
describe('showAddIssueForm event', () => {
let toggleAddItemForm;
let setItemInputValue;
beforeEach(() => {
setItemInputValue = jest.fn();
toggleAddItemForm = jest.fn();
setItemInputValue = jest.fn();
wrapper.vm.$store.hotUpdate({
actions: {
setItemInputValue,
toggleAddItemForm,
setItemInputValue,
},
});
});
it('dispatches setItemInputValue and toggleAddItemForm action', () => {
findAddIssuesButton().vm.$emit('click');
expect(setItemInputValue).toHaveBeenCalled();
expect(setItemInputValue.mock.calls[setItemInputValue.mock.calls.length - 1][1]).toBe('');
it('dispatches toggleAddItemForm action', () => {
findEpicsIssuesSplitButton().vm.$emit('showAddIssueForm');
expect(toggleAddItemForm).toHaveBeenCalled();
const payload = toggleAddItemForm.mock.calls[setItemInputValue.mock.calls.length - 1][1];
const payload = toggleAddItemForm.mock.calls[0][1];
expect(payload).toEqual({
issuableType: issuableTypesMap.ISSUE,
......@@ -157,6 +146,30 @@ describe('RelatedItemsTree', () => {
});
});
});
describe('showCreateIssueForm event', () => {
let toggleCreateIssueForm;
beforeEach(() => {
toggleCreateIssueForm = jest.fn();
wrapper.vm.$store.hotUpdate({
actions: {
toggleCreateIssueForm,
},
});
});
it('dispatches toggleCreateIssueForm action', () => {
findEpicsIssuesSplitButton().vm.$emit('showCreateIssueForm');
expect(toggleCreateIssueForm).toHaveBeenCalled();
const payload =
toggleCreateIssueForm.mock.calls[toggleCreateIssueForm.mock.calls.length - 1][1];
expect(payload).toEqual({ toggleState: true });
});
});
});
describe('template', () => {
......@@ -170,42 +183,17 @@ describe('RelatedItemsTree', () => {
expect(badgesContainerEl.isVisible()).toBe(true);
});
describe('when sub-epics feature is available', () => {
it('renders epics count and gl-icon', () => {
const epicsEl = wrapper.findAll('.issue-count-badge > span').at(0);
const epicIcon = epicsEl.find(GlIcon);
expect(epicsEl.text().trim()).toContain('2');
expect(epicIcon.isVisible()).toBe(true);
expect(epicIcon.props('name')).toBe('epic');
});
it('renders epics count and gl-icon', () => {
const epicsEl = wrapper.findAll('.issue-count-badge > span').at(0);
const epicIcon = epicsEl.find(GlIcon);
it('renders `Add an epic` dropdown button', () => {
expect(findEpicsSplitButton().isVisible()).toBe(true);
});
expect(epicsEl.text().trim()).toContain('2');
expect(epicIcon.isVisible()).toBe(true);
expect(epicIcon.props('name')).toBe('epic');
});
describe('when sub-epics feature is not available', () => {
beforeEach(() => {
wrapper.vm.$store.commit('SET_INITIAL_CONFIG', {
...mockInitialConfig,
allowSubEpics: false,
});
return wrapper.vm.$nextTick();
});
it('does not render epics count and gl-icon', () => {
const countBadgesEl = wrapper.findAll('.issue-count-badge > span');
const badgeIcon = countBadgesEl.at(0).find(GlIcon);
expect(countBadgesEl).toHaveLength(1);
expect(badgeIcon.props('name')).toBe('issues');
});
it('does not render `Add an epic` dropdown button', () => {
expect(findEpicsSplitButton().exists()).toBe(false);
});
it('renders `Add` dropdown button', () => {
expect(findEpicsIssuesSplitButton().isVisible()).toBe(true);
});
it('renders issues count and gl-icon', () => {
......@@ -216,38 +204,6 @@ describe('RelatedItemsTree', () => {
expect(issueIcon.isVisible()).toBe(true);
expect(issueIcon.props('name')).toBe('issues');
});
it('renders `Add an issue` dropdown button', () => {
const addIssueBtn = findAddIssuesButton();
expect(addIssueBtn.isVisible()).toBe(true);
expect(addIssueBtn.text()).toBe('Add an issue');
});
});
describe('slots', () => {
describe('issueActions', () => {
it('defaults to button', () => {
wrapper = createComponent();
expect(findAddIssuesButton().exists()).toBe(true);
});
it('uses provided slot content', () => {
const issueActions = {
template: '<p>custom content</p>',
};
wrapper = createComponent({
slots: {
issueActions,
},
});
expect(findAddIssuesButton().exists()).toBe(false);
expect(wrapper.find(issueActions).exists()).toBe(true);
});
});
});
});
});
......@@ -20,8 +20,8 @@ module QA
element :add_issue_input
end
view 'ee/app/assets/javascripts/related_items_tree/components/issue_actions_split_button.vue' do
element :issue_actions_split_button
view 'ee/app/assets/javascripts/related_items_tree/components/epic_issue_actions_split_button.vue' do
element :epic_issue_actions_split_button
end
view 'ee/app/assets/javascripts/related_items_tree/components/tree_item.vue' do
......@@ -33,7 +33,7 @@ module QA
end
def add_issue_to_epic(issue_url)
find_element(:issue_actions_split_button).find('button', text: 'Add an issue').click
find_element(:epic_issue_actions_split_button).find('button', text: 'Add an issue').click
fill_element :add_issue_input, issue_url
# Clicking the title blurs the input
click_element :title
......
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