diff --git a/ee/app/assets/javascripts/security_dashboard/components/shared/filtered_search/tokens/status_token.vue b/ee/app/assets/javascripts/security_dashboard/components/shared/filtered_search/tokens/status_token.vue new file mode 100644 index 0000000000000000000000000000000000000000..003f51340b4ef4d059aca57c986aa79585c75441 --- /dev/null +++ b/ee/app/assets/javascripts/security_dashboard/components/shared/filtered_search/tokens/status_token.vue @@ -0,0 +1,187 @@ +<script> +import { + GlIcon, + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + GlDropdownDivider, + GlDropdownSectionHeader, +} from '@gitlab/ui'; +import { VULNERABILITY_STATE_OBJECTS } from 'ee/vulnerabilities/constants'; +import { getSelectedOptionsText } from '~/lib/utils/listbox_helpers'; +import { s__, n__ } from '~/locale'; +import { GROUPS } from '../../filters/status_filter.vue'; +import { ALL_ID as ALL_STATUS_VALUE } from '../../filters/constants'; + +const { detected, confirmed } = VULNERABILITY_STATE_OBJECTS; + +const ALL_DISMISSED_VALUE = GROUPS[1].options[0].value; +const DISMISSAL_REASON_VALUES = GROUPS[1].options.slice(1).map(({ value }) => value); +const DEFAULT_VALUES = [detected.searchParamValue, confirmed.searchParamValue]; + +export default { + DEFAULT_VALUES, + GROUPS, + + components: { + GlIcon, + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + GlDropdownDivider, + GlDropdownSectionHeader, + }, + props: { + config: { + type: Object, + required: true, + }, + // contains the token, with the selected operand (e.g.: '=') and the data (comma separated, e.g.: 'MIT, GNU') + value: { + type: Object, + required: true, + }, + active: { + type: Boolean, + required: true, + }, + }, + data() { + return { + searchTerm: '', + selectedStatuses: [...DEFAULT_VALUES], + }; + }, + computed: { + tokenValue() { + return { + ...this.value, + // when the token is active (dropdown is open), we set the value to null to prevent an UX issue + // in which only the last selected item is being displayed. + // more information: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2381 + data: this.active ? null : this.selectedStatuses, + }; + }, + toggleText() { + // "All dismissal reasons" option is selected + if (this.selectedStatuses.length === 1 && this.selectedStatuses[0] === ALL_DISMISSED_VALUE) { + return s__('SecurityReports|Dismissed (all reasons)'); + } + + // Dismissal reason(s) is selected + if (this.selectedStatuses.every((value) => DISMISSAL_REASON_VALUES.includes(value))) { + return n__(`Dismissed (%d reason)`, `Dismissed (%d reasons)`, this.selectedStatuses.length); + } + + return getSelectedOptionsText({ + options: [...GROUPS[0].options, ...GROUPS[1].options], + selected: this.selectedStatuses, + }); + }, + }, + methods: { + setSearchTerm(token) { + // the data can be either a string or an array, in which case we don't want to perform the search + if (typeof token.data === 'string') { + this.searchTerm = token.data.toLowerCase(); + } + }, + toggleSelectedStatus(selectedValue) { + const allStatusSelected = selectedValue === ALL_STATUS_VALUE; + const allDismissedSelected = selectedValue === ALL_DISMISSED_VALUE; + + // Unselect + if (this.selectedStatuses.includes(selectedValue)) { + this.selectedStatuses = this.selectedStatuses.filter((s) => s !== selectedValue); + + if (this.selectedStatuses.length) { + return; + } + } + + // If there is no option selected or the All Statuses option is selected, simply set + // the array to ALL_STATUS_VALUE + if (!this.selectedStatuses.length || allStatusSelected) { + this.selectedStatuses = [ALL_STATUS_VALUE]; + return; + } + + // Remove other dismissal values when All Dismissed option is selected + if (allDismissedSelected) { + this.selectedStatuses = this.selectedStatuses.filter( + (s) => !DISMISSAL_REASON_VALUES.includes(s), + ); + } + // When a dismissal reason is selected, unselect All Dismissed option + else if (DISMISSAL_REASON_VALUES.includes(selectedValue)) { + this.selectedStatuses = this.selectedStatuses.filter((s) => s !== ALL_DISMISSED_VALUE); + } + + // Otherwise select the item. Make sure to unselect ALL_STATUS_VALUE anyways because + this.selectedStatuses = this.selectedStatuses.filter((s) => s !== ALL_STATUS_VALUE); + this.selectedStatuses.push(selectedValue); + }, + isStatusSelected(name) { + return Boolean(this.selectedStatuses.find((s) => name === s)); + }, + }, + groups: { + statusOptions: GROUPS[0].options, + dismissalReasonOptions: GROUPS[1].options, + }, + i18n: { + statusLabel: s__('SecurityReports|Status'), + dismissedAsLabel: GROUPS[1].text, + }, +}; +</script> + +<template> + <gl-filtered-search-token + :config="config" + v-bind="{ ...$props, ...$attrs }" + :multi-select-values="selectedStatuses" + :value="tokenValue" + v-on="$listeners" + @select="toggleSelectedStatus" + @input="setSearchTerm" + > + <template #view> + {{ toggleText }} + </template> + <template #suggestions> + <gl-dropdown-section-header>{{ $options.i18n.statusLabel }}</gl-dropdown-section-header> + <gl-dropdown-divider /> + <gl-filtered-search-suggestion + v-for="status in $options.groups.statusOptions" + :key="status.value" + :value="status.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': !isStatusSelected(status.value) }" + :data-testid="`status-icon-${status.value}`" + /> + {{ status.text }} + </div> + </gl-filtered-search-suggestion> + <gl-dropdown-divider /> + <gl-dropdown-section-header>{{ $options.i18n.dismissedAsLabel }}</gl-dropdown-section-header> + <gl-filtered-search-suggestion + v-for="status in $options.groups.dismissalReasonOptions" + :key="status.value" + :value="status.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': !isStatusSelected(status.value) }" + :data-testid="`status-icon-${status.value}`" + /> + {{ status.text }} + </div> + </gl-filtered-search-suggestion> + </template> + </gl-filtered-search-token> +</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 6e29a27469d4f8553e3969a4522cc623b35e84f9..b4b41ccf9f0c5a95a37fe09e17c6494990cf9a7f 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 @@ -1,5 +1,7 @@ <script> import { GlFilteredSearch } from '@gitlab/ui'; +import { OPERATORS_IS, OPERATOR_IS } from '~/vue_shared/components/filtered_search_bar/constants'; +import StatusToken from './tokens/status_token.vue'; export default { components: { @@ -7,13 +9,29 @@ export default { }, data() { return { - value: [], - currentFilterParams: null, + value: [ + { + type: 'status', + value: { + data: StatusToken.DEFAULT_VALUES, + operator: OPERATOR_IS, + }, + }, + ], }; }, computed: { tokens() { - return []; + return [ + { + type: 'status', + title: StatusToken.i18n.statusLabel, + multiSelect: true, + unique: true, + token: StatusToken, + operators: OPERATORS_IS, + }, + ]; }, }, }; @@ -23,6 +41,7 @@ export default { <gl-filtered-search :placeholder="s__('Vulnerability|Search or filter vulnerabilities...')" :available-tokens="tokens" + :value="value" terms-as-tokens /> </template> diff --git a/ee/spec/frontend/security_dashboard/components/shared/filtered_search/tokens/status_token_spec.js b/ee/spec/frontend/security_dashboard/components/shared/filtered_search/tokens/status_token_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..50ce45b39d1a3266ee2c82ce92093fee8f12c3f2 --- /dev/null +++ b/ee/spec/frontend/security_dashboard/components/shared/filtered_search/tokens/status_token_spec.js @@ -0,0 +1,186 @@ +import { GlFilteredSearchToken } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import StatusToken from 'ee/security_dashboard/components/shared/filtered_search/tokens/status_token.vue'; +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'; + +describe('Status Token component', () => { + let wrapper; + + const mockConfig = { + multiSelect: true, + unique: true, + operators: OPERATORS_IS, + }; + + const createWrapper = ({ + value = { data: StatusToken.DEFAULT_VALUES, operator: '=' }, + active = false, + stubs, + mountFn = shallowMountExtended, + } = {}) => { + wrapper = mountFn(StatusToken, { + propsData: { + config: mockConfig, + value, + active, + }, + provide: { + portalName: 'fake target', + alignSuggestions: jest.fn(), + termsAsTokens: () => false, + }, + + stubs, + }); + }; + + const findFilteredSearchToken = () => wrapper.findComponent(GlFilteredSearchToken); + const findCheckedIcon = (value) => wrapper.findByTestId(`status-icon-${value}`); + + const clickDropdownItem = async (...ids) => { + await Promise.all( + ids.map((id) => { + findFilteredSearchToken().vm.$emit('select', id); + return nextTick(); + }), + ); + + await nextTick(); + }; + + const allOptionsExcept = (value) => { + return StatusToken.GROUPS.flatMap((i) => i.options) + .map((i) => i.value) + .filter((i) => i !== value); + }; + + describe('default view', () => { + const findSlotView = () => wrapper.findByTestId('slot-view'); + const findSlotSuggestions = () => wrapper.findByTestId('slot-suggestions'); + + beforeEach(() => { + createWrapper({ + stubs: { + GlFilteredSearchToken: stubComponent(GlFilteredSearchToken, { + template: ` + <div> + <div data-testid="slot-view"> + <slot name="view"></slot> + </div> + <div data-testid="slot-suggestions"> + <slot name="suggestions"></slot> + </div> + </div>`, + }), + }, + }); + }); + + it('shows the label', () => { + expect(findSlotView().text()).toBe('Needs triage +1 more'); + }); + + it('shows the dropdown with correct options', () => { + expect( + findSlotSuggestions() + .text() + .split('\n') + .map((s) => s.trim()) + .filter((i) => i), + ).toEqual([ + 'Status', // subheader + 'All statuses', + 'Needs triage', + 'Confirmed', + 'Resolved', + 'Dismissed as...', // subheader + 'All dismissal reasons', + 'Acceptable risk', + 'False positive', + 'Mitigating control', + 'Used in tests', + 'Not applicable', + ]); + }); + }); + + describe('item selection', () => { + beforeEach(async () => { + createWrapper({}); + await clickDropdownItem('ALL'); + }); + + it('toggles the item selection when clicked on', async () => { + const isOptionChecked = (v) => !findCheckedIcon(v).classes('gl-visibility-hidden'); + + await clickDropdownItem('CONFIRMED', 'RESOLVED'); + + expect(isOptionChecked('ALL')).toBe(false); + expect(isOptionChecked('CONFIRMED')).toBe(true); + expect(isOptionChecked('RESOLVED')).toBe(true); + + // Add a dismissal reason + await clickDropdownItem('ACCEPTABLE_RISK'); + + expect(isOptionChecked('CONFIRMED')).toBe(true); + expect(isOptionChecked('RESOLVED')).toBe(true); + expect(isOptionChecked('ACCEPTABLE_RISK')).toBe(true); + expect(isOptionChecked('DISMISSED')).toBe(false); + + // Select all + await clickDropdownItem('ALL'); + + allOptionsExcept('ALL').forEach((value) => { + expect(isOptionChecked(value)).toBe(false); + }); + + // Select All Dismissed Values + await clickDropdownItem('DISMISSED'); + + allOptionsExcept('DISMISSED').forEach((value) => { + expect(isOptionChecked(value)).toBe(false); + }); + + // Selecting another dismissed should unselect All Dismissed values + await clickDropdownItem('USED_IN_TESTS'); + + expect(isOptionChecked('USED_IN_TESTS')).toBe(true); + expect(isOptionChecked('DISMISSED')).toBe(false); + }); + }); + + describe('toggle text', () => { + const findSlotView = () => wrapper.findAllByTestId('filtered-search-token-segment').at(2); + + beforeEach(async () => { + createWrapper({ value: {}, mountFn: mountExtended }); + + // Let's set initial state as ALL. It's easier to manipulate because + // selecting a new value should unselect this value automatically and + // we can start from an empty state. + await clickDropdownItem('ALL'); + }); + + it('shows "Dismissed (all reasons)" when only "All dismissal reasons" option is selected', async () => { + await clickDropdownItem('DISMISSED'); + expect(findSlotView().text()).toBe('Dismissed (all reasons)'); + }); + + it('shows "Dismissed (2 reasons)" when only 2 dismissal reasons are selected', async () => { + await clickDropdownItem('FALSE_POSITIVE', 'ACCEPTABLE_RISK'); + expect(findSlotView().text()).toBe('Dismissed (2 reasons)'); + }); + + it('shows "Confirmed +1 more" when confirmed and a dismissal reason are selected', async () => { + await clickDropdownItem('CONFIRMED', 'FALSE_POSITIVE'); + expect(findSlotView().text()).toBe('Confirmed +1 more'); + }); + + it('shows "Confirmed +1 more" when confirmed and all dismissal reasons are selected', async () => { + await clickDropdownItem('CONFIRMED', 'DISMISSED'); + expect(findSlotView().text()).toBe('Confirmed +1 more'); + }); + }); +}); 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 new file mode 100644 index 0000000000000000000000000000000000000000..6c571ae5a640219844b3fedf77d8b16c4bfe6c30 --- /dev/null +++ b/ee/spec/frontend/security_dashboard/components/shared/filtered_search/vulnerability_report_filtered_search_spec.js @@ -0,0 +1,46 @@ +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'; +import StatusToken from 'ee/security_dashboard/components/shared/filtered_search/tokens/status_token.vue'; +import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants'; + +describe('Vulnerability Report Filtered Search component', () => { + let wrapper; + + const findFilteredSearchComponent = () => wrapper.findComponent(GlFilteredSearch); + + const createWrapper = () => { + wrapper = mountExtended(FilteredSearch); + }; + + beforeEach(() => { + createWrapper(); + }); + + it('should mount the component with the correct config', () => { + const filteredSearch = findFilteredSearchComponent(); + + expect(filteredSearch.props('placeholder')).toEqual('Search or filter vulnerabilities...'); + + expect(filteredSearch.props('value')).toEqual([ + { + type: 'status', + value: { + data: StatusToken.DEFAULT_VALUES, + operator: '=', + }, + }, + ]); + + expect(filteredSearch.props('availableTokens')).toEqual([ + { + type: 'status', + title: 'Status', + multiSelect: true, + unique: true, + token: StatusToken, + operators: OPERATORS_IS, + }, + ]); + }); +});