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