diff --git a/finnow-api/source/transaction/api.d b/finnow-api/source/transaction/api.d index 3712932..26f4a95 100644 --- a/finnow-api/source/transaction/api.d +++ b/finnow-api/source/transaction/api.d @@ -14,7 +14,7 @@ import util.money; import util.pagination; import util.data; -immutable DEFAULT_TRANSACTION_PAGE = PageRequest(0, 10, [Sort("created_at", SortDir.DESC)]); +immutable DEFAULT_TRANSACTION_PAGE = PageRequest(1, 10, [Sort("timestamp", SortDir.DESC)]); struct TransactionResponse { import asdf : serdeTransformOut; diff --git a/finnow-api/source/transaction/data_impl_sqlite.d b/finnow-api/source/transaction/data_impl_sqlite.d index 3411c81..e0d1f34 100644 --- a/finnow-api/source/transaction/data_impl_sqlite.d +++ b/finnow-api/source/transaction/data_impl_sqlite.d @@ -192,13 +192,16 @@ class SqliteTransactionRepository : TransactionRepository { Page!Transaction findAll(PageRequest pr) { // TODO: Implement filtering or something! import std.array; + const string rootQuery = "SELECT * FROM " ~ TABLE_NAME; + const string countQuery = "SELECT COUNT(ID) FROM " ~ TABLE_NAME; auto sqlBuilder = appender!string; - sqlBuilder ~= "SELECT * FROM " ~ TABLE_NAME; + sqlBuilder ~= rootQuery; sqlBuilder ~= " "; sqlBuilder ~= pr.toSql(); string query = sqlBuilder[]; Transaction[] results = util.sqlite.findAll(db, query, &parseTransaction); - return Page!Transaction(results, pr); + ulong totalCount = util.sqlite.count(db, countQuery); + return Page!(Transaction).of(results, pr, totalCount); } Optional!Transaction findById(ulong id) { diff --git a/finnow-api/source/util/pagination.d b/finnow-api/source/util/pagination.d index 8290843..95f7311 100644 --- a/finnow-api/source/util/pagination.d +++ b/finnow-api/source/util/pagination.d @@ -36,8 +36,20 @@ struct Sort { } struct PageRequest { + /** + * The requested page number, starting from 1 for the first page, or zero + * for an unpaged request. + */ immutable uint page; + + /** + * The maximum number of items to include in each page of results. + */ immutable ushort size; + + /** + * A list of sorts to apply. + */ immutable Sort[] sorts; bool isUnpaged() const { @@ -59,6 +71,9 @@ struct PageRequest { .filter!(o => !o.isNull) .map!(o => o.value) .array; + if (s.length == 0 && defaults.sorts.length > 0) { + s = defaults.sorts.dup; + } return PageRequest(pg, sz, s.idup); } @@ -76,10 +91,12 @@ struct PageRequest { } app ~= " "; } - app ~= "LIMIT "; - app ~= size.to!string; - app ~= " OFFSET "; - app ~= page.to!string; + if (!isUnpaged()) { + app ~= "LIMIT "; + app ~= size.to!string; + app ~= " OFFSET "; + app ~= ((page - 1) * size).to!string; + } return app[]; } @@ -94,13 +111,50 @@ struct PageRequest { } } +/** + * Container for a paginated response, which contains the actual page of items, + * as well as some metadata to assist any API client in navigating to other + * pages. + */ struct Page(T) { T[] items; PageRequest pageRequest; + ulong totalElements; + ulong totalPages; + bool isFirst; + bool isLast; + Page!U mapTo(U)(U function(T) fn) { import std.algorithm : map; import std.array : array; - return Page!(U)(this.items.map!(fn).array, this.pageRequest); + return Page!(U)(items.map!(fn).array, pageRequest, totalElements, totalPages, isFirst, isLast); + } + + static Page of(T[] items, PageRequest pageRequest, ulong totalCount) { + ulong pageCount = getTotalPageCount(totalCount, pageRequest.size); + return Page( + items, + pageRequest, + totalCount, + pageCount, + pageRequest.page == 1, + pageRequest.page == pageCount + ); } } + +private ulong getTotalPageCount(ulong totalElements, ulong pageSize) { + return totalElements / pageSize + (totalElements % pageSize > 0 ? 1 : 0); +} + +unittest { + assert(getTotalPageCount(5, 1) == 5); + assert(getTotalPageCount(5, 2) == 3); + assert(getTotalPageCount(5, 3) == 2); + assert(getTotalPageCount(5, 4) == 2); + assert(getTotalPageCount(5, 5) == 1); + assert(getTotalPageCount(5, 6) == 1); + assert(getTotalPageCount(5, 123) == 1); + assert(getTotalPageCount(250, 100) == 3); +} diff --git a/web-app/src/App.vue b/web-app/src/App.vue index 3a6c5ed..2cab29d 100644 --- a/web-app/src/App.vue +++ b/web-app/src/App.vue @@ -1,5 +1,13 @@ - + diff --git a/web-app/src/api/base.ts b/web-app/src/api/base.ts index bdff042..3819d88 100644 --- a/web-app/src/api/base.ts +++ b/web-app/src/api/base.ts @@ -99,12 +99,23 @@ export abstract class ApiClient { try { const response = await fetch(this.baseUrl + path, settings) if (!response.ok) { - throw new StatusError(response.status, 'Status error') + const message = await response.text() + throw new StatusError(response.status, message) } return response } catch (error) { + if (error instanceof ApiError) throw error + let message = 'Request to ' + path + ' failed.' + if ( + typeof error === 'object' && + error !== null && + 'message' in error && + typeof error.message === 'string' + ) { + message = error.message + } console.error(error) - throw new NetworkError('Request to ' + path + ' failed.') + throw new NetworkError(message) } } } diff --git a/web-app/src/api/pagination.ts b/web-app/src/api/pagination.ts index c4edfb1..72f309d 100644 --- a/web-app/src/api/pagination.ts +++ b/web-app/src/api/pagination.ts @@ -24,4 +24,8 @@ export function toQueryParams(pageRequest: PageRequest): string { export interface Page { items: T[] pageRequest: PageRequest + totalElements: number + totalPages: number + isFirst: boolean + isLast: boolean } diff --git a/web-app/src/components/ModalWrapper.vue b/web-app/src/components/ModalWrapper.vue index 20d258f..0626e9f 100644 --- a/web-app/src/components/ModalWrapper.vue +++ b/web-app/src/components/ModalWrapper.vue @@ -2,6 +2,8 @@ import { ref, type Ref } from 'vue'; import AppButton from './AppButton.vue'; +defineProps<{ id?: string }>() + const dialog: Ref = ref(null) function show(): Promise { @@ -21,13 +23,13 @@ defineExpose({ show, close })