Commit b087a567 authored by Savas Vedova's avatar Savas Vedova

Merge branch '350343-use-pagination-for-vulnerability-report' into 'master'

Use pagination for vulnerability report paging

See merge request gitlab-org/gitlab!79834
parents a95beee1 87cf025c
import Vue from 'vue'; import Vue from 'vue';
import AgentShowPage from 'ee_else_ce/clusters/agents/components/show.vue'; import AgentShowPage from 'ee_else_ce/clusters/agents/components/show.vue';
import apolloProvider from './graphql/provider'; import apolloProvider from './graphql/provider';
import createRouter from './router';
export default () => { export default () => {
const el = document.querySelector('#js-cluster-agent-details'); const el = document.querySelector('#js-cluster-agent-details');
...@@ -20,6 +21,7 @@ export default () => { ...@@ -20,6 +21,7 @@ export default () => {
return new Vue({ return new Vue({
el, el,
apolloProvider, apolloProvider,
router: createRouter(),
provide: { provide: {
activityEmptyStateImage, activityEmptyStateImage,
agentName, agentName,
......
import Vue from 'vue';
import VueRouter from 'vue-router';
Vue.use(VueRouter);
// Vue Router requires a component to render if the route matches, but since we're only using it for
// querystring handling, we'll create an empty component.
const EmptyRouterComponent = {
render(createElement) {
return createElement('div');
},
};
export default () => {
// Name and path here don't really matter since we're not rendering anything if the route matches.
const routes = [{ path: '/', name: 'cluster_agents', component: EmptyRouterComponent }];
return new VueRouter({
mode: 'history',
base: window.location.pathname,
routes,
});
};
---
name: vulnerability_report_pagination
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/79834
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/351975
milestone: '14.8'
type: development
group: group::threat insights
default_enabled: false
<script> <script>
import { GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui'; import { GlLoadingIcon, GlIntersectionObserver, GlKeysetPagination } from '@gitlab/ui';
import { produce } from 'immer'; import { produce } from 'immer';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import VulnerabilityList from './vulnerability_list.vue'; import VulnerabilityList from './vulnerability_list.vue';
const PAGE_SIZE = 20;
// Deep searches an object for a key called 'vulnerabilities'. If it's not found, it will traverse // Deep searches an object for a key called 'vulnerabilities'. If it's not found, it will traverse
// down the object's first property until either it's found, or there's nothing left to search. Note // down the object's first property until either it's found, or there's nothing left to search. Note
// that this will only check the first property of any object, not all of them. // that this will only check the first property of any object, not all of them.
...@@ -19,7 +22,8 @@ const deepFindVulnerabilities = (data) => { ...@@ -19,7 +22,8 @@ const deepFindVulnerabilities = (data) => {
}; };
export default { export default {
components: { GlLoadingIcon, GlIntersectionObserver, VulnerabilityList }, components: { GlLoadingIcon, GlIntersectionObserver, VulnerabilityList, GlKeysetPagination },
mixins: [glFeatureFlagsMixin()],
inject: ['fullPath', 'canViewFalsePositive', 'hasJiraVulnerabilitiesIntegrationEnabled'], inject: ['fullPath', 'canViewFalsePositive', 'hasJiraVulnerabilitiesIntegrationEnabled'],
props: { props: {
query: { query: {
...@@ -49,7 +53,9 @@ export default { ...@@ -49,7 +53,9 @@ export default {
return { return {
vulnerabilities: [], vulnerabilities: [],
sort: undefined, sort: undefined,
pageInfo: undefined, pageInfo: {},
// The "before" querystring value on page load.
initialBefore: this.$route.query.before,
}; };
}, },
apollo: { apollo: {
...@@ -64,6 +70,13 @@ export default { ...@@ -64,6 +70,13 @@ export default {
sort: this.sort, sort: this.sort,
vetEnabled: this.canViewFalsePositive, vetEnabled: this.canViewFalsePositive,
includeExternalIssueLinks: this.hasJiraVulnerabilitiesIntegrationEnabled, includeExternalIssueLinks: this.hasJiraVulnerabilitiesIntegrationEnabled,
// If we're using "after" we need to use "first", and if we're using "before" we need to
// use "last". See this comment for more info:
// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/79834#note_831878506
first: this.before ? null : PAGE_SIZE,
last: this.before ? PAGE_SIZE : null,
before: this.before,
after: this.after,
...this.filters, ...this.filters,
}; };
}, },
...@@ -85,28 +98,70 @@ export default { ...@@ -85,28 +98,70 @@ export default {
}, },
}, },
computed: { computed: {
before: {
get() {
// The GraphQL query can only have a "before" or an "after" but not both, so if both are in
// the querystring, we'll only use "after" and pretend the "before" doesn't exist.
return this.after ? undefined : this.$route.query.before;
},
set(before) {
// Check if before will change, otherwise Vue Router will throw a "you're navigating to the
// same route" error.
if (before !== this.before) {
this.pushQuerystring({ before, after: undefined });
}
},
},
after: {
get() {
return this.$route.query.after;
},
set(after) {
// Check if after will change, otherwise Vue Router will throw a "you're navigating to the
// same route" error.
if (after !== this.after) {
this.pushQuerystring({ before: undefined, after });
}
},
},
// Used to show the infinite scrolling loading spinner. // Used to show the infinite scrolling loading spinner.
isLoadingVulnerabilities() { isLoadingVulnerabilities() {
return this.$apollo.queries.vulnerabilities.loading; return this.$apollo.queries.vulnerabilities.loading;
}, },
// Used to show the initial skeleton loader. // Used to show the initial skeleton loader.
isLoadingInitialVulnerabilities() { shouldShowLoadingSkeleton() {
return this.isLoadingVulnerabilities && this.vulnerabilities.length <= 0; return this.shouldUsePagination
? this.isLoadingVulnerabilities
: this.isLoadingVulnerabilities && this.vulnerabilities.length <= 0;
}, },
hasNextPage() { shouldUsePagination() {
return this.pageInfo?.hasNextPage; return this.glFeatures.vulnerabilityReportPagination;
}, },
}, },
watch: { watch: {
filters() { filters(newFilters, oldFilters) {
// Clear out the vulnerabilities so that the skeleton loader is shown. if (this.shouldUsePagination) {
this.vulnerabilities = []; // The first time the filters are set, it's done by the vulnerability-filters component, so
// we don't want to reset the paging. Every time after that will be from user interaction.
if (oldFilters !== null) {
this.resetPaging();
}
} else {
// Clear out the vulnerabilities so that the skeleton loader is shown.
this.vulnerabilities = [];
}
}, },
}, },
methods: { methods: {
updateSort(sort) { updateSort(sort) {
// Clear out the vulnerabilities so that the skeleton loader is shown. if (this.shouldUsePagination) {
this.vulnerabilities = []; // Reset the paging whenever the sort is changed.
this.resetPaging();
} else {
// Clear out the vulnerabilities so that the skeleton loader is shown.
this.vulnerabilities = [];
}
this.sort = sort; this.sort = sort;
}, },
fetchNextPage() { fetchNextPage() {
...@@ -119,6 +174,19 @@ export default { ...@@ -119,6 +174,19 @@ export default {
}, },
}); });
}, },
getNextPage() {
this.after = this.pageInfo.endCursor || this.before;
},
getPrevPage() {
this.before = this.pageInfo.startCursor || this.after;
},
resetPaging() {
this.before = undefined;
this.after = undefined;
},
pushQuerystring(data) {
this.$router.push({ query: { ...this.$route.query, ...data } });
},
}, },
}; };
</script> </script>
...@@ -126,7 +194,7 @@ export default { ...@@ -126,7 +194,7 @@ export default {
<template> <template>
<div> <div>
<vulnerability-list <vulnerability-list
:is-loading="isLoadingInitialVulnerabilities" :is-loading="shouldShowLoadingSkeleton"
:vulnerabilities="vulnerabilities" :vulnerabilities="vulnerabilities"
:fields="fields" :fields="fields"
:should-show-project-namespace="showProjectNamespace" :should-show-project-namespace="showProjectNamespace"
...@@ -134,7 +202,19 @@ export default { ...@@ -134,7 +202,19 @@ export default {
@sort-changed="updateSort" @sort-changed="updateSort"
/> />
<gl-intersection-observer v-if="hasNextPage" @appear="fetchNextPage"> <div v-if="shouldUsePagination" class="gl-text-center gl-mt-6">
<gl-keyset-pagination
:has-previous-page="pageInfo.hasPreviousPage"
:has-next-page="pageInfo.hasNextPage"
:start-cursor="pageInfo.startCursor"
:end-cursor="pageInfo.endCursor"
:disabled="isLoadingVulnerabilities"
@next="getNextPage"
@prev="getPrevPage"
/>
</div>
<gl-intersection-observer v-else-if="pageInfo.hasNextPage" @appear="fetchNextPage">
<gl-loading-icon v-if="isLoadingVulnerabilities" size="md" /> <gl-loading-icon v-if="isLoadingVulnerabilities" size="md" />
</gl-intersection-observer> </gl-intersection-observer>
</div> </div>
......
...@@ -29,6 +29,11 @@ export default { ...@@ -29,6 +29,11 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
isActive: {
type: Boolean,
required: false,
default: true,
},
showProjectFilter: { showProjectFilter: {
type: Boolean, type: Boolean,
required: false, required: false,
...@@ -91,6 +96,7 @@ export default { ...@@ -91,6 +96,7 @@ export default {
</div> </div>
<vulnerability-list-graphql <vulnerability-list-graphql
v-if="isActive"
class="gl-mt-6" class="gl-mt-6"
:query="query" :query="query"
:fields="fieldsToShow" :fields="fieldsToShow"
......
...@@ -7,6 +7,7 @@ import VulnerabilityReportHeader from './vulnerability_report_header.vue'; ...@@ -7,6 +7,7 @@ import VulnerabilityReportHeader from './vulnerability_report_header.vue';
import VulnerabilityReport from './vulnerability_report.vue'; import VulnerabilityReport from './vulnerability_report.vue';
import { REPORT_TAB } from './constants'; import { REPORT_TAB } from './constants';
const DEVELOPMENT_TAB_INDEX = 0;
const OPERATIONAL_TAB_INDEX = 1; const OPERATIONAL_TAB_INDEX = 1;
export default { export default {
...@@ -44,6 +45,9 @@ export default { ...@@ -44,6 +45,9 @@ export default {
const query = { const query = {
...this.$route.query, ...this.$route.query,
tab: index === OPERATIONAL_TAB_INDEX ? REPORT_TAB.OPERATIONAL : undefined, tab: index === OPERATIONAL_TAB_INDEX ? REPORT_TAB.OPERATIONAL : undefined,
// Reset pagination when the tab is changed.
before: undefined,
after: undefined,
}; };
this.$router.push({ query }); this.$router.push({ query });
...@@ -65,6 +69,8 @@ export default { ...@@ -65,6 +69,8 @@ export default {
), ),
}, },
REPORT_TAB, REPORT_TAB,
DEVELOPMENT_TAB_INDEX,
OPERATIONAL_TAB_INDEX,
}; };
</script> </script>
...@@ -86,6 +92,7 @@ export default { ...@@ -86,6 +92,7 @@ export default {
<slot name="header-development"></slot> <slot name="header-development"></slot>
<vulnerability-report <vulnerability-report
:is-active="tabIndex === $options.DEVELOPMENT_TAB_INDEX"
:type="$options.REPORT_TAB.DEVELOPMENT" :type="$options.REPORT_TAB.DEVELOPMENT"
:query="query" :query="query"
:show-project-filter="showProjectFilter" :show-project-filter="showProjectFilter"
...@@ -106,6 +113,7 @@ export default { ...@@ -106,6 +113,7 @@ export default {
<slot name="header-operational"></slot> <slot name="header-operational"></slot>
<vulnerability-report <vulnerability-report
:is-active="tabIndex === $options.OPERATIONAL_TAB_INDEX"
:type="$options.REPORT_TAB.OPERATIONAL" :type="$options.REPORT_TAB.OPERATIONAL"
:query="query" :query="query"
:show-project-filter="showProjectFilter" :show-project-filter="showProjectFilter"
......
#import "../fragments/vulnerability.fragment.graphql" #import "../fragments/vulnerability.fragment.graphql"
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
query groupVulnerabilities( query groupVulnerabilities(
$fullPath: ID! $fullPath: ID!
$before: String
$after: String $after: String
$first: Int = 20 $first: Int = 20
$last: Int
$projectId: [ID!] $projectId: [ID!]
$severity: [VulnerabilitySeverity!] $severity: [VulnerabilitySeverity!]
$reportType: [VulnerabilityReportType!] $reportType: [VulnerabilityReportType!]
...@@ -18,8 +21,10 @@ query groupVulnerabilities( ...@@ -18,8 +21,10 @@ query groupVulnerabilities(
group(fullPath: $fullPath) { group(fullPath: $fullPath) {
id id
vulnerabilities( vulnerabilities(
before: $before
after: $after after: $after
first: $first first: $first
last: $last
severity: $severity severity: $severity
reportType: $reportType reportType: $reportType
scanner: $scanner scanner: $scanner
...@@ -34,8 +39,7 @@ query groupVulnerabilities( ...@@ -34,8 +39,7 @@ query groupVulnerabilities(
...VulnerabilityFragment ...VulnerabilityFragment
} }
pageInfo { pageInfo {
endCursor ...PageInfo
hasNextPage
} }
} }
} }
......
#import "../fragments/vulnerability.fragment.graphql" #import "../fragments/vulnerability.fragment.graphql"
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
query instanceVulnerabilities( query instanceVulnerabilities(
$before: String
$after: String $after: String
$first: Int = 20 $first: Int = 20
$last: Int
$projectId: [ID!] $projectId: [ID!]
$severity: [VulnerabilitySeverity!] $severity: [VulnerabilitySeverity!]
$reportType: [VulnerabilityReportType!] $reportType: [VulnerabilityReportType!]
...@@ -15,8 +18,10 @@ query instanceVulnerabilities( ...@@ -15,8 +18,10 @@ query instanceVulnerabilities(
$vetEnabled: Boolean = false $vetEnabled: Boolean = false
) { ) {
vulnerabilities( vulnerabilities(
before: $before
after: $after after: $after
first: $first first: $first
last: $last
severity: $severity severity: $severity
reportType: $reportType reportType: $reportType
state: $state state: $state
...@@ -31,8 +36,7 @@ query instanceVulnerabilities( ...@@ -31,8 +36,7 @@ query instanceVulnerabilities(
...VulnerabilityFragment ...VulnerabilityFragment
} }
pageInfo { pageInfo {
endCursor ...PageInfo
hasNextPage
} }
} }
} }
#import "../fragments/vulnerability.fragment.graphql" #import "../fragments/vulnerability.fragment.graphql"
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
query projectVulnerabilities( query projectVulnerabilities(
$fullPath: ID! $fullPath: ID!
$before: String
$after: String $after: String
$first: Int = 20 $first: Int = 20
$last: Int
$severity: [VulnerabilitySeverity!] $severity: [VulnerabilitySeverity!]
$reportType: [VulnerabilityReportType!] $reportType: [VulnerabilityReportType!]
$scanner: [String!] $scanner: [String!]
...@@ -19,8 +22,10 @@ query projectVulnerabilities( ...@@ -19,8 +22,10 @@ query projectVulnerabilities(
project(fullPath: $fullPath) { project(fullPath: $fullPath) {
id id
vulnerabilities( vulnerabilities(
before: $before
after: $after after: $after
first: $first first: $first
last: $last
severity: $severity severity: $severity
reportType: $reportType reportType: $reportType
scanner: $scanner scanner: $scanner
...@@ -54,8 +59,7 @@ query projectVulnerabilities( ...@@ -54,8 +59,7 @@ query projectVulnerabilities(
} }
} }
pageInfo { pageInfo {
endCursor ...PageInfo
hasNextPage
} }
} }
} }
......
...@@ -7,6 +7,7 @@ module Groups ...@@ -7,6 +7,7 @@ module Groups
before_action do before_action do
push_frontend_feature_flag(:vulnerability_management_survey, type: :ops, default_enabled: :yaml) push_frontend_feature_flag(:vulnerability_management_survey, type: :ops, default_enabled: :yaml)
push_frontend_feature_flag(:vulnerability_report_pagination, current_user, default_enabled: :yaml)
end end
feature_category :vulnerability_management feature_category :vulnerability_management
......
...@@ -9,6 +9,7 @@ module Projects ...@@ -9,6 +9,7 @@ module Projects
before_action do before_action do
push_frontend_feature_flag(:vulnerability_management_survey, type: :ops, default_enabled: :yaml) push_frontend_feature_flag(:vulnerability_management_survey, type: :ops, default_enabled: :yaml)
push_frontend_feature_flag(:secure_vulnerability_training, @project, default_enabled: :yaml) push_frontend_feature_flag(:secure_vulnerability_training, @project, default_enabled: :yaml)
push_frontend_feature_flag(:vulnerability_report_pagination, current_user, default_enabled: :yaml)
end end
feature_category :vulnerability_management feature_category :vulnerability_management
......
...@@ -6,6 +6,7 @@ module Security ...@@ -6,6 +6,7 @@ module Security
before_action do before_action do
push_frontend_feature_flag(:vulnerability_management_survey, type: :ops, default_enabled: :yaml) push_frontend_feature_flag(:vulnerability_management_survey, type: :ops, default_enabled: :yaml)
push_frontend_feature_flag(:vulnerability_report_pagination, current_user, default_enabled: :yaml)
end end
end end
end end
...@@ -2,6 +2,7 @@ import Vue, { nextTick } from 'vue'; ...@@ -2,6 +2,7 @@ import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import { GlIntersectionObserver } from '@gitlab/ui'; import { GlIntersectionObserver } from '@gitlab/ui';
import VueRouter from 'vue-router';
import VulnerabilityListGraphql from 'ee/security_dashboard/components/shared/vulnerability_report/vulnerability_list_graphql.vue'; import VulnerabilityListGraphql from 'ee/security_dashboard/components/shared/vulnerability_report/vulnerability_list_graphql.vue';
import VulnerabilityList from 'ee/security_dashboard/components/shared/vulnerability_report/vulnerability_list.vue'; import VulnerabilityList from 'ee/security_dashboard/components/shared/vulnerability_report/vulnerability_list.vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
...@@ -13,6 +14,8 @@ import createFlash from '~/flash'; ...@@ -13,6 +14,8 @@ import createFlash from '~/flash';
jest.mock('~/flash'); jest.mock('~/flash');
Vue.use(VueApollo); Vue.use(VueApollo);
Vue.use(VueRouter);
const router = new VueRouter();
const fullPath = 'path'; const fullPath = 'path';
const portalName = 'portal-name'; const portalName = 'portal-name';
...@@ -24,7 +27,13 @@ const createVulnerabilitiesRequestHandler = ({ hasNextPage }) => ...@@ -24,7 +27,13 @@ const createVulnerabilitiesRequestHandler = ({ hasNextPage }) =>
id: 'group-1', id: 'group-1',
vulnerabilities: { vulnerabilities: {
nodes: [], nodes: [],
pageInfo: { endCursor: 'abc', hasNextPage }, pageInfo: {
__typename: 'PageInfo',
startCursor: 'abc',
endCursor: 'def',
hasNextPage,
hasPreviousPage: false,
},
}, },
}, },
}, },
...@@ -44,6 +53,7 @@ describe('Vulnerability list GraphQL component', () => { ...@@ -44,6 +53,7 @@ describe('Vulnerability list GraphQL component', () => {
fields = [], fields = [],
} = {}) => { } = {}) => {
wrapper = shallowMountExtended(VulnerabilityListGraphql, { wrapper = shallowMountExtended(VulnerabilityListGraphql, {
router,
apolloProvider: createMockApollo([[vulnerabilitiesQuery, vulnerabilitiesHandler]]), apolloProvider: createMockApollo([[vulnerabilitiesQuery, vulnerabilitiesHandler]]),
provide: { provide: {
fullPath, fullPath,
......
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