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
+84
View File
@@ -0,0 +1,84 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { IToastProps } from '@blueprintjs/core';
import alerts from './alerts/alerts';
import explorer from './explorer/alerts';
import { CreateToast } from './i18nToaster';
/** This collects alerts from all of the subsystems of the app */
const alertDomains = {
alerts,
explorer,
};
/** Gets the type of available alert domains. */
export type AlertDomain = keyof typeof alertDomains;
/**
* Gets the type of available specific alerts for a domain.
* @template D The domain.
*/
export type AlertSpecific<D extends AlertDomain> = keyof typeof alertDomains[D];
/**
* Gets the instance type of the object in the lookup table.
* @template D The domain.
* @template S The specific instance name in the domain.
*/
type AlertInstance<
D extends AlertDomain,
S extends AlertSpecific<D>,
> = typeof alertDomains[D][S] extends CreateToast<infer P, infer A>
? CreateToast<P, A>
: never;
/**
* Gets the type of the `onAlert` callback for a specific instance in the lookup table.
* @template D The domain.
* @template S The specific instance name in the domain.
*/
export type AlertCallback<
D extends AlertDomain,
S extends AlertSpecific<D>,
> = Parameters<AlertInstance<D, S>>[0];
/**
* Gets the type of available actions for a specific instance in the lookup table.
* @template D The domain.
* @template S The specific instance name in the domain.
*/
export type AlertActions<D extends AlertDomain, S extends AlertSpecific<D>> =
| Parameters<Parameters<AlertInstance<D, S>>[0]>[0];
/**
* Gets the type of the properties for a specific instance in the lookup table.
* @template D The domain.
* @template S The specific instance name in the domain.
*/
export type AlertProps<D extends AlertDomain, S extends AlertSpecific<D>> = Parameters<
AlertInstance<D, S>
>[1];
/**
* Gets the alert creation function from the lookup table and uses it to create
* a new alert (toast).
*
* @param domain The alert domain (app subsystem).
* @param specific The specific alert for the domain.
* @param onAlert The callback that will be called when the alert is dismissed.
* @param props Any additional properties required by this specific alert.
* @returns The newly created alert properties.
*/
export function getAlertProps<D extends AlertDomain, S extends AlertSpecific<D>>(
domain: D,
specific: S,
onAlert: AlertCallback<D, S>,
props: AlertProps<D, S>,
): IToastProps {
const create = alertDomains[domain][specific] as unknown as CreateToast<
Record<string, unknown> | never,
string
>;
return create(onAlert, props);
}
@@ -3,7 +3,7 @@
@use '@blueprintjs/core/lib/scss/variables' as bp;
pre.pb-notification-stack-trace {
pre.pb-alerts-stack-trace {
max-width: bp.$pt-grid-size * 50;
max-height: bp.$pt-grid-size * 50;
overflow: auto;
@@ -1,23 +1,21 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021-2022 The Pybricks Authors
// Copyright (c) 2022 The Pybricks Authors
// Provides special notification contents for unexpected errors.
import './UnexpectedErrorNotification.scss';
import './UnexpectedErrorAlert.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 { CreateToast } from '../i18nToaster';
import { I18nId } from './i18n';
type UnexpectedErrorNotificationProps = {
messageId: I18nId;
err: Error;
type UnexpectedErrorAlertProps = {
error: Error;
};
const UnexpectedErrorNotification: React.VoidFunctionComponent<
UnexpectedErrorNotificationProps
> = ({ messageId, err }) => {
const UnexpectedErrorAlert: React.VoidFunctionComponent<UnexpectedErrorAlertProps> = ({
error,
}) => {
// istanbul ignore next: babel-loader rewrites this line
const [i18n] = useI18n();
const [isExpanded, setIsExpanded] = useState(false);
@@ -25,7 +23,7 @@ const UnexpectedErrorNotification: React.VoidFunctionComponent<
return (
<>
<p>{i18n.translate(messageId, { errorMessage: err.message })}</p>
<p>{i18n.translate(I18nId.Message, { errorMessage: error.message })}</p>
<span>
<Button
aria-labelledby={labelId}
@@ -37,7 +35,7 @@ const UnexpectedErrorNotification: React.VoidFunctionComponent<
<span id={labelId}>{i18n.translate(I18nId.TechnicalInfo)}</span>
</span>
<Collapse isOpen={isExpanded}>
<pre className="pb-notification-stack-trace">{err.stack}</pre>
<pre className="pb-alerts-stack-trace">{error.stack}</pre>
</Collapse>
<div>
<ButtonGroup minimal={true} fill={true}>
@@ -46,7 +44,7 @@ const UnexpectedErrorNotification: React.VoidFunctionComponent<
icon="duplicate"
onClick={() =>
navigator.clipboard.writeText(
`\`\`\`\n${err.stack || err.message}\n\`\`\``,
`\`\`\`\n${error.stack || error.message}\n\`\`\``,
)
}
>
@@ -57,7 +55,7 @@ const UnexpectedErrorNotification: React.VoidFunctionComponent<
icon="virus"
href={`https://github.com/pybricks/support/issues?q=${encodeURIComponent(
'is:issue',
)}+${encodeURIComponent(err.message)}`}
)}+${encodeURIComponent(error.message)}`}
target="_blank"
>
{i18n.translate(I18nId.ReportBug)}
@@ -68,4 +66,11 @@ const UnexpectedErrorNotification: React.VoidFunctionComponent<
);
};
export default UnexpectedErrorNotification;
export const unexpectedError: CreateToast<{ error: Error }> = (onAction, { error }) => {
return {
message: <UnexpectedErrorAlert error={error} />,
icon: 'error',
intent: Intent.DANGER,
onDismiss: () => onAction('dismiss'),
};
};
+46
View File
@@ -0,0 +1,46 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { createAction } from '../actions';
import { AlertActions, AlertDomain, AlertProps, AlertSpecific } from '../alerts';
/**
* Action that requests to show an alert to the user.
*
* @param domain The alert domain (app subsystem).
* @param specific The specific alert for the domain.
* @param props Any additional properties required by this specific alert.
*/
export const alertsShowAlert = createAction(
<D extends AlertDomain, S extends AlertSpecific<D>>(
domain: D,
specific: S,
props: AlertProps<D, S>,
) => ({
type: 'alerts.action.showAlert',
domain,
specific,
props,
}),
);
/**
* Action that indicates the alert requested by {@link alertsShowAlert} was
* dismissed.
*
* @param domain The alert domain (app subsystem).
* @param specific The specific alert for the domain.
* @param action The user-selected action that dismissed the alert.
*/
export const alertsDidShowAlert = createAction(
<D extends AlertDomain, S extends AlertSpecific<D>>(
domain: D,
specific: S,
action: AlertActions<D, S>,
) => ({
type: 'alerts.action.didShowAlert',
domain,
specific,
action,
}),
);
+7
View File
@@ -0,0 +1,7 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { unexpectedError } from './UnexpectedErrorAlert';
// gathers all of the alert creation functions for passing up to the top level
export default { unexpectedError };
+12
View File
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2022 The Pybricks Authors
import { lookup } from '../../test';
import { I18nId } from './i18n';
import en from './translations/en.json';
describe('Ensure .json file has matches for I18nId', () => {
test.each(Object.values(I18nId))('%s', (id) => {
expect(lookup(en, id)).toBeDefined();
});
});
+9
View File
@@ -0,0 +1,9 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2022 The Pybricks Authors
export enum I18nId {
Message = 'message',
TechnicalInfo = 'technicalInfo',
CopyErrorMessage = 'copyErrorMessage',
ReportBug = 'reportBug',
}
+115
View File
@@ -0,0 +1,115 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { IToastOptions, IToastProps, IToaster } from '@blueprintjs/core';
import { waitFor } from '@testing-library/dom';
import { AsyncSaga } from '../../test';
import { alertsDidShowAlert, alertsShowAlert } from './actions';
import alerts from './sagas';
afterEach(() => {
jest.clearAllMocks();
});
class TestToaster implements IToaster {
private toasts = new Array<IToastOptions>();
public getToasts(): IToastOptions[] {
return this.toasts;
}
public show(props: IToastProps, key?: string): string {
if (!key) {
throw new Error('key is required!');
}
this.toasts.push({ key, ...props });
return key;
}
public dismiss(key: string): void {
const index = this.toasts.findIndex((t) => t.key === key);
if (index < 0) {
return;
}
const toast = this.toasts.at(index);
toast?.onDismiss?.(false);
this.toasts.splice(index, 1);
}
public clear(): void {
throw new Error('this method should never be called!');
}
}
describe('handleShowAlert', () => {
let toaster: TestToaster;
let saga: AsyncSaga;
beforeEach(async () => {
toaster = new TestToaster();
jest.spyOn(toaster, 'show');
jest.spyOn(toaster, 'dismiss');
saga = new AsyncSaga(alerts, { toaster });
});
it('should show toast', async () => {
saga.put(
alertsShowAlert('alerts', 'unexpectedError', {
error: { name: 'TestError', message: 'test error' },
}),
);
expect(toaster.dismiss).not.toHaveBeenCalled();
expect(toaster.show).toHaveBeenCalled();
toaster.dismiss(toaster.getToasts().at(-1)?.key ?? '');
await expect(saga.take()).resolves.toEqual(
alertsDidShowAlert('alerts', 'unexpectedError', 'dismiss'),
);
});
it('should show close and re-open toast with same key', async () => {
// request to show the same alert twice
saga.put(
alertsShowAlert('alerts', 'unexpectedError', {
error: { name: 'TestError', message: 'test error' },
}),
);
saga.put(
alertsShowAlert('alerts', 'unexpectedError', {
error: { name: 'TestError', message: 'test error' },
}),
);
// at this point, show has only been called once to display the first alert
expect(toaster.show).toHaveBeenCalled();
// and then dismiss was called to close it
expect(toaster.dismiss).toHaveBeenCalled();
// which should result in an action
await expect(saga.take()).resolves.toEqual(
alertsDidShowAlert('alerts', 'unexpectedError', 'dismiss'),
);
// then after a delay, the second alert is shown
await waitFor(() => expect(toaster.show).toHaveBeenCalledTimes(2));
// then we dismiss it manually, like normal
toaster.dismiss(toaster.getToasts().at(-1)?.key ?? '');
// and get the action for the second dismiss
await expect(saga.take()).resolves.toEqual(
alertsDidShowAlert('alerts', 'unexpectedError', 'dismiss'),
);
});
afterEach(async () => {
await saga.end();
});
});
+46
View File
@@ -0,0 +1,46 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { IToaster } from '@blueprintjs/core';
import { eventChannel } from 'redux-saga';
import { delay, getContext, put, take, takeEvery } from 'typed-redux-saga/macro';
import { getAlertProps } from '../alerts';
import { alertsDidShowAlert, alertsShowAlert } from './actions';
export type AlertsSagaContext = { toaster: IToaster };
/** Shows an alert to the user and avoids duplicate alerts. */
function* handleShowAlert(action: ReturnType<typeof alertsShowAlert>): Generator {
const toaster = yield* getContext<IToaster>('toaster');
const key = `${action.domain}.${action.specific}.${JSON.stringify(action.props)}`;
const existing = toaster.getToasts().filter((t) => t.key === key);
// if a toast with the same parameters is already open, close it so we
// can open it again without duplicates.
if (existing.length > 0) {
toaster.dismiss(key);
yield* delay(500);
}
const chan = eventChannel<string>((emit) => {
const props = getAlertProps(action.domain, action.specific, emit, action.props);
toaster.show(props, key);
// have to return an unsubscribe function to not break things
return () => undefined;
});
try {
const alertAction = yield* take(chan);
yield* put(alertsDidShowAlert(action.domain, action.specific, alertAction));
} finally {
chan.close();
}
}
export default function* (): Generator {
yield* takeEvery(alertsShowAlert, handleShowAlert);
}
+6
View File
@@ -0,0 +1,6 @@
{
"message": "An unexpected error occurred. Please consider reporting this so we can make a better error message.",
"technicalInfo": "Expand for detailed technical information",
"copyErrorMessage": "Copy Error Message",
"reportBug": "Report Bug"
}
+10
View File
@@ -0,0 +1,10 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { CustomError } from '../utils/customError';
/** Specific errors for editor subsystem. */
export type EditorErrorName = 'FileInUse';
/** Error class for editor subsystem. */
export class EditorError extends CustomError<EditorErrorName> {}
+91 -51
View File
@@ -10,6 +10,7 @@ import {
fileStorageDidReadFile,
fileStorageReadFile,
} from '../fileStorage/actions';
import { acquireLock } from '../utils';
import {
editorActivateFile,
editorCloseFile,
@@ -21,6 +22,7 @@ import {
editorDidOpenFile,
editorOpenFile,
} from './actions';
import { EditorError, EditorErrorName } from './error';
import { ActiveFileHistoryManager, OpenFileInfo, OpenFileManager } from './lib';
import editor from './sagas';
@@ -51,6 +53,22 @@ it('should activate files from storage', async () => {
await saga.end();
});
/**
* Asymmetric matcher for matching errors by name while ignoring the message.
* @param name The name to match.
* @returns An asymmetric matcher cast to an EditorError so that it can be
* passed to action functions.
*/
function expectEditorError(name: EditorErrorName): EditorError {
const matcher: jest.AsymmetricMatcher & Record<string, unknown> = {
$$typeof: Symbol.for('jest.asymmetricMatcher'),
asymmetricMatch: (other) => other instanceof EditorError && other.name === name,
toAsymmetricMatcher: () => `[EditorError: ${name}]`,
};
return matcher as unknown as EditorError;
}
describe('per-editor sagas', () => {
let saga: AsyncSaga;
let monacoEditor: monaco.editor.IStandaloneCodeEditor;
@@ -70,70 +88,92 @@ describe('per-editor sagas', () => {
});
describe('handleEditorOpenFile', () => {
beforeEach(async () => {
jest.spyOn(OpenFileManager.prototype, 'add');
jest.spyOn(OpenFileManager.prototype, 'remove');
saga.put(editorOpenFile('test.file'));
await expect(saga.take()).resolves.toEqual(
fileStorageReadFile('test.file'),
it('should fail if file is already in use', async () => {
const releaseLock = await acquireLock(
'pybricks.editor+pybricksCode:test.file',
);
expect(releaseLock).toBeDefined();
try {
saga.put(editorOpenFile('test.file'));
await expect(saga.take()).resolves.toEqual(
editorDidFailToOpenFile(
'test.file',
expectEditorError('FileInUse'),
),
);
} finally {
await releaseLock?.();
}
});
it('should propagate error from fileStorageReadFile', async () => {
const testError = new Error('test error');
saga.put(fileStorageDidFailToReadFile('test.file', testError));
await expect(saga.take()).resolves.toEqual(
editorDidFailToOpenFile('test.file', testError),
);
expect(OpenFileManager.prototype.add).not.toHaveBeenCalled();
});
describe('read succeeded', () => {
let model: monaco.editor.ITextModel;
describe('not already in use', () => {
beforeEach(async () => {
monaco.editor.onDidCreateModel((m) => (model = m));
saga.put(fileStorageDidReadFile('test.file', ''));
expect(model).toBeDefined();
jest.spyOn(OpenFileManager.prototype, 'add');
jest.spyOn(OpenFileManager.prototype, 'remove');
saga.put(editorOpenFile('test.file'));
await expect(saga.take()).resolves.toEqual(
editorDidOpenFile('test.file'),
fileStorageReadFile('test.file'),
);
expect(OpenFileManager.prototype.add).toHaveBeenCalled();
expect(OpenFileManager.prototype.remove).not.toHaveBeenCalled();
});
it('should close file if task is canceled', async () => {
jest.spyOn(model, 'dispose');
it('should propagate error from fileStorageReadFile', async () => {
const testError = new Error('test error');
saga.cancel();
// model should be disposed before fileStorageClose
expect(model.dispose).toHaveBeenCalled();
expect(OpenFileManager.prototype.remove).toHaveBeenCalled();
// editorDidCloseFile is not called since we did not put editorCloseFile
});
it('should close when requested', async () => {
jest.spyOn(model, 'dispose');
saga.put(editorCloseFile('test.file'));
// model should be disposed before fileStorageClose
expect(model.dispose).toHaveBeenCalled();
expect(OpenFileManager.prototype.remove).toHaveBeenCalled();
saga.put(fileStorageDidFailToReadFile('test.file', testError));
await expect(saga.take()).resolves.toEqual(
editorDidCloseFile('test.file'),
editorDidFailToOpenFile('test.file', testError),
);
expect(OpenFileManager.prototype.add).not.toHaveBeenCalled();
});
describe('read succeeded', () => {
let model: monaco.editor.ITextModel;
beforeEach(async () => {
monaco.editor.onDidCreateModel((m) => (model = m));
saga.put(fileStorageDidReadFile('test.file', ''));
expect(model).toBeDefined();
await expect(saga.take()).resolves.toEqual(
editorDidOpenFile('test.file'),
);
expect(OpenFileManager.prototype.add).toHaveBeenCalled();
expect(OpenFileManager.prototype.remove).not.toHaveBeenCalled();
});
it('should close file if task is canceled', async () => {
jest.spyOn(model, 'dispose');
saga.cancel();
// model should be disposed before fileStorageClose
expect(model.dispose).toHaveBeenCalled();
expect(OpenFileManager.prototype.remove).toHaveBeenCalled();
// editorDidCloseFile is not called since we did not put editorCloseFile
});
it('should close when requested', async () => {
jest.spyOn(model, 'dispose');
saga.put(editorCloseFile('test.file'));
// model should be disposed before fileStorageClose
expect(model.dispose).toHaveBeenCalled();
expect(OpenFileManager.prototype.remove).toHaveBeenCalled();
await expect(saga.take()).resolves.toEqual(
editorDidCloseFile('test.file'),
);
});
});
});
});
+17 -2
View File
@@ -5,6 +5,7 @@ import { monaco } from 'react-monaco-editor';
import { EventChannel, buffers, eventChannel } from 'redux-saga';
import {
SagaGenerator,
call,
delay,
fork,
getContext,
@@ -22,7 +23,7 @@ import {
fileStorageWriteFile,
} from '../fileStorage/actions';
import { RootState } from '../reducers';
import { defined, ensureError } from '../utils';
import { acquireLock, defined, ensureError } from '../utils';
import {
editorActivateFile,
editorCloseFile,
@@ -36,6 +37,7 @@ import {
editorGetValueResponse,
editorOpenFile,
} from './actions';
import { EditorError } from './error';
import { ActiveFileHistoryManager, OpenFileManager } from './lib';
import { pybricksMicroPythonId } from './pybricksMicroPython';
@@ -93,7 +95,7 @@ function* handleEditorOpenFile(
let closeRequested = false;
try {
const defer: Array<() => void> = [];
const defer: Array<() => void | Promise<void>> = [];
try {
const modelUri = monaco.Uri.from({
@@ -101,6 +103,19 @@ function* handleEditorOpenFile(
path: action.fileName,
});
const releaseLock = yield* call(() =>
acquireLock(`pybricks.editor+${modelUri}`),
);
if (!releaseLock) {
throw new EditorError(
'FileInUse',
'the file is already open in another editor',
);
}
defer.push(releaseLock);
yield* put(fileStorageReadFile(modelUri.fsPath));
const { didRead, didFailToRead } = yield* race({
+3 -3
View File
@@ -9,13 +9,13 @@ import { FileMetadata } from '../fileStorage';
import { useFileStorageMetadata } from '../fileStorage/hooks';
import Explorer from './Explorer';
import {
explorerActivateFile,
explorerArchiveAllFiles,
explorerCreateNewFile,
explorerDeleteFile,
explorerDuplicateFile,
explorerExportFile,
explorerImportFiles,
explorerUserActivateFile,
} from './actions';
afterEach(async () => {
@@ -74,7 +74,7 @@ describe('tree item', () => {
userEvent.click(treeItem);
expect(dispatch).toHaveBeenCalledWith(explorerActivateFile('test.file'));
expect(dispatch).toHaveBeenCalledWith(explorerUserActivateFile('test.file'));
});
it('should dispatch action when key is pressed', async () => {
@@ -86,7 +86,7 @@ describe('tree item', () => {
userEvent.click(treeItem);
userEvent.keyboard('{enter}');
expect(dispatch).toHaveBeenCalledWith(explorerActivateFile('test.file'));
expect(dispatch).toHaveBeenCalledWith(explorerUserActivateFile('test.file'));
});
describe('duplicate', () => {
+2 -2
View File
@@ -31,13 +31,13 @@ import { useFileStorageMetadata } from '../fileStorage/hooks';
import { isMacOS } from '../utils/os';
import { TreeItemContext, TreeItemData, renderers } from '../utils/tree-renderer';
import {
explorerActivateFile,
explorerArchiveAllFiles,
explorerCreateNewFile,
explorerDeleteFile,
explorerDuplicateFile,
explorerExportFile,
explorerImportFiles,
explorerUserActivateFile,
} from './actions';
import DeleteFileAlert from './deleteFileAlert/DeleteFileAlert';
import DuplicateFileDialog from './duplicateFileDialog/DuplicateFileDialog';
@@ -373,7 +373,7 @@ const FileTree: React.VoidFunctionComponent<FileTreeProps> = ({ i18n }) => {
canRename={false} // we implement our own rename handler
onFocusItem={(item) => setFocusedItem(item.index)}
onPrimaryAction={(item) =>
dispatch(explorerActivateFile(item.data.fileName))
dispatch(explorerUserActivateFile(item.data.fileName))
}
>
<div className="pb-explorer-file-tree">
+5 -18
View File
@@ -75,33 +75,20 @@ export const explorerDidFailToCreateNewFile = createAction((error: Error) => ({
* Request to activate a file (open or bring to foreground if already open).
* @param fileName The file name.
*/
export const explorerActivateFile = createAction((fileName: string) => ({
type: 'explorer.action.activateFile',
export const explorerUserActivateFile = createAction((fileName: string) => ({
type: 'explorer.user.action.activateFile',
fileName,
}));
/**
* Indicates that {@link explorerActivateFile} succeeded.
* Indicates that {@link explorerUserActivateFile} completed.
* @param fileName The file name.
*/
export const explorerDidActivateFile = createAction((fileName: string) => ({
type: 'explorer.action.didActivateFile',
export const explorerUserDidActivateFile = createAction((fileName: string) => ({
type: 'explorer.user.action.didActivateFile',
fileName,
}));
/**
* Indicates that {@link explorerActivateFile} failed.
* @param fileName The file name.
* @param error The error that was raised.
*/
export const explorerDidFailToActivateFile = createAction(
(fileName: string, error: Error) => ({
type: 'explorer.action.didFailToActivateFile',
fileName,
error,
}),
);
/**
* Action that requests to duplicate a file.
* @param fileName The file name.
@@ -0,0 +1,21 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import React from 'react';
import { testRender } from '../../../test';
import { fileInUse } from './FileInUseAlert';
it('should be valid', () => {
const callback = jest.fn();
const toast = fileInUse(callback, { fileName: 'test.file' });
// TODO: refactor this to a common function to be used by all alerts
// it should render
const [message] = testRender(<>{toast.message}</>);
expect(message).toBeDefined();
// it should have a dismiss callback
toast.onDismiss?.(false);
expect(callback).toHaveBeenCalledWith('dismiss');
});
+32
View File
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { Intent } from '@blueprintjs/core';
import { useI18n } from '@shopify/react-i18n';
import React from 'react';
import { CreateToast } from '../../i18nToaster';
import { I18nId } from './i18n';
type FileInUseAlertProps = {
fileName: string;
};
const FileInUseAlert: React.VoidFunctionComponent<FileInUseAlertProps> = ({
fileName,
}) => {
// istanbul ignore next: babel-loader rewrites this line
const [i18n] = useI18n();
return <>{i18n.translate(I18nId.FileInUseMessage, { fileName })}</>;
};
export const fileInUse: CreateToast<{ fileName: string }> = (
onAction,
{ fileName },
) => {
return {
message: <FileInUseAlert fileName={fileName} />,
icon: 'error',
intent: Intent.DANGER,
onDismiss: () => onAction('dismiss'),
};
};
+12
View File
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { lookup } from '../../../test';
import { I18nId } from './i18n';
import en from './translations/en.json';
describe('Ensure .json file has matches for I18nId', () => {
test.each(Object.values(I18nId))('%s', (id) => {
expect(lookup(en, id)).toBeDefined();
});
});
+6
View File
@@ -0,0 +1,6 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
export enum I18nId {
FileInUseMessage = 'fileInUse.message',
}
+7
View File
@@ -0,0 +1,7 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { fileInUse } from './FileInUseAlert';
// gathers all of the alert creation functions for passing up to the top level
export default { fileInUse };
+5
View File
@@ -0,0 +1,5 @@
{
"fileInUse": {
"message": "The file '{fileName}' could not be opened. It is already open in another window."
}
}
+24 -8
View File
@@ -5,6 +5,7 @@ import * as browserFsAccess from 'browser-fs-access';
import { FileWithHandle } from 'browser-fs-access';
import { mock } from 'jest-mock-extended';
import { AsyncSaga } from '../../test';
import { alertsShowAlert } from '../alerts/actions';
import {
editorActivateFile,
editorCloseFile,
@@ -12,6 +13,7 @@ import {
editorDidCloseFile,
editorDidFailToActivateFile,
} from '../editor/actions';
import { EditorError } from '../editor/error';
import {
fileStorageCopyFile,
fileStorageDeleteFile,
@@ -30,17 +32,14 @@ import {
} from '../fileStorage/actions';
import { pythonFileExtension } from '../pybricksMicropython/lib';
import {
explorerActivateFile,
explorerArchiveAllFiles,
explorerCreateNewFile,
explorerDeleteFile,
explorerDidActivateFile,
explorerDidArchiveAllFiles,
explorerDidCreateNewFile,
explorerDidDeleteFile,
explorerDidDuplicateFile,
explorerDidExportFile,
explorerDidFailToActivateFile,
explorerDidFailToArchiveAllFiles,
explorerDidFailToCreateNewFile,
explorerDidFailToDeleteFile,
@@ -51,6 +50,8 @@ import {
explorerDuplicateFile,
explorerExportFile,
explorerImportFiles,
explorerUserActivateFile,
explorerUserDidActivateFile,
} from './actions';
import {
deleteFileAlertDidAccept,
@@ -243,25 +244,40 @@ describe('handleExplorerActivateFile', () => {
beforeEach(async () => {
saga = new AsyncSaga(explorer);
saga.put(explorerActivateFile('test.file'));
saga.put(explorerUserActivateFile('test.file'));
await expect(saga.take()).resolves.toEqual(editorActivateFile('test.file'));
});
it('should propagate error', async () => {
it('should alert file in use error', async () => {
const testError = new EditorError('FileInUse', 'test error');
saga.put(editorDidFailToActivateFile('test.file', testError));
await expect(saga.take()).resolves.toEqual(
alertsShowAlert('explorer', 'fileInUse', { fileName: 'test.file' }),
);
await expect(saga.take()).resolves.toEqual(
explorerUserDidActivateFile('test.file'),
);
});
it('should alert unexpected error', async () => {
const testError = new Error('test error');
saga.put(editorDidFailToActivateFile('test.file', testError));
await expect(saga.take()).resolves.toEqual(
explorerDidFailToActivateFile('test.file', testError),
alertsShowAlert('alerts', 'unexpectedError', { error: testError }),
);
await expect(saga.take()).resolves.toEqual(
explorerUserDidActivateFile('test.file'),
);
});
it('should propagate success', async () => {
it('should notify success', async () => {
saga.put(editorDidActivateFile('test.file'));
await expect(saga.take()).resolves.toEqual(
explorerDidActivateFile('test.file'),
explorerUserDidActivateFile('test.file'),
);
});
+22 -16
View File
@@ -4,6 +4,7 @@
import { fileOpen, fileSave } from 'browser-fs-access';
import JSZip from 'jszip';
import { call, put, race, take, takeEvery } from 'typed-redux-saga/macro';
import { alertsShowAlert } from '../alerts/actions';
import {
editorActivateFile,
editorCloseFile,
@@ -11,6 +12,7 @@ import {
editorDidCloseFile,
editorDidFailToActivateFile,
} from '../editor/actions';
import { EditorError } from '../editor/error';
import { getPybricksMicroPythonFileTemplate } from '../editor/pybricksMicroPython';
import {
fileStorageCopyFile,
@@ -38,17 +40,14 @@ import {
} from '../pybricksMicropython/lib';
import { defined, ensureError, timestamp } from '../utils';
import {
explorerActivateFile,
explorerArchiveAllFiles,
explorerCreateNewFile,
explorerDeleteFile,
explorerDidActivateFile,
explorerDidArchiveAllFiles,
explorerDidCreateNewFile,
explorerDidDeleteFile,
explorerDidDuplicateFile,
explorerDidExportFile,
explorerDidFailToActivateFile,
explorerDidFailToArchiveAllFiles,
explorerDidFailToCreateNewFile,
explorerDidFailToDeleteFile,
@@ -59,6 +58,8 @@ import {
explorerDuplicateFile,
explorerExportFile,
explorerImportFiles,
explorerUserActivateFile,
explorerUserDidActivateFile,
} from './actions';
import {
deleteFileAlertDidAccept,
@@ -226,11 +227,11 @@ function* handleExplorerCreateNewFile(): Generator {
* @param action
*/
function* handleExplorerActivateFile(
action: ReturnType<typeof explorerActivateFile>,
action: ReturnType<typeof explorerUserActivateFile>,
): Generator {
yield* put(editorActivateFile(action.fileName));
const { didActivate, didFailToActivate } = yield* race({
const { didFailToActivate } = yield* race({
didActivate: take(
editorDidActivateFile.when((a) => a.fileName === action.fileName),
),
@@ -240,18 +241,23 @@ function* handleExplorerActivateFile(
});
if (didFailToActivate) {
yield* put(
explorerDidFailToActivateFile(
didFailToActivate.fileName,
didFailToActivate.error,
),
);
return;
if (
didFailToActivate.error instanceof EditorError &&
didFailToActivate.error.name === 'FileInUse'
) {
yield* put(
alertsShowAlert('explorer', 'fileInUse', { fileName: action.fileName }),
);
} else {
yield* put(
alertsShowAlert('alerts', 'unexpectedError', {
error: didFailToActivate.error,
}),
);
}
}
defined(didActivate);
yield* put(explorerDidActivateFile(didActivate.fileName));
yield* put(explorerUserDidActivateFile(action.fileName));
}
/** Connects user initiate duplicate file actions to the duplicate file dialog. */
@@ -380,7 +386,7 @@ export default function* (): Generator {
yield* takeEvery(explorerArchiveAllFiles, handleExplorerArchiveAllFiles);
yield* takeEvery(explorerImportFiles, handleExplorerImportFiles);
yield* takeEvery(explorerCreateNewFile, handleExplorerCreateNewFile);
yield* takeEvery(explorerActivateFile, handleExplorerActivateFile);
yield* takeEvery(explorerUserActivateFile, handleExplorerActivateFile);
yield* takeEvery(explorerDuplicateFile, handleExplorerDuplicateFile);
yield* takeEvery(explorerExportFile, handleExplorerExportFile);
yield* takeEvery(explorerDeleteFile, handleExplorerDeleteFile);
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021 The Pybricks Authors
// Copyright (c) 2021-2022 The Pybricks Authors
import { IToaster, Toaster } from '@blueprintjs/core';
import { IToastProps, IToaster, Toaster } from '@blueprintjs/core';
import { I18nContext, I18nManager } from '@shopify/react-i18n';
import React from 'react';
import ReactDOM from 'react-dom';
@@ -34,3 +34,24 @@ export function create(i18n: I18nManager): IToaster {
return toaster.current;
}
/**
* Template type alert callbacks.
*
* This is called when an alert is dismissed.
*
* @param action: The action that the user selected. This is usually 'dismiss'.
*/
export type ToastActionHandler<A extends string> = (action: A) => void;
/**
* Template type for all toast creation functions for alert components.
*
* @param onAction A callback that is called when the toast is dismissed.
* @param props Additional properties required by this toast, if any (usually
* replacements for translations).
*/
export type CreateToast<
P extends Record<string, unknown> = never,
A extends string = 'dismiss',
> = (onAction: ToastActionHandler<A>, props: P) => IToastProps;
+2 -1
View File
@@ -14,7 +14,7 @@ import App from './app/App';
import { appVersion } from './app/constants';
import { db } from './fileStorage/context';
import { i18nManager } from './i18n';
import * as I18nToaster from './notifications/I18nToaster';
import * as I18nToaster from './i18nToaster';
import { rootReducer } from './reducers';
import reportWebVitals from './reportWebVitals';
import rootSaga, { RootSagaContext } from './sagas';
@@ -30,6 +30,7 @@ const sagaMiddleware = createSagaMiddleware<RootSagaContext>({
notification: { toaster },
terminal: defaultTerminalContext,
fileStorage: db,
toaster,
},
});
-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."
},
+4 -1
View File
@@ -2,6 +2,7 @@
// Copyright (c) 2020-2022 The Pybricks Authors
import { all, put } from 'typed-redux-saga/macro';
import alerts, { AlertsSagaContext } from './alerts/sagas';
import { didStart } from './app/actions';
import app from './app/sagas';
import blePybricksService from './ble-pybricks-service/sagas';
@@ -21,6 +22,7 @@ import terminal, { TerminalSagaContext } from './terminal/sagas';
/* istanbul ignore next */
export default function* (): Generator {
yield* all([
alerts(),
app(),
blePybricksService(),
ble(),
@@ -44,6 +46,7 @@ export default function* (): Generator {
*/
export type RootSagaContext = {
nextMessageId: () => number;
} & FileStorageSageContext &
} & AlertsSagaContext &
FileStorageSageContext &
NotificationSagaContext &
TerminalSagaContext;