mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-12 09:36:27 +00:00
explorer: add support for importing ZIP
Also add a new dialog to get user input when file names conflict. Previously, we were not supplying a list of existing files, so existing files were silently written over. Fixes: https://github.com/pybricks/support/issues/833
This commit is contained in:
committed by
David Lechner
parent
d9e8a794d0
commit
6a9405e653
@@ -1,5 +1,5 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
// Copyright (c) 2022-2023 The Pybricks Authors
|
||||
|
||||
// A file explorer control.
|
||||
|
||||
@@ -58,6 +58,7 @@ import { useI18n } from './i18n';
|
||||
import NewFileWizard from './newFileWizard/NewFileWizard';
|
||||
import RenameFileDialog from './renameFileDialog/RenameFileDialog';
|
||||
import RenameImportDialog from './renameImportDialog/RenameImportDialog';
|
||||
import ReplaceImportDialog from './replaceImportDialog/ReplaceImportDialog';
|
||||
|
||||
type ActionButtonProps = {
|
||||
/** The DOM id for this instance. */
|
||||
@@ -455,6 +456,7 @@ const Explorer: React.VFC = () => {
|
||||
<NewFileWizard />
|
||||
<RenameFileDialog />
|
||||
<RenameImportDialog />
|
||||
<ReplaceImportDialog />
|
||||
<DuplicateFileDialog />
|
||||
<DeleteFileAlert />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2023 The Pybricks Authors
|
||||
|
||||
import { Intent } from '@blueprintjs/core';
|
||||
import React from 'react';
|
||||
import { pythonFileExtension } from '../../pybricksMicropython/lib';
|
||||
import type { CreateToast } from '../../toasterTypes';
|
||||
import { useI18n } from './i18n';
|
||||
|
||||
const NoPyFiles: React.VoidFunctionComponent = () => {
|
||||
const i18n = useI18n();
|
||||
return (
|
||||
<>
|
||||
{i18n.translate('noPyFiles.message', {
|
||||
py: <code>{pythonFileExtension}</code>,
|
||||
zip: 'ZIP',
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const noPyFiles: CreateToast = (onAction) => ({
|
||||
message: <NoPyFiles />,
|
||||
icon: 'info-sign',
|
||||
intent: Intent.PRIMARY,
|
||||
onDismiss: () => onAction('dismiss'),
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
// Copyright (c) 2022-2023 The Pybricks Authors
|
||||
|
||||
import { fileInUse } from './FileInUseAlert';
|
||||
import { noFilesToBackup } from './NoFilesToBackup';
|
||||
import { noPyFiles } from './NoPyFiles';
|
||||
|
||||
// gathers all of the alert creation functions for passing up to the top level
|
||||
export default { fileInUse, noFilesToBackup };
|
||||
export default { fileInUse, noFilesToBackup, noPyFiles };
|
||||
|
||||
@@ -4,5 +4,8 @@
|
||||
},
|
||||
"noFilesToBackup": {
|
||||
"message": "There are no files to backup. Create a new file first by clicking the {icon} icon."
|
||||
},
|
||||
"noPyFiles": {
|
||||
"message": "There were no {py} files in the {zip} file."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
// Copyright (c) 2022-2023 The Pybricks Authors
|
||||
|
||||
import { combineReducers } from 'redux';
|
||||
|
||||
@@ -8,6 +8,7 @@ import duplicateFileDialog from './duplicateFileDialog/reducers';
|
||||
import newFileWizard from './newFileWizard/reducers';
|
||||
import renameFileDialog from './renameFileDialog/reducers';
|
||||
import renameImportDialog from './renameImportDialog/reducers';
|
||||
import replaceImportDialog from './replaceImportDialog/reducers';
|
||||
|
||||
export default combineReducers({
|
||||
duplicateFileDialog,
|
||||
@@ -15,4 +16,5 @@ export default combineReducers({
|
||||
newFileWizard,
|
||||
renameFileDialog,
|
||||
renameImportDialog,
|
||||
replaceImportDialog,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022-2023 The Pybricks Authors
|
||||
|
||||
import { waitFor } from '@testing-library/dom';
|
||||
import React from 'react';
|
||||
import { testRender } from '../../../test';
|
||||
import RenameImportDialog from './ReplaceImportDialog';
|
||||
import {
|
||||
ReplaceImportDialogAction,
|
||||
replaceImportDialogDidAccept,
|
||||
replaceImportDialogDidCancel,
|
||||
} from './actions';
|
||||
|
||||
describe('replace button', () => {
|
||||
it.each([
|
||||
[/skip/i, ReplaceImportDialogAction.Skip, false],
|
||||
[/skip/i, ReplaceImportDialogAction.Skip, true],
|
||||
[/replace/i, ReplaceImportDialogAction.Replace, false],
|
||||
[/replace/i, ReplaceImportDialogAction.Replace, true],
|
||||
[/rename/i, ReplaceImportDialogAction.Rename, false],
|
||||
[/rename/i, ReplaceImportDialogAction.Rename, true],
|
||||
])(
|
||||
'should accept when %c%s button is clicked and remember checkbox is %s',
|
||||
async (buttonName, action, remember) => {
|
||||
const [user, dialog, dispatch] = testRender(<RenameImportDialog />, {
|
||||
explorer: {
|
||||
replaceImportDialog: { isOpen: true, fileName: 'old.file' },
|
||||
},
|
||||
});
|
||||
|
||||
if (remember) {
|
||||
const rememberCheckBox = dialog.getByRole('checkbox', {
|
||||
name: /remember/i,
|
||||
});
|
||||
await user.click(rememberCheckBox);
|
||||
}
|
||||
|
||||
const button = dialog.getByRole('button', { name: buttonName });
|
||||
await user.click(button);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
replaceImportDialogDidAccept(action, remember),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('should cancel when close button is clicked', async () => {
|
||||
const [user, dialog, dispatch] = testRender(<RenameImportDialog />, {
|
||||
explorer: { replaceImportDialog: { isOpen: true } },
|
||||
});
|
||||
|
||||
const button = dialog.getByRole('button', { name: 'Close' });
|
||||
|
||||
await waitFor(() => expect(button).toBeVisible());
|
||||
|
||||
await user.click(button);
|
||||
expect(dispatch).toHaveBeenCalledWith(replaceImportDialogDidCancel());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022-2023 The Pybricks Authors
|
||||
|
||||
import './replaceImportDialog.scss';
|
||||
import { Button, Checkbox, Classes, Dialog, Intent } from '@blueprintjs/core';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useSelector } from '../../reducers';
|
||||
import {
|
||||
ReplaceImportDialogAction,
|
||||
replaceImportDialogDidAccept,
|
||||
replaceImportDialogDidCancel,
|
||||
} from './actions';
|
||||
import { useI18n } from './i18n';
|
||||
|
||||
const RenameImportDialog: React.VFC = () => {
|
||||
const i18n = useI18n();
|
||||
const dispatch = useDispatch();
|
||||
const isOpen = useSelector((s) => s.explorer.replaceImportDialog.isOpen);
|
||||
const fileName = useSelector((s) => s.explorer.replaceImportDialog.fileName);
|
||||
const [remember, setRemember] = useState(false);
|
||||
|
||||
const handleSubmit = useCallback<React.FormEventHandler>(
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
dispatch(
|
||||
replaceImportDialogDidAccept(
|
||||
((e.nativeEvent as SubmitEvent).submitter as HTMLButtonElement)
|
||||
.value as ReplaceImportDialogAction,
|
||||
remember,
|
||||
),
|
||||
);
|
||||
},
|
||||
[dispatch, remember],
|
||||
);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
dispatch(replaceImportDialogDidCancel());
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
className="pb-explorer-replaceImportDialog"
|
||||
title={i18n.translate('title')}
|
||||
isOpen={isOpen}
|
||||
onOpening={() => setRemember(false)}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<form onSubmit={handleSubmit} method="dialog">
|
||||
<div className={Classes.DIALOG_BODY}>
|
||||
<p>{i18n.translate('message', { fileName })}</p>
|
||||
</div>
|
||||
<div className={Classes.DIALOG_FOOTER}>
|
||||
<Checkbox
|
||||
checked={remember}
|
||||
onChange={(e) =>
|
||||
setRemember((e.target as HTMLInputElement).checked)
|
||||
}
|
||||
>
|
||||
{i18n.translate('option.remember')}
|
||||
</Checkbox>
|
||||
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
|
||||
<Button
|
||||
intent="none"
|
||||
type="submit"
|
||||
value={ReplaceImportDialogAction.Skip}
|
||||
>
|
||||
{i18n.translate('action.skip')}
|
||||
</Button>
|
||||
<Button
|
||||
intent={Intent.DANGER}
|
||||
type="submit"
|
||||
value={ReplaceImportDialogAction.Replace}
|
||||
>
|
||||
{i18n.translate('action.replace')}
|
||||
</Button>
|
||||
<Button
|
||||
intent={Intent.PRIMARY}
|
||||
type="submit"
|
||||
value={ReplaceImportDialogAction.Rename}
|
||||
>
|
||||
{i18n.translate('action.rename')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default RenameImportDialog;
|
||||
@@ -0,0 +1,37 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2023 The Pybricks Authors
|
||||
|
||||
import { createAction } from '../../actions';
|
||||
|
||||
/**
|
||||
* Action that requests to show the replace file dialog.
|
||||
* @param fileName The file name.
|
||||
*/
|
||||
export const replaceImportDialogShow = createAction((fileName: string) => ({
|
||||
type: 'explorer.replaceImportDialog.action.show',
|
||||
fileName,
|
||||
}));
|
||||
|
||||
export enum ReplaceImportDialogAction {
|
||||
Skip = 'skip',
|
||||
Replace = 'replace',
|
||||
Rename = 'rename',
|
||||
}
|
||||
|
||||
/**
|
||||
* Action that indicates the replace file dialog was accepted.
|
||||
*/
|
||||
export const replaceImportDialogDidAccept = createAction(
|
||||
(action: ReplaceImportDialogAction, remember: boolean) => ({
|
||||
type: 'explorer.replaceImportDialog.action.didAccept',
|
||||
action,
|
||||
remember,
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Action that indicates the replace file dialog was canceled.
|
||||
*/
|
||||
export const replaceImportDialogDidCancel = createAction(() => ({
|
||||
type: 'explorer.replaceImportDialog.action.didCancel',
|
||||
}));
|
||||
@@ -0,0 +1,12 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
import { useI18n as useShopifyI18n } from '@shopify/react-i18n';
|
||||
import type { TypedI18n } from '../../i18n';
|
||||
import type translations from './translations/en.json';
|
||||
|
||||
export function useI18n(): TypedI18n<typeof translations> {
|
||||
// istanbul ignore next: babel-loader rewrites this line
|
||||
const [i18n] = useShopifyI18n();
|
||||
return i18n;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022-2023 The Pybricks Authors
|
||||
|
||||
import { Reducer, combineReducers } from 'redux';
|
||||
import {
|
||||
replaceImportDialogDidAccept,
|
||||
replaceImportDialogDidCancel,
|
||||
replaceImportDialogShow,
|
||||
} from './actions';
|
||||
|
||||
/** Controls the replace file dialog isOpen state. */
|
||||
const isOpen: Reducer<boolean> = (state = false, action) => {
|
||||
if (replaceImportDialogShow.matches(action)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
replaceImportDialogDidAccept.matches(action) ||
|
||||
replaceImportDialogDidCancel.matches(action)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
/** Controls the replace file dialog file name input box text. */
|
||||
const fileName: Reducer<string> = (state = '', action) => {
|
||||
if (replaceImportDialogShow.matches(action)) {
|
||||
return action.fileName;
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
export default combineReducers({ isOpen, fileName });
|
||||
@@ -0,0 +1,10 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2023 The Pybricks Authors
|
||||
|
||||
@use '@blueprintjs/core/lib/scss/variables' as bp;
|
||||
|
||||
.#{bp.$ns}-dialog.pb-explorer-replaceImportDialog {
|
||||
.#{bp.$ns}-dialog-footer-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"title": "Replace existing file?",
|
||||
"message": "A file already exists with the same name as the imported file '{fileName}'.",
|
||||
"option": {
|
||||
"remember": "Remember this answer when resolving additional conflicts."
|
||||
},
|
||||
"action": {
|
||||
"skip": "Keep the existing file and skip importing this file",
|
||||
"replace": "Replace the existing file with the imported file",
|
||||
"rename": "Keep the existing file and rename the imported file"
|
||||
}
|
||||
}
|
||||
+336
-13
@@ -1,9 +1,11 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
// Copyright (c) 2022-2023 The Pybricks Authors
|
||||
|
||||
import * as browserFsAccess from 'browser-fs-access';
|
||||
import { FileWithHandle } from 'browser-fs-access';
|
||||
import Dexie from 'dexie';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import JSZip from 'jszip';
|
||||
import { AsyncSaga, uuid } from '../../test';
|
||||
import { alertsShowAlert } from '../alerts/actions';
|
||||
import { Hub } from '../components/hubPicker';
|
||||
@@ -15,7 +17,7 @@ import {
|
||||
editorDidFailToActivateFile,
|
||||
} from '../editor/actions';
|
||||
import { EditorError } from '../editor/error';
|
||||
import { UUID } from '../fileStorage';
|
||||
import { FileMetadata, FileStorageDb, UUID } from '../fileStorage';
|
||||
import {
|
||||
fileStorageCopyFile,
|
||||
fileStorageDeleteFile,
|
||||
@@ -86,6 +88,11 @@ import {
|
||||
renameImportDialogDidAccept,
|
||||
renameImportDialogShow,
|
||||
} from './renameImportDialog/actions';
|
||||
import {
|
||||
ReplaceImportDialogAction,
|
||||
replaceImportDialogDidAccept,
|
||||
replaceImportDialogShow,
|
||||
} from './replaceImportDialog/actions';
|
||||
import explorer from './sagas';
|
||||
|
||||
jest.mock('browser-fs-access');
|
||||
@@ -190,17 +197,38 @@ describe('handleExplorerArchiveAllFiles', () => {
|
||||
});
|
||||
|
||||
describe('handleExplorerImportFiles', () => {
|
||||
const mockFileStorage = (...files: FileMetadata[]) => {
|
||||
return mock<FileStorageDb>({
|
||||
metadata: { toArray: () => Dexie.Promise.resolve(files) },
|
||||
});
|
||||
};
|
||||
|
||||
const mockPythonFile = (name: string, contents: string) => {
|
||||
return mock<FileWithHandle>({
|
||||
name,
|
||||
type: '',
|
||||
text: () => Promise.resolve(contents),
|
||||
});
|
||||
};
|
||||
|
||||
const mockZipFile = (name: string, contents: ArrayBuffer) => {
|
||||
return mock<FileWithHandle>({
|
||||
name,
|
||||
type: 'application/zip',
|
||||
arrayBuffer: () => Promise.resolve(contents),
|
||||
});
|
||||
};
|
||||
|
||||
it('should write file to storage', async () => {
|
||||
const testFileName = 'test.py';
|
||||
const testFileContents = '# test';
|
||||
|
||||
const saga = new AsyncSaga(explorer);
|
||||
const saga = new AsyncSaga(explorer, {
|
||||
fileStorage: mockFileStorage(),
|
||||
});
|
||||
|
||||
jest.spyOn(browserFsAccess, 'fileOpen').mockResolvedValueOnce([
|
||||
mock<FileWithHandle>({
|
||||
name: testFileName,
|
||||
text: () => Promise.resolve(testFileContents),
|
||||
}),
|
||||
mockPythonFile(testFileName, testFileContents),
|
||||
]);
|
||||
|
||||
saga.put(explorerImportFiles());
|
||||
@@ -219,7 +247,9 @@ describe('handleExplorerImportFiles', () => {
|
||||
it('should handle user cancellation', async () => {
|
||||
const cancelError = new DOMException('test message', 'AbortError');
|
||||
|
||||
const saga = new AsyncSaga(explorer);
|
||||
const saga = new AsyncSaga(explorer, {
|
||||
fileStorage: mockFileStorage(),
|
||||
});
|
||||
|
||||
jest.spyOn(browserFsAccess, 'fileOpen').mockRejectedValueOnce(cancelError);
|
||||
|
||||
@@ -235,13 +265,12 @@ describe('handleExplorerImportFiles', () => {
|
||||
const testFileName = 'bad#name.py';
|
||||
const testFileContents = '# test';
|
||||
|
||||
const saga = new AsyncSaga(explorer);
|
||||
const saga = new AsyncSaga(explorer, {
|
||||
fileStorage: mockFileStorage(),
|
||||
});
|
||||
|
||||
jest.spyOn(browserFsAccess, 'fileOpen').mockResolvedValueOnce([
|
||||
mock<FileWithHandle>({
|
||||
name: testFileName,
|
||||
text: () => Promise.resolve(testFileContents),
|
||||
}),
|
||||
mockPythonFile(testFileName, testFileContents),
|
||||
]);
|
||||
|
||||
saga.put(explorerImportFiles());
|
||||
@@ -264,6 +293,300 @@ describe('handleExplorerImportFiles', () => {
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
|
||||
describe('duplicate file name', () => {
|
||||
it.each([false, true])(
|
||||
'should handle user selected replace and remember is %s',
|
||||
async (remember) => {
|
||||
const testFileName1 = 'test1.py';
|
||||
const testFileContents1 = '# test';
|
||||
const testFileUuid1 = uuid(1);
|
||||
|
||||
const testFileName2 = 'test2.py';
|
||||
const testFileContents2 = '# test';
|
||||
const testFileUuid2 = uuid(2);
|
||||
|
||||
const saga = new AsyncSaga(explorer, {
|
||||
fileStorage: mockFileStorage(
|
||||
{
|
||||
uuid: testFileUuid1,
|
||||
path: testFileName1,
|
||||
sha256: '',
|
||||
viewState: null,
|
||||
},
|
||||
{
|
||||
uuid: testFileUuid2,
|
||||
path: testFileName2,
|
||||
sha256: '',
|
||||
viewState: null,
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
jest.spyOn(browserFsAccess, 'fileOpen').mockResolvedValueOnce([
|
||||
mockPythonFile(testFileName1, testFileContents1),
|
||||
mockPythonFile(testFileName2, testFileContents2),
|
||||
]);
|
||||
|
||||
saga.put(explorerImportFiles());
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
replaceImportDialogShow(testFileName1),
|
||||
);
|
||||
|
||||
saga.put(
|
||||
replaceImportDialogDidAccept(
|
||||
ReplaceImportDialogAction.Replace,
|
||||
remember,
|
||||
),
|
||||
);
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageWriteFile(testFileName1, testFileContents1),
|
||||
);
|
||||
|
||||
saga.put(fileStorageDidWriteFile(testFileName1, testFileUuid1));
|
||||
|
||||
if (!remember) {
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
replaceImportDialogShow(testFileName2),
|
||||
);
|
||||
|
||||
saga.put(
|
||||
replaceImportDialogDidAccept(
|
||||
ReplaceImportDialogAction.Replace,
|
||||
remember,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageWriteFile(testFileName2, testFileContents2),
|
||||
);
|
||||
|
||||
saga.put(fileStorageDidWriteFile(testFileName2, testFileUuid2));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(explorerDidImportFiles());
|
||||
|
||||
await saga.end();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([false, true])(
|
||||
'should handle user selected rename and remember is %s',
|
||||
async (remember) => {
|
||||
const testFileName1 = 'test1.py';
|
||||
const testFileContents1 = '# test';
|
||||
const testFileUuid1 = uuid(1);
|
||||
|
||||
const testFileName2 = 'test2.py';
|
||||
const testFileContents2 = '# test';
|
||||
const testFileUuid2 = uuid(2);
|
||||
|
||||
const saga = new AsyncSaga(explorer, {
|
||||
fileStorage: mockFileStorage(
|
||||
{
|
||||
uuid: testFileUuid1,
|
||||
path: testFileName1,
|
||||
sha256: '',
|
||||
viewState: null,
|
||||
},
|
||||
{
|
||||
uuid: testFileUuid2,
|
||||
path: testFileName2,
|
||||
sha256: '',
|
||||
viewState: null,
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
jest.spyOn(browserFsAccess, 'fileOpen').mockResolvedValueOnce([
|
||||
mockPythonFile(testFileName1, testFileContents1),
|
||||
mockPythonFile(testFileName2, testFileContents2),
|
||||
]);
|
||||
|
||||
saga.put(explorerImportFiles());
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
replaceImportDialogShow(testFileName1),
|
||||
);
|
||||
|
||||
saga.put(
|
||||
replaceImportDialogDidAccept(
|
||||
ReplaceImportDialogAction.Rename,
|
||||
remember,
|
||||
),
|
||||
);
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
renameImportDialogShow(testFileName1),
|
||||
);
|
||||
|
||||
const renamedFileName1 = 'good_name1.py';
|
||||
const renamedFileUuid1 = uuid(1);
|
||||
|
||||
saga.put(renameImportDialogDidAccept(testFileName1, renamedFileName1));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageWriteFile(renamedFileName1, testFileContents1),
|
||||
);
|
||||
|
||||
saga.put(fileStorageDidWriteFile(renamedFileName1, renamedFileUuid1));
|
||||
|
||||
if (!remember) {
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
replaceImportDialogShow(testFileName2),
|
||||
);
|
||||
|
||||
saga.put(
|
||||
replaceImportDialogDidAccept(
|
||||
ReplaceImportDialogAction.Rename,
|
||||
remember,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
renameImportDialogShow(testFileName2),
|
||||
);
|
||||
|
||||
const renamedFileName2 = 'good_name2.py';
|
||||
const renamedFileUuid2 = uuid(2);
|
||||
|
||||
saga.put(renameImportDialogDidAccept(testFileName2, renamedFileName2));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageWriteFile(renamedFileName2, testFileContents2),
|
||||
);
|
||||
|
||||
saga.put(fileStorageDidWriteFile(renamedFileName2, renamedFileUuid2));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(explorerDidImportFiles());
|
||||
|
||||
await saga.end();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([false, true])(
|
||||
'should handle user selected skip and remember is %s',
|
||||
async (remember) => {
|
||||
const testFileName1 = 'test1.py';
|
||||
const testFileContents1 = '# test';
|
||||
const testFileUuid1 = uuid(1);
|
||||
|
||||
const testFileName2 = 'test2.py';
|
||||
const testFileContents2 = '# test';
|
||||
const testFileUuid2 = uuid(2);
|
||||
|
||||
const saga = new AsyncSaga(explorer, {
|
||||
fileStorage: mockFileStorage(
|
||||
{
|
||||
uuid: testFileUuid1,
|
||||
path: testFileName1,
|
||||
sha256: '',
|
||||
viewState: null,
|
||||
},
|
||||
{
|
||||
uuid: testFileUuid2,
|
||||
path: testFileName2,
|
||||
sha256: '',
|
||||
viewState: null,
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
jest.spyOn(browserFsAccess, 'fileOpen').mockResolvedValueOnce([
|
||||
mockPythonFile(testFileName1, testFileContents1),
|
||||
mockPythonFile(testFileName2, testFileContents2),
|
||||
]);
|
||||
|
||||
saga.put(explorerImportFiles());
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
replaceImportDialogShow(testFileName1),
|
||||
);
|
||||
|
||||
saga.put(
|
||||
replaceImportDialogDidAccept(
|
||||
ReplaceImportDialogAction.Skip,
|
||||
remember,
|
||||
),
|
||||
);
|
||||
|
||||
if (!remember) {
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
replaceImportDialogShow(testFileName2),
|
||||
);
|
||||
|
||||
saga.put(
|
||||
replaceImportDialogDidAccept(
|
||||
ReplaceImportDialogAction.Skip,
|
||||
remember,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(explorerDidImportFiles());
|
||||
|
||||
await saga.end();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle ZIP files', async () => {
|
||||
const testFileName = 'test.py';
|
||||
const testFileContents = '# test';
|
||||
|
||||
const zipFile = new JSZip().file(testFileName, testFileContents);
|
||||
|
||||
const saga = new AsyncSaga(explorer, {
|
||||
fileStorage: mockFileStorage(),
|
||||
});
|
||||
|
||||
jest.spyOn(browserFsAccess, 'fileOpen').mockResolvedValueOnce([
|
||||
mockZipFile(
|
||||
'test.zip',
|
||||
await zipFile.generateAsync({ type: 'arraybuffer' }),
|
||||
),
|
||||
]);
|
||||
|
||||
saga.put(explorerImportFiles());
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageWriteFile(testFileName, testFileContents),
|
||||
);
|
||||
|
||||
saga.put(fileStorageDidWriteFile(testFileName, uuid(0)));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(explorerDidImportFiles());
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
|
||||
it('should notify user if ZIP file contains no Python files', async () => {
|
||||
const zipFile = new JSZip();
|
||||
|
||||
const saga = new AsyncSaga(explorer, {
|
||||
fileStorage: mockFileStorage(),
|
||||
});
|
||||
|
||||
jest.spyOn(browserFsAccess, 'fileOpen').mockResolvedValueOnce([
|
||||
mockZipFile(
|
||||
'test.zip',
|
||||
await zipFile.generateAsync({ type: 'arraybuffer' }),
|
||||
),
|
||||
]);
|
||||
|
||||
saga.put(explorerImportFiles());
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
alertsShowAlert('explorer', 'noPyFiles'),
|
||||
);
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(explorerDidImportFiles());
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleExplorerCreateNewFile', () => {
|
||||
|
||||
+153
-45
@@ -3,7 +3,15 @@
|
||||
|
||||
import { fileOpen, fileSave } from 'browser-fs-access';
|
||||
import JSZip from 'jszip';
|
||||
import { call, put, race, select, take, takeEvery } from 'typed-redux-saga/macro';
|
||||
import {
|
||||
call,
|
||||
getContext,
|
||||
put,
|
||||
race,
|
||||
select,
|
||||
take,
|
||||
takeEvery,
|
||||
} from 'typed-redux-saga/macro';
|
||||
import { alertsShowAlert } from '../alerts/actions';
|
||||
import { zipFileExtension, zipFileMimeType } from '../app/constants';
|
||||
import {
|
||||
@@ -15,6 +23,7 @@ import {
|
||||
} from '../editor/actions';
|
||||
import { EditorError } from '../editor/error';
|
||||
import { getPybricksMicroPythonFileTemplate } from '../editor/pybricksMicroPython';
|
||||
import { FileStorageDb } from '../fileStorage';
|
||||
import {
|
||||
fileStorageCopyFile,
|
||||
fileStorageDeleteFile,
|
||||
@@ -95,6 +104,12 @@ import {
|
||||
renameImportDialogDidCancel,
|
||||
renameImportDialogShow,
|
||||
} from './renameImportDialog/actions';
|
||||
import {
|
||||
ReplaceImportDialogAction,
|
||||
replaceImportDialogDidAccept,
|
||||
replaceImportDialogDidCancel,
|
||||
replaceImportDialogShow,
|
||||
} from './replaceImportDialog/actions';
|
||||
|
||||
function* handleExplorerArchiveAllFiles(): Generator {
|
||||
try {
|
||||
@@ -155,59 +170,152 @@ function* handleExplorerArchiveAllFiles(): Generator {
|
||||
}
|
||||
}
|
||||
|
||||
type ImportContext = {
|
||||
rememberedAction?: ReplaceImportDialogAction;
|
||||
};
|
||||
|
||||
function* importPythonFile(
|
||||
sourceFileName: string,
|
||||
sourceFileContents: string,
|
||||
context: ImportContext,
|
||||
): Generator {
|
||||
const [baseName] = sourceFileName.split(pythonFileExtensionRegex);
|
||||
let fileName = `${baseName}${pythonFileExtension}`;
|
||||
|
||||
const db = yield* getContext<FileStorageDb>('fileStorage');
|
||||
const existingFiles = yield* call(() => db.metadata.toArray());
|
||||
|
||||
const result = validateFileName(
|
||||
baseName,
|
||||
pythonFileExtension,
|
||||
existingFiles.map((f) => f.path),
|
||||
);
|
||||
|
||||
let replace = false;
|
||||
|
||||
if (result === FileNameValidationResult.AlreadyExists) {
|
||||
let action = context.rememberedAction;
|
||||
|
||||
if (action === undefined) {
|
||||
yield* put(replaceImportDialogShow(sourceFileName));
|
||||
|
||||
const { accepted, cancelled } = yield* race({
|
||||
accepted: take(replaceImportDialogDidAccept),
|
||||
cancelled: take(replaceImportDialogDidCancel),
|
||||
});
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
defined(accepted);
|
||||
|
||||
if (accepted.remember) {
|
||||
context.rememberedAction = accepted.action;
|
||||
}
|
||||
|
||||
action = accepted.action;
|
||||
}
|
||||
|
||||
if (action === ReplaceImportDialogAction.Skip) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === ReplaceImportDialogAction.Replace) {
|
||||
replace = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (result !== FileNameValidationResult.IsOk && !replace) {
|
||||
yield* put(renameImportDialogShow(sourceFileName));
|
||||
|
||||
const { accepted, cancelled } = yield* race({
|
||||
accepted: take(renameImportDialogDidAccept),
|
||||
cancelled: take(renameImportDialogDidCancel),
|
||||
});
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
defined(accepted);
|
||||
|
||||
fileName = accepted.newName;
|
||||
}
|
||||
|
||||
yield* put(fileStorageWriteFile(fileName, sourceFileContents));
|
||||
|
||||
const { didFailToWrite } = yield* race({
|
||||
didWrite: take(fileStorageDidWriteFile.when((a) => a.path === fileName)),
|
||||
didFailToWrite: take(
|
||||
fileStorageDidFailToWriteFile.when((a) => a.path === fileName),
|
||||
),
|
||||
});
|
||||
|
||||
if (didFailToWrite) {
|
||||
throw didFailToWrite.error;
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}),
|
||||
fileOpen([
|
||||
{
|
||||
id: 'pybricks-code-explorer-import',
|
||||
mimeTypes: [pythonFileMimeType],
|
||||
extensions: [pythonFileExtension],
|
||||
// TODO: translate description
|
||||
description: 'Python Files',
|
||||
multiple: true,
|
||||
excludeAcceptAllOption: true,
|
||||
},
|
||||
{
|
||||
mimeTypes: [zipFileMimeType],
|
||||
extensions: [zipFileExtension],
|
||||
// TODO: translate description
|
||||
description: 'ZIP Files',
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const context: ImportContext = {};
|
||||
|
||||
for (const file of selectedFiles) {
|
||||
// getting the text now to catch possible error *before* user interaction
|
||||
const text = yield* call(() => file.text());
|
||||
switch (file.type) {
|
||||
case '': // empty string means "could not be determined"
|
||||
case pythonFileMimeType:
|
||||
{
|
||||
// getting the text now to catch possible error *before* user interaction
|
||||
const text = yield* call(() => file.text());
|
||||
yield* importPythonFile(file.name, text, context);
|
||||
}
|
||||
break;
|
||||
case zipFileMimeType:
|
||||
{
|
||||
const zip = yield* call(() =>
|
||||
JSZip.loadAsync(file.arrayBuffer()),
|
||||
);
|
||||
|
||||
const [baseName] = file.name.split(pythonFileExtensionRegex);
|
||||
let fileName = `${baseName}${pythonFileExtension}`;
|
||||
const zipFiles = zip.filter((_, f) =>
|
||||
f.name.endsWith(pythonFileExtension),
|
||||
);
|
||||
|
||||
const result = validateFileName(baseName, pythonFileExtension, []);
|
||||
if (zipFiles.length === 0) {
|
||||
yield* put(alertsShowAlert('explorer', 'noPyFiles'));
|
||||
break;
|
||||
}
|
||||
|
||||
if (result !== FileNameValidationResult.IsOk) {
|
||||
yield* put(renameImportDialogShow(file.name));
|
||||
|
||||
const { accepted, cancelled } = yield* race({
|
||||
accepted: take(renameImportDialogDidAccept),
|
||||
cancelled: take(renameImportDialogDidCancel),
|
||||
});
|
||||
|
||||
if (cancelled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
defined(accepted);
|
||||
|
||||
fileName = accepted.newName;
|
||||
}
|
||||
|
||||
yield* put(fileStorageWriteFile(fileName, text));
|
||||
|
||||
const { didFailToWrite } = yield* race({
|
||||
didWrite: take(
|
||||
fileStorageDidWriteFile.when((a) => a.path === fileName),
|
||||
),
|
||||
didFailToWrite: take(
|
||||
fileStorageDidFailToWriteFile.when((a) => a.path === fileName),
|
||||
),
|
||||
});
|
||||
|
||||
if (didFailToWrite) {
|
||||
throw didFailToWrite.error;
|
||||
for (const zipFile of zipFiles) {
|
||||
const text = yield* call(() => zipFile.async('text'));
|
||||
yield* importPythonFile(zipFile.name, text, context);
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new Error(
|
||||
`'${file.name}' has unsupported file type: ${file.type}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user