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/ ...@@ -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 SecurityDashboardLayout from 'ee/security_dashboard/components/security_dashboard_layout.vue';
import VulnerabilitiesCountList from 'ee/security_dashboard/components/vulnerability_count_list.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 Filters from 'ee/security_dashboard/components/first_class_vulnerability_filters.vue';
import CsvExportButton from './csv_export_button.vue';
export default { export default {
components: { components: {
...@@ -11,6 +12,7 @@ export default { ...@@ -11,6 +12,7 @@ export default {
ReportsNotConfigured, ReportsNotConfigured,
SecurityDashboardLayout, SecurityDashboardLayout,
VulnerabilitiesCountList, VulnerabilitiesCountList,
CsvExportButton,
Filters, Filters,
}, },
props: { props: {
...@@ -37,6 +39,10 @@ export default { ...@@ -37,6 +39,10 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
vulnerabilitiesExportEndpoint: {
type: String,
required: true,
},
}, },
data() { data() {
return { return {
...@@ -56,6 +62,10 @@ export default { ...@@ -56,6 +62,10 @@ export default {
<template v-if="hasPipelineData"> <template v-if="hasPipelineData">
<security-dashboard-layout> <security-dashboard-layout>
<template #header> <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" /> <vulnerabilities-count-list :project-full-path="projectFullPath" />
<filters @filterChange="handleFilterChange" /> <filters @filterChange="handleFilterChange" />
</template> </template>
......
<script> <script>
import { isUndefined } from 'lodash'; 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 Icon from '~/vue_shared/components/icon.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
...@@ -8,10 +8,10 @@ import ReportsNotConfigured from './empty_states/reports_not_configured.vue'; ...@@ -8,10 +8,10 @@ import ReportsNotConfigured from './empty_states/reports_not_configured.vue';
import SecurityDashboard from './security_dashboard_vuex.vue'; import SecurityDashboard from './security_dashboard_vuex.vue';
export default { export default {
name: 'ProjectSecurityDashboard',
components: { components: {
GlEmptyState, GlEmptyState,
GlSprintf, GlSprintf,
GlLink,
Icon, Icon,
ReportsNotConfigured, ReportsNotConfigured,
SecurityDashboard, SecurityDashboard,
...@@ -94,7 +94,7 @@ export default { ...@@ -94,7 +94,7 @@ export default {
" "
> >
<template #pipelineLink> <template #pipelineLink>
<a :href="pipeline.path">#{{ pipeline.id }}</a> <gl-link :href="pipeline.path">#{{ pipeline.id }}</gl-link>
</template> </template>
<template #timeago> <template #timeago>
<timeago-tooltip :time="pipeline.created" /> <timeago-tooltip :time="pipeline.created" />
...@@ -113,14 +113,14 @@ export default { ...@@ -113,14 +113,14 @@ export default {
</span> </span>
<span class="js-security-dashboard-right pull-right"> <span class="js-security-dashboard-right pull-right">
<icon name="branch" /> <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> <span class="text-muted prepend-left-5 append-right-5">&middot;</span>
<icon name="commit" /> <icon name="commit" />
<a :href="commit.path" class="monospace">{{ commit.id }}</a> <gl-link :href="commit.path" class="monospace">{{ commit.id }}</gl-link>
</span> </span>
</div> </div>
</div> </div>
<h4 class="mt-4 mb-3">{{ __('Vulnerabilities') }}</h4> <h4>{{ __('Vulnerabilities') }}</h4>
<security-dashboard <security-dashboard
:lock-to-project="project" :lock-to-project="project"
:vulnerabilities-endpoint="vulnerabilitiesEndpoint" :vulnerabilities-endpoint="vulnerabilitiesEndpoint"
......
...@@ -41,6 +41,7 @@ export default ( ...@@ -41,6 +41,7 @@ export default (
if (dashboardType === DASHBOARD_TYPES.PROJECT) { if (dashboardType === DASHBOARD_TYPES.PROJECT) {
component = FirstClassProjectSecurityDashboard; component = FirstClassProjectSecurityDashboard;
props.projectFullPath = el.dataset.projectFullPath; props.projectFullPath = el.dataset.projectFullPath;
props.vulnerabilitiesExportEndpoint = el.dataset.vulnerabilitiesExportEndpoint;
} else if (dashboardType === DASHBOARD_TYPES.GROUP) { } else if (dashboardType === DASHBOARD_TYPES.GROUP) {
component = FirstClassGroupSecurityDashboard; component = FirstClassGroupSecurityDashboard;
props.groupFullPath = el.dataset.groupFullPath; props.groupFullPath = el.dataset.groupFullPath;
......
...@@ -8,67 +8,36 @@ import { parseBoolean } from '~/lib/utils/common_utils'; ...@@ -8,67 +8,36 @@ import { parseBoolean } from '~/lib/utils/common_utils';
export default () => { export default () => {
const securityTab = document.getElementById('js-security-report-app'); const securityTab = document.getElementById('js-security-report-app');
const props = {
const { ...securityTab.dataset,
commitId, hasPipelineData: parseBoolean(securityTab.dataset.hasPipelineData),
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,
}; };
if (parsedHasPipelineData) {
props = { if (props.hasPipelineData) {
...props, Object.assign(props, {
project: { project: {
id: projectId, id: props.projectId,
name: projectName, name: props.projectName,
}, },
triggeredBy: { triggeredBy: {
avatarPath: userAvatarPath, avatarPath: props.userAvatarPath,
name: userName, name: props.userName,
path: userPath, path: props.userPath,
}, },
pipeline: { pipeline: {
id: parsedPipelineId, id: parseInt(props.pipelineId, 10),
created: pipelineCreated, created: props.pipelineCreated,
path: pipelinePath, path: props.pipelinePath,
}, },
commit: { commit: {
id: commitId, id: props.commitId,
path: commitPath, path: props.commitPath,
}, },
branch: { branch: {
id: refId, id: props.refId,
path: refPath, path: props.refPath,
}, },
}; });
} }
const router = createRouter(); 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 ...@@ -27,7 +27,7 @@ exports[`Project Security Dashboard component Headline renders renders branch an
</svg> </svg>
<a <a
class="monospace" class="gl-link monospace"
href="http://test.host/branch" href="http://test.host/branch"
> >
master master
...@@ -49,7 +49,7 @@ exports[`Project Security Dashboard component Headline renders renders branch an ...@@ -49,7 +49,7 @@ exports[`Project Security Dashboard component Headline renders renders branch an
</svg> </svg>
<a <a
class="monospace" class="gl-link monospace"
href="http://test.host/commit" href="http://test.host/commit"
> >
1234adf 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_ ...@@ -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 SecurityDashboardLayout from 'ee/security_dashboard/components/security_dashboard_layout.vue';
import ProjectVulnerabilitiesApp from 'ee/vulnerabilities/components/project_vulnerabilities_app.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 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 = { const props = {
dashboardDocumentation: '/help/docs', dashboardDocumentation: '/help/docs',
emptyStateSvgPath: '/svgs/empty/svg', emptyStateSvgPath: '/svgs/empty/svg',
projectFullPath: '/group/project', projectFullPath: '/group/project',
securityDashboardHelpPath: '/security/dashboard/help-path', securityDashboardHelpPath: '/security/dashboard/help-path',
vulnerabilitiesExportEndpoint: '/vulnerabilities/exports',
}; };
const filters = { foo: 'bar' }; const filters = { foo: 'bar' };
...@@ -19,6 +21,7 @@ describe('First class Project Security Dashboard component', () => { ...@@ -19,6 +21,7 @@ describe('First class Project Security Dashboard component', () => {
const findFilters = () => wrapper.find(Filters); const findFilters = () => wrapper.find(Filters);
const findVulnerabilities = () => wrapper.find(ProjectVulnerabilitiesApp); const findVulnerabilities = () => wrapper.find(ProjectVulnerabilitiesApp);
const findUnconfiguredState = () => wrapper.find(ReportsNotConfigured); const findUnconfiguredState = () => wrapper.find(ReportsNotConfigured);
const findCsvExportButton = () => wrapper.find(CsvExportButton);
const createComponent = options => { const createComponent = options => {
wrapper = shallowMount(FirstClassProjectSecurityDashboard, { wrapper = shallowMount(FirstClassProjectSecurityDashboard, {
...@@ -59,6 +62,12 @@ describe('First class Project Security Dashboard component', () => { ...@@ -59,6 +62,12 @@ describe('First class Project Security Dashboard component', () => {
it('does not display the unconfigured state', () => { it('does not display the unconfigured state', () => {
expect(findUnconfiguredState().exists()).toBe(false); expect(findUnconfiguredState().exists()).toBe(false);
}); });
it('should display the csv export button', () => {
expect(findCsvExportButton().props('vulnerabilitiesExportEndpoint')).toEqual(
props.vulnerabilitiesExportEndpoint,
);
});
}); });
describe('with filter data', () => { describe('with filter data', () => {
......
...@@ -2,7 +2,6 @@ import { mount } from '@vue/test-utils'; ...@@ -2,7 +2,6 @@ import { mount } from '@vue/test-utils';
import { GlEmptyState } from '@gitlab/ui'; import { GlEmptyState } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
import ProjectSecurityDashboard from 'ee/security_dashboard/components/project_security_dashboard.vue'; import ProjectSecurityDashboard from 'ee/security_dashboard/components/project_security_dashboard.vue';
import createStore from 'ee/security_dashboard/store'; import createStore from 'ee/security_dashboard/store';
import { trimText } from 'helpers/text_helper'; import { trimText } from 'helpers/text_helper';
......
...@@ -17951,6 +17951,9 @@ msgstr "" ...@@ -17951,6 +17951,9 @@ msgstr ""
msgid "SecurityDashboard|The security dashboard displays the latest security report. Use it to find and fix vulnerabilities." msgid "SecurityDashboard|The security dashboard displays the latest security report. Use it to find and fix vulnerabilities."
msgstr "" msgstr ""
msgid "SecurityDashboard|There was an error while generating the report."
msgstr ""
msgid "SecurityDashboard|Unable to add %{invalidProjects}" msgid "SecurityDashboard|Unable to add %{invalidProjects}"
msgstr "" msgstr ""
...@@ -23402,6 +23405,9 @@ msgstr "" ...@@ -23402,6 +23405,9 @@ msgstr ""
msgid "You can move around the graph by using the arrow keys." msgid "You can move around the graph by using the arrow keys."
msgstr "" 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." msgid "You can now submit a merge request to get this change into the original branch."
msgstr "" 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