Properly implement pagination

This commit is contained in:
Alice Gaudon 2021-03-30 10:20:38 +02:00
parent caae753d74
commit 4692a23696
5 changed files with 222 additions and 24 deletions

View File

@ -1,16 +1,18 @@
import Model from "./db/Model"; import {WrappingError} from "./Utils";
export default class Pagination<T extends Model> { export default class Pagination {
private readonly models: T[];
public readonly page: number; public readonly page: number;
public readonly perPage: number; public readonly perPage: number;
public readonly totalCount: number; public readonly totalCount: number;
public constructor(models: T[], page: number, perPage: number, totalCount: number) { public constructor(page: number, perPage: number, totalCount: number) {
this.models = models;
this.page = page; this.page = page;
this.perPage = perPage; this.perPage = perPage;
this.totalCount = totalCount; this.totalCount = totalCount;
if (this.page < 1 || this.page > this.lastPage) {
throw new PageNotFoundError(this.page);
}
} }
public hasPrevious(): boolean { public hasPrevious(): boolean {
@ -18,7 +20,69 @@ export default class Pagination<T extends Model> {
} }
public hasNext(): boolean { 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.`);
}
} }

View File

@ -2,6 +2,8 @@ import ModelComponent from "./ModelComponent";
import Model, {ModelType} from "./Model"; import Model, {ModelType} from "./Model";
import ModelQuery, {ModelQueryResult, QueryFields} from "./ModelQuery"; import ModelQuery, {ModelQueryResult, QueryFields} from "./ModelQuery";
import {Request} from "express"; import {Request} from "express";
import {PageNotFoundError} from "../Pagination";
import {NotFoundHttpError} from "../HttpError";
export default class ModelFactory<M extends Model> { export default class ModelFactory<M extends Model> {
private static readonly factories: { [modelType: string]: ModelFactory<Model> | undefined } = {}; private static readonly factories: { [modelType: string]: ModelFactory<Model> | undefined } = {};
@ -86,16 +88,25 @@ export default class ModelFactory<M extends Model> {
return await query.first(); return await query.first();
} }
public async paginate(request: Request, perPage: number = 20, query?: ModelQuery<M>): Promise<ModelQueryResult<M>> { public async paginate(req: Request, perPage: number = 20, query?: ModelQuery<M>): Promise<ModelQueryResult<M>> {
const page = request.params.page ? parseInt(request.params.page) : 1; const page = req.params.page ? parseInt(req.params.page) : 1;
if (!query) query = this.select(); if (!query) query = this.select();
if (request.params.sortBy) { if (req.params.sortBy) {
const dir = request.params.sortDirection; const dir = req.params.sortDirection;
query = query.sortBy(request.params.sortBy, dir === 'ASC' || dir === 'DESC' ? dir : undefined); query = query.sortBy(req.params.sortBy, dir === 'ASC' || dir === 'DESC' ? dir : undefined);
} else { } else {
query = query.sortBy('id'); query = query.sortBy('id');
} }
try {
return await query.paginate(page, perPage); return await query.paginate(page, perPage);
} catch (e) {
if (e instanceof PageNotFoundError) {
throw new NotFoundHttpError(`page ${e.page}`, req.url, e);
} else {
throw e;
}
}
} }
} }

View File

@ -347,7 +347,7 @@ export default class ModelQuery<M extends Model> implements WhereFieldConsumer<M
public async paginate(page: number, perPage: number, connection?: Connection): Promise<ModelQueryResult<M>> { public async paginate(page: number, perPage: number, connection?: Connection): Promise<ModelQueryResult<M>> {
this.limit(perPage, (page - 1) * perPage); this.limit(perPage, (page - 1) * perPage);
const result = await this.get(connection); const result = await this.get(connection);
result.pagination = new Pagination<M>(result, page, perPage, await this.count(true, connection)); result.pagination = new Pagination(page, perPage, await this.count(true, connection));
return result; return result;
} }
@ -385,7 +385,7 @@ function inputToFieldOrValue(input: string, addTable?: string): string {
export interface ModelQueryResult<M extends Model> extends Array<M> { export interface ModelQueryResult<M extends Model> extends Array<M> {
originalData?: Record<string, unknown>[]; originalData?: Record<string, unknown>[];
pagination?: Pagination<M>; pagination?: Pagination;
pivot?: Record<string, unknown>[]; pivot?: Record<string, unknown>[];
} }

107
test/Pagination.test.ts Normal file
View File

@ -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]);
});
});

View File

@ -141,19 +141,35 @@
</script> </script>
{% endmacro %} {% endmacro %}
{% macro paginate(pagination, routeName) %} {% macro paginate(pagination, routeName, contextSize) %}
{% if pagination.hasPrevious() or pagination.hasNext() %} {% if pagination.hasPrevious() or pagination.hasNext() %}
<div class="pagination"> <nav class="pagination">
<ul>
{% if pagination.hasPrevious() %} {% if pagination.hasPrevious() %}
<a href="{{ route(routeName, {page: pagination.page - 1}) }}"><i data-feather="chevron-left"></i></a> <li><a href="{{ route(routeName, {page: pagination.page - 1}) }}"><i data-feather="chevron-left"></i> Previous</a></li>
{% for i in pagination.previousPages(contextSize) %}
{% if i == -1 %}
<li class="ellipsis">...</li>
{% else %}
<li><a href="{{ route(routeName, {page: i}) }}">{{ i }}</a></li>
{% endif %}
{% endfor %}
{% endif %} {% endif %}
<span>{{ pagination.page }}</span> <li class="active"><span>{{ pagination.page }}</span></li>
{% if pagination.hasNext() %} {% if pagination.hasNext() %}
<a href="{{ route(routeName, {page: pagination.page + 1}) }}"><i data-feather="chevron-right"></i></a> {% for i in pagination.nextPages(contextSize) %}
{% if i == -1 %}
<li class="ellipsis">...</li>
{% else %}
<li><a href="{{ route(routeName, {page: i}) }}">{{ i }}</a></li>
{% endif %} {% endif %}
</div> {% endfor %}
<li><a href="{{ route(routeName, {page: pagination.page + 1}) }}">Next <i data-feather="chevron-right"></i></a></li>
{% endif %}
</ul>
</nav>
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}