feat(frontend): create sortable data-table component

This commit is contained in:
Julian Lobbes 2023-05-23 17:06:04 +02:00
parent e4061b1654
commit 3db3be1d06
14 changed files with 530 additions and 276 deletions

View file

@ -60,6 +60,7 @@ def read_todos_for_user(
raise NotFoundException(f"User with id '{user_id}' not found.")
db_todos = db.query(todomodel.TodoItem).filter(todomodel.TodoItem.user_id == user_id).order_by(sortorder.call(sortby.field)).offset(skip).limit(limit).all()
return [todoschema.TodoItem.from_orm(db_todo) for db_todo in db_todos]

View file

@ -48,7 +48,7 @@ def read_todos(
db: Session = Depends(get_db)
):
try:
return todocrud.read_todos_for_user(db=db, user_id=user_id, skip=skip, limit=limit)
return todocrud.read_todos_for_user(db=db, user_id=user_id, skip=skip, limit=limit, sortby=sortby, sortorder=sortorder)
except InvalidFilterParameterException as e:
raise HTTPException(400, fmt(str(e)))
except NotFoundException as e:

View file

@ -1971,11 +1971,12 @@
}
},
"node_modules/@sveltejs/vite-plugin-svelte": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-2.2.0.tgz",
"integrity": "sha512-KDtdva+FZrZlyug15KlbXuubntAPKcBau0K7QhAIqC5SAy0uDbjZwoexDRx0L0J2T4niEfC6FnA9GuQQJKg+Aw==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-2.3.0.tgz",
"integrity": "sha512-NbgDn5/auWfGYFip7DheDj49/JLE6VugdtdLJjnQASYxXqrQjl81xaZzQsoSAxWk+j2mOkmPFy56gV2i63FUnA==",
"dev": true,
"dependencies": {
"@sveltejs/vite-plugin-svelte-inspector": "^1.0.1",
"debug": "^4.3.4",
"deepmerge": "^4.3.1",
"kleur": "^4.1.5",
@ -1991,6 +1992,23 @@
"vite": "^4.0.0"
}
},
"node_modules/@sveltejs/vite-plugin-svelte-inspector": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-1.0.1.tgz",
"integrity": "sha512-8ZXgDbAL1b2o7WHxnPsbkxTzZiZhMwOsCI/GFti3zFlh8unqJtUsgwRQV/XSULFcqkbZXz5v6MqMLSUpl3VKaA==",
"dev": true,
"dependencies": {
"debug": "^4.3.4"
},
"engines": {
"node": "^14.18.0 || >= 16"
},
"peerDependencies": {
"@sveltejs/vite-plugin-svelte": "^2.2.0",
"svelte": "^3.54.0",
"vite": "^4.0.0"
}
},
"node_modules/@sveltejs/vite-plugin-svelte/node_modules/magic-string": {
"version": "0.30.0",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.0.tgz",
@ -2201,9 +2219,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001488",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001488.tgz",
"integrity": "sha512-NORIQuuL4xGpIy6iCCQGN4iFjlBXtfKWIenlUuyZJumLRIindLb7wXM+GO8erEhb7vXfcnf4BAg2PrSDN5TNLQ==",
"version": "1.0.30001489",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001489.tgz",
"integrity": "sha512-x1mgZEXK8jHIfAxm+xgdpHpk50IN3z3q3zP261/WS+uvePxW8izXuCu6AHz0lkuYTlATDehiZ/tNyYBdSQsOUQ==",
"dev": true,
"funding": [
{
@ -2331,9 +2349,9 @@
}
},
"node_modules/devalue": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-4.3.1.tgz",
"integrity": "sha512-Kc0TSP9IUU9eg55au5Q3YtqaYI2cgntVpunJV9Exbm9nvlBeTE5p2NqYHfpuXK6+VF2hF5PI+BPFPUti7e2N1g==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-4.3.2.tgz",
"integrity": "sha512-KqFl6pOgOW+Y6wJgu80rHpo2/3H07vr8ntR9rkkFIRETewbf5GaYYcakYfiKz89K+sLsuPkQIZaXDMjUObZwWg==",
"dev": true
},
"node_modules/didyoumean": {
@ -2357,9 +2375,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.4.402",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.402.tgz",
"integrity": "sha512-gWYvJSkohOiBE6ecVYXkrDgNaUjo47QEKK0kQzmWyhkH+yoYiG44bwuicTGNSIQRG3WDMsWVZJLRnJnLNkbWvA==",
"version": "1.4.405",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.405.tgz",
"integrity": "sha512-JdDgnwU69FMZURoesf9gNOej2Cms1XJFfLk24y1IBtnAdhTcJY/mXnokmpmxHN59PcykBP4bgUU98vLY44Lhuw==",
"dev": true
},
"node_modules/es6-promise": {
@ -3265,9 +3283,9 @@
}
},
"node_modules/rollup": {
"version": "3.22.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-3.22.0.tgz",
"integrity": "sha512-imsigcWor5Y/dC0rz2q0bBt9PabcL3TORry2hAa6O6BuMvY71bqHyfReAz5qyAqiQATD1m70qdntqBfBQjVWpQ==",
"version": "3.23.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-3.23.0.tgz",
"integrity": "sha512-h31UlwEi7FHihLe1zbk+3Q7z1k/84rb9BSwmBSr/XjOCEaBJ2YyedQDuM0t/kfOS0IxM+vk1/zI9XxYj9V+NJQ==",
"dev": true,
"bin": {
"rollup": "dist/bin/rollup"
@ -3910,12 +3928,13 @@
"dev": true
},
"node_modules/yaml": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.2.2.tgz",
"integrity": "sha512-CBKFWExMn46Foo4cldiChEzn7S7SRV+wqiluAb6xmueD/fGyRHIhX8m14vVGgeFWjN540nKCNVj6P21eQjgTuA==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.0.tgz",
"integrity": "sha512-8/1wgzdKc7bc9E6my5wZjmdavHLvO/QOmLG1FBugblEvY4IXrLjlViIOmL24HthU042lWTDRO90Fz1Yp66UnMw==",
"dev": true,
"engines": {
"node": ">= 14"
"node": ">= 14",
"npm": ">= 7"
}
}
},
@ -5586,11 +5605,12 @@
}
},
"@sveltejs/vite-plugin-svelte": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-2.2.0.tgz",
"integrity": "sha512-KDtdva+FZrZlyug15KlbXuubntAPKcBau0K7QhAIqC5SAy0uDbjZwoexDRx0L0J2T4niEfC6FnA9GuQQJKg+Aw==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-2.3.0.tgz",
"integrity": "sha512-NbgDn5/auWfGYFip7DheDj49/JLE6VugdtdLJjnQASYxXqrQjl81xaZzQsoSAxWk+j2mOkmPFy56gV2i63FUnA==",
"dev": true,
"requires": {
"@sveltejs/vite-plugin-svelte-inspector": "^1.0.1",
"debug": "^4.3.4",
"deepmerge": "^4.3.1",
"kleur": "^4.1.5",
@ -5610,6 +5630,15 @@
}
}
},
"@sveltejs/vite-plugin-svelte-inspector": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-1.0.1.tgz",
"integrity": "sha512-8ZXgDbAL1b2o7WHxnPsbkxTzZiZhMwOsCI/GFti3zFlh8unqJtUsgwRQV/XSULFcqkbZXz5v6MqMLSUpl3VKaA==",
"dev": true,
"requires": {
"debug": "^4.3.4"
}
},
"@types/cookie": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.5.1.tgz",
@ -5746,9 +5775,9 @@
"dev": true
},
"caniuse-lite": {
"version": "1.0.30001488",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001488.tgz",
"integrity": "sha512-NORIQuuL4xGpIy6iCCQGN4iFjlBXtfKWIenlUuyZJumLRIindLb7wXM+GO8erEhb7vXfcnf4BAg2PrSDN5TNLQ==",
"version": "1.0.30001489",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001489.tgz",
"integrity": "sha512-x1mgZEXK8jHIfAxm+xgdpHpk50IN3z3q3zP261/WS+uvePxW8izXuCu6AHz0lkuYTlATDehiZ/tNyYBdSQsOUQ==",
"dev": true
},
"chokidar": {
@ -5825,9 +5854,9 @@
"dev": true
},
"devalue": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-4.3.1.tgz",
"integrity": "sha512-Kc0TSP9IUU9eg55au5Q3YtqaYI2cgntVpunJV9Exbm9nvlBeTE5p2NqYHfpuXK6+VF2hF5PI+BPFPUti7e2N1g==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-4.3.2.tgz",
"integrity": "sha512-KqFl6pOgOW+Y6wJgu80rHpo2/3H07vr8ntR9rkkFIRETewbf5GaYYcakYfiKz89K+sLsuPkQIZaXDMjUObZwWg==",
"dev": true
},
"didyoumean": {
@ -5848,9 +5877,9 @@
"integrity": "sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ=="
},
"electron-to-chromium": {
"version": "1.4.402",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.402.tgz",
"integrity": "sha512-gWYvJSkohOiBE6ecVYXkrDgNaUjo47QEKK0kQzmWyhkH+yoYiG44bwuicTGNSIQRG3WDMsWVZJLRnJnLNkbWvA==",
"version": "1.4.405",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.405.tgz",
"integrity": "sha512-JdDgnwU69FMZURoesf9gNOej2Cms1XJFfLk24y1IBtnAdhTcJY/mXnokmpmxHN59PcykBP4bgUU98vLY44Lhuw==",
"dev": true
},
"es6-promise": {
@ -6498,9 +6527,9 @@
}
},
"rollup": {
"version": "3.22.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-3.22.0.tgz",
"integrity": "sha512-imsigcWor5Y/dC0rz2q0bBt9PabcL3TORry2hAa6O6BuMvY71bqHyfReAz5qyAqiQATD1m70qdntqBfBQjVWpQ==",
"version": "3.23.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-3.23.0.tgz",
"integrity": "sha512-h31UlwEi7FHihLe1zbk+3Q7z1k/84rb9BSwmBSr/XjOCEaBJ2YyedQDuM0t/kfOS0IxM+vk1/zI9XxYj9V+NJQ==",
"dev": true,
"requires": {
"fsevents": "~2.3.2"
@ -6918,9 +6947,9 @@
"dev": true
},
"yaml": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.2.2.tgz",
"integrity": "sha512-CBKFWExMn46Foo4cldiChEzn7S7SRV+wqiluAb6xmueD/fGyRHIhX8m14vVGgeFWjN540nKCNVj6P21eQjgTuA==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.0.tgz",
"integrity": "sha512-8/1wgzdKc7bc9E6my5wZjmdavHLvO/QOmLG1FBugblEvY4IXrLjlViIOmL24HthU042lWTDRO90Fz1Yp66UnMw==",
"dev": true
}
}

View file

@ -0,0 +1,270 @@
<script lang='ts'>
import { onMount } from 'svelte';
import { Icon, ChevronRight, ChevronLeft, ChevronDoubleRight, ChevronDoubleLeft } from 'svelte-hero-icons';
import Th from '$lib/data-table/Th.svelte';
import Td from '$lib/data-table/Td.svelte';
export let caption: string | null = null;
export let columns: Column[];
export let itemCountEndpoint: string;
export let itemsEndpoint: string;
type Column = {
field: string,
heading: string,
type?: string,
sortable?: boolean,
};
type TotalItemCountApiResponse = {
total: number
};
// API error message bodies are expected to be in one of the following formats:
interface ApiErrorMessage {
detail: string
};
interface ApiErrorList {
detail: { msg: string; loc?: string[]; type?: string }[]
};
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 getTotalItemCount(): Promise<number> {
const response = await fetch(itemCountEndpoint);
const json = await response.json();
if (!response.ok) {
// Try to parse error body as a known format
if ('detail' in json) {
if (typeof json.detail === 'string') {
const errorBody = json as ApiErrorMessage;
errorMessages.push(errorBody.detail);
} else {
const errorBody = json as ApiErrorList;
for (let detail of errorBody.detail) {
if (typeof detail.msg === 'string') errorMessages.push(detail.msg);
}
}
}
throw new Error(`API request failed with status ${response.status}: ${response.statusText}`);
}
const apiResponse = json as TotalItemCountApiResponse;
return apiResponse.total;
}
async function getItems<T>(offset: number = currentItemsOffset, count: number = currentItemsPerPage): Promise<T[]> {
const urlParameters = new URLSearchParams({
skip: `${offset}`,
limit: `${count}`,
sortby: `${currentSortField}`,
sortorder: `${currentSortOrder}`,
});
const response = await fetch(`${itemsEndpoint}?${urlParameters}`);
const json = await response.json();
if (!response.ok) {
// Try to parse error body as a known format
if ('detail' in json) {
if (typeof json.detail === 'string') {
const errorBody = json as ApiErrorMessage;
errorMessages.push(errorBody.detail);
} else {
const errorBody = json as ApiErrorList;
for (let detail of errorBody.detail) {
if (typeof detail.msg === 'string') errorMessages.push(detail.msg);
}
}
}
throw new Error(`API request failed with status ${response.status}: ${response.statusText}`);
}
const apiResponse = json as T[];
return apiResponse;
}
async function updateTable() {
try {
currentState = 'loading';
totalItemCount = await getTotalItemCount();
currentItems = await getItems();
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'}
/>
{/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>

View file

@ -0,0 +1,24 @@
<script lang="ts">
import { Icon, Check, XMark } from 'svelte-hero-icons';
export let data: any;
export let type: 'string' | 'date' | 'boolean' = 'string';
</script>
<td>
{#if type === 'date'}
{#if data !== null && data !== undefined}
{new Date(data).toLocaleDateString()}
{:else}
<!-- date undefined -->
{/if}
{:else if type === 'boolean'}
{#if data}
<Icon src={Check} mini class="w-6 h-6" />
{:else}
<Icon src={XMark} mini class="w-6 h-6" />
{/if}
{:else}
{data}
{/if}
</td>

View file

@ -0,0 +1,39 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { Icon, ArrowSmallDown, ArrowSmallUp } from 'svelte-hero-icons';
export let field: string;
export let heading: any = field;
export let order: null | 'asc' | 'desc' = null;
export let sortable: boolean = true;
function getNextSortOrder(): string | null {
if (order === null) {
return 'asc';
} else if (order === 'asc') {
return 'desc';
} else {
return null;
}
};
const eventDispatcher = createEventDispatcher();
function triggerSort(): void {
if (!sortable) return;
eventDispatcher('sortEvent', {
order: getNextSortOrder(),
field: field,
});
}
</script>
<th on:click={triggerSort} class="select-none" class:hover:cursor-pointer={sortable}>
{heading ? heading : ''}
{#if order === 'asc' && sortable}
<Icon src={ArrowSmallDown} mini class="w-6 h-6 inline" />
{:else if order === 'desc' && sortable}
<Icon src={ArrowSmallUp} mini class="w-6 h-6 inline" />
{/if}
</th>

View file

@ -0,0 +1,7 @@
/**
* Generates an array containing each number from 0 up to and excluding n in sequence.
* Works like Python's range() function when using a single argument.
*/
export function range(n: number): number[] {
return Array.from(Array(n).keys());
}

View file

@ -21,7 +21,7 @@
<meta name="theme-color" content="#ffffff">
</svelte:head>
<script>
<script lang="ts">
import "../app.css";
import { page } from '$app/stores';

View file

@ -1,4 +1,4 @@
<script>
<script lang="ts">
console.log(import.meta.env.MODE)
</script>

View file

@ -0,0 +1,5 @@
<script lang="ts">
</script>
<h1>!</h1>

View file

@ -0,0 +1,52 @@
<script lang="ts">
import Table from '$lib/data-table/Table.svelte'
import type { Column } from '$lib/data-table/Table.svelte';
import type { UserPage } from "./+page";
export let data: UserPage;
const cols: Column[] = [
{
field: "id",
heading: "ID",
},
{
field: "title",
heading: "Title",
},
{
field: "description",
heading: "Description",
sortable: false,
},
{
field: "done",
heading: "Done",
type: "boolean",
},
{
field: "created",
heading: "Created",
type: "date",
},
{
field: "updated",
heading: "Updated",
type: "date",
},
{
field: "finished",
heading: "Finished",
type: "date",
},
];
const table = {
caption: `List of TODOs for user ${data.userId}`,
columns: cols,
itemsEndpoint: `/api/todo/user/${data.userId}/`,
itemCountEndpoint: `/api/todo/user/${data.userId}/total/`,
};
</script>
<Table {...table} />

View file

@ -0,0 +1,15 @@
import { error } from '@sveltejs/kit';
import type { PageLoad } from './$types';
export const load: PageLoad = ({ params }) => {
// TODO check if user exists
return {
userId: params.user
}
//throw error(404, 'Not found');
}
export interface UserPage {
userId: number
}

View file

@ -1,227 +1,39 @@
<script lang='ts'>
import { onMount } from 'svelte';
import {
Icon,
ChevronRight, ChevronLeft, ChevronDoubleRight, ChevronDoubleLeft,
} from 'svelte-hero-icons';
<script lang="ts">
import Table from '$lib/data-table/Table.svelte'
type Item = {
id: number;
email: string;
first_name: string;
last_name: string;
created: Date;
updated: Date;
}
const _tableColumnCount = 6;
let currentState: 'finished' | 'loading' | 'error';
let errorMessages: string[] = [];
let currentItems: Item[] = [];
let totalItemCount: number | null;
const itemsPerPageOptions = [10, 25, 50, 100];
let currentItemsPerPage: number;
let currentPage: number;
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();
}
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 () => {
// Initialize vars
currentItems = [];
totalItemCount = null;
currentItemsPerPage = itemsPerPageOptions[0];
currentPage = 0;
currentState = 'finished';
await updateTable();
});
const table = {
caption: "List of users",
columns: [
{
field: "id",
heading: "ID",
},
{
field: "email",
heading: "Email",
},
{
field: "first_name",
heading: "First Name",
},
{
field: "last_name",
heading: "Last Name",
},
{
field: "created",
heading: "Created",
type: 'date',
},
{
field: "updated",
heading: "Updated",
type: 'date',
},
],
itemCountEndpoint: "/api/users/total/",
itemsEndpoint: "/api/users/",
};
</script>
<table>
<caption>List of users</caption>
<thead>
<tr>
<th>ID</th>
<th>Email</th>
<th>First Name</th>
<th>Last Name</th>
<th>Created</th>
<th>Updated</th>
</tr>
</thead>
<tbody>
{#if currentState === 'finished'}
{#each currentItems as user}
<tr>
<td>{user.id}</td>
<td>{user.email}</td>
<td>{user.first_name}</td>
<td>{user.last_name}</td>
<td>{new Date(user.created).toLocaleDateString()}</td>
<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>
<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>
<Table {...table} />