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 { ...@@ -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,113 +254,128 @@ export default { ...@@ -198,113 +254,128 @@ export default {
</script> </script>
<template> <template>
<gl-loading-icon v-if="tableDataLoading" size="md" /> <gl-loading-icon v-if="tableDataLoading" size="md" />
<gl-table <div v-else-if="tableDataAvailable">
v-else-if="tableDataAvailable" <gl-table
: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"
class="gl-font-weight-bold gl-text-gray-900" class="gl-font-weight-bold gl-text-gray-900"
>{{ item.title }}</gl-link >{{ 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"
> >
<gl-icon name="approval" class="gl-mr-2" /><span>{{ <ul class="horizontal-list gl-mb-0">
formatApprovalText(item.approvedBy.nodes.length) <li class="gl-mr-3">{{ formatMergeRequestId(item.iid) }}</li>
}}</span> <li v-if="item.pipelines.nodes.length" class="gl-mr-3">
</li> <gl-icon
</ul> :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>
</div> </template>
</template>
<template #cell(date_merged)="{ item }"> <template #cell(date_merged)="{ item }">
<div :data-testid="$options.testIds.DATE_MERGED">{{ formatDateMerged(item.mergedAt) }}</div> <div :data-testid="$options.testIds.DATE_MERGED">{{ formatDateMerged(item.mergedAt) }}</div>
</template> </template>
<template #cell(time_to_merge)="{ item }"> <template #cell(time_to_merge)="{ item }">
<div :data-testid="$options.testIds.TIME_TO_MERGE"> <div :data-testid="$options.testIds.TIME_TO_MERGE">
{{ computeTimeToMerge(item.createdAt, item.mergedAt) }} {{ computeTimeToMerge(item.createdAt, item.mergedAt) }}
</div> </div>
</template> </template>
<template #cell(milestone)="{ item }"> <template #cell(milestone)="{ item }">
<div v-if="item.milestone" :data-testid="$options.testIds.MILESTONE"> <div v-if="item.milestone" :data-testid="$options.testIds.MILESTONE">
{{ item.milestone.title }} {{ item.milestone.title }}
</div> </div>
</template> </template>
<template #cell(commits)="{ item }"> <template #cell(commits)="{ item }">
<div :data-testid="$options.testIds.COMMITS">{{ item.commitCount }}</div> <div :data-testid="$options.testIds.COMMITS">{{ item.commitCount }}</div>
</template> </template>
<template #cell(pipelines)="{ item }"> <template #cell(pipelines)="{ item }">
<div :data-testid="$options.testIds.PIPELINES">{{ item.pipelines.nodes.length }}</div> <div :data-testid="$options.testIds.PIPELINES">{{ item.pipelines.nodes.length }}</div>
</template> </template>
<template #cell(line_changes)="{ item }"> <template #cell(line_changes)="{ item }">
<div :data-testid="$options.testIds.LINE_CHANGES"> <div :data-testid="$options.testIds.LINE_CHANGES">
<span class="gl-font-weight-bold gl-text-green-500">{{ <span class="gl-font-weight-bold gl-text-green-500">{{
formatLineChangeAdditions(item.diffStatsSummary.additions) formatLineChangeAdditions(item.diffStatsSummary.additions)
}}</span> }}</span>
<span class="gl-font-weight-bold gl-text-red-500">{{ <span class="gl-font-weight-bold gl-text-red-500">{{
formatLineChangeDeletions(item.diffStatsSummary.deletions) formatLineChangeDeletions(item.diffStatsSummary.deletions)
}}</span> }}</span>
</div> </div>
</template> </template>
<template #cell(assignees)="{ item }"> <template #cell(assignees)="{ item }">
<div :data-testid="$options.testIds.ASSIGNEES"> <div :data-testid="$options.testIds.ASSIGNEES">
<gl-avatars-inline <gl-avatars-inline
:avatars="item.assignees.nodes" :avatars="item.assignees.nodes"
:avatar-size="$options.avatarSize" :avatar-size="$options.avatarSize"
:max-visible="$options.assigneesVisible" :max-visible="$options.assigneesVisible"
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
<gl-avatar :src="avatar.avatarUrl" :size="$options.avatarSize" /> v-gl-tooltip
</gl-avatar-link> target="_blank"
</template> :href="avatar.webUrl"
</gl-avatars-inline> :title="avatar.name"
</div> >
</template> <gl-avatar :src="avatar.avatarUrl" :size="$options.avatarSize" />
</gl-table> </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">{{ <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