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>
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';
export default {
components: {
GlDropdown,
GlDropdownItem,
GlSearchBoxByType,
Icon,
},
props: {
......@@ -15,6 +15,11 @@ export default {
required: true,
},
},
data() {
return {
filterTerm: '',
};
},
computed: {
...mapGetters('filters', ['getFilter', 'getSelectedOptions', 'getSelectedOptionNames']),
filter() {
......@@ -26,6 +31,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 +48,9 @@ export default {
isSelected(option) {
return this.selection.has(option.id);
},
closeDropdown(event) {
this.$root.$emit('clicked::link', event);
},
},
};
</script>
......@@ -45,7 +58,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 +70,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-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
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: 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 () => ({
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, ...optionsObjectToArray(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, ...optionsObjectToArray(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']),
},
],
......
---
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 * 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', () => {
const mockedGetters = state => {
......@@ -28,13 +29,13 @@ describe('filters module getters', () => {
describe('getSelectedOptions', () => {
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))(
'report_type',
);
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', () => {
});
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))(
'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', () => {
......
......@@ -6,8 +6,36 @@ 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 && vm.$refs.searchBox.$el.querySelector('input');
beforeEach(() => {
store = createStore();
Component = Vue.extend(component);
});
afterEach(() => {
vm.$destroy();
});
describe('severity', () => {
beforeEach(() => {
......@@ -15,10 +43,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 +54,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()).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';
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 vulnerabilitiesMutationTypes from 'ee/security_dashboard/store/modules/vulnerabilities/mutation_types';
import { BASE_FILTERS } from 'ee/security_dashboard/store/modules/filters/constants';
describe('moderator', () => {
let store;
......@@ -22,7 +23,7 @@ describe('moderator', () => {
'filters/setFilterOptions',
Object({
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 ""
msgid "ciReport|(is loading, errors when loading results)"
msgstr ""
msgid "ciReport|All projects"
msgstr ""
msgid "ciReport|All report types"
msgstr ""
msgid "ciReport|All severities"
msgstr ""
msgid "ciReport|Class"
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