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 {
},
computed: {
firstSelectedOption() {
return this.selectedOptions[0] || '-';
return this.selectedOptions[0]?.name || '-';
},
extraOptionCount() {
return this.selectedOptions.length - 1;
......
<script>
import { isEqual, xor } from 'lodash';
import FilterBody from './filter_body.vue';
import FilterItem from './filter_item.vue';
export default {
components: {
FilterBody,
FilterItem,
},
components: { FilterBody, FilterItem },
props: {
filter: {
type: Object,
required: true,
},
showSearchBox: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
searchTerm: '',
selectedOptions: undefined,
};
},
computed: {
selection() {
return this.filter.selection;
selectedSet() {
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() {
return this.filter.options.filter(option =>
option.name.toLowerCase().includes(this.searchTerm.toLowerCase()),
);
},
selectedOptionsNames() {
return Array.from(this.selection).map(id => this.filter.options.find(x => x.id === id).name);
routeQueryIds() {
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: {
clickFilter(option) {
this.$emit('setFilter', { filterId: this.filter.id, optionId: option.id });
toggleOption(option) {
// 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) {
return this.selection.has(option.id);
return this.selectedSet.has(option);
},
},
};
......@@ -46,15 +100,23 @@ export default {
<filter-body
v-model.trim="searchTerm"
:name="filter.name"
:selected-options="selectedOptionsNames"
:show-search-box="filter.options.length >= 20"
:selected-options="selectedOptionsOrAll"
: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
v-for="option in filteredOptions"
:key="option.id"
:is-checked="isSelected(option)"
:text="option.name"
@click="clickFilter(option)"
data-testid="filterOption"
@click="toggleOption(option)"
/>
</filter-body>
</template>
<script>
import { isEqual } from 'lodash';
import { ALL, STATE } from 'ee/security_dashboard/store/modules/filters/constants';
import { setFilter } from 'ee/security_dashboard/store/modules/filters/utils';
import StandardFilter from 'ee/security_dashboard/components/filters/standard_filter.vue';
import { initFirstClassVulnerabilityFilters, mapProjects } from 'ee/security_dashboard/helpers';
import { debounce } from 'lodash';
import { stateFilter, severityFilter, scannerFilter, getProjectFilter } from '../helpers';
import StandardFilter from './filters/standard_filter.vue';
const searchBoxOptionCount = 20; // Number of options before the search box is shown.
export default {
components: {
......@@ -12,73 +12,28 @@ export default {
props: {
projects: { type: Array, required: false, default: undefined },
},
data() {
return {
filters: initFirstClassVulnerabilityFilters(this.projects),
};
},
data: () => ({
filterQuery: {},
}),
computed: {
selectedFilters() {
return this.filters.reduce((acc, { id, selection }) => {
if (!selection.has(ALL)) {
acc[id] = Array.from(selection);
}
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);
}
},
},
filters() {
return this.projects
? [stateFilter, severityFilter, scannerFilter, getProjectFilter(this.projects)]
: [stateFilter, severityFilter, scannerFilter];
},
created() {
if (Object.keys(this.selectedFilters).length === 0) {
this.$router.push({ query: { state: [STATE.DETECTED, STATE.CONFIRMED] } });
}
},
methods: {
setFilter(options) {
this.filters = setFilter(this.filters, options);
this.$router.push({ query: this.selectedFilters });
this.$emit('filterChange', this.selectedFilters);
updateFilterQuery(query) {
this.filterQuery = { ...this.filterQuery, ...query };
this.emitFilterChange();
},
// 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>
......@@ -90,7 +45,9 @@ export default {
:key="filter.id"
class="col-sm-6 col-md-4 col-lg-2 p-2"
:filter="filter"
@setFilter="setFilter"
:data-testid="filter.id"
:show-search-box="filter.options.length >= $options.searchBoxOptionCount"
@filter-changed="updateFilterQuery"
/>
</div>
</div>
......
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 { VULNERABILITY_STATES } from 'ee/vulnerabilities/constants';
import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils';
......@@ -11,41 +11,41 @@ const parseOptions = obj =>
export const mapProjects = projects =>
projects.map(p => ({ id: p.id.split('/').pop(), name: p.name }));
export const initFirstClassVulnerabilityFilters = projects => {
const filters = [
{
const stateOptions = parseOptions(VULNERABILITY_STATES);
const defaultStateOptions = stateOptions.filter(x => ['DETECTED', 'CONFIRMED'].includes(x.id));
export const stateFilter = {
name: s__('SecurityReports|Status'),
id: 'state',
options: [
{ id: ALL, name: s__('VulnerabilityStatusTypes|All') },
...parseOptions(VULNERABILITY_STATES),
],
selection: new Set([ALL]),
},
{
options: stateOptions,
allOption: BASE_FILTERS.state,
defaultOptions: defaultStateOptions,
};
export const severityFilter = {
name: s__('SecurityReports|Severity'),
id: 'severity',
options: [BASE_FILTERS.severity, ...parseOptions(SEVERITY_LEVELS)],
selection: new Set([ALL]),
},
{
options: parseOptions(SEVERITY_LEVELS),
allOption: BASE_FILTERS.severity,
defaultOptions: [],
};
export const scannerFilter = {
name: s__('Reports|Scanner'),
id: 'reportType',
options: [BASE_FILTERS.report_type, ...parseOptions(REPORT_TYPES)],
selection: new Set([ALL]),
},
];
options: parseOptions(REPORT_TYPES),
allOption: BASE_FILTERS.report_type,
defaultOptions: [],
};
if (Array.isArray(projects)) {
filters.push({
export const getProjectFilter = projects => {
return {
name: s__('SecurityReports|Project'),
id: 'projectId',
options: [BASE_FILTERS.project_id, ...mapProjects(projects)],
selection: new Set([ALL]),
});
}
return filters;
options: mapProjects(projects),
allOption: BASE_FILTERS.project_id,
defaultOptions: [],
};
};
/**
......
......@@ -7,6 +7,10 @@ export const STATE = {
};
export const BASE_FILTERS = {
state: {
name: s__('VulnerabilityStatusTypes|All'),
id: ALL,
},
severity: {
name: s__('ciReport|All severities'),
id: ALL,
......
......@@ -33,17 +33,19 @@ describe('Filter Body component', () => {
describe('dropdown button', () => {
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);
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', () => {
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);
expect(dropdownButton().text()).toMatch(/Option 1\s+\+2 more/);
expect(dropdownButton().text()).toMatchInterpolatedText('Option 1 +2 more');
});
});
......
import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui';
import StandardFilter from 'ee/security_dashboard/components/filters/standard_filter.vue';
import { mount } from '@vue/test-utils';
import { trimText } from 'helpers/text_helper';
import FilterBody from 'ee/security_dashboard/components/filters/filter_body.vue';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import VueRouter from 'vue-router';
const generateOption = index => ({
name: `Option ${index}`,
id: `option-${index}`,
});
const localVue = createLocalVue();
localVue.use(VueRouter);
const router = new VueRouter();
const generateOptions = length =>
Array.from({ length }).map((_, i) => ({ name: `Option ${i}`, id: `option-${i}`, index: i }));
const generateOptions = length => {
return Array.from({ length }).map((_, i) => generateOption(i));
const filter = {
id: 'filter',
name: 'filter',
options: generateOptions(12),
allOption: { id: 'allOptionId' },
defaultOptions: [],
};
const optionsAt = indexes => filter.options.filter(x => indexes.includes(x.index));
const optionIdsAt = indexes => optionsAt(indexes).map(x => x.id);
describe('Standard Filter component', () => {
let wrapper;
const createWrapper = propsData => {
wrapper = mount(StandardFilter, { propsData });
const createWrapper = (filterOptions, showSearchBox) => {
wrapper = shallowMount(StandardFilter, {
localVue,
router,
propsData: { filter: { ...filter, ...filterOptions }, showSearchBox },
});
};
const findSearchBox = () => wrapper.find(GlSearchBoxByType);
const isDropdownOpen = () => wrapper.find(GlDropdown).classes('show');
const dropdownItemsCount = () => wrapper.findAll(GlDropdownItem).length;
const dropdownItems = () => wrapper.findAll('[data-testid="filterOption"]');
const dropdownItemAt = index => dropdownItems().at(index);
const allOptionItem = () => wrapper.find('[data-testid="allOption"]');
const isChecked = item => item.props('isChecked');
const filterQuery = () => wrapper.vm.$route.query[filter.id];
const filterBody = () => wrapper.find(FilterBody);
const clickAllOptionItem = async () => {
allOptionItem().vm.$emit('click');
await wrapper.vm.$nextTick();
};
const clickItemAt = async index => {
dropdownItemAt(index).vm.$emit('click');
await wrapper.vm.$nextTick();
};
const expectSelectedItems = indexes => {
const checkedIndexes = dropdownItems().wrappers.map(item => isChecked(item));
const expectedIndexes = Array.from({ length: checkedIndexes.length }).map((_, index) =>
indexes.includes(index),
);
expect(checkedIndexes).toEqual(expectedIndexes);
};
const expectAllOptionSelected = () => {
expect(isChecked(allOptionItem())).toBe(true);
const checkedIndexes = dropdownItems().wrappers.map(item => isChecked(item));
const expectedIndexes = new Array(checkedIndexes.length).fill(false);
expect(checkedIndexes).toEqual(expectedIndexes);
};
afterEach(() => {
// Clear out the querystring if one exists. It persists between tests.
if (filterQuery()) {
wrapper.vm.$router.push('/');
}
wrapper.destroy();
});
describe('severity', () => {
let options;
describe('filter options', () => {
it('shows the filter options', () => {
createWrapper();
expect(dropdownItems()).toHaveLength(filter.options.length);
});
it('initially selects the default options', () => {
const ids = [2, 5, 7];
createWrapper({ defaultOptions: optionsAt(ids) });
expectSelectedItems(ids);
});
it('initially selects the All option if there are no default options', () => {
createWrapper();
expectAllOptionSelected();
});
});
describe('search box', () => {
it.each`
phrase | showSearchBox
${'shows'} | ${true}
${'does not show'} | ${false}
`('$phrase search box if showSearchBox is $showSearchBox', ({ showSearchBox }) => {
createWrapper({}, showSearchBox);
expect(filterBody().props('showSearchBox')).toBe(showSearchBox);
});
it('filters options when something is typed in the search box', async () => {
const expectedItems = filter.options.map(x => x.name).filter(x => x.includes('1'));
createWrapper({}, true);
filterBody().vm.$emit('input', '1');
await wrapper.vm.$nextTick();
expect(dropdownItems()).toHaveLength(3);
expect(dropdownItems().wrappers.map(x => x.props('text'))).toEqual(expectedItems);
});
});
describe('selecting options', () => {
beforeEach(() => {
options = generateOptions(8);
const filter = {
name: 'Severity',
id: 'severity',
options,
selection: new Set([options[0].id, options[1].id, options[2].id]),
createWrapper({ defaultOptions: optionsAt([1, 2, 3]) });
});
it('de-selects every option and selects the All option when all option is clicked', async () => {
const clickAndCheck = async () => {
await clickAllOptionItem();
expectAllOptionSelected();
};
// Click the all option 3 times. We're checking that it doesn't toggle.
await clickAndCheck();
await clickAndCheck();
await clickAndCheck();
});
it(`toggles an option's selection when it it repeatedly clicked`, async () => {
const item = dropdownItems().at(5);
let checkedState = isChecked(item);
const clickAndCheck = async () => {
await clickItemAt(5);
expect(isChecked(item)).toBe(!checkedState);
checkedState = !checkedState;
};
createWrapper({ filter });
// Click the option 3 times. We're checking that toggles.
await clickAndCheck();
await clickAndCheck();
await clickAndCheck();
});
it('multi-selects options when multiple items are clicked', async () => {
await [5, 6, 7].forEach(clickItemAt);
expectSelectedItems([1, 2, 3, 5, 6, 7]);
});
it('should display all 8 severity options', () => {
expect(dropdownItemsCount()).toEqual(8);
it('selects the All option when last selected option is unselected', async () => {
await [1, 2, 3].forEach(clickItemAt);
expectAllOptionSelected();
});
it('should display a check next to only the selected items', () => {
expect(
wrapper.findAll(`[data-testid="mobile-issue-close-icon"]:not(.gl-visibility-hidden)`),
).toHaveLength(3);
it('emits filter-changed event with default options when created', async () => {
const expectedIds = optionIdsAt([1, 2, 3]);
expect(wrapper.emitted('filter-changed')).toHaveLength(1);
expect(wrapper.emitted('filter-changed')[0][0]).toEqual({ [filter.id]: expectedIds });
});
it('should correctly display the selected text', () => {
const selectedText = trimText(wrapper.find('.dropdown-toggle').text());
it('emits filter-changed event when an option is clicked', async () => {
const expectedIds = optionIdsAt([1, 2, 3, 4]);
await clickItemAt(4);
expect(selectedText).toBe(`${options[0].name} +2 more`);
expect(wrapper.emitted('filter-changed')).toHaveLength(2);
expect(wrapper.emitted('filter-changed')[1][0]).toEqual({ [filter.id]: expectedIds });
});
});
it('should display "Severity" as the option name', () => {
expect(wrapper.find('[data-testid="name"]').text()).toEqual('Severity');
describe('filter querystring', () => {
const updateRouteQuery = async ids => {
// window.history.back() won't change the location nor fire the popstate event, so we need
// to fake it by doing it manually.
router.replace({ query: { [filter.id]: ids } });
window.dispatchEvent(new Event('popstate'));
await wrapper.vm.$nextTick();
};
describe('clicking on items', () => {
it('updates the querystring when options are clicked', async () => {
createWrapper();
const clickedIds = [];
[1, 3, 5].forEach(index => {
clickItemAt(index);
clickedIds.push(optionIdsAt([index])[0]);
expect(filterQuery()).toEqual(clickedIds);
});
});
it('sets the querystring properly when the All option is clicked', async () => {
createWrapper();
[1, 2, 3, 4].forEach(clickItemAt);
it('should not have a search box', () => {
expect(findSearchBox().exists()).toBe(false);
expect(filterQuery()).toHaveLength(4);
await clickAllOptionItem();
expect(filterQuery()).toEqual([filter.allOption.id]);
});
});
it('should not be open', () => {
expect(isDropdownOpen()).toBe(false);
describe('querystring on page load', () => {
it('selects correct items', () => {
updateRouteQuery(optionIdsAt([1, 3, 5, 7]));
createWrapper();
expectSelectedItems([1, 3, 5, 7]);
});
describe('when the dropdown is open', () => {
beforeEach(done => {
wrapper.find('.dropdown-toggle').trigger('click');
wrapper.vm.$root.$on('bv::dropdown::shown', () => done());
it('selects only valid items when querystring has valid and invalid IDs', async () => {
const ids = optionIdsAt([2, 4, 6]).concat(['some', 'invalid', 'ids']);
updateRouteQuery(ids);
createWrapper();
expectSelectedItems([2, 4, 6]);
});
it('should keep the menu open after clicking on an item', async () => {
expect(isDropdownOpen()).toBe(true);
wrapper.find(GlDropdownItem).trigger('click');
await wrapper.vm.$nextTick();
it('selects default options if querystring only has invalid items', async () => {
updateRouteQuery(['some', 'invalid', 'ids']);
createWrapper({ defaultOptions: optionsAt([4, 5, 8]) });
expect(isDropdownOpen()).toBe(true);
expectSelectedItems([4, 5, 8]);
});
it('selects All option if querystring only has invalid IDs and there are no default options', async () => {
updateRouteQuery(['some', 'invalid', 'ids']);
createWrapper();
expectAllOptionSelected();
});
});
describe('Project', () => {
describe('when there are lots of projects', () => {
const LOTS = 30;
describe('changing the querystring', () => {
it('selects the correct options', async () => {
createWrapper();
const indexes = [3, 5, 7];
await updateRouteQuery(optionIdsAt(indexes));
beforeEach(() => {
const options = generateOptions(LOTS);
const filter = {
name: 'Project',
id: 'project',
options,
selection: new Set([options[0].id]),
};
expectSelectedItems(indexes);
});
it('select default options when querystring is blank', async () => {
createWrapper({ defaultOptions: optionsAt([2, 5, 8]) });
await clickItemAt(3);
expectSelectedItems([2, 3, 5, 8]);
await updateRouteQuery([]);
expectSelectedItems([2, 5, 8]);
});
it('selects All option when querystring is blank and there are no default options', async () => {
createWrapper();
await clickItemAt(3);
expectSelectedItems([3]);
await updateRouteQuery([]);
expectAllOptionSelected();
});
it('selects All option when querystring has all option ID', async () => {
createWrapper({ defaultOptions: optionsAt([2, 4, 8]) });
expectSelectedItems([2, 4, 8]);
createWrapper({ filter });
await updateRouteQuery([filter.allOption.id]);
expectAllOptionSelected();
});
it('should display a search box', () => {
expect(findSearchBox().exists()).toBe(true);
it('selects All option if querystring has all option ID as well as other IDs', async () => {
createWrapper({ defaultOptions: optionsAt([5, 6, 9]) });
await updateRouteQuery([filter.allOption.id, ...optionIdsAt([1, 2])]);
expectAllOptionSelected();
});
it('selects only valid items when querystring has valid and invalid IDs', async () => {
createWrapper();
const ids = optionIdsAt([3, 7, 9]).concat(['some', 'invalid', 'ids']);
await updateRouteQuery(ids);
expectSelectedItems([3, 7, 9]);
});
it('selects default options if querystring only has invalid IDs', async () => {
createWrapper({ defaultOptions: optionsAt([1, 3, 4]) });
await clickItemAt(8);
expectSelectedItems([1, 3, 4, 8]);
await updateRouteQuery(['some', 'invalid', 'ids']);
expectSelectedItems([1, 3, 4]);
});
it(`should show all projects`, () => {
expect(dropdownItemsCount()).toBe(LOTS);
it('selects All option if querystring only has invalid IDs and there are no default options', async () => {
createWrapper();
await clickItemAt(8);
expectSelectedItems([8]);
await updateRouteQuery(['some', 'invalid', 'ids']);
expectAllOptionSelected();
});
it('should show only matching projects when a search term is entered', async () => {
findSearchBox().vm.$emit('input', '0');
it('does not change querystring for another filter when updating querystring for current filter', async () => {
createWrapper();
const ids = optionIdsAt([1, 2, 3]);
const other = ['6', '7', '8'];
const query = { [filter.id]: ids, other };
router.replace({ query });
window.dispatchEvent(new Event('popstate'));
await wrapper.vm.$nextTick();
expect(dropdownItemsCount()).toBe(3);
expectSelectedItems([1, 2, 3]);
expect(wrapper.vm.$route.query.other).toEqual(other);
});
});
});
......
import Vuex from 'vuex';
import Filters from 'ee/security_dashboard/components/filters.vue';
import createStore from 'ee/security_dashboard/store';
import { mount, createLocalVue } from '@vue/test-utils';
import { shallowMount, createLocalVue } from '@vue/test-utils';
const localVue = createLocalVue();
localVue.use(Vuex);
......@@ -11,7 +11,7 @@ describe('Filter component', () => {
let store;
const createWrapper = (props = {}) => {
wrapper = mount(Filters, {
wrapper = shallowMount(Filters, {
localVue,
store,
propsData: {
......
import VueRouter from 'vue-router';
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 StandardFilter from 'ee/security_dashboard/components/filters/standard_filter.vue';
......@@ -10,7 +9,6 @@ localVue.use(VueRouter);
describe('First class vulnerability filters component', () => {
let wrapper;
let filters;
const projects = [
{ id: 'gid://gitlab/Project/11', name: 'GitLab Org' },
......@@ -18,11 +16,8 @@ describe('First class vulnerability filters component', () => {
];
const findFilters = () => wrapper.findAll(StandardFilter);
const findStateFilter = () => findFilters().at(0);
const findSeverityFilter = () => findFilters().at(1);
const findReportTypeFilter = () => findFilters().at(2);
const findProjectFilter = () => findFilters().at(3);
const findLastFilter = () => findFilters().at(filters.length - 1);
const findStateFilter = () => wrapper.find('[data-testid="state"]');
const findProjectFilter = () => wrapper.find('[data-testid="projectId"]');
const createComponent = ({ propsData, listeners } = {}) => {
return shallowMount(Filters, { localVue, router, propsData, listeners });
......@@ -36,190 +31,39 @@ describe('First class vulnerability filters component', () => {
describe('on render without project filter', () => {
beforeEach(() => {
wrapper = createComponent();
filters = initFirstClassVulnerabilityFilters();
});
it('should render the filters', () => {
expect(findFilters()).toHaveLength(filters.length);
it('should render the default filters', () => {
expect(findFilters()).toHaveLength(3);
});
it('should call the setFilter mutation when setting a filter', () => {
const stub = jest.fn();
it('should emit filterChange when a filter is changed', () => {
const options = { foo: 'bar' };
findStateFilter().vm.$emit('filter-changed', options);
wrapper.setMethods({ setFilter: stub });
findStateFilter().vm.$emit('setFilter', options);
expect(stub).toHaveBeenCalledWith(options);
expect(wrapper.emitted('filterChange')[0][0]).toEqual(options);
});
});
describe('when project filter is populated dynamically', () => {
beforeEach(() => {
filters = initFirstClassVulnerabilityFilters([]);
wrapper = createComponent({ propsData: { projects: [] } });
wrapper = createComponent();
});
it('should render the project filter with one option', () => {
expect(findLastFilter().props('filter')).toEqual({
id: 'projectId',
name: 'Project',
options: [{ id: 'all', name: 'All projects' }],
selection: new Set(['all']),
});
it('should render the project filter with no options', async () => {
wrapper.setProps({ projects: [] });
await wrapper.vm.$nextTick();
expect(findProjectFilter().props('filter').options).toHaveLength(0);
});
it('should set the projects dynamically', () => {
it('should render the project filter with the expected options', async () => {
wrapper.setProps({ projects });
return 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();
});
await wrapper.vm.$nextTick();
it('should not trigger the filterChange additonally when the filters do not change', () => {
router.push({
query: {
...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] },
});
});
expect(findProjectFilter().props('filter').options).toEqual([
{ id: '11', name: projects[0].name },
{ id: '12', name: projects[1].name },
]);
});
});
});
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