Commit 56dfb49c authored by Daniel Tian's avatar Daniel Tian Committed by Savas Vedova

Move vulnerabilities GraphQL into its own component

parent fff3d03e
<script>
import { GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui';
import produce from 'immer';
import vulnerabilitiesQuery from 'ee/security_dashboard/graphql/queries/group_vulnerabilities.query.graphql';
import createFlash from '~/flash';
import { s__ } from '~/locale';
import VulnerabilityReportHeader from '../shared/vulnerability_report/vulnerability_report_header.vue';
import ReportNotConfiguredGroup from '../shared/empty_states/report_not_configured_group.vue';
import VulnerabilityCounts from '../shared/vulnerability_report/vulnerability_counts.vue';
import VulnerabilityList from '../shared/vulnerability_report/vulnerability_list.vue';
import VulnerabilityListGraphql from '../shared/vulnerability_report/vulnerability_list_graphql.vue';
import VulnerabilityFilters from '../shared/vulnerability_report/vulnerability_filters.vue';
import { FIELDS, FILTERS } from '../shared/vulnerability_report/constants';
export default {
components: {
VulnerabilityCounts,
VulnerabilityList,
VulnerabilityListGraphql,
VulnerabilityFilters,
GlIntersectionObserver,
GlLoadingIcon,
ReportNotConfiguredGroup,
VulnerabilityReportHeader,
},
inject: ['groupFullPath', 'canViewFalsePositive', 'canAdminVulnerability', 'hasProjects'],
inject: ['canAdminVulnerability', 'hasProjects'],
data() {
return {
vulnerabilities: [],
filters: undefined,
sort: undefined,
pageInfo: undefined,
};
},
apollo: {
vulnerabilities: {
query: vulnerabilitiesQuery,
errorPolicy: 'none',
variables() {
return {
fullPath: this.groupFullPath,
sort: this.sort,
vetEnabled: this.canViewFalsePositive,
...this.filters,
};
},
update({ group }) {
this.pageInfo = group.vulnerabilities.pageInfo;
return group.vulnerabilities.nodes;
},
error() {
createFlash({
message: s__(
'SecurityReports|Error fetching the vulnerability list. Please check your network connection and try again.',
),
});
},
skip() {
return !this.filters;
},
},
},
computed: {
// Used to show the loading icon at the bottom of the vulnerabilities list.
isLoadingVulnerabilities() {
return this.$apollo.queries.vulnerabilities.loading;
},
// Used to show the initial skeleton loader for the vulnerabilities list.
isLoadingInitialVulnerabilities() {
return this.isLoadingVulnerabilities && this.vulnerabilities.length <= 0;
},
hasNextPage() {
return this.pageInfo?.hasNextPage;
},
fields() {
return [
// Add the checkbox field if the user can use the bulk select feature.
......@@ -85,29 +37,9 @@ export default {
},
},
methods: {
updateSort(sort) {
// Clear out the vulnerabilities so that the skeleton loader is shown.
this.vulnerabilities = [];
this.sort = sort;
},
updateFilters(filters) {
// Clear out the vulnerabilities so that the skeleton loader is shown.
this.vulnerabilities = [];
this.filters = filters;
},
fetchNextPage() {
this.$apollo.queries.vulnerabilities.fetchMore({
variables: { after: this.pageInfo.endCursor },
updateQuery: (previousResult, { fetchMoreResult }) => {
return produce(fetchMoreResult, (draftData) => {
draftData.group.vulnerabilities.nodes = [
...previousResult.group.vulnerabilities.nodes,
...draftData.group.vulnerabilities.nodes,
];
});
},
});
},
},
filtersToShow: [
FILTERS.STATUS,
......@@ -116,6 +48,7 @@ export default {
FILTERS.ACTIVITY,
FILTERS.PROJECT,
],
vulnerabilitiesQuery,
};
</script>
......@@ -133,17 +66,12 @@ export default {
@filters-changed="updateFilters"
/>
<vulnerability-list
<vulnerability-list-graphql
class="gl-mt-6"
:is-loading="isLoadingInitialVulnerabilities"
:vulnerabilities="vulnerabilities"
:query="$options.vulnerabilitiesQuery"
:fields="fields"
should-show-project-namespace
@sort-changed="updateSort"
:filters="filters"
show-project-namespace
/>
<gl-intersection-observer v-if="hasNextPage" @appear="fetchNextPage">
<gl-loading-icon v-if="isLoadingVulnerabilities" size="md" />
</gl-intersection-observer>
</div>
</template>
<script>
import { GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui';
import { produce } from 'immer';
import createFlash from '~/flash';
import { s__ } from '~/locale';
import VulnerabilityList from './vulnerability_list.vue';
// 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
// that this will only check the first property of any object, not all of them.
const deepFindVulnerabilities = (data) => {
let currentData = data;
while (currentData !== undefined && currentData.vulnerabilities === undefined) {
[currentData] = Object.values(currentData);
}
return currentData?.vulnerabilities;
};
export default {
components: { GlLoadingIcon, GlIntersectionObserver, VulnerabilityList },
inject: ['fullPath', 'canViewFalsePositive'],
props: {
query: {
type: Object,
required: true,
},
filters: {
type: Object,
required: false,
default: null,
},
fields: {
type: Array,
required: true,
},
showProjectNamespace: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
vulnerabilities: [],
sort: undefined,
pageInfo: undefined,
};
},
apollo: {
vulnerabilities: {
query() {
return this.query;
},
errorPolicy: 'none',
variables() {
return {
fullPath: this.fullPath,
sort: this.sort,
vetEnabled: this.canViewFalsePositive,
...this.filters,
};
},
update(data) {
const vulnerabilities = deepFindVulnerabilities(data);
this.pageInfo = vulnerabilities.pageInfo;
return vulnerabilities.nodes;
},
error() {
createFlash({
message: s__(
'SecurityReports|Error fetching the vulnerability list. Please check your network connection and try again.',
),
});
},
skip() {
return !this.filters;
},
},
},
computed: {
// Used to show the infinite scrolling loading spinner.
isLoadingVulnerabilities() {
return this.$apollo.queries.vulnerabilities.loading;
},
// Used to show the initial skeleton loader.
isLoadingInitialVulnerabilities() {
return this.isLoadingVulnerabilities && this.vulnerabilities.length <= 0;
},
hasNextPage() {
return this.pageInfo?.hasNextPage;
},
},
watch: {
filters() {
// Clear out the vulnerabilities so that the skeleton loader is shown.
this.vulnerabilities = [];
},
},
methods: {
updateSort(sort) {
// Clear out the vulnerabilities so that the skeleton loader is shown.
this.vulnerabilities = [];
this.sort = sort;
},
fetchNextPage() {
this.$apollo.queries.vulnerabilities.fetchMore({
variables: { after: this.pageInfo.endCursor },
updateQuery: (previousResult, { fetchMoreResult }) => {
return produce(fetchMoreResult, (draftData) => {
deepFindVulnerabilities(draftData).nodes.unshift(...this.vulnerabilities);
});
},
});
},
},
};
</script>
<template>
<div>
<vulnerability-list
:is-loading="isLoadingInitialVulnerabilities"
:vulnerabilities="vulnerabilities"
:fields="fields"
:should-show-project-namespace="showProjectNamespace"
@sort-changed="updateSort"
/>
<gl-intersection-observer v-if="hasNextPage" @appear="fetchNextPage">
<gl-loading-icon v-if="isLoadingVulnerabilities" size="md" />
</gl-intersection-observer>
</div>
</template>
import { createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import { nextTick } from 'vue';
import { GlIntersectionObserver } from '@gitlab/ui';
import VulnerabilityList from 'ee/security_dashboard/components/shared/vulnerability_report/vulnerability_list.vue';
import VulnerabilityListGraphql from 'ee/security_dashboard/components/shared/vulnerability_report/vulnerability_list_graphql.vue';
import VulnerabilityReportDevelopment from 'ee/security_dashboard/components/group/vulnerability_report_development.vue';
import VulnerabilityCounts from 'ee/security_dashboard/components/shared/vulnerability_report/vulnerability_counts.vue';
import VulnerabilityFilters from 'ee/security_dashboard/components/shared/vulnerability_report/vulnerability_filters.vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import vulnerabilitiesQuery from 'ee/security_dashboard/graphql/queries/group_vulnerabilities.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import createFlash from '~/flash';
jest.mock('~/flash');
const localVue = createLocalVue();
localVue.use(VueApollo);
const groupFullPath = 'path';
const createVulnerabilitiesRequestHandler = ({ hasNextPage }) =>
jest.fn().mockResolvedValue({
data: {
group: {
vulnerabilities: {
nodes: [],
pageInfo: { endCursor: 'abc', hasNextPage },
},
},
},
});
const vulnerabilitiesRequestHandler = createVulnerabilitiesRequestHandler({ hasNextPage: true });
describe('Vulnerability counts component', () => {
let wrapper;
const createWrapper = ({
vulnerabilitiesHandler = vulnerabilitiesRequestHandler,
canViewFalsePositive = true,
filters = {},
} = {}) => {
const createWrapper = () => {
wrapper = shallowMountExtended(VulnerabilityReportDevelopment, {
localVue,
apolloProvider: createMockApollo([[vulnerabilitiesQuery, vulnerabilitiesHandler]]),
provide: {
groupFullPath,
canViewFalsePositive,
canAdminVulnerability: true,
hasProjects: true,
},
data: () => ({ filters }),
});
};
const findVulnerabilityCounts = () => wrapper.findComponent(VulnerabilityCounts);
const findVulnerabilityFilters = () => wrapper.findComponent(VulnerabilityFilters);
const findVulnerabilityList = () => wrapper.findComponent(VulnerabilityList);
const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver);
const findVulnerabilityListGraphql = () => wrapper.findComponent(VulnerabilityListGraphql);
afterEach(() => {
wrapper.destroy();
vulnerabilitiesRequestHandler.mockClear();
});
describe('vulnerability counts component', () => {
it('receives the filters prop from the filters component', () => {
const filters = {}; // Object itself does not matter, we're only checking that it's passed.
createWrapper({ filters });
expect(findVulnerabilityCounts().props('filters')).toBe(filters);
});
});
describe('group vulnerabilities query', () => {
it('calls the query once with the expected fullPath variable', () => {
createWrapper();
expect(vulnerabilitiesRequestHandler).toHaveBeenCalledTimes(1);
expect(vulnerabilitiesRequestHandler).toHaveBeenCalledWith(
expect.objectContaining({ fullPath: groupFullPath }),
);
});
it.each([true, false])(
'calls the query with the expected vetEnabled property when canViewFalsePositive is %s',
(canViewFalsePositive) => {
createWrapper({ canViewFalsePositive });
expect(vulnerabilitiesRequestHandler).toHaveBeenCalledWith(
expect.objectContaining({ vetEnabled: canViewFalsePositive }),
);
},
);
it('does not call the query if filters are not ready', () => {
createWrapper({ filters: null });
expect(vulnerabilitiesRequestHandler).not.toHaveBeenCalled();
});
it('shows an error message if the query fails', async () => {
const vulnerabilitiesHandler = jest.fn().mockRejectedValue(new Error());
createWrapper({ vulnerabilitiesHandler });
// Have to wait 2 ticks here, one for the query to finish loading, and one more for the
// GraphQL error handler to be called.
await nextTick();
await nextTick();
expect(createFlash).toHaveBeenCalledTimes(1);
});
});
describe('vulnerability list component', () => {
it('gets the expected vulnerabilities prop', async () => {
createWrapper();
const vulnerabilities = [];
await wrapper.setData({ vulnerabilities });
expect(findVulnerabilityList().props('vulnerabilities')).toEqual(vulnerabilities);
});
it('calls the vulnerabilities query with the data from the sort-changed event', async () => {
createWrapper();
// First call should be undefined, which uses the default sort.
expect(vulnerabilitiesRequestHandler).toHaveBeenCalledWith(
expect.objectContaining({ sort: undefined }),
);
const sort = 'sort';
findVulnerabilityList().vm.$emit('sort-changed', sort);
await nextTick();
expect(vulnerabilitiesRequestHandler).toHaveBeenCalledWith(expect.objectContaining({ sort }));
});
});
describe('intersection observer', () => {
it('is not shown when the vulnerabilities query is loading for the first time', () => {
createWrapper();
expect(findIntersectionObserver().exists()).toBe(false);
});
it('will fetch more data when the appear event is fired', async () => {
createWrapper();
await nextTick();
const spy = jest.spyOn(wrapper.vm.$apollo.queries.vulnerabilities, 'fetchMore');
findIntersectionObserver().vm.$emit('appear');
expect(spy).toHaveBeenCalledTimes(1);
});
it('is not shown if there is no next page', async () => {
createWrapper({
vulnerabilitiesHandler: createVulnerabilitiesRequestHandler({ hasNextPage: false }),
});
await nextTick();
expect(findIntersectionObserver().exists()).toBe(false);
});
});
describe('vulnerability filters component', () => {
it('will pass data from filters-changed event to vulnerabilities GraphQL query', async () => {
const vulnerabilitiesHandler = jest.fn().mockResolvedValue();
createWrapper({ vulnerabilitiesHandler });
// Sanity check, the report component will call this the first time it's mounted.
expect(vulnerabilitiesHandler).toHaveBeenCalledTimes(1);
it('will pass data from filters-changed event to the counts and list components', async () => {
createWrapper();
const data = { a: 1 };
findVulnerabilityFilters().vm.$emit('filters-changed', data);
await nextTick();
expect(vulnerabilitiesHandler).toHaveBeenCalledTimes(2);
expect(vulnerabilitiesHandler).toHaveBeenCalledWith(expect.objectContaining(data));
expect(findVulnerabilityCounts().props('filters')).toBe(data);
expect(findVulnerabilityListGraphql().props('filters')).toBe(data);
});
});
});
import { createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import { nextTick } from 'vue';
import { GlIntersectionObserver } from '@gitlab/ui';
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 { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import vulnerabilitiesQuery from 'ee/security_dashboard/graphql/queries/group_vulnerabilities.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import createFlash from '~/flash';
jest.mock('~/flash');
const localVue = createLocalVue();
localVue.use(VueApollo);
const fullPath = 'path';
const createVulnerabilitiesRequestHandler = ({ hasNextPage }) =>
jest.fn().mockResolvedValue({
data: {
group: {
vulnerabilities: {
nodes: [],
pageInfo: { endCursor: 'abc', hasNextPage },
},
},
},
});
const vulnerabilitiesRequestHandler = createVulnerabilitiesRequestHandler({ hasNextPage: true });
describe('Vulnerability list GraphQL component', () => {
let wrapper;
const createWrapper = ({
vulnerabilitiesHandler = vulnerabilitiesRequestHandler,
canViewFalsePositive = false,
showProjectNamespace = false,
filters = {},
fields = [],
} = {}) => {
wrapper = shallowMountExtended(VulnerabilityListGraphql, {
localVue,
apolloProvider: createMockApollo([[vulnerabilitiesQuery, vulnerabilitiesHandler]]),
provide: {
fullPath,
canViewFalsePositive,
},
propsData: {
query: vulnerabilitiesQuery,
filters,
fields,
showProjectNamespace,
},
});
};
const findVulnerabilityList = () => wrapper.findComponent(VulnerabilityList);
const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver);
afterEach(() => {
wrapper.destroy();
vulnerabilitiesRequestHandler.mockClear();
});
describe('vulnerabilities query', () => {
it('calls the query once with the expected fullPath variable', () => {
createWrapper();
expect(vulnerabilitiesRequestHandler).toHaveBeenCalledTimes(1);
expect(vulnerabilitiesRequestHandler).toHaveBeenCalledWith(
expect.objectContaining({ fullPath }),
);
});
it.each([true, false])(
'calls the query with the expected vetEnabled property when canViewFalsePositive is %s',
(canViewFalsePositive) => {
createWrapper({ canViewFalsePositive });
expect(vulnerabilitiesRequestHandler).toHaveBeenCalledWith(
expect.objectContaining({ vetEnabled: canViewFalsePositive }),
);
},
);
it('does not call the query if filters are not ready', () => {
createWrapper({ filters: null });
expect(vulnerabilitiesRequestHandler).not.toHaveBeenCalled();
});
it('shows an error message if the query fails', async () => {
const vulnerabilitiesHandler = jest.fn().mockRejectedValue(new Error());
createWrapper({ vulnerabilitiesHandler });
// Have to wait 2 ticks here, one for the query to finish loading, and one more for the
// GraphQL error handler to be called.
await nextTick();
await nextTick();
expect(createFlash).toHaveBeenCalledTimes(1);
});
});
describe('vulnerability list component', () => {
it('gets the expected props', async () => {
const fields = ['abc'];
const showProjectNamespace = true;
createWrapper({ fields, showProjectNamespace });
expect(findVulnerabilityList().props('fields')).toBe(fields);
expect(findVulnerabilityList().props('shouldShowProjectNamespace')).toBe(
showProjectNamespace,
);
});
it('calls the vulnerabilities query with the data from the sort-changed event', async () => {
createWrapper();
// First call should be undefined, which uses the default sort.
expect(vulnerabilitiesRequestHandler).toHaveBeenCalledWith(
expect.objectContaining({ sort: undefined }),
);
const sort = 'sort';
findVulnerabilityList().vm.$emit('sort-changed', sort);
await nextTick();
expect(vulnerabilitiesRequestHandler).toHaveBeenCalledWith(expect.objectContaining({ sort }));
});
});
describe('intersection observer', () => {
it('is not shown when the vulnerabilities query is loading for the first time', () => {
createWrapper();
expect(findIntersectionObserver().exists()).toBe(false);
});
it('will fetch more data when the appear event is fired', async () => {
createWrapper();
await nextTick();
const spy = jest.spyOn(wrapper.vm.$apollo.queries.vulnerabilities, 'fetchMore');
findIntersectionObserver().vm.$emit('appear');
expect(spy).toHaveBeenCalledTimes(1);
});
it('is not shown if there is no next page', async () => {
createWrapper({
vulnerabilitiesHandler: createVulnerabilitiesRequestHandler({ hasNextPage: false }),
});
await nextTick();
expect(findIntersectionObserver().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