Commit 92914b5a authored by Enrique Alcántara's avatar Enrique Alcántara

Merge branch 'jivanvl-change-pagination-type-jobs-table' into 'master'

Change pagination type on jobs table

See merge request gitlab-org/gitlab!80516
parents 5b373eb5 b4d54551
import { s__, __ } from '~/locale'; import { s__, __ } from '~/locale';
import { DEFAULT_TH_CLASSES } from '~/lib/utils/constants'; import { DEFAULT_TH_CLASSES } from '~/lib/utils/constants';
export const GRAPHQL_PAGE_SIZE = 30;
export const initialPaginationState = {
currentPage: 1,
prevPageCursor: '',
nextPageCursor: '',
first: GRAPHQL_PAGE_SIZE,
last: null,
};
/* Error constants */ /* Error constants */
export const POST_FAILURE = 'post_failure'; export const POST_FAILURE = 'post_failure';
export const DEFAULT = 'default'; export const DEFAULT = 'default';
......
import { isEqual } from 'lodash';
export default {
typePolicies: {
Project: {
fields: {
jobs: {
keyArgs: false,
},
},
},
CiJobConnection: {
merge(existing = {}, incoming, { args = {} }) {
let nodes;
if (Object.keys(existing).length !== 0 && isEqual(existing?.statuses, args?.statuses)) {
nodes = [...existing.nodes, ...incoming.nodes];
} else {
nodes = [...incoming.nodes];
}
return {
nodes,
statuses: Array.isArray(args.statuses) ? [...args.statuses] : args.statuses,
};
},
},
},
};
query getJobs( query getJobs($fullPath: ID!, $after: String, $statuses: [CiJobStatus!]) {
$fullPath: ID!
$first: Int
$last: Int
$after: String
$before: String
$statuses: [CiJobStatus!]
) {
project(fullPath: $fullPath) { project(fullPath: $fullPath) {
id id
jobs(after: $after, before: $before, first: $first, last: $last, statuses: $statuses) { __typename
jobs(after: $after, first: 30, statuses: $statuses) {
pageInfo { pageInfo {
endCursor endCursor
hasNextPage hasNextPage
hasPreviousPage hasPreviousPage
startCursor startCursor
__typename
} }
nodes { nodes {
__typename
artifacts { artifacts {
nodes { nodes {
downloadPath downloadPath
fileType fileType
__typename
} }
} }
allowFailure allowFailure
......
...@@ -4,12 +4,18 @@ import VueApollo from 'vue-apollo'; ...@@ -4,12 +4,18 @@ import VueApollo from 'vue-apollo';
import JobsTableApp from '~/jobs/components/table/jobs_table_app.vue'; import JobsTableApp from '~/jobs/components/table/jobs_table_app.vue';
import createDefaultClient from '~/lib/graphql'; import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils'; import { parseBoolean } from '~/lib/utils/common_utils';
import cacheConfig from './graphql/cache_config';
Vue.use(VueApollo); Vue.use(VueApollo);
Vue.use(GlToast); Vue.use(GlToast);
const apolloProvider = new VueApollo({ const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(), defaultClient: createDefaultClient(
{},
{
cacheConfig,
},
),
}); });
export default (containerId = 'js-jobs-table') => { export default (containerId = 'js-jobs-table') => {
......
<script> <script>
import { GlAlert, GlPagination, GlSkeletonLoader } from '@gitlab/ui'; import { GlAlert, GlSkeletonLoader, GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { GRAPHQL_PAGE_SIZE, initialPaginationState } from './constants';
import eventHub from './event_hub'; import eventHub from './event_hub';
import GetJobs from './graphql/queries/get_jobs.query.graphql'; import GetJobs from './graphql/queries/get_jobs.query.graphql';
import JobsTable from './jobs_table.vue'; import JobsTable from './jobs_table.vue';
...@@ -11,14 +10,16 @@ import JobsTableTabs from './jobs_table_tabs.vue'; ...@@ -11,14 +10,16 @@ import JobsTableTabs from './jobs_table_tabs.vue';
export default { export default {
i18n: { i18n: {
errorMsg: __('There was an error fetching the jobs for your project.'), errorMsg: __('There was an error fetching the jobs for your project.'),
loadingAriaLabel: __('Loading'),
}, },
components: { components: {
GlAlert, GlAlert,
GlPagination,
GlSkeletonLoader, GlSkeletonLoader,
JobsTable, JobsTable,
JobsTableEmptyState, JobsTableEmptyState,
JobsTableTabs, JobsTableTabs,
GlIntersectionObserver,
GlLoadingIcon,
}, },
inject: { inject: {
fullPath: { fullPath: {
...@@ -31,10 +32,6 @@ export default { ...@@ -31,10 +32,6 @@ export default {
variables() { variables() {
return { return {
fullPath: this.fullPath, fullPath: this.fullPath,
first: this.pagination.first,
last: this.pagination.last,
after: this.pagination.nextPageCursor,
before: this.pagination.prevPageCursor,
}; };
}, },
update(data) { update(data) {
...@@ -57,7 +54,7 @@ export default { ...@@ -57,7 +54,7 @@ export default {
hasError: false, hasError: false,
isAlertDismissed: false, isAlertDismissed: false,
scope: null, scope: null,
pagination: initialPaginationState, firstLoad: true,
}; };
}, },
computed: { computed: {
...@@ -67,14 +64,8 @@ export default { ...@@ -67,14 +64,8 @@ export default {
showEmptyState() { showEmptyState() {
return this.jobs.list.length === 0 && !this.scope; return this.jobs.list.length === 0 && !this.scope;
}, },
prevPage() { hasNextPage() {
return Math.max(this.pagination.currentPage - 1, 0); return this.jobs?.pageInfo?.hasNextPage;
},
nextPage() {
return this.jobs.pageInfo?.hasNextPage ? this.pagination.currentPage + 1 : null;
},
showPaginationControls() {
return Boolean(this.prevPage || this.nextPage) && !this.$apollo.loading;
}, },
}, },
mounted() { mounted() {
...@@ -88,26 +79,22 @@ export default { ...@@ -88,26 +79,22 @@ export default {
this.$apollo.queries.jobs.refetch({ statuses: this.scope }); this.$apollo.queries.jobs.refetch({ statuses: this.scope });
}, },
fetchJobsByStatus(scope) { fetchJobsByStatus(scope) {
this.firstLoad = true;
this.scope = scope; this.scope = scope;
this.$apollo.queries.jobs.refetch({ statuses: scope }); this.$apollo.queries.jobs.refetch({ statuses: scope });
}, },
handlePageChange(page) { fetchMoreJobs() {
const { startCursor, endCursor } = this.jobs.pageInfo; this.firstLoad = false;
if (page > this.pagination.currentPage) { if (!this.$apollo.queries.jobs.loading) {
this.pagination = { this.$apollo.queries.jobs.fetchMore({
...initialPaginationState, variables: {
nextPageCursor: endCursor, fullPath: this.fullPath,
currentPage: page, after: this.jobs?.pageInfo?.endCursor,
}; },
} else { });
this.pagination = {
last: GRAPHQL_PAGE_SIZE,
first: null,
prevPageCursor: startCursor,
currentPage: page,
};
} }
}, },
}, },
...@@ -128,7 +115,7 @@ export default { ...@@ -128,7 +115,7 @@ export default {
<jobs-table-tabs @fetchJobsByStatus="fetchJobsByStatus" /> <jobs-table-tabs @fetchJobsByStatus="fetchJobsByStatus" />
<div v-if="$apollo.loading" class="gl-mt-5"> <div v-if="$apollo.loading && firstLoad" class="gl-mt-5">
<gl-skeleton-loader :width="1248" :height="73"> <gl-skeleton-loader :width="1248" :height="73">
<circle cx="748.031" cy="37.7193" r="15.0307" /> <circle cx="748.031" cy="37.7193" r="15.0307" />
<circle cx="787.241" cy="37.7193" r="15.0307" /> <circle cx="787.241" cy="37.7193" r="15.0307" />
...@@ -149,14 +136,12 @@ export default { ...@@ -149,14 +136,12 @@ export default {
<jobs-table v-else :jobs="jobs.list" /> <jobs-table v-else :jobs="jobs.list" />
<gl-pagination <gl-intersection-observer v-if="hasNextPage" @appear="fetchMoreJobs">
v-if="showPaginationControls" <gl-loading-icon
:value="pagination.currentPage" v-if="$apollo.loading"
:prev-page="prevPage" size="md"
:next-page="nextPage" :aria-label="$options.i18n.loadingAriaLabel"
align="center"
class="gl-mt-3"
@input="handlePageChange"
/> />
</gl-intersection-observer>
</div> </div>
</template> </template>
import cacheConfig from '~/jobs/components/table/graphql/cache_config';
import {
CIJobConnectionExistingCache,
CIJobConnectionIncomingCache,
CIJobConnectionIncomingCacheRunningStatus,
} from '../../../mock_data';
const firstLoadArgs = { first: 3, statuses: 'PENDING' };
const runningArgs = { first: 3, statuses: 'RUNNING' };
describe('jobs/components/table/graphql/cache_config', () => {
describe('when fetching data with the same statuses', () => {
it('should contain cache nodes and a status when merging caches on first load', () => {
const res = cacheConfig.typePolicies.CiJobConnection.merge({}, CIJobConnectionIncomingCache, {
args: firstLoadArgs,
});
expect(res.nodes).toHaveLength(CIJobConnectionIncomingCache.nodes.length);
expect(res.statuses).toBe('PENDING');
});
it('should add to existing caches when merging caches after first load', () => {
const res = cacheConfig.typePolicies.CiJobConnection.merge(
CIJobConnectionExistingCache,
CIJobConnectionIncomingCache,
{
args: firstLoadArgs,
},
);
expect(res.nodes).toHaveLength(
CIJobConnectionIncomingCache.nodes.length + CIJobConnectionExistingCache.nodes.length,
);
});
});
describe('when fetching data with different statuses', () => {
it('should reset cache when a cache already exists', () => {
const res = cacheConfig.typePolicies.CiJobConnection.merge(
CIJobConnectionExistingCache,
CIJobConnectionIncomingCacheRunningStatus,
{
args: runningArgs,
},
);
expect(res.nodes).not.toEqual(CIJobConnectionExistingCache.nodes);
expect(res.nodes).toHaveLength(CIJobConnectionIncomingCacheRunningStatus.nodes.length);
});
});
});
import { GlSkeletonLoader, GlAlert, GlEmptyState, GlPagination } from '@gitlab/ui'; import { GlSkeletonLoader, GlAlert, GlEmptyState, GlIntersectionObserver } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils'; import { mount, shallowMount } from '@vue/test-utils';
import Vue from 'vue'; import Vue from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
...@@ -8,12 +8,7 @@ import getJobsQuery from '~/jobs/components/table/graphql/queries/get_jobs.query ...@@ -8,12 +8,7 @@ import getJobsQuery from '~/jobs/components/table/graphql/queries/get_jobs.query
import JobsTable from '~/jobs/components/table/jobs_table.vue'; import JobsTable from '~/jobs/components/table/jobs_table.vue';
import JobsTableApp from '~/jobs/components/table/jobs_table_app.vue'; import JobsTableApp from '~/jobs/components/table/jobs_table_app.vue';
import JobsTableTabs from '~/jobs/components/table/jobs_table_tabs.vue'; import JobsTableTabs from '~/jobs/components/table/jobs_table_tabs.vue';
import { import { mockJobsQueryResponse, mockJobsQueryEmptyResponse } from '../../mock_data';
mockJobsQueryResponse,
mockJobsQueryEmptyResponse,
mockJobsQueryResponseLastPage,
mockJobsQueryResponseFirstPage,
} from '../../mock_data';
const projectPath = 'gitlab-org/gitlab'; const projectPath = 'gitlab-org/gitlab';
Vue.use(VueApollo); Vue.use(VueApollo);
...@@ -30,10 +25,9 @@ describe('Job table app', () => { ...@@ -30,10 +25,9 @@ describe('Job table app', () => {
const findTabs = () => wrapper.findComponent(JobsTableTabs); const findTabs = () => wrapper.findComponent(JobsTableTabs);
const findAlert = () => wrapper.findComponent(GlAlert); const findAlert = () => wrapper.findComponent(GlAlert);
const findEmptyState = () => wrapper.findComponent(GlEmptyState); const findEmptyState = () => wrapper.findComponent(GlEmptyState);
const findPagination = () => wrapper.findComponent(GlPagination);
const findPrevious = () => findPagination().findAll('.page-item').at(0); const triggerInfiniteScroll = () =>
const findNext = () => findPagination().findAll('.page-item').at(1); wrapper.findComponent(GlIntersectionObserver).vm.$emit('appear');
const createMockApolloProvider = (handler) => { const createMockApolloProvider = (handler) => {
const requestHandlers = [[getJobsQuery, handler]]; const requestHandlers = [[getJobsQuery, handler]];
...@@ -53,7 +47,7 @@ describe('Job table app', () => { ...@@ -53,7 +47,7 @@ describe('Job table app', () => {
}; };
}, },
provide: { provide: {
projectPath, fullPath: projectPath,
}, },
apolloProvider: createMockApolloProvider(handler), apolloProvider: createMockApolloProvider(handler),
}); });
...@@ -69,7 +63,6 @@ describe('Job table app', () => { ...@@ -69,7 +63,6 @@ describe('Job table app', () => {
expect(findSkeletonLoader().exists()).toBe(true); expect(findSkeletonLoader().exists()).toBe(true);
expect(findTable().exists()).toBe(false); expect(findTable().exists()).toBe(false);
expect(findPagination().exists()).toBe(false);
}); });
}); });
...@@ -83,7 +76,6 @@ describe('Job table app', () => { ...@@ -83,7 +76,6 @@ describe('Job table app', () => {
it('should display the jobs table with data', () => { it('should display the jobs table with data', () => {
expect(findTable().exists()).toBe(true); expect(findTable().exists()).toBe(true);
expect(findSkeletonLoader().exists()).toBe(false); expect(findSkeletonLoader().exists()).toBe(false);
expect(findPagination().exists()).toBe(true);
}); });
it('should refetch jobs query on fetchJobsByStatus event', async () => { it('should refetch jobs query on fetchJobsByStatus event', async () => {
...@@ -95,41 +87,24 @@ describe('Job table app', () => { ...@@ -95,41 +87,24 @@ describe('Job table app', () => {
expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(1); expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(1);
}); });
describe('when infinite scrolling is triggered', () => {
beforeEach(() => {
triggerInfiniteScroll();
}); });
describe('pagination', () => { it('does not display a skeleton loader', () => {
it('should disable the next page button on the last page', async () => { expect(findSkeletonLoader().exists()).toBe(false);
createComponent({
handler: jest.fn().mockResolvedValue(mockJobsQueryResponseLastPage),
mountFn: mount,
data: {
pagination: { currentPage: 3 },
},
}); });
it('handles infinite scrolling by calling fetch more', async () => {
await waitForPromises(); await waitForPromises();
expect(findPrevious().exists()).toBe(true); expect(successHandler).toHaveBeenCalledWith({
expect(findNext().exists()).toBe(true); after: 'eyJpZCI6IjIzMTcifQ',
expect(findNext().classes('disabled')).toBe(true); fullPath: 'gitlab-org/gitlab',
}); });
it('should disable the previous page button on the first page', async () => {
createComponent({
handler: jest.fn().mockResolvedValue(mockJobsQueryResponseFirstPage),
mountFn: mount,
data: {
pagination: {
currentPage: 1,
},
},
}); });
await waitForPromises();
expect(findPrevious().exists()).toBe(true);
expect(findPrevious().classes('disabled')).toBe(true);
expect(findNext().exists()).toBe(true);
}); });
}); });
......
...@@ -1579,44 +1579,6 @@ export const mockJobsQueryResponse = { ...@@ -1579,44 +1579,6 @@ export const mockJobsQueryResponse = {
}, },
}; };
export const mockJobsQueryResponseLastPage = {
data: {
project: {
id: '1',
jobs: {
...mockJobsQueryResponse.data.project.jobs,
pageInfo: {
endCursor: 'eyJpZCI6IjIzMTcifQ',
hasNextPage: false,
hasPreviousPage: true,
startCursor: 'eyJpZCI6IjIzMzYifQ',
__typename: 'PageInfo',
},
},
__typename: 'Project',
},
},
};
export const mockJobsQueryResponseFirstPage = {
data: {
project: {
id: '1',
jobs: {
...mockJobsQueryResponse.data.project.jobs,
pageInfo: {
endCursor: 'eyJpZCI6IjIzMTcifQ',
hasNextPage: true,
hasPreviousPage: false,
startCursor: 'eyJpZCI6IjIzMzYifQ',
__typename: 'PageInfo',
},
},
__typename: 'Project',
},
},
};
export const mockJobsQueryEmptyResponse = { export const mockJobsQueryEmptyResponse = {
data: { data: {
project: { project: {
...@@ -1910,3 +1872,44 @@ export const cannotPlayScheduledJob = { ...@@ -1910,3 +1872,44 @@ export const cannotPlayScheduledJob = {
__typename: 'JobPermissions', __typename: 'JobPermissions',
}, },
}; };
export const CIJobConnectionIncomingCache = {
__typename: 'CiJobConnection',
pageInfo: {
__typename: 'PageInfo',
endCursor: 'eyJpZCI6IjIwNTEifQ',
hasNextPage: true,
hasPreviousPage: false,
startCursor: 'eyJpZCI6IjIxNzMifQ',
},
nodes: [
{ __ref: 'CiJob:gid://gitlab/Ci::Build/2057' },
{ __ref: 'CiJob:gid://gitlab/Ci::Build/2056' },
{ __ref: 'CiJob:gid://gitlab/Ci::Build/2051' },
],
};
export const CIJobConnectionIncomingCacheRunningStatus = {
__typename: 'CiJobConnection',
pageInfo: {
__typename: 'PageInfo',
endCursor: 'eyJpZCI6IjIwNTEifQ',
hasNextPage: true,
hasPreviousPage: false,
startCursor: 'eyJpZCI6IjIxNzMifQ',
},
nodes: [
{ __ref: 'CiJob:gid://gitlab/Ci::Build/2000' },
{ __ref: 'CiJob:gid://gitlab/Ci::Build/2001' },
{ __ref: 'CiJob:gid://gitlab/Ci::Build/2002' },
],
};
export const CIJobConnectionExistingCache = {
nodes: [
{ __ref: 'CiJob:gid://gitlab/Ci::Build/2057' },
{ __ref: 'CiJob:gid://gitlab/Ci::Build/2056' },
{ __ref: 'CiJob:gid://gitlab/Ci::Build/2051' },
],
statuses: 'PENDING',
};
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