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 {
}));
},
},
watch: {
severityCounts() {
this.$emit('counts-changed', this.severityCounts);
},
},
};
</script>
......
......@@ -24,6 +24,13 @@ export default {
data() {
return {
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: {
......@@ -47,12 +54,6 @@ export default {
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>
......
......@@ -70,13 +70,20 @@ export default {
this.graphqlFilters.reportType = REPORT_TYPE_PRESETS.OPERATIONAL;
}
},
emitCountsChanged(counts) {
this.$emit('counts-changed', counts);
},
},
};
</script>
<template>
<div>
<vulnerability-counts class="gl-mt-7" :filters="graphqlFilters" />
<vulnerability-counts
class="gl-mt-7"
:filters="graphqlFilters"
@counts-changed="emitCountsChanged"
/>
<vulnerability-filters
:filters="filtersToShow"
......
<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 SurveyRequestBanner from '../survey_request_banner.vue';
import VulnerabilityReportHeader from './vulnerability_report_header.vue';
......@@ -13,6 +14,7 @@ export default {
GlTabs,
GlTab,
GlCard,
GlBadge,
SurveyRequestBanner,
VulnerabilityReportHeader,
VulnerabilityReport,
......@@ -30,6 +32,8 @@ export default {
},
data() {
return {
developmentCounts: undefined,
operationalCounts: undefined,
tabIndex: this.$route.query.tab === REPORT_TAB.OPERATIONAL ? OPERATIONAL_TAB_INDEX : 0,
};
},
......@@ -45,6 +49,14 @@ export default {
this.$router.push({ query });
},
},
methods: {
updateDevelopmentCounts(counts) {
this.developmentCounts = sumBy(counts, (x) => x.count);
},
updateOperationalCounts(counts) {
this.operationalCounts = sumBy(counts, (x) => x.count);
},
},
i18n: {
developmentTab: s__('SecurityReports|Development vulnerabilities'),
operationalTab: s__('SecurityReports|Operational vulnerabilities'),
......@@ -64,17 +76,32 @@ export default {
<vulnerability-report-header />
<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>
<vulnerability-report
:type="$options.REPORT_TAB.DEVELOPMENT"
:query="query"
:show-project-filter="showProjectFilter"
@counts-changed="updateDevelopmentCounts"
/>
</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>
<slot name="header-operational"></slot>
......@@ -83,6 +110,7 @@ export default {
:type="$options.REPORT_TAB.OPERATIONAL"
:query="query"
:show-project-filter="showProjectFilter"
@counts-changed="updateOperationalCounts"
/>
</gl-tab>
</gl-tabs>
......
......@@ -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 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 { DASHBOARD_TYPES } from 'ee/security_dashboard/store/constants';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
......@@ -50,6 +51,7 @@ describe('Project vulnerability report app component', () => {
fullPath: '#',
autoFixDocumentation: '#',
pipeline,
dashboardType: DASHBOARD_TYPES.PROJECT,
glFeatures: { securityAutoFix },
},
stubs: {
......
......@@ -7,6 +7,7 @@ import { mountExtended } from 'helpers/vue_test_utils_helper';
import { DASHBOARD_TYPES, SEVERITY_LEVELS } from 'ee/security_dashboard/store/constants';
import createFlash from '~/flash';
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 { SEVERITIES } from '~/vulnerabilities/constants';
......@@ -16,7 +17,7 @@ const localVue = createLocalVue();
localVue.use(VueApollo);
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 = ({
data = counts,
......@@ -77,6 +78,17 @@ describe('Vulnerability counts component', () => {
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 () => {
const countsHandler = jest.fn().mockRejectedValue(new Error());
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 { GlTabs, GlTab } from '@gitlab/ui';
import VueRouter from 'vue-router';
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 SurveyRequestBanner from 'ee/security_dashboard/components/shared/survey_request_banner.vue';
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';
const localVue = createLocalVue();
localVue.use(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', () => {
let wrapper;
const createWrapper = ({ showProjectFilter = false } = {}) => {
wrapper = shallowMount(VulnerabilityReportTabs, {
wrapper = shallowMountExtended(VulnerabilityReportTabs, {
localVue,
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: {
query: projectVulnerabilitiesQuery,
showProjectFilter,
},
stubs: {
GlTabs,
GlTab,
GlBadge,
},
});
};
......@@ -49,8 +85,25 @@ describe('Vulnerability report tabs component', () => {
const tabs = wrapper.findAllComponents(GlTab);
expect(tabs).toHaveLength(2);
expect(tabs.at(0).attributes('title')).toBe('Development vulnerabilities');
expect(tabs.at(1).attributes('title')).toBe('Operational vulnerabilities');
expect(wrapper.findByTestId('tab-header-development').text()).toBe(
'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`
......
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