mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-15 02:54:07 +00:00
explorer: split archive saga from fileStorage
This splits the fileStorage archive into two sagas to separate concerns. fileStorage now just gives a dump of the database and explorer deals with the user interaction and zipping.
This commit is contained in:
@@ -6,9 +6,10 @@ 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 } from '../fileStorage/actions';
|
||||
import { FileMetadata } from '../fileStorage/actions';
|
||||
import Explorer from './Explorer';
|
||||
import {
|
||||
explorerArchiveAllFiles,
|
||||
explorerDeleteFile,
|
||||
explorerExportFile,
|
||||
explorerImportFiles,
|
||||
@@ -37,7 +38,7 @@ describe('archive button', () => {
|
||||
expect(button).toBeEnabled();
|
||||
|
||||
userEvent.click(button);
|
||||
expect(dispatch).toHaveBeenCalledWith(fileStorageArchiveAllFiles());
|
||||
expect(dispatch).toHaveBeenCalledWith(explorerArchiveAllFiles());
|
||||
});
|
||||
|
||||
it('should be disabled if there are no files', () => {
|
||||
|
||||
@@ -23,12 +23,12 @@ import {
|
||||
useTreeEnvironment,
|
||||
} from 'react-complex-tree';
|
||||
import { useDispatch } from 'react-redux';
|
||||
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 {
|
||||
explorerArchiveAllFiles,
|
||||
explorerDeleteFile,
|
||||
explorerExportFile,
|
||||
explorerImportFiles,
|
||||
@@ -141,7 +141,7 @@ const Header: React.VoidFunctionComponent<HeaderProps> = ({ i18n }) => {
|
||||
icon="archive"
|
||||
tooltip={i18n.translate(I18nId.HeaderExportAllTooltip)}
|
||||
disabled={files.length === 0}
|
||||
onClick={() => dispatch(fileStorageArchiveAllFiles())}
|
||||
onClick={() => dispatch(explorerArchiveAllFiles())}
|
||||
/>
|
||||
<ActionButton
|
||||
// NB: the "export" icon has an arrow pointing up, which is
|
||||
|
||||
@@ -23,6 +23,29 @@ export enum Hub {
|
||||
Essential = 'essentialhub',
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to archive (download) all files in the store.
|
||||
*/
|
||||
export const explorerArchiveAllFiles = createAction(() => ({
|
||||
type: 'explorer.action.archiveAllFiles',
|
||||
}));
|
||||
|
||||
/**
|
||||
* Indicates that {@link explorerArchiveAllFiles} succeeded.
|
||||
*/
|
||||
export const explorerDidArchiveAllFiles = createAction(() => ({
|
||||
type: 'explorer.action.didArchiveAllFiles',
|
||||
}));
|
||||
|
||||
/**
|
||||
* Indicates that {@link explorerArchiveAllFiles} failed.
|
||||
* @param error The error that was raised.
|
||||
*/
|
||||
export const explorerDidFailToArchiveAllFiles = createAction((error: Error) => ({
|
||||
type: 'explorer.action.didFailToArchiveAllFiles',
|
||||
error,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Action that requests to import (upload) files into the app.
|
||||
*/
|
||||
|
||||
@@ -6,11 +6,14 @@ import { FileWithHandle } from 'browser-fs-access';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import { AsyncSaga } from '../../test';
|
||||
import {
|
||||
fileStorageDidDumpAllFiles,
|
||||
fileStorageDidFailToDumpAllFiles,
|
||||
fileStorageDidFailToReadFile,
|
||||
fileStorageDidFailToRenameFile,
|
||||
fileStorageDidReadFile,
|
||||
fileStorageDidRenameFile,
|
||||
fileStorageDidWriteFile,
|
||||
fileStorageDumpAllFiles,
|
||||
fileStorageReadFile,
|
||||
fileStorageRenameFile,
|
||||
fileStorageWriteFile,
|
||||
@@ -18,9 +21,12 @@ import {
|
||||
import { pythonFileExtension } from '../pybricksMicropython/lib';
|
||||
import {
|
||||
Hub,
|
||||
explorerArchiveAllFiles,
|
||||
explorerCreateNewFile,
|
||||
explorerDidArchiveAllFiles,
|
||||
explorerDidCreateNewFile,
|
||||
explorerDidExportFile,
|
||||
explorerDidFailToArchiveAllFiles,
|
||||
explorerDidFailToExportFile,
|
||||
explorerDidFailToImportFiles,
|
||||
explorerDidFailToRenameFile,
|
||||
@@ -37,6 +43,70 @@ import {
|
||||
} from './renameFileDialog/actions';
|
||||
import explorer from './sagas';
|
||||
|
||||
jest.mock('browser-fs-access');
|
||||
|
||||
describe('handleExplorerArchiveAllFiles', () => {
|
||||
let saga: AsyncSaga;
|
||||
|
||||
beforeEach(async () => {
|
||||
saga = new AsyncSaga(explorer);
|
||||
});
|
||||
|
||||
describe('should call into fileStorage', () => {
|
||||
beforeEach(async () => {
|
||||
saga.put(explorerArchiveAllFiles());
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageDumpAllFiles());
|
||||
});
|
||||
|
||||
it('should propagate error when fileStorage fails', async () => {
|
||||
const testError = new Error('test error');
|
||||
|
||||
saga.put(fileStorageDidFailToDumpAllFiles(testError));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
explorerDidFailToArchiveAllFiles(testError),
|
||||
);
|
||||
});
|
||||
|
||||
describe('should continue when fileStorage succeeds', () => {
|
||||
beforeEach(async () => {
|
||||
saga.put(
|
||||
fileStorageDidDumpAllFiles([
|
||||
{ path: 'test.file', contents: 'test file contents' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should catch error', async () => {
|
||||
const testError = new Error('test error');
|
||||
|
||||
jest.spyOn(browserFsAccess, 'fileSave').mockImplementation(() => {
|
||||
throw testError;
|
||||
});
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
explorerDidFailToArchiveAllFiles(testError),
|
||||
);
|
||||
});
|
||||
|
||||
it('should archive file', async () => {
|
||||
jest.spyOn(browserFsAccess, 'fileSave');
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
explorerDidArchiveAllFiles(),
|
||||
);
|
||||
|
||||
expect(browserFsAccess.fileSave).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await saga.end();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleExplorerImportFiles', () => {
|
||||
it('should write file to storage', async () => {
|
||||
const testFileName = 'test.py';
|
||||
|
||||
+51
-1
@@ -2,6 +2,7 @@
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
import { fileOpen, fileSave } from 'browser-fs-access';
|
||||
import JSZip from 'jszip';
|
||||
import {
|
||||
call,
|
||||
put,
|
||||
@@ -13,12 +14,15 @@ import {
|
||||
} from 'typed-redux-saga/macro';
|
||||
import { getPybricksMicroPythonFileTemplate } from '../editor/pybricksMicroPython';
|
||||
import {
|
||||
fileStorageDidDumpAllFiles,
|
||||
fileStorageDidFailToDumpAllFiles,
|
||||
fileStorageDidFailToReadFile,
|
||||
fileStorageDidFailToRenameFile,
|
||||
fileStorageDidFailToWriteFile,
|
||||
fileStorageDidReadFile,
|
||||
fileStorageDidRenameFile,
|
||||
fileStorageDidWriteFile,
|
||||
fileStorageDumpAllFiles,
|
||||
fileStorageReadFile,
|
||||
fileStorageRenameFile,
|
||||
fileStorageWriteFile,
|
||||
@@ -31,11 +35,14 @@ import {
|
||||
validateFileName,
|
||||
} from '../pybricksMicropython/lib';
|
||||
import { RootState } from '../reducers';
|
||||
import { defined, ensureError } from '../utils';
|
||||
import { defined, ensureError, timestamp } from '../utils';
|
||||
import {
|
||||
explorerArchiveAllFiles,
|
||||
explorerCreateNewFile,
|
||||
explorerDidArchiveAllFiles,
|
||||
explorerDidCreateNewFile,
|
||||
explorerDidExportFile,
|
||||
explorerDidFailToArchiveAllFiles,
|
||||
explorerDidFailToCreateNewFile,
|
||||
explorerDidFailToExportFile,
|
||||
explorerDidFailToImportFiles,
|
||||
@@ -52,6 +59,48 @@ import {
|
||||
renameFileDialogShow,
|
||||
} from './renameFileDialog/actions';
|
||||
|
||||
function* handleExplorerArchiveAllFiles(): Generator {
|
||||
try {
|
||||
yield* put(fileStorageDumpAllFiles());
|
||||
|
||||
const { didDump, didFailToDump } = yield* race({
|
||||
didDump: take(fileStorageDidDumpAllFiles),
|
||||
didFailToDump: take(fileStorageDidFailToDumpAllFiles),
|
||||
});
|
||||
|
||||
if (didFailToDump) {
|
||||
throw didFailToDump.error;
|
||||
}
|
||||
|
||||
defined(didDump);
|
||||
|
||||
const zip = new JSZip();
|
||||
|
||||
for (const f of didDump.files) {
|
||||
yield* call(() => zip.file(f.path, f.contents));
|
||||
}
|
||||
|
||||
const zipData = yield* call(() => zip.generateAsync({ type: 'blob' }));
|
||||
|
||||
const fileName = `pybricks-backup-${timestamp()}.zip`;
|
||||
|
||||
yield* call(() =>
|
||||
fileSave(zipData, {
|
||||
id: 'pybricksCodeFileStorageArchive',
|
||||
fileName,
|
||||
extensions: ['.zip'],
|
||||
mimeTypes: ['application/zip'],
|
||||
// TODO: translate description
|
||||
description: 'Zip Files',
|
||||
}),
|
||||
);
|
||||
|
||||
yield* put(explorerDidArchiveAllFiles());
|
||||
} catch (err) {
|
||||
yield* put(explorerDidFailToArchiveAllFiles(ensureError(err)));
|
||||
}
|
||||
}
|
||||
|
||||
function* handleExplorerImportFiles(): Generator {
|
||||
try {
|
||||
const selectedFiles = yield* call(() =>
|
||||
@@ -221,6 +270,7 @@ function* handleExplorerExportFile(
|
||||
}
|
||||
|
||||
export default function* (): Generator {
|
||||
yield* takeEvery(explorerArchiveAllFiles, handleExplorerArchiveAllFiles);
|
||||
yield* takeEvery(explorerImportFiles, handleExplorerImportFiles);
|
||||
yield* takeEvery(explorerCreateNewFile, handleExplorerCreateNewFile);
|
||||
// takeLatest should ensure that if we trigger a new rename before the
|
||||
|
||||
Reference in New Issue
Block a user