Properly implement pagination
This commit is contained in:
parent
caae753d74
commit
4692a23696
@ -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.`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
107
test/Pagination.test.ts
Normal 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]);
|
||||||
|
});
|
||||||
|
});
|
@ -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 %}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user