diff --git a/app/assets/javascripts/jobs/components/table/constants.js b/app/assets/javascripts/jobs/components/table/constants.js new file mode 100644 index 0000000000000000000000000000000000000000..7e973a34e5c533869611476d7b5b5cbc690202af --- /dev/null +++ b/app/assets/javascripts/jobs/components/table/constants.js @@ -0,0 +1,9 @@ +export const GRAPHQL_PAGE_SIZE = 30; + +export const initialPaginationState = { + currentPage: 1, + prevPageCursor: '', + nextPageCursor: '', + first: GRAPHQL_PAGE_SIZE, + last: null, +}; diff --git a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql index c2104754bad344f490d807b024be94aa24e1bddd..68c6584cda623df6fd470547347396c956ab7887 100644 --- a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql +++ b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql @@ -1,6 +1,13 @@ -query getJobs($fullPath: ID!, $statuses: [CiJobStatus!]) { +query getJobs( + $fullPath: ID! + $first: Int + $last: Int + $after: String + $before: String + $statuses: [CiJobStatus!] +) { project(fullPath: $fullPath) { - jobs(first: 20, statuses: $statuses) { + jobs(after: $after, before: $before, first: $first, last: $last, statuses: $statuses) { pageInfo { endCursor hasNextPage diff --git a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue index 4bbb292ad94d17b3c157f5967b29574f2f8aa1a5..2061b1f1eb21622e1527cf22281de062c1af125d 100644 --- a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue +++ b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue @@ -1,6 +1,7 @@ <script> -import { GlAlert, GlSkeletonLoader } from '@gitlab/ui'; +import { GlAlert, GlPagination, GlSkeletonLoader } from '@gitlab/ui'; import { __ } from '~/locale'; +import { GRAPHQL_PAGE_SIZE, initialPaginationState } from './constants'; import GetJobs from './graphql/queries/get_jobs.query.graphql'; import JobsTable from './jobs_table.vue'; import JobsTableEmptyState from './jobs_table_empty_state.vue'; @@ -12,6 +13,7 @@ export default { }, components: { GlAlert, + GlPagination, GlSkeletonLoader, JobsTable, JobsTableEmptyState, @@ -28,10 +30,18 @@ export default { variables() { return { fullPath: this.fullPath, + first: this.pagination.first, + last: this.pagination.last, + after: this.pagination.nextPageCursor, + before: this.pagination.prevPageCursor, }; }, - update({ project }) { - return project?.jobs?.nodes || []; + update(data) { + const { jobs: { nodes: list = [], pageInfo = {} } = {} } = data.project || {}; + return { + list, + pageInfo, + }; }, error() { this.hasError = true; @@ -40,10 +50,11 @@ export default { }, data() { return { - jobs: null, + jobs: {}, hasError: false, isAlertDismissed: false, scope: null, + pagination: initialPaginationState, }; }, computed: { @@ -51,7 +62,16 @@ export default { return this.hasError && !this.isAlertDismissed; }, showEmptyState() { - return this.jobs.length === 0 && !this.scope; + return this.jobs.list.length === 0 && !this.scope; + }, + prevPage() { + return Math.max(this.pagination.currentPage - 1, 0); + }, + nextPage() { + return this.jobs.pageInfo?.hasNextPage ? this.pagination.currentPage + 1 : null; + }, + showPaginationControls() { + return Boolean(this.prevPage || this.nextPage) && !this.$apollo.loading; }, }, methods: { @@ -60,6 +80,24 @@ export default { this.$apollo.queries.jobs.refetch({ statuses: scope }); }, + handlePageChange(page) { + const { startCursor, endCursor } = this.jobs.pageInfo; + + if (page > this.pagination.currentPage) { + this.pagination = { + ...initialPaginationState, + nextPageCursor: endCursor, + currentPage: page, + }; + } else { + this.pagination = { + last: GRAPHQL_PAGE_SIZE, + first: null, + prevPageCursor: startCursor, + currentPage: page, + }; + } + }, }, }; </script> @@ -97,6 +135,16 @@ export default { <jobs-table-empty-state v-else-if="showEmptyState" /> - <jobs-table v-else :jobs="jobs" /> + <jobs-table v-else :jobs="jobs.list" /> + + <gl-pagination + v-if="showPaginationControls" + :value="pagination.currentPage" + :prev-page="prevPage" + :next-page="nextPage" + align="center" + class="gl-mt-3" + @input="handlePageChange" + /> </div> </template> diff --git a/spec/frontend/jobs/components/table/job_table_app_spec.js b/spec/frontend/jobs/components/table/job_table_app_spec.js index 9d1135e26c8442ceb29950a829d2ef6eb7b6f5e7..482d0df4e9a389ca6ee984244ad4b881a4bb461b 100644 --- a/spec/frontend/jobs/components/table/job_table_app_spec.js +++ b/spec/frontend/jobs/components/table/job_table_app_spec.js @@ -1,4 +1,4 @@ -import { GlSkeletonLoader, GlAlert, GlEmptyState } from '@gitlab/ui'; +import { GlSkeletonLoader, GlAlert, GlEmptyState, GlPagination } from '@gitlab/ui'; import { createLocalVue, mount, shallowMount } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; @@ -25,6 +25,10 @@ describe('Job table app', () => { const findTabs = () => wrapper.findComponent(JobsTableTabs); const findAlert = () => wrapper.findComponent(GlAlert); const findEmptyState = () => wrapper.findComponent(GlEmptyState); + const findPagination = () => wrapper.findComponent(GlPagination); + + const findPrevious = () => findPagination().findAll('.page-item').at(0); + const findNext = () => findPagination().findAll('.page-item').at(1); const createMockApolloProvider = (handler) => { const requestHandlers = [[getJobsQuery, handler]]; @@ -32,8 +36,17 @@ describe('Job table app', () => { return createMockApollo(requestHandlers); }; - const createComponent = (handler = successHandler, mountFn = shallowMount) => { + const createComponent = ({ + handler = successHandler, + mountFn = shallowMount, + data = {}, + } = {}) => { wrapper = mountFn(JobsTableApp, { + data() { + return { + ...data, + }; + }, provide: { projectPath, }, @@ -52,6 +65,7 @@ describe('Job table app', () => { expect(findSkeletonLoader().exists()).toBe(true); expect(findTable().exists()).toBe(false); + expect(findPagination().exists()).toBe(false); }); }); @@ -65,9 +79,10 @@ describe('Job table app', () => { it('should display the jobs table with data', () => { expect(findTable().exists()).toBe(true); expect(findSkeletonLoader().exists()).toBe(false); + expect(findPagination().exists()).toBe(true); }); - it('should retfech jobs query on fetchJobsByStatus event', async () => { + it('should refetch jobs query on fetchJobsByStatus event', async () => { jest.spyOn(wrapper.vm.$apollo.queries.jobs, 'refetch').mockImplementation(jest.fn()); expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0); @@ -78,9 +93,72 @@ describe('Job table app', () => { }); }); + describe('pagination', () => { + it('should disable the next page button on the last page', async () => { + createComponent({ + handler: successHandler, + mountFn: mount, + data: { + pagination: { + currentPage: 3, + }, + jobs: { + pageInfo: { + hasPreviousPage: true, + startCursor: 'abc', + endCursor: 'bcd', + }, + }, + }, + }); + + await wrapper.vm.$nextTick(); + + wrapper.setData({ + jobs: { + pageInfo: { + hasNextPage: false, + }, + }, + }); + + await wrapper.vm.$nextTick(); + + expect(findPrevious().exists()).toBe(true); + expect(findNext().exists()).toBe(true); + expect(findNext().classes('disabled')).toBe(true); + }); + + it('should disable the previous page button on the first page', async () => { + createComponent({ + handler: successHandler, + mountFn: mount, + data: { + pagination: { + currentPage: 1, + }, + jobs: { + pageInfo: { + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'abc', + endCursor: 'bcd', + }, + }, + }, + }); + + await wrapper.vm.$nextTick(); + + expect(findPrevious().exists()).toBe(true); + expect(findPrevious().classes('disabled')).toBe(true); + expect(findNext().exists()).toBe(true); + }); + }); + describe('error state', () => { it('should show an alert if there is an error fetching the data', async () => { - createComponent(failedHandler); + createComponent({ handler: failedHandler }); await waitForPromises(); @@ -90,7 +168,7 @@ describe('Job table app', () => { describe('empty state', () => { it('should display empty state if there are no jobs and tab scope is null', async () => { - createComponent(emptyHandler, mount); + createComponent({ handler: emptyHandler, mountFn: mount }); await waitForPromises(); @@ -99,7 +177,7 @@ describe('Job table app', () => { }); it('should not display empty state if there are jobs and tab scope is not null', async () => { - createComponent(successHandler, mount); + createComponent({ handler: successHandler, mountFn: mount }); await waitForPromises();