From 4692a236968100ac362f838760dcdb6db6a1f006 Mon Sep 17 00:00:00 2001 From: Alice Gaudon Date: Tue, 30 Mar 2021 10:20:38 +0200 Subject: [PATCH] Properly implement pagination --- src/Pagination.ts | 76 +++++++++++++++++++++++++--- src/db/ModelFactory.ts | 23 ++++++--- src/db/ModelQuery.ts | 4 +- test/Pagination.test.ts | 107 ++++++++++++++++++++++++++++++++++++++++ views/macros.njk | 36 ++++++++++---- 5 files changed, 222 insertions(+), 24 deletions(-) create mode 100644 test/Pagination.test.ts diff --git a/src/Pagination.ts b/src/Pagination.ts index 50055d9..b86cd07 100644 --- a/src/Pagination.ts +++ b/src/Pagination.ts @@ -1,16 +1,18 @@ -import Model from "./db/Model"; +import {WrappingError} from "./Utils"; -export default class Pagination { - private readonly models: T[]; +export default class Pagination { public readonly page: number; public readonly perPage: number; public readonly totalCount: number; - public constructor(models: T[], page: number, perPage: number, totalCount: number) { - this.models = models; + public constructor(page: number, perPage: number, totalCount: number) { this.page = page; this.perPage = perPage; this.totalCount = totalCount; + + if (this.page < 1 || this.page > this.lastPage) { + throw new PageNotFoundError(this.page); + } } public hasPrevious(): boolean { @@ -18,7 +20,69 @@ export default class Pagination { } public hasNext(): boolean { - return this.models.length >= this.perPage && this.page * this.perPage < this.totalCount; + return this.page < this.lastPage; } + public get lastPage(): number { + return Math.ceil(this.totalCount / this.perPage); + } + + public previousPages(contextSize: number): number[] { + const pages = []; + + let i = 1; + + // Leftmost context + while (i < this.page && i <= contextSize) { + pages.push(i); + i++; + } + + // Ellipsis + if (i < this.page - contextSize) { + pages.push(-1); + } + + // Middle left context + i = Math.max(i, this.page - contextSize); + while (i < this.page) { + pages.push(i); + i++; + } + + return pages; + } + + public nextPages(contextSize: number): number[] { + const pages = []; + + let i = this.page + 1; + // Middle right context + while (i <= this.lastPage && i <= this.page + contextSize) { + pages.push(i); + i++; + } + + // Ellipsis + if (this.page + contextSize + 1 < this.lastPage - contextSize + 1) { + pages.push(-1); + } + + // Rightmost context + i = Math.max(i, this.lastPage - contextSize + 1); + while (i <= this.lastPage) { + pages.push(i); + i++; + } + + return pages; + } +} + +export class PageNotFoundError extends WrappingError { + public constructor( + public readonly page: number, + ) { + super(`Page ${page} not found.`); + } } diff --git a/src/db/ModelFactory.ts b/src/db/ModelFactory.ts index 6d91c81..e24d2d2 100644 --- a/src/db/ModelFactory.ts +++ b/src/db/ModelFactory.ts @@ -2,6 +2,8 @@ import ModelComponent from "./ModelComponent"; import Model, {ModelType} from "./Model"; import ModelQuery, {ModelQueryResult, QueryFields} from "./ModelQuery"; import {Request} from "express"; +import {PageNotFoundError} from "../Pagination"; +import {NotFoundHttpError} from "../HttpError"; export default class ModelFactory { private static readonly factories: { [modelType: string]: ModelFactory | undefined } = {}; @@ -86,16 +88,25 @@ export default class ModelFactory { return await query.first(); } - public async paginate(request: Request, perPage: number = 20, query?: ModelQuery): Promise> { - const page = request.params.page ? parseInt(request.params.page) : 1; + public async paginate(req: Request, perPage: number = 20, query?: ModelQuery): Promise> { + const page = req.params.page ? parseInt(req.params.page) : 1; if (!query) query = this.select(); - if (request.params.sortBy) { - const dir = request.params.sortDirection; - query = query.sortBy(request.params.sortBy, dir === 'ASC' || dir === 'DESC' ? dir : undefined); + if (req.params.sortBy) { + const dir = req.params.sortDirection; + query = query.sortBy(req.params.sortBy, dir === 'ASC' || dir === 'DESC' ? dir : undefined); } else { query = query.sortBy('id'); } - return await query.paginate(page, perPage); + + try { + return await query.paginate(page, perPage); + } catch (e) { + if (e instanceof PageNotFoundError) { + throw new NotFoundHttpError(`page ${e.page}`, req.url, e); + } else { + throw e; + } + } } } diff --git a/src/db/ModelQuery.ts b/src/db/ModelQuery.ts index 472744d..c70caf6 100644 --- a/src/db/ModelQuery.ts +++ b/src/db/ModelQuery.ts @@ -347,7 +347,7 @@ export default class ModelQuery implements WhereFieldConsumer> { this.limit(perPage, (page - 1) * perPage); const result = await this.get(connection); - result.pagination = new Pagination(result, page, perPage, await this.count(true, connection)); + result.pagination = new Pagination(page, perPage, await this.count(true, connection)); return result; } @@ -385,7 +385,7 @@ function inputToFieldOrValue(input: string, addTable?: string): string { export interface ModelQueryResult extends Array { originalData?: Record[]; - pagination?: Pagination; + pagination?: Pagination; pivot?: Record[]; } diff --git a/test/Pagination.test.ts b/test/Pagination.test.ts new file mode 100644 index 0000000..8cf66bc --- /dev/null +++ b/test/Pagination.test.ts @@ -0,0 +1,107 @@ +import Pagination from "../src/Pagination"; + +describe('Pagination', () => { + const pagination = new Pagination(3, 5, 31); + test('Should have correct page', () => { + expect(pagination.page).toBe(3); + }); + + test('Should have correct perPage', () => { + expect(pagination.perPage).toBe(5); + }); + + test('Should have correct totalCount', () => { + expect(pagination.totalCount).toBe(31); + }); + + test('Should calculate correct last page', () => { + expect(new Pagination(3, 5, 30).lastPage).toBe(6); + expect(new Pagination(3, 5, 31).lastPage).toBe(7); + expect(new Pagination(3, 5, 32).lastPage).toBe(7); + expect(new Pagination(3, 5, 34).lastPage).toBe(7); + expect(new Pagination(3, 5, 35).lastPage).toBe(7); + expect(new Pagination(3, 5, 36).lastPage).toBe(8); + }); + + test('Should properly tell whether has a previous page', () => { + expect(pagination.hasPrevious()).toBe(true); + expect(new Pagination(1, 5, 15).hasPrevious()).toBe(false); + expect(new Pagination(2, 5, 15).hasPrevious()).toBe(true); + expect(new Pagination(3, 5, 15).hasPrevious()).toBe(true); + + expect(new Pagination(1, 5, 17).hasPrevious()).toBe(false); + expect(new Pagination(2, 5, 17).hasPrevious()).toBe(true); + expect(new Pagination(3, 5, 17).hasPrevious()).toBe(true); + expect(new Pagination(4, 5, 17).hasPrevious()).toBe(true); + }); + + test('Should properly tell whether has a next page', () => { + expect(pagination.hasPrevious()).toBe(true); + expect(new Pagination(1, 5, 15).hasNext()).toBe(true); + expect(new Pagination(2, 5, 15).hasNext()).toBe(true); + expect(new Pagination(3, 5, 15).hasNext()).toBe(false); + + expect(new Pagination(1, 5, 17).hasNext()).toBe(true); + expect(new Pagination(2, 5, 17).hasNext()).toBe(true); + expect(new Pagination(3, 5, 17).hasNext()).toBe(true); + expect(new Pagination(4, 5, 17).hasNext()).toBe(false); + }); + + test('Should throw on out of bound creation attempt', () => { + expect(() => { + new Pagination(-1, 5, 15); + }).toThrow('Page -1 not found.'); + expect(() => { + new Pagination(-20, 5, 15); + }).toThrow('Page -20 not found.'); + expect(() => { + new Pagination(3, 5, 15); + }).not.toThrow(); + expect(() => { + new Pagination(4, 5, 15); + }).toThrow('Page 4 not found.'); + expect(() => { + new Pagination(20, 5, 15); + }).toThrow('Page 20 not found.'); + }); + + test('Should generate proper previous pages with context', () => { + expect(new Pagination(1, 1, 100).previousPages(1)).toStrictEqual([]); + expect(new Pagination(1, 1, 100).previousPages(2)).toStrictEqual([]); + expect(new Pagination(1, 1, 100).previousPages(3)).toStrictEqual([]); + + expect(new Pagination(2, 1, 100).previousPages(1)).toStrictEqual([1]); + expect(new Pagination(2, 1, 100).previousPages(2)).toStrictEqual([1]); + expect(new Pagination(2, 1, 100).previousPages(3)).toStrictEqual([1]); + + expect(new Pagination(3, 1, 100).previousPages(1)).toStrictEqual([1, 2]); + expect(new Pagination(3, 1, 100).previousPages(2)).toStrictEqual([1, 2]); + expect(new Pagination(3, 1, 100).previousPages(3)).toStrictEqual([1, 2]); + + expect(new Pagination(10, 1, 100).previousPages(1)).toStrictEqual([1, -1, 9]); + expect(new Pagination(10, 1, 100).previousPages(2)).toStrictEqual([1, 2, -1, 8, 9]); + expect(new Pagination(10, 1, 100).previousPages(3)).toStrictEqual([1, 2, 3, -1, 7, 8, 9]); + }); + + test('Should generate proper next pages with context', () => { + let pagination = new Pagination(100, 1, 100); + expect(pagination.nextPages(1)).toStrictEqual([]); + expect(pagination.nextPages(2)).toStrictEqual([]); + expect(pagination.nextPages(3)).toStrictEqual([]); + + pagination = new Pagination(99, 1, 100); + expect(pagination.nextPages(1)).toStrictEqual([100]); + expect(pagination.nextPages(2)).toStrictEqual([100]); + expect(pagination.nextPages(3)).toStrictEqual([100]); + + pagination = new Pagination(98, 1, 100); + expect(pagination.nextPages(1)).toStrictEqual([99, 100]); + expect(pagination.nextPages(2)).toStrictEqual([99, 100]); + expect(pagination.nextPages(3)).toStrictEqual([99, 100]); + + pagination = new Pagination(90, 1, 100); + expect(pagination.nextPages(1)).toStrictEqual([91, -1, 100]); + expect(pagination.nextPages(2)).toStrictEqual([91, 92, -1, 99, 100]); + expect(pagination.nextPages(3)).toStrictEqual([91, 92, 93, -1, 98, 99, 100]); + }); +}); diff --git a/views/macros.njk b/views/macros.njk index e52ed06..8857876 100644 --- a/views/macros.njk +++ b/views/macros.njk @@ -141,19 +141,35 @@ {% endmacro %} -{% macro paginate(pagination, routeName) %} +{% macro paginate(pagination, routeName, contextSize) %} {% if pagination.hasPrevious() or pagination.hasNext() %} - + {% if pagination.hasNext() %} + {% for i in pagination.nextPages(contextSize) %} + {% if i == -1 %} +
  • ...
  • + {% else %} +
  • {{ i }}
  • + {% endif %} + {% endfor %} +
  • Next
  • + {% endif %} + + {% endif %} {% endmacro %}