diff --git a/web/satellite/src/components/common/PagesBlock.vue b/web/satellite/src/components/common/PagesBlock.vue
new file mode 100644
index 000000000..581250c51
--- /dev/null
+++ b/web/satellite/src/components/common/PagesBlock.vue
@@ -0,0 +1,78 @@
+// Copyright (C) 2019 Storj Labs, Inc.
+// See LICENSE for copying information.
+
+
+
+ {{page.index}}
+
+
+
+
+
+
diff --git a/web/satellite/src/components/common/Pagination.vue b/web/satellite/src/components/common/Pagination.vue
new file mode 100644
index 000000000..3b6fc8efd
--- /dev/null
+++ b/web/satellite/src/components/common/Pagination.vue
@@ -0,0 +1,262 @@
+// Copyright (C) 2019 Storj Labs, Inc.
+// See LICENSE for copying information.
+
+
+
+
+
+
+
+
diff --git a/web/satellite/src/types/fetch.d.ts b/web/satellite/src/types/fetch.d.ts
index 7e609238f..a2eb03995 100644
--- a/web/satellite/src/types/fetch.d.ts
+++ b/web/satellite/src/types/fetch.d.ts
@@ -8,3 +8,7 @@ declare type Answer = {
message: any;
};
};
+
+declare type OnPageClickCallback = (index: number) => Promise;
+
+declare type CheckSelected = (index: number) => boolean;
diff --git a/web/satellite/src/types/pagination.ts b/web/satellite/src/types/pagination.ts
new file mode 100644
index 000000000..4db58d97f
--- /dev/null
+++ b/web/satellite/src/types/pagination.ts
@@ -0,0 +1,22 @@
+// Copyright (C) 2019 Storj Labs, Inc.
+// See LICENSE for copying information.
+
+declare type OnPageClickCallback = (search: number) => Promise;
+
+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 {
+ await this.onClick(this.pageIndex);
+ }
+}
diff --git a/web/satellite/tests/unit/common/PagesBlock.spec.ts b/web/satellite/tests/unit/common/PagesBlock.spec.ts
new file mode 100644
index 000000000..416869c9d
--- /dev/null
+++ b/web/satellite/tests/unit/common/PagesBlock.spec.ts
@@ -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);
+ });
+});
diff --git a/web/satellite/tests/unit/common/Pagination.spec.ts b/web/satellite/tests/unit/common/Pagination.spec.ts
new file mode 100644
index 000000000..8c37224c4
--- /dev/null
+++ b/web/satellite/tests/unit/common/Pagination.spec.ts
@@ -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);
+ });
+});
diff --git a/web/satellite/tests/unit/common/__snapshots__/PagesBlock.spec.ts.snap b/web/satellite/tests/unit/common/__snapshots__/PagesBlock.spec.ts.snap
new file mode 100644
index 000000000..8ed35ee5f
--- /dev/null
+++ b/web/satellite/tests/unit/common/__snapshots__/PagesBlock.spec.ts.snap
@@ -0,0 +1,5 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Pagination.vue renders correctly with props 1`] = `1234
`;
+
+exports[`Pagination.vue renders correctly without props 1`] = ``;
diff --git a/web/satellite/tests/unit/common/__snapshots__/Pagination.spec.ts.snap b/web/satellite/tests/unit/common/__snapshots__/Pagination.spec.ts.snap
new file mode 100644
index 000000000..d33fe20e4
--- /dev/null
+++ b/web/satellite/tests/unit/common/__snapshots__/Pagination.spec.ts.snap
@@ -0,0 +1,42 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Pagination.vue renders correctly 1`] = `
+
+`;
+
+exports[`Pagination.vue renders correctly with props 1`] = `
+
+`;