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.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;
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 ~= " ";
|
||||
}
|
||||
if (!isUnpaged()) {
|
||||
app ~= "LIMIT ";
|
||||
app ~= size.to!string;
|
||||
app ~= " OFFSET ";
|
||||
app ~= page.to!string;
|
||||
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);
|
||||
}
|
||||
|
|
|
@ -1,5 +1,13 @@
|
|||
<script setup lang="ts"></script>
|
||||
<script setup lang="ts">
|
||||
import ModalWrapper from './components/ModalWrapper.vue';
|
||||
|
||||
</script>
|
||||
<template>
|
||||
<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>
|
||||
<style scoped></style>
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,4 +24,8 @@ export function toQueryParams(pageRequest: PageRequest): string {
|
|||
export interface Page<T> {
|
||||
items: T[]
|
||||
pageRequest: PageRequest
|
||||
totalElements: number
|
||||
totalPages: number
|
||||
isFirst: boolean
|
||||
isLast: boolean
|
||||
}
|
||||
|
|
|
@ -2,6 +2,8 @@
|
|||
import { ref, type Ref } from 'vue';
|
||||
import AppButton from './AppButton.vue';
|
||||
|
||||
defineProps<{ id?: string }>()
|
||||
|
||||
const dialog: Ref<HTMLDialogElement | null> = ref(null)
|
||||
|
||||
function show(): Promise<string | undefined> {
|
||||
|
@ -21,13 +23,13 @@ defineExpose({ show, close })
|
|||
</script>
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<dialog ref="dialog" class="app-modal-dialog">
|
||||
<dialog ref="dialog" class="app-modal-dialog" :id="id">
|
||||
|
||||
<slot></slot>
|
||||
|
||||
<div class="app-modal-dialog-actions">
|
||||
<slot name="buttons">
|
||||
<AppButton style="secondary" @click="close('close')">Close</AppButton>
|
||||
<AppButton style="secondary" @click="close()">Close</AppButton>
|
||||
</slot>
|
||||
</div>
|
||||
</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">
|
||||
import { AuthApiClient } from '@/api/auth';
|
||||
import { ApiError } from '@/api/base';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { showAlert } from '@/util/alert';
|
||||
import { ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
|
@ -24,7 +26,11 @@ async function doLogin() {
|
|||
await router.replace('/')
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(err)
|
||||
if (err instanceof ApiError) {
|
||||
await showAlert(err.message)
|
||||
} else {
|
||||
await showAlert('Request failed: ' + JSON.stringify(err))
|
||||
}
|
||||
} finally {
|
||||
disableForm.value = false
|
||||
}
|
||||
|
|
|
@ -1,21 +1,26 @@
|
|||
<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 PaginationControls from '@/components/PaginationControls.vue';
|
||||
import { useProfileStore } from '@/stores/profile-store';
|
||||
import { onMounted, ref, type Ref } from 'vue';
|
||||
|
||||
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 () => {
|
||||
await fetchPage(transactions.value.pageRequest)
|
||||
})
|
||||
|
||||
async function fetchPage(pageRequest: PageRequest) {
|
||||
if (!profileStore.state) return
|
||||
const api = new TransactionApiClient(profileStore.state)
|
||||
try {
|
||||
transactions.value = await api.getTransactions(transactions.value.pageRequest)
|
||||
transactions.value = await api.getTransactions(pageRequest)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="app-module">
|
||||
|
@ -40,5 +45,6 @@ onMounted(async () => {
|
|||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<PaginationControls :page="transactions" @update="pr => fetchPage(pr)" />
|
||||
</div>
|
||||
</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