Commit c9aa7e9f authored by Andrew Fontaine's avatar Andrew Fontaine

Merge branch '338239-add-counts-to-vuln-tabs' into 'master'

Show number of counts next to tabs

See merge request gitlab-org/gitlab!76859
parents c4d6756a 19435213
...@@ -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'),
...@@ -63,17 +75,32 @@ export default { ...@@ -63,17 +75,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>
...@@ -82,6 +109,7 @@ export default { ...@@ -82,6 +109,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