diff --git a/app/assets/javascripts/vue_shared/components/split_button.vue b/app/assets/javascripts/vue_shared/components/split_button.vue new file mode 100644 index 0000000000000000000000000000000000000000..f7dc00a345c13b685be51386470568aa5c6a195e --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/split_button.vue @@ -0,0 +1,76 @@ +<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> diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index ce74aa6ed027a48cedfd5d9daa2a78ccddffd4a2..d53a4c1286c504f68534e177678f55dce34a3f36 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -506,7 +506,8 @@ .dropdown-menu-selectable { li { a, - button { + button, + .dropdown-item { padding: 8px 40px; position: relative; diff --git a/ee/app/assets/javascripts/related_items_tree/components/create_issue_form.vue b/ee/app/assets/javascripts/related_items_tree/components/create_issue_form.vue new file mode 100644 index 0000000000000000000000000000000000000000..b0a9ccbc7ec732b50466798f825b78e4a66e67c4 --- /dev/null +++ b/ee/app/assets/javascripts/related_items_tree/components/create_issue_form.vue @@ -0,0 +1,10 @@ +<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> diff --git a/ee/app/assets/javascripts/related_items_tree/components/issue_actions_split_button.vue b/ee/app/assets/javascripts/related_items_tree/components/issue_actions_split_button.vue new file mode 100644 index 0000000000000000000000000000000000000000..3d83c07ca32d52d95b97e6059bbc68466270de93 --- /dev/null +++ b/ee/app/assets/javascripts/related_items_tree/components/issue_actions_split_button.vue @@ -0,0 +1,37 @@ +<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> diff --git a/ee/app/assets/javascripts/related_items_tree/components/related_items_tree_app.vue b/ee/app/assets/javascripts/related_items_tree/components/related_items_tree_app.vue index 1e232eaaa12ffabe5f1f8bbc811f966e8febd363..3182e588165ac90d33a3bf6dfef15d00ebcec127 100644 --- a/ee/app/assets/javascripts/related_items_tree/components/related_items_tree_app.vue +++ b/ee/app/assets/javascripts/related_items_tree/components/related_items_tree_app.vue @@ -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" diff --git a/ee/app/assets/javascripts/related_items_tree/components/related_items_tree_header.vue b/ee/app/assets/javascripts/related_items_tree/components/related_items_tree_header.vue index ea39cf16bea6f21d89e293298ac85983eda14f1b..3a8a43d8a37530eadcfd819814da6ebdf071ab3c 100644 --- a/ee/app/assets/javascripts/related_items_tree/components/related_items_tree_header.vue +++ b/ee/app/assets/javascripts/related_items_tree/components/related_items_tree_header.vue @@ -75,13 +75,16 @@ export default { size="sm" @onActionClick="handleActionClick" /> - <gl-button - :class="headerItems[1].qaClass" - class="ml-1 js-add-issues-button" - size="sm" - @click="handleActionClick({ id: 0, issuableType: 'issue' })" - >{{ __('Add an issue') }}</gl-button - > + + <slot name="issueActions"> + <gl-button + :class="headerItems[1].qaClass" + class="ml-1 js-add-issues-button" + size="sm" + @click="handleActionClick({ id: 0, issuableType: 'issue' })" + >{{ __('Add an issue') }}</gl-button + > + </slot> </template> </div> </div> diff --git a/ee/app/controllers/groups/epics_controller.rb b/ee/app/controllers/groups/epics_controller.rb index 94b645ae63579e73638032f6950acfa4519c4785..275bf35abd11a7cf019eea6444b98fe3df3830bc 100644 --- a/ee/app/controllers/groups/epics_controller.rb +++ b/ee/app/controllers/groups/epics_controller.rb @@ -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 diff --git a/ee/spec/features/epics/epic_issues_spec.rb b/ee/spec/features/epics/epic_issues_spec.rb index 2a6072143b81a377d785d05a92ae64b10da64c1e..ab167ebf2b12ad0c67d3acecfd877d6d58191c2d 100644 --- a/ee/spec/features/epics/epic_issues_spec.rb +++ b/ee/spec/features/epics/epic_issues_spec.rb @@ -40,6 +40,10 @@ def visit_epic 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 @@ def visit_epic 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 @@ def add_epics(references) 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) diff --git a/ee/spec/javascripts/related_items_tree/components/related_items_tree_app_spec.js b/ee/spec/javascripts/related_items_tree/components/related_items_tree_app_spec.js index edbf7d5b400f9d6114d19c94417d1b3f942b774b..209f698bc27053aac5e0e97bd4baa65b8dea2687 100644 --- a/ee/spec/javascripts/related_items_tree/components/related_items_tree_app_spec.js +++ b/ee/spec/javascripts/related_items_tree/components/related_items_tree_app_spec.js @@ -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); + }); + }); }); }); diff --git a/ee/spec/javascripts/related_items_tree/components/related_items_tree_header_spec.js b/ee/spec/javascripts/related_items_tree/components/related_items_tree_header_spec.js index 92d4335bc62b337326bab3270179e1363308a0a2..b5d1d1f46c094de06a620fbee5c8678f277fba94 100644 --- a/ee/spec/javascripts/related_items_tree/components/related_items_tree_header_spec.js +++ b/ee/spec/javascripts/related_items_tree/components/related_items_tree_header_spec.js @@ -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); + }); + }); + }); }); }); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 8c2e5072d475271f6d79cba789f55d1e725f9f65..b5a2c8a0b8cd2b2b555e8a7b34df59de61f45009 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -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 "" diff --git a/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap new file mode 100644 index 0000000000000000000000000000000000000000..95296de5a5dafa67294be1d5b7a0eced7ccb9c9f --- /dev/null +++ b/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap @@ -0,0 +1,37 @@ +// 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> +`; diff --git a/spec/frontend/vue_shared/components/split_button_spec.js b/spec/frontend/vue_shared/components/split_button_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..520abb02cf7fc6e2412c46716dbcabd80e901e58 --- /dev/null +++ b/spec/frontend/vue_shared/components/split_button_spec.js @@ -0,0 +1,104 @@ +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(); + })); + }); +});