Commit ff7a1892 authored by Savas Vedova's avatar Savas Vedova

Relocate create issue button

- Lock vulnerability linked issues
- Move the create issue button to the footer
- Update documentation
parent bb6cbf20
......@@ -29,6 +29,16 @@ export default {
required: false,
default: false,
},
isLocked: {
type: Boolean,
required: false,
default: false,
},
lockedMessage: {
type: String,
required: false,
default: '',
},
},
computed: {
stateTitle() {
......@@ -156,8 +166,17 @@ export default {
</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
v-if="canRemove"
v-else-if="canRemove"
ref="removeButton"
v-gl-tooltip
:disabled="removeDisabled"
......@@ -168,7 +187,7 @@ export default {
:aria-label="__('Remove')"
@click="onRemoveRequest"
>
<icon :size="16" class="btn-item-remove-icon" name="close" />
<gl-icon class="btn-item-remove-icon" name="close" />
</button>
</div>
</template>
......@@ -121,7 +121,7 @@ information with several options:
- [Solution](#solutions-for-vulnerabilities-auto-remediation): For some vulnerabilities,
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
......@@ -198,9 +198,10 @@ Pressing the "Dismiss Selected" button will dismiss all the selected vulnerabili
### Creating an issue for a vulnerability
You can create an issue for a vulnerability by selecting the **Create issue**
button from within the vulnerability modal, or by using the action buttons to the right of
a vulnerability row in the group security dashboard.
You can create an issue for a vulnerability by visiting the vulnerability's page and clicking
**Create issue**, which you can find in the **Related issues** section.
![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
vulnerability came from, and pre-populates it with some useful information taken from the vulnerability
......
......@@ -42,12 +42,15 @@ function createFooterApp() {
vulnerabilityFeedbackHelpPath,
hasMr,
discussionsUrl,
createIssueUrl,
state,
issueFeedback,
mergeRequestFeedback,
notesUrl,
project,
projectFingerprint,
remediations,
reportType,
solution,
id,
canModifyRelatedIssues,
......@@ -85,6 +88,12 @@ function createFooterApp() {
return new Vue({
el,
provide: {
reportType,
createIssueUrl,
projectFingerprint,
vulnerabilityId: id,
},
render: h =>
h(FooterApp, {
props,
......
......@@ -125,7 +125,10 @@ export default {
<template>
<div id="related-issues" class="related-issues-block">
<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
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 {
/>
</div>
</h3>
<slot name="headerActions"></slot>
</div>
<div
class="linked-issues-card-body bg-gray-light"
......
......@@ -133,6 +133,8 @@ export default {
:can-remove="canAdmin"
:can-reorder="canReorder"
:path-id-separator="pathIdSeparator"
:is-locked="issue.lockIssueRemoval"
:locked-message="issue.lockedMessage"
event-namespace="relatedIssue"
class="qa-related-issuable-item"
@relatedIssueRemoveRequest="$emit('relatedIssueRemoveRequest', $event)"
......
<script>
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { GlLoadingIcon, GlButton } from '@gitlab/ui';
import Api from 'ee/api';
import { CancelToken } from 'axios';
import SplitButton from 'ee/vue_shared/security_reports/components/split_button.vue';
......@@ -17,9 +17,10 @@ import VulnerabilitiesEventBus from './vulnerabilities_event_bus';
export default {
name: 'VulnerabilityHeader',
components: {
GlButton,
GlLoadingIcon,
GlButton,
ResolutionAlert,
VulnerabilityStateDropdown,
SplitButton,
......@@ -35,8 +36,8 @@ export default {
data() {
return {
isLoadingVulnerability: false,
isProcessingAction: false,
isLoadingVulnerability: false,
isLoadingUser: false,
vulnerability: this.initialVulnerability,
user: undefined,
......@@ -56,10 +57,6 @@ export default {
buttons.push(HEADER_ACTION_BUTTONS.patchDownload);
}
if (!this.hasIssue) {
buttons.push(HEADER_ACTION_BUTTONS.issueCreation);
}
return buttons;
},
canDownloadPatch() {
......@@ -151,38 +148,6 @@ export default {
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() {
this.isProcessingAction = true;
......@@ -299,7 +264,6 @@ export default {
:disabled="isProcessingAction"
class="js-split-button"
@createMergeRequest="createMergeRequest"
@createIssue="createIssue"
@downloadPatch="downloadPatch"
/>
<gl-button
......
<script>
import axios from 'axios';
import { GlButton } from '@gitlab/ui';
import RelatedIssuesStore from 'ee/related_issues/stores/related_issues_store';
import RelatedIssuesBlock from 'ee/related_issues/components/related_issues_block.vue';
import { issuableTypesMap, PathIdSeparator } from 'ee/related_issues/constants';
import { sprintf, __ } from '~/locale';
import { joinPaths } from '~/lib/utils/url_utility';
import { RELATED_ISSUES_ERRORS } from '../constants';
import { sprintf, __, s__ } from '~/locale';
import { joinPaths, redirectTo } from '~/lib/utils/url_utility';
import { RELATED_ISSUES_ERRORS, FEEDBACK_TYPES } from '../constants';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { getFormattedIssue, getAddRelatedIssueRequestParams } from '../helpers';
export default {
name: 'VulnerabilityRelatedIssues',
components: { RelatedIssuesBlock },
components: {
RelatedIssuesBlock,
GlButton,
},
props: {
endpoint: {
type: String,
......@@ -34,7 +38,9 @@ export default {
},
data() {
this.store = new RelatedIssuesStore();
return {
isProcessingAction: false,
state: this.store.state,
isFetching: false,
isSubmitting: false,
......@@ -46,11 +52,54 @@ export default {
vulnerabilityProjectId() {
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() {
this.fetchRelatedIssues();
},
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() {
this.isFormVisible = !this.isFormVisible;
},
......@@ -119,7 +168,19 @@ export default {
.get(this.endpoint)
.then(({ data }) => {
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(() => {
createFlash(__('An error occurred while fetching issues.'));
......@@ -168,6 +229,19 @@ export default {
@pendingIssuableRemoveRequest="removePendingReference"
@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>
</template>
......@@ -32,11 +32,6 @@ export const VULNERABILITY_STATES = {
};
export const HEADER_ACTION_BUTTONS = {
issueCreation: {
name: s__('ciReport|Create issue'),
tagline: s__('ciReport|Investigate this vulnerability by creating an issue'),
action: 'createIssue',
},
mergeRequestCreation: {
name: s__('ciReport|Resolve with merge request'),
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', () => {
});
});
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', () => {
beforeEach(() => {
wrapper = mount(RelatedIssuesBlock, {
......
......@@ -47,10 +47,13 @@ describe('Vulnerability Header', () => {
const diff = 'some diff to download';
const getVulnerability = ({ shouldShowCreateIssueButton, shouldShowMergeRequestButton }) => {
const getVulnerability = ({
shouldShowMergeRequestButton,
shouldShowDownloadPatchButton = true,
}) => {
return {
issue_feedback: shouldShowCreateIssueButton ? null : { issue_iid: 12 },
remediations: shouldShowMergeRequestButton ? [{ diff }] : null,
hasMr: !shouldShowDownloadPatchButton,
merge_request_feedback: {
merge_request_path: shouldShowMergeRequestButton ? null : 'some path',
},
......@@ -149,22 +152,21 @@ describe('Vulnerability Header', () => {
describe('split button', () => {
it('does render the create merge request and issue button as a split button', () => {
createWrapper(
getVulnerability({
shouldShowCreateIssueButton: true,
shouldShowMergeRequestButton: true,
}),
);
createWrapper(getVulnerability({ shouldShowMergeRequestButton: true }));
expect(findSplitButton().exists()).toBe(true);
const buttons = findSplitButton().props('buttons');
expect(buttons).toHaveLength(3);
expect(buttons).toHaveLength(2);
expect(buttons[0].name).toBe('Resolve with merge request');
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', () => {
createWrapper(getVulnerability({ shouldShowCreateIssueButton: true }));
createWrapper(
getVulnerability({
shouldShowMergeRequestButton: true,
shouldShowDownloadPatchButton: false,
}),
);
expect(findSplitButton().exists()).toBe(false);
});
});
......@@ -175,57 +177,13 @@ describe('Vulnerability Header', () => {
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', () => {
beforeEach(() => {
createWrapper({
...getVulnerability({ shouldShowMergeRequestButton: true }),
...getVulnerability({
shouldShowMergeRequestButton: true,
shouldShowDownloadPatchButton: false,
}),
state: 'resolved',
});
});
......@@ -253,6 +211,7 @@ describe('Vulnerability Header', () => {
project_fingerprint: defaultVulnerability.project_fingerprint,
vulnerability_data: {
...getVulnerability({ shouldShowMergeRequestButton: true }),
hasMr: true,
category: defaultVulnerability.report_type,
state: 'resolved',
},
......
......@@ -3,9 +3,12 @@ import MockAdapter from 'axios-mock-adapter';
import RelatedIssues from 'ee/vulnerabilities/components/related_issues.vue';
import RelatedIssuesBlock from 'ee/related_issues/components/related_issues_block.vue';
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 { deprecatedCreateFlash as createFlash } from '~/flash';
import httpStatusCodes from '~/lib/utils/http_status';
import * as urlUtility from '~/lib/utils/url_utility';
jest.mock('~/flash');
......@@ -21,11 +24,25 @@ describe('Vulnerability related issues component', () => {
canModifyRelatedIssues: true,
};
const vulnerabilityId = 5131;
const createIssueUrl = '/create/issue';
const projectFingerprint = 'project-fingerprint';
const reportType = 'vulnerability';
const issue1 = { id: 3, vulnerabilityLinkId: 987 };
const issue2 = { id: 25, vulnerabilityLinkId: 876 };
const createWrapper = async (data = {}) => {
wrapper = shallowMount(RelatedIssues, { propsData, data: () => data });
const createWrapper = async (data = {}, opts) => {
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 to set the state of the store, not replace the state property.
if (data.state) {
......@@ -36,6 +53,7 @@ describe('Vulnerability related issues component', () => {
const relatedIssuesBlock = () => wrapper.find(RelatedIssuesBlock);
const blockProp = prop => relatedIssuesBlock().props(prop);
const blockEmit = (eventName, data) => relatedIssuesBlock().vm.$emit(eventName, data);
const findCreateIssueButton = () => wrapper.find({ ref: 'createIssue' });
afterEach(() => {
wrapper.destroy();
......@@ -239,4 +257,71 @@ describe('Vulnerability related issues component', () => {
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.',
);
});
});
});
});
......@@ -21684,6 +21684,9 @@ msgstr ""
msgid "SecurityReports|Issue Created"
msgstr ""
msgid "SecurityReports|Issues created from a vulnerability cannot be removed."
msgstr ""
msgid "SecurityReports|Learn more about setting up your dashboard"
msgstr ""
......
......@@ -35,6 +35,9 @@ describe('RelatedIssuableItem', () => {
weight: '<div class="js-weight-slot"></div>',
};
const findRemoveButton = () => wrapper.find({ ref: 'removeButton' });
const findLockIcon = () => wrapper.find({ ref: 'lockIcon' });
beforeEach(() => {
mountComponent({ props, slots });
});
......@@ -143,25 +146,27 @@ describe('RelatedIssuableItem', () => {
});
describe('remove button', () => {
const removeButton = () => wrapper.find({ ref: 'removeButton' });
beforeEach(() => {
wrapper.setProps({ canRemove: true });
});
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 () => {
wrapper.setData({ removeDisabled: true });
await wrapper.vm.$nextTick();
expect(removeButton().attributes('disabled')).toEqual('disabled');
expect(findRemoveButton().attributes('disabled')).toEqual('disabled');
});
it('triggers onRemoveRequest when clicked', async () => {
removeButton().trigger('click');
findRemoveButton().trigger('click');
await wrapper.vm.$nextTick();
const { relatedIssueRemoveRequest } = wrapper.emitted();
......@@ -169,4 +174,23 @@ describe('RelatedIssuableItem', () => {
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