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 {
GlLoadingIcon,
GlAlert,
GlIcon,
GlPagination,
} from '@gitlab/ui';
import { s__, n__ } from '~/locale';
import { approximateDuration, differenceInSeconds } from '~/lib/utils/datetime_utility';
......@@ -23,13 +24,21 @@ import {
LINE_CHANGE_SYMBOLS,
ASSIGNEES_VISIBLE,
AVATAR_SIZE,
MAX_RECORDS,
PER_PAGE,
THROUGHPUT_TABLE_TEST_IDS,
PIPELINE_STATUS_ICON_CLASSES,
} from '../constants';
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 {
name: 'ThroughputTable',
components: {
......@@ -41,6 +50,7 @@ export default {
GlLoadingIcon,
GlAlert,
GlIcon,
GlPagination,
},
directives: {
GlTooltip: GlTooltipDirective,
......@@ -108,7 +118,8 @@ export default {
},
data() {
return {
throughputTableData: [],
throughputTableData: {},
pagination: initialPaginationState,
hasError: false,
};
},
......@@ -116,24 +127,24 @@ export default {
throughputTableData: {
query: throughputTableQuery,
variables() {
const options = filterToQueryObject({
sourceBranches: this.selectedSourceBranch,
targetBranches: this.selectedTargetBranch,
milestoneTitle: this.selectedMilestone,
authorUsername: this.selectedAuthor,
assigneeUsername: this.selectedAssignee,
labels: this.selectedLabelList,
});
return {
fullPath: this.fullPath,
limit: MAX_RECORDS,
startDate: dateFormat(this.startDate, 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() {
this.hasError = true;
},
......@@ -151,8 +162,18 @@ export default {
selectedAssignee: state => state.assignees.selected,
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() {
return this.throughputTableData.length;
return this.throughputTableData.list?.length;
},
tableDataLoading() {
return !this.hasError && this.$apollo.queries.throughputTableData.loading;
......@@ -165,6 +186,20 @@ export default {
: 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: {
formatMergeRequestId(id) {
......@@ -190,6 +225,27 @@ export default {
formatApprovalText(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,
avatarSize: AVATAR_SIZE,
......@@ -198,113 +254,128 @@ export default {
</script>
<template>
<gl-loading-icon v-if="tableDataLoading" size="md" />
<gl-table
v-else-if="tableDataAvailable"
:fields="$options.tableHeaderFields"
:items="throughputTableData"
stacked="sm"
thead-class="thead-white gl-border-b-solid gl-border-b-1 gl-border-b-gray-100"
>
<template #cell(mr_details)="{ item }">
<div
class="gl-display-flex gl-flex-direction-column gl-flex-grow-1"
:data-testid="$options.testIds.MERGE_REQUEST_DETAILS"
>
<div class="merge-request-title str-truncated">
<gl-link
:href="item.webUrl"
target="_blank"
class="gl-font-weight-bold gl-text-gray-900"
>{{ item.title }}</gl-link
>
<ul class="horizontal-list gl-mb-0">
<li class="gl-mr-3">{{ formatMergeRequestId(item.iid) }}</li>
<li v-if="item.pipelines.nodes.length" class="gl-mr-3">
<gl-icon
:name="item.pipelines.nodes[0].detailedStatus.icon"
:class="pipelineStatusClass(item.pipelines.nodes[0].detailedStatus.icon)"
/>
</li>
<li
class="gl-mr-3 gl-display-flex gl-align-items-center"
:class="{ 'gl-opacity-5': !item.labels.nodes.length }"
:data-testid="$options.testIds.LABEL_DETAILS"
>
<gl-icon name="label" class="gl-mr-1" /><span>{{ item.labels.nodes.length }}</span>
</li>
<li
class="gl-mr-3 gl-display-flex gl-align-items-center"
:class="{ 'gl-opacity-5': !item.userNotesCount }"
:data-testid="$options.testIds.COMMENT_COUNT"
>
<gl-icon name="comments" class="gl-mr-2" /><span>{{ item.userNotesCount }}</span>
</li>
<li
v-if="item.approvedBy.nodes.length"
class="gl-text-green-500"
:data-testid="$options.testIds.APPROVED"
<div v-else-if="tableDataAvailable">
<gl-table
:fields="$options.tableHeaderFields"
:items="throughputTableData.list"
stacked="sm"
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 }">
<div
class="gl-display-flex gl-flex-direction-column gl-flex-grow-1"
:data-testid="$options.testIds.MERGE_REQUEST_DETAILS"
>
<div class="merge-request-title gl-str-truncated">
<gl-link
:href="item.webUrl"
target="_blank"
class="gl-font-weight-bold gl-text-gray-900"
>{{ item.title }}</gl-link
>
<gl-icon name="approval" class="gl-mr-2" /><span>{{
formatApprovalText(item.approvedBy.nodes.length)
}}</span>
</li>
</ul>
<ul class="horizontal-list gl-mb-0">
<li class="gl-mr-3">{{ formatMergeRequestId(item.iid) }}</li>
<li v-if="item.pipelines.nodes.length" class="gl-mr-3">
<gl-icon
:name="item.pipelines.nodes[0].detailedStatus.icon"
:class="pipelineStatusClass(item.pipelines.nodes[0].detailedStatus.icon)"
/>
</li>
<li
class="gl-mr-3 gl-display-flex gl-align-items-center"
:class="{ 'gl-opacity-5': !item.labels.nodes.length }"
:data-testid="$options.testIds.LABEL_DETAILS"
>
<gl-icon name="label" class="gl-mr-1" /><span>{{ item.labels.nodes.length }}</span>
</li>
<li
class="gl-mr-3 gl-display-flex gl-align-items-center"
:class="{ 'gl-opacity-5': !item.userNotesCount }"
:data-testid="$options.testIds.COMMENT_COUNT"
>
<gl-icon name="comments" class="gl-mr-2" /><span>{{ item.userNotesCount }}</span>
</li>
<li
v-if="item.approvedBy.nodes.length"
class="gl-text-green-500"
:data-testid="$options.testIds.APPROVED"
>
<gl-icon name="approval" class="gl-mr-2" /><span>{{
formatApprovalText(item.approvedBy.nodes.length)
}}</span>
</li>
</ul>
</div>
</div>
</div>
</template>
</template>
<template #cell(date_merged)="{ item }">
<div :data-testid="$options.testIds.DATE_MERGED">{{ formatDateMerged(item.mergedAt) }}</div>
</template>
<template #cell(date_merged)="{ item }">
<div :data-testid="$options.testIds.DATE_MERGED">{{ formatDateMerged(item.mergedAt) }}</div>
</template>
<template #cell(time_to_merge)="{ item }">
<div :data-testid="$options.testIds.TIME_TO_MERGE">
{{ computeTimeToMerge(item.createdAt, item.mergedAt) }}
</div>
</template>
<template #cell(time_to_merge)="{ item }">
<div :data-testid="$options.testIds.TIME_TO_MERGE">
{{ computeTimeToMerge(item.createdAt, item.mergedAt) }}
</div>
</template>
<template #cell(milestone)="{ item }">
<div v-if="item.milestone" :data-testid="$options.testIds.MILESTONE">
{{ item.milestone.title }}
</div>
</template>
<template #cell(milestone)="{ item }">
<div v-if="item.milestone" :data-testid="$options.testIds.MILESTONE">
{{ item.milestone.title }}
</div>
</template>
<template #cell(commits)="{ item }">
<div :data-testid="$options.testIds.COMMITS">{{ item.commitCount }}</div>
</template>
<template #cell(commits)="{ item }">
<div :data-testid="$options.testIds.COMMITS">{{ item.commitCount }}</div>
</template>
<template #cell(pipelines)="{ item }">
<div :data-testid="$options.testIds.PIPELINES">{{ item.pipelines.nodes.length }}</div>
</template>
<template #cell(pipelines)="{ item }">
<div :data-testid="$options.testIds.PIPELINES">{{ item.pipelines.nodes.length }}</div>
</template>
<template #cell(line_changes)="{ item }">
<div :data-testid="$options.testIds.LINE_CHANGES">
<span class="gl-font-weight-bold gl-text-green-500">{{
formatLineChangeAdditions(item.diffStatsSummary.additions)
}}</span>
<span class="gl-font-weight-bold gl-text-red-500">{{
formatLineChangeDeletions(item.diffStatsSummary.deletions)
}}</span>
</div>
</template>
<template #cell(line_changes)="{ item }">
<div :data-testid="$options.testIds.LINE_CHANGES">
<span class="gl-font-weight-bold gl-text-green-500">{{
formatLineChangeAdditions(item.diffStatsSummary.additions)
}}</span>
<span class="gl-font-weight-bold gl-text-red-500">{{
formatLineChangeDeletions(item.diffStatsSummary.deletions)
}}</span>
</div>
</template>
<template #cell(assignees)="{ item }">
<div :data-testid="$options.testIds.ASSIGNEES">
<gl-avatars-inline
:avatars="item.assignees.nodes"
:avatar-size="$options.avatarSize"
:max-visible="$options.assigneesVisible"
collapsed
>
<template #avatar="{ avatar }">
<gl-avatar-link v-gl-tooltip target="_blank" :href="avatar.webUrl" :title="avatar.name">
<gl-avatar :src="avatar.avatarUrl" :size="$options.avatarSize" />
</gl-avatar-link>
</template>
</gl-avatars-inline>
</div>
</template>
</gl-table>
<template #cell(assignees)="{ item }">
<div :data-testid="$options.testIds.ASSIGNEES">
<gl-avatars-inline
:avatars="item.assignees.nodes"
:avatar-size="$options.avatarSize"
:max-visible="$options.assigneesVisible"
collapsed
>
<template #avatar="{ avatar }">
<gl-avatar-link
v-gl-tooltip
target="_blank"
:href="avatar.webUrl"
:title="avatar.name"
>
<gl-avatar :src="avatar.avatarUrl" :size="$options.avatarSize" />
</gl-avatar-link>
</template>
</gl-avatars-inline>
</div>
</template>
</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">{{
alertDetails.message
}}</gl-alert>
......
import { __ } from '~/locale';
export const DEFAULT_NUMBER_OF_DAYS = 365;
export const MAX_RECORDS = 100;
export const PER_PAGE = 20;
export const ASSIGNEES_VISIBLE = 2;
export const AVATAR_SIZE = 24;
......
query(
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
query getThroughputTableData(
$fullPath: ID!
$startDate: Time!
$endDate: Time!
$limit: Int!
$labels: [String!]
$authorUsername: String
$assigneeUsername: String
$milestoneTitle: String
$sourceBranches: [String!]
$targetBranches: [String!]
$firstPageSize: Int
$lastPageSize: Int
$prevPageCursor: String = ""
$nextPageCursor: String = ""
) {
project(fullPath: $fullPath) {
mergeRequests(
first: $limit
first: $firstPageSize
last: $lastPageSize
after: $nextPageCursor
before: $prevPageCursor
mergedAfter: $startDate
mergedBefore: $endDate
sort: MERGED_AT_DESC
......@@ -23,6 +31,9 @@ query(
sourceBranches: $sourceBranches
targetBranches: $targetBranches
) {
pageInfo {
...PageInfo
}
nodes {
iid
title
......
import Vuex from 'vuex';
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 ThroughputTable from 'ee/analytics/merge_request_analytics/components/throughput_table.vue';
import {
......@@ -13,6 +13,7 @@ import {
endDate,
fullPath,
throughputTableHeaders,
pageInfo,
} from '../mock_data';
const localVue = createLocalVue();
......@@ -58,12 +59,10 @@ describe('ThroughputTable', () => {
const additionalData = data => {
wrapper.setData({
throughputTableData: [
{
...throughputTableData[0],
...data,
},
],
throughputTableData: {
list: [{ ...throughputTableData[0], ...data }],
pageInfo,
},
});
};
......@@ -77,6 +76,18 @@ describe('ThroughputTable', () => {
const findColSubComponent = (colTestId, 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(() => {
wrapper.destroy();
wrapper = null;
......@@ -101,6 +112,10 @@ describe('ThroughputTable', () => {
it('does not display the table', () => {
displaysComponent(GlTable, false);
});
it('does not display the pagination', () => {
displaysComponent(GlPagination, false);
});
});
describe('while loading', () => {
......@@ -132,7 +147,12 @@ describe('ThroughputTable', () => {
describe('with data', () => {
beforeEach(() => {
wrapper = createComponent({ func: mount });
wrapper.setData({ throughputTableData });
wrapper.setData({
throughputTableData: {
list: throughputTableData,
pageInfo,
},
});
});
it('displays the table', () => {
......@@ -147,6 +167,10 @@ describe('ThroughputTable', () => {
displaysComponent(GlAlert, false);
});
it('displays the pagination', () => {
displaysComponent(GlPagination, true);
});
describe('table fields', () => {
it('displays the correct table headers', () => {
const headers = findTable().findAll(`[data-testid="${TEST_IDS.TABLE_HEADERS}"]`);
......@@ -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', () => {
beforeEach(() => {
wrapper = createComponent();
......
......@@ -57,6 +57,13 @@ export const throughputTableHeaders = [
'Assignees',
];
export const pageInfo = {
hasNextPage: true,
hasPreviousPage: false,
startCursor: 'abc',
endCursor: 'bcd',
};
export const throughputTableData = [
{
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