diff --git a/ee/app/assets/javascripts/security_dashboard/components/shared/charts/open_vulnerabilities_over_time.vue b/ee/app/assets/javascripts/security_dashboard/components/shared/charts/open_vulnerabilities_over_time.vue
index eee49d656745b8d258355addde742a1fa398add4..a6ff737ffbdc49a7cc761769e830653da3bf953c 100644
--- a/ee/app/assets/javascripts/security_dashboard/components/shared/charts/open_vulnerabilities_over_time.vue
+++ b/ee/app/assets/javascripts/security_dashboard/components/shared/charts/open_vulnerabilities_over_time.vue
@@ -25,6 +25,7 @@ export default {
},
chartOptions() {
return {
+ animation: false,
// Note: This is a workaround to remove the extra whitespace when the chart has no title
// Once https://gitlab.com/gitlab-org/gitlab-services/design.gitlab.com/-/issues/2199 has been fixed, this can be removed
grid: {
@@ -62,5 +63,11 @@ export default {
-
+
diff --git a/ee/app/assets/javascripts/security_dashboard/components/shared/group_vulnerabilities_over_time_panel.vue b/ee/app/assets/javascripts/security_dashboard/components/shared/group_vulnerabilities_over_time_panel.vue
index f71ecf2b36de559caa24d17220b055f2e657732b..9406a66670272e605a89a7c51bdd5855526e80c9 100644
--- a/ee/app/assets/javascripts/security_dashboard/components/shared/group_vulnerabilities_over_time_panel.vue
+++ b/ee/app/assets/javascripts/security_dashboard/components/shared/group_vulnerabilities_over_time_panel.vue
@@ -1,18 +1,30 @@
@@ -82,6 +121,16 @@ export default {
:show-alert-state="fetchError"
>
+
{
const chartSeriesDataBySeverity = {
- CRITICAL: { name: s__('severity|Critical'), data: [] },
- HIGH: { name: s__('severity|High'), data: [] },
- MEDIUM: { name: s__('severity|Medium'), data: [] },
- LOW: { name: s__('severity|Low'), data: [] },
- INFO: { name: s__('severity|Info'), data: [] },
- UNKNOWN: { name: s__('severity|Unknown'), data: [] },
+ CRITICAL: { name: SEVERITY_LEVELS.CRITICAL, data: [] },
+ HIGH: { name: SEVERITY_LEVELS.HIGH, data: [] },
+ MEDIUM: { name: SEVERITY_LEVELS.MEDIUM, data: [] },
+ LOW: { name: SEVERITY_LEVELS.LOW, data: [] },
+ INFO: { name: SEVERITY_LEVELS.INFO, data: [] },
+ UNKNOWN: { name: SEVERITY_LEVELS.UNKNOWN, data: [] },
};
vulnerabilitiesOverTime.forEach((node) => {
diff --git a/ee/spec/frontend/security_dashboard/components/shared/group_vulnerabilities_over_time_panel_spec.js b/ee/spec/frontend/security_dashboard/components/shared/group_vulnerabilities_over_time_panel_spec.js
index b9c41b00da91a76716868fa60e630b55ab9c0c9b..d09610aab028eae41f6b09d2e20b33c45a977b81 100644
--- a/ee/spec/frontend/security_dashboard/components/shared/group_vulnerabilities_over_time_panel_spec.js
+++ b/ee/spec/frontend/security_dashboard/components/shared/group_vulnerabilities_over_time_panel_spec.js
@@ -1,5 +1,6 @@
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
+import { GlCollapsibleListbox } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ExtendedDashboardPanel from '~/vue_shared/components/customizable_dashboard/extended_dashboard_panel.vue';
import VulnerabilitiesOverTimeChart from 'ee/security_dashboard/components/shared/charts/open_vulnerabilities_over_time.vue';
@@ -8,6 +9,7 @@ import getVulnerabilitiesOverTime from 'ee/security_dashboard/graphql/queries/ge
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { useFakeDate } from 'helpers/fake_date';
+import { ALL_ID } from 'ee/security_dashboard/components/shared/filters/constants';
Vue.use(VueApollo);
jest.mock('~/alert');
@@ -93,6 +95,7 @@ describe('GroupVulnerabilitiesOverTimePanel', () => {
const findSeverityButton = () => wrapper.findByTestId('severity-button');
const findReportTypeButton = () => wrapper.findByTestId('report-type-button');
const findEmptyState = () => wrapper.findByTestId('vulnerabilities-over-time-empty-state');
+ const findSeverityListbox = () => wrapper.findComponent(GlCollapsibleListbox);
beforeEach(() => {
createComponent();
@@ -156,6 +159,8 @@ describe('GroupVulnerabilitiesOverTimePanel', () => {
endDate: todayInIsoFormat,
includeBySeverity: true,
includeByReportType: false,
+ severity: [],
+ reportType: undefined,
});
});
@@ -175,6 +180,9 @@ describe('GroupVulnerabilitiesOverTimePanel', () => {
[availableFilterType]: ['filterValue'],
includeBySeverity: true,
includeByReportType: false,
+ projectId: availableFilterType === 'projectId' ? ['filterValue'] : undefined,
+ reportType: availableFilterType === 'reportType' ? ['filterValue'] : undefined,
+ severity: [],
});
},
);
@@ -196,6 +204,8 @@ describe('GroupVulnerabilitiesOverTimePanel', () => {
endDate: todayInIsoFormat,
includeBySeverity: true,
includeByReportType: false,
+ severity: [],
+ reportType: undefined,
});
});
@@ -212,6 +222,8 @@ describe('GroupVulnerabilitiesOverTimePanel', () => {
endDate: todayInIsoFormat,
includeBySeverity: false,
includeByReportType: true,
+ severity: [],
+ reportType: undefined,
});
});
});
@@ -350,4 +362,133 @@ describe('GroupVulnerabilitiesOverTimePanel', () => {
});
});
});
+
+ describe('severity filter listbox', () => {
+ const SEVERITY_OPTIONS = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'UNKNOWN'];
+
+ const expectSelectedSeverities = (severities) => {
+ expect(findSeverityListbox().props('selected')).toMatchObject(severities);
+ };
+
+ beforeEach(async () => {
+ await waitForPromises();
+ });
+
+ it('renders the severity filter listbox in the filters slot', () => {
+ expect(findSeverityListbox().exists()).toBe(true);
+ });
+
+ it('has correct props for the severity filter listbox', () => {
+ const listbox = findSeverityListbox();
+
+ expect(listbox.props()).toMatchObject({
+ multiple: true,
+ block: true,
+ size: 'small',
+ });
+ });
+
+ it('shows all severity options including "All severities"', () => {
+ const listbox = findSeverityListbox();
+ const items = listbox.props('items');
+
+ expect(items).toHaveLength(6); // 5 severities + "All severities"
+ expect(items[0]).toEqual({ value: ALL_ID, text: 'All severities' });
+
+ SEVERITY_OPTIONS.forEach((severity, index) => {
+ expect(items[index + 1].value).toBe(severity);
+ });
+ });
+
+ it('selects "All severities" by default', () => {
+ expectSelectedSeverities([ALL_ID]);
+ });
+
+ it('shows "All severities" as toggle text when no specific severities are selected', () => {
+ const listbox = findSeverityListbox();
+ expect(listbox.props('toggleText')).toBe('All severities');
+ });
+
+ it.each(SEVERITY_OPTIONS)('allows %s to be selected', async (severity) => {
+ const listbox = findSeverityListbox();
+
+ listbox.vm.$emit('select', [severity]);
+ await nextTick();
+
+ expectSelectedSeverities([severity]);
+ });
+
+ it('deselects "All severities" when a specific severity is selected', async () => {
+ const listbox = findSeverityListbox();
+
+ await listbox.vm.$emit('select', ['CRITICAL']);
+
+ expectSelectedSeverities(['CRITICAL']);
+ });
+
+ it('selects "All severities" and deselects everything else when "All severities" is clicked', async () => {
+ const listbox = findSeverityListbox();
+
+ // First select some specific severities
+ await listbox.vm.$emit('select', ['CRITICAL', 'HIGH']);
+ expectSelectedSeverities(['CRITICAL', 'HIGH']);
+
+ // Then click "All severities"
+ await listbox.vm.$emit('select', ['CRITICAL', 'HIGH', ALL_ID]);
+ await nextTick();
+
+ expectSelectedSeverities([ALL_ID]);
+ });
+
+ it('updates the GraphQL query variables when severity filter changes', async () => {
+ const { vulnerabilitiesOverTimeHandler } = createComponent();
+ await waitForPromises();
+
+ const listbox = findSeverityListbox();
+ await listbox.vm.$emit('select', ['CRITICAL', 'HIGH']);
+ await waitForPromises();
+
+ expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith({
+ fullPath: mockGroupFullPath,
+ projectId: mockFilters.projectId,
+ startDate: ninetyDaysAgoInIsoFormat,
+ endDate: todayInIsoFormat,
+ includeBySeverity: true,
+ includeByReportType: false,
+ severity: ['CRITICAL', 'HIGH'],
+ reportType: undefined,
+ });
+ });
+
+ it('passes empty severity array to GraphQL when "All severities" is selected', async () => {
+ const { vulnerabilitiesOverTimeHandler } = createComponent();
+ await waitForPromises();
+
+ const listbox = findSeverityListbox();
+ await listbox.vm.$emit('select', ['CRITICAL', 'HIGH']);
+ await listbox.vm.$emit('select', ['CRITICAL', 'HIGH', ALL_ID]);
+ await waitForPromises();
+
+ expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith({
+ fullPath: mockGroupFullPath,
+ projectId: mockFilters.projectId,
+ startDate: ninetyDaysAgoInIsoFormat,
+ endDate: todayInIsoFormat,
+ includeBySeverity: true,
+ includeByReportType: false,
+ severity: [],
+ reportType: undefined,
+ });
+ });
+
+ it('updates toggle text when specific severities are selected', async () => {
+ const listbox = findSeverityListbox();
+
+ await listbox.vm.$emit('select', ['CRITICAL']);
+ expect(listbox.props('toggleText')).toBe('Critical');
+
+ await listbox.vm.$emit('select', ['CRITICAL', 'HIGH']);
+ expect(listbox.props('toggleText')).toBe('Critical +1 more');
+ });
+ });
});
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 0f9550297926112f11bda48a5dfcd8653e680be0..b4bedc1557d2e6c079f514986a0fe523f419088c 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -6824,6 +6824,9 @@ msgstr ""
msgid "All required approvals must be given."
msgstr ""
+msgid "All severities"
+msgstr ""
+
msgid "All threads resolved!"
msgstr ""