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> {
|
||||
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<T extends Model> {
|
||||
}
|
||||
|
||||
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 ModelQuery, {ModelQueryResult, QueryFields} from "./ModelQuery";
|
||||
import {Request} from "express";
|
||||
import {PageNotFoundError} from "../Pagination";
|
||||
import {NotFoundHttpError} from "../HttpError";
|
||||
|
||||
export default class ModelFactory<M extends Model> {
|
||||
private static readonly factories: { [modelType: string]: ModelFactory<Model> | undefined } = {};
|
||||
@ -86,16 +88,25 @@ export default class ModelFactory<M extends Model> {
|
||||
return await query.first();
|
||||
}
|
||||
|
||||
public async paginate(request: Request, perPage: number = 20, query?: ModelQuery<M>): Promise<ModelQueryResult<M>> {
|
||||
const page = request.params.page ? parseInt(request.params.page) : 1;
|
||||
public async paginate(req: Request, perPage: number = 20, query?: ModelQuery<M>): Promise<ModelQueryResult<M>> {
|
||||
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');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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>> {
|
||||
this.limit(perPage, (page - 1) * perPage);
|
||||
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;
|
||||
}
|
||||
|
||||
@ -385,7 +385,7 @@ function inputToFieldOrValue(input: string, addTable?: string): string {
|
||||
|
||||
export interface ModelQueryResult<M extends Model> extends Array<M> {
|
||||
originalData?: Record<string, unknown>[];
|
||||
pagination?: Pagination<M>;
|
||||
pagination?: Pagination;
|
||||
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>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro paginate(pagination, routeName) %}
|
||||
{% macro paginate(pagination, routeName, contextSize) %}
|
||||
{% if pagination.hasPrevious() or pagination.hasNext() %}
|
||||
<div class="pagination">
|
||||
<nav class="pagination">
|
||||
<ul>
|
||||
{% 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 %}
|
||||
|
||||
<span>{{ pagination.page }}</span>
|
||||
<li class="active"><span>{{ pagination.page }}</span></li>
|
||||
|
||||
{% 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 %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<li><a href="{{ route(routeName, {page: pagination.page + 1}) }}">Next <i data-feather="chevron-right"></i></a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user