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.")
|
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()
|
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]
|
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)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
try:
|
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:
|
except InvalidFilterParameterException as e:
|
||||||
raise HTTPException(400, fmt(str(e)))
|
raise HTTPException(400, fmt(str(e)))
|
||||||
except NotFoundException as 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": {
|
"node_modules/@sveltejs/vite-plugin-svelte": {
|
||||||
"version": "2.2.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-2.3.0.tgz",
|
||||||
"integrity": "sha512-KDtdva+FZrZlyug15KlbXuubntAPKcBau0K7QhAIqC5SAy0uDbjZwoexDRx0L0J2T4niEfC6FnA9GuQQJKg+Aw==",
|
"integrity": "sha512-NbgDn5/auWfGYFip7DheDj49/JLE6VugdtdLJjnQASYxXqrQjl81xaZzQsoSAxWk+j2mOkmPFy56gV2i63FUnA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@sveltejs/vite-plugin-svelte-inspector": "^1.0.1",
|
||||||
"debug": "^4.3.4",
|
"debug": "^4.3.4",
|
||||||
"deepmerge": "^4.3.1",
|
"deepmerge": "^4.3.1",
|
||||||
"kleur": "^4.1.5",
|
"kleur": "^4.1.5",
|
||||||
|
@ -1991,6 +1992,23 @@
|
||||||
"vite": "^4.0.0"
|
"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": {
|
"node_modules/@sveltejs/vite-plugin-svelte/node_modules/magic-string": {
|
||||||
"version": "0.30.0",
|
"version": "0.30.0",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.0.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.0.tgz",
|
||||||
|
@ -2201,9 +2219,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001488",
|
"version": "1.0.30001489",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001488.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001489.tgz",
|
||||||
"integrity": "sha512-NORIQuuL4xGpIy6iCCQGN4iFjlBXtfKWIenlUuyZJumLRIindLb7wXM+GO8erEhb7vXfcnf4BAg2PrSDN5TNLQ==",
|
"integrity": "sha512-x1mgZEXK8jHIfAxm+xgdpHpk50IN3z3q3zP261/WS+uvePxW8izXuCu6AHz0lkuYTlATDehiZ/tNyYBdSQsOUQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
|
@ -2331,9 +2349,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/devalue": {
|
"node_modules/devalue": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/devalue/-/devalue-4.3.2.tgz",
|
||||||
"integrity": "sha512-Kc0TSP9IUU9eg55au5Q3YtqaYI2cgntVpunJV9Exbm9nvlBeTE5p2NqYHfpuXK6+VF2hF5PI+BPFPUti7e2N1g==",
|
"integrity": "sha512-KqFl6pOgOW+Y6wJgu80rHpo2/3H07vr8ntR9rkkFIRETewbf5GaYYcakYfiKz89K+sLsuPkQIZaXDMjUObZwWg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/didyoumean": {
|
"node_modules/didyoumean": {
|
||||||
|
@ -2357,9 +2375,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.4.402",
|
"version": "1.4.405",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.402.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.405.tgz",
|
||||||
"integrity": "sha512-gWYvJSkohOiBE6ecVYXkrDgNaUjo47QEKK0kQzmWyhkH+yoYiG44bwuicTGNSIQRG3WDMsWVZJLRnJnLNkbWvA==",
|
"integrity": "sha512-JdDgnwU69FMZURoesf9gNOej2Cms1XJFfLk24y1IBtnAdhTcJY/mXnokmpmxHN59PcykBP4bgUU98vLY44Lhuw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/es6-promise": {
|
"node_modules/es6-promise": {
|
||||||
|
@ -3265,9 +3283,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "3.22.0",
|
"version": "3.23.0",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-3.22.0.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-3.23.0.tgz",
|
||||||
"integrity": "sha512-imsigcWor5Y/dC0rz2q0bBt9PabcL3TORry2hAa6O6BuMvY71bqHyfReAz5qyAqiQATD1m70qdntqBfBQjVWpQ==",
|
"integrity": "sha512-h31UlwEi7FHihLe1zbk+3Q7z1k/84rb9BSwmBSr/XjOCEaBJ2YyedQDuM0t/kfOS0IxM+vk1/zI9XxYj9V+NJQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"rollup": "dist/bin/rollup"
|
"rollup": "dist/bin/rollup"
|
||||||
|
@ -3910,12 +3928,13 @@
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/yaml": {
|
"node_modules/yaml": {
|
||||||
"version": "2.2.2",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.0.tgz",
|
||||||
"integrity": "sha512-CBKFWExMn46Foo4cldiChEzn7S7SRV+wqiluAb6xmueD/fGyRHIhX8m14vVGgeFWjN540nKCNVj6P21eQjgTuA==",
|
"integrity": "sha512-8/1wgzdKc7bc9E6my5wZjmdavHLvO/QOmLG1FBugblEvY4IXrLjlViIOmL24HthU042lWTDRO90Fz1Yp66UnMw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 14"
|
"node": ">= 14",
|
||||||
|
"npm": ">= 7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -5586,11 +5605,12 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@sveltejs/vite-plugin-svelte": {
|
"@sveltejs/vite-plugin-svelte": {
|
||||||
"version": "2.2.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-2.3.0.tgz",
|
||||||
"integrity": "sha512-KDtdva+FZrZlyug15KlbXuubntAPKcBau0K7QhAIqC5SAy0uDbjZwoexDRx0L0J2T4niEfC6FnA9GuQQJKg+Aw==",
|
"integrity": "sha512-NbgDn5/auWfGYFip7DheDj49/JLE6VugdtdLJjnQASYxXqrQjl81xaZzQsoSAxWk+j2mOkmPFy56gV2i63FUnA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
|
"@sveltejs/vite-plugin-svelte-inspector": "^1.0.1",
|
||||||
"debug": "^4.3.4",
|
"debug": "^4.3.4",
|
||||||
"deepmerge": "^4.3.1",
|
"deepmerge": "^4.3.1",
|
||||||
"kleur": "^4.1.5",
|
"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": {
|
"@types/cookie": {
|
||||||
"version": "0.5.1",
|
"version": "0.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.5.1.tgz",
|
||||||
|
@ -5746,9 +5775,9 @@
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"caniuse-lite": {
|
"caniuse-lite": {
|
||||||
"version": "1.0.30001488",
|
"version": "1.0.30001489",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001488.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001489.tgz",
|
||||||
"integrity": "sha512-NORIQuuL4xGpIy6iCCQGN4iFjlBXtfKWIenlUuyZJumLRIindLb7wXM+GO8erEhb7vXfcnf4BAg2PrSDN5TNLQ==",
|
"integrity": "sha512-x1mgZEXK8jHIfAxm+xgdpHpk50IN3z3q3zP261/WS+uvePxW8izXuCu6AHz0lkuYTlATDehiZ/tNyYBdSQsOUQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"chokidar": {
|
"chokidar": {
|
||||||
|
@ -5825,9 +5854,9 @@
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"devalue": {
|
"devalue": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/devalue/-/devalue-4.3.2.tgz",
|
||||||
"integrity": "sha512-Kc0TSP9IUU9eg55au5Q3YtqaYI2cgntVpunJV9Exbm9nvlBeTE5p2NqYHfpuXK6+VF2hF5PI+BPFPUti7e2N1g==",
|
"integrity": "sha512-KqFl6pOgOW+Y6wJgu80rHpo2/3H07vr8ntR9rkkFIRETewbf5GaYYcakYfiKz89K+sLsuPkQIZaXDMjUObZwWg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"didyoumean": {
|
"didyoumean": {
|
||||||
|
@ -5848,9 +5877,9 @@
|
||||||
"integrity": "sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ=="
|
"integrity": "sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ=="
|
||||||
},
|
},
|
||||||
"electron-to-chromium": {
|
"electron-to-chromium": {
|
||||||
"version": "1.4.402",
|
"version": "1.4.405",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.402.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.405.tgz",
|
||||||
"integrity": "sha512-gWYvJSkohOiBE6ecVYXkrDgNaUjo47QEKK0kQzmWyhkH+yoYiG44bwuicTGNSIQRG3WDMsWVZJLRnJnLNkbWvA==",
|
"integrity": "sha512-JdDgnwU69FMZURoesf9gNOej2Cms1XJFfLk24y1IBtnAdhTcJY/mXnokmpmxHN59PcykBP4bgUU98vLY44Lhuw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"es6-promise": {
|
"es6-promise": {
|
||||||
|
@ -6498,9 +6527,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"rollup": {
|
"rollup": {
|
||||||
"version": "3.22.0",
|
"version": "3.23.0",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-3.22.0.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-3.23.0.tgz",
|
||||||
"integrity": "sha512-imsigcWor5Y/dC0rz2q0bBt9PabcL3TORry2hAa6O6BuMvY71bqHyfReAz5qyAqiQATD1m70qdntqBfBQjVWpQ==",
|
"integrity": "sha512-h31UlwEi7FHihLe1zbk+3Q7z1k/84rb9BSwmBSr/XjOCEaBJ2YyedQDuM0t/kfOS0IxM+vk1/zI9XxYj9V+NJQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
|
@ -6918,9 +6947,9 @@
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"yaml": {
|
"yaml": {
|
||||||
"version": "2.2.2",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.0.tgz",
|
||||||
"integrity": "sha512-CBKFWExMn46Foo4cldiChEzn7S7SRV+wqiluAb6xmueD/fGyRHIhX8m14vVGgeFWjN540nKCNVj6P21eQjgTuA==",
|
"integrity": "sha512-8/1wgzdKc7bc9E6my5wZjmdavHLvO/QOmLG1FBugblEvY4IXrLjlViIOmL24HthU042lWTDRO90Fz1Yp66UnMw==",
|
||||||
"dev": true
|
"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">
|
<meta name="theme-color" content="#ffffff">
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
import "../app.css";
|
import "../app.css";
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
<script>
|
<script lang="ts">
|
||||||
console.log(import.meta.env.MODE)
|
console.log(import.meta.env.MODE)
|
||||||
</script>
|
</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'>
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import Table from '$lib/data-table/Table.svelte'
|
||||||
import {
|
|
||||||
Icon,
|
|
||||||
ChevronRight, ChevronLeft, ChevronDoubleRight, ChevronDoubleLeft,
|
|
||||||
} from 'svelte-hero-icons';
|
|
||||||
|
|
||||||
type Item = {
|
const table = {
|
||||||
id: number;
|
caption: "List of users",
|
||||||
email: string;
|
columns: [
|
||||||
first_name: string;
|
{
|
||||||
last_name: string;
|
field: "id",
|
||||||
created: Date;
|
heading: "ID",
|
||||||
updated: Date;
|
},
|
||||||
}
|
{
|
||||||
const _tableColumnCount = 6;
|
field: "email",
|
||||||
|
heading: "Email",
|
||||||
let currentState: 'finished' | 'loading' | 'error';
|
},
|
||||||
let errorMessages: string[] = [];
|
{
|
||||||
|
field: "first_name",
|
||||||
let currentItems: Item[] = [];
|
heading: "First Name",
|
||||||
let totalItemCount: number | null;
|
},
|
||||||
|
{
|
||||||
const itemsPerPageOptions = [10, 25, 50, 100];
|
field: "last_name",
|
||||||
let currentItemsPerPage: number;
|
heading: "Last Name",
|
||||||
|
},
|
||||||
let currentPage: number;
|
{
|
||||||
let lastPage: number;
|
field: "created",
|
||||||
$: lastPage = totalItemCount === null ? 0 : Math.floor(totalItemCount / currentItemsPerPage);
|
heading: "Created",
|
||||||
$: currentItemsOffset = currentPage * currentItemsPerPage;
|
type: 'date',
|
||||||
|
},
|
||||||
// The <select> field value is not bound directly to 'currentItemsPerPage', because we need to know
|
{
|
||||||
// its previous value to stay on the right page.
|
field: "updated",
|
||||||
let selectedItemsPerPage: number;
|
heading: "Updated",
|
||||||
async function handleItemsPerPageChange() {
|
type: 'date',
|
||||||
currentPage = Math.floor(currentItemsOffset / selectedItemsPerPage);
|
},
|
||||||
currentItemsPerPage = selectedItemsPerPage;
|
],
|
||||||
await updateTable();
|
itemCountEndpoint: "/api/users/total/",
|
||||||
}
|
itemsEndpoint: "/api/users/",
|
||||||
|
};
|
||||||
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();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<table>
|
<Table {...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>
|
|
||||||
|
|
|
@ -2,16 +2,16 @@ import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
envPrefix: 'PUBLIC_',
|
envPrefix: 'PUBLIC_',
|
||||||
plugins: [sveltekit()],
|
plugins: [sveltekit()],
|
||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
port: 3000,
|
port: 3000,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
},
|
},
|
||||||
preview: {
|
preview: {
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
port: 3000,
|
port: 3000,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
Loading…
Add table
Reference in a new issue