Commit 2407eb4c authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch 'cngo-fix-issue-blocked-modal' into 'master'

Fix issue "blocked by" modal

See merge request gitlab-org/gitlab!48273
parents d8e8d666 9b9dbd4f
<script>
import { GlButton, GlDropdown, GlDropdownItem, GlIcon, GlLink, GlModal } from '@gitlab/ui';
import { mapGetters } from 'vuex';
import { mapActions, mapGetters, mapState } from 'vuex';
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 eventHub from '~/notes/event_hub';
import promoteToEpicMutation from '../queries/promote_to_epic.mutation.graphql';
import updateIssueMutation from '../queries/update_issue.mutation.graphql';
......@@ -72,15 +73,11 @@ export default {
default: '',
},
},
data() {
return {
isUpdatingState: false,
};
},
computed: {
...mapGetters(['getNoteableData']),
...mapState(['isToggleStateButtonLoading']),
...mapGetters(['openState', 'getBlockedByIssues']),
isClosed() {
return this.getNoteableData.state === IssuableStatus.Closed;
return this.openState === IssuableStatus.Closed;
},
buttonText() {
return this.isClosed
......@@ -107,9 +104,16 @@ export default {
return canClose || canReopen;
},
},
created() {
eventHub.$on('toggle.issuable.state', this.toggleIssueState);
},
beforeDestroy() {
eventHub.$off('toggle.issuable.state', this.toggleIssueState);
},
methods: {
...mapActions(['toggleStateButtonLoading']),
toggleIssueState() {
if (!this.isClosed && this.getNoteableData?.blocked_by_issues?.length) {
if (!this.isClosed && this.getBlockedByIssues.length) {
this.$refs.blockedByIssuesModal.show();
return;
}
......@@ -117,7 +121,7 @@ export default {
this.invokeUpdateIssueMutation();
},
invokeUpdateIssueMutation() {
this.isUpdatingState = true;
this.toggleStateButtonLoading(true);
this.$apollo
.mutate({
......@@ -148,11 +152,11 @@ export default {
})
.catch(() => createFlash({ message: __('Update failed. Please try again.') }))
.finally(() => {
this.isUpdatingState = false;
this.toggleStateButtonLoading(false);
});
},
promoteToEpic() {
this.isUpdatingState = true;
this.toggleStateButtonLoading(true);
this.$apollo
.mutate({
......@@ -179,7 +183,7 @@ export default {
})
.catch(() => createFlash({ message: this.$options.i18n.promoteErrorMessage }))
.finally(() => {
this.isUpdatingState = false;
this.toggleStateButtonLoading(false);
});
},
},
......@@ -191,7 +195,7 @@ export default {
<gl-dropdown class="gl-display-block gl-display-sm-none!" block :text="dropdownText">
<gl-dropdown-item
v-if="showToggleIssueStateButton"
:disabled="isUpdatingState"
:disabled="isToggleStateButtonLoading"
@click="toggleIssueState"
>
{{ buttonText }}
......@@ -199,7 +203,11 @@ export default {
<gl-dropdown-item v-if="canCreateIssue" :href="newIssuePath">
{{ newIssueTypeText }}
</gl-dropdown-item>
<gl-dropdown-item v-if="canPromoteToEpic" :disabled="isUpdatingState" @click="promoteToEpic">
<gl-dropdown-item
v-if="canPromoteToEpic"
:disabled="isToggleStateButtonLoading"
@click="promoteToEpic"
>
{{ __('Promote to epic') }}
</gl-dropdown-item>
<gl-dropdown-item v-if="!isIssueAuthor" :href="reportAbusePath">
......@@ -220,7 +228,7 @@ export default {
class="gl-display-none gl-display-sm-inline-flex!"
category="secondary"
:data-qa-selector="qaSelector"
:loading="isUpdatingState"
:loading="isToggleStateButtonLoading"
:variant="buttonVariant"
@click="toggleIssueState"
>
......@@ -243,7 +251,7 @@ export default {
</gl-dropdown-item>
<gl-dropdown-item
v-if="canPromoteToEpic"
:disabled="isUpdatingState"
:disabled="isToggleStateButtonLoading"
data-testid="promote-button"
@click="promoteToEpic"
>
......@@ -272,7 +280,7 @@ export default {
>
<p>{{ __('This issue is currently blocked by the following issues:') }}</p>
<ul>
<li v-for="issue in getNoteableData.blocked_by_issues" :key="issue.iid">
<li v-for="issue in getBlockedByIssues" :key="issue.iid">
<gl-link :href="issue.web_url">#{{ issue.iid }}</gl-link>
</li>
</ul>
......
......@@ -3,23 +3,23 @@ import $ from 'jquery';
import { mapActions, mapGetters, mapState } from 'vuex';
import { isEmpty } from 'lodash';
import Autosize from 'autosize';
import { GlAlert, GlIntersperse, GlLink, GlSprintf, GlButton, GlIcon } from '@gitlab/ui';
import { GlButton, GlIcon } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import { deprecatedCreateFlash as Flash } from '../../flash';
import Autosave from '../../autosave';
import { deprecatedCreateFlash as Flash } from '~/flash';
import Autosave from '~/autosave';
import {
capitalizeFirstCharacter,
convertToCamelCase,
splitCamelCase,
slugifyWithUnderscore,
} from '../../lib/utils/text_utility';
} from '~/lib/utils/text_utility';
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import * as constants from '../constants';
import eventHub from '../event_hub';
import NoteableWarning from '../../vue_shared/components/notes/noteable_warning.vue';
import markdownField from '../../vue_shared/components/markdown/field.vue';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import NoteableWarning from '~/vue_shared/components/notes/noteable_warning.vue';
import markdownField from '~/vue_shared/components/markdown/field.vue';
import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import noteSignedOutWidget from './note_signed_out_widget.vue';
import discussionLockedWidget from './discussion_locked_widget.vue';
import issuableStateMixin from '../mixins/issuable_state';
......@@ -34,10 +34,6 @@ export default {
userAvatarLink,
GlButton,
TimelineEntryItem,
GlAlert,
GlIntersperse,
GlLink,
GlSprintf,
GlIcon,
},
mixins: [issuableStateMixin],
......@@ -63,9 +59,8 @@ export default {
'getNoteableDataByProp',
'getNotesData',
'openState',
'getBlockedByIssues',
]),
...mapState(['isToggleStateButtonLoading', 'isToggleBlockedIssueWarning']),
...mapState(['isToggleStateButtonLoading']),
noteableDisplayName() {
return splitCamelCase(this.noteableType).toLowerCase();
},
......@@ -143,8 +138,8 @@ export default {
? __('merge request')
: __('issue');
},
isIssueType() {
return this.noteableDisplayName === constants.ISSUE_NOTEABLE_TYPE;
isMergeRequest() {
return this.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE;
},
trackingLabel() {
return slugifyWithUnderscore(`${this.commentButtonTitle} button`);
......@@ -172,11 +167,9 @@ export default {
'stopPolling',
'restartPolling',
'removePlaceholderNotes',
'closeIssue',
'reopenIssue',
'closeMergeRequest',
'reopenMergeRequest',
'toggleIssueLocalState',
'toggleStateButtonLoading',
'toggleBlockedIssueWarning',
]),
setIsSubmitButtonDisabled(note, isSubmitting) {
if (!isEmpty(note) && !isSubmitting) {
......@@ -186,8 +179,6 @@ export default {
}
},
handleSave(withIssueAction) {
this.isSubmitting = true;
if (this.note.length) {
const noteData = {
endpoint: this.endpoint,
......@@ -210,9 +201,10 @@ export default {
this.resizeTextarea();
this.stopPolling();
this.isSubmitting = true;
this.saveNote(noteData)
.then(() => {
this.enableButton();
this.restartPolling();
this.discard();
......@@ -221,7 +213,6 @@ export default {
}
})
.catch(() => {
this.enableButton();
this.discard(false);
const msg = __(
'Your comment could not be submitted! Please check your network connection and try again.',
......@@ -229,64 +220,31 @@ export default {
Flash(msg, 'alert', this.$el);
this.note = noteData.data.note.note; // Restore textarea content.
this.removePlaceholderNotes();
})
.finally(() => {
this.isSubmitting = false;
});
} else {
this.toggleIssueState();
}
},
enableButton() {
this.isSubmitting = false;
},
toggleIssueState() {
if (
this.noteableType.toLowerCase() === constants.ISSUE_NOTEABLE_TYPE &&
this.isOpen &&
this.getBlockedByIssues &&
this.getBlockedByIssues.length > 0
) {
this.toggleBlockedIssueWarning(true);
if (!this.isMergeRequest) {
eventHub.$emit('toggle.issuable.state');
return;
}
if (this.isOpen) {
this.forceCloseIssue();
} else {
this.reopenIssue()
.then(() => {
this.enableButton();
refreshUserMergeRequestCounts();
})
.catch(({ data }) => {
this.enableButton();
this.toggleStateButtonLoading(false);
let errorMessage = sprintf(
__('Something went wrong while reopening the %{issuable}. Please try again later'),
{ issuable: this.noteableDisplayName },
);
if (data) {
errorMessage = Object.values(data).join('\n');
}
const toggleMergeRequestState = this.isOpen
? this.closeMergeRequest
: this.reopenMergeRequest;
Flash(errorMessage);
});
}
},
forceCloseIssue() {
this.closeIssue()
.then(() => {
this.enableButton();
refreshUserMergeRequestCounts();
})
.catch(() => {
this.enableButton();
this.toggleStateButtonLoading(false);
Flash(
sprintf(
__('Something went wrong while closing the %{issuable}. Please try again later'),
{ issuable: this.noteableDisplayName },
),
);
});
const errorMessage = this.isOpen
? __('Something went wrong while closing the merge request. Please try again later')
: __('Something went wrong while reopening the merge request. Please try again later');
toggleMergeRequestState()
.then(refreshUserMergeRequestCounts)
.catch(() => Flash(errorMessage));
},
discard(shouldClear = true) {
// `blur` is needed to clear slash commands autocomplete cache if event fired.
......@@ -384,6 +342,7 @@ export default {
name="note[note]"
class="note-textarea js-vue-comment-form js-note-text js-gfm-input js-autosize markdown-area"
data-qa-selector="comment_field"
data-testid="comment-field"
data-supports-quick-actions="true"
:aria-label="__('Description')"
:placeholder="__('Write a comment or drag your files here…')"
......@@ -392,36 +351,7 @@ export default {
@keydown.ctrl.enter="handleSave()"
></textarea>
</markdown-field>
<gl-alert
v-if="isToggleBlockedIssueWarning"
class="gl-mt-5"
:title="__('Are you sure you want to close this blocked issue?')"
:primary-button-text="__('Yes, close issue')"
:secondary-button-text="__('Cancel')"
variant="warning"
:dismissible="false"
@primaryAction="toggleBlockedIssueWarning(false) && forceCloseIssue()"
@secondaryAction="toggleBlockedIssueWarning(false) && enableButton()"
>
<p>
<gl-sprintf
:message="
__('This issue is currently blocked by the following issues: %{issues}.')
"
>
<template #issues>
<gl-intersperse>
<gl-link
v-for="blockingIssue in getBlockedByIssues"
:key="blockingIssue.web_url"
:href="blockingIssue.web_url"
>#{{ blockingIssue.iid }}</gl-link
>
</gl-intersperse>
</template>
</gl-sprintf>
</p>
</gl-alert>
<div class="note-form-actions">
<div
class="btn-group gl-mr-3 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
......@@ -430,6 +360,7 @@ export default {
:disabled="isSubmitButtonDisabled"
class="js-comment-button js-comment-submit-button"
data-qa-selector="comment_button"
data-testid="comment-button"
type="submit"
category="primary"
variant="success"
......@@ -488,15 +419,13 @@ export default {
</div>
<gl-button
v-if="canToggleIssueState && !isToggleBlockedIssueWarning"
v-if="canToggleIssueState"
:loading="isToggleStateButtonLoading"
category="secondary"
:variant="buttonVariant"
:class="[
actionButtonClassNames,
'btn-comment btn-comment-and-close js-action-button',
]"
:disabled="isToggleStateButtonLoading || isSubmitting"
:class="[actionButtonClassNames, 'btn-comment btn-comment-and-close']"
:disabled="isSubmitting"
data-testid="close-reopen-button"
@click="handleSave(true)"
>{{ issueActionButtonTitle }}</gl-button
>
......
......@@ -244,21 +244,7 @@ export const toggleResolveNote = ({ commit, dispatch }, { endpoint, isResolved,
});
};
export const toggleBlockedIssueWarning = ({ commit }, value) => {
commit(types.TOGGLE_BLOCKED_ISSUE_WARNING, value);
// Hides Close issue button at the top of issue page
const closeDropdown = document.querySelector('.js-issuable-close-dropdown');
if (closeDropdown) {
closeDropdown.classList.toggle('d-none');
} else {
const closeButton = document.querySelector(
'.detail-page-header-actions .btn-close.btn-grouped',
);
closeButton.classList.toggle('d-md-block');
}
};
export const closeIssue = ({ commit, dispatch, state }) => {
export const closeMergeRequest = ({ commit, dispatch, state }) => {
dispatch('toggleStateButtonLoading', true);
return axios.put(state.notesData.closePath).then(({ data }) => {
commit(types.CLOSE_ISSUE);
......@@ -267,7 +253,7 @@ export const closeIssue = ({ commit, dispatch, state }) => {
});
};
export const reopenIssue = ({ commit, dispatch, state }) => {
export const reopenMergeRequest = ({ commit, dispatch, state }) => {
dispatch('toggleStateButtonLoading', true);
return axios.put(state.notesData.reopenPath).then(({ data }) => {
commit(types.REOPEN_ISSUE);
......
......@@ -26,7 +26,6 @@ export default () => ({
// View layer
isToggleStateButtonLoading: false,
isToggleBlockedIssueWarning: false,
isNotesFetched: false,
isLoading: true,
isLoadingDescriptionVersion: false,
......
......@@ -44,7 +44,6 @@ export const SET_RESOLVING_DISCUSSION = 'SET_RESOLVING_DISCUSSION';
export const CLOSE_ISSUE = 'CLOSE_ISSUE';
export const REOPEN_ISSUE = 'REOPEN_ISSUE';
export const TOGGLE_STATE_BUTTON_LOADING = 'TOGGLE_STATE_BUTTON_LOADING';
export const TOGGLE_BLOCKED_ISSUE_WARNING = 'TOGGLE_BLOCKED_ISSUE_WARNING';
export const SET_ISSUE_CONFIDENTIAL = 'SET_ISSUE_CONFIDENTIAL';
export const SET_ISSUABLE_LOCK = 'SET_ISSUABLE_LOCK';
......
......@@ -305,10 +305,6 @@ export default {
Object.assign(state, { isToggleStateButtonLoading: value });
},
[types.TOGGLE_BLOCKED_ISSUE_WARNING](state, value) {
Object.assign(state, { isToggleBlockedIssueWarning: value });
},
[types.SET_NOTES_FETCHED_STATE](state, value) {
Object.assign(state, { isNotesFetched: value });
},
......
......@@ -88,7 +88,7 @@ export default {
};
</script>
<template>
<div class="issuable-note-warning">
<div class="issuable-note-warning" data-testid="confidential-warning">
<gl-icon v-if="!isLockedAndConfidential" :name="warningIcon" :size="16" class="icon inline" />
<span v-if="isLockedAndConfidential" ref="lockedAndConfidential">
......
---
title: Fix issue blocked-by modal
merge_request: 48273
author:
type: fixed
......@@ -14,13 +14,39 @@ RSpec.describe 'Related issues', :js do
let_it_be(:issue_project_b_a) { create(:issue, project: project_b) }
let_it_be(:issue_project_unauthorized_a) { create(:issue, project: project_unauthorized) }
shared_examples 'issue closed by modal' do |selector|
it 'shows a modal to confirm closing the issue' do
# Workaround for modal not showing when issue is first added
visit project_issue_path(project, issue_a)
wait_for_requests
within(selector) do
click_button 'Close issue'
end
within('.modal-content', visible: true) do
expect(page).to have_text 'Are you sure you want to close this blocked issue?'
expect(page).to have_link("##{issue_b.iid}", href: project_issue_path(project, issue_b))
click_button 'Yes, close issue'
end
wait_for_requests
expect(page).not_to have_selector('.modal-content', visible: true)
within(first('.status-box', visible: :all)) do
expect(page).to have_text 'Closed'
end
end
end
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)
sign_in(user)
end
context 'with "Relates to", "Blocks", "Is blocked by" groupings' do
......@@ -97,29 +123,12 @@ RSpec.describe 'Related issues', :js do
expect(find('.js-related-issues-header-issue-count')).to have_content('1')
end
it 'hides the modal when issue is closed' do
# Workaround for modal not showing when issue is first added
visit project_issue_path(project, issue_a)
wait_for_requests
within('.new-note') do
button = find(:button, 'Close issue')
scroll_to(button)
button.click
end
click_button 'Yes, close issue'
wait_for_requests
find(:button, 'Yes, close issue', visible: false)
status_box = first('.status-box', visible: :all)
scroll_to(status_box)
context 'when clicking the top `Close issue` button in the issue header', :aggregate_failures do
it_behaves_like 'issue closed by modal', '.detail-page-header'
end
within(status_box) do
expect(page).to have_content 'Closed'
end
context 'when clicking the bottom `Close issue` button below the comment textarea', :aggregate_failures do
it_behaves_like 'issue closed by modal', '.new-note'
end
end
......
/* eslint-disable one-var */
import MockAdapter from 'axios-mock-adapter';
import $ from 'jquery';
import Issue from '~/issue';
import axios from '~/lib/utils/axios_utils';
import '~/lib/utils/text_utility';
describe('Issue', () => {
let testContext;
beforeEach(() => {
testContext = {};
});
let $btn, $dropdown, $alert, $boxOpen, $boxClosed;
preloadFixtures('ee/issues/blocked-issue.html');
describe('with blocked issue', () => {
let mock;
function setup() {
testContext.issue = new Issue();
testContext.$projectIssuesCounter = $('.issue_counter').first();
testContext.$projectIssuesCounter.text('1,001');
}
function mockCloseButtonResponseSuccess(url, response) {
mock.onPut(url).reply(() => [200, response]);
}
beforeEach(() => {
loadFixtures('ee/issues/blocked-issue.html');
mock = new MockAdapter(axios);
mock.onGet(/(.*)\/related_branches$/).reply(200, {});
jest.spyOn(axios, 'get');
});
afterEach(() => {
mock.restore();
});
it(`displays warning when attempting to close the issue`, done => {
setup();
$btn = $('.js-issuable-close-button');
$dropdown = $('.js-issuable-close-dropdown ');
$alert = $('.js-close-blocked-issue-warning');
expect($btn).toExist();
expect($btn).toHaveClass('btn-issue-blocked');
expect($dropdown).not.toHaveClass('hidden');
expect($alert).toHaveClass('hidden');
testContext.$triggeredButton = $btn;
testContext.$triggeredButton.trigger('click');
setImmediate(() => {
expect($alert).not.toHaveClass('hidden');
expect($dropdown).toHaveClass('hidden');
done();
});
});
it(`hides warning when cancelling closing the issue`, done => {
setup();
$btn = $('.js-issuable-close-button');
$alert = $('.js-close-blocked-issue-warning');
testContext.$triggeredButton = $btn;
testContext.$triggeredButton.trigger('click');
setImmediate(() => {
expect($alert).not.toHaveClass('hidden');
const $cancelbtn = $('.js-close-blocked-issue-warning .js-cancel-blocked-issue-warning');
$cancelbtn.trigger('click');
expect($alert).toHaveClass('hidden');
done();
});
});
it('closes the issue when clicking alert close button', done => {
$btn = $('.js-issuable-close-button');
$boxOpen = $('div.status-box-open');
$boxClosed = $('div.status-box-issue-closed');
expect($boxOpen).not.toHaveClass('hidden');
expect($boxOpen).toHaveText('Open');
expect($boxClosed).toHaveClass('hidden');
testContext.$triggeredButton = $btn;
mockCloseButtonResponseSuccess(testContext.$triggeredButton.data('endpoint'), {
id: 34,
});
setup();
testContext.$triggeredButton.trigger('click');
const $btnCloseAnyway = $('.js-close-blocked-issue-warning .btn-close-anyway');
$btnCloseAnyway.trigger('click');
setImmediate(() => {
expect($btn).toHaveText('Reopen');
expect($boxOpen).toHaveClass('hidden');
expect($boxClosed).not.toHaveClass('hidden');
expect($boxClosed).toHaveText('Closed');
done();
});
});
});
});
import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import CommentForm from '~/notes/components/comment_form.vue';
import createStore from '~/notes/stores';
import {
notesDataMock,
userDataMock,
noteableDataMock,
} from '../../../../../spec/frontend/notes/mock_data';
jest.mock('autosize');
jest.mock('~/commons/nav/user_merge_requests');
jest.mock('~/gl_form');
describe('issue_comment_form component', () => {
let store;
let wrapper;
let axiosMock;
const setupStore = (userData, noteableData) => {
store.dispatch('setUserData', userData);
store.dispatch('setNoteableData', noteableData);
store.dispatch('setNotesData', notesDataMock);
};
const mountComponent = (noteableType = 'issue') => {
wrapper = mount(CommentForm, {
propsData: {
noteableType,
},
store,
});
};
const findCloseBtn = () => wrapper.find('.btn-comment-and-close');
beforeEach(() => {
axiosMock = new MockAdapter(axios);
store = createStore();
// This is necessary as we query Close issue button at the top of issue page when clicking bottom button
setFixtures(
'<div class="detail-page-header-actions"><button class="btn-close btn-grouped"></button></div>',
);
});
afterEach(() => {
axiosMock.restore();
wrapper.destroy();
});
describe('when issue is not blocked by other issues', () => {
beforeEach(() => {
setupStore(userDataMock, noteableDataMock);
mountComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('should close the issue when clicking close issue button', done => {
jest.spyOn(wrapper.vm, 'closeIssue').mockResolvedValue();
findCloseBtn().trigger('click');
wrapper.vm.$nextTick(() => {
expect(wrapper.vm.closeIssue).toHaveBeenCalled();
done();
});
});
});
describe('when issue is blocked by other issues', () => {
let noteableDataMockBlocked;
beforeEach(() => {
noteableDataMockBlocked = Object.assign(noteableDataMock, {
blocked_by_issues: [
{
iid: 1,
web_url: 'path/to/issue',
},
],
});
setupStore(userDataMock, noteableDataMockBlocked);
mountComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('should display alert warning when attempting to close issue, close button is hidden', done => {
findCloseBtn().trigger('click');
wrapper.vm.$nextTick(() => {
const warning = wrapper.find('.gl-alert-warning');
expect(warning.exists()).toBe(true);
expect(warning.text()).toContain('Are you sure you want to close this blocked issue?');
const linkToBlockingIssue = warning.find('.gl-link');
expect(linkToBlockingIssue.text()).toContain(
noteableDataMockBlocked.blocked_by_issues[0].iid,
);
done();
});
});
it('should close the issue when clicking close issue button in alert', done => {
jest.spyOn(wrapper.vm, 'closeIssue').mockResolvedValue();
findCloseBtn().trigger('click');
wrapper.vm.$nextTick(() => {
expect(findCloseBtn().exists()).toBe(false);
const warning = wrapper.find('.gl-alert-warning');
const primaryButton = warning.find('.gl-alert-actions .gl-button');
expect(primaryButton.text()).toEqual('Yes, close issue');
primaryButton.trigger('click');
wrapper.vm.$nextTick(() => {
expect(warning.exists()).toBe(false);
done();
});
setTimeout(() => {
expect(wrapper.vm.closeIssue).toHaveBeenCalled();
done();
}, 1000);
done();
});
});
it('should dismiss alert warning when clicking cancel button in alert', done => {
findCloseBtn().trigger('click');
wrapper.vm.$nextTick(() => {
const warning = wrapper.find('.gl-alert-warning');
const secondaryButton = warning.find('.gl-alert-actions .btn-default');
expect(secondaryButton.text()).toEqual('Cancel');
secondaryButton.trigger('click');
wrapper.vm.$nextTick(() => {
expect(warning.exists()).toBe(false);
done();
});
});
});
});
});
......@@ -25422,7 +25422,7 @@ msgstr ""
msgid "Something went wrong while archiving a requirement."
msgstr ""
msgid "Something went wrong while closing the %{issuable}. Please try again later"
msgid "Something went wrong while closing the merge request. Please try again later"
msgstr ""
msgid "Something went wrong while creating a requirement."
......@@ -25509,7 +25509,7 @@ msgstr ""
msgid "Something went wrong while reopening a requirement."
msgstr ""
msgid "Something went wrong while reopening the %{issuable}. Please try again later"
msgid "Something went wrong while reopening the merge request. Please try again later"
msgstr ""
msgid "Something went wrong while resolving this discussion. Please try again."
......
......@@ -7,6 +7,7 @@ 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 eventHub from '~/notes/event_hub';
import createStore from '~/notes/stores';
jest.mock('~/flash');
......@@ -82,8 +83,10 @@ describe('HeaderActions component', () => {
} = {}) => {
mutateMock = jest.fn().mockResolvedValue(mutateResponse);
store.getters.getNoteableData.state = issueState;
store.getters.getNoteableData.blocked_by_issues = blockedByIssues;
store.dispatch('setNoteableData', {
blocked_by_issues: blockedByIssues,
state: issueState,
});
return shallowMount(HeaderActions, {
localVue,
......@@ -273,6 +276,26 @@ describe('HeaderActions component', () => {
});
});
describe('when `toggle.issuable.state` event is emitted', () => {
it('invokes a method to toggle the issue state', () => {
wrapper = mountComponent({ mutateResponse: updateIssueMutationResponse });
eventHub.$emit('toggle.issuable.state');
expect(mutateMock).toHaveBeenCalledWith(
expect.objectContaining({
variables: {
input: {
iid: defaultProps.iid,
projectPath: defaultProps.projectPath,
stateEvent: IssueStateEvent.Close,
},
},
}),
);
});
});
describe('modal', () => {
const blockedByIssues = [
{ iid: 13, web_url: 'gitlab-org/gitlab-test/-/issues/13' },
......
import $ from 'jquery';
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { mount, shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import Autosize from 'autosize';
import { trimText } from 'helpers/text_helper';
import axios from '~/lib/utils/axios_utils';
import createStore from '~/notes/stores';
import CommentForm from '~/notes/components/comment_form.vue';
import * as constants from '~/notes/constants';
import eventHub from '~/notes/event_hub';
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import { keyboardDownEvent } from '../../issue_show/helpers';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import { loggedOutnoteableData, notesDataMock, userDataMock, noteableDataMock } from '../mock_data';
jest.mock('autosize');
......@@ -20,17 +20,33 @@ describe('issue_comment_form component', () => {
let wrapper;
let axiosMock;
const setupStore = (userData, noteableData) => {
store.dispatch('setUserData', userData);
const findCloseReopenButton = () => wrapper.find('[data-testid="close-reopen-button"]');
const findCommentButton = () => wrapper.find('[data-testid="comment-button"]');
const findTextArea = () => wrapper.find('[data-testid="comment-field"]');
const mountComponent = ({
initialData = {},
noteableType = 'issue',
noteableData = noteableDataMock,
notesData = notesDataMock,
userData = userDataMock,
mountFunction = shallowMount,
} = {}) => {
store.dispatch('setNoteableData', noteableData);
store.dispatch('setNotesData', notesDataMock);
};
store.dispatch('setNotesData', notesData);
store.dispatch('setUserData', userData);
const mountComponent = (noteableType = 'issue') => {
wrapper = mount(CommentForm, {
wrapper = mountFunction(CommentForm, {
propsData: {
noteableType,
},
data() {
return {
...initialData,
};
},
store,
});
};
......@@ -46,168 +62,157 @@ describe('issue_comment_form component', () => {
});
describe('user is logged in', () => {
beforeEach(() => {
setupStore(userDataMock, noteableDataMock);
mountComponent();
});
describe('avatar', () => {
it('should render user avatar with link', () => {
mountComponent({ mountFunction: mount });
it('should render user avatar with link', () => {
expect(wrapper.find('.timeline-icon .user-avatar-link').attributes('href')).toEqual(
userDataMock.path,
);
expect(wrapper.find(UserAvatarLink).attributes('href')).toBe(userDataMock.path);
});
});
describe('handleSave', () => {
it('should request to save note when note is entered', () => {
wrapper.vm.note = 'hello world';
jest.spyOn(wrapper.vm, 'saveNote').mockReturnValue(new Promise(() => {}));
mountComponent({ mountFunction: mount, initialData: { note: 'hello world' } });
jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue();
jest.spyOn(wrapper.vm, 'resizeTextarea');
jest.spyOn(wrapper.vm, 'stopPolling');
wrapper.vm.handleSave();
findCloseReopenButton().trigger('click');
expect(wrapper.vm.isSubmitting).toEqual(true);
expect(wrapper.vm.note).toEqual('');
expect(wrapper.vm.isSubmitting).toBe(true);
expect(wrapper.vm.note).toBe('');
expect(wrapper.vm.saveNote).toHaveBeenCalled();
expect(wrapper.vm.stopPolling).toHaveBeenCalled();
expect(wrapper.vm.resizeTextarea).toHaveBeenCalled();
});
it('should toggle issue state when no note', () => {
mountComponent({ mountFunction: mount });
jest.spyOn(wrapper.vm, 'toggleIssueState');
wrapper.vm.handleSave();
findCloseReopenButton().trigger('click');
expect(wrapper.vm.toggleIssueState).toHaveBeenCalled();
});
it('should disable action button while submitting', done => {
it('should disable action button while submitting', async () => {
mountComponent({ mountFunction: mount, initialData: { note: 'hello world' } });
const saveNotePromise = Promise.resolve();
wrapper.vm.note = 'hello world';
jest.spyOn(wrapper.vm, 'saveNote').mockReturnValue(saveNotePromise);
jest.spyOn(wrapper.vm, 'stopPolling');
const actionButton = wrapper.find('.js-action-button');
wrapper.vm.handleSave();
wrapper.vm
.$nextTick()
.then(() => {
expect(actionButton.vm.disabled).toBeTruthy();
})
.then(saveNotePromise)
.then(wrapper.vm.$nextTick)
.then(() => {
expect(actionButton.vm.disabled).toBeFalsy();
})
.then(done)
.catch(done.fail);
const actionButton = findCloseReopenButton();
await actionButton.trigger('click');
expect(actionButton.props('disabled')).toBe(true);
await saveNotePromise;
await nextTick();
expect(actionButton.props('disabled')).toBe(false);
});
});
describe('textarea', () => {
it('should render textarea with placeholder', () => {
expect(wrapper.find('.js-main-target-form textarea').attributes('placeholder')).toEqual(
'Write a comment or drag your files here…',
);
});
describe('general', () => {
it('should render textarea with placeholder', () => {
mountComponent({ mountFunction: mount });
it('should make textarea disabled while requesting', done => {
const $submitButton = $(wrapper.find('.js-comment-submit-button').element);
wrapper.vm.note = 'hello world';
jest.spyOn(wrapper.vm, 'stopPolling');
jest.spyOn(wrapper.vm, 'saveNote').mockReturnValue(new Promise(() => {}));
wrapper.vm.$nextTick(() => {
// Wait for wrapper.vm.note change triggered. It should enable $submitButton.
$submitButton.trigger('click');
wrapper.vm.$nextTick(() => {
// Wait for wrapper.isSubmitting triggered. It should disable textarea.
expect(wrapper.find('.js-main-target-form textarea').attributes('disabled')).toBe(
'disabled',
);
done();
});
expect(findTextArea().attributes('placeholder')).toBe(
'Write a comment or drag your files here…',
);
});
});
it('should support quick actions', () => {
expect(
wrapper.find('.js-main-target-form textarea').attributes('data-supports-quick-actions'),
).toBe('true');
});
it('should make textarea disabled while requesting', async () => {
mountComponent({ mountFunction: mount });
it('should link to markdown docs', () => {
const { markdownDocsPath } = notesDataMock;
jest.spyOn(wrapper.vm, 'stopPolling');
jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue();
expect(
wrapper
.find(`a[href="${markdownDocsPath}"]`)
.text()
.trim(),
).toEqual('Markdown');
});
await wrapper.setData({ note: 'hello world' });
it('should link to quick actions docs', () => {
const { quickActionsDocsPath } = notesDataMock;
await findCommentButton().trigger('click');
expect(
wrapper
.find(`a[href="${quickActionsDocsPath}"]`)
.text()
.trim(),
).toEqual('quick actions');
});
expect(findTextArea().attributes('disabled')).toBe('disabled');
});
it('should support quick actions', () => {
mountComponent({ mountFunction: mount });
expect(findTextArea().attributes('data-supports-quick-actions')).toBe('true');
});
it('should link to markdown docs', () => {
mountComponent({ mountFunction: mount });
const { markdownDocsPath } = notesDataMock;
it('should resize textarea after note discarded', done => {
jest.spyOn(wrapper.vm, 'discard');
expect(wrapper.find(`a[href="${markdownDocsPath}"]`).text()).toBe('Markdown');
});
it('should link to quick actions docs', () => {
mountComponent({ mountFunction: mount });
wrapper.vm.note = 'foo';
wrapper.vm.discard();
const { quickActionsDocsPath } = notesDataMock;
expect(wrapper.find(`a[href="${quickActionsDocsPath}"]`).text()).toBe('quick actions');
});
it('should resize textarea after note discarded', async () => {
mountComponent({ mountFunction: mount, initialData: { note: 'foo' } });
jest.spyOn(wrapper.vm, 'discard');
wrapper.vm.discard();
await nextTick();
wrapper.vm.$nextTick(() => {
expect(Autosize.update).toHaveBeenCalled();
done();
});
});
describe('edit mode', () => {
beforeEach(() => {
mountComponent();
});
it('should enter edit mode when arrow up is pressed', () => {
jest.spyOn(wrapper.vm, 'editCurrentUserLastNote');
wrapper.find('.js-main-target-form textarea').value = 'Foo';
wrapper
.find('.js-main-target-form textarea')
.element.dispatchEvent(keyboardDownEvent(38, true));
findTextArea().trigger('keydown.up');
expect(wrapper.vm.editCurrentUserLastNote).toHaveBeenCalled();
});
it('inits autosave', () => {
expect(wrapper.vm.autosave).toBeDefined();
expect(wrapper.vm.autosave.key).toEqual(`autosave/Note/Issue/${noteableDataMock.id}`);
expect(wrapper.vm.autosave.key).toBe(`autosave/Note/Issue/${noteableDataMock.id}`);
});
});
describe('event enter', () => {
beforeEach(() => {
mountComponent();
});
it('should save note when cmd+enter is pressed', () => {
jest.spyOn(wrapper.vm, 'handleSave');
wrapper.find('.js-main-target-form textarea').value = 'Foo';
wrapper
.find('.js-main-target-form textarea')
.element.dispatchEvent(keyboardDownEvent(13, true));
findTextArea().trigger('keydown.enter', { metaKey: true });
expect(wrapper.vm.handleSave).toHaveBeenCalled();
});
it('should save note when ctrl+enter is pressed', () => {
jest.spyOn(wrapper.vm, 'handleSave');
wrapper.find('.js-main-target-form textarea').value = 'Foo';
wrapper
.find('.js-main-target-form textarea')
.element.dispatchEvent(keyboardDownEvent(13, false, true));
findTextArea().trigger('keydown.enter', { ctrlKey: true });
expect(wrapper.vm.handleSave).toHaveBeenCalled();
});
......@@ -216,137 +221,147 @@ describe('issue_comment_form component', () => {
describe('actions', () => {
it('should be possible to close the issue', () => {
expect(
wrapper
.find('.btn-comment-and-close')
.text()
.trim(),
).toEqual('Close issue');
mountComponent();
expect(findCloseReopenButton().text()).toBe('Close issue');
});
it('should render comment button as disabled', () => {
expect(wrapper.find('.js-comment-submit-button').attributes('disabled')).toEqual(
'disabled',
);
mountComponent();
expect(findCommentButton().props('disabled')).toBe(true);
});
it('should enable comment button if it has note', done => {
wrapper.vm.note = 'Foo';
wrapper.vm.$nextTick(() => {
expect(wrapper.find('.js-comment-submit-button').attributes('disabled')).toBeFalsy();
done();
});
it('should enable comment button if it has note', async () => {
mountComponent();
await wrapper.setData({ note: 'Foo' });
expect(findCommentButton().props('disabled')).toBe(false);
});
it('should update buttons texts when it has note', done => {
wrapper.vm.note = 'Foo';
wrapper.vm.$nextTick(() => {
expect(
wrapper
.find('.btn-comment-and-close')
.text()
.trim(),
).toEqual('Comment & close issue');
done();
});
it('should update buttons texts when it has note', () => {
mountComponent({ initialData: { note: 'Foo' } });
expect(findCloseReopenButton().text()).toBe('Comment & close issue');
});
it('updates button text with noteable type', done => {
wrapper.setProps({ noteableType: constants.MERGE_REQUEST_NOTEABLE_TYPE });
wrapper.vm.$nextTick(() => {
expect(
wrapper
.find('.btn-comment-and-close')
.text()
.trim(),
).toEqual('Close merge request');
done();
});
it('updates button text with noteable type', () => {
mountComponent({ noteableType: constants.MERGE_REQUEST_NOTEABLE_TYPE });
expect(findCloseReopenButton().text()).toBe('Close merge request');
});
describe('when clicking close/reopen button', () => {
it('should disable button and show a loading spinner', () => {
const toggleStateButton = wrapper.find('.js-action-button');
it('should show a loading spinner', async () => {
mountComponent({
noteableType: constants.MERGE_REQUEST_NOTEABLE_TYPE,
mountFunction: mount,
});
toggleStateButton.trigger('click');
await findCloseReopenButton().trigger('click');
return wrapper.vm.$nextTick().then(() => {
expect(toggleStateButton.element.disabled).toEqual(true);
expect(toggleStateButton.props('loading')).toBe(true);
});
expect(findCloseReopenButton().props('loading')).toBe(true);
});
});
describe('when toggling state', () => {
it('should update MR count', done => {
jest.spyOn(wrapper.vm, 'closeIssue').mockResolvedValue();
describe('when issue', () => {
it('emits event to toggle state', () => {
mountComponent({ mountFunction: mount });
wrapper.vm.toggleIssueState();
jest.spyOn(eventHub, '$emit');
wrapper.vm.$nextTick(() => {
expect(refreshUserMergeRequestCounts).toHaveBeenCalled();
findCloseReopenButton().trigger('click');
done();
expect(eventHub.$emit).toHaveBeenCalledWith('toggle.issuable.state');
});
});
describe('when merge request', () => {
describe('when open', () => {
it('makes an API call to open the merge request', () => {
mountComponent({
noteableType: constants.MERGE_REQUEST_NOTEABLE_TYPE,
noteableData: { ...noteableDataMock, state: constants.OPENED },
mountFunction: mount,
});
jest.spyOn(wrapper.vm, 'closeMergeRequest').mockResolvedValue();
findCloseReopenButton().trigger('click');
expect(wrapper.vm.closeMergeRequest).toHaveBeenCalled();
});
});
describe('when closed', () => {
it('makes an API call to close the merge request', () => {
mountComponent({
noteableType: constants.MERGE_REQUEST_NOTEABLE_TYPE,
noteableData: { ...noteableDataMock, state: constants.CLOSED },
mountFunction: mount,
});
jest.spyOn(wrapper.vm, 'reopenMergeRequest').mockResolvedValue();
findCloseReopenButton().trigger('click');
expect(wrapper.vm.reopenMergeRequest).toHaveBeenCalled();
});
});
it('should update MR count', async () => {
mountComponent({
noteableType: constants.MERGE_REQUEST_NOTEABLE_TYPE,
mountFunction: mount,
});
jest.spyOn(wrapper.vm, 'closeMergeRequest').mockResolvedValue();
await findCloseReopenButton().trigger('click');
expect(refreshUserMergeRequestCounts).toHaveBeenCalled();
});
});
});
});
describe('issue is confidential', () => {
it('shows information warning', done => {
store.dispatch('setNoteableData', Object.assign(noteableDataMock, { confidential: true }));
wrapper.vm.$nextTick(() => {
expect(wrapper.find('.confidential-issue-warning')).toBeDefined();
done();
it('shows information warning', () => {
mountComponent({
noteableData: { ...noteableDataMock, confidential: true },
mountFunction: mount,
});
expect(wrapper.find('[data-testid="confidential-warning"]').exists()).toBe(true);
});
});
});
describe('user is not logged in', () => {
beforeEach(() => {
setupStore(null, loggedOutnoteableData);
mountComponent();
mountComponent({ userData: null, noteableData: loggedOutnoteableData, mountFunction: mount });
});
it('should render signed out widget', () => {
expect(trimText(wrapper.text())).toEqual('Please register or sign in to reply');
expect(wrapper.text()).toBe('Please register or sign in to reply');
});
it('should not render submission form', () => {
expect(wrapper.find('textarea').exists()).toBe(false);
expect(findTextArea().exists()).toBe(false);
});
});
describe('when issuable is open', () => {
beforeEach(() => {
setupStore(userDataMock, noteableDataMock);
});
it.each([['opened', 'warning'], ['reopened', 'warning']])(
'when %i, it changes the variant of the btn to %i',
(a, expected) => {
store.state.noteableData.state = a;
mountComponent();
expect(wrapper.find('.js-action-button').props('variant')).toBe(expected);
},
);
});
describe('when issuable is not open', () => {
beforeEach(() => {
setupStore(userDataMock, noteableDataMock);
mountComponent();
});
describe('close/reopen button variants', () => {
it.each([
[constants.OPENED, 'warning'],
[constants.REOPENED, 'warning'],
[constants.CLOSED, 'default'],
])('when %s, the variant of the btn is %s', (state, expected) => {
mountComponent({ noteableData: { ...noteableDataMock, state } });
it('should render the "default" variant of the button', () => {
expect(wrapper.find('.js-action-button').props('variant')).toBe('warning');
expect(findCloseReopenButton().props('variant')).toBe(expected);
});
});
});
......@@ -174,10 +174,10 @@ describe('Actions Notes Store', () => {
axiosMock.onAny().reply(200, {});
});
describe('closeIssue', () => {
describe('closeMergeRequest', () => {
it('sets state as closed', done => {
store
.dispatch('closeIssue', { notesData: { closeIssuePath: '' } })
.dispatch('closeMergeRequest', { notesData: { closeIssuePath: '' } })
.then(() => {
expect(store.state.noteableData.state).toEqual('closed');
expect(store.state.isToggleStateButtonLoading).toEqual(false);
......@@ -187,10 +187,10 @@ describe('Actions Notes Store', () => {
});
});
describe('reopenIssue', () => {
describe('reopenMergeRequest', () => {
it('sets state as reopened', done => {
store
.dispatch('reopenIssue', { notesData: { reopenIssuePath: '' } })
.dispatch('reopenMergeRequest', { notesData: { reopenIssuePath: '' } })
.then(() => {
expect(store.state.noteableData.state).toEqual('reopened');
expect(store.state.isToggleStateButtonLoading).toEqual(false);
......@@ -253,30 +253,6 @@ describe('Actions Notes Store', () => {
});
});
describe('toggleBlockedIssueWarning', () => {
it('should set issue warning as true', done => {
testAction(
actions.toggleBlockedIssueWarning,
true,
{},
[{ type: 'TOGGLE_BLOCKED_ISSUE_WARNING', payload: true }],
[],
done,
);
});
it('should set issue warning as false', done => {
testAction(
actions.toggleBlockedIssueWarning,
false,
{},
[{ type: 'TOGGLE_BLOCKED_ISSUE_WARNING', payload: false }],
[],
done,
);
});
});
describe('fetchData', () => {
describe('given there are no notes', () => {
const lastFetchedAt = '13579';
......
......@@ -697,42 +697,6 @@ describe('Notes Store mutations', () => {
});
});
describe('TOGGLE_BLOCKED_ISSUE_WARNING', () => {
it('should set isToggleBlockedIssueWarning as true', () => {
const state = {
discussions: [],
targetNoteHash: null,
lastFetchedAt: null,
isToggleStateButtonLoading: false,
isToggleBlockedIssueWarning: false,
notesData: {},
userData: {},
noteableData: {},
};
mutations.TOGGLE_BLOCKED_ISSUE_WARNING(state, true);
expect(state.isToggleBlockedIssueWarning).toEqual(true);
});
it('should set isToggleBlockedIssueWarning as false', () => {
const state = {
discussions: [],
targetNoteHash: null,
lastFetchedAt: null,
isToggleStateButtonLoading: false,
isToggleBlockedIssueWarning: true,
notesData: {},
userData: {},
noteableData: {},
};
mutations.TOGGLE_BLOCKED_ISSUE_WARNING(state, false);
expect(state.isToggleBlockedIssueWarning).toEqual(false);
});
});
describe('SET_APPLYING_BATCH_STATE', () => {
const buildDiscussions = suggestionsInfo => {
const suggestions = suggestionsInfo.map(({ suggestionId }) => ({ id: suggestionId }));
......
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