explorer: split archive saga from fileStorage

This splits the fileStorage archive into two sagas to separate concerns.
fileStorage now just gives a dump of the database and explorer deals
with the user interaction and zipping.
This commit is contained in:
David Lechner
2022-04-04 17:48:36 -05:00
parent 7cb0cf3151
commit 05731e50fc
12 changed files with 209 additions and 67 deletions
+3 -2
View File
@@ -6,9 +6,10 @@ import { cleanup } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { testRender, uuid } from '../../test';
import { FileMetadata, fileStorageArchiveAllFiles } from '../fileStorage/actions';
import { FileMetadata } from '../fileStorage/actions';
import Explorer from './Explorer';
import {
explorerArchiveAllFiles,
explorerDeleteFile,
explorerExportFile,
explorerImportFiles,
@@ -37,7 +38,7 @@ describe('archive button', () => {
expect(button).toBeEnabled();
userEvent.click(button);
expect(dispatch).toHaveBeenCalledWith(fileStorageArchiveAllFiles());
expect(dispatch).toHaveBeenCalledWith(explorerArchiveAllFiles());
});
it('should be disabled if there are no files', () => {
+2 -2
View File
@@ -23,12 +23,12 @@ import {
useTreeEnvironment,
} from 'react-complex-tree';
import { useDispatch } from 'react-redux';
import { fileStorageArchiveAllFiles } from '../fileStorage/actions';
import { useSelector } from '../reducers';
import { isMacOS } from '../utils/os';
import { preventBrowserNativeContextMenu } from '../utils/react';
import { TreeItemContext, TreeItemData, renderers } from '../utils/tree-renderer';
import {
explorerArchiveAllFiles,
explorerDeleteFile,
explorerExportFile,
explorerImportFiles,
@@ -141,7 +141,7 @@ const Header: React.VoidFunctionComponent<HeaderProps> = ({ i18n }) => {
icon="archive"
tooltip={i18n.translate(I18nId.HeaderExportAllTooltip)}
disabled={files.length === 0}
onClick={() => dispatch(fileStorageArchiveAllFiles())}
onClick={() => dispatch(explorerArchiveAllFiles())}
/>
<ActionButton
// NB: the "export" icon has an arrow pointing up, which is
+23
View File
@@ -23,6 +23,29 @@ export enum Hub {
Essential = 'essentialhub',
}
/**
* Request to archive (download) all files in the store.
*/
export const explorerArchiveAllFiles = createAction(() => ({
type: 'explorer.action.archiveAllFiles',
}));
/**
* Indicates that {@link explorerArchiveAllFiles} succeeded.
*/
export const explorerDidArchiveAllFiles = createAction(() => ({
type: 'explorer.action.didArchiveAllFiles',
}));
/**
* Indicates that {@link explorerArchiveAllFiles} failed.
* @param error The error that was raised.
*/
export const explorerDidFailToArchiveAllFiles = createAction((error: Error) => ({
type: 'explorer.action.didFailToArchiveAllFiles',
error,
}));
/**
* Action that requests to import (upload) files into the app.
*/
+70
View File
@@ -6,11 +6,14 @@ import { FileWithHandle } from 'browser-fs-access';
import { mock } from 'jest-mock-extended';
import { AsyncSaga } from '../../test';
import {
fileStorageDidDumpAllFiles,
fileStorageDidFailToDumpAllFiles,
fileStorageDidFailToReadFile,
fileStorageDidFailToRenameFile,
fileStorageDidReadFile,
fileStorageDidRenameFile,
fileStorageDidWriteFile,
fileStorageDumpAllFiles,
fileStorageReadFile,
fileStorageRenameFile,
fileStorageWriteFile,
@@ -18,9 +21,12 @@ import {
import { pythonFileExtension } from '../pybricksMicropython/lib';
import {
Hub,
explorerArchiveAllFiles,
explorerCreateNewFile,
explorerDidArchiveAllFiles,
explorerDidCreateNewFile,
explorerDidExportFile,
explorerDidFailToArchiveAllFiles,
explorerDidFailToExportFile,
explorerDidFailToImportFiles,
explorerDidFailToRenameFile,
@@ -37,6 +43,70 @@ import {
} from './renameFileDialog/actions';
import explorer from './sagas';
jest.mock('browser-fs-access');
describe('handleExplorerArchiveAllFiles', () => {
let saga: AsyncSaga;
beforeEach(async () => {
saga = new AsyncSaga(explorer);
});
describe('should call into fileStorage', () => {
beforeEach(async () => {
saga.put(explorerArchiveAllFiles());
await expect(saga.take()).resolves.toEqual(fileStorageDumpAllFiles());
});
it('should propagate error when fileStorage fails', async () => {
const testError = new Error('test error');
saga.put(fileStorageDidFailToDumpAllFiles(testError));
await expect(saga.take()).resolves.toEqual(
explorerDidFailToArchiveAllFiles(testError),
);
});
describe('should continue when fileStorage succeeds', () => {
beforeEach(async () => {
saga.put(
fileStorageDidDumpAllFiles([
{ path: 'test.file', contents: 'test file contents' },
]),
);
});
it('should catch error', async () => {
const testError = new Error('test error');
jest.spyOn(browserFsAccess, 'fileSave').mockImplementation(() => {
throw testError;
});
await expect(saga.take()).resolves.toEqual(
explorerDidFailToArchiveAllFiles(testError),
);
});
it('should archive file', async () => {
jest.spyOn(browserFsAccess, 'fileSave');
await expect(saga.take()).resolves.toEqual(
explorerDidArchiveAllFiles(),
);
expect(browserFsAccess.fileSave).toHaveBeenCalled();
});
});
});
afterEach(async () => {
await saga.end();
});
});
describe('handleExplorerImportFiles', () => {
it('should write file to storage', async () => {
const testFileName = 'test.py';
+51 -1
View File
@@ -2,6 +2,7 @@
// Copyright (c) 2022 The Pybricks Authors
import { fileOpen, fileSave } from 'browser-fs-access';
import JSZip from 'jszip';
import {
call,
put,
@@ -13,12 +14,15 @@ import {
} from 'typed-redux-saga/macro';
import { getPybricksMicroPythonFileTemplate } from '../editor/pybricksMicroPython';
import {
fileStorageDidDumpAllFiles,
fileStorageDidFailToDumpAllFiles,
fileStorageDidFailToReadFile,
fileStorageDidFailToRenameFile,
fileStorageDidFailToWriteFile,
fileStorageDidReadFile,
fileStorageDidRenameFile,
fileStorageDidWriteFile,
fileStorageDumpAllFiles,
fileStorageReadFile,
fileStorageRenameFile,
fileStorageWriteFile,
@@ -31,11 +35,14 @@ import {
validateFileName,
} from '../pybricksMicropython/lib';
import { RootState } from '../reducers';
import { defined, ensureError } from '../utils';
import { defined, ensureError, timestamp } from '../utils';
import {
explorerArchiveAllFiles,
explorerCreateNewFile,
explorerDidArchiveAllFiles,
explorerDidCreateNewFile,
explorerDidExportFile,
explorerDidFailToArchiveAllFiles,
explorerDidFailToCreateNewFile,
explorerDidFailToExportFile,
explorerDidFailToImportFiles,
@@ -52,6 +59,48 @@ import {
renameFileDialogShow,
} from './renameFileDialog/actions';
function* handleExplorerArchiveAllFiles(): Generator {
try {
yield* put(fileStorageDumpAllFiles());
const { didDump, didFailToDump } = yield* race({
didDump: take(fileStorageDidDumpAllFiles),
didFailToDump: take(fileStorageDidFailToDumpAllFiles),
});
if (didFailToDump) {
throw didFailToDump.error;
}
defined(didDump);
const zip = new JSZip();
for (const f of didDump.files) {
yield* call(() => zip.file(f.path, f.contents));
}
const zipData = yield* call(() => zip.generateAsync({ type: 'blob' }));
const fileName = `pybricks-backup-${timestamp()}.zip`;
yield* call(() =>
fileSave(zipData, {
id: 'pybricksCodeFileStorageArchive',
fileName,
extensions: ['.zip'],
mimeTypes: ['application/zip'],
// TODO: translate description
description: 'Zip Files',
}),
);
yield* put(explorerDidArchiveAllFiles());
} catch (err) {
yield* put(explorerDidFailToArchiveAllFiles(ensureError(err)));
}
}
function* handleExplorerImportFiles(): Generator {
try {
const selectedFiles = yield* call(() =>
@@ -221,6 +270,7 @@ function* handleExplorerExportFile(
}
export default function* (): Generator {
yield* takeEvery(explorerArchiveAllFiles, handleExplorerArchiveAllFiles);
yield* takeEvery(explorerImportFiles, handleExplorerImportFiles);
yield* takeEvery(explorerCreateNewFile, handleExplorerCreateNewFile);
// takeLatest should ensure that if we trigger a new rename before the
+15 -11
View File
@@ -326,24 +326,28 @@ export const fileStorageDidFailToRenameFile = createAction(
);
/**
* Request to archive (download) all files in the store.
* Requests file storage to dump all file paths and contents currently in storage.
*/
export const fileStorageArchiveAllFiles = createAction(() => ({
type: 'fileStorage.action.archiveAllFiles',
export const fileStorageDumpAllFiles = createAction(() => ({
type: 'fileStorage.action.dumpAllFiles',
}));
/**
* Indicates that fileStorageArchiveAllFiles() succeeded.
* Indicates that {@link fileStorageDumpAllFiles} succeeded.
* @param files: An array of all file paths and contents.
*/
export const fileStorageDidArchiveAllFiles = createAction(() => ({
type: 'fileStorage.action.didArchiveAllFiles',
}));
export const fileStorageDidDumpAllFiles = createAction(
(files: ReadonlyArray<Readonly<{ path: string; contents: string }>>) => ({
type: 'fileStorage.action.didDumpAllFiles',
files,
}),
);
/**
* Indicates that fileStorageArchiveAllFiles() failed.
* @param error The error that was raised.
* Indicates that {@link fileStorageDumpAllFiles} succeeded.
* @param error The error.
*/
export const fileStorageDidFailToArchiveAllFiles = createAction((error: Error) => ({
type: 'fileStorage.action.didFailToArchiveAllFiles',
export const fileStorageDidFailToDumpAllFiles = createAction((error: Error) => ({
type: 'fileStorage.action.didFailToDumpAllFiles',
error,
}));
+19 -16
View File
@@ -1,7 +1,6 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import * as browserFsAccess from 'browser-fs-access';
import 'fake-indexeddb/auto';
import Dexie from 'dexie';
import 'dexie-observable';
@@ -11,16 +10,15 @@ import {
FD,
FileMetadata,
FileOpenMode,
fileStorageArchiveAllFiles,
fileStorageClose,
fileStorageDeleteFile,
fileStorageDidAddItem,
fileStorageDidArchiveAllFiles,
fileStorageDidChangeItem,
fileStorageDidClose,
fileStorageDidDeleteFile,
fileStorageDidFailToArchiveAllFiles,
fileStorageDidDumpAllFiles,
fileStorageDidFailToDeleteFile,
fileStorageDidFailToDumpAllFiles,
fileStorageDidFailToInitialize,
fileStorageDidFailToOpen,
fileStorageDidFailToRead,
@@ -36,6 +34,7 @@ import {
fileStorageDidRenameFile,
fileStorageDidWrite,
fileStorageDidWriteFile,
fileStorageDumpAllFiles,
fileStorageOpen,
fileStorageRead,
fileStorageReadFile,
@@ -45,8 +44,6 @@ import {
} from './actions';
import fileStorage from './sagas';
jest.mock('browser-fs-access');
/** SHA256 hash of '' */
const emptyFileSha256 =
'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
@@ -700,33 +697,39 @@ describe('renameFile', () => {
});
});
describe('archive', () => {
describe('dump all files', () => {
let saga: AsyncSaga;
let testFile: FileMetadata;
let testFileContents: string;
beforeEach(async () => {
saga = new AsyncSaga(fileStorage);
await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([]));
await setUpTestFile(saga);
[testFile, testFileContents] = await setUpTestFile(saga);
});
it('should archive file', async () => {
jest.spyOn(browserFsAccess, 'fileSave');
saga.put(fileStorageDumpAllFiles());
saga.put(fileStorageArchiveAllFiles());
await expect(saga.take()).resolves.toEqual(fileStorageDidArchiveAllFiles());
expect(browserFsAccess.fileSave).toHaveBeenCalled();
await expect(saga.take()).resolves.toEqual(
fileStorageDidDumpAllFiles([
{ path: testFile.path, contents: testFileContents },
]),
);
});
it('should catch error', async () => {
const testError = new Error('test error');
jest.spyOn(browserFsAccess, 'fileSave').mockRejectedValue(testError);
saga.put(fileStorageArchiveAllFiles());
jest.spyOn(Dexie.prototype, 'transaction').mockImplementation(() => {
throw testError;
});
saga.put(fileStorageDumpAllFiles());
await expect(saga.take()).resolves.toEqual(
fileStorageDidFailToArchiveAllFiles(testError),
fileStorageDidFailToDumpAllFiles(testError),
);
});
+15 -24
View File
@@ -1,7 +1,6 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { fileSave } from 'browser-fs-access';
import Dexie, { Table } from 'dexie';
import {
ICreateChange,
@@ -10,10 +9,9 @@ import {
IUpdateChange,
} from 'dexie-observable/api';
import 'dexie-observable';
import JSZip from 'jszip';
import { eventChannel } from 'redux-saga';
import { call, fork, put, race, take, takeEvery } from 'typed-redux-saga/macro';
import { defined, ensureError, timestamp } from '../utils';
import { defined, ensureError } from '../utils';
import { sha256Digest } from '../utils/crypto';
import { createCountFunc } from '../utils/iter';
import {
@@ -21,16 +19,15 @@ import {
FileMetadata,
FileOpenMode,
UUID,
fileStorageArchiveAllFiles,
fileStorageClose,
fileStorageDeleteFile,
fileStorageDidAddItem,
fileStorageDidArchiveAllFiles,
fileStorageDidChangeItem,
fileStorageDidClose,
fileStorageDidDeleteFile,
fileStorageDidFailToArchiveAllFiles,
fileStorageDidDumpAllFiles,
fileStorageDidFailToDeleteFile,
fileStorageDidFailToDumpAllFiles,
fileStorageDidFailToInitialize,
fileStorageDidFailToOpen,
fileStorageDidFailToRead,
@@ -46,6 +43,7 @@ import {
fileStorageDidRenameFile,
fileStorageDidWrite,
fileStorageDidWriteFile,
fileStorageDumpAllFiles,
fileStorageOpen,
fileStorageRead,
fileStorageReadFile,
@@ -615,30 +613,23 @@ function* handleRenameFile(
}
}
function* handleArchiveAllFiles(db: FileStorageDb): Generator {
function* handleDumpAllFiles(db: FileStorageDb): Generator {
try {
const zip = new JSZip();
const dump = new Array<{ path: string; contents: string }>();
yield* call(() => db._contents.each((f) => zip.file(f.path, f.contents)));
const zipData = yield* call(() => zip.generateAsync({ type: 'blob' }));
const fileName = `pybricks-backup-${timestamp()}.zip`;
// REVISIT: consider using dexie-export-import addon if we want to do
// a full backup instead of just the file contents
// https://www.npmjs.com/package/dexie-export-import
yield* call(() =>
fileSave(zipData, {
id: 'pybricksCodeFileStorageArchive',
fileName,
extensions: ['.zip'],
mimeTypes: ['application/zip'],
// TODO: translate description
description: 'Zip Files',
}),
db.transaction('r', db._contents, () =>
db._contents.each((f) => dump.push(f)),
),
);
yield* put(fileStorageDidArchiveAllFiles());
yield* put(fileStorageDidDumpAllFiles(dump));
} catch (err) {
yield* put(fileStorageDidFailToArchiveAllFiles(ensureError(err)));
yield* put(fileStorageDidFailToDumpAllFiles(ensureError(err)));
}
}
@@ -701,7 +692,7 @@ function* initialize(): Generator {
yield* takeEvery(fileStorageWriteFile, handleWriteFile);
yield* takeEvery(fileStorageDeleteFile, handleDeleteFile, db);
yield* takeEvery(fileStorageRenameFile, handleRenameFile, db);
yield* takeEvery(fileStorageArchiveAllFiles, handleArchiveAllFiles, db);
yield* takeEvery(fileStorageDumpAllFiles, handleDumpAllFiles, db);
const files = yield* call(() => db.metadata.toArray());
+1 -1
View File
@@ -18,8 +18,8 @@ export enum I18nId {
ExplorerFailedToImportFiles = 'explorer.failedToImportFiles',
ExplorerFailedToCreate = 'explorer.failedToCreate',
ExplorerFailedToExport = 'explorer.failedToExport',
ExplorerFailedToArchive = 'explorer.failedToArchive',
FileStorageFailedToInitialize = 'fileStorage.failedToInitialize',
FileStorageFailedToArchive = 'fileStorage.failedToArchive',
FlashFirmwareTimedOut = 'flashFirmware.timedOut',
FlashFirmwareBleError = 'flashFirmware.bleError',
FlashFirmwareDisconnected = 'flashFirmware.disconnected',
+3 -3
View File
@@ -18,13 +18,13 @@ import {
} from '../ble/actions';
import {
explorerDeleteFile,
explorerDidFailToArchiveAllFiles,
explorerDidFailToCreateNewFile,
explorerDidFailToExportFile,
explorerDidFailToImportFiles,
} from '../explorer/actions';
import {
fileStorageDeleteFile,
fileStorageDidFailToArchiveAllFiles,
fileStorageDidFailToInitialize,
fileStorageDidRemoveItem,
} from '../fileStorage/actions';
@@ -112,7 +112,7 @@ test.each([
appDidCheckForUpdate(false),
bleDIServiceDidReceiveFirmwareRevision('3.0.0'),
fileStorageDidFailToInitialize(new Error('test error')),
fileStorageDidFailToArchiveAllFiles(new Error('test error')),
explorerDidFailToArchiveAllFiles(new Error('test error')),
explorerDidFailToImportFiles(new Error('test error')),
explorerDidFailToCreateNewFile(new Error('test error')),
explorerDidFailToExportFile('test.file', new Error('test error')),
@@ -135,7 +135,7 @@ test.each([
serviceWorkerDidSucceed(),
appDidCheckForUpdate(true),
bleDIServiceDidReceiveFirmwareRevision(firmwareVersion),
fileStorageDidFailToArchiveAllFiles(new DOMException('test message', 'AbortError')),
explorerDidFailToArchiveAllFiles(new DOMException('test message', 'AbortError')),
explorerDidFailToImportFiles(new DOMException('test message', 'AbortError')),
explorerDidFailToExportFile(
'test.file',
+4 -4
View File
@@ -19,13 +19,13 @@ import {
} from '../ble/actions';
import {
explorerDeleteFile,
explorerDidFailToArchiveAllFiles,
explorerDidFailToCreateNewFile,
explorerDidFailToExportFile,
explorerDidFailToImportFiles,
} from '../explorer/actions';
import {
fileStorageDeleteFile,
fileStorageDidFailToArchiveAllFiles,
fileStorageDidFailToInitialize,
fileStorageDidRemoveItem,
} from '../fileStorage/actions';
@@ -388,14 +388,14 @@ function* showFileStorageFailToInitialize(
}
function* showFileStorageFailToArchive(
action: ReturnType<typeof fileStorageDidFailToArchiveAllFiles>,
action: ReturnType<typeof explorerDidFailToArchiveAllFiles>,
): Generator {
if (action.error.name === 'AbortError') {
// user clicked cancel button - not an error
return;
}
yield* showUnexpectedError(I18nId.FileStorageFailedToArchive, action.error);
yield* showUnexpectedError(I18nId.ExplorerFailedToArchive, action.error);
}
function* showDeleteFileWarning(action: ReturnType<typeof explorerDeleteFile>) {
@@ -472,7 +472,7 @@ export default function* (): Generator {
yield* takeEvery(appDidCheckForUpdate, showNoUpdateInfo);
yield* takeEvery(bleDIServiceDidReceiveFirmwareRevision, checkVersion);
yield* takeEvery(fileStorageDidFailToInitialize, showFileStorageFailToInitialize);
yield* takeEvery(fileStorageDidFailToArchiveAllFiles, showFileStorageFailToArchive);
yield* takeEvery(explorerDidFailToArchiveAllFiles, showFileStorageFailToArchive);
yield* takeEvery(explorerDeleteFile, showDeleteFileWarning);
yield* takeEvery(explorerDidFailToImportFiles, showExplorerFailToImportFiles);
yield* takeEvery(explorerDidFailToCreateNewFile, showExplorerFailToCreateFile);
+3 -3
View File
@@ -21,11 +21,11 @@
},
"failedToImportFiles": "Failed to import file(s).",
"failedToCreate": "Failed to create file.",
"failedToExport": "Failed to export file."
"failedToExport": "Failed to export file.",
"failedToArchive": "Failed to archive files.'"
},
"fileStorage": {
"failedToInitialize": "Failed to initial file storage. Changes will not be automatically saved.",
"failedToArchive": "Failed to archive files.'"
"failedToInitialize": "Failed to initial file storage. Changes will not be automatically saved."
},
"flashFirmware": {
"timedOut": "The hub took too long to respond. Restart the hub and try again.",