mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-12 09:36:27 +00:00
implement notifications
This commit is contained in:
+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 });
|
||||
Reference in New Issue
Block a user