explorer: implement import action

This commit is contained in:
David Lechner
2022-03-11 15:41:16 -06:00
parent 7b095765ac
commit 93111b6af4
12 changed files with 182 additions and 10 deletions
+2 -1
View File
@@ -276,8 +276,9 @@ module.exports = {
'[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs|cjs|ts|tsx)$',
);
// https://github.com/react-monaco-editor/react-monaco-editor/issues/306#issuecomment-701025628
// https://github.com/GoogleChromeLabs/browser-fs-access/issues/42
jestConfig.transformIgnorePatterns[index] =
'[/\\\\]node_modules[/\\\\](?!(monaco-editor|react-monaco-editor)[/\\\\]).+\\.(js|jsx|mjs|cjs|ts|tsx)$';
'[/\\\\]node_modules[/\\\\](?!(monaco-editor|react-monaco-editor|browser-fs-access)[/\\\\]).+\\.(js|jsx|mjs|cjs|ts|tsx)$';
return jestConfig;
},
},
+1
View File
@@ -32,6 +32,7 @@
"@types/wicg-file-system-access": "^2020.9.5",
"@types/zen-push": "^0.1.1",
"babel-plugin-macros": "^3.0.1",
"browser-fs-access": "^0.25.0",
"canvas": "^2.9.0",
"copy-webpack-plugin": "^6.4.1",
"file-saver": "^2.0.5",
+12 -1
View File
@@ -10,7 +10,7 @@ import {
fileStorageExportFile,
} from '../fileStorage/actions';
import Explorer from './Explorer';
import { explorerDeleteFile } from './actions';
import { explorerDeleteFile, explorerImportFiles } from './actions';
describe('archive button', () => {
it('should be enabled if there are files', () => {
@@ -38,6 +38,17 @@ describe('archive button', () => {
});
});
describe('import file button', () => {
it('should dispatch action when clicked', async () => {
const [explorer, dispatch] = testRender(<Explorer />);
const button = explorer.getByTitle('Import a file');
userEvent.click(button);
expect(dispatch).toHaveBeenCalledWith(explorerImportFiles());
});
});
describe('new file button', () => {
it('should show new file wizard', async () => {
const [explorer] = testRender(<Explorer />);
+2 -2
View File
@@ -20,7 +20,7 @@ import {
} from '../fileStorage/actions';
import { useSelector } from '../reducers';
import NewFileWizard from './NewFileWizard';
import { explorerDeleteFile } from './actions';
import { explorerDeleteFile, explorerImportFiles } from './actions';
import { ExplorerStringId } from './i18n';
import en from './i18n.en.json';
@@ -122,7 +122,7 @@ const Header: React.VFC = () => {
// even though this is the "import" action
icon="export"
toolTipId={ExplorerStringId.HeaderImportTooltip}
onClick={() => alert('not implemented')}
onClick={() => dispatch(explorerImportFiles())}
/>
<ActionButton
icon="plus"
+23
View File
@@ -23,6 +23,29 @@ export enum Hub {
Essential = 'essentialhub',
}
/**
* Action that requests to import (upload) files into the app.
*/
export const explorerImportFiles = createAction(() => ({
type: 'explorer.action.importFiles',
}));
/**
* Action that indicates that explorerImportFiles() succeeded.
*/
export const explorerDidImportFiles = createAction(() => ({
type: 'explorer.action.didImportFiles',
}));
/**
* Action that indicates that explorerImportFiles() failed.
* @param error The error.
*/
export const explorerDidFailToImportFiles = createAction((error: Error) => ({
type: 'explorer.action.didFailToImportFiles',
error,
}));
/**
* Action that requests to create a new file.
* @param fileName The requested new file name (without file extension).
+52 -1
View File
@@ -1,11 +1,62 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import * as browserFsAccess from 'browser-fs-access';
import { FileWithHandle } from 'browser-fs-access';
import { mock } from 'jest-mock-extended';
import { AsyncSaga } from '../../test';
import { fileStorageWriteFile } from '../fileStorage/actions';
import { pythonFileExtension } from '../pybricksMicropython/lib';
import { Hub, explorerCreateNewFile } from './actions';
import {
Hub,
explorerCreateNewFile,
explorerDidFailToImportFiles,
explorerDidImportFiles,
explorerImportFiles,
} from './actions';
import explorer from './sagas';
describe('handleExplorerImportFiles', () => {
it('should write file to storage', async () => {
const testFileName = 'test.py';
const testFileContents = '# test';
const saga = new AsyncSaga(explorer, { fileStorage: { fileNames: [] } });
jest.spyOn(browserFsAccess, 'fileOpen').mockResolvedValueOnce([
mock<FileWithHandle>({
name: testFileName,
text: () => Promise.resolve(testFileContents),
}),
]);
saga.put(explorerImportFiles());
const action = await saga.take();
expect(action).toEqual(fileStorageWriteFile(testFileName, testFileContents));
const action2 = await saga.take();
expect(action2).toEqual(explorerDidImportFiles());
await saga.end();
});
it('should handle user cancellation', async () => {
const cancelError = new DOMException('test message', 'AbortError');
const saga = new AsyncSaga(explorer);
jest.spyOn(browserFsAccess, 'fileOpen').mockRejectedValueOnce(cancelError);
saga.put(explorerImportFiles());
const action = await saga.take();
expect(action).toEqual(explorerDidFailToImportFiles(cancelError));
await saga.end();
});
});
describe('handleExplorerCreateNewFile', () => {
it('should dispatch fileStorage action', async () => {
const saga = new AsyncSaga(explorer);
+66 -2
View File
@@ -1,10 +1,73 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { put, takeEvery } from 'typed-redux-saga/macro';
import { fileOpen } from 'browser-fs-access';
import { call, put, select, takeEvery } from 'typed-redux-saga/macro';
import { getPybricksMicroPythonFileTemplate } from '../editor/pybricksMicroPython';
import { fileStorageWriteFile } from '../fileStorage/actions';
import { explorerCreateNewFile } from './actions';
import {
FileNameValidationResult,
pythonFileExtension,
pythonFileExtensionRegex,
pythonFileMimeType,
validateFileName,
} from '../pybricksMicropython/lib';
import { RootState } from '../reducers';
import { ensureError } from '../utils';
import {
explorerCreateNewFile,
explorerDidFailToImportFiles,
explorerDidImportFiles,
explorerImportFiles,
} from './actions';
function* handleExplorerImportFiles(): Generator {
try {
const selectedFiles = yield* call(() =>
fileOpen({
id: 'pybricks-code-explorer-import',
mimeTypes: [pythonFileMimeType],
extensions: [pythonFileExtension],
// TODO: translate description
description: 'Python Files',
multiple: true,
excludeAcceptAllOption: true,
}),
);
for (const file of selectedFiles) {
// getting the text now to catch possible error *before* user interaction
const text = yield* call(() => file.text());
const [baseName] = file.name.split(pythonFileExtensionRegex);
const existingFiles = yield* select(
(s: RootState) => s.fileStorage.fileNames,
);
const result = validateFileName(
baseName,
pythonFileExtension,
existingFiles,
);
if (result != FileNameValidationResult.IsOk) {
// TODO: validate file name and allow user to rename or skip
console.error(
'skipping file',
file.name,
FileNameValidationResult[result],
);
continue;
}
yield* put(fileStorageWriteFile(`${baseName}${pythonFileExtension}`, text));
}
yield* put(explorerDidImportFiles());
} catch (err) {
yield* put(explorerDidFailToImportFiles(ensureError(err)));
}
}
function* handleExplorerCreateNewFile(
action: ReturnType<typeof explorerCreateNewFile>,
@@ -20,5 +83,6 @@ function* handleExplorerCreateNewFile(
}
export default function* (): Generator {
yield* takeEvery(explorerImportFiles, handleExplorerImportFiles);
yield* takeEvery(explorerCreateNewFile, handleExplorerCreateNewFile);
}
+2 -1
View File
@@ -18,7 +18,8 @@
"deleteFile": {
"message": "The file {fileName} will be permanently deleted. This cannot be undone.",
"action": "Delete"
}
},
"failedToImportFiles": "Failed to import file(s)"
},
"fileStorage": {
"failedToInitialize": "Failed to initial file storage. Changes will not be automatically saved.",
+1
View File
@@ -15,6 +15,7 @@ export enum MessageId {
EditorFailedToSaveFile = 'editor.failedToSaveFile',
ExplorerDeleteFileMessage = 'explorer.deleteFile.message',
ExplorerDeleteFileAction = 'explorer.deleteFile.action',
ExplorerFailedToImportFiles = 'explorer.failedToImportFiles',
FileStorageFailedToInitialize = 'fileStorage.failedToInitialize',
FileStorageFailedToRead = 'fileStorage.failedToRead',
FileStorageFailedToWrite = 'fileStorage.failedToWrite',
+3 -1
View File
@@ -17,7 +17,7 @@ import {
didFailToConnect as bleDidFailToConnect,
} from '../ble/actions';
import { didFailToSaveAs } from '../editor/actions';
import { explorerDeleteFile } from '../explorer/actions';
import { explorerDeleteFile, explorerDidFailToImportFiles } from '../explorer/actions';
import {
fileStorageDeleteFile,
fileStorageDidFailToArchiveAllFiles,
@@ -118,6 +118,7 @@ test.each([
fileStorageDidFailToDeleteFile('test.file', new Error('test error')),
fileStorageDidFailToExportFile('test.file', new Error('test error')),
fileStorageDidFailToArchiveAllFiles(new Error('test error')),
explorerDidFailToImportFiles(new Error('test error')),
])('actions that should show notification: %o', async (action: AnyAction) => {
const { toaster, saga } = createTestToasterSaga();
@@ -143,6 +144,7 @@ test.each([
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();
+13 -1
View File
@@ -18,7 +18,7 @@ import {
didFailToConnect as bleDeviceDidFailToConnect,
} from '../ble/actions';
import { didFailToSaveAs } from '../editor/actions';
import { explorerDeleteFile } from '../explorer/actions';
import { explorerDeleteFile, explorerDidFailToImportFiles } from '../explorer/actions';
import {
fileStorageDeleteFile,
fileStorageDidFailToArchiveAllFiles,
@@ -480,6 +480,17 @@ function* showDeleteFileWarning(action: ReturnType<typeof explorerDeleteFile>) {
yield* put(fileStorageDeleteFile(action.fileName));
}
function* showExplorerFailToImportFiles(
action: ReturnType<typeof explorerDidFailToImportFiles>,
): Generator {
if (action.error.name === 'AbortError') {
// user clicked cancel button - not an error
return;
}
yield* showUnexpectedError(MessageId.ExplorerFailedToImportFiles, action.error);
}
export default function* (): Generator {
yield* takeEvery(bleDeviceDidFailToConnect, showBleDeviceDidFailToConnectError);
yield* takeEvery(bootloaderDidFailToConnect, showBootloaderDidFailToConnectError);
@@ -498,4 +509,5 @@ export default function* (): Generator {
yield* takeEvery(fileStorageDidFailToExportFile, showFileStorageFailToExport);
yield* takeEvery(fileStorageDidFailToArchiveAllFiles, showFileStorageFailToArchive);
yield* takeEvery(explorerDeleteFile, showDeleteFileWarning);
yield* takeEvery(explorerDidFailToImportFiles, showExplorerFailToImportFiles);
}
+5
View File
@@ -3369,6 +3369,11 @@ brorand@^1.0.1, brorand@^1.1.0:
resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f"
integrity sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=
browser-fs-access@^0.25.0:
version "0.25.0"
resolved "https://registry.yarnpkg.com/browser-fs-access/-/browser-fs-access-0.25.0.tgz#3b32fd86cc6e3ae18136225acdcae932e3b3fb43"
integrity sha512-ovMv4bO/+dPXTqpBpzvrW3N66oJhhNkGZZpE+1vZhQDWmU0FOTc2lD2T0AOuaFOSV0LV71j4+UFnYE1RW2XY2Q==
browser-process-hrtime@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz#3c9b4b7d782c8121e56f10106d84c0d0ffc94626"