Commit f1cb5b45 authored by Kev's avatar Kev

Add activity filter to security dashboards

This adds the activity filter to all three vulnerability
reports (project, group, instance). It is a custom filter
that extends the standard filter.
parent a0d465de
<script>
import { xor, remove } from 'lodash';
import { GlDropdownDivider } from '@gitlab/ui';
import { activityOptions } from '../../helpers';
import FilterBody from './filter_body.vue';
import FilterItem from './filter_item.vue';
import StandardFilter from './standard_filter.vue';
const { NO_ACTIVITY, WITH_ISSUES, NO_LONGER_DETECTED } = activityOptions;
export default {
components: { FilterBody, FilterItem, GlDropdownDivider },
extends: StandardFilter,
computed: {
filterObject() {
// This is the object used to update the GraphQL query.
if (this.isNoOptionsSelected) {
return {
hasIssues: undefined,
hasResolution: undefined,
};
}
return {
hasIssues: this.isSelected(WITH_ISSUES),
hasResolution: this.isSelected(NO_LONGER_DETECTED),
};
},
multiselectOptions() {
return [WITH_ISSUES, NO_LONGER_DETECTED];
},
},
methods: {
toggleOption(option) {
if (option === NO_ACTIVITY) {
this.selectedOptions = this.selectedSet.has(NO_ACTIVITY) ? [] : [NO_ACTIVITY];
} else {
remove(this.selectedOptions, NO_ACTIVITY);
// Toggle the option's existence in the array.
this.selectedOptions = xor(this.selectedOptions, [option]);
}
this.updateRouteQuery();
},
},
NO_ACTIVITY,
};
</script>
<template>
<filter-body
:name="filter.name"
:selected-options="selectedOptionsOrAll"
:show-search-box="false"
>
<filter-item
:is-checked="isNoOptionsSelected"
:text="filter.allOption.name"
:data-testid="`option:${filter.allOption.name}`"
@click="deselectAllOptions"
/>
<filter-item
:is-checked="isSelected($options.NO_ACTIVITY)"
:text="$options.NO_ACTIVITY.name"
:data-testid="`option:${$options.NO_ACTIVITY.name}`"
@click="toggleOption($options.NO_ACTIVITY)"
/>
<gl-dropdown-divider />
<filter-item
v-for="option in multiselectOptions"
:key="option.name"
:is-checked="isSelected(option)"
:text="option.name"
:data-testid="`option:${option.name}`"
@click="toggleOption(option)"
/>
</filter-body>
</template>
<script> <script>
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { stateFilter, severityFilter, scannerFilter, getProjectFilter } from '../helpers'; import {
stateFilter,
severityFilter,
scannerFilter,
activityFilter,
getProjectFilter,
} from '../helpers';
import StandardFilter from './filters/standard_filter.vue'; import StandardFilter from './filters/standard_filter.vue';
import ActivityFilter from './filters/activity_filter.vue';
const searchBoxOptionCount = 20; // Number of options before the search box is shown. const searchBoxOptionCount = 20; // Number of options before the search box is shown.
export default { export default {
components: {
StandardFilter,
},
props: { props: {
projects: { type: Array, required: false, default: undefined }, projects: { type: Array, required: false, default: undefined },
}, },
...@@ -17,9 +21,13 @@ export default { ...@@ -17,9 +21,13 @@ export default {
}), }),
computed: { computed: {
filters() { filters() {
return this.projects const filters = [stateFilter, severityFilter, scannerFilter, activityFilter];
? [stateFilter, severityFilter, scannerFilter, getProjectFilter(this.projects)]
: [stateFilter, severityFilter, scannerFilter]; if (this.projects) {
filters.push(getProjectFilter(this.projects));
}
return filters;
}, },
}, },
methods: { methods: {
...@@ -32,6 +40,9 @@ export default { ...@@ -32,6 +40,9 @@ export default {
emitFilterChange: debounce(function emit() { emitFilterChange: debounce(function emit() {
this.$emit('filterChange', this.filterQuery); this.$emit('filterChange', this.filterQuery);
}), }),
getFilterComponent({ id }) {
return id === activityFilter.id ? ActivityFilter : StandardFilter;
},
}, },
searchBoxOptionCount, searchBoxOptionCount,
}; };
...@@ -40,7 +51,8 @@ export default { ...@@ -40,7 +51,8 @@ export default {
<template> <template>
<div class="dashboard-filters border-bottom bg-gray-light"> <div class="dashboard-filters border-bottom bg-gray-light">
<div class="row mx-0 p-2"> <div class="row mx-0 p-2">
<standard-filter <component
:is="getFilterComponent(filter)"
v-for="filter in filters" v-for="filter in filters"
:key="filter.id" :key="filter.id"
class="col-sm-6 col-md-4 col-lg-2 p-2" class="col-sm-6 col-md-4 col-lg-2 p-2"
......
...@@ -11,6 +11,8 @@ query group( ...@@ -11,6 +11,8 @@ query group(
$scanner: [String!] $scanner: [String!]
$state: [VulnerabilityState!] $state: [VulnerabilityState!]
$sort: VulnerabilitySort $sort: VulnerabilitySort
$hasIssues: Boolean
$hasResolution: Boolean
) { ) {
group(fullPath: $fullPath) { group(fullPath: $fullPath) {
vulnerabilities( vulnerabilities(
...@@ -22,6 +24,8 @@ query group( ...@@ -22,6 +24,8 @@ query group(
state: $state state: $state
projectId: $projectId projectId: $projectId
sort: $sort sort: $sort
hasIssues: $hasIssues
hasResolution: $hasResolution
) { ) {
nodes { nodes {
...Vulnerability ...Vulnerability
......
...@@ -10,6 +10,8 @@ query instance( ...@@ -10,6 +10,8 @@ query instance(
$scanner: [String!] $scanner: [String!]
$state: [VulnerabilityState!] $state: [VulnerabilityState!]
$sort: VulnerabilitySort $sort: VulnerabilitySort
$hasIssues: Boolean
$hasResolution: Boolean
) { ) {
vulnerabilities( vulnerabilities(
after: $after after: $after
...@@ -20,6 +22,8 @@ query instance( ...@@ -20,6 +22,8 @@ query instance(
projectId: $projectId projectId: $projectId
scanner: $scanner scanner: $scanner
sort: $sort sort: $sort
hasIssues: $hasIssues
hasResolution: $hasResolution
) { ) {
nodes { nodes {
...Vulnerability ...Vulnerability
......
...@@ -10,6 +10,8 @@ query project( ...@@ -10,6 +10,8 @@ query project(
$scanner: [String!] $scanner: [String!]
$state: [VulnerabilityState!] $state: [VulnerabilityState!]
$sort: VulnerabilitySort $sort: VulnerabilitySort
$hasIssues: Boolean
$hasResolution: Boolean
) { ) {
project(fullPath: $fullPath) { project(fullPath: $fullPath) {
vulnerabilities( vulnerabilities(
...@@ -20,6 +22,8 @@ query project( ...@@ -20,6 +22,8 @@ query project(
scanner: $scanner scanner: $scanner
state: $state state: $state
sort: $sort sort: $sort
hasIssues: $hasIssues
hasResolution: $hasResolution
) { ) {
nodes { nodes {
...Vulnerability ...Vulnerability
......
...@@ -38,6 +38,20 @@ export const scannerFilter = { ...@@ -38,6 +38,20 @@ export const scannerFilter = {
defaultOptions: [], defaultOptions: [],
}; };
export const activityOptions = {
NO_ACTIVITY: { id: 'NO_ACTIVITY', name: s__('SecurityReports|No activity') },
WITH_ISSUES: { id: 'WITH_ISSUES', name: s__('SecurityReports|With issues') },
NO_LONGER_DETECTED: { id: 'NO_LONGER_DETECTED', name: s__('SecurityReports|No longer detected') },
};
export const activityFilter = {
name: s__('Reports|Activity'),
id: 'activity',
options: Object.values(activityOptions),
allOption: BASE_FILTERS.activity,
defaultOptions: [],
};
export const getProjectFilter = (projects) => { export const getProjectFilter = (projects) => {
return { return {
name: s__('SecurityReports|Project'), name: s__('SecurityReports|Project'),
......
...@@ -23,6 +23,10 @@ export const BASE_FILTERS = { ...@@ -23,6 +23,10 @@ export const BASE_FILTERS = {
name: s__('ciReport|All scanners'), name: s__('ciReport|All scanners'),
id: ALL, id: ALL,
}, },
activity: {
name: s__('SecurityReports|All'),
id: ALL,
},
project_id: { project_id: {
name: s__('ciReport|All projects'), name: s__('ciReport|All projects'),
id: ALL, id: ALL,
......
---
title: Add activity filter to security dashboards
merge_request: 48196
author: Kev @KevSlashNull
type: added
import { shallowMount } from '@vue/test-utils';
import { activityFilter, activityOptions } from 'ee/security_dashboard/helpers';
import ActivityFilter from 'ee/security_dashboard/components/filters/activity_filter.vue';
const { NO_ACTIVITY, WITH_ISSUES, NO_LONGER_DETECTED } = activityOptions;
describe('Activity Filter component', () => {
let wrapper;
const findItemWithName = (name) => wrapper.find(`[data-testid="option:${name}"]`);
const expectSelectedItems = (items) => {
const checkedItems = wrapper
.findAll('[data-testid^="option:"]')
.wrappers.filter((x) => x.props('isChecked'))
.map((x) => x.props('text'));
const expectedItems = items.map((x) => x.name);
expect(checkedItems.sort()).toEqual(expectedItems.sort());
};
const createWrapper = () => {
wrapper = shallowMount(ActivityFilter, {
propsData: { filter: activityFilter },
});
};
const clickItem = (item) => {
findItemWithName(item.name).vm.$emit('click');
};
beforeEach(() => {
createWrapper();
});
afterEach(() => {
wrapper.destroy();
});
it('renders the options', () => {
activityFilter.options.forEach((option) => {
expect(findItemWithName(option.name).exists()).toBe(true);
});
});
it.each`
selectedOptions | expectedOption
${[NO_ACTIVITY]} | ${WITH_ISSUES}
${[WITH_ISSUES, NO_LONGER_DETECTED]} | ${NO_ACTIVITY}
`(
'deselects mutually exclusive options when $expectedOption.id is selected',
async ({ selectedOptions, expectedOption }) => {
await wrapper.setData({ selectedOptions });
expectSelectedItems(selectedOptions);
await clickItem(expectedOption);
expectSelectedItems([expectedOption]);
},
);
describe('filter-changed event', () => {
it('contains the correct filterObject for the all option', async () => {
await clickItem(activityFilter.allOption);
expect(wrapper.emitted('filter-changed')).toHaveLength(2);
expect(wrapper.emitted('filter-changed')[1][0]).toStrictEqual({
hasIssues: undefined,
hasResolution: undefined,
});
});
it.each`
selectedOptions | hasIssues | hasResolution
${[NO_ACTIVITY]} | ${false} | ${false}
${[WITH_ISSUES]} | ${true} | ${false}
${[NO_LONGER_DETECTED]} | ${false} | ${true}
${[WITH_ISSUES, NO_LONGER_DETECTED]} | ${true} | ${true}
`(
'contains the correct filterObject for $selectedOptions',
async ({ selectedOptions, hasIssues, hasResolution }) => {
await selectedOptions.map(clickItem);
expectSelectedItems(selectedOptions);
expect(wrapper.emitted('filter-changed')[1][0]).toEqual({ hasIssues, hasResolution });
},
);
});
});
...@@ -32,7 +32,7 @@ describe('Filter component', () => { ...@@ -32,7 +32,7 @@ describe('Filter component', () => {
wrapper = null; wrapper = null;
}); });
describe('filters', () => { describe('severity', () => {
beforeEach(() => { beforeEach(() => {
createWrapper(); createWrapper();
}); });
......
...@@ -24457,6 +24457,9 @@ msgstr "" ...@@ -24457,6 +24457,9 @@ msgstr ""
msgid "Reports|Actions" msgid "Reports|Actions"
msgstr "" msgstr ""
msgid "Reports|Activity"
msgstr ""
msgid "Reports|An error occured while loading report" msgid "Reports|An error occured while loading report"
msgstr "" msgstr ""
...@@ -25715,6 +25718,9 @@ msgstr "" ...@@ -25715,6 +25718,9 @@ msgstr ""
msgid "SecurityReports|Add projects to your group" msgid "SecurityReports|Add projects to your group"
msgstr "" msgstr ""
msgid "SecurityReports|All"
msgstr ""
msgid "SecurityReports|Comment added to '%{vulnerabilityName}'" msgid "SecurityReports|Comment added to '%{vulnerabilityName}'"
msgstr "" msgstr ""
...@@ -25793,6 +25799,12 @@ msgstr "" ...@@ -25793,6 +25799,12 @@ msgstr ""
msgid "SecurityReports|More information" msgid "SecurityReports|More information"
msgstr "" msgstr ""
msgid "SecurityReports|No activity"
msgstr ""
msgid "SecurityReports|No longer detected"
msgstr ""
msgid "SecurityReports|No vulnerabilities found" msgid "SecurityReports|No vulnerabilities found"
msgstr "" msgstr ""
...@@ -25918,6 +25930,9 @@ msgstr "" ...@@ -25918,6 +25930,9 @@ msgstr ""
msgid "SecurityReports|While it's rare to have no vulnerabilities, it can happen. In any event, we ask that you double check your settings to make sure you've set up your dashboard correctly." msgid "SecurityReports|While it's rare to have no vulnerabilities, it can happen. In any event, we ask that you double check your settings to make sure you've set up your dashboard correctly."
msgstr "" msgstr ""
msgid "SecurityReports|With issues"
msgstr ""
msgid "SecurityReports|Won't fix / Accept risk" msgid "SecurityReports|Won't fix / Accept risk"
msgstr "" msgstr ""
......
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