Commit eca2347f authored by Jose Ivan Vargas's avatar Jose Ivan Vargas

Merge branch...

Merge branch '215174-in-a-mr-please-resolve-these-threads-should-be-a-hyperlink-pointing-to-the-threads-to-be' into 'master'

Reword unresolved discussions widget and make 'resolve threads' interactive

See merge request gitlab-org/gitlab!36164
parents d09080e5 f5c96d80
...@@ -2,9 +2,13 @@ ...@@ -2,9 +2,13 @@
/* global Mousetrap */ /* global Mousetrap */
import 'mousetrap'; import 'mousetrap';
import discussionNavigation from '~/notes/mixins/discussion_navigation'; import discussionNavigation from '~/notes/mixins/discussion_navigation';
import eventHub from '~/notes/event_hub';
export default { export default {
mixins: [discussionNavigation], mixins: [discussionNavigation],
created() {
eventHub.$on('jumpToFirstUnresolvedDiscussion', this.jumpToFirstUnresolvedDiscussion);
},
mounted() { mounted() {
Mousetrap.bind('n', this.jumpToNextDiscussion); Mousetrap.bind('n', this.jumpToNextDiscussion);
Mousetrap.bind('p', this.jumpToPreviousDiscussion); Mousetrap.bind('p', this.jumpToPreviousDiscussion);
...@@ -12,6 +16,8 @@ export default { ...@@ -12,6 +16,8 @@ export default {
beforeDestroy() { beforeDestroy() {
Mousetrap.unbind('n'); Mousetrap.unbind('n');
Mousetrap.unbind('p'); Mousetrap.unbind('p');
eventHub.$off('jumpToFirstUnresolvedDiscussion', this.jumpToFirstUnresolvedDiscussion);
}, },
render() { render() {
return this.$slots.default; return this.$slots.default;
......
...@@ -113,6 +113,14 @@ export default { ...@@ -113,6 +113,14 @@ export default {
handleDiscussionJump(this, this.previousUnresolvedDiscussionId); handleDiscussionJump(this, this.previousUnresolvedDiscussionId);
}, },
jumpToFirstUnresolvedDiscussion() {
this.setCurrentDiscussionId(null)
.then(() => {
this.jumpToNextDiscussion();
})
.catch(() => {});
},
/** /**
* Go to the next discussion from the given discussionId * Go to the next discussion from the given discussionId
* @param {String} discussionId The id we are jumping from * @param {String} discussionId The id we are jumping from
......
<script> <script>
import { GlButton } from '@gitlab/ui';
import statusIcon from '../mr_widget_status_icon.vue'; import statusIcon from '../mr_widget_status_icon.vue';
import notesEventHub from '~/notes/event_hub';
export default { export default {
name: 'UnresolvedDiscussions', name: 'UnresolvedDiscussions',
components: { components: {
statusIcon, statusIcon,
GlButton,
}, },
props: { props: {
mr: { mr: {
...@@ -12,23 +15,39 @@ export default { ...@@ -12,23 +15,39 @@ export default {
required: true, required: true,
}, },
}, },
methods: {
jumpToFirstUnresolvedDiscussion() {
notesEventHub.$emit('jumpToFirstUnresolvedDiscussion');
},
},
}; };
</script> </script>
<template> <template>
<div class="mr-widget-body media"> <div class="mr-widget-body media gl-flex-wrap">
<status-icon :show-disabled-button="true" status="warning" /> <status-icon :show-disabled-button="true" status="warning" />
<div class="media-body space-children"> <div class="media-body">
<span class="bold"> <span class="gl-ml-3 gl-font-weight-bold gl-display-block gl-w-100">{{
{{ s__('mrWidget|There are unresolved threads. Please resolve these threads') }} s__('mrWidget|Before this can be merged, one or more threads must be resolved.')
</span> }}</span>
<a <gl-button
data-testid="jump-to-first"
class="gl-ml-3"
size="small"
icon="comment-next"
@click="jumpToFirstUnresolvedDiscussion"
>
{{ s__('mrWidget|Jump to first unresolved thread') }}
</gl-button>
<gl-button
v-if="mr.createIssueToResolveDiscussionsPath" v-if="mr.createIssueToResolveDiscussionsPath"
:href="mr.createIssueToResolveDiscussionsPath" :href="mr.createIssueToResolveDiscussionsPath"
class="btn btn-default btn-sm js-create-issue" class="js-create-issue gl-ml-3"
size="small"
icon="issue-new"
> >
{{ s__('mrWidget|Create an issue to resolve them later') }} {{ s__('mrWidget|Resolve all threads in new issue') }}
</a> </gl-button>
</div> </div>
</div> </div>
</template> </template>
---
title: Prompt to resolve unresolved threads on an MR is a button that jumps to the
first such thread
merge_request: 36164
author:
type: added
...@@ -28451,6 +28451,9 @@ msgstr "" ...@@ -28451,6 +28451,9 @@ msgstr ""
msgid "mrWidget|Are you adding technical debt or code vulnerabilities?" msgid "mrWidget|Are you adding technical debt or code vulnerabilities?"
msgstr "" msgstr ""
msgid "mrWidget|Before this can be merged, one or more threads must be resolved."
msgstr ""
msgid "mrWidget|Cancel automatic merge" msgid "mrWidget|Cancel automatic merge"
msgstr "" msgstr ""
...@@ -28475,9 +28478,6 @@ msgstr "" ...@@ -28475,9 +28478,6 @@ msgstr ""
msgid "mrWidget|Closes" msgid "mrWidget|Closes"
msgstr "" msgstr ""
msgid "mrWidget|Create an issue to resolve them later"
msgstr ""
msgid "mrWidget|Delete source branch" msgid "mrWidget|Delete source branch"
msgstr "" msgstr ""
...@@ -28511,6 +28511,9 @@ msgstr "" ...@@ -28511,6 +28511,9 @@ msgstr ""
msgid "mrWidget|In the merge train at position %{mergeTrainPosition}" msgid "mrWidget|In the merge train at position %{mergeTrainPosition}"
msgstr "" msgstr ""
msgid "mrWidget|Jump to first unresolved thread"
msgstr ""
msgid "mrWidget|Loading deployment statistics" msgid "mrWidget|Loading deployment statistics"
msgstr "" msgstr ""
...@@ -28574,6 +28577,9 @@ msgstr "" ...@@ -28574,6 +28577,9 @@ msgstr ""
msgid "mrWidget|Request to merge" msgid "mrWidget|Request to merge"
msgstr "" msgstr ""
msgid "mrWidget|Resolve all threads in new issue"
msgstr ""
msgid "mrWidget|Resolve conflicts" msgid "mrWidget|Resolve conflicts"
msgstr "" msgstr ""
...@@ -28625,9 +28631,6 @@ msgstr "" ...@@ -28625,9 +28631,6 @@ msgstr ""
msgid "mrWidget|There are merge conflicts" msgid "mrWidget|There are merge conflicts"
msgstr "" msgstr ""
msgid "mrWidget|There are unresolved threads. Please resolve these threads"
msgstr ""
msgid "mrWidget|This feature merges changes from the target branch to the source branch. You cannot use this feature since the source branch is protected." msgid "mrWidget|This feature merges changes from the target branch to the source branch. You cannot use this feature since the source branch is protected."
msgstr "" msgstr ""
......
...@@ -8,10 +8,14 @@ RSpec.describe 'Resolving all open threads in a merge request from an issue', :j ...@@ -8,10 +8,14 @@ RSpec.describe 'Resolving all open threads in a merge request from an issue', :j
let(:merge_request) { create(:merge_request, source_project: project) } let(:merge_request) { create(:merge_request, source_project: project) }
let!(:discussion) { create(:diff_note_on_merge_request, noteable: merge_request, project: project).to_discussion } let!(:discussion) { create(:diff_note_on_merge_request, noteable: merge_request, project: project).to_discussion }
def resolve_all_discussions_link_selector def resolve_all_discussions_link_selector(title: "")
text = "Resolve all threads in new issue"
url = new_project_issue_path(project, merge_request_to_resolve_discussions_of: merge_request.iid) url = new_project_issue_path(project, merge_request_to_resolve_discussions_of: merge_request.iid)
%Q{a[title="#{text}"][href="#{url}"]}
if title.empty?
%Q{a[href="#{url}"]}
else
%Q{a[title="#{title}"][href="#{url}"]}
end
end end
describe 'as a user with access to the project' do describe 'as a user with access to the project' do
...@@ -23,7 +27,7 @@ RSpec.describe 'Resolving all open threads in a merge request from an issue', :j ...@@ -23,7 +27,7 @@ RSpec.describe 'Resolving all open threads in a merge request from an issue', :j
it 'shows a button to resolve all threads by creating a new issue' do it 'shows a button to resolve all threads by creating a new issue' do
within('.line-resolve-all-container') do within('.line-resolve-all-container') do
expect(page).to have_selector resolve_all_discussions_link_selector expect(page).to have_selector resolve_all_discussions_link_selector( title: "Resolve all threads in new issue" )
end end
end end
...@@ -34,6 +38,7 @@ RSpec.describe 'Resolving all open threads in a merge request from an issue', :j ...@@ -34,6 +38,7 @@ RSpec.describe 'Resolving all open threads in a merge request from an issue', :j
it 'hides the link for creating a new issue' do it 'hides the link for creating a new issue' do
expect(page).not_to have_selector resolve_all_discussions_link_selector expect(page).not_to have_selector resolve_all_discussions_link_selector
expect(page).not_to have_content "Resolve all threads in new issue"
end end
end end
...@@ -57,7 +62,7 @@ RSpec.describe 'Resolving all open threads in a merge request from an issue', :j ...@@ -57,7 +62,7 @@ RSpec.describe 'Resolving all open threads in a merge request from an issue', :j
end end
it 'does not show a link to create a new issue' do it 'does not show a link to create a new issue' do
expect(page).not_to have_link 'Create an issue to resolve them later' expect(page).not_to have_link 'Resolve all threads in new issue'
end end
end end
...@@ -67,18 +72,20 @@ RSpec.describe 'Resolving all open threads in a merge request from an issue', :j ...@@ -67,18 +72,20 @@ RSpec.describe 'Resolving all open threads in a merge request from an issue', :j
end end
it 'shows a warning that the merge request contains unresolved threads' do it 'shows a warning that the merge request contains unresolved threads' do
expect(page).to have_content 'There are unresolved threads.' expect(page).to have_content 'Before this can be merged,'
end end
it 'has a link to resolve all threads by creating an issue' do it 'has a link to resolve all threads by creating an issue' do
page.within '.mr-widget-body' do page.within '.mr-widget-body' do
expect(page).to have_link 'Create an issue to resolve them later', href: new_project_issue_path(project, merge_request_to_resolve_discussions_of: merge_request.iid) expect(page).to have_link 'Resolve all threads in new issue', href: new_project_issue_path(project, merge_request_to_resolve_discussions_of: merge_request.iid)
end end
end end
context 'creating an issue for threads' do context 'creating an issue for threads' do
before do before do
page.click_link 'Create an issue to resolve them later', href: new_project_issue_path(project, merge_request_to_resolve_discussions_of: merge_request.iid) page.within '.mr-widget-body' do
page.click_link 'Resolve all threads in new issue', href: new_project_issue_path(project, merge_request_to_resolve_discussions_of: merge_request.iid)
end
end end
it_behaves_like 'creating an issue for a thread' it_behaves_like 'creating an issue for a thread'
......
...@@ -21,7 +21,7 @@ RSpec.describe 'Merge request > User sees merge button depending on unresolved t ...@@ -21,7 +21,7 @@ RSpec.describe 'Merge request > User sees merge button depending on unresolved t
context 'with unresolved threads' do context 'with unresolved threads' do
it 'does not allow to merge' do it 'does not allow to merge' do
expect(page).not_to have_button 'Merge' expect(page).not_to have_button 'Merge'
expect(page).to have_content('There are unresolved threads.') expect(page).to have_content('Before this can be merged,')
end end
end end
......
/* global Mousetrap */ /* global Mousetrap */
import 'mousetrap'; import 'mousetrap';
import Vue from 'vue';
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import DiscussionKeyboardNavigator from '~/notes/components/discussion_keyboard_navigator.vue'; import DiscussionKeyboardNavigator from '~/notes/components/discussion_keyboard_navigator.vue';
import eventHub from '~/notes/event_hub';
describe('notes/components/discussion_keyboard_navigator', () => { describe('notes/components/discussion_keyboard_navigator', () => {
const localVue = createLocalVue(); const localVue = createLocalVue();
...@@ -29,10 +31,29 @@ describe('notes/components/discussion_keyboard_navigator', () => { ...@@ -29,10 +31,29 @@ describe('notes/components/discussion_keyboard_navigator', () => {
}); });
afterEach(() => { afterEach(() => {
wrapper.destroy(); if (wrapper) {
wrapper.destroy();
}
wrapper = null; wrapper = null;
}); });
describe('on create', () => {
let onSpy;
let vm;
beforeEach(() => {
onSpy = jest.spyOn(eventHub, '$on');
vm = new (Vue.extend(DiscussionKeyboardNavigator))();
});
it('listens for jumpToFirstUnresolvedDiscussion events', () => {
expect(onSpy).toHaveBeenCalledWith(
'jumpToFirstUnresolvedDiscussion',
vm.jumpToFirstUnresolvedDiscussion,
);
});
});
describe('on mount', () => { describe('on mount', () => {
beforeEach(() => { beforeEach(() => {
createComponent(); createComponent();
...@@ -52,11 +73,16 @@ describe('notes/components/discussion_keyboard_navigator', () => { ...@@ -52,11 +73,16 @@ describe('notes/components/discussion_keyboard_navigator', () => {
}); });
describe('on destroy', () => { describe('on destroy', () => {
let jumpFn;
beforeEach(() => { beforeEach(() => {
jest.spyOn(Mousetrap, 'unbind'); jest.spyOn(Mousetrap, 'unbind');
jest.spyOn(eventHub, '$off');
createComponent(); createComponent();
jumpFn = wrapper.vm.jumpToFirstUnresolvedDiscussion;
wrapper.destroy(); wrapper.destroy();
}); });
...@@ -65,6 +91,10 @@ describe('notes/components/discussion_keyboard_navigator', () => { ...@@ -65,6 +91,10 @@ describe('notes/components/discussion_keyboard_navigator', () => {
expect(Mousetrap.unbind).toHaveBeenCalledWith('p'); expect(Mousetrap.unbind).toHaveBeenCalledWith('p');
}); });
it('unbinds event hub listeners', () => {
expect(eventHub.$off).toHaveBeenCalledWith('jumpToFirstUnresolvedDiscussion', jumpFn);
});
it('does not call jumpToNextDiscussion when pressing `n`', () => { it('does not call jumpToNextDiscussion when pressing `n`', () => {
Mousetrap.trigger('n'); Mousetrap.trigger('n');
......
...@@ -66,6 +66,35 @@ describe('Discussion navigation mixin', () => { ...@@ -66,6 +66,35 @@ describe('Discussion navigation mixin', () => {
const findDiscussion = (selector, id) => const findDiscussion = (selector, id) =>
document.querySelector(`${selector}[data-discussion-id="${id}"]`); document.querySelector(`${selector}[data-discussion-id="${id}"]`);
describe('jumpToFirstUnresolvedDiscussion method', () => {
let vm;
beforeEach(() => {
createComponent();
({ vm } = wrapper);
jest.spyOn(store, 'dispatch');
jest.spyOn(vm, 'jumpToNextDiscussion');
});
it('triggers the setCurrentDiscussionId action with null as the value', () => {
vm.jumpToFirstUnresolvedDiscussion();
expect(store.dispatch).toHaveBeenCalledWith('setCurrentDiscussionId', null);
});
it('triggers the jumpToNextDiscussion action when the previous store action succeeds', () => {
store.dispatch.mockResolvedValue();
vm.jumpToFirstUnresolvedDiscussion();
return vm.$nextTick().then(() => {
expect(vm.jumpToNextDiscussion).toHaveBeenCalled();
});
});
});
describe('cycle through discussions', () => { describe('cycle through discussions', () => {
beforeEach(() => { beforeEach(() => {
window.mrTabs = { eventHub: createEventHub(), tabShown: jest.fn() }; window.mrTabs = { eventHub: createEventHub(), tabShown: jest.fn() };
......
import Vue from 'vue'; import { mount } from '@vue/test-utils';
import mountComponent from 'helpers/vue_mount_component_helper';
import UnresolvedDiscussions from '~/vue_merge_request_widget/components/states/unresolved_discussions.vue'; import UnresolvedDiscussions from '~/vue_merge_request_widget/components/states/unresolved_discussions.vue';
import notesEventHub from '~/notes/event_hub';
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
function createComponent({ path = '' } = {}) {
return mount(UnresolvedDiscussions, {
propsData: {
mr: {
createIssueToResolveDiscussionsPath: path,
},
},
});
}
describe('UnresolvedDiscussions', () => { describe('UnresolvedDiscussions', () => {
const Component = Vue.extend(UnresolvedDiscussions); let wrapper;
let vm;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => { afterEach(() => {
vm.$destroy(); wrapper.destroy();
});
it('triggers the correct notes event when the jump to first unresolved discussion button is clicked', () => {
jest.spyOn(notesEventHub, '$emit');
wrapper.find('[data-testid="jump-to-first"]').trigger('click');
expect(notesEventHub.$emit).toHaveBeenCalledWith('jumpToFirstUnresolvedDiscussion');
}); });
describe('with threads path', () => { describe('with threads path', () => {
beforeEach(() => { beforeEach(() => {
vm = mountComponent(Component, { wrapper = createComponent({ path: TEST_HOST });
mr: { });
createIssueToResolveDiscussionsPath: TEST_HOST,
}, afterEach(() => {
}); wrapper.destroy();
}); });
it('should have correct elements', () => { it('should have correct elements', () => {
expect(vm.$el.innerText).toContain( expect(wrapper.element.innerText).toContain(
'There are unresolved threads. Please resolve these threads', `Before this can be merged, one or more threads must be resolved.`,
); );
expect(vm.$el.innerText).toContain('Create an issue to resolve them later'); expect(wrapper.element.innerText).toContain('Jump to first unresolved thread');
expect(vm.$el.querySelector('.js-create-issue').getAttribute('href')).toEqual(TEST_HOST); expect(wrapper.element.innerText).toContain('Resolve all threads in new issue');
expect(wrapper.element.querySelector('.js-create-issue').getAttribute('href')).toEqual(
TEST_HOST,
);
}); });
}); });
describe('without threads path', () => { describe('without threads path', () => {
beforeEach(() => {
vm = mountComponent(Component, { mr: {} });
});
it('should not show create issue link if user cannot create issue', () => { it('should not show create issue link if user cannot create issue', () => {
expect(vm.$el.innerText).toContain( expect(wrapper.element.innerText).toContain(
'There are unresolved threads. Please resolve these threads', `Before this can be merged, one or more threads must be resolved.`,
); );
expect(vm.$el.querySelector('.js-create-issue')).toEqual(null); expect(wrapper.element.innerText).toContain('Jump to first unresolved thread');
expect(wrapper.element.innerText).not.toContain('Resolve all threads in new issue');
expect(wrapper.element.querySelector('.js-create-issue')).toEqual(null);
}); });
}); });
}); });
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