Skip to content
Snippets Groups Projects
Commit 9908381b authored by David Pisek's avatar David Pisek :palm_tree:
Browse files

Merge branch '409699-migrate-dropdown-to-gl-collapsible-listbox' into 'master'

Migrate status filter to collapsible listbox

See merge request !120320



Merged-by: default avatarDavid Pisek <dpisek@gitlab.com>
Approved-by: default avatarDaniel Tian <dtian@gitlab.com>
Approved-by: default avatarDavid Pisek <dpisek@gitlab.com>
Reviewed-by: default avatarDavid Pisek <dpisek@gitlab.com>
Reviewed-by: Savas Vedova's avatarSavas Vedova <svedova@gitlab.com>
Co-authored-by: Savas Vedova's avatarSavas Vedova <svedova@gitlab.com>
parents 257f6034 57f1b349
No related branches found
No related tags found
1 merge request!120320Migrate status filter to collapsible listbox
Pipeline #870170581 passed
<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>
......@@ -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;
......
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),
);
});
});
});
......@@ -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'}
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment