Skip to content
Snippets Groups Projects
Commit 801ec7cf authored by Miguel Rincon's avatar Miguel Rincon
Browse files

Add pagination to runners

This change adds a pagination to the admin runner UI by
creating a new pagination component for the runners.

It also updates the GraphQL query with the required parameters and
pagination information.
parent bf020294
No related branches found
No related tags found
No related merge requests found
Showing
with 446 additions and 30 deletions
Loading
Loading
@@ -113,6 +113,7 @@ export default {
this.$emit('input', {
filters,
sort,
pagination: { page: 1 },
});
},
onSort(sort) {
Loading
Loading
@@ -121,6 +122,7 @@ export default {
this.$emit('input', {
filters,
sort,
pagination: { page: 1 },
});
},
},
Loading
Loading
Loading
Loading
@@ -136,7 +136,5 @@ export default {
<!-- TODO add actions to update runners -->
</template>
</gl-table>
<!-- TODO implement pagination -->
</div>
</template>
<script>
import { GlPagination } from '@gitlab/ui';
export default {
components: {
GlPagination,
},
props: {
value: {
required: false,
type: Object,
default: () => ({
page: 1,
}),
},
pageInfo: {
required: false,
type: Object,
default: () => ({}),
},
},
computed: {
prevPage() {
return this.pageInfo?.hasPreviousPage ? this.value?.page - 1 : null;
},
nextPage() {
return this.pageInfo?.hasNextPage ? this.value?.page + 1 : null;
},
},
methods: {
handlePageChange(page) {
if (page > this.value.page) {
this.$emit('input', {
page,
after: this.pageInfo.endCursor,
});
} else {
this.$emit('input', {
page,
before: this.pageInfo.startCursor,
});
}
},
},
};
</script>
<template>
<gl-pagination
:value="value.page"
:prev-page="prevPage"
:next-page="nextPage"
align="center"
class="gl-pagination gl-mt-3"
@input="handlePageChange"
/>
</template>
import { s__ } from '~/locale';
 
export const RUNNER_PAGE_SIZE = 20;
export const I18N_DETAILS_TITLE = s__('Runners|Runner #%{runner_id}');
 
export const RUNNER_ENTITY_TYPE = 'Ci::Runner';
Loading
Loading
@@ -11,6 +13,9 @@ export const RUNNER_ENTITY_TYPE = 'Ci::Runner';
export const PARAM_KEY_STATUS = 'status';
export const PARAM_KEY_RUNNER_TYPE = 'runner_type';
export const PARAM_KEY_SORT = 'sort';
export const PARAM_KEY_PAGE = 'page';
export const PARAM_KEY_AFTER = 'after';
export const PARAM_KEY_BEFORE = 'before';
 
// CiRunnerType
 
Loading
Loading
query getRunners($status: CiRunnerStatus, $type: CiRunnerType, $sort: CiRunnerSort) {
runners(status: $status, type: $type, sort: $sort) {
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
query getRunners(
$before: String
$after: String
$first: Int
$last: Int
$status: CiRunnerStatus
$type: CiRunnerType
$sort: CiRunnerSort
) {
runners(
before: $before
after: $after
first: $first
last: $last
status: $status
type: $type
sort: $sort
) {
nodes {
id
description
Loading
Loading
@@ -13,5 +31,8 @@ query getRunners($status: CiRunnerStatus, $type: CiRunnerType, $sort: CiRunnerSo
tagList
contactedAt
}
pageInfo {
...PageInfo
}
}
}
Loading
Loading
@@ -3,7 +3,11 @@ import {
PARAM_KEY_STATUS,
PARAM_KEY_RUNNER_TYPE,
PARAM_KEY_SORT,
PARAM_KEY_PAGE,
PARAM_KEY_AFTER,
PARAM_KEY_BEFORE,
DEFAULT_SORT,
RUNNER_PAGE_SIZE,
} from '../constants';
 
const getValuesFromFilters = (paramKey, filters) => {
Loading
Loading
@@ -30,6 +34,23 @@ const getFilterFromParams = (paramKey, params) => {
});
};
 
const getPaginationFromParams = (params) => {
const page = parseInt(params[PARAM_KEY_PAGE], 10);
const after = params[PARAM_KEY_AFTER];
const before = params[PARAM_KEY_BEFORE];
if (page && (before || after)) {
return {
page,
before,
after,
};
}
return {
page: 1,
};
};
export const fromUrlQueryToSearch = (query = window.location.search) => {
const params = queryToObject(query, { gatherArrays: true });
 
Loading
Loading
@@ -39,10 +60,14 @@ export const fromUrlQueryToSearch = (query = window.location.search) => {
...getFilterFromParams(PARAM_KEY_RUNNER_TYPE, params),
],
sort: params[PARAM_KEY_SORT] || DEFAULT_SORT,
pagination: getPaginationFromParams(params),
};
};
 
export const fromSearchToUrl = ({ filters = [], sort = null }, url = window.location.href) => {
export const fromSearchToUrl = (
{ filters = [], sort = null, pagination = {} },
url = window.location.href,
) => {
const urlParams = {
[PARAM_KEY_STATUS]: getValuesFromFilters(PARAM_KEY_STATUS, filters),
[PARAM_KEY_RUNNER_TYPE]: getValuesFromFilters(PARAM_KEY_RUNNER_TYPE, filters),
Loading
Loading
@@ -52,10 +77,21 @@ export const fromSearchToUrl = ({ filters = [], sort = null }, url = window.loca
urlParams[PARAM_KEY_SORT] = sort;
}
 
// Remove pagination params for first page
if (pagination?.page === 1) {
urlParams[PARAM_KEY_PAGE] = null;
urlParams[PARAM_KEY_BEFORE] = null;
urlParams[PARAM_KEY_AFTER] = null;
} else {
urlParams[PARAM_KEY_PAGE] = pagination.page;
urlParams[PARAM_KEY_BEFORE] = pagination.before;
urlParams[PARAM_KEY_AFTER] = pagination.after;
}
return setUrlParams(urlParams, url, false, true, true);
};
 
export const fromSearchToVariables = ({ filters = [], sort = null } = {}) => {
export const fromSearchToVariables = ({ filters = [], sort = null, pagination = {} } = {}) => {
const variables = {};
 
// TODO Get more than one value when GraphQL API supports OR for "status"
Loading
Loading
@@ -68,5 +104,13 @@ export const fromSearchToVariables = ({ filters = [], sort = null } = {}) => {
variables.sort = sort;
}
 
if (pagination.before) {
variables.before = pagination.before;
variables.last = RUNNER_PAGE_SIZE;
} else {
variables.after = pagination.after;
variables.first = RUNNER_PAGE_SIZE;
}
return variables;
};
Loading
Loading
@@ -4,6 +4,7 @@ import { updateHistory } from '~/lib/utils/url_utility';
import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
import RunnerList from '../components/runner_list.vue';
import RunnerManualSetupHelp from '../components/runner_manual_setup_help.vue';
import RunnerPagination from '../components/runner_pagination.vue';
import RunnerTypeHelp from '../components/runner_type_help.vue';
import getRunnersQuery from '../graphql/get_runners.query.graphql';
import {
Loading
Loading
@@ -18,6 +19,7 @@ export default {
RunnerList,
RunnerManualSetupHelp,
RunnerTypeHelp,
RunnerPagination,
},
props: {
activeRunnersCount: {
Loading
Loading
@@ -32,7 +34,10 @@ export default {
data() {
return {
search: fromUrlQueryToSearch(),
runners: [],
runners: {
items: [],
pageInfo: {},
},
};
},
apollo: {
Loading
Loading
@@ -41,8 +46,12 @@ export default {
variables() {
return this.variables;
},
update({ runners }) {
return runners?.nodes || [];
update(data) {
const { runners } = data;
return {
items: runners?.nodes || [],
pageInfo: runners?.pageInfo || {},
};
},
error(err) {
this.captureException(err);
Loading
Loading
@@ -57,17 +66,19 @@ export default {
return this.$apollo.queries.runners.loading;
},
noRunnersFound() {
return !this.runnersLoading && !this.runners.length;
return !this.runnersLoading && !this.runners.items.length;
},
},
watch: {
search() {
// TODO Implement back button reponse using onpopstate
updateHistory({
url: fromSearchToUrl(this.search),
title: document.title,
});
search: {
deep: true,
handler() {
// TODO Implement back button reponse using onpopstate
updateHistory({
url: fromSearchToUrl(this.search),
title: document.title,
});
},
},
},
errorCaptured(err) {
Loading
Loading
@@ -99,11 +110,13 @@ export default {
<div v-if="noRunnersFound" class="gl-text-center gl-p-5">
{{ __('No runners found') }}
</div>
<runner-list
v-else
:runners="runners"
:loading="runnersLoading"
:active-runners-count="activeRunnersCount"
/>
<template v-else>
<runner-list
:runners="runners.items"
:loading="runnersLoading"
:active-runners-count="activeRunnersCount"
/>
<runner-pagination v-model="search.pagination" :page-info="runners.pageInfo" />
</template>
</div>
</template>
Loading
Loading
@@ -118,6 +118,7 @@ describe('RunnerList', () => {
{
filters: mockFilters,
sort: mockDefaultSort,
pagination: { page: 1 },
},
]);
});
Loading
Loading
@@ -129,6 +130,7 @@ describe('RunnerList', () => {
{
filters: [],
sort: mockOtherSort,
pagination: { page: 1 },
},
]);
});
Loading
Loading
import { GlPagination } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import RunnerPagination from '~/runner/components/runner_pagination.vue';
const mockStartCursor = 'START_CURSOR';
const mockEndCursor = 'END_CURSOR';
describe('RunnerPagination', () => {
let wrapper;
const findPagination = () => wrapper.findComponent(GlPagination);
const createComponent = ({ page = 1, hasPreviousPage = false, hasNextPage = true } = {}) => {
wrapper = mount(RunnerPagination, {
propsData: {
value: {
page,
},
pageInfo: {
hasPreviousPage,
hasNextPage,
startCursor: mockStartCursor,
endCursor: mockEndCursor,
},
},
});
};
afterEach(() => {
wrapper.destroy();
});
describe('When on the first page', () => {
beforeEach(() => {
createComponent({
page: 1,
hasPreviousPage: false,
hasNextPage: true,
});
});
it('Contains the current page information', () => {
expect(findPagination().props('value')).toBe(1);
expect(findPagination().props('prevPage')).toBe(null);
expect(findPagination().props('nextPage')).toBe(2);
});
it('Shows prev page disabled', () => {
expect(findPagination().find('[aria-disabled]').text()).toBe('Prev');
});
it('Shows next page link', () => {
expect(findPagination().find('a').text()).toBe('Next');
});
it('Goes to the second page', () => {
findPagination().vm.$emit('input', 2);
expect(wrapper.emitted('input')[0]).toEqual([
{
after: mockEndCursor,
page: 2,
},
]);
});
});
describe('When in between pages', () => {
beforeEach(() => {
createComponent({
page: 2,
hasPreviousPage: true,
hasNextPage: true,
});
});
it('Contains the current page information', () => {
expect(findPagination().props('value')).toBe(2);
expect(findPagination().props('prevPage')).toBe(1);
expect(findPagination().props('nextPage')).toBe(3);
});
it('Shows the next and previous pages', () => {
const links = findPagination().findAll('a');
expect(links).toHaveLength(2);
expect(links.at(0).text()).toBe('Prev');
expect(links.at(1).text()).toBe('Next');
});
it('Goes to the last page', () => {
findPagination().vm.$emit('input', 3);
expect(wrapper.emitted('input')[0]).toEqual([
{
after: mockEndCursor,
page: 3,
},
]);
});
it('Goes to the first page', () => {
findPagination().vm.$emit('input', 1);
expect(wrapper.emitted('input')[0]).toEqual([
{
before: mockStartCursor,
page: 1,
},
]);
});
});
describe('When in the last page', () => {
beforeEach(() => {
createComponent({
page: 3,
hasPreviousPage: true,
hasNextPage: false,
});
});
it('Contains the current page', () => {
expect(findPagination().props('value')).toBe(3);
expect(findPagination().props('prevPage')).toBe(2);
expect(findPagination().props('nextPage')).toBe(null);
});
it('Shows next page link', () => {
expect(findPagination().find('a').text()).toBe('Prev');
});
it('Shows next page disabled', () => {
expect(findPagination().find('[aria-disabled]').text()).toBe('Next');
});
});
describe('When only one page', () => {
beforeEach(() => {
createComponent({
page: 1,
hasPreviousPage: false,
hasNextPage: false,
});
});
it('does not display pagination', () => {
expect(wrapper.html()).toBe('');
});
it('Contains the current page', () => {
expect(findPagination().props('value')).toBe(1);
});
it('Shows no more page buttons', () => {
expect(findPagination().props('prevPage')).toBe(null);
expect(findPagination().props('nextPage')).toBe(null);
});
});
});
Loading
Loading
@@ -31,6 +31,13 @@ export const runnersData = {
__typename: 'CiRunner',
},
],
pageInfo: {
endCursor: 'GRAPHQL_END_CURSOR',
startCursor: 'GRAPHQL_START_CURSOR',
hasNextPage: true,
hasPreviousPage: false,
__typename: 'PageInfo',
},
__typename: 'CiRunnerConnection',
},
},
Loading
Loading
import { RUNNER_PAGE_SIZE } from '~/runner/constants';
import {
fromUrlQueryToSearch,
fromSearchToUrl,
Loading
Loading
@@ -9,26 +10,28 @@ describe('search_params.js', () => {
{
name: 'a default query',
urlQuery: '',
search: { filters: [], sort: 'CREATED_DESC' },
graphqlVariables: { sort: 'CREATED_DESC' },
search: { filters: [], pagination: { page: 1 }, sort: 'CREATED_DESC' },
graphqlVariables: { sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE },
},
{
name: 'a single status',
urlQuery: '?status[]=ACTIVE',
search: {
filters: [{ type: 'status', value: { data: 'ACTIVE', operator: '=' } }],
pagination: { page: 1 },
sort: 'CREATED_DESC',
},
graphqlVariables: { status: 'ACTIVE', sort: 'CREATED_DESC' },
graphqlVariables: { status: 'ACTIVE', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE },
},
{
name: 'single instance type',
urlQuery: '?runner_type[]=INSTANCE_TYPE',
search: {
filters: [{ type: 'runner_type', value: { data: 'INSTANCE_TYPE', operator: '=' } }],
pagination: { page: 1 },
sort: 'CREATED_DESC',
},
graphqlVariables: { type: 'INSTANCE_TYPE', sort: 'CREATED_DESC' },
graphqlVariables: { type: 'INSTANCE_TYPE', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE },
},
{
name: 'multiple runner status',
Loading
Loading
@@ -38,9 +41,10 @@ describe('search_params.js', () => {
{ type: 'status', value: { data: 'ACTIVE', operator: '=' } },
{ type: 'status', value: { data: 'PAUSED', operator: '=' } },
],
pagination: { page: 1 },
sort: 'CREATED_DESC',
},
graphqlVariables: { status: 'ACTIVE', sort: 'CREATED_DESC' },
graphqlVariables: { status: 'ACTIVE', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE },
},
{
name: 'multiple status, a single instance type and a non default sort',
Loading
Loading
@@ -50,9 +54,52 @@ describe('search_params.js', () => {
{ type: 'status', value: { data: 'ACTIVE', operator: '=' } },
{ type: 'runner_type', value: { data: 'INSTANCE_TYPE', operator: '=' } },
],
pagination: { page: 1 },
sort: 'CREATED_ASC',
},
graphqlVariables: { status: 'ACTIVE', type: 'INSTANCE_TYPE', sort: 'CREATED_ASC' },
graphqlVariables: {
status: 'ACTIVE',
type: 'INSTANCE_TYPE',
sort: 'CREATED_ASC',
first: RUNNER_PAGE_SIZE,
},
},
{
name: 'the next page',
urlQuery: '?page=2&after=AFTER_CURSOR',
search: { filters: [], pagination: { page: 2, after: 'AFTER_CURSOR' }, sort: 'CREATED_DESC' },
graphqlVariables: { sort: 'CREATED_DESC', after: 'AFTER_CURSOR', first: RUNNER_PAGE_SIZE },
},
{
name: 'the previous page',
urlQuery: '?page=2&before=BEFORE_CURSOR',
search: {
filters: [],
pagination: { page: 2, before: 'BEFORE_CURSOR' },
sort: 'CREATED_DESC',
},
graphqlVariables: { sort: 'CREATED_DESC', before: 'BEFORE_CURSOR', last: RUNNER_PAGE_SIZE },
},
{
name:
'the next page filtered by multiple status, a single instance type and a non default sort',
urlQuery:
'?status[]=ACTIVE&runner_type[]=INSTANCE_TYPE&sort=CREATED_ASC&page=2&after=AFTER_CURSOR',
search: {
filters: [
{ type: 'status', value: { data: 'ACTIVE', operator: '=' } },
{ type: 'runner_type', value: { data: 'INSTANCE_TYPE', operator: '=' } },
],
pagination: { page: 2, after: 'AFTER_CURSOR' },
sort: 'CREATED_ASC',
},
graphqlVariables: {
status: 'ACTIVE',
type: 'INSTANCE_TYPE',
sort: 'CREATED_ASC',
after: 'AFTER_CURSOR',
first: RUNNER_PAGE_SIZE,
},
},
];
 
Loading
Loading
@@ -62,6 +109,24 @@ describe('search_params.js', () => {
expect(fromUrlQueryToSearch(urlQuery)).toEqual(search);
});
});
it('When a page cannot be parsed as a number, it defaults to `1`', () => {
expect(fromUrlQueryToSearch('?page=NONSENSE&after=AFTER_CURSOR').pagination).toEqual({
page: 1,
});
});
it('When a page is less than 1, it defaults to `1`', () => {
expect(fromUrlQueryToSearch('?page=0&after=AFTER_CURSOR').pagination).toEqual({
page: 1,
});
});
it('When a page with no cursor is given, it defaults to `1`', () => {
expect(fromUrlQueryToSearch('?page=2').pagination).toEqual({
page: 1,
});
});
});
 
describe('fromSearchToUrl', () => {
Loading
Loading
import * as Sentry from '@sentry/browser';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { createLocalVue, mount, shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import { TEST_HOST } from 'helpers/test_constants';
Loading
Loading
@@ -9,14 +9,17 @@ import { updateHistory } from '~/lib/utils/url_utility';
import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue';
import RunnerList from '~/runner/components/runner_list.vue';
import RunnerManualSetupHelp from '~/runner/components/runner_manual_setup_help.vue';
import RunnerPagination from '~/runner/components/runner_pagination.vue';
import RunnerTypeHelp from '~/runner/components/runner_type_help.vue';
 
import {
CREATED_ASC,
CREATED_DESC,
DEFAULT_SORT,
INSTANCE_TYPE,
PARAM_KEY_STATUS,
STATUS_ACTIVE,
RUNNER_PAGE_SIZE,
} from '~/runner/constants';
import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql';
import RunnerListApp from '~/runner/runner_list/runner_list_app.vue';
Loading
Loading
@@ -26,6 +29,7 @@ import { runnersData } from '../mock_data';
const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN';
const mockActiveRunnersCount = 2;
const mocKRunners = runnersData.data.runners.nodes;
const mockPageInfo = runnersData.data.runners.pageInfo;
 
jest.mock('@sentry/browser');
jest.mock('~/lib/utils/url_utility', () => ({
Loading
Loading
@@ -44,6 +48,7 @@ describe('RunnerListApp', () => {
const findRunnerTypeHelp = () => wrapper.findComponent(RunnerTypeHelp);
const findRunnerManualSetupHelp = () => wrapper.findComponent(RunnerManualSetupHelp);
const findRunnerList = () => wrapper.findComponent(RunnerList);
const findRunnerPagination = () => wrapper.findComponent(RunnerPagination);
const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar);
 
const createComponentWithApollo = ({ props = {}, mountFn = shallowMount } = {}) => {
Loading
Loading
@@ -101,6 +106,7 @@ describe('RunnerListApp', () => {
status: undefined,
type: undefined,
sort: DEFAULT_SORT,
first: RUNNER_PAGE_SIZE,
});
});
 
Loading
Loading
@@ -128,6 +134,7 @@ describe('RunnerListApp', () => {
{ type: 'runner_type', value: { data: INSTANCE_TYPE, operator: '=' } },
],
sort: 'CREATED_DESC',
pagination: { page: 1 },
});
});
 
Loading
Loading
@@ -136,6 +143,7 @@ describe('RunnerListApp', () => {
status: STATUS_ACTIVE,
type: INSTANCE_TYPE,
sort: DEFAULT_SORT,
first: RUNNER_PAGE_SIZE,
});
});
});
Loading
Loading
@@ -159,6 +167,7 @@ describe('RunnerListApp', () => {
expect(mockRunnersQuery).toHaveBeenLastCalledWith({
status: STATUS_ACTIVE,
sort: CREATED_ASC,
first: RUNNER_PAGE_SIZE,
});
});
});
Loading
Loading
@@ -193,4 +202,37 @@ describe('RunnerListApp', () => {
expect(Sentry.captureException).toHaveBeenCalled();
});
});
describe('Pagination', () => {
beforeEach(() => {
createComponentWithApollo({ mountFn: mount });
});
it('more pages can be selected', () => {
expect(findRunnerPagination().text()).toMatchInterpolatedText('Prev Next');
});
it('cannot navigate to the previous page', () => {
expect(findRunnerPagination().find('[aria-disabled]').text()).toBe('Prev');
});
it('navigates to the next page', async () => {
const nextPageBtn = findRunnerPagination().find('a');
expect(nextPageBtn.text()).toBe('Next');
await nextPageBtn.trigger('click');
expect(mockRunnersQuery).toHaveBeenLastCalledWith({
sort: CREATED_DESC,
first: RUNNER_PAGE_SIZE,
after: expect.any(String),
});
expect(mockRunnersQuery).toHaveBeenLastCalledWith({
sort: CREATED_DESC,
first: RUNNER_PAGE_SIZE,
after: mockPageInfo.endCursor,
});
});
});
});
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment