Commit 41dffe1c authored by Fernando Arias's avatar Fernando Arias Committed by Peter Hegman

Add pagination to corpus management

* Implement with graphQL
* Optimize graphQL queries for corpus management
* Add pagination unit tests
* Hide pagination when not enough rows
* Hide pagination
* Add table empty state
* Add unit tests
* Update pot files
parent c4d6756a
<script>
import { GlLoadingIcon, GlLink } from '@gitlab/ui';
import { GlLoadingIcon, GlLink, GlKeysetPagination } from '@gitlab/ui';
import CorpusTable from 'ee/security_configuration/corpus_management/components/corpus_table.vue';
import CorpusUpload from 'ee/security_configuration/corpus_management/components/corpus_upload.vue';
import { s__, __ } from '~/locale';
......@@ -9,6 +9,7 @@ export default {
components: {
GlLoadingIcon,
GlLink,
GlKeysetPagination,
CorpusTable,
CorpusUpload,
},
......@@ -16,12 +17,14 @@ export default {
states: {
query: getCorpusesQuery,
variables() {
return {
projectPath: this.projectFullPath,
};
return this.queryVariables;
},
update: (data) => {
return data;
const { pageInfo } = data.project.corpuses;
return {
...data,
pageInfo,
};
},
error() {
this.states = null;
......@@ -29,20 +32,67 @@ export default {
},
},
inject: ['projectFullPath', 'corpusHelpPath'],
data() {
return {
pagination: {
firstPageSize: this.$options.pageSize,
lastPageSize: null,
},
};
},
pageSize: 10,
i18n: {
header: s__('CorpusManagement|Fuzz testing corpus management'),
subHeader: s__(
'CorpusManagement|Corpus are used in fuzz testing as mutation source to Improve future testing.',
),
learnMore: __('Learn More'),
previousPage: __('Prev'),
nextPage: __('Next'),
},
computed: {
corpuses() {
return this.states?.project.corpuses.nodes || [];
},
pageInfo() {
return this.states?.pageInfo || {};
},
isLoading() {
return this.$apollo.loading;
},
queryVariables() {
return {
projectPath: this.projectFullPath,
...this.pagination,
};
},
hasPagination() {
return Boolean(this.states) && (this.pageInfo.hasPreviousPage || this.pageInfo.hasNextPage);
},
},
methods: {
fetchCorpuses() {
this.pagination = {
afterCursor: null,
beforeCursor: null,
firstPageSize: this.$options.pageSize,
};
this.$apollo.queries.states.refetch();
},
nextPage() {
this.pagination = {
firstPageSize: this.$options.pageSize,
lastPageSize: null,
afterCursor: this.states.pageInfo.endCursor,
};
},
prevPage() {
this.pagination = {
firstPageSize: null,
lastPageSize: this.$options.pageSize,
beforeCursor: this.states.pageInfo.startCursor,
};
},
},
};
</script>
......@@ -59,10 +109,21 @@ export default {
</p>
</header>
<gl-loading-icon v-if="isLoading" size="lg" />
<corpus-upload @corpus-added="fetchCorpuses" />
<gl-loading-icon v-if="isLoading" size="lg" class="gl-py-13" />
<template v-else>
<corpus-upload />
<corpus-table :corpuses="corpuses" />
</template>
<div v-if="hasPagination" class="gl-display-flex gl-justify-content-center gl-mt-5">
<gl-keyset-pagination
v-bind="pageInfo"
:prev-text="$options.i18n.previousPage"
:next-text="$options.i18n.nextPage"
@prev="prevPage"
@next="nextPage"
/>
</div>
</div>
</template>
......@@ -52,6 +52,9 @@ export default {
thClass,
},
],
i18n: {
emptyTable: s__('CorpusManagement|Currently, there are no uploaded or generated corpuses.'),
},
methods: {
onDelete({ name }) {
this.$apollo.mutate({
......@@ -73,7 +76,11 @@ export default {
};
</script>
<template>
<gl-table :items="corpuses" :fields="$options.fields">
<gl-table :items="corpuses" :fields="$options.fields" show-empty>
<template #empty>
{{ $options.i18n.emptyTable }}
</template>
<template #cell(name)="{ item }">
<name :corpus="item" />
</template>
......
......@@ -5,7 +5,7 @@ import { s__, __ } from '~/locale';
import addCorpusMutation from '../graphql/mutations/add_corpus.mutation.graphql';
import resetCorpus from '../graphql/mutations/reset_corpus.mutation.graphql';
import uploadCorpus from '../graphql/mutations/upload_corpus.mutation.graphql';
import getCorpusesQuery from '../graphql/queries/get_corpuses.query.graphql';
import getUploadState from '../graphql/queries/get_upload_state.query.graphql';
import CorpusUploadForm from './corpus_upload_form.vue';
export default {
......@@ -26,10 +26,7 @@ export default {
inject: ['projectFullPath'],
apollo: {
states: {
query: getCorpusesQuery,
variables() {
return this.queryVariables;
},
query: getUploadState,
update(data) {
return data;
},
......@@ -76,19 +73,17 @@ export default {
},
methods: {
addCorpus() {
this.$apollo.mutate({
return this.$apollo
.mutate({
mutation: addCorpusMutation,
refetchQueries: [
{
query: getCorpusesQuery,
variables: this.queryVariables,
},
],
variables: {
name: this.$options.i18n.newCorpus,
projectPath: this.projectFullPath,
packageId: this.states.uploadState.uploadedPackageId,
},
})
.then(() => {
this.$emit('corpus-added');
});
},
resetCorpus() {
......
fragment UploadState on UploadState {
isUploading
progress
cancelSource
uploadedPackageId
}
query getCorpuses($projectPath: ID!) {
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
#import "../fragments/uploadState.fragment.graphql"
query getCorpuses(
$projectPath: ID!
$beforeCursor: String = ""
$afterCursor: String = ""
$firstPageSize: Int
$lastPageSize: Int
) {
project(fullPath: $projectPath) {
id
corpuses {
corpuses(
before: $beforeCursor
after: $afterCursor
first: $firstPageSize
last: $lastPageSize
) {
nodes {
id
package {
......@@ -24,12 +38,12 @@ query getCorpuses($projectPath: ID!) {
}
}
}
pageInfo {
...PageInfo
}
}
uploadState(projectPath: $projectPath) @client {
isUploading
progress
cancelSource
uploadedPackageId
}
uploadState @client {
...UploadState
}
}
#import "../fragments/uploadState.fragment.graphql"
query getUploadState {
uploadState @client {
...UploadState
}
}
......@@ -3,15 +3,14 @@ import { publishPackage } from '~/api/packages_api';
import axios from '~/lib/utils/axios_utils';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { TYPE_PACKAGES_PACKAGE } from '~/graphql_shared/constants';
import getCorpusesQuery from '../queries/get_corpuses.query.graphql';
import getUploadState from '../queries/get_upload_state.query.graphql';
import updateProgress from '../mutations/update_progress.mutation.graphql';
import uploadComplete from '../mutations/upload_complete.mutation.graphql';
import corpusCreate from '../mutations/corpus_create.mutation.graphql';
export default {
Query: {
/* eslint-disable no-unused-vars */
uploadState(_, { projectPath }) {
uploadState() {
return {
isUploading: false,
progress: 0,
......@@ -24,7 +23,7 @@ export default {
Mutation: {
addCorpus: (_, { projectPath, packageId }, { cache, client }) => {
const sourceData = cache.readQuery({
query: getCorpusesQuery,
query: getUploadState,
variables: { projectPath },
});
......@@ -33,7 +32,7 @@ export default {
draftState.uploadState.progress = 0;
});
cache.writeQuery({ query: getCorpusesQuery, data, variables: { projectPath } });
cache.writeQuery({ query: getUploadState, data, variables: { projectPath } });
client.mutate({
mutation: corpusCreate,
......@@ -60,7 +59,7 @@ export default {
const source = CancelToken.source();
const sourceData = cache.readQuery({
query: getCorpusesQuery,
query: getUploadState,
variables: { projectPath },
});
......@@ -70,7 +69,7 @@ export default {
uploadState.cancelSource = source;
});
cache.writeQuery({ query: getCorpusesQuery, data: targetData, variables: { projectPath } });
cache.writeQuery({ query: getUploadState, data: targetData, variables: { projectPath } });
publishPackage(
{ projectPath, name, version: 0, fileName: `${name}.zip`, files },
......@@ -83,13 +82,13 @@ export default {
variables: { projectPath, packageId: data.package_id },
});
})
.catch((e) => {
.catch(() => {
/* TODO: Error handling */
});
},
uploadComplete: (_, { projectPath, packageId }, { cache }) => {
const sourceData = cache.readQuery({
query: getCorpusesQuery,
query: getUploadState,
variables: { projectPath },
});
......@@ -100,11 +99,11 @@ export default {
uploadState.uploadedPackageId = packageId;
});
cache.writeQuery({ query: getCorpusesQuery, data, variables: { projectPath } });
cache.writeQuery({ query: getUploadState, data, variables: { projectPath } });
},
updateProgress: (_, { projectPath, progress }, { cache }) => {
const sourceData = cache.readQuery({
query: getCorpusesQuery,
query: getUploadState,
variables: { projectPath },
});
......@@ -114,11 +113,11 @@ export default {
uploadState.progress = progress;
});
cache.writeQuery({ query: getCorpusesQuery, data, variables: { projectPath } });
cache.writeQuery({ query: getUploadState, data, variables: { projectPath } });
},
resetCorpus: (_, { projectPath }, { cache }) => {
const sourceData = cache.readQuery({
query: getCorpusesQuery,
query: getUploadState,
variables: { projectPath },
});
......@@ -131,7 +130,7 @@ export default {
uploadState.cancelToken = null;
});
cache.writeQuery({ query: getCorpusesQuery, data, variables: { projectPath } });
cache.writeQuery({ query: getUploadState, data, variables: { projectPath } });
},
},
};
import { GlLoadingIcon } from '@gitlab/ui';
import { merge } from 'lodash';
import { GlLoadingIcon, GlKeysetPagination } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { shallowMount } from '@vue/test-utils';
import CorpusManagement from 'ee/security_configuration/corpus_management/components/corpus_management.vue';
import CorpusTable from 'ee/security_configuration/corpus_management/components/corpus_table.vue';
import CorpusUpload from 'ee/security_configuration/corpus_management/components/corpus_upload.vue';
import { corpuses } from './mock_data';
import getCorpusesQuery from 'ee/security_configuration/corpus_management/graphql/queries/get_corpuses.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { getCorpusesQueryResponse } from './mock_data';
const TEST_PROJECT_FULL_PATH = '/namespace/project';
const TEST_CORPUS_HELP_PATH = '/docs/corpus-management';
......@@ -11,19 +20,49 @@ const TEST_CORPUS_HELP_PATH = '/docs/corpus-management';
describe('EE - CorpusManagement', () => {
let wrapper;
const createComponentFactory = (mountFn = shallowMount) => (options = {}) => {
const defaultMocks = {
$apollo: {
loading: false,
const createMockApolloProvider = ({
getCorpusesQueryRequestHandler = jest.fn().mockResolvedValue(getCorpusesQueryResponse),
} = {}) => {
Vue.use(VueApollo);
const requestHandlers = [[getCorpusesQuery, getCorpusesQueryRequestHandler]];
const mockResolvers = {
Query: {
uploadState() {
return {
isUploading: false,
progress: 0,
cancelSource: null,
uploadedPackageId: null,
__typename: 'UploadState',
};
},
},
};
return createMockApollo(requestHandlers, mockResolvers);
};
const findPagination = () => wrapper.findComponent(GlKeysetPagination);
const nextPage = (cursor) => {
findPagination().vm.$emit('next', cursor);
return findPagination().vm.$nextTick();
};
const prevPage = (cursor) => {
findPagination().vm.$emit('prev', cursor);
return findPagination().vm.$nextTick();
};
const createComponentFactory = (mountFn = shallowMount) => (options = {}) => {
wrapper = mountFn(CorpusManagement, {
mocks: defaultMocks,
provide: {
projectFullPath: TEST_PROJECT_FULL_PATH,
corpusHelpPath: TEST_CORPUS_HELP_PATH,
},
apolloProvider: createMockApolloProvider(),
...options,
});
};
......@@ -36,39 +75,119 @@ describe('EE - CorpusManagement', () => {
describe('corpus management', () => {
describe('when loaded', () => {
beforeEach(() => {
const data = () => {
return { states: { project: { corpuses } } };
};
it('bootstraps and renders the component', async () => {
createComponent();
await waitForPromises();
createComponent({ data });
});
it('bootstraps and renders the component', () => {
expect(wrapper.findComponent(CorpusManagement).exists()).toBe(true);
expect(wrapper.findComponent(CorpusTable).exists()).toBe(true);
expect(wrapper.findComponent(CorpusUpload).exists()).toBe(true);
expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(false);
});
it('renders the correct header', () => {
it('renders the correct header', async () => {
createComponent();
await waitForPromises();
const header = wrapper.findComponent(CorpusManagement).find('header');
expect(header.element).toMatchSnapshot();
});
describe('pagination', () => {
it('hides pagination when no previous or next pages are available', async () => {
createComponent({
apolloProvider: createMockApolloProvider({
getCorpusesQueryRequestHandler: jest.fn().mockResolvedValue(
merge({}, getCorpusesQueryResponse, {
data: {
project: {
corpuses: {
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
},
},
},
},
}),
),
}),
});
await waitForPromises();
expect(findPagination().exists()).toBe(false);
});
it('passes correct props to GlKeysetPagination', async () => {
createComponent();
await waitForPromises();
expect(findPagination().exists()).toBe(true);
expect(findPagination().props()).toMatchObject({
disabled: false,
endCursor: 'end-cursor',
hasNextPage: true,
hasPreviousPage: true,
nextButtonLink: null,
nextText: 'Next',
prevButtonLink: null,
prevText: 'Prev',
startCursor: 'start-cursor',
});
});
it('updates query variables when going to previous page', async () => {
const getCorpusesQueryRequestHandler = jest
.fn()
.mockResolvedValue(getCorpusesQueryResponse);
createComponent({
apolloProvider: createMockApolloProvider({ getCorpusesQueryRequestHandler }),
});
await waitForPromises();
await prevPage(getCorpusesQueryResponse.data.project.corpuses.pageInfo.startCursor);
expect(getCorpusesQueryRequestHandler).toHaveBeenCalledWith({
beforeCursor: getCorpusesQueryResponse.data.project.corpuses.pageInfo.startCursor,
afterCursor: '',
projectPath: TEST_PROJECT_FULL_PATH,
lastPageSize: 10,
firstPageSize: null,
});
});
it('updates query variables when going to next page', async () => {
const getCorpusesQueryRequestHandler = jest
.fn()
.mockResolvedValue(getCorpusesQueryResponse);
createComponent({
apolloProvider: createMockApolloProvider({ getCorpusesQueryRequestHandler }),
});
await waitForPromises();
await nextPage(getCorpusesQueryResponse.data.project.corpuses.pageInfo.endCursor);
expect(getCorpusesQueryRequestHandler).toHaveBeenLastCalledWith({
afterCursor: getCorpusesQueryResponse.data.project.corpuses.pageInfo.endCursor,
beforeCursor: '',
projectPath: TEST_PROJECT_FULL_PATH,
firstPageSize: 10,
lastPageSize: null,
});
});
});
});
describe('when loading', () => {
it('shows loading state when loading', () => {
const mocks = {
$apollo: {
loading: jest.fn().mockResolvedValue(true),
},
};
createComponent({ mocks });
createComponent();
expect(wrapper.findComponent(CorpusManagement).exists()).toBe(true);
expect(wrapper.findComponent(CorpusUpload).exists()).toBe(true);
expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
expect(wrapper.findComponent(CorpusTable).exists()).toBe(false);
expect(wrapper.findComponent(CorpusUpload).exists()).toBe(false);
});
});
});
......
......@@ -60,5 +60,13 @@ describe('Corpus table', () => {
actionComponent.vm.$emit('delete', 'corpus-name');
expect(mutate).toHaveBeenCalledTimes(1);
});
describe('with no corpuses', () => {
it('renders the empty state', async () => {
wrapper.setProps({ corpuses: [] });
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain('Currently, there are no uploaded or generated corpuses');
});
});
});
});
const pipelines = {
nodes: [
{
id: 'gid://gitlab/Packages::PackagePipelines/1',
ref: 'farias-gl/go-fuzzing-example',
path: 'gitlab-examples/security/security-reports/-/jobs/1107103952',
updatedAt: new Date(2020, 4, 3).toString(),
createdAt: new Date(2020, 4, 3).toString(),
},
],
};
......@@ -11,6 +12,7 @@ const pipelines = {
const packageFiles = {
nodes: [
{
id: 'gid://gitlab/Packages::PackageFile/1',
downloadPath: '/download-path',
size: 4e8,
},
......@@ -19,7 +21,9 @@ const packageFiles = {
export const corpuses = [
{
id: 'gid://gitlab/AppSec::Fuzzing::Coverage::Corpus/1',
package: {
id: 'gid://gitlab/Packages::Package/1',
name: 'Corpus-sample-1-13830-23932',
updatedAt: new Date(2021, 2, 12).toString(),
pipelines,
......@@ -27,7 +31,9 @@ export const corpuses = [
},
},
{
id: 'gid://gitlab/AppSec::Fuzzing::Coverage::Corpus/2',
package: {
id: 'gid://gitlab/Packages::Package/2',
name: 'Corpus-sample-2-5830-2393',
updatedAt: new Date(2021, 3, 12).toString(),
pipelines,
......@@ -35,7 +41,9 @@ export const corpuses = [
},
},
{
id: 'gid://gitlab/AppSec::Fuzzing::Coverage::Corpus/3',
package: {
id: 'gid://gitlab/Packages::Package/3',
name: 'Corpus-sample-3-1431-4425',
updatedAt: new Date(2021, 4, 12).toString(),
pipelines: {
......@@ -49,6 +57,7 @@ export const corpuses = [
packageFiles: {
nodes: [
{
id: 'gid://gitlab/Packages::PackageFile/1',
downloadPath: '/download-path',
size: 3.21e8,
},
......@@ -57,7 +66,9 @@ export const corpuses = [
},
},
{
id: 'gid://gitlab/AppSec::Fuzzing::Coverage::Corpus/4',
package: {
id: 'gid://gitlab/Packages::Package/3',
name: 'Corpus-sample-4-5830-1393',
updatedAt: new Date(2021, 5, 12).toString(),
pipelines,
......@@ -65,7 +76,9 @@ export const corpuses = [
},
},
{
id: 'gid://gitlab/AppSec::Fuzzing::Coverage::Corpus/5',
package: {
id: 'gid://gitlab/Packages::Package/4',
name: 'Corpus-sample-5-13830-23932',
updatedAt: new Date(2021, 6, 12).toString(),
pipelines,
......@@ -73,7 +86,9 @@ export const corpuses = [
},
},
{
id: 'gid://gitlab/AppSec::Fuzzing::Coverage::Corpus/6',
package: {
id: 'gid://gitlab/Packages::Package/5',
name: 'Corpus-sample-6-2450-2393',
updatedAt: new Date(2021, 7, 12).toString(),
pipelines,
......@@ -81,3 +96,20 @@ export const corpuses = [
},
},
];
export const getCorpusesQueryResponse = {
data: {
project: {
id: 'gid://gitlab/Project/8',
corpuses: {
nodes: corpuses,
pageInfo: {
hasNextPage: true,
hasPreviousPage: true,
startCursor: 'start-cursor',
endCursor: 'end-cursor',
},
},
},
},
};
......@@ -9775,6 +9775,9 @@ msgstr ""
msgid "CorpusManagement|Corpus name"
msgstr ""
msgid "CorpusManagement|Currently, there are no uploaded or generated corpuses."
msgstr ""
msgid "CorpusManagement|Fuzz testing corpus management"
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