explorer: implement file renaming

This commit is contained in:
David Lechner
2022-03-12 14:45:06 -06:00
parent 33974c52b3
commit d8f958d18e
11 changed files with 334 additions and 105 deletions
+1
View File
@@ -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",
+32 -5
View File
@@ -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 (
<ButtonGroup minimal={true} style={visible ? {} : { display: 'none' }}>
@@ -77,7 +98,12 @@ const FileActionButtonGroup = forwardRef<
icon="edit"
toolTipId={ExplorerStringId.TreeItemRenameTooltip}
toolTipReplacements={{ fileName: props.fileName }}
onClick={() => alert('not implemented')}
onClick={() => setIsRenameDialogOpen(true)}
/>
<RenameFileDialog
oldName={props.fileName}
isOpen={isRenameDialogOpen}
onClose={() => setIsRenameDialogOpen(false)}
/>
<ActionButton
// NB: the "import" icon has an arrow pointing down, which is
@@ -140,10 +166,11 @@ const Header: React.VFC = () => {
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<FileActionButtonGroupRef>;
}>
@@ -163,7 +190,7 @@ const FileTree: React.VFC = () => {
nodeData: { actionButtonGroupRef },
};
}),
[fileNames],
[debouncedFileNames],
);
return (
+131
View File
@@ -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<FileNameValidationResult, FileNameValidationResult.Unknown>;
};
/**
* Component that maps FileNameValidationResult to help message to display to user.
*/
const FileNameHelpText: React.VoidFunctionComponent<FileNameHelpTextProps> = (
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: <code className={Classes.CODE}>az</code>,
underscore: <code className={Classes.CODE}>_</code>,
},
)}
</>
);
case FileNameValidationResult.HasInvalidCharacters:
return (
<>
{i18n.translate(
NewFileWizardStringId.FileNameHelpTextHasInvalidCharacters,
{
letters: <code className={Classes.CODE}>az</code>,
numbers: <code className={Classes.CODE}>09</code>,
dash: <code className={Classes.CODE}>-</code>,
underscore: <code className={Classes.CODE}>_</code>,
},
)}
</>
);
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<HTMLInputElement>;
/** 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<FileNameFormGroupProps> = (
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 (
<FormGroup
label={i18n.translate(NewFileWizardStringId.FileNameLabel)}
intent={fileNameIntent}
subLabel={<FileNameHelpText validation={fileNameValidation} />}
>
<InputGroup
aria-label="File name"
value={props.fileName}
inputRef={props.inputRef}
intent={fileNameIntent}
rightElement={<Tag>{props.fileExtension}</Tag>}
onChange={(e) => props.onChange(e.target.value)}
/>
</FormGroup>
);
};
export default FileNameFormGroup;
+13 -98
View File
@@ -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<FileNameHelpTextProps> = (
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: <code className={Classes.CODE}>az</code>,
underscore: <code className={Classes.CODE}>_</code>,
},
)}
</>
);
case FileNameValidationResult.HasInvalidCharacters:
return (
<>
{i18n.translate(
NewFileWizardStringId.FileNameHelpTextHasInvalidCharacters,
{
letters: <code className={Classes.CODE}>az</code>,
numbers: <code className={Classes.CODE}>09</code>,
dash: <code className={Classes.CODE}>-</code>,
underscore: <code className={Classes.CODE}>_</code>,
},
)}
</>
);
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<NewFileWizardProps> = (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<HTMLInputElement>(null);
const fileNameIntent =
fileNameValidation === FileNameValidationResult.IsOk ? 'none' : 'danger';
const handleFileNameChanged = (fileName: string) => {
setFileNameValidation(
validateFileName(fileName, pythonFileExtension, fileNames),
);
setFileName(fileName);
};
return (
<Dialog
icon="plus"
title={i18n.translate(NewFileWizardStringId.Title)}
isOpen={props.isOpen}
onOpening={() => handleFileNameChanged('')}
onOpening={() => setFileName('')}
onOpened={() => fileNameInputRef.current?.focus()}
onClose={() => props.onClose()}
>
<div className={Classes.DIALOG_BODY}>
<FormGroup
label={i18n.translate(NewFileWizardStringId.FileNameLabel)}
intent={fileNameIntent}
subLabel={<FileNameHelpText validation={fileNameValidation} />}
>
<InputGroup
aria-label="File name"
value={fileName}
inputRef={fileNameInputRef}
intent={fileNameIntent}
rightElement={<Tag>{pythonFileExtension}</Tag>}
onChange={(e) => handleFileNameChanged(e.target.value)}
/>
</FormGroup>
<FileNameFormGroup
fileName={fileName}
fileExtension={pythonFileExtension}
inputRef={fileNameInputRef}
onChange={(n) => setFileName(n)}
onValidation={(r) => setFileNameValidation(r)}
/>
<FormGroup label={i18n.translate(NewFileWizardStringId.SmartHubLabel)}>
<RadioGroup
selectedValue={hubType}
+48
View File
@@ -0,0 +1,48 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { waitFor } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { testRender } from '../../test';
import { fileStorageRenameFile } from '../fileStorage/actions';
import RenameFileDialog from './RenameFileDialog';
describe('rename button', () => {
it('should close the dialog and dispatch an action when Rename is clicked', async () => {
const onClose = jest.fn();
const [dialog, dispatch] = testRender(
<RenameFileDialog oldName="old.file" isOpen={true} onClose={onClose} />,
);
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(
<RenameFileDialog oldName="old.file" isOpen={true} onClose={onClose} />,
);
const button = dialog.getByLabelText('Close');
await waitFor(() => expect(button).toBeVisible());
userEvent.click(button);
expect(onClose).toHaveBeenCalled();
expect(dispatch).not.toHaveBeenCalled();
});
});
+83
View File
@@ -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<RenameFileDialogProps> = (
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<HTMLInputElement>(null);
return (
<Dialog
title={i18n.translate(RenameFileStringId.Title, {
fileName: props.oldName,
})}
isOpen={props.isOpen}
onOpening={() => setNewName(baseName)}
onOpened={() => {
inputRef.current?.select();
inputRef.current?.focus();
}}
onClose={() => props.onClose()}
>
<div className={Classes.DIALOG_BODY}>
<FileNameFormGroup
fileName={newName}
fileExtension={extension}
inputRef={inputRef}
onChange={(n) => setNewName(n)}
onValidation={(r) => setResult(r)}
/>
</div>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Button
aria-label="Rename"
intent="primary"
disabled={result !== FileNameValidationResult.IsOk}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
props.onClose();
dispatch(
fileStorageRenameFile(
props.oldName,
`${newName}${extension}`,
),
);
}}
>
{i18n.translate(RenameFileStringId.ActionRename)}
</Button>
</div>
</div>
</Dialog>
);
};
export default RenameFileDialog;
+6
View File
@@ -31,5 +31,11 @@
"action": {
"create": "Create"
}
},
"renameFile": {
"title": "Rename '{fileName}'",
"action": {
"rename": "Rename"
}
}
}
+7 -1
View File
@@ -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();
});
});
+5
View File
@@ -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',
}
+3 -1
View File
@@ -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<string>,
): FileNameValidationResult {
): Exclude<FileNameValidationResult, FileNameValidationResult.Unknown> {
if (existingFiles.includes(`${fileName}${extension}`)) {
return FileNameValidationResult.AlreadyExists;
}
+5
View File
@@ -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"