mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-12 01:23:52 +00:00
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:
@@ -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);
|
||||
}
|
||||
+1
-1
@@ -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;
|
||||
+20
-15
@@ -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'),
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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({
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
@@ -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');
|
||||
});
|
||||
@@ -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'),
|
||||
};
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
export enum I18nId {
|
||||
FileInUseMessage = 'fileInUse.message',
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"fileInUse": {
|
||||
"message": "The file '{fileName}' could not be opened. It is already open in another window."
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user