mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-12 01:23:52 +00:00
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:
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -47,11 +47,5 @@
|
||||
"action": {
|
||||
"create": "Create"
|
||||
}
|
||||
},
|
||||
"renameFile": {
|
||||
"title": "Rename '{fileName}'",
|
||||
"action": {
|
||||
"rename": "Rename"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
+18
-36
@@ -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());
|
||||
});
|
||||
});
|
||||
+27
-27
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
export enum RenameFileDialogStringId {
|
||||
Title = 'renameFileDialog.title',
|
||||
ActionRename = 'renameFileDialog.action.rename',
|
||||
}
|
||||
@@ -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 });
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user