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 {
@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 {
@apply border-collapse;

View File

@ -1,11 +1,7 @@
<script lang='ts'>
import { onMount } from 'svelte';
const errorMessages : String[] = [];
let error = false;
let loading = true;
type User = {
type Item = {
id: number;
email: string;
first_name: string;
@ -13,124 +9,131 @@
created: Date;
updated: Date;
}
let currentItems: User[] = [];
let totalItemCount = 0;
let limit = 10;
let currentPage = 1;
let skip = 0;
$: totalPageCount = Math.ceil(totalItemCount / limit);
const _tableColumnCount = 6;
async function getUsersCount() {
const response = await fetch(`/api/users/total/`);
const responseJson = await response.json();
let currentState: 'finished' | 'loading' | 'error';
let errorMessages: string[] = [];
if (response.ok) {
if (responseJson) {
if (responseJson.hasOwnProperty('total')) {
if (typeof responseJson.total === 'number') {
return responseJson.total;
}
}
}
error = true;
let currentItems: Item[] = [];
let totalItemCount: number | null;
const itemsPerPageOptions = [10, 25, 50, 100];
let currentItemsPerPage: number;
let currentPage: number;
let lastPage: number;
$: if (totalItemCount) {
lastPage = Math.ceil(totalItemCount / currentItemsPerPage) - 1;
} 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);
}
}
}
}
}
}
if (errorMessages.length === 0) errorMessages.push("Error during API call.")
error = true;
}
lastPage = 0;
}
async function getUsers(skip=0, limit=100) {
loading = true;
$: currentItemsOffset = currentPage * currentItemsPerPage;
totalItemCount = await getUsersCount();
if (error) return;
$: gotoPrevPageDisabled = currentState !== 'finished' || currentPage === 0 || lastPage === 0;
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;
skip -= limit;
getUsers(skip, limit);
await updateTable();
}
function handleNextClick() {
$: gotoNextPageDisabled = currentState !== 'finished' || currentPage === lastPage || lastPage === 0;
async function handleGotoNextPage() {
if (gotoNextPageDisabled) return;
currentPage += 1;
skip += limit;
getUsers(skip, limit);
await updateTable();
}
function handleFirstClick() {
currentPage = 1;
skip = 0;
getUsers(skip, limit);
$: gotoFirstPageDisabled = currentState !== 'finished' || currentPage === 0 || lastPage === 0;
async function handleGotoFirstPage() {
if (gotoFirstPageDisabled) return;
currentPage = 0;
await updateTable();
}
function handleLastClick() {
currentPage = totalPageCount;
skip = limit * (totalPageCount - 1);
getUsers(skip, limit);
$: gotoLastPageDisabled = currentState !== 'finished' || currentPage === lastPage || lastPage === 0;
async function handleGotoLastPage() {
if (gotoLastPageDisabled) return;
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 () => {
getUsers(skip, limit);
// Initialize vars
currentItems = [];
totalItemCount = null;
currentItemsPerPage = itemsPerPageOptions[0];
currentPage = 0;
currentState = 'finished';
await updateTable();
});
</script>
<div class="flex flex-col items-center">
<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>
<thead>
@ -144,6 +147,7 @@
</tr>
</thead>
<tbody>
{#if currentState === 'finished'}
{#each currentItems as user}
<tr>
<td>{user.id}</td>
@ -154,25 +158,37 @@
<td>{new Date(user.updated).toLocaleDateString()}</td>
</tr>
{/each}
{:else if currentState === 'loading'}
<tr>
<td colspan={_tableColumnCount}>Loading...</td>
</tr>
{:else if currentState === 'error'}
<tr>
{#each errorMessages as message}
<td colspan={_tableColumnCount} class="text-red-500">{message}</td>
{/each}
</tr>
{/if}
</tbody>
</table>
</div>
<div class="flex items-center">
<select bind:value={limit} on:change={getUsers(skip, limit)}>
<option value="10">
10
</option>
<option value="25">
25
</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}
</div>
<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>