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 Vue from 'vue';
import HeaderApp from 'ee/vulnerabilities/components/header.vue'; import MainApp from 'ee/vulnerabilities/components/vulnerability.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';
function createHeaderApp() { function createMainApp() {
const el = document.getElementById('js-vulnerability-header'); const el = document.getElementById('js-vulnerability-main');
const vulnerability = JSON.parse(el.dataset.vulnerability); const vulnerability = JSON.parse(el.dataset.vulnerability);
return new Vue({ return new Vue({
el, 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: { provide: {
reportType, reportType: vulnerability.report_type,
createIssueUrl, createIssueUrl: vulnerability.create_issue_url,
projectFingerprint, projectFingerprint: vulnerability.project_fingerprint,
vulnerabilityId: id, vulnerabilityId: vulnerability.id,
}, },
render: h => render: h =>
h(FooterApp, { h(MainApp, {
props, props: { vulnerability },
}), }),
}); });
} }
window.addEventListener('DOMContentLoaded', () => { window.addEventListener('DOMContentLoaded', () => {
createHeaderApp(); createMainApp();
createDetailsApp();
createFooterApp();
}); });
...@@ -10,7 +10,6 @@ import { deprecatedCreateFlash as createFlash } from '~/flash'; ...@@ -10,7 +10,6 @@ import { deprecatedCreateFlash as createFlash } from '~/flash';
import { s__, __ } from '~/locale'; import { s__, __ } from '~/locale';
import RelatedIssues from './related_issues.vue'; import RelatedIssues from './related_issues.vue';
import HistoryEntry from './history_entry.vue'; import HistoryEntry from './history_entry.vue';
import VulnerabilitiesEventBus from './vulnerabilities_event_bus';
import initUserPopovers from '~/user_popovers'; import initUserPopovers from '~/user_popovers';
export default { export default {
...@@ -84,8 +83,6 @@ export default { ...@@ -84,8 +83,6 @@ export default {
created() { created() {
this.fetchDiscussions(); this.fetchDiscussions();
VulnerabilitiesEventBus.$on('VULNERABILITY_STATE_CHANGE', this.fetchDiscussions);
}, },
updated() { updated() {
...@@ -187,7 +184,7 @@ export default { ...@@ -187,7 +184,7 @@ export default {
// Emit an event that tells the header to refresh the vulnerability. // Emit an event that tells the header to refresh the vulnerability.
if (isVulnerabilityStateChanged) { if (isVulnerabilityStateChanged) {
VulnerabilitiesEventBus.$emit('VULNERABILITY_STATE_CHANGED'); this.$emit('vulnerability-state-change');
} }
}, },
}, },
......
...@@ -13,7 +13,6 @@ import ResolutionAlert from './resolution_alert.vue'; ...@@ -13,7 +13,6 @@ import ResolutionAlert from './resolution_alert.vue';
import VulnerabilityStateDropdown from './vulnerability_state_dropdown.vue'; import VulnerabilityStateDropdown from './vulnerability_state_dropdown.vue';
import StatusDescription from './status_description.vue'; import StatusDescription from './status_description.vue';
import { VULNERABILITY_STATE_OBJECTS, FEEDBACK_TYPES, HEADER_ACTION_BUTTONS } from '../constants'; import { VULNERABILITY_STATE_OBJECTS, FEEDBACK_TYPES, HEADER_ACTION_BUTTONS } from '../constants';
import VulnerabilitiesEventBus from './vulnerabilities_event_bus';
export default { export default {
name: 'VulnerabilityHeader', name: 'VulnerabilityHeader',
...@@ -116,14 +115,6 @@ export default { ...@@ -116,14 +115,6 @@ export default {
}, },
}, },
created() {
VulnerabilitiesEventBus.$on('VULNERABILITY_STATE_CHANGED', this.refreshVulnerability);
},
destroyed() {
VulnerabilitiesEventBus.$off('VULNERABILITY_STATE_CHANGED', this.refreshVulnerability);
},
methods: { methods: {
triggerClick(action) { triggerClick(action) {
const fn = this[action]; const fn = this[action];
...@@ -135,6 +126,7 @@ export default { ...@@ -135,6 +126,7 @@ export default {
Api.changeVulnerabilityState(this.vulnerability.id, newState) Api.changeVulnerabilityState(this.vulnerability.id, newState)
.then(({ data }) => { .then(({ data }) => {
Object.assign(this.vulnerability, data); Object.assign(this.vulnerability, data);
this.$emit('vulnerability-state-change');
}) })
.catch(() => { .catch(() => {
createFlash( createFlash(
...@@ -145,7 +137,6 @@ export default { ...@@ -145,7 +137,6 @@ export default {
}) })
.finally(() => { .finally(() => {
this.isLoadingVulnerability = false; this.isLoadingVulnerability = false;
VulnerabilitiesEventBus.$emit('VULNERABILITY_STATE_CHANGE');
}); });
}, },
createMergeRequest() { 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 @@ ...@@ -5,6 +5,4 @@
- page_description @vulnerability.description - page_description @vulnerability.description
- vulnerability_init_details = { vulnerability: vulnerability_details_json(@vulnerability, @pipeline)} - vulnerability_init_details = { vulnerability: vulnerability_details_json(@vulnerability, @pipeline)}
#js-vulnerability-header{ data: vulnerability_init_details } #js-vulnerability-main{ data: vulnerability_init_details }
#js-vulnerability-details{ data: vulnerability_init_details }
#js-vulnerability-footer{ 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 ...@@ -34,10 +34,10 @@ RSpec.describe Projects::Security::VulnerabilitiesController do
expect(response.body).to have_text(vulnerability.title) expect(response.body).to have_text(vulnerability.title)
end end
it 'renders the solution card' do it 'renders the vulnerability component' do
show_vulnerability show_vulnerability
expect(response.body).to have_css("#js-vulnerability-footer") expect(response.body).to have_css("#js-vulnerability-main")
end end
end end
......
...@@ -2,7 +2,6 @@ import { shallowMount } from '@vue/test-utils'; ...@@ -2,7 +2,6 @@ import { shallowMount } from '@vue/test-utils';
import Api from 'ee/api'; import Api from 'ee/api';
import VulnerabilityFooter from 'ee/vulnerabilities/components/footer.vue'; import VulnerabilityFooter from 'ee/vulnerabilities/components/footer.vue';
import HistoryEntry from 'ee/vulnerabilities/components/history_entry.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 RelatedIssues from 'ee/vulnerabilities/components/related_issues.vue';
import SolutionCard from 'ee/vue_shared/security_reports/components/solution_card.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'; import IssueNote from 'ee/vue_shared/security_reports/components/issue_note.vue';
...@@ -64,11 +63,14 @@ describe('Vulnerability Footer', () => { ...@@ -64,11 +63,14 @@ describe('Vulnerability Footer', () => {
mockAxios.reset(); mockAxios.reset();
}); });
describe('vulnerabilities event bus listener', () => { describe('fetching discussions', () => {
it('calls the discussion url on vulnerabilities event bus emit of VULNERABILITY_STATE_CHANGE', () => { it('calls the discussion url on if fetchDiscussions is called by the root', async () => {
createWrapper(); createWrapper();
jest.spyOn(axios, 'get'); jest.spyOn(axios, 'get');
VulnerabilitiesEventBus.$emit('VULNERABILITY_STATE_CHANGE'); wrapper.vm.fetchDiscussions();
await axios.waitForAll();
expect(axios.get).toHaveBeenCalledTimes(1); expect(axios.get).toHaveBeenCalledTimes(1);
}); });
}); });
...@@ -252,16 +254,18 @@ describe('Vulnerability Footer', () => { ...@@ -252,16 +254,18 @@ describe('Vulnerability Footer', () => {
}); });
}); });
it('emits the VULNERABILITY_STATE_CHANGED event when the system note is new', async () => { it('emits the vulnerability-state-change event when the system note is new', async () => {
const spy = jest.spyOn(VulnerabilitiesEventBus, '$emit'); const handler = jest.fn();
wrapper.vm.$on('vulnerability-state-change', handler);
const note = { system: true, id: 1, discussion_id: 3 }; const note = { system: true, id: 1, discussion_id: 3 };
createNotesRequest(note); createNotesRequest(note);
await axios.waitForAll(); await axios.waitForAll();
await startTimeoutsAndAwaitRequests(); await startTimeoutsAndAwaitRequests();
expect(spy).toHaveBeenCalledTimes(1); expect(handler).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith('VULNERABILITY_STATE_CHANGED');
}); });
}); });
}); });
......
...@@ -9,7 +9,6 @@ import StatusDescription from 'ee/vulnerabilities/components/status_description. ...@@ -9,7 +9,6 @@ import StatusDescription from 'ee/vulnerabilities/components/status_description.
import ResolutionAlert from 'ee/vulnerabilities/components/resolution_alert.vue'; import ResolutionAlert from 'ee/vulnerabilities/components/resolution_alert.vue';
import SplitButton from 'ee/vue_shared/security_reports/components/split_button.vue'; import SplitButton from 'ee/vue_shared/security_reports/components/split_button.vue';
import VulnerabilityStateDropdown from 'ee/vulnerabilities/components/vulnerability_state_dropdown.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 { FEEDBACK_TYPES, VULNERABILITY_STATE_OBJECTS } from 'ee/vulnerabilities/constants';
import { deprecatedCreateFlash as createFlash } from '~/flash'; import { deprecatedCreateFlash as createFlash } from '~/flash';
import * as urlUtility from '~/lib/utils/url_utility'; import * as urlUtility from '~/lib/utils/url_utility';
...@@ -123,7 +122,6 @@ describe('Vulnerability Header', () => { ...@@ -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', () => { 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 newState = 'dismiss';
const spy = jest.spyOn(VulnerabilitiesEventBus, '$emit');
mockAxios.onPost().reply(201, { state: newState }); mockAxios.onPost().reply(201, { state: newState });
expect(findBadge().text()).not.toBe(newState); expect(findBadge().text()).not.toBe(newState);
...@@ -132,8 +130,7 @@ describe('Vulnerability Header', () => { ...@@ -132,8 +130,7 @@ describe('Vulnerability Header', () => {
dropdown.vm.$emit('change'); dropdown.vm.$emit('change');
return waitForPromises().then(() => { return waitForPromises().then(() => {
expect(spy).toHaveBeenCalledTimes(1); expect(wrapper.emitted()['vulnerability-state-change']).toBeTruthy();
expect(spy).toHaveBeenCalledWith('VULNERABILITY_STATE_CHANGE');
}); });
}); });
...@@ -141,7 +138,7 @@ describe('Vulnerability Header', () => { ...@@ -141,7 +138,7 @@ describe('Vulnerability Header', () => {
const dropdown = wrapper.find(VulnerabilityStateDropdown); const dropdown = wrapper.find(VulnerabilityStateDropdown);
mockAxios.onPost().reply(400); mockAxios.onPost().reply(400);
dropdown.vm.$emit('change'); dropdown.vm.$emit('change', 'dismissed');
return waitForPromises().then(() => { return waitForPromises().then(() => {
expect(mockAxios.history.post).toHaveLength(1); expect(mockAxios.history.post).toHaveLength(1);
...@@ -367,45 +364,4 @@ describe('Vulnerability Header', () => { ...@@ -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