Re-engineer UI in TypeScript (#89)

* Re-engineer UI in TypeScript
* Switch to named imports where possible
* Restructure file system layout
* Update depencencies
* Update README.md
* Change explicit colors for better support for dark theme
This commit is contained in:
rjwats
2020-02-09 10:21:13 +00:00
committed by GitHub
parent ea6aa78d60
commit 260e9a18d0
121 changed files with 7450 additions and 5963 deletions

View File

@ -0,0 +1,10 @@
import React, { FC } from "react";
import { FormControlLabel, FormControlLabelProps } from "@material-ui/core";
const BlockFormControlLabel: FC<FormControlLabelProps> = (props) => (
<div>
<FormControlLabel {...props} />
</div>
)
export default BlockFormControlLabel;

View File

@ -0,0 +1,7 @@
import { styled, Box } from "@material-ui/core";
const FormActions = styled(Box)(({ theme }) => ({
marginTop: theme.spacing(1)
}));
export default FormActions;

View File

@ -0,0 +1,13 @@
import { Button, styled } from "@material-ui/core";
const FormButton = styled(Button)(({ theme }) => ({
margin: theme.spacing(0, 1),
'&:last-child': {
marginRight: 0,
},
'&:first-child': {
marginLeft: 0,
}
}));
export default FormButton;

View File

@ -0,0 +1,23 @@
import { Avatar, makeStyles } from "@material-ui/core";
import React, { FC } from "react";
interface HighlightAvatarProps {
color: string;
}
const useStyles = makeStyles({
root: (props: HighlightAvatarProps) => ({
backgroundColor: props.color
})
});
const HighlightAvatar: FC<HighlightAvatarProps> = (props) => {
const classes = useStyles(props);
return (
<Avatar className={classes.root}>
{props.children}
</Avatar>
);
}
export default HighlightAvatar;

View File

@ -1,58 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { makeStyles } from '@material-ui/core/styles';
import Button from '@material-ui/core/Button';
import LinearProgress from '@material-ui/core/LinearProgress';
import Typography from '@material-ui/core/Typography';
const useStyles = makeStyles(theme => ({
loadingSettings: {
margin: theme.spacing(0.5),
},
loadingSettingsDetails: {
margin: theme.spacing(4),
textAlign: "center"
},
button: {
marginRight: theme.spacing(2),
marginTop: theme.spacing(2),
}
}));
export default function LoadingNotification(props) {
const classes = useStyles();
const { fetched, errorMessage, onReset, render } = props;
return (
<div>
{
fetched ?
errorMessage ?
<div className={classes.loadingSettings}>
<Typography variant="h6" className={classes.loadingSettingsDetails}>
{errorMessage}
</Typography>
<Button variant="contained" color="secondary" className={classes.button} onClick={onReset}>
Reset
</Button>
</div>
:
render()
:
<div className={classes.loadingSettings}>
<LinearProgress className={classes.loadingSettingsDetails} />
<Typography variant="h6" className={classes.loadingSettingsDetails}>
Loading...
</Typography>
</div>
}
</div>
);
}
LoadingNotification.propTypes = {
fetched: PropTypes.bool.isRequired,
onReset: PropTypes.func.isRequired,
errorMessage: PropTypes.string,
render: PropTypes.func.isRequired
};

View File

@ -1,42 +1,28 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Link, withRouter } from 'react-router-dom';
import React, { RefObject } from 'react';
import { Link, withRouter, RouteComponentProps } from 'react-router-dom';
import { Drawer, AppBar, Toolbar, Avatar, Divider, Button, IconButton } from '@material-ui/core';
import { ClickAwayListener, Popper, Hidden, Typography } from '@material-ui/core';
import { List, ListItem, ListItemIcon, ListItemText, ListItemAvatar } from '@material-ui/core';
import { Card, CardContent, CardActions } from '@material-ui/core';
import { withStyles, createStyles, Theme, WithTheme, WithStyles, withTheme } from '@material-ui/core/styles';
import { withStyles } from '@material-ui/core/styles';
import Drawer from '@material-ui/core/Drawer';
import AppBar from '@material-ui/core/AppBar';
import Toolbar from '@material-ui/core/Toolbar';
import Typography from '@material-ui/core/Typography';
import IconButton from '@material-ui/core/IconButton';
import Hidden from '@material-ui/core/Hidden';
import Divider from '@material-ui/core/Divider';
import Button from '@material-ui/core/Button';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import ListItemIcon from '@material-ui/core/ListItemIcon';
import ListItemText from '@material-ui/core/ListItemText';
import ListItemAvatar from '@material-ui/core/ListItemAvatar';
import Popper from '@material-ui/core/Popper';
import MenuIcon from '@material-ui/icons/Menu';
import WifiIcon from '@material-ui/icons/Wifi';
import SettingsIcon from '@material-ui/icons/Settings';
import AccessTimeIcon from '@material-ui/icons/AccessTime';
import AccountCircleIcon from '@material-ui/icons/AccountCircle';
import SettingsInputAntennaIcon from '@material-ui/icons/SettingsInputAntenna';
import LockIcon from '@material-ui/icons/Lock';
import ClickAwayListener from '@material-ui/core/ClickAwayListener';
import Card from '@material-ui/core/Card';
import CardContent from '@material-ui/core/CardContent';
import CardActions from '@material-ui/core/CardActions';
import Avatar from '@material-ui/core/Avatar';
import MenuIcon from '@material-ui/icons/Menu';
import ProjectMenu from '../project/ProjectMenu';
import { PROJECT_NAME } from '../constants/Env';
import { withAuthenticationContext } from '../authentication/Context.js';
import { PROJECT_NAME } from '../api';
import { withAuthenticatedContext, AuthenticatedContextProps } from '../authentication';
const drawerWidth = 290;
const styles = theme => ({
const styles = (theme: Theme) => createStyles({
root: {
display: 'flex',
},
@ -77,26 +63,38 @@ const styles = theme => ({
"& > * + *": {
marginLeft: theme.spacing(2),
}
},
}
});
class MenuAppBar extends React.Component {
state = {
mobileOpen: false,
authMenuOpen: false
};
interface MenuAppBarState {
mobileOpen: boolean;
authMenuOpen: boolean;
}
anchorRef = React.createRef();
interface MenuAppBarProps extends AuthenticatedContextProps, WithTheme, WithStyles<typeof styles>, RouteComponentProps {
sectionTitle: string;
}
class MenuAppBar extends React.Component<MenuAppBarProps, MenuAppBarState> {
constructor(props: MenuAppBarProps) {
super(props);
this.state = {
mobileOpen: false,
authMenuOpen: false
};
}
anchorRef: RefObject<HTMLButtonElement> = React.createRef();
handleToggle = () => {
this.setState({ authMenuOpen: !this.state.authMenuOpen });
}
handleClose = (event) => {
if (this.anchorRef.current && this.anchorRef.current.contains(event.target)) {
handleClose = (event: React.MouseEvent<Document>) => {
if (this.anchorRef.current && this.anchorRef.current.contains(event.currentTarget)) {
return;
}
this.setState({ authMenuOpen: false });
}
@ -105,13 +103,13 @@ class MenuAppBar extends React.Component {
};
render() {
const { classes, theme, children, sectionTitle, authenticationContext } = this.props;
const { classes, theme, children, sectionTitle, authenticatedContext } = this.props;
const { mobileOpen, authMenuOpen } = this.state;
const path = this.props.match.url;
const drawer = (
<div>
<Toolbar>
<Typography variant="h6" color="primary">
<Typography variant="h6" color="textPrimary">
{PROJECT_NAME}
</Typography>
<Divider absolute />
@ -138,7 +136,7 @@ class MenuAppBar extends React.Component {
</ListItemIcon>
<ListItemText primary="Network Time" />
</ListItem>
<ListItem to='/security/' selected={path.startsWith('/security/')} button component={Link} disabled={!authenticationContext.isAdmin()}>
<ListItem to='/security/' selected={path.startsWith('/security/')} button component={Link} disabled={!authenticatedContext.me.admin}>
<ListItemIcon>
<LockIcon />
</ListItemIcon>
@ -156,7 +154,7 @@ class MenuAppBar extends React.Component {
return (
<div className={classes.root}>
<AppBar position="fixed" className={classes.appBar}>
<AppBar position="fixed" className={classes.appBar} elevation={0}>
<Toolbar>
<IconButton
color="inherit"
@ -191,13 +189,13 @@ class MenuAppBar extends React.Component {
<AccountCircleIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={"Signed in as: " + authenticationContext.user.username} secondary={authenticationContext.isAdmin() ? "Admin User" : undefined} />
<ListItemText primary={"Signed in as: " + authenticatedContext.me.username} secondary={authenticatedContext.me.admin ? "Admin User" : undefined} />
</ListItem>
</List>
</CardContent>
<Divider />
<CardActions className={classes.authMenuActions}>
<Button variant="contained" color="primary" onClick={authenticationContext.signOut}>Sign Out</Button>
<Button variant="contained" fullWidth color="primary" onClick={authenticatedContext.signOut}>Sign Out</Button>
</CardActions>
</Card>
</ClickAwayListener>
@ -243,14 +241,10 @@ class MenuAppBar extends React.Component {
}
}
MenuAppBar.propTypes = {
classes: PropTypes.object.isRequired,
theme: PropTypes.object.isRequired,
sectionTitle: PropTypes.string.isRequired,
};
export default withAuthenticationContext(
withRouter(
withStyles(styles, { withTheme: true })(MenuAppBar)
export default withRouter(
withTheme(
withAuthenticatedContext(
withStyles(styles)(MenuAppBar)
)
)
);

View File

@ -1,21 +1,25 @@
import React from 'react';
import { TextValidator } from 'react-material-ui-form-validator';
import { withStyles } from '@material-ui/core/styles';
import { InputAdornment } from '@material-ui/core';
import Visibility from '@material-ui/icons/Visibility';
import VisibilityOff from '@material-ui/icons/VisibilityOff';
import IconButton from '@material-ui/core/IconButton';
import { TextValidator, ValidatorComponentProps } from 'react-material-ui-form-validator';
const styles = theme => (
{
input: {
"&::-ms-reveal": {
display: "none"
}
import { withStyles, WithStyles, createStyles } from '@material-ui/core/styles';
import { InputAdornment, IconButton } from '@material-ui/core';
import {Visibility,VisibilityOff } from '@material-ui/icons';
const styles = createStyles({
input: {
"&::-ms-reveal": {
display: "none"
}
});
}
});
class PasswordValidator extends React.Component {
type PasswordValidatorProps = WithStyles<typeof styles> & Exclude<ValidatorComponentProps, "type" | "InputProps">;
interface PasswordValidatorState {
showPassword: boolean;
}
class PasswordValidator extends React.Component<PasswordValidatorProps, PasswordValidatorState> {
state = {
showPassword: false

View File

@ -1,125 +0,0 @@
import React from 'react';
import { withSnackbar } from 'notistack';
import { redirectingAuthorizedFetch } from '../authentication/Authentication';
/*
* It is unlikely this application will grow complex enough to require redux.
*
* This HOC acts as an interface to a REST service, providing data and change
* event callbacks to the wrapped components along with a function to persist the
* changes.
*/
export const restComponent = (endpointUrl, FormComponent) => {
return withSnackbar(
class extends React.Component {
constructor(props) {
super(props);
this.state = {
data: null,
fetched: false,
errorMessage: null
};
this.setState = this.setState.bind(this);
this.loadData = this.loadData.bind(this);
this.saveData = this.saveData.bind(this);
this.setData = this.setData.bind(this);
}
setData(data) {
this.setState({
data: data,
fetched: true,
errorMessage: null
});
}
loadData() {
this.setState({
data: null,
fetched: false,
errorMessage: null
});
redirectingAuthorizedFetch(endpointUrl)
.then(response => {
if (response.status === 200) {
return response.json();
}
throw Error("Invalid status code: " + response.status);
})
.then(json => { this.setState({ data: json, fetched: true }) })
.catch(error => {
const errorMessage = error.message || "Unknown error";
this.props.enqueueSnackbar("Problem fetching: " + errorMessage, {
variant: 'error',
});
this.setState({ data: null, fetched: true, errorMessage });
});
}
saveData(e) {
this.setState({ fetched: false });
redirectingAuthorizedFetch(endpointUrl, {
method: 'POST',
body: JSON.stringify(this.state.data),
headers: {
'Content-Type': 'application/json'
}
})
.then(response => {
if (response.status === 200) {
return response.json();
}
throw Error("Invalid status code: " + response.status);
})
.then(json => {
this.props.enqueueSnackbar("Changes successfully applied.", {
variant: 'success',
});
this.setState({ data: json, fetched: true });
}).catch(error => {
const errorMessage = error.message || "Unknown error";
this.props.enqueueSnackbar("Problem saving: " + errorMessage, {
variant: 'error',
});
this.setState({ data: null, fetched: true, errorMessage });
});
}
handleValueChange = name => (event) => {
const { data } = this.state;
data[name] = event.target.value;
this.setState({ data });
};
handleSliderChange = name => (event, newValue) => {
const { data } = this.state;
data[name] = newValue;
this.setState({ data });
};
handleCheckboxChange = name => event => {
const { data } = this.state;
data[name] = event.target.checked;
this.setState({ data });
}
render() {
return <FormComponent
handleValueChange={this.handleValueChange}
handleCheckboxChange={this.handleCheckboxChange}
handleSliderChange={this.handleSliderChange}
setData={this.setData}
saveData={this.saveData}
loadData={this.loadData}
{...this.state}
{...this.props}
/>;
}
}
);
}

View File

@ -0,0 +1,116 @@
import React from 'react';
import { withSnackbar, WithSnackbarProps } from 'notistack';
import { redirectingAuthorizedFetch } from '../authentication';
export interface RestControllerProps<D> extends WithSnackbarProps {
handleValueChange: (name: keyof D) => (event: React.ChangeEvent<HTMLInputElement>) => void;
handleCheckboxChange: (name: keyof D) => (event: React.ChangeEvent<HTMLInputElement>, checked: boolean) => void;
handleSliderChange: (name: keyof D) => (event: React.ChangeEvent<{}>, value: number | number[]) => void;
setData: (data: D) => void;
saveData: () => void;
loadData: () => void;
data?: D;
loading: boolean;
errorMessage?: string;
}
interface RestControllerState<D> {
data?: D;
loading: boolean;
errorMessage?: string;
}
export function restController<D, P extends RestControllerProps<D>>(endpointUrl: string, RestController: React.ComponentType<P & RestControllerProps<D>>) {
return withSnackbar(
class extends React.Component<Omit<P, keyof RestControllerProps<D>> & WithSnackbarProps, RestControllerState<D>> {
state: RestControllerState<D> = {
data: undefined,
loading: false,
errorMessage: undefined
};
setData = (data: D) => {
this.setState({
data,
loading: false,
errorMessage: undefined
});
}
loadData = () => {
this.setState({
data: undefined,
loading: true,
errorMessage: undefined
});
redirectingAuthorizedFetch(endpointUrl).then(response => {
if (response.status === 200) {
return response.json();
}
throw Error("Invalid status code: " + response.status);
}).then(json => {
this.setState({ data: json, loading: false })
}).catch(error => {
const errorMessage = error.message || "Unknown error";
this.props.enqueueSnackbar("Problem fetching: " + errorMessage, { variant: 'error' });
this.setState({ data: undefined, loading: false, errorMessage });
});
}
saveData = () => {
this.setState({ loading: true });
redirectingAuthorizedFetch(endpointUrl, {
method: 'POST',
body: JSON.stringify(this.state.data),
headers: {
'Content-Type': 'application/json'
}
}).then(response => {
if (response.status === 200) {
return response.json();
}
throw Error("Invalid status code: " + response.status);
}).then(json => {
this.props.enqueueSnackbar("Changes successfully applied.", { variant: 'success' });
this.setState({ data: json, loading: false });
}).catch(error => {
const errorMessage = error.message || "Unknown error";
this.props.enqueueSnackbar("Problem saving: " + errorMessage, { variant: 'error' });
this.setState({ data: undefined, loading: false, errorMessage });
});
}
handleValueChange = (name: keyof D) => (event: React.ChangeEvent<HTMLInputElement>) => {
const data = { ...this.state.data!, [name]: event.target.value };
this.setState({ data });
}
handleCheckboxChange = (name: keyof D) => (event: React.ChangeEvent<HTMLInputElement>) => {
const data = { ...this.state.data!, [name]: event.target.checked };
this.setState({ data });
}
handleSliderChange = (name: keyof D) => (event: React.ChangeEvent<{}>, value: number | number[]) => {
const data = { ...this.state.data!, [name]: value };
this.setState({ data });
};
render() {
return <RestController
handleValueChange={this.handleValueChange}
handleCheckboxChange={this.handleCheckboxChange}
handleSliderChange={this.handleSliderChange}
setData={this.setData}
saveData={this.saveData}
loadData={this.loadData}
{...this.state}
{...this.props as P}
/>;
}
});
}

View File

@ -0,0 +1,55 @@
import React from 'react';
import { makeStyles, Theme, createStyles } from '@material-ui/core/styles';
import { Button, LinearProgress, Typography } from '@material-ui/core';
import { RestControllerProps } from './RestController';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
loadingSettings: {
margin: theme.spacing(0.5),
},
loadingSettingsDetails: {
margin: theme.spacing(4),
textAlign: "center"
},
button: {
marginRight: theme.spacing(2),
marginTop: theme.spacing(2),
}
})
);
export type RestFormProps<D> = Omit<RestControllerProps<D>, "loading" | "errorMessage"> & { data: D };
interface RestFormLoaderProps<D> extends RestControllerProps<D> {
render: (props: RestFormProps<D>) => JSX.Element;
}
export default function RestFormLoader<D>(props: RestFormLoaderProps<D>) {
const { loading, errorMessage, loadData, render, data, ...rest } = props;
const classes = useStyles();
if (loading || !data) {
return (
<div className={classes.loadingSettings}>
<LinearProgress className={classes.loadingSettingsDetails} />
<Typography variant="h6" className={classes.loadingSettingsDetails}>
Loading...
</Typography>
</div>
);
}
if (errorMessage) {
return (
<div className={classes.loadingSettings}>
<Typography variant="h6" className={classes.loadingSettingsDetails}>
{errorMessage}
</Typography>
<Button variant="contained" color="secondary" className={classes.button} onClick={loadData}>
Reset
</Button>
</div>
);
}
return render({ ...rest, loadData, data });
}

View File

@ -1,37 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import Paper from '@material-ui/core/Paper';
import { withStyles } from '@material-ui/core/styles';
import Typography from '@material-ui/core/Typography';
const styles = theme => ({
content: {
padding: theme.spacing(2),
margin: theme.spacing(3),
}
});
function SectionContent(props) {
const { children, classes, title, titleGutter } = props;
return (
<Paper className={classes.content}>
<Typography variant="h6" gutterBottom={titleGutter}>
{title}
</Typography>
{children}
</Paper>
);
}
SectionContent.propTypes = {
classes: PropTypes.object.isRequired,
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node
]).isRequired,
title: PropTypes.string.isRequired,
titleGutter: PropTypes.bool
};
export default withStyles(styles)(SectionContent);

View File

@ -0,0 +1,33 @@
import React from 'react';
import { Typography, Paper } from '@material-ui/core';
import { createStyles, Theme, makeStyles } from '@material-ui/core/styles';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
content: {
padding: theme.spacing(2),
margin: theme.spacing(3),
}
})
);
interface SectionContentProps {
title: string;
titleGutter?: boolean;
}
const SectionContent: React.FC<SectionContentProps> = (props) => {
const { children, title, titleGutter } = props;
const classes = useStyles();
return (
<Paper className={classes.content}>
<Typography variant="h6" gutterBottom={titleGutter}>
{title}
</Typography>
{children}
</Paper>
);
};
export default SectionContent;

View File

@ -0,0 +1,11 @@
export { default as BlockFormControlLabel } from './BlockFormControlLabel';
export { default as FormActions } from './FormActions';
export { default as FormButton } from './FormButton';
export { default as HighlightAvatar } from './HighlightAvatar';
export { default as MenuAppBar } from './MenuAppBar';
export { default as PasswordValidator } from './PasswordValidator';
export { default as RestFormLoader } from './RestFormLoader';
export { default as SectionContent } from './SectionContent';
export * from './RestFormLoader';
export * from './RestController';