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