Merge pull request #818 from gotify/upgrade-ui

Upgrade UI
This commit is contained in:
Jannis Mattheis 2025-08-08 10:58:23 +02:00 committed by GitHub
commit ebf6a6423d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
65 changed files with 5168 additions and 14162 deletions

View File

@ -7,12 +7,11 @@ ifdef GOTOOLCHAIN
else else
GO_VERSION=$(shell go mod edit -json | jq -r .Toolchain | sed -e 's/go//') GO_VERSION=$(shell go mod edit -json | jq -r .Toolchain | sed -e 's/go//')
endif endif
DOCKER_BUILD_IMAGE=gotify/build DOCKER_BUILD_IMAGE=docker.io/gotify/build
DOCKER_WORKDIR=/proj DOCKER_WORKDIR=/proj
DOCKER_RUN=docker run --rm -e LD_FLAGS="$$LD_FLAGS" -v "$$PWD/.:${DOCKER_WORKDIR}" -v "`go env GOPATH`/pkg/mod/.:/go/pkg/mod:ro" -w ${DOCKER_WORKDIR} DOCKER_RUN=docker run --rm -e LD_FLAGS="$$LD_FLAGS" -v "$$PWD/.:${DOCKER_WORKDIR}" -v "`go env GOPATH`/pkg/mod/.:/go/pkg/mod:ro" -w ${DOCKER_WORKDIR}
DOCKER_GO_BUILD=go build -mod=readonly -a -installsuffix cgo -ldflags "$$LD_FLAGS" DOCKER_GO_BUILD=go build -mod=readonly -a -installsuffix cgo -ldflags "$$LD_FLAGS"
DOCKER_TEST_LEVEL ?= 0 # Optionally run a test during docker build DOCKER_TEST_LEVEL ?= 0 # Optionally run a test during docker build
NODE_OPTIONS=$(shell if node --help | grep -q -- "--openssl-legacy-provider"; then echo --openssl-legacy-provider; fi)
test: test-coverage test-js test: test-coverage test-js
check: check-go check-swagger check-js check: check-go check-swagger check-js
@ -116,7 +115,7 @@ _build_within_docker:
${DOCKER_GO_BUILD} -o ${OUTPUT} ${DOCKER_GO_BUILD} -o ${OUTPUT}
build-js: build-js:
(cd ui && NODE_OPTIONS="${NODE_OPTIONS}" yarn build) (cd ui && yarn build)
build-linux-amd64: build-linux-amd64:
${DOCKER_RUN} ${DOCKER_BUILD_IMAGE}:$(GO_VERSION)-linux-amd64 make _build_within_docker OUTPUT=${BUILD_DIR}/gotify-linux-amd64 ${DOCKER_RUN} ${DOCKER_BUILD_IMAGE}:$(GO_VERSION)-linux-amd64 make _build_within_docker OUTPUT=${BUILD_DIR}/gotify-linux-amd64

View File

@ -1,2 +0,0 @@
src/setupTests.ts
src/registerServiceWorker.ts

View File

@ -1,91 +0,0 @@
---
extends:
- eslint:recommended
- plugin:@typescript-eslint/eslint-recommended
- plugin:@typescript-eslint/recommended
- plugin:@typescript-eslint/recommended-requiring-type-checking
- plugin:react/recommended
- plugin:import/errors
- plugin:import/typescript
- plugin:jest/recommended
- prettier
env:
browser: true
es6: true
node: true
parser: "@typescript-eslint/parser"
parserOptions:
project: tsconfig.json
sourceType: module
plugins:
- "@typescript-eslint"
- react
- import
- unicorn
settings:
react:
version: detect
rules:
consistent-return: error
default-case: error
default-param-last: error
no-loop-func: off
arrow-body-style: [error, as-needed]
import/no-useless-path-segments: error
import/group-exports: off
import/extensions: [error, never]
import/no-duplicates: error
import/first: error
import/no-unused-modules: error
unicorn/no-abusive-eslint-disable: off
unicorn/no-array-instanceof: error
unicorn/no-unreadable-array-destructuring: error
unicorn/no-zero-fractions: error
react/jsx-key: error
react/jsx-pascal-case: error
react/destructuring-assignment: off
react/function-component-definition: off
react/no-array-index-key: error
react/no-deprecated: off
react/no-string-refs: error
react/no-this-in-sfc: error
react/no-typos: error
react/no-unknown-property: error
react/prefer-stateless-function: off
react/prop-types: off
jest/expect-expect: off
jest/no-jasmine-globals: off
"@typescript-eslint/require-await": off
"@typescript-eslint/restrict-template-expressions": off
"@typescript-eslint/array-type": [error, {default: array-simple}]
"@typescript-eslint/await-thenable": error
"@typescript-eslint/no-unused-vars": error
"@typescript-eslint/no-use-before-define": off
"@typescript-eslint/no-unsafe-call": off
"@typescript-eslint/consistent-type-assertions": [error, {assertionStyle: as}]
"@typescript-eslint/no-extra-non-null-assertion": error
"@typescript-eslint/no-inferrable-types": error
"@typescript-eslint/no-this-alias": error
"@typescript-eslint/no-throw-literal": error
"@typescript-eslint/no-non-null-assertion": off
"@typescript-eslint/prefer-nullish-coalescing": error
"@typescript-eslint/prefer-optional-chain": error
"@typescript-eslint/prefer-readonly": off
"@typescript-eslint/unbound-method": error
"@typescript-eslint/no-empty-function": off
"@typescript-eslint/explicit-module-boundary-types": off
"@typescript-eslint/ban-ts-comment": off
"@typescript-eslint/no-floating-promises": off
"@typescript-eslint/no-unsafe-member-access": off
"@typescript-eslint/no-unsafe-return": off
"@typescript-eslint/no-unsafe-assignment": off
"@typescript-eslint/restrict-plus-operands": off
"@typescript-eslint/no-misused-promises": off
"@typescript-eslint/no-explicit-any": error

6
ui/eslint.config.mjs Normal file
View File

@ -0,0 +1,6 @@
// @ts-check
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
export default tseslint.config(eslint.configs.recommended, tseslint.configs.recommended);

View File

@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="theme-color" content="#3f51b5"> <meta name="theme-color" content="#3f51b5">
<link rel="manifest" href="%PUBLIC_URL%/manifest.json"> <link rel="manifest" href="manifest.json">
<title>Gotify</title> <title>Gotify</title>
<link rel="apple-touch-icon-precomposed" sizes="57x57" href="static/apple-touch-icon-57x57.png" /> <link rel="apple-touch-icon-precomposed" sizes="57x57" href="static/apple-touch-icon-57x57.png" />
@ -35,8 +35,7 @@
Gotify requires JavaScript. Gotify requires JavaScript.
</noscript> </noscript>
<div id="root"></div> <div id="root"></div>
<% if (process.env.NODE_ENV === 'production') { %> <script>window.config = %CONFIG%;</script>
<script>window.config = %CONFIG%;</script> <script type="module" src="/src/index.tsx"></script>
<% } %>
</body> </body>
</html> </html>

View File

@ -5,72 +5,59 @@
"homepage": ".", "homepage": ".",
"proxy": "http://localhost:80", "proxy": "http://localhost:80",
"dependencies": { "dependencies": {
"@material-ui/core": "^4.11.4", "@emotion/react": "^11.14.0",
"@material-ui/icons": "^4.9.1", "@emotion/styled": "^11.14.1",
"axios": "^0.21.1", "@mui/icons-material": "^7.2.0",
"codemirror": "^5.61.1", "@mui/material": "^7.2.0",
"detect-browser": "^5.2.0", "@uiw/codemirror-theme-material": "^4.24.2",
"js-base64": "^3.6.1", "@uiw/react-codemirror": "^4.24.2",
"mobx": "^5.15.6", "@vitejs/plugin-react": "^4.7.0",
"mobx-react": "^6.3.0", "axios": "^1.11.0",
"mobx-utils": "^5.6.1", "detect-browser": "^5.3.0",
"mobx": "^6.13.7",
"mobx-react": "^9.2.0",
"mobx-utils": "^6.1.1",
"notifyjs": "^3.0.0", "notifyjs": "^3.0.0",
"prop-types": "^15.6.2", "notistack": "^3.0.2",
"react": "^16.4.2", "react": "^19.1.1",
"react-codemirror2": "^7.2.1", "react-dom": "^19.1.1",
"react-dom": "^16.4.2", "react-markdown": "^10.1.0",
"react-infinite": "^0.13.0", "react-router": "^7.7.1",
"react-markdown": "^6.0.2", "react-router-dom": "^7.7.1",
"react-router": "^5.2.0", "react-timeago": "^8.3.0",
"react-router-dom": "^5.2.0", "react-virtuoso": "^4.13.0",
"react-timeago": "^6.2.1", "remark-gfm": "^4.0.1",
"remark-gfm": "^1.0.0", "remove-markdown": "^0.6.2",
"remove-markdown": "^0.3.0", "tss-react": "^4.9.19",
"typeface-roboto": "1.1.13" "typeface-roboto": "1.1.13",
"vite": "^7.0.6",
"vitest": "^3.2.4"
}, },
"scripts": { "scripts": {
"start": "react-scripts start", "start": "vite",
"build": "react-scripts build", "prebuild": "tsc",
"test": "react-scripts test --env=node", "build": "vite build",
"eject": "react-scripts eject", "test": "vitest --disable-console-intercept --no-file-parallelism",
"lint": "eslint \"src/**/*.{ts,tsx}\"", "lint": "eslint \"src/**/*.{ts,tsx}\"",
"format": "prettier \"src/**/*.{ts,tsx}\" --write", "format": "prettier \"src/**/*.{ts,tsx}\" --write",
"testformat": "prettier \"src/**/*.{ts,tsx}\" --list-different" "testformat": "prettier \"src/**/*.{ts,tsx}\" --list-different"
}, },
"devDependencies": { "devDependencies": {
"@types/codemirror": "5.60.0", "@eslint/js": "^9.32.0",
"@types/detect-browser": "^4.0.0", "@types/notifyjs": "^3.0.5",
"@types/get-port": "^4.0.0", "@types/react": "^19.1.9",
"@types/jest": "^26.0.23", "@types/react-dom": "^19.1.7",
"@types/js-base64": "^3.3.1", "@types/react-router-dom": "^5.3.3",
"@types/node": "^15.12.2", "@types/remove-markdown": "^0.3.4",
"@types/notifyjs": "^3.0.2", "eslint": "^9.32.0",
"@types/puppeteer": "^5.4.6", "get-port": "^7.1.0",
"@types/react": "^16.9.49", "prettier": "^3.6.2",
"@types/react-dom": "^16.9.8", "puppeteer": "^24.15.0",
"@types/react-infinite": "0.0.35", "rimraf": "^6.0.1",
"@types/react-router-dom": "^5.1.7",
"@types/remove-markdown": "^0.3.0",
"@types/rimraf": "^3.0.0",
"@typescript-eslint/eslint-plugin": "^4.1.0",
"@typescript-eslint/parser": "^4.1.0",
"eslint-config-prettier": "^6.11.0",
"eslint-plugin-import": "^2.22.0",
"eslint-plugin-jest": "^24.0.0",
"eslint-plugin-prefer-arrow": "^1.2.2",
"eslint-plugin-react": "^7.20.6",
"eslint-plugin-unicorn": "^21.0.0",
"get-port": "^5.1.1",
"prettier": "^2.3.1",
"puppeteer": "^17.1.3",
"react-scripts": "^4.0.3",
"rimraf": "^3.0.2",
"tree-kill": "^1.2.0", "tree-kill": "^1.2.0",
"typescript": "4.0.2", "typescript": "^5.9.2",
"wait-on": "^5.3.0" "typescript-eslint": "^8.38.0",
}, "wait-on": "^8.0.4"
"eslintConfig": {
"extends": "react-app"
}, },
"browserslist": { "browserslist": {
"production": [ "production": [

View File

@ -35,7 +35,6 @@ func Register(r *gin.Engine, version model.VersionInfo, register bool) {
ui.GET("/", serveFile("index.html", "text/html", replaceConfig)) ui.GET("/", serveFile("index.html", "text/html", replaceConfig))
ui.GET("/index.html", serveFile("index.html", "text/html", replaceConfig)) ui.GET("/index.html", serveFile("index.html", "text/html", replaceConfig))
ui.GET("/manifest.json", serveFile("manifest.json", "application/json", noop)) ui.GET("/manifest.json", serveFile("manifest.json", "application/json", noop))
ui.GET("/asset-manifest.json", serveFile("asset-manifest.json", "application/json", noop))
subBox, err := fs.Sub(box, "build") subBox, err := fs.Sub(box, "build")
if err != nil { if err != nil {

View File

@ -1,9 +1,8 @@
import axios, {AxiosError, AxiosResponse} from 'axios'; import axios, {AxiosError, AxiosResponse} from 'axios';
import * as config from './config'; import * as config from './config';
import {Base64} from 'js-base64';
import {detect} from 'detect-browser'; import {detect} from 'detect-browser';
import {SnackReporter} from './snack/SnackManager'; import {SnackReporter} from './snack/SnackManager';
import {observable} from 'mobx'; import {observable, makeObservable} from 'mobx';
import {IClient, IUser} from './types'; import {IClient, IUser} from './types';
const tokenKey = 'gotify-login-key'; const tokenKey = 'gotify-login-key';
@ -12,16 +11,21 @@ export class CurrentUser {
private tokenCache: string | null = null; private tokenCache: string | null = null;
private reconnectTimeoutId: number | null = null; private reconnectTimeoutId: number | null = null;
private reconnectTime = 7500; private reconnectTime = 7500;
@observable
public loggedIn = false; public loggedIn = false;
@observable public refreshKey = 0;
public authenticating = true; public authenticating = true;
@observable
public user: IUser = {name: 'unknown', admin: false, id: -1}; public user: IUser = {name: 'unknown', admin: false, id: -1};
@observable
public connectionErrorMessage: string | null = null; public connectionErrorMessage: string | null = null;
public constructor(private readonly snack: SnackReporter) {} public constructor(private readonly snack: SnackReporter) {
makeObservable(this, {
loggedIn: observable,
authenticating: observable,
user: observable,
connectionErrorMessage: observable,
refreshKey: observable,
});
}
public token = (): string => { public token = (): string => {
if (this.tokenCache !== null) { if (this.tokenCache !== null) {
@ -51,12 +55,13 @@ export class CurrentUser {
this.login(name, pass); this.login(name, pass);
return true; return true;
}) })
.catch((error: AxiosError) => { .catch((error: AxiosError<{error?: string; errorDescription?: string}>) => {
if (!error || !error.response) { if (!error || !error.response) {
this.snack('No network connection or server unavailable.'); this.snack('No network connection or server unavailable.');
return false; return false;
} }
const {data} = error.response; const {data} = error.response;
this.snack( this.snack(
`Register failed: ${data?.error ?? 'unknown'}: ${data?.errorDescription ?? ''}` `Register failed: ${data?.error ?? 'unknown'}: ${data?.errorDescription ?? ''}`
); );
@ -74,8 +79,7 @@ export class CurrentUser {
url: config.get('url') + 'client', url: config.get('url') + 'client',
method: 'POST', method: 'POST',
data: {name}, data: {name},
// eslint-disable-next-line @typescript-eslint/naming-convention headers: {Authorization: 'Basic ' + btoa(username + ':' + password)},
headers: {Authorization: 'Basic ' + Base64.encode(username + ':' + password)},
}) })
.then((resp: AxiosResponse<IClient>) => { .then((resp: AxiosResponse<IClient>) => {
this.snack(`A client named '${name}' was created for your session.`); this.snack(`A client named '${name}' was created for your session.`);
@ -98,41 +102,38 @@ export class CurrentUser {
return Promise.reject(); return Promise.reject();
} }
return ( return axios
axios .create()
.create() .get(config.get('url') + 'current/user', {headers: {'X-Gotify-Key': this.token()}})
// eslint-disable-next-line @typescript-eslint/naming-convention .then((passThrough) => {
.get(config.get('url') + 'current/user', {headers: {'X-Gotify-Key': this.token()}}) this.user = passThrough.data;
.then((passThrough) => { this.loggedIn = true;
this.user = passThrough.data; this.authenticating = false;
this.loggedIn = true; this.connectionErrorMessage = null;
this.authenticating = false; this.reconnectTime = 7500;
this.connectionErrorMessage = null; return passThrough;
this.reconnectTime = 7500; })
return passThrough; .catch((error: AxiosError) => {
}) this.authenticating = false;
.catch((error: AxiosError) => { if (!error || !error.response) {
this.authenticating = false; this.connectionError('No network connection or server unavailable.');
if (!error || !error.response) {
this.connectionError('No network connection or server unavailable.');
return Promise.reject(error);
}
if (error.response.status >= 500) {
this.connectionError(
`${error.response.statusText} (code: ${error.response.status}).`
);
return Promise.reject(error);
}
this.connectionErrorMessage = null;
if (error.response.status >= 400 && error.response.status < 500) {
this.logout();
}
return Promise.reject(error); return Promise.reject(error);
}) }
);
if (error.response.status >= 500) {
this.connectionError(
`${error.response.statusText} (code: ${error.response.status}).`
);
return Promise.reject(error);
}
this.connectionErrorMessage = null;
if (error.response.status >= 400 && error.response.status < 500) {
this.logout();
}
return Promise.reject(error);
});
}; };
public logout = async () => { public logout = async () => {

View File

@ -1,97 +1,78 @@
import Button from '@material-ui/core/Button'; import Button from '@mui/material/Button';
import Dialog from '@material-ui/core/Dialog'; import Dialog from '@mui/material/Dialog';
import DialogActions from '@material-ui/core/DialogActions'; import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@material-ui/core/DialogContent'; import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@material-ui/core/DialogContentText'; import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@material-ui/core/DialogTitle'; import DialogTitle from '@mui/material/DialogTitle';
import TextField from '@material-ui/core/TextField'; import TextField from '@mui/material/TextField';
import Tooltip from '@material-ui/core/Tooltip'; import Tooltip from '@mui/material/Tooltip';
import {NumberField} from '../common/NumberField'; import {NumberField} from '../common/NumberField';
import React, {Component} from 'react'; import React, {useState} from 'react';
interface IProps { interface IProps {
fClose: VoidFunction; fClose: VoidFunction;
fOnSubmit: (name: string, description: string, defaultPriority: number) => void; fOnSubmit: (name: string, description: string, defaultPriority: number) => Promise<void>;
} }
interface IState { export const AddApplicationDialog = ({fClose, fOnSubmit}: IProps) => {
name: string; const [name, setName] = useState('');
description: string; const [description, setDescription] = useState('');
defaultPriority: number; const [defaultPriority, setDefaultPriority] = useState(0);
}
export default class AddDialog extends Component<IProps, IState> { const submitEnabled = name.length !== 0;
public state = {name: '', description: '', defaultPriority: 0}; const submitAndClose = async () => {
await fOnSubmit(name, description, defaultPriority);
fClose();
};
public render() { return (
const {fClose, fOnSubmit} = this.props; <Dialog open={true} onClose={fClose} aria-labelledby="form-dialog-title" id="app-dialog">
const {name, description, defaultPriority} = this.state; <DialogTitle id="form-dialog-title">Create an application</DialogTitle>
const submitEnabled = this.state.name.length !== 0; <DialogContent>
const submitAndClose = () => { <DialogContentText>An application is allowed to send messages.</DialogContentText>
fOnSubmit(name, description, defaultPriority); <TextField
fClose(); autoFocus
}; margin="dense"
return ( className="name"
<Dialog label="Name *"
open={true} type="text"
onClose={fClose} value={name}
aria-labelledby="form-dialog-title" onChange={(e) => setName(e.target.value)}
id="app-dialog"> fullWidth
<DialogTitle id="form-dialog-title">Create an application</DialogTitle> />
<DialogContent> <TextField
<DialogContentText> margin="dense"
An application is allowed to send messages. className="description"
</DialogContentText> label="Short Description"
<TextField value={description}
autoFocus onChange={(e) => setDescription(e.target.value)}
margin="dense" fullWidth
className="name" multiline
label="Name *" />
type="text" <NumberField
value={name} margin="dense"
onChange={this.handleChange.bind(this, 'name')} className="priority"
fullWidth label="Default Priority"
/> value={defaultPriority}
<TextField onChange={(value) => setDefaultPriority(value)}
margin="dense" fullWidth
className="description" />
label="Short Description" </DialogContent>
value={description} <DialogActions>
onChange={this.handleChange.bind(this, 'description')} <Button onClick={fClose}>Cancel</Button>
fullWidth <Tooltip title={submitEnabled ? '' : 'name is required'}>
multiline <div>
/> <Button
<NumberField className="create"
margin="dense" disabled={!submitEnabled}
className="priority" onClick={submitAndClose}
label="Default Priority" color="primary"
value={defaultPriority} variant="contained">
onChange={(value) => this.setState({defaultPriority: value})} Create
fullWidth </Button>
/> </div>
</DialogContent> </Tooltip>
<DialogActions> </DialogActions>
<Button onClick={fClose}>Cancel</Button> </Dialog>
<Tooltip title={submitEnabled ? '' : 'name is required'}> );
<div> };
<Button
className="create"
disabled={!submitEnabled}
onClick={submitAndClose}
color="primary"
variant="contained">
Create
</Button>
</div>
</Tooltip>
</DialogActions>
</Dialog>
);
}
private handleChange(propertyName: string, event: React.ChangeEvent<HTMLInputElement>) {
const state = this.state;
state[propertyName] = event.target.value;
this.setState(state);
}
}

View File

@ -1,7 +1,7 @@
import {BaseStore} from '../common/BaseStore'; import {BaseStore} from '../common/BaseStore';
import axios from 'axios'; import axios from 'axios';
import * as config from '../config'; import * as config from '../config';
import {action} from 'mobx'; import {action, makeObservable} from 'mobx';
import {SnackReporter} from '../snack/SnackManager'; import {SnackReporter} from '../snack/SnackManager';
import {IApplication} from '../types'; import {IApplication} from '../types';
@ -10,6 +10,12 @@ export class AppStore extends BaseStore<IApplication> {
public constructor(private readonly snack: SnackReporter) { public constructor(private readonly snack: SnackReporter) {
super(); super();
makeObservable(this, {
uploadImage: action,
update: action,
create: action,
});
} }
protected requestItems = (): Promise<IApplication[]> => protected requestItems = (): Promise<IApplication[]> =>
@ -23,7 +29,6 @@ export class AppStore extends BaseStore<IApplication> {
return this.snack('Application deleted'); return this.snack('Application deleted');
}); });
@action
public uploadImage = async (id: number, file: Blob): Promise<void> => { public uploadImage = async (id: number, file: Blob): Promise<void> => {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
@ -34,7 +39,6 @@ export class AppStore extends BaseStore<IApplication> {
this.snack('Application image updated'); this.snack('Application image updated');
}; };
@action
public update = async ( public update = async (
id: number, id: number,
name: string, name: string,
@ -50,7 +54,6 @@ export class AppStore extends BaseStore<IApplication> {
this.snack('Application updated'); this.snack('Application updated');
}; };
@action
public create = async ( public create = async (
name: string, name: string,
description: string, description: string,

View File

@ -1,152 +1,141 @@
import Grid from '@material-ui/core/Grid'; import React, {ChangeEvent, useEffect, useRef, useState} from 'react';
import IconButton from '@material-ui/core/IconButton'; import Grid from '@mui/material/Grid';
import Paper from '@material-ui/core/Paper'; import IconButton from '@mui/material/IconButton';
import Table from '@material-ui/core/Table'; import Paper from '@mui/material/Paper';
import TableBody from '@material-ui/core/TableBody'; import Table from '@mui/material/Table';
import TableCell from '@material-ui/core/TableCell'; import TableBody from '@mui/material/TableBody';
import TableHead from '@material-ui/core/TableHead'; import TableCell from '@mui/material/TableCell';
import TableRow from '@material-ui/core/TableRow'; import TableHead from '@mui/material/TableHead';
import Delete from '@material-ui/icons/Delete'; import TableRow from '@mui/material/TableRow';
import Edit from '@material-ui/icons/Edit'; import Delete from '@mui/icons-material/Delete';
import CloudUpload from '@material-ui/icons/CloudUpload'; import Edit from '@mui/icons-material/Edit';
import React, {ChangeEvent, Component, SFC} from 'react'; import CloudUpload from '@mui/icons-material/CloudUpload';
import Button from '@mui/material/Button';
import ConfirmDialog from '../common/ConfirmDialog'; import ConfirmDialog from '../common/ConfirmDialog';
import DefaultPage from '../common/DefaultPage'; import DefaultPage from '../common/DefaultPage';
import Button from '@material-ui/core/Button';
import CopyableSecret from '../common/CopyableSecret'; import CopyableSecret from '../common/CopyableSecret';
import AddApplicationDialog from './AddApplicationDialog'; import {AddApplicationDialog} from './AddApplicationDialog';
import {observer} from 'mobx-react';
import {observable} from 'mobx';
import {inject, Stores} from '../inject';
import * as config from '../config'; import * as config from '../config';
import UpdateDialog from './UpdateApplicationDialog'; import {UpdateApplicationDialog} from './UpdateApplicationDialog';
import {IApplication} from '../types'; import {IApplication} from '../types';
import {LastUsedCell} from '../common/LastUsedCell'; import {LastUsedCell} from '../common/LastUsedCell';
import {useStores} from '../stores';
import {observer} from 'mobx-react-lite';
@observer const Applications = observer(() => {
class Applications extends Component<Stores<'appStore'>> { const {appStore} = useStores();
@observable const apps = appStore.getItems();
private deleteId: number | false = false; const [toDeleteApp, setToDeleteApp] = useState<IApplication>();
@observable const [toUpdateApp, setToUpdateApp] = useState<IApplication>();
private updateId: number | false = false; const [createDialog, setCreateDialog] = useState<boolean>(false);
@observable
private createDialog = false;
private uploadId = -1; const fileInputRef = useRef<HTMLInputElement>(null);
private upload: HTMLInputElement | null = null; const uploadId = useRef(-1);
public componentDidMount = () => this.props.appStore.refresh(); useEffect(() => void appStore.refresh(), []);
public render() { const handleImageUploadClick = (id: number) => {
const { uploadId.current = id;
createDialog, if (fileInputRef.current) {
deleteId, fileInputRef.current.click();
updateId,
props: {appStore},
} = this;
const apps = appStore.getItems();
return (
<DefaultPage
title="Applications"
rightControl={
<Button
id="create-app"
variant="contained"
color="primary"
onClick={() => (this.createDialog = true)}>
Create Application
</Button>
}
maxWidth={1000}>
<Grid item xs={12}>
<Paper elevation={6} style={{overflowX: 'auto'}}>
<Table id="app-table">
<TableHead>
<TableRow>
<TableCell padding="checkbox" style={{width: 80}} />
<TableCell>Name</TableCell>
<TableCell>Token</TableCell>
<TableCell>Description</TableCell>
<TableCell>Priority</TableCell>
<TableCell>Last Used</TableCell>
<TableCell />
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{apps.map((app: IApplication) => (
<Row
key={app.id}
description={app.description}
defaultPriority={app.defaultPriority}
image={app.image}
name={app.name}
value={app.token}
lastUsed={app.lastUsed}
fUpload={() => this.uploadImage(app.id)}
fDelete={() => (this.deleteId = app.id)}
fEdit={() => (this.updateId = app.id)}
noDelete={app.internal}
/>
))}
</TableBody>
</Table>
<input
ref={(upload) => (this.upload = upload)}
type="file"
style={{display: 'none'}}
onChange={this.onUploadImage}
/>
</Paper>
</Grid>
{createDialog && (
<AddApplicationDialog
fClose={() => (this.createDialog = false)}
fOnSubmit={appStore.create}
/>
)}
{updateId !== false && (
<UpdateDialog
fClose={() => (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 && (
<ConfirmDialog
title="Confirm Delete"
text={'Delete ' + appStore.getByID(deleteId).name + '?'}
fClose={() => (this.deleteId = false)}
fOnSubmit={() => appStore.remove(deleteId)}
/>
)}
</DefaultPage>
);
}
private uploadImage = (id: number) => {
this.uploadId = id;
if (this.upload) {
this.upload.click();
} }
}; };
private onUploadImage = (e: ChangeEvent<HTMLInputElement>) => { const onUploadImage = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) { if (!file) {
return; return;
} }
if (['image/png', 'image/jpeg', 'image/gif'].indexOf(file.type) !== -1) { if (['image/png', 'image/jpeg', 'image/gif'].indexOf(file.type) !== -1) {
this.props.appStore.uploadImage(this.uploadId, file); appStore.uploadImage(uploadId.current, file);
} else { } else {
alert('Uploaded file must be of type png, jpeg or gif.'); alert('Uploaded file must be of type png, jpeg or gif.');
} }
}; };
}
return (
<DefaultPage
title="Applications"
rightControl={
<Button
id="create-app"
variant="contained"
color="primary"
onClick={() => setCreateDialog(true)}>
Create Application
</Button>
}
maxWidth={1000}>
<Grid size={12}>
<Paper elevation={6} style={{overflowX: 'auto'}}>
<Table id="app-table">
<TableHead>
<TableRow>
<TableCell padding="checkbox" style={{width: 80}} />
<TableCell>Name</TableCell>
<TableCell>Token</TableCell>
<TableCell>Description</TableCell>
<TableCell>Priority</TableCell>
<TableCell>Last Used</TableCell>
<TableCell />
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{apps.map((app: IApplication) => (
<Row
key={app.id}
description={app.description}
defaultPriority={app.defaultPriority}
image={app.image}
name={app.name}
value={app.token}
lastUsed={app.lastUsed}
fUpload={() => handleImageUploadClick(app.id)}
fDelete={() => setToDeleteApp(app)}
fEdit={() => setToUpdateApp(app)}
noDelete={app.internal}
/>
))}
</TableBody>
</Table>
<input
ref={fileInputRef}
type="file"
style={{display: 'none'}}
onChange={onUploadImage}
/>
</Paper>
</Grid>
{createDialog && (
<AddApplicationDialog
fClose={() => setCreateDialog(false)}
fOnSubmit={appStore.create}
/>
)}
{toUpdateApp != null && (
<UpdateApplicationDialog
fClose={() => setToUpdateApp(undefined)}
fOnSubmit={(name, description, defaultPriority) =>
appStore.update(toUpdateApp.id, name, description, defaultPriority)
}
initialDescription={toUpdateApp?.description}
initialName={toUpdateApp?.name}
initialDefaultPriority={toUpdateApp?.defaultPriority}
/>
)}
{toDeleteApp != null && (
<ConfirmDialog
title="Confirm Delete"
text={'Delete ' + toDeleteApp.name + '?'}
fClose={() => setToDeleteApp(undefined)}
fOnSubmit={() => appStore.remove(toDeleteApp.id)}
/>
)}
</DefaultPage>
);
});
interface IRowProps { interface IRowProps {
name: string; name: string;
@ -161,21 +150,21 @@ interface IRowProps {
fEdit: VoidFunction; fEdit: VoidFunction;
} }
const Row: SFC<IRowProps> = observer( const Row = ({
({ name,
name, value,
value, noDelete,
noDelete, description,
description, defaultPriority,
defaultPriority, lastUsed,
lastUsed, fDelete,
fDelete, fUpload,
fUpload, image,
image, fEdit,
fEdit, }: IRowProps) => {
}) => ( return (
<TableRow> <TableRow>
<TableCell padding="default"> <TableCell padding="normal">
<div style={{display: 'flex'}}> <div style={{display: 'flex'}}>
<img src={config.get('url') + image} alt="app logo" width="40" height="40" /> <img src={config.get('url') + image} alt="app logo" width="40" height="40" />
<IconButton onClick={fUpload} style={{height: 40}}> <IconButton onClick={fUpload} style={{height: 40}}>
@ -203,7 +192,7 @@ const Row: SFC<IRowProps> = observer(
</IconButton> </IconButton>
</TableCell> </TableCell>
</TableRow> </TableRow>
) );
); };
export default inject('appStore')(Applications); export default Applications;

View File

@ -1,109 +1,87 @@
import Button from '@material-ui/core/Button'; import Button from '@mui/material/Button';
import Dialog from '@material-ui/core/Dialog'; import Dialog from '@mui/material/Dialog';
import DialogActions from '@material-ui/core/DialogActions'; import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@material-ui/core/DialogContent'; import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@material-ui/core/DialogContentText'; import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@material-ui/core/DialogTitle'; import DialogTitle from '@mui/material/DialogTitle';
import TextField from '@material-ui/core/TextField'; import TextField from '@mui/material/TextField';
import Tooltip from '@material-ui/core/Tooltip'; import Tooltip from '@mui/material/Tooltip';
import {NumberField} from '../common/NumberField'; import {NumberField} from '../common/NumberField';
import React, {Component} from 'react'; import React, {useState} from 'react';
interface IProps { interface IProps {
fClose: VoidFunction; fClose: VoidFunction;
fOnSubmit: (name: string, description: string, defaultPriority: number) => void; fOnSubmit: (name: string, description: string, defaultPriority: number) => Promise<void>;
initialName: string; initialName: string;
initialDescription: string; initialDescription: string;
initialDefaultPriority: number; initialDefaultPriority: number;
} }
interface IState { export const UpdateApplicationDialog = ({
name: string; initialName,
description: string; initialDescription,
defaultPriority: number; 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<IProps, IState> { const submitEnabled = name.length !== 0;
public state = {name: '', description: '', defaultPriority: 0}; const submitAndClose = async () => {
await fOnSubmit(name, description, defaultPriority);
fClose();
};
constructor(props: IProps) { return (
super(props); <Dialog open={true} onClose={fClose} aria-labelledby="form-dialog-title" id="app-dialog">
this.state = { <DialogTitle id="form-dialog-title">Update an application</DialogTitle>
name: props.initialName, <DialogContent>
description: props.initialDescription, <DialogContentText>An application is allowed to send messages.</DialogContentText>
defaultPriority: props.initialDefaultPriority, <TextField
}; autoFocus
} margin="dense"
className="name"
public render() { label="Name *"
const {fClose, fOnSubmit} = this.props; type="text"
const {name, description, defaultPriority} = this.state; value={name}
const submitEnabled = this.state.name.length !== 0; onChange={(e) => setName(e.target.value)}
const submitAndClose = () => { fullWidth
fOnSubmit(name, description, defaultPriority); />
fClose(); <TextField
}; margin="dense"
return ( className="description"
<Dialog label="Short Description"
open={true} value={description}
onClose={fClose} onChange={(e) => setDescription(e.target.value)}
aria-labelledby="form-dialog-title" fullWidth
id="app-dialog"> multiline
<DialogTitle id="form-dialog-title">Update an application</DialogTitle> />
<DialogContent> <NumberField
<DialogContentText> margin="dense"
An application is allowed to send messages. className="priority"
</DialogContentText> label="Default Priority"
<TextField value={defaultPriority}
autoFocus onChange={(e) => setDefaultPriority(e)}
margin="dense" fullWidth
className="name" />
label="Name *" </DialogContent>
type="text" <DialogActions>
value={name} <Button onClick={fClose}>Cancel</Button>
onChange={this.handleChange.bind(this, 'name')} <Tooltip title={submitEnabled ? '' : 'name is required'}>
fullWidth <div>
/> <Button
<TextField className="update"
margin="dense" disabled={!submitEnabled}
className="description" onClick={submitAndClose}
label="Short Description" color="primary"
value={description} variant="contained">
onChange={this.handleChange.bind(this, 'description')} Update
fullWidth </Button>
multiline </div>
/> </Tooltip>
<NumberField </DialogActions>
margin="dense" </Dialog>
className="priority" );
label="Default Priority" };
value={defaultPriority}
onChange={(value) => this.setState({defaultPriority: value})}
fullWidth
/>
</DialogContent>
<DialogActions>
<Button onClick={fClose}>Cancel</Button>
<Tooltip title={submitEnabled ? '' : 'name is required'}>
<div>
<Button
className="update"
disabled={!submitEnabled}
onClick={submitAndClose}
color="primary"
variant="contained">
Update
</Button>
</div>
</Tooltip>
</DialogActions>
</Dialog>
);
}
private handleChange(propertyName: string, event: React.ChangeEvent<HTMLInputElement>) {
const state = this.state;
state[propertyName] = event.target.value;
this.setState(state);
}
}

View File

@ -1,71 +1,58 @@
import Button from '@material-ui/core/Button'; import React, {useState} from 'react';
import Dialog from '@material-ui/core/Dialog'; import Button from '@mui/material/Button';
import DialogActions from '@material-ui/core/DialogActions'; import Dialog from '@mui/material/Dialog';
import DialogContent from '@material-ui/core/DialogContent'; import DialogActions from '@mui/material/DialogActions';
import DialogTitle from '@material-ui/core/DialogTitle'; import DialogContent from '@mui/material/DialogContent';
import TextField from '@material-ui/core/TextField'; import DialogTitle from '@mui/material/DialogTitle';
import Tooltip from '@material-ui/core/Tooltip'; import TextField from '@mui/material/TextField';
import React, {Component} from 'react'; import Tooltip from '@mui/material/Tooltip';
interface IProps { interface IProps {
fClose: VoidFunction; fClose: VoidFunction;
fOnSubmit: (name: string) => void; fOnSubmit: (name: string) => Promise<void>;
} }
export default class AddDialog extends Component<IProps, {name: string}> { const AddClientDialog = ({fClose, fOnSubmit}: IProps) => {
public state = {name: ''}; const [name, setName] = useState('');
public render() { const submitEnabled = name.length !== 0;
const {fClose, fOnSubmit} = this.props; const submitAndClose = async () => {
const {name} = this.state; await fOnSubmit(name);
const submitEnabled = this.state.name.length !== 0; fClose();
const submitAndClose = () => { };
fOnSubmit(name);
fClose();
};
return (
<Dialog
open={true}
onClose={fClose}
aria-labelledby="form-dialog-title"
id="client-dialog">
<DialogTitle id="form-dialog-title">Create a client</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
className="name"
label="Name *"
type="email"
value={name}
onChange={this.handleChange.bind(this, 'name')}
fullWidth
/>
</DialogContent>
<DialogActions>
<Button onClick={fClose}>Cancel</Button>
<Tooltip
placement={'bottom-start'}
title={submitEnabled ? '' : 'name is required'}>
<div>
<Button
className="create"
disabled={!submitEnabled}
onClick={submitAndClose}
color="primary"
variant="contained">
Create
</Button>
</div>
</Tooltip>
</DialogActions>
</Dialog>
);
}
private handleChange(propertyName: string, event: React.ChangeEvent<HTMLInputElement>) { return (
const state = this.state; <Dialog open={true} onClose={fClose} aria-labelledby="form-dialog-title" id="client-dialog">
state[propertyName] = event.target.value; <DialogTitle id="form-dialog-title">Create a client</DialogTitle>
this.setState(state); <DialogContent>
} <TextField
} autoFocus
margin="dense"
className="name"
label="Name *"
type="email"
value={name}
onChange={(e) => setName(e.target.value)}
fullWidth
/>
</DialogContent>
<DialogActions>
<Button onClick={fClose}>Cancel</Button>
<Tooltip placement={'bottom-start'} title={submitEnabled ? '' : 'name is required'}>
<div>
<Button
className="create"
disabled={!submitEnabled}
onClick={submitAndClose}
color="primary"
variant="contained">
Create
</Button>
</div>
</Tooltip>
</DialogActions>
</Dialog>
);
};
export default AddClientDialog;

View File

@ -1,13 +1,19 @@
import {BaseStore} from '../common/BaseStore'; import {BaseStore} from '../common/BaseStore';
import axios from 'axios'; import axios from 'axios';
import * as config from '../config'; import * as config from '../config';
import {action} from 'mobx'; import {action, makeObservable} from 'mobx';
import {SnackReporter} from '../snack/SnackManager'; import {SnackReporter} from '../snack/SnackManager';
import {IClient} from '../types'; import {IClient} from '../types';
export class ClientStore extends BaseStore<IClient> { export class ClientStore extends BaseStore<IClient> {
public constructor(private readonly snack: SnackReporter) { public constructor(private readonly snack: SnackReporter) {
super(); super();
makeObservable(this, {
update: action,
createNoNotifcation: action,
create: action,
});
} }
protected requestItems = (): Promise<IClient[]> => protected requestItems = (): Promise<IClient[]> =>
@ -19,21 +25,18 @@ export class ClientStore extends BaseStore<IClient> {
.then(() => this.snack('Client deleted')); .then(() => this.snack('Client deleted'));
} }
@action
public update = async (id: number, name: string): Promise<void> => { public update = async (id: number, name: string): Promise<void> => {
await axios.put(`${config.get('url')}client/${id}`, {name}); await axios.put(`${config.get('url')}client/${id}`, {name});
await this.refresh(); await this.refresh();
this.snack('Client updated'); this.snack('Client updated');
}; };
@action
public createNoNotifcation = async (name: string): Promise<IClient> => { public createNoNotifcation = async (name: string): Promise<IClient> => {
const client = await axios.post(`${config.get('url')}client`, {name}); const client = await axios.post(`${config.get('url')}client`, {name});
await this.refresh(); await this.refresh();
return client.data; return client.data;
}; };
@action
public create = async (name: string): Promise<void> => { public create = async (name: string): Promise<void> => {
await this.createNoNotifcation(name); await this.createNoNotifcation(name);
this.snack('Client added'); this.snack('Client added');

View File

@ -1,110 +1,97 @@
import Grid from '@material-ui/core/Grid'; import React, {useEffect, useState} from 'react';
import IconButton from '@material-ui/core/IconButton'; import Grid from '@mui/material/Grid';
import Paper from '@material-ui/core/Paper'; import IconButton from '@mui/material/IconButton';
import Table from '@material-ui/core/Table'; import Paper from '@mui/material/Paper';
import TableBody from '@material-ui/core/TableBody'; import Table from '@mui/material/Table';
import TableCell from '@material-ui/core/TableCell'; import TableBody from '@mui/material/TableBody';
import TableHead from '@material-ui/core/TableHead'; import TableCell from '@mui/material/TableCell';
import TableRow from '@material-ui/core/TableRow'; import TableHead from '@mui/material/TableHead';
import Delete from '@material-ui/icons/Delete'; import TableRow from '@mui/material/TableRow';
import Edit from '@material-ui/icons/Edit'; import Delete from '@mui/icons-material/Delete';
import React, {Component, SFC} from 'react'; import Edit from '@mui/icons-material/Edit';
import Button from '@mui/material/Button';
import ConfirmDialog from '../common/ConfirmDialog'; import ConfirmDialog from '../common/ConfirmDialog';
import DefaultPage from '../common/DefaultPage'; import DefaultPage from '../common/DefaultPage';
import Button from '@material-ui/core/Button';
import AddClientDialog from './AddClientDialog'; import AddClientDialog from './AddClientDialog';
import UpdateDialog from './UpdateClientDialog'; import UpdateClientDialog from './UpdateClientDialog';
import {observer} from 'mobx-react';
import {observable} from 'mobx';
import {inject, Stores} from '../inject';
import {IClient} from '../types'; import {IClient} from '../types';
import CopyableSecret from '../common/CopyableSecret'; import CopyableSecret from '../common/CopyableSecret';
import {LastUsedCell} from '../common/LastUsedCell'; import {LastUsedCell} from '../common/LastUsedCell';
import {observer} from 'mobx-react';
import {useStores} from '../stores';
@observer const Clients = observer(() => {
class Clients extends Component<Stores<'clientStore'>> { const {clientStore} = useStores();
@observable const [toDeleteClient, setToDeleteClient] = useState<IClient>();
private showDialog = false; const [toUpdateClient, setToUpdateClient] = useState<IClient>();
@observable const [createDialog, setCreateDialog] = useState<boolean>(false);
private deleteId: false | number = false; const clients = clientStore.getItems();
@observable
private updateId: false | number = false;
public componentDidMount = () => this.props.clientStore.refresh(); useEffect(() => void clientStore.refresh(), []);
public render() { return (
const { <DefaultPage
deleteId, title="Clients"
updateId, rightControl={
showDialog, <Button
props: {clientStore}, id="create-client"
} = this; variant="contained"
const clients = clientStore.getItems(); color="primary"
onClick={() => setCreateDialog(true)}>
return ( Create Client
<DefaultPage </Button>
title="Clients" }>
rightControl={ <Grid size={12}>
<Button <Paper elevation={6} style={{overflowX: 'auto'}}>
id="create-client" <Table id="client-table">
variant="contained" <TableHead>
color="primary" <TableRow style={{textAlign: 'center'}}>
onClick={() => (this.showDialog = true)}> <TableCell>Name</TableCell>
Create Client <TableCell style={{width: 200}}>Token</TableCell>
</Button> <TableCell>Last Used</TableCell>
}> <TableCell />
<Grid item xs={12}> <TableCell />
<Paper elevation={6} style={{overflowX: 'auto'}}> </TableRow>
<Table id="client-table"> </TableHead>
<TableHead> <TableBody>
<TableRow style={{textAlign: 'center'}}> {clients.map((client: IClient) => (
<TableCell>Name</TableCell> <Row
<TableCell style={{width: 200}}>Token</TableCell> key={client.id}
<TableCell>Last Used</TableCell> name={client.name}
<TableCell /> value={client.token}
<TableCell /> lastUsed={client.lastUsed}
</TableRow> fEdit={() => setToUpdateClient(client)}
</TableHead> fDelete={() => setToDeleteClient(client)}
<TableBody> />
{clients.map((client: IClient) => ( ))}
<Row </TableBody>
key={client.id} </Table>
name={client.name} </Paper>
value={client.token} </Grid>
lastUsed={client.lastUsed} {createDialog && (
fEdit={() => (this.updateId = client.id)} <AddClientDialog
fDelete={() => (this.deleteId = client.id)} fClose={() => setCreateDialog(false)}
/> fOnSubmit={clientStore.create}
))} />
</TableBody> )}
</Table> {toUpdateClient != null && (
</Paper> <UpdateClientDialog
</Grid> fClose={() => setToUpdateClient(undefined)}
{showDialog && ( fOnSubmit={(name) => clientStore.update(toUpdateClient.id, name)}
<AddClientDialog initialName={toUpdateClient.name}
fClose={() => (this.showDialog = false)} />
fOnSubmit={clientStore.create} )}
/> {toDeleteClient != null && (
)} <ConfirmDialog
{updateId !== false && ( title="Confirm Delete"
<UpdateDialog text={'Delete ' + toDeleteClient.name + '?'}
fClose={() => (this.updateId = false)} fClose={() => setToDeleteClient(undefined)}
fOnSubmit={(name) => clientStore.update(updateId, name)} fOnSubmit={() => clientStore.remove(toDeleteClient.id)}
initialName={clientStore.getByID(updateId).name} />
/> )}
)} </DefaultPage>
{deleteId !== false && ( );
<ConfirmDialog });
title="Confirm Delete"
text={'Delete ' + clientStore.getByID(deleteId).name + '?'}
fClose={() => (this.deleteId = false)}
fOnSubmit={() => clientStore.remove(deleteId)}
/>
)}
</DefaultPage>
);
}
}
interface IRowProps { interface IRowProps {
name: string; name: string;
@ -114,7 +101,7 @@ interface IRowProps {
fDelete: VoidFunction; fDelete: VoidFunction;
} }
const Row: SFC<IRowProps> = ({name, value, lastUsed, fEdit, fDelete}) => ( const Row = ({name, value, lastUsed, fEdit, fDelete}: IRowProps) => (
<TableRow> <TableRow>
<TableCell>{name}</TableCell> <TableCell>{name}</TableCell>
<TableCell> <TableCell>
@ -139,4 +126,4 @@ const Row: SFC<IRowProps> = ({name, value, lastUsed, fEdit, fDelete}) => (
</TableRow> </TableRow>
); );
export default inject('clientStore')(Clients); export default Clients;

View File

@ -1,86 +1,64 @@
import Button from '@material-ui/core/Button'; import React, {useState} from 'react';
import Dialog from '@material-ui/core/Dialog'; import Button from '@mui/material/Button';
import DialogActions from '@material-ui/core/DialogActions'; import Dialog from '@mui/material/Dialog';
import DialogContent from '@material-ui/core/DialogContent'; import DialogActions from '@mui/material/DialogActions';
import DialogContentText from '@material-ui/core/DialogContentText'; import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@material-ui/core/DialogTitle'; import DialogContentText from '@mui/material/DialogContentText';
import TextField from '@material-ui/core/TextField'; import DialogTitle from '@mui/material/DialogTitle';
import Tooltip from '@material-ui/core/Tooltip'; import TextField from '@mui/material/TextField';
import React, {Component} from 'react'; import Tooltip from '@mui/material/Tooltip';
interface IProps { interface IProps {
fClose: VoidFunction; fClose: VoidFunction;
fOnSubmit: (name: string) => void; fOnSubmit: (name: string) => Promise<void>;
initialName: string; initialName: string;
} }
interface IState { const UpdateClientDialog = ({fClose, fOnSubmit, initialName = ''}: IProps) => {
name: string; const [name, setName] = useState(initialName);
}
export default class UpdateDialog extends Component<IProps, IState> { const submitEnabled = name.length !== 0;
public state = {name: ''}; const submitAndClose = async () => {
await fOnSubmit(name);
fClose();
};
constructor(props: IProps) { return (
super(props); <Dialog open={true} onClose={fClose} aria-labelledby="form-dialog-title" id="client-dialog">
this.state = { <DialogTitle id="form-dialog-title">Update a Client</DialogTitle>
name: props.initialName, <DialogContent>
}; <DialogContentText>
} A client manages messages, clients, applications and users (with admin
permissions).
</DialogContentText>
<TextField
autoFocus
margin="dense"
className="name"
label="Name *"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
fullWidth
/>
</DialogContent>
<DialogActions>
<Button onClick={fClose}>Cancel</Button>
<Tooltip title={submitEnabled ? '' : 'name is required'}>
<div>
<Button
className="update"
disabled={!submitEnabled}
onClick={submitAndClose}
color="primary"
variant="contained">
Update
</Button>
</div>
</Tooltip>
</DialogActions>
</Dialog>
);
};
public render() { export default UpdateClientDialog;
const {fClose, fOnSubmit} = this.props;
const {name} = this.state;
const submitEnabled = this.state.name.length !== 0;
const submitAndClose = () => {
fOnSubmit(name);
fClose();
};
return (
<Dialog
open={true}
onClose={fClose}
aria-labelledby="form-dialog-title"
id="client-dialog">
<DialogTitle id="form-dialog-title">Update a Client</DialogTitle>
<DialogContent>
<DialogContentText>
A client manages messages, clients, applications and users (with admin
permissions).
</DialogContentText>
<TextField
autoFocus
margin="dense"
className="name"
label="Name *"
type="text"
value={name}
onChange={this.handleChange.bind(this, 'name')}
fullWidth
/>
</DialogContent>
<DialogActions>
<Button onClick={fClose}>Cancel</Button>
<Tooltip title={submitEnabled ? '' : 'name is required'}>
<div>
<Button
className="update"
disabled={!submitEnabled}
onClick={submitAndClose}
color="primary"
variant="contained">
Update
</Button>
</div>
</Tooltip>
</DialogActions>
</Dialog>
);
}
private handleChange(propertyName: string, event: React.ChangeEvent<HTMLInputElement>) {
const state = {};
state[propertyName] = event.target.value;
this.setState(state);
}
}

View File

@ -1,4 +1,4 @@
import {action, observable} from 'mobx'; import {action, observable, makeObservable} from 'mobx';
interface HasID { interface HasID {
id: number; id: number;
@ -12,25 +12,21 @@ export interface IClearable {
* Base implementation for handling items with ids. * Base implementation for handling items with ids.
*/ */
export abstract class BaseStore<T extends HasID> implements IClearable { export abstract class BaseStore<T extends HasID> implements IClearable {
@observable
protected items: T[] = []; protected items: T[] = [];
protected abstract requestItems(): Promise<T[]>; protected abstract requestItems(): Promise<T[]>;
protected abstract requestDelete(id: number): Promise<void>; protected abstract requestDelete(id: number): Promise<void>;
@action
public remove = async (id: number): Promise<void> => { public remove = async (id: number): Promise<void> => {
await this.requestDelete(id); await this.requestDelete(id);
await this.refresh(); await this.refresh();
}; };
@action
public refresh = async (): Promise<void> => { public refresh = async (): Promise<void> => {
this.items = await this.requestItems().then((items) => items || []); this.items = await this.requestItems().then((items) => items || []);
}; };
@action
public refreshIfMissing = async (id: number): Promise<void> => { public refreshIfMissing = async (id: number): Promise<void> => {
if (this.getByIDOrUndefined(id) === undefined) { if (this.getByIDOrUndefined(id) === undefined) {
await this.refresh(); await this.refresh();
@ -50,8 +46,18 @@ export abstract class BaseStore<T extends HasID> implements IClearable {
public getItems = (): T[] => this.items; public getItems = (): T[] => this.items;
@action
public clear = (): void => { public clear = (): void => {
this.items = []; this.items = [];
}; };
constructor() {
// eslint-disable-next-line
makeObservable<BaseStore<any>, 'items'>(this, {
items: observable,
remove: action,
refresh: action,
refreshIfMissing: action,
clear: action,
});
}
} }

View File

@ -1,9 +1,9 @@
import Button from '@material-ui/core/Button'; import Button from '@mui/material/Button';
import Dialog from '@material-ui/core/Dialog'; import Dialog from '@mui/material/Dialog';
import DialogActions from '@material-ui/core/DialogActions'; import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@material-ui/core/DialogContent'; import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@material-ui/core/DialogContentText'; import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@material-ui/core/DialogTitle'; import DialogTitle from '@mui/material/DialogTitle';
import React from 'react'; import React from 'react';
interface IProps { interface IProps {

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import Button from '@material-ui/core/Button'; import Button from '@mui/material/Button';
import Typography from '@material-ui/core/Typography'; import Typography from '@mui/material/Typography';
interface ConnectionErrorBannerProps { interface ConnectionErrorBannerProps {
height: number; height: number;

View File

@ -1,21 +1,24 @@
import Paper from '@material-ui/core/Paper'; import Paper from '@mui/material/Paper';
import {withStyles, WithStyles} from '@material-ui/core/styles'; import {makeStyles} from 'tss-react/mui';
import * as React from 'react'; import * as React from 'react';
const styles = () => ({ const useStyles = makeStyles()(() => ({
paper: { paper: {
padding: 16, padding: 16,
}, },
}); }));
interface IProps extends WithStyles<'paper'> { interface IProps {
style?: React.CSSProperties; style?: React.CSSProperties;
} }
const Container: React.FC<IProps> = ({classes, children, style}) => ( const Container: React.FC<React.PropsWithChildren<IProps>> = ({children, style}) => {
<Paper elevation={6} className={classes.paper} style={style}> const {classes} = useStyles();
{children} return (
</Paper> <Paper elevation={6} className={classes.paper} style={style}>
); {children}
</Paper>
);
};
export default withStyles(styles)(Container); export default Container;

View File

@ -1,42 +1,22 @@
import IconButton from '@material-ui/core/IconButton'; import IconButton from '@mui/material/IconButton';
import Typography from '@material-ui/core/Typography'; import Typography from '@mui/material/Typography';
import Visibility from '@material-ui/icons/Visibility'; import Visibility from '@mui/icons-material/Visibility';
import Copy from '@material-ui/icons/FileCopyOutlined'; import Copy from '@mui/icons-material/FileCopyOutlined';
import VisibilityOff from '@material-ui/icons/VisibilityOff'; import VisibilityOff from '@mui/icons-material/VisibilityOff';
import React, {Component, CSSProperties} from 'react'; import React, {CSSProperties} from 'react';
import {Stores, inject} from '../inject'; import {useStores} from '../stores';
interface IProps { interface IProps {
value: string; value: string;
style?: CSSProperties; style?: CSSProperties;
} }
interface IState { const CopyableSecret = ({value, style}: IProps) => {
visible: boolean; const [visible, setVisible] = React.useState(false);
} const text = visible ? value : '•••••••••••••••';
const {snackManager} = useStores();
class CopyableSecret extends Component<IProps & Stores<'snackManager'>, IState> { const toggleVisibility = () => setVisible((b) => !b);
public state = {visible: false}; const copyToClipboard = async () => {
public render() {
const {value, style} = this.props;
const text = this.state.visible ? value : '•••••••••••••••';
return (
<div style={style}>
<IconButton onClick={this.copyToClipboard} title="Copy to clipboard">
<Copy />
</IconButton>
<IconButton onClick={this.toggleVisibility} className="toggle-visibility">
{this.state.visible ? <VisibilityOff /> : <Visibility />}
</IconButton>
<Typography style={{fontFamily: 'monospace', fontSize: 16}}>{text}</Typography>
</div>
);
}
private toggleVisibility = () => this.setState({visible: !this.state.visible});
private copyToClipboard = async () => {
const {snackManager, value} = this.props;
try { try {
await navigator.clipboard.writeText(value); await navigator.clipboard.writeText(value);
snackManager.snack('Copied to clipboard'); snackManager.snack('Copied to clipboard');
@ -45,6 +25,17 @@ class CopyableSecret extends Component<IProps & Stores<'snackManager'>, IState>
snackManager.snack('Failed to copy to clipboard'); snackManager.snack('Failed to copy to clipboard');
} }
}; };
} return (
<div style={style}>
<IconButton onClick={copyToClipboard} title="Copy to clipboard" size="large">
<Copy />
</IconButton>
<IconButton onClick={toggleVisibility} className="toggle-visibility" size="large">
{visible ? <VisibilityOff /> : <Visibility />}
</IconButton>
<Typography style={{fontFamily: 'monospace', fontSize: 16}}>{text}</Typography>
</div>
);
};
export default inject('snackManager')(CopyableSecret); export default CopyableSecret;

View File

@ -1,5 +1,5 @@
import Grid from '@material-ui/core/Grid'; import Grid from '@mui/material/Grid';
import Typography from '@material-ui/core/Typography'; import Typography from '@mui/material/Typography';
import React, {FC} from 'react'; import React, {FC} from 'react';
interface IProps { interface IProps {
@ -8,10 +8,15 @@ interface IProps {
maxWidth?: number; maxWidth?: number;
} }
const DefaultPage: FC<IProps> = ({title, rightControl, maxWidth = 700, children}) => ( const DefaultPage: FC<React.PropsWithChildren<IProps>> = ({
title,
rightControl,
maxWidth = 700,
children,
}) => (
<main style={{margin: '0 auto', maxWidth}}> <main style={{margin: '0 auto', maxWidth}}>
<Grid container spacing={4}> <Grid container spacing={4}>
<Grid item xs={12} style={{display: 'flex', flexWrap: 'wrap'}}> <Grid size={{xs: 12}} style={{display: 'flex', flexWrap: 'wrap'}}>
<Typography variant="h4" style={{flex: 1}}> <Typography variant="h4" style={{flex: 1}}>
{title} {title}
</Typography> </Typography>

View File

@ -1,4 +1,4 @@
import {Typography} from '@material-ui/core'; import {Typography} from '@mui/material';
import React from 'react'; import React from 'react';
import TimeAgo from 'react-timeago'; import TimeAgo from 'react-timeago';

View File

@ -1,12 +1,12 @@
import CircularProgress from '@material-ui/core/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import Grid from '@material-ui/core/Grid'; import Grid from '@mui/material/Grid';
import React from 'react'; import React from 'react';
import DefaultPage from './DefaultPage'; import DefaultPage from './DefaultPage';
export default function LoadingSpinner() { export default function LoadingSpinner() {
return ( return (
<DefaultPage title="" maxWidth={250}> <DefaultPage title="" maxWidth={250}>
<Grid item xs={12} style={{textAlign: 'center'}}> <Grid size={{xs: 12}} style={{textAlign: 'center'}}>
<CircularProgress size={40} /> <CircularProgress size={40} />
</Grid> </Grid>
</DefaultPage> </DefaultPage>

View File

@ -3,5 +3,5 @@ import ReactMarkdown from 'react-markdown';
import gfm from 'remark-gfm'; import gfm from 'remark-gfm';
export const Markdown = ({children}: {children: string}) => ( export const Markdown = ({children}: {children: string}) => (
<ReactMarkdown plugins={[gfm]}>{children}</ReactMarkdown> <ReactMarkdown remarkPlugins={[gfm]}>{children}</ReactMarkdown>
); );

View File

@ -1,4 +1,4 @@
import {TextField, TextFieldProps} from '@material-ui/core'; import {TextField, TextFieldProps} from '@mui/material';
import React from 'react'; import React from 'react';
export interface NumberFieldProps { export interface NumberFieldProps {

View File

@ -1,48 +1,37 @@
import Fab from '@material-ui/core/Fab'; import Fab from '@mui/material/Fab';
import KeyboardArrowUp from '@material-ui/icons/KeyboardArrowUp'; import KeyboardArrowUp from '@mui/icons-material/KeyboardArrowUp';
import React, {Component} from 'react'; import React from 'react';
class ScrollUpButton extends Component { const ScrollUpButton = () => {
state = { const [state, setState] = React.useState({display: 'none', opacity: 0});
display: 'none', React.useEffect(() => {
opacity: 0, const scrollHandler = () => {
}; const currentScrollPos = Math.max(window.pageYOffset - 1000, 0);
componentDidMount() { const opacity = Math.min(currentScrollPos / 1000, 1);
window.addEventListener('scroll', this.scrollHandler); 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() { return (
window.removeEventListener('scroll', this.scrollHandler); <Fab
} color="primary"
style={{
scrollHandler = () => { position: 'fixed',
const currentScrollPos = window.pageYOffset; bottom: '30px',
const opacity = Math.min(currentScrollPos / 500, 1); right: '30px',
const nextState = {display: currentScrollPos > 0 ? 'inherit' : 'none', opacity}; zIndex: 100000,
if (this.state.display !== nextState.display || this.state.opacity !== nextState.opacity) { display: state.display,
this.setState(nextState); opacity: state.opacity,
} }}
}; onClick={() => window.scrollTo(0, 0)}>
<KeyboardArrowUp />
public render() { </Fab>
return ( );
<Fab };
color="primary"
style={{
position: 'fixed',
bottom: '30px',
right: '30px',
zIndex: 100000,
display: this.state.display,
opacity: this.state.opacity,
}}
onClick={this.scrollUp}>
<KeyboardArrowUp />
</Fab>
);
}
private scrollUp = () => window.scrollTo(0, 0);
}
export default ScrollUpButton; export default ScrollUpButton;

View File

@ -1,68 +1,63 @@
import Button from '@material-ui/core/Button'; import React, {useState} from 'react';
import Dialog from '@material-ui/core/Dialog'; import Button from '@mui/material/Button';
import DialogActions from '@material-ui/core/DialogActions'; import Dialog from '@mui/material/Dialog';
import DialogContent from '@material-ui/core/DialogContent'; import DialogActions from '@mui/material/DialogActions';
import DialogTitle from '@material-ui/core/DialogTitle'; import DialogContent from '@mui/material/DialogContent';
import TextField from '@material-ui/core/TextField'; import DialogTitle from '@mui/material/DialogTitle';
import Tooltip from '@material-ui/core/Tooltip'; import TextField from '@mui/material/TextField';
import React, {Component} from 'react'; import Tooltip from '@mui/material/Tooltip';
import {observable} from 'mobx';
import {observer} from 'mobx-react'; import {observer} from 'mobx-react';
import {inject, Stores} from '../inject'; import {useStores} from '../stores';
interface IProps { interface IProps {
fClose: VoidFunction; fClose: VoidFunction;
} }
@observer const SettingsDialog = observer(({fClose}: IProps) => {
class SettingsDialog extends Component<IProps & Stores<'currentUser'>> { const [pass, setPass] = useState('');
@observable const {currentUser} = useStores();
private pass = '';
public render() { const submitAndClose = async () => {
const {pass} = this; currentUser.changePassword(pass);
const {fClose, currentUser} = this.props; fClose();
const submitAndClose = () => { };
currentUser.changePassword(pass);
fClose();
};
return (
<Dialog
open={true}
onClose={fClose}
aria-labelledby="form-dialog-title"
id="changepw-dialog">
<DialogTitle id="form-dialog-title">Change Password</DialogTitle>
<DialogContent>
<TextField
className="newpass"
autoFocus
margin="dense"
type="password"
label="New Password *"
value={pass}
onChange={(e) => (this.pass = e.target.value)}
fullWidth
/>
</DialogContent>
<DialogActions>
<Button onClick={fClose}>Cancel</Button>
<Tooltip title={pass.length !== 0 ? '' : 'Password is required'}>
<div>
<Button
className="change"
disabled={pass.length === 0}
onClick={submitAndClose}
color="primary"
variant="contained">
Change
</Button>
</div>
</Tooltip>
</DialogActions>
</Dialog>
);
}
}
export default inject('currentUser')(SettingsDialog); return (
<Dialog
open={true}
onClose={fClose}
aria-labelledby="form-dialog-title"
id="changepw-dialog">
<DialogTitle id="form-dialog-title">Change Password</DialogTitle>
<DialogContent>
<TextField
className="newpass"
autoFocus
margin="dense"
type="password"
label="New Password *"
value={pass}
onChange={(e) => setPass(e.target.value)}
fullWidth
/>
</DialogContent>
<DialogActions>
<Button onClick={fClose}>Cancel</Button>
<Tooltip title={pass.length !== 0 ? '' : 'Password is required'}>
<div>
<Button
className="change"
disabled={pass.length === 0}
onClick={submitAndClose}
color="primary"
variant="contained">
Change
</Button>
</div>
</Tooltip>
</DialogActions>
</Dialog>
);
});
export default SettingsDialog;

View File

@ -6,7 +6,6 @@ export interface IConfig {
version: IVersion; version: IVersion;
} }
// eslint-disable-next-line @typescript-eslint/no-unused-vars
declare global { declare global {
interface Window { interface Window {
config?: Partial<IConfig>; config?: Partial<IConfig>;

View File

@ -1,5 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import * as ReactDOM from 'react-dom'; import {createRoot} from 'react-dom/client';
import 'typeface-roboto'; import 'typeface-roboto';
import {initAxios} from './apiAuth'; import {initAxios} from './apiAuth';
import * as config from './config'; import * as config from './config';
@ -9,14 +9,12 @@ import {CurrentUser} from './CurrentUser';
import {AppStore} from './application/AppStore'; import {AppStore} from './application/AppStore';
import {WebSocketStore} from './message/WebSocketStore'; import {WebSocketStore} from './message/WebSocketStore';
import {SnackManager} from './snack/SnackManager'; import {SnackManager} from './snack/SnackManager';
import {InjectProvider, StoreMapping} from './inject';
import {UserStore} from './user/UserStore'; import {UserStore} from './user/UserStore';
import {MessagesStore} from './message/MessagesStore'; import {MessagesStore} from './message/MessagesStore';
import {ClientStore} from './client/ClientStore'; import {ClientStore} from './client/ClientStore';
import {PluginStore} from './plugin/PluginStore'; import {PluginStore} from './plugin/PluginStore';
import {registerReactions} from './reactions'; import {registerReactions} from './reactions';
import {StoreContext, StoreMapping} from './stores';
const devUrl = 'http://localhost:3000/';
const {port, hostname, protocol, pathname} = window.location; const {port, hostname, protocol, pathname} = window.location;
const slashes = protocol.concat('//'); const slashes = protocol.concat('//');
@ -50,12 +48,7 @@ const initStores = (): StoreMapping => {
}; };
(function clientJS() { (function clientJS() {
if (process.env.NODE_ENV === 'production') { config.set('url', prodUrl);
config.set('url', prodUrl);
} else {
config.set('url', devUrl);
config.set('register', true);
}
const stores = initStores(); const stores = initStores();
initAxios(stores.currentUser, stores.snackManager.snack); initAxios(stores.currentUser, stores.snackManager.snack);
@ -67,11 +60,10 @@ const initStores = (): StoreMapping => {
stores.wsStore.close(); stores.wsStore.close();
}; };
ReactDOM.render( createRoot(document.getElementById('root')!).render(
<InjectProvider stores={stores}> <StoreContext.Provider value={stores}>
<Layout /> <Layout />
</InjectProvider>, </StoreContext.Provider>
document.getElementById('root')
); );
unregister(); unregister();
})(); })();

View File

@ -1,70 +1,68 @@
import AppBar from '@material-ui/core/AppBar'; import AppBar from '@mui/material/AppBar';
import Button from '@material-ui/core/Button'; import Button, {ButtonProps} from '@mui/material/Button';
import IconButton from '@material-ui/core/IconButton'; import IconButton from '@mui/material/IconButton';
import {createStyles, Theme, WithStyles, withStyles} from '@material-ui/core/styles'; import {Theme} from '@mui/material/styles';
import Toolbar from '@material-ui/core/Toolbar'; import {makeStyles} from 'tss-react/mui';
import Typography from '@material-ui/core/Typography'; import Toolbar from '@mui/material/Toolbar';
import AccountCircle from '@material-ui/icons/AccountCircle'; import Typography from '@mui/material/Typography';
import Chat from '@material-ui/icons/Chat'; import AccountCircle from '@mui/icons-material/AccountCircle';
import DevicesOther from '@material-ui/icons/DevicesOther'; import Chat from '@mui/icons-material/Chat';
import ExitToApp from '@material-ui/icons/ExitToApp'; import DevicesOther from '@mui/icons-material/DevicesOther';
import Highlight from '@material-ui/icons/Highlight'; import ExitToApp from '@mui/icons-material/ExitToApp';
import GitHubIcon from '@material-ui/icons/GitHub'; import Highlight from '@mui/icons-material/Highlight';
import MenuIcon from '@material-ui/icons/Menu'; import GitHubIcon from '@mui/icons-material/GitHub';
import Apps from '@material-ui/icons/Apps'; import MenuIcon from '@mui/icons-material/Menu';
import SupervisorAccount from '@material-ui/icons/SupervisorAccount'; import Apps from '@mui/icons-material/Apps';
import React, {Component, CSSProperties} from 'react'; import SupervisorAccount from '@mui/icons-material/SupervisorAccount';
import React, {CSSProperties} from 'react';
import {Link} from 'react-router-dom'; import {Link} from 'react-router-dom';
import {observer} from 'mobx-react'; import {useMediaQuery} from '@mui/material';
import {Hidden, PropTypes, withWidth} from '@material-ui/core';
import {Breakpoint} from '@material-ui/core/styles/createBreakpoints';
const styles = (theme: Theme) => const useStyles = makeStyles()((theme: Theme) => ({
createStyles({ appBar: {
appBar: { zIndex: theme.zIndex.drawer + 1,
zIndex: theme.zIndex.drawer + 1, [theme.breakpoints.down('sm')]: {
[theme.breakpoints.down('xs')]: { paddingBottom: 10,
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', justifyContent: 'space-between',
[theme.breakpoints.down('xs')]: {
flexWrap: 'wrap',
},
},
menuButtons: {
display: 'flex',
[theme.breakpoints.down('sm')]: {
flex: 1,
},
justifyContent: 'center',
[theme.breakpoints.down('xs')]: {
flexBasis: '100%',
marginTop: 5,
order: 1,
justifyContent: 'space-between',
},
},
title: {
[theme.breakpoints.up('md')]: {
flex: 1,
},
display: 'flex',
alignItems: 'center', alignItems: 'center',
}, },
titleName: { },
paddingRight: 10, title: {
[theme.breakpoints.up('md')]: {
flex: 1,
}, },
link: { display: 'flex',
color: 'inherit', alignItems: 'center',
textDecoration: 'none', },
}, titleName: {
}); paddingRight: 10,
},
link: {
color: 'inherit',
textDecoration: 'none',
},
}));
type Styles = WithStyles<'link' | 'menuButtons' | 'toolbar' | 'titleName' | 'title' | 'appBar'>; interface IProps {
interface IProps extends Styles {
loggedIn: boolean; loggedIn: boolean;
name: string; name: string;
admin: boolean; admin: boolean;
@ -73,145 +71,142 @@ interface IProps extends Styles {
showSettings: VoidFunction; showSettings: VoidFunction;
logout: VoidFunction; logout: VoidFunction;
style: CSSProperties; style: CSSProperties;
width: Breakpoint;
setNavOpen: (open: boolean) => void; setNavOpen: (open: boolean) => void;
} }
@observer const Header = ({
class Header extends Component<IProps> { version,
public render() { name,
const { loggedIn,
classes, admin,
version, toggleTheme,
name, logout,
loggedIn, style,
admin, setNavOpen,
toggleTheme, showSettings,
logout, }: IProps) => {
style, const {classes} = useStyles();
setNavOpen,
width,
} = this.props;
const position = width === 'xs' ? 'sticky' : 'fixed'; return (
<AppBar
return ( sx={{position: {xs: 'sticky', sm: 'fixed'}}}
<AppBar position={position} style={style} className={classes.appBar}> style={style}
<Toolbar className={classes.toolbar}> className={classes.appBar}>
<div className={classes.title}> <Toolbar className={classes.toolbar}>
<Link to="/" className={classes.link}> <div className={classes.title}>
<Typography variant="h5" className={classes.titleName} color="inherit"> <Link to="/" className={classes.link}>
Gotify <Typography variant="h5" className={classes.titleName} color="inherit">
</Typography> Gotify
</Link> </Typography>
<a
href={'https://github.com/gotify/server/releases/tag/v' + version}
className={classes.link}>
<Typography variant="button" color="inherit">
@{version}
</Typography>
</a>
</div>
{loggedIn && this.renderButtons(name, admin, logout, width, setNavOpen)}
<div>
<IconButton onClick={toggleTheme} color="inherit">
<Highlight />
</IconButton>
<a
href="https://github.com/gotify/server"
className={classes.link}
target="_blank"
rel="noopener noreferrer">
<IconButton color="inherit">
<GitHubIcon />
</IconButton>
</a>
</div>
</Toolbar>
</AppBar>
);
}
private renderButtons(
name: string,
admin: boolean,
logout: VoidFunction,
width: Breakpoint,
setNavOpen: (open: boolean) => void
) {
const {classes, showSettings} = this.props;
return (
<div className={classes.menuButtons}>
<Hidden smUp implementation="css">
<ResponsiveButton
icon={<MenuIcon />}
onClick={() => setNavOpen(true)}
label="menu"
width={width}
color="inherit"
/>
</Hidden>
{admin && (
<Link className={classes.link} to="/users" id="navigate-users">
<ResponsiveButton
icon={<SupervisorAccount />}
label="users"
width={width}
color="inherit"
/>
</Link> </Link>
<a
href={'https://github.com/gotify/server/releases/tag/v' + version}
className={classes.link}>
<Typography variant="button" color="inherit">
@{version}
</Typography>
</a>
</div>
{loggedIn && (
<Buttons
admin={admin}
name={name}
logout={logout}
setNavOpen={setNavOpen}
showSettings={showSettings}
/>
)} )}
<Link className={classes.link} to="/applications" id="navigate-apps"> <div>
<ResponsiveButton icon={<Chat />} label="apps" width={width} color="inherit" /> <IconButton onClick={toggleTheme} color="inherit" size="large">
<Highlight />
</IconButton>
<a
href="https://github.com/gotify/server"
className={classes.link}
target="_blank"
rel="noopener noreferrer">
<IconButton color="inherit" size="large">
<GitHubIcon />
</IconButton>
</a>
</div>
</Toolbar>
</AppBar>
);
};
const Buttons = ({
showSettings,
name,
admin,
logout,
setNavOpen,
}: {
name: string;
admin: boolean;
logout: VoidFunction;
setNavOpen: (open: boolean) => void;
showSettings: VoidFunction;
}) => {
const {classes} = useStyles();
return (
<div className={classes.menuButtons}>
<ResponsiveButton
sx={{display: {sm: 'none', xs: 'block'}}}
icon={<MenuIcon />}
onClick={() => setNavOpen(true)}
label="menu"
color="inherit"
/>
{admin && (
<Link className={classes.link} to="/users" id="navigate-users">
<ResponsiveButton icon={<SupervisorAccount />} label="users" color="inherit" />
</Link> </Link>
<Link className={classes.link} to="/clients" id="navigate-clients"> )}
<ResponsiveButton <Link className={classes.link} to="/applications" id="navigate-apps">
icon={<DevicesOther />} <ResponsiveButton icon={<Chat />} label="apps" color="inherit" />
label="clients" </Link>
width={width} <Link className={classes.link} to="/clients" id="navigate-clients">
color="inherit" <ResponsiveButton icon={<DevicesOther />} label="clients" color="inherit" />
/> </Link>
</Link> <Link className={classes.link} to="/plugins" id="navigate-plugins">
<Link className={classes.link} to="/plugins" id="navigate-plugins"> <ResponsiveButton icon={<Apps />} label="plugins" color="inherit" />
<ResponsiveButton </Link>
icon={<Apps />} <ResponsiveButton
label="plugins" icon={<AccountCircle />}
width={width} label={name}
color="inherit" onClick={showSettings}
/> id="changepw"
</Link> color="inherit"
<ResponsiveButton />
icon={<AccountCircle />} <ResponsiveButton
label={name} icon={<ExitToApp />}
onClick={showSettings} label="Logout"
id="changepw" onClick={logout}
width={width} id="logout"
color="inherit" color="inherit"
/> />
<ResponsiveButton </div>
icon={<ExitToApp />} );
label="Logout" };
onClick={logout}
id="logout"
width={width}
color="inherit"
/>
</div>
);
}
}
const ResponsiveButton: React.FC<{ const ResponsiveButton: React.FC<{
width: Breakpoint; color: 'inherit';
color: PropTypes.Color; sx?: ButtonProps['sx'];
label: string; label: string;
id?: string; id?: string;
onClick?: () => void; onClick?: () => void;
icon: React.ReactNode; icon: React.ReactNode;
}> = ({width, icon, label, ...rest}) => { }> = ({icon, label, ...rest}) => {
if (width === 'xs' || width === 'sm') { const matches = useMediaQuery('(max-width:1000px)');
return <IconButton {...rest}>{icon}</IconButton>; if (matches) {
return (
<IconButton {...rest} size="large">
{icon}
</IconButton>
);
} }
return ( return (
<Button startIcon={icon} {...rest}> <Button startIcon={icon} {...rest}>
@ -220,4 +215,4 @@ const ResponsiveButton: React.FC<{
); );
}; };
export default withWidth()(withStyles(styles, {withTheme: true})(Header)); export default Header;

View File

@ -1,49 +1,48 @@
import {createMuiTheme, MuiThemeProvider, Theme, WithStyles, withStyles} from '@material-ui/core'; import {createTheme, ThemeProvider, StyledEngineProvider, Theme} from '@mui/material';
import CssBaseline from '@material-ui/core/CssBaseline'; import {makeStyles} from 'tss-react/mui';
import CssBaseline from '@mui/material/CssBaseline';
import * as React from 'react'; import * as React from 'react';
import {HashRouter, Redirect, Route, Switch} from 'react-router-dom'; import {HashRouter, Navigate, Route, Routes} from 'react-router-dom';
import Header from './Header'; import Header from './Header';
import LoadingSpinner from '../common/LoadingSpinner';
import Navigation from './Navigation'; import Navigation from './Navigation';
import ScrollUpButton from '../common/ScrollUpButton'; import ScrollUpButton from '../common/ScrollUpButton';
import SettingsDialog from '../common/SettingsDialog'; import SettingsDialog from '../common/SettingsDialog';
import SnackBarHandler from '../snack/SnackBarHandler';
import * as config from '../config'; import * as config from '../config';
import Applications from '../application/Applications'; import Applications from '../application/Applications';
import Clients from '../client/Clients'; import Clients from '../client/Clients';
import Plugins from '../plugin/Plugins'; import Plugins from '../plugin/Plugins';
import PluginDetailView from '../plugin/PluginDetailView';
import Login from '../user/Login'; import Login from '../user/Login';
import Messages from '../message/Messages'; import Messages from '../message/Messages';
import Users from '../user/Users'; import Users from '../user/Users';
import {observer} from 'mobx-react'; import {observer} from 'mobx-react';
import {observable} from 'mobx';
import {inject, Stores} from '../inject';
import {ConnectionErrorBanner} from '../common/ConnectionErrorBanner'; import {ConnectionErrorBanner} from '../common/ConnectionErrorBanner';
import {useStores} from '../stores';
import {SnackbarProvider} from 'notistack';
import LoadingSpinner from '../common/LoadingSpinner';
const styles = (theme: Theme) => ({ const useStyles = makeStyles()((theme: Theme) => ({
content: { content: {
margin: '0 auto', margin: '0 auto',
marginTop: 64, marginTop: 64,
padding: theme.spacing(4), padding: theme.spacing(4),
width: '100%', width: '100%',
[theme.breakpoints.down('xs')]: { [theme.breakpoints.down('sm')]: {
marginTop: 0, marginTop: 0,
}, },
}, },
}); }));
const localStorageThemeKey = 'gotify-theme'; const localStorageThemeKey = 'gotify-theme';
type ThemeKey = 'dark' | 'light'; type ThemeKey = 'dark' | 'light';
const themeMap: Record<ThemeKey, Theme> = { const themeMap: Record<ThemeKey, Theme> = {
light: createMuiTheme({ light: createTheme({
palette: { palette: {
type: 'light', mode: 'light',
}, },
}), }),
dark: createMuiTheme({ dark: createTheme({
palette: { palette: {
type: 'dark', mode: 'dark',
}, },
}), }),
}; };
@ -51,50 +50,44 @@ const themeMap: Record<ThemeKey, Theme> = {
const isThemeKey = (value: string | null): value is ThemeKey => const isThemeKey = (value: string | null): value is ThemeKey =>
value === 'light' || value === 'dark'; value === 'light' || value === 'dark';
@observer const Layout = observer(() => {
class Layout extends React.Component< const {
WithStyles<'content'> & Stores<'currentUser' | 'snackManager'> currentUser: {
> { loggedIn,
@observable user: {name, admin},
private currentTheme: ThemeKey = 'dark'; logout,
@observable tryReconnect,
private showSettings = false; connectionErrorMessage,
@observable refreshKey,
private navOpen = false; },
} = useStores();
const {classes} = useStyles();
const [currentTheme, setCurrentTheme] = React.useState<ThemeKey>(() => {
const stored = window.localStorage.getItem(localStorageThemeKey);
return isThemeKey(stored) ? stored : 'dark';
});
const theme = themeMap[currentTheme];
const {version} = config.get('version');
const [navOpen, setNavOpen] = React.useState(false);
const [showSettings, setShowSettings] = React.useState(false);
private setNavOpen(open: boolean) { const toggleTheme = () => {
this.navOpen = open; const next = currentTheme === 'dark' ? 'light' : 'dark';
} setCurrentTheme(next);
localStorage.setItem(localStorageThemeKey, next);
};
public componentDidMount() { const authed = (children: React.ReactNode) => (
const localStorageTheme = window.localStorage.getItem(localStorageThemeKey); <RequireAuth loggedIn={loggedIn}>{children}</RequireAuth>
if (isThemeKey(localStorageTheme)) { );
this.currentTheme = localStorageTheme;
} else {
window.localStorage.setItem(localStorageThemeKey, this.currentTheme);
}
}
public render() { return (
const {showSettings, currentTheme} = this; <StyledEngineProvider injectFirst>
const { <ThemeProvider theme={theme}>
classes,
currentUser: {
loggedIn,
authenticating,
user: {name, admin},
logout,
tryReconnect,
connectionErrorMessage,
},
} = this.props;
const theme = themeMap[currentTheme];
const loginRoute = () => (loggedIn ? <Redirect to="/" /> : <Login />);
const {version} = config.get('version');
return (
<MuiThemeProvider theme={theme}>
<HashRouter> <HashRouter>
<div> {/* This forces all components to fully rerender including useEffects.
The refreshKey is updated when store data was cleaned and pages should refetch their data. */}
<div key={refreshKey}>
{!connectionErrorMessage ? null : ( {!connectionErrorMessage ? null : (
<ConnectionErrorBanner <ConnectionErrorBanner
height={64} height={64}
@ -105,65 +98,79 @@ class Layout extends React.Component<
<div style={{display: 'flex', flexDirection: 'column'}}> <div style={{display: 'flex', flexDirection: 'column'}}>
<CssBaseline /> <CssBaseline />
<Header <Header
style={{top: !connectionErrorMessage ? 0 : 64}}
admin={admin} admin={admin}
name={name} name={name}
style={{top: !connectionErrorMessage ? 0 : 64}}
version={version} version={version}
loggedIn={loggedIn} loggedIn={loggedIn}
toggleTheme={this.toggleTheme.bind(this)} toggleTheme={toggleTheme}
showSettings={() => (this.showSettings = true)} showSettings={() => setShowSettings(true)}
logout={logout} logout={logout}
setNavOpen={this.setNavOpen.bind(this)} setNavOpen={setNavOpen}
/> />
<div style={{display: 'flex'}}> <div style={{display: 'flex'}}>
<Navigation <Navigation
loggedIn={loggedIn} loggedIn={loggedIn}
navOpen={this.navOpen} navOpen={navOpen}
setNavOpen={this.setNavOpen.bind(this)} setNavOpen={setNavOpen}
/> />
<main className={classes.content}> <main className={classes.content}>
<Switch> <Routes>
{authenticating ? ( <Route path="/login" element={<Login />} />
<Route path="/"> <Route path="/" element={authed(<Messages />)} />
<LoadingSpinner /> <Route
</Route> path="/messages/:id"
) : null} element={authed(<Messages />)}
<Route exact path="/login" render={loginRoute} /> />
{loggedIn ? null : <Redirect to="/login" />}
<Route exact path="/" component={Messages} />
<Route exact path="/messages/:id" component={Messages} />
<Route <Route
exact
path="/applications" path="/applications"
component={Applications} element={authed(<Applications />)}
/> />
<Route exact path="/clients" component={Clients} /> <Route path="/clients" element={authed(<Clients />)} />
<Route exact path="/users" component={Users} /> <Route path="/users" element={authed(<Users />)} />
<Route exact path="/plugins" component={Plugins} /> <Route path="/plugins" element={authed(<Plugins />)} />
<Route <Route
exact
path="/plugins/:id" path="/plugins/:id"
component={PluginDetailView} element={authed(
<Lazy
component={() =>
import('../plugin/PluginDetailView')
}
/>
)}
/> />
</Switch> </Routes>
</main> </main>
</div> </div>
{showSettings && ( {showSettings && (
<SettingsDialog fClose={() => (this.showSettings = false)} /> <SettingsDialog fClose={() => setShowSettings(false)} />
)} )}
<ScrollUpButton /> <ScrollUpButton />
<SnackBarHandler /> <SnackbarProvider />
</div> </div>
</div> </div>
</HashRouter> </HashRouter>
</MuiThemeProvider> </ThemeProvider>
); </StyledEngineProvider>
} );
});
private toggleTheme() { // eslint-disable-next-line
this.currentTheme = this.currentTheme === 'dark' ? 'light' : 'dark'; const Lazy = ({component}: {component: () => Promise<{default: React.ComponentType<any>}>}) => {
localStorage.setItem(localStorageThemeKey, this.currentTheme); const Component = React.lazy(component);
}
}
export default withStyles(styles, {withTheme: true})(inject('currentUser', 'snackManager')(Layout)); return (
<React.Suspense fallback={<LoadingSpinner />}>
<Component />
</React.Suspense>
);
};
const RequireAuth: React.FC<React.PropsWithChildren<{loggedIn: boolean}>> = ({
children,
loggedIn,
}) => {
return loggedIn ? <>{children}</> : <Navigate replace={true} to="/login" />;
};
export default Layout;

View File

@ -1,25 +1,25 @@
import Divider from '@material-ui/core/Divider'; import Divider from '@mui/material/Divider';
import Drawer from '@material-ui/core/Drawer'; import Drawer from '@mui/material/Drawer';
import {StyleRules, Theme, WithStyles, withStyles} from '@material-ui/core/styles'; import {Theme} from '@mui/material/styles';
import React, {Component} from 'react'; import React from 'react';
import {Link} from 'react-router-dom'; import {Link} from 'react-router-dom';
import {observer} from 'mobx-react'; import {observer} from 'mobx-react';
import {inject, Stores} from '../inject';
import {mayAllowPermission, requestPermission} from '../snack/browserNotification'; import {mayAllowPermission, requestPermission} from '../snack/browserNotification';
import { import {
Button, Button,
Hidden,
IconButton, IconButton,
Typography, Typography,
ListItem,
ListItemText, ListItemText,
ListItemAvatar, ListItemAvatar,
Avatar, Avatar,
} from '@material-ui/core'; ListItemButton,
import {DrawerProps} from '@material-ui/core/Drawer/Drawer'; } from '@mui/material';
import CloseIcon from '@material-ui/icons/Close'; import {DrawerProps} from '@mui/material/Drawer/Drawer';
import CloseIcon from '@mui/icons-material/Close';
import {makeStyles} from 'tss-react/mui';
import {useStores} from '../stores';
const styles = (theme: Theme): StyleRules<'root' | 'drawerPaper' | 'toolbar' | 'link'> => ({ const useStyles = makeStyles()((theme: Theme) => ({
root: { root: {
height: '100%', height: '100%',
}, },
@ -29,14 +29,13 @@ const styles = (theme: Theme): StyleRules<'root' | 'drawerPaper' | 'toolbar' | '
minHeight: '100%', minHeight: '100%',
height: '100vh', height: '100vh',
}, },
toolbar: theme.mixins.toolbar, // eslint-disable-next-line
toolbar: theme.mixins.toolbar as any,
link: { link: {
color: 'inherit', color: 'inherit',
textDecoration: 'none', textDecoration: 'none',
}, },
}); }));
type Styles = WithStyles<'root' | 'drawerPaper' | 'toolbar' | 'link'>;
interface IProps { interface IProps {
loggedIn: boolean; loggedIn: boolean;
@ -44,98 +43,92 @@ interface IProps {
setNavOpen: (open: boolean) => void; setNavOpen: (open: boolean) => void;
} }
@observer const Navigation = observer(({loggedIn, navOpen, setNavOpen}: IProps) => {
class Navigation extends Component< const [showRequestNotification, setShowRequestNotification] =
IProps & Styles & Stores<'appStore'>, React.useState(mayAllowPermission);
{showRequestNotification: boolean} const {classes} = useStyles();
> { const {appStore} = useStores();
public state = {showRequestNotification: mayAllowPermission()}; const apps = appStore.getItems();
public render() { const userApps =
const {classes, loggedIn, appStore, navOpen, setNavOpen} = this.props; apps.length === 0
const {showRequestNotification} = this.state; ? null
const apps = appStore.getItems(); : apps.map((app) => (
<Link
onClick={() => setNavOpen(false)}
className={`${classes.link} item`}
to={'/messages/' + app.id}
key={app.id}>
<ListItemButton>
<ListItemAvatar style={{minWidth: 42}}>
<Avatar
style={{width: 32, height: 32}}
src={app.image}
variant="square"
/>
</ListItemAvatar>
<ListItemText primary={app.name} />
</ListItemButton>
</Link>
));
const userApps = const placeholderItems = [
apps.length === 0 <ListItemButton disabled key={-1}>
? null <ListItemText primary="Some Server" />
: apps.map((app) => ( </ListItemButton>,
<Link <ListItemButton disabled key={-2}>
onClick={() => setNavOpen(false)} <ListItemText primary="A Raspberry PI" />
className={`${classes.link} item`} </ListItemButton>,
to={'/messages/' + app.id} ];
key={app.id}>
<ListItem button>
<ListItemAvatar style={{minWidth: 42}}>
<Avatar
style={{width: 32, height: 32}}
src={app.image}
variant="square"
/>
</ListItemAvatar>
<ListItemText primary={app.name} />
</ListItem>
</Link>
));
const placeholderItems = [ return (
<ListItem button disabled key={-1}> <ResponsiveDrawer
<ListItemText primary="Some Server" /> classes={{root: classes.root, paper: classes.drawerPaper}}
</ListItem>, navOpen={navOpen}
<ListItem button disabled key={-2}> setNavOpen={setNavOpen}
<ListItemText primary="A Raspberry PI" /> id="message-navigation">
</ListItem>, <div className={classes.toolbar} />
]; <Link className={classes.link} to="/" onClick={() => setNavOpen(false)}>
<ListItemButton disabled={!loggedIn} className="all">
return ( <ListItemText primary="All Messages" />
<ResponsiveDrawer </ListItemButton>
classes={{root: classes.root, paper: classes.drawerPaper}} </Link>
navOpen={navOpen} <Divider />
setNavOpen={setNavOpen} <div>{loggedIn ? userApps : placeholderItems}</div>
id="message-navigation"> <Divider />
<div className={classes.toolbar} /> <Typography align="center" style={{marginTop: 10}}>
<Link className={classes.link} to="/" onClick={() => setNavOpen(false)}> {showRequestNotification ? (
<ListItem button disabled={!loggedIn} className="all"> <Button
<ListItemText primary="All Messages" /> onClick={() => {
</ListItem> requestPermission();
</Link> setShowRequestNotification(false);
<Divider /> }}>
<div>{loggedIn ? userApps : placeholderItems}</div> Enable Notifications
<Divider /> </Button>
<Typography align="center" style={{marginTop: 10}}> ) : null}
{showRequestNotification ? ( </Typography>
<Button </ResponsiveDrawer>
onClick={() => { );
requestPermission(); });
this.setState({showRequestNotification: false});
}}>
Enable Notifications
</Button>
) : null}
</Typography>
</ResponsiveDrawer>
);
}
}
const ResponsiveDrawer: React.FC< const ResponsiveDrawer: React.FC<
DrawerProps & {navOpen: boolean; setNavOpen: (open: boolean) => void} DrawerProps & {navOpen: boolean; setNavOpen: (open: boolean) => void}
> = ({navOpen, setNavOpen, children, ...rest}) => ( > = ({navOpen, setNavOpen, children, ...rest}) => (
<> <>
<Hidden smUp implementation="css"> <Drawer
<Drawer variant="temporary" open={navOpen} {...rest}> sx={{display: {sm: 'none', xs: 'block'}}}
<IconButton onClick={() => setNavOpen(false)}> variant="temporary"
<CloseIcon /> open={navOpen}
</IconButton> {...rest}>
{children} <IconButton onClick={() => setNavOpen(false)} size="large">
</Drawer> <CloseIcon />
</Hidden> </IconButton>
<Hidden xsDown implementation="css"> {children}
<Drawer variant="permanent" {...rest}> </Drawer>
{children} <Drawer sx={{display: {xs: 'none', sm: 'block'}}} variant="permanent" {...rest}>
</Drawer> {children}
</Hidden> </Drawer>
</> </>
); );
export default withStyles(styles, {withTheme: true})(inject('appStore')(Navigation)); export default Navigation;

View File

@ -1,81 +1,81 @@
import {Button} from '@material-ui/core'; import {Button, Theme, useMediaQuery, useTheme} from '@mui/material';
import IconButton from '@material-ui/core/IconButton'; import IconButton from '@mui/material/IconButton';
import {createStyles, Theme, withStyles, WithStyles} from '@material-ui/core/styles'; import {makeStyles} from 'tss-react/mui';
import Typography from '@material-ui/core/Typography'; import Typography from '@mui/material/Typography';
import {ExpandLess, ExpandMore} from '@material-ui/icons'; import {ExpandLess, ExpandMore} from '@mui/icons-material';
import Delete from '@material-ui/icons/Delete'; import Delete from '@mui/icons-material/Delete';
import React, {RefObject} from 'react'; import React from 'react';
import TimeAgo from 'react-timeago'; import TimeAgo from 'react-timeago';
import Container from '../common/Container'; import Container from '../common/Container';
import {Markdown} from '../common/Markdown'; import {Markdown} from '../common/Markdown';
import * as config from '../config'; import * as config from '../config';
import {IMessageExtras} from '../types'; import {IMessageExtras} from '../types';
import {contentType, RenderMode} from './extras'; import {contentType, RenderMode} from './extras';
import {makeIntlFormatter} from 'react-timeago/defaultFormatter';
const PREVIEW_LENGTH = 500; const PREVIEW_LENGTH = 500;
const styles = (theme: Theme) => const useStyles = makeStyles()((theme: Theme) => ({
createStyles({ header: {
header: { display: 'flex',
display: 'flex', flexWrap: 'wrap',
flexWrap: 'wrap', marginBottom: 0,
marginBottom: 0, },
headerTitle: {
flex: 1,
},
trash: {
marginTop: -15,
marginRight: -15,
},
wrapperPadding: {
marginBottom: 12,
},
messageContentWrapper: {
minWidth: 200,
width: '100%',
},
image: {
marginRight: 15,
[theme.breakpoints.down('md')]: {
width: 32,
height: 32,
}, },
headerTitle: { },
flex: 1, date: {
[theme.breakpoints.down('md')]: {
order: 1,
flexBasis: '100%',
opacity: 0.7,
}, },
trash: { },
marginTop: -15, imageWrapper: {
marginRight: -15, display: 'flex',
},
plainContent: {
whiteSpace: 'pre-wrap',
},
content: {
maxHeight: PREVIEW_LENGTH,
wordBreak: 'break-all',
overflowY: 'hidden',
'&.expanded': {
maxHeight: 'none',
}, },
wrapperPadding: { '& p': {
padding: 12, margin: 0,
}, },
messageContentWrapper: { '& a': {
width: '100%', color: '#ff7f50',
maxWidth: 585,
}, },
image: { '& pre': {
marginRight: 15, overflow: 'auto',
[theme.breakpoints.down('sm')]: {
width: 32,
height: 32,
},
}, },
date: { '& img': {
[theme.breakpoints.down('sm')]: { maxWidth: '100%',
order: 1,
flexBasis: '100%',
opacity: 0.7,
},
}, },
imageWrapper: { },
display: 'flex', }));
},
plainContent: {
whiteSpace: 'pre-wrap',
},
content: {
maxHeight: PREVIEW_LENGTH,
wordBreak: 'break-all',
overflowY: 'hidden',
'&.expanded': {
maxHeight: 'none',
},
'& p': {
margin: 0,
},
'& a': {
color: '#ff7f50',
},
'& pre': {
overflow: 'auto',
},
'& img': {
maxWidth: '100%',
},
},
});
interface IProps { interface IProps {
title: string; title: string;
@ -83,14 +83,11 @@ interface IProps {
date: string; date: string;
content: string; content: string;
priority: number; priority: number;
appName: string;
fDelete: VoidFunction; fDelete: VoidFunction;
extras?: IMessageExtras; extras?: IMessageExtras;
height: (height: number) => void;
}
interface IState {
expanded: boolean; expanded: boolean;
isOverflowing: boolean; onExpand: (expand: boolean) => void;
} }
const priorityColor = (priority: number) => { const priorityColor = (priority: number) => {
@ -103,66 +100,58 @@ const priorityColor = (priority: number) => {
} }
}; };
class Message extends React.PureComponent<IProps & WithStyles<typeof styles>, IState> { const Message = ({
public state = {expanded: false, isOverflowing: false}; fDelete,
private node: HTMLDivElement | null = null; title,
private previewRef: RefObject<HTMLDivElement>; date,
image,
priority,
content,
extras,
appName,
onExpand,
expanded: initialExpanded,
}: IProps) => {
const theme = useTheme();
const [previewRef, setPreviewRef] = React.useState<HTMLDivElement | null>(null);
const {classes} = useStyles();
const [expanded, setExpanded] = React.useState(initialExpanded);
const [isOverflowing, setOverflowing] = React.useState(false);
const dateWrapped = useMediaQuery(theme.breakpoints.down('md'));
constructor(props: IProps & WithStyles<typeof styles>) { React.useEffect(() => {
super(props); setOverflowing(!!previewRef && previewRef.scrollHeight > previewRef.clientHeight);
this.previewRef = React.createRef(); }, [previewRef]);
}
public componentDidMount = () => { React.useEffect(() => void onExpand(expanded), [expanded]);
if (this.previewRef.current) {
this.setState({
isOverflowing:
this.previewRef.current.scrollHeight > this.previewRef.current.clientHeight,
});
}
this.updateHeightInParent();
};
public togglePreviewHeight = () => { const togglePreviewHeight = () => setExpanded((b) => !b);
this.setState(
(state) => ({expanded: !state.expanded}),
() => this.updateHeightInParent()
);
};
private updateHeightInParent = () => const renderContent = () => {
this.props.height(this.node ? this.node.getBoundingClientRect().height : 0); switch (contentType(extras)) {
private renderContent = () => {
const content = this.props.content;
switch (contentType(this.props.extras)) {
case RenderMode.Markdown: case RenderMode.Markdown:
return <Markdown>{content}</Markdown>; return <Markdown>{content}</Markdown>;
case RenderMode.Plain: case RenderMode.Plain:
default: default:
return <span className={this.props.classes.plainContent}>{content}</span>; return <span className={classes.plainContent}>{content}</span>;
} }
}; };
return (
public render(): React.ReactNode { <div className={`${classes.wrapperPadding} message`}>
const {fDelete, classes, title, date, image, priority} = this.props; <Container
style={{
return ( display: 'flex',
<div className={`${classes.wrapperPadding} message`} ref={(ref) => (this.node = ref)}> flexWrap: 'wrap',
<Container borderLeftColor: priorityColor(priority),
style={{ borderLeftWidth: 6,
display: 'flex', borderLeftStyle: 'solid',
flexWrap: 'wrap', }}>
borderLeftColor: priorityColor(priority), <div style={{display: 'flex', width: '100%'}}>
borderLeftWidth: 6,
borderLeftStyle: 'solid',
}}>
<div className={classes.imageWrapper}> <div className={classes.imageWrapper}>
{image !== null ? ( {image !== null ? (
<img <img
src={config.get('url') + image} src={config.get('url') + image}
alt="app logo" alt={`${appName} logo`}
width="70" width="70"
height="70" height="70"
className={classes.image} className={classes.image}
@ -175,38 +164,46 @@ class Message extends React.PureComponent<IProps & WithStyles<typeof styles>, IS
{title} {title}
</Typography> </Typography>
<Typography variant="body1" className={classes.date}> <Typography variant="body1" className={classes.date}>
<TimeAgo date={date} /> <TimeAgo
date={date}
formatter={makeIntlFormatter({
style: dateWrapped ? 'long' : 'narrow',
})}
/>
</Typography> </Typography>
<IconButton onClick={fDelete} className={`${classes.trash} delete`}> <IconButton
onClick={fDelete}
className={`${classes.trash} delete`}
size="large">
<Delete /> <Delete />
</IconButton> </IconButton>
</div> </div>
<Typography <Typography
component="div" component="div"
ref={this.previewRef} ref={setPreviewRef}
className={`${classes.content} content ${ className={`${classes.content} content ${
this.state.isOverflowing && this.state.expanded ? 'expanded' : '' isOverflowing && expanded ? 'expanded' : ''
}`}> }`}>
{this.renderContent()} {renderContent()}
</Typography> </Typography>
</div> </div>
{this.state.isOverflowing && ( </div>
<Button {isOverflowing && (
style={{marginTop: 16}} <Button
onClick={() => this.togglePreviewHeight()} style={{marginTop: 16}}
variant="contained" onClick={togglePreviewHeight}
color="primary" variant="contained"
size="large" color="primary"
fullWidth={true} size="large"
startIcon={this.state.expanded ? <ExpandLess /> : <ExpandMore />}> fullWidth={true}
{this.state.expanded ? 'Read Less' : 'Read More'} startIcon={expanded ? <ExpandLess /> : <ExpandMore />}>
</Button> {expanded ? 'Read Less' : 'Read More'}
)} </Button>
</Container> )}
</div> </Container>
); </div>
} );
} };
export default withStyles(styles, {withTheme: true})(Message); export default Message;

View File

@ -1,146 +1,47 @@
import Grid from '@material-ui/core/Grid'; import Grid from '@mui/material/Grid';
import Typography from '@material-ui/core/Typography'; import Typography from '@mui/material/Typography';
import React, {Component} from 'react'; import React from 'react';
import {RouteComponentProps} from 'react-router'; import {useParams} from 'react-router';
import DefaultPage from '../common/DefaultPage'; import DefaultPage from '../common/DefaultPage';
import Button from '@material-ui/core/Button'; import Button from '@mui/material/Button';
import Message from './Message'; import Message from './Message';
import {observer} from 'mobx-react'; import {observer} from 'mobx-react';
import {inject, Stores} from '../inject';
import {observable} from 'mobx';
import ReactInfinite from 'react-infinite';
import {IMessage} from '../types'; import {IMessage} from '../types';
import ConfirmDialog from '../common/ConfirmDialog'; import ConfirmDialog from '../common/ConfirmDialog';
import LoadingSpinner from '../common/LoadingSpinner'; import LoadingSpinner from '../common/LoadingSpinner';
import {useStores} from '../stores';
import {Virtuoso} from 'react-virtuoso';
type IProps = RouteComponentProps<{id: string}>; const Messages = observer(() => {
const {id} = useParams<{id: string}>();
const appId = id == null ? -1 : parseInt(id as string, 10);
interface IState { const [deleteAll, setDeleteAll] = React.useState(false);
appId: number; const [isLoadingMore, setLoadingMore] = React.useState(false);
} const {messagesStore, appStore} = useStores();
const messages = messagesStore.get(appId);
const hasMore = messagesStore.canLoadMore(appId);
const name = appStore.getName(appId);
const hasMessages = messages.length !== 0;
const expandedState = React.useRef<Record<number, boolean>>({});
@observer const deleteMessage = (message: IMessage) => () => messagesStore.removeSingle(message);
class Messages extends Component<IProps & Stores<'messagesStore' | 'appStore'>, IState> {
@observable
private heights: Record<string, number> = {};
@observable
private deleteAll = false;
private static appId(props: IProps) { React.useEffect(() => {
if (props === undefined) { if (!messagesStore.loaded(appId)) {
return -1; messagesStore.loadMore(appId);
} }
const {match} = props; }, [appId]);
return match.params.id !== undefined ? parseInt(match.params.id, 10) : -1;
}
public state = {appId: -1}; const renderMessage = (index: number, message: IMessage) => (
private isLoadingMore = false;
public componentWillReceiveProps(nextProps: IProps & Stores<'messagesStore' | 'appStore'>) {
this.updateAllWithProps(nextProps);
}
public componentWillMount() {
window.onscroll = () => {
if (
window.innerHeight + window.pageYOffset >=
document.body.offsetHeight - window.innerHeight * 2
) {
this.checkIfLoadMore();
}
};
this.updateAll();
}
public render() {
const {appId} = this.state;
const {messagesStore, appStore} = this.props;
const messages = messagesStore.get(appId);
const hasMore = messagesStore.canLoadMore(appId);
const name = appStore.getName(appId);
const hasMessages = messages.length !== 0;
return (
<DefaultPage
title={name}
rightControl={
<div>
<Button
id="refresh-all"
variant="contained"
color="primary"
onClick={() => messagesStore.refreshByApp(appId)}
style={{marginRight: 5}}>
Refresh
</Button>
<Button
id="delete-all"
variant="contained"
disabled={!hasMessages}
color="primary"
onClick={() => {
this.deleteAll = true;
}}>
Delete All
</Button>
</div>
}>
{!messagesStore.loaded(appId) ? (
<LoadingSpinner />
) : hasMessages ? (
<div style={{width: '100%'}} id="messages">
<ReactInfinite
key={appId}
useWindowAsScrollContainer
preloadBatchSize={window.innerHeight * 3}
elementHeight={messages.map((m) => this.heights[m.id] || 1)}>
{messages.map(this.renderMessage)}
</ReactInfinite>
{hasMore ? <LoadingSpinner /> : this.label("You've reached the end")}
</div>
) : (
this.label('No messages')
)}
{this.deleteAll && (
<ConfirmDialog
title="Confirm Delete"
text={'Delete all messages?'}
fClose={() => (this.deleteAll = false)}
fOnSubmit={() => messagesStore.removeByApp(appId)}
/>
)}
</DefaultPage>
);
}
private updateAllWithProps = (props: IProps & Stores<'messagesStore'>) => {
const appId = Messages.appId(props);
this.setState({appId});
if (!props.messagesStore.exists(appId)) {
props.messagesStore.loadMore(appId);
}
};
private updateAll = () => this.updateAllWithProps(this.props);
private deleteMessage = (message: IMessage) => () =>
this.props.messagesStore.removeSingle(message);
private renderMessage = (message: IMessage) => (
<Message <Message
key={message.id} key={index}
height={(height: number) => { fDelete={deleteMessage(message)}
if (!this.heights[message.id]) { onExpand={(expanded) => (expandedState.current[message.id] = expanded)}
this.heights[message.id] = height;
}
}}
fDelete={this.deleteMessage(message)}
title={message.title} title={message.title}
date={message.date} date={message.date}
appName={appStore.getName(message.appid)}
expanded={expandedState.current[message.id] ?? false}
content={message.message} content={message.message}
image={message.image} image={message.image}
extras={message.extras} extras={message.extras}
@ -148,21 +49,82 @@ class Messages extends Component<IProps & Stores<'messagesStore' | 'appStore'>,
/> />
); );
private checkIfLoadMore() { const checkIfLoadMore = () => {
const {appId} = this.state; if (!isLoadingMore && messagesStore.canLoadMore(appId)) {
if (!this.isLoadingMore && this.props.messagesStore.canLoadMore(appId)) { setLoadingMore(true);
this.isLoadingMore = true; messagesStore.loadMore(appId).then(() => setLoadingMore(false));
this.props.messagesStore.loadMore(appId).then(() => (this.isLoadingMore = false));
} }
} };
private label = (text: string) => ( const messageFooter = () => {
<Grid item xs={12}> if (hasMore) {
return <LoadingSpinner />;
}
if (hasMessages) {
return label("You've reached the end");
}
return null;
};
const renderMessages = () => (
<Virtuoso
id="messages"
style={{width: '100%'}}
useWindowScroll
totalCount={messages.length}
endReached={checkIfLoadMore}
data={messages}
itemContent={renderMessage}
components={{
Footer: messageFooter,
EmptyPlaceholder: () => label('No messages'),
}}
/>
);
const label = (text: string) => (
<Grid size={{xs: 12}}>
<Typography variant="caption" component="div" gutterBottom align="center"> <Typography variant="caption" component="div" gutterBottom align="center">
{text} {text}
</Typography> </Typography>
</Grid> </Grid>
); );
} return (
<DefaultPage
title={name}
rightControl={
<div>
<Button
id="refresh-all"
variant="contained"
color="primary"
onClick={() => messagesStore.refreshByApp(appId)}
style={{marginRight: 5}}>
Refresh
</Button>
<Button
id="delete-all"
variant="contained"
disabled={!hasMessages}
color="primary"
onClick={() => {
setDeleteAll(true);
}}>
Delete All
</Button>
</div>
}>
{!messagesStore.loaded(appId) ? <LoadingSpinner /> : renderMessages()}
export default inject('messagesStore', 'appStore')(Messages); {deleteAll && (
<ConfirmDialog
title="Confirm Delete"
text={'Delete all messages?'}
fClose={() => setDeleteAll(false)}
fOnSubmit={() => messagesStore.removeByApp(appId)}
/>
)}
</DefaultPage>
);
});
export default Messages;

View File

@ -1,5 +1,5 @@
import {BaseStore} from '../common/BaseStore'; import {BaseStore} from '../common/BaseStore';
import {action, IObservableArray, observable, reaction} from 'mobx'; import {action, IObservableArray, observable, reaction, makeObservable} from 'mobx';
import axios, {AxiosResponse} from 'axios'; import axios, {AxiosResponse} from 'axios';
import * as config from '../config'; import * as config from '../config';
import {createTransformer} from 'mobx-utils'; import {createTransformer} from 'mobx-utils';
@ -16,7 +16,6 @@ interface MessagesState {
} }
export class MessagesStore { export class MessagesStore {
@observable
private state: Record<string, MessagesState> = {}; private state: Record<string, MessagesState> = {};
private loading = false; private loading = false;
@ -25,6 +24,16 @@ export class MessagesStore {
private readonly appStore: BaseStore<IApplication>, private readonly appStore: BaseStore<IApplication>,
private readonly snack: SnackReporter private readonly snack: SnackReporter
) { ) {
makeObservable<MessagesStore, 'state'>(this, {
state: observable,
loadMore: action,
publishSingleMessage: action,
removeByApp: action,
removeSingle: action,
clearAll: action,
refreshByApp: action,
});
reaction(() => appStore.getItems(), this.createEmptyStatesForApps); reaction(() => appStore.getItems(), this.createEmptyStatesForApps);
} }
@ -39,7 +48,6 @@ export class MessagesStore {
public canLoadMore = (appId: number) => this.stateOf(appId, /*create*/ false).hasMore; public canLoadMore = (appId: number) => this.stateOf(appId, /*create*/ false).hasMore;
@action
public loadMore = async (appId: number) => { public loadMore = async (appId: number) => {
const state = this.stateOf(appId); const state = this.stateOf(appId);
if (!state.hasMore || this.loading) { if (!state.hasMore || this.loading) {
@ -47,19 +55,21 @@ export class MessagesStore {
} }
this.loading = true; this.loading = true;
const pagedResult = await this.fetchMessages(appId, state.nextSince).then( try {
(resp) => resp.data const pagedResult = await this.fetchMessages(appId, state.nextSince).then(
); (resp) => resp.data
);
state.messages.replace([...state.messages, ...pagedResult.messages]);
state.nextSince = pagedResult.paging.since ?? 0;
state.hasMore = 'next' in pagedResult.paging;
state.loaded = true;
} finally {
this.loading = false;
}
state.messages.replace([...state.messages, ...pagedResult.messages]);
state.nextSince = pagedResult.paging.since ?? 0;
state.hasMore = 'next' in pagedResult.paging;
state.loaded = true;
this.loading = false;
return Promise.resolve(); return Promise.resolve();
}; };
@action
public publishSingleMessage = (message: IMessage) => { public publishSingleMessage = (message: IMessage) => {
if (this.exists(AllMessages)) { if (this.exists(AllMessages)) {
this.stateOf(AllMessages).messages.unshift(message); this.stateOf(AllMessages).messages.unshift(message);
@ -69,7 +79,6 @@ export class MessagesStore {
} }
}; };
@action
public removeByApp = async (appId: number) => { public removeByApp = async (appId: number) => {
if (appId === AllMessages) { if (appId === AllMessages) {
await axios.delete(config.get('url') + 'message'); await axios.delete(config.get('url') + 'message');
@ -84,7 +93,6 @@ export class MessagesStore {
await this.loadMore(appId); await this.loadMore(appId);
}; };
@action
public removeSingle = async (message: IMessage) => { public removeSingle = async (message: IMessage) => {
await axios.delete(config.get('url') + 'message/' + message.id); await axios.delete(config.get('url') + 'message/' + message.id);
if (this.exists(AllMessages)) { if (this.exists(AllMessages)) {
@ -96,13 +104,11 @@ export class MessagesStore {
this.snack('Message deleted'); this.snack('Message deleted');
}; };
@action
public clearAll = () => { public clearAll = () => {
this.state = {}; this.state = {};
this.createEmptyStatesForApps(this.appStore.getItems()); this.createEmptyStatesForApps(this.appStore.getItems());
}; };
@action
public refreshByApp = async (appId: number) => { public refreshByApp = async (appId: number) => {
this.clearAll(); this.clearAll();
this.loadMore(appId); this.loadMore(appId);
@ -136,15 +142,17 @@ export class MessagesStore {
} }
}; };
private getUnCached = (appId: number): Array<IMessage & {image: string | null}> => { private getUnCached = (appId: number): Array<IMessage> => {
const appToImage = this.appStore const appToImage: Partial<Record<string, string>> = this.appStore
.getItems() .getItems()
.reduce((all, app) => ({...all, [app.id]: app.image}), {}); .reduce((all, app) => ({...all, [app.id]: app.image}), {});
return this.stateOf(appId, false).messages.map((message: IMessage) => ({ return this.stateOf(appId, false).messages.map(
...message, (message: IMessage): IMessage => ({
image: appToImage[message.appid] || null, ...message,
})); image: appToImage[message.appid],
})
);
}; };
public get = createTransformer(this.getUnCached); public get = createTransformer(this.getUnCached);

View File

@ -7,9 +7,7 @@ export enum RenderMode {
export const contentType = (extras?: IMessageExtras): RenderMode => { export const contentType = (extras?: IMessageExtras): RenderMode => {
const type = extract(extras, 'client::display', 'contentType'); const type = extract(extras, 'client::display', 'contentType');
const valid = Object.keys(RenderMode) const valid = Object.values(RenderMode).includes(type);
.map((k) => RenderMode[k])
.some((mode) => mode === type);
return valid ? type : RenderMode.Plain; return valid ? type : RenderMode.Plain;
}; };

View File

@ -1,123 +1,91 @@
import React, {Component} from 'react'; import React from 'react';
import {RouteComponentProps} from 'react-router'; import {useParams} from 'react-router';
import {Markdown} from '../common/Markdown'; import {Markdown} from '../common/Markdown';
import {UnControlled as CodeMirror} from 'react-codemirror2'; import {material} from '@uiw/codemirror-theme-material';
import 'codemirror/lib/codemirror.css'; import CodeMirror from '@uiw/react-codemirror';
import 'codemirror/theme/material.css'; import Info from '@mui/icons-material/Info';
import 'codemirror/mode/yaml/yaml'; import Build from '@mui/icons-material/Build';
import Info from '@material-ui/icons/Info'; import Subject from '@mui/icons-material/Subject';
import Build from '@material-ui/icons/Build'; import Refresh from '@mui/icons-material/Refresh';
import Subject from '@material-ui/icons/Subject'; import Button from '@mui/material/Button';
import Refresh from '@material-ui/icons/Refresh'; import Typography from '@mui/material/Typography';
import Button from '@material-ui/core/Button';
import Typography from '@material-ui/core/Typography';
import DefaultPage from '../common/DefaultPage'; import DefaultPage from '../common/DefaultPage';
import * as config from '../config'; import * as config from '../config';
import Container from '../common/Container'; import Container from '../common/Container';
import {inject, Stores} from '../inject';
import {IPlugin} from '../types'; import {IPlugin} from '../types';
import LoadingSpinner from '../common/LoadingSpinner'; 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<string>();
const [displayText, setDisplayText] = React.useState<string>();
interface IState { const pluginInfo = pluginStore.getByIDOrUndefined(pluginID);
displayText: string | null;
currentConfig: string | null;
}
class PluginDetailView extends Component<IProps & Stores<'pluginStore'>, IState> { const refreshFeatures = async () => {
private pluginID: number = parseInt(this.props.match.params.id, 10); await pluginStore.refreshIfMissing(pluginID);
private pluginInfo = () => this.props.pluginStore.getByID(this.pluginID); await Promise.all([refreshConfigurer(), refreshDisplayer()]);
public state: IState = {
displayText: null,
currentConfig: null,
}; };
public componentWillMount() { React.useEffect(() => void refreshFeatures(), [pluginID]);
this.refreshFeatures();
}
public componentWillReceiveProps(nextProps: IProps & Stores<'pluginStore'>) { const refreshConfigurer = async () => {
this.pluginID = parseInt(nextProps.match.params.id, 10); if (pluginInfo?.capabilities.indexOf('configurer') !== -1) {
this.refreshFeatures(); setCurrentConfig(await pluginStore.requestConfig(pluginID));
}
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 refreshDisplayer = async () => {
if (pluginInfo?.capabilities.indexOf('displayer') !== -1) {
setDisplayText(await pluginStore.requestDisplay(pluginID));
}
};
if (pluginInfo == null) {
return <LoadingSpinner />;
} }
private async refreshDisplayer() { const handleSaveConfig = async (newConfig: string) => {
const { await pluginStore.changeConfig(pluginID, newConfig);
props: {pluginStore}, await refreshFeatures();
} = this; };
if (this.pluginInfo().capabilities.indexOf('displayer') !== -1) {
const response = await pluginStore.requestDisplay(this.pluginID);
this.setState({displayText: response});
}
}
public render() { return (
const pluginInfo = this.props.pluginStore.getByIDOrUndefined(this.pluginID); <DefaultPage title={pluginInfo.name} maxWidth={1000}>
if (pluginInfo === undefined) { <PanelWrapper name={'Plugin Info'} icon={Info}>
return <LoadingSpinner />; <PluginInfo pluginInfo={pluginInfo} />
} </PanelWrapper>
return ( {pluginInfo.capabilities.indexOf('configurer') !== -1 ? (
<DefaultPage title={pluginInfo.name} maxWidth={1000}> <PanelWrapper
<PanelWrapper name={'Plugin Info'} icon={Info}> name={'Configurer'}
<PluginInfo pluginInfo={pluginInfo} /> description={'This is the configuration panel for this plugin.'}
icon={Build}
refresh={refreshConfigurer}>
<ConfigurerPanel
pluginInfo={pluginInfo}
initialConfig={currentConfig != null ? currentConfig : 'Loading...'}
save={handleSaveConfig}
/>
</PanelWrapper> </PanelWrapper>
{pluginInfo.capabilities.indexOf('configurer') !== -1 ? ( ) : null}{' '}
<PanelWrapper {pluginInfo.capabilities.indexOf('displayer') !== -1 ? (
name={'Configurer'} <PanelWrapper
description={'This is the configuration panel for this plugin.'} name={'Displayer'}
icon={Build} description={'This is the information generated by the plugin.'}
refresh={this.refreshConfigurer.bind(this)}> refresh={refreshDisplayer}
<ConfigurerPanel icon={Subject}>
pluginInfo={pluginInfo} <DisplayerPanel
initialConfig={ pluginInfo={pluginInfo}
this.state.currentConfig !== null displayText={displayText != null ? displayText : 'Loading...'}
? this.state.currentConfig />
: 'Loading...' </PanelWrapper>
} ) : null}
save={async (newConfig) => { </DefaultPage>
await this.props.pluginStore.changeConfig(this.pluginID, newConfig); );
await this.refreshFeatures(); };
}}
/>
</PanelWrapper>
) : null}{' '}
{pluginInfo.capabilities.indexOf('displayer') !== -1 ? (
<PanelWrapper
name={'Displayer'}
description={'This is the information generated by the plugin.'}
refresh={this.refreshDisplayer.bind(this)}
icon={Subject}>
<DisplayerPanel
pluginInfo={pluginInfo}
displayText={
this.state.displayText !== null
? this.state.displayText
: 'Loading...'
}
/>
</PanelWrapper>
) : null}
</DefaultPage>
);
}
}
interface IPanelWrapperProps { interface IPanelWrapperProps {
name: string; name: string;
@ -126,7 +94,7 @@ interface IPanelWrapperProps {
icon?: React.ComponentType; icon?: React.ComponentType;
} }
const PanelWrapper: React.FC<IPanelWrapperProps> = ({ const PanelWrapper: React.FC<React.PropsWithChildren<IPanelWrapperProps>> = ({
name, name,
description, description,
refresh, refresh,
@ -178,50 +146,39 @@ interface IConfigurerPanelProps {
initialConfig: string; initialConfig: string;
save: (newConfig: string) => Promise<void>; save: (newConfig: string) => Promise<void>;
} }
class ConfigurerPanel extends Component<IConfigurerPanelProps, {unsavedChanges: string | null}> { const ConfigurerPanel = ({initialConfig, save}: IConfigurerPanelProps) => {
public state = {unsavedChanges: null}; const [unsavedChanges, setUnsavedChanges] = React.useState<string | null>(null);
const onChange = React.useCallback(
public render() { (value: string | null) => {
return ( let newConf: string | null = value;
<div> if (value === initialConfig) {
<CodeMirror newConf = null;
value={this.props.initialConfig} }
options={{ setUnsavedChanges(newConf);
mode: 'yaml', },
theme: 'material', [initialConfig]
lineNumbers: true, );
}} return (
onChange={(_, _1, value) => { <div>
let newConf: string | null = value; <CodeMirror value={initialConfig} theme={material} onChange={onChange} />
if (value === this.props.initialConfig) { <br />
newConf = null; <Button
} variant="contained"
this.setState({unsavedChanges: newConf}); color="primary"
}} fullWidth={true}
/> disabled={unsavedChanges === null || unsavedChanges === initialConfig}
<br /> className="config-save"
<Button onClick={() => {
variant="contained" const newConfig = unsavedChanges;
color="primary" save(newConfig!).then(() => {
fullWidth={true} setUnsavedChanges(null);
disabled={ });
this.state.unsavedChanges === null || }}>
this.state.unsavedChanges === this.props.initialConfig <Typography variant="button">Save</Typography>
} </Button>
className="config-save" </div>
onClick={() => { );
const newConfig = this.state.unsavedChanges; };
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.props.save(newConfig!).then(() => {
this.setState({unsavedChanges: null});
});
}}>
<Typography variant="button">Save</Typography>
</Button>
</div>
);
}
}
interface IDisplayerPanelProps { interface IDisplayerPanelProps {
pluginInfo: IPlugin; pluginInfo: IPlugin;
@ -233,58 +190,57 @@ const DisplayerPanel: React.FC<IDisplayerPanelProps> = ({displayText}) => (
</Typography> </Typography>
); );
class PluginInfo extends Component<{pluginInfo: IPlugin}> { interface IPluginInfo {
public render() { pluginInfo: IPlugin;
const {
props: {
pluginInfo: {name, author, modulePath, website, license, capabilities, id, token},
},
} = this;
return (
<div style={{wordWrap: 'break-word'}}>
{name ? (
<Typography variant="body2" className="name">
Name: <span>{name}</span>
</Typography>
) : null}
{author ? (
<Typography variant="body2" className="author">
Author: <span>{author}</span>
</Typography>
) : null}
<Typography variant="body2" className="module-path">
Module Path: <span>{modulePath}</span>
</Typography>
{website ? (
<Typography variant="body2" className="website">
Website: <span>{website}</span>
</Typography>
) : null}
{license ? (
<Typography variant="body2" className="license">
License: <span>{license}</span>
</Typography>
) : null}
<Typography variant="body2" className="capabilities">
Capabilities: <span>{capabilities.join(', ')}</span>
</Typography>
{capabilities.indexOf('webhooker') !== -1 ? (
<Typography variant="body2">
Custom Route Prefix:{' '}
{((url) => (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="custom-route">
{url}
</a>
))(`${config.get('url')}plugin/${id}/custom/${token}/`)}
</Typography>
) : null}
</div>
);
}
} }
export default inject('pluginStore')(PluginDetailView); const PluginInfo = ({pluginInfo}: IPluginInfo) => {
const {name, author, modulePath, website, license, capabilities, id, token} = pluginInfo;
return (
<div style={{wordWrap: 'break-word'}}>
{name ? (
<Typography variant="body2" className="name">
Name: <span>{name}</span>
</Typography>
) : null}
{author ? (
<Typography variant="body2" className="author">
Author: <span>{author}</span>
</Typography>
) : null}
<Typography variant="body2" className="module-path">
Module Path: <span>{modulePath}</span>
</Typography>
{website ? (
<Typography variant="body2" className="website">
Website: <span>{website}</span>
</Typography>
) : null}
{license ? (
<Typography variant="body2" className="license">
License: <span>{license}</span>
</Typography>
) : null}
<Typography variant="body2" className="capabilities">
Capabilities: <span>{capabilities.join(', ')}</span>
</Typography>
{capabilities.indexOf('webhooker') !== -1 ? (
<Typography variant="body2">
Custom Route Prefix:{' '}
{((url) => (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="custom-route">
{url}
</a>
))(`${config.get('url')}plugin/${id}/custom/${token}/`)}
</Typography>
) : null}
</div>
);
};
export default PluginDetailView;

View File

@ -1,5 +1,5 @@
import axios from 'axios'; import axios from 'axios';
import {action} from 'mobx'; import {action, makeObservable} from 'mobx';
import {BaseStore} from '../common/BaseStore'; import {BaseStore} from '../common/BaseStore';
import * as config from '../config'; import * as config from '../config';
import {SnackReporter} from '../snack/SnackManager'; import {SnackReporter} from '../snack/SnackManager';
@ -10,6 +10,11 @@ export class PluginStore extends BaseStore<IPlugin> {
public constructor(private readonly snack: SnackReporter) { public constructor(private readonly snack: SnackReporter) {
super(); super();
makeObservable(this, {
changeConfig: action,
changeEnabledState: action,
});
} }
public requestConfig = (id: number): Promise<string> => public requestConfig = (id: number): Promise<string> =>
@ -31,7 +36,6 @@ export class PluginStore extends BaseStore<IPlugin> {
return id === -1 ? 'All Plugins' : plugin !== undefined ? plugin.name : 'unknown'; return id === -1 ? 'All Plugins' : plugin !== undefined ? plugin.name : 'unknown';
}; };
@action
public changeConfig = async (id: number, newConfig: string): Promise<void> => { public changeConfig = async (id: number, newConfig: string): Promise<void> => {
await axios.post(`${config.get('url')}plugin/${id}/config`, newConfig, { await axios.post(`${config.get('url')}plugin/${id}/config`, newConfig, {
headers: {'content-type': 'application/x-yaml'}, headers: {'content-type': 'application/x-yaml'},
@ -40,7 +44,6 @@ export class PluginStore extends BaseStore<IPlugin> {
await this.refresh(); await this.refresh();
}; };
@action
public changeEnabledState = async (id: number, enabled: boolean): Promise<void> => { public changeEnabledState = async (id: number, enabled: boolean): Promise<void> => {
await axios.post(`${config.get('url')}plugin/${id}/${enabled ? 'enable' : 'disable'}`); await axios.post(`${config.get('url')}plugin/${id}/${enabled ? 'enable' : 'disable'}`);
this.snack(`Plugin ${enabled ? 'enabled' : 'disabled'}`); this.snack(`Plugin ${enabled ? 'enabled' : 'disabled'}`);

View File

@ -1,67 +1,58 @@
import React, {Component, SFC} from 'react'; import React from 'react';
import {Link} from 'react-router-dom'; import {Link} from 'react-router-dom';
import Grid from '@material-ui/core/Grid'; import Grid from '@mui/material/Grid';
import Paper from '@material-ui/core/Paper'; import Paper from '@mui/material/Paper';
import Table from '@material-ui/core/Table'; import Table from '@mui/material/Table';
import TableBody from '@material-ui/core/TableBody'; import TableBody from '@mui/material/TableBody';
import TableCell from '@material-ui/core/TableCell'; import TableCell from '@mui/material/TableCell';
import TableHead from '@material-ui/core/TableHead'; import TableHead from '@mui/material/TableHead';
import TableRow from '@material-ui/core/TableRow'; import TableRow from '@mui/material/TableRow';
import Settings from '@material-ui/icons/Settings'; import Settings from '@mui/icons-material/Settings';
import {Switch, Button} from '@material-ui/core'; import {Switch, Button} from '@mui/material';
import DefaultPage from '../common/DefaultPage'; import DefaultPage from '../common/DefaultPage';
import CopyableSecret from '../common/CopyableSecret'; import CopyableSecret from '../common/CopyableSecret';
import {observer} from 'mobx-react'; import {observer} from 'mobx-react';
import {inject, Stores} from '../inject';
import {IPlugin} from '../types'; import {IPlugin} from '../types';
import {useStores} from '../stores';
@observer const Plugins = observer(() => {
class Plugins extends Component<Stores<'pluginStore'>> { const {pluginStore} = useStores();
public componentDidMount = () => this.props.pluginStore.refresh(); React.useEffect(() => void pluginStore.refresh(), []);
const plugins = pluginStore.getItems();
public render() { return (
const { <DefaultPage title="Plugins" maxWidth={1000}>
props: {pluginStore}, <Grid size={{xs: 12}}>
} = this; <Paper elevation={6} style={{overflowX: 'auto'}}>
const plugins = pluginStore.getItems(); <Table id="plugin-table">
return ( <TableHead>
<DefaultPage title="Plugins" maxWidth={1000}> <TableRow>
<Grid item xs={12}> <TableCell>ID</TableCell>
<Paper elevation={6} style={{overflowX: 'auto'}}> <TableCell>Enabled</TableCell>
<Table id="plugin-table"> <TableCell>Name</TableCell>
<TableHead> <TableCell>Token</TableCell>
<TableRow> <TableCell>Details</TableCell>
<TableCell>ID</TableCell> </TableRow>
<TableCell>Enabled</TableCell> </TableHead>
<TableCell>Name</TableCell> <TableBody>
<TableCell>Token</TableCell> {plugins.map((plugin: IPlugin) => (
<TableCell>Details</TableCell> <Row
</TableRow> key={plugin.token}
</TableHead> id={plugin.id}
<TableBody> token={plugin.token}
{plugins.map((plugin: IPlugin) => ( name={plugin.name}
<Row enabled={plugin.enabled}
key={plugin.token} fToggleStatus={() =>
id={plugin.id} pluginStore.changeEnabledState(plugin.id, !plugin.enabled)
token={plugin.token} }
name={plugin.name} />
enabled={plugin.enabled} ))}
fToggleStatus={() => </TableBody>
this.props.pluginStore.changeEnabledState( </Table>
plugin.id, </Paper>
!plugin.enabled </Grid>
) </DefaultPage>
} );
/> });
))}
</TableBody>
</Table>
</Paper>
</Grid>
</DefaultPage>
);
}
}
interface IRowProps { interface IRowProps {
id: number; id: number;
@ -71,7 +62,7 @@ interface IRowProps {
fToggleStatus: VoidFunction; fToggleStatus: VoidFunction;
} }
const Row: SFC<IRowProps> = observer(({name, id, token, enabled, fToggleStatus}) => ( const Row: React.FC<IRowProps> = observer(({name, id, token, enabled, fToggleStatus}) => (
<TableRow> <TableRow>
<TableCell>{id}</TableCell> <TableCell>{id}</TableCell>
<TableCell> <TableCell>
@ -96,4 +87,4 @@ const Row: SFC<IRowProps> = observer(({name, id, token, enabled, fToggleStatus})
</TableRow> </TableRow>
)); ));
export default inject('pluginStore')(Plugins); export default Plugins;

View File

@ -1,6 +1,6 @@
import {StoreMapping} from './inject';
import {reaction} from 'mobx'; import {reaction} from 'mobx';
import * as Notifications from './snack/browserNotification'; import * as Notifications from './snack/browserNotification';
import {StoreMapping} from './stores';
export const registerReactions = (stores: StoreMapping) => { export const registerReactions = (stores: StoreMapping) => {
const clearAll = () => { const clearAll = () => {
@ -40,6 +40,7 @@ export const registerReactions = (stores: StoreMapping) => {
if (!connectionErrorMessage) { if (!connectionErrorMessage) {
clearAll(); clearAll();
loadAll(); loadAll();
stores.currentUser.refreshKey++;
} }
} }
); );

View File

@ -1 +0,0 @@
jest.setTimeout(process.env.CI === 'true' ? 50000 : 20000);

View File

@ -1,83 +0,0 @@
import IconButton from '@material-ui/core/IconButton';
import Snackbar from '@material-ui/core/Snackbar';
import Close from '@material-ui/icons/Close';
import React, {Component} from 'react';
import {observable, reaction} from 'mobx';
import {observer} from 'mobx-react';
import {inject, Stores} from '../inject';
@observer
class SnackBarHandler extends Component<Stores<'snackManager'>> {
private static MAX_VISIBLE_SNACK_TIME_IN_MS = 6000;
private static MIN_VISIBLE_SNACK_TIME_IN_MS = 1000;
@observable
private open = false;
@observable
private openWhen = 0;
private dispose: () => void = () => {};
public componentDidMount = () =>
(this.dispose = reaction(() => this.props.snackManager.counter, this.onNewSnack));
public componentWillUnmount = () => this.dispose();
public render() {
const {message: current, hasNext} = this.props.snackManager;
const duration = hasNext()
? SnackBarHandler.MIN_VISIBLE_SNACK_TIME_IN_MS
: SnackBarHandler.MAX_VISIBLE_SNACK_TIME_IN_MS;
return (
<Snackbar
anchorOrigin={{vertical: 'bottom', horizontal: 'left'}}
open={this.open}
autoHideDuration={duration}
onClose={this.closeCurrentSnack}
onExited={this.openNextSnack}
message={<span id="message-id">{current}</span>}
action={
<IconButton
key="close"
aria-label="Close"
color="inherit"
onClick={this.closeCurrentSnack}>
<Close />
</IconButton>
}
/>
);
}
private onNewSnack = () => {
const {open, openWhen} = this;
if (!open) {
this.openNextSnack();
return;
}
const snackOpenSince = Date.now() - openWhen;
if (snackOpenSince > SnackBarHandler.MIN_VISIBLE_SNACK_TIME_IN_MS) {
this.closeCurrentSnack();
} else {
setTimeout(
this.closeCurrentSnack,
SnackBarHandler.MIN_VISIBLE_SNACK_TIME_IN_MS - snackOpenSince
);
}
};
private openNextSnack = () => {
if (this.props.snackManager.hasNext()) {
this.open = true;
this.openWhen = Date.now();
this.props.snackManager.next();
}
};
private closeCurrentSnack = () => (this.open = false);
}
export default inject('snackManager')(SnackBarHandler);

View File

@ -1,30 +1,11 @@
import {action, observable} from 'mobx'; import {enqueueSnackbar} from 'notistack';
export interface SnackReporter { export interface SnackReporter {
(message: string): void; (message: string): void;
} }
export class SnackManager { export class SnackManager {
@observable
private messages: string[] = [];
@observable
public message: string | null = null;
@observable
public counter = 0;
@action
public next = (): void => {
if (!this.hasNext()) {
throw new Error('There is nothing here :(');
}
this.message = this.messages.shift() as string;
};
public hasNext = () => this.messages.length > 0;
@action
public snack: SnackReporter = (message: string): void => { public snack: SnackReporter = (message: string): void => {
this.messages.push(message); enqueueSnackbar({message, variant: 'info'});
this.counter++;
}; };
} }

View File

@ -5,7 +5,6 @@ import {MessagesStore} from './message/MessagesStore';
import {CurrentUser} from './CurrentUser'; import {CurrentUser} from './CurrentUser';
import {ClientStore} from './client/ClientStore'; import {ClientStore} from './client/ClientStore';
import {AppStore} from './application/AppStore'; import {AppStore} from './application/AppStore';
import {inject as mobxInject, Provider} from 'mobx-react';
import {WebSocketStore} from './message/WebSocketStore'; import {WebSocketStore} from './message/WebSocketStore';
import {PluginStore} from './plugin/PluginStore'; import {PluginStore} from './plugin/PluginStore';
@ -20,18 +19,10 @@ export interface StoreMapping {
wsStore: WebSocketStore; wsStore: WebSocketStore;
} }
export type AllStores = Extract<keyof StoreMapping, string>; export const StoreContext = React.createContext<StoreMapping | undefined>(undefined);
export type Stores<T extends AllStores> = Pick<StoreMapping, T>;
export const inject = export const useStores = (): StoreMapping => {
<I extends AllStores>(...stores: I[]) => const mapping = React.useContext(StoreContext);
// eslint-disable-next-line @typescript-eslint/ban-types if (!mapping) throw new Error('uninitialized');
<P extends {}>( return mapping;
node: React.ComponentType<P> };
): React.ComponentType<Pick<P, Exclude<keyof P, I>>> =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mobxInject(...stores)(node) as any;
export const InjectProvider: React.FC<{stores: StoreMapping}> = ({children, stores}) => (
<Provider {...stores}>{children}</Provider>
);

View File

@ -1,6 +1,7 @@
import {Page} from 'puppeteer'; import {Page} from 'puppeteer';
import {newTest, GotifyTest} from './setup'; import {newTest, GotifyTest} from './setup';
import {count, innerText, waitForExists, waitToDisappear, clearField} from './utils'; import {count, innerText, waitForExists, waitToDisappear, clearField} from './utils';
import {afterAll, beforeAll, describe, expect, it} from 'vitest';
import * as auth from './authentication'; import * as auth from './authentication';
import * as selector from './selector'; import * as selector from './selector';
@ -63,6 +64,7 @@ const createApp =
await page.type($dialog.input('.name'), name); await page.type($dialog.input('.name'), name);
await page.type($dialog.textarea('.description'), description); await page.type($dialog.textarea('.description'), description);
await page.click($dialog.button('.create')); await page.click($dialog.button('.create'));
await waitToDisappear(page, $dialog.selector());
}; };
describe('Application', () => { describe('Application', () => {

View File

@ -1,5 +1,6 @@
import {Page} from 'puppeteer'; import {Page} from 'puppeteer';
import {waitForExists} from './utils'; import {waitForExists} from './utils';
import {expect} from 'vitest';
import * as selector from './selector'; import * as selector from './selector';
const $loginForm = selector.form('#login-form'); const $loginForm = selector.form('#login-form');

View File

@ -1,6 +1,7 @@
import {Page} from 'puppeteer'; import {Page} from 'puppeteer';
import {newTest, GotifyTest} from './setup'; import {newTest, GotifyTest} from './setup';
import {count, innerText, waitForExists, waitToDisappear, clearField} from './utils'; import {count, innerText, waitForExists, waitToDisappear, clearField} from './utils';
import {afterAll, beforeAll, describe, expect, it} from 'vitest';
import * as auth from './authentication'; import * as auth from './authentication';
import * as selector from './selector'; import * as selector from './selector';
@ -65,6 +66,7 @@ describe('Client', () => {
await page.waitForSelector($dialog.selector()); await page.waitForSelector($dialog.selector());
await page.type($dialog.input('.name'), name); await page.type($dialog.input('.name'), name);
await page.click($dialog.button('.create')); await page.click($dialog.button('.create'));
await waitToDisappear(page, $dialog.selector());
}; };
it('phone', createClient('phone')); it('phone', createClient('phone'));
it('desktop app', createClient('desktop app')); it('desktop app', createClient('desktop app'));
@ -98,7 +100,6 @@ describe('Client', () => {
expect(await count(page, $table.rows())).toBe(2); expect(await count(page, $table.rows())).toBe(2);
}); });
// eslint-disable-next-line
it('deletes own client', async () => { it('deletes own client', async () => {
await page.click($table.cell(1, Col.Delete, '.delete')); await page.click($table.cell(1, Col.Delete, '.delete'));

View File

@ -2,6 +2,7 @@
import {Page} from 'puppeteer'; import {Page} from 'puppeteer';
import {newTest, GotifyTest} from './setup'; import {newTest, GotifyTest} from './setup';
import {clickByText, count, innerText, waitForCount, waitForExists} from './utils'; import {clickByText, count, innerText, waitForCount, waitForExists} from './utils';
import {afterAll, beforeAll, describe, expect, it} from 'vitest';
import * as auth from './authentication'; import * as auth from './authentication';
import * as selector from './selector'; import * as selector from './selector';
import axios from 'axios'; import axios from 'axios';
@ -16,7 +17,6 @@ beforeAll(async () => {
afterAll(async () => await gotify.close()); afterAll(async () => await gotify.close());
// eslint-disable-next-line
const axiosAuth = {auth: {username: 'admin', password: 'admin'}}; const axiosAuth = {auth: {username: 'admin', password: 'admin'}};
let windowsServerToken: string; let windowsServerToken: string;
@ -35,7 +35,6 @@ const navigate = async (appName: string) => {
await waitForExists(page, selector.heading(), appName); await waitForExists(page, selector.heading(), appName);
}; };
// eslint-disable-next-line
describe('Messages', () => { describe('Messages', () => {
it('does login', async () => await auth.login(page)); it('does login', async () => await auth.login(page));
it('is on messages', async () => { it('is on messages', async () => {
@ -83,11 +82,11 @@ describe('Messages', () => {
await navigate('All Messages'); await navigate('All Messages');
}); });
it('has no messages', async () => { it('has no messages', async () => {
expect(await count(page, '#messages')).toBe(0); expect(await count(page, '#messages .message')).toBe(0);
}); });
it('has no messages in app', async () => { it('has no messages in app', async () => {
await navigate('Windows'); await navigate('Windows');
expect(await count(page, '#messages')).toBe(0); expect(await count(page, '#messages .message')).toBe(0);
await navigate('All Messages'); await navigate('All Messages');
}); });

View File

@ -1,7 +1,7 @@
import * as os from 'os'; import * as os from 'os';
import {Page} from 'puppeteer'; import {Page} from 'puppeteer';
import axios from 'axios'; import axios from 'axios';
import {afterAll, beforeAll, describe, expect, it} from 'vitest';
import * as auth from './authentication'; import * as auth from './authentication';
import * as selector from './selector'; import * as selector from './selector';
import {GotifyTest, newTest, newPluginDir} from './setup'; import {GotifyTest, newTest, newPluginDir} from './setup';
@ -57,6 +57,7 @@ const getDisplayer = async () => await innerText(page, '.displayer');
const hasReceivedMessage = async (title: RegExp, content: RegExp) => { const hasReceivedMessage = async (title: RegExp, content: RegExp) => {
await page.click('#message-navigation a'); await page.click('#message-navigation a');
await waitForExists(page, selector.heading(), 'All Messages'); await waitForExists(page, selector.heading(), 'All Messages');
await waitForCount(page, '#messages .message', 1);
expect(await innerText(page, '.title')).toMatch(title); expect(await innerText(page, '.title')).toMatch(title);
expect(await innerText(page, '.content')).toMatch(content); expect(await innerText(page, '.content')).toMatch(content);
@ -136,11 +137,11 @@ describe('plugin', () => {
await (await page.$('.config-save'))!.getProperty('disabled') await (await page.$('.config-save'))!.getProperty('disabled')
).jsonValue() ).jsonValue()
).toBe(true); ).toBe(true);
await page.waitForSelector('.CodeMirror .CodeMirror-code'); await page.waitForSelector('.cm-editor .cm-content');
await page.waitForFunction( await page.waitForFunction(
'document.querySelector(".CodeMirror .CodeMirror-code").innerText.toLowerCase().indexOf("loading")<0' 'document.querySelector(".cm-editor .cm-content").innerText.toLowerCase().indexOf("loading")<0'
); );
await page.click('.CodeMirror .CodeMirror-code > div'); await page.click('.cm-editor .cm-content > div');
await page.keyboard.press('x'); await page.keyboard.press('x');
await page.waitForFunction( await page.waitForFunction(
'document.querySelector(".config-save") && !document.querySelector(".config-save").disabled' 'document.querySelector(".config-save") && !document.querySelector(".config-save").disabled'
@ -156,13 +157,11 @@ describe('plugin', () => {
await (await page.$('.config-save'))!.getProperty('disabled') await (await page.$('.config-save'))!.getProperty('disabled')
).jsonValue() ).jsonValue()
).toBe(true); ).toBe(true);
await page.waitForSelector('.CodeMirror .CodeMirror-code > div'); await page.waitForSelector('.cm-editor .cm-content > div');
await page.waitForFunction( await page.waitForFunction(
'document.querySelector(".CodeMirror .CodeMirror-code > div").innerText.toLowerCase().indexOf("loading")<0' 'document.querySelector(".cm-editor .cm-content > div").innerText.toLowerCase().indexOf("loading")<0'
);
expect(await innerText(page, '.CodeMirror .CodeMirror-code > div')).toMatch(
/x$/
); );
expect(await innerText(page, '.cm-editor .cm-content > div')).toMatch(/x$/);
}); });
}); });
it('sends messages', async () => { it('sends messages', async () => {
@ -172,6 +171,9 @@ describe('plugin', () => {
await inDetailPage(1, async () => { await inDetailPage(1, async () => {
await page.waitForSelector('.displayer a'); await page.waitForSelector('.displayer a');
const hook = await page.$eval('.displayer a', (el) => el.getAttribute('href')); const hook = await page.$eval('.displayer a', (el) => el.getAttribute('href'));
if (!hook) {
throw 'href not found';
}
await axios.get(hook); await axios.get(hook);
}); });
}); });

View File

@ -1,10 +1,10 @@
import getPort from 'get-port'; import getPort from 'get-port';
import {spawn, exec, ChildProcess} from 'child_process'; import {spawn, exec, ChildProcess} from 'child_process';
import rimraf from 'rimraf'; import {rimrafSync} from 'rimraf';
import path from 'path'; import path from 'path';
import puppeteer, {Browser, Page} from 'puppeteer'; import puppeteer, {Browser, Page} from 'puppeteer';
import fs from 'fs'; import fs from 'fs';
// @ts-ignore // @ts-expect-error no types
import wait from 'wait-on'; import wait from 'wait-on';
import kill from 'tree-kill'; import kill from 'tree-kill';
@ -50,9 +50,11 @@ export const newTest = async (pluginsDir = ''): Promise<GotifyTest> => {
close: async () => { close: async () => {
await Promise.all([ await Promise.all([
browser.close(), browser.close(),
new Promise((resolve) => kill(gotifyInstance.pid!, 'SIGKILL', () => resolve())), new Promise((resolve) =>
kill(gotifyInstance.pid!, 'SIGKILL', () => resolve(undefined))
),
]); ]);
rimraf.sync(gotifyFile, {maxBusyTries: 8}); rimrafSync(gotifyFile, {maxRetries: 8});
}, },
url: gotifyURL, url: gotifyURL,
browser, browser,

View File

@ -1,6 +1,7 @@
import {Page} from 'puppeteer'; import {Page} from 'puppeteer';
import {newTest, GotifyTest} from './setup'; import {newTest, GotifyTest} from './setup';
import {clearField, count, innerText, waitForExists, waitToDisappear} from './utils'; import {clearField, count, innerText, waitForExists, waitToDisappear} from './utils';
import {afterAll, beforeAll, describe, expect, it} from 'vitest';
import * as auth from './authentication'; import * as auth from './authentication';
import * as selector from './selector'; import * as selector from './selector';

View File

@ -59,7 +59,7 @@ export const waitForExists = async (page: Page, selector: string, text: string):
export const clearField = async (element: ElementHandle | Page, selector: string) => { export const clearField = async (element: ElementHandle | Page, selector: string) => {
const elementHandle = await element.$(selector); const elementHandle = await element.$(selector);
if (!elementHandle) { if (!elementHandle) {
fail(); throw 'element handle not set';
} }
await elementHandle.click(); await elementHandle.click();
await elementHandle.focus(); await elementHandle.focus();

View File

@ -1,3 +1,4 @@
// eslint-disable-next-line
import Notify = require('notifyjs'); import Notify = require('notifyjs');
export as namespace notifyjs; export as namespace notifyjs;
export = Notify; export = Notify;

View File

@ -1,9 +1,19 @@
declare module 'react-timeago' { declare module 'react-timeago' {
import React from 'react'; import React from 'react';
export type FormatterOptions = {
style?: 'long' | 'short' | 'narrow';
};
export type Formatter = (options: FormatterOptions) => React.ReactNode;
export interface ITimeAgoProps { export interface ITimeAgoProps {
date: string; date: string;
formatter?: Formatter;
} }
export default class TimeAgo extends React.Component<ITimeAgoProps, unknown> {} export default class TimeAgo extends React.Component<ITimeAgoProps, unknown> {}
} }
declare module 'react-timeago/defaultFormatter' {
declare function makeIntlFormatter(options: FormatterOptions): Formatter;
}

View File

@ -1,124 +1,107 @@
import Button from '@material-ui/core/Button'; import Button from '@mui/material/Button';
import Dialog from '@material-ui/core/Dialog'; import Dialog from '@mui/material/Dialog';
import DialogActions from '@material-ui/core/DialogActions'; import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@material-ui/core/DialogContent'; import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@material-ui/core/DialogTitle'; import DialogTitle from '@mui/material/DialogTitle';
import FormControlLabel from '@material-ui/core/FormControlLabel'; import FormControlLabel from '@mui/material/FormControlLabel';
import Switch from '@material-ui/core/Switch'; import Switch from '@mui/material/Switch';
import TextField from '@material-ui/core/TextField'; import TextField from '@mui/material/TextField';
import Tooltip from '@material-ui/core/Tooltip'; import Tooltip from '@mui/material/Tooltip';
import React, {ChangeEvent, Component} from 'react'; import React from 'react';
interface IProps { interface IProps {
name?: string; name?: string;
admin?: boolean; admin?: boolean;
fClose: VoidFunction; fClose: VoidFunction;
fOnSubmit: (name: string, pass: string, admin: boolean) => void; fOnSubmit: (name: string, pass: string, admin: boolean) => Promise<void>;
isEdit?: boolean; isEdit?: boolean;
} }
interface IState { const AddEditUserDialog = ({
name: string; fClose,
pass: string; fOnSubmit,
admin: boolean; 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<IProps, IState> { const namePresent = name.length !== 0;
public state = { const passPresent = pass.length !== 0 || isEdit;
name: this.props.name ?? '', const submitAndClose = async () => {
pass: '', await fOnSubmit(name, pass, admin);
admin: this.props.admin ?? false, fClose();
}; };
return (
public render() { <Dialog
const {fClose, fOnSubmit, isEdit} = this.props; open={true}
const {name, pass, admin} = this.state; onClose={fClose}
const namePresent = this.state.name.length !== 0; aria-labelledby="form-dialog-title"
const passPresent = this.state.pass.length !== 0 || isEdit; id="add-edit-user-dialog">
const submitAndClose = () => { <DialogTitle id="form-dialog-title">
fOnSubmit(name, pass, admin); {isEdit ? 'Edit ' + name : 'Add a user'}
fClose(); </DialogTitle>
}; <DialogContent>
return ( <TextField
<Dialog autoFocus
open={true} margin="dense"
onClose={fClose} className="name"
aria-labelledby="form-dialog-title" label="Username *"
id="add-edit-user-dialog"> value={name}
<DialogTitle id="form-dialog-title"> name="username"
{isEdit ? 'Edit ' + this.props.name : 'Add a user'} id="username"
</DialogTitle> onChange={(e) => setName(e.target.value)}
<DialogContent> fullWidth
<TextField />
autoFocus <TextField
margin="dense" margin="dense"
className="name" className="password"
label="Username *" type="password"
value={name} value={pass}
name="username" fullWidth
id="username" label={isEdit ? 'Password (empty if no change)' : 'Password *'}
onChange={this.handleChange.bind(this, 'name')} name="password"
fullWidth id="password"
/> onChange={(e) => setPass(e.target.value)}
<TextField />
margin="dense" <FormControlLabel
className="password" control={
type="password" <Switch
value={pass} checked={admin}
fullWidth className="admin-rights"
label={isEdit ? 'Password (empty if no change)' : 'Password *'} onChange={(e) => setAdmin(e.target.checked)}
name="password" value="admin"
id="password" />
onChange={this.handleChange.bind(this, 'pass')} }
/> label="has administrator rights"
<FormControlLabel />
control={ </DialogContent>
<Switch <DialogActions>
checked={admin} <Button onClick={fClose}>Cancel</Button>
className="admin-rights" <Tooltip
onChange={this.handleChecked.bind(this, 'admin')} placement={'bottom-start'}
value="admin" title={
/> namePresent
} ? passPresent
label="has administrator rights" ? ''
/> : 'password is required'
</DialogContent> : 'username is required'
<DialogActions> }>
<Button onClick={fClose}>Cancel</Button> <div>
<Tooltip <Button
placement={'bottom-start'} className="save-create"
title={ disabled={!passPresent || !namePresent}
namePresent onClick={submitAndClose}
? passPresent color="primary"
? '' variant="contained">
: 'password is required' {isEdit ? 'Save' : 'Create'}
: 'username is required' </Button>
}> </div>
<div> </Tooltip>
<Button </DialogActions>
className="save-create" </Dialog>
disabled={!passPresent || !namePresent} );
onClick={submitAndClose} };
color="primary" export default AddEditUserDialog;
variant="contained">
{isEdit ? 'Save' : 'Create'}
</Button>
</div>
</Tooltip>
</DialogActions>
</Dialog>
);
}
private handleChange(propertyName: string, event: ChangeEvent<HTMLInputElement>) {
const state = this.state;
state[propertyName] = event.target.value;
this.setState(state);
}
private handleChecked(propertyName: string, event: ChangeEvent<HTMLInputElement>) {
const state = this.state;
state[propertyName] = event.target.checked;
this.setState(state);
}
}

View File

@ -1,97 +1,95 @@
import Button from '@material-ui/core/Button'; import Button from '@mui/material/Button';
import Grid from '@material-ui/core/Grid'; import Grid from '@mui/material/Grid';
import TextField from '@material-ui/core/TextField'; import TextField from '@mui/material/TextField';
import React, {Component, FormEvent} from 'react'; import React from 'react';
import Container from '../common/Container'; import Container from '../common/Container';
import DefaultPage from '../common/DefaultPage'; 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 * as config from '../config';
import RegistrationDialog from './Register'; import RegistrationDialog from './Register';
import {useStores} from '../stores';
import {observer} from 'mobx-react';
import {useNavigate} from 'react-router';
@observer const Login = observer(() => {
class Login extends Component<Stores<'currentUser'>> { const [username, setUsername] = React.useState('');
@observable const [password, setPassword] = React.useState('');
private username = ''; const [registerDialog, setRegisterDialog] = React.useState(false);
@observable const {currentUser} = useStores();
private password = ''; const navigate = useNavigate();
@observable React.useEffect(() => {
private registerDialog = false; if (currentUser.loggedIn) {
navigate('/');
public render() { }
const {username, password, registerDialog} = this; }, [currentUser.loggedIn]);
return ( const registerButton = () => {
<DefaultPage title="Login" rightControl={this.registerButton()} maxWidth={250}>
<Grid item xs={12} style={{textAlign: 'center'}}>
<Container>
<form onSubmit={this.preventDefault} id="login-form">
<TextField
autoFocus
id="username"
className="name"
label="Username"
name="username"
margin="dense"
autoComplete="username"
value={username}
onChange={(e) => (this.username = e.target.value)}
/>
<TextField
id="password"
type="password"
className="password"
label="Password"
name="password"
margin="normal"
autoComplete="current-password"
value={password}
onChange={(e) => (this.password = e.target.value)}
/>
<Button
type="submit"
variant="contained"
size="large"
className="login"
color="primary"
disabled={!!this.props.currentUser.connectionErrorMessage}
style={{marginTop: 15, marginBottom: 5}}
onClick={this.login}>
Login
</Button>
</form>
</Container>
</Grid>
{registerDialog && (
<RegistrationDialog
fClose={() => (this.registerDialog = false)}
fOnSubmit={this.props.currentUser.register}
/>
)}
</DefaultPage>
);
}
private login = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
this.props.currentUser.login(this.username, this.password);
};
private registerButton = () => {
if (config.get('register')) if (config.get('register'))
return ( return (
<Button <Button
id="register" id="register"
variant="contained" variant="contained"
color="primary" color="primary"
onClick={() => (this.registerDialog = true)}> onClick={() => setRegisterDialog(true)}>
Register Register
</Button> </Button>
); );
else return null; else return null;
}; };
const login = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
currentUser.login(username, password);
};
return (
<DefaultPage title="Login" rightControl={registerButton()} maxWidth={250}>
<Grid size={{xs: 12}} style={{textAlign: 'center'}}>
<Container>
<form onSubmit={(e) => e.preventDefault()} id="login-form">
<TextField
autoFocus
id="username"
className="name"
label="Username"
name="username"
margin="dense"
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<TextField
id="password"
type="password"
className="password"
label="Password"
name="password"
margin="normal"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Button
type="submit"
variant="contained"
size="large"
className="login"
color="primary"
disabled={
!!currentUser.connectionErrorMessage || currentUser.authenticating
}
style={{marginTop: 15, marginBottom: 5}}
loading={currentUser.authenticating}
onClick={login}>
Login
</Button>
</form>
</Container>
</Grid>
{registerDialog && (
<RegistrationDialog
fClose={() => setRegisterDialog(false)}
fOnSubmit={currentUser.register}
/>
)}
</DefaultPage>
);
});
private preventDefault = (e: FormEvent<HTMLFormElement>) => e.preventDefault(); export default Login;
}
export default inject('currentUser')(Login);

View File

@ -1,11 +1,11 @@
import Button from '@material-ui/core/Button'; import Button from '@mui/material/Button';
import Dialog from '@material-ui/core/Dialog'; import Dialog from '@mui/material/Dialog';
import DialogActions from '@material-ui/core/DialogActions'; import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@material-ui/core/DialogContent'; import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@material-ui/core/DialogTitle'; import DialogTitle from '@mui/material/DialogTitle';
import TextField from '@material-ui/core/TextField'; import TextField from '@mui/material/TextField';
import Tooltip from '@material-ui/core/Tooltip'; import Tooltip from '@mui/material/Tooltip';
import React, {ChangeEvent, Component} from 'react'; import React from 'react';
interface IProps { interface IProps {
name?: string; name?: string;
@ -13,92 +13,85 @@ interface IProps {
fOnSubmit: (name: string, pass: string) => Promise<boolean>; fOnSubmit: (name: string, pass: string) => Promise<boolean>;
} }
interface IState { const RegistrationDialog = ({fClose, fOnSubmit, name: initialName = ''}: IProps) => {
name: string; const [name, setName] = React.useState(initialName);
pass: string; const [pass, setPass] = React.useState('');
} const namePresent = name.length !== 0;
const passPresent = pass.length !== 0;
export default class RegistrationDialog extends Component<IProps, IState> { const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
public state = { setName(e.target.value);
name: '',
pass: '',
}; };
public render() { const handlePassChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const {fClose, fOnSubmit} = this.props; setPass(e.target.value);
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 (
<Dialog
open={true}
onClose={fClose}
aria-labelledby="form-dialog-title"
id="add-edit-user-dialog">
<DialogTitle id="form-dialog-title">Registration</DialogTitle>
<DialogContent>
<TextField
autoFocus
id="register-username"
margin="dense"
className="name"
label="Username *"
name="username"
value={name}
autoComplete="username"
onChange={this.handleChange.bind(this, 'name')}
fullWidth
/>
<TextField
id="register-password"
margin="dense"
className="password"
type="password"
value={pass}
fullWidth
label="Password *"
name="password"
autoComplete="new-password"
onChange={this.handleChange.bind(this, 'pass')}
/>
</DialogContent>
<DialogActions>
<Button onClick={fClose}>Cancel</Button>
<Tooltip
placement={'bottom-start'}
title={
namePresent
? passPresent
? ''
: 'password is required'
: 'username is required'
}>
<div>
<Button
className="save-create"
disabled={!passPresent || !namePresent}
onClick={submitAndClose}
color="primary"
variant="contained">
Register
</Button>
</div>
</Tooltip>
</DialogActions>
</Dialog>
);
}
private handleChange(propertyName: string, event: ChangeEvent<HTMLInputElement>) { const submitAndClose = (): void => {
const state = this.state; fOnSubmit(name, pass).then((success) => {
state[propertyName] = event.target.value; if (success) {
this.setState(state); fClose();
} }
} });
};
return (
<Dialog
open={true}
onClose={fClose}
aria-labelledby="form-dialog-title"
id="add-edit-user-dialog">
<DialogTitle id="form-dialog-title">Registration</DialogTitle>
<DialogContent>
<TextField
autoFocus
id="register-username"
margin="dense"
className="name"
label="Username *"
name="username"
value={name}
autoComplete="username"
onChange={handleNameChange}
fullWidth
/>
<TextField
id="register-password"
margin="dense"
className="password"
type="password"
value={pass}
fullWidth
label="Password *"
name="password"
autoComplete="new-password"
onChange={handlePassChange}
/>
</DialogContent>
<DialogActions>
<Button onClick={fClose}>Cancel</Button>
<Tooltip
placement={'bottom-start'}
title={
namePresent
? passPresent
? ''
: 'password is required'
: 'username is required'
}>
<div>
<Button
className="save-create"
disabled={!passPresent || !namePresent}
onClick={submitAndClose}
color="primary"
variant="contained">
Register
</Button>
</div>
</Tooltip>
</DialogActions>
</Dialog>
);
};
export default RegistrationDialog;

View File

@ -1,13 +1,18 @@
import {BaseStore} from '../common/BaseStore'; import {BaseStore} from '../common/BaseStore';
import axios from 'axios'; import axios from 'axios';
import * as config from '../config'; import * as config from '../config';
import {action} from 'mobx'; import {action, makeObservable} from 'mobx';
import {SnackReporter} from '../snack/SnackManager'; import {SnackReporter} from '../snack/SnackManager';
import {IUser} from '../types'; import {IUser} from '../types';
export class UserStore extends BaseStore<IUser> { export class UserStore extends BaseStore<IUser> {
constructor(private readonly snack: SnackReporter) { constructor(private readonly snack: SnackReporter) {
super(); super();
makeObservable(this, {
create: action,
update: action,
});
} }
protected requestItems = (): Promise<IUser[]> => protected requestItems = (): Promise<IUser[]> =>
@ -19,14 +24,12 @@ export class UserStore extends BaseStore<IUser> {
.then(() => this.snack('User deleted')); .then(() => this.snack('User deleted'));
} }
@action
public create = async (name: string, pass: string, admin: boolean) => { public create = async (name: string, pass: string, admin: boolean) => {
await axios.post(`${config.get('url')}user`, {name, pass, admin}); await axios.post(`${config.get('url')}user`, {name, pass, admin});
await this.refresh(); await this.refresh();
this.snack('User created'); this.snack('User created');
}; };
@action
public update = async (id: number, name: string, pass: string | null, admin: boolean) => { public update = async (id: number, name: string, pass: string | null, admin: boolean) => {
await axios.post(config.get('url') + 'user/' + id, {name, pass, admin}); await axios.post(config.get('url') + 'user/' + id, {name, pass, admin});
await this.refresh(); await this.refresh();

View File

@ -1,30 +1,21 @@
import Grid from '@material-ui/core/Grid'; import Grid from '@mui/material/Grid';
import IconButton from '@material-ui/core/IconButton'; import IconButton from '@mui/material/IconButton';
import Paper from '@material-ui/core/Paper'; import Paper from '@mui/material/Paper';
import {withStyles, WithStyles} from '@material-ui/core/styles'; import Table from '@mui/material/Table';
import Table from '@material-ui/core/Table'; import TableBody from '@mui/material/TableBody';
import TableBody from '@material-ui/core/TableBody'; import TableCell from '@mui/material/TableCell';
import TableCell from '@material-ui/core/TableCell'; import TableHead from '@mui/material/TableHead';
import TableHead from '@material-ui/core/TableHead'; import TableRow from '@mui/material/TableRow';
import TableRow from '@material-ui/core/TableRow'; import Delete from '@mui/icons-material/Delete';
import Delete from '@material-ui/icons/Delete'; import Edit from '@mui/icons-material/Edit';
import Edit from '@material-ui/icons/Edit'; import React from 'react';
import React, {Component, SFC} from 'react';
import ConfirmDialog from '../common/ConfirmDialog'; import ConfirmDialog from '../common/ConfirmDialog';
import DefaultPage from '../common/DefaultPage'; import DefaultPage from '../common/DefaultPage';
import Button from '@material-ui/core/Button'; import Button from '@mui/material/Button';
import AddEditDialog from './AddEditUserDialog'; import AddEditDialog from './AddEditUserDialog';
import {observer} from 'mobx-react';
import {observable} from 'mobx';
import {inject, Stores} from '../inject';
import {IUser} from '../types'; import {IUser} from '../types';
import {useStores} from '../stores';
const styles = () => ({ import {observer} from 'mobx-react';
wrapper: {
margin: '0 auto',
maxWidth: 700,
},
});
interface IRowProps { interface IRowProps {
name: string; name: string;
@ -33,102 +24,86 @@ interface IRowProps {
fEdit: VoidFunction; fEdit: VoidFunction;
} }
const UserRow: SFC<IRowProps> = ({name, admin, fDelete, fEdit}) => ( const UserRow: React.FC<IRowProps> = ({name, admin, fDelete, fEdit}) => (
<TableRow> <TableRow>
<TableCell>{name}</TableCell> <TableCell>{name}</TableCell>
<TableCell>{admin ? 'Yes' : 'No'}</TableCell> <TableCell>{admin ? 'Yes' : 'No'}</TableCell>
<TableCell align="right" padding="none"> <TableCell align="right" padding="none">
<IconButton onClick={fEdit} className="edit"> <IconButton onClick={fEdit} className="edit" size="large">
<Edit /> <Edit />
</IconButton> </IconButton>
<IconButton onClick={fDelete} className="delete"> <IconButton onClick={fDelete} className="delete" size="large">
<Delete /> <Delete />
</IconButton> </IconButton>
</TableCell> </TableCell>
</TableRow> </TableRow>
); );
@observer const Users = observer(() => {
class Users extends Component<WithStyles<'wrapper'> & Stores<'userStore'>> { const [deleteUser, setDeleteUser] = React.useState<IUser>();
@observable const [editUser, setEditUser] = React.useState<IUser>();
private createDialog = false; const [createDialog, setCreateDialog] = React.useState(false);
@observable const {userStore} = useStores();
private deleteId: number | false = false; React.useEffect(() => void userStore.refresh(), []);
@observable const users = userStore.getItems();
private editId: number | false = false; return (
<DefaultPage
title="Users"
rightControl={
<Button
id="create-user"
variant="contained"
color="primary"
onClick={() => setCreateDialog(true)}>
Create User
</Button>
}>
<Grid size={{xs: 12}}>
<Paper elevation={6} style={{overflowX: 'auto'}}>
<Table id="user-table">
<TableHead>
<TableRow style={{textAlign: 'center'}}>
<TableCell>Username</TableCell>
<TableCell>Admin</TableCell>
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{users.map((user: IUser) => (
<UserRow
key={user.id}
name={user.name}
admin={user.admin}
fDelete={() => setDeleteUser(user)}
fEdit={() => setEditUser(user)}
/>
))}
</TableBody>
</Table>
</Paper>
</Grid>
{createDialog && (
<AddEditDialog fClose={() => setCreateDialog(false)} fOnSubmit={userStore.create} />
)}
{editUser && (
<AddEditDialog
fClose={() => setEditUser(undefined)}
fOnSubmit={userStore.update.bind(this, editUser.id)}
name={editUser.name}
admin={editUser.admin}
isEdit={true}
/>
)}
{deleteUser && (
<ConfirmDialog
title="Confirm Delete"
text={'Delete ' + deleteUser.name + '?'}
fClose={() => setDeleteUser(undefined)}
fOnSubmit={() => userStore.remove(deleteUser.id)}
/>
)}
</DefaultPage>
);
});
public componentDidMount = () => this.props.userStore.refresh(); export default Users;
public render() {
const {
deleteId,
editId,
createDialog,
props: {userStore},
} = this;
const users = userStore.getItems();
return (
<DefaultPage
title="Users"
rightControl={
<Button
id="create-user"
variant="contained"
color="primary"
onClick={() => (this.createDialog = true)}>
Create User
</Button>
}>
<Grid item xs={12}>
<Paper elevation={6} style={{overflowX: 'auto'}}>
<Table id="user-table">
<TableHead>
<TableRow style={{textAlign: 'center'}}>
<TableCell>Username</TableCell>
<TableCell>Admin</TableCell>
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{users.map((user: IUser) => (
<UserRow
key={user.id}
name={user.name}
admin={user.admin}
fDelete={() => (this.deleteId = user.id)}
fEdit={() => (this.editId = user.id)}
/>
))}
</TableBody>
</Table>
</Paper>
</Grid>
{createDialog && (
<AddEditDialog
fClose={() => (this.createDialog = false)}
fOnSubmit={userStore.create}
/>
)}
{editId !== false && (
<AddEditDialog
fClose={() => (this.editId = false)}
fOnSubmit={userStore.update.bind(this, editId)}
name={userStore.getByID(editId).name}
admin={userStore.getByID(editId).admin}
isEdit={true}
/>
)}
{deleteId !== false && (
<ConfirmDialog
title="Confirm Delete"
text={'Delete ' + userStore.getByID(deleteId).name + '?'}
fClose={() => (this.deleteId = false)}
fOnSubmit={() => userStore.remove(deleteId)}
/>
)}
</DefaultPage>
);
}
}
export default withStyles(styles)(inject('userStore')(Users));

View File

@ -1,12 +1,9 @@
{ {
"compilerOptions": { "compilerOptions": {
"baseUrl": "src", "target": "ES2020",
"outDir": "build/dist", "lib": ["ES2020", "DOM", "DOM.Iterable"],
"target": "es5", "allowImportingTsExtensions": true,
"lib": [
"es6",
"dom"
],
"sourceMap": true, "sourceMap": true,
"allowJs": true, "allowJs": true,
"jsx": "react", "jsx": "react",
@ -17,10 +14,8 @@
"noImplicitThis": true, "noImplicitThis": true,
"noImplicitAny": true, "noImplicitAny": true,
"strictNullChecks": true, "strictNullChecks": true,
"suppressImplicitAnyIndexErrors": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"skipLibCheck": true, "skipLibCheck": true,
"esModuleInterop": true, "esModuleInterop": true,
"strict": true, "strict": true,
@ -28,7 +23,6 @@
"noEmit": true, "noEmit": true,
"module": "esnext", "module": "esnext",
"resolveJsonModule": true, "resolveJsonModule": true,
"keyofStringsOnly": true,
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true
}, },
"exclude": [ "exclude": [

2
ui/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
// Example: vite-env.d.ts
/// <reference types="vite/client" />

36
ui/vite.config.ts Normal file
View File

@ -0,0 +1,36 @@
import {defineConfig} from 'vite';
import react from '@vitejs/plugin-react';
const GOTIFY_SERVER_PORT = process.env.GOTIFY_SERVER_PORT ?? '80';
export default defineConfig({
build: {
outDir: 'build',
emptyOutDir: true,
sourcemap: false,
assetsDir: 'static',
},
plugins: [react()],
define: {
// Some libraries use the global object, even though it doesn't exist in the browser.
// Alternatively, we could add `<script>window.global = window;</script>` to index.html.
// https://github.com/vitejs/vite/discussions/5912
global: {},
},
server: {
host: '0.0.0.0',
proxy: {
'^/(application|message|client|current|user|plugin|version|image)': {
target: `http://localhost:${GOTIFY_SERVER_PORT}/`,
changeOrigin: true,
secure: false,
},
'/stream': {
target: `ws://localhost:${GOTIFY_SERVER_PORT}/`,
ws: true,
rewriteWsOrigin: true,
},
},
cors: false,
},
});

10
ui/vitest.config.js Normal file
View File

@ -0,0 +1,10 @@
import {defineConfig} from 'vitest/config';
const timeout = process.env.CI === 'true' ? 60000 : 30000;
export default defineConfig({
test: {
testTimeout: timeout,
hookTimeout: timeout,
},
});

14818
ui/yarn.lock

File diff suppressed because it is too large Load Diff