feat(frontend): create sortable data-table component
This commit is contained in:
parent
e4061b1654
commit
3db3be1d06
14 changed files with 530 additions and 276 deletions
|
@ -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]
|
||||
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
103
frontend/package-lock.json
generated
103
frontend/package-lock.json
generated
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
270
frontend/src/lib/data-table/Table.svelte
Normal file
270
frontend/src/lib/data-table/Table.svelte
Normal 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>
|
24
frontend/src/lib/data-table/Td.svelte
Normal file
24
frontend/src/lib/data-table/Td.svelte
Normal 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>
|
39
frontend/src/lib/data-table/Th.svelte
Normal file
39
frontend/src/lib/data-table/Th.svelte
Normal 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>
|
7
frontend/src/lib/utils/utils.ts
Normal file
7
frontend/src/lib/utils/utils.ts
Normal 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());
|
||||
}
|
|
@ -21,7 +21,7 @@
|
|||
<meta name="theme-color" content="#ffffff">
|
||||
</svelte:head>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import "../app.css";
|
||||
import { page } from '$app/stores';
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<script>
|
||||
<script lang="ts">
|
||||
console.log(import.meta.env.MODE)
|
||||
</script>
|
||||
|
||||
|
|
5
frontend/src/routes/[user]/+page.svelte
Normal file
5
frontend/src/routes/[user]/+page.svelte
Normal file
|
@ -0,0 +1,5 @@
|
|||
<script lang="ts">
|
||||
|
||||
</script>
|
||||
|
||||
<h1>!</h1>
|
52
frontend/src/routes/[user]/todos/+page.svelte
Normal file
52
frontend/src/routes/[user]/todos/+page.svelte
Normal 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} />
|
15
frontend/src/routes/[user]/todos/+page.ts
Normal file
15
frontend/src/routes/[user]/todos/+page.ts
Normal 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
|
||||
}
|
|
@ -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} />
|
||||
|
|
Loading…
Add table
Reference in a new issue