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

Merge branch '210327-improve-vulnerability-filter-logic' into 'master'

Improve vulnerability filter component logic

See merge request gitlab-org/gitlab!45614
parents 20341ac0 2ba8c796
...@@ -31,7 +31,7 @@ export default { ...@@ -31,7 +31,7 @@ export default {
}, },
computed: { computed: {
firstSelectedOption() { firstSelectedOption() {
return this.selectedOptions[0] || '-'; return this.selectedOptions[0]?.name || '-';
}, },
extraOptionCount() { extraOptionCount() {
return this.selectedOptions.length - 1; return this.selectedOptions.length - 1;
......
<script> <script>
import { isEqual, xor } from 'lodash';
import FilterBody from './filter_body.vue'; import FilterBody from './filter_body.vue';
import FilterItem from './filter_item.vue'; import FilterItem from './filter_item.vue';
export default { export default {
components: { components: { FilterBody, FilterItem },
FilterBody,
FilterItem,
},
props: { props: {
filter: { filter: {
type: Object, type: Object,
required: true, required: true,
}, },
showSearchBox: {
type: Boolean,
required: false,
default: false,
},
}, },
data() { data() {
return { return {
searchTerm: '', searchTerm: '',
selectedOptions: undefined,
}; };
}, },
computed: { computed: {
selection() { selectedSet() {
return this.filter.selection; return new Set(this.selectedOptions);
},
isNoOptionsSelected() {
return this.selectedOptions.length <= 0;
},
selectedOptionsOrAll() {
return this.selectedOptions.length ? this.selectedOptions : [this.filter.allOption];
},
queryObject() {
// This is the object used to update the querystring.
return { [this.filter.id]: this.selectedOptionsOrAll.map(x => x.id) };
},
filterObject() {
// This is the object used by the GraphQL query.
return { [this.filter.id]: this.selectedOptions.map(x => x.id) };
}, },
filteredOptions() { filteredOptions() {
return this.filter.options.filter(option => return this.filter.options.filter(option =>
option.name.toLowerCase().includes(this.searchTerm.toLowerCase()), option.name.toLowerCase().includes(this.searchTerm.toLowerCase()),
); );
}, },
selectedOptionsNames() { routeQueryIds() {
return Array.from(this.selection).map(id => this.filter.options.find(x => x.id === id).name); const ids = this.$route.query[this.filter.id] || [];
return Array.isArray(ids) ? ids : [ids];
},
routeQueryOptions() {
const options = this.filter.options.filter(x => this.routeQueryIds.includes(x.id));
const hasAllId = this.routeQueryIds.includes(this.filter.allOption.id);
if (options.length && !hasAllId) {
return options;
}
return hasAllId ? [] : this.filter.defaultOptions;
},
},
watch: {
selectedOptions() {
this.$emit('filter-changed', this.filterObject);
},
}, },
created() {
this.selectedOptions = this.routeQueryOptions;
// When the user clicks the forward/back browser buttons, update the selected options.
window.addEventListener('popstate', () => {
this.selectedOptions = this.routeQueryOptions;
});
}, },
methods: { methods: {
clickFilter(option) { toggleOption(option) {
this.$emit('setFilter', { filterId: this.filter.id, optionId: option.id }); // Toggle the option's existence in the array.
this.selectedOptions = xor(this.selectedOptions, [option]);
this.updateRouteQuery();
},
deselectAllOptions() {
this.selectedOptions = [];
this.updateRouteQuery();
},
updateRouteQuery() {
const query = { query: { ...this.$route.query, ...this.queryObject } };
// To avoid a console error, don't update the querystring if it's the same as the current one.
if (!isEqual(this.routeQueryIds, this.queryObject[this.filter.id])) {
this.$router.push(query);
}
}, },
isSelected(option) { isSelected(option) {
return this.selection.has(option.id); return this.selectedSet.has(option);
}, },
}, },
}; };
...@@ -46,15 +100,23 @@ export default { ...@@ -46,15 +100,23 @@ export default {
<filter-body <filter-body
v-model.trim="searchTerm" v-model.trim="searchTerm"
:name="filter.name" :name="filter.name"
:selected-options="selectedOptionsNames" :selected-options="selectedOptionsOrAll"
:show-search-box="filter.options.length >= 20" :show-search-box="showSearchBox"
> >
<filter-item
v-if="filter.allOption && !searchTerm.length"
:is-checked="isNoOptionsSelected"
:text="filter.allOption.name"
data-testid="allOption"
@click="deselectAllOptions"
/>
<filter-item <filter-item
v-for="option in filteredOptions" v-for="option in filteredOptions"
:key="option.id" :key="option.id"
:is-checked="isSelected(option)" :is-checked="isSelected(option)"
:text="option.name" :text="option.name"
@click="clickFilter(option)" data-testid="filterOption"
@click="toggleOption(option)"
/> />
</filter-body> </filter-body>
</template> </template>
<script> <script>
import { isEqual } from 'lodash'; import { debounce } from 'lodash';
import { ALL, STATE } from 'ee/security_dashboard/store/modules/filters/constants'; import { stateFilter, severityFilter, scannerFilter, getProjectFilter } from '../helpers';
import { setFilter } from 'ee/security_dashboard/store/modules/filters/utils'; import StandardFilter from './filters/standard_filter.vue';
import StandardFilter from 'ee/security_dashboard/components/filters/standard_filter.vue';
import { initFirstClassVulnerabilityFilters, mapProjects } from 'ee/security_dashboard/helpers'; const searchBoxOptionCount = 20; // Number of options before the search box is shown.
export default { export default {
components: { components: {
...@@ -12,73 +12,28 @@ export default { ...@@ -12,73 +12,28 @@ export default {
props: { props: {
projects: { type: Array, required: false, default: undefined }, projects: { type: Array, required: false, default: undefined },
}, },
data() { data: () => ({
return { filterQuery: {},
filters: initFirstClassVulnerabilityFilters(this.projects), }),
};
},
computed: { computed: {
selectedFilters() { filters() {
return this.filters.reduce((acc, { id, selection }) => { return this.projects
if (!selection.has(ALL)) { ? [stateFilter, severityFilter, scannerFilter, getProjectFilter(this.projects)]
acc[id] = Array.from(selection); : [stateFilter, severityFilter, scannerFilter];
}
return acc;
}, {});
},
},
watch: {
/**
* Initially the project list empty. We fetch them dynamically from GraphQL while
* fetching the list of vulnerabilities. We display the project filter with the base
* option and when the projects are fetched we add them to the list.
*/
projects(newProjects, oldProjects) {
if (oldProjects.length === 0) {
const projectFilter = this.filters[3];
projectFilter.options = [projectFilter.options[0], ...mapProjects(this.projects)];
}
},
'$route.query': {
immediate: true,
handler(newQuery) {
let changed;
this.filters.forEach((filter, i) => {
let urlFilter = newQuery[filter.id];
if (typeof urlFilter === 'undefined') {
urlFilter = [ALL];
} else if (!Array.isArray(urlFilter)) {
urlFilter = [urlFilter];
}
if (isEqual(this.selectedFilters[filter.id], newQuery[filter.id]) === false) {
changed = true;
this.filters[i].selection = new Set(urlFilter);
}
});
if (changed) {
this.$emit('filterChange', this.selectedFilters);
}
},
},
}, },
created() {
if (Object.keys(this.selectedFilters).length === 0) {
this.$router.push({ query: { state: [STATE.DETECTED, STATE.CONFIRMED] } });
}
}, },
methods: { methods: {
setFilter(options) { updateFilterQuery(query) {
this.filters = setFilter(this.filters, options); this.filterQuery = { ...this.filterQuery, ...query };
this.$router.push({ query: this.selectedFilters }); this.emitFilterChange();
this.$emit('filterChange', this.selectedFilters);
}, },
// All the filters will emit @filter-changed when the page is first loaded, which will trigger
// this method multiple times. We'll debounce it so that it only runs once.
emitFilterChange: debounce(function emit() {
this.$emit('filterChange', this.filterQuery);
}),
}, },
searchBoxOptionCount,
}; };
</script> </script>
...@@ -90,7 +45,9 @@ export default { ...@@ -90,7 +45,9 @@ export default {
: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"
:filter="filter" :filter="filter"
@setFilter="setFilter" :data-testid="filter.id"
:show-search-box="filter.options.length >= $options.searchBoxOptionCount"
@filter-changed="updateFilterQuery"
/> />
</div> </div>
</div> </div>
......
import isPlainObject from 'lodash/isPlainObject'; import isPlainObject from 'lodash/isPlainObject';
import { ALL, BASE_FILTERS } from 'ee/security_dashboard/store/modules/filters/constants'; import { BASE_FILTERS } from 'ee/security_dashboard/store/modules/filters/constants';
import { REPORT_TYPES, SEVERITY_LEVELS } from 'ee/security_dashboard/store/constants'; import { REPORT_TYPES, SEVERITY_LEVELS } from 'ee/security_dashboard/store/constants';
import { VULNERABILITY_STATES } from 'ee/vulnerabilities/constants'; import { VULNERABILITY_STATES } from 'ee/vulnerabilities/constants';
import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils';
...@@ -11,41 +11,41 @@ const parseOptions = obj => ...@@ -11,41 +11,41 @@ const parseOptions = obj =>
export const mapProjects = projects => export const mapProjects = projects =>
projects.map(p => ({ id: p.id.split('/').pop(), name: p.name })); projects.map(p => ({ id: p.id.split('/').pop(), name: p.name }));
export const initFirstClassVulnerabilityFilters = projects => { const stateOptions = parseOptions(VULNERABILITY_STATES);
const filters = [ const defaultStateOptions = stateOptions.filter(x => ['DETECTED', 'CONFIRMED'].includes(x.id));
{
export const stateFilter = {
name: s__('SecurityReports|Status'), name: s__('SecurityReports|Status'),
id: 'state', id: 'state',
options: [ options: stateOptions,
{ id: ALL, name: s__('VulnerabilityStatusTypes|All') }, allOption: BASE_FILTERS.state,
...parseOptions(VULNERABILITY_STATES), defaultOptions: defaultStateOptions,
], };
selection: new Set([ALL]),
}, export const severityFilter = {
{
name: s__('SecurityReports|Severity'), name: s__('SecurityReports|Severity'),
id: 'severity', id: 'severity',
options: [BASE_FILTERS.severity, ...parseOptions(SEVERITY_LEVELS)], options: parseOptions(SEVERITY_LEVELS),
selection: new Set([ALL]), allOption: BASE_FILTERS.severity,
}, defaultOptions: [],
{ };
export const scannerFilter = {
name: s__('Reports|Scanner'), name: s__('Reports|Scanner'),
id: 'reportType', id: 'reportType',
options: [BASE_FILTERS.report_type, ...parseOptions(REPORT_TYPES)], options: parseOptions(REPORT_TYPES),
selection: new Set([ALL]), allOption: BASE_FILTERS.report_type,
}, defaultOptions: [],
]; };
if (Array.isArray(projects)) { export const getProjectFilter = projects => {
filters.push({ return {
name: s__('SecurityReports|Project'), name: s__('SecurityReports|Project'),
id: 'projectId', id: 'projectId',
options: [BASE_FILTERS.project_id, ...mapProjects(projects)], options: mapProjects(projects),
selection: new Set([ALL]), allOption: BASE_FILTERS.project_id,
}); defaultOptions: [],
} };
return filters;
}; };
/** /**
......
...@@ -7,6 +7,10 @@ export const STATE = { ...@@ -7,6 +7,10 @@ export const STATE = {
}; };
export const BASE_FILTERS = { export const BASE_FILTERS = {
state: {
name: s__('VulnerabilityStatusTypes|All'),
id: ALL,
},
severity: { severity: {
name: s__('ciReport|All severities'), name: s__('ciReport|All severities'),
id: ALL, id: ALL,
......
...@@ -33,17 +33,19 @@ describe('Filter Body component', () => { ...@@ -33,17 +33,19 @@ describe('Filter Body component', () => {
describe('dropdown button', () => { describe('dropdown button', () => {
it('shows the selected option name if only one option is selected', () => { it('shows the selected option name if only one option is selected', () => {
const props = { selectedOptions: ['Some Selected Option'] }; const option = { name: 'Some Selected Option' };
const props = { selectedOptions: [option] };
createComponent(props); createComponent(props);
expect(dropdownButton().text()).toBe(props.selectedOptions[0]); expect(dropdownButton().text()).toBe(option.name);
}); });
it('shows the selected option name and "+x more" if more than one option is selected', () => { it('shows the selected option name and "+x more" if more than one option is selected', () => {
const props = { selectedOptions: ['Option 1', 'Option 2', 'Option 3'] }; const options = [{ name: 'Option 1' }, { name: 'Option 2' }, { name: 'Option 3' }];
const props = { selectedOptions: options };
createComponent(props); createComponent(props);
expect(dropdownButton().text()).toMatch(/Option 1\s+\+2 more/); expect(dropdownButton().text()).toMatchInterpolatedText('Option 1 +2 more');
}); });
}); });
......
import Vuex from 'vuex'; import Vuex from 'vuex';
import Filters from 'ee/security_dashboard/components/filters.vue'; import Filters from 'ee/security_dashboard/components/filters.vue';
import createStore from 'ee/security_dashboard/store'; import createStore from 'ee/security_dashboard/store';
import { mount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(Vuex); localVue.use(Vuex);
...@@ -11,7 +11,7 @@ describe('Filter component', () => { ...@@ -11,7 +11,7 @@ describe('Filter component', () => {
let store; let store;
const createWrapper = (props = {}) => { const createWrapper = (props = {}) => {
wrapper = mount(Filters, { wrapper = shallowMount(Filters, {
localVue, localVue,
store, store,
propsData: { propsData: {
......
import VueRouter from 'vue-router'; import VueRouter from 'vue-router';
import { createLocalVue, shallowMount } from '@vue/test-utils'; import { createLocalVue, shallowMount } from '@vue/test-utils';
import { initFirstClassVulnerabilityFilters } from 'ee/security_dashboard/helpers';
import Filters from 'ee/security_dashboard/components/first_class_vulnerability_filters.vue'; import Filters from 'ee/security_dashboard/components/first_class_vulnerability_filters.vue';
import StandardFilter from 'ee/security_dashboard/components/filters/standard_filter.vue'; import StandardFilter from 'ee/security_dashboard/components/filters/standard_filter.vue';
...@@ -10,7 +9,6 @@ localVue.use(VueRouter); ...@@ -10,7 +9,6 @@ localVue.use(VueRouter);
describe('First class vulnerability filters component', () => { describe('First class vulnerability filters component', () => {
let wrapper; let wrapper;
let filters;
const projects = [ const projects = [
{ id: 'gid://gitlab/Project/11', name: 'GitLab Org' }, { id: 'gid://gitlab/Project/11', name: 'GitLab Org' },
...@@ -18,11 +16,8 @@ describe('First class vulnerability filters component', () => { ...@@ -18,11 +16,8 @@ describe('First class vulnerability filters component', () => {
]; ];
const findFilters = () => wrapper.findAll(StandardFilter); const findFilters = () => wrapper.findAll(StandardFilter);
const findStateFilter = () => findFilters().at(0); const findStateFilter = () => wrapper.find('[data-testid="state"]');
const findSeverityFilter = () => findFilters().at(1); const findProjectFilter = () => wrapper.find('[data-testid="projectId"]');
const findReportTypeFilter = () => findFilters().at(2);
const findProjectFilter = () => findFilters().at(3);
const findLastFilter = () => findFilters().at(filters.length - 1);
const createComponent = ({ propsData, listeners } = {}) => { const createComponent = ({ propsData, listeners } = {}) => {
return shallowMount(Filters, { localVue, router, propsData, listeners }); return shallowMount(Filters, { localVue, router, propsData, listeners });
...@@ -36,190 +31,39 @@ describe('First class vulnerability filters component', () => { ...@@ -36,190 +31,39 @@ describe('First class vulnerability filters component', () => {
describe('on render without project filter', () => { describe('on render without project filter', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createComponent(); wrapper = createComponent();
filters = initFirstClassVulnerabilityFilters();
}); });
it('should render the filters', () => { it('should render the default filters', () => {
expect(findFilters()).toHaveLength(filters.length); expect(findFilters()).toHaveLength(3);
}); });
it('should call the setFilter mutation when setting a filter', () => { it('should emit filterChange when a filter is changed', () => {
const stub = jest.fn();
const options = { foo: 'bar' }; const options = { foo: 'bar' };
findStateFilter().vm.$emit('filter-changed', options);
wrapper.setMethods({ setFilter: stub }); expect(wrapper.emitted('filterChange')[0][0]).toEqual(options);
findStateFilter().vm.$emit('setFilter', options);
expect(stub).toHaveBeenCalledWith(options);
}); });
}); });
describe('when project filter is populated dynamically', () => { describe('when project filter is populated dynamically', () => {
beforeEach(() => { beforeEach(() => {
filters = initFirstClassVulnerabilityFilters([]); wrapper = createComponent();
wrapper = createComponent({ propsData: { projects: [] } });
}); });
it('should render the project filter with one option', () => { it('should render the project filter with no options', async () => {
expect(findLastFilter().props('filter')).toEqual({ wrapper.setProps({ projects: [] });
id: 'projectId', await wrapper.vm.$nextTick();
name: 'Project', expect(findProjectFilter().props('filter').options).toHaveLength(0);
options: [{ id: 'all', name: 'All projects' }],
selection: new Set(['all']),
});
}); });
it('should set the projects dynamically', () => { it('should render the project filter with the expected options', async () => {
wrapper.setProps({ projects }); wrapper.setProps({ projects });
return wrapper.vm.$nextTick(() => { await wrapper.vm.$nextTick();
expect(findLastFilter().props('filter')).toEqual(
expect.objectContaining({
options: [
{ id: 'all', name: 'All projects' },
{ id: '11', name: 'GitLab Org' },
{ id: '12', name: 'GitLab Com' },
],
}),
);
});
});
});
describe('when project filter is ready on mount', () => {
beforeEach(() => {
filters = initFirstClassVulnerabilityFilters([]);
wrapper = createComponent({ propsData: { projects } });
});
it('should set the projects dynamically', () => {
expect(findLastFilter().props('filter')).toEqual(
expect.objectContaining({
options: [
{ id: 'all', name: 'All projects' },
{ id: '11', name: 'GitLab Org' },
{ id: '12', name: 'GitLab Com' },
],
}),
);
});
});
describe('when no filter is persisted in the URL', () => {
beforeEach(() => {
wrapper = createComponent({
propsData: { projects },
});
});
it('should redirect the user to an updated the URL and default the filters to CONFIRMED + DETECTED state', () => {
expect(findStateFilter().props('filter')).toEqual(
expect.objectContaining({
selection: new Set(['DETECTED', 'CONFIRMED']),
}),
);
});
});
describe.each`
filter | value | selector
${'state'} | ${'DETECTED,DISMISSED'} | ${findStateFilter}
${'severity'} | ${'MEDIUM'} | ${findSeverityFilter}
${'reportType'} | ${'SAST'} | ${findReportTypeFilter}
${'projectId'} | ${'12'} | ${findProjectFilter}
`('when filters are persisted', ({ filter, value, selector }) => {
describe(`with filter set to ${filter}: ${value}`, () => {
let filterChangeSpy;
beforeEach(() => {
filterChangeSpy = jest.fn();
wrapper = createComponent({
propsData: { projects },
listeners: { filterChange: filterChangeSpy },
});
// reset the router query in-between test cases
router.push({ query: {} });
router.push({ query: { [filter]: value.split(',') } }, () => {});
});
it(`should have the ${filter} filter as pre-selected`, () => {
expect(selector().props('filter').selection).toEqual(new Set(value.split(',')));
});
it('should emit a filterChange event', () => {
expect(wrapper.emitted().filterChange).toBeTruthy();
});
it('should not trigger the filterChange additonally when the filters do not change', () => { expect(findProjectFilter().props('filter').options).toEqual([
router.push({ { id: '11', name: projects[0].name },
query: { { id: '12', name: projects[1].name },
...wrapper.vm.$route.query, ]);
'some-unrelated-query-param': 'true',
},
});
return wrapper.vm.$nextTick(() => {
expect(filterChangeSpy).toHaveBeenCalledTimes(1);
});
});
it('should trigger the filterChange when the filters are reset', () => {
router.push({ query: {} });
return wrapper.vm.$nextTick(() => {
expect(filterChangeSpy).toHaveBeenNthCalledWith(2, {});
});
});
it('should reset the filters when the URL contains no more filters', () => {
router.push({ query: {} });
return wrapper.vm.$nextTick(() => {
expect(selector().props('filter').selection).toEqual(new Set(['all']));
});
});
});
});
describe.each`
filter | selector | index
${'state'} | ${findStateFilter} | ${0}
${'severity'} | ${findSeverityFilter} | ${1}
${'reportType'} | ${findReportTypeFilter} | ${2}
${'projectId'} | ${findProjectFilter} | ${3}
`('when setFilter is called', ({ filter, selector, index }) => {
describe(filter, () => {
let filterId;
let optionId;
let routePushSpy;
beforeEach(() => {
filters = initFirstClassVulnerabilityFilters(projects);
filterId = filters[index].id;
optionId = filters[index].options[1].id;
wrapper = createComponent({ propsData: { projects } });
routePushSpy = jest.spyOn(router, 'push');
selector().vm.$emit('setFilter', { optionId, filterId });
});
afterEach(() => {
// This will reset the query state
router.push('/');
});
it('should set the filter', () => {
expect(selector().props('filter').selection).toEqual(new Set([optionId]));
});
it('should emit a filterChange event', () => {
expect(wrapper.emitted().filterChange).toBeTruthy();
});
it('should update the path', () => {
expect(routePushSpy).toHaveBeenCalledWith({
query: { [filterId]: [optionId] },
});
});
}); });
}); });
}); });
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