Commit 2ba8c796 authored by Daniel Tian's avatar Daniel Tian Committed by Jose Ivan Vargas

Improve vulnerability filter component logic

parent 6b18dd94
...@@ -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 { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui';
import StandardFilter from 'ee/security_dashboard/components/filters/standard_filter.vue'; import StandardFilter from 'ee/security_dashboard/components/filters/standard_filter.vue';
import { mount } from '@vue/test-utils'; import FilterBody from 'ee/security_dashboard/components/filters/filter_body.vue';
import { trimText } from 'helpers/text_helper'; import { createLocalVue, shallowMount } from '@vue/test-utils';
import VueRouter from 'vue-router';
const generateOption = index => ({ const localVue = createLocalVue();
name: `Option ${index}`, localVue.use(VueRouter);
id: `option-${index}`, const router = new VueRouter();
});
const generateOptions = length =>
Array.from({ length }).map((_, i) => ({ name: `Option ${i}`, id: `option-${i}`, index: i }));
const generateOptions = length => { const filter = {
return Array.from({ length }).map((_, i) => generateOption(i)); 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', () => { describe('Standard Filter component', () => {
let wrapper; let wrapper;
const createWrapper = propsData => { const createWrapper = (filterOptions, showSearchBox) => {
wrapper = mount(StandardFilter, { propsData }); wrapper = shallowMount(StandardFilter, {
localVue,
router,
propsData: { filter: { ...filter, ...filterOptions }, showSearchBox },
});
}; };
const findSearchBox = () => wrapper.find(GlSearchBoxByType); const dropdownItems = () => wrapper.findAll('[data-testid="filterOption"]');
const isDropdownOpen = () => wrapper.find(GlDropdown).classes('show'); const dropdownItemAt = index => dropdownItems().at(index);
const dropdownItemsCount = () => wrapper.findAll(GlDropdownItem).length; 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(() => { afterEach(() => {
// Clear out the querystring if one exists. It persists between tests.
if (filterQuery()) {
wrapper.vm.$router.push('/');
}
wrapper.destroy(); wrapper.destroy();
}); });
describe('severity', () => { describe('filter options', () => {
let 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(() => { beforeEach(() => {
options = generateOptions(8); createWrapper({ defaultOptions: optionsAt([1, 2, 3]) });
const filter = { });
name: 'Severity',
id: 'severity', it('de-selects every option and selects the All option when all option is clicked', async () => {
options, const clickAndCheck = async () => {
selection: new Set([options[0].id, options[1].id, options[2].id]), 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', () => { it('selects the All option when last selected option is unselected', async () => {
expect(dropdownItemsCount()).toEqual(8); await [1, 2, 3].forEach(clickItemAt);
expectAllOptionSelected();
}); });
it('should display a check next to only the selected items', () => { it('emits filter-changed event with default options when created', async () => {
expect( const expectedIds = optionIdsAt([1, 2, 3]);
wrapper.findAll(`[data-testid="mobile-issue-close-icon"]:not(.gl-visibility-hidden)`), expect(wrapper.emitted('filter-changed')).toHaveLength(1);
).toHaveLength(3); expect(wrapper.emitted('filter-changed')[0][0]).toEqual({ [filter.id]: expectedIds });
}); });
it('should correctly display the selected text', () => { it('emits filter-changed event when an option is clicked', async () => {
const selectedText = trimText(wrapper.find('.dropdown-toggle').text()); 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', () => { describe('filter querystring', () => {
expect(wrapper.find('[data-testid="name"]').text()).toEqual('Severity'); 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(filterQuery()).toHaveLength(4);
expect(findSearchBox().exists()).toBe(false);
await clickAllOptionItem();
expect(filterQuery()).toEqual([filter.allOption.id]);
});
}); });
it('should not be open', () => { describe('querystring on page load', () => {
expect(isDropdownOpen()).toBe(false); it('selects correct items', () => {
updateRouteQuery(optionIdsAt([1, 3, 5, 7]));
createWrapper();
expectSelectedItems([1, 3, 5, 7]);
}); });
describe('when the dropdown is open', () => { it('selects only valid items when querystring has valid and invalid IDs', async () => {
beforeEach(done => { const ids = optionIdsAt([2, 4, 6]).concat(['some', 'invalid', 'ids']);
wrapper.find('.dropdown-toggle').trigger('click'); updateRouteQuery(ids);
wrapper.vm.$root.$on('bv::dropdown::shown', () => done()); createWrapper();
expectSelectedItems([2, 4, 6]);
}); });
it('should keep the menu open after clicking on an item', async () => { it('selects default options if querystring only has invalid items', async () => {
expect(isDropdownOpen()).toBe(true); updateRouteQuery(['some', 'invalid', 'ids']);
wrapper.find(GlDropdownItem).trigger('click'); createWrapper({ defaultOptions: optionsAt([4, 5, 8]) });
await wrapper.vm.$nextTick();
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('changing the querystring', () => {
describe('when there are lots of projects', () => { it('selects the correct options', async () => {
const LOTS = 30; createWrapper();
const indexes = [3, 5, 7];
await updateRouteQuery(optionIdsAt(indexes));
beforeEach(() => { expectSelectedItems(indexes);
const options = generateOptions(LOTS); });
const filter = {
name: 'Project', it('select default options when querystring is blank', async () => {
id: 'project', createWrapper({ defaultOptions: optionsAt([2, 5, 8]) });
options,
selection: new Set([options[0].id]), 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', () => { it('selects All option if querystring has all option ID as well as other IDs', async () => {
expect(findSearchBox().exists()).toBe(true); 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`, () => { it('selects All option if querystring only has invalid IDs and there are no default options', async () => {
expect(dropdownItemsCount()).toBe(LOTS); 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 () => { it('does not change querystring for another filter when updating querystring for current filter', async () => {
findSearchBox().vm.$emit('input', '0'); 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(); 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 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