Merge pull request #252 from pybricks/dlech

updates
This commit is contained in:
David Lechner
2021-01-18 19:05:14 -06:00
committed by GitHub
26 changed files with 524 additions and 401 deletions
+1
View File
@@ -0,0 +1 @@
REACT_APP_VERSION=$npm_package_version
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@pybricks/pybricks-code",
"version": "0.1.0",
"version": "1.0.0-beta.1",
"license": "MIT",
"author": "The Pybricks Authors",
"repository": {
+16 -5
View File
@@ -7,8 +7,10 @@ import { Action } from 'redux';
/** App action types. */
export enum AppActionType {
/** Reload the app. */
Reload = 'app.action.reload',
/** The app has just ben started. */
Startup = 'app.action.startup',
DidStart = 'app.action.didStart',
/** Open settings dialog. */
OpenSettings = 'app.action.openSettings',
/** Close settings dialog. */
@@ -23,12 +25,20 @@ export enum AppActionType {
CloseLicenseDialog = 'app.action.closeLicenseDialog',
}
/** Action that requests the app to reload. */
export type AppReloadAction = Action<AppActionType.Reload>;
/** Creates an action that requests the app to reload. */
export function reload(): AppReloadAction {
return { type: AppActionType.Reload };
}
/** Action that indicates the app has just started. */
export type AppStartupAction = Action<AppActionType.Startup>;
export type AppDidStartAction = Action<AppActionType.DidStart>;
/** Creates an action that indicates the app has just started. */
export function startup(): AppStartupAction {
return { type: AppActionType.Startup };
export function didStart(): AppDidStartAction {
return { type: AppActionType.DidStart };
}
/** Action to open the settings dialog. */
@@ -81,7 +91,8 @@ export function closeLicenseDialog(): AppCloseLicenseDialogAction {
/** common type for all app actions. */
export type AppAction =
| AppStartupAction
| AppReloadAction
| AppDidStartAction
| AppOpenSettingsAction
| AppCloseSettingsAction
| AppOpenAboutDialogAction
+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;
+7 -1
View File
@@ -23,6 +23,8 @@ import en from './about-i18n.en.json';
import './about.scss';
const version = process.env.REACT_APP_VERSION;
type StateProps = { showAboutDialog: boolean };
type DispatchProps = { onClose: () => void; onLicenseButtonClick: () => void };
@@ -33,7 +35,11 @@ class AboutDialog extends React.Component<AboutDialogProps> {
render(): JSX.Element {
const { i18n, showAboutDialog, onClose, onLicenseButtonClick } = this.props;
return (
<Dialog title={appName} isOpen={showAboutDialog} onClose={() => onClose()}>
<Dialog
title={`${appName} v${version}`}
isOpen={showAboutDialog}
onClose={() => onClose()}
>
<div className={Classes.DIALOG_BODY}>
<div className="pb-about-icon">
<img src="favicon.ico" />
+1 -1
View File
@@ -78,7 +78,7 @@ class Editor extends React.Component<EditorProps> {
render(): JSX.Element {
const { darkMode, i18n, onSessionChanged } = this.props;
return (
<div className="h-100 watermark">
<div className="h-100">
<ResizeSensor onResize={(): void => this.editor?.resize()}>
<AceEditor
ref={this.editorRef}
+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);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"about": {
"description": "A simple web app for programming LEGO® Powered Up smart hubs using Pybricks MicroPython.",
"description": "MicroPython for LEGO® Powered Up smart hubs.",
"licenseButton": { "label": "Open Source Licenses" },
"websiteButton": { "label": "Pybricks Website" }
}
+5 -3
View File
@@ -5,17 +5,19 @@
@import '../variables.scss';
// make ace editor match app backgound color
.#{$ns}-dark .ace_gutter {
// make ace editor match app backgound color
background-color: $pt-dark-app-background-color;
}
.ace_gutter {
// make ace editor match app backgound color
background-color: $pt-app-background-color;
}
.watermark::after {
// add "BETA" watermark
.pb-beta .ace_scroller::after {
content: "";
background: url("./images/beta.svg");
opacity: 1;
+11 -4
View File
@@ -6,11 +6,18 @@
"connectFailed": "Unexpected error while trying to connect. Check console log and report the error."
},
"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"
"programChanged": {
"message": "The program was changed in another window. Do you want to delete this program and replace it with the new program?",
"action": "Reload"
}
},
"mpy": {
"error": "{errorMessage}"
},
"serviceWorker": {
"success": "Content is cached for offline use.",
"update": "New content is available and will be used when all tabs for this page are closed.'"
"update": {
"message": "A new version of {appName} is available. Click {action} to start using the new version.",
"action": "Restart"
}
}
}
+5 -4
View File
@@ -8,8 +8,9 @@ export enum MessageId {
BleGattPermission = 'ble.gattPermission',
BleGattServiceNotFound = 'ble.gattServiceNotFound',
BleNoWebBluetooth = 'ble.noWebBluetooth',
ProgramChanged = 'editor.programChanged',
ServiceWorkerSuccess = 'serviceWorker.success',
ServiceWorkerUpdate = 'serviceWorker.update',
YesReloadProgram = 'editor.yesReloadProgram',
ProgramChangedMessage = 'editor.programChanged.message',
ProgramChangedAction = 'editor.programChanged.action',
ServiceWorkerUpdateMessage = 'serviceWorker.update.message',
ServiceWorkerUpdateAction = 'serviceWorker.update.action',
MpyError = 'mpy.error',
}
+5
View File
@@ -12,6 +12,7 @@
body {
// no scrolling of the page
overflow: hidden;
user-select: none;
}
// Utility classes
@@ -39,6 +40,10 @@ body {
// global style tweaks
.#{$ns}-dialog {
user-select: none;
}
.#{$ns}-form-group > .#{$ns}-label {
font-weight: bolder;
}
+12 -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),
@@ -46,6 +48,11 @@ store.subscribe(() => {
}
});
// special styling for beta versions
if (process.env.REACT_APP_VERSION?.match(/beta/)) {
document.body.classList.add('pb-beta');
}
sagaMiddleware.run(rootSaga);
ReactDOM.render(
@@ -64,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 });
+25
View File
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021 The Pybricks Authors
import { call, takeEvery } from 'redux-saga/effects';
import { AppActionType } from '../actions/app';
function* reload(): Generator {
console.log('reload');
// unregister the service worker so that when the page reloads, it uses
// the new version
const registrations = (yield call(() =>
navigator.serviceWorker.getRegistrations(),
)) as ServiceWorkerRegistration[];
for (const r of registrations) {
yield call(() => r.unregister());
}
location.reload();
}
export default function* app(): Generator {
yield takeEvery(AppActionType.Reload, reload);
}
+7 -3
View File
@@ -1,8 +1,9 @@
// 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';
import { didStart } from '../actions/app';
import app from './app';
import bleUart from './ble-uart';
import editor from './editor';
import errorLog from './error-log';
@@ -12,12 +13,14 @@ 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';
/* istanbul ignore next */
export default function* (): Generator {
yield all([
app(),
bleUart(),
lwp3BootloaderBle(),
lwp3BootloaderProtocol(),
@@ -27,8 +30,9 @@ export default function* (): Generator {
hub(),
license(),
mpy(),
notification(),
settings(),
terminal(),
put(startup()),
put(didStart()),
]);
}
+88
View File
@@ -0,0 +1,88 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021 The Pybricks Authors
import { IToaster } from '@blueprintjs/core';
import { AsyncSaga } from '../../test';
import { Action } from '../actions';
import {
BleDeviceFailToConnectReasonType,
didFailToConnect as bleDidFailToConnect,
} from '../actions/ble';
import { storageChanged } from '../actions/editor';
import {
BootloaderConnectionFailureReason,
didFailToConnect as bootloaderDidFailToConnect,
} from '../actions/lwp3-bootloader';
import { didFailToCompile } from '../actions/mpy';
import { add } from '../actions/notification';
import { didSucceed, didUpdate } from '../actions/service-worker';
import notification from './notification';
test.each([
bleDidFailToConnect({ reason: BleDeviceFailToConnectReasonType.NoWebBluetooth }),
bleDidFailToConnect({ reason: BleDeviceFailToConnectReasonType.NoGatt }),
bleDidFailToConnect({ reason: BleDeviceFailToConnectReasonType.NoService }),
bleDidFailToConnect({
reason: BleDeviceFailToConnectReasonType.Unknown,
err: { name: 'test', message: 'unknown' },
}),
bootloaderDidFailToConnect(BootloaderConnectionFailureReason.Unknown),
bootloaderDidFailToConnect(BootloaderConnectionFailureReason.NoWebBluetooth),
bootloaderDidFailToConnect(BootloaderConnectionFailureReason.GattServiceNotFound),
storageChanged('test'),
didFailToCompile('reason'),
add('warning', 'message'),
add('error', 'message', 'url'),
didUpdate({} as ServiceWorkerRegistration),
])('actions that should show notification: %o', async (action: Action) => {
const getToasts = jest.fn().mockReturnValue([]);
const show = jest.fn();
const dismiss = jest.fn();
const clear = jest.fn();
const toaster: IToaster = {
getToasts,
show,
dismiss,
clear,
};
const saga = new AsyncSaga(notification, { notification: { toaster } });
saga.put(action);
expect(show).toBeCalled();
expect(dismiss).not.toBeCalled();
expect(clear).not.toBeCalled();
await saga.end();
});
test.each([
bleDidFailToConnect({ reason: BleDeviceFailToConnectReasonType.Canceled }),
bootloaderDidFailToConnect(BootloaderConnectionFailureReason.Canceled),
didSucceed({} as ServiceWorkerRegistration),
])('actions that should not show a notification: %o', async (action: Action) => {
const getToasts = jest.fn().mockReturnValue([]);
const show = jest.fn();
const dismiss = jest.fn();
const clear = jest.fn();
const toaster: IToaster = {
getToasts,
show,
dismiss,
clear,
};
const saga = new AsyncSaga(notification, { notification: { toaster } });
saga.put(action);
expect(getToasts).not.toBeCalled();
expect(show).not.toBeCalled();
expect(dismiss).not.toBeCalled();
expect(clear).not.toBeCalled();
await saga.end();
});
+269
View File
@@ -0,0 +1,269 @@
// 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 { reload } from '../actions/app';
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';
import { appName } from '../settings/ui';
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.ProgramChangedMessage,
undefined,
dispatchAction(MessageId.ProgramChangedAction, 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 {
const ch = channel<React.MouseEvent<HTMLElement>>();
const action = dispatchAction(
MessageId.ServiceWorkerUpdateAction,
ch.put,
'refresh',
);
yield* showSingleton(
Level.Info,
MessageId.ServiceWorkerUpdateMessage,
{
appName,
action: React.createElement('strong', undefined, action.text),
},
action,
ch.close,
);
yield take(ch);
yield put(reload());
}
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);
}
+10 -10
View File
@@ -4,7 +4,7 @@
// Tests for settings sagas.
import { AsyncSaga } from '../../test';
import { startup } from '../actions/app';
import { didStart } from '../actions/app';
import { didBooleanChange, didFailToSetBoolean, setBoolean } from '../actions/settings';
import { SettingsState } from '../reducers/settings';
import { SettingId } from '../settings/user';
@@ -25,7 +25,7 @@ describe('startup', () => {
).mockReturnValue(null);
innerWidth = 1024;
saga.put(startup());
saga.put(didStart());
// does nothing
@@ -41,7 +41,7 @@ describe('startup', () => {
).mockReturnValue(null);
innerWidth = 800;
saga.put(startup());
saga.put(didStart());
// does nothing
@@ -64,7 +64,7 @@ describe('startup', () => {
});
innerWidth = 1024;
saga.put(startup());
saga.put(didStart());
// does nothing
@@ -87,7 +87,7 @@ describe('startup', () => {
});
innerWidth = 800;
saga.put(startup());
saga.put(didStart());
// requests documentation to be shown
const action = await saga.take();
@@ -112,7 +112,7 @@ describe('startup', () => {
});
innerWidth = 1024;
saga.put(startup());
saga.put(didStart());
// requests documentation to be hidden
const action = await saga.take();
@@ -137,7 +137,7 @@ describe('startup', () => {
});
innerWidth = 800;
saga.put(startup());
saga.put(didStart());
// does nothing
@@ -154,7 +154,7 @@ describe('startup', () => {
'getItem',
).mockReturnValue(null);
saga.put(startup());
saga.put(didStart());
// does nothing
@@ -176,7 +176,7 @@ describe('startup', () => {
}
});
saga.put(startup());
saga.put(didStart());
// requests to enable dark mode
const action = await saga.take();
@@ -200,7 +200,7 @@ describe('startup', () => {
}
});
saga.put(startup());
saga.put(didStart());
// does nothing
+1 -1
View File
@@ -101,6 +101,6 @@ function* storeSetting(action: SettingsSetBooleanAction): Generator {
export default function* (): Generator {
yield fork(monitorLocalStorage);
yield takeEvery(AppActionType.Startup, loadSettings);
yield takeEvery(AppActionType.DidStart, loadSettings);
yield takeEvery(SettingsActionType.SetBoolean, storeSetting);
}
+2 -2
View File
@@ -3,7 +3,7 @@
import { AsyncSaga, delay } from '../../test';
import { startup } from '../actions/app';
import { didStart } from '../actions/app';
import {
BleUartActionType,
BleUartWriteAction,
@@ -295,7 +295,7 @@ describe('Data receiver filters out hub status', () => {
test('Terminal data source responds to send data actions', async () => {
const saga = new AsyncSaga(terminal);
saga.put(startup());
saga.put(didStart());
const dataSourceAction = await saga.take();
expect(dataSourceAction.type).toBe(TerminalActionType.SetDataSource);
+3 -3
View File
@@ -14,7 +14,7 @@ import {
} from 'redux-saga/effects';
import PushStream from 'zen-push';
import { Action } from '../actions';
import { AppActionType, AppStartupAction } from '../actions/app';
import { AppActionType, AppDidStartAction } from '../actions/app';
import {
BleUartActionType,
BleUartNotifyAction,
@@ -36,7 +36,7 @@ const encoder = new TextEncoder();
const decoder = new TextDecoder();
const terminalDataSource = new PushStream<string>();
function* startup(_action: AppStartupAction): Generator {
function* startup(_action: AppDidStartAction): Generator {
yield put(setDataSource(terminalDataSource.observable));
}
@@ -146,7 +146,7 @@ function sendTerminalData(action: TerminalDataReceiveDataAction): void {
}
export default function* (): Generator {
yield takeEvery(AppActionType.Startup, startup);
yield takeEvery(AppActionType.DidStart, startup);
yield takeEvery(BleUartActionType.Notify, receiveUartData);
yield fork(receiveTerminalData);
yield takeEvery(TerminalActionType.SendData, sendTerminalData);
+2 -1
View File
@@ -12,7 +12,7 @@ export class AsyncSaga {
private state: Partial<RootState>;
private task: Task;
public constructor(saga: Saga) {
public constructor(saga: Saga, context?: Record<string, unknown>) {
this.channel = stdChannel();
this.dispatches = [];
this.takers = [];
@@ -25,6 +25,7 @@ export class AsyncSaga {
onError: (e, _i): void => {
throw e;
},
context,
},
saga,
);