diff --git a/frontend/src/APIConnection.ts b/frontend/src/APIConnection.ts index a7f42def..828cd839 100644 --- a/frontend/src/APIConnection.ts +++ b/frontend/src/APIConnection.ts @@ -1,5 +1,3 @@ -import {UpdateResult} from "./UserProfile"; - export type User = { url: string; id: number; @@ -36,6 +34,19 @@ export type CachedAPIResponse = { parents: string[]; } +export type APIMessage = { + severity: "error" | "warning" | "info" | "success"; + message: string; + context?: any; +} + +export type MessageHandler = (message: APIMessage) => void + +export type ConnectionProps = { + base_url?: string; + message_handlers?: MessageHandler[]; +} + function clean_url(url: string, baseURL: string): string { url = url.toLowerCase(); if (!url.startsWith(baseURL)) @@ -97,15 +108,29 @@ class ResponseCache { } } +/** + * @class + * APIConnection manages a connection to the backend REST API. + * It handles user management directly, and offers a modified + * fetch interface for everything else. + * + * Results are cached and can be referenced directly with their + * canonical URL and indirectly with a parent URL. + */ export class APIConnection { url: string = 'http://localhost:5000/'.toLowerCase() user: User | null = null cache_expiry_time = 60_000 // 1 minute results: ResponseCache cookies: any + message_handlers: MessageHandler[] = [] - constructor() { - console.info("Spawn API connection") + constructor(props?: ConnectionProps) { + if (props?.base_url) + this.url = props.base_url + if (props?.message_handlers) + this.message_handlers = props.message_handlers + console.info(`Spawn API connection (${this.url})`) const local_user = window.localStorage.getItem('user') if (local_user) this.user = JSON.parse(local_user) @@ -128,7 +153,7 @@ export class APIConnection { }) } - update_user(email: string, password: string, currentPassword: string): Promise { + update_user(email: string, password: string, currentPassword: string): Promise { if (!this.user) return new Promise(() => {}) return fetch( @@ -149,9 +174,9 @@ export class APIConnection { .then(user => { this.user = {...user, token: this.user?.token} }) - .then(() => ({success: true, message: 'Updated successfully'})) + .then(() => ({severity: "success", message: 'Updated successfully'})) return r.json() - .then(r => ({success: false, message: r.error})) + .then(r => ({severity: "error", message: r.error})) }) } @@ -179,26 +204,10 @@ export class APIConnection { return fetch(this.url + "logout/", {method: 'POST'}) } - get_cookie(name: string) { - const value = `; ${document.cookie}`; - const parts = value.split(`; ${name}=`); - if (parts.length === 2) return parts?.pop()?.split(';').shift(); - return null - } - get is_logged_in() { return !!this.user?.token } - async get_is_logged_in(skip: boolean = true): Promise { - await this.login('admin', 'admin') - const cookie = this.get_cookie('csrf_access_token'); - if (cookie === undefined || !this.user) - if (!skip) - return this.get_is_logged_in(false); - return cookie !== undefined && this.user !== null; - } - _prepare_fetch_headers(url: string, options?: any) { if (!this.is_logged_in) throw new Error(`Cannot fetch ${url}: not logged on.`) @@ -230,9 +239,15 @@ export class APIConnection { return null; return response.json() as Promise; }) + .catch(e => { + this.message_handlers.forEach(h => h({severity: "error", message: e.message, context: e})) + return url + }) .then(json => { if (void_cache) this.results.purge(parent) + if (typeof json === "string") + return json if (json === null) { this.results.remove(url) return url diff --git a/frontend/src/AsyncTable.tsx b/frontend/src/AsyncTable.tsx index 24106b2f..f13b7919 100644 --- a/frontend/src/AsyncTable.tsx +++ b/frontend/src/AsyncTable.tsx @@ -186,7 +186,7 @@ export default class AsyncTable extends Component { + update_all = async (use_cache: boolean = false) => { this.reset_new_row() return this.get_data(this.props.url, use_cache) } diff --git a/frontend/src/Core.tsx b/frontend/src/Core.tsx index 89c3f5ba..5597dd2a 100644 --- a/frontend/src/Core.tsx +++ b/frontend/src/Core.tsx @@ -39,10 +39,12 @@ import ListItem from '@mui/material/ListItem'; import ListItemIcon from '@mui/material/ListItemIcon'; import ListItemText from '@mui/material/ListItemText'; import { ReactComponent as GalvanalyserLogo } from './Galvanalyser-logo.svg'; -import Connection from "./APIConnection"; +import Connection, {APIMessage} from "./APIConnection"; import Stack from "@mui/material/Stack"; import Tokens from "./Tokens"; import UserProfile from "./UserProfile"; +import Snackbar from "@mui/material/Snackbar"; +import Alert from "@mui/material/Alert"; const PrivateRoute = (component: JSX.Element) => { const logged = Connection.is_logged_in; @@ -82,7 +84,7 @@ const useStyles = makeStyles((theme) => ({ }), }, galvanalyserLogo: { - height: '40px' + height: '40px' }, menuButton: { marginRight: 36, @@ -164,35 +166,48 @@ export default function Core() { setOpen(false); }; + const [snackbarOpen, setSnackbarOpen] = React.useState(false) + const [apiMessage, setAPIMessage] = React.useState(null) + const handleSnackbarClose = (e: any, reason?: string) => { + if (reason !== 'clickaway') { + setSnackbarOpen(false) + setAPIMessage(null) + } + } + Connection.message_handlers.push((h) => { + setAPIMessage(h) + setSnackbarOpen(true) + }) + const mainListItems = ( - + - + - + - + @@ -214,78 +229,83 @@ export default function Core() { const Layout = (
- - - - - - - - - + + + + + + + + + - - - - - - - -
- - - -
- - {mainListItems} -
-
-
+ + + + + + + +
+ + + +
+ + {mainListItems} +
+
+
-
+
+ + + {apiMessage?.message} + +
); /* A looks through its children s and renders the first one that matches the current URL. */ return ( - - }/> - - - - - - - - - - + + }/> + + + + + + + + + + ); } diff --git a/frontend/src/UserProfile.tsx b/frontend/src/UserProfile.tsx index 41c99102..376f4ec2 100644 --- a/frontend/src/UserProfile.tsx +++ b/frontend/src/UserProfile.tsx @@ -2,7 +2,7 @@ import React, {SyntheticEvent, useState} from 'react'; import TextField from '@mui/material/TextField'; import Paper from '@mui/material/Paper'; import Container from '@mui/material/Container'; -import Connection from "./APIConnection"; +import Connection, {APIMessage} from "./APIConnection"; import useStyles from "./UseStyles"; import Typography from "@mui/material/Typography"; import Stack from "@mui/material/Stack"; @@ -10,17 +10,12 @@ import Button from "@mui/material/Button"; import Alert from "@mui/material/Alert"; import Snackbar from "@mui/material/Snackbar"; -export type UpdateResult = { - success: boolean; - message: string; -} - export default function UserProfile() { const classes = useStyles(); const [email, setEmail] = useState(Connection.user?.email || '') const [password, setPassword] = useState('') const [currentPassword, setCurrentPassword] = useState('') - const [updateResult, setUpdateResult] = useState() + const [updateResult, setUpdateResult] = useState() const [open, setOpen] = useState(false) const updateUser = () => Connection.update_user(email, password, currentPassword) @@ -91,7 +86,7 @@ export default function UserProfile() {
- + {updateResult?.message}