diff --git a/src/actions/notification.ts b/src/actions/notification.ts index 9ce438b3..cf2def5f 100644 --- a/src/actions/notification.ts +++ b/src/actions/notification.ts @@ -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 & { - /** - * Unique ID for this notification instance. - */ - readonly id: number; /** * The type of notification. */ @@ -36,17 +25,6 @@ export type NotificationAddAction = Action & { readonly helpUrl?: string; }; -export type NotificationRemoveAction = Action & { - /** - * 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; diff --git a/src/components/I18nToaster.tsx b/src/components/I18nToaster.tsx new file mode 100644 index 00000000..d7094575 --- /dev/null +++ b/src/components/I18nToaster.tsx @@ -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(); + + ReactDOM.render( + + + , + 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; +} diff --git a/src/components/Notification.tsx b/src/components/Notification.tsx index 7f87cb3f..7a2edbee 100644 --- a/src/components/Notification.tsx +++ b/src/components/Notification.tsx @@ -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 { - render(): JSX.Element { - const { - action, - helpUrl, - i18n, - level, - message, - messageId, - onAction, - onClose, - replacements, - } = this.props; - return ( - onClose()} - timeout={0} - intent={mapIntent(level)} - icon={mapIcon(level)} - message={ -
-

- {messageId - ? i18n.translate(messageId, replacements) - : message || 'missing message!'} -

- {helpUrl && ( -

- - More info - -

- )} -
- } - 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)}; +} diff --git a/src/components/NotificationStack.tsx b/src/components/NotificationStack.tsx deleted file mode 100644 index 415a2809..00000000 --- a/src/components/NotificationStack.tsx +++ /dev/null @@ -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 { - 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/notification-i18n.en.json b/src/components/notification-i18n.en.json index 197d5a7d..bdf93b8a 100644 --- a/src/components/notification-i18n.en.json +++ b/src/components/notification-i18n.en.json @@ -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.", diff --git a/src/components/notification-i18n.ts b/src/components/notification-i18n.ts index 9d4fe68c..75192cd5 100644 --- a/src/components/notification-i18n.ts +++ b/src/components/notification-i18n.ts @@ -12,4 +12,5 @@ export enum MessageId { ServiceWorkerSuccess = 'serviceWorker.success', ServiceWorkerUpdate = 'serviceWorker.update', YesReloadProgram = 'editor.yesReloadProgram', + MpyError = 'mpy.error', } diff --git a/src/index.tsx b/src/index.tsx index 3fb2796a..ba9e496e 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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( >
- diff --git a/src/reducers/index.ts b/src/reducers/index.ts index 940b7bf4..25c65740 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -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, diff --git a/src/reducers/notification.ts b/src/reducers/notification.ts deleted file mode 100644 index 690fa1ab..00000000 --- a/src/reducers/notification.ts +++ /dev/null @@ -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; - -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 = (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 }); diff --git a/src/sagas/index.ts b/src/sagas/index.ts index 7682125d..fc2b5626 100644 --- a/src/sagas/index.ts +++ b/src/sagas/index.ts @@ -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()), diff --git a/src/sagas/notification.ts b/src/sagas/notification.ts new file mode 100644 index 00000000..aafec817 --- /dev/null +++ b/src/sagas/notification.ts @@ -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) => 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>(); + + 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); +}