From b16298b08ffe48f0be5b7f6e11dbeb5b3916f1b0 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Mon, 21 Mar 2022 20:52:58 -0500 Subject: [PATCH] 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. --- src/explorer/Explorer.test.tsx | 22 +- src/explorer/Explorer.tsx | 208 +++++++----------- src/explorer/actions.ts | 23 ++ src/explorer/i18n.en.json | 6 - src/explorer/i18n.test.ts | 8 +- src/explorer/i18n.ts | 5 - src/explorer/reducers.ts | 8 + src/explorer/renameFileDialog/18n.test.ts | 12 + .../RenameFileDialog.test.tsx | 54 ++--- .../RenameFileDialog.tsx | 54 ++--- src/explorer/renameFileDialog/actions.ts | 33 +++ src/explorer/renameFileDialog/i18n.en.json | 8 + src/explorer/renameFileDialog/i18n.ts | 7 + src/explorer/renameFileDialog/reducers.ts | 38 ++++ src/explorer/sagas.test.ts | 68 +++++- src/explorer/sagas.ts | 72 +++++- src/reducers.ts | 2 + 17 files changed, 396 insertions(+), 232 deletions(-) create mode 100644 src/explorer/reducers.ts create mode 100644 src/explorer/renameFileDialog/18n.test.ts rename src/explorer/{ => renameFileDialog}/RenameFileDialog.test.tsx (53%) rename src/explorer/{ => renameFileDialog}/RenameFileDialog.tsx (62%) create mode 100644 src/explorer/renameFileDialog/actions.ts create mode 100644 src/explorer/renameFileDialog/i18n.en.json create mode 100644 src/explorer/renameFileDialog/i18n.ts create mode 100644 src/explorer/renameFileDialog/reducers.ts diff --git a/src/explorer/Explorer.test.tsx b/src/explorer/Explorer.test.tsx index db73b75d..2e8155a9 100644 --- a/src/explorer/Explorer.test.tsx +++ b/src/explorer/Explorer.test.tsx @@ -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(, { + it('should dispatch action when button is clicked', async () => { + const [explorer, dispatch] = testRender(, { 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(, { + it('should dispatch action when key is pressed', async () => { + const [explorer, dispatch] = testRender(, { 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 () => { diff --git a/src/explorer/Explorer.tsx b/src/explorer/Explorer.tsx index 6a4d8704..db1f2969 100644 --- a/src/explorer/Explorer.tsx +++ b/src/explorer/Explorer.tsx @@ -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 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 ( toolTipId={ExplorerStringId.TreeItemRenameTooltip} toolTipReplacements={{ fileName }} focusable={false} - onClick={handleRename} + onClick={() => dispatch(explorerRenameFile(fileName))} />
  • ${i18n.translate( ExplorerStringId.TreeLiveDescriptorIntroKeybindingsRename, - { key: '{keybinding:renameItem}' }, + { key: 'f2' }, )}
  • ${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( + () => [ + { + 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
    {renderers.renderTreeContainer(props)}
    ; +}; + const FileTree: React.VFC = () => { const [i18n] = useI18n({ id: 'explorer', translations: { en }, fallback: en }); const [focusedItem, setFocusedItem] = useState(); 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( - (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( - () => [ - { - 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 ( -
    - {renderers.renderTreeContainer(props)} -
    - ); - }, - [renderers, focusedItem, dispatch], - ); - - // override default renderRenameInput since we have a separate rename dialog - const renderRenameInput = useCallback( - ({ item }) => ( - - {getItemTitle(item)} - - ), - [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>(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 = () => { {...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)} >
    @@ -397,13 +347,6 @@ const FileTree: React.VFC = () => { treeId={treeId} rootItem={rootItemIndex} treeLabel={i18n.translate(ExplorerStringId.TreeLabel)} - ref={treeRef} - /> -
    @@ -416,6 +359,7 @@ const Explorer: React.VFC = () => {
    + ); }; diff --git a/src/explorer/actions.ts b/src/explorer/actions.ts index 1f7cc4fe..d1d43d2c 100644 --- a/src/explorer/actions.ts +++ b/src/explorer/actions.ts @@ -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. diff --git a/src/explorer/i18n.en.json b/src/explorer/i18n.en.json index c89fa76a..7518657a 100644 --- a/src/explorer/i18n.en.json +++ b/src/explorer/i18n.en.json @@ -47,11 +47,5 @@ "action": { "create": "Create" } - }, - "renameFile": { - "title": "Rename '{fileName}'", - "action": { - "rename": "Rename" - } } } diff --git a/src/explorer/i18n.test.ts b/src/explorer/i18n.test.ts index 43c545b2..6c478b14 100644 --- a/src/explorer/i18n.test.ts +++ b/src/explorer/i18n.test.ts @@ -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(); - }); -}); diff --git a/src/explorer/i18n.ts b/src/explorer/i18n.ts index 24c39c8a..1a1eaefc 100644 --- a/src/explorer/i18n.ts +++ b/src/explorer/i18n.ts @@ -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', -} diff --git a/src/explorer/reducers.ts b/src/explorer/reducers.ts new file mode 100644 index 00000000..03f36dfd --- /dev/null +++ b/src/explorer/reducers.ts @@ -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 }); diff --git a/src/explorer/renameFileDialog/18n.test.ts b/src/explorer/renameFileDialog/18n.test.ts new file mode 100644 index 00000000..afd2a2f5 --- /dev/null +++ b/src/explorer/renameFileDialog/18n.test.ts @@ -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(); + }); +}); diff --git a/src/explorer/RenameFileDialog.test.tsx b/src/explorer/renameFileDialog/RenameFileDialog.test.tsx similarity index 53% rename from src/explorer/RenameFileDialog.test.tsx rename to src/explorer/renameFileDialog/RenameFileDialog.test.tsx index 2ff96644..120476a1 100644 --- a/src/explorer/RenameFileDialog.test.tsx +++ b/src/explorer/renameFileDialog/RenameFileDialog.test.tsx @@ -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( - , - ); + const [dialog, dispatch] = testRender(, { + 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( - , - ); + const [dialog, dispatch] = testRender(, { + 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( - , - ); + const [dialog, dispatch] = testRender(, { + 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()); }); }); diff --git a/src/explorer/RenameFileDialog.tsx b/src/explorer/renameFileDialog/RenameFileDialog.tsx similarity index 62% rename from src/explorer/RenameFileDialog.tsx rename to src/explorer/renameFileDialog/RenameFileDialog.tsx index 55225fbf..7755c803 100644 --- a/src/explorer/RenameFileDialog.tsx +++ b/src/explorer/renameFileDialog/RenameFileDialog.tsx @@ -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 = ({ - 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 = ({ const handleSubmit = useCallback( (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 ( = ({ inputRef.current?.select(); inputRef.current?.focus(); }} - onClose={onCancel} + onClose={handleClose} >
    @@ -75,7 +75,7 @@ const RenameFileDialog: React.VoidFunctionComponent = ({ disabled={result !== FileNameValidationResult.IsOk} type="submit" > - {i18n.translate(RenameFileStringId.ActionRename)} + {i18n.translate(RenameFileDialogStringId.ActionRename)}
    diff --git a/src/explorer/renameFileDialog/actions.ts b/src/explorer/renameFileDialog/actions.ts new file mode 100644 index 00000000..8c0cae08 --- /dev/null +++ b/src/explorer/renameFileDialog/actions.ts @@ -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', +})); diff --git a/src/explorer/renameFileDialog/i18n.en.json b/src/explorer/renameFileDialog/i18n.en.json new file mode 100644 index 00000000..775399a0 --- /dev/null +++ b/src/explorer/renameFileDialog/i18n.en.json @@ -0,0 +1,8 @@ +{ + "renameFileDialog": { + "title": "Rename '{fileName}'", + "action": { + "rename": "Rename" + } + } +} diff --git a/src/explorer/renameFileDialog/i18n.ts b/src/explorer/renameFileDialog/i18n.ts new file mode 100644 index 00000000..70743a11 --- /dev/null +++ b/src/explorer/renameFileDialog/i18n.ts @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +export enum RenameFileDialogStringId { + Title = 'renameFileDialog.title', + ActionRename = 'renameFileDialog.action.rename', +} diff --git a/src/explorer/renameFileDialog/reducers.ts b/src/explorer/renameFileDialog/reducers.ts new file mode 100644 index 00000000..3ac7bfa0 --- /dev/null +++ b/src/explorer/renameFileDialog/reducers.ts @@ -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 = (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 = (state = initialDialogFileName, action) => { + if (renameFileDialogShow.matches(action)) { + return action.oldName; + } + + return state; +}; + +export default combineReducers({ isOpen, fileName }); diff --git a/src/explorer/sagas.test.ts b/src/explorer/sagas.test.ts index f8df6a16..806890c1 100644 --- a/src/explorer/sagas.test.ts +++ b/src/explorer/sagas.test.ts @@ -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(); + }); +}); diff --git a/src/explorer/sagas.ts b/src/explorer/sagas.ts index e73daac4..980d6b67 100644 --- a/src/explorer/sagas.ts +++ b/src/explorer/sagas.ts @@ -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, +): 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); } diff --git a/src/reducers.ts b/src/reducers.ts index 7d21bc3b..4af658a8 100644 --- a/src/reducers.ts +++ b/src/reducers.ts @@ -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,