feat(frontend): user list pagination

This commit is contained in:
Julian Lobbes 2023-05-22 03:12:10 +02:00
parent 335a97740d
commit b6bb849e2c
2 changed files with 167 additions and 146 deletions

View file

@ -51,6 +51,11 @@
button:focus { button:focus {
@apply ring-1 ring-neutral-400 ring-offset-1; @apply ring-1 ring-neutral-400 ring-offset-1;
} }
button:disabled {
@apply opacity-25;
@apply bg-neutral-100;
@apply border-2 border-neutral-200;
}
table { table {
@apply border-collapse; @apply border-collapse;

View file

@ -1,11 +1,7 @@
<script lang='ts'> <script lang='ts'>
import { onMount } from 'svelte'; import { onMount } from 'svelte';
const errorMessages : String[] = []; type Item = {
let error = false;
let loading = true;
type User = {
id: number; id: number;
email: string; email: string;
first_name: string; first_name: string;
@ -13,125 +9,132 @@
created: Date; created: Date;
updated: Date; updated: Date;
} }
let currentItems: User[] = []; const _tableColumnCount = 6;
let totalItemCount = 0;
let limit = 10;
let currentPage = 1;
let skip = 0;
$: totalPageCount = Math.ceil(totalItemCount / limit);
async function getUsersCount() { let currentState: 'finished' | 'loading' | 'error';
const response = await fetch(`/api/users/total/`); let errorMessages: string[] = [];
const responseJson = await response.json();
if (response.ok) { let currentItems: Item[] = [];
if (responseJson) { let totalItemCount: number | null;
if (responseJson.hasOwnProperty('total')) {
if (typeof responseJson.total === 'number') { const itemsPerPageOptions = [10, 25, 50, 100];
return responseJson.total; let currentItemsPerPage: number;
}
} let currentPage: number;
} let lastPage: number;
error = true; $: if (totalItemCount) {
lastPage = Math.ceil(totalItemCount / currentItemsPerPage) - 1;
} else { } else {
// Try to parse the error messages lastPage = 0;
if (responseJson) {
if (responseJson.hasOwnProperty('detail')) {
if (Array.isArray(responseJson.detail)) {
for (let errorObj of responseJson.detail) {
if (errorObj.hasOwnProperty('msg')) {
if (typeof errorObj.msg === 'string') {
errorMessages.push(errorObj.msg);
}
}
}
}
}
}
if (errorMessages.length === 0) errorMessages.push("Error during API call.")
error = true;
}
} }
async function getUsers(skip=0, limit=100) { $: currentItemsOffset = currentPage * currentItemsPerPage;
loading = true;
totalItemCount = await getUsersCount(); $: gotoPrevPageDisabled = currentState !== 'finished' || currentPage === 0 || lastPage === 0;
if (error) return; async function handleGotoPrevPage() {
if (gotoPrevPageDisabled) return;
const response = await fetch(`/api/users/?skip=${skip}&limit=${limit}`);
const responseJson = await response.json();
if (response.ok) {
currentItems = responseJson;
loading = false;
return responseJson;
} else {
// Try to parse the error messages
if (responseJson) {
if (responseJson.hasOwnProperty('detail')) {
if (Array.isArray(responseJson.detail)) {
for (let errorObj of responseJson.detail) {
if (errorObj.hasOwnProperty('msg')) {
if (typeof errorObj.msg === 'string') {
errorMessages.push(errorObj.msg);
}
}
}
}
}
}
loading = false;
error = true;
}
}
function handlePrevClick() {
currentPage -= 1; currentPage -= 1;
skip -= limit; await updateTable();
getUsers(skip, limit);
} }
function handleNextClick() { $: gotoNextPageDisabled = currentState !== 'finished' || currentPage === lastPage || lastPage === 0;
async function handleGotoNextPage() {
if (gotoNextPageDisabled) return;
currentPage += 1; currentPage += 1;
skip += limit; await updateTable();
getUsers(skip, limit);
} }
function handleFirstClick() { $: gotoFirstPageDisabled = currentState !== 'finished' || currentPage === 0 || lastPage === 0;
currentPage = 1; async function handleGotoFirstPage() {
skip = 0; if (gotoFirstPageDisabled) return;
getUsers(skip, limit);
currentPage = 0;
await updateTable();
} }
function handleLastClick() { $: gotoLastPageDisabled = currentState !== 'finished' || currentPage === lastPage || lastPage === 0;
currentPage = totalPageCount; async function handleGotoLastPage() {
skip = limit * (totalPageCount - 1); if (gotoLastPageDisabled) return;
getUsers(skip, limit);
currentPage = lastPage;
await updateTable();
}
interface ApiError {
// FIXME error message parsing doesn't work right now
detail: { msg: string; loc?: string[]; type?: string }[]
}
async function getTotalItemCount(): Promise<number> {
interface ApiResponse {
total: number
}
const response = await fetch(`/api/users/total/`);
const json = await response.json();
if (!response.ok) {
// Capture error messages from response body
const apiError = json as ApiError;
for (let detail of apiError.detail) {
errorMessages.push(detail.msg);
}
throw new Error(`API request failed with status ${response.status}: ${response.statusText}`);
}
const apiResponse = json as ApiResponse;
return apiResponse.total;
}
async function getItems(offset: number = currentItemsOffset, count: number = currentItemsPerPage): Promise<Item[]> {
const urlParameters = new URLSearchParams({
skip: `${offset}`,
limit: `${count}`,
});
const response = await fetch(`/api/users/?${urlParameters}`);
const json = await response.json();
if (!response.ok) {
// Capture error messages from response body
const apiError = json as ApiError;
for (let detail of apiError.detail) {
errorMessages.push(detail.msg);
}
throw new Error(`API request failed with status ${response.status}: ${response.statusText}`);
}
const apiResponse = json as Item[];
return apiResponse;
}
async function updateTable() {
try {
currentState = 'loading';
totalItemCount = await getTotalItemCount();
currentItems = await getItems();
currentState = 'finished';
} catch (error) {
console.log(error);
currentState = 'error';
}
} }
onMount(async () => { onMount(async () => {
getUsers(skip, limit); // Initialize vars
currentItems = [];
totalItemCount = null;
currentItemsPerPage = itemsPerPageOptions[0];
currentPage = 0;
currentState = 'finished';
await updateTable();
}); });
</script> </script>
<div class="flex flex-col items-center"> <table>
<h1 class="text-center">Users</h1>
{#if loading}
<div class="min-h-screen flex flex-col items-center justify-center">
<p>Loading...</p>
</div>
{:else if error}
{#if errorMessages.length}
{#each errorMessages as errorMessage}
<p style="color: red">{errorMessage}</p>
{/each}
{:else}
<p style="color: red">An unknown error occurred.</p>
{/if}
{:else}
<div>
<table>
<caption>List of users</caption> <caption>List of users</caption>
<thead> <thead>
<tr> <tr>
@ -144,6 +147,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#if currentState === 'finished'}
{#each currentItems as user} {#each currentItems as user}
<tr> <tr>
<td>{user.id}</td> <td>{user.id}</td>
@ -154,25 +158,37 @@
<td>{new Date(user.updated).toLocaleDateString()}</td> <td>{new Date(user.updated).toLocaleDateString()}</td>
</tr> </tr>
{/each} {/each}
</tbody> {:else if currentState === 'loading'}
</table> <tr>
</div> <td colspan={_tableColumnCount}>Loading...</td>
<div class="flex items-center"> </tr>
<select bind:value={limit} on:change={getUsers(skip, limit)}> {:else if currentState === 'error'}
<option value="10"> <tr>
10 {#each errorMessages as message}
</option> <td colspan={_tableColumnCount} class="text-red-500">{message}</td>
<option value="25"> {/each}
25 </tr>
</option>
<option value="50">
50
</option>
</select>
<button on:click={handleFirstClick} disabled={currentPage === 1} class="disabled:opacity-10">first</button>
<button on:click={handlePrevClick} disabled={currentPage === 1} class="disabled:opacity-10">prev</button>
<button on:click={handleNextClick} disabled={currentPage === totalPageCount} class="disabled:opacity-10">next</button>
<button on:click={handleLastClick} disabled={currentPage === totalPageCount} class="disabled:opacity-10">last</button>
</div>
{/if} {/if}
</div> </tbody>
</table>
<button on:click={handleGotoFirstPage} disabled={gotoFirstPageDisabled}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
<path fill-rule="evenodd" d="M15.79 14.77a.75.75 0 01-1.06.02l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 111.04 1.08L11.832 10l3.938 3.71a.75.75 0 01.02 1.06zm-6 0a.75.75 0 01-1.06.02l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 111.04 1.08L5.832 10l3.938 3.71a.75.75 0 01.02 1.06z" clip-rule="evenodd" />
</svg>
</button>
<button on:click={handleGotoPrevPage} disabled={gotoPrevPageDisabled}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
<path fill-rule="evenodd" d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" clip-rule="evenodd" />
</svg>
</button>
<button on:click={handleGotoNextPage} disabled={gotoNextPageDisabled}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
<path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clip-rule="evenodd" />
</svg>
</button>
<button on:click={handleGotoLastPage} disabled={gotoLastPageDisabled}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
<path fill-rule="evenodd" d="M10.21 14.77a.75.75 0 01.02-1.06L14.168 10 10.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clip-rule="evenodd" />
<path fill-rule="evenodd" d="M4.21 14.77a.75.75 0 01.02-1.06L8.168 10 4.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clip-rule="evenodd" />
</svg>
</button>