web/satellite: pagination component implemented (#2722)

This commit is contained in:
Nikolay Yurchenko 2019-08-09 15:51:28 +03:00 committed by GitHub
parent 02b7be74fb
commit fc4c675ffa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 778 additions and 0 deletions

View File

@ -0,0 +1,78 @@
// Copyright (C) 2019 Storj Labs, Inc.
// See LICENSE for copying information.
<template>
<div class="pages-container">
<span
v-for="page in pages"
:class="{'selected': checkSelected(page.index)}"
@click="page.select()"
:key="page.index">{{page.index}}</span>
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop } from 'vue-property-decorator';
import { Page } from '@/types/pagination';
@Component
export default class PagesBlock extends Vue {
@Prop({default: []})
public readonly pages: Page[];
@Prop({default: () => false})
public readonly checkSelected: CheckSelected;
}
</script>
<style scoped lang="scss">
.pages-container {
display: flex;
}
.selected {
color: #2379EC;
font-family: 'font_bold';
&:after {
content: '';
display: block;
position: absolute;
bottom: -4px;
left: 0;
width: 10px;
height: 2px;
background-color: #2379EC;
}
}
span {
font-family: 'font_medium';
font-size: 16px;
margin-right: 15px;
width: 10px;
text-align: center;
cursor: pointer;
display: block;
position: relative;
transition: all .2s ease;
&:hover {
color: #2379EC;
&:after {
content: '';
display: block;
position: absolute;
bottom: -4px;
left: 0;
width: 100%;
height: 2px;
background-color: #2379EC;
}
}
&:last-child {
margin-right: 0;
}
}
</style>

View File

@ -0,0 +1,262 @@
// Copyright (C) 2019 Storj Labs, Inc.
// See LICENSE for copying information.
<template>
<div class="pagination-container">
<div class="pagination-container__pages">
<div v-html="arrowLeft" @click="prevPage" class="pagination-container__button"></div>
<div class="pagination-container__items">
<PagesBlock :pages="firstBlockPages" :checkSelected="isSelected"/>
<span v-if="isFirstDotsShown">...</span>
<PagesBlock :pages="middleBlockPages" :checkSelected="isSelected"/>
<span v-if="isSecondDotsShown">...</span>
<PagesBlock :pages="lastBlockPages" :checkSelected="isSelected"/>
</div>
<div v-html="arrowRight" @click="nextPage" class="pagination-container__button"></div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop } from 'vue-property-decorator';
import { EMPTY_STATE_IMAGES } from '@/utils/constants/emptyStatesImages';
import PagesBlock from '@/components/common/PagesBlock.vue';
import { Page } from '@/types/pagination';
@Component({
components: {
PagesBlock,
}
})
export default class Pagination extends Vue {
// TODO: use svg loader
public readonly arrowLeft: string = EMPTY_STATE_IMAGES.ARROW_LEFT;
public readonly arrowRight: string = EMPTY_STATE_IMAGES.ARROW_RIGHT;
private readonly MAX_PAGES_PER_BLOCK: number = 3;
private readonly MAX_PAGES_OFF_BLOCKS: number = 6;
private currentPageNumber: number = 1;
public isLoading = false;
public pagesArray: Page[] = [];
public firstBlockPages: Page[] = [];
public middleBlockPages: Page[] = [];
public lastBlockPages: Page[] = [];
@Prop({default: 0})
private readonly totalPageCount: number;
@Prop({default: () => { return new Promise(() => false); }})
private readonly onPageClickCallback: OnPageClickCallback;
public mounted() {
this.populatePagesArray();
}
public get isFirstPage(): boolean {
return this.currentPageNumber === 1;
}
public get isLastPage(): boolean {
return this.currentPageNumber === this.totalPageCount;
}
public get isFirstDotsShown(): boolean {
return this.middleBlockPages.length <= this.MAX_PAGES_PER_BLOCK
&& this.pagesArray.length > this.MAX_PAGES_OFF_BLOCKS;
}
public get isSecondDotsShown(): boolean {
return !!this.middleBlockPages.length;
}
public isSelected(page: number): boolean {
return page === this.currentPageNumber;
}
public async onPageClick(page: number): Promise<void> {
if (this.isLoading) {
return;
}
this.isLoading = true;
await this.onPageClickCallback(page);
this.setCurrentPage(page);
this.reorganizePageBlocks();
this.isLoading = false;
}
public async nextPage(): Promise<void> {
if (this.isLastPage || this.isLoading) {
return;
}
this.isLoading = true;
await this.onPageClickCallback(this.currentPageNumber + 1);
this.incrementCurrentPage();
this.reorganizePageBlocks();
this.isLoading = false;
}
public async prevPage(): Promise<void> {
if (this.isFirstPage || this.isLoading) {
return;
}
this.isLoading = true;
await this.onPageClickCallback(this.currentPageNumber - 1);
this.decrementCurrentPage();
this.reorganizePageBlocks();
this.isLoading = false;
}
private populatePagesArray(): void {
if (!this.totalPageCount) {
return;
}
if (this.$route.query.pageNumber) {
const pageNumber = parseInt(this.$route.query.pageNumber as string);
this.setCurrentPage(pageNumber);
// Here we need to set short timeout to let router to set up after page
// hard reload before we can replace query with current page number
setTimeout(this.updateRouterPathWithPageNumber, 1);
}
for (let i = 1; i <= this.totalPageCount; i++) {
this.pagesArray.push(new Page(i, this.onPageClick));
}
if (this.isPagesTotalOffBlocks()) {
this.firstBlockPages = this.pagesArray.slice();
return;
}
this.reorganizePageBlocks();
}
private reorganizePageBlocks(): void {
if (this.isPagesTotalOffBlocks()) {
return;
}
if (this.isCurrentInFirstBlock()) {
this.setBlocksIfCurrentInFirstBlock();
return;
}
if (!this.isCurrentInFirstBlock() && !this.isCurrentInLastBlock()) {
this.setBlocksIfCurrentInMiddleBlock();
return;
}
if (this.isCurrentInLastBlock()) {
this.setBlocksIfCurrentInLastBlock();
}
}
private setBlocksIfCurrentInFirstBlock(): void {
this.firstBlockPages = this.pagesArray.slice(0, 3);
this.middleBlockPages = [];
this.lastBlockPages = this.pagesArray.slice(-1);
}
private setBlocksIfCurrentInMiddleBlock(): void {
this.firstBlockPages = this.pagesArray.slice(0, 1);
this.middleBlockPages = this.pagesArray.slice(this.currentPageNumber - 2, this.currentPageNumber + 1);
this.lastBlockPages = this.pagesArray.slice(-1);
}
private setBlocksIfCurrentInLastBlock(): void {
this.firstBlockPages = this.pagesArray.slice(0, 1);
this.middleBlockPages = [];
this.lastBlockPages = this.pagesArray.slice(-3);
}
private isCurrentInFirstBlock(): boolean {
return this.currentPageNumber < this.MAX_PAGES_PER_BLOCK;
}
private isCurrentInLastBlock(): boolean {
return this.totalPageCount - this.currentPageNumber < this.MAX_PAGES_PER_BLOCK - 1;
}
private isPagesTotalOffBlocks(): boolean {
return this.totalPageCount <= this.MAX_PAGES_OFF_BLOCKS;
}
private incrementCurrentPage(): void {
this.currentPageNumber++;
this.updateRouterPathWithPageNumber();
}
private decrementCurrentPage(): void {
this.currentPageNumber--;
this.updateRouterPathWithPageNumber();
}
private setCurrentPage(pageNumber: number): void {
this.currentPageNumber = pageNumber;
this.updateRouterPathWithPageNumber();
}
private updateRouterPathWithPageNumber() {
this.$router.replace({ query: { pageNumber: this.currentPageNumber.toString() } });
}
}
</script>
<style scoped lang="scss">
.pagination-container {
display: flex;
align-items: center;
justify-content: space-between;
padding-left: 25px;
margin-top: 25px;
&__pages {
display: flex;
align-items: center;
}
&__counter {
p {
font-family: 'font_medium';
font-size: 16px;
color: #AFB7C1;
}
}
&__button {
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border: 1px solid #AFB7C1;
border-radius: 6px;
width: 30px;
height: 30px;
&:hover {
svg {
path {
fill: #fff !important;
}
}
}
}
&__items {
margin: 0 20px;
display: flex;
span {
margin: 0 20px;
}
}
}
</style>

View File

@ -8,3 +8,7 @@ declare type Answer = {
message: any;
};
};
declare type OnPageClickCallback = (index: number) => Promise<any>;
declare type CheckSelected = (index: number) => boolean;

View File

@ -0,0 +1,22 @@
// Copyright (C) 2019 Storj Labs, Inc.
// See LICENSE for copying information.
declare type OnPageClickCallback = (search: number) => Promise<void>;
export class Page {
private readonly pageIndex: number = 1;
private readonly onClick: OnPageClickCallback;
constructor(index: number, callback: OnPageClickCallback) {
this.pageIndex = index;
this.onClick = callback;
}
public get index() {
return this.pageIndex;
}
public async select(): Promise<void> {
await this.onClick(this.pageIndex);
}
}

View File

@ -0,0 +1,56 @@
// Copyright (C) 2019 Storj Labs, Inc.
// See LICENSE for copying information.
import { mount, shallowMount } from '@vue/test-utils';
import * as sinon from 'sinon';
import PagesBlock from '@/components/common/PagesBlock.vue';
import { Page } from '@/types/pagination';
describe('Pagination.vue', () => {
it('renders correctly without props', () => {
const wrapper = shallowMount(PagesBlock);
expect(wrapper).toMatchSnapshot();
});
it('renders correctly with props', () => {
const callbackSpy = sinon.spy();
const pagesArray: Page[] = [];
const SELECTED_PAGE_INDEX: number = 3;
for (let i = 1; i <= 4; i++) {
pagesArray.push(new Page(i, callbackSpy));
}
const wrapper = shallowMount(PagesBlock, {
propsData: {
pages: pagesArray,
checkSelected: (i: number) => i === SELECTED_PAGE_INDEX
}
});
expect(wrapper).toMatchSnapshot();
expect(wrapper.findAll('span').length).toBe(4);
expect(wrapper.findAll('span').at(2).classes().includes('selected')).toBe(true);
});
it('behaves correctly on page click', async () => {
const callbackSpy = sinon.spy();
let pagesArray: Page[] = [];
for (let i = 1; i <= 3; i++) {
pagesArray.push(new Page(i, callbackSpy));
}
const wrapper = shallowMount(PagesBlock, {
propsData: {
pages: pagesArray,
checkSelected: () => false
}
});
wrapper.findAll('span').at(1).trigger('click');
expect(callbackSpy.callCount).toBe(1);
});
});

View File

@ -0,0 +1,309 @@
// Copyright (C) 2019 Storj Labs, Inc.
// See LICENSE for copying information.
import { mount, shallowMount } from '@vue/test-utils';
import * as sinon from 'sinon';
import Pagination from '@/components/common/Pagination.vue';
describe('Pagination.vue', () => {
it('renders correctly', () => {
const wrapper = shallowMount(Pagination);
expect(wrapper).toMatchSnapshot();
});
it('renders correctly with props', () => {
const wrapper = shallowMount(Pagination, {
propsData: {
totalPageCount: 10,
onPageClickCallback: () => new Promise(() => false)
},
mocks: {
$route: {
query: {
pageNumber: 2
}
},
$router: {
replace: () => false
}
}
});
expect(wrapper).toMatchSnapshot();
});
it('inits correctly with totalPageCount equals 10 and current pageNumber in first block', () => {
const wrapper = mount(Pagination, {
propsData: {
totalPageCount: 10,
onPageClickCallback: () => new Promise(() => false)
},
mocks: {
$route: {
query: {
pageNumber: 2
}
},
$router: {
replace: () => false
}
}
});
const wrapperData = wrapper.vm.$data;
expect(wrapperData.currentPageNumber).toBe(2);
expect(wrapperData.pagesArray.length).toBe(10);
expect(wrapperData.firstBlockPages.length).toBe(3);
expect(wrapperData.middleBlockPages.length).toBe(0);
expect(wrapperData.lastBlockPages.length).toBe(1);
expect(wrapper.findAll('span').at(1).classes().includes('selected')).toBe(true);
});
it('inits correctly with totalPageCount equals 10 and current pageNumber in middle block', () => {
const wrapper = shallowMount(Pagination, {
propsData: {
totalPageCount: 12,
onPageClickCallback: () => new Promise(() => false)
},
mocks: {
$route: {
query: {
pageNumber: 5
}
},
$router: {
replace: () => false
}
}
});
const wrapperData = wrapper.vm.$data;
expect(wrapperData.currentPageNumber).toBe(5);
expect(wrapperData.pagesArray.length).toBe(12);
expect(wrapperData.firstBlockPages.length).toBe(1);
expect(wrapperData.middleBlockPages.length).toBe(3);
expect(wrapperData.lastBlockPages.length).toBe(1);
});
it('inits correctly with totalPageCount equals 10 and current pageNumber in last block', () => {
const wrapper = shallowMount(Pagination, {
propsData: {
totalPageCount: 13,
onPageClickCallback: () => new Promise(() => false)
},
mocks: {
$route: {
query: {
pageNumber: 12
}
},
$router: {
replace: () => false
}
}
});
const wrapperData = wrapper.vm.$data;
expect(wrapperData.currentPageNumber).toBe(12);
expect(wrapperData.pagesArray.length).toBe(13);
expect(wrapperData.firstBlockPages.length).toBe(1);
expect(wrapperData.middleBlockPages.length).toBe(0);
expect(wrapperData.lastBlockPages.length).toBe(3);
});
it('inits correctly with totalPageCount equals 4 and no current pageNumber in query', () => {
const wrapper = shallowMount(Pagination, {
propsData: {
totalPageCount: 4,
onPageClickCallback: () => new Promise(() => false)
},
mocks: {
$route: {
query: {
pageNumber: null
}
},
$router: {
replace: () => false
}
}
});
const wrapperData = wrapper.vm.$data;
expect(wrapperData.currentPageNumber).toBe(1);
expect(wrapperData.pagesArray.length).toBe(4);
expect(wrapperData.firstBlockPages.length).toBe(4);
expect(wrapperData.middleBlockPages.length).toBe(0);
expect(wrapperData.lastBlockPages.length).toBe(0);
});
it('behaves correctly on page click', async () => {
const routerReplaceSpy = sinon.spy();
const callbackSpy = sinon.stub();
const wrapper = mount(Pagination, {
propsData: {
totalPageCount: 9,
onPageClickCallback: callbackSpy
},
mocks: {
$route: {
query: {
pageNumber: null
}
},
$router: {
replace: routerReplaceSpy
}
}
});
const wrapperData = wrapper.vm.$data;
wrapper.findAll('span').at(2).trigger('click');
await expect(callbackSpy.callCount).toBe(1);
expect(routerReplaceSpy.callCount).toBe(1);
expect(wrapperData.currentPageNumber).toBe(3);
expect(wrapperData.firstBlockPages.length).toBe(1);
expect(wrapperData.middleBlockPages.length).toBe(3);
expect(wrapperData.lastBlockPages.length).toBe(1);
});
it('behaves correctly on next page button click', async () => {
const routerReplaceSpy = sinon.spy();
const callbackSpy = sinon.stub();
const wrapper = mount(Pagination, {
propsData: {
totalPageCount: 9,
onPageClickCallback: callbackSpy
},
mocks: {
$route: {
query: {
pageNumber: null
}
},
$router: {
replace: routerReplaceSpy
}
}
});
const wrapperData = wrapper.vm.$data;
wrapper.findAll('.pagination-container__button').at(1).trigger('click');
await expect(callbackSpy.callCount).toBe(1);
expect(routerReplaceSpy.callCount).toBe(1);
expect(wrapperData.currentPageNumber).toBe(2);
expect(wrapperData.firstBlockPages.length).toBe(3);
expect(wrapperData.middleBlockPages.length).toBe(0);
expect(wrapperData.lastBlockPages.length).toBe(1);
});
it('behaves correctly on previous page button click', async () => {
const routerReplaceSpy = sinon.spy();
const callbackSpy = sinon.stub();
const wrapper = mount(Pagination, {
propsData: {
totalPageCount: 9,
onPageClickCallback: callbackSpy
},
mocks: {
$route: {
query: {
pageNumber: 8
}
},
$router: {
replace: routerReplaceSpy
}
}
});
const wrapperData = wrapper.vm.$data;
wrapper.findAll('.pagination-container__button').at(0).trigger('click');
await expect(callbackSpy.callCount).toBe(1);
expect(routerReplaceSpy.callCount).toBe(2);
expect(wrapperData.currentPageNumber).toBe(7);
expect(wrapperData.firstBlockPages.length).toBe(1);
expect(wrapperData.middleBlockPages.length).toBe(3);
expect(wrapperData.lastBlockPages.length).toBe(1);
});
it('behaves correctly on previous page button click when current is 1', async () => {
const routerReplaceSpy = sinon.spy();
const callbackSpy = sinon.stub();
const wrapper = mount(Pagination, {
propsData: {
totalPageCount: 9,
onPageClickCallback: callbackSpy
},
mocks: {
$route: {
query: {
pageNumber: null
}
},
$router: {
replace: routerReplaceSpy
}
}
});
const wrapperData = wrapper.vm.$data;
wrapper.findAll('.pagination-container__button').at(0).trigger('click');
await expect(callbackSpy.callCount).toBe(0);
expect(routerReplaceSpy.callCount).toBe(0);
expect(wrapperData.currentPageNumber).toBe(1);
expect(wrapperData.firstBlockPages.length).toBe(3);
expect(wrapperData.middleBlockPages.length).toBe(0);
expect(wrapperData.lastBlockPages.length).toBe(1);
});
it('behaves correctly on next page button click when current is last', async () => {
const routerReplaceSpy = sinon.spy();
const callbackSpy = sinon.stub();
const wrapper = mount(Pagination, {
propsData: {
totalPageCount: 9,
onPageClickCallback: callbackSpy
},
mocks: {
$route: {
query: {
pageNumber: 9
}
},
$router: {
replace: routerReplaceSpy
}
}
});
const wrapperData = wrapper.vm.$data;
wrapper.findAll('.pagination-container__button').at(1).trigger('click');
await expect(callbackSpy.callCount).toBe(0);
expect(routerReplaceSpy.callCount).toBe(1);
expect(wrapperData.currentPageNumber).toBe(9);
expect(wrapperData.firstBlockPages.length).toBe(1);
expect(wrapperData.middleBlockPages.length).toBe(0);
expect(wrapperData.lastBlockPages.length).toBe(3);
});
});

View File

@ -0,0 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Pagination.vue renders correctly with props 1`] = `<div class="pages-container"><span class="">1</span><span class="">2</span><span class="selected">3</span><span class="">4</span></div>`;
exports[`Pagination.vue renders correctly without props 1`] = `<div class="pages-container"></div>`;

View File

@ -0,0 +1,42 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Pagination.vue renders correctly 1`] = `
<div class="pagination-container">
<div class="pagination-container__pages">
<div class="pagination-container__button"><svg width="6" height="9" viewBox="0 0 6 9" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.80077e-07 4.26316L6 0L6 9L2.80077e-07 4.26316Z" fill="#354049"></path>
</svg></div>
<div class="pagination-container__items">
<pagesblock-stub pages="" checkselected="function () { [native code] }"></pagesblock-stub>
<!---->
<pagesblock-stub pages="" checkselected="function () { [native code] }"></pagesblock-stub>
<!---->
<pagesblock-stub pages="" checkselected="function () { [native code] }"></pagesblock-stub>
</div>
<div class="pagination-container__button"><svg width="6" height="9" viewBox="0 0 6 9" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 4.73684L0 9L1.20219e-06 -9.53674e-07L6 4.73684Z" fill="#354049"></path>
</svg>
</div>
</div>
</div>
`;
exports[`Pagination.vue renders correctly with props 1`] = `
<div class="pagination-container">
<div class="pagination-container__pages">
<div class="pagination-container__button"><svg width="6" height="9" viewBox="0 0 6 9" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.80077e-07 4.26316L6 0L6 9L2.80077e-07 4.26316Z" fill="#354049"></path>
</svg></div>
<div class="pagination-container__items">
<pagesblock-stub pages="[object Object],[object Object],[object Object]" checkselected="function () { [native code] }"></pagesblock-stub> <span>...</span>
<pagesblock-stub pages="" checkselected="function () { [native code] }"></pagesblock-stub>
<!---->
<pagesblock-stub pages="[object Object]" checkselected="function () { [native code] }"></pagesblock-stub>
</div>
<div class="pagination-container__button"><svg width="6" height="9" viewBox="0 0 6 9" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 4.73684L0 9L1.20219e-06 -9.53674e-07L6 4.73684Z" fill="#354049"></path>
</svg>
</div>
</div>
</div>
`;