mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-12 09:36:27 +00:00
explorer: implement import action
This commit is contained in:
@@ -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 />);
|
||||
|
||||
@@ -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,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).
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user