Commit 9b9dbd4f authored by Coung Ngo's avatar Coung Ngo Committed by Natalia Tepluhina

Fix issue blocked modal

There are two "blocked by" modals on the issue page, and the older
one is not functional due to a recent update of the issue header
from Haml to Vue. This commit fixes this by making the issue page
use the same modal and sharing more state and behaviour between
different Vue apps on the issue page.
parent f5b96fb2
<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,
......
......@@ -43,7 +43,6 @@ export const SET_FETCHING_DISCUSSIONS = 'SET_FETCHING_DISCUSSIONS';
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';
......
......@@ -301,10 +301,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
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
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)
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);
describe('avatar', () => {
it('should render user avatar with link', () => {
mountComponent({ mountFunction: mount });
mountComponent();
expect(wrapper.find(UserAvatarLink).attributes('href')).toBe(userDataMock.path);
});
it('should render user avatar with link', () => {
expect(wrapper.find('.timeline-icon .user-avatar-link').attributes('href')).toEqual(
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');
const actionButton = findCloseReopenButton();
wrapper.vm.handleSave();
await actionButton.trigger('click');
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);
expect(actionButton.props('disabled')).toBe(true);
await saveNotePromise;
await nextTick();
expect(actionButton.props('disabled')).toBe(false);
});
});
describe('textarea', () => {
describe('general', () => {
it('should render textarea with placeholder', () => {
expect(wrapper.find('.js-main-target-form textarea').attributes('placeholder')).toEqual(
mountComponent({ mountFunction: mount });
expect(findTextArea().attributes('placeholder')).toBe(
'Write a comment or drag your files here…',
);
});
it('should make textarea disabled while requesting', done => {
const $submitButton = $(wrapper.find('.js-comment-submit-button').element);
wrapper.vm.note = 'hello world';
it('should make textarea disabled while requesting', async () => {
mountComponent({ mountFunction: mount });
jest.spyOn(wrapper.vm, 'stopPolling');
jest.spyOn(wrapper.vm, 'saveNote').mockReturnValue(new Promise(() => {}));
jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue();
wrapper.vm.$nextTick(() => {
// Wait for wrapper.vm.note change triggered. It should enable $submitButton.
$submitButton.trigger('click');
await wrapper.setData({ note: 'hello world' });
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();
});
});
await findCommentButton().trigger('click');
expect(findTextArea().attributes('disabled')).toBe('disabled');
});
it('should support quick actions', () => {
expect(
wrapper.find('.js-main-target-form textarea').attributes('data-supports-quick-actions'),
).toBe('true');
mountComponent({ mountFunction: mount });
expect(findTextArea().attributes('data-supports-quick-actions')).toBe('true');
});
it('should link to markdown docs', () => {
mountComponent({ mountFunction: mount });
const { markdownDocsPath } = notesDataMock;
expect(
wrapper
.find(`a[href="${markdownDocsPath}"]`)
.text()
.trim(),
).toEqual('Markdown');
expect(wrapper.find(`a[href="${markdownDocsPath}"]`).text()).toBe('Markdown');
});
it('should link to quick actions docs', () => {
mountComponent({ mountFunction: mount });
const { quickActionsDocsPath } = notesDataMock;
expect(
wrapper
.find(`a[href="${quickActionsDocsPath}"]`)
.text()
.trim(),
).toEqual('quick actions');
expect(wrapper.find(`a[href="${quickActionsDocsPath}"]`).text()).toBe('quick actions');
});
it('should resize textarea after note discarded', done => {
it('should resize textarea after note discarded', async () => {
mountComponent({ mountFunction: mount, initialData: { note: 'foo' } });
jest.spyOn(wrapper.vm, 'discard');
wrapper.vm.note = 'foo';
wrapper.vm.discard();
wrapper.vm.$nextTick(() => {
await 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();
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();
});
expect(findCommentButton().props('disabled')).toBe(true);
});
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');
it('should enable comment button if it has note', async () => {
mountComponent();
done();
});
await wrapper.setData({ note: 'Foo' });
expect(findCommentButton().props('disabled')).toBe(false);
});
it('updates button text with noteable type', done => {
wrapper.setProps({ noteableType: constants.MERGE_REQUEST_NOTEABLE_TYPE });
it('should update buttons texts when it has note', () => {
mountComponent({ initialData: { note: 'Foo' } });
wrapper.vm.$nextTick(() => {
expect(
wrapper
.find('.btn-comment-and-close')
.text()
.trim(),
).toEqual('Close merge request');
done();
expect(findCloseReopenButton().text()).toBe('Comment & close issue');
});
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('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();
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,
});
describe('user is not logged in', () => {
beforeEach(() => {
setupStore(null, loggedOutnoteableData);
jest.spyOn(wrapper.vm, 'closeMergeRequest').mockResolvedValue();
mountComponent();
await findCloseReopenButton().trigger('click');
expect(refreshUserMergeRequestCounts).toHaveBeenCalled();
});
});
});
});
it('should render signed out widget', () => {
expect(trimText(wrapper.text())).toEqual('Please register or sign in to reply');
describe('issue is confidential', () => {
it('shows information warning', () => {
mountComponent({
noteableData: { ...noteableDataMock, confidential: true },
mountFunction: mount,
});
it('should not render submission form', () => {
expect(wrapper.find('textarea').exists()).toBe(false);
expect(wrapper.find('[data-testid="confidential-warning"]').exists()).toBe(true);
});
});
});
describe('when issuable is open', () => {
describe('user is not logged in', () => {
beforeEach(() => {
setupStore(userDataMock, noteableDataMock);
mountComponent({ userData: null, noteableData: loggedOutnoteableData, mountFunction: mount });
});
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);
},
);
it('should render signed out widget', () => {
expect(wrapper.text()).toBe('Please register or sign in to reply');
});
describe('when issuable is not open', () => {
beforeEach(() => {
setupStore(userDataMock, noteableDataMock);
mountComponent();
it('should not render submission form', () => {
expect(findTextArea().exists()).toBe(false);
});
});
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';
......
......@@ -687,42 +687,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