Commit d441a318 authored by Andrew Fontaine's avatar Andrew Fontaine

Merge branch '222782-relocate-create-issue-button' into 'master'

Relocate create issue button

See merge request gitlab-org/gitlab!39533
parents 7326957d ff7a1892
...@@ -29,6 +29,16 @@ export default { ...@@ -29,6 +29,16 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
isLocked: {
type: Boolean,
required: false,
default: false,
},
lockedMessage: {
type: String,
required: false,
default: '',
},
}, },
computed: { computed: {
stateTitle() { stateTitle() {
...@@ -156,8 +166,17 @@ export default { ...@@ -156,8 +166,17 @@ export default {
</div> </div>
</div> </div>
<span
v-if="isLocked"
ref="lockIcon"
v-gl-tooltip
class="gl-px-3 gl-display-inline-block gl-cursor-not-allowed"
:title="lockedMessage"
>
<gl-icon name="lock" />
</span>
<button <button
v-if="canRemove" v-else-if="canRemove"
ref="removeButton" ref="removeButton"
v-gl-tooltip v-gl-tooltip
:disabled="removeDisabled" :disabled="removeDisabled"
...@@ -168,7 +187,7 @@ export default { ...@@ -168,7 +187,7 @@ export default {
:aria-label="__('Remove')" :aria-label="__('Remove')"
@click="onRemoveRequest" @click="onRemoveRequest"
> >
<icon :size="16" class="btn-item-remove-icon" name="close" /> <gl-icon class="btn-item-remove-icon" name="close" />
</button> </button>
</div> </div>
</template> </template>
...@@ -121,7 +121,7 @@ information with several options: ...@@ -121,7 +121,7 @@ information with several options:
- [Solution](#solutions-for-vulnerabilities-auto-remediation): For some vulnerabilities, - [Solution](#solutions-for-vulnerabilities-auto-remediation): For some vulnerabilities,
a solution is provided for how to fix the vulnerability. a solution is provided for how to fix the vulnerability.
![Interacting with security reports](img/interacting_with_vulnerability_v13_0.png) ![Interacting with security reports](img/interacting_with_vulnerability_v13_3.png)
### View details of a DAST vulnerability ### View details of a DAST vulnerability
...@@ -198,9 +198,10 @@ Pressing the "Dismiss Selected" button will dismiss all the selected vulnerabili ...@@ -198,9 +198,10 @@ Pressing the "Dismiss Selected" button will dismiss all the selected vulnerabili
### Creating an issue for a vulnerability ### Creating an issue for a vulnerability
You can create an issue for a vulnerability by selecting the **Create issue** You can create an issue for a vulnerability by visiting the vulnerability's page and clicking
button from within the vulnerability modal, or by using the action buttons to the right of **Create issue**, which you can find in the **Related issues** section.
a vulnerability row in the group security dashboard.
![Create issue from vulnerability](img/create_issue_from_vulnerability_v13_3.png)
This creates a [confidential issue](../project/issues/confidential_issues.md) in the project the This creates a [confidential issue](../project/issues/confidential_issues.md) in the project the
vulnerability came from, and pre-populates it with some useful information taken from the vulnerability vulnerability came from, and pre-populates it with some useful information taken from the vulnerability
......
...@@ -42,12 +42,15 @@ function createFooterApp() { ...@@ -42,12 +42,15 @@ function createFooterApp() {
vulnerabilityFeedbackHelpPath, vulnerabilityFeedbackHelpPath,
hasMr, hasMr,
discussionsUrl, discussionsUrl,
createIssueUrl,
state, state,
issueFeedback, issueFeedback,
mergeRequestFeedback, mergeRequestFeedback,
notesUrl, notesUrl,
project, project,
projectFingerprint,
remediations, remediations,
reportType,
solution, solution,
id, id,
canModifyRelatedIssues, canModifyRelatedIssues,
...@@ -85,6 +88,12 @@ function createFooterApp() { ...@@ -85,6 +88,12 @@ function createFooterApp() {
return new Vue({ return new Vue({
el, el,
provide: {
reportType,
createIssueUrl,
projectFingerprint,
vulnerabilityId: id,
},
render: h => render: h =>
h(FooterApp, { h(FooterApp, {
props, props,
......
...@@ -125,7 +125,10 @@ export default { ...@@ -125,7 +125,10 @@ export default {
<template> <template>
<div id="related-issues" class="related-issues-block"> <div id="related-issues" class="related-issues-block">
<div class="card card-slim gl-overflow-hidden"> <div class="card card-slim gl-overflow-hidden">
<div :class="{ 'panel-empty-heading border-bottom-0': !hasBody }" class="card-header"> <div
:class="{ 'panel-empty-heading border-bottom-0': !hasBody }"
class="card-header gl-display-flex gl-justify-content-space-between"
>
<h3 <h3
class="card-title h5 position-relative gl-my-0 gl-display-flex gl-align-items-center gl-h-7" class="card-title h5 position-relative gl-my-0 gl-display-flex gl-align-items-center gl-h-7"
> >
...@@ -164,6 +167,7 @@ export default { ...@@ -164,6 +167,7 @@ export default {
/> />
</div> </div>
</h3> </h3>
<slot name="headerActions"></slot>
</div> </div>
<div <div
class="linked-issues-card-body bg-gray-light" class="linked-issues-card-body bg-gray-light"
......
...@@ -133,6 +133,8 @@ export default { ...@@ -133,6 +133,8 @@ export default {
:can-remove="canAdmin" :can-remove="canAdmin"
:can-reorder="canReorder" :can-reorder="canReorder"
:path-id-separator="pathIdSeparator" :path-id-separator="pathIdSeparator"
:is-locked="issue.lockIssueRemoval"
:locked-message="issue.lockedMessage"
event-namespace="relatedIssue" event-namespace="relatedIssue"
class="qa-related-issuable-item" class="qa-related-issuable-item"
@relatedIssueRemoveRequest="$emit('relatedIssueRemoveRequest', $event)" @relatedIssueRemoveRequest="$emit('relatedIssueRemoveRequest', $event)"
......
<script> <script>
import { GlButton, GlLoadingIcon } from '@gitlab/ui'; import { GlLoadingIcon, GlButton } from '@gitlab/ui';
import Api from 'ee/api'; import Api from 'ee/api';
import { CancelToken } from 'axios'; import { CancelToken } from 'axios';
import SplitButton from 'ee/vue_shared/security_reports/components/split_button.vue'; import SplitButton from 'ee/vue_shared/security_reports/components/split_button.vue';
...@@ -17,9 +17,10 @@ import VulnerabilitiesEventBus from './vulnerabilities_event_bus'; ...@@ -17,9 +17,10 @@ import VulnerabilitiesEventBus from './vulnerabilities_event_bus';
export default { export default {
name: 'VulnerabilityHeader', name: 'VulnerabilityHeader',
components: { components: {
GlButton,
GlLoadingIcon, GlLoadingIcon,
GlButton,
ResolutionAlert, ResolutionAlert,
VulnerabilityStateDropdown, VulnerabilityStateDropdown,
SplitButton, SplitButton,
...@@ -35,8 +36,8 @@ export default { ...@@ -35,8 +36,8 @@ export default {
data() { data() {
return { return {
isLoadingVulnerability: false,
isProcessingAction: false, isProcessingAction: false,
isLoadingVulnerability: false,
isLoadingUser: false, isLoadingUser: false,
vulnerability: this.initialVulnerability, vulnerability: this.initialVulnerability,
user: undefined, user: undefined,
...@@ -56,10 +57,6 @@ export default { ...@@ -56,10 +57,6 @@ export default {
buttons.push(HEADER_ACTION_BUTTONS.patchDownload); buttons.push(HEADER_ACTION_BUTTONS.patchDownload);
} }
if (!this.hasIssue) {
buttons.push(HEADER_ACTION_BUTTONS.issueCreation);
}
return buttons; return buttons;
}, },
canDownloadPatch() { canDownloadPatch() {
...@@ -151,38 +148,6 @@ export default { ...@@ -151,38 +148,6 @@ export default {
VulnerabilitiesEventBus.$emit('VULNERABILITY_STATE_CHANGE'); VulnerabilitiesEventBus.$emit('VULNERABILITY_STATE_CHANGE');
}); });
}, },
createIssue() {
this.isProcessingAction = true;
const {
report_type: category,
project_fingerprint: projectFingerprint,
id,
} = this.vulnerability;
axios
.post(this.vulnerability.create_issue_url, {
vulnerability_feedback: {
feedback_type: FEEDBACK_TYPES.ISSUE,
category,
project_fingerprint: projectFingerprint,
vulnerability_data: {
...this.vulnerability,
category,
vulnerability_id: id,
},
},
})
.then(({ data: { issue_url } }) => {
redirectTo(issue_url);
})
.catch(() => {
this.isProcessingAction = false;
createFlash(
s__('VulnerabilityManagement|Something went wrong, could not create an issue.'),
);
});
},
createMergeRequest() { createMergeRequest() {
this.isProcessingAction = true; this.isProcessingAction = true;
...@@ -299,7 +264,6 @@ export default { ...@@ -299,7 +264,6 @@ export default {
:disabled="isProcessingAction" :disabled="isProcessingAction"
class="js-split-button" class="js-split-button"
@createMergeRequest="createMergeRequest" @createMergeRequest="createMergeRequest"
@createIssue="createIssue"
@downloadPatch="downloadPatch" @downloadPatch="downloadPatch"
/> />
<gl-button <gl-button
......
<script> <script>
import axios from 'axios'; import axios from 'axios';
import { GlButton } from '@gitlab/ui';
import RelatedIssuesStore from 'ee/related_issues/stores/related_issues_store'; import RelatedIssuesStore from 'ee/related_issues/stores/related_issues_store';
import RelatedIssuesBlock from 'ee/related_issues/components/related_issues_block.vue'; import RelatedIssuesBlock from 'ee/related_issues/components/related_issues_block.vue';
import { issuableTypesMap, PathIdSeparator } from 'ee/related_issues/constants'; import { issuableTypesMap, PathIdSeparator } from 'ee/related_issues/constants';
import { sprintf, __ } from '~/locale'; import { sprintf, __, s__ } from '~/locale';
import { joinPaths } from '~/lib/utils/url_utility'; import { joinPaths, redirectTo } from '~/lib/utils/url_utility';
import { RELATED_ISSUES_ERRORS } from '../constants'; import { RELATED_ISSUES_ERRORS, FEEDBACK_TYPES } from '../constants';
import { deprecatedCreateFlash as createFlash } from '~/flash'; import { deprecatedCreateFlash as createFlash } from '~/flash';
import { getFormattedIssue, getAddRelatedIssueRequestParams } from '../helpers'; import { getFormattedIssue, getAddRelatedIssueRequestParams } from '../helpers';
export default { export default {
name: 'VulnerabilityRelatedIssues', name: 'VulnerabilityRelatedIssues',
components: { RelatedIssuesBlock }, components: {
RelatedIssuesBlock,
GlButton,
},
props: { props: {
endpoint: { endpoint: {
type: String, type: String,
...@@ -34,7 +38,9 @@ export default { ...@@ -34,7 +38,9 @@ export default {
}, },
data() { data() {
this.store = new RelatedIssuesStore(); this.store = new RelatedIssuesStore();
return { return {
isProcessingAction: false,
state: this.store.state, state: this.store.state,
isFetching: false, isFetching: false,
isSubmitting: false, isSubmitting: false,
...@@ -46,11 +52,54 @@ export default { ...@@ -46,11 +52,54 @@ export default {
vulnerabilityProjectId() { vulnerabilityProjectId() {
return this.projectPath.replace(/^\//, ''); // Remove the leading slash, i.e. '/root/test' -> 'root/test'. return this.projectPath.replace(/^\//, ''); // Remove the leading slash, i.e. '/root/test' -> 'root/test'.
}, },
isIssueAlreadyCreated() {
return Boolean(this.state.relatedIssues.find(i => i.lockIssueRemoval));
},
},
inject: {
vulnerabilityId: {
type: Number,
},
projectFingerprint: {
type: String,
},
createIssueUrl: {
type: String,
},
reportType: {
type: String,
},
}, },
created() { created() {
this.fetchRelatedIssues(); this.fetchRelatedIssues();
}, },
methods: { methods: {
createIssue() {
this.isProcessingAction = true;
return axios
.post(this.createIssueUrl, {
vulnerability_feedback: {
feedback_type: FEEDBACK_TYPES.ISSUE,
category: this.reportType,
project_fingerprint: this.projectFingerprint,
vulnerability_data: {
...this.vulnerability,
category: this.reportType,
vulnerability_id: this.vulnerabilityId,
},
},
})
.then(({ data: { issue_url } }) => {
redirectTo(issue_url);
})
.catch(() => {
this.isProcessingAction = false;
createFlash(
s__('VulnerabilityManagement|Something went wrong, could not create an issue.'),
);
});
},
toggleFormVisibility() { toggleFormVisibility() {
this.isFormVisible = !this.isFormVisible; this.isFormVisible = !this.isFormVisible;
}, },
...@@ -119,7 +168,19 @@ export default { ...@@ -119,7 +168,19 @@ export default {
.get(this.endpoint) .get(this.endpoint)
.then(({ data }) => { .then(({ data }) => {
const issues = data.map(getFormattedIssue); const issues = data.map(getFormattedIssue);
this.store.setRelatedIssues(issues); this.store.setRelatedIssues(
issues.map(i => {
const lockIssueRemoval = i.vulnerability_link_type === 'created';
return {
...i,
lockIssueRemoval,
lockedMessage: lockIssueRemoval
? s__('SecurityReports|Issues created from a vulnerability cannot be removed.')
: undefined,
};
}),
);
}) })
.catch(() => { .catch(() => {
createFlash(__('An error occurred while fetching issues.')); createFlash(__('An error occurred while fetching issues.'));
...@@ -168,6 +229,19 @@ export default { ...@@ -168,6 +229,19 @@ export default {
@pendingIssuableRemoveRequest="removePendingReference" @pendingIssuableRemoveRequest="removePendingReference"
@relatedIssueRemoveRequest="removeRelatedIssue" @relatedIssueRemoveRequest="removeRelatedIssue"
> >
<template #headerText>{{ __('Related issues') }}</template> <template #headerText>
{{ __('Related issues') }}
</template>
<template v-if="!isIssueAlreadyCreated && !isFetching" #headerActions>
<gl-button
ref="createIssue"
variant="success"
category="secondary"
:loading="isProcessingAction"
@click="createIssue"
>
{{ __('Create issue') }}
</gl-button>
</template>
</related-issues-block> </related-issues-block>
</template> </template>
...@@ -32,11 +32,6 @@ export const VULNERABILITY_STATES = { ...@@ -32,11 +32,6 @@ export const VULNERABILITY_STATES = {
}; };
export const HEADER_ACTION_BUTTONS = { export const HEADER_ACTION_BUTTONS = {
issueCreation: {
name: s__('ciReport|Create issue'),
tagline: s__('ciReport|Investigate this vulnerability by creating an issue'),
action: 'createIssue',
},
mergeRequestCreation: { mergeRequestCreation: {
name: s__('ciReport|Resolve with merge request'), name: s__('ciReport|Resolve with merge request'),
tagline: s__('ciReport|Automatically apply the patch in a new branch'), tagline: s__('ciReport|Automatically apply the patch in a new branch'),
......
---
title: Relocate create issue button from header section to the related issues section
merge_request: 39533
author:
type: changed
...@@ -60,6 +60,22 @@ describe('RelatedIssuesBlock', () => { ...@@ -60,6 +60,22 @@ describe('RelatedIssuesBlock', () => {
}); });
}); });
describe('with headerActions slot', () => {
it('displays header actions slot data', () => {
const headerActions = '<button data-testid="custom-button">custom button</button>';
wrapper = shallowMount(RelatedIssuesBlock, {
propsData: {
pathIdSeparator: PathIdSeparator.Issue,
issuableType: 'issue',
},
slots: { headerActions },
});
expect(wrapper.find('[data-testid="custom-button"]').html()).toBe(headerActions);
});
});
describe('with isFetching=true', () => { describe('with isFetching=true', () => {
beforeEach(() => { beforeEach(() => {
wrapper = mount(RelatedIssuesBlock, { wrapper = mount(RelatedIssuesBlock, {
......
...@@ -47,10 +47,13 @@ describe('Vulnerability Header', () => { ...@@ -47,10 +47,13 @@ describe('Vulnerability Header', () => {
const diff = 'some diff to download'; const diff = 'some diff to download';
const getVulnerability = ({ shouldShowCreateIssueButton, shouldShowMergeRequestButton }) => { const getVulnerability = ({
shouldShowMergeRequestButton,
shouldShowDownloadPatchButton = true,
}) => {
return { return {
issue_feedback: shouldShowCreateIssueButton ? null : { issue_iid: 12 },
remediations: shouldShowMergeRequestButton ? [{ diff }] : null, remediations: shouldShowMergeRequestButton ? [{ diff }] : null,
hasMr: !shouldShowDownloadPatchButton,
merge_request_feedback: { merge_request_feedback: {
merge_request_path: shouldShowMergeRequestButton ? null : 'some path', merge_request_path: shouldShowMergeRequestButton ? null : 'some path',
}, },
...@@ -149,22 +152,21 @@ describe('Vulnerability Header', () => { ...@@ -149,22 +152,21 @@ describe('Vulnerability Header', () => {
describe('split button', () => { describe('split button', () => {
it('does render the create merge request and issue button as a split button', () => { it('does render the create merge request and issue button as a split button', () => {
createWrapper( createWrapper(getVulnerability({ shouldShowMergeRequestButton: true }));
getVulnerability({
shouldShowCreateIssueButton: true,
shouldShowMergeRequestButton: true,
}),
);
expect(findSplitButton().exists()).toBe(true); expect(findSplitButton().exists()).toBe(true);
const buttons = findSplitButton().props('buttons'); const buttons = findSplitButton().props('buttons');
expect(buttons).toHaveLength(3); expect(buttons).toHaveLength(2);
expect(buttons[0].name).toBe('Resolve with merge request'); expect(buttons[0].name).toBe('Resolve with merge request');
expect(buttons[1].name).toBe('Download patch to resolve'); expect(buttons[1].name).toBe('Download patch to resolve');
expect(buttons[2].name).toBe('Create issue');
}); });
it('does not render the split button if there is only one action', () => { it('does not render the split button if there is only one action', () => {
createWrapper(getVulnerability({ shouldShowCreateIssueButton: true })); createWrapper(
getVulnerability({
shouldShowMergeRequestButton: true,
shouldShowDownloadPatchButton: false,
}),
);
expect(findSplitButton().exists()).toBe(false); expect(findSplitButton().exists()).toBe(false);
}); });
}); });
...@@ -175,57 +177,13 @@ describe('Vulnerability Header', () => { ...@@ -175,57 +177,13 @@ describe('Vulnerability Header', () => {
expect(findGlButton().exists()).toBe(false); expect(findGlButton().exists()).toBe(false);
}); });
describe('create issue', () => {
beforeEach(() => createWrapper(getVulnerability({ shouldShowCreateIssueButton: true })));
it('does display if there is only one action and not an issue already created', () => {
expect(findGlButton().exists()).toBe(true);
expect(findGlButton().text()).toBe('Create issue');
});
it('calls create issue endpoint on click and redirects to new issue', () => {
const issueUrl = '/group/project/issues/123';
const spy = jest.spyOn(urlUtility, 'redirectTo');
mockAxios.onPost(defaultVulnerability.create_issue_url).reply(200, {
issue_url: issueUrl,
});
findGlButton().vm.$emit('click');
return waitForPromises().then(() => {
expect(mockAxios.history.post).toHaveLength(1);
const [postRequest] = mockAxios.history.post;
expect(postRequest.url).toBe(defaultVulnerability.create_issue_url);
expect(JSON.parse(postRequest.data)).toMatchObject({
vulnerability_feedback: {
feedback_type: FEEDBACK_TYPES.ISSUE,
category: defaultVulnerability.report_type,
project_fingerprint: defaultVulnerability.project_fingerprint,
vulnerability_data: {
...getVulnerability({ shouldShowCreateIssueButton: true }),
category: defaultVulnerability.report_type,
vulnerability_id: defaultVulnerability.id,
},
},
});
expect(spy).toHaveBeenCalledWith(issueUrl);
});
});
it('shows an error message when issue creation fails', () => {
mockAxios.onPost(defaultVulnerability.create_issue_url).reply(500);
findGlButton().vm.$emit('click');
return waitForPromises().then(() => {
expect(mockAxios.history.post).toHaveLength(1);
expect(createFlash).toHaveBeenCalledWith(
'Something went wrong, could not create an issue.',
);
});
});
});
describe('create merge request', () => { describe('create merge request', () => {
beforeEach(() => { beforeEach(() => {
createWrapper({ createWrapper({
...getVulnerability({ shouldShowMergeRequestButton: true }), ...getVulnerability({
shouldShowMergeRequestButton: true,
shouldShowDownloadPatchButton: false,
}),
state: 'resolved', state: 'resolved',
}); });
}); });
...@@ -253,6 +211,7 @@ describe('Vulnerability Header', () => { ...@@ -253,6 +211,7 @@ describe('Vulnerability Header', () => {
project_fingerprint: defaultVulnerability.project_fingerprint, project_fingerprint: defaultVulnerability.project_fingerprint,
vulnerability_data: { vulnerability_data: {
...getVulnerability({ shouldShowMergeRequestButton: true }), ...getVulnerability({ shouldShowMergeRequestButton: true }),
hasMr: true,
category: defaultVulnerability.report_type, category: defaultVulnerability.report_type,
state: 'resolved', state: 'resolved',
}, },
......
...@@ -3,9 +3,12 @@ import MockAdapter from 'axios-mock-adapter'; ...@@ -3,9 +3,12 @@ import MockAdapter from 'axios-mock-adapter';
import RelatedIssues from 'ee/vulnerabilities/components/related_issues.vue'; import RelatedIssues from 'ee/vulnerabilities/components/related_issues.vue';
import RelatedIssuesBlock from 'ee/related_issues/components/related_issues_block.vue'; import RelatedIssuesBlock from 'ee/related_issues/components/related_issues_block.vue';
import { issuableTypesMap, PathIdSeparator } from 'ee/related_issues/constants'; import { issuableTypesMap, PathIdSeparator } from 'ee/related_issues/constants';
import { FEEDBACK_TYPES } from 'ee/vulnerabilities/constants';
import waitForPromises from 'helpers/wait_for_promises';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { deprecatedCreateFlash as createFlash } from '~/flash'; import { deprecatedCreateFlash as createFlash } from '~/flash';
import httpStatusCodes from '~/lib/utils/http_status'; import httpStatusCodes from '~/lib/utils/http_status';
import * as urlUtility from '~/lib/utils/url_utility';
jest.mock('~/flash'); jest.mock('~/flash');
...@@ -21,11 +24,25 @@ describe('Vulnerability related issues component', () => { ...@@ -21,11 +24,25 @@ describe('Vulnerability related issues component', () => {
canModifyRelatedIssues: true, canModifyRelatedIssues: true,
}; };
const vulnerabilityId = 5131;
const createIssueUrl = '/create/issue';
const projectFingerprint = 'project-fingerprint';
const reportType = 'vulnerability';
const issue1 = { id: 3, vulnerabilityLinkId: 987 }; const issue1 = { id: 3, vulnerabilityLinkId: 987 };
const issue2 = { id: 25, vulnerabilityLinkId: 876 }; const issue2 = { id: 25, vulnerabilityLinkId: 876 };
const createWrapper = async (data = {}) => { const createWrapper = async (data = {}, opts) => {
wrapper = shallowMount(RelatedIssues, { propsData, data: () => data }); wrapper = shallowMount(RelatedIssues, {
propsData,
data: () => data,
provide: {
vulnerabilityId,
projectFingerprint,
createIssueUrl,
reportType,
},
...opts,
});
// Need this special check because RelatedIssues creates the store and uses its state in the data function, so we // Need this special check because RelatedIssues creates the store and uses its state in the data function, so we
// need to set the state of the store, not replace the state property. // need to set the state of the store, not replace the state property.
if (data.state) { if (data.state) {
...@@ -36,6 +53,7 @@ describe('Vulnerability related issues component', () => { ...@@ -36,6 +53,7 @@ describe('Vulnerability related issues component', () => {
const relatedIssuesBlock = () => wrapper.find(RelatedIssuesBlock); const relatedIssuesBlock = () => wrapper.find(RelatedIssuesBlock);
const blockProp = prop => relatedIssuesBlock().props(prop); const blockProp = prop => relatedIssuesBlock().props(prop);
const blockEmit = (eventName, data) => relatedIssuesBlock().vm.$emit(eventName, data); const blockEmit = (eventName, data) => relatedIssuesBlock().vm.$emit(eventName, data);
const findCreateIssueButton = () => wrapper.find({ ref: 'createIssue' });
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
...@@ -239,4 +257,71 @@ describe('Vulnerability related issues component', () => { ...@@ -239,4 +257,71 @@ describe('Vulnerability related issues component', () => {
expect(createFlash).toHaveBeenCalledTimes(1); expect(createFlash).toHaveBeenCalledTimes(1);
}); });
}); });
describe('when linked issue is already created', () => {
beforeEach(() => {
createWrapper(
{
isFetching: false,
state: { relatedIssues: [issue1, { ...issue2, vulnerabilityLinkType: 'created' }] },
},
{ stubs: { RelatedIssuesBlock } },
);
});
it('does not display the create issue button', () => {
expect(findCreateIssueButton().exists()).toBe(false);
});
});
describe('when linked issue is not yet created', () => {
beforeEach(async () => {
mockAxios.onGet(propsData.endpoint).replyOnce(httpStatusCodes.OK, [issue1, issue2]);
createWrapper({}, { stubs: { RelatedIssuesBlock } });
await axios.waitForAll();
});
it('displays the create issue button', () => {
expect(findCreateIssueButton().exists()).toBe(true);
});
it('calls create issue endpoint on click and redirects to new issue', async () => {
const issueUrl = '/group/project/issues/123';
const spy = jest.spyOn(urlUtility, 'redirectTo');
mockAxios.onPost(propsData.createIssueUrl).reply(200, {
issue_url: issueUrl,
});
findCreateIssueButton().vm.$emit('click');
await waitForPromises();
const [postRequest] = mockAxios.history.post;
expect(mockAxios.history.post).toHaveLength(1);
expect(postRequest.url).toBe(createIssueUrl);
expect(spy).toHaveBeenCalledWith(issueUrl);
expect(JSON.parse(postRequest.data)).toMatchObject({
vulnerability_feedback: {
feedback_type: FEEDBACK_TYPES.ISSUE,
category: reportType,
project_fingerprint: projectFingerprint,
vulnerability_data: {
category: reportType,
vulnerability_id: vulnerabilityId,
},
},
});
});
it('shows an error message when issue creation fails', () => {
mockAxios.onPost(createIssueUrl).reply(500);
findCreateIssueButton().vm.$emit('click');
return waitForPromises().then(() => {
expect(mockAxios.history.post).toHaveLength(1);
expect(createFlash).toHaveBeenCalledWith(
'Something went wrong, could not create an issue.',
);
});
});
});
}); });
...@@ -21693,6 +21693,9 @@ msgstr "" ...@@ -21693,6 +21693,9 @@ msgstr ""
msgid "SecurityReports|Issue Created" msgid "SecurityReports|Issue Created"
msgstr "" msgstr ""
msgid "SecurityReports|Issues created from a vulnerability cannot be removed."
msgstr ""
msgid "SecurityReports|Learn more about setting up your dashboard" msgid "SecurityReports|Learn more about setting up your dashboard"
msgstr "" msgstr ""
......
...@@ -35,6 +35,9 @@ describe('RelatedIssuableItem', () => { ...@@ -35,6 +35,9 @@ describe('RelatedIssuableItem', () => {
weight: '<div class="js-weight-slot"></div>', weight: '<div class="js-weight-slot"></div>',
}; };
const findRemoveButton = () => wrapper.find({ ref: 'removeButton' });
const findLockIcon = () => wrapper.find({ ref: 'lockIcon' });
beforeEach(() => { beforeEach(() => {
mountComponent({ props, slots }); mountComponent({ props, slots });
}); });
...@@ -143,25 +146,27 @@ describe('RelatedIssuableItem', () => { ...@@ -143,25 +146,27 @@ describe('RelatedIssuableItem', () => {
}); });
describe('remove button', () => { describe('remove button', () => {
const removeButton = () => wrapper.find({ ref: 'removeButton' });
beforeEach(() => { beforeEach(() => {
wrapper.setProps({ canRemove: true }); wrapper.setProps({ canRemove: true });
}); });
it('renders if canRemove', () => { it('renders if canRemove', () => {
expect(removeButton().exists()).toBe(true); expect(findRemoveButton().exists()).toBe(true);
});
it('does not render the lock icon', () => {
expect(findLockIcon().exists()).toBe(false);
}); });
it('renders disabled button when removeDisabled', async () => { it('renders disabled button when removeDisabled', async () => {
wrapper.setData({ removeDisabled: true }); wrapper.setData({ removeDisabled: true });
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
expect(removeButton().attributes('disabled')).toEqual('disabled'); expect(findRemoveButton().attributes('disabled')).toEqual('disabled');
}); });
it('triggers onRemoveRequest when clicked', async () => { it('triggers onRemoveRequest when clicked', async () => {
removeButton().trigger('click'); findRemoveButton().trigger('click');
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
const { relatedIssueRemoveRequest } = wrapper.emitted(); const { relatedIssueRemoveRequest } = wrapper.emitted();
...@@ -169,4 +174,23 @@ describe('RelatedIssuableItem', () => { ...@@ -169,4 +174,23 @@ describe('RelatedIssuableItem', () => {
expect(relatedIssueRemoveRequest[0]).toEqual([props.idKey]); expect(relatedIssueRemoveRequest[0]).toEqual([props.idKey]);
}); });
}); });
describe('when issue is locked', () => {
const lockedMessage = 'Issues created from a vulnerability cannot be removed';
beforeEach(() => {
wrapper.setProps({
isLocked: true,
lockedMessage,
});
});
it('does not render the remove button', () => {
expect(findRemoveButton().exists()).toBe(false);
});
it('renders the lock icon with the correct title', () => {
expect(findLockIcon().attributes('title')).toBe(lockedMessage);
});
});
}); });
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