Commit b6a019ff authored by Coung Ngo's avatar Coung Ngo Committed by Natalia Tepluhina

Add `Delete issue` option in issue page ellipsis dropdown

parent c8b5da56
...@@ -289,13 +289,11 @@ export default { ...@@ -289,13 +289,11 @@ export default {
window.addEventListener('beforeunload', this.handleBeforeUnloadEvent); window.addEventListener('beforeunload', this.handleBeforeUnloadEvent);
eventHub.$on('delete.issuable', this.deleteIssuable);
eventHub.$on('update.issuable', this.updateIssuable); eventHub.$on('update.issuable', this.updateIssuable);
eventHub.$on('close.form', this.closeForm); eventHub.$on('close.form', this.closeForm);
eventHub.$on('open.form', this.openForm); eventHub.$on('open.form', this.openForm);
}, },
beforeDestroy() { beforeDestroy() {
eventHub.$off('delete.issuable', this.deleteIssuable);
eventHub.$off('update.issuable', this.updateIssuable); eventHub.$off('update.issuable', this.updateIssuable);
eventHub.$off('close.form', this.closeForm); eventHub.$off('close.form', this.closeForm);
eventHub.$off('open.form', this.openForm); eventHub.$off('open.form', this.openForm);
...@@ -418,25 +416,6 @@ export default { ...@@ -418,25 +416,6 @@ export default {
}); });
}, },
deleteIssuable(payload) {
return this.service
.deleteIssuable(payload)
.then((res) => res.data)
.then((data) => {
// Stop the poll so we don't get 404's with the issuable not existing
this.poll.stop();
visitUrl(data.web_url);
})
.catch(() => {
createFlash({
message: sprintf(__('Error deleting %{issuableType}'), {
issuableType: this.issuableType,
}),
});
});
},
hideStickyHeader() { hideStickyHeader() {
this.isStickyHeaderShowing = false; this.isStickyHeaderShowing = false;
}, },
...@@ -475,6 +454,7 @@ export default { ...@@ -475,6 +454,7 @@ export default {
<div> <div>
<div v-if="canUpdate && showForm"> <div v-if="canUpdate && showForm">
<form-component <form-component
:endpoint="endpoint"
:form-state="formState" :form-state="formState"
:initial-description-text="initialDescriptionText" :initial-description-text="initialDescriptionText"
:can-destroy="canDestroy" :can-destroy="canDestroy"
......
<script>
import { GlModal } from '@gitlab/ui';
import csrf from '~/lib/utils/csrf';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import { __, sprintf } from '~/locale';
export default {
actionCancel: { text: __('Cancel') },
csrf,
components: {
GlModal,
},
props: {
issuePath: {
type: String,
required: true,
},
issueType: {
type: String,
required: true,
},
modalId: {
type: String,
required: true,
},
title: {
type: String,
required: true,
},
},
computed: {
actionPrimary() {
return {
attributes: { variant: 'danger' },
text: this.title,
};
},
bodyText() {
return this.issueType.toLowerCase() === 'epic'
? __('Delete this epic and all descendants?')
: sprintf(__('%{issuableType} will be removed! Are you sure?'), {
issuableType: capitalizeFirstCharacter(this.issueType),
});
},
},
methods: {
submitForm() {
this.$emit('delete');
this.$refs.form.submit();
},
},
};
</script>
<template>
<gl-modal
:action-cancel="$options.actionCancel"
:action-primary="actionPrimary"
:modal-id="modalId"
size="sm"
:title="title"
@primary="submitForm"
>
<form ref="form" :action="issuePath" method="post">
<input type="hidden" name="_method" value="delete" />
<input type="hidden" name="authenticity_token" :value="$options.csrf.token" />
<input type="hidden" name="destroy_confirm" value="true" />
{{ bodyText }}
</form>
</gl-modal>
</template>
<script> <script>
import { GlButton, GlModal, GlModalDirective } from '@gitlab/ui'; import { GlButton, GlModalDirective } from '@gitlab/ui';
import { uniqueId } from 'lodash'; import { uniqueId } from 'lodash';
import { __, sprintf } from '~/locale'; import { __, sprintf } from '~/locale';
import Tracking from '~/tracking';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import updateMixin from '../mixins/update'; import updateMixin from '../mixins/update';
import getIssueStateQuery from '../queries/get_issue_state.query.graphql'; import getIssueStateQuery from '../queries/get_issue_state.query.graphql';
import DeleteIssueModal from './delete_issue_modal.vue';
const issuableTypes = { const issuableTypes = {
issue: __('Issue'), issue: __('Issue'),
...@@ -12,20 +14,26 @@ const issuableTypes = { ...@@ -12,20 +14,26 @@ const issuableTypes = {
incident: __('Incident'), incident: __('Incident'),
}; };
const trackingMixin = Tracking.mixin({ label: 'delete_issue' });
export default { export default {
components: { components: {
DeleteIssueModal,
GlButton, GlButton,
GlModal,
}, },
directives: { directives: {
GlModal: GlModalDirective, GlModal: GlModalDirective,
}, },
mixins: [updateMixin], mixins: [trackingMixin, updateMixin],
props: { props: {
canDestroy: { canDestroy: {
type: Boolean, type: Boolean,
required: true, required: true,
}, },
endpoint: {
required: true,
type: String,
},
formState: { formState: {
type: Object, type: Object,
required: true, required: true,
...@@ -65,27 +73,9 @@ export default { ...@@ -65,27 +73,9 @@ export default {
issuableType: this.typeToShow.toLowerCase(), issuableType: this.typeToShow.toLowerCase(),
}); });
}, },
deleteIssuableModalText() {
return this.issuableType === 'epic'
? __('Delete this epic and all descendants?')
: sprintf(__('%{issuableType} will be removed! Are you sure?'), {
issuableType: this.typeToShow,
});
},
isSubmitEnabled() { isSubmitEnabled() {
return this.formState.title.trim() !== ''; return this.formState.title.trim() !== '';
}, },
modalActionProps() {
return {
primary: {
text: this.deleteIssuableButtonText,
attributes: [{ variant: 'danger' }, { loading: this.deleteLoading }],
},
cancel: {
text: __('Cancel'),
},
};
},
shouldShowDeleteButton() { shouldShowDeleteButton() {
return this.canDestroy && this.showDeleteButton; return this.canDestroy && this.showDeleteButton;
}, },
...@@ -101,7 +91,7 @@ export default { ...@@ -101,7 +91,7 @@ export default {
}, },
deleteIssuable() { deleteIssuable() {
this.deleteLoading = true; this.deleteLoading = true;
eventHub.$emit('delete.issuable', { destroy_confirm: true }); eventHub.$emit('delete.issuable');
}, },
}, },
}; };
...@@ -135,22 +125,17 @@ export default { ...@@ -135,22 +125,17 @@ export default {
variant="danger" variant="danger"
class="qa-delete-button" class="qa-delete-button"
data-testid="issuable-delete-button" data-testid="issuable-delete-button"
@click="track('click_button')"
> >
{{ deleteIssuableButtonText }} {{ deleteIssuableButtonText }}
</gl-button> </gl-button>
<gl-modal <delete-issue-modal
ref="removeModal" :issue-path="endpoint"
:issue-type="typeToShow"
:modal-id="modalId" :modal-id="modalId"
size="sm" :title="deleteIssuableButtonText"
:action-primary="modalActionProps.primary" @delete="deleteIssuable"
:action-cancel="modalActionProps.cancel" />
@primary="deleteIssuable"
>
<template #modal-title>{{ deleteIssuableButtonText }}</template>
<div>
<p class="gl-mb-1">{{ deleteIssuableModalText }}</p>
</div>
</gl-modal>
</div> </div>
</div> </div>
</template> </template>
...@@ -26,6 +26,10 @@ export default { ...@@ -26,6 +26,10 @@ export default {
type: Boolean, type: Boolean,
required: true, required: true,
}, },
endpoint: {
type: String,
required: true,
},
formState: { formState: {
type: Object, type: Object,
required: true, required: true,
...@@ -213,6 +217,7 @@ export default { ...@@ -213,6 +217,7 @@ export default {
:enable-autocomplete="enableAutocomplete" :enable-autocomplete="enableAutocomplete"
/> />
<edit-actions <edit-actions
:endpoint="endpoint"
:form-state="formState" :form-state="formState"
:can-destroy="canDestroy" :can-destroy="canDestroy"
:show-delete-button="showDeleteButton" :show-delete-button="showDeleteButton"
......
<script> <script>
import { GlButton, GlDropdown, GlDropdownItem, GlLink, GlModal } from '@gitlab/ui'; import {
GlButton,
GlDropdown,
GlDropdownDivider,
GlDropdownItem,
GlLink,
GlModal,
GlModalDirective,
} from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex'; import { mapActions, mapGetters, mapState } from 'vuex';
import createFlash, { FLASH_TYPES } from '~/flash'; import createFlash, { FLASH_TYPES } from '~/flash';
import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants';
...@@ -10,23 +18,21 @@ import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; ...@@ -10,23 +18,21 @@ import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import { visitUrl } from '~/lib/utils/url_utility'; import { visitUrl } from '~/lib/utils/url_utility';
import { s__, __, sprintf } from '~/locale'; import { s__, __, sprintf } from '~/locale';
import eventHub from '~/notes/event_hub'; import eventHub from '~/notes/event_hub';
import Tracking from '~/tracking';
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';
import DeleteIssueModal from './delete_issue_modal.vue';
const trackingMixin = Tracking.mixin({ label: 'delete_issue' });
export default { export default {
components: {
GlButton,
GlDropdown,
GlDropdownItem,
GlLink,
GlModal,
},
actionCancel: { actionCancel: {
text: __('Cancel'), text: __('Cancel'),
}, },
actionPrimary: { actionPrimary: {
text: __('Yes, close issue'), text: __('Yes, close issue'),
}, },
deleteModalId: 'delete-modal-id',
i18n: { i18n: {
promoteErrorMessage: __( promoteErrorMessage: __(
'Something went wrong while promoting the issue to an epic. Please try again.', 'Something went wrong while promoting the issue to an epic. Please try again.',
...@@ -35,10 +41,26 @@ export default { ...@@ -35,10 +41,26 @@ export default {
'The issue was successfully promoted to an epic. Redirecting to epic...', 'The issue was successfully promoted to an epic. Redirecting to epic...',
), ),
}, },
components: {
DeleteIssueModal,
GlButton,
GlDropdown,
GlDropdownDivider,
GlDropdownItem,
GlLink,
GlModal,
},
directives: {
GlModal: GlModalDirective,
},
mixins: [trackingMixin],
inject: { inject: {
canCreateIssue: { canCreateIssue: {
default: false, default: false,
}, },
canDestroyIssue: {
default: false,
},
canPromoteToEpic: { canPromoteToEpic: {
default: false, default: false,
}, },
...@@ -57,6 +79,9 @@ export default { ...@@ -57,6 +79,9 @@ export default {
isIssueAuthor: { isIssueAuthor: {
default: false, default: false,
}, },
issuePath: {
default: '',
},
issueType: { issueType: {
default: IssuableType.Issue, default: IssuableType.Issue,
}, },
...@@ -92,6 +117,9 @@ export default { ...@@ -92,6 +117,9 @@ export default {
? sprintf(__('Reopen %{issueType}'), { issueType: this.issueTypeText }) ? sprintf(__('Reopen %{issueType}'), { issueType: this.issueTypeText })
: sprintf(__('Close %{issueType}'), { issueType: this.issueTypeText }); : sprintf(__('Close %{issueType}'), { issueType: this.issueTypeText });
}, },
deleteButtonText() {
return sprintf(__('Delete %{issuableType}'), { issuableType: this.issueTypeText });
},
qaSelector() { qaSelector() {
return this.isClosed ? 'reopen_issue_button' : 'close_issue_button'; return this.isClosed ? 'reopen_issue_button' : 'close_issue_button';
}, },
...@@ -141,8 +169,7 @@ export default { ...@@ -141,8 +169,7 @@ export default {
}) })
.then(({ data }) => { .then(({ data }) => {
if (data.updateIssue.errors.length) { if (data.updateIssue.errors.length) {
createFlash({ message: data.updateIssue.errors.join('. ') }); throw new Error();
return;
} }
const payload = { const payload = {
...@@ -175,8 +202,7 @@ export default { ...@@ -175,8 +202,7 @@ export default {
}) })
.then(({ data }) => { .then(({ data }) => {
if (data.promoteToEpic.errors.length) { if (data.promoteToEpic.errors.length) {
createFlash({ message: data.promoteToEpic.errors.join('; ') }); throw new Error();
return;
} }
createFlash({ createFlash({
...@@ -228,6 +254,16 @@ export default { ...@@ -228,6 +254,16 @@ export default {
> >
{{ __('Submit as spam') }} {{ __('Submit as spam') }}
</gl-dropdown-item> </gl-dropdown-item>
<template v-if="canDestroyIssue">
<gl-dropdown-divider />
<gl-dropdown-item
v-gl-modal="$options.deleteModalId"
variant="danger"
@click="track('click_dropdown')"
>
{{ deleteButtonText }}
</gl-dropdown-item>
</template>
</gl-dropdown> </gl-dropdown>
<gl-button <gl-button
...@@ -271,6 +307,16 @@ export default { ...@@ -271,6 +307,16 @@ export default {
> >
{{ __('Submit as spam') }} {{ __('Submit as spam') }}
</gl-dropdown-item> </gl-dropdown-item>
<template v-if="canDestroyIssue">
<gl-dropdown-divider />
<gl-dropdown-item
v-gl-modal="$options.deleteModalId"
variant="danger"
@click="track('click_dropdown')"
>
{{ deleteButtonText }}
</gl-dropdown-item>
</template>
</gl-dropdown> </gl-dropdown>
<gl-modal <gl-modal
...@@ -288,5 +334,12 @@ export default { ...@@ -288,5 +334,12 @@ export default {
</li> </li>
</ul> </ul>
</gl-modal> </gl-modal>
<delete-issue-modal
:issue-path="issuePath"
:issue-type="issueType"
:modal-id="$options.deleteModalId"
:title="deleteButtonText"
/>
</div> </div>
</template> </template>
...@@ -81,12 +81,14 @@ export function initIncidentHeaderActions(store) { ...@@ -81,12 +81,14 @@ export function initIncidentHeaderActions(store) {
store, store,
provide: { provide: {
canCreateIssue: parseBoolean(el.dataset.canCreateIncident), canCreateIssue: parseBoolean(el.dataset.canCreateIncident),
canDestroyIssue: parseBoolean(el.dataset.canDestroyIssue),
canPromoteToEpic: parseBoolean(el.dataset.canPromoteToEpic), canPromoteToEpic: parseBoolean(el.dataset.canPromoteToEpic),
canReopenIssue: parseBoolean(el.dataset.canReopenIssue), canReopenIssue: parseBoolean(el.dataset.canReopenIssue),
canReportSpam: parseBoolean(el.dataset.canReportSpam), canReportSpam: parseBoolean(el.dataset.canReportSpam),
canUpdateIssue: parseBoolean(el.dataset.canUpdateIssue), canUpdateIssue: parseBoolean(el.dataset.canUpdateIssue),
iid: el.dataset.iid, iid: el.dataset.iid,
isIssueAuthor: parseBoolean(el.dataset.isIssueAuthor), isIssueAuthor: parseBoolean(el.dataset.isIssueAuthor),
issuePath: el.dataset.issuePath,
issueType: el.dataset.issueType, issueType: el.dataset.issueType,
newIssuePath: el.dataset.newIssuePath, newIssuePath: el.dataset.newIssuePath,
projectPath: el.dataset.projectPath, projectPath: el.dataset.projectPath,
......
...@@ -66,12 +66,14 @@ export function initIssueHeaderActions(store) { ...@@ -66,12 +66,14 @@ export function initIssueHeaderActions(store) {
store, store,
provide: { provide: {
canCreateIssue: parseBoolean(el.dataset.canCreateIssue), canCreateIssue: parseBoolean(el.dataset.canCreateIssue),
canDestroyIssue: parseBoolean(el.dataset.canDestroyIssue),
canPromoteToEpic: parseBoolean(el.dataset.canPromoteToEpic), canPromoteToEpic: parseBoolean(el.dataset.canPromoteToEpic),
canReopenIssue: parseBoolean(el.dataset.canReopenIssue), canReopenIssue: parseBoolean(el.dataset.canReopenIssue),
canReportSpam: parseBoolean(el.dataset.canReportSpam), canReportSpam: parseBoolean(el.dataset.canReportSpam),
canUpdateIssue: parseBoolean(el.dataset.canUpdateIssue), canUpdateIssue: parseBoolean(el.dataset.canUpdateIssue),
iid: el.dataset.iid, iid: el.dataset.iid,
isIssueAuthor: parseBoolean(el.dataset.isIssueAuthor), isIssueAuthor: parseBoolean(el.dataset.isIssueAuthor),
issuePath: el.dataset.issuePath,
issueType: el.dataset.issueType, issueType: el.dataset.issueType,
newIssuePath: el.dataset.newIssuePath, newIssuePath: el.dataset.newIssuePath,
projectPath: el.dataset.projectPath, projectPath: el.dataset.projectPath,
......
...@@ -193,11 +193,13 @@ module IssuesHelper ...@@ -193,11 +193,13 @@ module IssuesHelper
{ {
can_create_issue: show_new_issue_link?(project).to_s, can_create_issue: show_new_issue_link?(project).to_s,
can_create_incident: create_issue_type_allowed?(project, :incident).to_s, can_create_incident: create_issue_type_allowed?(project, :incident).to_s,
can_destroy_issue: can?(current_user, :"destroy_#{issuable.to_ability_name}", issuable).to_s,
can_reopen_issue: can?(current_user, :reopen_issue, issuable).to_s, can_reopen_issue: can?(current_user, :reopen_issue, issuable).to_s,
can_report_spam: issuable.submittable_as_spam_by?(current_user).to_s, can_report_spam: issuable.submittable_as_spam_by?(current_user).to_s,
can_update_issue: can?(current_user, :update_issue, issuable).to_s, can_update_issue: can?(current_user, :update_issue, issuable).to_s,
iid: issuable.iid, iid: issuable.iid,
is_issue_author: (issuable.author == current_user).to_s, is_issue_author: (issuable.author == current_user).to_s,
issue_path: issuable_path(issuable),
issue_type: issuable_display_type(issuable), issue_type: issuable_display_type(issuable),
new_issue_path: new_project_issue_path(project, new_issuable_params), new_issue_path: new_project_issue_path(project, new_issuable_params),
project_path: project.full_path, project_path: project.full_path,
......
...@@ -34,7 +34,7 @@ To learn how the GitLab Strategic Marketing department uses GitLab issues with [ ...@@ -34,7 +34,7 @@ To learn how the GitLab Strategic Marketing department uses GitLab issues with [
- [Edit issues](managing_issues.md#edit-an-issue) - [Edit issues](managing_issues.md#edit-an-issue)
- [Move issues](managing_issues.md#moving-issues) - [Move issues](managing_issues.md#moving-issues)
- [Close issues](managing_issues.md#closing-issues) - [Close issues](managing_issues.md#closing-issues)
- [Delete issues](managing_issues.md#deleting-issues) - [Delete issues](managing_issues.md#delete-an-issue)
- [Promote issues](managing_issues.md#promote-an-issue-to-an-epic) - [Promote issues](managing_issues.md#promote-an-issue-to-an-epic)
- [Set a due date](due_dates.md) - [Set a due date](due_dates.md)
- [Import issues](csv_import.md) - [Import issues](csv_import.md)
......
...@@ -439,12 +439,23 @@ can change an issue's type. To do this, edit the issue and select an issue type ...@@ -439,12 +439,23 @@ can change an issue's type. To do this, edit the issue and select an issue type
![Change the issue type](img/issue_type_change_v13_12.png) ![Change the issue type](img/issue_type_change_v13_12.png)
## Deleting issues ## Delete an issue
Users with the [Owner role](../../permissions.md) can delete an issue by > Deleting from the vertical ellipsis menu [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/299933) in GitLab 14.6.
editing it and selecting **Delete issue**.
![delete issue - button](img/delete_issue_v13_11.png) Prerequisites:
- You must have the [Owner role](../../permissions.md) for a project.
To delete an issue:
1. In an issue, select the vertical ellipsis (**{ellipsis_v}**).
1. Select **Delete issue**.
Alternatively:
1. In an issue, select **Edit title and description** (**{pencil}**).
1. Select **Delete issue**.
## Promote an issue to an epic **(PREMIUM)** ## Promote an issue to an epic **(PREMIUM)**
......
...@@ -13708,9 +13708,6 @@ msgstr "" ...@@ -13708,9 +13708,6 @@ msgstr ""
msgid "Error creating the snippet" msgid "Error creating the snippet"
msgstr "" msgstr ""
msgid "Error deleting %{issuableType}"
msgstr ""
msgid "Error deleting project. Check logs for error details." msgid "Error deleting project. Check logs for error details."
msgstr "" msgstr ""
......
...@@ -4,7 +4,8 @@ require 'spec_helper' ...@@ -4,7 +4,8 @@ require 'spec_helper'
RSpec.describe 'issue header', :js do RSpec.describe 'issue header', :js do
let_it_be(:user) { create(:user) } let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) } let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:issue) { create(:issue, project: project) } let_it_be(:issue) { create(:issue, project: project) }
let_it_be(:closed_issue) { create(:issue, :closed, project: project) } let_it_be(:closed_issue) { create(:issue, :closed, project: project) }
let_it_be(:closed_locked_issue) { create(:issue, :closed, :locked, project: project) } let_it_be(:closed_locked_issue) { create(:issue, :closed, :locked, project: project) }
...@@ -12,7 +13,7 @@ RSpec.describe 'issue header', :js do ...@@ -12,7 +13,7 @@ RSpec.describe 'issue header', :js do
context 'when user has permission to update' do context 'when user has permission to update' do
before do before do
project.add_maintainer(user) group.add_owner(user)
sign_in(user) sign_in(user)
end end
...@@ -24,9 +25,10 @@ RSpec.describe 'issue header', :js do ...@@ -24,9 +25,10 @@ RSpec.describe 'issue header', :js do
click_button 'Issue actions' click_button 'Issue actions'
end end
it 'only shows the "New issue" and "Report abuse" items', :aggregate_failures do it 'shows the "New issue", "Report abuse", and "Delete issue" items', :aggregate_failures do
expect(page).to have_link 'New issue' expect(page).to have_link 'New issue'
expect(page).to have_link 'Report abuse' expect(page).to have_link 'Report abuse'
expect(page).to have_button 'Delete issue'
expect(page).not_to have_link 'Submit as spam' expect(page).not_to have_link 'Submit as spam'
end end
end end
...@@ -116,6 +118,7 @@ RSpec.describe 'issue header', :js do ...@@ -116,6 +118,7 @@ RSpec.describe 'issue header', :js do
expect(page).to have_link 'New issue' expect(page).to have_link 'New issue'
expect(page).to have_link 'Report abuse' expect(page).to have_link 'Report abuse'
expect(page).not_to have_link 'Submit as spam' expect(page).not_to have_link 'Submit as spam'
expect(page).not_to have_button 'Delete issue'
end end
end end
......
...@@ -326,44 +326,6 @@ describe('Issuable output', () => { ...@@ -326,44 +326,6 @@ describe('Issuable output', () => {
}); });
}); });
describe('deleteIssuable', () => {
it('changes URL when deleted', () => {
jest.spyOn(wrapper.vm.service, 'deleteIssuable').mockResolvedValue({
data: {
web_url: '/test',
},
});
return wrapper.vm.deleteIssuable().then(() => {
expect(visitUrl).toHaveBeenCalledWith('/test');
});
});
it('stops polling when deleting', () => {
const spy = jest.spyOn(wrapper.vm.poll, 'stop');
jest.spyOn(wrapper.vm.service, 'deleteIssuable').mockResolvedValue({
data: {
web_url: '/test',
},
});
return wrapper.vm.deleteIssuable().then(() => {
expect(spy).toHaveBeenCalledWith();
});
});
it('closes form on error', () => {
jest.spyOn(wrapper.vm.service, 'deleteIssuable').mockRejectedValue();
return wrapper.vm.deleteIssuable().then(() => {
expect(eventHub.$emit).not.toHaveBeenCalledWith('close.form');
expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
'Error deleting issue',
);
});
});
});
describe('updateAndShowForm', () => { describe('updateAndShowForm', () => {
it('shows locked warning if form is open & data is different', () => { it('shows locked warning if form is open & data is different', () => {
return wrapper.vm return wrapper.vm
......
import { GlModal } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import DeleteIssueModal from '~/issues/show/components/delete_issue_modal.vue';
jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' }));
describe('DeleteIssueModal component', () => {
let wrapper;
const defaultProps = {
issuePath: 'gitlab-org/gitlab-test/-/issues/1',
issueType: 'issue',
modalId: 'modal-id',
title: 'Delete issue',
};
const findForm = () => wrapper.find('form');
const findModal = () => wrapper.findComponent(GlModal);
const mountComponent = (props = {}) =>
shallowMount(DeleteIssueModal, { propsData: { ...defaultProps, ...props } });
afterEach(() => {
wrapper.destroy();
});
describe('modal', () => {
it('renders', () => {
wrapper = mountComponent();
expect(findModal().props()).toMatchObject({
actionCancel: DeleteIssueModal.actionCancel,
actionPrimary: {
attributes: { variant: 'danger' },
text: defaultProps.title,
},
modalId: defaultProps.modalId,
size: 'sm',
title: defaultProps.title,
});
});
describe('when "primary" event is emitted', () => {
let formSubmitSpy;
beforeEach(() => {
wrapper = mountComponent();
formSubmitSpy = jest.spyOn(wrapper.vm.$refs.form, 'submit');
findModal().vm.$emit('primary');
});
it('"delete" event is emitted by DeleteIssueModal', () => {
expect(wrapper.emitted('delete')).toEqual([[]]);
});
it('submits the form', () => {
expect(formSubmitSpy).toHaveBeenCalled();
});
});
});
describe('form', () => {
beforeEach(() => {
wrapper = mountComponent();
});
it('renders with action and method', () => {
expect(findForm().attributes()).toEqual({
action: defaultProps.issuePath,
method: 'post',
});
});
it('contains form data', () => {
const formData = wrapper.findAll('input').wrappers.reduce(
(acc, input) => ({
...acc,
[input.element.name]: input.element.value,
}),
{},
);
expect(formData).toEqual({
_method: 'delete',
authenticity_token: 'mock-csrf-token',
destroy_confirm: 'true',
});
});
});
describe('body text', () => {
describe('when issue type is not epic', () => {
it('renders', () => {
wrapper = mountComponent();
expect(findForm().text()).toBe('Issue will be removed! Are you sure?');
});
});
describe('when issue type is epic', () => {
it('renders', () => {
wrapper = mountComponent({ issueType: 'epic' });
expect(findForm().text()).toBe('Delete this epic and all descendants?');
});
});
});
});
import { GlButton, GlModal } from '@gitlab/ui'; import { GlButton } from '@gitlab/ui';
import { createLocalVue } from '@vue/test-utils'; import Vue from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import { mockTracking } from 'helpers/tracking_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import IssuableEditActions from '~/issues/show/components/edit_actions.vue'; import IssuableEditActions from '~/issues/show/components/edit_actions.vue';
import DeleteIssueModal from '~/issues/show/components/delete_issue_modal.vue';
import eventHub from '~/issues/show/event_hub'; import eventHub from '~/issues/show/event_hub';
import { import {
getIssueStateQueryResponse, getIssueStateQueryResponse,
updateIssueStateQueryResponse, updateIssueStateQueryResponse,
} from '../mock_data/apollo_mock'; } from '../mock_data/apollo_mock';
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('Edit Actions component', () => { describe('Edit Actions component', () => {
let wrapper; let wrapper;
let fakeApollo; let fakeApollo;
let mockIssueStateData; let mockIssueStateData;
Vue.use(VueApollo);
const mockResolvers = { const mockResolvers = {
Query: { Query: {
issueState() { issueState() {
...@@ -43,6 +43,7 @@ describe('Edit Actions component', () => { ...@@ -43,6 +43,7 @@ describe('Edit Actions component', () => {
title: 'GitLab Issue', title: 'GitLab Issue',
}, },
canDestroy: true, canDestroy: true,
endpoint: 'gitlab-org/gitlab-test/-/issues/1',
issuableType: 'issue', issuableType: 'issue',
...props, ...props,
}, },
...@@ -56,11 +57,7 @@ describe('Edit Actions component', () => { ...@@ -56,11 +57,7 @@ describe('Edit Actions component', () => {
}); });
}; };
async function deleteIssuable(localWrapper) { const findModal = () => wrapper.findComponent(DeleteIssueModal);
localWrapper.findComponent(GlModal).vm.$emit('primary');
}
const findModal = () => wrapper.findComponent(GlModal);
const findEditButtons = () => wrapper.findAllComponents(GlButton); const findEditButtons = () => wrapper.findAllComponents(GlButton);
const findDeleteButton = () => wrapper.findByTestId('issuable-delete-button'); const findDeleteButton = () => wrapper.findByTestId('issuable-delete-button');
const findSaveButton = () => wrapper.findByTestId('issuable-save-button'); const findSaveButton = () => wrapper.findByTestId('issuable-save-button');
...@@ -123,9 +120,30 @@ describe('Edit Actions component', () => { ...@@ -123,9 +120,30 @@ describe('Edit Actions component', () => {
}); });
}); });
describe('renders create modal with the correct information', () => { describe('delete issue button', () => {
it('renders correct modal id', () => { let trackingSpy;
expect(findModal().attributes('modalid')).toBe(modalId);
beforeEach(() => {
trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
});
it('tracks clicking on button', () => {
findDeleteButton().vm.$emit('click');
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_button', {
label: 'delete_issue',
});
});
});
describe('delete issue modal', () => {
it('renders', () => {
expect(findModal().props()).toEqual({
issuePath: 'gitlab-org/gitlab-test/-/issues/1',
issueType: 'Issue',
modalId,
title: 'Delete issue',
});
}); });
}); });
...@@ -141,8 +159,8 @@ describe('Edit Actions component', () => { ...@@ -141,8 +159,8 @@ describe('Edit Actions component', () => {
it('sends the `delete.issuable` event when clicking the delete confirm button', async () => { it('sends the `delete.issuable` event when clicking the delete confirm button', async () => {
expect(eventHub.$emit).toHaveBeenCalledTimes(0); expect(eventHub.$emit).toHaveBeenCalledTimes(0);
await deleteIssuable(wrapper); findModal().vm.$emit('delete');
expect(eventHub.$emit).toHaveBeenCalledWith('delete.issuable', { destroy_confirm: true }); expect(eventHub.$emit).toHaveBeenCalledWith('delete.issuable');
expect(eventHub.$emit).toHaveBeenCalledTimes(1); expect(eventHub.$emit).toHaveBeenCalledTimes(1);
}); });
}); });
......
...@@ -13,6 +13,7 @@ describe('Inline edit form component', () => { ...@@ -13,6 +13,7 @@ describe('Inline edit form component', () => {
let wrapper; let wrapper;
const defaultProps = { const defaultProps = {
canDestroy: true, canDestroy: true,
endpoint: 'gitlab-org/gitlab-test/-/issues/1',
formState: { formState: {
title: 'b', title: 'b',
description: 'a', description: 'a',
......
import { GlButton, GlDropdown, GlDropdownItem, GlLink, GlModal } from '@gitlab/ui'; import { GlButton, GlDropdown, GlDropdownItem, GlLink, GlModal } from '@gitlab/ui';
import { createLocalVue, shallowMount } from '@vue/test-utils'; import Vue from 'vue';
import { shallowMount } from '@vue/test-utils';
import Vuex from 'vuex'; import Vuex from 'vuex';
import { mockTracking } from 'helpers/tracking_helper';
import createFlash, { FLASH_TYPES } from '~/flash'; import createFlash, { FLASH_TYPES } from '~/flash';
import { IssuableType } from '~/vue_shared/issuable/show/constants'; import { IssuableType } from '~/vue_shared/issuable/show/constants';
import DeleteIssueModal from '~/issues/show/components/delete_issue_modal.vue';
import HeaderActions from '~/issues/show/components/header_actions.vue'; import HeaderActions from '~/issues/show/components/header_actions.vue';
import { IssuableStatus } from '~/issues/constants'; import { IssuableStatus } from '~/issues/constants';
import { IssueStateEvent } from '~/issues/show/constants'; import { IssueStateEvent } from '~/issues/show/constants';
...@@ -19,18 +22,20 @@ describe('HeaderActions component', () => { ...@@ -19,18 +22,20 @@ describe('HeaderActions component', () => {
let wrapper; let wrapper;
let visitUrlSpy; let visitUrlSpy;
const localVue = createLocalVue(); Vue.use(Vuex);
localVue.use(Vuex);
const store = createStore(); const store = createStore();
const defaultProps = { const defaultProps = {
canCreateIssue: true, canCreateIssue: true,
canDestroyIssue: true,
canPromoteToEpic: true, canPromoteToEpic: true,
canReopenIssue: true, canReopenIssue: true,
canReportSpam: true, canReportSpam: true,
canUpdateIssue: true, canUpdateIssue: true,
iid: '32', iid: '32',
isIssueAuthor: true, isIssueAuthor: true,
issuePath: 'gitlab-org/gitlab-test/-/issues/1',
issueType: IssuableType.Issue, issueType: IssuableType.Issue,
newIssuePath: 'gitlab-org/gitlab-test/-/issues/new', newIssuePath: 'gitlab-org/gitlab-test/-/issues/new',
projectPath: 'gitlab-org/gitlab-test', projectPath: 'gitlab-org/gitlab-test',
...@@ -61,17 +66,12 @@ describe('HeaderActions component', () => { ...@@ -61,17 +66,12 @@ describe('HeaderActions component', () => {
}, },
}; };
const findToggleIssueStateButton = () => wrapper.find(GlButton); const findToggleIssueStateButton = () => wrapper.findComponent(GlButton);
const findDropdownAt = (index) => wrapper.findAllComponents(GlDropdown).at(index);
const findDropdownAt = (index) => wrapper.findAll(GlDropdown).at(index); const findMobileDropdownItems = () => findDropdownAt(0).findAllComponents(GlDropdownItem);
const findDesktopDropdownItems = () => findDropdownAt(1).findAllComponents(GlDropdownItem);
const findMobileDropdownItems = () => findDropdownAt(0).findAll(GlDropdownItem); const findModal = () => wrapper.findComponent(GlModal);
const findModalLinkAt = (index) => findModal().findAllComponents(GlLink).at(index);
const findDesktopDropdownItems = () => findDropdownAt(1).findAll(GlDropdownItem);
const findModal = () => wrapper.find(GlModal);
const findModalLinkAt = (index) => findModal().findAll(GlLink).at(index);
const mountComponent = ({ const mountComponent = ({
props = {}, props = {},
...@@ -87,7 +87,6 @@ describe('HeaderActions component', () => { ...@@ -87,7 +87,6 @@ describe('HeaderActions component', () => {
}); });
return shallowMount(HeaderActions, { return shallowMount(HeaderActions, {
localVue,
store, store,
provide: { provide: {
...defaultProps, ...defaultProps,
...@@ -168,17 +167,19 @@ describe('HeaderActions component', () => { ...@@ -168,17 +167,19 @@ describe('HeaderActions component', () => {
${'desktop dropdown'} | ${false} | ${findDesktopDropdownItems} ${'desktop dropdown'} | ${false} | ${findDesktopDropdownItems}
`('$description', ({ isCloseIssueItemVisible, findDropdownItems }) => { `('$description', ({ isCloseIssueItemVisible, findDropdownItems }) => {
describe.each` describe.each`
description | itemText | isItemVisible | canUpdateIssue | canCreateIssue | isIssueAuthor | canReportSpam | canPromoteToEpic description | itemText | isItemVisible | canUpdateIssue | canCreateIssue | isIssueAuthor | canReportSpam | canPromoteToEpic | canDestroyIssue
${`when user can update ${issueType}`} | ${`Close ${issueType}`} | ${isCloseIssueItemVisible} | ${true} | ${true} | ${true} | ${true} | ${true} ${`when user can update ${issueType}`} | ${`Close ${issueType}`} | ${isCloseIssueItemVisible} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true}
${`when user cannot update ${issueType}`} | ${`Close ${issueType}`} | ${false} | ${false} | ${true} | ${true} | ${true} | ${true} ${`when user cannot update ${issueType}`} | ${`Close ${issueType}`} | ${false} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true}
${`when user can create ${issueType}`} | ${`New ${issueType}`} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} ${`when user can create ${issueType}`} | ${`New ${issueType}`} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true}
${`when user cannot create ${issueType}`} | ${`New ${issueType}`} | ${false} | ${true} | ${false} | ${true} | ${true} | ${true} ${`when user cannot create ${issueType}`} | ${`New ${issueType}`} | ${false} | ${true} | ${false} | ${true} | ${true} | ${true} | ${true}
${'when user can promote to epic'} | ${'Promote to epic'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} ${'when user can promote to epic'} | ${'Promote to epic'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true}
${'when user cannot promote to epic'} | ${'Promote to epic'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${false} ${'when user cannot promote to epic'} | ${'Promote to epic'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${false} | ${true}
${'when user can report abuse'} | ${'Report abuse'} | ${true} | ${true} | ${true} | ${false} | ${true} | ${true} ${'when user can report abuse'} | ${'Report abuse'} | ${true} | ${true} | ${true} | ${false} | ${true} | ${true} | ${true}
${'when user cannot report abuse'} | ${'Report abuse'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} ${'when user cannot report abuse'} | ${'Report abuse'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true}
${'when user can submit as spam'} | ${'Submit as spam'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} ${'when user can submit as spam'} | ${'Submit as spam'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true}
${'when user cannot submit as spam'} | ${'Submit as spam'} | ${false} | ${true} | ${true} | ${true} | ${false} | ${true} ${'when user cannot submit as spam'} | ${'Submit as spam'} | ${false} | ${true} | ${true} | ${true} | ${false} | ${true} | ${true}
${`when user can delete ${issueType}`} | ${`Delete ${issueType}`} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true}
${`when user cannot delete ${issueType}`} | ${`Delete ${issueType}`} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false}
`( `(
'$description', '$description',
({ ({
...@@ -189,6 +190,7 @@ describe('HeaderActions component', () => { ...@@ -189,6 +190,7 @@ describe('HeaderActions component', () => {
isIssueAuthor, isIssueAuthor,
canReportSpam, canReportSpam,
canPromoteToEpic, canPromoteToEpic,
canDestroyIssue,
}) => { }) => {
beforeEach(() => { beforeEach(() => {
wrapper = mountComponent({ wrapper = mountComponent({
...@@ -199,6 +201,7 @@ describe('HeaderActions component', () => { ...@@ -199,6 +201,7 @@ describe('HeaderActions component', () => {
issueType, issueType,
canReportSpam, canReportSpam,
canPromoteToEpic, canPromoteToEpic,
canDestroyIssue,
}, },
}); });
}); });
...@@ -215,6 +218,23 @@ describe('HeaderActions component', () => { ...@@ -215,6 +218,23 @@ describe('HeaderActions component', () => {
}); });
}); });
describe('delete issue button', () => {
let trackingSpy;
beforeEach(() => {
wrapper = mountComponent();
trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
});
it('tracks clicking on button', () => {
findDesktopDropdownItems().at(3).vm.$emit('click');
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_dropdown', {
label: 'delete_issue',
});
});
});
describe('when "Promote to epic" button is clicked', () => { describe('when "Promote to epic" button is clicked', () => {
describe('when response is successful', () => { describe('when response is successful', () => {
beforeEach(() => { beforeEach(() => {
...@@ -268,7 +288,7 @@ describe('HeaderActions component', () => { ...@@ -268,7 +288,7 @@ describe('HeaderActions component', () => {
it('shows an error message', () => { it('shows an error message', () => {
expect(createFlash).toHaveBeenCalledWith({ expect(createFlash).toHaveBeenCalledWith({
message: promoteToEpicMutationErrorResponse.data.promoteToEpic.errors.join('; '), message: HeaderActions.i18n.promoteErrorMessage,
}); });
}); });
}); });
...@@ -294,7 +314,7 @@ describe('HeaderActions component', () => { ...@@ -294,7 +314,7 @@ describe('HeaderActions component', () => {
}); });
}); });
describe('modal', () => { describe('blocked by issues 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' },
{ iid: 79, web_url: 'gitlab-org/gitlab-test/-/issues/79' }, { iid: 79, web_url: 'gitlab-org/gitlab-test/-/issues/79' },
...@@ -346,4 +366,17 @@ describe('HeaderActions component', () => { ...@@ -346,4 +366,17 @@ describe('HeaderActions component', () => {
}); });
}); });
}); });
describe('delete issue modal', () => {
it('renders', () => {
wrapper = mountComponent();
expect(wrapper.findComponent(DeleteIssueModal).props()).toEqual({
issuePath: defaultProps.issuePath,
issueType: defaultProps.issueType,
modalId: HeaderActions.deleteModalId,
title: 'Delete issue',
});
});
});
}); });
...@@ -278,11 +278,13 @@ RSpec.describe IssuesHelper do ...@@ -278,11 +278,13 @@ RSpec.describe IssuesHelper do
it 'returns expected result' do it 'returns expected result' do
expected = { expected = {
can_create_issue: 'true', can_create_issue: 'true',
can_destroy_issue: 'true',
can_reopen_issue: 'true', can_reopen_issue: 'true',
can_report_spam: 'false', can_report_spam: 'false',
can_update_issue: 'true', can_update_issue: 'true',
iid: issue.iid, iid: issue.iid,
is_issue_author: 'false', is_issue_author: 'false',
issue_path: issue_path(issue),
issue_type: 'issue', issue_type: 'issue',
new_issue_path: new_project_issue_path(project, { issue: { description: "Related to \##{issue.iid}.\n\n" } }), new_issue_path: new_project_issue_path(project, { issue: { description: "Related to \##{issue.iid}.\n\n" } }),
project_path: project.full_path, project_path: project.full_path,
......
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