diff --git a/app/src/components/ConnectionSetup/ConnectButton.tsx b/app/src/components/ConnectionSetup/ConnectButton.tsx new file mode 100644 index 0000000..ea66bfb --- /dev/null +++ b/app/src/components/ConnectionSetup/ConnectButton.tsx @@ -0,0 +1,25 @@ +import ConnectionHealthIndicator from '../helper/ConnectionHealthIndicator' +import PowerSettingsNew from '@material-ui/icons/PowerSettingsNew' +import React from 'react' +import { Button } from '@material-ui/core' + +function ConnectButton(props: { connecting: boolean; classes: any; toggle: () => void }) { + const { classes, toggle, connecting } = props + + if (connecting) { + return ( + + + Abort + + ) + } + + return ( + + Connect + + ) +} + +export default ConnectButton diff --git a/app/src/components/ConnectionSetup/ConnectionSettings.tsx b/app/src/components/ConnectionSetup/ConnectionSettings.tsx index a42a6f1..cc517c0 100644 --- a/app/src/components/ConnectionSetup/ConnectionSettings.tsx +++ b/app/src/components/ConnectionSetup/ConnectionSettings.tsx @@ -1,8 +1,8 @@ -import * as React from 'react' +import ConnectButton from './ConnectButton' import Delete from '@material-ui/icons/Delete' -import Settings from '@material-ui/icons/Settings' -import PowerSettingsNew from '@material-ui/icons/PowerSettingsNew' +import React, { useCallback, useState } from 'react' import Save from '@material-ui/icons/Save' +import Settings from '@material-ui/icons/Settings' import Visibility from '@material-ui/icons/Visibility' import VisibilityOff from '@material-ui/icons/VisibilityOff' import { AppState } from '../../reducers' @@ -10,23 +10,21 @@ import { bindActionCreators } from 'redux' import { connect } from 'react-redux' import { connectionActions, connectionManagerActions } from '../../actions' import { ConnectionOptions, toMqttConnection } from '../../model/ConnectionOptions' +import { KeyCodes } from '../../utils/KeyCodes' import { Theme, withStyles } from '@material-ui/core/styles' - +import { ToggleSwitch } from './ToggleSwitch' +import { useGlobalKeyEventHandler } from '../../effects/useGlobalKeyEventHandler' import { Button, FormControl, - FormControlLabel, Grid, IconButton, Input, InputAdornment, InputLabel, MenuItem, - Switch, TextField, } from '@material-ui/core' -import ConnectionHealthIndicator from '../helper/ConnectionHealthIndicator' -import { KeyCodes } from '../../utils/KeyCodes' interface Props { connection: ConnectionOptions @@ -39,57 +37,66 @@ interface Props { const protocols = ['mqtt', 'ws'] -interface State { - showPassword: boolean -} +function ConnectionSettings(props: Props) { + const [showPassword, setShowPassword] = useState(false) -class ConnectionSettings extends React.Component { - constructor(props: any) { - super(props) - - this.state = { - showPassword: false, + const toggleConnect = useCallback(() => { + if (props.connecting) { + props.actions.disconnect() + return } + + if (!props.connection) { + return + } + + const mqttOptions = toMqttConnection(props.connection) + if (mqttOptions) { + props.actions.connect(mqttOptions, props.connection.id) + } + }, [props.connection, props.connecting]) + + useGlobalKeyEventHandler(KeyCodes.escape, props.actions.disconnect) + useGlobalKeyEventHandler(KeyCodes.enter, toggleConnect, [props.connecting]) + + const handleClickShowPassword = useCallback(() => { + setShowPassword(!showPassword) + }, [showPassword]) + + function requiresBasePath() { + return props.connection.protocol !== 'mqtt' } - private handleClickShowPassword = () => { - this.setState({ showPassword: !this.state.showPassword }) - } - - private requiresBasePath() { - return this.props.connection.protocol !== 'mqtt' - } - - private renderBasePathInput() { + function renderBasePathInput() { return ( ) } - private handleChange = (name: string) => (event: any) => { - if (!this.props.connection) { + const handleChange = (name: string) => (event: any) => { + if (!props.connection) { return } - this.updateConnection(name, event.target.value) + updateConnection(name, event.target.value) } - private updateConnection(name: string, value: any) { - this.props.managerActions.updateConnection(this.props.connection.id, { + const updateConnection = (name: string, value: any) => { + props.managerActions.updateConnection(props.connection.id, { [name]: value, }) } - private renderProtocols() { - const { classes, connection } = this.props + const renderProtocols = () => { + const { classes, connection } = props const protocolItems = protocols.map((value: string) => ( @@ -103,7 +110,7 @@ class ConnectionSettings extends React.Component { label="Protocol" className={classes.textField} value={connection.protocol} - onChange={this.updateProtocol} + onChange={updateProtocol} margin="normal" > {protocolItems} @@ -111,213 +118,141 @@ class ConnectionSettings extends React.Component { ) } - private updateProtocol = (event: React.ChangeEvent) => { + const updateProtocol = (event: React.ChangeEvent) => { const value = event.target.value - this.updateConnection('protocol', value) + updateConnection('protocol', value) if (event.target.value === 'mqtt') { - this.updateConnection('basePath', undefined) + updateConnection('basePath', undefined) } else { - this.updateConnection('basePath', 'ws') + updateConnection('basePath', 'ws') } } - private renderCertValidationSwitch() { - const { classes, connection } = this.props - - const certSwitch = ( - - ) - - return ( - - - - ) - } - - private toggleCertValidation = () => { - this.props.managerActions.updateConnection(this.props.connection.id, { - certValidation: !this.props.connection.certValidation, + const toggleCertValidation = () => { + props.managerActions.updateConnection(props.connection.id, { + certValidation: !props.connection.certValidation, }) } - private renderTlsSwitch() { - const { classes, connection } = this.props - - const tlsSwitch = - - return ( - - - - ) - } - - private toggleTls = () => { - this.props.managerActions.updateConnection(this.props.connection.id, { - encryption: !this.props.connection.encryption, + const toggleTls = () => { + props.managerActions.updateConnection(props.connection.id, { + encryption: !props.connection.encryption, }) } - private renderConnectButton() { - const { classes, actions } = this.props - - if (this.props.connecting) { - return ( - - - Abort - - ) - } + function PasswordVisibilityButton(props: { showPassword: boolean; toggle: () => void }) { return ( - - Connect - - ) - } - - private connect() { - if (!this.props.connection) { - return - } - - const mqttOptions = toMqttConnection(this.props.connection) - if (mqttOptions) { - this.props.actions.connect(mqttOptions, this.props.connection.id) - } - } - - private onClickConnect = () => { - this.connect() - } - - private handleKeyEvent = (event: KeyboardEvent) => { - if (event.keyCode === KeyCodes.enter) { - this.connect() - event.preventDefault() - } else if (event.keyCode === KeyCodes.escape) { - this.props.actions.disconnect() - event.preventDefault() - } - } - - public componentDidMount() { - document.addEventListener('keydown', this.handleKeyEvent, false) - } - - public componentWillUnmount() { - document.removeEventListener('keydown', this.handleKeyEvent, false) - } - - public render() { - const { classes, connection } = this.props - - const passwordVisibilityButton = ( - - {this.state.showPassword ? : } + + {props.showPassword ? : } ) - - return ( - - - - - - - - {this.renderCertValidationSwitch()} - - - {this.renderTlsSwitch()} - - - {this.renderProtocols()} - - - - - - - - {this.requiresBasePath() ? this.renderBasePathInput() : null} - - - - - - Password - - - - - - - - this.props.managerActions.deleteConnection(this.props.connection.id)} - > - Delete - - - Advanced - - - - - Save - - {this.renderConnectButton()} - - - - - ) } + + const { classes, connection } = props + + return ( + + + + + + + + + + + + + + {renderProtocols()} + + + + + + + + {requiresBasePath() ? renderBasePathInput() : null} + + + + + + Password + } + /> + + + + + + + props.managerActions.deleteConnection(props.connection.id)} + > + Delete + + + Advanced + + + + + Save + + + + + + + ) } const mapStateToProps = (state: AppState) => { diff --git a/app/src/components/ConnectionSetup/ToggleSwitch.tsx b/app/src/components/ConnectionSetup/ToggleSwitch.tsx new file mode 100644 index 0000000..b24d764 --- /dev/null +++ b/app/src/components/ConnectionSetup/ToggleSwitch.tsx @@ -0,0 +1,12 @@ +import React from 'react' +import { FormControlLabel, Switch } from '@material-ui/core' + +export function ToggleSwitch(props: { value: boolean; classes: any; toggle: () => void; label: string }) { + const { classes, value, toggle, label } = props + const toggleSwitch = + return ( + + + + ) +} diff --git a/app/src/components/Layout/SearchBar.tsx b/app/src/components/Layout/SearchBar.tsx new file mode 100644 index 0000000..f06cc4e --- /dev/null +++ b/app/src/components/Layout/SearchBar.tsx @@ -0,0 +1,136 @@ +import React, { useCallback, useState, useRef } from 'react' +import ClearAdornment from '../helper/ClearAdornment' +import Search from '@material-ui/icons/Search' +import { AppState } from '../../reducers' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' +import { InputBase } from '@material-ui/core' +import { settingsActions } from '../../actions' +import { fade, Theme, withStyles } from '@material-ui/core/styles' +import { useGlobalKeyEventHandler } from '../../effects/useGlobalKeyEventHandler' + +function SearchBar(props: { + classes: any + topicFilter?: string + hasConnection: boolean + actions: { + settings: typeof settingsActions + } +}) { + const { actions, classes, hasConnection, topicFilter } = props + + const [hasFocus, setHasFocus] = useState(false) + const inputRef = useRef() + const onFocus = useCallback(() => setHasFocus(true), []) + const onBlur = useCallback(() => setHasFocus(false), []) + + const clearFilter = useCallback(() => { + actions.settings.filterTopics('') + }, []) + + const onFilterChange = useCallback((event: React.ChangeEvent) => { + actions.settings.filterTopics(event.target.value) + }, []) + + useGlobalKeyEventHandler(undefined, event => { + const isCharacter = event.key.length === 1 + const isBackspace = event.key === 'Backspace' + const isBodyActiveElement = document.activeElement && document.activeElement.tagName === 'BODY' + + if ((isCharacter || isBackspace) && !hasFocus && isBodyActiveElement && hasConnection) { + // Focus input field, no preventDefault the event will reach the input element after it has been focussed + inputRef.current && inputRef.current.focus() + } + }) + + return ( + + + + + + + + } + classes={{ root: classes.inputRoot, input: classes.inputInput }} + /> + + ) +} + +const mapStateToProps = (state: AppState) => { + return { + topicFilter: state.settings.get('topicFilter'), + hasConnection: Boolean(state.connection.connectionId), + } +} + +const mapDispatchToProps = (dispatch: any) => { + return { + actions: { + settings: bindActionCreators(settingsActions, dispatch), + }, + } +} + +const styles = (theme: Theme) => ({ + search: { + position: 'relative' as 'relative', + borderRadius: theme.shape.borderRadius, + backgroundColor: fade(theme.palette.common.white, 0.15), + '&:hover': { + backgroundColor: fade(theme.palette.common.white, 0.25), + }, + marginRight: theme.spacing(2), + marginLeft: 0, + flexGrow: 1, + maxWidth: '60%', + + [theme.breakpoints.up('md')]: { + maxWidth: '30%', + + marginLeft: theme.spacing(4), + width: 'auto' as 'auto', + }, + [theme.breakpoints.up(750)]: { + marginLeft: theme.spacing(4), + width: 'auto' as 'auto', + }, + }, + searchIcon: { + width: theme.spacing(6), + height: '100%', + position: 'absolute' as 'absolute', + pointerEvents: 'none' as 'none', + display: 'flex' as 'flex', + alignItems: 'center' as 'center', + justifyContent: 'center' as 'center', + }, + inputRoot: { + color: 'inherit' as 'inherit', + width: '100%', + }, + inputInput: { + paddingTop: theme.spacing(1), + paddingRight: theme.spacing(1), + paddingBottom: theme.spacing(1), + paddingLeft: theme.spacing(6), + transition: theme.transitions.create('width'), + width: '100%', + }, +}) + +export default connect( + mapStateToProps, + mapDispatchToProps +)(withStyles(styles)(SearchBar)) diff --git a/app/src/components/Layout/TitleBar.tsx b/app/src/components/Layout/TitleBar.tsx index ca7d758..95afdff 100644 --- a/app/src/components/Layout/TitleBar.tsx +++ b/app/src/components/Layout/TitleBar.tsx @@ -1,17 +1,15 @@ import * as React from 'react' -import ClearAdornment from '../helper/ClearAdornment' import CloudOff from '@material-ui/icons/CloudOff' import ConnectionHealthIndicator from '../helper/ConnectionHealthIndicator' import Menu from '@material-ui/icons/Menu' import PauseButton from './PauseButton' -import Search from '@material-ui/icons/Search' +import SearchBar from './SearchBar' +import { AppBar, Button, IconButton, Toolbar, Typography } from '@material-ui/core' import { AppState } from '../../reducers' import { bindActionCreators } from 'redux' import { connect } from 'react-redux' import { connectionActions, globalActions, settingsActions } from '../../actions' -import { fade } from '@material-ui/core/styles/colorManipulator' import { Theme, withStyles } from '@material-ui/core/styles' -import { AppBar, Button, IconButton, InputBase, Toolbar, Typography } from '@material-ui/core' const styles = (theme: Theme) => ({ title: { @@ -21,29 +19,6 @@ const styles = (theme: Theme) => ({ }, whiteSpace: 'nowrap' as 'nowrap', }, - search: { - position: 'relative' as 'relative', - borderRadius: theme.shape.borderRadius, - backgroundColor: fade(theme.palette.common.white, 0.15), - '&:hover': { - backgroundColor: fade(theme.palette.common.white, 0.25), - }, - marginRight: theme.spacing(2), - marginLeft: 0, - flexGrow: 1, - maxWidth: '60%', - - [theme.breakpoints.up('md')]: { - maxWidth: '30%', - - marginLeft: theme.spacing(4), - width: 'auto' as 'auto', - }, - [theme.breakpoints.up(750)]: { - marginLeft: theme.spacing(4), - width: 'auto' as 'auto', - }, - }, disconnectIcon: { [theme.breakpoints.down('xs')]: { display: 'none' as 'none', @@ -51,27 +26,6 @@ const styles = (theme: Theme) => ({ marginRight: '8px', paddingLeft: '8px', }, - searchIcon: { - width: theme.spacing(6), - height: '100%', - position: 'absolute' as 'absolute', - pointerEvents: 'none' as 'none', - display: 'flex' as 'flex', - alignItems: 'center' as 'center', - justifyContent: 'center' as 'center', - }, - inputRoot: { - color: 'inherit' as 'inherit', - width: '100%', - }, - inputInput: { - paddingTop: theme.spacing(1), - paddingRight: theme.spacing(1), - paddingBottom: theme.spacing(1), - paddingLeft: theme.spacing(6), - transition: theme.transitions.create('width'), - width: '100%', - }, menuButton: { marginLeft: -12, marginRight: 20, @@ -98,37 +52,6 @@ class TitleBar extends React.Component { this.state = {} } - private renderSearch() { - const { classes, topicFilter } = this.props - - return ( - - - - - - - - } - classes={{ root: classes.inputRoot, input: classes.inputInput }} - /> - - ) - } - - private clearFilter = () => { - this.props.actions.settings.filterTopics('') - } - - private onFilterChange = (event: React.ChangeEvent) => { - this.props.actions.settings.filterTopics(event.target.value) - } - public render() { const { actions, classes } = this.props @@ -146,7 +69,7 @@ class TitleBar extends React.Component { MQTT Explorer - {this.renderSearch()} + Disconnect diff --git a/app/src/effects/useGlobalKeyEventHandler.tsx b/app/src/effects/useGlobalKeyEventHandler.tsx new file mode 100644 index 0000000..a9251a4 --- /dev/null +++ b/app/src/effects/useGlobalKeyEventHandler.tsx @@ -0,0 +1,24 @@ +import { useEffect } from 'react' +import { KeyCodes } from '../utils/KeyCodes' + +export function useGlobalKeyEventHandler( + key: KeyCodes | undefined, + callback: (event: KeyboardEvent) => void, + dependencies?: Array +) { + useEffect(() => { + function handleKeyEvent(event: KeyboardEvent) { + if (key === undefined) { + callback(event) + } else if (event.keyCode === key) { + callback(event) + event.preventDefault() + } + } + + document.addEventListener('keydown', handleKeyEvent, false) + return function cleanup() { + document.removeEventListener('keydown', handleKeyEvent, false) + } + }, dependencies) +} diff --git a/app/src/effects/useKeyEventHandler.tsx b/app/src/effects/useKeyEventHandler.tsx new file mode 100644 index 0000000..0ef85d3 --- /dev/null +++ b/app/src/effects/useKeyEventHandler.tsx @@ -0,0 +1,31 @@ +import { KeyCodes } from '../utils/KeyCodes' +import { useCallback } from 'react' + +export function useKeyEventHandler(key: KeyCodes, callback: () => void, dependencies: Array = []) { + return useKeyEventHandlers([{ key, callback }], dependencies) +} + +export function useKeyEventHandlers( + actions: Array<{ + key: KeyCodes + callback: (event: KeyboardEvent) => void + preventDefault?: boolean + stopPropagation?: boolean + }>, + dependencies: Array = [] +) { + return useCallback(() => { + return function handleKeyEvent(event: KeyboardEvent) { + const action = actions.find(a => a.key === event.keyCode) + if (action) { + action.callback(event) + if (action.preventDefault !== false) { + event.preventDefault() + } + if (action.stopPropagation !== false) { + event.stopPropagation() + } + } + } + }, dependencies) +} diff --git a/app/tsconfig.json b/app/tsconfig.json index b6cf602..3b89ef3 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -10,14 +10,12 @@ "sourceMap": true, "module": "esnext", "target": "es2017", - "jsx": "react" + "jsx": "react", + "types": ["react"], + "allowSyntheticDefaultImports": true }, - "include": [ - "./src/**/*" - ], - "exclude": [ - "**/*.d.ts" - ], + "include": ["./src/**/*"], + "exclude": ["**/*.d.ts"], "awesomeTypescriptLoaderOptions": { "useCache": true, "transpileModule": true,