Commit 8221fc2e authored by Andrew Fontaine's avatar Andrew Fontaine

Merge branch '329658-admin-runners-pagination' into 'master'

Add pagination to runners

See merge request gitlab-org/gitlab!62307
parents a323b8b8 801ec7cf
......@@ -113,6 +113,7 @@ export default {
this.$emit('input', {
filters,
sort,
pagination: { page: 1 },
});
},
onSort(sort) {
......@@ -121,6 +122,7 @@ export default {
this.$emit('input', {
filters,
sort,
pagination: { page: 1 },
});
},
},
......
......@@ -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';
......@@ -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
......
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
......@@ -13,5 +31,8 @@ query getRunners($status: CiRunnerStatus, $type: CiRunnerType, $sort: CiRunnerSo
tagList
contactedAt
}
pageInfo {
...PageInfo
}
}
}
......@@ -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) => {
......@@ -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 });
......@@ -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),
......@@ -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"
......@@ -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;
};
......@@ -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 {
......@@ -18,6 +19,7 @@ export default {
RunnerList,
RunnerManualSetupHelp,
RunnerTypeHelp,
RunnerPagination,
},
props: {
activeRunnersCount: {
......@@ -32,7 +34,10 @@ export default {
data() {
return {
search: fromUrlQueryToSearch(),
runners: [],
runners: {
items: [],
pageInfo: {},
},
};
},
apollo: {
......@@ -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);
......@@ -57,19 +66,21 @@ export default {
return this.$apollo.queries.runners.loading;
},
noRunnersFound() {
return !this.runnersLoading && !this.runners.length;
return !this.runnersLoading && !this.runners.items.length;
},
},
watch: {
search() {
search: {
deep: true,
handler() {
// TODO Implement back button reponse using onpopstate
updateHistory({
url: fromSearchToUrl(this.search),
title: document.title,
});
},
},
},
errorCaptured(err) {
this.captureException(err);
},
......@@ -99,11 +110,13 @@ export default {
<div v-if="noRunnersFound" class="gl-text-center gl-p-5">
{{ __('No runners found') }}
</div>
<template v-else>
<runner-list
v-else
:runners="runners"
:runners="runners.items"
:loading="runnersLoading"
:active-runners-count="activeRunnersCount"
/>
<runner-pagination v-model="search.pagination" :page-info="runners.pageInfo" />
</template>
</div>
</template>
......@@ -118,6 +118,7 @@ describe('RunnerList', () => {
{
filters: mockFilters,
sort: mockDefaultSort,
pagination: { page: 1 },
},
]);
});
......@@ -129,6 +130,7 @@ describe('RunnerList', () => {
{
filters: [],
sort: mockOtherSort,
pagination: { page: 1 },
},
]);
});
......
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);
});
});
});
......@@ -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',
},
},
......
import { RUNNER_PAGE_SIZE } from '~/runner/constants';
import {
fromUrlQueryToSearch,
fromSearchToUrl,
......@@ -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',
......@@ -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',
......@@ -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,
},
},
];
......@@ -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', () => {
......
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';
......@@ -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';
......@@ -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', () => ({
......@@ -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 } = {}) => {
......@@ -101,6 +106,7 @@ describe('RunnerListApp', () => {
status: undefined,
type: undefined,
sort: DEFAULT_SORT,
first: RUNNER_PAGE_SIZE,
});
});
......@@ -128,6 +134,7 @@ describe('RunnerListApp', () => {
{ type: 'runner_type', value: { data: INSTANCE_TYPE, operator: '=' } },
],
sort: 'CREATED_DESC',
pagination: { page: 1 },
});
});
......@@ -136,6 +143,7 @@ describe('RunnerListApp', () => {
status: STATUS_ACTIVE,
type: INSTANCE_TYPE,
sort: DEFAULT_SORT,
first: RUNNER_PAGE_SIZE,
});
});
});
......@@ -159,6 +167,7 @@ describe('RunnerListApp', () => {
expect(mockRunnersQuery).toHaveBeenLastCalledWith({
status: STATUS_ACTIVE,
sort: CREATED_ASC,
first: RUNNER_PAGE_SIZE,
});
});
});
......@@ -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,
});
});
});
});
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