rework alerts

This starts moving alerts to the same subsystem where they are relevant
instead of putting everything in notifications.

So far, only the explorer file in use error is handled like this.
This commit is contained in:
David Lechner
2022-05-20 19:25:18 -05:00
parent 4e88605b16
commit e7366c8afd
31 changed files with 649 additions and 138 deletions
-36
View File
@@ -1,36 +0,0 @@
// 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;
}
@@ -1,10 +0,0 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
@use '@blueprintjs/core/lib/scss/variables' as bp;
pre.pb-notification-stack-trace {
max-width: bp.$pt-grid-size * 50;
max-height: bp.$pt-grid-size * 50;
overflow: auto;
}
@@ -1,71 +0,0 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021-2022 The Pybricks Authors
// Provides special notification contents for unexpected errors.
import './UnexpectedErrorNotification.scss';
import { AnchorButton, Button, ButtonGroup, Collapse, Intent } from '@blueprintjs/core';
import { useI18n } from '@shopify/react-i18n';
import React, { useState } from 'react';
import { useId } from 'react-aria';
import { I18nId } from './i18n';
type UnexpectedErrorNotificationProps = {
messageId: I18nId;
err: Error;
};
const UnexpectedErrorNotification: React.VoidFunctionComponent<
UnexpectedErrorNotificationProps
> = ({ messageId, err }) => {
// istanbul ignore next: babel-loader rewrites this line
const [i18n] = useI18n();
const [isExpanded, setIsExpanded] = useState(false);
const labelId = useId();
return (
<>
<p>{i18n.translate(messageId, { errorMessage: err.message })}</p>
<span>
<Button
aria-labelledby={labelId}
minimal={true}
small={true}
icon={isExpanded ? 'chevron-down' : 'chevron-right'}
onClick={() => setIsExpanded((v) => !v)}
/>
<span id={labelId}>{i18n.translate(I18nId.TechnicalInfo)}</span>
</span>
<Collapse isOpen={isExpanded}>
<pre className="pb-notification-stack-trace">{err.stack}</pre>
</Collapse>
<div>
<ButtonGroup minimal={true} fill={true}>
<Button
intent={Intent.DANGER}
icon="duplicate"
onClick={() =>
navigator.clipboard.writeText(
`\`\`\`\n${err.stack || err.message}\n\`\`\``,
)
}
>
{i18n.translate(I18nId.CopyErrorMessage)}
</Button>
<AnchorButton
intent={Intent.DANGER}
icon="virus"
href={`https://github.com/pybricks/support/issues?q=${encodeURIComponent(
'is:issue',
)}+${encodeURIComponent(err.message)}`}
target="_blank"
>
{i18n.translate(I18nId.ReportBug)}
</AnchorButton>
</ButtonGroup>
</div>
</>
);
};
export default UnexpectedErrorNotification;
-3
View File
@@ -5,9 +5,6 @@
export enum I18nId {
AppNoUpdateFound = 'app.noUpdateFound',
CopyErrorMessage = 'copyErrorMessage',
TechnicalInfo = 'technicalInfo',
ReportBug = 'reportBug',
BleUnexpectedError = 'ble.unexpectedError',
BleGattPermission = 'ble.gattPermission',
BleGattServiceNotFound = 'ble.gattServiceNotFound',
+2 -2
View File
@@ -32,6 +32,7 @@ import {
MetadataProblem,
didFailToFinish,
} from '../firmware/actions';
import * as i18nToaster from '../i18nToaster';
import {
BootloaderConnectionFailureReason,
didFailToConnect as bootloaderDidFailToConnect,
@@ -41,14 +42,13 @@ import {
serviceWorkerDidSucceed,
serviceWorkerDidUpdate,
} from '../service-worker/actions';
import * as I18nToaster from './I18nToaster';
import { add } from './actions';
import { I18nId } from './i18n';
import notification from './sagas';
function createTestToasterSaga(): { toaster: IToaster; saga: AsyncSaga } {
const i18n = new I18nManager({ locale: 'en' });
const toaster = I18nToaster.create(i18n);
const toaster = i18nToaster.create(i18n);
jest.spyOn(toaster, 'clear');
jest.spyOn(toaster, 'dismiss');
+15 -10
View File
@@ -10,6 +10,7 @@ import React from 'react';
import { channel } from 'redux-saga';
import * as semver from 'semver';
import { delay, getContext, put, take, takeEvery } from 'typed-redux-saga/macro';
import { getAlertProps } from '../alerts';
import { appDidCheckForUpdate, appReload } from '../app/actions';
import { appName } from '../app/constants';
import { bleDIServiceDidReceiveFirmwareRevision } from '../ble-device-info-service/actions';
@@ -18,6 +19,7 @@ import {
didFailToConnect as bleDeviceDidFailToConnect,
} from '../ble/actions';
import { editorDidFailToOpenFile } from '../editor/actions';
import { EditorError } from '../editor/error';
import {
explorerDidFailToArchiveAllFiles,
explorerDidFailToCreateNewFile,
@@ -37,7 +39,6 @@ import { serviceWorkerDidUpdate } from '../service-worker/actions';
import { pythonVersionToSemver } from '../utils/version';
import NotificationAction from './NotificationAction';
import NotificationMessage from './NotificationMessage';
import UnexpectedErrorNotification from './UnexpectedErrorNotification';
import { add as addNotification } from './actions';
import { I18nId } from './i18n';
@@ -159,14 +160,15 @@ function* showSingleton(
}
/** Shows a special notification for unexpected errors. */
function* showUnexpectedError(messageId: I18nId, err: Error): Generator {
function* showUnexpectedError(messageId: I18nId, error: Error): Generator {
const { toaster } = yield* getContext<NotificationContext>('notification');
toaster.show({
intent: mapIntent(Level.Error),
icon: mapIcon(Level.Error),
message: React.createElement(UnexpectedErrorNotification, { messageId, err }),
timeout: 0,
});
const key = `alerts.unexpectedError.${messageId}`;
toaster.show(
getAlertProps('alerts', 'unexpectedError', () => toaster.dismiss(key), {
error,
}),
);
}
function* showBleDeviceDidFailToConnectError(
@@ -441,9 +443,12 @@ function* showExplorerFailToExport(
}
function* showEditorDidFailToOpenFile(
action: ReturnType<typeof explorerDidFailToExportFile>,
action: ReturnType<typeof editorDidFailToOpenFile>,
): Generator {
// TODO: add a better error message for the case where a file is already in use
if (action.error instanceof EditorError && action.error.name === 'FileInUse') {
return;
}
yield* showUnexpectedError(I18nId.EditorFailedToOpenFile, action.error);
}
-3
View File
@@ -1,7 +1,4 @@
{
"copyErrorMessage": "Copy Error Message",
"technicalInfo": "Expand for detailed technical information",
"reportBug": "Report Bug",
"app": {
"noUpdateFound": "{appName} is already up to date."
},