227 lines
6.9 KiB
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>
|