From 57f1b34990371da3e700f21f57cf922506d415ba Mon Sep 17 00:00:00 2001 From: Savas Vedova <svedova@gitlab.com> Date: Wed, 17 May 2023 11:49:05 +0000 Subject: [PATCH] Migrate status filter to collapsible listbox Use GlCollapsibleListbox instead of the deprecated GlDropdown component. Changelog: changed EE: true --- .../shared/filters/status_filter.vue | 94 ++++++++---------- .../components/shared/filters/utils.js | 4 +- .../shared/filters/status_filter_spec.js | 95 +++++-------------- .../components/shared/filters/utils_spec.js | 7 ++ 4 files changed, 74 insertions(+), 126 deletions(-) diff --git a/ee/app/assets/javascripts/security_dashboard/components/shared/filters/status_filter.vue b/ee/app/assets/javascripts/security_dashboard/components/shared/filters/status_filter.vue index d0528c7289500..44cc6755e1bdb 100644 --- a/ee/app/assets/javascripts/security_dashboard/components/shared/filters/status_filter.vue +++ b/ee/app/assets/javascripts/security_dashboard/components/shared/filters/status_filter.vue @@ -1,71 +1,69 @@ <script> -import { GlDropdown } from '@gitlab/ui'; -import { xor } from 'lodash'; +import { GlCollapsibleListbox } from '@gitlab/ui'; import { s__ } from '~/locale'; import { VULNERABILITY_STATE_OBJECTS } from 'ee/vulnerabilities/constants'; -import DropdownButtonText from './dropdown_button_text.vue'; import QuerystringSync from './querystring_sync.vue'; -import FilterItem from './filter_item.vue'; import { ALL_ID } from './constants'; +import { getSelectedOptionsText } from './utils'; const { detected, confirmed, dismissed, resolved } = VULNERABILITY_STATE_OBJECTS; // For backwards compatibility with existing bookmarks, the ID needs to be capitalized. export const DROPDOWN_OPTIONS = [ - { id: detected.state.toUpperCase(), text: detected.buttonText }, - { id: confirmed.state.toUpperCase(), text: confirmed.buttonText }, - { id: dismissed.state.toUpperCase(), text: dismissed.buttonText }, - { id: resolved.state.toUpperCase(), text: resolved.buttonText }, + { value: ALL_ID, text: s__('SecurityReports|All statuses') }, + { value: detected.state.toUpperCase(), text: detected.buttonText }, + { value: confirmed.state.toUpperCase(), text: confirmed.buttonText }, + { value: dismissed.state.toUpperCase(), text: dismissed.buttonText }, + { value: resolved.state.toUpperCase(), text: resolved.buttonText }, ]; -export const VALID_IDS = [ALL_ID, ...DROPDOWN_OPTIONS.map(({ id }) => id)]; -export const DEFAULT_IDS = [detected.state.toUpperCase(), confirmed.state.toUpperCase()]; +export const VALID_IDS = DROPDOWN_OPTIONS.map(({ value }) => value); +export const DEFAULT_IDS = [VALID_IDS[1], VALID_IDS[2]]; export default { - components: { GlDropdown, DropdownButtonText, QuerystringSync, FilterItem }, + components: { + GlCollapsibleListbox, + QuerystringSync, + }, data: () => ({ selected: DEFAULT_IDS, }), computed: { - selectedIds() { - return this.selected.length ? this.selected : [ALL_ID]; - }, - 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(DROPDOWN_OPTIONS, this.selected); }, }, watch: { selected: { immediate: true, handler() { - this.$emit('filter-changed', { state: this.selected }); + this.$emit('filter-changed', { + state: this.selected.filter((value) => value !== ALL_ID), + }); }, }, }, methods: { - deselectAll() { - this.selected = []; - }, - toggleSelected(id) { - this.selected = xor(this.selected, [id]); + updateSelected(selected) { + if (selected.length <= 0 || selected.at(-1) === ALL_ID) { + this.selected = [ALL_ID]; + } else { + this.selected = selected.filter((value) => value !== ALL_ID); + } }, - setSelected(ids) { - if (ids.includes(ALL_ID)) { - this.selected = []; - } else if (!ids.length) { - this.selected = DEFAULT_IDS; + updateSelectedFromQS(selected) { + if (selected.includes(ALL_ID)) { + this.selected = [ALL_ID]; + } else if (selected.length > 0) { + this.selected = selected; } else { - this.selected = ids; + this.selected = DEFAULT_IDS; } }, }, i18n: { label: s__('SecurityReports|Status'), - allItemsText: s__('SecurityReports|All statuses'), }, DROPDOWN_OPTIONS, VALID_IDS, - ALL_ID, }; </script> @@ -73,29 +71,19 @@ export default { <div> <querystring-sync querystring-key="state" - :value="selectedIds" + :value="selected" :valid-values="$options.VALID_IDS" - @input="setSelected" + @input="updateSelectedFromQS" /> <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" + block + multiple + :items="$options.DROPDOWN_OPTIONS" + :selected="selected" + :toggle-text="toggleText" + @select="updateSelected" + /> </div> </template> diff --git a/ee/app/assets/javascripts/security_dashboard/components/shared/filters/utils.js b/ee/app/assets/javascripts/security_dashboard/components/shared/filters/utils.js index 51e83e2a47bfc..213eca941860d 100644 --- a/ee/app/assets/javascripts/security_dashboard/components/shared/filters/utils.js +++ b/ee/app/assets/javascripts/security_dashboard/components/shared/filters/utils.js @@ -9,12 +9,12 @@ import { n__ } from '~/locale'; * - If more than one option is selected, returns the text of the first option * followed by the text "+X more", where X is the number of additional selected options * - * @param {Array<{ id: number | string }>} options + * @param {Array<{ id: number | string, value: string }>} options * @param {Array<{ id: number | string }>} selected * @returns {String} */ export const getSelectedOptionsText = (options, selected, placeholder = '') => { - const selectedOptions = options.filter(({ id }) => selected.includes(id)); + const selectedOptions = options.filter(({ id, value }) => selected.includes(id || value)); if (selectedOptions.length === 0) { return placeholder; diff --git a/ee/spec/frontend/security_dashboard/components/shared/filters/status_filter_spec.js b/ee/spec/frontend/security_dashboard/components/shared/filters/status_filter_spec.js index 1f00469f64c0e..d81452a343221 100644 --- a/ee/spec/frontend/security_dashboard/components/shared/filters/status_filter_spec.js +++ b/ee/spec/frontend/security_dashboard/components/shared/filters/status_filter_spec.js @@ -1,4 +1,4 @@ -import { GlDropdown } from '@gitlab/ui'; +import { GlCollapsibleListbox } from '@gitlab/ui'; import { nextTick } from 'vue'; import StatusFilter, { DEFAULT_IDS, @@ -7,11 +7,9 @@ import StatusFilter, { } from 'ee/security_dashboard/components/shared/filters/status_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 = DROPDOWN_OPTIONS.map(({ value }) => value); describe('Status Filter component', () => { let wrapper; @@ -23,22 +21,13 @@ describe('Status Filter component', () => { }; const findQuerystringSync = () => wrapper.findComponent(QuerystringSync); - const findDropdownItems = () => wrapper.findAllComponents(FilterItem); - const findDropdownItem = (id) => wrapper.findByTestId(id); + const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); - const clickDropdownItem = async (id) => { - findDropdownItem(id).vm.$emit('click'); + const clickDropdownItem = async (...ids) => { + findListbox().vm.$emit('select', [...ids]); await nextTick(); }; - const expectSelectedItems = (ids) => { - const checkedItems = findDropdownItems() - .wrappers.filter((item) => item.props('isChecked')) - .map((item) => item.attributes('data-testid')); - - expect(checkedItems).toEqual(ids); - }; - beforeEach(() => { createWrapper(); }); @@ -61,13 +50,12 @@ describe('Status Filter component', () => { it.each` emitted | expected ${['CONFIRMED', 'RESOLVED']} | ${['CONFIRMED', 'RESOLVED']} - ${[]} | ${DEFAULT_IDS} ${[ALL_ID]} | ${[ALL_ID]} `('restores selected items - $emitted', async ({ emitted, expected }) => { findQuerystringSync().vm.$emit('input', emitted); await nextTick(); - expectSelectedItems(expected); + expect(findListbox().props('selected')).toEqual(expected); }); }); @@ -77,92 +65,57 @@ describe('Status Filter component', () => { }); it('shows the dropdown with correct header text', () => { - expect(wrapper.findComponent(GlDropdown).props('headerText')).toBe(StatusFilter.i18n.label); + expect(findListbox().props('headerText')).toBe(StatusFilter.i18n.label); }); - it('shows the DropdownButtonText component with the correct props', () => { - expect(wrapper.findComponent(DropdownButtonText).props()).toMatchObject({ - items: ['Needs triage', 'Confirmed'], - name: StatusFilter.i18n.label, - }); + it('shows the placeholder correctly', async () => { + await clickDropdownItem('CONFIRMED', 'RESOLVED'); + expect(findListbox().props('toggleText')).toBe('Confirmed +1 more'); }); }); describe('dropdown items', () => { it('shows all dropdown items with correct text', () => { - expect(findDropdownItems()).toHaveLength(DROPDOWN_OPTIONS.length + 1); - - expect(findDropdownItem(ALL_ID).text()).toBe(StatusFilter.i18n.allItemsText); - DROPDOWN_OPTIONS.forEach(({ id, text }) => { - expect(findDropdownItem(id).text()).toBe(text); - }); - }); - - it('allows multiple items to be selected', async () => { - const ids = []; - // Deselect everything to begin with. - clickDropdownItem(ALL_ID); - - for await (const id of OPTION_IDS) { - await clickDropdownItem(id); - ids.push(id); - - expectSelectedItems(ids); - } + expect(findListbox().props('items')).toEqual(DROPDOWN_OPTIONS); }); it('toggles the item selection when clicked on', async () => { - // Deselect everything to begin with. - clickDropdownItem(ALL_ID); - - for await (const id of OPTION_IDS) { - await clickDropdownItem(id); - - expectSelectedItems([id]); - - await clickDropdownItem(id); - - expectSelectedItems([ALL_ID]); - } + await clickDropdownItem('CONFIRMED', 'RESOLVED'); + expect(findListbox().props('selected')).toEqual(['CONFIRMED', 'RESOLVED']); + await clickDropdownItem('DETECTED'); + expect(findListbox().props('selected')).toEqual(['DETECTED']); }); it('selects default items when created', () => { - expectSelectedItems(DEFAULT_IDS); + expect(findListbox().props('selected')).toEqual(DEFAULT_IDS); }); it('selects ALL item and deselects everything else when it is clicked', async () => { await clickDropdownItem(ALL_ID); - await clickDropdownItem(ALL_ID); // Click again to verify that it doesn't toggle. - - expectSelectedItems([ALL_ID]); + expect(findListbox().props('selected')).toEqual([ALL_ID]); }); it('deselects the ALL item when another item is clicked', async () => { - await clickDropdownItem(ALL_ID); - await clickDropdownItem(OPTION_IDS[0]); - - expectSelectedItems([OPTION_IDS[0]]); + await clickDropdownItem(ALL_ID, 'CONFIRMED'); + expect(findListbox().props('selected')).toEqual(['CONFIRMED']); }); }); describe('filter-changed event', () => { it('emits filter-changed event with default IDs when created', () => { - expect(wrapper.emitted('filter-changed')[0][0].state).toBe(DEFAULT_IDS); + expect(wrapper.emitted('filter-changed')[0][0].state).toEqual(DEFAULT_IDS); }); it('emits filter-changed event when selected item is changed', async () => { - const ids = []; - // Deselect everything to begin with. await clickDropdownItem(ALL_ID); expect(wrapper.emitted('filter-changed')[1][0].state).toEqual([]); - for await (const id of OPTION_IDS) { - await clickDropdownItem(id); - ids.push(id); + await clickDropdownItem(...OPTION_IDS); - expect(wrapper.emitted('filter-changed')[ids.length + 1][0].state).toEqual(ids); - } + expect(wrapper.emitted('filter-changed')[2][0].state).toEqual( + OPTION_IDS.filter((id) => id !== ALL_ID), + ); }); }); }); diff --git a/ee/spec/frontend/security_dashboard/components/shared/filters/utils_spec.js b/ee/spec/frontend/security_dashboard/components/shared/filters/utils_spec.js index dfc68d01ec922..192fb228d0e2c 100644 --- a/ee/spec/frontend/security_dashboard/components/shared/filters/utils_spec.js +++ b/ee/spec/frontend/security_dashboard/components/shared/filters/utils_spec.js @@ -28,6 +28,13 @@ describe('getSelectedOptionsText', () => { expect(getSelectedOptionsText(options, selected)).toBe('first'); }); + it('should also work with the value property', () => { + const options = [{ value: 1, text: 'first' }]; + const selected = [options[0].value]; + + expect(getSelectedOptionsText(options, selected)).toBe('first'); + }); + it.each` options | expectedText ${[{ id: 1, text: 'first' }, { id: 2, text: 'second' }]} | ${'first +1 more'} -- GitLab