Commit 88359376 authored by Jose Ivan Vargas's avatar Jose Ivan Vargas

Merge branch '259255-Create-activity-filter-new' into 'master'

[RUN-AS-IF-FOSS]Add activity filter to security dashboards

See merge request gitlab-org/gitlab!48196
parents 57a56e53 f1cb5b45
<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>
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 ActivityFilter from './filters/activity_filter.vue';
const searchBoxOptionCount = 20; // Number of options before the search box is shown.
export default {
components: {
StandardFilter,
},
props: {
projects: { type: Array, required: false, default: undefined },
},
......@@ -17,9 +21,13 @@ export default {
}),
computed: {
filters() {
return this.projects
? [stateFilter, severityFilter, scannerFilter, getProjectFilter(this.projects)]
: [stateFilter, severityFilter, scannerFilter];
const filters = [stateFilter, severityFilter, scannerFilter, activityFilter];
if (this.projects) {
filters.push(getProjectFilter(this.projects));
}
return filters;
},
},
methods: {
......@@ -32,6 +40,9 @@ export default {
emitFilterChange: debounce(function emit() {
this.$emit('filterChange', this.filterQuery);
}),
getFilterComponent({ id }) {
return id === activityFilter.id ? ActivityFilter : StandardFilter;
},
},
searchBoxOptionCount,
};
......@@ -40,7 +51,8 @@ export default {
<template>
<div class="dashboard-filters border-bottom bg-gray-light">
<div class="row mx-0 p-2">
<standard-filter
<component
:is="getFilterComponent(filter)"
v-for="filter in filters"
:key="filter.id"
class="col-sm-6 col-md-4 col-lg-2 p-2"
......
......@@ -11,6 +11,8 @@ query group(
$scanner: [String!]
$state: [VulnerabilityState!]
$sort: VulnerabilitySort
$hasIssues: Boolean
$hasResolution: Boolean
) {
group(fullPath: $fullPath) {
vulnerabilities(
......@@ -22,6 +24,8 @@ query group(
state: $state
projectId: $projectId
sort: $sort
hasIssues: $hasIssues
hasResolution: $hasResolution
) {
nodes {
...Vulnerability
......
......@@ -10,6 +10,8 @@ query instance(
$scanner: [String!]
$state: [VulnerabilityState!]
$sort: VulnerabilitySort
$hasIssues: Boolean
$hasResolution: Boolean
) {
vulnerabilities(
after: $after
......@@ -20,6 +22,8 @@ query instance(
projectId: $projectId
scanner: $scanner
sort: $sort
hasIssues: $hasIssues
hasResolution: $hasResolution
) {
nodes {
...Vulnerability
......
......@@ -10,6 +10,8 @@ query project(
$scanner: [String!]
$state: [VulnerabilityState!]
$sort: VulnerabilitySort
$hasIssues: Boolean
$hasResolution: Boolean
) {
project(fullPath: $fullPath) {
vulnerabilities(
......@@ -20,6 +22,8 @@ query project(
scanner: $scanner
state: $state
sort: $sort
hasIssues: $hasIssues
hasResolution: $hasResolution
) {
nodes {
...Vulnerability
......
......@@ -38,6 +38,20 @@ export const scannerFilter = {
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) => {
return {
name: s__('SecurityReports|Project'),
......
......@@ -23,6 +23,10 @@ export const BASE_FILTERS = {
name: s__('ciReport|All scanners'),
id: ALL,
},
activity: {
name: s__('SecurityReports|All'),
id: ALL,
},
project_id: {
name: s__('ciReport|All projects'),
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', () => {
wrapper = null;
});
describe('filters', () => {
describe('severity', () => {
beforeEach(() => {
createWrapper();
});
......
......@@ -24544,6 +24544,9 @@ msgstr ""
msgid "Reports|Actions"
msgstr ""
msgid "Reports|Activity"
msgstr ""
msgid "Reports|An error occured while loading report"
msgstr ""
......@@ -25805,6 +25808,9 @@ msgstr ""
msgid "SecurityReports|Add projects to your group"
msgstr ""
msgid "SecurityReports|All"
msgstr ""
msgid "SecurityReports|Comment added to '%{vulnerabilityName}'"
msgstr ""
......@@ -25883,6 +25889,12 @@ msgstr ""
msgid "SecurityReports|More information"
msgstr ""
msgid "SecurityReports|No activity"
msgstr ""
msgid "SecurityReports|No longer detected"
msgstr ""
msgid "SecurityReports|No vulnerabilities found"
msgstr ""
......@@ -26008,6 +26020,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."
msgstr ""
msgid "SecurityReports|With issues"
msgstr ""
msgid "SecurityReports|Won't fix / Accept risk"
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