Commit 1073f51a authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '233974-update-issue-header-actions-ui' into 'master'

Update issue header actions UI

See merge request gitlab-org/gitlab!44440
parents ff44ac74 0770fb5f
<script>
import { GlButton, GlDropdown, GlDropdownItem, GlIcon, GlLink, GlModal } from '@gitlab/ui';
import { mapGetters } from 'vuex';
import createFlash from '~/flash';
import { IssuableStatus, IssueStateEvent } from '~/issue_show/constants';
import { __ } from '~/locale';
import updateIssueMutation from '../queries/update_issue.mutation.graphql';
export default {
components: {
GlButton,
GlDropdown,
GlDropdownItem,
GlIcon,
GlLink,
GlModal,
},
actionCancel: {
text: __('Cancel'),
},
actionPrimary: {
text: __('Yes, close issue'),
attributes: [{ variant: 'warning' }],
},
inject: [
'canCreateIssue',
'canReopenIssue',
'canReportSpam',
'canUpdateIssue',
'iid',
'isIssueAuthor',
'newIssuePath',
'projectPath',
'reportAbusePath',
'submitAsSpamPath',
],
data() {
return {
isUpdatingState: false,
};
},
computed: {
...mapGetters(['getNoteableData']),
isClosed() {
return this.getNoteableData.state === IssuableStatus.Closed;
},
buttonText() {
return this.isClosed ? __('Reopen issue') : __('Close issue');
},
buttonVariant() {
return this.isClosed ? 'default' : 'warning';
},
showToggleIssueButton() {
const canClose = !this.isClosed && this.canUpdateIssue;
const canReopen = this.isClosed && this.canReopenIssue;
return canClose || canReopen;
},
},
methods: {
toggleIssueState() {
if (!this.isClosed && this.getNoteableData?.blocked_by_issues?.length) {
this.$refs.blockedByIssuesModal.show();
return;
}
this.invokeUpdateIssueMutation();
},
invokeUpdateIssueMutation() {
this.isUpdatingState = true;
this.$apollo
.mutate({
mutation: updateIssueMutation,
variables: {
input: {
iid: this.iid.toString(),
projectPath: this.projectPath,
stateEvent: this.isClosed ? IssueStateEvent.Reopen : IssueStateEvent.Close,
},
},
})
.then(({ data }) => {
if (data.updateIssue.errors.length) {
createFlash(data.updateIssue.errors.join('. '));
return;
}
const payload = {
detail: {
data: { id: this.iid },
isClosed: !this.isClosed,
},
};
// Dispatch event which updates open/close state, shared among the issue show page
document.dispatchEvent(new CustomEvent('issuable_vue_app:change', payload));
})
.catch(() => createFlash(__('Update failed. Please try again.')))
.finally(() => {
this.isUpdatingState = false;
});
},
},
};
</script>
<template>
<div class="detail-page-header-actions">
<gl-dropdown class="gl-display-block gl-display-sm-none!" block :text="__('Issue actions')">
<gl-dropdown-item
v-if="showToggleIssueButton"
:disabled="isUpdatingState"
@click="toggleIssueState"
>
{{ buttonText }}
</gl-dropdown-item>
<gl-dropdown-item v-if="canCreateIssue" :href="newIssuePath">
{{ __('New issue') }}
</gl-dropdown-item>
<gl-dropdown-item v-if="!isIssueAuthor" :href="reportAbusePath">
{{ __('Report abuse') }}
</gl-dropdown-item>
<gl-dropdown-item
v-if="canReportSpam"
:href="submitAsSpamPath"
data-method="post"
rel="nofollow"
>
{{ __('Submit as spam') }}
</gl-dropdown-item>
</gl-dropdown>
<gl-button
v-if="showToggleIssueButton"
class="gl-display-none gl-display-sm-inline-flex!"
category="secondary"
:loading="isUpdatingState"
:variant="buttonVariant"
@click="toggleIssueState"
>
{{ buttonText }}
</gl-button>
<gl-dropdown
class="gl-display-none gl-display-sm-inline-flex!"
toggle-class="gl-border-0! gl-shadow-none!"
no-caret
right
>
<template #button-content>
<gl-icon name="ellipsis_v" aria-hidden="true" />
<span class="gl-sr-only">{{ __('Actions') }}</span>
</template>
<gl-dropdown-item v-if="canCreateIssue" :href="newIssuePath">
{{ __('New issue') }}
</gl-dropdown-item>
<gl-dropdown-item v-if="!isIssueAuthor" :href="reportAbusePath">
{{ __('Report abuse') }}
</gl-dropdown-item>
<gl-dropdown-item
v-if="canReportSpam"
:href="submitAsSpamPath"
data-method="post"
rel="nofollow"
>
{{ __('Submit as spam') }}
</gl-dropdown-item>
</gl-dropdown>
<gl-modal
ref="blockedByIssuesModal"
modal-id="blocked-by-issues-modal"
:action-cancel="$options.actionCancel"
:action-primary="$options.actionPrimary"
:title="__('Are you sure you want to close this blocked issue?')"
@primary="invokeUpdateIssueMutation"
>
<p>{{ __('This issue is currently blocked by the following issues:') }}</p>
<ul>
<li v-for="issue in getNoteableData.blocked_by_issues" :key="issue.iid">
<gl-link :href="issue.web_url">#{{ issue.iid }}</gl-link>
</li>
</ul>
</gl-modal>
</div>
</template>
......@@ -18,5 +18,10 @@ export const IssuableType = {
MergeRequest: 'merge_request',
};
export const IssueStateEvent = {
Close: 'CLOSE',
Reopen: 'REOPEN',
};
export const STATUS_PAGE_PUBLISHED = __('Published on status page');
export const JOIN_ZOOM_MEETING = __('Join Zoom meeting');
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { mapGetters } from 'vuex';
import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
import IssuableApp from './components/app.vue';
import HeaderActions from './components/header_actions.vue';
export default function initIssuableApp(issuableData, store) {
export function initIssuableApp(issuableData, store) {
return new Vue({
el: document.getElementById('js-issuable-app'),
store,
......@@ -19,3 +23,36 @@ export default function initIssuableApp(issuableData, store) {
},
});
}
export function initIssueHeaderActions(store) {
const el = document.querySelector('.js-issue-header-actions');
if (!el) {
return undefined;
}
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
return new Vue({
el,
apolloProvider,
store,
provide: {
canCreateIssue: parseBoolean(el.dataset.canCreateIssue),
canReopenIssue: parseBoolean(el.dataset.canReopenIssue),
canReportSpam: parseBoolean(el.dataset.canReportSpam),
canUpdateIssue: parseBoolean(el.dataset.canUpdateIssue),
iid: el.dataset.iid,
isIssueAuthor: parseBoolean(el.dataset.isIssueAuthor),
newIssuePath: el.dataset.newIssuePath,
projectPath: el.dataset.projectPath,
reportAbusePath: el.dataset.reportAbusePath,
submitAsSpamPath: el.dataset.submitAsSpamPath,
},
render: createElement => createElement(HeaderActions),
});
}
mutation updateIssue($input: UpdateIssueInput!) {
updateIssue(input: $input) {
errors
}
}
......@@ -5,7 +5,7 @@ import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
import ZenMode from '~/zen_mode';
import '~/notes/index';
import { store } from '~/notes/stores';
import initIssueApp from '~/issue_show/issue';
import { initIssuableApp, initIssueHeaderActions } from '~/issue_show/issue';
import initIncidentApp from '~/issue_show/incident';
import initIssuableHeaderWarning from '~/vue_shared/components/issuable/init_issuable_header_warning';
import initSentryErrorStackTraceApp from '~/sentry_error_stack_trace';
......@@ -24,13 +24,14 @@ export default function() {
initIncidentApp(issuableData);
break;
case IssuableType.Issue:
initIssueApp(issuableData, store);
initIssuableApp(issuableData, store);
break;
default:
break;
}
initIssuableHeaderWarning(store);
initIssueHeaderActions(store);
initSentryErrorStackTraceApp();
initRelatedMergeRequestsApp();
......
......@@ -44,6 +44,7 @@ class Projects::IssuesController < Projects::ApplicationController
push_frontend_feature_flag(:vue_issuable_sidebar, project.group)
push_frontend_feature_flag(:tribute_autocomplete, @project)
push_frontend_feature_flag(:vue_issuables_list, project)
push_frontend_feature_flag(:vue_issue_header, @project)
end
before_action only: :show do
......
......@@ -152,6 +152,21 @@ module IssuesHelper
sort: 'desc'
}
end
def issue_header_actions_data(project, issue, current_user)
{
can_create_issue: show_new_issue_link?(project).to_s,
can_reopen_issue: can?(current_user, :reopen_issue, issue).to_s,
can_report_spam: issue.submittable_as_spam_by?(current_user).to_s,
can_update_issue: can?(current_user, :update_issue, issue).to_s,
iid: issue.iid,
is_issue_author: issue.author == current_user,
new_issue_path: new_project_issue_path(project),
project_path: project.full_path,
report_abuse_path: new_abuse_report_path(user_id: issue.author.id, ref_url: issue_url(issue)),
submit_as_spam_path: mark_as_spam_project_issue_path(project, issue)
}
end
end
IssuesHelper.prepend_if_ee('EE::IssuesHelper')
......@@ -23,6 +23,9 @@
%a.btn.gl-button.btn-default.float-right.gl-display-block.d-sm-none.gutter-toggle.issuable-gutter-toggle.js-sidebar-toggle{ href: "#" }
= sprite_icon('chevron-double-lg-left')
- if Feature.enabled?(:vue_issue_header, @project)
.js-issue-header-actions{ data: issue_header_actions_data(@project, @issue, current_user) }
- else
.detail-page-header-actions.js-issuable-actions.js-issuable-buttons{ data: { "action": "close-reopen" } }
.clearfix.issue-btn-group.dropdown
%button.btn.gl-button.btn-default.float-left.d-md-none.d-lg-none.d-xl-none{ type: "button", data: { toggle: "dropdown" } }
......
---
name: vue_issue_header
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/44440
rollout_issue_url:
type: development
group: group::project management
default_enabled: false
......@@ -16,6 +16,8 @@ RSpec.describe 'Related issues', :js do
context 'when user has permission to manage related issues' do
before do
stub_feature_flags(vue_issue_header: false)
project.add_maintainer(user)
project_b.add_maintainer(user)
gitlab_sign_in(user)
......
......@@ -14,6 +14,8 @@ RSpec.describe 'Issue Sidebar' do
let_it_be(:issue_no_group) { create(:labeled_issue, project: project_without_group, labels: [label]) }
before do
stub_feature_flags(vue_issue_header: false)
sign_in(user)
end
......
......@@ -16,6 +16,8 @@ RSpec.describe Projects::IssuesController, '(JavaScript fixtures)', type: :contr
end
before do
stub_feature_flags(vue_issue_header: false)
project.add_developer(user)
sign_in(user)
......
......@@ -5493,6 +5493,9 @@ msgstr ""
msgid "Close epic"
msgstr ""
msgid "Close issue"
msgstr ""
msgid "Close milestone"
msgstr ""
......@@ -14771,6 +14774,9 @@ msgstr ""
msgid "Issue Boards"
msgstr ""
msgid "Issue actions"
msgstr ""
msgid "Issue already promoted to epic."
msgstr ""
......@@ -22372,6 +22378,9 @@ msgstr ""
msgid "Reopen epic"
msgstr ""
msgid "Reopen issue"
msgstr ""
msgid "Reopen milestone"
msgstr ""
......@@ -27292,6 +27301,9 @@ msgstr ""
msgid "This is your current session"
msgstr ""
msgid "This issue is currently blocked by the following issues:"
msgstr ""
msgid "This issue is currently blocked by the following issues: %{issues}."
msgstr ""
......
......@@ -13,6 +13,8 @@ RSpec.describe "User views incident" do
end
before do
stub_feature_flags(vue_issue_header: false)
sign_in(user)
visit(project_issues_incident_path(project, incident))
......
......@@ -7,6 +7,10 @@ RSpec.describe 'Issuables Close/Reopen/Report toggle' do
let(:user) { create(:user) }
before do
stub_feature_flags(vue_issue_header: false)
end
shared_examples 'an issuable close/reopen/report toggle' do
let(:container) { find('.issuable-close-dropdown') }
let(:human_model_name) { issuable.model_name.human.downcase }
......
......@@ -13,6 +13,8 @@ RSpec.describe "User views issue" do
end
before do
stub_feature_flags(vue_issue_header: false)
sign_in(user)
visit(project_issue_path(project, issue))
......
......@@ -16,6 +16,8 @@ RSpec.describe Projects::IssuesController, '(JavaScript fixtures)', type: :contr
end
before do
stub_feature_flags(vue_issue_header: false)
sign_in(admin)
end
......
import { GlButton, GlDropdown, GlDropdownItem, GlLink, GlModal } from '@gitlab/ui';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import HeaderActions from '~/issue_show/components/header_actions.vue';
import { IssuableStatus, IssueStateEvent } from '~/issue_show/constants';
import createStore from '~/notes/stores';
describe('HeaderActions component', () => {
let dispatchEventSpy;
let wrapper;
const localVue = createLocalVue();
localVue.use(Vuex);
const store = createStore();
const defaultProps = {
canCreateIssue: true,
canReopenIssue: true,
canReportSpam: true,
canUpdateIssue: true,
iid: '32',
isIssueAuthor: true,
newIssuePath: 'gitlab-org/gitlab-test/-/issues/new',
projectPath: 'gitlab-org/gitlab-test',
reportAbusePath:
'-/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%2Fgitlab-org%2Fgitlab-test%2F-%2Fissues%2F32&user_id=1',
submitAsSpamPath: 'gitlab-org/gitlab-test/-/issues/32/submit_as_spam',
};
const mutate = jest.fn().mockResolvedValue({ data: { updateIssue: { errors: [] } } });
const findToggleIssueStateButton = () => wrapper.find(GlButton);
const findDropdownAt = index => wrapper.findAll(GlDropdown).at(index);
const findMobileDropdownItems = () => findDropdownAt(0).findAll(GlDropdownItem);
const findDesktopDropdownItems = () => findDropdownAt(1).findAll(GlDropdownItem);
const findModal = () => wrapper.find(GlModal);
const findModalLinkAt = index =>
findModal()
.findAll(GlLink)
.at(index);
const mountComponent = ({
props = {},
issueState = IssuableStatus.Open,
blockedByIssues = [],
} = {}) => {
store.getters.getNoteableData.state = issueState;
store.getters.getNoteableData.blocked_by_issues = blockedByIssues;
return shallowMount(HeaderActions, {
localVue,
store,
provide: {
...defaultProps,
...props,
},
mocks: {
$apollo: {
mutate,
},
},
});
};
afterEach(() => {
if (dispatchEventSpy) {
dispatchEventSpy.mockRestore();
}
wrapper.destroy();
});
describe('close/reopen button', () => {
describe.each`
description | issueState | buttonText | newIssueState
${'when the issue is open'} | ${IssuableStatus.Open} | ${'Close issue'} | ${IssueStateEvent.Close}
${'when the issue is closed'} | ${IssuableStatus.Closed} | ${'Reopen issue'} | ${IssueStateEvent.Reopen}
`('$description', ({ issueState, buttonText, newIssueState }) => {
beforeEach(() => {
dispatchEventSpy = jest.spyOn(document, 'dispatchEvent');
wrapper = mountComponent({ issueState });
});
it(`has text "${buttonText}"`, () => {
expect(findToggleIssueStateButton().text()).toBe(buttonText);
});
it('calls apollo mutation', () => {
findToggleIssueStateButton().vm.$emit('click');
expect(mutate).toHaveBeenCalledWith(
expect.objectContaining({
variables: {
input: {
iid: defaultProps.iid.toString(),
projectPath: defaultProps.projectPath,
stateEvent: newIssueState,
},
},
}),
);
});
it('dispatches a custom event to update the issue page', async () => {
findToggleIssueStateButton().vm.$emit('click');
await wrapper.vm.$nextTick();
expect(dispatchEventSpy).toHaveBeenCalledTimes(1);
});
});
});
describe.each`
description | isCloseIssueItemVisible | findDropdownItems
${'mobile dropdown'} | ${true} | ${findMobileDropdownItems}
${'desktop dropdown'} | ${false} | ${findDesktopDropdownItems}
`('$description', ({ isCloseIssueItemVisible, findDropdownItems }) => {
describe.each`
description | itemText | isItemVisible | canUpdateIssue | canCreateIssue | isIssueAuthor | canReportSpam
${'when user can update issue'} | ${'Close issue'} | ${isCloseIssueItemVisible} | ${true} | ${true} | ${true} | ${true}
${'when user cannot update issue'} | ${'Close issue'} | ${false} | ${false} | ${true} | ${true} | ${true}
${'when user can create issue'} | ${'New issue'} | ${true} | ${true} | ${true} | ${true} | ${true}
${'when user cannot create issue'} | ${'New issue'} | ${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,
}) => {
beforeEach(() => {
wrapper = mountComponent({
props: {
canUpdateIssue,
canCreateIssue,
isIssueAuthor,
canReportSpam,
},
});
});
it(`${isItemVisible ? 'shows' : 'hides'} "${itemText}" item`, () => {
expect(
findDropdownItems()
.filter(item => item.text() === itemText)
.exists(),
).toBe(isItemVisible);
});
},
);
});
describe('modal', () => {
const blockedByIssues = [
{ iid: 13, web_url: 'gitlab-org/gitlab-test/-/issues/13' },
{ iid: 79, web_url: 'gitlab-org/gitlab-test/-/issues/79' },
];
beforeEach(() => {
wrapper = mountComponent({ blockedByIssues });
});
it('has title text', () => {
expect(findModal().attributes('title')).toBe(
'Are you sure you want to close this blocked issue?',
);
});
it('has body text', () => {
expect(findModal().text()).toContain(
'This issue is currently blocked by the following issues:',
);
});
it('calls apollo mutation when primary button is clicked', () => {
findModal().vm.$emit('primary');
expect(mutate).toHaveBeenCalledWith(
expect.objectContaining({
variables: {
input: {
iid: defaultProps.iid.toString(),
projectPath: defaultProps.projectPath,
stateEvent: IssueStateEvent.Close,
},
},
}),
);
});
describe.each`
ordinal | index
${'first'} | ${0}
${'second'} | ${1}
`('$ordinal blocked-by issue link', ({ index }) => {
it('has link text', () => {
expect(findModalLinkAt(index).text()).toBe(`#${blockedByIssues[index].iid}`);
});
it('has url', () => {
expect(findModalLinkAt(index).attributes('href')).toBe(blockedByIssues[index].web_url);
});
});
});
});
......@@ -2,7 +2,7 @@ import MockAdapter from 'axios-mock-adapter';
import { useMockIntersectionObserver } from 'helpers/mock_dom_observer';
import waitForPromises from 'helpers/wait_for_promises';
import axios from '~/lib/utils/axios_utils';
import initIssuableApp from '~/issue_show/issue';
import { initIssuableApp } from '~/issue_show/issue';
import * as parseData from '~/issue_show/utils/parse_data';
import { appProps } from './mock_data';
import createStore from '~/notes/stores';
......
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