mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-15 02:54:07 +00:00
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:
@@ -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' });
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user