convert notifications to static API

This converts notifications from a reducer to a saga. This way, our
state doesn't get bogged down with a bunch of notifications.
This commit is contained in:
David Lechner
2021-01-18 17:29:03 -06:00
parent fc6ee4ff78
commit 54439a4cbc
11 changed files with 325 additions and 363 deletions
+4 -32
View File
@@ -1,27 +1,16 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020 The Pybricks Authors
// Copyright (c) 2020-2021 The Pybricks Authors
import { Action } from 'redux';
import { createCountFunc } from '../utils/iter';
export enum NotificationActionType {
/**
* Add a notification to the list of notifications.
*/
/** 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 type NotificationAddAction = Action<NotificationActionType.Add> & {
/**
* Unique ID for this notification instance.
*/
readonly id: number;
/**
* The type of notification.
*/
@@ -36,17 +25,6 @@ export type NotificationAddAction = Action<NotificationActionType.Add> & {
readonly helpUrl?: string;
};
export type NotificationRemoveAction = Action<NotificationActionType.Remove> & {
/**
* ID of an existing notification.
*/
readonly id: number;
};
export type NotificationAction = NotificationAddAction | NotificationRemoveAction;
const nextId = createCountFunc();
/**
* Action to add a notification to the list.
* @param level The severity level
@@ -58,13 +36,7 @@ export function add(
message: string,
helpUrl?: string,
): NotificationAddAction {
return { type: NotificationActionType.Add, id: -nextId(), level, message, helpUrl };
return { type: NotificationActionType.Add, level, message, helpUrl };
}
/**
* Action to removes a notification from the list.
* @param id The id of the notification to remove
*/
export function remove(id: number): NotificationRemoveAction {
return { type: NotificationActionType.Remove, id };
}
export type NotificationAction = NotificationAddAction;
+36
View File
@@ -0,0 +1,36 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021 The Pybricks Authors
import { IToaster, Toaster } from '@blueprintjs/core';
import { I18nContext, I18nManager } from '@shopify/react-i18n';
import React from 'react';
import ReactDOM from 'react-dom';
/**
* Creates an `IToaster` for static usage similar to `Toaster.create()` except
* that it is wrapped in an `I18nContext.Provider` so that messages can be
* translated.
*
* @param i18n The i18n manager object.
*/
export function create(i18n: I18nManager): IToaster {
const containerElement = document.createElement('div');
document.body.appendChild(containerElement);
const toaster = React.createRef<Toaster>();
ReactDOM.render(
<I18nContext.Provider value={i18n}>
<Toaster usePortal={false} ref={toaster} />
</I18nContext.Provider>,
containerElement,
);
// istanbul ignore if: should not happen since we are rendering the component
if (toaster.current === null) {
throw new Error('failed to set toaster ref');
}
return toaster.current;
}
+12 -109
View File
@@ -1,121 +1,24 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020 The Pybricks Authors
import { IconName, Intent, Toast } from '@blueprintjs/core';
import { Replacements, WithI18nProps, withI18n } from '@shopify/react-i18n';
import { Replacements, useI18n } from '@shopify/react-i18n';
import React from 'react';
import { connect } from 'react-redux';
import { Action, Dispatch } from '../actions';
import { NotificationLevel, remove } from '../actions/notification';
import { Level, MessageAction } from '../reducers/notification';
import { MessageId } from './notification-i18n';
import en from './notification-i18n.en.json';
interface DispatchProps {
onAction: (action: Action) => void;
onClose: () => void;
}
// provides translation for notification text
interface OwnProps {
id: number;
level: NotificationLevel;
message?: string;
messageId?: MessageId;
type OwnProps = {
messageId: MessageId;
replacements?: Replacements;
helpUrl?: string;
action?: MessageAction;
}
};
type NotificationProps = DispatchProps & OwnProps & WithI18nProps;
function mapIntent(level: NotificationLevel): Intent {
switch (level) {
case Level.Error:
return Intent.DANGER;
case Level.Warning:
return Intent.WARNING;
case Level.Info:
return Intent.PRIMARY;
default:
return Intent.NONE;
}
}
function mapIcon(level: NotificationLevel): IconName | undefined {
switch (level) {
case Level.Error:
return 'error';
case Level.Warning:
return 'warning-sign';
case Level.Info:
return 'info-sign';
default:
return undefined;
}
}
class Notification extends React.Component<NotificationProps> {
render(): JSX.Element {
const {
action,
helpUrl,
i18n,
level,
message,
messageId,
onAction,
onClose,
replacements,
} = this.props;
return (
<Toast
onDismiss={(): void => onClose()}
timeout={0}
intent={mapIntent(level)}
icon={mapIcon(level)}
message={
<div>
<p>
{messageId
? i18n.translate(messageId, replacements)
: message || 'missing message!'}
</p>
{helpUrl && (
<p>
<a
href={helpUrl}
target="_blank"
rel="noopener noreferrer"
>
More info
</a>
</p>
)}
</div>
}
action={
action && {
text: i18n.translate(action.titleId),
onClick: (): void => onAction(action.action),
}
}
/>
);
}
}
const mapDispatchToProps = (dispatch: Dispatch, ownProps: OwnProps): DispatchProps => ({
onAction: (a): Action => dispatch(a),
onClose: (): Action => dispatch(remove(ownProps.id)),
});
export default connect(
null,
mapDispatchToProps,
)(
withI18n({
export default function Notification(props: OwnProps): JSX.Element {
const [i18n] = useI18n({
id: 'notification',
fallback: en,
translations: { en },
})(Notification),
);
fallback: en,
});
const { messageId, replacements } = props;
return <>{i18n.translate(messageId, replacements)}</>;
}
-42
View File
@@ -1,42 +0,0 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020 The Pybricks Authors
import { Toaster } from '@blueprintjs/core';
import React from 'react';
import { connect } from 'react-redux';
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 (
<Toaster>
{this.props.list.map((n) => (
<Notification
id={n.id}
key={n.id}
level={n.level}
message={n.message}
messageId={n.messageId}
replacements={n.replacements}
helpUrl={n.helpUrl}
action={n.action}
/>
))}
</Toaster>
);
}
}
const mapStateToProps = (state: RootState): StateProps => ({
list: state.notification.list,
});
export default connect(mapStateToProps)(NotificationStack);
+4 -1
View File
@@ -7,7 +7,10 @@
},
"editor": {
"programChanged": "The program was changed in another window. Do you want to delete this program and replace it with the new program?",
"yesReloadProgram": "Yes"
"yesReloadProgram": "Reload"
},
"mpy": {
"error": "{errorMessage}"
},
"serviceWorker": {
"success": "Content is cached for offline use.",
+1
View File
@@ -12,4 +12,5 @@ export enum MessageId {
ServiceWorkerSuccess = 'serviceWorker.success',
ServiceWorkerUpdate = 'serviceWorker.update',
YesReloadProgram = 'editor.yesReloadProgram',
MpyError = 'mpy.error',
}
+7 -6
View File
@@ -12,21 +12,23 @@ import createSagaMiddleware from 'redux-saga';
import './index.scss';
import { didSucceed, didUpdate } from './actions/service-worker';
import App from './components/App';
import NotificationStack from './components/NotificationStack';
import * as I18nToaster from './components/I18nToaster';
import rootReducer from './reducers';
import reportWebVitals from './reportWebVitals';
import rootSaga from './sagas';
import * as serviceWorkerRegistration from './serviceWorkerRegistration';
const sagaMiddleware = createSagaMiddleware();
// TODO: add runtime option or filter - logger affects firmware flash performance
const loggerMiddleware = createLogger({ predicate: () => false });
const i18n = new I18nManager({
locale: 'en',
onError: (err): void => console.error(err),
});
const toaster = I18nToaster.create(i18n);
const sagaMiddleware = createSagaMiddleware({ context: { notification: { toaster } } });
// TODO: add runtime option or filter - logger affects firmware flash performance
const loggerMiddleware = createLogger({ predicate: () => false });
const store = createStore(
rootReducer,
applyMiddleware(sagaMiddleware, loggerMiddleware),
@@ -69,7 +71,6 @@ ReactDOM.render(
>
<div id="vh" className="h-100 w-100 p-absolute" />
</ResizeSensor>
<NotificationStack />
<App />
</I18nContext.Provider>
</Provider>
-3
View File
@@ -8,7 +8,6 @@ import bootloader, { BootloaderState } from './bootloader';
import editor, { EditorState } from './editor';
import hub, { HubState } from './hub';
import license, { LicenseState } from './license';
import notification, { NotificationState } from './notification';
import settings, { SettingsState } from './settings';
import status, { StatusState } from './status';
import terminal, { TerminalState } from './terminal';
@@ -23,7 +22,6 @@ export interface RootState {
readonly editor: EditorState;
readonly hub: HubState;
readonly license: LicenseState;
readonly notification: NotificationState;
readonly settings: SettingsState;
readonly status: StatusState;
readonly terminal: TerminalState;
@@ -36,7 +34,6 @@ export default combineReducers({
editor,
hub,
license,
notification,
settings,
status,
terminal,
-169
View File
@@ -1,169 +0,0 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020 The Pybricks Authors
import { Replacements } from '@shopify/react-i18n';
import { Reducer } from 'react';
import { combineReducers } from 'redux';
import { Action } from '../actions';
import { BleDeviceActionType, BleDeviceFailToConnectReasonType } from '../actions/ble';
import { EditorActionType, reloadProgram } from '../actions/editor';
import {
BootloaderConnectionActionType,
BootloaderConnectionFailureReason,
} from '../actions/lwp3-bootloader';
import { MpyActionType } from '../actions/mpy';
import { NotificationActionType } from '../actions/notification';
import { ServiceWorkerActionType } from '../actions/service-worker';
import { MessageId } from '../components/notification-i18n';
import { createCountFunc } from '../utils/iter';
/**
* Severity level of notification.
*/
export enum Level {
/**
* This is an error (requires user action to resolve)
*/
Error = 'error',
/**
* This is a warning (user could take action or ignore)
*/
Warning = 'warning',
/**
* This is just FYI (no user action required)
*/
Info = 'info',
}
export interface MessageAction {
titleId: MessageId;
action: Action;
}
export interface Notification {
readonly id: number;
readonly level: Level;
readonly message?: string;
readonly messageId?: MessageId;
readonly replacements?: Replacements;
readonly helpUrl?: string;
readonly action?: MessageAction;
}
export type NotificationList = Array<Notification>;
const nextId = createCountFunc();
function append(
state: NotificationList,
level: Level,
messageId: MessageId,
replacements?: Replacements,
helpUrl?: string,
action?: MessageAction,
): NotificationList {
return [
...state,
{ id: nextId(), level, messageId, replacements, helpUrl, action },
];
}
const list: Reducer<NotificationList, Action> = (state = [], action) => {
switch (action.type) {
case BleDeviceActionType.DidFailToConnect:
switch (action.reason) {
case BleDeviceFailToConnectReasonType.NoGatt:
return append(state, Level.Error, MessageId.BleGattPermission);
case BleDeviceFailToConnectReasonType.NoService:
return append(
state,
Level.Error,
MessageId.BleGattServiceNotFound,
{ serviceName: 'Pybricks', hubName: 'Pybricks Hub' },
);
case BleDeviceFailToConnectReasonType.NoWebBluetooth:
return append(
state,
Level.Error,
MessageId.BleNoWebBluetooth,
undefined,
'https://github.com/WebBluetoothCG/web-bluetooth/blob/master/implementation-status.md',
);
case BleDeviceFailToConnectReasonType.Unknown:
return append(state, Level.Error, MessageId.BleConnectFailed);
}
return state;
case BootloaderConnectionActionType.DidFailToConnect:
switch (action.reason) {
case BootloaderConnectionFailureReason.GattServiceNotFound:
return append(
state,
Level.Error,
MessageId.BleGattServiceNotFound,
{ serviceName: 'LEGO Bootloader', hubName: 'LEGO Bootloader' },
);
case BootloaderConnectionFailureReason.NoWebBluetooth:
return append(
state,
Level.Error,
MessageId.BleNoWebBluetooth,
undefined,
'https://github.com/WebBluetoothCG/web-bluetooth/blob/master/implementation-status.md',
);
case BootloaderConnectionFailureReason.Unknown:
return append(state, Level.Error, MessageId.BleConnectFailed);
}
return state;
case EditorActionType.StorageChanged:
if (state.find((x) => x.messageId === MessageId.ProgramChanged)) {
// don't show message again if it is already shown
return state;
}
return append(
state,
Level.Info,
MessageId.ProgramChanged,
undefined,
undefined,
{
titleId: MessageId.YesReloadProgram,
action: reloadProgram(),
},
);
case MpyActionType.DidFailToCompile:
return [
...state,
{ id: nextId(), level: Level.Error, message: action.err },
];
case NotificationActionType.Add:
return [
...state,
{
id: action.id,
level: action.level as Level,
message: action.message,
helpUrl: action.helpUrl,
},
];
case NotificationActionType.Remove:
return state.filter((e) => e.id !== action.id);
case ServiceWorkerActionType.DidUpdate:
return append(
state,
Level.Info,
MessageId.ServiceWorkerUpdate,
undefined,
'https://github.com/pybricks/pybricks-code/issues/102',
);
case ServiceWorkerActionType.DidSucceed:
return append(state, Level.Info, MessageId.ServiceWorkerSuccess);
default:
return state;
}
};
export interface NotificationState {
readonly list: NotificationList;
}
export default combineReducers({ list });
+3 -1
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020 The Pybricks Authors
// Copyright (c) 2020-2021 The Pybricks Authors
import { all, put } from 'redux-saga/effects';
import { startup } from '../actions/app';
@@ -12,6 +12,7 @@ import license from './license';
import lwp3BootloaderBle from './lwp3-bootloader-ble';
import lwp3BootloaderProtocol from './lwp3-bootloader-protocol';
import mpy from './mpy';
import notification from './notification';
import settings from './settings';
import terminal from './terminal';
@@ -27,6 +28,7 @@ export default function* (): Generator {
hub(),
license(),
mpy(),
notification(),
settings(),
terminal(),
put(startup()),
+258
View File
@@ -0,0 +1,258 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2021 The Pybricks Authors
// Saga for managing notifications (toasts)
import {
IActionProps,
ILinkProps,
IToaster,
IconName,
Intent,
} from '@blueprintjs/core';
import { Replacements } from '@shopify/react-i18n';
import React from 'react';
import { channel } from 'redux-saga';
import { delay, getContext, put, take, takeEvery } from 'redux-saga/effects';
import {
BleDeviceActionType,
BleDeviceDidFailToConnectAction,
BleDeviceFailToConnectReasonType,
} from '../actions/ble';
import { EditorActionType, reloadProgram } from '../actions/editor';
import {
BootloaderConnectionActionType,
BootloaderConnectionDidFailToConnectAction,
BootloaderConnectionFailureReason,
} from '../actions/lwp3-bootloader';
import { MpyActionType, MpyDidFailToCompileAction } from '../actions/mpy';
import { NotificationActionType, NotificationAddAction } from '../actions/notification';
import { ServiceWorkerActionType } from '../actions/service-worker';
import Notification from '../components/Notification';
import { MessageId } from '../components/notification-i18n';
type NotificationContext = {
toaster: IToaster;
};
/** Severity level of notification. */
enum Level {
/** This is an error (requires user action to resolve). */
Error = 'error',
/** This is a warning (user could take action or ignore). */
Warning = 'warning',
/** This is just FYI (no user action required). */
Info = 'info',
}
function mapIntent(level: Level): Intent {
switch (level) {
case Level.Error:
return Intent.DANGER;
case Level.Warning:
return Intent.WARNING;
case Level.Info:
return Intent.PRIMARY;
default:
return Intent.NONE;
}
}
function mapIcon(level: Level): IconName | undefined {
switch (level) {
case Level.Error:
return 'error';
case Level.Warning:
return 'warning-sign';
case Level.Info:
return 'info-sign';
default:
return undefined;
}
}
/**
* Converts a URL to an action that can be passed to `IToaster.show()`.
* @param helpUrl A URL.
*/
function helpAction(helpUrl: string): IActionProps & ILinkProps {
return {
icon: 'help',
href: helpUrl,
target: '_blank',
};
}
function dispatchAction(
messageId: MessageId,
onClick: (event: React.MouseEvent<HTMLElement>) => void,
icon?: IconName,
): IActionProps {
return {
icon: icon,
text: React.createElement(Notification, { messageId }),
onClick,
};
}
/**
* Shows a message. If a message with the same `messageId` is already
* showing, it will be closed before showing the new message.
* @param level The severity level.
* @param messageId The translation lookup ID.
* @param replacements Replacements for the translation string.
* @param action Optional action to add to the notification.
* @param onDismiss Optional hook for when notification is dismissed.
*/
function* showSingleton(
level: Level,
messageId: MessageId,
replacements?: Replacements,
action?: IActionProps & ILinkProps,
onDismiss?: (didTimeoutExpire: boolean) => void,
): Generator {
const { toaster } = (yield getContext('notification')) as NotificationContext;
// if the message is already showing, close it and wait some time so that
// users can see that something triggered the message again
if (
toaster
.getToasts()
.map((x) => x.key)
.includes(messageId)
) {
toaster.dismiss(messageId);
yield delay(500);
}
toaster.show(
{
intent: mapIntent(level),
icon: mapIcon(level),
message: React.createElement(Notification, { messageId, replacements }),
timeout: 0,
action,
onDismiss,
},
messageId,
);
}
function* showBleDeviceDidFailToConnectError(
action: BleDeviceDidFailToConnectAction,
): Generator {
switch (action.reason) {
case BleDeviceFailToConnectReasonType.NoGatt:
yield* showSingleton(Level.Error, MessageId.BleGattPermission);
break;
case BleDeviceFailToConnectReasonType.NoService:
yield* showSingleton(Level.Error, MessageId.BleGattServiceNotFound, {
serviceName: 'Pybricks',
hubName: 'Pybricks Hub',
});
break;
case BleDeviceFailToConnectReasonType.NoWebBluetooth:
yield* showSingleton(
Level.Error,
MessageId.BleNoWebBluetooth,
undefined,
helpAction(
'https://github.com/WebBluetoothCG/web-bluetooth/blob/master/implementation-status.md',
),
);
break;
case BleDeviceFailToConnectReasonType.Unknown:
yield* showSingleton(Level.Error, MessageId.BleConnectFailed);
break;
}
}
function* showBootloaderDidFailToConnectError(
action: BootloaderConnectionDidFailToConnectAction,
): Generator {
switch (action.reason) {
case BootloaderConnectionFailureReason.GattServiceNotFound:
yield* showSingleton(Level.Error, MessageId.BleGattServiceNotFound, {
serviceName: 'LEGO Bootloader',
hubName: 'LEGO Bootloader',
});
break;
case BootloaderConnectionFailureReason.NoWebBluetooth:
yield* showSingleton(
Level.Error,
MessageId.BleNoWebBluetooth,
undefined,
helpAction(
'https://github.com/WebBluetoothCG/web-bluetooth/blob/master/implementation-status.md',
),
);
break;
case BootloaderConnectionFailureReason.Unknown:
yield* showSingleton(Level.Error, MessageId.BleConnectFailed);
break;
}
}
function* showEditorStorageChanged(): Generator {
const ch = channel<React.MouseEvent<HTMLElement>>();
yield* showSingleton(
Level.Info,
MessageId.ProgramChanged,
undefined,
dispatchAction(MessageId.YesReloadProgram, ch.put, 'tick'),
ch.close,
);
// if the notification is dismissed without clicking on the action, the
// saga will be cancelled here
yield take(ch);
yield put(reloadProgram());
}
function* showCompilerError(action: MpyDidFailToCompileAction): Generator {
yield* showSingleton(Level.Error, MessageId.MpyError, { errorMessage: action.err });
}
function* addNotification(action: NotificationAddAction): Generator {
const { toaster } = (yield getContext('notification')) as NotificationContext;
toaster.show({
intent: mapIntent(action.level as Level),
icon: mapIcon(action.level as Level),
message: action.message,
timeout: 0,
action: action.helpUrl ? helpAction(action.helpUrl) : undefined,
});
}
function* showServiceWorkerUpdate(): Generator {
yield* showSingleton(
Level.Info,
MessageId.ServiceWorkerUpdate,
undefined,
helpAction('https://github.com/pybricks/pybricks-code/issues/102'),
);
}
function* showServiceWorkerSuccess(): Generator {
yield* showSingleton(Level.Info, MessageId.ServiceWorkerSuccess);
}
export default function* (): Generator {
yield takeEvery(
BleDeviceActionType.DidFailToConnect,
showBleDeviceDidFailToConnectError,
);
yield takeEvery(
BootloaderConnectionActionType.DidFailToConnect,
showBootloaderDidFailToConnectError,
);
yield takeEvery(EditorActionType.StorageChanged, showEditorStorageChanged);
yield takeEvery(MpyActionType.DidFailToCompile, showCompilerError);
yield takeEvery(NotificationActionType.Add, addNotification);
yield takeEvery(ServiceWorkerActionType.DidUpdate, showServiceWorkerUpdate);
yield takeEvery(ServiceWorkerActionType.DidSucceed, showServiceWorkerSuccess);
}