Commit 19435213 authored by Savas Vedova's avatar Savas Vedova

Show number of counts next to tabs

This commit shows the number of vulnerabilities in the tab title.
These numbers are fetched using GraphQL and are updated whenever
the filters change.
parent 946e0078
...@@ -61,6 +61,11 @@ export default { ...@@ -61,6 +61,11 @@ export default {
})); }));
}, },
}, },
watch: {
severityCounts() {
this.$emit('counts-changed', this.severityCounts);
},
},
}; };
</script> </script>
......
...@@ -24,6 +24,13 @@ export default { ...@@ -24,6 +24,13 @@ export default {
data() { data() {
return { return {
filterQuery: {}, filterQuery: {},
// When this component is first shown, every filter will emit its own @filter-changed event at
// the same time, which will trigger this method multiple times. We'll debounce it so that it
// only runs once. Note that this is in data() so that it's unique per instance. Otherwise,
// every instance of this component will share the same debounce function.
emitFilterChange: debounce(function emit() {
this.$emit('filters-changed', this.filterQuery);
}),
}; };
}, },
methods: { methods: {
...@@ -47,12 +54,6 @@ export default { ...@@ -47,12 +54,6 @@ export default {
this.emitFilterChange(); this.emitFilterChange();
} }
}, },
// When this component is first shown, every filter will emit its own @filter-changed event at
// the same time, which will trigger this method multiple times. We'll debounce it so that it
// only runs once.
emitFilterChange: debounce(function emit() {
this.$emit('filters-changed', this.filterQuery);
}),
}, },
}; };
</script> </script>
......
...@@ -70,13 +70,20 @@ export default { ...@@ -70,13 +70,20 @@ export default {
this.graphqlFilters.reportType = REPORT_TYPE_PRESETS.OPERATIONAL; this.graphqlFilters.reportType = REPORT_TYPE_PRESETS.OPERATIONAL;
} }
}, },
emitCountsChanged(counts) {
this.$emit('counts-changed', counts);
},
}, },
}; };
</script> </script>
<template> <template>
<div> <div>
<vulnerability-counts class="gl-mt-7" :filters="graphqlFilters" /> <vulnerability-counts
class="gl-mt-7"
:filters="graphqlFilters"
@counts-changed="emitCountsChanged"
/>
<vulnerability-filters <vulnerability-filters
:filters="filtersToShow" :filters="filtersToShow"
......
<script> <script>
import { GlTabs, GlTab, GlCard } from '@gitlab/ui'; import { GlTabs, GlTab, GlCard, GlBadge } from '@gitlab/ui';
import { sumBy } from 'lodash';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import SurveyRequestBanner from '../survey_request_banner.vue'; import SurveyRequestBanner from '../survey_request_banner.vue';
import VulnerabilityReportHeader from './vulnerability_report_header.vue'; import VulnerabilityReportHeader from './vulnerability_report_header.vue';
...@@ -13,6 +14,7 @@ export default { ...@@ -13,6 +14,7 @@ export default {
GlTabs, GlTabs,
GlTab, GlTab,
GlCard, GlCard,
GlBadge,
SurveyRequestBanner, SurveyRequestBanner,
VulnerabilityReportHeader, VulnerabilityReportHeader,
VulnerabilityReport, VulnerabilityReport,
...@@ -30,6 +32,8 @@ export default { ...@@ -30,6 +32,8 @@ export default {
}, },
data() { data() {
return { return {
developmentCounts: undefined,
operationalCounts: undefined,
tabIndex: this.$route.query.tab === REPORT_TAB.OPERATIONAL ? OPERATIONAL_TAB_INDEX : 0, tabIndex: this.$route.query.tab === REPORT_TAB.OPERATIONAL ? OPERATIONAL_TAB_INDEX : 0,
}; };
}, },
...@@ -45,6 +49,14 @@ export default { ...@@ -45,6 +49,14 @@ export default {
this.$router.push({ query }); this.$router.push({ query });
}, },
}, },
methods: {
updateDevelopmentCounts(counts) {
this.developmentCounts = sumBy(counts, (x) => x.count);
},
updateOperationalCounts(counts) {
this.operationalCounts = sumBy(counts, (x) => x.count);
},
},
i18n: { i18n: {
developmentTab: s__('SecurityReports|Development vulnerabilities'), developmentTab: s__('SecurityReports|Development vulnerabilities'),
operationalTab: s__('SecurityReports|Operational vulnerabilities'), operationalTab: s__('SecurityReports|Operational vulnerabilities'),
...@@ -64,17 +76,32 @@ export default { ...@@ -64,17 +76,32 @@ export default {
<vulnerability-report-header /> <vulnerability-report-header />
<gl-tabs v-model="tabIndex" class="gl-mt-5" content-class="gl-pt-0"> <gl-tabs v-model="tabIndex" class="gl-mt-5" content-class="gl-pt-0">
<gl-tab :title="$options.i18n.developmentTab" lazy> <gl-tab>
<template #title>
<span data-testid="tab-header-development">{{ $options.i18n.developmentTab }}</span>
<gl-badge size="sm" class="gl-tab-counter-badge">
<span>{{ developmentCounts }}</span>
</gl-badge>
</template>
<slot name="header-development"></slot> <slot name="header-development"></slot>
<vulnerability-report <vulnerability-report
:type="$options.REPORT_TAB.DEVELOPMENT" :type="$options.REPORT_TAB.DEVELOPMENT"
:query="query" :query="query"
:show-project-filter="showProjectFilter" :show-project-filter="showProjectFilter"
@counts-changed="updateDevelopmentCounts"
/> />
</gl-tab> </gl-tab>
<gl-tab :title="$options.i18n.operationalTab" lazy> <gl-tab>
<template #title>
<span data-testid="tab-header-operational">{{ $options.i18n.operationalTab }}</span>
<gl-badge size="sm" class="gl-tab-counter-badge">
<span>{{ operationalCounts }}</span>
</gl-badge>
</template>
<gl-card body-class="gl-p-6">{{ $options.i18n.operationalTabMessage }}</gl-card> <gl-card body-class="gl-p-6">{{ $options.i18n.operationalTabMessage }}</gl-card>
<slot name="header-operational"></slot> <slot name="header-operational"></slot>
...@@ -83,6 +110,7 @@ export default { ...@@ -83,6 +110,7 @@ export default {
:type="$options.REPORT_TAB.OPERATIONAL" :type="$options.REPORT_TAB.OPERATIONAL"
:query="query" :query="query"
:show-project-filter="showProjectFilter" :show-project-filter="showProjectFilter"
@counts-changed="updateOperationalCounts"
/> />
</gl-tab> </gl-tab>
</gl-tabs> </gl-tabs>
......
...@@ -10,6 +10,7 @@ import ProjectPipelineStatus from 'ee/security_dashboard/components/shared/proje ...@@ -10,6 +10,7 @@ import ProjectPipelineStatus from 'ee/security_dashboard/components/shared/proje
import SecurityScannerAlert from 'ee/security_dashboard/components/project/security_scanner_alert.vue'; import SecurityScannerAlert from 'ee/security_dashboard/components/project/security_scanner_alert.vue';
import securityScannersQuery from 'ee/security_dashboard/graphql/queries/project_security_scanners.query.graphql'; import securityScannersQuery from 'ee/security_dashboard/graphql/queries/project_security_scanners.query.graphql';
import AutoFixUserCallout from 'ee/security_dashboard/components/shared/auto_fix_user_callout.vue'; import AutoFixUserCallout from 'ee/security_dashboard/components/shared/auto_fix_user_callout.vue';
import { DASHBOARD_TYPES } from 'ee/security_dashboard/store/constants';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
...@@ -50,6 +51,7 @@ describe('Project vulnerability report app component', () => { ...@@ -50,6 +51,7 @@ describe('Project vulnerability report app component', () => {
fullPath: '#', fullPath: '#',
autoFixDocumentation: '#', autoFixDocumentation: '#',
pipeline, pipeline,
dashboardType: DASHBOARD_TYPES.PROJECT,
glFeatures: { securityAutoFix }, glFeatures: { securityAutoFix },
}, },
stubs: { stubs: {
......
...@@ -7,6 +7,7 @@ import { mountExtended } from 'helpers/vue_test_utils_helper'; ...@@ -7,6 +7,7 @@ import { mountExtended } from 'helpers/vue_test_utils_helper';
import { DASHBOARD_TYPES, SEVERITY_LEVELS } from 'ee/security_dashboard/store/constants'; import { DASHBOARD_TYPES, SEVERITY_LEVELS } from 'ee/security_dashboard/store/constants';
import createFlash from '~/flash'; import createFlash from '~/flash';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import countsQuery from 'ee/security_dashboard/graphql/queries/vulnerability_severities_count.query.graphql'; import countsQuery from 'ee/security_dashboard/graphql/queries/vulnerability_severities_count.query.graphql';
import { SEVERITIES } from '~/vulnerabilities/constants'; import { SEVERITIES } from '~/vulnerabilities/constants';
...@@ -16,7 +17,7 @@ const localVue = createLocalVue(); ...@@ -16,7 +17,7 @@ const localVue = createLocalVue();
localVue.use(VueApollo); localVue.use(VueApollo);
const fullPath = 'path'; const fullPath = 'path';
const counts = { critical: 1, high: 2, info: 3, low: 4, medium: 5, unknown: 6 }; const counts = { critical: 1, high: 2, medium: 5, low: 4, info: 3, unknown: 6 };
const getCountsRequestHandler = ({ const getCountsRequestHandler = ({
data = counts, data = counts,
...@@ -77,6 +78,17 @@ describe('Vulnerability counts component', () => { ...@@ -77,6 +78,17 @@ describe('Vulnerability counts component', () => {
expect(defaultCountsRequestHandler).not.toHaveBeenCalled(); expect(defaultCountsRequestHandler).not.toHaveBeenCalled();
}); });
it('emits a count-changed event when the severity counts change', async () => {
createWrapper({ filters: { a: 1, b: 2 } });
await waitForPromises();
expect(wrapper.emitted('counts-changed')[0][0]).toEqual(
Object.entries(counts).map(([severity, count]) => ({
severity,
count,
})),
);
});
it('shows an error message if the query fails', async () => { it('shows an error message if the query fails', async () => {
const countsHandler = jest.fn().mockRejectedValue(new Error()); const countsHandler = jest.fn().mockRejectedValue(new Error());
createWrapper({ countsHandler }); createWrapper({ countsHandler });
......
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { createLocalVue } from '@vue/test-utils';
import { GlTabs, GlTab, GlBadge } from '@gitlab/ui';
import { nextTick } from 'vue'; import { nextTick } from 'vue';
import { GlTabs, GlTab } from '@gitlab/ui';
import VueRouter from 'vue-router'; import VueRouter from 'vue-router';
import VulnerabilityReportTabs from 'ee/security_dashboard/components/shared/vulnerability_report/vulnerability_report_tabs.vue'; import VulnerabilityReportTabs from 'ee/security_dashboard/components/shared/vulnerability_report/vulnerability_report_tabs.vue';
import VulnerabilityReport from 'ee/security_dashboard/components/shared/vulnerability_report/vulnerability_report.vue'; import VulnerabilityReport from 'ee/security_dashboard/components/shared/vulnerability_report/vulnerability_report.vue';
import SurveyRequestBanner from 'ee/security_dashboard/components/shared/survey_request_banner.vue'; import SurveyRequestBanner from 'ee/security_dashboard/components/shared/survey_request_banner.vue';
import projectVulnerabilitiesQuery from 'ee/security_dashboard/graphql/queries/project_vulnerabilities.query.graphql'; import projectVulnerabilitiesQuery from 'ee/security_dashboard/graphql/queries/project_vulnerabilities.query.graphql';
import waitForPromises from 'helpers/wait_for_promises';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { DASHBOARD_TYPES } from 'ee/security_dashboard/store/constants';
import { REPORT_TAB } from 'ee/security_dashboard/components/shared/vulnerability_report/constants'; import { REPORT_TAB } from 'ee/security_dashboard/components/shared/vulnerability_report/constants';
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(VueRouter); localVue.use(VueRouter);
const router = new VueRouter(); const router = new VueRouter();
const countsDevelopment = [
{ severity: 'critical', count: 1 },
{ severity: 'high', count: 2 },
{ severity: 'info', count: 3 },
{ severity: 'low', count: 4 },
{ severity: 'medium', count: 5 },
{ severity: 'unknown', count: 6 },
];
const countsOperational = [
{ severity: 'critical', count: 1 },
{ severity: 'high', count: 0 },
{ severity: 'info', count: 0 },
{ severity: 'low', count: 10 },
{ severity: 'medium', count: 2 },
{ severity: 'unknown', count: 1 },
];
describe('Vulnerability report tabs component', () => { describe('Vulnerability report tabs component', () => {
let wrapper; let wrapper;
const createWrapper = ({ showProjectFilter = false } = {}) => { const createWrapper = ({ showProjectFilter = false } = {}) => {
wrapper = shallowMount(VulnerabilityReportTabs, { wrapper = shallowMountExtended(VulnerabilityReportTabs, {
localVue, localVue,
router, router,
provide: {
fullPath: '/full/path',
surveyRequestSvgPath: '/survey/path',
dashboardDocumentation: '/dashboard/documentation/path',
vulnerabilitiesExportEndpoint: '/vuln/export/path',
emptyStateSvgPath: '/empty/state/svg/path',
hasJiraVulnerabilitiesIntegrationEnabled: false,
canAdminVulnerability: true,
canViewFalsePositive: false,
dashboardType: DASHBOARD_TYPES.INSTANCE,
},
propsData: { propsData: {
query: projectVulnerabilitiesQuery, query: projectVulnerabilitiesQuery,
showProjectFilter, showProjectFilter,
}, },
stubs: {
GlTabs,
GlTab,
GlBadge,
},
}); });
}; };
...@@ -49,8 +85,25 @@ describe('Vulnerability report tabs component', () => { ...@@ -49,8 +85,25 @@ describe('Vulnerability report tabs component', () => {
const tabs = wrapper.findAllComponents(GlTab); const tabs = wrapper.findAllComponents(GlTab);
expect(tabs).toHaveLength(2); expect(tabs).toHaveLength(2);
expect(tabs.at(0).attributes('title')).toBe('Development vulnerabilities'); expect(wrapper.findByTestId('tab-header-development').text()).toBe(
expect(tabs.at(1).attributes('title')).toBe('Operational vulnerabilities'); 'Development vulnerabilities',
);
expect(wrapper.findByTestId('tab-header-operational').text()).toBe(
'Operational vulnerabilities',
);
});
it('displays the counts for each tab', async () => {
createWrapper();
const tabs = wrapper.findAllComponents(GlTab);
const reports = findVulnerabilityReports();
reports.at(0).vm.$emit('filters', { severity: 'critical' });
reports.at(0).vm.$emit('counts-changed', countsDevelopment);
reports.at(1).vm.$emit('counts-changed', countsOperational);
await waitForPromises();
expect(tabs.at(0).findComponent(GlBadge).text()).toBe('21');
expect(tabs.at(1).findComponent(GlBadge).text()).toBe('14');
}); });
it.each` it.each`
......
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