diff --git a/ee/app/assets/javascripts/security_dashboard/components/shared/filters/severity_filter.vue b/ee/app/assets/javascripts/security_dashboard/components/shared/filters/severity_filter.vue index 2b00eae874d5bc91af07cbb8cac63f02398dad7c..f9a72e9923833a22833f3c4eaab5c7595e4ec7e4 100644 --- a/ee/app/assets/javascripts/security_dashboard/components/shared/filters/severity_filter.vue +++ b/ee/app/assets/javascripts/security_dashboard/components/shared/filters/severity_filter.vue @@ -1,79 +1,80 @@ <script> -import { GlDropdown } from '@gitlab/ui'; -import { xor } from 'lodash'; +import { GlCollapsibleListbox } from '@gitlab/ui'; import { s__ } from '~/locale'; import { SEVERITY_LEVELS } from 'ee/security_dashboard/store/constants'; -import DropdownButtonText from './dropdown_button_text.vue'; +import { getSelectedOptionsText } from 'ee/security_dashboard/components/shared/filters/utils'; import QuerystringSync from './querystring_sync.vue'; -import FilterItem from './filter_item.vue'; import { ALL_ID } from './constants'; // For backwards compatibility with existing bookmarks, the ID needs to be capitalized. -export const DROPDOWN_OPTIONS = Object.entries(SEVERITY_LEVELS).map(([id, text]) => ({ - id: id.toUpperCase(), +export const SEVERITY_LEVEL_ITEMS = Object.entries(SEVERITY_LEVELS).map(([id, text]) => ({ + value: id.toUpperCase(), text, })); -const VALID_IDS = DROPDOWN_OPTIONS.map(({ id }) => id); + +export const FILTER_ITEMS = [ + { + value: ALL_ID, + text: s__('SecurityReports|All severities'), + }, + ...SEVERITY_LEVEL_ITEMS, +]; + +const VALID_IDS = SEVERITY_LEVEL_ITEMS.map(({ value }) => value); export default { - components: { GlDropdown, DropdownButtonText, QuerystringSync, FilterItem }, + components: { GlCollapsibleListbox, QuerystringSync }, data: () => ({ - selected: [], + selectedIds: [ALL_ID], }), computed: { - selectedItemTexts() { - const options = DROPDOWN_OPTIONS.filter(({ id }) => this.selected.includes(id)); - // Return the text for selected items, or all items if nothing is selected. - return options.length ? options.map(({ text }) => text) : [this.$options.i18n.allItemsText]; + toggleText() { + return getSelectedOptionsText(FILTER_ITEMS, this.selectedIds); }, }, watch: { - selected() { - this.$emit('filter-changed', { severity: this.selected }); + selectedIds: { + handler() { + this.$emit('filter-changed', { + severity: this.selectedIds.filter((value) => value !== ALL_ID), + }); + }, }, }, methods: { - deselectAll() { - this.selected = []; - }, - toggleSelected(id) { - this.selected = xor(this.selected, [id]); - }, - setSelected(ids) { - this.selected = ids.filter((id) => VALID_IDS.includes(id)); + updateSelected(selected) { + if (!selected.length || selected.at(-1) === ALL_ID) { + this.selectedIds = [ALL_ID]; + } else { + this.selectedIds = selected.filter((value) => value !== ALL_ID); + } }, }, i18n: { label: s__('SecurityReports|Severity'), - allItemsText: s__('SecurityReports|All severities'), }, - DROPDOWN_OPTIONS, - ALL_ID, + FILTER_ITEMS, + VALID_IDS, }; </script> <template> <div> - <querystring-sync querystring-key="severity" :value="selected" @input="setSelected" /> + <querystring-sync + :value="selectedIds" + querystring-key="severity" + :valid-values="$options.VALID_IDS" + @input="updateSelected" + /> <label class="gl-mb-2">{{ $options.i18n.label }}</label> - <gl-dropdown :header-text="$options.i18n.label" block toggle-class="gl-mb-0"> - <template #button-text> - <dropdown-button-text :items="selectedItemTexts" :name="$options.i18n.label" /> - </template> - <filter-item - :is-checked="!selected.length" - :text="$options.i18n.allItemsText" - :data-testid="$options.ALL_ID" - @click="deselectAll" - /> - <filter-item - v-for="{ id, text } in $options.DROPDOWN_OPTIONS" - :key="id" - :data-testid="id" - :is-checked="selected.includes(id)" - :text="text" - @click="toggleSelected(id)" - /> - </gl-dropdown> + <gl-collapsible-listbox + :header-text="$options.i18n.label" + :items="$options.FILTER_ITEMS" + :selected="selectedIds" + :toggle-text="toggleText" + multiple + block + @select="updateSelected" + /> </div> </template> diff --git a/ee/spec/frontend/security_dashboard/components/pipeline/filters_spec.js b/ee/spec/frontend/security_dashboard/components/pipeline/filters_spec.js index 797ec56b94e91310161250dd36f83fba95780091..ddb9af2d7ebe5b87f15b9a9dd5a8c033470cb2e3 100644 --- a/ee/spec/frontend/security_dashboard/components/pipeline/filters_spec.js +++ b/ee/spec/frontend/security_dashboard/components/pipeline/filters_spec.js @@ -69,15 +69,16 @@ describe('Filter component', () => { createWrapper({ mountFn: mount }); await nextTick(); + // The other filters will trigger the mock as well, so we'll clear it before clicking on a // scanner filter item. mock.mockClear(); const filterId = 'severity'; const optionId = 'MEDIUM'; - const option = wrapper.findByTestId(optionId); - option.vm.$emit('click'); - await nextTick(); + const option = wrapper.findByTestId(`listbox-item-${optionId}`); + + await option.trigger('click'); expect(mock).toHaveBeenCalledTimes(1); expect(mock).toHaveBeenCalledWith(expect.any(Object), { [filterId]: [optionId] }); diff --git a/ee/spec/frontend/security_dashboard/components/shared/filters/severity_filter_spec.js b/ee/spec/frontend/security_dashboard/components/shared/filters/severity_filter_spec.js index 648ee7d310619372d8f3b41d8d3a477ef4789b2c..a477e8dcee8b08ff1dbbcd63d882dfbe540a8fdb 100644 --- a/ee/spec/frontend/security_dashboard/components/shared/filters/severity_filter_spec.js +++ b/ee/spec/frontend/security_dashboard/components/shared/filters/severity_filter_spec.js @@ -1,15 +1,13 @@ -import { GlDropdown } from '@gitlab/ui'; -import { nextTick } from 'vue'; +import { GlCollapsibleListbox } from '@gitlab/ui'; import SeverityFilter, { - DROPDOWN_OPTIONS, + SEVERITY_LEVEL_ITEMS, + FILTER_ITEMS, } from 'ee/security_dashboard/components/shared/filters/severity_filter.vue'; import { ALL_ID } from 'ee/security_dashboard/components/shared/filters/constants'; import QuerystringSync from 'ee/security_dashboard/components/shared/filters/querystring_sync.vue'; -import DropdownButtonText from 'ee/security_dashboard/components/shared/filters/dropdown_button_text.vue'; -import FilterItem from 'ee/security_dashboard/components/shared/filters/filter_item.vue'; import { mountExtended } from 'helpers/vue_test_utils_helper'; -const OPTION_IDS = DROPDOWN_OPTIONS.map(({ id }) => id); +const OPTION_IDS = SEVERITY_LEVEL_ITEMS.map(({ value }) => value); describe('Severity Filter component', () => { let wrapper; @@ -21,20 +19,13 @@ describe('Severity Filter component', () => { }; const findQuerystringSync = () => wrapper.findComponent(QuerystringSync); - const findDropdownItems = () => wrapper.findAllComponents(FilterItem); - const findDropdownItem = (id) => wrapper.findByTestId(id); + const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); + const findListboxItem = (id) => wrapper.findByTestId(`listbox-item-${id}`); - const clickDropdownItem = async (id) => { - findDropdownItem(id).vm.$emit('click'); - await nextTick(); - }; + const clickListboxItem = (id) => findListboxItem(id).trigger('click'); const expectSelectedItems = (ids) => { - const checkedItems = findDropdownItems() - .wrappers.filter((item) => item.props('isChecked')) - .map((item) => item.attributes('data-testid')); - - expect(checkedItems).toEqual(ids); + expect(findListbox().props('selected')).toEqual(ids); }; beforeEach(() => { @@ -45,25 +36,23 @@ describe('Severity Filter component', () => { it('has expected props', () => { expect(findQuerystringSync().props()).toMatchObject({ querystringKey: 'severity', - value: [], + value: [ALL_ID], + validValues: OPTION_IDS, }); }); - it('receives empty array when All Statuses option is clicked', async () => { - await clickDropdownItem(ALL_ID); + it('receives "ALL_ID" when All Statuses option is clicked', async () => { + await clickListboxItem(ALL_ID); - expect(findQuerystringSync().props('value')).toEqual([]); + expect(findQuerystringSync().props('value')).toEqual([ALL_ID]); }); it.each` - emitted | expected - ${['HIGH', 'MEDIUM']} | ${['HIGH', 'MEDIUM']} - ${['INVALID', 'LOW', 'UNKNOWN']} | ${['LOW', 'UNKNOWN']} - ${['INVALID']} | ${[ALL_ID]} - ${[]} | ${[ALL_ID]} + emitted | expected + ${['HIGH', 'MEDIUM']} | ${['HIGH', 'MEDIUM']} + ${[]} | ${[ALL_ID]} `('restores selected items - $emitted', async ({ emitted, expected }) => { - findQuerystringSync().vm.$emit('input', emitted); - await nextTick(); + await findQuerystringSync().vm.$emit('input', emitted); expectSelectedItems(expected); }); @@ -74,49 +63,25 @@ describe('Severity Filter component', () => { expect(wrapper.find('label').text()).toBe(SeverityFilter.i18n.label); }); - it('shows the dropdown with correct header text', () => { - expect(wrapper.findComponent(GlDropdown).props('headerText')).toBe(SeverityFilter.i18n.label); - }); - - it('shows the DropdownButtonText component with the correct props', () => { - expect(wrapper.findComponent(DropdownButtonText).props()).toMatchObject({ - items: [SeverityFilter.i18n.allItemsText], - name: SeverityFilter.i18n.label, + it('shows the ListBox component with the correct props', () => { + expect(findListbox().props()).toMatchObject({ + items: FILTER_ITEMS, + toggleText: 'All severities', + multiple: true, + block: true, }); }); }); describe('dropdown items', () => { - it('shows all dropdown items with correct text', () => { - expect(findDropdownItems()).toHaveLength(DROPDOWN_OPTIONS.length + 1); - - expect(findDropdownItem(ALL_ID).text()).toBe(SeverityFilter.i18n.allItemsText); - DROPDOWN_OPTIONS.forEach(({ id, text }) => { - expect(findDropdownItem(id).text()).toBe(text); - }); - }); + it.each(OPTION_IDS)('toggles the item selection when clicked on %s', async (id) => { + await clickListboxItem(id); - it('allows multiple items to be selected', async () => { - const ids = []; + expectSelectedItems([id]); - for await (const id of OPTION_IDS) { - await clickDropdownItem(id); - ids.push(id); + await clickListboxItem(id); - expectSelectedItems(ids); - } - }); - - it('toggles the item selection when clicked on', async () => { - for await (const id of OPTION_IDS) { - await clickDropdownItem(id); - - expectSelectedItems([id]); - - await clickDropdownItem(id); - - expectSelectedItems([ALL_ID]); - } + expectSelectedItems([ALL_ID]); }); it('selects ALL item when created', () => { @@ -124,15 +89,15 @@ describe('Severity Filter component', () => { }); it('selects ALL item and deselects everything else when it is clicked', async () => { - await clickDropdownItem(OPTION_IDS[0]); - await clickDropdownItem(ALL_ID); - await clickDropdownItem(ALL_ID); // Click again to verify that it doesn't toggle. + await clickListboxItem(OPTION_IDS[0]); + await clickListboxItem(ALL_ID); + await clickListboxItem(ALL_ID); // Click again to verify that it doesn't toggle. expectSelectedItems([ALL_ID]); }); it('deselects the ALL item when another item is clicked', async () => { - await clickDropdownItem(OPTION_IDS[0]); + await clickListboxItem(OPTION_IDS[0]); expectSelectedItems([OPTION_IDS[0]]); }); @@ -143,10 +108,10 @@ describe('Severity Filter component', () => { const ids = []; for await (const id of OPTION_IDS) { - await clickDropdownItem(id); + await clickListboxItem(id); ids.push(id); - expect(wrapper.emitted('filter-changed')[ids.length - 1][0].severity).toEqual(ids); + expect(wrapper.emitted('filter-changed').at(-1)[0].severity).toEqual(ids); } }); });