fileStorage: use dexie-react-hooks

This lets the dexie-react-hooks library do more of the work for us
instead of having to make redux-sagas wrappers for everything.
This commit is contained in:
David Lechner
2022-05-18 17:06:08 -05:00
parent 4580869e6f
commit a005992a21
22 changed files with 183 additions and 464 deletions
+24 -31
View File
@@ -5,6 +5,8 @@ import { cleanup } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { testRender, uuid } from '../../test';
import { FileMetadata } from '../fileStorage';
import { useFileStorageMetadata } from '../fileStorage/hooks';
import Explorer from './Explorer';
import {
explorerActivateFile,
@@ -15,7 +17,6 @@ import {
explorerExportFile,
explorerImportFiles,
} from './actions';
import { ExplorerFileInfo } from './reducers';
afterEach(async () => {
jest.restoreAllMocks();
@@ -23,16 +24,16 @@ afterEach(async () => {
localStorage.clear();
});
const testFile: ExplorerFileInfo = {
id: uuid(0),
name: 'test.file',
const testFile: FileMetadata = {
uuid: uuid(0),
path: 'test.file',
sha256: '',
};
describe('archive button', () => {
it('should dispatch action when clicked', () => {
const [explorer, dispatch] = testRender(<Explorer />, {
explorer: { files: [testFile] },
});
jest.mocked(useFileStorageMetadata).mockReturnValue([testFile]);
const [explorer, dispatch] = testRender(<Explorer />);
const button = explorer.getByTitle('Backup all files');
expect(button).toBeEnabled();
@@ -66,9 +67,8 @@ describe('new file button', () => {
describe('tree item', () => {
it('should dispatch action when clicked', async () => {
const [explorer, dispatch] = testRender(<Explorer />, {
explorer: { files: [testFile] },
});
jest.mocked(useFileStorageMetadata).mockReturnValue([testFile]);
const [explorer, dispatch] = testRender(<Explorer />);
const treeItem = explorer.getByRole('treeitem', { name: 'test.file' });
@@ -78,9 +78,8 @@ describe('tree item', () => {
});
it('should dispatch action when key is pressed', async () => {
const [explorer, dispatch] = testRender(<Explorer />, {
explorer: { files: [testFile] },
});
jest.mocked(useFileStorageMetadata).mockReturnValue([testFile]);
const [explorer, dispatch] = testRender(<Explorer />);
const treeItem = explorer.getByRole('treeitem', { name: 'test.file' });
@@ -92,9 +91,8 @@ describe('tree item', () => {
describe('duplicate', () => {
it('should dispatch action when button is clicked', async () => {
const [explorer, dispatch] = testRender(<Explorer />, {
explorer: { files: [testFile] },
});
jest.mocked(useFileStorageMetadata).mockReturnValue([testFile]);
const [explorer, dispatch] = testRender(<Explorer />);
// NB: this button is intentionally not accessible (by role) since
// there is a keyboard shortcut.
@@ -109,9 +107,8 @@ describe('tree item', () => {
});
it('should dispatch action when key is pressed', async () => {
const [explorer, dispatch] = testRender(<Explorer />, {
explorer: { files: [testFile] },
});
jest.mocked(useFileStorageMetadata).mockReturnValue([testFile]);
const [explorer, dispatch] = testRender(<Explorer />);
const treeItem = explorer.getByRole('treeitem', { name: 'test.file' });
@@ -124,9 +121,8 @@ describe('tree item', () => {
describe('export', () => {
it('should dispatch export action when button is clicked', async () => {
const [explorer, dispatch] = testRender(<Explorer />, {
explorer: { files: [testFile] },
});
jest.mocked(useFileStorageMetadata).mockReturnValue([testFile]);
const [explorer, dispatch] = testRender(<Explorer />);
// NB: this button is intentionally not accessible (by role) since
// there is a keyboard shortcut.
@@ -141,9 +137,8 @@ describe('tree item', () => {
});
it('should dispatch export action when key is pressed', async () => {
const [explorer, dispatch] = testRender(<Explorer />, {
explorer: { files: [testFile] },
});
jest.mocked(useFileStorageMetadata).mockReturnValue([testFile]);
const [explorer, dispatch] = testRender(<Explorer />);
const treeItem = explorer.getByRole('treeitem', { name: 'test.file' });
@@ -156,9 +151,8 @@ describe('tree item', () => {
describe('delete', () => {
it('should dispatch delete action when button is clicked', async () => {
const [explorer, dispatch] = testRender(<Explorer />, {
explorer: { files: [testFile] },
});
jest.mocked(useFileStorageMetadata).mockReturnValue([testFile]);
const [explorer, dispatch] = testRender(<Explorer />);
// NB: this button is intentionally not accessible (by role) since
// there is a keyboard shortcut.
@@ -173,9 +167,8 @@ describe('tree item', () => {
});
it('should dispatch delete action when key is pressed', async () => {
const [explorer, dispatch] = testRender(<Explorer />, {
explorer: { files: [testFile] },
});
jest.mocked(useFileStorageMetadata).mockReturnValue([testFile]);
const [explorer, dispatch] = testRender(<Explorer />);
const treeItem = explorer.getByRole('treeitem', { name: 'test.file' });
+5 -8
View File
@@ -27,7 +27,7 @@ import {
import { useDispatch } from 'react-redux';
import { Toolbar } from '../components/toolbar/Toolbar';
import { useToolbarItemFocus } from '../components/toolbar/aria';
import { useSelector } from '../reducers';
import { useFileStorageMetadata } from '../fileStorage/hooks';
import { isMacOS } from '../utils/os';
import { TreeItemContext, TreeItemData, renderers } from '../utils/tree-renderer';
import {
@@ -308,7 +308,7 @@ type FileTreeProps = {
const FileTree: React.VoidFunctionComponent<FileTreeProps> = ({ i18n }) => {
const [focusedItem, setFocusedItem] = useState<TreeItemIndex>();
const files = useSelector((s) => s.explorer.files);
const files = useFileStorageMetadata() ?? [];
const liveDescriptors = useLiveDescriptors(i18n);
const rootItemIndex = 'root';
@@ -317,12 +317,12 @@ const FileTree: React.VoidFunctionComponent<FileTreeProps> = ({ i18n }) => {
() =>
files.reduce(
(obj, file) => {
const index = file.id;
const index = file.uuid;
obj[index] = {
index,
data: {
fileName: file.name,
fileName: file.path,
icon: 'document',
secondaryLabel: (
<TreeItemContext.Consumer>
@@ -344,10 +344,7 @@ const FileTree: React.VoidFunctionComponent<FileTreeProps> = ({ i18n }) => {
index: rootItemIndex,
data: { fileName: '/' },
hasChildren: true,
children: [...files]
// REVISIT: consider using Intl.Collator() for i18n.locale
.sort((a, b) => a.name.localeCompare(b.name))
.map((n) => n.id),
children: [...files].map((f) => f.uuid),
},
} as Record<TreeItemIndex, FileTreeItem>,
),
@@ -5,6 +5,7 @@ import { Button, Classes, Dialog } from '@blueprintjs/core';
import { useI18n } from '@shopify/react-i18n';
import React, { useCallback, useRef, useState } from 'react';
import { useDispatch } from 'react-redux';
import { useFileStorageMetadata } from '../../fileStorage/hooks';
import {
FileNameValidationResult,
validateFileName,
@@ -24,11 +25,11 @@ const DuplicateFileDialog: React.VFC = () => {
const [baseName, extension] = oldName.split(/(\.\w+)$/);
const [newName, setNewName] = useState(baseName);
const files = useSelector((s) => s.explorer.files);
const files = useFileStorageMetadata() ?? [];
const result = validateFileName(
newName,
extension,
files.map((f) => f.name),
files.map((f) => f.path),
);
const inputRef = useRef<HTMLInputElement>(null);
+3 -2
View File
@@ -12,6 +12,7 @@ import {
import { useI18n } from '@shopify/react-i18n';
import React, { useCallback, useRef, useState } from 'react';
import { useDispatch } from 'react-redux';
import { useFileStorageMetadata } from '../../fileStorage/hooks';
import {
FileNameValidationResult,
pythonFileExtension,
@@ -33,11 +34,11 @@ const NewFileWizard: React.VoidFunctionComponent = () => {
const isOpen = useSelector((s) => s.explorer.newFileWizard.isOpen);
const [fileName, setFileName] = useState('');
const files = useSelector((s) => s.explorer.files);
const files = useFileStorageMetadata() ?? [];
const fileNameValidation = validateFileName(
fileName,
pythonFileExtension,
files.map((f) => f.name),
files.map((f) => f.path),
);
const [hubType, setHubType] = useState(defaultHub);
-92
View File
@@ -1,92 +0,0 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { uuid } from '../../test';
import {
FileMetadata,
fileStorageDidAddItem,
fileStorageDidChangeItem,
fileStorageDidInitialize,
fileStorageDidRemoveItem,
} from '../fileStorage/actions';
import reducers, { ExplorerFileInfo } from './reducers';
type State = ReturnType<typeof reducers>;
describe('files', () => {
const testFile: ExplorerFileInfo = {
id: uuid(0),
name: 'test.file',
};
const testFileMetadata: FileMetadata = {
uuid: uuid(0),
path: 'test.file',
sha256: '',
};
const modifiedFile: ExplorerFileInfo = {
...testFile,
name: 'modified.file',
};
const modifiedFileMetadata: FileMetadata = {
...testFileMetadata,
path: 'modified.file',
};
beforeAll(() => {
// check validity of test data before starting tests
expect(testFile).not.toEqual(modifiedFile);
expect(testFileMetadata).not.toEqual(modifiedFileMetadata);
});
it('should get a list when file storage is initialized', () => {
expect(
reducers(
{ files: [] as readonly ExplorerFileInfo[] } as State,
fileStorageDidInitialize([testFileMetadata]),
).files,
).toEqual([testFile]);
});
it('should modify the list when a file is added to storage', () => {
expect(
reducers(
{ files: [] as readonly ExplorerFileInfo[] } as State,
fileStorageDidAddItem(testFileMetadata),
).files,
).toEqual([testFile]);
});
it('should modify the list when an item is renamed in storage', () => {
expect(
reducers(
{ files: [testFile] as readonly ExplorerFileInfo[] } as State,
fileStorageDidChangeItem(testFileMetadata, modifiedFileMetadata),
).files,
).toEqual([modifiedFile]);
});
it('should not modify the list if a change is made other than renaming', () => {
const originalList: readonly ExplorerFileInfo[] = [testFile];
expect(
reducers(
{ files: originalList } as State,
fileStorageDidChangeItem(testFileMetadata, {
...testFileMetadata,
sha256: 'changed',
}),
).files,
).toBe(originalList);
});
it('should modify the list when a file is removed from storage', () => {
expect(
reducers(
{ files: [testFile] as readonly ExplorerFileInfo[] } as State,
fileStorageDidRemoveItem(testFileMetadata),
).files,
).not.toContain(testFile);
});
});
+1 -48
View File
@@ -1,61 +1,14 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { Reducer, combineReducers } from 'redux';
import {
FileMetadata,
fileStorageDidAddItem,
fileStorageDidChangeItem,
fileStorageDidInitialize,
fileStorageDidRemoveItem,
} from '../fileStorage/actions';
import { combineReducers } from 'redux';
import deleteFileAlert from './deleteFileAlert/reducers';
import duplicateFileDialog from './duplicateFileDialog/reducers';
import newFileWizard from './newFileWizard/reducers';
import renameFileDialog from './renameFileDialog/reducers';
export type ExplorerFileInfo = Readonly<{
/** A unique identifier for this file (not the path, which can change). */
id: string;
/** The file name (including extension - without directory). */
name: string;
}>;
function metadataToInfo(file: FileMetadata): ExplorerFileInfo {
return { id: file.uuid, name: file.path };
}
const files: Reducer<readonly ExplorerFileInfo[]> = (state = [], action) => {
if (fileStorageDidInitialize.matches(action)) {
return action.files.map(metadataToInfo);
}
if (fileStorageDidAddItem.matches(action)) {
return [...state, metadataToInfo(action.file)];
}
if (fileStorageDidChangeItem.matches(action)) {
// We only care about UUID and the path. UUID can't change so if the
// path didn't change, there is nothing to do.
if (action.oldFile.path === action.file.path) {
return state;
}
return state.map((f) =>
f.id === action.file.uuid ? metadataToInfo(action.file) : f,
);
}
if (fileStorageDidRemoveItem.matches(action)) {
return state.filter((value) => value.id !== action.file.uuid);
}
return state;
};
export default combineReducers({
files,
duplicateFileDialog,
deleteFileAlert,
newFileWizard,
@@ -5,6 +5,7 @@ import { Button, Classes, Dialog } from '@blueprintjs/core';
import { useI18n } from '@shopify/react-i18n';
import React, { useCallback, useRef, useState } from 'react';
import { useDispatch } from 'react-redux';
import { useFileStorageMetadata } from '../../fileStorage/hooks';
import {
FileNameValidationResult,
validateFileName,
@@ -24,11 +25,11 @@ const RenameFileDialog: React.VFC = () => {
const [baseName, extension] = oldName.split(/(\.\w+)$/);
const [newName, setNewName] = useState(baseName);
const files = useSelector((s) => s.explorer.files);
const files = useFileStorageMetadata() ?? [];
const result = validateFileName(
newName,
extension,
files.map((f) => f.name),
files.map((f) => f.path),
);
const inputRef = useRef<HTMLInputElement>(null);
+1 -18
View File
@@ -4,7 +4,7 @@
import * as browserFsAccess from 'browser-fs-access';
import { FileWithHandle } from 'browser-fs-access';
import { mock } from 'jest-mock-extended';
import { AsyncSaga, uuid } from '../../test';
import { AsyncSaga } from '../../test';
import {
editorActivateFile,
editorCloseFile,
@@ -23,7 +23,6 @@ import {
fileStorageDidFailToDumpAllFiles,
fileStorageDidFailToReadFile,
fileStorageDidReadFile,
fileStorageDidRemoveItem,
fileStorageDidWriteFile,
fileStorageDumpAllFiles,
fileStorageReadFile,
@@ -399,22 +398,6 @@ describe('handleExplorerDeleteFile', () => {
);
});
it('should fail with AbortError if file was removed before user accept/cancel', async () => {
saga.put(
fileStorageDidRemoveItem({ path: testFile, uuid: uuid(0), sha256: '' }),
);
// should programmatically cancel the dialog
await expect(saga.take()).resolves.toEqual(deleteFileAlertDidCancel());
await expect(saga.take()).resolves.toEqual(
explorerDidFailToDeleteFile(
testFile,
new DOMException('file was removed', 'AbortError'),
),
);
});
describe('accepted', () => {
beforeEach(async () => {
saga.put(deleteFileAlertDidAccept());
+3 -20
View File
@@ -3,7 +3,7 @@
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, put, race, take, takeEvery } from 'typed-redux-saga/macro';
import {
editorActivateFile,
editorCloseFile,
@@ -24,7 +24,6 @@ import {
fileStorageDidFailToReadFile,
fileStorageDidFailToWriteFile,
fileStorageDidReadFile,
fileStorageDidRemoveItem,
fileStorageDidWriteFile,
fileStorageDumpAllFiles,
fileStorageReadFile,
@@ -37,7 +36,6 @@ import {
pythonFileMimeType,
validateFileName,
} from '../pybricksMicropython/lib';
import { RootState } from '../reducers';
import { defined, ensureError, timestamp } from '../utils';
import {
explorerActivateFile,
@@ -143,13 +141,8 @@ function* handleExplorerImportFiles(): Generator {
const text = yield* call(() => file.text());
const [baseName] = file.name.split(pythonFileExtensionRegex);
const existingFiles = yield* select((s: RootState) => s.explorer.files);
const result = validateFileName(
baseName,
pythonFileExtension,
existingFiles.map((f) => f.name),
);
const result = validateFileName(baseName, pythonFileExtension, []);
if (result != FileNameValidationResult.IsOk) {
// TODO: validate file name and allow user to rename or skip
@@ -347,25 +340,15 @@ function* handleExplorerDeleteFile(action: ReturnType<typeof explorerDeleteFile>
try {
yield* put(deleteFileAlertShow(action.fileName));
const { didCancel, didRemove } = yield* race({
const { didCancel } = yield* race({
didAccept: take(deleteFileAlertDidAccept),
didCancel: take(deleteFileAlertDidCancel),
didRemove: take(
fileStorageDidRemoveItem.when((a) => a.file.path === action.fileName),
),
});
if (didCancel) {
throw new DOMException('user canceled', 'AbortError');
}
// automatically cancel the dialog, if the file was removed, e.g. it was
// deleted in a different window
if (didRemove) {
yield* put(deleteFileAlertDidCancel());
throw new DOMException('file was removed', 'AbortError');
}
// at this point we know the user accepted
// have to close editor before deleting, otherwise we get "in use" error