From d8f958d18e106f84e1d11fe872bc45f060c45165 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Sat, 12 Mar 2022 14:45:06 -0600 Subject: [PATCH] explorer: implement file renaming --- package.json | 1 + src/explorer/Explorer.tsx | 37 ++++++- src/explorer/FileNameFormGroup.tsx | 131 +++++++++++++++++++++++++ src/explorer/NewFileWizard.tsx | 111 +++------------------ src/explorer/RenameFileDialog.test.tsx | 48 +++++++++ src/explorer/RenameFileDialog.tsx | 83 ++++++++++++++++ src/explorer/i18n.en.json | 6 ++ src/explorer/i18n.test.ts | 8 +- src/explorer/i18n.ts | 5 + src/pybricksMicropython/lib.ts | 4 +- yarn.lock | 5 + 11 files changed, 334 insertions(+), 105 deletions(-) create mode 100644 src/explorer/FileNameFormGroup.tsx create mode 100644 src/explorer/RenameFileDialog.test.tsx create mode 100644 src/explorer/RenameFileDialog.tsx diff --git a/package.json b/package.json index 237c5e76..66c59d28 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "spdx-satisfies": "^5.0.0", "typed-redux-saga": "^1.4.0", "typescript": "~4.6.2", + "usehooks-ts": "^2.4.2", "web-vitals": "^2.1.4", "xterm": "^4.18.0", "xterm-addon-fit": "^0.5.0", diff --git a/src/explorer/Explorer.tsx b/src/explorer/Explorer.tsx index cbaa254f..1d4a0497 100644 --- a/src/explorer/Explorer.tsx +++ b/src/explorer/Explorer.tsx @@ -12,14 +12,22 @@ import { TreeNodeInfo, } from '@blueprintjs/core'; import { useI18n } from '@shopify/react-i18n'; -import React, { forwardRef, useImperativeHandle, useMemo, useState } from 'react'; +import React, { + forwardRef, + useEffect, + useImperativeHandle, + useMemo, + useState, +} from 'react'; import { useDispatch } from 'react-redux'; +import { useDebounce } from 'usehooks-ts'; import { fileStorageArchiveAllFiles, fileStorageExportFile, } from '../fileStorage/actions'; import { useSelector } from '../reducers'; import NewFileWizard from './NewFileWizard'; +import RenameFileDialog from './RenameFileDialog'; import { explorerDeleteFile, explorerImportFiles } from './actions'; import { ExplorerStringId } from './i18n'; import en from './i18n.en.json'; @@ -68,8 +76,21 @@ const FileActionButtonGroup = forwardRef< >((props, ref) => { const dispatch = useDispatch(); const [visible, setVisible] = useState(false); + const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); - useImperativeHandle(ref, () => ({ setVisible })); + // HACK: Hide buttons if file is removed from storage. Without this, if a + // file is renamed to a new name then renamed again to the original name, + // the buttons will be showing even though the list item is not hovered + // because the list item was removed before the mouseleave event was + // received. + const fileNames = useSelector((s) => s.fileStorage.fileNames); + useEffect(() => { + if (!fileNames.includes(props.fileName)) { + setVisible(false); + } + }, [fileNames, props.fileName, setVisible]); + + useImperativeHandle(ref, () => ({ setVisible }), [setVisible]); return ( @@ -77,7 +98,12 @@ const FileActionButtonGroup = forwardRef< icon="edit" toolTipId={ExplorerStringId.TreeItemRenameTooltip} toolTipReplacements={{ fileName: props.fileName }} - onClick={() => alert('not implemented')} + onClick={() => setIsRenameDialogOpen(true)} + /> + setIsRenameDialogOpen(false)} /> { const FileTree: React.VFC = () => { const fileNames = useSelector((s) => s.fileStorage.fileNames); + const debouncedFileNames = useDebounce(fileNames); const treeContents = useMemo( () => - [...fileNames].map< + [...debouncedFileNames].map< TreeNodeInfo<{ actionButtonGroupRef: React.RefObject; }> @@ -163,7 +190,7 @@ const FileTree: React.VFC = () => { nodeData: { actionButtonGroupRef }, }; }), - [fileNames], + [debouncedFileNames], ); return ( diff --git a/src/explorer/FileNameFormGroup.tsx b/src/explorer/FileNameFormGroup.tsx new file mode 100644 index 00000000..615acb59 --- /dev/null +++ b/src/explorer/FileNameFormGroup.tsx @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { Classes, FormGroup, InputGroup, Intent, Tag } from '@blueprintjs/core'; +import { useI18n } from '@shopify/react-i18n'; +import React, { useMemo } from 'react'; +import { FileNameValidationResult, validateFileName } from '../pybricksMicropython/lib'; +import { useSelector } from '../reducers'; +import { NewFileWizardStringId } from './i18n'; +import en from './i18n.en.json'; + +type FileNameHelpTextProps = { + /** The result of the file name validation. */ + validation: Exclude; +}; + +/** + * Component that maps FileNameValidationResult to help message to display to user. + */ +const FileNameHelpText: React.VoidFunctionComponent = ( + props, +) => { + const [i18n] = useI18n({ id: 'explorer', translations: { en }, fallback: en }); + + switch (props.validation) { + case FileNameValidationResult.IsOk: + return <>{i18n.translate(NewFileWizardStringId.FileNameHelpTextIsOk)}; + case FileNameValidationResult.IsEmpty: + return <>{i18n.translate(NewFileWizardStringId.FileNameHelpTextIsEmpty)}; + case FileNameValidationResult.HasSpaces: + return ( + <>{i18n.translate(NewFileWizardStringId.FileNameHelpTextHasSpaces)} + ); + case FileNameValidationResult.HasFileExtension: + return ( + <> + {i18n.translate( + NewFileWizardStringId.FileNameHelpTextHasFileExtension, + )} + + ); + case FileNameValidationResult.HasInvalidFirstCharacter: + return ( + <> + {i18n.translate( + NewFileWizardStringId.FileNameHelpTextHasInvalidFirstCharacter, + { + letters: a…z, + underscore: _, + }, + )} + + ); + case FileNameValidationResult.HasInvalidCharacters: + return ( + <> + {i18n.translate( + NewFileWizardStringId.FileNameHelpTextHasInvalidCharacters, + { + letters: a…z, + numbers: 0…9, + dash: -, + underscore: _, + }, + )} + + ); + case FileNameValidationResult.AlreadyExists: + return ( + <> + {i18n.translate( + NewFileWizardStringId.FileNameHelpTextAlreadyExists, + )} + + ); + } +}; + +type FileNameFormGroupProps = { + /** The file name in the input (without file extension). */ + readonly fileName: string; + /** The file extension (including leading ".") */ + readonly fileExtension: string; + /** Ref to get handle to input (e.g to be able to call focus()) */ + readonly inputRef?: React.RefObject; + /** Called when the user changes the text in the input box. */ + readonly onChange: (newName: string) => void; + /** Called when `fileName` is validated. */ + readonly onValidation: (result: FileNameValidationResult) => void; +}; + +/** + * Component used to get a valid new file name. + */ +const FileNameFormGroup: React.VoidFunctionComponent = ( + props, +) => { + const [i18n] = useI18n({ id: 'explorer', translations: { en }, fallback: en }); + const fileNames = useSelector((s) => s.fileStorage.fileNames); + + const [fileNameValidation, fileNameIntent] = useMemo(() => { + const result = validateFileName(props.fileName, props.fileExtension, fileNames); + + // can't call callback now because it would break react, so defer it + setTimeout(() => props.onValidation(result), 0); + + return [ + result, + result === FileNameValidationResult.IsOk ? Intent.NONE : Intent.DANGER, + ]; + }, [props.fileName, props.fileExtension, fileNames]); + + return ( + } + > + {props.fileExtension}} + onChange={(e) => props.onChange(e.target.value)} + /> + + ); +}; + +export default FileNameFormGroup; diff --git a/src/explorer/NewFileWizard.tsx b/src/explorer/NewFileWizard.tsx index 3b925a36..3dad311b 100644 --- a/src/explorer/NewFileWizard.tsx +++ b/src/explorer/NewFileWizard.tsx @@ -6,10 +6,8 @@ import { Classes, Dialog, FormGroup, - InputGroup, Radio, RadioGroup, - Tag, } from '@blueprintjs/core'; import { useI18n } from '@shopify/react-i18n'; import React, { useRef, useState } from 'react'; @@ -17,9 +15,8 @@ import { useDispatch } from 'react-redux'; import { FileNameValidationResult, pythonFileExtension, - validateFileName, } from '../pybricksMicropython/lib'; -import { useSelector } from '../reducers'; +import FileNameFormGroup from './FileNameFormGroup'; import { Hub, explorerCreateNewFile } from './actions'; import { NewFileWizardStringId } from './i18n'; import en from './i18n.en.json'; @@ -27,124 +24,42 @@ import en from './i18n.en.json'; // This should be set to the most commonly used hub. const defaultHub = Hub.Technic; -type FileNameHelpTextProps = { - validation: FileNameValidationResult; -}; - -/** - * Component that maps FileNameValidationResult to help message to display to user. - */ -const FileNameHelpText: React.VoidFunctionComponent = ( - props, -) => { - const [i18n] = useI18n({ id: 'newFileWizard', translations: { en }, fallback: en }); - - switch (props.validation) { - case FileNameValidationResult.IsOk: - return <>{i18n.translate(NewFileWizardStringId.FileNameHelpTextIsOk)}; - case FileNameValidationResult.IsEmpty: - return <>{i18n.translate(NewFileWizardStringId.FileNameHelpTextIsEmpty)}; - case FileNameValidationResult.HasSpaces: - return ( - <>{i18n.translate(NewFileWizardStringId.FileNameHelpTextHasSpaces)} - ); - case FileNameValidationResult.HasFileExtension: - return ( - <> - {i18n.translate( - NewFileWizardStringId.FileNameHelpTextHasFileExtension, - )} - - ); - case FileNameValidationResult.HasInvalidFirstCharacter: - return ( - <> - {i18n.translate( - NewFileWizardStringId.FileNameHelpTextHasInvalidFirstCharacter, - { - letters: a…z, - underscore: _, - }, - )} - - ); - case FileNameValidationResult.HasInvalidCharacters: - return ( - <> - {i18n.translate( - NewFileWizardStringId.FileNameHelpTextHasInvalidCharacters, - { - letters: a…z, - numbers: 0…9, - dash: -, - underscore: _, - }, - )} - - ); - case FileNameValidationResult.AlreadyExists: - return ( - <> - {i18n.translate( - NewFileWizardStringId.FileNameHelpTextAlreadyExists, - )} - - ); - } -}; - type NewFileWizardProps = { + /** Controls if the dialog is open. */ readonly isOpen: boolean; + /** Called when the dialog is closed. */ readonly onClose: () => void; }; const NewFileWizard: React.VoidFunctionComponent = (props) => { - const [i18n] = useI18n({ id: 'newFileWizard', translations: { en }, fallback: en }); + const [i18n] = useI18n({ id: 'explorer', translations: { en }, fallback: en }); const dispatch = useDispatch(); - const fileNames = useSelector((s) => s.fileStorage.fileNames); const [fileName, setFileName] = useState(''); const [fileNameValidation, setFileNameValidation] = useState( - FileNameValidationResult.IsEmpty, + FileNameValidationResult.Unknown, ); const [hubType, setHubType] = useState(defaultHub); const fileNameInputRef = useRef(null); - const fileNameIntent = - fileNameValidation === FileNameValidationResult.IsOk ? 'none' : 'danger'; - - const handleFileNameChanged = (fileName: string) => { - setFileNameValidation( - validateFileName(fileName, pythonFileExtension, fileNames), - ); - setFileName(fileName); - }; - return ( handleFileNameChanged('')} + onOpening={() => setFileName('')} onOpened={() => fileNameInputRef.current?.focus()} onClose={() => props.onClose()} >
- } - > - {pythonFileExtension}} - onChange={(e) => handleFileNameChanged(e.target.value)} - /> - + setFileName(n)} + onValidation={(r) => setFileNameValidation(r)} + /> { + it('should close the dialog and dispatch an action when Rename is clicked', async () => { + const onClose = jest.fn(); + const [dialog, dispatch] = testRender( + , + ); + + const button = dialog.getByLabelText('Rename'); + + // 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'); + await waitFor(() => expect(button).not.toBeDisabled()); + + userEvent.click(button); + expect(onClose).toHaveBeenCalled(); + expect(dispatch).toHaveBeenCalledWith( + fileStorageRenameFile('old.file', 'new.file'), + ); + }); + + it('should be cancellable', async () => { + const onClose = jest.fn(); + + const [dialog, dispatch] = testRender( + , + ); + + const button = dialog.getByLabelText('Close'); + + await waitFor(() => expect(button).toBeVisible()); + + userEvent.click(button); + expect(onClose).toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/src/explorer/RenameFileDialog.tsx b/src/explorer/RenameFileDialog.tsx new file mode 100644 index 00000000..34f293f6 --- /dev/null +++ b/src/explorer/RenameFileDialog.tsx @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { Button, Classes, Dialog } from '@blueprintjs/core'; +import { useI18n } from '@shopify/react-i18n'; +import React, { useRef, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { fileStorageRenameFile } from '../fileStorage/actions'; +import { FileNameValidationResult } from '../pybricksMicropython/lib'; +import FileNameFormGroup from './FileNameFormGroup'; +import { RenameFileStringId } 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 closed. */ + onClose: () => void; +}; + +const RenameFileDialog: React.VoidFunctionComponent = ( + props, +) => { + const [i18n] = useI18n({ id: 'explorer', translations: { en }, fallback: en }); + const dispatch = useDispatch(); + + const [baseName, extension] = props.oldName.split(/(\.\w+)$/); + + const [newName, setNewName] = useState(baseName); + const [result, setResult] = useState(FileNameValidationResult.Unknown); + + const inputRef = useRef(null); + + return ( + setNewName(baseName)} + onOpened={() => { + inputRef.current?.select(); + inputRef.current?.focus(); + }} + onClose={() => props.onClose()} + > +
+ setNewName(n)} + onValidation={(r) => setResult(r)} + /> +
+
+
+ +
+
+
+ ); +}; + +export default RenameFileDialog; diff --git a/src/explorer/i18n.en.json b/src/explorer/i18n.en.json index 0dfcd595..bca57c69 100644 --- a/src/explorer/i18n.en.json +++ b/src/explorer/i18n.en.json @@ -31,5 +31,11 @@ "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 6c478b14..43c545b2 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 } from './i18n'; +import { ExplorerStringId, NewFileWizardStringId, RenameFileStringId } from './i18n'; import en from './i18n.en.json'; describe('Ensure .json file has matches for ExplorerStringId', () => { @@ -16,3 +16,9 @@ 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 582bbc3f..8c7028dc 100644 --- a/src/explorer/i18n.ts +++ b/src/explorer/i18n.ts @@ -25,3 +25,8 @@ 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/pybricksMicropython/lib.ts b/src/pybricksMicropython/lib.ts index 47a85fbb..8e51d8bb 100644 --- a/src/pybricksMicropython/lib.ts +++ b/src/pybricksMicropython/lib.ts @@ -12,6 +12,8 @@ export const pythonFileMimeType = 'text/x-python'; /** File name validation results. */ export enum FileNameValidationResult { + /** The result is not yet known. */ + Unknown, /** The file name is acceptable. */ IsOk, /** The file name is an empty string. */ @@ -40,7 +42,7 @@ export function validateFileName( fileName: string, extension: string, existingFiles: ReadonlyArray, -): FileNameValidationResult { +): Exclude { if (existingFiles.includes(`${fileName}${extension}`)) { return FileNameValidationResult.AlreadyExists; } diff --git a/yarn.lock b/yarn.lock index 0c0a6bf5..6f8db233 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12370,6 +12370,11 @@ use@^3.1.0: resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ== +usehooks-ts@^2.4.2: + version "2.4.2" + resolved "https://registry.yarnpkg.com/usehooks-ts/-/usehooks-ts-2.4.2.tgz#a9a5df9d04dcce993d7e8ec088965ba44dfcd9cc" + integrity sha512-YqD5HaloGpRSoNaXhAxR+CHTKda44mDtFqNJk5gRuy4qeHVxpp8ycN3LY0txYqNudCVuAk1f8m6M0ojQ5Aph8Q== + util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"