Added pagination controls and more pagination info.

This commit is contained in:
Andrew Lalis 2025-08-03 20:54:15 -04:00
parent 4f5a46d187
commit ae94dcebe6
11 changed files with 180 additions and 18 deletions

View File

@ -14,7 +14,7 @@ import util.money;
import util.pagination; import util.pagination;
import util.data; 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 { struct TransactionResponse {
import asdf : serdeTransformOut; import asdf : serdeTransformOut;

View File

@ -192,13 +192,16 @@ class SqliteTransactionRepository : TransactionRepository {
Page!Transaction findAll(PageRequest pr) { Page!Transaction findAll(PageRequest pr) {
// TODO: Implement filtering or something! // TODO: Implement filtering or something!
import std.array; import std.array;
const string rootQuery = "SELECT * FROM " ~ TABLE_NAME;
const string countQuery = "SELECT COUNT(ID) FROM " ~ TABLE_NAME;
auto sqlBuilder = appender!string; auto sqlBuilder = appender!string;
sqlBuilder ~= "SELECT * FROM " ~ TABLE_NAME; sqlBuilder ~= rootQuery;
sqlBuilder ~= " "; sqlBuilder ~= " ";
sqlBuilder ~= pr.toSql(); sqlBuilder ~= pr.toSql();
string query = sqlBuilder[]; string query = sqlBuilder[];
Transaction[] results = util.sqlite.findAll(db, query, &parseTransaction); 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) { Optional!Transaction findById(ulong id) {

View File

@ -36,8 +36,20 @@ struct Sort {
} }
struct PageRequest { struct PageRequest {
/**
* The requested page number, starting from 1 for the first page, or zero
* for an unpaged request.
*/
immutable uint page; immutable uint page;
/**
* The maximum number of items to include in each page of results.
*/
immutable ushort size; immutable ushort size;
/**
* A list of sorts to apply.
*/
immutable Sort[] sorts; immutable Sort[] sorts;
bool isUnpaged() const { bool isUnpaged() const {
@ -59,6 +71,9 @@ struct PageRequest {
.filter!(o => !o.isNull) .filter!(o => !o.isNull)
.map!(o => o.value) .map!(o => o.value)
.array; .array;
if (s.length == 0 && defaults.sorts.length > 0) {
s = defaults.sorts.dup;
}
return PageRequest(pg, sz, s.idup); return PageRequest(pg, sz, s.idup);
} }
@ -76,10 +91,12 @@ struct PageRequest {
} }
app ~= " "; app ~= " ";
} }
if (!isUnpaged()) {
app ~= "LIMIT "; app ~= "LIMIT ";
app ~= size.to!string; app ~= size.to!string;
app ~= " OFFSET "; app ~= " OFFSET ";
app ~= page.to!string; app ~= ((page - 1) * size).to!string;
}
return app[]; 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) { struct Page(T) {
T[] items; T[] items;
PageRequest pageRequest; PageRequest pageRequest;
ulong totalElements;
ulong totalPages;
bool isFirst;
bool isLast;
Page!U mapTo(U)(U function(T) fn) { Page!U mapTo(U)(U function(T) fn) {
import std.algorithm : map; import std.algorithm : map;
import std.array : array; 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);
}

View File

@ -1,5 +1,13 @@
<script setup lang="ts"></script> <script setup lang="ts">
import ModalWrapper from './components/ModalWrapper.vue';
</script>
<template> <template>
<RouterView></RouterView> <RouterView></RouterView>
<!-- Global alert modal used by util/alert.ts -->
<ModalWrapper id="global-alert-modal">
<p id="global-alert-modal-text">This is an alert!</p>
</ModalWrapper>
</template> </template>
<style scoped></style> <style scoped></style>

View File

@ -99,12 +99,23 @@ export abstract class ApiClient {
try { try {
const response = await fetch(this.baseUrl + path, settings) const response = await fetch(this.baseUrl + path, settings)
if (!response.ok) { if (!response.ok) {
throw new StatusError(response.status, 'Status error') const message = await response.text()
throw new StatusError(response.status, message)
} }
return response return response
} catch (error) { } 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) console.error(error)
throw new NetworkError('Request to ' + path + ' failed.') throw new NetworkError(message)
} }
} }
} }

View File

@ -24,4 +24,8 @@ export function toQueryParams(pageRequest: PageRequest): string {
export interface Page<T> { export interface Page<T> {
items: T[] items: T[]
pageRequest: PageRequest pageRequest: PageRequest
totalElements: number
totalPages: number
isFirst: boolean
isLast: boolean
} }

View File

@ -2,6 +2,8 @@
import { ref, type Ref } from 'vue'; import { ref, type Ref } from 'vue';
import AppButton from './AppButton.vue'; import AppButton from './AppButton.vue';
defineProps<{ id?: string }>()
const dialog: Ref<HTMLDialogElement | null> = ref(null) const dialog: Ref<HTMLDialogElement | null> = ref(null)
function show(): Promise<string | undefined> { function show(): Promise<string | undefined> {
@ -21,13 +23,13 @@ defineExpose({ show, close })
</script> </script>
<template> <template>
<Teleport to="body"> <Teleport to="body">
<dialog ref="dialog" class="app-modal-dialog"> <dialog ref="dialog" class="app-modal-dialog" :id="id">
<slot></slot> <slot></slot>
<div class="app-modal-dialog-actions"> <div class="app-modal-dialog-actions">
<slot name="buttons"> <slot name="buttons">
<AppButton style="secondary" @click="close('close')">Close</AppButton> <AppButton style="secondary" @click="close()">Close</AppButton>
</slot> </slot>
</div> </div>
</dialog> </dialog>

View File

@ -0,0 +1,48 @@
<script setup lang="ts">
import type { Page, PageRequest } from '@/api/pagination';
const props = defineProps<{ page?: Page<unknown> }>()
const emit = defineEmits<{
update: [pageRequest: PageRequest]
}>()
function updatePage(newPage: number) {
if (!props.page) return
emit('update', {
page: newPage,
size: props.page.pageRequest.size,
sorts: props.page.pageRequest.sorts
})
}
function incrementPage(step: number) {
if (!props.page) return
emit('update', {
page: props.page.pageRequest.page + step,
size: props.page.pageRequest.size,
sorts: props.page.pageRequest.sorts
})
}
</script>
<template>
<div>
<button :disabled="!page || page.isFirst" @click="updatePage(1)">
First Page
</button>
<button :disabled="!page || page.isFirst" @click="incrementPage(-1)">
Previous Page
</button>
<span>Page {{ page?.pageRequest.page }} / {{ page?.totalPages }}</span>
<button :disabled="!page || page.isLast" @click="incrementPage(1)">
Next Page
</button>
<button :disabled="!page || page.isLast" @click="updatePage(page?.totalPages ?? 0)">
Last Page
</button>
</div>
</template>

View File

@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { AuthApiClient } from '@/api/auth'; import { AuthApiClient } from '@/api/auth';
import { ApiError } from '@/api/base';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { showAlert } from '@/util/alert';
import { ref } from 'vue'; import { ref } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
@ -24,7 +26,11 @@ async function doLogin() {
await router.replace('/') await router.replace('/')
} }
} catch (err) { } catch (err) {
console.warn(err) if (err instanceof ApiError) {
await showAlert(err.message)
} else {
await showAlert('Request failed: ' + JSON.stringify(err))
}
} finally { } finally {
disableForm.value = false disableForm.value = false
} }

View File

@ -1,21 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Page } from '@/api/pagination'; import type { Page, PageRequest } from '@/api/pagination';
import { TransactionApiClient, type Transaction } from '@/api/transaction'; import { TransactionApiClient, type Transaction } from '@/api/transaction';
import PaginationControls from '@/components/PaginationControls.vue';
import { useProfileStore } from '@/stores/profile-store'; import { useProfileStore } from '@/stores/profile-store';
import { onMounted, ref, type Ref } from 'vue'; import { onMounted, ref, type Ref } from 'vue';
const profileStore = useProfileStore() const profileStore = useProfileStore()
const transactions: Ref<Page<Transaction>> = ref({ items: [], pageRequest: { page: 0, size: 10, sorts: [] } }) const transactions: Ref<Page<Transaction>> = ref({ items: [], pageRequest: { page: 1, size: 10, sorts: [] }, totalElements: 0, totalPages: 0, isFirst: true, isLast: true })
onMounted(async () => { onMounted(async () => {
await fetchPage(transactions.value.pageRequest)
})
async function fetchPage(pageRequest: PageRequest) {
if (!profileStore.state) return if (!profileStore.state) return
const api = new TransactionApiClient(profileStore.state) const api = new TransactionApiClient(profileStore.state)
try { try {
transactions.value = await api.getTransactions(transactions.value.pageRequest) transactions.value = await api.getTransactions(pageRequest)
} catch (err) { } catch (err) {
console.error(err) console.error(err)
} }
}) }
</script> </script>
<template> <template>
<div class="app-module"> <div class="app-module">
@ -40,5 +45,6 @@ onMounted(async () => {
</tr> </tr>
</tbody> </tbody>
</table> </table>
<PaginationControls :page="transactions" @update="pr => fetchPage(pr)" />
</div> </div>
</template> </template>

20
web-app/src/util/alert.ts Normal file
View File

@ -0,0 +1,20 @@
export function showAlert(message: string): Promise<void> {
const modal: HTMLDialogElement = document.getElementById(
'global-alert-modal',
) as HTMLDialogElement
const modalText: HTMLParagraphElement = document.getElementById(
'global-alert-modal-text',
) as HTMLParagraphElement
modalText.innerText = message
return new Promise((resolve) => {
modal.showModal()
modal.addEventListener(
'close',
() => {
resolve()
},
{ once: true },
)
})
}