Commit ac1f366c authored by Enrique Alcántara's avatar Enrique Alcántara

Merge branch '229842-merge-request-analytics-paginate-data-table' into 'master'

Merge Request Analytics: Paginate data table

See merge request gitlab-org/gitlab!42806
parents 9d18fb55 287480ad
......@@ -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