Commit f41fd4b4 authored by Savas Vedova's avatar Savas Vedova

Add project filter

- To instance level dashboard and
- To group level dashboard
- Update queries to reflect this filter
- Add tests
- Rename constants to helpers
parent 46e975af
...@@ -32,12 +32,16 @@ export default { ...@@ -32,12 +32,16 @@ export default {
data() { data() {
return { return {
filters: {}, filters: {},
projects: [],
}; };
}, },
methods: { methods: {
handleFilterChange(filters) { handleFilterChange(filters) {
this.filters = filters; this.filters = filters;
}, },
handleProjectsFetch(projects) {
this.projects = projects;
},
}, },
}; };
</script> </script>
...@@ -45,13 +49,14 @@ export default { ...@@ -45,13 +49,14 @@ export default {
<template> <template>
<security-dashboard-layout> <security-dashboard-layout>
<template #header> <template #header>
<filters @filterChange="handleFilterChange" /> <filters :projects="projects" @filterChange="handleFilterChange" />
</template> </template>
<group-security-vulnerabilities <group-security-vulnerabilities
:dashboard-documentation="dashboardDocumentation" :dashboard-documentation="dashboardDocumentation"
:empty-state-svg-path="emptyStateSvgPath" :empty-state-svg-path="emptyStateSvgPath"
:group-full-path="groupFullPath" :group-full-path="groupFullPath"
:filters="filters" :filters="filters"
@projectFetch="handleProjectsFetch"
/> />
<template #aside> <template #aside>
<vulnerability-severity :endpoint="vulnerableProjectsEndpoint" /> <vulnerability-severity :endpoint="vulnerableProjectsEndpoint" />
......
...@@ -51,6 +51,7 @@ export default { ...@@ -51,6 +51,7 @@ export default {
}, },
update: ({ group }) => group.vulnerabilities.nodes, update: ({ group }) => group.vulnerabilities.nodes,
result({ data }) { result({ data }) {
this.$emit('projectFetch', data.group.projects.nodes);
this.pageInfo = data.group.vulnerabilities.pageInfo; this.pageInfo = data.group.vulnerabilities.pageInfo;
}, },
error() { error() {
......
...@@ -52,6 +52,7 @@ export default { ...@@ -52,6 +52,7 @@ export default {
data() { data() {
return { return {
filters: {}, filters: {},
graphqlProjectList: [], // TODO: Rename me to projects once we back the project selector with GraphQL as well
showProjectSelector: false, showProjectSelector: false,
}; };
}, },
...@@ -94,6 +95,9 @@ export default { ...@@ -94,6 +95,9 @@ export default {
toggleProjectSelector() { toggleProjectSelector() {
this.showProjectSelector = !this.showProjectSelector; this.showProjectSelector = !this.showProjectSelector;
}, },
handleProjectFetch(projects) {
this.graphqlProjectList = projects;
},
}, },
}; };
</script> </script>
...@@ -111,7 +115,12 @@ export default { ...@@ -111,7 +115,12 @@ export default {
>{{ toggleButtonProps.text }}</gl-button >{{ toggleButtonProps.text }}</gl-button
> >
</header> </header>
<filters v-if="shouldShowDashboard" @filterChange="handleFilterChange" /> <filters
v-if="shouldShowDashboard"
:projects="graphqlProjectList"
@filterChange="handleFilterChange"
@projectFetch="handleProjectFetch"
/>
</template> </template>
<instance-security-vulnerabilities <instance-security-vulnerabilities
v-if="shouldShowDashboard" v-if="shouldShowDashboard"
...@@ -119,6 +128,7 @@ export default { ...@@ -119,6 +128,7 @@ export default {
:dashboard-documentation="dashboardDocumentation" :dashboard-documentation="dashboardDocumentation"
:empty-state-svg-path="emptyStateSvgPath" :empty-state-svg-path="emptyStateSvgPath"
:filters="filters" :filters="filters"
@projectFetch="handleProjectFetch"
/> />
<gl-empty-state <gl-empty-state
v-else-if="shouldShowEmptyState" v-else-if="shouldShowEmptyState"
......
...@@ -57,6 +57,7 @@ export default { ...@@ -57,6 +57,7 @@ export default {
result({ data, loading }) { result({ data, loading }) {
this.isFirstResultLoading = loading; this.isFirstResultLoading = loading;
this.pageInfo = data.vulnerabilities.pageInfo; this.pageInfo = data.vulnerabilities.pageInfo;
this.$emit('projectFetch', data.instanceSecurityDashboard.projects.nodes);
}, },
error() { error() {
this.errorLoadingVulnerabilities = true; this.errorLoadingVulnerabilities = true;
......
<script> <script>
import { initFirstClassVulnerabilityFilters } from 'ee/security_dashboard/constants'; import { initFirstClassVulnerabilityFilters, mapProjects } from 'ee/security_dashboard/helpers';
import { ALL } from 'ee/security_dashboard/store/modules/filters/constants'; import { ALL } from 'ee/security_dashboard/store/modules/filters/constants';
import { setFilter } from 'ee/security_dashboard/store/modules/filters/utils'; import { setFilter } from 'ee/security_dashboard/store/modules/filters/utils';
import DashboardFilter from 'ee/security_dashboard/components/filter.vue'; import DashboardFilter from 'ee/security_dashboard/components/filter.vue';
...@@ -8,11 +8,27 @@ export default { ...@@ -8,11 +8,27 @@ export default {
components: { components: {
DashboardFilter, DashboardFilter,
}, },
props: {
projects: { type: Array, required: false, default: undefined },
},
data() { data() {
return { return {
filters: initFirstClassVulnerabilityFilters(), filters: initFirstClassVulnerabilityFilters(this.projects),
}; };
}, },
watch: {
/**
* Initially the project list empty. We fetch them dynamically from GraphQL while
* fetching the list of vulnerabilities. We display the project filter with the base
* option and when the projects are fetched we add them to the list.
*/
projects(newProjects, oldProjects) {
if (oldProjects.length === 0) {
const projectFilter = this.filters[3];
projectFilter.options = [projectFilter.options[0], ...mapProjects(this.projects)];
}
},
},
methods: { methods: {
setFilter(options) { setFilter(options) {
const selectedFilters = {}; const selectedFilters = {};
......
import { s__ } from '~/locale';
import { ALL, BASE_FILTERS } from 'ee/security_dashboard/store/modules/filters/constants';
import { REPORT_TYPES, SEVERITY_LEVELS } from 'ee/security_dashboard/store/constants';
import { VULNERABILITY_STATES } from 'ee/vulnerabilities/constants';
const parseOptions = obj =>
Object.entries(obj).map(([id, name]) => ({ id: id.toUpperCase(), name }));
export const initFirstClassVulnerabilityFilters = () => [
{
name: s__('SecurityReports|Status'),
id: 'state',
options: [
{ id: ALL, name: s__('VulnerabilityStatusTypes|All') },
...parseOptions(VULNERABILITY_STATES),
],
selection: new Set([ALL]),
},
{
name: s__('SecurityReports|Severity'),
id: 'severity',
options: [BASE_FILTERS.severity, ...parseOptions(SEVERITY_LEVELS)],
selection: new Set([ALL]),
},
{
name: s__('SecurityReports|Report type'),
id: 'reportType',
options: [BASE_FILTERS.report_type, ...parseOptions(REPORT_TYPES)],
selection: new Set([ALL]),
},
];
export default () => ({});
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" #import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
#import "ee/vulnerabilities/graphql/vulnerability.fragment.graphql" #import "ee/vulnerabilities/graphql/vulnerability.fragment.graphql"
#import "./project.fragment.graphql"
query group( query group(
$fullPath: ID!, $fullPath: ID!
$after: String, $after: String
$first: Int, $first: Int
$projectId: [ID!]
$severity: [VulnerabilitySeverity!] $severity: [VulnerabilitySeverity!]
$reportType: [VulnerabilityReportType!] $reportType: [VulnerabilityReportType!]
$state: [VulnerabilityState!] $state: [VulnerabilityState!]
) { ) {
group(fullPath: $fullPath) { group(fullPath: $fullPath) {
projects(hasVulnerabilities: true) {
nodes {
...Project
}
}
vulnerabilities( vulnerabilities(
after:$after, after: $after
first:$first, first: $first
severity: $severity severity: $severity
reportType: $reportType reportType: $reportType
state: $state state: $state
projectId: $projectId
){ ){
nodes{ nodes{
...Vulnerability ...Vulnerability
......
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" #import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
#import "ee/vulnerabilities/graphql/vulnerability.fragment.graphql" #import "ee/vulnerabilities/graphql/vulnerability.fragment.graphql"
#import "./project.fragment.graphql"
query instance( query instance(
$after: String, $after: String
$first: Int $first: Int
$projectId: [ID!]
$severity: [VulnerabilitySeverity!] $severity: [VulnerabilitySeverity!]
$reportType: [VulnerabilityReportType!] $reportType: [VulnerabilityReportType!]
$state: [VulnerabilityState!] $state: [VulnerabilityState!]
) { ) {
instanceSecurityDashboard {
projects{
nodes{
...Project
}
}
}
vulnerabilities( vulnerabilities(
after:$after, after: $after
first:$first, first: $first
severity: $severity severity: $severity
reportType: $reportType reportType: $reportType
state: $state state: $state
projectId: $projectId
) { ) {
nodes { nodes {
...Vulnerability ...Vulnerability
......
import { s__ } from '~/locale';
import { ALL, BASE_FILTERS } from 'ee/security_dashboard/store/modules/filters/constants';
import { REPORT_TYPES, SEVERITY_LEVELS } from 'ee/security_dashboard/store/constants';
import { VULNERABILITY_STATES } from 'ee/vulnerabilities/constants';
const parseOptions = obj =>
Object.entries(obj).map(([id, name]) => ({ id: id.toUpperCase(), name }));
export const mapProjects = projects =>
projects.map(p => ({ id: p.id.split('/').pop(), name: p.name }));
export const initFirstClassVulnerabilityFilters = projects => {
const filters = [
{
name: s__('SecurityReports|Status'),
id: 'state',
options: [
{ id: ALL, name: s__('VulnerabilityStatusTypes|All') },
...parseOptions(VULNERABILITY_STATES),
],
selection: new Set([ALL]),
},
{
name: s__('SecurityReports|Severity'),
id: 'severity',
options: [BASE_FILTERS.severity, ...parseOptions(SEVERITY_LEVELS)],
selection: new Set([ALL]),
},
{
name: s__('SecurityReports|Report type'),
id: 'reportType',
options: [BASE_FILTERS.report_type, ...parseOptions(REPORT_TYPES)],
selection: new Set([ALL]),
},
];
if (Array.isArray(projects)) {
filters.push({
name: s__('SecurityReports|Project'),
id: 'projectId',
options: [BASE_FILTERS.project_id, ...mapProjects(projects)],
selection: new Set([ALL]),
});
}
return filters;
};
export default () => ({});
---
title: Add project filter
merge_request: 31444
author:
type: added
...@@ -52,7 +52,15 @@ describe('First Class Group Dashboard Component', () => { ...@@ -52,7 +52,15 @@ describe('First Class Group Dashboard Component', () => {
expect(findFilters().exists()).toBe(true); expect(findFilters().exists()).toBe(true);
}); });
it('it responds to the filterChange event', () => { it('responds to the projectFetch event', () => {
const projects = [{ id: 1, name: 'GitLab Org' }];
findGroupVulnerabilities().vm.$listeners.projectFetch(projects);
return wrapper.vm.$nextTick(() => {
expect(findFilters().props('projects')).toEqual(projects);
});
});
it('responds to the filterChange event', () => {
const filters = { severity: 'critical' }; const filters = { severity: 'critical' };
findFilters().vm.$listeners.filterChange(filters); findFilters().vm.$listeners.filterChange(filters);
return wrapper.vm.$nextTick(() => { return wrapper.vm.$nextTick(() => {
......
...@@ -92,7 +92,15 @@ describe('First Class Instance Dashboard Component', () => { ...@@ -92,7 +92,15 @@ describe('First Class Instance Dashboard Component', () => {
expect(findFilters().exists()).toBe(true); expect(findFilters().exists()).toBe(true);
}); });
it('it responds to the filterChange event', () => { it('responds to the projectFetch event', () => {
const projects = [{ id: 1, name: 'GitLab Org' }];
findInstanceVulnerabilities().vm.$listeners.projectFetch(projects);
return wrapper.vm.$nextTick(() => {
expect(findFilters().props('projects')).toEqual(projects);
});
});
it('responds to the filterChange event', () => {
const filters = { severity: 'critical' }; const filters = { severity: 'critical' };
findFilters().vm.$listeners.filterChange(filters); findFilters().vm.$listeners.filterChange(filters);
return wrapper.vm.$nextTick(() => { return wrapper.vm.$nextTick(() => {
......
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { initFirstClassVulnerabilityFilters } from 'ee/security_dashboard/constants'; import { initFirstClassVulnerabilityFilters } from 'ee/security_dashboard/helpers';
import Filters from 'ee/security_dashboard/components/first_class_vulnerability_filters.vue'; import Filters from 'ee/security_dashboard/components/first_class_vulnerability_filters.vue';
import Filter from 'ee/security_dashboard/components/filter.vue'; import Filter from 'ee/security_dashboard/components/filter.vue';
describe('First class vulnerability filters component', () => { describe('First class vulnerability filters component', () => {
let wrapper; let wrapper;
let filters; let filters;
const projects = [
{ id: 'gid://gitlab/Project/11', name: 'GitLab Org' },
{ id: 'gid://gitlab/Project/12', name: 'GitLab Com' },
];
const findFilters = () => wrapper.findAll(Filter); const findFilters = () => wrapper.findAll(Filter);
const findFirstFilter = () => findFilters().at(0); const findFirstFilter = () => findFilters().at(0);
const findLastFilter = () => findFilters().at(filters.length - 1);
const createComponent = () => { const createComponent = ({ propsData } = {}) => {
wrapper = shallowMount(Filters); return shallowMount(Filters, { propsData });
}; };
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null;
}); });
describe('on render', () => { describe('on render without project filter', () => {
beforeEach(() => { beforeEach(() => {
createComponent(); wrapper = createComponent();
filters = initFirstClassVulnerabilityFilters(); filters = initFirstClassVulnerabilityFilters();
}); });
...@@ -41,29 +47,79 @@ describe('First class vulnerability filters component', () => { ...@@ -41,29 +47,79 @@ describe('First class vulnerability filters component', () => {
expect(stub).toHaveBeenCalledWith(options); expect(stub).toHaveBeenCalledWith(options);
}); });
});
describe('when setFilter is called', () => { describe('when project filter is populated dynamically', () => {
let filterId; beforeEach(() => {
let optionId; filters = initFirstClassVulnerabilityFilters([]);
wrapper = createComponent({ propsData: { projects: [] } });
});
beforeEach(() => { it('should render the project filter with one option', () => {
filterId = filters[0].id; expect(findLastFilter().props('filter')).toEqual({
optionId = filters[0].options[1].id; id: 'projectId',
name: 'Project',
options: [{ id: 'all', name: 'All projects' }],
selection: new Set(['all']),
});
});
wrapper.vm.setFilter({ filterId, optionId }); it('should set the projects dynamically', () => {
wrapper.setProps({ projects });
return wrapper.vm.$nextTick(() => {
expect(findLastFilter().props('filter')).toEqual(
expect.objectContaining({
options: [
{ id: 'all', name: 'All projects' },
{ id: '11', name: 'GitLab Org' },
{ id: '12', name: 'GitLab Com' },
],
}),
);
}); });
});
});
it('should set the filters locally', () => { describe('when project filter is ready on mount', () => {
const expectedFilters = initFirstClassVulnerabilityFilters(); beforeEach(() => {
expectedFilters[0].selection = new Set([optionId]); filters = initFirstClassVulnerabilityFilters([]);
wrapper = createComponent({ propsData: { projects } });
});
expect(wrapper.vm.filters).toEqual(expectedFilters); it('should set the projects dynamically', () => {
}); expect(findLastFilter().props('filter')).toEqual(
expect.objectContaining({
options: [
{ id: 'all', name: 'All projects' },
{ id: '11', name: 'GitLab Org' },
{ id: '12', name: 'GitLab Com' },
],
}),
);
});
});
it('should emit selected filters when a filter is set', () => { describe('when setFilter is called', () => {
expect(wrapper.emitted().filterChange).toBeTruthy(); let filterId;
expect(wrapper.emitted().filterChange[0]).toEqual([{ [filterId]: [optionId] }]); let optionId;
});
beforeEach(() => {
filterId = filters[0].id;
optionId = filters[0].options[1].id;
wrapper = createComponent();
wrapper.vm.setFilter({ filterId, optionId });
});
it('should set the filters locally', () => {
const expectedFilters = initFirstClassVulnerabilityFilters();
expectedFilters[0].selection = new Set([optionId]);
expect(wrapper.vm.filters).toEqual(expectedFilters);
});
it('should emit selected filters when a filter is set', () => {
expect(wrapper.emitted().filterChange).toBeTruthy();
expect(wrapper.emitted().filterChange[0]).toEqual([{ [filterId]: [optionId] }]);
}); });
}); });
}); });
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