From 0ca5156fed386babb3d222a963636c3829f78609 Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Sun, 3 Aug 2025 21:38:53 +0200 Subject: [PATCH] fix: migrate most components to functional components Co-authored-by: Matthias Fechner --- ui/src/application/AddApplicationDialog.tsx | 146 ++++---- ui/src/application/Applications.tsx | 259 +++++++------ .../application/UpdateApplicationDialog.tsx | 161 ++++----- ui/src/client/AddClientDialog.tsx | 101 +++--- ui/src/client/Clients.tsx | 171 ++++----- ui/src/client/UpdateClientDialog.tsx | 116 +++--- ui/src/common/Container.tsx | 18 +- ui/src/common/CopyableSecret.tsx | 54 ++- ui/src/common/ScrollUpButton.tsx | 73 ++-- ui/src/common/SettingsDialog.tsx | 101 +++--- ui/src/index.tsx | 9 +- ui/src/layout/Header.tsx | 295 +++++++-------- ui/src/layout/Layout.tsx | 219 +++++------ ui/src/layout/Navigation.tsx | 179 +++++---- ui/src/plugin/PluginDetailView.tsx | 339 ++++++++---------- ui/src/plugin/Plugins.tsx | 91 +++-- ui/src/stores.tsx | 28 ++ ui/src/user/AddEditUserDialog.tsx | 193 +++++----- ui/src/user/Login.tsx | 142 ++++---- ui/src/user/Register.tsx | 165 ++++----- ui/src/user/Users.tsx | 159 ++++---- 21 files changed, 1397 insertions(+), 1622 deletions(-) create mode 100644 ui/src/stores.tsx diff --git a/ui/src/application/AddApplicationDialog.tsx b/ui/src/application/AddApplicationDialog.tsx index f380c4c..99d465e 100644 --- a/ui/src/application/AddApplicationDialog.tsx +++ b/ui/src/application/AddApplicationDialog.tsx @@ -7,94 +7,72 @@ import DialogTitle from '@mui/material/DialogTitle'; import TextField from '@mui/material/TextField'; import Tooltip from '@mui/material/Tooltip'; import {NumberField} from '../common/NumberField'; -import React, {Component} from 'react'; +import React, {useState} from 'react'; interface IProps { fClose: VoidFunction; - fOnSubmit: (name: string, description: string, defaultPriority: number) => void; + fOnSubmit: (name: string, description: string, defaultPriority: number) => Promise; } -interface IState { - name: string; - description: string; - defaultPriority: number; -} +export const AddApplicationDialog = ({fClose, fOnSubmit}: IProps) => { + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [defaultPriority, setDefaultPriority] = useState(0); -export default class AddDialog extends Component { - public state = {name: '', description: '', defaultPriority: 0}; + const submitEnabled = name.length !== 0; + const submitAndClose = async () => { + await fOnSubmit(name, description, defaultPriority); + fClose(); + }; - public render() { - const {fClose, fOnSubmit} = this.props; - const {name, description, defaultPriority} = this.state; - const submitEnabled = this.state.name.length !== 0; - const submitAndClose = () => { - fOnSubmit(name, description, defaultPriority); - fClose(); - }; - return ( - - Create an application - - - An application is allowed to send messages. - - - - this.setState({defaultPriority: value})} - fullWidth - /> - - - - -
- -
-
-
-
- ); - } - - private handleChange( - propertyName: 'description' | 'name', - event: React.ChangeEvent - ) { - const state = this.state; - state[propertyName] = event.target.value; - this.setState(state); - } -} + return ( + + Create an application + + An application is allowed to send messages. + setName(e.target.value)} + fullWidth + /> + setDescription(e.target.value)} + fullWidth + multiline + /> + setDefaultPriority(value)} + fullWidth + /> + + + + +
+ +
+
+
+
+ ); +}; diff --git a/ui/src/application/Applications.tsx b/ui/src/application/Applications.tsx index c1a8b05..9161d82 100644 --- a/ui/src/application/Applications.tsx +++ b/ui/src/application/Applications.tsx @@ -1,3 +1,4 @@ +import React, {ChangeEvent, useEffect, useRef, useState} from 'react'; import Grid from '@mui/material/Grid'; import IconButton from '@mui/material/IconButton'; import Paper from '@mui/material/Paper'; @@ -9,144 +10,132 @@ import TableRow from '@mui/material/TableRow'; import Delete from '@mui/icons-material/Delete'; import Edit from '@mui/icons-material/Edit'; import CloudUpload from '@mui/icons-material/CloudUpload'; -import React, {ChangeEvent, Component, SFC} from 'react'; +import Button from '@mui/material/Button'; + import ConfirmDialog from '../common/ConfirmDialog'; import DefaultPage from '../common/DefaultPage'; -import Button from '@mui/material/Button'; import CopyableSecret from '../common/CopyableSecret'; -import AddApplicationDialog from './AddApplicationDialog'; -import {observer} from 'mobx-react'; -import {observable} from 'mobx'; -import {inject, Stores} from '../inject'; +import {AddApplicationDialog} from './AddApplicationDialog'; import * as config from '../config'; -import UpdateDialog from './UpdateApplicationDialog'; +import {UpdateApplicationDialog} from './UpdateApplicationDialog'; import {IApplication} from '../types'; import {LastUsedCell} from '../common/LastUsedCell'; +import {useStores} from '../stores'; +import {observer} from 'mobx-react-lite'; -@observer -class Applications extends Component> { - @observable - private deleteId: number | false = false; - @observable - private updateId: number | false = false; - @observable - private createDialog = false; +const Applications = observer(() => { + const {appStore} = useStores(); + const apps = appStore.getItems(); + const [toDeleteApp, setToDeleteApp] = useState(); + const [toUpdateApp, setToUpdateApp] = useState(); + const [createDialog, setCreateDialog] = useState(false); - private uploadId = -1; - private upload: HTMLInputElement | null = null; + const fileInputRef = useRef(null); + const uploadId = useRef(-1); - public componentDidMount = () => this.props.appStore.refresh(); + useEffect(() => void appStore.refresh(), []); - public render() { - const { - createDialog, - deleteId, - updateId, - props: {appStore}, - } = this; - const apps = appStore.getItems(); - return ( - (this.createDialog = true)}> - Create Application - - } - maxWidth={1000}> - - - - - - - Name - Token - Description - Priority - Last Used - - - - - - {apps.map((app: IApplication) => ( - this.uploadImage(app.id)} - fDelete={() => (this.deleteId = app.id)} - fEdit={() => (this.updateId = app.id)} - noDelete={app.internal} - /> - ))} - -
- (this.upload = upload)} - type="file" - style={{display: 'none'}} - onChange={this.onUploadImage} - /> -
-
- {createDialog && ( - (this.createDialog = false)} - fOnSubmit={appStore.create} - /> - )} - {updateId !== false && ( - (this.updateId = false)} - fOnSubmit={(name, description, defaultPriority) => - appStore.update(updateId, name, description, defaultPriority) - } - initialDescription={appStore.getByID(updateId).description} - initialName={appStore.getByID(updateId).name} - initialDefaultPriority={appStore.getByID(updateId).defaultPriority} - /> - )} - {deleteId !== false && ( - (this.deleteId = false)} - fOnSubmit={() => appStore.remove(deleteId)} - /> - )} -
- ); - } - - private uploadImage = (id: number) => { - this.uploadId = id; - if (this.upload) { - this.upload.click(); + const handleImageUploadClick = (id: number) => { + uploadId.current = id; + if (fileInputRef.current) { + fileInputRef.current.click(); } }; - private onUploadImage = (e: ChangeEvent) => { + const onUploadImage = (e: ChangeEvent) => { const file = e.target.files?.[0]; if (!file) { return; } if (['image/png', 'image/jpeg', 'image/gif'].indexOf(file.type) !== -1) { - this.props.appStore.uploadImage(this.uploadId, file); + appStore.uploadImage(uploadId.current, file); } else { alert('Uploaded file must be of type png, jpeg or gif.'); } }; -} + + return ( + setCreateDialog(true)}> + Create Application + + } + maxWidth={1000}> + + + + + + + Name + Token + Description + Priority + Last Used + + + + + + {apps.map((app: IApplication) => ( + handleImageUploadClick(app.id)} + fDelete={() => setToDeleteApp(app)} + fEdit={() => setToUpdateApp(app)} + noDelete={app.internal} + /> + ))} + +
+ +
+
+ {createDialog && ( + setCreateDialog(false)} + fOnSubmit={appStore.create} + /> + )} + {toUpdateApp != null && ( + setToUpdateApp(undefined)} + fOnSubmit={(name, description, defaultPriority) => + appStore.update(toUpdateApp.id, name, description, defaultPriority) + } + initialDescription={toUpdateApp?.description} + initialName={toUpdateApp?.name} + initialDefaultPriority={toUpdateApp?.defaultPriority} + /> + )} + {toDeleteApp != null && ( + setToDeleteApp(undefined)} + fOnSubmit={() => appStore.remove(toDeleteApp.id)} + /> + )} +
+ ); +}); interface IRowProps { name: string; @@ -161,24 +150,24 @@ interface IRowProps { fEdit: VoidFunction; } -const Row: SFC = observer( - ({ - name, - value, - noDelete, - description, - defaultPriority, - lastUsed, - fDelete, - fUpload, - image, - fEdit, - }) => ( +const Row = ({ + name, + value, + noDelete, + description, + defaultPriority, + lastUsed, + fDelete, + fUpload, + image, + fEdit, +}: IRowProps) => { + return (
app logo - +
@@ -193,17 +182,17 @@ const Row: SFC = observer(
- + - +
- ) -); + ); +}; -export default inject('appStore')(Applications); +export default Applications; diff --git a/ui/src/application/UpdateApplicationDialog.tsx b/ui/src/application/UpdateApplicationDialog.tsx index b1edb5b..a4d13fa 100644 --- a/ui/src/application/UpdateApplicationDialog.tsx +++ b/ui/src/application/UpdateApplicationDialog.tsx @@ -7,106 +7,81 @@ import DialogTitle from '@mui/material/DialogTitle'; import TextField from '@mui/material/TextField'; import Tooltip from '@mui/material/Tooltip'; import {NumberField} from '../common/NumberField'; -import React, {Component} from 'react'; +import React, {useState} from 'react'; interface IProps { fClose: VoidFunction; - fOnSubmit: (name: string, description: string, defaultPriority: number) => void; + fOnSubmit: (name: string, description: string, defaultPriority: number) => Promise; initialName: string; initialDescription: string; initialDefaultPriority: number; } -interface IState { - name: string; - description: string; - defaultPriority: number; -} +export const UpdateApplicationDialog = ({ + initialName, + initialDescription, + initialDefaultPriority, + fClose, + fOnSubmit, +}: IProps) => { + const [name, setName] = useState(initialName); + const [description, setDescription] = useState(initialDescription); + const [defaultPriority, setDefaultPriority] = useState(initialDefaultPriority); -export default class UpdateDialog extends Component { - public state = {name: '', description: '', defaultPriority: 0}; + const submitEnabled = name.length !== 0; + const submitAndClose = async () => { + await fOnSubmit(name, description, defaultPriority); + fClose(); + }; - constructor(props: IProps) { - super(props); - this.state = { - name: props.initialName, - description: props.initialDescription, - defaultPriority: props.initialDefaultPriority, - }; - } - - public render() { - const {fClose, fOnSubmit} = this.props; - const {name, description, defaultPriority} = this.state; - const submitEnabled = this.state.name.length !== 0; - const submitAndClose = () => { - fOnSubmit(name, description, defaultPriority); - fClose(); - }; - return ( - - Update an application - - - An application is allowed to send messages. - - - - this.setState({defaultPriority: value})} - fullWidth - /> - - - - -
- -
-
-
-
- ); - } - - private handleChange( - propertyName: 'name' | 'description', - event: React.ChangeEvent - ) { - const state = this.state; - state[propertyName] = event.target.value; - this.setState(state); - } -} + return ( + + Update an application + + An application is allowed to send messages. + setName(e.target.value)} + fullWidth + /> + setDescription(e.target.value)} + fullWidth + multiline + /> + setDefaultPriority(e)} + fullWidth + /> + + + + +
+ +
+
+
+
+ ); +}; diff --git a/ui/src/client/AddClientDialog.tsx b/ui/src/client/AddClientDialog.tsx index 4f24cc3..50cac3d 100644 --- a/ui/src/client/AddClientDialog.tsx +++ b/ui/src/client/AddClientDialog.tsx @@ -1,3 +1,4 @@ +import React, {useState} from 'react'; import Button from '@mui/material/Button'; import Dialog from '@mui/material/Dialog'; import DialogActions from '@mui/material/DialogActions'; @@ -5,67 +6,53 @@ import DialogContent from '@mui/material/DialogContent'; import DialogTitle from '@mui/material/DialogTitle'; import TextField from '@mui/material/TextField'; import Tooltip from '@mui/material/Tooltip'; -import React, {Component} from 'react'; interface IProps { fClose: VoidFunction; - fOnSubmit: (name: string) => void; + fOnSubmit: (name: string) => Promise; } -export default class AddDialog extends Component { - public state = {name: ''}; +const AddClientDialog = ({fClose, fOnSubmit}: IProps) => { + const [name, setName] = useState(''); - public render() { - const {fClose, fOnSubmit} = this.props; - const {name} = this.state; - const submitEnabled = this.state.name.length !== 0; - const submitAndClose = () => { - fOnSubmit(name); - fClose(); - }; - return ( - - Create a client - - - - - - -
- -
-
-
-
- ); - } + const submitEnabled = name.length !== 0; + const submitAndClose = async () => { + await fOnSubmit(name); + fClose(); + }; - private handleChange(propertyName: 'name', event: React.ChangeEvent) { - const state = this.state; - state[propertyName] = event.target.value; - this.setState(state); - } -} + return ( + + Create a client + + setName(e.target.value)} + fullWidth + /> + + + + +
+ +
+
+
+
+ ); +}; + +export default AddClientDialog; diff --git a/ui/src/client/Clients.tsx b/ui/src/client/Clients.tsx index edcc10c..7aa0b8e 100644 --- a/ui/src/client/Clients.tsx +++ b/ui/src/client/Clients.tsx @@ -1,3 +1,4 @@ +import React, {useEffect, useState} from 'react'; import Grid from '@mui/material/Grid'; import IconButton from '@mui/material/IconButton'; import Paper from '@mui/material/Paper'; @@ -8,103 +9,89 @@ import TableHead from '@mui/material/TableHead'; import TableRow from '@mui/material/TableRow'; import Delete from '@mui/icons-material/Delete'; import Edit from '@mui/icons-material/Edit'; -import React, {Component, SFC} from 'react'; +import Button from '@mui/material/Button'; import ConfirmDialog from '../common/ConfirmDialog'; import DefaultPage from '../common/DefaultPage'; -import Button from '@mui/material/Button'; import AddClientDialog from './AddClientDialog'; -import UpdateDialog from './UpdateClientDialog'; -import {observer} from 'mobx-react'; -import {observable} from 'mobx'; -import {inject, Stores} from '../inject'; +import UpdateClientDialog from './UpdateClientDialog'; import {IClient} from '../types'; import CopyableSecret from '../common/CopyableSecret'; import {LastUsedCell} from '../common/LastUsedCell'; +import {observer} from 'mobx-react'; +import {useStores} from '../stores'; -@observer -class Clients extends Component> { - @observable - private showDialog = false; - @observable - private deleteId: false | number = false; - @observable - private updateId: false | number = false; +const Clients = observer(() => { + const {clientStore} = useStores(); + const [toDeleteClient, setToDeleteClient] = useState(); + const [toUpdateClient, setToUpdateClient] = useState(); + const [createDialog, setCreateDialog] = useState(false); + const clients = clientStore.getItems(); - public componentDidMount = () => this.props.clientStore.refresh(); + useEffect(() => void clientStore.refresh(), []); - public render() { - const { - deleteId, - updateId, - showDialog, - props: {clientStore}, - } = this; - const clients = clientStore.getItems(); - - return ( - (this.showDialog = true)}> - Create Client - - }> - - - - - - Name - Token - Last Used - - - - - - {clients.map((client: IClient) => ( - (this.updateId = client.id)} - fDelete={() => (this.deleteId = client.id)} - /> - ))} - -
-
-
- {showDialog && ( - (this.showDialog = false)} - fOnSubmit={clientStore.create} - /> - )} - {updateId !== false && ( - (this.updateId = false)} - fOnSubmit={(name) => clientStore.update(updateId, name)} - initialName={clientStore.getByID(updateId).name} - /> - )} - {deleteId !== false && ( - (this.deleteId = false)} - fOnSubmit={() => clientStore.remove(deleteId)} - /> - )} -
- ); - } -} + return ( + setCreateDialog(true)}> + Create Client + + }> + + + + + + Name + Token + Last Used + + + + + + {clients.map((client: IClient) => ( + setToUpdateClient(client)} + fDelete={() => setToDeleteClient(client)} + /> + ))} + +
+
+
+ {createDialog && ( + setCreateDialog(false)} + fOnSubmit={clientStore.create} + /> + )} + {toUpdateClient != null && ( + setToUpdateClient(undefined)} + fOnSubmit={(name) => clientStore.update(toUpdateClient.id, name)} + initialName={toUpdateClient.name} + /> + )} + {toDeleteClient != null && ( + setToDeleteClient(undefined)} + fOnSubmit={() => clientStore.remove(toDeleteClient.id)} + /> + )} +
+ ); +}); interface IRowProps { name: string; @@ -114,7 +101,7 @@ interface IRowProps { fDelete: VoidFunction; } -const Row: SFC = ({name, value, lastUsed, fEdit, fDelete}) => ( +const Row = ({name, value, lastUsed, fEdit, fDelete}: IRowProps) => ( {name} @@ -127,16 +114,16 @@ const Row: SFC = ({name, value, lastUsed, fEdit, fDelete}) => ( - + - + ); -export default inject('clientStore')(Clients); +export default Clients; diff --git a/ui/src/client/UpdateClientDialog.tsx b/ui/src/client/UpdateClientDialog.tsx index a8a113e..e6e7dae 100644 --- a/ui/src/client/UpdateClientDialog.tsx +++ b/ui/src/client/UpdateClientDialog.tsx @@ -1,3 +1,4 @@ +import React, {useState} from 'react'; import Button from '@mui/material/Button'; import Dialog from '@mui/material/Dialog'; import DialogActions from '@mui/material/DialogActions'; @@ -6,81 +7,58 @@ import DialogContentText from '@mui/material/DialogContentText'; import DialogTitle from '@mui/material/DialogTitle'; import TextField from '@mui/material/TextField'; import Tooltip from '@mui/material/Tooltip'; -import React, {Component} from 'react'; interface IProps { fClose: VoidFunction; - fOnSubmit: (name: string) => void; + fOnSubmit: (name: string) => Promise; initialName: string; } -interface IState { - name: string; -} +const UpdateClientDialog = ({fClose, fOnSubmit, initialName = ''}: IProps) => { + const [name, setName] = useState(initialName); -export default class UpdateDialog extends Component { - public state = {name: ''}; + const submitEnabled = name.length !== 0; + const submitAndClose = async () => { + await fOnSubmit(name); + fClose(); + }; - constructor(props: IProps) { - super(props); - this.state = { - name: props.initialName, - }; - } + return ( + + Update a Client + + + A client manages messages, clients, applications and users (with admin + permissions). + + setName(e.target.value)} + fullWidth + /> + + + + +
+ +
+
+
+
+ ); +}; - public render() { - const {fClose, fOnSubmit} = this.props; - const {name} = this.state; - const submitEnabled = this.state.name.length !== 0; - const submitAndClose = () => { - fOnSubmit(name); - fClose(); - }; - return ( - - Update a Client - - - A client manages messages, clients, applications and users (with admin - permissions). - - - - - - -
- -
-
-
-
- ); - } - - private handleChange(propertyName: 'name', event: React.ChangeEvent) { - const state = this.state; - state[propertyName] = event.target.value; - this.setState(state); - } -} +export default UpdateClientDialog; diff --git a/ui/src/common/Container.tsx b/ui/src/common/Container.tsx index e0c3410..8dcdbd2 100644 --- a/ui/src/common/Container.tsx +++ b/ui/src/common/Container.tsx @@ -1,21 +1,19 @@ import Paper from '@mui/material/Paper'; -import {withStyles} from 'tss-react/mui'; +import {makeStyles} from 'tss-react/mui'; import * as React from 'react'; -const styles = () => - ({ - paper: { - padding: 16, - }, - } as const); +const useStyles = makeStyles()(() => ({ + paper: { + padding: 16, + }, +})); interface IProps { style?: React.CSSProperties; - classes?: Partial, string>>; } const Container: React.FC = ({children, style, ...props}) => { - const classes = withStyles.getClasses(props); + const {classes} = useStyles(); return ( {children} @@ -23,4 +21,4 @@ const Container: React.FC = ({children, style, ...props}) => { ); }; -export default withStyles(Container, styles); +export default Container; diff --git a/ui/src/common/CopyableSecret.tsx b/ui/src/common/CopyableSecret.tsx index 7c22af2..0c1dfbd 100644 --- a/ui/src/common/CopyableSecret.tsx +++ b/ui/src/common/CopyableSecret.tsx @@ -3,43 +3,20 @@ import Typography from '@mui/material/Typography'; import Visibility from '@mui/icons-material/Visibility'; import Copy from '@mui/icons-material/FileCopyOutlined'; import VisibilityOff from '@mui/icons-material/VisibilityOff'; -import React, {Component, CSSProperties} from 'react'; -import {Stores, inject} from '../inject'; +import React, {CSSProperties} from 'react'; +import {useStores} from '../stores'; interface IProps { value: string; style?: CSSProperties; } -interface IState { - visible: boolean; -} - -class CopyableSecret extends Component, IState> { - public state = {visible: false}; - - public render() { - const {value, style} = this.props; - const text = this.state.visible ? value : '•••••••••••••••'; - return ( -
- - - - - {this.state.visible ? : } - - {text} -
- ); - } - - private toggleVisibility = () => this.setState({visible: !this.state.visible}); - private copyToClipboard = async () => { - const {snackManager, value} = this.props; +const CopyableSecret = ({value, style}: IProps) => { + const [visible, setVisible] = React.useState(false); + const text = visible ? value : '•••••••••••••••'; + const {snackManager} = useStores(); + const toggleVisibility = () => setVisible((b) => !b); + const copyToClipboard = async () => { try { await navigator.clipboard.writeText(value); snackManager.snack('Copied to clipboard'); @@ -48,6 +25,17 @@ class CopyableSecret extends Component, IState> snackManager.snack('Failed to copy to clipboard'); } }; -} + return ( +
+ + + + + {visible ? : } + + {text} +
+ ); +}; -export default inject('snackManager')(CopyableSecret); +export default CopyableSecret; diff --git a/ui/src/common/ScrollUpButton.tsx b/ui/src/common/ScrollUpButton.tsx index 1f185d4..9bd7429 100644 --- a/ui/src/common/ScrollUpButton.tsx +++ b/ui/src/common/ScrollUpButton.tsx @@ -1,48 +1,37 @@ import Fab from '@mui/material/Fab'; import KeyboardArrowUp from '@mui/icons-material/KeyboardArrowUp'; -import React, {Component} from 'react'; +import React from 'react'; -class ScrollUpButton extends Component { - state = { - display: 'none', - opacity: 0, - }; - componentDidMount() { - window.addEventListener('scroll', this.scrollHandler); - } +const ScrollUpButton = () => { + const [state, setState] = React.useState({display: 'none', opacity: 0}); + React.useEffect(() => { + const scrollHandler = () => { + const currentScrollPos = window.pageYOffset; + const opacity = Math.min(currentScrollPos / 500, 1); + const nextState = {display: currentScrollPos > 0 ? 'inherit' : 'none', opacity}; + if (state.display !== nextState.display || state.opacity !== nextState.opacity) { + setState(nextState); + } + }; + window.addEventListener('scroll', scrollHandler); + return () => window.removeEventListener('scroll', scrollHandler); + }, []); - componentWillUnmount() { - window.removeEventListener('scroll', this.scrollHandler); - } - - scrollHandler = () => { - const currentScrollPos = window.pageYOffset; - const opacity = Math.min(currentScrollPos / 500, 1); - const nextState = {display: currentScrollPos > 0 ? 'inherit' : 'none', opacity}; - if (this.state.display !== nextState.display || this.state.opacity !== nextState.opacity) { - this.setState(nextState); - } - }; - - public render() { - return ( - - - - ); - } - - private scrollUp = () => window.scrollTo(0, 0); -} + return ( + window.scrollTo(0, 0)}> + + + ); +}; export default ScrollUpButton; diff --git a/ui/src/common/SettingsDialog.tsx b/ui/src/common/SettingsDialog.tsx index 9fd3e45..5f407a7 100644 --- a/ui/src/common/SettingsDialog.tsx +++ b/ui/src/common/SettingsDialog.tsx @@ -1,3 +1,4 @@ +import React, {useState} from 'react'; import Button from '@mui/material/Button'; import Dialog from '@mui/material/Dialog'; import DialogActions from '@mui/material/DialogActions'; @@ -5,64 +6,58 @@ import DialogContent from '@mui/material/DialogContent'; import DialogTitle from '@mui/material/DialogTitle'; import TextField from '@mui/material/TextField'; import Tooltip from '@mui/material/Tooltip'; -import React, {Component} from 'react'; -import {observable} from 'mobx'; import {observer} from 'mobx-react'; -import {inject, Stores} from '../inject'; +import {useStores} from '../stores'; interface IProps { fClose: VoidFunction; } -@observer -class SettingsDialog extends Component> { - @observable - private pass = ''; +const SettingsDialog = observer(({fClose}: IProps) => { + const [pass, setPass] = useState(''); + const {currentUser} = useStores(); - public render() { - const {pass} = this; - const {fClose, currentUser} = this.props; - const submitAndClose = () => { - currentUser.changePassword(pass); - fClose(); - }; - return ( - - Change Password - - (this.pass = e.target.value)} - fullWidth - /> - - - - -
- -
-
-
-
- ); - } -} + const submitAndClose = async () => { + currentUser.changePassword(pass); + fClose(); + }; -export default inject('currentUser')(SettingsDialog); + return ( + + Change Password + + setPass(e.target.value)} + fullWidth + /> + + + + +
+ +
+
+
+
+ ); +}); + +export default SettingsDialog; diff --git a/ui/src/index.tsx b/ui/src/index.tsx index 9755fe4..c8a6a4f 100644 --- a/ui/src/index.tsx +++ b/ui/src/index.tsx @@ -15,6 +15,7 @@ import {MessagesStore} from './message/MessagesStore'; import {ClientStore} from './client/ClientStore'; import {PluginStore} from './plugin/PluginStore'; import {registerReactions} from './reactions'; +import {StoreContext} from './stores'; const {port, hostname, protocol, pathname} = window.location; const slashes = protocol.concat('//'); @@ -61,9 +62,11 @@ const initStores = (): StoreMapping => { }; ReactDOM.render( - - - , + + + + + , document.getElementById('root') ); unregister(); diff --git a/ui/src/layout/Header.tsx b/ui/src/layout/Header.tsx index 3c4b9b7..b871f35 100644 --- a/ui/src/layout/Header.tsx +++ b/ui/src/layout/Header.tsx @@ -2,7 +2,7 @@ import AppBar from '@mui/material/AppBar'; import Button, {ButtonProps} from '@mui/material/Button'; import IconButton from '@mui/material/IconButton'; import {Theme} from '@mui/material/styles'; -import {withStyles} from 'tss-react/mui'; +import {makeStyles} from 'tss-react/mui'; import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; import AccountCircle from '@mui/icons-material/AccountCircle'; @@ -14,62 +14,59 @@ import GitHubIcon from '@mui/icons-material/GitHub'; import MenuIcon from '@mui/icons-material/Menu'; import Apps from '@mui/icons-material/Apps'; import SupervisorAccount from '@mui/icons-material/SupervisorAccount'; -import React, {Component, CSSProperties} from 'react'; +import React, {CSSProperties} from 'react'; import {Link} from 'react-router-dom'; -import {observer} from 'mobx-react'; import {useMediaQuery} from '@mui/material'; -const styles = (theme: Theme) => - ({ - appBar: { - zIndex: theme.zIndex.drawer + 1, - [theme.breakpoints.down('sm')]: { - paddingBottom: 10, - }, +const useStyles = makeStyles()((theme: Theme) => ({ + appBar: { + zIndex: theme.zIndex.drawer + 1, + [theme.breakpoints.down('sm')]: { + paddingBottom: 10, }, - toolbar: { + }, + toolbar: { + justifyContent: 'space-between', + [theme.breakpoints.down('sm')]: { + flexWrap: 'wrap', + }, + }, + menuButtons: { + display: 'flex', + [theme.breakpoints.down('md')]: { + flex: 1, + }, + justifyContent: 'center', + [theme.breakpoints.down('sm')]: { + flexBasis: '100%', + marginTop: 5, + order: 1, + height: 50, justifyContent: 'space-between', - [theme.breakpoints.down('sm')]: { - flexWrap: 'wrap', - }, - }, - menuButtons: { - display: 'flex', - [theme.breakpoints.down('md')]: { - flex: 1, - }, - justifyContent: 'center', - [theme.breakpoints.down('sm')]: { - flexBasis: '100%', - marginTop: 5, - order: 1, - height: 50, - justifyContent: 'space-between', - alignItems: 'center', - }, - }, - title: { - [theme.breakpoints.up('md')]: { - flex: 1, - }, - display: 'flex', alignItems: 'center', }, - titleName: { - paddingRight: 10, + }, + title: { + [theme.breakpoints.up('md')]: { + flex: 1, }, - link: { - color: 'inherit', - textDecoration: 'none', - }, - } as const); + display: 'flex', + alignItems: 'center', + }, + titleName: { + paddingRight: 10, + }, + link: { + color: 'inherit', + textDecoration: 'none', + }, +})); interface IProps { loggedIn: boolean; name: string; admin: boolean; version: string; - classes?: Partial, string>>; toggleTheme: VoidFunction; showSettings: VoidFunction; logout: VoidFunction; @@ -77,107 +74,123 @@ interface IProps { setNavOpen: (open: boolean) => void; } -@observer -class Header extends Component { - public render() { - const {version, name, loggedIn, admin, toggleTheme, logout, style, setNavOpen} = this.props; +const Header = ({ + version, + name, + loggedIn, + admin, + toggleTheme, + logout, + style, + setNavOpen, + showSettings, +}: IProps) => { + const {classes} = useStyles(); - const classes = withStyles.getClasses(this.props); - - return ( - - -
- - - Gotify - - - - - @{version} - - -
- {loggedIn && this.renderButtons(name, admin, logout, setNavOpen)} -
- - - - - - - - - -
-
-
- ); - } - - private renderButtons( - name: string, - admin: boolean, - logout: VoidFunction, - setNavOpen: (open: boolean) => void - ) { - const classes = withStyles.getClasses(this.props); - const {showSettings} = this.props; - return ( -
- } - onClick={() => setNavOpen(true)} - label="menu" - color="inherit" - /> - {admin && ( - - } - label="users" - color="inherit" - /> + return ( + + +
+ + + Gotify + + + + @{version} + + +
+ {loggedIn && ( + )} - - } label="apps" color="inherit" /> +
+ + + + + + + + + +
+
+
+ ); +}; + +const Buttons = ({ + showSettings, + name, + admin, + logout, + setNavOpen, +}: { + name: string; + admin: boolean; + logout: VoidFunction; + setNavOpen: (open: boolean) => void; + showSettings: VoidFunction; +}) => { + const {classes} = useStyles(); + + return ( +
+ } + onClick={() => setNavOpen(true)} + label="menu" + color="inherit" + /> + {admin && ( + + } label="users" color="inherit" /> - - } label="clients" color="inherit" /> - - - } label="plugins" color="inherit" /> - - } - label={name} - onClick={showSettings} - id="changepw" - color="inherit" - /> - } - label="Logout" - onClick={logout} - id="logout" - color="inherit" - /> -
- ); - } -} + )} + + } label="apps" color="inherit" /> + + + } label="clients" color="inherit" /> + + + } label="plugins" color="inherit" /> + + } + label={name} + onClick={showSettings} + id="changepw" + color="inherit" + /> + } + label="Logout" + onClick={logout} + id="logout" + color="inherit" + /> +
+ ); +}; const ResponsiveButton: React.FC<{ color: 'inherit'; @@ -202,4 +215,4 @@ const ResponsiveButton: React.FC<{ ); }; -export default withStyles(Header, styles); +export default Header; diff --git a/ui/src/layout/Layout.tsx b/ui/src/layout/Layout.tsx index fa91054..5e46d14 100644 --- a/ui/src/layout/Layout.tsx +++ b/ui/src/layout/Layout.tsx @@ -1,5 +1,5 @@ import {createTheme, ThemeProvider, StyledEngineProvider, Theme} from '@mui/material'; -import {withStyles} from 'tss-react/mui'; +import {makeStyles} from 'tss-react/mui'; import CssBaseline from '@mui/material/CssBaseline'; import * as React from 'react'; import {HashRouter, Redirect, Route, Switch} from 'react-router-dom'; @@ -18,11 +18,10 @@ import Login from '../user/Login'; import Messages from '../message/Messages'; import Users from '../user/Users'; import {observer} from 'mobx-react'; -import {observable} from 'mobx'; -import {inject, Stores} from '../inject'; import {ConnectionErrorBanner} from '../common/ConnectionErrorBanner'; +import {useStores} from '../stores'; -const styles = (theme: Theme) => ({ +const useStyles = makeStyles()((theme: Theme) => ({ content: { margin: '0 auto', marginTop: 64, @@ -32,7 +31,7 @@ const styles = (theme: Theme) => ({ marginTop: 0, }, }, -}); +})); const localStorageThemeKey = 'gotify-theme'; type ThemeKey = 'dark' | 'light'; @@ -52,127 +51,103 @@ const themeMap: Record = { const isThemeKey = (value: string | null): value is ThemeKey => value === 'light' || value === 'dark'; -interface LayoutProps { - classes?: Partial, string>>; -} +const Layout = observer(() => { + const { + currentUser: { + loggedIn, + authenticating, + user: {name, admin}, + logout, + tryReconnect, + connectionErrorMessage, + }, + } = useStores(); + const {classes} = useStyles(); + const [currentTheme, setCurrentTheme] = React.useState(() => { + const stored = window.localStorage.getItem(localStorageThemeKey); + return isThemeKey(stored) ? stored : 'dark'; + }); + const theme = themeMap[currentTheme]; + const loginRoute = () => (loggedIn ? : ); + const {version} = config.get('version'); + const [navOpen, setNavOpen] = React.useState(false); + const [showSettings, setShowSettings] = React.useState(false); -@observer -class Layout extends React.Component> { - @observable - private currentTheme: ThemeKey = 'dark'; - @observable - private showSettings = false; - @observable - private navOpen = false; + const toggleTheme = () => { + const next = currentTheme === 'dark' ? 'light' : 'dark'; + setCurrentTheme(next); + localStorage.setItem(localStorageThemeKey, next); + }; - private setNavOpen(open: boolean) { - this.navOpen = open; - } - - public componentDidMount() { - const localStorageTheme = window.localStorage.getItem(localStorageThemeKey); - if (isThemeKey(localStorageTheme)) { - this.currentTheme = localStorageTheme; - } else { - window.localStorage.setItem(localStorageThemeKey, this.currentTheme); - } - } - - public render() { - const {showSettings, currentTheme} = this; - const { - currentUser: { - loggedIn, - authenticating, - user: {name, admin}, - logout, - tryReconnect, - connectionErrorMessage, - }, - } = this.props; - const classes = withStyles.getClasses(this.props); - const theme = themeMap[currentTheme]; - const loginRoute = () => (loggedIn ? : ); - const {version} = config.get('version'); - return ( - - - -
- {!connectionErrorMessage ? null : ( - tryReconnect()} - message={connectionErrorMessage} - /> - )} -
- -
+ + +
+ {!connectionErrorMessage ? null : ( + tryReconnect()} + message={connectionErrorMessage} + /> + )} +
+ +
setShowSettings(true)} + logout={logout} + setNavOpen={setNavOpen} + /> +
+ (this.showSettings = true)} - logout={logout} - setNavOpen={this.setNavOpen.bind(this)} + navOpen={navOpen} + setNavOpen={setNavOpen} /> -
- -
- - {authenticating ? ( - - - - ) : null} - - {loggedIn ? null : } - - - - - - - - -
-
- {showSettings && ( - (this.showSettings = false)} /> - )} - - +
+ + {authenticating ? ( + + + + ) : null} + + {loggedIn ? null : } + + + + + + + + +
+ {showSettings && ( + setShowSettings(false)} /> + )} + +
- - - - ); - } +
+
+
+ + ); +}); - private toggleTheme() { - this.currentTheme = this.currentTheme === 'dark' ? 'light' : 'dark'; - localStorage.setItem(localStorageThemeKey, this.currentTheme); - } -} - -export default withStyles(inject('currentUser', 'snackManager')(Layout), styles); +export default Layout; diff --git a/ui/src/layout/Navigation.tsx b/ui/src/layout/Navigation.tsx index ef01b0d..c587256 100644 --- a/ui/src/layout/Navigation.tsx +++ b/ui/src/layout/Navigation.tsx @@ -1,10 +1,9 @@ import Divider from '@mui/material/Divider'; import Drawer from '@mui/material/Drawer'; import {Theme} from '@mui/material/styles'; -import React, {Component} from 'react'; +import React from 'react'; import {Link} from 'react-router-dom'; import {observer} from 'mobx-react'; -import {inject, Stores} from '../inject'; import {mayAllowPermission, requestPermission} from '../snack/browserNotification'; import { Button, @@ -17,108 +16,100 @@ import { } from '@mui/material'; import {DrawerProps} from '@mui/material/Drawer/Drawer'; import CloseIcon from '@mui/icons-material/Close'; -import {withStyles} from 'tss-react/mui'; +import {makeStyles} from 'tss-react/mui'; +import {useStores} from '../stores'; -const styles = (theme: Theme) => - ({ - root: { - height: '100%', - }, - drawerPaper: { - position: 'relative', - width: 250, - minHeight: '100%', - height: '100vh', - }, - // eslint-disable-next-line - toolbar: theme.mixins.toolbar as any, - link: { - color: 'inherit', - textDecoration: 'none', - }, - } as const); +const useStyles = makeStyles()((theme: Theme) => ({ + root: { + height: '100%', + }, + drawerPaper: { + position: 'relative', + width: 250, + minHeight: '100%', + height: '100vh', + }, + // eslint-disable-next-line + toolbar: theme.mixins.toolbar as any, + link: { + color: 'inherit', + textDecoration: 'none', + }, +})); interface IProps { loggedIn: boolean; navOpen: boolean; - classes?: Partial, string>>; setNavOpen: (open: boolean) => void; } -@observer -class Navigation extends Component< - IProps & Stores<'appStore'>, - {showRequestNotification: boolean} -> { - public state = {showRequestNotification: mayAllowPermission()}; +const Navigation = observer(({loggedIn, navOpen, setNavOpen}: IProps) => { + const [showRequestNotification, setShowRequestNotification] = + React.useState(mayAllowPermission); + const {classes} = useStyles(); + const {appStore} = useStores(); + const apps = appStore.getItems(); - public render() { - const {loggedIn, appStore, navOpen, setNavOpen} = this.props; - const classes = withStyles.getClasses(this.props); - const {showRequestNotification} = this.state; - const apps = appStore.getItems(); + const userApps = + apps.length === 0 + ? null + : apps.map((app) => ( + setNavOpen(false)} + className={`${classes.link} item`} + to={'/messages/' + app.id} + key={app.id}> + + + + + + + + )); - const userApps = - apps.length === 0 - ? null - : apps.map((app) => ( - setNavOpen(false)} - className={`${classes.link} item`} - to={'/messages/' + app.id} - key={app.id}> - - - - - - - - )); + const placeholderItems = [ + + + , + + + , + ]; - const placeholderItems = [ - - - , - - - , - ]; - - return ( - -
- setNavOpen(false)}> - - - - - -
{loggedIn ? userApps : placeholderItems}
- - - {showRequestNotification ? ( - - ) : null} - - - ); - } -} + return ( + +
+ setNavOpen(false)}> + + + + + +
{loggedIn ? userApps : placeholderItems}
+ + + {showRequestNotification ? ( + + ) : null} + + + ); +}); const ResponsiveDrawer: React.FC< DrawerProps & {navOpen: boolean; setNavOpen: (open: boolean) => void} @@ -140,4 +131,4 @@ const ResponsiveDrawer: React.FC< ); -export default withStyles(inject('appStore')(Navigation), styles); +export default Navigation; diff --git a/ui/src/plugin/PluginDetailView.tsx b/ui/src/plugin/PluginDetailView.tsx index 8a29a9f..2bbb770 100644 --- a/ui/src/plugin/PluginDetailView.tsx +++ b/ui/src/plugin/PluginDetailView.tsx @@ -1,5 +1,5 @@ -import React, {Component} from 'react'; -import {RouteComponentProps} from 'react-router'; +import React from 'react'; +import {useParams} from 'react-router'; import {Markdown} from '../common/Markdown'; import {UnControlled as CodeMirror} from 'react-codemirror2'; import 'codemirror/lib/codemirror.css'; @@ -14,110 +14,80 @@ import Typography from '@mui/material/Typography'; import DefaultPage from '../common/DefaultPage'; import * as config from '../config'; import Container from '../common/Container'; -import {inject, Stores} from '../inject'; import {IPlugin} from '../types'; import LoadingSpinner from '../common/LoadingSpinner'; +import {useStores} from '../stores'; -type IProps = RouteComponentProps<{id: string}>; +const PluginDetailView = () => { + const {id} = useParams<{id: string}>(); + const pluginID = parseInt(id as string, 10); + const {pluginStore} = useStores(); + const [currentConfig, setCurrentConfig] = React.useState(); + const [displayText, setDisplayText] = React.useState(); -interface IState { - displayText: string | null; - currentConfig: string | null; -} + const pluginInfo = pluginStore.getByIDOrUndefined(pluginID); -class PluginDetailView extends Component, IState> { - private pluginID: number = parseInt(this.props.match.params.id, 10); - private pluginInfo = () => this.props.pluginStore.getByID(this.pluginID); - - public state: IState = { - displayText: null, - currentConfig: null, + const refreshFeatures = async () => { + await pluginStore.refreshIfMissing(pluginID); + await Promise.all([refreshConfigurer(), refreshDisplayer()]); }; - public componentWillMount() { - this.refreshFeatures(); - } + React.useEffect(() => void refreshFeatures(), [pluginID]); - public componentWillReceiveProps(nextProps: IProps & Stores<'pluginStore'>) { - this.pluginID = parseInt(nextProps.match.params.id, 10); - this.refreshFeatures(); - } - - private async refreshFeatures() { - await this.props.pluginStore.refreshIfMissing(this.pluginID); - return await Promise.all([this.refreshConfigurer(), this.refreshDisplayer()]); - } - - private async refreshConfigurer() { - const { - props: {pluginStore}, - } = this; - if (this.pluginInfo().capabilities.indexOf('configurer') !== -1) { - const response = await pluginStore.requestConfig(this.pluginID); - this.setState({currentConfig: response}); + const refreshConfigurer = async () => { + if (pluginInfo?.capabilities.indexOf('configurer') !== -1) { + setCurrentConfig(await pluginStore.requestConfig(pluginID)); } + }; + + const refreshDisplayer = async () => { + if (pluginInfo?.capabilities.indexOf('displayer') !== -1) { + setDisplayText(await pluginStore.requestDisplay(pluginID)); + } + }; + + if (pluginInfo == null) { + return ; } - private async refreshDisplayer() { - const { - props: {pluginStore}, - } = this; - if (this.pluginInfo().capabilities.indexOf('displayer') !== -1) { - const response = await pluginStore.requestDisplay(this.pluginID); - this.setState({displayText: response}); - } - } + const handleSaveConfig = async (newConfig: string) => { + await pluginStore.changeConfig(pluginID, newConfig); + await refreshFeatures(); + }; - public render() { - const pluginInfo = this.props.pluginStore.getByIDOrUndefined(this.pluginID); - if (pluginInfo === undefined) { - return ; - } - return ( - - - + return ( + + + + + {pluginInfo.capabilities.indexOf('configurer') !== -1 ? ( + + - {pluginInfo.capabilities.indexOf('configurer') !== -1 ? ( - - { - await this.props.pluginStore.changeConfig(this.pluginID, newConfig); - await this.refreshFeatures(); - }} - /> - - ) : null}{' '} - {pluginInfo.capabilities.indexOf('displayer') !== -1 ? ( - - - - ) : null} - - ); - } -} + ) : null}{' '} + {pluginInfo.capabilities.indexOf('displayer') !== -1 ? ( + + + + ) : null} + + ); +}; interface IPanelWrapperProps { name: string; @@ -178,49 +148,43 @@ interface IConfigurerPanelProps { initialConfig: string; save: (newConfig: string) => Promise; } -class ConfigurerPanel extends Component { - public state = {unsavedChanges: null}; - - public render() { - return ( -
- { - let newConf: string | null = value; - if (value === this.props.initialConfig) { - newConf = null; - } - this.setState({unsavedChanges: newConf}); - }} - /> -
- -
- ); - } -} + setUnsavedChanges(newConf); + }} + /> +
+ +
+ ); +}; interface IDisplayerPanelProps { pluginInfo: IPlugin; @@ -232,58 +196,57 @@ const DisplayerPanel: React.FC = ({displayText}) => ( ); -class PluginInfo extends Component<{pluginInfo: IPlugin}> { - public render() { - const { - props: { - pluginInfo: {name, author, modulePath, website, license, capabilities, id, token}, - }, - } = this; - return ( -
- {name ? ( - - Name: {name} - - ) : null} - {author ? ( - - Author: {author} - - ) : null} - - Module Path: {modulePath} - - {website ? ( - - Website: {website} - - ) : null} - {license ? ( - - License: {license} - - ) : null} - - Capabilities: {capabilities.join(', ')} - - {capabilities.indexOf('webhooker') !== -1 ? ( - - Custom Route Prefix:{' '} - {((url) => ( - - {url} - - ))(`${config.get('url')}plugin/${id}/custom/${token}/`)} - - ) : null} -
- ); - } +interface IPluginInfo { + pluginInfo: IPlugin; } -export default inject('pluginStore')(PluginDetailView); +const PluginInfo = ({pluginInfo}: IPluginInfo) => { + const {name, author, modulePath, website, license, capabilities, id, token} = pluginInfo; + + return ( +
+ {name ? ( + + Name: {name} + + ) : null} + {author ? ( + + Author: {author} + + ) : null} + + Module Path: {modulePath} + + {website ? ( + + Website: {website} + + ) : null} + {license ? ( + + License: {license} + + ) : null} + + Capabilities: {capabilities.join(', ')} + + {capabilities.indexOf('webhooker') !== -1 ? ( + + Custom Route Prefix:{' '} + {((url) => ( + + {url} + + ))(`${config.get('url')}plugin/${id}/custom/${token}/`)} + + ) : null} +
+ ); +}; + +export default PluginDetailView; diff --git a/ui/src/plugin/Plugins.tsx b/ui/src/plugin/Plugins.tsx index 2535598..1874353 100644 --- a/ui/src/plugin/Plugins.tsx +++ b/ui/src/plugin/Plugins.tsx @@ -1,4 +1,4 @@ -import React, {Component, SFC} from 'react'; +import React, {SFC} from 'react'; import {Link} from 'react-router-dom'; import Grid from '@mui/material/Grid'; import Paper from '@mui/material/Paper'; @@ -12,56 +12,47 @@ import {Switch, Button} from '@mui/material'; import DefaultPage from '../common/DefaultPage'; import CopyableSecret from '../common/CopyableSecret'; import {observer} from 'mobx-react'; -import {inject, Stores} from '../inject'; import {IPlugin} from '../types'; +import {useStores} from '../stores'; -@observer -class Plugins extends Component> { - public componentDidMount = () => this.props.pluginStore.refresh(); - - public render() { - const { - props: {pluginStore}, - } = this; - const plugins = pluginStore.getItems(); - return ( - - - - - - - ID - Enabled - Name - Token - Details - - - - {plugins.map((plugin: IPlugin) => ( - - this.props.pluginStore.changeEnabledState( - plugin.id, - !plugin.enabled - ) - } - /> - ))} - -
-
-
-
- ); - } -} +const Plugins = observer(() => { + const {pluginStore} = useStores(); + React.useEffect(() => void pluginStore.refresh(), []); + const plugins = pluginStore.getItems(); + return ( + + + + + + + ID + Enabled + Name + Token + Details + + + + {plugins.map((plugin: IPlugin) => ( + + pluginStore.changeEnabledState(plugin.id, !plugin.enabled) + } + /> + ))} + +
+
+
+
+ ); +}); interface IRowProps { id: number; @@ -96,4 +87,4 @@ const Row: SFC = observer(({name, id, token, enabled, fToggleStatus}) )); -export default inject('pluginStore')(Plugins); +export default Plugins; diff --git a/ui/src/stores.tsx b/ui/src/stores.tsx new file mode 100644 index 0000000..325b90f --- /dev/null +++ b/ui/src/stores.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import {UserStore} from './user/UserStore'; +import {SnackManager} from './snack/SnackManager'; +import {MessagesStore} from './message/MessagesStore'; +import {CurrentUser} from './CurrentUser'; +import {ClientStore} from './client/ClientStore'; +import {AppStore} from './application/AppStore'; +import {WebSocketStore} from './message/WebSocketStore'; +import {PluginStore} from './plugin/PluginStore'; + +export interface StoreMapping { + userStore: UserStore; + snackManager: SnackManager; + messagesStore: MessagesStore; + currentUser: CurrentUser; + clientStore: ClientStore; + appStore: AppStore; + pluginStore: PluginStore; + wsStore: WebSocketStore; +} + +export const StoreContext = React.createContext(undefined); + +export const useStores = (): StoreMapping => { + const mapping = React.useContext(StoreContext); + if (!mapping) throw new Error('uninitialized'); + return mapping; +}; diff --git a/ui/src/user/AddEditUserDialog.tsx b/ui/src/user/AddEditUserDialog.tsx index 87bf482..720b883 100644 --- a/ui/src/user/AddEditUserDialog.tsx +++ b/ui/src/user/AddEditUserDialog.tsx @@ -7,118 +7,101 @@ import FormControlLabel from '@mui/material/FormControlLabel'; import Switch from '@mui/material/Switch'; import TextField from '@mui/material/TextField'; import Tooltip from '@mui/material/Tooltip'; -import React, {ChangeEvent, Component} from 'react'; +import React from 'react'; interface IProps { name?: string; admin?: boolean; fClose: VoidFunction; - fOnSubmit: (name: string, pass: string, admin: boolean) => void; + fOnSubmit: (name: string, pass: string, admin: boolean) => Promise; isEdit?: boolean; } -interface IState { - name: string; - pass: string; - admin: boolean; -} +const AddEditUserDialog = ({ + fClose, + fOnSubmit, + isEdit, + name: initialName = '', + admin: initialAdmin = false, +}: IProps) => { + const [name, setName] = React.useState(initialName); + const [pass, setPass] = React.useState(''); + const [admin, setAdmin] = React.useState(initialAdmin); -export default class AddEditDialog extends Component { - public state = { - name: this.props.name ?? '', - pass: '', - admin: this.props.admin ?? false, + const namePresent = name.length !== 0; + const passPresent = pass.length !== 0 || isEdit; + const submitAndClose = async () => { + await fOnSubmit(name, pass, admin); + fClose(); }; - - public render() { - const {fClose, fOnSubmit, isEdit} = this.props; - const {name, pass, admin} = this.state; - const namePresent = this.state.name.length !== 0; - const passPresent = this.state.pass.length !== 0 || isEdit; - const submitAndClose = () => { - fOnSubmit(name, pass, admin); - fClose(); - }; - return ( - - - {isEdit ? 'Edit ' + this.props.name : 'Add a user'} - - - - - - } - label="has administrator rights" - /> - - - - -
- -
-
-
-
- ); - } - - private handleChange(propertyName: 'name' | 'pass', event: ChangeEvent) { - const state = this.state; - state[propertyName] = event.target.value; - this.setState(state); - } - - private handleChecked(propertyName: 'admin', event: ChangeEvent) { - const state = this.state; - state[propertyName] = event.target.checked; - this.setState(state); - } -} + return ( + + + {isEdit ? 'Edit ' + name : 'Add a user'} + + + setName(e.target.value)} + fullWidth + /> + setPass(e.target.value)} + /> + setAdmin(e.target.checked)} + value="admin" + /> + } + label="has administrator rights" + /> + + + + +
+ +
+
+
+
+ ); +}; +export default AddEditUserDialog; diff --git a/ui/src/user/Login.tsx b/ui/src/user/Login.tsx index 3c767f2..d8e47f2 100644 --- a/ui/src/user/Login.tsx +++ b/ui/src/user/Login.tsx @@ -1,97 +1,85 @@ import Button from '@mui/material/Button'; import Grid from '@mui/material/Grid'; import TextField from '@mui/material/TextField'; -import React, {Component, FormEvent} from 'react'; +import React from 'react'; import Container from '../common/Container'; import DefaultPage from '../common/DefaultPage'; -import {observable} from 'mobx'; -import {observer} from 'mobx-react'; -import {inject, Stores} from '../inject'; import * as config from '../config'; import RegistrationDialog from './Register'; +import {useStores} from '../stores'; +import {observer} from 'mobx-react'; -@observer -class Login extends Component> { - @observable - private username = ''; - @observable - private password = ''; - @observable - private registerDialog = false; - - public render() { - const {username, password, registerDialog} = this; - return ( - - - -
- (this.username = e.target.value)} - /> - (this.password = e.target.value)} - /> - - -
-
- {registerDialog && ( - (this.registerDialog = false)} - fOnSubmit={this.props.currentUser.register} - /> - )} -
- ); - } - - private login = (e: React.MouseEvent) => { - e.preventDefault(); - this.props.currentUser.login(this.username, this.password); - }; - - private registerButton = () => { +const Login = observer(() => { + const [username, setUsername] = React.useState(''); + const [password, setPassword] = React.useState(''); + const [registerDialog, setRegisterDialog] = React.useState(false); + const {currentUser} = useStores(); + const registerButton = () => { if (config.get('register')) return ( ); else return null; }; + const login = (e: React.MouseEvent) => { + e.preventDefault(); + currentUser.login(username, password); + }; + return ( + + + +
e.preventDefault()} id="login-form"> + setUsername(e.target.value)} + /> + setPassword(e.target.value)} + /> + + +
+
+ {registerDialog && ( + setRegisterDialog(false)} + fOnSubmit={currentUser.register} + /> + )} +
+ ); +}); - private preventDefault = (e: FormEvent) => e.preventDefault(); -} - -export default inject('currentUser')(Login); +export default Login; diff --git a/ui/src/user/Register.tsx b/ui/src/user/Register.tsx index bd1589c..5b8e32f 100644 --- a/ui/src/user/Register.tsx +++ b/ui/src/user/Register.tsx @@ -5,7 +5,7 @@ import DialogContent from '@mui/material/DialogContent'; import DialogTitle from '@mui/material/DialogTitle'; import TextField from '@mui/material/TextField'; import Tooltip from '@mui/material/Tooltip'; -import React, {ChangeEvent, Component} from 'react'; +import React from 'react'; interface IProps { name?: string; @@ -13,92 +13,85 @@ interface IProps { fOnSubmit: (name: string, pass: string) => Promise; } -interface IState { - name: string; - pass: string; -} +const RegistrationDialog = ({fClose, fOnSubmit, name: initialName = ''}: IProps) => { + const [name, setName] = React.useState(initialName); + const [pass, setPass] = React.useState(''); + const namePresent = name.length !== 0; + const passPresent = pass.length !== 0; -export default class RegistrationDialog extends Component { - public state = { - name: '', - pass: '', + const handleNameChange = (e: React.ChangeEvent) => { + setName(e.target.value); }; - public render() { - const {fClose, fOnSubmit} = this.props; - const {name, pass} = this.state; - const namePresent = this.state.name.length !== 0; - const passPresent = this.state.pass.length !== 0; - const submitAndClose = (): void => { - fOnSubmit(name, pass).then((success) => { - if (success) { - fClose(); - } - }); - }; - return ( - - Registration - - - - - - - -
- -
-
-
-
- ); - } + const handlePassChange = (e: React.ChangeEvent) => { + setPass(e.target.value); + }; - private handleChange(propertyName: keyof IState, event: ChangeEvent) { - const state = this.state; - state[propertyName] = event.target.value; - this.setState(state); - } -} + const submitAndClose = (): void => { + fOnSubmit(name, pass).then((success) => { + if (success) { + fClose(); + } + }); + }; + + return ( + + Registration + + + + + + + +
+ +
+
+
+
+ ); +}; +export default RegistrationDialog; diff --git a/ui/src/user/Users.tsx b/ui/src/user/Users.tsx index 644dea9..6387445 100644 --- a/ui/src/user/Users.tsx +++ b/ui/src/user/Users.tsx @@ -8,15 +8,14 @@ import TableHead from '@mui/material/TableHead'; import TableRow from '@mui/material/TableRow'; import Delete from '@mui/icons-material/Delete'; import Edit from '@mui/icons-material/Edit'; -import React, {Component, SFC} from 'react'; +import React from 'react'; import ConfirmDialog from '../common/ConfirmDialog'; import DefaultPage from '../common/DefaultPage'; import Button from '@mui/material/Button'; import AddEditDialog from './AddEditUserDialog'; -import {observer} from 'mobx-react'; -import {observable} from 'mobx'; -import {inject, Stores} from '../inject'; import {IUser} from '../types'; +import {useStores} from '../stores'; +import {observer} from 'mobx-react'; interface IRowProps { name: string; @@ -25,7 +24,7 @@ interface IRowProps { fEdit: VoidFunction; } -const UserRow: SFC = ({name, admin, fDelete, fEdit}) => ( +const UserRow: React.FC = ({name, admin, fDelete, fEdit}) => ( {name} {admin ? 'Yes' : 'No'} @@ -40,87 +39,71 @@ const UserRow: SFC = ({name, admin, fDelete, fEdit}) => ( ); -@observer -class Users extends Component> { - @observable - private createDialog = false; - @observable - private deleteId: number | false = false; - @observable - private editId: number | false = false; +const Users = observer(() => { + const [deleteUser, setDeleteUser] = React.useState(); + const [editUser, setEditUser] = React.useState(); + const [createDialog, setCreateDialog] = React.useState(false); + const {userStore} = useStores(); + React.useEffect(() => void userStore.refresh(), []); + const users = userStore.getItems(); + return ( + setCreateDialog(true)}> + Create User + + }> + + + + + + Username + Admin + + + + + {users.map((user: IUser) => ( + setDeleteUser(user)} + fEdit={() => setEditUser(user)} + /> + ))} + +
+
+
+ {createDialog && ( + setCreateDialog(false)} fOnSubmit={userStore.create} /> + )} + {editUser && ( + setEditUser(undefined)} + fOnSubmit={userStore.update.bind(this, editUser.id)} + name={editUser.name} + admin={editUser.admin} + isEdit={true} + /> + )} + {deleteUser && ( + setDeleteUser(undefined)} + fOnSubmit={() => userStore.remove(deleteUser.id)} + /> + )} +
+ ); +}); - public componentDidMount = () => this.props.userStore.refresh(); - - public render() { - const { - deleteId, - editId, - createDialog, - props: {userStore}, - } = this; - const users = userStore.getItems(); - return ( - (this.createDialog = true)}> - Create User - - }> - - - - - - Username - Admin - - - - - {users.map((user: IUser) => ( - (this.deleteId = user.id)} - fEdit={() => (this.editId = user.id)} - /> - ))} - -
-
-
- {createDialog && ( - (this.createDialog = false)} - fOnSubmit={userStore.create} - /> - )} - {editId !== false && ( - (this.editId = false)} - fOnSubmit={userStore.update.bind(this, editId)} - name={userStore.getByID(editId).name} - admin={userStore.getByID(editId).admin} - isEdit={true} - /> - )} - {deleteId !== false && ( - (this.deleteId = false)} - fOnSubmit={() => userStore.remove(deleteId)} - /> - )} -
- ); - } -} - -export default inject('userStore')(Users); +export default Users;