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);
}