Commit 0a149963 authored by Mark Florian's avatar Mark Florian

Improve dropdowns in Group Security Dashboard

Each dropdown now:

- stays open as items in it are checked/unchecked
- has a header and close button
- shows a search box when there are many items in it
- use a more specific name for the "All" filter; e.g., "All severities"
parent 99854cec
<script>
import { mapGetters, mapActions } from 'vuex';
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { GlDropdown, GlDropdownItem, GlSearchBox } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
GlDropdown,
GlDropdownItem,
GlSearchBox,
Icon,
},
props: {
......@@ -15,6 +16,11 @@ export default {
required: true,
},
},
data() {
return {
filterTerm: '',
};
},
computed: {
...mapGetters('filters', ['getFilter', 'getSelectedOptions', 'getSelectedOptionNames']),
filter() {
......@@ -26,6 +32,11 @@ export default {
selectedOptionText() {
return this.getSelectedOptionNames(this.filterId) || '-';
},
filteredOptions() {
return this.filter.options.filter(option =>
option.name.toLowerCase().includes(this.filterTerm.toLowerCase()),
);
},
},
methods: {
...mapActions('filters', ['setFilter']),
......@@ -38,6 +49,9 @@ export default {
isSelected(option) {
return this.selection.has(option.id);
},
closeDropdown(event) {
this.$root.$emit('clicked::link', event);
},
},
};
</script>
......@@ -45,7 +59,7 @@ export default {
<template>
<div class="dashboard-filter">
<strong class="js-name">{{ filter.name }}</strong>
<gl-dropdown class="d-block mt-1">
<gl-dropdown class="d-block mt-1" menu-class="dropdown-extended-height">
<template slot="button-content">
<span class="text-truncate">
{{ selectedOptionText.firstOption }}
......@@ -57,20 +71,56 @@ export default {
<i class="fa fa-chevron-down" aria-hidden="true"></i>
</template>
<gl-dropdown-item
v-for="option in filter.options"
:key="option.id"
@click="clickFilter(option)"
<div class="dropdown-title mb-0">
{{ filter.name }}
<button
ref="close"
class="btn-blank float-right"
type="button"
:aria-label="__('Close')"
@click="closeDropdown"
>
<icon name="close" aria-hidden="true" class="vertical-align-middle" />
</button>
</div>
<gl-search-box
v-if="filter.options.length >= 20"
ref="searchBox"
v-model="filterTerm"
class="m-2"
:placeholder="__('Filter...')"
/>
<div class="dropdown-content" style="max-height: 280px">
<button
v-for="option in filteredOptions"
:key="option.id"
role="menuitem"
type="button"
class="dropdown-item"
@click="clickFilter(option)"
>
<span class="d-flex">
<icon
v-if="isSelected(option)"
class="flex-shrink-0 js-check"
name="mobile-issue-close"
/>
<span :class="isSelected(option) ? 'prepend-left-4' : 'prepend-left-20'">{{
option.name
}}</span>
</span>
</button>
</div>
<button
v-if="filteredOptions.length === 0"
type="button"
class="dropdown-item no-pointer-events text-secondary"
>
<icon
v-if="isSelected(option)"
class="vertical-align-middle js-check"
name="mobile-issue-close"
/>
<span class="vertical-align-middle" :class="{ 'prepend-left-20': !isSelected(option) }">{{
option.name
}}</span>
</gl-dropdown-item>
{{ __('No matching results') }}
</button>
</gl-dropdown>
</div>
</template>
......
import * as vulnerabilitiesMutationTypes from './modules/vulnerabilities/mutation_types';
import * as filtersMutationTypes from './modules/filters/mutation_types';
import * as projectsMutationTypes from './modules/projects/mutation_types';
import { BASE_FILTERS } from './modules/filters/constants';
export default function configureModerator(store) {
store.$router.beforeEach((to, from, next) => {
......@@ -19,10 +20,7 @@ export default function configureModerator(store) {
store.dispatch('filters/setFilterOptions', {
filterId: 'project_id',
options: [
{
name: 'All',
id: 'all',
},
BASE_FILTERS.project_id,
...payload.projects.map(project => ({
name: project.name,
id: project.id.toString(),
......
......@@ -27,3 +27,18 @@ export const REPORT_TYPES = {
dependency_scanning: s__('ciReport|Dependency Scanning'),
sast: s__('ciReport|SAST'),
};
export const BASE_FILTERS = {
severity: {
name: 'All severities',
id: 'all',
},
report_type: {
name: 'All report types',
id: 'all',
},
project_id: {
name: 'All projects',
id: 'all',
},
};
import { SEVERITY_LEVELS, REPORT_TYPES } from './constants';
import { SEVERITY_LEVELS, REPORT_TYPES, BASE_FILTERS } from './constants';
const mapToArray = map => Object.entries(map).map(([id, name]) => ({ id, name }));
export default () => ({
filters: [
{
name: 'Severity',
id: 'severity',
options: [
{
name: 'All',
id: 'all',
},
...Object.entries(SEVERITY_LEVELS).map(severity => {
const [id, name] = severity;
return { id, name };
}),
],
options: [BASE_FILTERS.severity, ...mapToArray(SEVERITY_LEVELS)],
selection: new Set(['all']),
},
{
name: 'Report type',
id: 'report_type',
options: [
{
name: 'All',
id: 'all',
},
...Object.entries(REPORT_TYPES).map(type => {
const [id, name] = type;
return { id, name };
}),
],
options: [BASE_FILTERS.report_type, ...mapToArray(REPORT_TYPES)],
selection: new Set(['all']),
},
{
name: 'Project',
id: 'project_id',
options: [
{
name: 'All',
id: 'all',
},
],
options: [BASE_FILTERS.project_id],
selection: new Set(['all']),
},
],
......
......@@ -6,8 +6,37 @@ import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper
describe('Filter component', () => {
let vm;
let props;
const store = createStore();
const Component = Vue.extend(component);
let store;
let Component;
function isDropdownOpen() {
const toggleButton = vm.$el.querySelector('.dropdown-toggle');
return toggleButton.getAttribute('aria-expanded') === 'true';
}
function setProjectsCount(count) {
const projects = new Array(count).fill(null).map((_, i) => ({
name: i.toString(),
id: i.toString(),
}));
store.dispatch('filters/setFilterOptions', {
filterId: 'project_id',
options: projects,
});
}
const findSearchInput = () =>
(vm.$refs.searchBox || null) && vm.$refs.searchBox.$el.querySelector('input');
beforeEach(() => {
store = createStore();
Component = Vue.extend(component);
});
afterEach(() => {
vm.$destroy();
});
describe('severity', () => {
beforeEach(() => {
......@@ -15,10 +44,6 @@ describe('Filter component', () => {
vm = mountComponentWithStore(Component, { store, props });
});
afterEach(() => {
vm.$destroy();
});
it('should display all 8 severity options', () => {
expect(vm.$el.querySelectorAll('.dropdown-item').length).toEqual(8);
});
......@@ -30,5 +55,68 @@ describe('Filter component', () => {
it('should display "Severity" as the option name', () => {
expect(vm.$el.querySelector('.js-name').textContent).toContain('Severity');
});
it('should not have a search box', () => {
expect(findSearchInput()).toBeNull();
});
it('should not be open', () => {
expect(isDropdownOpen()).toBe(false);
});
describe('when the dropdown is open', () => {
beforeEach(done => {
vm.$el.querySelector('.dropdown-toggle').click();
vm.$nextTick(done);
});
it('should keep the menu open after clicking on an item', done => {
expect(isDropdownOpen()).toBe(true);
vm.$el.querySelector('.dropdown-item').click();
vm.$nextTick(() => {
expect(isDropdownOpen()).toBe(true);
done();
});
});
it('should close the menu when the close button is clicked', done => {
expect(isDropdownOpen()).toBe(true);
vm.$refs.close.click();
vm.$nextTick(() => {
expect(isDropdownOpen()).toBe(false);
done();
});
});
});
});
describe('Project', () => {
describe('when there are lots of projects', () => {
const lots = 30;
beforeEach(done => {
props = { filterId: 'project_id', dashboardDocumentation: '' };
vm = mountComponentWithStore(Component, { store, props });
setProjectsCount(lots);
vm.$nextTick(done);
});
it('should display a search box', () => {
expect(findSearchInput()).not.toBeNull();
});
it(`should show all projects`, () => {
expect(vm.$el.querySelectorAll('.dropdown-item').length).toBe(lots);
});
it('should show only matching projects when a search term is entered', done => {
const input = findSearchInput();
input.value = '0';
input.dispatchEvent(new Event('input'));
vm.$nextTick(() => {
expect(vm.$el.querySelectorAll('.dropdown-item').length).toBe(3);
done();
});
});
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment