Commit 8a68e857 authored by Fatih Acet's avatar Fatih Acet

Merge branch '6240-add-filters-to-gsd' into 'master'

Adds basic filtering to the Group Security Dashboard frontend

See merge request gitlab-org/gitlab-ee!8886
parents 2efc19f8 0af419d3
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import { s__ } from '~/locale';
import { spriteIcon } from '~/lib/utils/common_utils';
import Tabs from '~/vue_shared/components/tabs/tabs';
import Tab from '~/vue_shared/components/tabs/tab.vue';
import { mapActions, mapState, mapGetters } from 'vuex';
import IssueModal from 'ee/vue_shared/security_reports/components/modal.vue';
import Filters from './filters.vue';
import SecurityDashboardTable from './security_dashboard_table.vue';
import VulnerabilityChart from './vulnerability_chart.vue';
import VulnerabilityCountList from './vulnerability_count_list.vue';
import Icon from '~/vue_shared/components/icon.vue';
import popover from '~/vue_shared/directives/popover';
export default {
name: 'SecurityDashboardApp',
directives: {
popover,
},
components: {
Icon,
Filters,
IssueModal,
SecurityDashboardTable,
Tab,
Tabs,
VulnerabilityChart,
VulnerabilityCountList,
},
......@@ -52,33 +42,8 @@ export default {
},
},
computed: {
...mapGetters('vulnerabilities', ['vulnerabilitiesCountByReportType']),
...mapState('vulnerabilities', ['modal']),
sastCount() {
return this.vulnerabilitiesCountByReportType('sast');
},
popoverOptions() {
return {
trigger: 'click',
placement: 'right',
title: s__('Security Reports|At this time, the security dashboard only supports SAST.'),
content: `
<a
title="${s__('Security Reports|Security dashboard documentation')}"
href="${this.dashboardDocumentation}"
target="_blank"
rel="noopener
noreferrer"
>
<span class="vertical-align-middle">${s__(
'Security Reports|Security dashboard documentation',
)}</span>
${spriteIcon('external-link', 's16 vertical-align-middle')}
</a>
`,
html: true,
};
},
...mapGetters('filters', ['activeFilters']),
},
created() {
this.setVulnerabilitiesEndpoint(this.vulnerabilitiesEndpoint);
......@@ -88,33 +53,28 @@ export default {
},
methods: {
...mapActions('vulnerabilities', [
'setVulnerabilitiesCountEndpoint',
'setVulnerabilitiesHistoryEndpoint',
'setVulnerabilitiesEndpoint',
'fetchVulnerabilitiesCount',
'createIssue',
'dismissVulnerability',
'fetchVulnerabilities',
'fetchVulnerabilitiesCount',
'fetchVulnerabilitiesHistory',
'revertDismissal',
'setVulnerabilitiesCountEndpoint',
'setVulnerabilitiesEndpoint',
'setVulnerabilitiesHistoryEndpoint',
]),
filterChange() {
this.fetchVulnerabilities(this.activeFilters);
this.fetchVulnerabilitiesCount(this.activeFilters);
this.fetchVulnerabilitiesHistory(this.activeFilters);
},
},
};
</script>
<template>
<div>
<tabs stop-propagation>
<tab active>
<template slot="title">
<span>{{ __('SAST') }}</span>
<span v-if="sastCount" class="badge badge-pill"> {{ sastCount }} </span>
<span
v-popover="popoverOptions"
class="text-muted prepend-left-4"
:aria-label="__('help')"
>
<icon name="question" class="vertical-align-middle" />
</span>
</template>
<filters :dashboard-documentation="dashboardDocumentation" @change="filterChange" />
<vulnerability-count-list />
<h4 class="my-4">{{ __('Vulnerability Chart') }}</h4>
<vulnerability-chart />
......@@ -123,8 +83,6 @@ export default {
:dashboard-documentation="dashboardDocumentation"
:empty-state-svg-path="emptyStateSvgPath"
/>
</tab>
</tabs>
<issue-modal
:modal="modal"
:vulnerability-feedback-help-path="vulnerabilityFeedbackHelpPath"
......
<script>
import { mapGetters, mapActions } from 'vuex';
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import ReportTypePopover from './report_type_popover.vue';
export default {
components: {
GlDropdown,
GlDropdownItem,
ReportTypePopover,
Icon,
},
props: {
filterId: {
type: String,
required: true,
},
dashboardDocumentation: {
type: String,
required: true,
},
},
computed: {
...mapGetters('filters', ['getFilter', 'getSelectedOptions']),
filter() {
return this.getFilter(this.filterId);
},
selectedOptionText() {
const [selectedOption] = this.getSelectedOptions(this.filterId);
return (selectedOption && selectedOption.name) || '-';
},
},
methods: {
...mapActions('filters', ['setFilter']),
clickFilter(option) {
this.setFilter({
filterId: this.filterId,
optionId: option.id,
});
this.$emit('change');
},
},
};
</script>
<template>
<div class="dashboard-filter">
<strong class="js-name">{{ filter.name }}</strong>
<report-type-popover
v-if="filterId === 'type'"
:dashboard-documentation="dashboardDocumentation"
/>
<gl-dropdown :text="selectedOptionText" class="d-block mt-1">
<gl-dropdown-item
v-for="option in filter.options"
:key="option.id"
@click="clickFilter(option);"
>
<icon
v-if="option.selected"
class="vertical-align-middle js-check"
name="mobile-issue-close"
/>
<span class="vertical-align-middle" :class="{ 'prepend-left-20': !option.selected }">{{
option.name
}}</span>
</gl-dropdown-item>
</gl-dropdown>
</div>
</template>
<style>
.dashboard-filter .dropdown-toggle {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
</style>
<script>
import { mapState } from 'vuex';
import DashboardFilter from './filter.vue';
export default {
components: {
DashboardFilter,
},
props: {
dashboardDocumentation: {
type: String,
required: true,
},
},
computed: {
...mapState('filters', ['filters']),
},
};
</script>
<template>
<div class="dashboard-filters border-bottom bg-light">
<div class="row mx-0 p-2">
<dashboard-filter
v-for="filter in filters"
:key="filter.id"
class="col-sm-6 col-md-4 col-lg-2 p-2 js-filter"
:filter-id="filter.id"
:dashboard-documentation="dashboardDocumentation"
@change="$emit('change');"
/>
</div>
</div>
</template>
<script>
import { GlPopover } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
GlPopover,
Icon,
},
props: {
dashboardDocumentation: {
type: String,
required: true,
},
},
};
</script>
<template>
<span class="vertical-align-middle text-muted js-help">
<icon id="reports-info" name="question" :aria-label="__('help')" />
<gl-popover
target="reports-info"
placement="right"
triggers="click"
:title="s__('Security Reports|At this time, the security dashboard only supports SAST.')"
>
<a
v-if="dashboardDocumentation"
target="_blank"
rel="noopener noreferrer"
:title="s__('Security Reports|Security dashboard documentation')"
:href="dashboardDocumentation"
>
<span class="vertical-align-middle">{{
s__('Security Reports|Security dashboard documentation')
}}</span>
<icon name="external-link" :size="16" class="vertical-align-middle" />
</a>
</gl-popover>
</span>
</template>
......@@ -29,6 +29,7 @@ export default {
'pageInfo',
'vulnerabilities',
]),
...mapGetters('filters', ['activeFilters']),
...mapGetters('vulnerabilities', ['dashboardListError']),
showEmptyState() {
return (
......@@ -47,6 +48,9 @@ export default {
},
methods: {
...mapActions('vulnerabilities', ['fetchVulnerabilities', 'openModal']),
fetchPage(page) {
this.fetchVulnerabilities({ ...this.activeFilters, page });
},
},
};
</script>
......@@ -93,7 +97,7 @@ export default {
<pagination
v-if="showPagination"
:change="fetchVulnerabilities"
:change="fetchPage"
:page-info="pageInfo"
class="justify-content-center prepend-top-default"
/>
......
import Vue from 'vue';
import Vuex from 'vuex';
import vulnerabilities from './modules/vulnerabilities/index';
import filters from './modules/filters/index';
Vue.use(Vuex);
......@@ -8,5 +9,6 @@ export default () =>
new Vuex.Store({
modules: {
vulnerabilities,
filters,
},
});
import * as types from './mutation_types';
export const setFilter = ({ commit }, payload) => {
commit(types.SET_FILTER, payload);
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
// This is no longer needed after gitlab-ce#52179 is merged
export default () => {};
import { s__ } from '~/locale';
export const SEVERITIES = {
critical: 'Critical',
high: 'High',
medium: 'Medium',
low: 'Low',
unknown: 'Unknown',
experimental: 'Experimental',
ignore: 'Ignore',
undefined: 'Undefined',
};
export const REPORT_TYPES = {
sast: s__('ciReport|SAST'),
};
export const getFilter = state => filterId => state.filters.find(filter => filter.id === filterId);
export const getSelectedOptions = (state, getters) => filterId =>
getters.getFilter(filterId).options.filter(option => option.selected);
export const getSelectedOptionIds = (state, getters) => filterId =>
getters.getSelectedOptions(filterId).map(option => option.id);
export const getFilterIds = state => state.filters.map(filter => filter.id);
/**
* Loops through all the filters and returns all the active ones
* stripping out any that are set to 'all'
* @returns Object
* e.g. { type: ['sast'], severity: ['high', 'medium'] }
*/
export const activeFilters = (state, getters) =>
getters.getFilterIds.reduce(
(result, filterId) => ({
...result,
[filterId]: getters.getSelectedOptionIds(filterId).filter(option => option !== 'all'),
}),
{},
);
// prevent babel-plugin-rewire from generating an invalid default during karma tests
// This is no longer needed after gitlab-ce#52179 is merged
export default () => {};
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import state from './state';
export default {
namespaced: true,
actions,
getters,
mutations,
state,
};
export const SET_FILTER = 'SET_FILTER';
// This is here because es-lint requires a default export when there are less than two named exports
export default SET_FILTER;
import * as types from './mutation_types';
export default {
[types.SET_FILTER](state, payload) {
const { filterId, optionId } = payload;
const activeFilter = state.filters.find(filter => filter.id === filterId);
if (activeFilter) {
activeFilter.options = [
...activeFilter.options.map(option => ({
...option,
selected: option.id === optionId,
})),
];
}
},
};
import { SEVERITIES, REPORT_TYPES } from './constants';
export default () => ({
filters: [
{
name: 'Severity',
id: 'severity',
options: [
{
name: 'All',
id: 'all',
selected: true,
},
...Object.entries(SEVERITIES).map(severity => {
const [id, name] = severity;
return { id, name };
}),
],
},
{
name: 'Report type',
id: 'type',
options: [
{
name: REPORT_TYPES.sast,
id: 'sast',
selected: true,
},
],
},
],
});
......@@ -13,12 +13,13 @@ export const setVulnerabilitiesCountEndpoint = ({ commit }, endpoint) => {
commit(types.SET_VULNERABILITIES_COUNT_ENDPOINT, endpoint);
};
export const fetchVulnerabilitiesCount = ({ state, dispatch }) => {
export const fetchVulnerabilitiesCount = ({ state, dispatch }, params = {}) => {
dispatch('requestVulnerabilitiesCount');
axios({
method: 'GET',
url: state.vulnerabilitiesCountEndpoint,
params,
})
.then(response => {
const { data } = response;
......@@ -41,15 +42,13 @@ export const receiveVulnerabilitiesCountError = ({ commit }) => {
commit(types.RECEIVE_VULNERABILITIES_COUNT_ERROR);
};
export const fetchVulnerabilities = ({ state, dispatch }, pageNumber) => {
export const fetchVulnerabilities = ({ state, dispatch }, params = {}) => {
dispatch('requestVulnerabilities');
const page = pageNumber || (state.pageInfo && state.pageInfo.page) || 1;
axios({
method: 'GET',
url: state.vulnerabilitiesEndpoint,
params: { page },
params,
})
.then(response => {
const { headers, data } = response;
......@@ -208,12 +207,13 @@ export const setVulnerabilitiesHistoryEndpoint = ({ commit }, endpoint) => {
commit(types.SET_VULNERABILITIES_HISTORY_ENDPOINT, endpoint);
};
export const fetchVulnerabilitiesHistory = ({ state, dispatch }) => {
export const fetchVulnerabilitiesHistory = ({ state, dispatch }, params = {}) => {
dispatch('requestVulnerabilitiesHistory');
axios({
method: 'GET',
url: state.vulnerabilitiesHistoryEndpoint,
params,
})
.then(response => {
const { data } = response;
......
---
title: Adds basic filtering to the Group Security Dashboard frontend
merge_request: 8886
author:
type: added
import Vue from 'vue';
import component from 'ee/security_dashboard/components/filter.vue';
import createStore from 'ee/security_dashboard/store';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
describe('Filter component', () => {
let vm;
let props;
const store = createStore();
const Component = Vue.extend(component);
describe('severity', () => {
beforeEach(() => {
props = { filterId: 'severity', dashboardDocumentation: '' };
vm = mountComponentWithStore(Component, { store, props });
});
afterEach(() => {
vm.$destroy();
});
it('should display all 9 severity options', () => {
expect(vm.$el.querySelectorAll('.dropdown-item').length).toEqual(9);
});
it('should display a check next to only the selected item', () => {
expect(vm.$el.querySelectorAll('.dropdown-item .js-check').length).toEqual(1);
});
it('should display "Severity" as the option name', () => {
expect(vm.$el.querySelector('.js-name').textContent).toContain('Severity');
});
it('should not display the help popover', () => {
expect(vm.$el.querySelector('.js-help')).toBeNull();
});
});
describe('type', () => {
beforeEach(() => {
props = { filterId: 'type', dashboardDocumentation: '' };
vm = mountComponentWithStore(Component, { store, props });
});
it('should display the help popover', () => {
expect(vm.$el.querySelector('.js-help')).not.toBeNull();
});
});
});
import Vue from 'vue';
import component from 'ee/security_dashboard/components/filters.vue';
import createStore from 'ee/security_dashboard/store';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
describe('Filter component', () => {
let vm;
const props = { dashboardDocumentation: '' };
const store = createStore();
const Component = Vue.extend(component);
describe('severity', () => {
beforeEach(() => {
vm = mountComponentWithStore(Component, { store, props });
});
afterEach(() => {
vm.$destroy();
});
it('should display both filters', () => {
expect(vm.$el.querySelectorAll('.js-filter').length).toEqual(2);
});
});
});
......@@ -3,19 +3,15 @@ import component from 'ee/security_dashboard/components/vulnerability_count_list
import createStore from 'ee/security_dashboard/store';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { resetStore } from '../helpers';
import mockData from '../store/vulnerabilities/data/mock_data_vulnerabilities_count.json';
describe('Vulnerability Count List', () => {
const Component = Vue.extend(component);
const store = createStore();
const counts = {
sast: {
critical: 22,
},
};
let vm;
beforeEach(() => {
store.dispatch('vulnerabilities/receiveVulnerabilitiesCountSuccess', { data: counts });
store.dispatch('vulnerabilities/receiveVulnerabilitiesCountSuccess', { data: mockData });
vm = mountComponentWithStore(Component, { store });
});
......@@ -25,7 +21,11 @@ describe('Vulnerability Count List', () => {
});
it('should fetch the counts for each severity', () => {
expect(vm.counts[0]).toEqual({ severity: 'critical', count: 22 });
const { sast, container_scanning, dependency_scanning, dast } = mockData;
const count =
sast.critical + container_scanning.critical + dependency_scanning.critical + dast.critical;
expect(vm.counts[0]).toEqual({ severity: 'critical', count });
});
it('should render a counter for each severity', () => {
......
import vulnerabilitiesState from 'ee/security_dashboard/store/modules/vulnerabilities/state';
import filterState from 'ee/security_dashboard/store/modules/filters/state';
// eslint-disable-next-line import/prefer-default-export
export const resetStore = store => {
const newState = {
vulnerabilities: vulnerabilitiesState(),
filters: filterState(),
};
store.replaceState(newState);
};
import testAction from 'spec/helpers/vuex_action_helper';
import createState from 'ee/security_dashboard/store/modules/filters/state';
import * as types from 'ee/security_dashboard/store/modules/filters/mutation_types';
import * as actions from 'ee/security_dashboard/store/modules/filters/actions';
describe('filters actions', () => {
describe('setFilter', () => {
it('should commit the SET_FILTER mutuation', done => {
const state = createState();
const payload = { filterId: 'type', optionId: 'sast' };
testAction(
actions.setFilter,
payload,
state,
[
{
type: types.SET_FILTER,
payload,
},
],
[],
done,
);
});
});
});
import createState from 'ee/security_dashboard/store/modules/filters/state';
import * as getters from 'ee/security_dashboard/store/modules/filters/getters';
describe('filters module getters', () => {
const mockedGetters = state => {
const getFilter = filterId => getters.getFilter(state)(filterId);
const getSelectedOptions = filterId =>
getters.getSelectedOptions(state, { getFilter })(filterId);
const getSelectedOptionIds = filterId =>
getters.getSelectedOptionIds(state, { getSelectedOptions })(filterId);
const getFilterIds = getters.getFilterIds(state);
return {
getFilter,
getSelectedOptions,
getSelectedOptionIds,
getFilterIds,
};
};
describe('getFilter', () => {
it('should return the type filter information', () => {
const state = createState();
const typeFilter = getters.getFilter(state)('type');
expect(typeFilter.name).toEqual('Report type');
});
});
describe('getSelectedOptions', () => {
it('should return "SAST" as the selcted option', () => {
const state = createState();
const selectedOptions = getters.getSelectedOptions(state, mockedGetters(state))('type');
expect(selectedOptions).toHaveLength(1);
expect(selectedOptions[0].name).toEqual('SAST');
});
});
describe('getSelectedOptionIds', () => {
it('should return "sast" as the selcted option ID', () => {
const state = createState();
const selectedOptionIds = getters.getSelectedOptionIds(state, mockedGetters(state))('type');
expect(selectedOptionIds).toHaveLength(1);
expect(selectedOptionIds[0]).toEqual('sast');
});
});
describe('activeFilters', () => {
it('should return no severity filters', () => {
const state = createState();
const activeFilters = getters.activeFilters(state, mockedGetters(state));
expect(activeFilters.severity).toHaveLength(0);
});
it('should return the SAST type filter', () => {
const state = createState();
const activeFilters = getters.activeFilters(state, mockedGetters(state));
expect(activeFilters.type).toHaveLength(1);
expect(activeFilters.type[0]).toEqual('sast');
});
it('should return multiple project filters"', () => {
const state = createState();
const projectFilter = {
id: 'project',
options: [{ id: 'one', selected: true }, { id: 'anotherone', selected: true }],
};
state.filters.push(projectFilter);
const activeFilters = getters.activeFilters(state, mockedGetters(state));
expect(activeFilters.project).toHaveLength(2);
});
});
});
import createState from 'ee/security_dashboard/store/modules/filters/state';
import * as types from 'ee/security_dashboard/store/modules/filters/mutation_types';
import mutations from 'ee/security_dashboard/store/modules/filters/mutations';
describe('filters module mutations', () => {
describe('SET_FILTER', () => {
let state;
let typeFilter;
let sastOption;
beforeEach(() => {
state = createState();
[typeFilter] = state.filters;
[, sastOption] = typeFilter.options;
mutations[types.SET_FILTER](state, {
filterId: typeFilter.id,
optionId: sastOption.id,
});
});
it('should make SAST the selected option', () => {
expect(state.filters[0].options[1].selected).toEqual(true);
});
it('should remove ALL as the selected option', () => {
expect(state.filters[0].options[0].selected).toEqual(false);
});
});
});
......@@ -13,6 +13,8 @@ import mockDataVulnerabilitiesHistory from './data/mock_data_vulnerabilities_his
describe('vulnerabiliites count actions', () => {
const data = mockDataVulnerabilitiesCount;
const params = { filters: { type: ['sast'] } };
const filteredData = mockDataVulnerabilitiesCount.sast;
describe('setVulnerabilitiesCountEndpoint', () => {
it('should commit the correct mutuation', done => {
......@@ -50,7 +52,11 @@ describe('vulnerabiliites count actions', () => {
describe('on success', () => {
beforeEach(() => {
mock.onGet(state.vulnerabilitiesCountEndpoint).replyOnce(200, data);
mock
.onGet(state.vulnerabilitiesCountEndpoint, { params })
.replyOnce(200, filteredData)
.onGet(state.vulnerabilitiesCountEndpoint)
.replyOnce(200, data);
});
it('should dispatch the request and success actions', done => {
......@@ -69,6 +75,23 @@ describe('vulnerabiliites count actions', () => {
done,
);
});
it('should send the passed filters to the endpoint', done => {
testAction(
actions.fetchVulnerabilitiesCount,
params,
state,
[],
[
{ type: 'requestVulnerabilitiesCount' },
{
type: 'receiveVulnerabilitiesCountSuccess',
payload: { data: filteredData },
},
],
done,
);
});
});
describe('on error', () => {
......@@ -137,6 +160,8 @@ describe('vulnerabiliites count actions', () => {
describe('vulnerabilities actions', () => {
const data = mockDataVulnerabilities;
const params = { filters: { severity: ['critical'] } };
const filteredData = mockDataVulnerabilities.filter(vuln => vuln.severity === 'critical');
const pageInfo = {
page: 1,
nextPage: 2,
......@@ -169,7 +194,11 @@ describe('vulnerabilities actions', () => {
describe('on success', () => {
beforeEach(() => {
mock.onGet(state.vulnerabilitiesEndpoint).replyOnce(200, data, headers);
mock
.onGet(state.vulnerabilitiesEndpoint, { params })
.replyOnce(200, filteredData, headers)
.onGet(state.vulnerabilitiesEndpoint)
.replyOnce(200, data, headers);
});
it('should dispatch the request and success actions', done => {
......@@ -188,6 +217,23 @@ describe('vulnerabilities actions', () => {
done,
);
});
it('should pass through the filters', done => {
testAction(
actions.fetchVulnerabilities,
params,
state,
[],
[
{ type: 'requestVulnerabilities' },
{
type: 'receiveVulnerabilitiesSuccess',
payload: { data: filteredData, headers },
},
],
done,
);
});
});
describe('on error', () => {
......@@ -636,8 +682,10 @@ describe('revert vulnerability dismissal', () => {
});
});
describe('vulnerabiliites timeline actions', () => {
describe('vulnerabilities history actions', () => {
const data = mockDataVulnerabilitiesHistory;
const params = { filters: { severity: ['critical'] } };
const filteredData = mockDataVulnerabilitiesHistory.critical;
describe('setVulnerabilitiesHistoryEndpoint', () => {
it('should commit the correct mutuation', done => {
......@@ -675,7 +723,11 @@ describe('vulnerabiliites timeline actions', () => {
describe('on success', () => {
beforeEach(() => {
mock.onGet(state.vulnerabilitiesHistoryEndpoint).replyOnce(200, data);
mock
.onGet(state.vulnerabilitiesHistoryEndpoint, { params })
.replyOnce(200, filteredData)
.onGet(state.vulnerabilitiesHistoryEndpoint)
.replyOnce(200, data);
});
it('should dispatch the request and success actions', done => {
......@@ -694,6 +746,23 @@ describe('vulnerabiliites timeline actions', () => {
done,
);
});
it('return the filtered results', done => {
testAction(
actions.fetchVulnerabilitiesHistory,
params,
state,
[],
[
{ type: 'requestVulnerabilitiesHistory' },
{
type: 'receiveVulnerabilitiesHistorySuccess',
payload: { data: filteredData },
},
],
done,
);
});
});
describe('on error', () => {
......
......@@ -7534,9 +7534,6 @@ msgstr ""
msgid "SAML for %{group_name}"
msgstr ""
msgid "SAST"
msgstr ""
msgid "SHA1 fingerprint of the SAML token signing certificate. Get this from your identity provider, where it can also be called \"Thumbprint\"."
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