diff --git a/package.json b/package.json index 69ae08f7..385aff27 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "@types/react": "^16.9.0", "@types/react-dom": "^16.9.0", "@types/react-redux": "^7.1.7", + "@types/react-transition-group": "^4.2.4", "@types/redux-logger": "^3.0.7", "@types/web-bluetooth": "^0.0.5", "ace-builds": "^1.4.9", @@ -24,6 +25,7 @@ "react-dropzone": "^10.2.2", "react-redux": "^7.2.0", "react-scripts": "3.4.1", + "react-transition-group": "^4.3.0", "redux": "^4.0.5", "redux-logger": "^3.0.6", "redux-observable": "^1.2.0", diff --git a/src/actions/ble.ts b/src/actions/ble.ts index c60e3819..d27677bf 100644 --- a/src/actions/ble.ts +++ b/src/actions/ble.ts @@ -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 { value: DataView; } -type AnyBLEAction = BLEConnectAction | BLEDataAction; - -export type BLEThunkAction = ThunkAction, {}, {}, AnyBLEAction>; +export type BLEThunkAction = ThunkAction, {}, {}, Action>; function beginConnect(): BLEConnectAction { return { type: BLEConnectActionType.BeginConnect }; @@ -70,13 +69,18 @@ function endDisconnect(): BLEConnectAction { export function connect(): BLEThunkAction { return async function (dispatch): Promise { + 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; } diff --git a/src/actions/notification.ts b/src/actions/notification.ts new file mode 100644 index 00000000..ec113bf9 --- /dev/null +++ b/src/actions/notification.ts @@ -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 { + /** + * 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 { + /** + * 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 }; +} diff --git a/src/components/FlashButton.tsx b/src/components/FlashButton.tsx index 86b31612..105d3eb1 100644 --- a/src/components/FlashButton.tsx +++ b/src/components/FlashButton.tsx @@ -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; -type DispatchProps = Pick; +type DispatchProps = Pick; type OwnProps = Pick; 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 = ( diff --git a/src/components/Notification.tsx b/src/components/Notification.tsx new file mode 100644 index 00000000..40b7707d --- /dev/null +++ b/src/components/Notification.tsx @@ -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 { + render(): JSX.Element { + const title = mapTitle(this.props.style); + return ( + { + this.props.onClose(); + }} + transition={false} + > + + + {title} + + + {this.props.message} + + ); + } +} + +const mapDispatchToProps = (dispatch: Dispatch, ownProps: OwnProps): DispatchProps => ({ + onClose: (): void => { + dispatch(notification.remove(ownProps.id)); + }, +}); + +export default connect(null, mapDispatchToProps)(Notification); diff --git a/src/components/NotificationStack.tsx b/src/components/NotificationStack.tsx new file mode 100644 index 00000000..6c19d9fe --- /dev/null +++ b/src/components/NotificationStack.tsx @@ -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 { + render(): JSX.Element { + return ( +
+
+ + {this.props.list.map((n) => ( + + + + ))} + +
+
+ ); + } +} + +const mapStateToProps = (state: RootState): StateProps => ({ + list: state.notification.list, +}); + +export default connect(mapStateToProps)(NotificationStack); diff --git a/src/components/OpenFileButton.tsx b/src/components/OpenFileButton.tsx index 3edf06ac..42a13ff0 100644 --- a/src/components/OpenFileButton.tsx +++ b/src/components/OpenFileButton.tsx @@ -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 { 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); }); } diff --git a/src/index.scss b/src/index.scss index eb89ca3a..9c8309b5 100644 --- a/src/index.scss +++ b/src/index.scss @@ -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; + } + } +} diff --git a/src/index.tsx b/src/index.tsx index f974eddb..7a240949 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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( + , diff --git a/src/reducers/index.ts b/src/reducers/index.ts index 91d4028d..ec658841 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -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 }); diff --git a/src/reducers/notification.ts b/src/reducers/notification.ts new file mode 100644 index 00000000..614d17ae --- /dev/null +++ b/src/reducers/notification.ts @@ -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 = (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 }); diff --git a/yarn.lock b/yarn.lock index fb907241..63c4f0d2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2227,6 +2227,13 @@ hoist-non-react-statics "^3.3.0" redux "^4.0.0" +"@types/react-transition-group@^4.2.4": + version "4.2.4" + resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.2.4.tgz#c7416225987ccdb719262766c1483da8f826838d" + integrity sha512-8DMUaDqh0S70TjkqU0DxOu80tFUiiaS9rxkWip/nb7gtvAsbqOXm02UCmR8zdcjWujgeYPiPNTVpVpKzUDotwA== + dependencies: + "@types/react" "*" + "@types/react@*", "@types/react@^16.9.0": version "16.9.32" resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.32.tgz#f6368625b224604148d1ddf5920e4fefbd98d383" @@ -10173,7 +10180,7 @@ react-scripts@3.4.1: optionalDependencies: fsevents "2.1.2" -react-transition-group@^4.0.0: +react-transition-group@^4.0.0, react-transition-group@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.3.0.tgz#fea832e386cf8796c58b61874a3319704f5ce683" integrity sha512-1qRV1ZuVSdxPlPf4O8t7inxUGpdyO5zG9IoNfJxSO0ImU2A1YWkEQvFPuIPZmMLkg5hYs7vv5mMOyfgSkvAwvw==