Commit 93d74803 authored by Daniel Tian's avatar Daniel Tian Committed by Savas Vedova

Add page size selector to vulnerability report

Changelog: added
MR: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/83604
EE: true
parent 6d7e0f06
---
name: vulnerability_report_page_size_selector
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/82438
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/356888
milestone: '14.10'
type: development
group: group::threat insights
default_enabled: false
<script>
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
export const PAGE_SIZES = [20, 50, 100];
export default {
components: { GlDropdown, GlDropdownItem },
props: {
value: {
type: Number,
required: true,
},
},
methods: {
emitInput(pageSize) {
this.$emit('input', pageSize);
},
getPageSizeText(pageSize) {
return sprintf(s__('SecurityReports|Show %{pageSize} items'), { pageSize });
},
},
PAGE_SIZES,
};
</script>
<template>
<gl-dropdown :text="getPageSizeText(value)" right menu-class="gl-w-auto! gl-min-w-0">
<gl-dropdown-item
v-for="pageSize in $options.PAGE_SIZES"
:key="pageSize"
@click="emitInput(pageSize)"
>
<span class="gl-white-space-nowrap">{{ getPageSizeText(pageSize) }}</span>
</gl-dropdown-item>
</gl-dropdown>
</template>
......@@ -11,7 +11,6 @@ import {
import { Portal } from 'portal-vue';
import DashboardHasNoVulnerabilities from 'ee/security_dashboard/components/shared/empty_states/dashboard_has_no_vulnerabilities.vue';
import FiltersProducedNoResults from 'ee/security_dashboard/components/shared/empty_states/filters_produced_no_results.vue';
import { VULNERABILITIES_PER_PAGE } from 'ee/security_dashboard/store/constants';
import SeverityBadge from 'ee/vue_shared/security_reports/components/severity_badge.vue';
import convertReportType from 'ee/vue_shared/security_reports/store/utils/convert_report_type';
import getPrimaryIdentifier from 'ee/vue_shared/security_reports/store/utils/get_primary_identifier';
......@@ -81,6 +80,10 @@ export default {
type: String,
required: true,
},
pageSize: {
type: Number,
required: true,
},
sort: {
type: Object,
required: false,
......@@ -254,7 +257,6 @@ export default {
return VULNERABILITY_STATES[stateName] || stateName;
},
},
VULNERABILITIES_PER_PAGE,
};
</script>
......@@ -416,7 +418,7 @@ export default {
<template #table-busy>
<gl-skeleton-loading
v-for="n in $options.VULNERABILITIES_PER_PAGE"
v-for="n in pageSize"
:key="n"
class="gl-m-3 js-skeleton-loader"
:lines="2"
......
......@@ -8,10 +8,13 @@ import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { convertToSnakeCase } from '~/lib/utils/text_utility';
import { parseBoolean } from '~/lib/utils/common_utils';
import { DASHBOARD_TYPES } from 'ee/security_dashboard/store/constants';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import VulnerabilityList from './vulnerability_list.vue';
import { FIELDS } from './constants';
import PageSizeSelector from './page_size_selector.vue';
const PAGE_SIZE = 20;
export const DEFAULT_PAGE_SIZE = 20;
const PAGE_SIZE_STORAGE_KEY = 'vulnerability_list_page_size';
const GRAPHQL_DATA_PATH = {
[DASHBOARD_TYPES.PROJECT]: 'project.vulnerabilities',
......@@ -21,7 +24,14 @@ const GRAPHQL_DATA_PATH = {
};
export default {
components: { GlLoadingIcon, GlIntersectionObserver, VulnerabilityList, GlKeysetPagination },
components: {
GlLoadingIcon,
GlIntersectionObserver,
VulnerabilityList,
GlKeysetPagination,
PageSizeSelector,
LocalStorageSync,
},
mixins: [glFeatureFlagsMixin()],
inject: {
dashboardType: {
......@@ -70,6 +80,7 @@ export default {
pageInfo: {},
// The "before" querystring value on page load.
initialBefore: this.$route.query.before,
pageSize: DEFAULT_PAGE_SIZE,
};
},
apollo: {
......@@ -88,8 +99,8 @@ export default {
// 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,
first: this.before ? null : this.pageSize,
last: this.before ? this.pageSize : null,
before: this.before,
after: this.after,
...this.filters,
......@@ -164,6 +175,9 @@ export default {
shouldUsePagination() {
return Boolean(this.glFeatures.vulnerabilityReportPagination);
},
shouldShowPageSizeSelector() {
return Boolean(this.glFeatures.vulnerabilityReportPageSizeSelector);
},
},
watch: {
filters(newFilters, oldFilters) {
......@@ -217,6 +231,7 @@ export default {
return get(data, GRAPHQL_DATA_PATH[this.dashboardType]);
},
},
PAGE_SIZE_STORAGE_KEY,
};
</script>
......@@ -227,12 +242,13 @@ export default {
:vulnerabilities="vulnerabilities"
:fields="fields"
:sort.sync="sort"
:page-size="pageSize"
:should-show-project-namespace="showProjectNamespace"
:portal-name="portalName"
@vulnerability-clicked="$emit('vulnerability-clicked', $event)"
/>
<div v-if="shouldUsePagination" class="gl-text-center gl-mt-6">
<div v-if="shouldUsePagination" class="gl-text-center gl-mt-6 gl-relative">
<gl-keyset-pagination
:has-previous-page="pageInfo.hasPreviousPage"
:has-next-page="pageInfo.hasNextPage"
......@@ -242,6 +258,15 @@ export default {
@next="getNextPage"
@prev="getPrevPage"
/>
<local-storage-sync
v-if="shouldShowPageSizeSelector"
v-model="pageSize"
as-json
:storage-key="$options.PAGE_SIZE_STORAGE_KEY"
>
<page-size-selector v-model="pageSize" class="gl-absolute gl-right-0" />
</local-storage-sync>
</div>
<gl-intersection-observer v-else-if="pageInfo.hasNextPage" @appear="fetchNextPage">
......
import { s__ } from '~/locale';
export const VULNERABILITIES_PER_PAGE = 20;
export const DETECTION_METHODS = [
s__('Vulnerability|GitLab Security Report'),
s__('Vulnerability|External Security Report'),
......
......@@ -13,6 +13,7 @@ module EE
push_frontend_feature_flag(:graphql_code_quality_full_report, project, type: :development, 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)
push_frontend_feature_flag(:vulnerability_report_page_size_selector, default_enabled: :yaml)
end
feature_category :license_compliance, [:licenses]
......
......@@ -8,6 +8,7 @@ module Groups
before_action do
push_frontend_feature_flag(:vulnerability_management_survey, type: :ops, default_enabled: :yaml)
push_frontend_feature_flag(:vulnerability_report_pagination, current_user, default_enabled: :yaml)
push_frontend_feature_flag(:vulnerability_report_page_size_selector, default_enabled: :yaml)
end
feature_category :vulnerability_management
......
......@@ -10,6 +10,7 @@ module Projects
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(:vulnerability_report_pagination, current_user, default_enabled: :yaml)
push_frontend_feature_flag(:vulnerability_report_page_size_selector, default_enabled: :yaml)
push_frontend_feature_flag(:new_vulnerability_form, @project, default_enabled: :yaml)
end
......
......@@ -7,6 +7,7 @@ module Security
before_action do
push_frontend_feature_flag(:vulnerability_management_survey, type: :ops, default_enabled: :yaml)
push_frontend_feature_flag(:vulnerability_report_pagination, current_user, default_enabled: :yaml)
push_frontend_feature_flag(:vulnerability_report_page_size_selector, default_enabled: :yaml)
end
end
end
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import PageSizeSelector, {
PAGE_SIZES,
} from 'ee/security_dashboard/components/shared/vulnerability_report/page_size_selector.vue';
describe('Page size selector component', () => {
let wrapper;
const createWrapper = ({ pageSize = 20 } = {}) => {
wrapper = shallowMount(PageSizeSelector, {
propsData: { value: pageSize },
});
};
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
afterEach(() => {
wrapper.destroy();
});
it.each(PAGE_SIZES)('shows expected text in the dropdown button for page size %s', (pageSize) => {
createWrapper({ pageSize });
expect(findDropdown().props('text')).toBe(`Show ${pageSize} items`);
});
it('shows the expected dropdown items', () => {
createWrapper();
PAGE_SIZES.forEach((pageSize, index) => {
expect(findDropdownItems().at(index).text()).toBe(`Show ${pageSize} items`);
});
});
it('will emit the new page size when a dropdown item is clicked', () => {
createWrapper();
findDropdownItems().wrappers.forEach((itemWrapper, index) => {
itemWrapper.vm.$emit('click');
expect(wrapper.emitted('input')[index][0]).toBe(PAGE_SIZES[index]);
});
});
});
......@@ -2,15 +2,19 @@ import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import { GlIntersectionObserver, GlKeysetPagination } from '@gitlab/ui';
import VueRouter from 'vue-router';
import VulnerabilityListGraphql from 'ee/security_dashboard/components/shared/vulnerability_report/vulnerability_list_graphql.vue';
import { shallowMount } from '@vue/test-utils';
import VulnerabilityListGraphql, {
DEFAULT_PAGE_SIZE,
} 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 { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import PageSizeSelector from 'ee/security_dashboard/components/shared/vulnerability_report/page_size_selector.vue';
import vulnerabilitiesQuery from 'ee/security_dashboard/graphql/queries/group_vulnerabilities.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import { FIELDS } from 'ee/security_dashboard/components/shared/vulnerability_report/constants';
import { DASHBOARD_TYPES } from 'ee/security_dashboard/store/constants';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import { vulnerabilities } from '../../mock_data';
jest.mock('~/flash');
......@@ -57,10 +61,11 @@ describe('Vulnerability list GraphQL component', () => {
showProjectNamespace = false,
hasJiraVulnerabilitiesIntegrationEnabled = false,
vulnerabilityReportPagination = false,
vulnerabilityReportPageSizeSelector = false,
filters = {},
fields = [],
} = {}) => {
wrapper = shallowMountExtended(VulnerabilityListGraphql, {
wrapper = shallowMount(VulnerabilityListGraphql, {
router,
apolloProvider: createMockApollo([[vulnerabilitiesQuery, vulnerabilitiesHandler]]),
provide: {
......@@ -68,7 +73,7 @@ describe('Vulnerability list GraphQL component', () => {
dashboardType: DASHBOARD_TYPES.GROUP,
canViewFalsePositive,
hasJiraVulnerabilitiesIntegrationEnabled,
glFeatures: { vulnerabilityReportPagination },
glFeatures: { vulnerabilityReportPagination, vulnerabilityReportPageSizeSelector },
},
propsData: {
query: vulnerabilitiesQuery,
......@@ -83,6 +88,8 @@ describe('Vulnerability list GraphQL component', () => {
const findVulnerabilityList = () => wrapper.findComponent(VulnerabilityList);
const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver);
const findPagination = () => wrapper.findComponent(GlKeysetPagination);
const findPageSizeSelector = () => wrapper.findComponent(PageSizeSelector);
const findLocalStorageSync = () => wrapper.findComponent(LocalStorageSync);
afterEach(() => {
wrapper.destroy();
......@@ -318,6 +325,62 @@ describe('Vulnerability list GraphQL component', () => {
);
});
describe('page size selector', () => {
const expectPageSizeUsed = (pageSize) => {
expect(vulnerabilitiesRequestHandler).toHaveBeenCalledWith(
expect.objectContaining({ first: pageSize }),
);
// Vulnerability list needs the page size to show the correct number of skeleton loaders.
expect(findVulnerabilityList().props('pageSize')).toBe(pageSize);
};
it('is not shown if the pagination feature flag is off', () => {
createWrapper();
expect(findLocalStorageSync().exists()).toBe(false);
expect(findPageSizeSelector().exists()).toBe(false);
});
it('is not shown if the pagination feature flag is on but the page size selector feature flag is off', () => {
createWrapper({ vulnerabilityReportPagination: true });
expect(findLocalStorageSync().exists()).toBe(false);
expect(findPageSizeSelector().exists()).toBe(false);
});
describe('both feature flags enabled', () => {
beforeEach(() => {
createWrapper({
vulnerabilityReportPagination: true,
vulnerabilityReportPageSizeSelector: true,
});
});
it('uses the default page size if page size selector was not changed', () => {
expectPageSizeUsed(DEFAULT_PAGE_SIZE);
});
it('uses the page size selected by the page size selector', async () => {
const pageSize = 50;
findPageSizeSelector().vm.$emit('input', pageSize);
await nextTick();
expectPageSizeUsed(pageSize);
});
it('sets up the local storage sync correctly', async () => {
const pageSize = 123;
findPageSizeSelector().vm.$emit('input', pageSize);
await nextTick();
expect(findLocalStorageSync().props()).toMatchObject({
asJson: true,
value: pageSize,
});
});
});
});
describe('intersection observer', () => {
it('is not shown if the pagination feature flag is on', () => {
createWrapper({ vulnerabilityReportPagination: true });
......
......@@ -35,6 +35,7 @@ describe('Vulnerability list component', () => {
vulnerabilities: [],
fields: FIELD_PRESETS.DEVELOPMENT,
portalName,
pageSize: 20,
...props,
},
stubs: {
......@@ -83,6 +84,7 @@ describe('Vulnerability list component', () => {
const findVendorNames = () => wrapper.findByTestId('vulnerability-vendor');
const findCheckAllCheckbox = () => wrapper.findByTestId('vulnerability-checkbox-all');
const findAllRowCheckboxes = () => wrapper.findAllByTestId('vulnerability-checkbox');
const findSkeletonLoading = () => wrapper.findAllComponents(GlSkeletonLoading);
afterEach(() => {
wrapper.destroy();
......@@ -562,7 +564,7 @@ describe('Vulnerability list component', () => {
createWrapper({ props: { isLoading, vulnerabilities } });
expect(findCell('status').exists()).toEqual(!isLoading);
expect(wrapper.findComponent(GlSkeletonLoading).exists()).toEqual(isLoading);
expect(findSkeletonLoading().exists()).toBe(isLoading);
});
});
......@@ -690,6 +692,15 @@ describe('Vulnerability list component', () => {
});
});
describe('pageSize prop', () => {
it('shows the same number of skeleton loaders as the pageSize prop', () => {
const pageSize = 17;
createWrapper({ props: { pageSize, isLoading: true } });
expect(findSkeletonLoading()).toHaveLength(pageSize);
});
});
describe('operational vulnerabilities', () => {
beforeEach(() => {
createWrapper({
......
......@@ -33603,6 +33603,9 @@ msgstr ""
msgid "SecurityReports|Severity"
msgstr ""
msgid "SecurityReports|Show %{pageSize} items"
msgstr ""
msgid "SecurityReports|Sometimes a scanner can't determine a finding's severity. Those findings may still be a potential source of risk though. Please review these manually."
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