Commit a8144497 authored by Mark Florian's avatar Mark Florian

Merge branch '197494-export-vulnerability-findings-as-csv' into 'master'

Add a button to export CSV data

See merge request gitlab-org/gitlab!26838
parents bccaab28 13cde9ae
<script>
import {
GlPopover,
GlIcon,
GlLink,
GlNewButton,
GlTooltipDirective,
GlLoadingIcon,
} from '@gitlab/ui';
import { s__ } from '~/locale';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import pollUntilComplete from '~/lib/utils/poll_until_complete';
export const STORAGE_KEY = 'vulnerability_csv_export_popover_dismissed';
export default {
components: {
GlIcon,
GlNewButton,
GlPopover,
GlLink,
GlLoadingIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
vulnerabilitiesExportEndpoint: {
type: String,
required: true,
},
},
data: () => ({
isPreparingCsvExport: false,
showPopover: localStorage.getItem(STORAGE_KEY) !== 'true',
}),
methods: {
closePopover() {
this.showPopover = false;
try {
localStorage.setItem(STORAGE_KEY, 'true');
} catch (e) {
// Ignore the error - this is just a safety measure.
}
},
initiateCsvExport() {
this.isPreparingCsvExport = true;
this.closePopover();
axios
.post(this.vulnerabilitiesExportEndpoint)
.then(({ data }) => pollUntilComplete(data._links.self))
.then(({ data }) => {
const anchor = document.createElement('a');
anchor.download = '';
anchor.href = data._links.download;
anchor.click();
})
.catch(() => {
createFlash(s__('SecurityDashboard|There was an error while generating the report.'));
})
.finally(() => {
this.isPreparingCsvExport = false;
});
},
},
};
</script>
<template>
<gl-new-button
ref="csvExportButton"
v-gl-tooltip.hover
class="align-self-center"
:title="__('Export as CSV')"
:loading="isPreparingCsvExport"
@click="initiateCsvExport"
>
<gl-icon
v-if="!isPreparingCsvExport"
ref="exportIcon"
name="export"
class="mr-0 position-top-0"
/>
<gl-loading-icon v-else />
<gl-popover
ref="popover"
:target="() => $refs.csvExportButton.$el"
:show="showPopover"
placement="left"
triggers="manual"
>
<p class="gl-font-size-14">
{{ __('You can now export your security dashboard to a CSV report.') }}
</p>
<gl-link
ref="popoverExternalLink"
target="_blank"
href="https://gitlab.com/gitlab-org/gitlab/issues/197111"
class="d-flex align-items-center mb-3"
>
{{ __('More information and share feedback') }}
<gl-icon name="external-link" :size="12" class="ml-1" />
</gl-link>
<gl-new-button ref="popoverButton" class="w-100" @click="closePopover">
{{ __('Got it!') }}
</gl-new-button>
</gl-popover>
</gl-new-button>
</template>
......@@ -4,6 +4,7 @@ import ReportsNotConfigured from 'ee/security_dashboard/components/empty_states/
import SecurityDashboardLayout from 'ee/security_dashboard/components/security_dashboard_layout.vue';
import VulnerabilitiesCountList from 'ee/security_dashboard/components/vulnerability_count_list.vue';
import Filters from 'ee/security_dashboard/components/first_class_vulnerability_filters.vue';
import CsvExportButton from './csv_export_button.vue';
export default {
components: {
......@@ -11,6 +12,7 @@ export default {
ReportsNotConfigured,
SecurityDashboardLayout,
VulnerabilitiesCountList,
CsvExportButton,
Filters,
},
props: {
......@@ -37,6 +39,10 @@ export default {
required: false,
default: false,
},
vulnerabilitiesExportEndpoint: {
type: String,
required: true,
},
},
data() {
return {
......@@ -56,6 +62,10 @@ export default {
<template v-if="hasPipelineData">
<security-dashboard-layout>
<template #header>
<div class="mt-4 d-flex">
<h4 class="flex-grow mt-0 mb-0">{{ __('Vulnerabilities') }}</h4>
<csv-export-button :vulnerabilities-export-endpoint="vulnerabilitiesExportEndpoint" />
</div>
<vulnerabilities-count-list :project-full-path="projectFullPath" />
<filters @filterChange="handleFilterChange" />
</template>
......
<script>
import { isUndefined } from 'lodash';
import { GlEmptyState, GlSprintf } from '@gitlab/ui';
import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
......@@ -8,10 +8,10 @@ import ReportsNotConfigured from './empty_states/reports_not_configured.vue';
import SecurityDashboard from './security_dashboard_vuex.vue';
export default {
name: 'ProjectSecurityDashboard',
components: {
GlEmptyState,
GlSprintf,
GlLink,
Icon,
ReportsNotConfigured,
SecurityDashboard,
......@@ -94,7 +94,7 @@ export default {
"
>
<template #pipelineLink>
<a :href="pipeline.path">#{{ pipeline.id }}</a>
<gl-link :href="pipeline.path">#{{ pipeline.id }}</gl-link>
</template>
<template #timeago>
<timeago-tooltip :time="pipeline.created" />
......@@ -113,14 +113,14 @@ export default {
</span>
<span class="js-security-dashboard-right pull-right">
<icon name="branch" />
<a :href="branch.path" class="monospace">{{ branch.id }}</a>
<gl-link :href="branch.path" class="monospace">{{ branch.id }}</gl-link>
<span class="text-muted prepend-left-5 append-right-5">&middot;</span>
<icon name="commit" />
<a :href="commit.path" class="monospace">{{ commit.id }}</a>
<gl-link :href="commit.path" class="monospace">{{ commit.id }}</gl-link>
</span>
</div>
</div>
<h4 class="mt-4 mb-3">{{ __('Vulnerabilities') }}</h4>
<h4>{{ __('Vulnerabilities') }}</h4>
<security-dashboard
:lock-to-project="project"
:vulnerabilities-endpoint="vulnerabilitiesEndpoint"
......
......@@ -41,6 +41,7 @@ export default (
if (dashboardType === DASHBOARD_TYPES.PROJECT) {
component = FirstClassProjectSecurityDashboard;
props.projectFullPath = el.dataset.projectFullPath;
props.vulnerabilitiesExportEndpoint = el.dataset.vulnerabilitiesExportEndpoint;
} else if (dashboardType === DASHBOARD_TYPES.GROUP) {
component = FirstClassGroupSecurityDashboard;
props.groupFullPath = el.dataset.groupFullPath;
......
......@@ -8,67 +8,36 @@ import { parseBoolean } from '~/lib/utils/common_utils';
export default () => {
const securityTab = document.getElementById('js-security-report-app');
const {
commitId,
commitPath,
dashboardDocumentation,
emptyStateSvgPath,
hasPipelineData,
pipelineCreated,
pipelineId,
pipelinePath,
projectId,
projectName,
refId,
refPath,
securityDashboardHelpPath,
userAvatarPath,
userName,
userPath,
vulnerabilitiesEndpoint,
vulnerabilitiesSummaryEndpoint,
vulnerabilityFeedbackHelpPath,
} = securityTab.dataset;
const parsedPipelineId = parseInt(pipelineId, 10);
const parsedHasPipelineData = parseBoolean(hasPipelineData);
let props = {
dashboardDocumentation,
emptyStateSvgPath,
hasPipelineData: parsedHasPipelineData,
securityDashboardHelpPath,
vulnerabilitiesEndpoint,
vulnerabilitiesSummaryEndpoint,
vulnerabilityFeedbackHelpPath,
const props = {
...securityTab.dataset,
hasPipelineData: parseBoolean(securityTab.dataset.hasPipelineData),
};
if (parsedHasPipelineData) {
props = {
...props,
if (props.hasPipelineData) {
Object.assign(props, {
project: {
id: projectId,
name: projectName,
id: props.projectId,
name: props.projectName,
},
triggeredBy: {
avatarPath: userAvatarPath,
name: userName,
path: userPath,
avatarPath: props.userAvatarPath,
name: props.userName,
path: props.userPath,
},
pipeline: {
id: parsedPipelineId,
created: pipelineCreated,
path: pipelinePath,
id: parseInt(props.pipelineId, 10),
created: props.pipelineCreated,
path: props.pipelinePath,
},
commit: {
id: commitId,
path: commitPath,
id: props.commitId,
path: props.commitPath,
},
branch: {
id: refId,
path: refPath,
id: props.refId,
path: props.refPath,
},
};
});
}
const router = createRouter();
......
---
title: Add a button to export vulnerabilities in CSV format.
merge_request: 26838
author:
type: added
......@@ -27,7 +27,7 @@ exports[`Project Security Dashboard component Headline renders renders branch an
</svg>
<a
class="monospace"
class="gl-link monospace"
href="http://test.host/branch"
>
master
......@@ -49,7 +49,7 @@ exports[`Project Security Dashboard component Headline renders renders branch an
</svg>
<a
class="monospace"
class="gl-link monospace"
href="http://test.host/commit"
>
1234adf
......
import { shallowMount } from '@vue/test-utils';
import { GlLoadingIcon, GlIcon } from '@gitlab/ui';
import statusCodes from '~/lib/utils/http_status';
import createFlash from '~/flash';
import MockAdapter from 'axios-mock-adapter';
import { TEST_HOST } from 'helpers/test_constants';
import CsvExportButton, {
STORAGE_KEY,
} from 'ee/security_dashboard/components/csv_export_button.vue';
import axios from '~/lib/utils/axios_utils';
jest.mock('~/flash');
const vulnerabilitiesExportEndpoint = `${TEST_HOST}/vulnerability_findings.csv`;
describe('Csv Button Export', () => {
let mock;
let wrapper;
const issueUrl = 'https://gitlab.com/gitlab-org/gitlab/issues/197111';
const findPopoverExternalLink = () => wrapper.find({ ref: 'popoverExternalLink' });
const findPopoverButton = () => wrapper.find({ ref: 'popoverButton' });
const findPopover = () => wrapper.find({ ref: 'popover' });
const findCsvExportButton = () => wrapper.find({ ref: 'csvExportButton' });
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findExportIcon = () => wrapper.find({ ref: 'exportIcon' });
const createComponent = () => {
return shallowMount(CsvExportButton, {
propsData: {
vulnerabilitiesExportEndpoint,
},
stubs: {
GlIcon,
GlLoadingIcon,
},
});
};
afterEach(() => {
wrapper.destroy();
localStorage.removeItem(STORAGE_KEY);
});
describe('when the user sees the button for the first time', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
wrapper = createComponent();
});
it('renders correctly', () => {
expect(findPopoverExternalLink().attributes('href')).toBe(issueUrl);
expect(wrapper.text()).toContain('More information and share feedback');
expect(wrapper.text()).toContain(
'You can now export your security dashboard to a CSV report.',
);
});
it('is a link that initiates the download and polls until the file is ready, and then opens the download in a new tab.', () => {
const downloadAnchor = document.createElement('a');
const statusLink = '/poll/until/complete';
const downloadLink = '/link/to/download';
let spy;
mock
.onPost(vulnerabilitiesExportEndpoint)
.reply(statusCodes.ACCEPTED, { _links: { self: statusLink } });
mock.onGet(statusLink).reply(() => {
// We need to mock it at this stage because vue internally uses
// document.createElement to mount the elements.
spy = jest.spyOn(document, 'createElement').mockImplementationOnce(() => {
downloadAnchor.click = jest.fn();
return downloadAnchor;
});
return [statusCodes.OK, { _links: { download: downloadLink } }];
});
findCsvExportButton().vm.$emit('click');
return axios.waitForAll().then(() => {
expect(spy).toHaveBeenCalledWith('a');
expect(downloadAnchor.href).toContain(downloadLink);
expect(downloadAnchor.hasAttribute('download')).toBe(true);
expect(downloadAnchor.click).toHaveBeenCalled();
});
});
it('shows the flash error when backend fails to generate the export', () => {
mock.onPost(vulnerabilitiesExportEndpoint).reply(statusCodes.NOT_FOUND, {});
findCsvExportButton().vm.$emit('click');
return axios.waitForAll().then(() => {
expect(createFlash).toHaveBeenCalledWith('There was an error while generating the report.');
});
});
it('displays the export icon when not loading and the loading icon when loading', () => {
expect(findExportIcon().props('name')).toBe('export');
expect(findLoadingIcon().exists()).toBe(false);
wrapper.setData({
isPreparingCsvExport: true,
});
return wrapper.vm.$nextTick(() => {
expect(findExportIcon().exists()).toBe(false);
expect(findLoadingIcon().exists()).toBe(true);
});
});
it('displays the popover by default', () => {
expect(findPopover().attributes('show')).toBeTruthy();
});
it('closes the popover when the button is clicked', () => {
const button = findPopoverButton();
expect(button.text().trim()).toBe('Got it!');
button.vm.$emit('click');
return wrapper.vm.$nextTick(() => {
expect(findPopover().attributes('show')).toBeFalsy();
});
});
});
describe('when user closed the popover before', () => {
beforeEach(() => {
localStorage.setItem(STORAGE_KEY, 'true');
wrapper = createComponent();
});
it('does not display the popover anymore', () => {
expect(findPopover().attributes('show')).toBeFalsy();
});
});
});
......@@ -4,12 +4,14 @@ import Filters from 'ee/security_dashboard/components/first_class_vulnerability_
import SecurityDashboardLayout from 'ee/security_dashboard/components/security_dashboard_layout.vue';
import ProjectVulnerabilitiesApp from 'ee/vulnerabilities/components/project_vulnerabilities_app.vue';
import ReportsNotConfigured from 'ee/security_dashboard/components/empty_states/reports_not_configured.vue';
import CsvExportButton from 'ee/security_dashboard/components/csv_export_button.vue';
const props = {
dashboardDocumentation: '/help/docs',
emptyStateSvgPath: '/svgs/empty/svg',
projectFullPath: '/group/project',
securityDashboardHelpPath: '/security/dashboard/help-path',
vulnerabilitiesExportEndpoint: '/vulnerabilities/exports',
};
const filters = { foo: 'bar' };
......@@ -19,6 +21,7 @@ describe('First class Project Security Dashboard component', () => {
const findFilters = () => wrapper.find(Filters);
const findVulnerabilities = () => wrapper.find(ProjectVulnerabilitiesApp);
const findUnconfiguredState = () => wrapper.find(ReportsNotConfigured);
const findCsvExportButton = () => wrapper.find(CsvExportButton);
const createComponent = options => {
wrapper = shallowMount(FirstClassProjectSecurityDashboard, {
......@@ -59,6 +62,12 @@ describe('First class Project Security Dashboard component', () => {
it('does not display the unconfigured state', () => {
expect(findUnconfiguredState().exists()).toBe(false);
});
it('should display the csv export button', () => {
expect(findCsvExportButton().props('vulnerabilitiesExportEndpoint')).toEqual(
props.vulnerabilitiesExportEndpoint,
);
});
});
describe('with filter data', () => {
......
......@@ -2,7 +2,6 @@ import { mount } from '@vue/test-utils';
import { GlEmptyState } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import { TEST_HOST } from 'helpers/test_constants';
import ProjectSecurityDashboard from 'ee/security_dashboard/components/project_security_dashboard.vue';
import createStore from 'ee/security_dashboard/store';
import { trimText } from 'helpers/text_helper';
......
......@@ -17951,6 +17951,9 @@ msgstr ""
msgid "SecurityDashboard|The security dashboard displays the latest security report. Use it to find and fix vulnerabilities."
msgstr ""
msgid "SecurityDashboard|There was an error while generating the report."
msgstr ""
msgid "SecurityDashboard|Unable to add %{invalidProjects}"
msgstr ""
......@@ -23402,6 +23405,9 @@ msgstr ""
msgid "You can move around the graph by using the arrow keys."
msgstr ""
msgid "You can now export your security dashboard to a CSV report."
msgstr ""
msgid "You can now submit a merge request to get this change into the original branch."
msgstr ""
......
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