implement notifications

This commit is contained in:
David Lechner
2020-04-20 13:57:58 -05:00
parent cf5d801a9a
commit b1e99e0582
12 changed files with 258 additions and 18 deletions
+20 -9
View File
@@ -1,5 +1,6 @@
import { Action } from 'redux';
import { ThunkAction } from 'redux-thunk';
import * as notification from './notification';
const pybricksServiceUUID = 'c5f50001-8280-46da-89f4-6d8051e4aeef';
@@ -48,9 +49,7 @@ export interface BLEDataAction extends Action<BLEDataActionType> {
value: DataView;
}
type AnyBLEAction = BLEConnectAction | BLEDataAction;
export type BLEThunkAction = ThunkAction<Promise<void>, {}, {}, AnyBLEAction>;
export type BLEThunkAction = ThunkAction<Promise<void>, {}, {}, Action>;
function beginConnect(): BLEConnectAction {
return { type: BLEConnectActionType.BeginConnect };
@@ -70,13 +69,18 @@ function endDisconnect(): BLEConnectAction {
export function connect(): BLEThunkAction {
return async function (dispatch): Promise<void> {
dispatch(notification.add('error', 'A device is already connected.'));
if (device !== undefined) {
console.error('Already have a connected device');
dispatch(notification.add('error', 'A device is already connected.'));
return;
}
if (navigator.bluetooth === undefined) {
// TODO: dispatch error toast action
console.error('Browser does not support WebBluetooth or it is not enabled');
dispatch(
notification.add(
'error',
'Browser does not support WebBluetooth or it is not enabled',
),
);
return;
}
dispatch(beginConnect());
@@ -86,20 +90,26 @@ export function connect(): BLEThunkAction {
optionalServices: [bleNusServiceUUID],
});
} catch (err) {
// this can happen if the use cancels the dialog
if (
err instanceof DOMException &&
err.code === DOMException.NOT_FOUND_ERR
) {
// this can happen if the use cancels the dialog
console.debug('User cancelled connect');
} else {
console.error(err);
dispatch(
notification.add(
'error',
'Unexpected error, check developer console for details.',
),
);
}
dispatch(endDisconnect());
return;
}
if (device.gatt === undefined) {
console.error('Device does not support GATT');
dispatch(notification.add('error', 'Device does not support GATT'));
dispatch(endDisconnect());
return;
}
@@ -121,7 +131,8 @@ export function connect(): BLEThunkAction {
});
await txChar.startNotifications();
} catch (err) {
console.error('getting nRF UART service failed');
console.error(err);
dispatch(notification.add('error', 'Getting nRF UART service failed'));
device.gatt.disconnect();
return;
}
+49
View File
@@ -0,0 +1,49 @@
import { Action } from 'redux';
export enum NotificationActionType {
/**
* Add a notification to the list of notifications.
*/
Add = 'notification.action.add',
/**
* Remove a notification from the list of notifications.
*/
Remove = 'notification.action.Remove',
}
export type NotificationLevel = 'error' | 'warning' | 'info';
export interface NotificationAddAction extends Action<NotificationActionType.Add> {
/**
* Unique ID for this notification instance.
*/
readonly id: number;
/**
* The type of notification.
*/
readonly level: NotificationLevel;
/**
* The message to be displayed to the user.
*/
readonly message: string;
}
export interface NotificationRemoveAction
extends Action<NotificationActionType.Remove> {
/**
* ID of an existing notification.
*/
readonly id: number;
}
export type NotificationAction = NotificationAddAction | NotificationRemoveAction;
let nextId = 0;
export function add(level: NotificationLevel, message: string): NotificationAddAction {
return { type: NotificationActionType.Add, id: nextId++, level, message };
}
export function remove(id: number): NotificationRemoveAction {
return { type: NotificationActionType.Remove, id };
}
+7 -1
View File
@@ -2,6 +2,7 @@ import { connect } from 'react-redux';
import { AnyAction } from 'redux';
import { ThunkDispatch } from 'redux-thunk';
import { flashFirmware } from '../actions/bootloader';
import * as notification from '../actions/notification';
import { RootState } from '../reducers';
import { BootloaderConnectionState } from '../reducers/bootloader';
import OpenFileButton, { OpenFileButtonProps } from './OpenFileButton';
@@ -9,7 +10,7 @@ import OpenFileButton, { OpenFileButtonProps } from './OpenFileButton';
type Dispatch = ThunkDispatch<{}, {}, AnyAction>;
type StateProps = Pick<OpenFileButtonProps, 'enabled'>;
type DispatchProps = Pick<OpenFileButtonProps, 'onFile'>;
type DispatchProps = Pick<OpenFileButtonProps, 'onFile' | 'onReject'>;
type OwnProps = Pick<OpenFileButtonProps, 'id'>;
const mapStateToProps = (state: RootState): StateProps => ({
@@ -20,6 +21,11 @@ const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({
onFile: (data): void => {
dispatch(flashFirmware(data));
},
onReject: (file): void => {
dispatch(
notification.add('error', `'${file.name}' is not a valid firmware file.`),
);
},
});
const mergeProps = (
+60
View File
@@ -0,0 +1,60 @@
import React from 'react';
import Toast from 'react-bootstrap/Toast';
import { connect } from 'react-redux';
import { Action } from 'redux';
import { ThunkDispatch } from 'redux-thunk';
import * as notification from '../actions/notification';
type Dispatch = ThunkDispatch<{}, {}, Action>;
interface DispatchProps {
onClose: () => void;
}
interface OwnProps {
id: number;
style: string;
message: string;
}
type NotificationProps = DispatchProps & OwnProps;
function mapTitle(style: string): string {
switch (style) {
case 'danger':
return 'Error';
case 'warning':
return 'Warning';
default:
return 'Info';
}
}
class Notification extends React.Component<NotificationProps> {
render(): JSX.Element {
const title = mapTitle(this.props.style);
return (
<Toast
onClose={(): void => {
this.props.onClose();
}}
transition={false}
>
<Toast.Header>
<strong className={`mr-auto text-${this.props.style}`}>
{title}
</strong>
</Toast.Header>
<Toast.Body>{this.props.message}</Toast.Body>
</Toast>
);
}
}
const mapDispatchToProps = (dispatch: Dispatch, ownProps: OwnProps): DispatchProps => ({
onClose: (): void => {
dispatch(notification.remove(ownProps.id));
},
});
export default connect(null, mapDispatchToProps)(Notification);
+49
View File
@@ -0,0 +1,49 @@
import React from 'react';
import { Collapse } from 'react-bootstrap';
import { connect } from 'react-redux';
import { TransitionGroup } from 'react-transition-group';
import { RootState } from '../reducers';
import { NotificationList } from '../reducers/notification';
import Notification from './Notification';
interface StateProps {
list: NotificationList;
}
type NotificationStackProps = StateProps;
class NotificationStack extends React.Component<NotificationStackProps> {
render(): JSX.Element {
return (
<div aria-live="polite" aria-atomic="true" style={{ position: 'relative' }}>
<div
style={{
position: 'absolute',
top: '10px',
right: '10px',
minWidth: '350px',
zIndex: 999,
}}
>
<TransitionGroup>
{this.props.list.map((n) => (
<Collapse key={n.id} in={true}>
<Notification
id={n.id}
style={n.style}
message={n.message}
/>
</Collapse>
))}
</TransitionGroup>
</div>
</div>
);
}
}
const mapStateToProps = (state: RootState): StateProps => ({
list: state.notification.list,
});
export default connect(mapStateToProps)(NotificationStack);
+4 -3
View File
@@ -16,8 +16,10 @@ export interface OpenFileButtonProps {
readonly icon: string;
/** When true or undefined, the button is enabled. */
readonly enabled?: boolean;
/** Callback that is called when the button is activated (clicked). */
/** Callback that is called when a file has been selected and opened for reading. */
readonly onFile: (data: ArrayBuffer) => void;
/** Callback that is called when a file has been rejected (e.g. bad file extension). */
readonly onReject: (file: File) => void;
}
/**
@@ -54,8 +56,7 @@ class OpenFileButton extends React.Component<OpenFileButtonProps> {
private onDropRejected(rejectedFiles: File[]): void {
// should only be one file since multiple={false}
rejectedFiles.forEach((f) => {
// TODO: proper bootstrap toast
alert(`bad file ${f.name}`);
this.props.onReject(f);
});
}
+15 -3
View File
@@ -2,6 +2,18 @@
// $body-bg: #000;
// Import Bootstrap and its default variables
@import "~bootswatch/dist/lumen/variables";
@import "~bootstrap/scss/bootstrap";
@import "~bootswatch/dist/lumen/bootswatch";
@import '~bootswatch/dist/lumen/variables';
@import '~bootstrap/scss/bootstrap';
@import '~bootswatch/dist/lumen/bootswatch';
// TODO: this can be removed if bootswatch package is updated with this fix
.toast {
.close {
color: $black;
&:not(:disabled):not(.disabled):hover,
&:not(:disabled):not(.disabled):focus {
color: $black;
}
}
}
+2
View File
@@ -8,6 +8,7 @@ import createSagaMiddleware from 'redux-saga';
import thunkMiddleware from 'redux-thunk';
import './index.scss';
import App from './components/App';
import NotificationStack from './components/NotificationStack';
import rootEpic from './epics';
import rootReducer from './reducers';
import rootSaga from './sagas';
@@ -35,6 +36,7 @@ epicMiddleware.run(rootEpic);
ReactDOM.render(
<React.StrictMode>
<Provider store={store}>
<NotificationStack />
<App />
</Provider>
</React.StrictMode>,
+3 -1
View File
@@ -3,6 +3,7 @@ import ble, { BLEState } from './ble';
import bootloader, { BootloaderState } from './bootloader';
import editor, { EditorState } from './editor';
import hub, { HubState } from './hub';
import notification, { NotificationState } from './notification';
/**
* Root state for redux store.
@@ -12,6 +13,7 @@ export interface RootState {
readonly ble: BLEState;
readonly editor: EditorState;
readonly hub: HubState;
readonly notification: NotificationState;
}
export default combineReducers({ bootloader, ble, editor, hub });
export default combineReducers({ bootloader, ble, editor, hub, notification });
+39
View File
@@ -0,0 +1,39 @@
import { Reducer } from 'react';
import { combineReducers } from 'redux';
import { NotificationAction, NotificationActionType } from '../actions/notification';
export type NotificationList = Array<{
readonly id: number;
readonly style: string;
readonly message: string;
}>;
const levelMap = {
error: 'danger',
warning: 'warning',
info: 'info',
};
const list: Reducer<NotificationList, NotificationAction> = (state = [], action) => {
switch (action.type) {
case NotificationActionType.Add:
return [
...state,
{
id: action.id,
style: levelMap[action.level],
message: action.message,
},
];
case NotificationActionType.Remove:
return state.filter((e) => e.id !== action.id);
default:
return state;
}
};
export interface NotificationState {
readonly list: NotificationList;
}
export default combineReducers({ list });