Commit be4abe77 authored by Filipa Lacerda's avatar Filipa Lacerda Committed by Phil Hughes

Stops page reload when changing tabs or pages - uses API requests instead

parent 62287fec
......@@ -309,6 +309,42 @@ export const setParamInURL = (param, value) => {
return search;
};
/**
* Given a string of query parameters creates an object.
*
* @example
* `scope=all&page=2` -> { scope: 'all', page: '2'}
* `scope=all` -> { scope: 'all' }
* ``-> {}
* @param {String} query
* @returns {Object}
*/
export const parseQueryStringIntoObject = (query = '') => {
if (query === '') return {};
return query
.split('&')
.reduce((acc, element) => {
const val = element.split('=');
Object.assign(acc, {
[val[0]]: decodeURIComponent(val[1]),
});
return acc;
}, {});
};
export const buildUrlWithCurrentLocation = param => (param ? `${window.location.pathname}${param}` : window.location.pathname);
/**
* Based on the current location and the string parameters provided
* creates a new entry in the history without reloading the page.
*
* @param {String} param
*/
export const historyPushState = (newUrl) => {
window.history.pushState({}, document.title, newUrl);
};
/**
* Converts permission provided as strings to booleans.
*
......
......@@ -60,7 +60,6 @@ export default class Poll {
checkConditions(response) {
const headers = normalizeHeaders(response.headers);
const pollInterval = parseInt(headers[this.intervalHeader], 10);
if (pollInterval > 0 && response.status === httpStatusCodes.OK && this.canPoll) {
this.timeoutID = setTimeout(() => {
this.makeRequest();
......@@ -102,7 +101,12 @@ export default class Poll {
/**
* Restarts polling after it has been stoped
*/
restart() {
restart(options) {
// update data
if (options && options.data) {
this.options.data = options.data;
}
this.canPoll = true;
this.makeRequest();
}
......
......@@ -2,16 +2,8 @@
export default {
name: 'PipelineNavigationTabs',
props: {
scope: {
type: String,
required: true,
},
count: {
type: Object,
required: true,
},
paths: {
type: Object,
tabs: {
type: Array,
required: true,
},
},
......@@ -23,68 +15,37 @@
// 0 is valid in a badge, but evaluates to false, we need to check for undefined
return count !== undefined;
},
onTabClick(tab) {
this.$emit('onChangeTab', tab.scope);
},
},
};
</script>
<template>
<ul class="nav-links scrolling-tabs">
<li
class="js-pipelines-tab-all"
:class="{ active: scope === 'all'}">
<a :href="paths.allPath">
All
<span
v-if="shouldRenderBadge(count.all)"
class="badge js-totalbuilds-count">
{{count.all}}
</span>
</a>
</li>
<li
class="js-pipelines-tab-pending"
:class="{ active: scope === 'pending'}">
<a :href="paths.pendingPath">
Pending
<span
v-if="shouldRenderBadge(count.pending)"
class="badge">
{{count.pending}}
</span>
</a>
</li>
<li
class="js-pipelines-tab-running"
:class="{ active: scope === 'running'}">
<a :href="paths.runningPath">
Running
<span
v-if="shouldRenderBadge(count.running)"
class="badge">
{{count.running}}
</span>
</a>
</li>
<li
class="js-pipelines-tab-finished"
:class="{ active: scope === 'finished'}">
<a :href="paths.finishedPath">
Finished
v-for="(tab, i) in tabs"
:key="i"
:class="{
active: tab.isActive,
}"
>
<a
role="button"
@click="onTabClick(tab)"
:class="`js-pipelines-tab-${tab.scope}`"
>
{{ tab.name }}
<span
v-if="shouldRenderBadge(count.finished)"
class="badge">
{{count.finished}}
v-if="shouldRenderBadge(tab.count)"
class="badge"
>
{{tab.count}}
</span>
</a>
</li>
<li
class="js-pipelines-tab-branches"
:class="{ active: scope === 'branches'}">
<a :href="paths.branchesPath">Branches</a>
</li>
<li
class="js-pipelines-tab-tags"
:class="{ active: scope === 'tags'}">
<a :href="paths.tagsPath">Tags</a>
</li>
</ul>
</template>
<script>
import _ from 'underscore';
import PipelinesService from '../services/pipelines_service';
import pipelinesMixin from '../mixins/pipelines';
import tablePagination from '../../vue_shared/components/table_pagination.vue';
import navigationTabs from './navigation_tabs.vue';
import navigationControls from './nav_controls.vue';
import { convertPermissionToBoolean, getParameterByName, setParamInURL } from '../../lib/utils/common_utils';
import {
convertPermissionToBoolean,
getParameterByName,
historyPushState,
buildUrlWithCurrentLocation,
parseQueryStringIntoObject,
} from '../../lib/utils/common_utils';
export default {
props: {
......@@ -41,27 +48,18 @@
autoDevopsPath: pipelinesData.helpAutoDevopsPath,
newPipelinePath: pipelinesData.newPipelinePath,
canCreatePipeline: pipelinesData.canCreatePipeline,
allPath: pipelinesData.allPath,
pendingPath: pipelinesData.pendingPath,
runningPath: pipelinesData.runningPath,
finishedPath: pipelinesData.finishedPath,
branchesPath: pipelinesData.branchesPath,
tagsPath: pipelinesData.tagsPath,
hasCi: pipelinesData.hasCi,
ciLintPath: pipelinesData.ciLintPath,
state: this.store.state,
apiScope: 'all',
pagenum: 1,
scope: getParameterByName('scope') || 'all',
page: getParameterByName('page') || '1',
requestData: {},
};
},
computed: {
canCreatePipelineParsed() {
return convertPermissionToBoolean(this.canCreatePipeline);
},
scope() {
const scope = getParameterByName('scope');
return scope === null ? 'all' : scope;
},
/**
* The empty state should only be rendered when the request is made to fetch all pipelines
......@@ -106,46 +104,112 @@
hasCiEnabled() {
return this.hasCi !== undefined;
},
paths() {
return {
allPath: this.allPath,
pendingPath: this.pendingPath,
finishedPath: this.finishedPath,
runningPath: this.runningPath,
branchesPath: this.branchesPath,
tagsPath: this.tagsPath,
};
},
pageParameter() {
return getParameterByName('page') || this.pagenum;
},
scopeParameter() {
return getParameterByName('scope') || this.apiScope;
tabs() {
const { count } = this.state;
return [
{
name: 'All',
scope: 'all',
count: count.all,
isActive: this.scope === 'all',
},
{
name: 'Pending',
scope: 'pending',
count: count.pending,
isActive: this.scope === 'pending',
},
{
name: 'Running',
scope: 'running',
count: count.running,
isActive: this.scope === 'running',
},
{
name: 'Finished',
scope: 'finished',
count: count.finished,
isActive: this.scope === 'finished',
},
{
name: 'Branches',
scope: 'branches',
isActive: this.scope === 'branches',
},
{
name: 'Tags',
scope: 'tags',
isActive: this.scope === 'tags',
},
];
},
},
created() {
this.service = new PipelinesService(this.endpoint);
this.requestData = { page: this.pageParameter, scope: this.scopeParameter };
this.requestData = { page: this.page, scope: this.scope };
},
methods: {
successCallback(resp) {
return resp.json().then((response) => {
// Because we are polling & the user is interacting verify if the response received
// matches the last request made
if (_.isEqual(parseQueryStringIntoObject(resp.url.split('?')[1]), this.requestData)) {
this.store.storeCount(response.count);
this.store.storePagination(resp.headers);
this.setCommonData(response.pipelines);
}
});
},
/**
* Will change the page number and update the URL.
*
* @param {Number} pageNumber desired page to go to.
* Handles URL and query parameter changes.
* When the user uses the pagination or the tabs,
* - update URL
* - Make API request to the server with new parameters
* - Update the polling function
* - Update the internal state
*/
change(pageNumber) {
const param = setParamInURL('page', pageNumber);
updateContent(parameters) {
// stop polling
this.poll.stop();
const queryString = Object.keys(parameters).map((parameter) => {
const value = parameters[parameter];
// update internal state for UI
this[parameter] = value;
return `${parameter}=${encodeURIComponent(value)}`;
}).join('&');
gl.utils.visitUrl(param);
return param;
// update polling parameters
this.requestData = parameters;
historyPushState(buildUrlWithCurrentLocation(`?${queryString}`));
this.isLoading = true;
// fetch new data
return this.service.getPipelines(this.requestData)
.then((response) => {
this.isLoading = false;
this.successCallback(response);
// restart polling
this.poll.restart({ data: this.requestData });
})
.catch(() => {
this.isLoading = false;
this.errorCallback();
// restart polling
this.poll.restart();
});
},
successCallback(resp) {
return resp.json().then((response) => {
this.store.storeCount(response.count);
this.store.storePagination(resp.headers);
this.setCommonData(response.pipelines);
});
onChangeTab(scope) {
this.updateContent({ scope, page: '1' });
},
onChangePage(page) {
/* URLS parameters are strings, we need to parse to match types */
this.updateContent({ scope: this.scope, page: Number(page).toString() });
},
},
};
......@@ -154,7 +218,7 @@
<div class="pipelines-container">
<div
class="top-area scrolling-tabs-container inner-page-scroll-tabs"
v-if="!isLoading && !shouldRenderEmptyState">
v-if="!shouldRenderEmptyState">
<div class="fade-left">
<i
class="fa fa-angle-left"
......@@ -167,17 +231,17 @@
aria-hidden="true">
</i>
</div>
<navigation-tabs
:scope="scope"
:count="state.count"
:paths="paths"
:tabs="tabs"
@onChangeTab="onChangeTab"
/>
<navigation-controls
:new-pipeline-path="newPipelinePath"
:has-ci-enabled="hasCiEnabled"
:help-page-path="helpPagePath"
:ciLintPath="ciLintPath"
:ci-lint-path="ciLintPath"
:can-create-pipeline="canCreatePipelineParsed "
/>
</div>
......@@ -188,6 +252,7 @@
label="Loading Pipelines"
size="3"
v-if="isLoading"
class="prepend-top-20"
/>
<empty-state
......@@ -221,8 +286,8 @@
<table-pagination
v-if="shouldRenderPagination"
:change="change"
:pageInfo="state.pageInfo"
:change="onChangePage"
:page-info="state.pageInfo"
/>
</div>
</div>
......
......@@ -9,12 +9,6 @@
"error-state-svg-path" => image_path('illustrations/pipelines_failed.svg'),
"new-pipeline-path" => new_project_pipeline_path(@project),
"can-create-pipeline" => can?(current_user, :create_pipeline, @project).to_s,
"all-path" => project_pipelines_path(@project),
"pending-path" => project_pipelines_path(@project, scope: :pending),
"running-path" => project_pipelines_path(@project, scope: :running),
"finished-path" => project_pipelines_path(@project, scope: :finished),
"branches-path" => project_pipelines_path(@project, scope: :branches),
"tags-path" => project_pipelines_path(@project, scope: :tags),
"has-ci" => @repository.gitlab_ci_yml,
"ci-lint-path" => ci_lint_path } }
......
---
title: Stop reloading the page when using pagination and tabs - use API calls - in
Pipelines table
merge_request:
author:
type: other
......@@ -56,31 +56,37 @@ describe 'Pipelines', :js do
end
it 'shows a tab for All pipelines and count' do
expect(page.find('.js-pipelines-tab-all a').text).to include('All')
expect(page.find('.js-pipelines-tab-all').text).to include('All')
expect(page.find('.js-pipelines-tab-all .badge').text).to include('1')
end
it 'shows a tab for Pending pipelines and count' do
expect(page.find('.js-pipelines-tab-pending a').text).to include('Pending')
expect(page.find('.js-pipelines-tab-pending').text).to include('Pending')
expect(page.find('.js-pipelines-tab-pending .badge').text).to include('0')
end
it 'shows a tab for Running pipelines and count' do
expect(page.find('.js-pipelines-tab-running a').text).to include('Running')
expect(page.find('.js-pipelines-tab-running').text).to include('Running')
expect(page.find('.js-pipelines-tab-running .badge').text).to include('1')
end
it 'shows a tab for Finished pipelines and count' do
expect(page.find('.js-pipelines-tab-finished a').text).to include('Finished')
expect(page.find('.js-pipelines-tab-finished').text).to include('Finished')
expect(page.find('.js-pipelines-tab-finished .badge').text).to include('0')
end
it 'shows a tab for Branches' do
expect(page.find('.js-pipelines-tab-branches a').text).to include('Branches')
expect(page.find('.js-pipelines-tab-branches').text).to include('Branches')
end
it 'shows a tab for Tags' do
expect(page.find('.js-pipelines-tab-tags a').text).to include('Tags')
expect(page.find('.js-pipelines-tab-tags').text).to include('Tags')
end
it 'updates content when tab is clicked' do
page.find('.js-pipelines-tab-pending').click
wait_for_requests
expect(page).to have_content('No pipelines to show.')
end
end
......@@ -396,6 +402,14 @@ describe 'Pipelines', :js do
expect(page).to have_selector('.gl-pagination .page', count: 2)
end
it 'should show updated content' do
visit project_pipelines_path(project)
wait_for_requests
page.find('.js-next-button a').click
expect(page).to have_selector('.gl-pagination .page', count: 2)
end
end
end
......
%div
#pipelines-list-vue{ data: { endpoint: 'foo',
"css-class" => 'foo',
"help-page-path" => 'foo',
"help-auto-devops-path" => 'foo',
"empty-state-svg-path" => 'foo',
"error-state-svg-path" => 'foo',
"new-pipeline-path" => 'foo',
"can-create-pipeline" => 'true',
"all-path" => 'foo',
"pending-path" => 'foo',
"running-path" => 'foo',
"finished-path" => 'foo',
"branches-path" => 'foo',
"tags-path" => 'foo',
"has-ci" => 'foo',
"ci-lint-path" => 'foo' } }
......@@ -183,6 +183,36 @@ describe('common_utils', () => {
});
});
describe('historyPushState', () => {
afterEach(() => {
window.history.replaceState({}, null, null);
});
it('should call pushState with the correct path', () => {
spyOn(window.history, 'pushState');
commonUtils.historyPushState('newpath?page=2');
expect(window.history.pushState).toHaveBeenCalled();
expect(window.history.pushState.calls.allArgs()[0][2]).toContain('newpath?page=2');
});
});
describe('parseQueryStringIntoObject', () => {
it('should return object with query parameters', () => {
expect(commonUtils.parseQueryStringIntoObject('scope=all&page=2')).toEqual({ scope: 'all', page: '2' });
expect(commonUtils.parseQueryStringIntoObject('scope=all')).toEqual({ scope: 'all' });
expect(commonUtils.parseQueryStringIntoObject()).toEqual({});
});
});
describe('buildUrlWithCurrentLocation', () => {
it('should build an url with current location and given parameters', () => {
expect(commonUtils.buildUrlWithCurrentLocation()).toEqual(window.location.pathname);
expect(commonUtils.buildUrlWithCurrentLocation('?page=2')).toEqual(`${window.location.pathname}?page=2`);
});
});
describe('getParameterByName', () => {
beforeEach(() => {
window.history.pushState({}, null, '?scope=all&p=2');
......
......@@ -155,7 +155,7 @@ describe('Poll', () => {
successCallback: () => {
Polling.stop();
setTimeout(() => {
Polling.restart();
Polling.restart({ data: { page: 4 } });
}, 0);
},
errorCallback: callbacks.error,
......@@ -170,10 +170,10 @@ describe('Poll', () => {
Polling.stop();
expect(service.fetch.calls.count()).toEqual(2);
expect(service.fetch).toHaveBeenCalledWith({ page: 1 });
expect(service.fetch).toHaveBeenCalledWith({ page: 4 });
expect(Polling.stop).toHaveBeenCalled();
expect(Polling.restart).toHaveBeenCalled();
expect(Polling.options.data).toEqual({ page: 4 });
done();
});
});
......
......@@ -8,120 +8,48 @@ describe('navigation tabs pipeline component', () => {
let data;
beforeEach(() => {
data = {
scope: 'all',
count: {
all: 16,
running: 1,
pending: 10,
finished: 0,
data = [
{
name: 'All',
scope: 'all',
count: 1,
isActive: true,
},
{
name: 'Pending',
scope: 'pending',
count: 0,
isActive: false,
},
paths: {
allPath: '/gitlab-org/gitlab-ce/pipelines',
pendingPath: '/gitlab-org/gitlab-ce/pipelines?scope=pending',
finishedPath: '/gitlab-org/gitlab-ce/pipelines?scope=finished',
runningPath: '/gitlab-org/gitlab-ce/pipelines?scope=running',
branchesPath: '/gitlab-org/gitlab-ce/pipelines?scope=branches',
tagsPath: '/gitlab-org/gitlab-ce/pipelines?scope=tags',
{
name: 'Running',
scope: 'running',
isActive: false,
},
};
];
Component = Vue.extend(navigationTabs);
vm = mountComponent(Component, { tabs: data });
});
afterEach(() => {
vm.$destroy();
});
it('should render tabs with correct paths', () => {
vm = mountComponent(Component, data);
// All
const allTab = vm.$el.querySelector('.js-pipelines-tab-all a');
expect(allTab.textContent.trim()).toContain('All');
expect(allTab.getAttribute('href')).toEqual(data.paths.allPath);
// Pending
const pendingTab = vm.$el.querySelector('.js-pipelines-tab-pending a');
expect(pendingTab.textContent.trim()).toContain('Pending');
expect(pendingTab.getAttribute('href')).toEqual(data.paths.pendingPath);
// Running
const runningTab = vm.$el.querySelector('.js-pipelines-tab-running a');
expect(runningTab.textContent.trim()).toContain('Running');
expect(runningTab.getAttribute('href')).toEqual(data.paths.runningPath);
// Finished
const finishedTab = vm.$el.querySelector('.js-pipelines-tab-finished a');
expect(finishedTab.textContent.trim()).toContain('Finished');
expect(finishedTab.getAttribute('href')).toEqual(data.paths.finishedPath);
// Branches
const branchesTab = vm.$el.querySelector('.js-pipelines-tab-branches a');
expect(branchesTab.textContent.trim()).toContain('Branches');
// Tags
const tagsTab = vm.$el.querySelector('.js-pipelines-tab-tags a');
expect(tagsTab.textContent.trim()).toContain('Tags');
it('should render tabs', () => {
expect(vm.$el.querySelectorAll('li').length).toEqual(data.length);
});
describe('scope', () => {
it('should render scope provided as active tab', () => {
vm = mountComponent(Component, data);
expect(vm.$el.querySelector('.js-pipelines-tab-all').className).toContain('active');
});
it('should render active tab', () => {
expect(vm.$el.querySelector('.active .js-pipelines-tab-all')).toBeDefined();
});
describe('badges', () => {
it('should render provided number', () => {
vm = mountComponent(Component, data);
// All
expect(
vm.$el.querySelector('.js-totalbuilds-count').textContent.trim(),
).toContain(data.count.all);
// Pending
expect(
vm.$el.querySelector('.js-pipelines-tab-pending .badge').textContent.trim(),
).toContain(data.count.pending);
// Running
expect(
vm.$el.querySelector('.js-pipelines-tab-running .badge').textContent.trim(),
).toContain(data.count.running);
// Finished
expect(
vm.$el.querySelector('.js-pipelines-tab-finished .badge').textContent.trim(),
).toContain(data.count.finished);
});
it('should not render badge when number is undefined', () => {
vm = mountComponent(Component, {
scope: 'all',
paths: {},
count: {},
});