fastapi-svelte-template/frontend/src/lib/components/data-table/Table.svelte

227 lines
6.9 KiB
Svelte

<script lang='ts'>
import { onMount } from 'svelte';
import { Icon, ChevronRight, ChevronLeft, ChevronDoubleRight, ChevronDoubleLeft } from 'svelte-hero-icons';
import Th from '$lib/components/data-table/Th.svelte';
import Td from '$lib/components/data-table/Td.svelte';
import type { Column, Endpoint } from '$lib/components/data-table/types';
import { interpolateString as i } from '$lib/components/data-table/utils';
/**
* The caption for the data table.
*/
export let caption: string | null = null;
/**
* The columns for the data table.
*/
export let columns: Column[];
/**
* The function which fetches rows for the data table.
* Must support pagination and sorting.
*/
export let getItemsEndpoint: Endpoint = {
callable: () => [],
args: [],
}
/**
* The function which fetches the total number of items. Used for pagination.
*/
export let getItemCountEndpoint: Endpoint = {
callable: () => 0,
args: [],
}
type SortEvent = {
detail: {
order: null | 'asc' | 'desc',
field: string,
}
}
let currentSortField: string = columns[0].field;
let currentSortOrder: 'asc' | 'desc' = 'asc';
let currentState: 'finished' | 'loading' | 'error' = 'finished';
let errorMessages: string[] = [];
let currentItems: any[] = []; // TODO apply generics here
let totalItemCount: number | null = null;
const itemsPerPageOptions = [10, 25, 50, 100];
let currentItemsPerPage: number = itemsPerPageOptions[0];
let currentPage: number = 0;
let lastPage: number;
$: lastPage = totalItemCount === null ? 0 : Math.floor(totalItemCount / currentItemsPerPage);
$: currentItemsOffset = currentPage * currentItemsPerPage;
// The <select> field value is not bound directly to 'currentItemsPerPage', because we need to know
// its previous value to stay on the right page.
let selectedItemsPerPage: number;
async function handleItemsPerPageChange() {
currentPage = Math.floor(currentItemsOffset / selectedItemsPerPage);
currentItemsPerPage = selectedItemsPerPage;
await updateTable();
}
function handleCurrentPageInputFocussed(this: HTMLInputElement) {
this.select();
}
$: selectedCurrentPage = typeof currentPage === 'number' ? currentPage + 1 : 1;
async function handleCurrentPageInputChanged() {
if (selectedCurrentPage === currentPage + 1) {
return;
} else if (selectedCurrentPage < 1) {
// TODO show error message
console.warn(`Selected page number ${selectedCurrentPage} is smaller than 1.`);
selectedCurrentPage = currentPage + 1;
return;
} else if (selectedCurrentPage > lastPage + 1) {
// TODO show error message
console.warn(`Selected page number ${selectedCurrentPage} is greater than ${lastPage + 1}.`);
selectedCurrentPage = currentPage + 1;
return;
}
currentPage = selectedCurrentPage - 1;
await updateTable();
}
$: gotoPrevPageDisabled = currentState !== 'finished' || currentPage === 0 || lastPage === 0;
async function handleGotoPrevPage() {
if (gotoPrevPageDisabled) return;
currentPage -= 1;
await updateTable();
}
$: gotoNextPageDisabled = currentState !== 'finished' || currentPage === lastPage || lastPage === 0;
async function handleGotoNextPage() {
if (gotoNextPageDisabled) return;
currentPage += 1;
await updateTable();
}
$: gotoFirstPageDisabled = currentState !== 'finished' || currentPage === 0 || lastPage === 0;
async function handleGotoFirstPage() {
if (gotoFirstPageDisabled) return;
currentPage = 0;
await updateTable();
}
$: gotoLastPageDisabled = currentState !== 'finished' || currentPage === lastPage || lastPage === 0;
async function handleGotoLastPage() {
if (gotoLastPageDisabled) return;
currentPage = lastPage;
await updateTable();
}
async function updateTable() {
try {
currentState = 'loading';
totalItemCount = await getItemCountEndpoint.callable(
...getItemCountEndpoint.args,
);
currentItems = await getItemsEndpoint.callable(
...getItemsEndpoint.args,
currentItemsOffset,
currentItemsPerPage,
currentSortField,
currentSortOrder
);
currentState = 'finished';
} catch (error: any) {
if (!errorMessages.length) {
// Make sure we have an error message to show to the user
if (typeof error.message === 'string') {
errorMessages.push(error.message);
} else {
errorMessages.push("An unknown error occurred.");
}
}
currentState = 'error';
}
}
async function handleSortEvent(sortEvent: SortEvent) {
currentSortField = sortEvent.detail.field;
currentSortOrder = sortEvent.detail.order === null ? 'asc' : sortEvent.detail.order;
currentPage = 0;
await updateTable();
}
onMount(async () => {
await updateTable();
});
</script>
<table>
{#if caption !== null}
<caption>{caption}</caption>
{/if}
<thead>
<tr>
{#each columns as column}
<Th
on:sortEvent={handleSortEvent}
field={column.field}
heading={column.heading}
order={currentSortField === column.field ? currentSortOrder : null}
sortable={column.sortable ?? true}
/>
{/each}
</tr>
</thead>
<tbody>
{#if currentState === 'finished'}
{#each currentItems as item}
<tr>
{#each columns as column}
<Td
data={item[column.field]}
type={column.type ?? 'string'}
isLink={column.isLink ?? false}
linkTarget={i(column.linkTarget, item) ?? ''}
/>
{/each}
</tr>
{/each}
{:else if currentState === 'loading'}
<tr>
<td colspan={columns.length}>Loading...</td>
</tr>
{:else if currentState === 'error'}
{#each errorMessages as message}
<tr>
<td colspan={columns.length} class="text-center text-red-500">{message}</td>
</tr>
{/each}
{/if}
</tbody>
</table>
<button on:click={handleGotoFirstPage} disabled={gotoFirstPageDisabled}>
<Icon src={ChevronDoubleLeft} mini class="w-6 h-6" />
</button>
<button on:click={handleGotoPrevPage} disabled={gotoPrevPageDisabled}>
<Icon src={ChevronLeft} mini class="w-6 h-6" />
</button>
<input
type=number bind:value={selectedCurrentPage} min=0 max={lastPage} disabled={lastPage === 0}
on:change={handleCurrentPageInputChanged} on:focus={handleCurrentPageInputFocussed}
>
<span>of {lastPage + 1}</span>
<button on:click={handleGotoNextPage} disabled={gotoNextPageDisabled}>
<Icon src={ChevronRight} mini class="w-6 h-6" />
</button>
<button on:click={handleGotoLastPage} disabled={gotoLastPageDisabled}>
<Icon src={ChevronDoubleRight} mini class="w-6 h-6" />
</button>
<select bind:value={selectedItemsPerPage} on:change={handleItemsPerPageChange}>
{#each itemsPerPageOptions as option}
<option selected={option === currentItemsPerPage} value={option}>{option}</option>
{/each}
</select>