explorer/renameFileDialog: isolate

This provides more separation between the explorer and the rename file
dialog. This makes writing tests easier and make reading the code easier.
This commit is contained in:
David Lechner
2022-03-25 15:54:04 -05:00
parent 549fe88a00
commit b16298b08f
17 changed files with 396 additions and 232 deletions
+7 -15
View File
@@ -11,7 +11,7 @@ import {
fileStorageExportFile,
} from '../fileStorage/actions';
import Explorer from './Explorer';
import { explorerDeleteFile, explorerImportFiles } from './actions';
import { explorerDeleteFile, explorerImportFiles, explorerRenameFile } from './actions';
afterEach(async () => {
cleanup();
@@ -73,8 +73,8 @@ describe('new file button', () => {
});
describe('tree item', () => {
it('should show rename dialog when button is clicked', async () => {
const [explorer] = testRender(<Explorer />, {
it('should dispatch action when button is clicked', async () => {
const [explorer, dispatch] = testRender(<Explorer />, {
fileStorage: { fileNames: ['test.file'] },
});
@@ -88,15 +88,11 @@ describe('tree item', () => {
userEvent.click(button);
const dialog = await explorer.findByRole('dialog', {
name: "Rename 'test.file'",
});
expect(dialog).toBeVisible();
expect(dispatch).toHaveBeenCalledWith(explorerRenameFile('test.file'));
});
it('should show rename dialog when key is pressed', async () => {
const [explorer] = testRender(<Explorer />, {
it('should dispatch action when key is pressed', async () => {
const [explorer, dispatch] = testRender(<Explorer />, {
fileStorage: { fileNames: ['test.file'] },
});
@@ -109,11 +105,7 @@ describe('tree item', () => {
userEvent.click(treeItem);
userEvent.keyboard('{f2}');
const dialog = await explorer.findByRole('dialog', {
name: "Rename 'test.file'",
});
expect(dialog).toBeVisible();
expect(dispatch).toHaveBeenCalledWith(explorerRenameFile('test.file'));
});
it('should dispatch delete action when button is clicked', async () => {
+76 -132
View File
@@ -6,21 +6,19 @@
import {
Button,
ButtonGroup,
Classes,
Divider,
HotkeyConfig,
IconName,
useHotkeys,
} from '@blueprintjs/core';
import { useI18n } from '@shopify/react-i18n';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import {
ControlledTreeEnvironment,
LiveDescriptors,
Tree,
TreeItem,
TreeItemIndex,
TreeRef,
useTree,
useTreeEnvironment,
} from 'react-complex-tree';
@@ -29,17 +27,16 @@ import { useDebounce } from 'usehooks-ts';
import {
fileStorageArchiveAllFiles,
fileStorageExportFile,
fileStorageRenameFile,
} 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 NewFileWizard from './NewFileWizard';
import RenameFileDialog from './RenameFileDialog';
import { explorerDeleteFile, explorerImportFiles } from './actions';
import { explorerDeleteFile, explorerImportFiles, explorerRenameFile } from './actions';
import { ExplorerStringId } from './i18n';
import en from './i18n.en.json';
import RenameFileDialog from './renameFileDialog/RenameFileDialog';
import './explorer.scss';
type ActionButtonProps = {
@@ -92,17 +89,10 @@ const FileActionButtonGroup: React.VoidFunctionComponent<ActionButtonGroupProps>
item,
}) => {
const dispatch = useDispatch();
const { treeId, setRenamingItem } = useTree();
const environment = useTreeEnvironment();
const fileName = environment.getItemTitle(item);
// this is essentially the same implementation as the keyboard shortcut
const handleRename = useCallback(() => {
environment.onStartRenamingItem?.(item, treeId);
setRenamingItem(item.index);
}, [environment, item, treeId, setRenamingItem]);
return (
<ButtonGroup
aria-hidden={true}
@@ -114,7 +104,7 @@ const FileActionButtonGroup: React.VoidFunctionComponent<ActionButtonGroupProps>
toolTipId={ExplorerStringId.TreeItemRenameTooltip}
toolTipReplacements={{ fileName }}
focusable={false}
onClick={handleRename}
onClick={() => dispatch(explorerRenameFile(fileName))}
/>
<ActionButton
// NB: the "import" icon has an arrow pointing down, which is
@@ -198,7 +188,7 @@ function useLiveDescriptors(): LiveDescriptors {
)}</li>
<li>${i18n.translate(
ExplorerStringId.TreeLiveDescriptorIntroKeybindingsRename,
{ key: '{keybinding:renameItem}' },
{ key: 'f2' },
)}</li>
<li>${i18n.translate(
ExplorerStringId.TreeLiveDescriptorIntroKeybindingsExport,
@@ -221,13 +211,81 @@ function useLiveDescriptors(): LiveDescriptors {
);
}
/**
* Adds additional key bindings to {@link renderers.renderTreeContainer}.
*
* REVISIT: maybe there will be a better way to do this some day:
* https://github.com/lukasbach/react-complex-tree/issues/47
*/
const renderTreeContainer: typeof renderers.renderTreeContainer = (props) => {
const dispatch = useDispatch();
const { treeId } = useTree();
const environment = useTreeEnvironment();
const focusedItem = environment.viewState[treeId]?.focusedItem;
const isActiveTree = environment.activeTreeId === treeId;
const hotKeyActive =
isActiveTree; /* && !dnd.isProgrammaticallyDragging && !isRenaming */
const handleRenameKeyDown = useCallback(() => {
if (focusedItem !== undefined) {
const fileName = environment.getItemTitle(environment.items[focusedItem]);
dispatch(explorerRenameFile(fileName));
}
}, [environment]);
const handleDeleteKeyDown = useCallback(() => {
if (focusedItem !== undefined) {
const fileName = environment.getItemTitle(environment.items[focusedItem]);
dispatch(explorerDeleteFile(fileName));
}
}, [environment]);
const handleExportKeyDown = useCallback(() => {
if (focusedItem !== undefined) {
const fileName = environment.getItemTitle(environment.items[focusedItem]);
dispatch(fileStorageExportFile(fileName));
}
}, [environment]);
const hotkeys = useMemo<readonly HotkeyConfig[]>(
() => [
{
combo: 'f2',
label: 'Rename',
disabled: !hotKeyActive,
preventDefault: true,
onKeyDown: handleRenameKeyDown,
},
{
combo: 'del',
label: 'Delete',
disabled: !hotKeyActive,
preventDefault: true,
onKeyDown: handleDeleteKeyDown,
},
{
combo: 'mod+e',
label: 'Export',
disabled: !hotKeyActive,
preventDefault: true,
onKeyDown: handleExportKeyDown,
},
],
[hotKeyActive, handleDeleteKeyDown],
);
const { handleKeyDown } = useHotkeys(hotkeys);
return <div onKeyDown={handleKeyDown}>{renderers.renderTreeContainer(props)}</div>;
};
const FileTree: React.VFC = () => {
const [i18n] = useI18n({ id: 'explorer', translations: { en }, fallback: en });
const [focusedItem, setFocusedItem] = useState<TreeItemIndex>();
const fileNames = useSelector((s) => s.fileStorage.fileNames);
const debouncedFileNames = useDebounce(fileNames);
const liveDescriptors = useLiveDescriptors();
const dispatch = useDispatch();
const rootItemIndex = '/';
@@ -266,113 +324,6 @@ const FileTree: React.VFC = () => {
const getItemTitle = useCallback((item: FileTreeItem) => item.data.fileName, []);
const [renameFileName, setRenameFileName] = useState('');
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const renderTreeContainer = useCallback<typeof renderers.renderTreeContainer>(
(props) => {
const { treeId, renamingItem } = useTree();
const environment = useTreeEnvironment();
const isActiveTree = environment.activeTreeId === treeId;
const isRenaming = !!renamingItem;
const hotKeyActive =
isActiveTree && /*!dnd.isProgrammaticallyDragging &&*/ !isRenaming;
const handleDeleteKeyDown = useCallback(() => {
if (focusedItem) {
const fileName = environment.getItemTitle(
environment.items[focusedItem],
);
dispatch(explorerDeleteFile(fileName));
}
}, [environment]);
const handleExportKeyDown = useCallback(() => {
if (focusedItem) {
const fileName = environment.getItemTitle(
environment.items[focusedItem],
);
dispatch(fileStorageExportFile(fileName));
}
}, [environment]);
const hotkeys = useMemo<readonly HotkeyConfig[]>(
() => [
{
combo: 'del',
label: 'Delete',
disabled: !hotKeyActive,
preventDefault: true,
onKeyDown: handleDeleteKeyDown,
},
{
combo: 'mod+e',
label: 'Export',
disabled: !hotKeyActive,
preventDefault: true,
onKeyDown: handleExportKeyDown,
},
],
[hotKeyActive, handleDeleteKeyDown],
);
const { handleKeyDown } = useHotkeys(hotkeys);
return (
<div onKeyDown={handleKeyDown}>
{renderers.renderTreeContainer(props)}
</div>
);
},
[renderers, focusedItem, dispatch],
);
// override default renderRenameInput since we have a separate rename dialog
const renderRenameInput = useCallback(
({ item }) => (
<span className={[Classes.TREE_NODE_LABEL, Classes.TEXT_MUTED].join(' ')}>
{getItemTitle(item)}
</span>
),
[getItemTitle],
);
const handleStartRenamingItem = useCallback(
(item: FileTreeItem) => {
// we are ignoring most of the props since we are opening a dialog
// instead of using an inline input and button
setRenameFileName(getItemTitle(item));
setIsRenameDialogOpen(true);
},
[getItemTitle, setRenameFileName, setIsRenameDialogOpen],
);
const treeRef = useRef<TreeRef<TreeItemData>>(null);
const handleRenameDialogAccept = useCallback(
(oldName: string, newName: string) => {
setIsRenameDialogOpen(false);
// completeRenamingItem is not implemented
treeRef.current?.stopRenamingItem();
dispatch(fileStorageRenameFile(oldName, newName));
// HACK: This is fragile, ideally we would rename an existing node
// rather than removing and replacing the node. The delay has to
// be long enough to avoid the debounce.
setTimeout(() => treeRef.current?.focusItem(`/${newName}`), 1000);
},
[setIsRenameDialogOpen, treeRef],
);
const handleRenameDialogCancel = useCallback(() => {
setIsRenameDialogOpen(false);
treeRef.current?.abortRenamingItem();
if (focusedItem) {
requestAnimationFrame(() => treeRef.current?.focusItem(focusedItem));
}
}, [setIsRenameDialogOpen, treeRef, focusedItem]);
const treeId = 'pb-explorer-file-tree';
const viewState = useMemo(
@@ -384,12 +335,11 @@ const FileTree: React.VFC = () => {
<ControlledTreeEnvironment<FileTreeItemData>
{...renderers}
renderTreeContainer={renderTreeContainer}
renderRenameInput={renderRenameInput}
items={treeItems}
getItemTitle={getItemTitle}
viewState={viewState}
liveDescriptors={liveDescriptors}
onStartRenamingItem={handleStartRenamingItem}
canRename={false} // we implement our own rename handler
onFocusItem={(item) => setFocusedItem(item.index)}
>
<div className="pb-explorer-file-tree">
@@ -397,13 +347,6 @@ const FileTree: React.VFC = () => {
treeId={treeId}
rootItem={rootItemIndex}
treeLabel={i18n.translate(ExplorerStringId.TreeLabel)}
ref={treeRef}
/>
<RenameFileDialog
oldName={renameFileName}
isOpen={isRenameDialogOpen}
onAccept={handleRenameDialogAccept}
onCancel={handleRenameDialogCancel}
/>
</div>
</ControlledTreeEnvironment>
@@ -416,6 +359,7 @@ const Explorer: React.VFC = () => {
<Header />
<Divider />
<FileTree />
<RenameFileDialog />
</div>
);
};
+23
View File
@@ -61,6 +61,29 @@ export const explorerCreateNewFile = createAction(
}),
);
/**
* Action that requests to rename a file.
* @param fileName The file name.
*/
export const explorerRenameFile = createAction((fileName: string) => ({
type: 'explorer.action.renameFile',
fileName,
}));
/**
* Action that indicates that {@link explorerRenameFile} succeeded.
*/
export const explorerDidRenameFile = createAction(() => ({
type: 'explorer.action.didRenameFile',
}));
/**
* Action that indicates that {@link explorerRenameFile} failed.
*/
export const explorerDidFailToRenameFile = createAction(() => ({
type: 'explorer.action.didFailToRenameFile',
}));
/**
* Action that requests to delete a file.
* @param fileName The file name.
-6
View File
@@ -47,11 +47,5 @@
"action": {
"create": "Create"
}
},
"renameFile": {
"title": "Rename '{fileName}'",
"action": {
"rename": "Rename"
}
}
}
+1 -7
View File
@@ -2,7 +2,7 @@
// Copyright (c) 2022 The Pybricks Authors
import { lookup } from '../../test';
import { ExplorerStringId, NewFileWizardStringId, RenameFileStringId } from './i18n';
import { ExplorerStringId, NewFileWizardStringId } from './i18n';
import en from './i18n.en.json';
describe('Ensure .json file has matches for ExplorerStringId', () => {
@@ -16,9 +16,3 @@ describe('Ensure .json file has matches for NewFileWizardStringId', () => {
expect(lookup(en, id)).toBeDefined();
});
});
describe('Ensure .json file has matches for RenameFileStringId', () => {
test.each(Object.values(RenameFileStringId))('%s', (id) => {
expect(lookup(en, id)).toBeDefined();
});
});
-5
View File
@@ -33,8 +33,3 @@ export enum NewFileWizardStringId {
SmartHubLabel = 'newFileWizard.smartHub.label',
ActionCreate = 'newFileWizard.action.create',
}
export enum RenameFileStringId {
Title = 'renameFile.title',
ActionRename = 'renameFile.action.rename',
}
+8
View File
@@ -0,0 +1,8 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { combineReducers } from 'redux';
import renameFileDialog from './renameFileDialog/reducers';
export default combineReducers({ renameFileDialog });
+12
View File
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { lookup } from '../../../test';
import { RenameFileDialogStringId } from './i18n';
import en from './i18n.en.json';
describe('Ensure .json file has matches for RenameFileStringId', () => {
test.each(Object.values(RenameFileDialogStringId))('%s', (id) => {
expect(lookup(en, id)).toBeDefined();
});
});
@@ -4,21 +4,15 @@
import { waitFor } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { testRender } from '../../test';
import { testRender } from '../../../test';
import RenameFileDialog from './RenameFileDialog';
import { renameFileDialogDidAccept, renameFileDialogDidCancel } from './actions';
describe('rename button', () => {
it('should accept the dialog Rename is clicked', async () => {
const onAccept = jest.fn();
const onCancel = jest.fn();
const [dialog] = testRender(
<RenameFileDialog
oldName="old.file"
isOpen={true}
onAccept={onAccept}
onCancel={onCancel}
/>,
);
const [dialog, dispatch] = testRender(<RenameFileDialog />, {
explorer: { renameFileDialog: { isOpen: true, fileName: 'old.file' } },
});
const button = dialog.getByRole('button', { name: 'Rename' });
@@ -29,48 +23,36 @@ describe('rename button', () => {
await waitFor(() => expect(button).not.toBeDisabled());
userEvent.click(button);
expect(onAccept).toHaveBeenCalledWith('old.file', 'new.file');
expect(dispatch).toHaveBeenCalledWith(
renameFileDialogDidAccept('old.file', 'new.file'),
);
});
it('should accept the dialog when enter is pressed in the text input', async () => {
const onAccept = jest.fn();
const onCancel = jest.fn();
const [dialog] = testRender(
<RenameFileDialog
oldName="old.file"
isOpen={true}
onAccept={onAccept}
onCancel={onCancel}
/>,
);
const [dialog, dispatch] = testRender(<RenameFileDialog />, {
explorer: { renameFileDialog: { isOpen: true, fileName: 'old.file' } },
});
// have to type a new file name before Rename button is enabled
const input = dialog.getByLabelText('File name');
await waitFor(() => expect(input).toHaveFocus());
userEvent.type(input, 'new{enter}');
expect(onAccept).toHaveBeenCalledWith('old.file', 'new.file');
expect(dispatch).toHaveBeenCalledWith(
renameFileDialogDidAccept('old.file', 'new.file'),
);
});
it('should be cancellable', async () => {
const onAccept = jest.fn();
const onCancel = jest.fn();
const [dialog, dispatch] = testRender(
<RenameFileDialog
oldName="old.file"
isOpen={true}
onAccept={onAccept}
onCancel={onCancel}
/>,
);
const [dialog, dispatch] = testRender(<RenameFileDialog />, {
explorer: { renameFileDialog: { isOpen: true } },
});
const button = dialog.getByRole('button', { name: 'Close' });
await waitFor(() => expect(button).toBeVisible());
userEvent.click(button);
expect(onCancel).toHaveBeenCalled();
expect(dispatch).not.toHaveBeenCalled();
expect(dispatch).toHaveBeenCalledWith(renameFileDialogDidCancel());
});
});
@@ -4,30 +4,26 @@
import { Button, Classes, Dialog } from '@blueprintjs/core';
import { useI18n } from '@shopify/react-i18n';
import React, { useCallback, useRef, useState } from 'react';
import { FileNameValidationResult, validateFileName } from '../pybricksMicropython/lib';
import { useSelector } from '../reducers';
import FileNameFormGroup from './FileNameFormGroup';
import { RenameFileStringId } from './i18n';
import { useDispatch } from 'react-redux';
import {
FileNameValidationResult,
validateFileName,
} from '../../pybricksMicropython/lib';
import { useSelector } from '../../reducers';
import FileNameFormGroup from '../FileNameFormGroup';
import { renameFileDialogDidAccept, renameFileDialogDidCancel } from './actions';
import { RenameFileDialogStringId } from './i18n';
import en from './i18n.en.json';
type RenameFileDialogProps = {
/** The current file name (including file extension). */
oldName: string;
/** Controls the dialog open state. */
isOpen: boolean;
/** Called when the dialog is accepted. */
onAccept: (oldName: string, newName: string) => void;
/** Called when the dialog is canceled. */
onCancel: () => void;
};
const RenameFileDialog: React.VoidFunctionComponent<RenameFileDialogProps> = ({
oldName,
isOpen,
onAccept,
onCancel,
}) => {
const [i18n] = useI18n({ id: 'explorer', translations: { en }, fallback: en });
const RenameFileDialog: React.VFC = () => {
const dispatch = useDispatch();
const isOpen = useSelector((s) => s.explorer.renameFileDialog.isOpen);
const oldName = useSelector((s) => s.explorer.renameFileDialog.fileName);
const [i18n] = useI18n({
id: 'renameFileDialog',
translations: { en },
fallback: en,
});
const [baseName, extension] = oldName.split(/(\.\w+)$/);
@@ -40,14 +36,18 @@ const RenameFileDialog: React.VoidFunctionComponent<RenameFileDialogProps> = ({
const handleSubmit = useCallback<React.FormEventHandler>(
(e) => {
e.preventDefault();
onAccept(oldName, `${newName}${extension}`);
dispatch(renameFileDialogDidAccept(oldName, `${newName}${extension}`));
},
[onAccept, oldName, newName, extension],
[dispatch, oldName, newName, extension],
);
const handleClose = useCallback(() => {
dispatch(renameFileDialogDidCancel());
}, [dispatch]);
return (
<Dialog
title={i18n.translate(RenameFileStringId.Title, {
title={i18n.translate(RenameFileDialogStringId.Title, {
fileName: oldName,
})}
isOpen={isOpen}
@@ -56,7 +56,7 @@ const RenameFileDialog: React.VoidFunctionComponent<RenameFileDialogProps> = ({
inputRef.current?.select();
inputRef.current?.focus();
}}
onClose={onCancel}
onClose={handleClose}
>
<form onSubmit={handleSubmit}>
<div className={Classes.DIALOG_BODY}>
@@ -75,7 +75,7 @@ const RenameFileDialog: React.VoidFunctionComponent<RenameFileDialogProps> = ({
disabled={result !== FileNameValidationResult.IsOk}
type="submit"
>
{i18n.translate(RenameFileStringId.ActionRename)}
{i18n.translate(RenameFileDialogStringId.ActionRename)}
</Button>
</div>
</div>
+33
View File
@@ -0,0 +1,33 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { createAction } from '../../actions';
/**
* Action that requests to show the rename file dialog.
* @param oldName The old file name.
*/
export const renameFileDialogShow = createAction((oldName: string) => ({
type: 'explorer.renameFileDialog.action.show',
oldName,
}));
/**
* Action that indicates the rename file dialog was accepted.
* @param oldName The old file name.
* @param newName The new file name.
*/
export const renameFileDialogDidAccept = createAction(
(oldName: string, newName: string) => ({
type: 'explorer.renameFileDialog.action.didAccept',
oldName,
newName,
}),
);
/**
* Action that indicates the rename file dialog was canceled.
*/
export const renameFileDialogDidCancel = createAction(() => ({
type: 'explorer.renameFileDialog.action.didCancel',
}));
@@ -0,0 +1,8 @@
{
"renameFileDialog": {
"title": "Rename '{fileName}'",
"action": {
"rename": "Rename"
}
}
}
+7
View File
@@ -0,0 +1,7 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
export enum RenameFileDialogStringId {
Title = 'renameFileDialog.title',
ActionRename = 'renameFileDialog.action.rename',
}
+38
View File
@@ -0,0 +1,38 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { Reducer, combineReducers } from 'redux';
import {
renameFileDialogDidAccept,
renameFileDialogDidCancel,
renameFileDialogShow,
} from './actions';
const initialDialogFileName = '';
/** Controls the rename file dialog isOpen state. */
const isOpen: Reducer<boolean> = (state = false, action) => {
if (renameFileDialogShow.matches(action)) {
return true;
}
if (
renameFileDialogDidAccept.matches(action) ||
renameFileDialogDidCancel.matches(action)
) {
return false;
}
return state;
};
/** Controls the rename file dialog file name input box text. */
const fileName: Reducer<string> = (state = initialDialogFileName, action) => {
if (renameFileDialogShow.matches(action)) {
return action.oldName;
}
return state;
};
export default combineReducers({ isOpen, fileName });
+67 -1
View File
@@ -5,15 +5,28 @@ import * as browserFsAccess from 'browser-fs-access';
import { FileWithHandle } from 'browser-fs-access';
import { mock } from 'jest-mock-extended';
import { AsyncSaga } from '../../test';
import { fileStorageWriteFile } from '../fileStorage/actions';
import {
fileStorageDidFailToRenameFile,
fileStorageDidRenameFile,
fileStorageRenameFile,
fileStorageWriteFile,
} from '../fileStorage/actions';
import { pythonFileExtension } from '../pybricksMicropython/lib';
import {
Hub,
explorerCreateNewFile,
explorerDidFailToImportFiles,
explorerDidFailToRenameFile,
explorerDidImportFiles,
explorerDidRenameFile,
explorerImportFiles,
explorerRenameFile,
} from './actions';
import {
renameFileDialogDidAccept,
renameFileDialogDidCancel,
renameFileDialogShow,
} from './renameFileDialog/actions';
import explorer from './sagas';
describe('handleExplorerImportFiles', () => {
@@ -83,3 +96,56 @@ describe('handleExplorerCreateNewFile', () => {
await saga.end();
});
});
describe('handleExplorerRenameFile', () => {
let saga: AsyncSaga;
beforeEach(async () => {
saga = new AsyncSaga(explorer);
saga.put(explorerRenameFile('old.file'));
const action = await saga.take();
expect(action).toEqual(renameFileDialogShow('old.file'));
});
it('should do nothing if canceled', async () => {
saga.put(renameFileDialogDidCancel());
const action = await saga.take();
expect(action).toEqual(explorerDidFailToRenameFile());
});
describe('should attempt to rename file if accepted', () => {
beforeEach(async () => {
saga.put(renameFileDialogDidAccept('old.file', 'new.file'));
const action = await saga.take();
expect(action).toEqual(fileStorageRenameFile('old.file', 'new.file'));
});
test('and chain failure', async () => {
saga.put(
fileStorageDidFailToRenameFile(
'old.file',
'new.file',
new Error('test error'),
),
);
const action = await saga.take();
expect(action).toEqual(explorerDidFailToRenameFile());
});
test('and chain success', async () => {
saga.put(fileStorageDidRenameFile('old.file', 'new.file'));
const action = await saga.take();
expect(action).toEqual(explorerDidRenameFile());
});
});
afterEach(async () => {
await saga.end();
});
});
+69 -3
View File
@@ -2,9 +2,22 @@
// Copyright (c) 2022 The Pybricks Authors
import { fileOpen } from 'browser-fs-access';
import { call, put, select, takeEvery } from 'typed-redux-saga/macro';
import {
call,
put,
race,
select,
take,
takeEvery,
takeLatest,
} from 'typed-redux-saga/macro';
import { getPybricksMicroPythonFileTemplate } from '../editor/pybricksMicroPython';
import { fileStorageWriteFile } from '../fileStorage/actions';
import {
fileStorageDidFailToRenameFile,
fileStorageDidRenameFile,
fileStorageRenameFile,
fileStorageWriteFile,
} from '../fileStorage/actions';
import {
FileNameValidationResult,
pythonFileExtension,
@@ -13,13 +26,21 @@ import {
validateFileName,
} from '../pybricksMicropython/lib';
import { RootState } from '../reducers';
import { ensureError } from '../utils';
import { defined, ensureError } from '../utils';
import {
explorerCreateNewFile,
explorerDidFailToImportFiles,
explorerDidFailToRenameFile,
explorerDidImportFiles,
explorerDidRenameFile,
explorerImportFiles,
explorerRenameFile,
} from './actions';
import {
renameFileDialogDidAccept,
renameFileDialogDidCancel,
renameFileDialogShow,
} from './renameFileDialog/actions';
function* handleExplorerImportFiles(): Generator {
try {
@@ -82,7 +103,52 @@ function* handleExplorerCreateNewFile(
);
}
/** Connects user initiate rename file actions to the rename file dialog. */
function* handleExplorerRenameFile(
action: ReturnType<typeof explorerRenameFile>,
): Generator {
yield* put(renameFileDialogShow(action.fileName));
const { accepted, canceled } = yield* race({
accepted: take(renameFileDialogDidAccept),
canceled: take(renameFileDialogDidCancel),
});
if (canceled) {
yield* put(explorerDidFailToRenameFile());
return;
}
defined(accepted);
yield* put(fileStorageRenameFile(accepted.oldName, accepted.newName));
const { failed } = yield* race({
succeeded: take(
fileStorageDidRenameFile.when(
(a) => a.oldName === accepted.oldName && a.newName === accepted.newName,
),
),
failed: take(
fileStorageDidFailToRenameFile.when(
(a) => a.oldName === accepted.oldName && a.newName === accepted.newName,
),
),
});
if (failed) {
yield* put(explorerDidFailToRenameFile());
return;
}
yield* put(explorerDidRenameFile());
}
export default function* (): Generator {
yield* takeEvery(explorerImportFiles, handleExplorerImportFiles);
yield* takeEvery(explorerCreateNewFile, handleExplorerCreateNewFile);
// takeLatest should ensure that if we trigger a new rename before the
// previous one is finished, the old one will be canceled. We don't expect
// this to happen in practice though.
yield* takeLatest(explorerRenameFile, handleExplorerRenameFile);
}
+2
View File
@@ -5,6 +5,7 @@ import { TypedUseSelectorHook, useSelector as useReduxSelector } from 'react-red
import { Reducer, combineReducers } from 'redux';
import app from './app/reducers';
import ble from './ble/reducers';
import explorer from './explorer/reducers';
import fileStorage from './fileStorage/reducers';
import firmware from './firmware/reducers';
import hub from './hub/reducers';
@@ -17,6 +18,7 @@ export const rootReducer = combineReducers({
app,
bootloader,
ble,
explorer,
fileStorage,
firmware,
hub,