Commit aafb4321 authored by Savas Vedova's avatar Savas Vedova

Add sorting functionality to severity column

- Update graphql queries
- Implement backend sorting for vulnerability list
- Add changelog
parent 49d7ee1e
...@@ -28,6 +28,8 @@ export default { ...@@ -28,6 +28,8 @@ export default {
pageInfo: {}, pageInfo: {},
vulnerabilities: [], vulnerabilities: [],
errorLoadingVulnerabilities: false, errorLoadingVulnerabilities: false,
sortBy: 'severity',
sortDirection: 'desc',
}; };
}, },
apollo: { apollo: {
...@@ -37,6 +39,7 @@ export default { ...@@ -37,6 +39,7 @@ export default {
return { return {
fullPath: this.groupFullPath, fullPath: this.groupFullPath,
first: VULNERABILITIES_PER_PAGE, first: VULNERABILITIES_PER_PAGE,
sort: this.sort,
...this.filters, ...this.filters,
}; };
}, },
...@@ -56,6 +59,9 @@ export default { ...@@ -56,6 +59,9 @@ export default {
isLoadingFirstResult() { isLoadingFirstResult() {
return this.isLoadingQuery && this.vulnerabilities.length === 0; return this.isLoadingQuery && this.vulnerabilities.length === 0;
}, },
sort() {
return `${this.sortBy}_${this.sortDirection}`;
},
}, },
methods: { methods: {
onErrorDismiss() { onErrorDismiss() {
...@@ -74,6 +80,10 @@ export default { ...@@ -74,6 +80,10 @@ export default {
}); });
} }
}, },
handleSortChange({ sortBy, sortDesc }) {
this.sortDirection = sortDesc ? 'desc' : 'asc';
this.sortBy = sortBy;
},
}, },
}; };
</script> </script>
...@@ -98,6 +108,7 @@ export default { ...@@ -98,6 +108,7 @@ export default {
:is-loading="isLoadingFirstResult" :is-loading="isLoadingFirstResult"
:vulnerabilities="vulnerabilities" :vulnerabilities="vulnerabilities"
should-show-project-namespace should-show-project-namespace
@sort-changed="handleSortChange"
/> />
<gl-intersection-observer <gl-intersection-observer
v-if="pageInfo.hasNextPage" v-if="pageInfo.hasNextPage"
......
...@@ -27,12 +27,17 @@ export default { ...@@ -27,12 +27,17 @@ export default {
isFirstResultLoading: true, isFirstResultLoading: true,
vulnerabilities: [], vulnerabilities: [],
errorLoadingVulnerabilities: false, errorLoadingVulnerabilities: false,
sortBy: 'severity',
sortDirection: 'desc',
}; };
}, },
computed: { computed: {
isQueryLoading() { isQueryLoading() {
return this.$apollo.queries.vulnerabilities.loading; return this.$apollo.queries.vulnerabilities.loading;
}, },
sort() {
return `${this.sortBy}_${this.sortDirection}`;
},
}, },
apollo: { apollo: {
vulnerabilities: { vulnerabilities: {
...@@ -41,6 +46,7 @@ export default { ...@@ -41,6 +46,7 @@ export default {
variables() { variables() {
return { return {
first: VULNERABILITIES_PER_PAGE, first: VULNERABILITIES_PER_PAGE,
sort: this.sort,
...this.filters, ...this.filters,
}; };
}, },
...@@ -69,6 +75,10 @@ export default { ...@@ -69,6 +75,10 @@ export default {
}); });
} }
}, },
handleSortChange({ sortBy, sortDesc }) {
this.sortDirection = sortDesc ? 'desc' : 'asc';
this.sortBy = sortBy;
},
}, },
}; };
</script> </script>
...@@ -93,6 +103,7 @@ export default { ...@@ -93,6 +103,7 @@ export default {
:is-loading="isFirstResultLoading" :is-loading="isFirstResultLoading"
:vulnerabilities="vulnerabilities" :vulnerabilities="vulnerabilities"
should-show-project-namespace should-show-project-namespace
@sort-changed="handleSortChange"
/> />
<gl-intersection-observer <gl-intersection-observer
v-if="pageInfo.hasNextPage" v-if="pageInfo.hasNextPage"
......
...@@ -34,6 +34,8 @@ export default { ...@@ -34,6 +34,8 @@ export default {
vulnerabilities: [], vulnerabilities: [],
securityScanners: {}, securityScanners: {},
errorLoadingVulnerabilities: false, errorLoadingVulnerabilities: false,
sortBy: 'severity',
sortDirection: 'desc',
}; };
}, },
apollo: { apollo: {
...@@ -43,6 +45,7 @@ export default { ...@@ -43,6 +45,7 @@ export default {
return { return {
fullPath: this.projectFullPath, fullPath: this.projectFullPath,
first: VULNERABILITIES_PER_PAGE, first: VULNERABILITIES_PER_PAGE,
sort: this.sort,
...this.filters, ...this.filters,
}; };
}, },
...@@ -86,6 +89,9 @@ export default { ...@@ -86,6 +89,9 @@ export default {
isLoadingFirstVulnerabilities() { isLoadingFirstVulnerabilities() {
return this.isLoadingVulnerabilities && this.vulnerabilities.length === 0; return this.isLoadingVulnerabilities && this.vulnerabilities.length === 0;
}, },
sort() {
return `${this.sortBy}_${this.sortDirection}`;
},
}, },
methods: { methods: {
fetchNextPage() { fetchNextPage() {
...@@ -106,6 +112,10 @@ export default { ...@@ -106,6 +112,10 @@ export default {
refetchVulnerabilities() { refetchVulnerabilities() {
this.$apollo.queries.vulnerabilities.refetch(); this.$apollo.queries.vulnerabilities.refetch();
}, },
handleSortChange({ sortBy, sortDesc }) {
this.sortDirection = sortDesc ? 'desc' : 'asc';
this.sortBy = sortBy;
},
}, },
i18n: { i18n: {
CONTAINER_SCANNING: __('Container Scanning'), CONTAINER_SCANNING: __('Container Scanning'),
...@@ -132,6 +142,7 @@ export default { ...@@ -132,6 +142,7 @@ export default {
:vulnerabilities="vulnerabilities" :vulnerabilities="vulnerabilities"
:security-scanners="securityScanners" :security-scanners="securityScanners"
@refetch-vulnerabilities="refetchVulnerabilities" @refetch-vulnerabilities="refetchVulnerabilities"
@sort-changed="handleSortChange"
/> />
<gl-intersection-observer <gl-intersection-observer
v-if="pageInfo.hasNextPage" v-if="pageInfo.hasNextPage"
......
...@@ -27,7 +27,6 @@ export const SCANNER_ALERT_DISMISSED_LOCAL_STORAGE_KEY = ...@@ -27,7 +27,6 @@ export const SCANNER_ALERT_DISMISSED_LOCAL_STORAGE_KEY =
'vulnerability_list_scanner_alert_dismissed'; 'vulnerability_list_scanner_alert_dismissed';
export default { export default {
name: 'VulnerabilityList',
components: { components: {
GlFormCheckbox, GlFormCheckbox,
GlLink, GlLink,
...@@ -82,9 +81,14 @@ export default { ...@@ -82,9 +81,14 @@ export default {
return { return {
selectedVulnerabilities: {}, selectedVulnerabilities: {},
scannerAlertDismissed: 'false', scannerAlertDismissed: 'false',
sortBy: 'severity',
sortDesc: true,
}; };
}, },
computed: { computed: {
isSortable() {
return Boolean(this.$listeners['sort-changed']);
},
hasAnyScannersOtherThanGitLab() { hasAnyScannersOtherThanGitLab() {
return this.vulnerabilities.some(v => v.scanner?.vendor !== 'GitLab'); return this.vulnerabilities.some(v => v.scanner?.vendor !== 'GitLab');
}, },
...@@ -140,6 +144,7 @@ export default { ...@@ -140,6 +144,7 @@ export default {
label: s__('Vulnerability|Severity'), label: s__('Vulnerability|Severity'),
thClass: 'severity', thClass: 'severity',
tdClass: 'severity', tdClass: 'severity',
sortable: this.isSortable,
}, },
{ {
key: 'title', key: 'title',
...@@ -263,6 +268,11 @@ export default { ...@@ -263,6 +268,11 @@ export default {
useConvertReportType(reportType) { useConvertReportType(reportType) {
return convertReportType(reportType); return convertReportType(reportType);
}, },
handleSortChange(args) {
if (args.sortBy) {
this.$emit('sort-changed', args);
}
},
}, },
VULNERABILITIES_PER_PAGE, VULNERABILITIES_PER_PAGE,
SCANNER_ALERT_DISMISSED_LOCAL_STORAGE_KEY, SCANNER_ALERT_DISMISSED_LOCAL_STORAGE_KEY,
...@@ -287,10 +297,15 @@ export default { ...@@ -287,10 +297,15 @@ export default {
:fields="fields" :fields="fields"
:items="vulnerabilities" :items="vulnerabilities"
:thead-class="theadClass" :thead-class="theadClass"
:sort-desc="sortDesc"
:sort-by="sortBy"
sort-icon-left
no-local-sorting
stacked="sm" stacked="sm"
class="vulnerability-list" class="vulnerability-list"
show-empty show-empty
responsive responsive
@sort-changed="handleSortChange"
> >
<template #head(checkbox)> <template #head(checkbox)>
<gl-form-checkbox <gl-form-checkbox
......
...@@ -10,6 +10,7 @@ query group( ...@@ -10,6 +10,7 @@ query group(
$reportType: [VulnerabilityReportType!] $reportType: [VulnerabilityReportType!]
$scanner: [String!] $scanner: [String!]
$state: [VulnerabilityState!] $state: [VulnerabilityState!]
$sort: VulnerabilitySort
) { ) {
group(fullPath: $fullPath) { group(fullPath: $fullPath) {
vulnerabilities( vulnerabilities(
...@@ -20,6 +21,7 @@ query group( ...@@ -20,6 +21,7 @@ query group(
scanner: $scanner scanner: $scanner
state: $state state: $state
projectId: $projectId projectId: $projectId
sort: $sort
) { ) {
nodes { nodes {
...Vulnerability ...Vulnerability
......
...@@ -9,6 +9,7 @@ query instance( ...@@ -9,6 +9,7 @@ query instance(
$reportType: [VulnerabilityReportType!] $reportType: [VulnerabilityReportType!]
$scanner: [String!] $scanner: [String!]
$state: [VulnerabilityState!] $state: [VulnerabilityState!]
$sort: VulnerabilitySort
) { ) {
vulnerabilities( vulnerabilities(
after: $after after: $after
...@@ -18,6 +19,7 @@ query instance( ...@@ -18,6 +19,7 @@ query instance(
state: $state state: $state
projectId: $projectId projectId: $projectId
scanner: $scanner scanner: $scanner
sort: $sort
) { ) {
nodes { nodes {
...Vulnerability ...Vulnerability
......
...@@ -9,6 +9,7 @@ query project( ...@@ -9,6 +9,7 @@ query project(
$reportType: [VulnerabilityReportType!] $reportType: [VulnerabilityReportType!]
$scanner: [String!] $scanner: [String!]
$state: [VulnerabilityState!] $state: [VulnerabilityState!]
$sort: VulnerabilitySort
) { ) {
project(fullPath: $fullPath) { project(fullPath: $fullPath) {
vulnerabilities( vulnerabilities(
...@@ -18,6 +19,7 @@ query project( ...@@ -18,6 +19,7 @@ query project(
reportType: $reportType reportType: $reportType
scanner: $scanner scanner: $scanner
state: $state state: $state
sort: $sort
) { ) {
nodes { nodes {
...Vulnerability ...Vulnerability
......
---
title: Add sorting functionality to vulnerability list
merge_request: 42347
author:
type: added
...@@ -109,6 +109,23 @@ describe('First Class Group Dashboard Vulnerabilities Component', () => { ...@@ -109,6 +109,23 @@ describe('First Class Group Dashboard Vulnerabilities Component', () => {
vulnerabilities, vulnerabilities,
}); });
}); });
it('defaults to severity column for sorting', () => {
expect(wrapper.vm.sortBy).toBe('severity');
});
it('defaults to desc as sorting direction', () => {
expect(wrapper.vm.sortDirection).toBe('desc');
});
it('handles sorting', () => {
findVulnerabilities().vm.$listeners['sort-changed']({
sortBy: 'description',
sortDesc: false,
});
expect(wrapper.vm.sortBy).toBe('description');
expect(wrapper.vm.sortDirection).toBe('asc');
});
}); });
describe('when there is more than a page of vulnerabilities', () => { describe('when there is more than a page of vulnerabilities', () => {
......
...@@ -125,6 +125,23 @@ describe('First Class Instance Dashboard Vulnerabilities Component', () => { ...@@ -125,6 +125,23 @@ describe('First Class Instance Dashboard Vulnerabilities Component', () => {
vulnerabilities, vulnerabilities,
}); });
}); });
it('defaults to severity column for sorting', () => {
expect(wrapper.vm.sortBy).toBe('severity');
});
it('defaults to desc as sorting direction', () => {
expect(wrapper.vm.sortDirection).toBe('desc');
});
it('handles sorting', () => {
findVulnerabilities().vm.$listeners['sort-changed']({
sortBy: 'description',
sortDesc: false,
});
expect(wrapper.vm.sortBy).toBe('description');
expect(wrapper.vm.sortDirection).toBe('asc');
});
}); });
describe('when there is more than a page of vulnerabilities', () => { describe('when there is more than a page of vulnerabilities', () => {
......
...@@ -76,6 +76,23 @@ describe('Vulnerabilities app component', () => { ...@@ -76,6 +76,23 @@ describe('Vulnerabilities app component', () => {
it('should not render the alert', () => { it('should not render the alert', () => {
expect(findAlert().exists()).toBe(false); expect(findAlert().exists()).toBe(false);
}); });
it('defaults to severity column for sorting', () => {
expect(wrapper.vm.sortBy).toBe('severity');
});
it('defaults to desc as sorting direction', () => {
expect(wrapper.vm.sortDirection).toBe('desc');
});
it('handles sorting', () => {
findVulnerabilityList().vm.$listeners['sort-changed']({
sortBy: 'description',
sortDesc: false,
});
expect(wrapper.vm.sortBy).toBe('description');
expect(wrapper.vm.sortDirection).toBe('asc');
});
}); });
describe('with more than a page of vulnerabilities', () => { describe('with more than a page of vulnerabilities', () => {
......
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlTable } from '@gitlab/ui';
import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import RemediatedBadge from 'ee/vulnerabilities/components/remediated_badge.vue'; import RemediatedBadge from 'ee/vulnerabilities/components/remediated_badge.vue';
import SecurityScannerAlert from 'ee/security_dashboard/components/security_scanner_alert.vue'; import SecurityScannerAlert from 'ee/security_dashboard/components/security_scanner_alert.vue';
...@@ -18,7 +18,7 @@ describe('Vulnerability list component', () => { ...@@ -18,7 +18,7 @@ describe('Vulnerability list component', () => {
let wrapper; let wrapper;
const createWrapper = ({ props = {} }) => { const createWrapper = ({ props = {}, listeners }) => {
return mount(VulnerabilityList, { return mount(VulnerabilityList, {
propsData: { propsData: {
vulnerabilities: [], vulnerabilities: [],
...@@ -27,6 +27,7 @@ describe('Vulnerability list component', () => { ...@@ -27,6 +27,7 @@ describe('Vulnerability list component', () => {
stubs: { stubs: {
GlPopover: true, GlPopover: true,
}, },
listeners,
provide: () => ({ provide: () => ({
noVulnerabilitiesSvgPath: '#', noVulnerabilitiesSvgPath: '#',
dashboardDocumentation: '#', dashboardDocumentation: '#',
...@@ -37,6 +38,8 @@ describe('Vulnerability list component', () => { ...@@ -37,6 +38,8 @@ describe('Vulnerability list component', () => {
}); });
}; };
const findTable = () => wrapper.find(GlTable);
const findSortableColumn = () => wrapper.find('[aria-sort="descending"]');
const findCell = label => wrapper.find(`.js-${label}`); const findCell = label => wrapper.find(`.js-${label}`);
const findRow = (index = 0) => wrapper.findAll('tbody tr').at(index); const findRow = (index = 0) => wrapper.findAll('tbody tr').at(index);
const findSecurityScannerAlert = () => wrapper.find(SecurityScannerAlert); const findSecurityScannerAlert = () => wrapper.find(SecurityScannerAlert);
...@@ -444,4 +447,40 @@ describe('Vulnerability list component', () => { ...@@ -444,4 +447,40 @@ describe('Vulnerability list component', () => {
}); });
}); });
}); });
describe('when has a sort-changed listener defined', () => {
let spy;
beforeEach(() => {
spy = jest.fn();
wrapper = createWrapper({
listeners: { 'sort-changed': spy },
});
});
it('is sortable', () => {
expect(findSortableColumn().attributes('class')).toContain('severity');
});
it('triggers the listener when sortBy is not an empty value', () => {
const args = { sortBy: 'severity', sortDesc: false };
findTable().vm.$emit('sort-changed', args);
expect(spy).toHaveBeenCalledWith(args);
});
it('does not trigger the listener when sortBy is an empty value', () => {
findTable().vm.$emit('sort-changed', {});
expect(spy).not.toHaveBeenCalled();
});
});
describe('when does not have a sort-changed listener defined', () => {
beforeEach(() => {
wrapper = createWrapper({});
});
it('is not sortable', () => {
expect(findSortableColumn().exists()).toBe(false);
});
});
}); });
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