Commit da0d31c0 authored by Tim Zallmann's avatar Tim Zallmann

Merge branch '9253-add-full-feature-dropdowns-to-group-security-dashboard-filters' into 'master'

Add full feature dropdowns to GSD

Closes #9253

See merge request gitlab-org/gitlab-ee!10138
parents c7b04e81 2c0dd6cc
<script> <script>
import { mapGetters, mapActions } from 'vuex'; import { mapGetters, mapActions } from 'vuex';
import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { GlDropdown, GlSearchBoxByType } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
export default { export default {
components: { components: {
GlDropdown, GlDropdown,
GlDropdownItem, GlSearchBoxByType,
Icon, Icon,
}, },
props: { props: {
...@@ -15,6 +15,11 @@ export default { ...@@ -15,6 +15,11 @@ export default {
required: true, required: true,
}, },
}, },
data() {
return {
filterTerm: '',
};
},
computed: { computed: {
...mapGetters('filters', ['getFilter', 'getSelectedOptions', 'getSelectedOptionNames']), ...mapGetters('filters', ['getFilter', 'getSelectedOptions', 'getSelectedOptionNames']),
filter() { filter() {
...@@ -26,6 +31,11 @@ export default { ...@@ -26,6 +31,11 @@ export default {
selectedOptionText() { selectedOptionText() {
return this.getSelectedOptionNames(this.filterId) || '-'; return this.getSelectedOptionNames(this.filterId) || '-';
}, },
filteredOptions() {
return this.filter.options.filter(option =>
option.name.toLowerCase().includes(this.filterTerm.toLowerCase()),
);
},
}, },
methods: { methods: {
...mapActions('filters', ['setFilter']), ...mapActions('filters', ['setFilter']),
...@@ -38,6 +48,9 @@ export default { ...@@ -38,6 +48,9 @@ export default {
isSelected(option) { isSelected(option) {
return this.selection.has(option.id); return this.selection.has(option.id);
}, },
closeDropdown(event) {
this.$root.$emit('clicked::link', event);
},
}, },
}; };
</script> </script>
...@@ -45,7 +58,7 @@ export default { ...@@ -45,7 +58,7 @@ export default {
<template> <template>
<div class="dashboard-filter"> <div class="dashboard-filter">
<strong class="js-name">{{ filter.name }}</strong> <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"> <template slot="button-content">
<span class="text-truncate"> <span class="text-truncate">
{{ selectedOptionText.firstOption }} {{ selectedOptionText.firstOption }}
...@@ -57,20 +70,56 @@ export default { ...@@ -57,20 +70,56 @@ export default {
<i class="fa fa-chevron-down" aria-hidden="true"></i> <i class="fa fa-chevron-down" aria-hidden="true"></i>
</template> </template>
<gl-dropdown-item <div class="dropdown-title mb-0">
v-for="option in filter.options" {{ filter.name }}
:key="option.id" <button
@click="clickFilter(option)" 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-by-type
v-if="filter.options.length >= 20"
ref="searchBox"
v-model="filterTerm"
class="m-2"
:placeholder="__('Filter...')"
/>
<div :class="{ 'dropdown-content': filterId === 'project_id' }">
<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 {{ __('No matching results') }}
v-if="isSelected(option)" </button>
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>
</gl-dropdown> </gl-dropdown>
</div> </div>
</template> </template>
......
import * as vulnerabilitiesMutationTypes from './modules/vulnerabilities/mutation_types'; import * as vulnerabilitiesMutationTypes from './modules/vulnerabilities/mutation_types';
import * as filtersMutationTypes from './modules/filters/mutation_types'; import * as filtersMutationTypes from './modules/filters/mutation_types';
import * as projectsMutationTypes from './modules/projects/mutation_types'; import * as projectsMutationTypes from './modules/projects/mutation_types';
import { BASE_FILTERS } from './modules/filters/constants';
export default function configureModerator(store) { export default function configureModerator(store) {
store.$router.beforeEach((to, from, next) => { store.$router.beforeEach((to, from, next) => {
...@@ -19,10 +20,7 @@ export default function configureModerator(store) { ...@@ -19,10 +20,7 @@ export default function configureModerator(store) {
store.dispatch('filters/setFilterOptions', { store.dispatch('filters/setFilterOptions', {
filterId: 'project_id', filterId: 'project_id',
options: [ options: [
{ BASE_FILTERS.project_id,
name: 'All',
id: 'all',
},
...payload.projects.map(project => ({ ...payload.projects.map(project => ({
name: project.name, name: project.name,
id: project.id.toString(), id: project.id.toString(),
......
...@@ -27,3 +27,18 @@ export const REPORT_TYPES = { ...@@ -27,3 +27,18 @@ export const REPORT_TYPES = {
dependency_scanning: s__('ciReport|Dependency Scanning'), dependency_scanning: s__('ciReport|Dependency Scanning'),
sast: s__('ciReport|SAST'), sast: s__('ciReport|SAST'),
}; };
export const BASE_FILTERS = {
severity: {
name: s__('ciReport|All severities'),
id: 'all',
},
report_type: {
name: s__('ciReport|All report types'),
id: 'all',
},
project_id: {
name: s__('ciReport|All projects'),
id: 'all',
},
};
import { SEVERITY_LEVELS, REPORT_TYPES } from './constants'; import { SEVERITY_LEVELS, REPORT_TYPES, BASE_FILTERS } from './constants';
const optionsObjectToArray = obj => Object.entries(obj).map(([id, name]) => ({ id, name }));
export default () => ({ export default () => ({
filters: [ filters: [
{ {
name: 'Severity', name: 'Severity',
id: 'severity', id: 'severity',
options: [ options: [BASE_FILTERS.severity, ...optionsObjectToArray(SEVERITY_LEVELS)],
{
name: 'All',
id: 'all',
},
...Object.entries(SEVERITY_LEVELS).map(severity => {
const [id, name] = severity;
return { id, name };
}),
],
selection: new Set(['all']), selection: new Set(['all']),
}, },
{ {
name: 'Report type', name: 'Report type',
id: 'report_type', id: 'report_type',
options: [ options: [BASE_FILTERS.report_type, ...optionsObjectToArray(REPORT_TYPES)],
{
name: 'All',
id: 'all',
},
...Object.entries(REPORT_TYPES).map(type => {
const [id, name] = type;
return { id, name };
}),
],
selection: new Set(['all']), selection: new Set(['all']),
}, },
{ {
name: 'Project', name: 'Project',
id: 'project_id', id: 'project_id',
options: [ options: [BASE_FILTERS.project_id],
{
name: 'All',
id: 'all',
},
],
selection: new Set(['all']), selection: new Set(['all']),
}, },
], ],
......
---
title: Make editing the filters in the Group Security Dashboard easier.
merge_request: 10138
author:
type: fixed
import createState from 'ee/security_dashboard/store/modules/filters/state'; import createState from 'ee/security_dashboard/store/modules/filters/state';
import * as getters from 'ee/security_dashboard/store/modules/filters/getters'; import * as getters from 'ee/security_dashboard/store/modules/filters/getters';
import { BASE_FILTERS } from 'ee/security_dashboard/store/modules/filters/constants';
describe('filters module getters', () => { describe('filters module getters', () => {
const mockedGetters = state => { const mockedGetters = state => {
...@@ -28,13 +29,13 @@ describe('filters module getters', () => { ...@@ -28,13 +29,13 @@ describe('filters module getters', () => {
describe('getSelectedOptions', () => { describe('getSelectedOptions', () => {
describe('with one selected option', () => { describe('with one selected option', () => {
it('should return "All" as the selected option', () => { it('should return the base filter as the selected option', () => {
const selectedOptions = getters.getSelectedOptions(state, mockedGetters(state))( const selectedOptions = getters.getSelectedOptions(state, mockedGetters(state))(
'report_type', 'report_type',
); );
expect(selectedOptions).toHaveLength(1); expect(selectedOptions).toHaveLength(1);
expect(selectedOptions[0].name).toEqual('All'); expect(selectedOptions[0].name).toBe(BASE_FILTERS.report_type.name);
}); });
}); });
...@@ -57,12 +58,13 @@ describe('filters module getters', () => { ...@@ -57,12 +58,13 @@ describe('filters module getters', () => {
}); });
describe('getSelectedOptionNames', () => { describe('getSelectedOptionNames', () => {
it('should return "All" as the selected option', () => { it('should return the base filter as the selected option', () => {
const selectedOptionNames = getters.getSelectedOptionNames(state, mockedGetters(state))( const selectedOptionNames = getters.getSelectedOptionNames(state, mockedGetters(state))(
'severity', 'severity',
); );
expect(selectedOptionNames).toEqual({ firstOption: 'All', extraOptionCount: '' }); expect(selectedOptionNames.firstOption).toBe(BASE_FILTERS.severity.name);
expect(selectedOptionNames.extraOptionCount).toBe('');
}); });
it('should return the correct message when multiple filters are selected', () => { it('should return the correct message when multiple filters are selected', () => {
......
...@@ -6,8 +6,36 @@ import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper ...@@ -6,8 +6,36 @@ import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper
describe('Filter component', () => { describe('Filter component', () => {
let vm; let vm;
let props; let props;
const store = createStore(); let store;
const Component = Vue.extend(component); 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 && vm.$refs.searchBox.$el.querySelector('input');
beforeEach(() => {
store = createStore();
Component = Vue.extend(component);
});
afterEach(() => {
vm.$destroy();
});
describe('severity', () => { describe('severity', () => {
beforeEach(() => { beforeEach(() => {
...@@ -15,10 +43,6 @@ describe('Filter component', () => { ...@@ -15,10 +43,6 @@ describe('Filter component', () => {
vm = mountComponentWithStore(Component, { store, props }); vm = mountComponentWithStore(Component, { store, props });
}); });
afterEach(() => {
vm.$destroy();
});
it('should display all 8 severity options', () => { it('should display all 8 severity options', () => {
expect(vm.$el.querySelectorAll('.dropdown-item').length).toEqual(8); expect(vm.$el.querySelectorAll('.dropdown-item').length).toEqual(8);
}); });
...@@ -30,5 +54,68 @@ describe('Filter component', () => { ...@@ -30,5 +54,68 @@ describe('Filter component', () => {
it('should display "Severity" as the option name', () => { it('should display "Severity" as the option name', () => {
expect(vm.$el.querySelector('.js-name').textContent).toContain('Severity'); expect(vm.$el.querySelector('.js-name').textContent).toContain('Severity');
}); });
it('should not have a search box', () => {
expect(findSearchInput()).not.toEqual(jasmine.any(HTMLElement));
});
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()).toEqual(jasmine.any(HTMLElement));
});
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();
});
});
});
}); });
}); });
...@@ -2,6 +2,7 @@ import createStore from 'ee/security_dashboard/store/index'; ...@@ -2,6 +2,7 @@ import createStore from 'ee/security_dashboard/store/index';
import * as projectsMutationTypes from 'ee/security_dashboard/store/modules/projects/mutation_types'; import * as projectsMutationTypes from 'ee/security_dashboard/store/modules/projects/mutation_types';
import * as filtersMutationTypes from 'ee/security_dashboard/store/modules/filters/mutation_types'; import * as filtersMutationTypes from 'ee/security_dashboard/store/modules/filters/mutation_types';
import * as vulnerabilitiesMutationTypes from 'ee/security_dashboard/store/modules/vulnerabilities/mutation_types'; import * as vulnerabilitiesMutationTypes from 'ee/security_dashboard/store/modules/vulnerabilities/mutation_types';
import { BASE_FILTERS } from 'ee/security_dashboard/store/modules/filters/constants';
describe('moderator', () => { describe('moderator', () => {
let store; let store;
...@@ -22,7 +23,7 @@ describe('moderator', () => { ...@@ -22,7 +23,7 @@ describe('moderator', () => {
'filters/setFilterOptions', 'filters/setFilterOptions',
Object({ Object({
filterId: 'project_id', filterId: 'project_id',
options: [{ name: 'All', id: 'all' }, { name: 'foo', id: '1' }], options: [BASE_FILTERS.project_id, { name: 'foo', id: '1' }],
}), }),
); );
}); });
......
...@@ -12392,6 +12392,15 @@ msgstr "" ...@@ -12392,6 +12392,15 @@ msgstr ""
msgid "ciReport|(is loading, errors when loading results)" msgid "ciReport|(is loading, errors when loading results)"
msgstr "" msgstr ""
msgid "ciReport|All projects"
msgstr ""
msgid "ciReport|All report types"
msgstr ""
msgid "ciReport|All severities"
msgstr ""
msgid "ciReport|Class" msgid "ciReport|Class"
msgstr "" msgstr ""
......
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