feat(frontend): improve Table component, add todo detail route

This commit is contained in:
Julian Lobbes 2023-05-31 23:06:49 +01:00
parent 0fbcd7116b
commit e33501eaa6
14 changed files with 312 additions and 156 deletions

View file

@ -59,6 +59,8 @@ def read_todo(
if not (current_user.is_admin or current_user.id == todo_owner.id):
raise HTTPException(403, "You are not authorized to view this content.")
return todo
@router.get("/user/{user_id}", response_model=list[todoschema.TodoItem])
def read_todos(

View file

@ -1,5 +1,5 @@
import { error } from '@sveltejs/kit';
import type { ItemCount, User } from './types';
import type { ItemCount, TodoItem, User } from './types';
import { getResponseBodyOrError } from '$lib/api/utils';
import { getTokenFromLocalstorage } from '$lib/auth/session';
@ -45,10 +45,22 @@ export async function readUser(userId: number, jwt: string = getTokenOrError()):
* @param {string} jwt - The JWT appended as a bearer token to authorize the request.
* @throws{error} - If the request fails or is not permitted.
*/
export async function readUsers(jwt: string = getTokenOrError()): Promise<User[]> {
// TODO add pagination and sorting query params
// TODO refactor Table.svelte
const endpoint = '/api/admin/users/';
export async function readUsers(
skip: number,
limit: number,
sortby: string,
sortorder: string,
jwt: string = getTokenOrError(),
): Promise<User[]> {
const urlParameters = new URLSearchParams({
skip: `${skip}`,
limit: `${limit}`,
sortby: `${sortby}`,
sortorder: `${sortorder}`,
});
const endpoint = `/api/admin/users/?${urlParameters}`;
const response = await fetch(endpoint, {
method: 'GET',
headers: {
@ -83,3 +95,88 @@ export async function readUserCount(jwt: string = getTokenOrError()): Promise<nu
const itemCount = responseJson as ItemCount;
return itemCount.total;
}
/**
* Retrieves the list of all todo-items for the user with the specified ID from the backend API.
*
* @param {string} jwt - The JWT appended as a bearer token to authorize the request.
* @throws{error} - If the request fails or is not permitted.
*/
export async function readTodos(
userId: number,
skip: number,
limit: number,
sortby: string,
sortorder: string,
jwt: string = getTokenOrError(),
): Promise<TodoItem[]> {
const urlParameters = new URLSearchParams({
skip: `${skip}`,
limit: `${limit}`,
sortby: `${sortby}`,
sortorder: `${sortorder}`,
});
const endpoint = `/api/todos/user/${userId}?${urlParameters}`;
const response = await fetch(endpoint, {
method: 'GET',
headers: {
'Accept': 'application/json',
'Authorization': `Bearer ${jwt}`,
'Content-Type': 'application/json',
}
});
const responseJson = await getResponseBodyOrError(response);
return responseJson as TodoItem[];
}
/**
* Retrieves the total todo-item count for the user with the specified ID from the backend API.
*
* @param {string} jwt - The JWT appended as a bearer token to authorize the request.
* @throws{error} - If the request fails or is not permitted.
*/
export async function readTodoCount(
userId: number,
jwt: string = getTokenOrError()
): Promise<number> {
const endpoint = `/api/todos/user/${userId}/total/`;
const response = await fetch(endpoint, {
method: 'GET',
headers: {
'Accept': 'application/json',
'Authorization': `Bearer ${jwt}`,
'Content-Type': 'application/json',
}
});
const responseJson = await getResponseBodyOrError(response);
const itemCount = responseJson as ItemCount;
return itemCount.total;
}
/**
* Retrieves the todo-item with the specified ID from the backend API.
*
* @param {string} jwt - The JWT appended as a bearer token to authorize the request.
* @throws{error} - If the request fails or is not permitted.
*/
export async function readTodo(
todoId: number,
jwt: string = getTokenOrError()
): Promise<TodoItem> {
const endpoint = `/api/todos/${todoId}`;
const response = await fetch(endpoint, {
method: 'GET',
headers: {
'Accept': 'application/json',
'Authorization': `Bearer ${jwt}`,
'Content-Type': 'application/json',
}
});
const responseJson = await getResponseBodyOrError(response);
return responseJson as TodoItem;
}

View file

@ -1,3 +1,7 @@
export type ItemCount = {
total: number
}
export type User = {
id: number,
email: string,
@ -8,6 +12,12 @@ export type User = {
is_admin: boolean,
}
export type ItemCount = {
total: number
export type TodoItem = {
id: number,
title: string,
description: string,
done: boolean,
created: Date,
updated: Date,
finished: Date,
}

View file

@ -1,33 +1,35 @@
<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';
import Th from '$lib/components/data-table/Th.svelte';
import Td from '$lib/components/data-table/Td.svelte';
import type { Column, Endpoint } from '$lib/components/data-table/types';
import { interpolateString as i } from '$lib/components/data-table/utils';
/**
* The caption for the data table.
*/
export let caption: string | null = null;
/**
* The columns for the data table.
*/
export let columns: Column[];
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 }[]
};
/**
* The function which fetches rows for the data table.
* Must support pagination and sorting.
*/
export let getItemsEndpoint: Endpoint = {
callable: () => [],
args: [],
}
/**
* The function which fetches the total number of items. Used for pagination.
*/
export let getItemCountEndpoint: Endpoint = {
callable: () => 0,
args: [],
}
type SortEvent = {
detail: {
@ -117,66 +119,19 @@
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();
totalItemCount = await getItemCountEndpoint.callable(
...getItemCountEndpoint.args,
);
currentItems = await getItemsEndpoint.callable(
...getItemsEndpoint.args,
currentItemsOffset,
currentItemsPerPage,
currentSortField,
currentSortOrder
);
currentState = 'finished';
} catch (error: any) {
if (!errorMessages.length) {
@ -229,6 +184,8 @@
<Td
data={item[column.field]}
type={column.type ?? 'string'}
isLink={column.isLink ?? false}
linkTarget={i(column.linkTarget, item) ?? ''}
/>
{/each}
</tr>

View file

@ -3,6 +3,8 @@
export let data: any;
export let type: 'string' | 'date' | 'boolean' = 'string';
export let isLink: boolean = false;
export let linkTarget: string = "";
</script>
<td>
@ -19,6 +21,10 @@
<Icon src={XMark} mini class="w-6 h-6" />
{/if}
{:else}
{data}
{#if isLink}
<a href={linkTarget}>{data}</a>
{:else}
{data}
{/if}
{/if}
</td>

View file

@ -0,0 +1,13 @@
export type Column = {
field: string,
heading: string,
type?: string,
sortable?: boolean,
isLink?: boolean,
linkTarget?: string,
}
export type Endpoint = {
callable: Function,
args: any[],
}

View file

@ -0,0 +1,11 @@
export function interpolateString(templateString: string, replacementItems: { [key: string]: any }): string {
if (typeof templateString !== 'string') {
return '';
}
return templateString.replace(/%(\w+)%/g, (match, key) => {
if (replacementItems.hasOwnProperty(key)) {
return replacementItems[key].toString();
}
return match;
});
}

View file

@ -0,0 +1,33 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { logout, storedUser } from '$lib/auth/session';
import type { StoredUser } from '$lib/auth/session';
let user: StoredUser | null = null;
storedUser.subscribe((value) => {
user = value;
});
function handleLogout() {
logout();
goto('/');
}
</script>
<nav>
<ul>
<li class="inline">
<a href="/">Home</a>
{#if user}
<a href={`/users/${user.id}/todos`}>Todos</a>
<a href={`/users/${user.id}`}>My Profile</a>
{#if user.isAdmin}
<a href={`/users/all`}>All Users</a>
{/if}
<button on:click={handleLogout}>Log Out</button>
{:else}
<a href="/login">Log In</a>
{/if}
</li>
</ul>
</nav>

View file

@ -23,11 +23,10 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import "../app.css";
import { page } from '$app/stores';
import { loadUserIntoStore, logout, storedUser } from '$lib/auth/session';
import type { StoredUser } from '$lib/auth/session';
import { loadUserIntoStore } from '$lib/auth/session';
import Navbar from '$lib/components/navbar/Navbar.svelte';
export let title = import.meta.env.PUBLIC_TITLE;
export let description = import.meta.env.PUBLIC_DESCRIPTION;
@ -35,32 +34,10 @@
const ogImageUrl = new URL('/images/common/og-image.webp', import.meta.url).href
let user: StoredUser | null = null;
storedUser.subscribe((value) => {
user = value;
});
function handleLogout() {
logout();
goto('/');
}
onMount(async () => {
loadUserIntoStore();
});
</script>
<nav>
<ul>
<li class="inline">
<a href="/">Home</a>
{#if user}
<a href={`/users/${user.id}`}>My Profile</a>
<button on:click={handleLogout}>Log Out</button>
{:else}
<a href="/login">Log In</a>
{/if}
</li>
</ul>
</nav>
<Navbar />
<slot />

View file

@ -0,0 +1,7 @@
<script lang="ts">
import type { TodoDetailPage } from "./+page";
export let data: TodoDetailPage;
</script>
<h1>{data.todo.title}</h1>

View file

@ -0,0 +1,18 @@
import type { PageLoad } from './$types';
import type { TodoItem } from '$lib/api/types';
import { readTodo } from '$lib/api/endpoints';
export const ssr = false;
export const load = (async ({ params }) => {
// check if user exists
const todoId = params.todo;
const todo = await readTodo(todoId);
return {
todo: todo
};
}) satisfies PageLoad;
export interface UserProfilePage {
todo: TodoItem
}

View file

@ -1,11 +1,12 @@
<script lang="ts">
import Table from '$lib/data-table/Table.svelte'
import type { Column } from '$lib/data-table/Table.svelte';
import Table from '$lib/components/data-table/Table.svelte'
import type { Column } from '$lib/components/data-table/types';
import type { UserTodosPage } from "./+page";
import { readTodos, readTodoCount } from '$lib/api/endpoints';
export let data: UserTodosPage;
const cols: Column[] = [
const columns: Column[] = [
{
field: "id",
heading: "ID",
@ -13,6 +14,8 @@
{
field: "title",
heading: "Title",
isLink: true,
linkTarget: '/todos/%id%',
},
{
field: "description",
@ -43,9 +46,19 @@
const table = {
caption: `List of TODOs for user ${data.user.first_name} ${data.user.last_name}`,
columns: cols,
itemsEndpoint: `/api/todos/user/${data.user.id}/`,
itemCountEndpoint: `/api/todos/user/${data.user.id}/total/`,
columns: columns,
getItemsEndpoint: {
callable: readTodos,
args: [
data.user.id
]
},
getItemCountEndpoint: {
callable: readTodoCount,
args: [
data.user.id
]
}
};
</script>

View file

@ -1,43 +1,55 @@
<script lang="ts">
import Table from '$lib/data-table/Table.svelte'
import Table from '$lib/components/data-table/Table.svelte';
import type { Column } from '$lib/components/data-table/types';
import { readUsers, readUserCount } from '$lib/api/endpoints';
const columns: Column[] = [
{
field: "id",
heading: "ID",
},
{
field: "email",
heading: "Email",
isLink: true,
linkTarget: '/users/%id%',
},
{
field: "first_name",
heading: "First Name",
},
{
field: "last_name",
heading: "Last Name",
},
{
field: "created",
heading: "Created",
type: 'date',
},
{
field: "updated",
heading: "Updated",
type: 'date',
},
{
field: "is_admin",
heading: "Admin",
type: 'boolean',
},
]
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',
},
{
field: "is_admin",
heading: "Admin",
type: 'boolean',
},
],
itemCountEndpoint: "/api/users/total/",
itemsEndpoint: "/api/users/",
columns: columns,
getItemsEndpoint: {
callable: readUsers,
args: []
},
getItemCountEndpoint: {
callable: readUserCount,
args: []
}
};
</script>