fileStorage: add export actions

These are largely copied from the editor saveAs actions and allow
exporting ("downloading") a file from the browser local storage to
the file system.
This commit is contained in:
David Lechner
2022-03-01 13:44:08 -06:00
parent 80219d297f
commit a87e2c664c
7 changed files with 265 additions and 1 deletions
+31
View File
@@ -77,3 +77,34 @@ export const fileStorageDidFailToWriteFile = createAction(
error,
}),
);
/**
* Request to export (download) a file.
* @param fileName The name of the file.
*/
export const fileStorageExportFile = createAction((fileName: string) => ({
type: 'fileStorage.action.exportFile',
fileName,
}));
/**
* Indicates that fileStorageExportFile(fileName) succeeded.
* @param fileName The name of the file.
*/
export const fileStorageDidExportFile = createAction((fileName: string) => ({
type: 'fileStorage.action.didExportFile',
fileName,
}));
/**
* Indicates that fileStorageExportFile(fileName) failed.
* @param fileName The name of the file.
* @param error The error that was raised.
*/
export const fileStorageDidFailToExportFile = createAction(
(fileName: string, error: Error) => ({
type: 'fileStorage.action.didFailToExportFile',
fileName,
error,
}),
);
+148
View File
@@ -1,18 +1,25 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import FileSaver from 'file-saver';
import { mock } from 'jest-mock-extended';
import { AsyncSaga } from '../../test';
import {
fileStorageDidChangeItem,
fileStorageDidExportFile,
fileStorageDidFailToExportFile,
fileStorageDidFailToReadFile,
fileStorageDidInitialize,
fileStorageDidReadFile,
fileStorageDidWriteFile,
fileStorageExportFile,
fileStorageReadFile,
fileStorageWriteFile,
} from './actions';
import fileStorage from './sagas';
jest.mock('file-saver');
beforeEach(() => {
// localForge uses localStorage as backend in test environment, so we need
// to start with a clean slate in each test
@@ -83,3 +90,144 @@ it('should dispatch fail action if file does not exist', async () => {
await saga.end();
});
describe('export', () => {
/**
* helper function that writes test file to storage for later use in a test
* @param saga The saga.
* @returns The test file name and test file contents.
*/
async function setUpTestFile(saga: AsyncSaga): Promise<[string, string]> {
const testFileName = 'test.file';
const testFileContents = 'test file contents';
const action0 = await saga.take();
expect(action0).toEqual(fileStorageDidInitialize([]));
saga.put(fileStorageWriteFile(testFileName, testFileContents));
const action1 = await saga.take();
expect(action1).toEqual(fileStorageDidWriteFile(testFileName));
const action2 = await saga.take();
expect(action2).toEqual(fileStorageDidChangeItem(testFileName));
return [testFileName, testFileContents];
}
it('should fail if file does not exist', async () => {
const testFileName = 'test.file';
const saga = new AsyncSaga(fileStorage);
const action0 = await saga.take();
expect(action0).toEqual(fileStorageDidInitialize([]));
saga.put(fileStorageExportFile(testFileName));
const action = await saga.take();
expect(action).toEqual(
fileStorageDidFailToExportFile(
testFileName,
new Error('file does not exist'),
),
);
await saga.end();
});
it('should export file with web file system api', async () => {
const saga = new AsyncSaga(fileStorage);
// window.showSaveFilePicker is not defined in the test environment
// so we can't use spyOn().
const mockWriteable = mock<FileSystemWritableFileStream>();
const originalShowSaveFilePicker = window.showSaveFilePicker;
window.showSaveFilePicker = jest.fn().mockResolvedValue(
mock<FileSystemFileHandle>({
createWritable: jest.fn().mockResolvedValue(mockWriteable),
}),
);
const [testFileName] = await setUpTestFile(saga);
saga.put(fileStorageExportFile(testFileName));
const action = await saga.take();
expect(action).toEqual(fileStorageDidExportFile(testFileName));
expect(window.showSaveFilePicker).toHaveBeenCalled();
expect(mockWriteable.write).toHaveBeenCalled();
expect(mockWriteable.close).toHaveBeenCalled();
await saga.end();
window.showSaveFilePicker = originalShowSaveFilePicker;
});
it('should get error from web file system api', async () => {
const saga = new AsyncSaga(fileStorage);
// window.showSaveFilePicker is not defined in the test environment
// so we can't use spyOn().
const testError = new Error('test error');
const originalShowSaveFilePicker = window.showSaveFilePicker;
window.showSaveFilePicker = jest.fn().mockResolvedValue(
mock<FileSystemFileHandle>({
createWritable: jest.fn().mockRejectedValue(testError),
}),
);
const [testFileName] = await setUpTestFile(saga);
saga.put(fileStorageExportFile(testFileName));
const action = await saga.take();
expect(action).toEqual(fileStorageDidFailToExportFile(testFileName, testError));
expect(window.showSaveFilePicker).toHaveBeenCalled();
await saga.end();
window.showSaveFilePicker = originalShowSaveFilePicker;
});
it('should export file using fallback', async () => {
const saga = new AsyncSaga(fileStorage);
const mockFileSaverSaveAs = jest.spyOn(FileSaver, 'saveAs');
const [testFileName] = await setUpTestFile(saga);
saga.put(fileStorageExportFile(testFileName));
const action = await saga.take();
expect(action).toEqual(fileStorageDidExportFile(testFileName));
expect(mockFileSaverSaveAs).toHaveBeenCalled();
await saga.end();
mockFileSaverSaveAs.mockRestore();
});
it('should get error from fallback', async () => {
const saga = new AsyncSaga(fileStorage);
const testError = new Error('test error');
const mockFileSaverSaveAs = jest
.spyOn(FileSaver, 'saveAs')
.mockImplementation(() => {
throw testError;
});
const [testFileName] = await setUpTestFile(saga);
saga.put(fileStorageExportFile(testFileName));
const action = await saga.take();
expect(action).toEqual(fileStorageDidFailToExportFile(testFileName, testError));
expect(mockFileSaverSaveAs).toHaveBeenCalled();
await saga.end();
mockFileSaverSaveAs.mockRestore();
});
});
+64
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import FileSaver from 'file-saver';
import localForage from 'localforage';
import { extendPrototype } from 'localforage-observable';
import { eventChannel } from 'redux-saga';
@@ -9,6 +10,8 @@ import Observable from 'zen-observable';
import { ensureError } from '../utils';
import {
fileStorageDidChangeItem,
fileStorageDidExportFile,
fileStorageDidFailToExportFile,
fileStorageDidFailToInitialize,
fileStorageDidFailToReadFile,
fileStorageDidFailToWriteFile,
@@ -16,6 +19,7 @@ import {
fileStorageDidReadFile,
fileStorageDidRemoveItem,
fileStorageDidWriteFile,
fileStorageExportFile,
fileStorageReadFile,
fileStorageWriteFile,
} from './actions';
@@ -78,6 +82,65 @@ function* handleWriteFile(
}
}
function* handleExportFile(
files: LocalForage,
action: ReturnType<typeof fileStorageExportFile>,
): Generator {
const data = yield* call(() => files.getItem<string>(action.fileName));
if (data === null) {
yield* put(
fileStorageDidFailToExportFile(
action.fileName,
new Error('file does not exist'),
),
);
return;
}
const blob = new Blob([data], { type: 'text/x-python;charset=utf-8' });
if (window.showSaveFilePicker) {
// This uses https://wicg.github.io/file-system-access which is not
// available in all browsers
try {
const handle = yield* call(() =>
window.showSaveFilePicker({
suggestedName: 'main.py',
types: [
{
accept: { 'text/x-python': '.py' },
// TODO: translate description
description: 'Python Files',
},
],
}),
);
const writeable = yield* call(() => handle.createWritable());
yield* call(() => writeable.write(blob));
yield* call(() => writeable.close());
} catch (err) {
yield* put(
fileStorageDidFailToExportFile(action.fileName, ensureError(err)),
);
return;
}
} else {
// this is a fallback to use the standard browser download mechanism
try {
FileSaver.saveAs(blob, 'main.py');
} catch (err) {
yield* put(
fileStorageDidFailToExportFile(action.fileName, ensureError(err)),
);
return;
}
}
yield* put(fileStorageDidExportFile(action.fileName));
}
/**
* Initializes the storage backend.
*/
@@ -130,6 +193,7 @@ function* initialize(): Generator {
yield* takeEvery(localForageChannel, handleFileStorageDidChange);
yield* takeEvery(fileStorageReadFile, handleReadFile, files);
yield* takeEvery(fileStorageWriteFile, handleWriteFile, files);
yield* takeEvery(fileStorageExportFile, handleExportFile, files);
const fileNames = yield* call(() => files.keys());
+2 -1
View File
@@ -17,7 +17,8 @@
"fileStorage": {
"failedToInitialize": "Failed to initial file storage. Changes will not be automatically saved.",
"failedToRead": "Failed to read file.",
"failedToWrite": "Failed to write file."
"failedToWrite": "Failed to write file.",
"failedToExport": "Failed to export file.'"
},
"flashFirmware": {
"timedOut": "The hub took too long to respond. Restart the hub and try again.",
+1
View File
@@ -16,6 +16,7 @@ export enum MessageId {
FileStorageFailedToInitialize = 'fileStorage.failedToInitialize',
FileStorageFailedToRead = 'fileStorage.failedToRead',
FileStorageFailedToWrite = 'fileStorage.failedToWrite',
FileStorageFailedToExport = 'fileStorage.failedToExport',
FlashFirmwareTimedOut = 'flashFirmware.timedOut',
FlashFirmwareBleError = 'flashFirmware.bleError',
FlashFirmwareDisconnected = 'flashFirmware.disconnected',
+6
View File
@@ -17,6 +17,7 @@ import {
} from '../ble/actions';
import { didFailToSaveAs } from '../editor/actions';
import {
fileStorageDidFailToExportFile,
fileStorageDidFailToInitialize,
fileStorageDidFailToReadFile,
fileStorageDidFailToWriteFile,
@@ -90,6 +91,7 @@ test.each([
fileStorageDidFailToInitialize(new Error('test error')),
fileStorageDidFailToReadFile('test.file', new Error('test error')),
fileStorageDidFailToWriteFile('test.file', new Error('test error')),
fileStorageDidFailToExportFile('test.file', new Error('test error')),
])('actions that should show notification: %o', async (action: AnyAction) => {
const getToasts = jest.fn().mockReturnValue([]);
const show = jest.fn();
@@ -122,6 +124,10 @@ test.each([
didCheckForUpdate(true),
bleDIServiceDidReceiveFirmwareRevision(firmwareVersion),
didFailToSaveAs(new DOMException('test message', 'AbortError')),
fileStorageDidFailToExportFile(
'test.file',
new DOMException('test message', 'AbortError'),
),
])('actions that should not show a notification: %o', async (action: AnyAction) => {
const getToasts = jest.fn().mockReturnValue([]);
const show = jest.fn();
+13
View File
@@ -19,6 +19,7 @@ import {
} from '../ble/actions';
import { didFailToSaveAs } from '../editor/actions';
import {
fileStorageDidFailToExportFile,
fileStorageDidFailToInitialize,
fileStorageDidFailToReadFile,
fileStorageDidFailToWriteFile,
@@ -402,6 +403,17 @@ function* showFileStorageFailToWrite(
yield* showUnexpectedError(MessageId.FileStorageFailedToWrite, action.error);
}
function* showFileStorageFailToExport(
action: ReturnType<typeof fileStorageDidFailToExportFile>,
): Generator {
if (action.error.name === 'AbortError') {
// user clicked cancel button - not an error
return;
}
yield* showUnexpectedError(MessageId.FileStorageFailedToExport, action.error);
}
export default function* (): Generator {
yield* takeEvery(bleDeviceDidFailToConnect, showBleDeviceDidFailToConnectError);
yield* takeEvery(bootloaderDidFailToConnect, showBootloaderDidFailToConnectError);
@@ -416,4 +428,5 @@ export default function* (): Generator {
yield* takeEvery(fileStorageDidFailToInitialize, showFileStorageFailToInitialize);
yield* takeEvery(fileStorageDidFailToReadFile, showFileStorageFailToRead);
yield* takeEvery(fileStorageDidFailToWriteFile, showFileStorageFailToWrite);
yield* takeEvery(fileStorageDidFailToExportFile, showFileStorageFailToExport);
}