mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-15 02:54:07 +00:00
implement notifications
This commit is contained in:
@@ -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",
|
||||
|
||||
+20
-9
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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 });
|
||||
|
||||
@@ -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 });
|
||||
@@ -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==
|
||||
|
||||
Reference in New Issue
Block a user