Commit d24d77a9 authored by Paul Slaughter's avatar Paul Slaughter

Resolve discussion when suggestion is applied

- Adds color and a tooltip to describe this new behavior
- Does not resolve if discussion is already resolved
- Adds an action `resolveDiscussion` to simplify `toggleResolveNote`
- Updates docs

https://gitlab.com/gitlab-org/gitlab-ce/issues/54405
parent dfe59c75
...@@ -83,10 +83,12 @@ export default { ...@@ -83,10 +83,12 @@ export default {
formCancelHandler(shouldConfirm, isDirty) { formCancelHandler(shouldConfirm, isDirty) {
this.$emit('cancelForm', shouldConfirm, isDirty); this.$emit('cancelForm', shouldConfirm, isDirty);
}, },
applySuggestion({ suggestionId, flashContainer, callback }) { applySuggestion({ suggestionId, flashContainer, callback = () => {} }) {
const { discussion_id: discussionId, id: noteId } = this.note; const { discussion_id: discussionId, id: noteId } = this.note;
this.submitSuggestion({ discussionId, noteId, suggestionId, flashContainer, callback }); return this.submitSuggestion({ discussionId, noteId, suggestionId, flashContainer }).then(
callback,
);
}, },
}, },
}; };
......
...@@ -142,6 +142,23 @@ export const createNewNote = ({ commit, dispatch }, { endpoint, data }) => ...@@ -142,6 +142,23 @@ export const createNewNote = ({ commit, dispatch }, { endpoint, data }) =>
export const removePlaceholderNotes = ({ commit }) => commit(types.REMOVE_PLACEHOLDER_NOTES); export const removePlaceholderNotes = ({ commit }) => commit(types.REMOVE_PLACEHOLDER_NOTES);
export const resolveDiscussion = ({ state, dispatch, getters }, { discussionId }) => {
const discussion = utils.findNoteObjectById(state.discussions, discussionId);
const isResolved = getters.isDiscussionResolved(discussionId);
if (!discussion) {
return Promise.reject();
} else if (isResolved) {
return Promise.resolve();
}
return dispatch('toggleResolveNote', {
endpoint: discussion.resolve_path,
isResolved,
discussion: true,
});
};
export const toggleResolveNote = ({ commit, dispatch }, { endpoint, isResolved, discussion }) => export const toggleResolveNote = ({ commit, dispatch }, { endpoint, isResolved, discussion }) =>
service service
.toggleResolveNote(endpoint, isResolved) .toggleResolveNote(endpoint, isResolved)
...@@ -420,15 +437,13 @@ export const updateResolvableDiscussonsCounts = ({ commit }) => ...@@ -420,15 +437,13 @@ export const updateResolvableDiscussonsCounts = ({ commit }) =>
commit(types.UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS); commit(types.UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS);
export const submitSuggestion = ( export const submitSuggestion = (
{ commit }, { commit, dispatch },
{ discussionId, noteId, suggestionId, flashContainer, callback }, { discussionId, noteId, suggestionId, flashContainer },
) => { ) =>
service service
.applySuggestion(suggestionId) .applySuggestion(suggestionId)
.then(() => { .then(() => commit(types.APPLY_SUGGESTION, { discussionId, noteId, suggestionId }))
commit(types.APPLY_SUGGESTION, { discussionId, noteId, suggestionId }); .then(() => dispatch('resolveDiscussion', { discussionId }).catch(() => {}))
callback();
})
.catch(err => { .catch(err => {
const defaultMessage = __( const defaultMessage = __(
'Something went wrong while applying the suggestion. Please try again.', 'Something went wrong while applying the suggestion. Please try again.',
...@@ -436,9 +451,7 @@ export const submitSuggestion = ( ...@@ -436,9 +451,7 @@ export const submitSuggestion = (
const flashMessage = err.response.data ? `${err.response.data.message}.` : defaultMessage; const flashMessage = err.response.data ? `${err.response.data.message}.` : defaultMessage;
Flash(__(flashMessage), 'alert', flashContainer); Flash(__(flashMessage), 'alert', flashContainer);
callback();
}); });
};
export const convertToDiscussion = ({ commit }, noteId) => export const convertToDiscussion = ({ commit }, noteId) =>
commit(types.CONVERT_TO_DISCUSSION, noteId); commit(types.CONVERT_TO_DISCUSSION, noteId);
......
<script> <script>
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import { GlButton, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
export default { export default {
components: { Icon }, components: { Icon, GlButton, GlLoadingIcon },
directives: { 'gl-tooltip': GlTooltipDirective },
props: { props: {
canApply: { canApply: {
type: Boolean, type: Boolean,
...@@ -21,7 +23,6 @@ export default { ...@@ -21,7 +23,6 @@ export default {
}, },
data() { data() {
return { return {
isAppliedSuccessfully: false,
isApplying: false, isApplying: false,
}; };
}, },
...@@ -47,14 +48,19 @@ export default { ...@@ -47,14 +48,19 @@ export default {
</a> </a>
</div> </div>
<span v-if="isApplied" class="badge badge-success">{{ __('Applied') }}</span> <span v-if="isApplied" class="badge badge-success">{{ __('Applied') }}</span>
<button <div v-if="isApplying" class="d-flex align-items-center text-secondary">
v-if="canApply" <gl-loading-icon class="d-flex-center mr-2" />
type="button" <span>{{ __('Applying suggestion') }}</span>
class="btn qa-apply-btn" </div>
<gl-button
v-else-if="canApply"
v-gl-tooltip.viewport="__('This also resolves the discussion')"
class="btn-inverted qa-apply-btn"
:disabled="isApplying" :disabled="isApplying"
variant="success"
@click="applySuggestion" @click="applySuggestion"
> >
{{ __('Apply suggestion') }} {{ __('Apply suggestion') }}
</button> </gl-button>
</div> </div>
</template> </template>
---
title: Resolve discussion when apply suggestion
merge_request: 28160
author:
type: changed
...@@ -401,13 +401,10 @@ the Merge Request authored by the user that applied them. ...@@ -401,13 +401,10 @@ the Merge Request authored by the user that applied them.
![Apply suggestions](img/suggestion.png) ![Apply suggestions](img/suggestion.png)
> **Note:**
Discussions are _not_ automatically resolved. Will be introduced by
[#54405](https://gitlab.com/gitlab-org/gitlab-ce/issues/54405).
Once the author applies a suggestion, it will be marked with the **Applied** label, Once the author applies a suggestion, it will be marked with the **Applied** label,
and GitLab will create a new commit with the message `Apply suggestion to <file-name>` the discussion will be automatically resolved, and GitLab will create a new commit
and push the suggested change directly into the codebase in the merge request's branch. with the message `Apply suggestion to <file-name>` and push the suggested change
directly into the codebase in the merge request's branch.
[Developer permission](../permissions.md) is required to do so. [Developer permission](../permissions.md) is required to do so.
> **Note:** > **Note:**
......
...@@ -1024,6 +1024,9 @@ msgstr "" ...@@ -1024,6 +1024,9 @@ msgstr ""
msgid "Applying multiple commands" msgid "Applying multiple commands"
msgstr "" msgstr ""
msgid "Applying suggestion"
msgstr ""
msgid "Apr" msgid "Apr"
msgstr "" msgstr ""
...@@ -9517,6 +9520,9 @@ msgstr "" ...@@ -9517,6 +9520,9 @@ msgstr ""
msgid "This action can lead to data loss. To prevent accidental actions we ask you to confirm your intention." msgid "This action can lead to data loss. To prevent accidental actions we ask you to confirm your intention."
msgstr "" msgstr ""
msgid "This also resolves the discussion"
msgstr ""
msgid "This application was created by %{link_to_owner}." msgid "This application was created by %{link_to_owner}."
msgstr "" msgstr ""
......
...@@ -121,7 +121,7 @@ describe 'User comments on a diff', :js do ...@@ -121,7 +121,7 @@ describe 'User comments on a diff', :js do
end end
context 'multi-line suggestions' do context 'multi-line suggestions' do
it 'suggestion is presented' do before do
click_diff_line(find("[id='#{sample_compare.changes[1][:line_code]}']")) click_diff_line(find("[id='#{sample_compare.changes[1][:line_code]}']"))
page.within('.js-discussion-note-form') do page.within('.js-discussion-note-form') do
...@@ -130,7 +130,9 @@ describe 'User comments on a diff', :js do ...@@ -130,7 +130,9 @@ describe 'User comments on a diff', :js do
end end
wait_for_requests wait_for_requests
end
it 'suggestion is presented' do
page.within('.diff-discussions') do page.within('.diff-discussions') do
expect(page).to have_button('Apply suggestion') expect(page).to have_button('Apply suggestion')
expect(page).to have_content('Suggested change') expect(page).to have_content('Suggested change')
...@@ -160,22 +162,24 @@ describe 'User comments on a diff', :js do ...@@ -160,22 +162,24 @@ describe 'User comments on a diff', :js do
end end
it 'suggestion is appliable' do it 'suggestion is appliable' do
click_diff_line(find("[id='#{sample_compare.changes[1][:line_code]}']")) page.within('.diff-discussions') do
expect(page).not_to have_content('Applied')
page.within('.js-discussion-note-form') do
fill_in('note_note', with: "```suggestion:-3+5\n# change to a\n# comment\n# with\n# broken\n# lines\n```")
click_button('Comment')
end
click_button('Apply suggestion')
wait_for_requests wait_for_requests
expect(page).to have_content('Applied')
end
end
it 'resolves discussion when applied' do
page.within('.diff-discussions') do page.within('.diff-discussions') do
expect(page).not_to have_content('Applied') expect(page).not_to have_content('Unresolve discussion')
click_button('Apply suggestion') click_button('Apply suggestion')
wait_for_requests wait_for_requests
expect(page).to have_content('Applied') expect(page).to have_content('Unresolve discussion')
end end
end end
end end
......
import { GlLoadingIcon } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import SuggestionDiffHeader from '~/vue_shared/components/markdown/suggestion_diff_header.vue';
const localVue = createLocalVue();
const DEFAULT_PROPS = {
canApply: true,
isApplied: false,
helpPagePath: 'path_to_docs',
};
describe('Suggestion Diff component', () => {
let wrapper;
const createComponent = props => {
wrapper = shallowMount(localVue.extend(SuggestionDiffHeader), {
propsData: {
...DEFAULT_PROPS,
...props,
},
localVue,
sync: false,
});
};
afterEach(() => {
wrapper.destroy();
});
const findApplyButton = () => wrapper.find('.qa-apply-btn');
const findHeader = () => wrapper.find('.qa-suggestion-diff-header');
const findHelpButton = () => wrapper.find('.js-help-btn');
const findLoading = () => wrapper.find(GlLoadingIcon);
it('renders a suggestion header', () => {
createComponent();
const header = findHeader();
expect(header.exists()).toBe(true);
expect(header.html().includes('Suggested change')).toBe(true);
});
it('renders a help button', () => {
createComponent();
expect(findHelpButton().exists()).toBe(true);
});
it('renders an apply button', () => {
createComponent();
const applyBtn = findApplyButton();
expect(applyBtn.exists()).toBe(true);
expect(applyBtn.html().includes('Apply suggestion')).toBe(true);
});
it('does not render an apply button if `canApply` is set to false', () => {
createComponent({ canApply: false });
expect(findApplyButton().exists()).toBe(false);
});
describe('when apply suggestion is clicked', () => {
beforeEach(done => {
createComponent();
findApplyButton().vm.$emit('click');
wrapper.vm.$nextTick(done);
});
it('emits apply', () => {
expect(wrapper.emittedByOrder()).toEqual([{ name: 'apply', args: [expect.any(Function)] }]);
});
it('hides apply button', () => {
expect(findApplyButton().exists()).toBe(false);
});
it('shows loading', () => {
expect(findLoading().exists()).toBe(true);
expect(wrapper.text()).toContain('Applying suggestion');
});
it('when callback of apply is called, hides loading', done => {
const [callback] = wrapper.emitted().apply[0];
callback();
wrapper.vm
.$nextTick()
.then(() => {
expect(findApplyButton().exists()).toBe(true);
expect(findLoading().exists()).toBe(false);
})
.then(done)
.catch(done.fail);
});
});
});
...@@ -3,11 +3,12 @@ import $ from 'jquery'; ...@@ -3,11 +3,12 @@ import $ from 'jquery';
import _ from 'underscore'; import _ from 'underscore';
import { TEST_HOST } from 'spec/test_constants'; import { TEST_HOST } from 'spec/test_constants';
import { headersInterceptor } from 'spec/helpers/vue_resource_helper'; import { headersInterceptor } from 'spec/helpers/vue_resource_helper';
import * as actions from '~/notes/stores/actions'; import actionsModule, * as actions from '~/notes/stores/actions';
import * as mutationTypes from '~/notes/stores/mutation_types'; import * as mutationTypes from '~/notes/stores/mutation_types';
import * as notesConstants from '~/notes/constants'; import * as notesConstants from '~/notes/constants';
import createStore from '~/notes/stores'; import createStore from '~/notes/stores';
import mrWidgetEventHub from '~/vue_merge_request_widget/event_hub'; import mrWidgetEventHub from '~/vue_merge_request_widget/event_hub';
import service from '~/notes/services/notes_service';
import testAction from '../../helpers/vuex_action_helper'; import testAction from '../../helpers/vuex_action_helper';
import { resetStore } from '../helpers'; import { resetStore } from '../helpers';
import { import {
...@@ -18,11 +19,21 @@ import { ...@@ -18,11 +19,21 @@ import {
individualNote, individualNote,
} from '../mock_data'; } from '../mock_data';
const TEST_ERROR_MESSAGE = 'Test error message';
describe('Actions Notes Store', () => { describe('Actions Notes Store', () => {
let commit;
let dispatch;
let state;
let store; let store;
let flashSpy;
beforeEach(() => { beforeEach(() => {
store = createStore(); store = createStore();
commit = jasmine.createSpy('commit');
dispatch = jasmine.createSpy('dispatch');
state = {};
flashSpy = spyOnDependency(actionsModule, 'Flash');
}); });
afterEach(() => { afterEach(() => {
...@@ -604,21 +615,6 @@ describe('Actions Notes Store', () => { ...@@ -604,21 +615,6 @@ describe('Actions Notes Store', () => {
}); });
describe('updateOrCreateNotes', () => { describe('updateOrCreateNotes', () => {
let commit;
let dispatch;
let state;
beforeEach(() => {
commit = jasmine.createSpy('commit');
dispatch = jasmine.createSpy('dispatch');
state = {};
});
afterEach(() => {
commit.calls.reset();
dispatch.calls.reset();
});
it('Updates existing note', () => { it('Updates existing note', () => {
const note = { id: 1234 }; const note = { id: 1234 };
const getters = { notesById: { 1234: note } }; const getters = { notesById: { 1234: note } };
...@@ -751,4 +747,106 @@ describe('Actions Notes Store', () => { ...@@ -751,4 +747,106 @@ describe('Actions Notes Store', () => {
); );
}); });
}); });
describe('resolveDiscussion', () => {
let getters;
let discussionId;
beforeEach(() => {
discussionId = discussionMock.id;
state.discussions = [discussionMock];
getters = {
isDiscussionResolved: () => false,
};
});
it('when unresolved, dispatches action', done => {
testAction(
actions.resolveDiscussion,
{ discussionId },
{ ...state, ...getters },
[],
[
{
type: 'toggleResolveNote',
payload: {
endpoint: discussionMock.resolve_path,
isResolved: false,
discussion: true,
},
},
],
done,
);
});
it('when resolved, does nothing', done => {
getters.isDiscussionResolved = id => id === discussionId;
testAction(
actions.resolveDiscussion,
{ discussionId },
{ ...state, ...getters },
[],
[],
done,
);
});
});
describe('submitSuggestion', () => {
const discussionId = 'discussion-id';
const noteId = 'note-id';
const suggestionId = 'suggestion-id';
let flashContainer;
beforeEach(() => {
spyOn(service, 'applySuggestion');
dispatch.and.returnValue(Promise.resolve());
service.applySuggestion.and.returnValue(Promise.resolve());
flashContainer = {};
});
const testSubmitSuggestion = (done, expectFn) => {
actions
.submitSuggestion(
{ commit, dispatch },
{ discussionId, noteId, suggestionId, flashContainer },
)
.then(expectFn)
.then(done)
.catch(done.fail);
};
it('when service success, commits and resolves discussion', done => {
testSubmitSuggestion(done, () => {
expect(commit.calls.allArgs()).toEqual([
[mutationTypes.APPLY_SUGGESTION, { discussionId, noteId, suggestionId }],
]);
expect(dispatch.calls.allArgs()).toEqual([['resolveDiscussion', { discussionId }]]);
expect(flashSpy).not.toHaveBeenCalled();
});
});
it('when service fails, flashes error message', done => {
const response = { response: { data: { message: TEST_ERROR_MESSAGE } } };
service.applySuggestion.and.returnValue(Promise.reject(response));
testSubmitSuggestion(done, () => {
expect(commit).not.toHaveBeenCalled();
expect(dispatch).not.toHaveBeenCalled();
expect(flashSpy).toHaveBeenCalledWith(`${TEST_ERROR_MESSAGE}.`, 'alert', flashContainer);
});
});
it('when resolve discussion fails, fail gracefully', done => {
dispatch.and.returnValue(Promise.reject());
testSubmitSuggestion(done, () => {
expect(flashSpy).not.toHaveBeenCalled();
});
});
});
}); });
import Vue from 'vue';
import SuggestionDiffHeaderComponent from '~/vue_shared/components/markdown/suggestion_diff_header.vue';
const MOCK_DATA = {
canApply: true,
isApplied: false,
helpPagePath: 'path_to_docs',
};
describe('Suggestion Diff component', () => {
let vm;
function createComponent(propsData) {
const Component = Vue.extend(SuggestionDiffHeaderComponent);
return new Component({
propsData,
}).$mount();
}
beforeEach(done => {
vm = createComponent(MOCK_DATA);
Vue.nextTick(done);
});
describe('init', () => {
it('renders a suggestion header', () => {
const header = vm.$el.querySelector('.qa-suggestion-diff-header');
expect(header).not.toBeNull();
expect(header.innerHTML.includes('Suggested change')).toBe(true);
});
it('renders a help button', () => {
const helpBtn = vm.$el.querySelector('.js-help-btn');
expect(helpBtn).not.toBeNull();
});
it('renders an apply button', () => {
const applyBtn = vm.$el.querySelector('.qa-apply-btn');
expect(applyBtn).not.toBeNull();
expect(applyBtn.innerHTML.includes('Apply suggestion')).toBe(true);
});
it('does not render an apply button if `canApply` is set to false', () => {
const props = Object.assign(MOCK_DATA, { canApply: false });
vm = createComponent(props);
expect(vm.$el.querySelector('.qa-apply-btn')).toBeNull();
});
});
describe('applySuggestion', () => {
it('emits when the apply button is clicked', () => {
const props = Object.assign(MOCK_DATA, { canApply: true });
vm = createComponent(props);
spyOn(vm, '$emit');
vm.applySuggestion();
expect(vm.$emit).toHaveBeenCalled();
});
it('does not emit when the canApply is set to false', () => {
spyOn(vm, '$emit');
vm.canApply = false;
vm.applySuggestion();
expect(vm.$emit).not.toHaveBeenCalled();
});
});
});
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