fileStorage: new file storage backend

This replaces the localStorage file storage with a new backend that uses
localForage (indexeddb) for storing the file contents. This will also
allow storing multiple files.
This commit is contained in:
David Lechner
2022-02-24 16:59:49 -06:00
parent a0a1ae7126
commit e4b7a9aec7
19 changed files with 636 additions and 107 deletions
+3
View File
@@ -4,6 +4,9 @@
## [Unreleased]
### Changed
- Changed file storage backend.
### Fixed
- Fix tooltips not closing when expected ([pybricks-code#275]).
+2
View File
@@ -37,6 +37,8 @@
"file-saver": "^2.0.5",
"jszip": "^3.7.1",
"license-webpack-plugin": "^3.0.0",
"localforage": "^1.10.0",
"localforage-observable": "^2.1.1",
"monaco-editor": "^0.30.1",
"monaco-editor-webpack-plugin": "^6.0.0",
"monaco-themes": "^0.4.0",
+3 -1
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2021 The Pybricks Authors
// Copyright (c) 2020-2022 The Pybricks Authors
import { useDispatch as useReduxDispatch } from 'react-redux';
import { Dispatch as ReduxDispatch } from 'redux';
@@ -13,6 +13,7 @@ import {
} from './ble-pybricks-service/actions';
import { BLEAction, BLEConnectAction } from './ble/actions';
import { EditorAction } from './editor/actions';
import { FileStorageAction } from './fileStorage/actions';
import { FlashFirmwareAction } from './firmware/actions';
import { HubAction, HubMessageAction } from './hub/actions';
import { LicenseAction } from './licenses/actions';
@@ -46,6 +47,7 @@ export type Action =
| BootloaderDidFailToRequestAction
| BootloaderRequestAction
| BootloaderResponseAction
| FileStorageAction
| EditorAction
| FlashFirmwareAction
| HubAction
+5 -19
View File
@@ -10,16 +10,17 @@ import {
import { useI18n } from '@shopify/react-i18n';
import tomorrowNightEightiesTheme from 'monaco-themes/themes/Tomorrow-Night-Eighties.json';
import xcodeTheme from 'monaco-themes/themes/Xcode_default.json';
import React, { useEffect, useRef } from 'react';
import React, { useRef } from 'react';
import MonacoEditor, { monaco } from 'react-monaco-editor';
import { IDisposable } from 'xterm';
import { useDispatch } from '../actions';
import { fileStorageWriteFile } from '../fileStorage/actions';
import { compile } from '../mpy/actions';
import { useSelector } from '../reducers';
import { toggleBoolean } from '../settings/actions';
import { BooleanSettingId } from '../settings/defaults';
import { isMacOS } from '../utils/os';
import { setEditSession, storageChanged } from './actions';
import { setEditSession } from './actions';
import { EditorStringId } from './i18n';
import en from './i18n.en.json';
import * as pybricksMicroPython from './pybricksMicroPython';
@@ -128,21 +129,6 @@ const Editor: React.FunctionComponent = (_props) => {
const editorRef = useRef<MonacoEditor>(null);
const dispatch = useDispatch();
const onStorage = (e: StorageEvent): void => {
if (
e.key === 'program' &&
e.newValue &&
e.newValue !== editorRef.current?.editor?.getValue()
) {
dispatch(storageChanged(e.newValue));
}
};
useEffect(() => {
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
});
const darkMode = useSelector((s) => s.settings.darkMode);
const [i18n] = useI18n({ id: 'editor', translations: { en }, fallback: en });
@@ -166,7 +152,6 @@ const Editor: React.FunctionComponent = (_props) => {
contextmenu: false,
rulers: [80],
}}
value={localStorage.getItem('program')}
editorDidMount={(editor, _monaco) => {
const subscriptions = new Array<IDisposable>();
// FIXME: editor does not respond to changes in i18n
@@ -221,7 +206,8 @@ const Editor: React.FunctionComponent = (_props) => {
editor.focus();
dispatch(setEditSession(editor));
}}
onChange={(v) => localStorage.setItem('program', v)}
// REVIST: need to ensure we have exclusive access to file
onChange={(v) => dispatch(fileStorageWriteFile('main.py', v))}
/>
</ContextMenu2>
</ResizeSensor2>
+1 -28
View File
@@ -15,10 +15,6 @@ export enum EditorActionType {
DidFailToSaveAs = 'editor.action.didFailToSaveAs',
/** Open a file. */
Open = 'editor.action.open',
/** Storage was changed outside of the app. */
StorageChanged = 'editor.action.storageChanged',
/** Reload program from local storage. */
ReloadProgram = 'editor.action.reloadProgram',
}
export type CurrentEditorAction = Action<EditorActionType.Current> & {
@@ -81,27 +77,6 @@ export function open(data: ArrayBuffer): EditorOpenAction {
return { type: EditorActionType.Open, data };
}
/**Action that indicates the local storage has changed. */
export type EditorStorageChangedAction = Action<EditorActionType.StorageChanged> & {
newValue: string;
};
/**
* Creates an action that indicates the local storage has changed.
* @param newValue The new program.
*/
export function storageChanged(newValue: string): EditorStorageChangedAction {
return { type: EditorActionType.StorageChanged, newValue };
}
/** Action to request reloading the program from local storage. */
export type EditorReloadProgramAction = Action<EditorActionType.ReloadProgram>;
/** Creates and action to request reloading the program from local storage. */
export function reloadProgram(): EditorReloadProgramAction {
return { type: EditorActionType.ReloadProgram };
}
/**
* Common type for all editor actions.
*/
@@ -110,6 +85,4 @@ export type EditorAction =
| EditorOpenAction
| EditorSaveAsAction
| EditorDidSaveAsAction
| EditorDidFailToSaveAsAction
| EditorStorageChangedAction
| EditorReloadProgramAction;
| EditorDidFailToSaveAsAction;
+1 -12
View File
@@ -5,7 +5,7 @@ import FileSaver from 'file-saver';
import { mock } from 'jest-mock-extended';
import { monaco } from 'react-monaco-editor';
import { AsyncSaga } from '../../test';
import { didFailToSaveAs, didSaveAs, open, reloadProgram, saveAs } from './actions';
import { didFailToSaveAs, didSaveAs, open, saveAs } from './actions';
import editor from './sagas';
jest.mock('react-monaco-editor');
@@ -123,14 +123,3 @@ describe('saveAs', () => {
mockFileSaverSaveAs.mockRestore();
});
});
test('reloadProgram', async () => {
const mockEditor = mock<monaco.editor.ICodeEditor>();
const saga = new AsyncSaga(editor, { editor: { current: mockEditor } });
saga.put(reloadProgram());
expect(mockEditor.setValue).toHaveBeenCalled();
await saga.end();
});
+54 -11
View File
@@ -1,14 +1,29 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2021 The Pybricks Authors
// Copyright (c) 2020-2022 The Pybricks Authors
import FileSaver from 'file-saver';
import { call, put, select, takeEvery } from 'typed-redux-saga/macro';
import {
call,
put,
race,
select,
take,
takeEvery,
takeLatest,
} from 'typed-redux-saga/macro';
import { Action } from '../actions';
import {
FileStorageActionType,
FileStorageDidFailToReadFileAction,
FileStorageDidReadFileAction,
fileStorageReadFile,
} from '../fileStorage/actions';
import { RootState } from '../reducers';
import { ensureError } from '../utils';
import {
CurrentEditorAction,
EditorActionType,
EditorOpenAction,
EditorReloadProgramAction,
EditorSaveAsAction,
didFailToSaveAs,
didSaveAs,
@@ -78,20 +93,48 @@ function* saveAs(_action: EditorSaveAsAction): Generator {
yield* put(didSaveAs());
}
function* reloadProgram(_action: EditorReloadProgramAction): Generator {
const editor = yield* select((s: RootState) => s.editor.current);
// istanbul ignore next: it is a bug to dispatch this action with no current editor
if (editor === null) {
console.error('reloadProgram: No current editor');
function* handleEditSession(action: CurrentEditorAction): Generator {
if (action.editSession === null) {
// there is not current edit session, nothing to do
return;
}
editor.setValue(localStorage.getItem('program') || '');
// ensure storage has been initialized
const isStorageInitialized = yield* select(
(s: RootState) => s.fileStorage.isInitialized,
);
if (!isStorageInitialized) {
yield* take(FileStorageActionType.DidInitialize);
}
// TODO: get current file from state
const currentFileName = 'main.py';
// TODO: implement locking to ensure exclusive access to file
yield* put(fileStorageReadFile(currentFileName));
const { result } = yield* race({
result: take<FileStorageDidReadFileAction>(
(a: Action) =>
a.type === FileStorageActionType.DidReadFile &&
a.fileName === currentFileName,
),
error: take<FileStorageDidFailToReadFileAction>(
(a: Action) =>
a.type === FileStorageActionType.DidFailToReadFile &&
a.fileName === currentFileName,
),
});
if (result) {
action.editSession?.setValue(result.fileContents);
}
}
export default function* (): Generator {
yield* takeEvery(EditorActionType.Open, open);
yield* takeEvery(EditorActionType.SaveAs, saveAs);
yield* takeEvery(EditorActionType.ReloadProgram, reloadProgram);
yield* takeLatest(EditorActionType.Current, handleEditSession);
}
+169
View File
@@ -0,0 +1,169 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { Action } from 'redux';
export enum FileStorageActionType {
/** Action that indicates that the storage backend is ready to use. */
DidInitialize = 'fileStorage.action.didInitialize',
/** Action that indicates that the storage backend failed to initialize. */
DidFailToInitialize = 'fileStorage.action.didFailToInitialize',
/** Action that indicates that an item in the storage was created or changed by us or in another tab. */
DidChangeItem = 'fileStorage.action.didChangeItem',
/** Action that indicates that an item in the storage was removed by us or in another tab. */
DidRemoveItem = 'fileStorage.action.didRemoveItem',
/** Requests to read a file from storage. */
ReadFile = 'fileStorage.action.readFile',
/** Response to read file request indicating success. */
DidReadFile = 'fileStorage.action.didReadFile',
/** Response to read file request indicating failure. */
DidFailToReadFile = 'fileStorage.action.didFailToReadFile',
/** Requests to write a file to storage. */
WriteFile = 'fileStorage.action.writeFile',
/** Response to write file request indicating success. */
DidWriteFile = 'fileStorage.action.didWriteFile',
/** Response to write file request indicating failure. */
DidFailToWriteFile = 'fileStorage.action.didFailToWriteFile',
}
/** Action that indicates that the storage backend is ready to use. */
export type FileStorageDidInitializeAction =
Action<FileStorageActionType.DidInitialize>;
/** Action that indicates that the storage backend is ready to use. */
export function fileStorageDidInitialize(): FileStorageDidInitializeAction {
return { type: FileStorageActionType.DidInitialize };
}
/** Action that indicates that the storage backend failed to initialize. */
export type FileStorageDidFailToInitializeAction =
Action<FileStorageActionType.DidFailToInitialize> & { error: Error };
/** Action that indicates that the storage backend failed to initialize. */
export function fileStorageDidFailToInitialize(
err: Error,
): FileStorageDidFailToInitializeAction {
return { type: FileStorageActionType.DidFailToInitialize, error: err };
}
/** Action that indicates that an item in the storage was created or changed by us or in another tab. */
export type FileStorageDidChangeItemAction =
Action<FileStorageActionType.DidChangeItem> & {
fileName: string;
};
/** Action that indicates that an item in the storage was created or changed by us or in another tab. */
export function fileStorageDidChangeItem(
fileName: string,
): FileStorageDidChangeItemAction {
return { type: FileStorageActionType.DidChangeItem, fileName };
}
/** Action that indicates that an item in the storage was removed by us or in another tab. */
export type FileStorageDidRemoveItemAction =
Action<FileStorageActionType.DidRemoveItem> & {
fileName: string;
};
/** Action that indicates that an item in the storage was removed by us or in another tab. */
export function fileStorageDidRemoveItem(
fileName: string,
): FileStorageDidRemoveItemAction {
return { type: FileStorageActionType.DidRemoveItem, fileName };
}
/** Requests to read a file from storage. */
export type FileStorageReadFileAction = Action<FileStorageActionType.ReadFile> & {
fileName: string;
};
/** Requests to read a file from storage. */
export function fileStorageReadFile(fileName: string): FileStorageReadFileAction {
return { type: FileStorageActionType.ReadFile, fileName };
}
/** Response to read file request indicating success. */
export type FileStorageDidReadFileAction = Action<FileStorageActionType.DidReadFile> & {
fileName: string;
fileContents: string;
};
/** Response to read file request indicating success. */
export function fileStorageDidReadFile(
fileName: string,
fileContents: string,
): FileStorageDidReadFileAction {
return { type: FileStorageActionType.DidReadFile, fileName, fileContents };
}
/** Response to read file request indicating failure. */
export type FileStorageDidFailToReadFileAction =
Action<FileStorageActionType.DidFailToReadFile> & {
fileName: string;
error: Error;
};
/** Response to read file request indicating failure. */
export function fileStorageDidFailToReadFile(
fileName: string,
error: Error,
): FileStorageDidFailToReadFileAction {
return { type: FileStorageActionType.DidFailToReadFile, fileName, error };
}
/** Requests to write a file to storage. */
export type FileStorageWriteFileAction = Action<FileStorageActionType.WriteFile> & {
fileName: string;
fileContents: string;
};
/** Requests to write a file to storage. */
export function fileStorageWriteFile(
fileName: string,
fileContents: string,
): FileStorageWriteFileAction {
return { type: FileStorageActionType.WriteFile, fileName, fileContents };
}
/** Response to write file request indicating success. */
export type FileStorageDidWriteFileAction =
Action<FileStorageActionType.DidWriteFile> & {
fileName: string;
};
/** Response to write file request indicating success. */
export function fileStorageDidWriteFile(
fileName: string,
): FileStorageDidWriteFileAction {
return { type: FileStorageActionType.DidWriteFile, fileName };
}
/** Response to write file request indicating failure. */
export type FileStorageDidFailToWriteFileAction =
Action<FileStorageActionType.DidFailToWriteFile> & {
fileName: string;
error: Error;
};
/** Response to write file request indicating failure. */
export function fileStorageDidFailToWriteFile(
fileName: string,
error: Error,
): FileStorageDidFailToWriteFileAction {
return { type: FileStorageActionType.DidFailToWriteFile, fileName, error };
}
/**
* Common type for all file storage actions.
*/
export type FileStorageAction =
| FileStorageDidInitializeAction
| FileStorageDidFailToInitializeAction
| FileStorageDidChangeItemAction
| FileStorageDidRemoveItemAction
| FileStorageReadFileAction
| FileStorageDidReadFileAction
| FileStorageDidFailToReadFileAction
| FileStorageWriteFileAction
| FileStorageDidWriteFileAction
| FileStorageDidFailToWriteFileAction;
+56
View File
@@ -0,0 +1,56 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { Action } from '../actions';
import {
fileStorageDidChangeItem,
fileStorageDidInitialize,
fileStorageDidRemoveItem,
} from './actions';
import reducers from './reducers';
type State = ReturnType<typeof reducers>;
test('initial state', () => {
expect(reducers(undefined, {} as Action)).toMatchInlineSnapshot(`
Object {
"fileNames": Set {},
"isInitialized": false,
}
`);
});
test('isInitialized', () => {
expect(
reducers({ isInitialized: false } as State, fileStorageDidInitialize())
.isInitialized,
).toBeTruthy();
});
test('fileNames', () => {
const testFileName = 'test.file';
// if item is not in set, add it
expect(
reducers(
{ fileNames: new Set() } as State,
fileStorageDidChangeItem(testFileName),
).fileNames,
).toEqual(new Set([testFileName]));
// if item is already in set, there should not be duplicates
expect(
reducers(
{ fileNames: new Set([testFileName]) } as State,
fileStorageDidChangeItem(testFileName),
).fileNames,
).toEqual(new Set([testFileName]));
// if item is in set, it should be removed
expect(
reducers(
{ fileNames: new Set([testFileName]) } as State,
fileStorageDidRemoveItem(testFileName),
).fileNames,
).not.toContain(testFileName);
});
+28
View File
@@ -0,0 +1,28 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { Reducer, combineReducers } from 'redux';
import { Action } from '../actions';
import { FileStorageActionType } from './actions';
const isInitialized: Reducer<boolean, Action> = (state = false, action) => {
switch (action.type) {
case FileStorageActionType.DidInitialize:
return true;
default:
return state;
}
};
const fileNames: Reducer<Set<string>, Action> = (state = new Set(), action) => {
switch (action.type) {
case FileStorageActionType.DidChangeItem:
return new Set([...state, action.fileName]);
case FileStorageActionType.DidRemoveItem:
return new Set([...state].filter((value) => value !== action.fileName));
default:
return state;
}
};
export default combineReducers({ isInitialized, fileNames });
+85
View File
@@ -0,0 +1,85 @@
import { AsyncSaga } from '../../test';
import {
FileStorageActionType,
fileStorageDidChangeItem,
fileStorageDidInitialize,
fileStorageDidReadFile,
fileStorageDidWriteFile,
fileStorageReadFile,
fileStorageWriteFile,
} from './actions';
import fileStorage from './sagas';
beforeEach(() => {
// localForge uses localStorage as backend in test environment, so we need
// to start with a clean slate in each test
localStorage.clear();
});
it('should migrate old program from local storage during initialization', async () => {
const oldProgramKey = 'program';
const oldProgramContents = '# test program';
// add item to localStorage to simulate an existing program
localStorage.setItem(oldProgramKey, oldProgramContents);
expect(localStorage.getItem(oldProgramKey)).toBe(oldProgramContents);
const saga = new AsyncSaga(fileStorage);
// initialization should remove the localStorage entry
let action = await saga.take();
expect(action).toEqual(fileStorageDidInitialize());
expect(localStorage.getItem(oldProgramKey)).toBeNull();
// and add it to the new storage backend
action = await saga.take();
expect(action).toEqual(fileStorageDidChangeItem('main.py'));
await saga.end();
});
it('should read and write files', async () => {
const saga = new AsyncSaga(fileStorage);
let action = await saga.take();
expect(action).toEqual(fileStorageDidInitialize());
const testFileName = 'test.file';
const testFileContents = 'test file contents';
// test writing a file
saga.put(fileStorageWriteFile(testFileName, testFileContents));
// writing file triggers response
action = await saga.take();
expect(action).toEqual(fileStorageDidWriteFile(testFileName));
// and as a side-effect, triggers item change as well
action = await saga.take();
expect(action).toEqual(fileStorageDidChangeItem(testFileName));
// test reading the same file back
saga.put(fileStorageReadFile(testFileName));
action = await saga.take();
expect(action).toEqual(fileStorageDidReadFile(testFileName, testFileContents));
await saga.end();
});
it('should dispatch fail action if file does not exist', async () => {
const saga = new AsyncSaga(fileStorage);
let action = await saga.take();
expect(action).toEqual(fileStorageDidInitialize());
const testFileName = 'test.file';
saga.put(fileStorageReadFile(testFileName));
action = await saga.take();
expect(action).toHaveProperty('type', FileStorageActionType.DidFailToReadFile);
await saga.end();
});
+140
View File
@@ -0,0 +1,140 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import localForage from 'localforage';
import { extendPrototype } from 'localforage-observable';
import { eventChannel } from 'redux-saga';
import { call, fork, put, takeEvery } from 'typed-redux-saga/macro';
import Observable from 'zen-observable';
import { ensureError } from '../utils';
import {
FileStorageActionType,
FileStorageReadFileAction,
FileStorageWriteFileAction,
fileStorageDidChangeItem,
fileStorageDidFailToInitialize,
fileStorageDidFailToReadFile,
fileStorageDidFailToWriteFile,
fileStorageDidInitialize,
fileStorageDidReadFile,
fileStorageDidRemoveItem,
fileStorageDidWriteFile,
} from './actions';
/**
* Converts localForage change events to redux actions.
* @param change The storage change event.
*/
function* handleFileStorageDidChange(change: LocalForageObservableChange): Generator {
switch (change.methodName) {
case 'setItem':
if (change.success) {
yield* put(fileStorageDidChangeItem(change.key));
}
break;
case 'removeItem':
if (change.success) {
yield* put(fileStorageDidRemoveItem(change.key));
}
break;
}
}
/**
* Handles requests to read a file.
* @param files The storage instance.
* @param action The requested action.
*/
function* handleReadFile(
files: LocalForage,
action: FileStorageReadFileAction,
): Generator {
try {
const value = yield* call(() => files.getItem<string>(action.fileName));
if (value === null) {
throw new Error('file does not exist');
}
yield* put(fileStorageDidReadFile(action.fileName, value));
} catch (err) {
yield* put(fileStorageDidFailToReadFile(action.fileName, ensureError(err)));
}
}
/**
* Saves the file contents to storage.
* @param files The localForage instance.
* @param action The action that triggered this saga.
*/
function* handleWriteFile(files: LocalForage, action: FileStorageWriteFileAction) {
try {
yield* call(() => files.setItem(action.fileName, action.fileContents));
yield* put(fileStorageDidWriteFile(action.fileName));
} catch (err) {
yield* put(fileStorageDidFailToWriteFile(action.fileName, ensureError(err)));
}
}
/**
* Initializes the storage backend.
*/
function* initialize(): Generator {
try {
// set up storage
const files = extendPrototype(
localForage.createInstance({ name: 'fileStorage' }),
);
files.newObservable.factory = (subscribe) =>
// @ts-expect-error localforage-observable Subscription is missing
// closed property compared to zen-observable Subscription.
new Observable(subscribe);
yield* call(() => files.ready());
files.configObservables({
crossTabNotification: true,
crossTabChangeDetection: true,
});
// wire storage observable to redux-sagas
const localForageChannel = eventChannel<LocalForageObservableChange>((emit) => {
const filesObservable = files.newObservable({
crossTabNotification: true,
});
const subscription = filesObservable.subscribe({
next: (value) => emit(value),
});
return () => subscription.unsubscribe();
});
// subscribe to events
yield* takeEvery(localForageChannel, handleFileStorageDidChange);
yield* takeEvery(FileStorageActionType.ReadFile, handleReadFile, files);
yield* takeEvery(FileStorageActionType.WriteFile, handleWriteFile, files);
// migrate from old storage
// Previous versions of pybricks code used local storage to save a single program.
const oldProgram = localStorage.getItem('program');
if (oldProgram !== null) {
yield* call(() => files.setItem('main.py', oldProgram));
localStorage.removeItem('program');
}
yield* put(fileStorageDidInitialize());
} catch (err) {
yield* put(fileStorageDidFailToInitialize(ensureError(err)));
}
}
export default function* (): Generator {
yield* fork(initialize);
}
+5 -4
View File
@@ -12,12 +12,13 @@
"unexpectedError": "Unexpected error while trying to connect: {errorMessage}"
},
"editor": {
"programChanged": {
"message": "The program was changed in another window.\nDo you want to delete this program and replace it with the new program?",
"action": "Reload"
},
"failedToSaveFile": "Failed to save the program."
},
"fileStorage": {
"failedToInitialize": "Failed to initial file storage. Changes will not be automatically saved.",
"failedToRead": "Failed to read file.",
"failedToWrite": "Failed to write file."
},
"flashFirmware": {
"timedOut": "The hub took too long to respond. Restart the hub and try again.",
"bleError": "There was a problem with Bluetooth.",
+4 -3
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2021 The Pybricks Authors
// Copyright (c) 2020-2022 The Pybricks Authors
//
// Notification translation keys.
@@ -13,6 +13,9 @@ export enum MessageId {
BleNoWebBluetooth = 'ble.noWebBluetooth',
BleNoBluetooth = 'ble.noBluetooth',
EditorFailedToSaveFile = 'editor.failedToSaveFile',
FileStorageFailedToInitialize = 'fileStorage.failedToInitialize',
FileStorageFailedToRead = 'fileStorage.failedToRead',
FileStorageFailedToWrite = 'fileStorage.failedToWrite',
FlashFirmwareTimedOut = 'flashFirmware.timedOut',
FlashFirmwareBleError = 'flashFirmware.bleError',
FlashFirmwareDisconnected = 'flashFirmware.disconnected',
@@ -25,8 +28,6 @@ export enum MessageId {
FlashFirmwareCompileError = 'flashFirmware.compileError',
FlashFirmwareSizeTooBig = 'flashFirmware.sizeTooBig',
FlashFirmwareUnexpectedError = 'flashFirmware.unexpectedError',
ProgramChangedMessage = 'editor.programChanged.message',
ProgramChangedAction = 'editor.programChanged.action',
ServiceWorkerUpdateMessage = 'serviceWorker.update.message',
ServiceWorkerUpdateAction = 'serviceWorker.update.action',
MpyError = 'mpy.error',
+10 -3
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021 The Pybricks Authors
// Copyright (c) 2021-2022 The Pybricks Authors
import { IToaster } from '@blueprintjs/core';
import {
@@ -15,7 +15,12 @@ import {
BleDeviceFailToConnectReasonType,
didFailToConnect as bleDidFailToConnect,
} from '../ble/actions';
import { didFailToSaveAs, storageChanged } from '../editor/actions';
import { didFailToSaveAs } from '../editor/actions';
import {
fileStorageDidFailToInitialize,
fileStorageDidFailToReadFile,
fileStorageDidFailToWriteFile,
} from '../fileStorage/actions';
import {
FailToFinishReasonType,
HubError,
@@ -50,7 +55,6 @@ test.each([
bootloaderDidFailToConnect(BootloaderConnectionFailureReason.NoWebBluetooth),
bootloaderDidFailToConnect(BootloaderConnectionFailureReason.NoBluetooth),
bootloaderDidFailToConnect(BootloaderConnectionFailureReason.GattServiceNotFound),
storageChanged('test'),
didFailToCompile(['reason']),
add('warning', 'message'),
add('error', 'message', 'url'),
@@ -83,6 +87,9 @@ test.each([
didCheckForUpdate(false),
bleDIServiceDidReceiveFirmwareRevision('3.0.0'),
didFailToSaveAs(new DOMException('test message', 'NotAllowedError')),
fileStorageDidFailToInitialize(new Error('test error')),
fileStorageDidFailToReadFile('test.file', new Error('test error')),
fileStorageDidFailToWriteFile('test.file', new Error('test error')),
])('actions that should show notification: %o', async (action: Action) => {
const getToasts = jest.fn().mockReturnValue([]);
const show = jest.fn();
+37 -24
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2021 The Pybricks Authors
// Copyright (c) 2020-2022 The Pybricks Authors
// Saga for managing notifications (toasts)
@@ -21,11 +21,13 @@ import {
BleDeviceDidFailToConnectAction,
BleDeviceFailToConnectReasonType,
} from '../ble/actions';
import { EditorActionType, EditorDidFailToSaveAsAction } from '../editor/actions';
import {
EditorActionType,
EditorDidFailToSaveAsAction,
reloadProgram,
} from '../editor/actions';
FileStorageActionType,
FileStorageDidFailToInitializeAction,
FileStorageDidFailToReadFileAction,
FileStorageDidFailToWriteFileAction,
} from '../fileStorage/actions';
import {
FailToFinishReasonType,
FlashFirmwareActionType,
@@ -246,24 +248,6 @@ function* showEditorFailToSaveFile(action: EditorDidFailToSaveAsAction): Generat
yield* showUnexpectedError(MessageId.EditorFailedToSaveFile, action.err);
}
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* showFlashFirmwareError(
action: FlashFirmwareDidFailToFinishAction,
): Generator {
@@ -411,6 +395,24 @@ function* checkVersion(
}
}
function* showFileStorageFailToInitialize(
action: FileStorageDidFailToInitializeAction,
): Generator {
yield* showUnexpectedError(MessageId.FileStorageFailedToInitialize, action.error);
}
function* showFileStorageFailToRead(
action: FileStorageDidFailToReadFileAction,
): Generator {
yield* showUnexpectedError(MessageId.FileStorageFailedToRead, action.error);
}
function* showFileStorageFailToWrite(
action: FileStorageDidFailToWriteFileAction,
): Generator {
yield* showUnexpectedError(MessageId.FileStorageFailedToWrite, action.error);
}
export default function* (): Generator {
yield* takeEvery(
BleDeviceActionType.DidFailToConnect,
@@ -421,7 +423,6 @@ export default function* (): Generator {
showBootloaderDidFailToConnectError,
);
yield* takeEvery(EditorActionType.DidFailToSaveAs, showEditorFailToSaveFile);
yield* takeEvery(EditorActionType.StorageChanged, showEditorStorageChanged);
yield* takeEvery(FlashFirmwareActionType.DidFailToFinish, showFlashFirmwareError);
yield* takeEvery(MpyActionType.DidCompile, dismissCompilerError);
yield* takeEvery(MpyActionType.DidFailToCompile, showCompilerError);
@@ -429,4 +430,16 @@ export default function* (): Generator {
yield* takeEvery(ServiceWorkerActionType.DidUpdate, showServiceWorkerUpdate);
yield* takeEvery(AppActionType.DidCheckForUpdate, showNoUpdateInfo);
yield* takeEvery(BleDIServiceActionType.DidReceiveFirmwareRevision, checkVersion);
yield* takeEvery(
FileStorageActionType.DidFailToInitialize,
showFileStorageFailToInitialize,
);
yield* takeEvery(
FileStorageActionType.DidFailToReadFile,
showFileStorageFailToRead,
);
yield* takeEvery(
FileStorageActionType.DidFailToWriteFile,
showFileStorageFailToWrite,
);
}
+3 -1
View File
@@ -1,11 +1,12 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2021 The Pybricks Authors
// Copyright (c) 2020-2022 The Pybricks Authors
import { TypedUseSelectorHook, useSelector as useReduxSelector } from 'react-redux';
import { Reducer, combineReducers } from 'redux';
import app from './app/reducers';
import ble from './ble/reducers';
import editor from './editor/reducers';
import fileStorage from './fileStorage/reducers';
import firmware from './firmware/reducers';
import hub from './hub/reducers';
import licenses from './licenses/reducers';
@@ -20,6 +21,7 @@ export const rootReducer = combineReducers({
bootloader,
ble,
editor,
fileStorage,
firmware,
hub,
licenses,
+3 -1
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2021 The Pybricks Authors
// Copyright (c) 2020-2022 The Pybricks Authors
import { all, put } from 'typed-redux-saga/macro';
import { didStart } from './app/actions';
@@ -8,6 +8,7 @@ import blePybricksService from './ble-pybricks-service/sagas';
import ble from './ble/sagas';
import editor from './editor/sagas';
import errorLog from './error-log/sagas';
import fileStorage from './fileStorage/sagas';
import flashFirmware from './firmware/sagas';
import hub from './hub/sagas';
import licenses from './licenses/sagas';
@@ -24,6 +25,7 @@ export default function* (): Generator {
app(),
blePybricksService(),
ble(),
fileStorage(),
lwp3BootloaderBle(),
lwp3BootloaderProtocol(),
editor(),
+27
View File
@@ -7862,6 +7862,13 @@ license-webpack-plugin@^3.0.0:
resolved "https://registry.yarnpkg.com/license-webpack-plugin/-/license-webpack-plugin-3.0.0.tgz#530fb297cee402cdf19a80f59e1c1ec1720dbc7e"
integrity sha512-Owp0mXaJu/09h9hvZTazMni/Ni7bjh4R4xIfLhWP1O2wrhhKtezAA8U42TTeNDpyDMUD2ljeGC8Jh9xSFnyq4Q==
lie@3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/lie/-/lie-3.1.1.tgz#9a436b2cc7746ca59de7a41fa469b3efb76bd87e"
integrity sha1-mkNrLMd0bKWd56QfpGmz77dr2H4=
dependencies:
immediate "~3.0.5"
lie@~3.3.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/lie/-/lie-3.3.0.tgz#dcf82dee545f46074daf200c7c1c5a08e0f40f6a"
@@ -7906,6 +7913,21 @@ loader-utils@^1.1.0, loader-utils@^1.2.3, loader-utils@^1.4.0:
emojis-list "^3.0.0"
json5 "^1.0.1"
localforage-observable@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/localforage-observable/-/localforage-observable-2.1.1.tgz#a30c8a8aec72d59a1b138238f3c4b9356264fc99"
integrity sha512-p4E9FtopJlOa2ENk9Pw5puVOaOJ7tnLCJ+54iJXlG76wrmDAifDdbIcQc6QNAHJNIGLr+YKg0O3PeA77dEgD/w==
dependencies:
localforage "^1.5.0"
zen-observable "^0.2.1"
localforage@^1.10.0, localforage@^1.5.0:
version "1.10.0"
resolved "https://registry.yarnpkg.com/localforage/-/localforage-1.10.0.tgz#5c465dc5f62b2807c3a84c0c6a1b1b3212781dd4"
integrity sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==
dependencies:
lie "3.1.1"
locate-path@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e"
@@ -13039,6 +13061,11 @@ yocto-queue@^0.1.0:
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
zen-observable@^0.2.1:
version "0.2.1"
resolved "https://registry.yarnpkg.com/zen-observable/-/zen-observable-0.2.1.tgz#c47676a64132b8475a61aa49e514755b5b9663f3"
integrity sha1-xHZ2pkEyuEdaYapJ5RR1W1uWY/M=
zen-observable@^0.7.0:
version "0.7.1"
resolved "https://registry.yarnpkg.com/zen-observable/-/zen-observable-0.7.1.tgz#f84075c0ee085594d3566e1d6454207f126411b3"