Commit 287480ad authored by Brandon Labuschagne's avatar Brandon Labuschagne Committed by Enrique Alcántara

Add pagination to throughput table

This commit introduces pagination into the MR
Analytics Throughput table component.
parent b7376396
...@@ -11,6 +11,7 @@ import { ...@@ -11,6 +11,7 @@ import {
GlLoadingIcon, GlLoadingIcon,
GlAlert, GlAlert,
GlIcon, GlIcon,
GlPagination,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { s__, n__ } from '~/locale'; import { s__, n__ } from '~/locale';
import { approximateDuration, differenceInSeconds } from '~/lib/utils/datetime_utility'; import { approximateDuration, differenceInSeconds } from '~/lib/utils/datetime_utility';
...@@ -23,13 +24,21 @@ import { ...@@ -23,13 +24,21 @@ import {
LINE_CHANGE_SYMBOLS, LINE_CHANGE_SYMBOLS,
ASSIGNEES_VISIBLE, ASSIGNEES_VISIBLE,
AVATAR_SIZE, AVATAR_SIZE,
MAX_RECORDS, PER_PAGE,
THROUGHPUT_TABLE_TEST_IDS, THROUGHPUT_TABLE_TEST_IDS,
PIPELINE_STATUS_ICON_CLASSES, PIPELINE_STATUS_ICON_CLASSES,
} from '../constants'; } from '../constants';
const TH_TEST_ID = { 'data-testid': THROUGHPUT_TABLE_TEST_IDS.TABLE_HEADERS }; const TH_TEST_ID = { 'data-testid': THROUGHPUT_TABLE_TEST_IDS.TABLE_HEADERS };
const initialPaginationState = {
currentPage: 1,
prevPageCursor: '',
nextPageCursor: '',
firstPageSize: PER_PAGE,
lastPageSize: null,
};
export default { export default {
name: 'ThroughputTable', name: 'ThroughputTable',
components: { components: {
...@@ -41,6 +50,7 @@ export default { ...@@ -41,6 +50,7 @@ export default {
GlLoadingIcon, GlLoadingIcon,
GlAlert, GlAlert,
GlIcon, GlIcon,
GlPagination,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
...@@ -108,7 +118,8 @@ export default { ...@@ -108,7 +118,8 @@ export default {
}, },
data() { data() {
return { return {
throughputTableData: [], throughputTableData: {},
pagination: initialPaginationState,
hasError: false, hasError: false,
}; };
}, },
...@@ -116,24 +127,24 @@ export default { ...@@ -116,24 +127,24 @@ export default {
throughputTableData: { throughputTableData: {
query: throughputTableQuery, query: throughputTableQuery,
variables() { variables() {
const options = filterToQueryObject({
sourceBranches: this.selectedSourceBranch,
targetBranches: this.selectedTargetBranch,
milestoneTitle: this.selectedMilestone,
authorUsername: this.selectedAuthor,
assigneeUsername: this.selectedAssignee,
labels: this.selectedLabelList,
});
return { return {
fullPath: this.fullPath, fullPath: this.fullPath,
limit: MAX_RECORDS,
startDate: dateFormat(this.startDate, dateFormats.isoDate), startDate: dateFormat(this.startDate, dateFormats.isoDate),
endDate: dateFormat(this.endDate, dateFormats.isoDate), endDate: dateFormat(this.endDate, dateFormats.isoDate),
...options, firstPageSize: this.pagination.firstPageSize,
lastPageSize: this.pagination.lastPageSize,
prevPageCursor: this.pagination.prevPageCursor,
nextPageCursor: this.pagination.nextPageCursor,
...this.options,
};
},
update(data) {
const { mergeRequests: { nodes: list = [], pageInfo = {} } = {} } = data.project || {};
return {
list,
pageInfo,
}; };
}, },
update: data => data.project.mergeRequests.nodes,
error() { error() {
this.hasError = true; this.hasError = true;
}, },
...@@ -151,8 +162,18 @@ export default { ...@@ -151,8 +162,18 @@ export default {
selectedAssignee: state => state.assignees.selected, selectedAssignee: state => state.assignees.selected,
selectedLabelList: state => state.labels.selectedList, selectedLabelList: state => state.labels.selectedList,
}), }),
options() {
return filterToQueryObject({
sourceBranches: this.selectedSourceBranch,
targetBranches: this.selectedTargetBranch,
milestoneTitle: this.selectedMilestone,
authorUsername: this.selectedAuthor,
assigneeUsername: this.selectedAssignee,
labels: this.selectedLabelList,
});
},
tableDataAvailable() { tableDataAvailable() {
return this.throughputTableData.length; return this.throughputTableData.list?.length;
}, },
tableDataLoading() { tableDataLoading() {
return !this.hasError && this.$apollo.queries.throughputTableData.loading; return !this.hasError && this.$apollo.queries.throughputTableData.loading;
...@@ -165,6 +186,20 @@ export default { ...@@ -165,6 +186,20 @@ export default {
: THROUGHPUT_TABLE_STRINGS.NO_DATA, : THROUGHPUT_TABLE_STRINGS.NO_DATA,
}; };
}, },
prevPage() {
return Math.max(this.pagination.currentPage - 1, 0);
},
nextPage() {
return this.throughputTableData.pageInfo.hasNextPage ? this.pagination.currentPage + 1 : null;
},
showPaginationControls() {
return Boolean(this.prevPage || this.nextPage);
},
},
watch: {
options() {
this.resetPagination();
},
}, },
methods: { methods: {
formatMergeRequestId(id) { formatMergeRequestId(id) {
...@@ -190,6 +225,27 @@ export default { ...@@ -190,6 +225,27 @@ export default {
formatApprovalText(approvals) { formatApprovalText(approvals) {
return n__('%d Approval', '%d Approvals', approvals); return n__('%d Approval', '%d Approvals', approvals);
}, },
handlePageChange(page) {
const { startCursor, endCursor } = this.throughputTableData.pageInfo;
if (page > this.pagination.currentPage) {
this.pagination = {
...initialPaginationState,
nextPageCursor: endCursor,
currentPage: page,
};
} else {
this.pagination = {
lastPageSize: PER_PAGE,
firstPageSize: null,
prevPageCursor: startCursor,
currentPage: page,
};
}
},
resetPagination() {
this.pagination = initialPaginationState;
},
}, },
assigneesVisible: ASSIGNEES_VISIBLE, assigneesVisible: ASSIGNEES_VISIBLE,
avatarSize: AVATAR_SIZE, avatarSize: AVATAR_SIZE,
...@@ -198,19 +254,19 @@ export default { ...@@ -198,19 +254,19 @@ export default {
</script> </script>
<template> <template>
<gl-loading-icon v-if="tableDataLoading" size="md" /> <gl-loading-icon v-if="tableDataLoading" size="md" />
<div v-else-if="tableDataAvailable">
<gl-table <gl-table
v-else-if="tableDataAvailable"
:fields="$options.tableHeaderFields" :fields="$options.tableHeaderFields"
:items="throughputTableData" :items="throughputTableData.list"
stacked="sm" stacked="sm"
thead-class="thead-white gl-border-b-solid gl-border-b-1 gl-border-b-gray-100" thead-class="gl-bg-white gl-text-color-secondary gl-border-t-0 gl-border-b-solid gl-border-b-1 gl-border-b-gray-100"
> >
<template #cell(mr_details)="{ item }"> <template #cell(mr_details)="{ item }">
<div <div
class="gl-display-flex gl-flex-direction-column gl-flex-grow-1" class="gl-display-flex gl-flex-direction-column gl-flex-grow-1"
:data-testid="$options.testIds.MERGE_REQUEST_DETAILS" :data-testid="$options.testIds.MERGE_REQUEST_DETAILS"
> >
<div class="merge-request-title str-truncated"> <div class="merge-request-title gl-str-truncated">
<gl-link <gl-link
:href="item.webUrl" :href="item.webUrl"
target="_blank" target="_blank"
...@@ -297,7 +353,12 @@ export default { ...@@ -297,7 +353,12 @@ export default {
collapsed collapsed
> >
<template #avatar="{ avatar }"> <template #avatar="{ avatar }">
<gl-avatar-link v-gl-tooltip target="_blank" :href="avatar.webUrl" :title="avatar.name"> <gl-avatar-link
v-gl-tooltip
target="_blank"
:href="avatar.webUrl"
:title="avatar.name"
>
<gl-avatar :src="avatar.avatarUrl" :size="$options.avatarSize" /> <gl-avatar :src="avatar.avatarUrl" :size="$options.avatarSize" />
</gl-avatar-link> </gl-avatar-link>
</template> </template>
...@@ -305,6 +366,16 @@ export default { ...@@ -305,6 +366,16 @@ export default {
</div> </div>
</template> </template>
</gl-table> </gl-table>
<gl-pagination
v-if="showPaginationControls"
:value="pagination.currentPage"
:prev-page="prevPage"
:next-page="nextPage"
align="center"
class="gl-mt-3"
@input="handlePageChange"
/>
</div>
<gl-alert v-else :variant="alertDetails.class" :dismissible="false" class="gl-mt-4">{{ <gl-alert v-else :variant="alertDetails.class" :dismissible="false" class="gl-mt-4">{{
alertDetails.message alertDetails.message
}}</gl-alert> }}</gl-alert>
......
import { __ } from '~/locale'; import { __ } from '~/locale';
export const DEFAULT_NUMBER_OF_DAYS = 365; export const DEFAULT_NUMBER_OF_DAYS = 365;
export const MAX_RECORDS = 100; export const PER_PAGE = 20;
export const ASSIGNEES_VISIBLE = 2; export const ASSIGNEES_VISIBLE = 2;
export const AVATAR_SIZE = 24; export const AVATAR_SIZE = 24;
......
query( #import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
query getThroughputTableData(
$fullPath: ID! $fullPath: ID!
$startDate: Time! $startDate: Time!
$endDate: Time! $endDate: Time!
$limit: Int!
$labels: [String!] $labels: [String!]
$authorUsername: String $authorUsername: String
$assigneeUsername: String $assigneeUsername: String
$milestoneTitle: String $milestoneTitle: String
$sourceBranches: [String!] $sourceBranches: [String!]
$targetBranches: [String!] $targetBranches: [String!]
$firstPageSize: Int
$lastPageSize: Int
$prevPageCursor: String = ""
$nextPageCursor: String = ""
) { ) {
project(fullPath: $fullPath) { project(fullPath: $fullPath) {
mergeRequests( mergeRequests(
first: $limit first: $firstPageSize
last: $lastPageSize
after: $nextPageCursor
before: $prevPageCursor
mergedAfter: $startDate mergedAfter: $startDate
mergedBefore: $endDate mergedBefore: $endDate
sort: MERGED_AT_DESC sort: MERGED_AT_DESC
...@@ -23,6 +31,9 @@ query( ...@@ -23,6 +31,9 @@ query(
sourceBranches: $sourceBranches sourceBranches: $sourceBranches
targetBranches: $targetBranches targetBranches: $targetBranches
) { ) {
pageInfo {
...PageInfo
}
nodes { nodes {
iid iid
title title
......
import Vuex from 'vuex'; import Vuex from 'vuex';
import { mount, shallowMount, createLocalVue } from '@vue/test-utils'; import { mount, shallowMount, createLocalVue } from '@vue/test-utils';
import { GlAlert, GlLoadingIcon, GlTable, GlIcon, GlAvatarsInline } from '@gitlab/ui'; import { GlAlert, GlLoadingIcon, GlTable, GlIcon, GlAvatarsInline, GlPagination } from '@gitlab/ui';
import store from 'ee/analytics/merge_request_analytics/store'; import store from 'ee/analytics/merge_request_analytics/store';
import ThroughputTable from 'ee/analytics/merge_request_analytics/components/throughput_table.vue'; import ThroughputTable from 'ee/analytics/merge_request_analytics/components/throughput_table.vue';
import { import {
...@@ -13,6 +13,7 @@ import { ...@@ -13,6 +13,7 @@ import {
endDate, endDate,
fullPath, fullPath,
throughputTableHeaders, throughputTableHeaders,
pageInfo,
} from '../mock_data'; } from '../mock_data';
const localVue = createLocalVue(); const localVue = createLocalVue();
...@@ -58,12 +59,10 @@ describe('ThroughputTable', () => { ...@@ -58,12 +59,10 @@ describe('ThroughputTable', () => {
const additionalData = data => { const additionalData = data => {
wrapper.setData({ wrapper.setData({
throughputTableData: [ throughputTableData: {
{ list: [{ ...throughputTableData[0], ...data }],
...throughputTableData[0], pageInfo,
...data,
}, },
],
}); });
}; };
...@@ -77,6 +76,18 @@ describe('ThroughputTable', () => { ...@@ -77,6 +76,18 @@ describe('ThroughputTable', () => {
const findColSubComponent = (colTestId, childComponent) => const findColSubComponent = (colTestId, childComponent) =>
findCol(colTestId).find(childComponent); findCol(colTestId).find(childComponent);
const findPagination = () => wrapper.find(GlPagination);
const findPrevious = () =>
findPagination()
.findAll('.page-item')
.at(0);
const findNext = () =>
findPagination()
.findAll('.page-item')
.at(1);
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null; wrapper = null;
...@@ -101,6 +112,10 @@ describe('ThroughputTable', () => { ...@@ -101,6 +112,10 @@ describe('ThroughputTable', () => {
it('does not display the table', () => { it('does not display the table', () => {
displaysComponent(GlTable, false); displaysComponent(GlTable, false);
}); });
it('does not display the pagination', () => {
displaysComponent(GlPagination, false);
});
}); });
describe('while loading', () => { describe('while loading', () => {
...@@ -132,7 +147,12 @@ describe('ThroughputTable', () => { ...@@ -132,7 +147,12 @@ describe('ThroughputTable', () => {
describe('with data', () => { describe('with data', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createComponent({ func: mount }); wrapper = createComponent({ func: mount });
wrapper.setData({ throughputTableData }); wrapper.setData({
throughputTableData: {
list: throughputTableData,
pageInfo,
},
});
}); });
it('displays the table', () => { it('displays the table', () => {
...@@ -147,6 +167,10 @@ describe('ThroughputTable', () => { ...@@ -147,6 +167,10 @@ describe('ThroughputTable', () => {
displaysComponent(GlAlert, false); displaysComponent(GlAlert, false);
}); });
it('displays the pagination', () => {
displaysComponent(GlPagination, true);
});
describe('table fields', () => { describe('table fields', () => {
it('displays the correct table headers', () => { it('displays the correct table headers', () => {
const headers = findTable().findAll(`[data-testid="${TEST_IDS.TABLE_HEADERS}"]`); const headers = findTable().findAll(`[data-testid="${TEST_IDS.TABLE_HEADERS}"]`);
...@@ -350,6 +374,60 @@ describe('ThroughputTable', () => { ...@@ -350,6 +374,60 @@ describe('ThroughputTable', () => {
}); });
}); });
describe('pagination', () => {
beforeEach(() => {
wrapper = createComponent({ func: mount });
wrapper.setData({
throughputTableData: {
list: throughputTableData,
pageInfo,
},
});
});
it('disables the prev button on the first page', () => {
expect(findPrevious().classes()).toContain('disabled');
expect(findNext().classes()).not.toContain('disabled');
});
it('disables the next button on the last page', async () => {
wrapper.setData({
pagination: {
currentPage: 3,
},
throughputTableData: {
pageInfo: {
hasNextPage: false,
},
},
});
await wrapper.vm.$nextTick();
expect(findPrevious().classes()).not.toContain('disabled');
expect(findNext().classes()).toContain('disabled');
});
it('shows the prev and next buttons on middle pages', async () => {
wrapper.setData({
pagination: {
currentPage: 2,
},
throughputTableData: {
pageInfo: {
hasNextPage: true,
hasPrevPage: true,
},
},
});
await wrapper.vm.$nextTick();
expect(findPrevious().classes()).not.toContain('disabled');
expect(findNext().classes()).not.toContain('disabled');
});
});
describe('with errors', () => { describe('with errors', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createComponent(); wrapper = createComponent();
......
...@@ -57,6 +57,13 @@ export const throughputTableHeaders = [ ...@@ -57,6 +57,13 @@ export const throughputTableHeaders = [
'Assignees', 'Assignees',
]; ];
export const pageInfo = {
hasNextPage: true,
hasPreviousPage: false,
startCursor: 'abc',
endCursor: 'bcd',
};
export const throughputTableData = [ export const throughputTableData = [
{ {
iid: '1', iid: '1',
......
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