Commit f9087286 authored by Jose Ivan Vargas's avatar Jose Ivan Vargas

Merge branch '214598-Unify-Standalone-Vulnerabilities-Page' into 'master'

Update Standalone Vulnerabilities Page to be a Single Vue App

Closes #214598

See merge request gitlab-org/gitlab!40189
parents afa79d13 c39a9fb0
import Vue from 'vue';
import HeaderApp from 'ee/vulnerabilities/components/header.vue';
import DetailsApp from 'ee/vulnerabilities/components/details.vue';
import FooterApp from 'ee/vulnerabilities/components/footer.vue';
import { VULNERABILITY_STATE_OBJECTS } from 'ee/vulnerabilities/constants';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import MainApp from 'ee/vulnerabilities/components/vulnerability.vue';
function createHeaderApp() {
const el = document.getElementById('js-vulnerability-header');
function createMainApp() {
const el = document.getElementById('js-vulnerability-main');
const vulnerability = JSON.parse(el.dataset.vulnerability);
return new Vue({
el,
render: h =>
h(HeaderApp, {
props: {
initialVulnerability: vulnerability,
},
}),
});
}
function createDetailsApp() {
const el = document.getElementById('js-vulnerability-details');
const vulnerability = JSON.parse(el.dataset.vulnerability);
return new Vue({
el,
render: h => h(DetailsApp, { props: { vulnerability } }),
});
}
function createFooterApp() {
const el = document.getElementById('js-vulnerability-footer');
if (!el) {
return false;
}
const {
vulnerabilityFeedbackHelpPath,
hasMr,
discussionsUrl,
createIssueUrl,
state,
issueFeedback,
mergeRequestFeedback,
notesUrl,
project,
projectFingerprint,
remediations,
reportType,
solution,
id,
canModifyRelatedIssues,
relatedIssuesHelpPath,
} = convertObjectPropsToCamelCase(JSON.parse(el.dataset.vulnerability));
const remediation = remediations?.length ? remediations[0] : null;
const hasDownload = Boolean(
state !== VULNERABILITY_STATE_OBJECTS.resolved.state && remediation?.diff?.length && !hasMr,
);
const hasRemediation = Boolean(remediation);
const props = {
vulnerabilityId: id,
discussionsUrl,
notesUrl,
solutionInfo: {
solution,
remediation,
hasDownload,
hasMr,
hasRemediation,
vulnerabilityFeedbackHelpPath,
isStandaloneVulnerability: true,
},
issueFeedback,
mergeRequestFeedback,
canModifyRelatedIssues,
project: {
url: project.full_path,
value: project.full_name,
},
relatedIssuesHelpPath,
};
return new Vue({
el,
provide: {
reportType,
createIssueUrl,
projectFingerprint,
vulnerabilityId: id,
reportType: vulnerability.report_type,
createIssueUrl: vulnerability.create_issue_url,
projectFingerprint: vulnerability.project_fingerprint,
vulnerabilityId: vulnerability.id,
},
render: h =>
h(FooterApp, {
props,
h(MainApp, {
props: { vulnerability },
}),
});
}
window.addEventListener('DOMContentLoaded', () => {
createHeaderApp();
createDetailsApp();
createFooterApp();
createMainApp();
});
......@@ -10,7 +10,6 @@ import { deprecatedCreateFlash as createFlash } from '~/flash';
import { s__, __ } from '~/locale';
import RelatedIssues from './related_issues.vue';
import HistoryEntry from './history_entry.vue';
import VulnerabilitiesEventBus from './vulnerabilities_event_bus';
import initUserPopovers from '~/user_popovers';
export default {
......@@ -84,8 +83,6 @@ export default {
created() {
this.fetchDiscussions();
VulnerabilitiesEventBus.$on('VULNERABILITY_STATE_CHANGE', this.fetchDiscussions);
},
updated() {
......@@ -187,7 +184,7 @@ export default {
// Emit an event that tells the header to refresh the vulnerability.
if (isVulnerabilityStateChanged) {
VulnerabilitiesEventBus.$emit('VULNERABILITY_STATE_CHANGED');
this.$emit('vulnerability-state-change');
}
},
},
......
......@@ -13,7 +13,6 @@ import ResolutionAlert from './resolution_alert.vue';
import VulnerabilityStateDropdown from './vulnerability_state_dropdown.vue';
import StatusDescription from './status_description.vue';
import { VULNERABILITY_STATE_OBJECTS, FEEDBACK_TYPES, HEADER_ACTION_BUTTONS } from '../constants';
import VulnerabilitiesEventBus from './vulnerabilities_event_bus';
export default {
name: 'VulnerabilityHeader',
......@@ -116,14 +115,6 @@ export default {
},
},
created() {
VulnerabilitiesEventBus.$on('VULNERABILITY_STATE_CHANGED', this.refreshVulnerability);
},
destroyed() {
VulnerabilitiesEventBus.$off('VULNERABILITY_STATE_CHANGED', this.refreshVulnerability);
},
methods: {
triggerClick(action) {
const fn = this[action];
......@@ -135,6 +126,7 @@ export default {
Api.changeVulnerabilityState(this.vulnerability.id, newState)
.then(({ data }) => {
Object.assign(this.vulnerability, data);
this.$emit('vulnerability-state-change');
})
.catch(() => {
createFlash(
......@@ -145,7 +137,6 @@ export default {
})
.finally(() => {
this.isLoadingVulnerability = false;
VulnerabilitiesEventBus.$emit('VULNERABILITY_STATE_CHANGE');
});
},
createMergeRequest() {
......
import createEventHub from '~/helpers/event_hub_factory';
export default createEventHub();
<script>
import { VULNERABILITY_STATE_OBJECTS } from 'ee/vulnerabilities/constants';
import VulnerabilityHeader from './header.vue';
import VulnerabilityDetails from './details.vue';
import VulnerabilityFooter from './footer.vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
export default {
components: { VulnerabilityHeader, VulnerabilityDetails, VulnerabilityFooter },
props: {
vulnerability: {
type: Object,
required: true,
},
},
computed: {
footerInfo() {
const {
vulnerabilityFeedbackHelpPath,
hasMr,
discussionsUrl,
createIssueUrl,
state,
issueFeedback,
mergeRequestFeedback,
notesUrl,
projectFingerprint,
remediations,
reportType,
solution,
id,
canModifyRelatedIssues,
relatedIssuesHelpPath,
} = convertObjectPropsToCamelCase(this.vulnerability);
const remediation = remediations?.length ? remediations[0] : null;
const hasDownload = Boolean(
state !== VULNERABILITY_STATE_OBJECTS.resolved.state && remediation?.diff?.length && !hasMr,
);
const hasRemediation = Boolean(remediation);
const props = {
vulnerabilityId: id,
discussionsUrl,
notesUrl,
projectFingerprint,
solutionInfo: {
solution,
remediation,
hasDownload,
hasMr,
hasRemediation,
vulnerabilityFeedbackHelpPath,
isStandaloneVulnerability: true,
},
createIssueUrl,
reportType,
issueFeedback,
mergeRequestFeedback,
canModifyRelatedIssues,
project: {
url: this.vulnerability.project.full_path,
value: this.vulnerability.project.full_name,
},
relatedIssuesHelpPath,
};
return props;
},
},
methods: {
refreshHeader() {
this.$refs.header.refreshVulnerability();
},
refreshFooter() {
this.$refs.footer.fetchDiscussions();
},
},
};
</script>
<template>
<div>
<vulnerability-header
ref="header"
:initial-vulnerability="vulnerability"
@vulnerability-state-change="refreshFooter"
/>
<vulnerability-details :vulnerability="vulnerability" />
<vulnerability-footer
ref="footer"
v-bind="footerInfo"
@vulnerability-state-change="refreshHeader"
/>
</div>
</template>
......@@ -5,6 +5,4 @@
- page_description @vulnerability.description
- vulnerability_init_details = { vulnerability: vulnerability_details_json(@vulnerability, @pipeline)}
#js-vulnerability-header{ data: vulnerability_init_details }
#js-vulnerability-details{ data: vulnerability_init_details }
#js-vulnerability-footer{ data: vulnerability_init_details }
#js-vulnerability-main{ data: vulnerability_init_details }
---
title: Update Standalone Vulnerabilities Page to be a Single Vue App
merge_request: 40189
author: Kev @KevSlashNull
type: other
......@@ -34,10 +34,10 @@ RSpec.describe Projects::Security::VulnerabilitiesController do
expect(response.body).to have_text(vulnerability.title)
end
it 'renders the solution card' do
it 'renders the vulnerability component' do
show_vulnerability
expect(response.body).to have_css("#js-vulnerability-footer")
expect(response.body).to have_css("#js-vulnerability-main")
end
end
......
......@@ -2,7 +2,6 @@ import { shallowMount } from '@vue/test-utils';
import Api from 'ee/api';
import VulnerabilityFooter from 'ee/vulnerabilities/components/footer.vue';
import HistoryEntry from 'ee/vulnerabilities/components/history_entry.vue';
import VulnerabilitiesEventBus from 'ee/vulnerabilities/components/vulnerabilities_event_bus';
import RelatedIssues from 'ee/vulnerabilities/components/related_issues.vue';
import SolutionCard from 'ee/vue_shared/security_reports/components/solution_card.vue';
import IssueNote from 'ee/vue_shared/security_reports/components/issue_note.vue';
......@@ -64,11 +63,14 @@ describe('Vulnerability Footer', () => {
mockAxios.reset();
});
describe('vulnerabilities event bus listener', () => {
it('calls the discussion url on vulnerabilities event bus emit of VULNERABILITY_STATE_CHANGE', () => {
describe('fetching discussions', () => {
it('calls the discussion url on if fetchDiscussions is called by the root', async () => {
createWrapper();
jest.spyOn(axios, 'get');
VulnerabilitiesEventBus.$emit('VULNERABILITY_STATE_CHANGE');
wrapper.vm.fetchDiscussions();
await axios.waitForAll();
expect(axios.get).toHaveBeenCalledTimes(1);
});
});
......@@ -252,16 +254,18 @@ describe('Vulnerability Footer', () => {
});
});
it('emits the VULNERABILITY_STATE_CHANGED event when the system note is new', async () => {
const spy = jest.spyOn(VulnerabilitiesEventBus, '$emit');
it('emits the vulnerability-state-change event when the system note is new', async () => {
const handler = jest.fn();
wrapper.vm.$on('vulnerability-state-change', handler);
const note = { system: true, id: 1, discussion_id: 3 };
createNotesRequest(note);
await axios.waitForAll();
await startTimeoutsAndAwaitRequests();
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith('VULNERABILITY_STATE_CHANGED');
expect(handler).toHaveBeenCalledTimes(1);
});
});
});
......
......@@ -9,7 +9,6 @@ import StatusDescription from 'ee/vulnerabilities/components/status_description.
import ResolutionAlert from 'ee/vulnerabilities/components/resolution_alert.vue';
import SplitButton from 'ee/vue_shared/security_reports/components/split_button.vue';
import VulnerabilityStateDropdown from 'ee/vulnerabilities/components/vulnerability_state_dropdown.vue';
import VulnerabilitiesEventBus from 'ee/vulnerabilities/components/vulnerabilities_event_bus';
import { FEEDBACK_TYPES, VULNERABILITY_STATE_OBJECTS } from 'ee/vulnerabilities/constants';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import * as urlUtility from '~/lib/utils/url_utility';
......@@ -123,7 +122,6 @@ describe('Vulnerability Header', () => {
it('when the vulnerability state dropdown emits a change event, the vulnerabilities event bus event is emitted with the proper event', () => {
const newState = 'dismiss';
const spy = jest.spyOn(VulnerabilitiesEventBus, '$emit');
mockAxios.onPost().reply(201, { state: newState });
expect(findBadge().text()).not.toBe(newState);
......@@ -132,8 +130,7 @@ describe('Vulnerability Header', () => {
dropdown.vm.$emit('change');
return waitForPromises().then(() => {
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith('VULNERABILITY_STATE_CHANGE');
expect(wrapper.emitted()['vulnerability-state-change']).toBeTruthy();
});
});
......@@ -141,7 +138,7 @@ describe('Vulnerability Header', () => {
const dropdown = wrapper.find(VulnerabilityStateDropdown);
mockAxios.onPost().reply(400);
dropdown.vm.$emit('change');
dropdown.vm.$emit('change', 'dismissed');
return waitForPromises().then(() => {
expect(mockAxios.history.post).toHaveLength(1);
......@@ -367,45 +364,4 @@ describe('Vulnerability Header', () => {
});
});
});
describe('when vulnerability state is changed', () => {
it('refreshes the vulnerability', async () => {
const url = Api.buildUrl(Api.vulnerabilityPath).replace(':id', defaultVulnerability.id);
const vulnerability = { state: 'dismissed' };
mockAxios.onGet(url).replyOnce(200, vulnerability);
createWrapper();
VulnerabilitiesEventBus.$emit('VULNERABILITY_STATE_CHANGED');
await waitForPromises();
expect(findBadge().text()).toBe(vulnerability.state);
expect(findStatusDescription().props('vulnerability')).toMatchObject(vulnerability);
});
it('shows an error message when the vulnerability cannot be loaded', async () => {
mockAxios.onGet().replyOnce(500);
createWrapper();
VulnerabilitiesEventBus.$emit('VULNERABILITY_STATE_CHANGED');
await waitForPromises();
expect(createFlash).toHaveBeenCalledTimes(1);
expect(mockAxios.history.get).toHaveLength(1);
});
it('cancels a pending refresh request if the vulnerability state has changed', async () => {
mockAxios.onGet().reply(200);
createWrapper();
VulnerabilitiesEventBus.$emit('VULNERABILITY_STATE_CHANGED');
const source = wrapper.vm.refreshVulnerabilitySource;
const spy = jest.spyOn(source, 'cancel');
VulnerabilitiesEventBus.$emit('VULNERABILITY_STATE_CHANGED');
await waitForPromises();
expect(createFlash).toHaveBeenCalledTimes(0);
expect(mockAxios.history.get).toHaveLength(1);
expect(spy).toHaveBeenCalled();
expect(wrapper.vm.refreshVulnerabilitySource).not.toBe(source); // Check that the source has changed.
});
});
});
import { shallowMount } from '@vue/test-utils';
import Main from 'ee/vulnerabilities/components/vulnerability.vue';
import Header from 'ee/vulnerabilities/components/header.vue';
import Details from 'ee/vulnerabilities/components/details.vue';
import Footer from 'ee/vulnerabilities/components/footer.vue';
import AxiosMockAdapter from 'axios-mock-adapter';
const mockAxios = new AxiosMockAdapter();
describe('Vulnerability', () => {
let wrapper;
const vulnerability = {
id: 1,
created_at: new Date().toISOString(),
report_type: 'sast',
state: 'detected',
create_mr_url: '/create_mr_url',
create_issue_url: '/create_issue_url',
project_fingerprint: 'abc123',
pipeline: {
id: 2,
created_at: new Date().toISOString(),
url: 'pipeline_url',
sourceBranch: 'master',
},
description: 'description',
identifiers: 'identifiers',
links: 'links',
location: 'location',
name: 'name',
project: {
full_path: '/project_full_path',
full_name: 'Test Project',
},
discussions_url: '/discussion_url',
notes_url: '/notes_url',
can_modify_related_issues: false,
related_issues_help_path: '/help_path',
merge_request_feedback: null,
issue_feedback: null,
remediation: null,
};
const createWrapper = () => {
wrapper = shallowMount(Main, {
propsData: {
vulnerability,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
mockAxios.reset();
});
beforeEach(createWrapper);
const findHeader = () => wrapper.find(Header);
const findDetails = () => wrapper.find(Details);
const findFooter = () => wrapper.find(Footer);
describe('default behavior', () => {
it('consists of header, details, and footer', () => {
expect(findHeader().exists()).toBe(true);
expect(findDetails().exists()).toBe(true);
expect(findFooter().exists()).toBe(true);
});
it('passes the correct properties to the children', () => {
expect(findHeader().props()).toMatchObject({
initialVulnerability: vulnerability,
});
expect(findDetails().props()).toMatchObject({ vulnerability });
expect(findFooter().props()).toMatchObject({
vulnerabilityId: vulnerability.id,
discussionsUrl: vulnerability.discussions_url,
notesUrl: vulnerability.notes_url,
solutionInfo: {
solution: vulnerability.solution,
remediation: vulnerability.remediation,
hasDownload: Boolean(vulnerability.has_download),
hasMr: vulnerability.has_mr,
hasRemediation: Boolean(vulnerability.has_remediation),
vulnerabilityFeedbackHelpPath: vulnerability.vulnerability_feedback_help_path,
isStandaloneVulnerability: true,
},
issueFeedback: vulnerability.issue_feedback,
mergeRequestFeedback: vulnerability.merge_request_feedback,
canModifyRelatedIssues: vulnerability.can_modify_related_issues,
project: {
url: vulnerability.project.full_path,
value: vulnerability.project.full_name,
},
relatedIssuesHelpPath: vulnerability.related_issues_help_path,
});
});
});
describe('vulnerability state change event', () => {
let fetchDiscussions;
let refreshVulnerability;
beforeEach(() => {
fetchDiscussions = jest.fn();
refreshVulnerability = jest.fn();
findHeader().vm.refreshVulnerability = refreshVulnerability;
findFooter().vm.fetchDiscussions = fetchDiscussions;
});
it('updates the footer notes when the vulnerbility state was changed', () => {
findHeader().vm.$emit('vulnerability-state-change');
expect(fetchDiscussions).toHaveBeenCalledTimes(1);
expect(refreshVulnerability).not.toHaveBeenCalled();
});
it('updates the header when the footer received a state-change note', () => {
findFooter().vm.$emit('vulnerability-state-change');
expect(fetchDiscussions).not.toHaveBeenCalled();
expect(refreshVulnerability).toHaveBeenCalledTimes(1);
});
});
});
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