Added pagination controls and more pagination info.
This commit is contained in:
parent
4f5a46d187
commit
ae94dcebe6
|
@ -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;
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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 ~= " ";
|
||||||
}
|
}
|
||||||
app ~= "LIMIT ";
|
if (!isUnpaged()) {
|
||||||
app ~= size.to!string;
|
app ~= "LIMIT ";
|
||||||
app ~= " OFFSET ";
|
app ~= size.to!string;
|
||||||
app ~= page.to!string;
|
app ~= " OFFSET ";
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
Loading…
Reference in New Issue