Commit 4d5e175e authored by Coung Ngo's avatar Coung Ngo Committed by Alexandru Croitor

Add "Promote to epic" issue actions dropdown item

Add new "Promote to epic" button for issues so that
users can more easily promote an issue to an epic.
Currently, a user can only promote an issue through
the /promote quick action.
parent fea3a72b
<script>
import { GlButton, GlDropdown, GlDropdownItem, GlIcon, GlLink, GlModal } from '@gitlab/ui';
import { mapGetters } from 'vuex';
import createFlash from '~/flash';
import createFlash, { FLASH_TYPES } from '~/flash';
import { IssuableType } from '~/issuable_show/constants';
import { IssuableStatus, IssueStateEvent } from '~/issue_show/constants';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import { visitUrl } from '~/lib/utils/url_utility';
import { __, sprintf } from '~/locale';
import promoteToEpicMutation from '../queries/promote_to_epic.mutation.graphql';
import updateIssueMutation from '../queries/update_issue.mutation.graphql';
export default {
......@@ -24,10 +26,21 @@ export default {
text: __('Yes, close issue'),
attributes: [{ variant: 'warning' }],
},
i18n: {
promoteErrorMessage: __(
'Something went wrong while promoting the issue to an epic. Please try again.',
),
promoteSuccessMessage: __(
'The issue was successfully promoted to an epic. Redirecting to epic...',
),
},
inject: {
canCreateIssue: {
default: false,
},
canPromoteToEpic: {
default: false,
},
canReopenIssue: {
default: false,
},
......@@ -135,6 +148,37 @@ export default {
this.isUpdatingState = false;
});
},
promoteToEpic() {
this.isUpdatingState = true;
this.$apollo
.mutate({
mutation: promoteToEpicMutation,
variables: {
input: {
iid: this.iid,
projectPath: this.projectPath,
},
},
})
.then(({ data }) => {
if (data.promoteToEpic.errors.length) {
createFlash({ message: data.promoteToEpic.errors.join('; ') });
return;
}
createFlash({
message: this.$options.i18n.promoteSuccessMessage,
type: FLASH_TYPES.SUCCESS,
});
visitUrl(data.promoteToEpic.epic.webPath);
})
.catch(() => createFlash({ message: this.$options.i18n.promoteErrorMessage }))
.finally(() => {
this.isUpdatingState = false;
});
},
},
};
</script>
......@@ -152,6 +196,9 @@ export default {
<gl-dropdown-item v-if="canCreateIssue" :href="newIssuePath">
{{ newIssueTypeText }}
</gl-dropdown-item>
<gl-dropdown-item v-if="canPromoteToEpic" :disabled="isUpdatingState" @click="promoteToEpic">
{{ __('Promote to epic') }}
</gl-dropdown-item>
<gl-dropdown-item v-if="!isIssueAuthor" :href="reportAbusePath">
{{ __('Report abuse') }}
</gl-dropdown-item>
......@@ -190,6 +237,14 @@ export default {
<gl-dropdown-item v-if="canCreateIssue" :href="newIssuePath">
{{ newIssueTypeText }}
</gl-dropdown-item>
<gl-dropdown-item
v-if="canPromoteToEpic"
:disabled="isUpdatingState"
data-testid="promote-button"
@click="promoteToEpic"
>
{{ __('Promote to epic') }}
</gl-dropdown-item>
<gl-dropdown-item v-if="!isIssueAuthor" :href="reportAbusePath">
{{ __('Report abuse') }}
</gl-dropdown-item>
......
......@@ -45,6 +45,7 @@ export function initIssueHeaderActions(store) {
store,
provide: {
canCreateIssue: parseBoolean(el.dataset.canCreateIssue),
canPromoteToEpic: parseBoolean(el.dataset.canPromoteToEpic),
canReopenIssue: parseBoolean(el.dataset.canReopenIssue),
canReportSpam: parseBoolean(el.dataset.canReportSpam),
canUpdateIssue: parseBoolean(el.dataset.canUpdateIssue),
......
mutation promoteToEpic($input: PromoteToEpicInput!) {
promoteToEpic(input: $input) {
epic {
webPath
}
errors
}
}
......@@ -95,12 +95,13 @@ While you can view and manage the full details of an issue on the [issue page](#
you can also work with multiple issues at a time using the [Issues List](#issues-list),
[Issue Boards](#issue-boards), Issue references, and [Epics](#epics)**(PREMIUM)**.
Key actions for Issues include:
Key actions for issues include:
- [Creating issues](managing_issues.md#create-a-new-issue)
- [Moving issues](managing_issues.md#moving-issues)
- [Closing issues](managing_issues.md#closing-issues)
- [Deleting issues](managing_issues.md#deleting-issues)
- [Promoting issues](managing_issues.md#promote-an-issue-to-an-epic) **(PREMIUM)**
### Issue page
......
......@@ -7,9 +7,15 @@ info: To determine the technical writer assigned to the Stage/Group associated w
# Managing issues
[GitLab Issues](index.md) are the fundamental medium for collaborating on ideas and
planning work in GitLab. [Creating](#create-a-new-issue), [moving](#moving-issues),
[closing](#closing-issues), and [deleting](#deleting-issues) are key actions that
you can do with issues.
planning work in GitLab.
Key actions for issues include:
- [Creating issues](#create-a-new-issue)
- [Moving issues](#moving-issues)
- [Closing issues](#closing-issues)
- [Deleting issues](#deleting-issues)
- [Promoting issues](#promote-an-issue-to-an-epic) **(PREMIUM)**
## Create a new issue
......@@ -280,6 +286,23 @@ editing it and clicking on the delete button.
![delete issue - button](img/delete_issue.png)
## Promote an issue to an epic **(PREMIUM)**
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/3777) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 11.6.
> - [Moved](https://gitlab.com/gitlab-org/gitlab/-/issues/37081) to [GitLab Premium](https://about.gitlab.com/pricing/) in 12.8.
> - Promoting issues to epics via the UI [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/233974) in [GitLab Premium](https://about.gitlab.com/pricing/) 13.6.
You can promote an issue to an epic in the immediate parent group.
To promote an issue to an epic:
1. In an issue, select the vertical ellipsis (**{ellipsis_v}**) button.
1. Select **Promote to epic**.
Alternatively, you can use the `/promote` [quick action](../quick_actions.md#quick-actions-for-issues-merge-requests-and-epics).
Read more about promoting an issue to an epic on the [Manage epics page](../../group/epics/manage_epics.md#promote-an-issue-to-an-epic).
## Add an issue to an iteration **(STARTER)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/216158) in [GitLab Starter](https://about.gitlab.com/pricing/) 13.2.
......
......@@ -31,15 +31,6 @@ module EE
end
end
override :issue_closed_link
def issue_closed_link(issue, current_user, css_class: '')
if issue.promoted? && can?(current_user, :read_epic, issue.promoted_to_epic)
link_to(s_('IssuableStatus|promoted'), issue.promoted_to_epic, class: css_class)
else
super
end
end
def issue_in_subepic?(issue, epic_id)
# This helper is used if a list of issues are filtered by epic id
return false if epic_id.blank?
......@@ -55,9 +46,27 @@ module EE
issue.incident? && issue.project.feature_available?(:incident_timeline_view)
end
# OVERRIDES
override :scoped_labels_available?
def scoped_labels_available?(parent)
parent.feature_available?(:scoped_labels)
end
override :issue_closed_link
def issue_closed_link(issue, current_user, css_class: '')
if issue.promoted? && can?(current_user, :read_epic, issue.promoted_to_epic)
link_to(s_('IssuableStatus|promoted'), issue.promoted_to_epic, class: css_class)
else
super
end
end
override :issue_header_actions_data
def issue_header_actions_data(project, issuable, current_user)
actions = super
actions[:can_promote_to_epic] = issuable.can_be_promoted_to_epic?(current_user).to_s
actions
end
end
end
......@@ -163,6 +163,16 @@ module EE
user&.can?(:admin_epic, project.group)
end
def can_be_promoted_to_epic?(user, group = nil)
group ||= project.group
return false unless user
return false unless group
persisted? && supports_epic? && !promoted? &&
user.can?(:admin_issue, project) && user.can?(:create_epic, group)
end
# Issue position on boards list should be relative to all group projects
def parent_ids
return super unless has_group_boards?
......
......@@ -15,6 +15,10 @@ module EE
prevent :create_design
prevent :create_note
end
rule { can_be_promoted_to_epic }.policy do
enable :promote_to_epic
end
end
end
end
---
title: Promote an Issue to an Epic via the UI
merge_request: 47306
author:
type: added
......@@ -70,11 +70,7 @@ module EE
icon 'confidential'
types Issue
condition do
quick_action_target.persisted? &&
quick_action_target.supports_epic? &&
!quick_action_target.promoted? &&
current_user.can?(:admin_issue, project) &&
current_user.can?(:create_epic, project.group)
quick_action_target.can_be_promoted_to_epic?(current_user)
end
command :promote do
@updates[:promote_to_epic] = true
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Issue actions', :js do
let(:group) { create(:group) }
let(:project) { create(:project, group: group) }
let(:issue) { create(:issue, project: project) }
let(:user) { create(:user) }
before do
stub_licensed_features(epics: true)
sign_in(user)
end
describe 'promote issue to epic action' do
context 'when user is unauthorized' do
before do
group.add_guest(user)
visit project_issue_path(project, issue)
end
it 'does not show "Promote to epic" item in issue actions dropdown' do
page.within '.detail-page-header' do
# Click on ellipsis dropdown button
click_button 'Issue actions'
expect(page).not_to have_button('Promote to epic')
end
end
end
context 'when user is authorized' do
before do
group.add_owner(user)
visit project_issue_path(project, issue)
end
it 'clicking "Promote to epic" creates and redirects user to epic' do
page.within '.detail-page-header' do
# Click on ellipsis dropdown button
click_button 'Issue actions'
click_button 'Promote to epic'
end
wait_for_requests
expect(page).to have_current_path(group_epic_path(group, 1))
end
end
end
end
......@@ -880,6 +880,76 @@ RSpec.describe Issue do
end
end
describe '#can_be_promoted_to_epic?' do
before do
stub_licensed_features(epics: true)
end
let_it_be(:user) { create(:user) }
let(:group) { nil }
subject { issue.can_be_promoted_to_epic?(user, group) }
context 'when project on the issue does not have a parent group' do
let(:project) { create(:project) }
let(:issue) { create(:issue, project: project) }
before do
project.add_developer(user)
end
it { is_expected.to be_falsey }
end
context 'when project on the issue is in a subgroup' do
let(:parent_group) { create(:group) }
let(:group) { create(:group, parent: parent_group) }
let(:project) { create(:project, group: group) }
let(:issue) { create(:issue, project: project) }
before do
group.add_developer(user)
project.add_developer(user)
end
it { is_expected.to be_truthy }
end
context 'when project has a parent group' do
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:issue) { create(:issue, project: project) }
context 'when a user is not a project member' do
it { is_expected.to be_falsey }
end
context 'when a user is a project member' do
before do
project.add_developer(user)
end
it { is_expected.to be_falsey }
end
context 'when a user is a group member' do
before do
group.add_developer(user)
end
it { is_expected.to be_truthy }
context 'when issue is an incident' do
before do
issue.update!(issue_type: :incident)
end
it { is_expected.to be_falsey }
end
end
end
end
describe '#supports_iterations?' do
let(:group) { build_stubbed(:group) }
let(:project_with_group) { build_stubbed(:project, group: group) }
......
......@@ -3,14 +3,15 @@
require 'spec_helper'
RSpec.describe IssuePolicy do
let(:owner) { build_stubbed(:user) }
let(:namespace) { build_stubbed(:namespace, owner: owner) }
let(:project) { build_stubbed(:project, namespace: namespace) }
let(:issue) { build_stubbed(:issue, project: project) }
let_it_be(:owner) { create(:user) }
let_it_be(:namespace) { create(:group) }
let_it_be(:project) { create(:project, group: namespace) }
let_it_be(:issue) { create(:issue, project: project) }
subject { described_class.new(owner, issue) }
before do
namespace.add_owner(owner)
allow(issue).to receive(:namespace).and_return namespace
allow(project).to receive(:design_management_enabled?).and_return true
end
......
......@@ -21789,6 +21789,9 @@ msgstr ""
msgid "Promote issue to an epic"
msgstr ""
msgid "Promote to epic"
msgstr ""
msgid "Promote to group label"
msgstr ""
......@@ -25320,6 +25323,9 @@ msgstr ""
msgid "Something went wrong while performing the action."
msgstr ""
msgid "Something went wrong while promoting the issue to an epic. Please try again."
msgstr ""
msgid "Something went wrong while reopening a requirement."
msgstr ""
......@@ -26935,6 +26941,9 @@ msgstr ""
msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage."
msgstr ""
msgid "The issue was successfully promoted to an epic. Redirecting to epic..."
msgstr ""
msgid "The license for Deploy Board is required to use this feature."
msgstr ""
......
import { GlButton, GlDropdown, GlDropdownItem, GlLink, GlModal } from '@gitlab/ui';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import createFlash, { FLASH_TYPES } from '~/flash';
import { IssuableType } from '~/issuable_show/constants';
import HeaderActions from '~/issue_show/components/header_actions.vue';
import { IssuableStatus, IssueStateEvent } from '~/issue_show/constants';
import promoteToEpicMutation from '~/issue_show/queries/promote_to_epic.mutation.graphql';
import * as urlUtility from '~/lib/utils/url_utility';
import createStore from '~/notes/stores';
jest.mock('~/flash');
describe('HeaderActions component', () => {
let dispatchEventSpy;
let mutateMock;
let wrapper;
let visitUrlSpy;
const localVue = createLocalVue();
localVue.use(Vuex);
......@@ -16,6 +23,7 @@ describe('HeaderActions component', () => {
const defaultProps = {
canCreateIssue: true,
canPromoteToEpic: true,
canReopenIssue: true,
canReportSpam: true,
canUpdateIssue: true,
......@@ -29,7 +37,27 @@ describe('HeaderActions component', () => {
submitAsSpamPath: 'gitlab-org/gitlab-test/-/issues/32/submit_as_spam',
};
const mutate = jest.fn().mockResolvedValue({ data: { updateIssue: { errors: [] } } });
const updateIssueMutationResponse = { data: { updateIssue: { errors: [] } } };
const promoteToEpicMutationResponse = {
data: {
promoteToEpic: {
errors: [],
epic: {
webPath: '/groups/gitlab-org/-/epics/1',
},
},
},
};
const promoteToEpicMutationErrorResponse = {
data: {
promoteToEpic: {
errors: ['The issue has already been promoted to an epic.'],
epic: {},
},
},
};
const findToggleIssueStateButton = () => wrapper.find(GlButton);
......@@ -50,7 +78,10 @@ describe('HeaderActions component', () => {
props = {},
issueState = IssuableStatus.Open,
blockedByIssues = [],
mutateResponse = {},
} = {}) => {
mutateMock = jest.fn().mockResolvedValue(mutateResponse);
store.getters.getNoteableData.state = issueState;
store.getters.getNoteableData.blocked_by_issues = blockedByIssues;
......@@ -63,7 +94,7 @@ describe('HeaderActions component', () => {
},
mocks: {
$apollo: {
mutate,
mutate: mutateMock,
},
},
});
......@@ -73,6 +104,9 @@ describe('HeaderActions component', () => {
if (dispatchEventSpy) {
dispatchEventSpy.mockRestore();
}
if (visitUrlSpy) {
visitUrlSpy.mockRestore();
}
wrapper.destroy();
});
......@@ -90,7 +124,11 @@ describe('HeaderActions component', () => {
beforeEach(() => {
dispatchEventSpy = jest.spyOn(document, 'dispatchEvent');
wrapper = mountComponent({ props: { issueType }, issueState });
wrapper = mountComponent({
props: { issueType },
issueState,
mutateResponse: updateIssueMutationResponse,
});
});
it(`has text "${buttonText}"`, () => {
......@@ -100,11 +138,11 @@ describe('HeaderActions component', () => {
it('calls apollo mutation', () => {
findToggleIssueStateButton().vm.$emit('click');
expect(mutate).toHaveBeenCalledWith(
expect(mutateMock).toHaveBeenCalledWith(
expect.objectContaining({
variables: {
input: {
iid: defaultProps.iid.toString(),
iid: defaultProps.iid,
projectPath: defaultProps.projectPath,
stateEvent: newIssueState,
},
......@@ -129,15 +167,17 @@ describe('HeaderActions component', () => {
${'desktop dropdown'} | ${false} | ${findDesktopDropdownItems}
`('$description', ({ isCloseIssueItemVisible, findDropdownItems }) => {
describe.each`
description | itemText | isItemVisible | canUpdateIssue | canCreateIssue | isIssueAuthor | canReportSpam
${`when user can update ${issueType}`} | ${`Close ${issueType}`} | ${isCloseIssueItemVisible} | ${true} | ${true} | ${true} | ${true}
${`when user cannot update ${issueType}`} | ${`Close ${issueType}`} | ${false} | ${false} | ${true} | ${true} | ${true}
${`when user can create ${issueType}`} | ${`New ${issueType}`} | ${true} | ${true} | ${true} | ${true} | ${true}
${`when user cannot create ${issueType}`} | ${`New ${issueType}`} | ${false} | ${true} | ${false} | ${true} | ${true}
${'when user can report abuse'} | ${'Report abuse'} | ${true} | ${true} | ${true} | ${false} | ${true}
${'when user cannot report abuse'} | ${'Report abuse'} | ${false} | ${true} | ${true} | ${true} | ${true}
${'when user can submit as spam'} | ${'Submit as spam'} | ${true} | ${true} | ${true} | ${true} | ${true}
${'when user cannot submit as spam'} | ${'Submit as spam'} | ${false} | ${true} | ${true} | ${true} | ${false}
description | itemText | isItemVisible | canUpdateIssue | canCreateIssue | isIssueAuthor | canReportSpam | canPromoteToEpic
${`when user can update ${issueType}`} | ${`Close ${issueType}`} | ${isCloseIssueItemVisible} | ${true} | ${true} | ${true} | ${true} | ${true}
${`when user cannot update ${issueType}`} | ${`Close ${issueType}`} | ${false} | ${false} | ${true} | ${true} | ${true} | ${true}
${`when user can create ${issueType}`} | ${`New ${issueType}`} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true}
${`when user cannot create ${issueType}`} | ${`New ${issueType}`} | ${false} | ${true} | ${false} | ${true} | ${true} | ${true}
${'when user can promote to epic'} | ${'Promote to epic'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true}
${'when user cannot promote to epic'} | ${'Promote to epic'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${false}
${'when user can report abuse'} | ${'Report abuse'} | ${true} | ${true} | ${true} | ${false} | ${true} | ${true}
${'when user cannot report abuse'} | ${'Report abuse'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true}
${'when user can submit as spam'} | ${'Submit as spam'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true}
${'when user cannot submit as spam'} | ${'Submit as spam'} | ${false} | ${true} | ${true} | ${true} | ${false} | ${true}
`(
'$description',
({
......@@ -147,6 +187,7 @@ describe('HeaderActions component', () => {
canCreateIssue,
isIssueAuthor,
canReportSpam,
canPromoteToEpic,
}) => {
beforeEach(() => {
wrapper = mountComponent({
......@@ -156,6 +197,7 @@ describe('HeaderActions component', () => {
isIssueAuthor,
issueType,
canReportSpam,
canPromoteToEpic,
},
});
});
......@@ -172,6 +214,65 @@ describe('HeaderActions component', () => {
});
});
describe('when "Promote to epic" button is clicked', () => {
describe('when response is successful', () => {
beforeEach(() => {
visitUrlSpy = jest.spyOn(urlUtility, 'visitUrl').mockReturnValue({});
wrapper = mountComponent({
mutateResponse: promoteToEpicMutationResponse,
});
wrapper.find('[data-testid="promote-button"]').vm.$emit('click');
});
it('invokes GraphQL mutation when clicked', () => {
expect(mutateMock).toHaveBeenCalledWith(
expect.objectContaining({
mutation: promoteToEpicMutation,
variables: {
input: {
iid: defaultProps.iid,
projectPath: defaultProps.projectPath,
},
},
}),
);
});
it('shows a success message and tells the user they are being redirected', () => {
expect(createFlash).toHaveBeenCalledWith({
message: 'The issue was successfully promoted to an epic. Redirecting to epic...',
type: FLASH_TYPES.SUCCESS,
});
});
it('redirects to newly created epic path', () => {
expect(visitUrlSpy).toHaveBeenCalledWith(
promoteToEpicMutationResponse.data.promoteToEpic.epic.webPath,
);
});
});
describe('when response contains errors', () => {
beforeEach(() => {
visitUrlSpy = jest.spyOn(urlUtility, 'visitUrl').mockReturnValue({});
wrapper = mountComponent({
mutateResponse: promoteToEpicMutationErrorResponse,
});
wrapper.find('[data-testid="promote-button"]').vm.$emit('click');
});
it('shows an error message', () => {
expect(createFlash).toHaveBeenCalledWith({
message: promoteToEpicMutationErrorResponse.data.promoteToEpic.errors.join('; '),
});
});
});
});
describe('modal', () => {
const blockedByIssues = [
{ iid: 13, web_url: 'gitlab-org/gitlab-test/-/issues/13' },
......@@ -197,7 +298,7 @@ describe('HeaderActions component', () => {
it('calls apollo mutation when primary button is clicked', () => {
findModal().vm.$emit('primary');
expect(mutate).toHaveBeenCalledWith(
expect(mutateMock).toHaveBeenCalledWith(
expect.objectContaining({
variables: {
input: {
......
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