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 {
}));
},
},
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'),
......@@ -63,17 +75,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>
......@@ -82,6 +109,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