From dbebd6513ebf12616e9bb8504d107fe7799f2a27 Mon Sep 17 00:00:00 2001 From: Savas Vedova <svedova@gitlab.com> Date: Tue, 2 Apr 2024 08:58:32 +0000 Subject: [PATCH] Add filter logic to the severity token This commit adds support for updating the vulnerability report based on the selected filter. --- .../filtered_search/tokens/severity_token.vue | 94 ++++++++++----- .../vulnerability_report_filtered_search.vue | 30 +++-- .../tokens/severity_token_spec.js | 69 ++++++++++- ...lnerability_report_filtered_search_spec.js | 113 ++++++++++++------ 4 files changed, 227 insertions(+), 79 deletions(-) diff --git a/ee/app/assets/javascripts/security_dashboard/components/shared/filtered_search/tokens/severity_token.vue b/ee/app/assets/javascripts/security_dashboard/components/shared/filtered_search/tokens/severity_token.vue index 8f0dc73794218..3e0c64b6c0716 100644 --- a/ee/app/assets/javascripts/security_dashboard/components/shared/filtered_search/tokens/severity_token.vue +++ b/ee/app/assets/javascripts/security_dashboard/components/shared/filtered_search/tokens/severity_token.vue @@ -3,13 +3,20 @@ import { GlIcon, GlFilteredSearchToken, GlFilteredSearchSuggestion } from '@gitl import { SEVERITY_LEVELS } from 'ee/security_dashboard/store/constants'; import { getSelectedOptionsText } from '~/lib/utils/listbox_helpers'; import { s__ } from '~/locale'; +import QuerystringSync from '../../filters/querystring_sync.vue'; import { ALL_ID as ALL_SEVERITIES_VALUE } from '../../filters/constants'; +import eventHub from '../event_hub'; + +const VALID_IDS = Object.entries(SEVERITY_LEVELS).map(([id]) => id.toUpperCase()); export default { + VALID_IDS, + components: { GlIcon, GlFilteredSearchToken, GlFilteredSearchSuggestion, + QuerystringSync, }, props: { config: { @@ -28,7 +35,7 @@ export default { }, data() { return { - selectedSeverities: [ALL_SEVERITIES_VALUE], + selectedSeverities: this.value.data || [ALL_SEVERITIES_VALUE], }; }, computed: { @@ -49,6 +56,26 @@ export default { }, }, methods: { + emitFiltersChanged() { + eventHub.$emit('filters-changed', { + severity: this.selectedSeverities.filter((value) => value !== ALL_SEVERITIES_VALUE), + }); + }, + resetSelected() { + this.selectedSeverities = [ALL_SEVERITIES_VALUE]; + this.emitFiltersChanged(); + }, + updateSelectedFromQS(values) { + // This happens when we clear the token and re-select `Severity` + // to open the dropdown. At that stage we simply want to wait + // for the user to select new severities. + if (!values.length) { + return; + } + + this.selectedSeverities = values; + this.emitFiltersChanged(); + }, toggleSelected(selectedValue) { const allSeveritiesSelected = selectedValue === ALL_SEVERITIES_VALUE; @@ -84,33 +111,42 @@ export default { </script> <template> - <gl-filtered-search-token - :config="config" - v-bind="{ ...$props, ...$attrs }" - :multi-select-values="selectedSeverities" - :value="tokenValue" - v-on="$listeners" - @select="toggleSelected" + <querystring-sync + querystring-key="severity" + :value="selectedSeverities" + :valid-values="$options.VALID_IDS" + @input="updateSelectedFromQS" > - <template #view> - {{ toggleText }} - </template> - <template #suggestions> - <gl-filtered-search-suggestion - v-for="severity in $options.items" - :key="severity.value" - :value="severity.value" - > - <div class="gl-display-flex gl-align-items-center"> - <gl-icon - name="check" - class="gl-mr-3 gl-flex-shrink-0 gl-text-gray-700" - :class="{ 'gl-visibility-hidden': !isSeveritySelected(severity.value) }" - :data-testid="`severity-icon-${severity.value}`" - /> - {{ severity.text }} - </div> - </gl-filtered-search-suggestion> - </template> - </gl-filtered-search-token> + <gl-filtered-search-token + :config="config" + v-bind="{ ...$props, ...$attrs }" + :multi-select-values="selectedSeverities" + :value="tokenValue" + v-on="$listeners" + @select="toggleSelected" + @destroy="resetSelected" + @complete="emitFiltersChanged" + > + <template #view> + {{ toggleText }} + </template> + <template #suggestions> + <gl-filtered-search-suggestion + v-for="severity in $options.items" + :key="severity.value" + :value="severity.value" + > + <div class="gl-display-flex gl-align-items-center"> + <gl-icon + name="check" + class="gl-mr-3 gl-flex-shrink-0 gl-text-gray-700" + :class="{ 'gl-visibility-hidden': !isSeveritySelected(severity.value) }" + :data-testid="`severity-icon-${severity.value}`" + /> + {{ severity.text }} + </div> + </gl-filtered-search-suggestion> + </template> + </gl-filtered-search-token> + </querystring-sync> </template> diff --git a/ee/app/assets/javascripts/security_dashboard/components/shared/filtered_search/vulnerability_report_filtered_search.vue b/ee/app/assets/javascripts/security_dashboard/components/shared/filtered_search/vulnerability_report_filtered_search.vue index 190ce6a8c836f..d0121a5db724d 100644 --- a/ee/app/assets/javascripts/security_dashboard/components/shared/filtered_search/vulnerability_report_filtered_search.vue +++ b/ee/app/assets/javascripts/security_dashboard/components/shared/filtered_search/vulnerability_report_filtered_search.vue @@ -10,16 +10,28 @@ export default { GlFilteredSearch, }, data() { - return { - value: [ - { - type: 'state', - value: { - data: StatusToken.DEFAULT_VALUES, - operator: OPERATOR_OR, - }, + const value = [ + { + type: 'state', + value: { + data: StatusToken.DEFAULT_VALUES, + operator: OPERATOR_OR, }, - ], + }, + ]; + + if (this.$route.query.severity) { + value.push({ + type: 'severity', + value: { + data: this.$route.query.severity.split(','), + operator: OPERATOR_OR, + }, + }); + } + + return { + value, }; }, computed: { diff --git a/ee/spec/frontend/security_dashboard/components/shared/filtered_search/tokens/severity_token_spec.js b/ee/spec/frontend/security_dashboard/components/shared/filtered_search/tokens/severity_token_spec.js index 88fa4a05f77c3..bd0e4e3e698f9 100644 --- a/ee/spec/frontend/security_dashboard/components/shared/filtered_search/tokens/severity_token_spec.js +++ b/ee/spec/frontend/security_dashboard/components/shared/filtered_search/tokens/severity_token_spec.js @@ -1,12 +1,18 @@ import { GlFilteredSearchToken } from '@gitlab/ui'; -import { nextTick } from 'vue'; +import Vue, { nextTick } from 'vue'; +import VueRouter from 'vue-router'; import SeverityToken from 'ee/security_dashboard/components/shared/filtered_search/tokens/severity_token.vue'; +import QuerystringSync from 'ee/security_dashboard/components/shared/filters/querystring_sync.vue'; +import eventHub from 'ee/security_dashboard/components/shared/filtered_search/event_hub'; import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants'; import { stubComponent } from 'helpers/stub_component'; import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; +Vue.use(VueRouter); + describe('Severity Token component', () => { let wrapper; + let router; const mockConfig = { multiSelect: true, @@ -20,7 +26,10 @@ describe('Severity Token component', () => { stubs, mountFn = shallowMountExtended, } = {}) => { + router = new VueRouter({ mode: 'history' }); + wrapper = mountFn(SeverityToken, { + router, propsData: { config: mockConfig, value, @@ -36,8 +45,10 @@ describe('Severity Token component', () => { }); }; + const findQuerystringSync = () => wrapper.findComponent(QuerystringSync); const findFilteredSearchToken = () => wrapper.findComponent(GlFilteredSearchToken); const findCheckedIcon = (value) => wrapper.findByTestId(`severity-icon-${value}`); + const isOptionChecked = (v) => !findCheckedIcon(v).classes('gl-visibility-hidden'); const clickDropdownItem = async (...ids) => { await Promise.all( @@ -47,11 +58,14 @@ describe('Severity Token component', () => { }), ); + findFilteredSearchToken().vm.$emit('complete'); await nextTick(); }; const allOptionsExcept = (value) => { - return SeverityToken.items.map((i) => i.value).filter((i) => i !== value); + const exempt = Array.isArray(value) ? value : [value]; + + return SeverityToken.items.map((i) => i.value).filter((i) => !exempt.includes(i)); }; describe('default view', () => { @@ -98,8 +112,6 @@ describe('Severity Token component', () => { }); it('toggles the item selection when clicked on', async () => { - const isOptionChecked = (v) => !findCheckedIcon(v).classes('gl-visibility-hidden'); - await clickDropdownItem('CRITICAL', 'HIGH'); expect(isOptionChecked('ALL')).toBe(false); @@ -129,6 +141,18 @@ describe('Severity Token component', () => { expect(isOptionChecked(value)).toBe(false); }); }); + + it('emits filters-changed event when a filter is selected', async () => { + const spy = jest.fn(); + eventHub.$on('filters-changed', spy); + + // Select 2 states + await clickDropdownItem('MEDIUM', 'HIGH'); + + expect(spy).toHaveBeenCalledWith({ + severity: ['MEDIUM', 'HIGH'], + }); + }); }); describe('toggle text', () => { @@ -158,4 +182,41 @@ describe('Severity Token component', () => { expect(findSlotView().text()).toBe('Low'); }); }); + + describe('QuerystringSync component', () => { + beforeEach(() => { + createWrapper({}); + }); + + it('has expected props', () => { + expect(findQuerystringSync().props()).toMatchObject({ + querystringKey: 'severity', + value: ['ALL'], + validValues: SeverityToken.VALID_IDS, + }); + }); + + it('receives ALL_STATUS_VALUE when All Statuses option is clicked', async () => { + await clickDropdownItem('ALL'); + + expect(findQuerystringSync().props('value')).toEqual(['ALL']); + }); + + it.each` + emitted | expected + ${['CRITICAL', 'MEDIUM']} | ${['CRITICAL', 'MEDIUM']} + ${['ALL']} | ${['ALL']} + `('restores selected items from the query string - $emitted', async ({ emitted, expected }) => { + findQuerystringSync().vm.$emit('input', emitted); + await nextTick(); + + expected.forEach((item) => { + expect(isOptionChecked(item)).toBe(true); + }); + + allOptionsExcept(expected).forEach((item) => { + expect(isOptionChecked(item)).toBe(false); + }); + }); + }); }); diff --git a/ee/spec/frontend/security_dashboard/components/shared/filtered_search/vulnerability_report_filtered_search_spec.js b/ee/spec/frontend/security_dashboard/components/shared/filtered_search/vulnerability_report_filtered_search_spec.js index 2d725ad9a0fb3..0fe50508f3a9b 100644 --- a/ee/spec/frontend/security_dashboard/components/shared/filtered_search/vulnerability_report_filtered_search_spec.js +++ b/ee/spec/frontend/security_dashboard/components/shared/filtered_search/vulnerability_report_filtered_search_spec.js @@ -1,3 +1,5 @@ +import Vue from 'vue'; +import VueRouter from 'vue-router'; import { GlFilteredSearch } from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import FilteredSearch from 'ee/security_dashboard/components/shared/filtered_search/vulnerability_report_filtered_search.vue'; @@ -6,61 +8,98 @@ import SeverityToken from 'ee/security_dashboard/components/shared/filtered_sear import eventHub from 'ee/security_dashboard/components/shared/filtered_search/event_hub'; import { OPERATORS_OR } from '~/vue_shared/components/filtered_search_bar/constants'; +Vue.use(VueRouter); + describe('Vulnerability Report Filtered Search component', () => { let wrapper; + let router; const findFilteredSearchComponent = () => wrapper.findComponent(GlFilteredSearch); - const createWrapper = () => { + const createWrapper = ({ query } = {}) => { + router = new VueRouter({ mode: 'history' }); + + if (query) { + router.push({ query }); + } + wrapper = mountExtended(FilteredSearch, { + router, stubs: { QuerystringSync: true, }, }); }; - beforeEach(() => { - createWrapper(); - }); + describe('with empty query parameters', () => { + beforeEach(() => { + createWrapper(); + }); - it('should mount the component with the correct config', () => { - const filteredSearch = findFilteredSearchComponent(); + it('should mount the component with the correct config', () => { + const filteredSearch = findFilteredSearchComponent(); - expect(filteredSearch.props('placeholder')).toEqual('Search or filter vulnerabilities...'); + expect(filteredSearch.props('placeholder')).toEqual('Search or filter vulnerabilities...'); - expect(filteredSearch.props('value')).toEqual([ - { - type: 'state', - value: { - data: StatusToken.DEFAULT_VALUES, - operator: '||', + expect(filteredSearch.props('value')).toEqual([ + { + type: 'state', + value: { + data: StatusToken.DEFAULT_VALUES, + operator: '||', + }, }, - }, - ]); + ]); - expect(filteredSearch.props('availableTokens')).toEqual([ - { - type: 'state', - title: 'Status', - multiSelect: true, - unique: true, - token: StatusToken, - operators: OPERATORS_OR, - }, - { - type: 'severity', - title: 'Severity', - multiSelect: true, - unique: true, - token: SeverityToken, - operators: OPERATORS_OR, - }, - ]); + expect(filteredSearch.props('availableTokens')).toEqual([ + { + type: 'state', + title: 'Status', + multiSelect: true, + unique: true, + token: StatusToken, + operators: OPERATORS_OR, + }, + { + type: 'severity', + title: 'Severity', + multiSelect: true, + unique: true, + token: SeverityToken, + operators: OPERATORS_OR, + }, + ]); + }); + + it('should propagate when event hub emits a `filters-changed` event', () => { + const eventObj = { state: ['DISMISSED'] }; + eventHub.$emit('filters-changed', eventObj); + expect(wrapper.emitted('filters-changed')).toEqual([[eventObj]]); + }); }); - it('should propagate when event hub emits a `filters-changed` event', () => { - const eventObj = { state: ['DISMISSED'] }; - eventHub.$emit('filters-changed', eventObj); - expect(wrapper.emitted('filters-changed')).toEqual([[eventObj]]); + describe('with non-empty query parameters', () => { + beforeEach(() => { + createWrapper({ query: { severity: 'MEDIUM,LOW' } }); + }); + + it('should pass route parameters to the severity token', () => { + expect(findFilteredSearchComponent().props('value')).toEqual([ + { + type: 'state', + value: { + data: StatusToken.DEFAULT_VALUES, + operator: '||', + }, + }, + { + type: 'severity', + value: { + data: ['MEDIUM', 'LOW'], + operator: '||', + }, + }, + ]); + }); }); }); -- GitLab