explorer: move export from fileStorage

This commit is contained in:
David Lechner
2022-04-01 18:45:24 -05:00
parent 1709591afc
commit 7ef08122e8
12 changed files with 213 additions and 186 deletions
+9 -8
View File
@@ -6,13 +6,14 @@ 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,
fileStorageExportFile,
} from '../fileStorage/actions';
import { FileMetadata, fileStorageArchiveAllFiles } from '../fileStorage/actions';
import Explorer from './Explorer';
import { explorerDeleteFile, explorerImportFiles, explorerRenameFile } from './actions';
import {
explorerDeleteFile,
explorerExportFile,
explorerImportFiles,
explorerRenameFile,
} from './actions';
afterEach(async () => {
cleanup();
@@ -153,7 +154,7 @@ describe('tree item', () => {
userEvent.click(button);
expect(dispatch).toHaveBeenCalledWith(fileStorageExportFile('test.file'));
expect(dispatch).toHaveBeenCalledWith(explorerExportFile('test.file'));
});
it('should dispatch export action when key is pressed', async () => {
@@ -166,6 +167,6 @@ describe('tree item', () => {
userEvent.click(treeItem);
userEvent.keyboard('{ctrl}e');
expect(dispatch).toHaveBeenCalledWith(fileStorageExportFile('test.file'));
expect(dispatch).toHaveBeenCalledWith(explorerExportFile('test.file'));
});
});
+9 -7
View File
@@ -23,15 +23,17 @@ import {
useTreeEnvironment,
} from 'react-complex-tree';
import { useDispatch } from 'react-redux';
import {
fileStorageArchiveAllFiles,
fileStorageExportFile,
} from '../fileStorage/actions';
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 { explorerDeleteFile, explorerImportFiles, explorerRenameFile } from './actions';
import {
explorerDeleteFile,
explorerExportFile,
explorerImportFiles,
explorerRenameFile,
} from './actions';
import { I18nId } from './i18n';
import NewFileWizard from './newFileWizard/NewFileWizard';
import RenameFileDialog from './renameFileDialog/RenameFileDialog';
@@ -110,7 +112,7 @@ const FileActionButtonGroup: React.VoidFunctionComponent<ActionButtonGroupProps>
icon="import"
tooltip={i18n.translate(I18nId.TreeItemExportTooltip, { fileName })}
focusable={false}
onClick={() => dispatch(fileStorageExportFile(fileName))}
onClick={() => dispatch(explorerExportFile(fileName))}
/>
<ActionButton
icon="trash"
@@ -236,7 +238,7 @@ const renderTreeContainer: typeof renderers.renderTreeContainer = (props) => {
const handleExportKeyDown = useCallback(() => {
if (focusedItem !== undefined) {
const fileName = environment.getItemTitle(environment.items[focusedItem]);
dispatch(fileStorageExportFile(fileName));
dispatch(explorerExportFile(fileName));
}
}, [environment]);
+31
View File
@@ -86,6 +86,37 @@ export const explorerRenameFile = createAction((fileName: string) => ({
fileName,
}));
/**
* Request to export (download) a file.
* @param fileName The file name.
*/
export const explorerExportFile = createAction((fileName: string) => ({
type: 'explorer.action.exportFile',
fileName,
}));
/**
* Indicates that explorerExportFile(fileName) succeeded.
* @param fileName The file name.
*/
export const explorerDidExportFile = createAction((fileName: string) => ({
type: 'explorer.action.didExportFile',
fileName,
}));
/**
* Indicates that explorerExportFile(fileName) failed.
* @param fileName The file name.
* @param error The error that was raised.
*/
export const explorerDidFailToExportFile = createAction(
(fileName: string, error: Error) => ({
type: 'explorer.action.didFailToExportFile',
fileName,
error,
}),
);
/**
* Action that indicates that {@link explorerRenameFile} succeeded.
*/
+74
View File
@@ -6,11 +6,15 @@ import { FileWithHandle } from 'browser-fs-access';
import { mock } from 'jest-mock-extended';
import { AsyncSaga, uuid } from '../../test';
import {
fileStorageDidFailToOpenFile,
fileStorageDidFailToReadFile,
fileStorageDidFailToRenameFile,
fileStorageDidOpenFile,
fileStorageDidReadFile,
fileStorageDidRenameFile,
fileStorageDidWriteFile,
fileStorageOpenFile,
fileStorageReadFile,
fileStorageRenameFile,
fileStorageWriteFile,
} from '../fileStorage/actions';
@@ -19,10 +23,13 @@ import {
Hub,
explorerCreateNewFile,
explorerDidCreateNewFile,
explorerDidExportFile,
explorerDidFailToExportFile,
explorerDidFailToImportFiles,
explorerDidFailToRenameFile,
explorerDidImportFiles,
explorerDidRenameFile,
explorerExportFile,
explorerImportFiles,
explorerRenameFile,
} from './actions';
@@ -159,3 +166,70 @@ describe('handleExplorerRenameFile', () => {
await saga.end();
});
});
describe('handleExplorerExportFile', () => {
let saga: AsyncSaga;
const testFile = 'test.file';
const testFileId = uuid(0);
const testFileContents = '# test file contents';
const testError = new Error('test error');
beforeEach(async () => {
saga = new AsyncSaga(explorer);
saga.put(explorerExportFile(testFile));
await expect(saga.take()).resolves.toEqual(fileStorageOpenFile(testFile));
});
it('should fail if file does not exist', async () => {
saga.put(fileStorageDidFailToOpenFile(testFile, testError));
await expect(saga.take()).resolves.toEqual(
explorerDidFailToExportFile(testFile, testError),
);
});
describe('should read file', () => {
beforeEach(async () => {
saga.put(fileStorageDidOpenFile(testFile, testFileId));
await expect(saga.take()).resolves.toEqual(fileStorageReadFile(testFileId));
});
it('should catch read error', async () => {
saga.put(fileStorageDidFailToReadFile(testFileId, testError));
await expect(saga.take()).resolves.toEqual(
explorerDidFailToExportFile(testFile, testError),
);
});
describe('should read file', () => {
it('should export file', async () => {
jest.spyOn(browserFsAccess, 'fileSave').mockResolvedValue(null);
saga.put(fileStorageDidReadFile(testFileId, testFileContents));
await expect(saga.take()).resolves.toEqual(
explorerDidExportFile('test.file'),
);
expect(browserFsAccess.fileSave).toHaveBeenCalled();
});
it('should catch error', async () => {
jest.spyOn(browserFsAccess, 'fileSave').mockRejectedValue(testError);
saga.put(fileStorageDidReadFile(testFileId, testFileContents));
await expect(saga.take()).resolves.toEqual(
explorerDidFailToExportFile('test.file', testError),
);
});
});
});
afterEach(async () => {
await saga.end();
});
});
+63 -1
View File
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { fileOpen } from 'browser-fs-access';
import { fileOpen, fileSave } from 'browser-fs-access';
import {
call,
put,
@@ -14,12 +14,15 @@ import {
import { getPybricksMicroPythonFileTemplate } from '../editor/pybricksMicroPython';
import {
fileStorageDidFailToOpenFile,
fileStorageDidFailToReadFile,
fileStorageDidFailToRenameFile,
fileStorageDidFailToWriteFile,
fileStorageDidOpenFile,
fileStorageDidReadFile,
fileStorageDidRenameFile,
fileStorageDidWriteFile,
fileStorageOpenFile,
fileStorageReadFile,
fileStorageRenameFile,
fileStorageWriteFile,
} from '../fileStorage/actions';
@@ -35,11 +38,14 @@ import { defined, ensureError } from '../utils';
import {
explorerCreateNewFile,
explorerDidCreateNewFile,
explorerDidExportFile,
explorerDidFailToCreateNewFile,
explorerDidFailToExportFile,
explorerDidFailToImportFiles,
explorerDidFailToRenameFile,
explorerDidImportFiles,
explorerDidRenameFile,
explorerExportFile,
explorerImportFiles,
explorerRenameFile,
} from './actions';
@@ -207,6 +213,61 @@ function* handleExplorerRenameFile(
yield* put(explorerDidRenameFile());
}
function* handleExplorerExportFile(
action: ReturnType<typeof explorerExportFile>,
): Generator {
try {
yield* put(fileStorageOpenFile(action.fileName));
const { didOpen, didFailToOpen } = yield* race({
didOpen: take(
fileStorageDidOpenFile.when((a) => a.path === action.fileName),
),
didFailToOpen: take(
fileStorageDidFailToOpenFile.when((a) => a.path === action.fileName),
),
});
if (didFailToOpen) {
throw didFailToOpen.error;
}
defined(didOpen);
yield* put(fileStorageReadFile(didOpen.id));
const { didRead, didFailToRead } = yield* race({
didRead: take(fileStorageDidReadFile.when((a) => a.id === didOpen.id)),
didFailToRead: take(
fileStorageDidFailToReadFile.when((a) => a.id === didOpen.id),
),
});
if (didFailToRead) {
throw didFailToRead.error;
}
defined(didRead);
const blob = new Blob([didRead.contents], { type: `${pythonFileMimeType}` });
yield* call(() =>
fileSave(blob, {
id: 'pybricksCodeFileStorageExport',
fileName: didOpen.path,
extensions: [pythonFileExtension],
mimeTypes: [pythonFileMimeType],
// TODO: translate description
description: 'Python Files',
}),
);
yield* put(explorerDidExportFile(action.fileName));
} catch (err) {
yield* put(explorerDidFailToExportFile(action.fileName, ensureError(err)));
}
}
export default function* (): Generator {
yield* takeEvery(explorerImportFiles, handleExplorerImportFiles);
yield* takeEvery(explorerCreateNewFile, handleExplorerCreateNewFile);
@@ -214,4 +275,5 @@ export default function* (): Generator {
// previous one is finished, the old one will be canceled. We don't expect
// this to happen in practice though.
yield* takeLatest(explorerRenameFile, handleExplorerRenameFile);
yield* takeLatest(explorerExportFile, handleExplorerExportFile);
}
-31
View File
@@ -231,37 +231,6 @@ export const fileStorageDidFailToRenameFile = createAction(
}),
);
/**
* Request to export (download) a file.
* @param id The file handle UUID.
*/
export const fileStorageExportFile = createAction((fileName: string) => ({
type: 'fileStorage.action.exportFile',
fileName,
}));
/**
* Indicates that fileStorageExportFile(fileName) succeeded.
* @param fileName The file handle UUID.
*/
export const fileStorageDidExportFile = createAction((fileName: string) => ({
type: 'fileStorage.action.didExportFile',
fileName,
}));
/**
* Indicates that fileStorageExportFile(fileName) failed.
* @param fileName The file name.
* @param error The error that was raised.
*/
export const fileStorageDidFailToExportFile = createAction(
(fileName: string, error: Error) => ({
type: 'fileStorage.action.didFailToExportFile',
fileName,
error,
}),
);
/**
* Request to archive (download) all files in the store.
*/
-56
View File
@@ -15,9 +15,7 @@ import {
fileStorageDidArchiveAllFiles,
fileStorageDidChangeItem,
fileStorageDidDeleteFile,
fileStorageDidExportFile,
fileStorageDidFailToArchiveAllFiles,
fileStorageDidFailToExportFile,
fileStorageDidFailToReadFile,
fileStorageDidInitialize,
fileStorageDidOpenFile,
@@ -25,7 +23,6 @@ import {
fileStorageDidRemoveItem,
fileStorageDidRenameFile,
fileStorageDidWriteFile,
fileStorageExportFile,
fileStorageOpenFile,
fileStorageReadFile,
fileStorageRenameFile,
@@ -219,59 +216,6 @@ describe('rename', () => {
await saga.end();
});
});
describe('export', () => {
it('should fail if file does not exist', async () => {
const testFile = 'test.file';
const saga = new AsyncSaga(fileStorage);
await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([]));
saga.put(fileStorageExportFile(testFile));
await expect(saga.take()).resolves.toEqual(
fileStorageDidFailToExportFile(testFile, new Error('file does not exist')),
);
await saga.end();
});
it('should export file', async () => {
const saga = new AsyncSaga(fileStorage);
const [testFile] = await setUpTestFile(saga);
jest.spyOn(browserFsAccess, 'fileSave');
saga.put(fileStorageExportFile(testFile.path));
await expect(saga.take()).resolves.toEqual(
fileStorageDidExportFile(testFile.path),
);
expect(browserFsAccess.fileSave).toHaveBeenCalled();
await saga.end();
});
it('should catch error', async () => {
const saga = new AsyncSaga(fileStorage);
const [testFile] = await setUpTestFile(saga);
const testError = new Error('test error');
jest.spyOn(browserFsAccess, 'fileSave').mockRejectedValue(testError);
saga.put(fileStorageExportFile(testFile.path));
await expect(saga.take()).resolves.toEqual(
fileStorageDidFailToExportFile(testFile.path, testError),
);
await saga.end();
});
});
describe('archive', () => {
it('should archive file', async () => {
const saga = new AsyncSaga(fileStorage);
-54
View File
@@ -13,7 +13,6 @@ import 'dexie-observable';
import JSZip from 'jszip';
import { eventChannel } from 'redux-saga';
import { call, fork, put, take, takeEvery } from 'typed-redux-saga/macro';
import { pythonFileExtension, pythonFileMimeType } from '../pybricksMicropython/lib';
import { ensureError, timestamp } from '../utils';
import { sha256Digest } from '../utils/crypto';
import {
@@ -25,10 +24,8 @@ import {
fileStorageDidArchiveAllFiles,
fileStorageDidChangeItem,
fileStorageDidDeleteFile,
fileStorageDidExportFile,
fileStorageDidFailToArchiveAllFiles,
fileStorageDidFailToDeleteFile,
fileStorageDidFailToExportFile,
fileStorageDidFailToInitialize,
fileStorageDidFailToOpenFile,
fileStorageDidFailToReadFile,
@@ -40,7 +37,6 @@ import {
fileStorageDidRemoveItem,
fileStorageDidRenameFile,
fileStorageDidWriteFile,
fileStorageExportFile,
fileStorageOpenFile,
fileStorageReadFile,
fileStorageRenameFile,
@@ -256,55 +252,6 @@ function* handleWriteFile(
}
}
function* handleExportFile(
db: FileStorageDb,
action: ReturnType<typeof fileStorageExportFile>,
): Generator {
const file = yield* call(() =>
db.transaction('r', db.metadata, db._contents, async () => {
const metadata = await db.metadata
.where('path')
.equals(action.fileName)
.first();
if (!metadata) {
return undefined;
}
return await db._contents.get(metadata.path);
}),
);
if (!file) {
yield* put(
fileStorageDidFailToExportFile(
action.fileName,
new Error('file does not exist'),
),
);
return;
}
const blob = new Blob([file.contents], { type: `${pythonFileMimeType}` });
try {
yield* call(() =>
fileSave(blob, {
id: 'pybricksCodeFileStorageExport',
fileName: file.path,
extensions: [pythonFileExtension],
mimeTypes: [pythonFileMimeType],
// TODO: translate description
description: 'Python Files',
}),
);
yield* put(fileStorageDidExportFile(action.fileName));
} catch (err) {
yield* put(fileStorageDidFailToExportFile(action.fileName, ensureError(err)));
}
}
/**
* Deletes a file from storage.
* @param db The database instance.
@@ -466,7 +413,6 @@ function* initialize(): Generator {
yield* takeEvery(fileStorageWriteFile, handleWriteFile, db);
yield* takeEvery(fileStorageDeleteFile, handleDeleteFile, db);
yield* takeEvery(fileStorageRenameFile, handleRenameFile, db);
yield* takeEvery(fileStorageExportFile, handleExportFile, db);
yield* takeEvery(fileStorageArchiveAllFiles, handleArchiveAllFiles, db);
const files = yield* call(() => db.metadata.toArray());
+3 -4
View File
@@ -16,11 +16,10 @@ export enum I18nId {
ExplorerDeleteFileMessage = 'explorer.deleteFile.message',
ExplorerDeleteFileAction = 'explorer.deleteFile.action',
ExplorerFailedToImportFiles = 'explorer.failedToImportFiles',
ExplorerFailedToCreate = 'explorer.failedToCreate',
ExplorerFailedToExport = 'explorer.failedToExport',
FileStorageFailedToInitialize = 'fileStorage.failedToInitialize',
FileStorageFailedToRead = 'fileStorage.failedToRead',
FileStorageFailedToWrite = 'fileStorage.failedToWrite',
FileStorageFailedToDelete = 'fileStorage.failedToDelete',
FileStorageFailedToExport = 'fileStorage.failedToExport',
FileStorageFailedToArchive = 'fileStorage.failedToArchive',
FlashFirmwareTimedOut = 'flashFirmware.timedOut',
FlashFirmwareBleError = 'flashFirmware.bleError',
FlashFirmwareDisconnected = 'flashFirmware.disconnected',
+5 -5
View File
@@ -19,12 +19,12 @@ import {
import {
explorerDeleteFile,
explorerDidFailToCreateNewFile,
explorerDidFailToExportFile,
explorerDidFailToImportFiles,
} from '../explorer/actions';
import {
fileStorageDeleteFile,
fileStorageDidFailToArchiveAllFiles,
fileStorageDidFailToExportFile,
fileStorageDidFailToInitialize,
fileStorageDidRemoveItem,
} from '../fileStorage/actions';
@@ -112,10 +112,10 @@ test.each([
appDidCheckForUpdate(false),
bleDIServiceDidReceiveFirmwareRevision('3.0.0'),
fileStorageDidFailToInitialize(new Error('test error')),
fileStorageDidFailToExportFile('test.file', new Error('test error')),
fileStorageDidFailToArchiveAllFiles(new Error('test error')),
explorerDidFailToImportFiles(new Error('test error')),
explorerDidFailToCreateNewFile(new Error('test error')),
explorerDidFailToExportFile('test.file', new Error('test error')),
])('actions that should show notification: %o', async (action: AnyAction) => {
const { toaster, saga } = createTestToasterSaga();
@@ -135,12 +135,12 @@ test.each([
serviceWorkerDidSucceed(),
appDidCheckForUpdate(true),
bleDIServiceDidReceiveFirmwareRevision(firmwareVersion),
fileStorageDidFailToExportFile(
fileStorageDidFailToArchiveAllFiles(new DOMException('test message', 'AbortError')),
explorerDidFailToImportFiles(new DOMException('test message', 'AbortError')),
explorerDidFailToExportFile(
'test.file',
new DOMException('test message', 'AbortError'),
),
fileStorageDidFailToArchiveAllFiles(new DOMException('test message', 'AbortError')),
explorerDidFailToImportFiles(new DOMException('test message', 'AbortError')),
])('actions that should not show a notification: %o', async (action: AnyAction) => {
const { toaster, saga } = createTestToasterSaga();
+15 -15
View File
@@ -20,12 +20,12 @@ import {
import {
explorerDeleteFile,
explorerDidFailToCreateNewFile,
explorerDidFailToExportFile,
explorerDidFailToImportFiles,
} from '../explorer/actions';
import {
fileStorageDeleteFile,
fileStorageDidFailToArchiveAllFiles,
fileStorageDidFailToExportFile,
fileStorageDidFailToInitialize,
fileStorageDidRemoveItem,
} from '../fileStorage/actions';
@@ -387,17 +387,6 @@ function* showFileStorageFailToInitialize(
yield* showUnexpectedError(I18nId.FileStorageFailedToInitialize, action.error);
}
function* showFileStorageFailToExport(
action: ReturnType<typeof fileStorageDidFailToExportFile>,
): Generator {
if (action.error.name === 'AbortError') {
// user clicked cancel button - not an error
return;
}
yield* showUnexpectedError(I18nId.FileStorageFailedToExport, action.error);
}
function* showFileStorageFailToArchive(
action: ReturnType<typeof fileStorageDidFailToArchiveAllFiles>,
): Generator {
@@ -406,7 +395,7 @@ function* showFileStorageFailToArchive(
return;
}
yield* showUnexpectedError(I18nId.FileStorageFailedToExport, action.error);
yield* showUnexpectedError(I18nId.FileStorageFailedToArchive, action.error);
}
function* showDeleteFileWarning(action: ReturnType<typeof explorerDeleteFile>) {
@@ -458,7 +447,18 @@ function* showExplorerFailToImportFiles(
function* showExplorerFailToCreateFile(
action: ReturnType<typeof explorerDidFailToCreateNewFile>,
): Generator {
yield* showUnexpectedError(I18nId.FileStorageFailedToDelete, action.error);
yield* showUnexpectedError(I18nId.ExplorerFailedToCreate, action.error);
}
function* showExplorerFailToExport(
action: ReturnType<typeof explorerDidFailToExportFile>,
): Generator {
if (action.error.name === 'AbortError') {
// user clicked cancel button - not an error
return;
}
yield* showUnexpectedError(I18nId.ExplorerFailedToExport, action.error);
}
export default function* (): Generator {
@@ -472,9 +472,9 @@ export default function* (): Generator {
yield* takeEvery(appDidCheckForUpdate, showNoUpdateInfo);
yield* takeEvery(bleDIServiceDidReceiveFirmwareRevision, checkVersion);
yield* takeEvery(fileStorageDidFailToInitialize, showFileStorageFailToInitialize);
yield* takeEvery(fileStorageDidFailToExportFile, showFileStorageFailToExport);
yield* takeEvery(fileStorageDidFailToArchiveAllFiles, showFileStorageFailToArchive);
yield* takeEvery(explorerDeleteFile, showDeleteFileWarning);
yield* takeEvery(explorerDidFailToImportFiles, showExplorerFailToImportFiles);
yield* takeEvery(explorerDidFailToCreateNewFile, showExplorerFailToCreateFile);
yield* takeEvery(explorerDidFailToExportFile, showExplorerFailToExport);
}
+4 -5
View File
@@ -19,14 +19,13 @@
"message": "The file {fileName} will be permanently deleted. This cannot be undone.",
"action": "Delete"
},
"failedToImportFiles": "Failed to import file(s)"
"failedToImportFiles": "Failed to import file(s).",
"failedToCreate": "Failed to create file.",
"failedToExport": "Failed to export file."
},
"fileStorage": {
"failedToInitialize": "Failed to initial file storage. Changes will not be automatically saved.",
"failedToRead": "Failed to read file.",
"failedToWrite": "Failed to write file.",
"failedToDelete": "Failed to delete file.",
"failedToExport": "Failed to export file.'"
"failedToArchive": "Failed to archive files.'"
},
"flashFirmware": {
"timedOut": "The hub took too long to respond. Restart the hub and try again.",