explorer: implement duplicate file feature

This commit is contained in:
David Lechner
2022-04-08 18:11:09 -05:00
parent 30741b13ef
commit e0a6926d01
25 changed files with 693 additions and 37 deletions
+33
View File
@@ -11,6 +11,7 @@ import {
explorerArchiveAllFiles,
explorerCreateNewFile,
explorerDeleteFile,
explorerDuplicateFile,
explorerExportFile,
explorerImportFiles,
} from './actions';
@@ -101,6 +102,38 @@ describe('tree item', () => {
expect(dispatch).toHaveBeenCalledWith(explorerActivateFile('test.file'));
});
describe('duplicate', () => {
it('should dispatch action when button is clicked', async () => {
const [explorer, dispatch] = testRender(<Explorer />, {
explorer: { files: [testFile] },
});
// NB: this button is intentionally not accessible (by role) since
// there is a keyboard shortcut.
const button = explorer.getByTitle('Duplicate test.file');
userEvent.click(button);
expect(dispatch).toHaveBeenCalledWith(explorerDuplicateFile('test.file'));
// should not propagate to treeitem
expect(dispatch).toHaveBeenCalledTimes(1);
});
it('should dispatch action when key is pressed', async () => {
const [explorer, dispatch] = testRender(<Explorer />, {
explorer: { files: [testFile] },
});
const treeItem = explorer.getByRole('treeitem', { name: 'test.file' });
userEvent.click(treeItem);
userEvent.keyboard('{ctrl}d');
expect(dispatch).toHaveBeenCalledWith(explorerDuplicateFile('test.file'));
});
});
describe('export', () => {
it('should dispatch export action when button is clicked', async () => {
const [explorer, dispatch] = testRender(<Explorer />, {
+30
View File
@@ -33,10 +33,12 @@ import {
explorerArchiveAllFiles,
explorerCreateNewFile,
explorerDeleteFile,
explorerDuplicateFile,
explorerExportFile,
explorerImportFiles,
} from './actions';
import DeleteFileAlert from './deleteFileAlert/DeleteFileAlert';
import DuplicateFileDialog from './duplicateFileDialog/DuplicateFileDialog';
import { I18nId } from './i18n';
import NewFileWizard from './newFileWizard/NewFileWizard';
@@ -107,6 +109,12 @@ const FileActionButtonGroup: React.VoidFunctionComponent<ActionButtonGroupProps>
className="pb-explorer-file-action-button-group"
minimal={true}
>
<ActionButton
icon="duplicate"
tooltip={i18n.translate(I18nId.TreeItemDuplicateTooltip, { fileName })}
focusable={false}
onClick={() => dispatch(explorerDuplicateFile(fileName))}
/>
<ActionButton
// NB: the "import" icon has an arrow pointing down, which is
// what we want here since import is analogous to download
@@ -181,6 +189,10 @@ function useLiveDescriptors(i18n: I18n): LiveDescriptors {
I18nId.TreeLiveDescriptorIntroKeybindingsPrimaryAction,
{ key: '{keybinding:primaryAction}' },
)}</li>
<li>${i18n.translate(
I18nId.TreeLiveDescriptorIntroKeybindingsDuplicate,
{ key: `${isMacOS() ? 'cmd' : 'ctrl'}+d` },
)}</li>
<li>${i18n.translate(
I18nId.TreeLiveDescriptorIntroKeybindingsExport,
{ key: `${isMacOS() ? 'cmd' : 'ctrl'}+e` },
@@ -216,6 +228,13 @@ const renderTreeContainer: typeof renderers.renderTreeContainer = (props) => {
const hotKeyActive =
isActiveTree; /* && !dnd.isProgrammaticallyDragging && !isRenaming */
const handleDuplicateKeyDown = useCallback(() => {
if (focusedItem !== undefined) {
const fileName = environment.getItemTitle(environment.items[focusedItem]);
dispatch(explorerDuplicateFile(fileName));
}
}, [environment]);
const handleDeleteKeyDown = useCallback(() => {
if (focusedItem !== undefined) {
const fileName = environment.getItemTitle(environment.items[focusedItem]);
@@ -232,11 +251,20 @@ const renderTreeContainer: typeof renderers.renderTreeContainer = (props) => {
const hotkeys = useMemo<readonly HotkeyConfig[]>(
() => [
{
combo: 'mod+d',
label: 'Duplicate',
disabled: !hotKeyActive,
preventDefault: true,
stopPropagation: true,
onKeyDown: handleDuplicateKeyDown,
},
{
combo: 'del',
label: 'Delete',
disabled: !hotKeyActive,
preventDefault: true,
stopPropagation: true,
onKeyDown: handleDeleteKeyDown,
},
{
@@ -244,6 +272,7 @@ const renderTreeContainer: typeof renderers.renderTreeContainer = (props) => {
label: 'Export',
disabled: !hotKeyActive,
preventDefault: true,
stopPropagation: true,
onKeyDown: handleExportKeyDown,
},
],
@@ -354,6 +383,7 @@ const Explorer: React.VFC = () => {
<Divider />
<FileTree i18n={i18n} />
<NewFileWizard />
<DuplicateFileDialog />
<DeleteFileAlert />
</div>
);
+31
View File
@@ -102,6 +102,37 @@ export const explorerDidFailToActivateFile = createAction(
}),
);
/**
* Action that requests to duplicate a file.
* @param fileName The file name.
*/
export const explorerDuplicateFile = createAction((fileName: string) => ({
type: 'explorer.action.duplicateFile',
fileName,
}));
/**
* Action that indicates that {@link explorerDuplicateFile} succeeded.
* @param fileName The file name.
*/
export const explorerDidDuplicateFile = createAction((fileName: string) => ({
type: 'explorer.action.didDuplicateFile',
fileName,
}));
/**
* Action that indicates that {@link explorerDuplicateFile} failed.
* @param fileName The file name.
* @param err The error.
*/
export const explorerDidFailToDuplicateFile = createAction(
(fileName: string, error: Error) => ({
type: 'explorer.action.didFailToDuplicateFile',
fileName,
error,
}),
);
/**
* Request to export (download) a file.
* @param fileName The file name.
@@ -0,0 +1,80 @@
// 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 DuplicateFileDialog from './DuplicateFileDialog';
import { duplicateFileDialogDidAccept, duplicateFileDialogDidCancel } from './actions';
describe('duplicate button', () => {
it('should accept the dialog Duplicate is clicked', async () => {
const [dialog, dispatch] = testRender(<DuplicateFileDialog />, {
explorer: {
duplicateFileDialog: { isOpen: true, fileName: 'source.file' },
},
});
const button = dialog.getByRole('button', { name: 'Duplicate' });
// have to type a new file name before Duplicate button is enabled
const input = dialog.getByRole('textbox', { name: 'File name' });
await waitFor(() => expect(input).toHaveFocus());
userEvent.type(input, 'new');
await waitFor(() => expect(button).not.toBeDisabled());
userEvent.click(button);
expect(dispatch).toHaveBeenCalledWith(
duplicateFileDialogDidAccept('source.file', 'new.file'),
);
});
it('should accept the dialog when enter is pressed in the text input', async () => {
const [dialog, dispatch] = testRender(<DuplicateFileDialog />, {
explorer: {
duplicateFileDialog: { isOpen: true, fileName: 'source.file' },
},
});
// have to type a new file name before Duplicate button is enabled
const input = dialog.getByRole('textbox', { name: 'File name' });
await waitFor(() => expect(input).toHaveFocus());
userEvent.type(input, 'new{enter}');
expect(dispatch).toHaveBeenCalledWith(
duplicateFileDialogDidAccept('source.file', 'new.file'),
);
});
it('should cancel when user clicks close button', async () => {
const [dialog, dispatch] = testRender(<DuplicateFileDialog />, {
explorer: {
duplicateFileDialog: { isOpen: true, fileName: 'source.file' },
},
});
const button = dialog.getByRole('button', { name: 'Close' });
await waitFor(() => expect(button).toBeVisible());
userEvent.click(button);
expect(dispatch).toHaveBeenCalledWith(duplicateFileDialogDidCancel());
});
it('should cancel when user user presses esc key', async () => {
const [dialog, dispatch] = testRender(<DuplicateFileDialog />, {
explorer: {
duplicateFileDialog: { isOpen: true, fileName: 'source.file' },
},
});
await waitFor(() =>
expect(dialog.getByRole('textbox', { name: 'File name' })).toHaveFocus(),
);
userEvent.keyboard('{esc}');
expect(dispatch).toHaveBeenCalledWith(duplicateFileDialogDidCancel());
});
});
@@ -0,0 +1,87 @@
// 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, { useCallback, useRef, useState } from 'react';
import { useDispatch } from 'react-redux';
import {
FileNameValidationResult,
validateFileName,
} from '../../pybricksMicropython/lib';
import { useSelector } from '../../reducers';
import FileNameFormGroup from '../fileNameFormGroup/FileNameFormGroup';
import { duplicateFileDialogDidAccept, duplicateFileDialogDidCancel } from './actions';
import { I18nId } from './i18n';
const DuplicateFileDialog: React.VFC = () => {
// istanbul ignore next: babel-loader rewrites this line
const [i18n] = useI18n();
const dispatch = useDispatch();
const isOpen = useSelector((s) => s.explorer.duplicateFileDialog.isOpen);
const oldName = useSelector((s) => s.explorer.duplicateFileDialog.fileName);
const [baseName, extension] = oldName.split(/(\.\w+)$/);
const [newName, setNewName] = useState(baseName);
const files = useSelector((s) => s.explorer.files);
const result = validateFileName(
newName,
extension,
files.map((f) => f.name),
);
const inputRef = useRef<HTMLInputElement>(null);
const handleSubmit = useCallback<React.FormEventHandler>(
(e) => {
e.preventDefault();
dispatch(duplicateFileDialogDidAccept(oldName, `${newName}${extension}`));
},
[dispatch, oldName, newName, extension],
);
const handleClose = useCallback(() => {
dispatch(duplicateFileDialogDidCancel());
}, [dispatch]);
return (
<Dialog
title={i18n.translate(I18nId.Title, {
fileName: oldName,
})}
isOpen={isOpen}
onOpening={() => setNewName(baseName)}
onOpened={() => {
inputRef.current?.select();
inputRef.current?.focus();
}}
onClose={handleClose}
>
<form onSubmit={handleSubmit}>
<div className={Classes.DIALOG_BODY}>
<FileNameFormGroup
fileName={newName}
fileExtension={extension}
validationResult={result}
inputRef={inputRef}
onChange={setNewName}
/>
</div>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Button
intent="primary"
disabled={result !== FileNameValidationResult.IsOk}
type="submit"
>
{i18n.translate(I18nId.ActionAccept)}
</Button>
</div>
</div>
</form>
</Dialog>
);
};
export default DuplicateFileDialog;
@@ -0,0 +1,33 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { createAction } from '../../actions';
/**
* Action that requests to show the duplicate file dialog.
* @param oldName The old file name.
*/
export const duplicateFileDialogShow = createAction((oldName: string) => ({
type: 'explorer.duplicateFileDialog.action.show',
oldName,
}));
/**
* Action that indicates the duplicate file dialog was accepted.
* @param oldName The old file name.
* @param newName The new file name.
*/
export const duplicateFileDialogDidAccept = createAction(
(oldName: string, newName: string) => ({
type: 'explorer.duplicateFileDialog.action.didAccept',
oldName,
newName,
}),
);
/**
* Action that indicates the duplicate file dialog was canceled.
*/
export const duplicateFileDialogDidCancel = createAction(() => ({
type: 'explorer.duplicateFileDialog.action.didCancel',
}));
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { lookup } from '../../../test';
import { I18nId } from './i18n';
import en from './translations/en.json';
describe('Ensure .json file has matches for I18nId', () => {
test.each(Object.values(I18nId))('%s', (id) => {
expect(lookup(en, id)).toBeDefined();
});
});
+7
View File
@@ -0,0 +1,7 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
export enum I18nId {
Title = 'title',
ActionAccept = 'action.accept',
}
@@ -0,0 +1,36 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { Reducer, combineReducers } from 'redux';
import {
duplicateFileDialogDidAccept,
duplicateFileDialogDidCancel,
duplicateFileDialogShow,
} from './actions';
/** Controls the duplicate file dialog isOpen state. */
const isOpen: Reducer<boolean> = (state = false, action) => {
if (duplicateFileDialogShow.matches(action)) {
return true;
}
if (
duplicateFileDialogDidAccept.matches(action) ||
duplicateFileDialogDidCancel.matches(action)
) {
return false;
}
return state;
};
/** Controls the duplicate file dialog file name input box text. */
const fileName: Reducer<string> = (state = '', action) => {
if (duplicateFileDialogShow.matches(action)) {
return action.oldName;
}
return state;
};
export default combineReducers({ isOpen, fileName });
@@ -0,0 +1,6 @@
{
"title": "Duplicate '{fileName}'",
"action": {
"accept": "Duplicate"
}
}
+2
View File
@@ -11,9 +11,11 @@ export enum I18nId {
TreeLiveDescriptorIntroAccessibilityGuide = 'tree.liveDescriptor.intro.accessibilityGuide',
TreeLiveDescriptorIntroNavigation = 'tree.liveDescriptor.intro.navigation',
TreeLiveDescriptorIntroKeybindingsPrimaryAction = 'tree.liveDescriptor.intro.keybindings.primaryAction',
TreeLiveDescriptorIntroKeybindingsDuplicate = 'tree.liveDescriptor.intro.keybindings.duplicate',
TreeLiveDescriptorIntroKeybindingsExport = 'tree.liveDescriptor.intro.keybindings.export',
TreeLiveDescriptorIntroKeybindingsDelete = 'tree.liveDescriptor.intro.keybindings.delete',
TreeLiveDescriptorSearching = 'tree.liveDescriptor.searching',
TreeItemDeleteTooltip = 'treeItem.deleteTooltip',
TreeItemExportTooltip = 'treeItem.exportTooltip',
TreeItemDuplicateTooltip = 'treeItem.duplicateTooltip',
}
+2
View File
@@ -11,6 +11,7 @@ import {
} from '../fileStorage/actions';
import deleteFileAlert from './deleteFileAlert/reducers';
import duplicateFileDialog from './duplicateFileDialog/reducers';
import newFileWizard from './newFileWizard/reducers';
import renameFileDialog from './renameFileDialog/reducers';
@@ -55,6 +56,7 @@ const files: Reducer<readonly ExplorerFileInfo[]> = (state = [], action) => {
export default combineReducers({
files,
duplicateFileDialog,
deleteFileAlert,
newFileWizard,
renameFileDialog,
+66
View File
@@ -13,9 +13,12 @@ import {
editorDidFailToActivateFile,
} from '../editor/actions';
import {
fileStorageCopyFile,
fileStorageDeleteFile,
fileStorageDidCopyFile,
fileStorageDidDeleteFile,
fileStorageDidDumpAllFiles,
fileStorageDidFailToCopyFile,
fileStorageDidFailToDeleteFile,
fileStorageDidFailToDumpAllFiles,
fileStorageDidFailToReadFile,
@@ -36,14 +39,17 @@ import {
explorerDidArchiveAllFiles,
explorerDidCreateNewFile,
explorerDidDeleteFile,
explorerDidDuplicateFile,
explorerDidExportFile,
explorerDidFailToActivateFile,
explorerDidFailToArchiveAllFiles,
explorerDidFailToCreateNewFile,
explorerDidFailToDeleteFile,
explorerDidFailToDuplicateFile,
explorerDidFailToExportFile,
explorerDidFailToImportFiles,
explorerDidImportFiles,
explorerDuplicateFile,
explorerExportFile,
explorerImportFiles,
} from './actions';
@@ -52,6 +58,11 @@ import {
deleteFileAlertDidCancel,
deleteFileAlertShow,
} from './deleteFileAlert/actions';
import {
duplicateFileDialogDidAccept,
duplicateFileDialogDidCancel,
duplicateFileDialogShow,
} from './duplicateFileDialog/actions';
import {
Hub,
newFileWizardDidAccept,
@@ -252,6 +263,61 @@ describe('handleExplorerActivateFile', () => {
});
});
describe('handleExplorerDuplicateFile', () => {
let saga: AsyncSaga;
beforeEach(async () => {
saga = new AsyncSaga(explorer);
saga.put(explorerDuplicateFile('old.file'));
await expect(saga.take()).resolves.toEqual(duplicateFileDialogShow('old.file'));
});
it('should dispatch action if canceled', async () => {
saga.put(duplicateFileDialogDidCancel());
await expect(saga.take()).resolves.toEqual(
explorerDidFailToDuplicateFile(
'old.file',
new DOMException('user canceled', 'AbortError'),
),
);
});
describe('user accepted', () => {
beforeEach(async () => {
saga.put(duplicateFileDialogDidAccept('old.file', 'new.file'));
await expect(saga.take()).resolves.toEqual(
fileStorageCopyFile('old.file', 'new.file'),
);
});
it('should propagate failure', async () => {
const testError = new Error('test error');
saga.put(fileStorageDidFailToCopyFile('old.file', testError));
await expect(saga.take()).resolves.toEqual(
explorerDidFailToDuplicateFile('old.file', testError),
);
});
it('should dispatch action on fileStorageDuplicateFile success', async () => {
saga.put(fileStorageDidCopyFile('old.file'));
await expect(saga.take()).resolves.toEqual(
explorerDidDuplicateFile('old.file'),
);
});
});
afterEach(async () => {
await saga.end();
});
});
describe('handleExplorerExportFile', () => {
let saga: AsyncSaga;
const testFile = 'test.file';
+54
View File
@@ -13,9 +13,12 @@ import {
} from '../editor/actions';
import { getPybricksMicroPythonFileTemplate } from '../editor/pybricksMicroPython';
import {
fileStorageCopyFile,
fileStorageDeleteFile,
fileStorageDidCopyFile,
fileStorageDidDeleteFile,
fileStorageDidDumpAllFiles,
fileStorageDidFailToCopyFile,
fileStorageDidFailToDeleteFile,
fileStorageDidFailToDumpAllFiles,
fileStorageDidFailToReadFile,
@@ -45,14 +48,17 @@ import {
explorerDidArchiveAllFiles,
explorerDidCreateNewFile,
explorerDidDeleteFile,
explorerDidDuplicateFile,
explorerDidExportFile,
explorerDidFailToActivateFile,
explorerDidFailToArchiveAllFiles,
explorerDidFailToCreateNewFile,
explorerDidFailToDeleteFile,
explorerDidFailToDuplicateFile,
explorerDidFailToExportFile,
explorerDidFailToImportFiles,
explorerDidImportFiles,
explorerDuplicateFile,
explorerExportFile,
explorerImportFiles,
} from './actions';
@@ -61,6 +67,11 @@ import {
deleteFileAlertDidCancel,
deleteFileAlertShow,
} from './deleteFileAlert/actions';
import {
duplicateFileDialogDidAccept,
duplicateFileDialogDidCancel,
duplicateFileDialogShow,
} from './duplicateFileDialog/actions';
import {
newFileWizardDidAccept,
newFileWizardDidCancel,
@@ -246,6 +257,48 @@ function* handleExplorerActivateFile(
yield* put(explorerDidActivateFile(didActivate.fileName));
}
/** Connects user initiate duplicate file actions to the duplicate file dialog. */
function* handleExplorerDuplicateFile(
action: ReturnType<typeof explorerDuplicateFile>,
): Generator {
try {
yield* put(duplicateFileDialogShow(action.fileName));
const { didAccept, didCancel } = yield* race({
didAccept: take(duplicateFileDialogDidAccept),
didCancel: take(duplicateFileDialogDidCancel),
});
if (didCancel) {
throw new DOMException('user canceled', 'AbortError');
}
defined(didAccept);
// REVISIT: if editor is not flushed to storage right away, we would
// need to check for open editors here
yield* put(fileStorageCopyFile(action.fileName, didAccept.newName));
const { didFailToCopy } = yield* race({
didCopy: take(
fileStorageDidCopyFile.when((a) => a.path === action.fileName),
),
didFailToCopy: take(
fileStorageDidFailToCopyFile.when((a) => a.path === action.fileName),
),
});
if (didFailToCopy) {
throw didFailToCopy.error;
}
yield* put(explorerDidDuplicateFile(action.fileName));
} catch (err) {
yield* put(explorerDidFailToDuplicateFile(action.fileName, ensureError(err)));
}
}
function* handleExplorerExportFile(
action: ReturnType<typeof explorerExportFile>,
): Generator {
@@ -341,6 +394,7 @@ export default function* (): Generator {
yield* takeEvery(explorerImportFiles, handleExplorerImportFiles);
yield* takeEvery(explorerCreateNewFile, handleExplorerCreateNewFile);
yield* takeEvery(explorerActivateFile, handleExplorerActivateFile);
yield* takeEvery(explorerDuplicateFile, handleExplorerDuplicateFile);
yield* takeEvery(explorerExportFile, handleExplorerExportFile);
yield* takeEvery(explorerDeleteFile, handleExplorerDeleteFile);
}
+4 -2
View File
@@ -12,6 +12,7 @@
"navigation": "Navigate the tree with the arrow keys. Start typing the name of a file to search for a file. Additional keybindings are available:",
"keybindings": {
"primaryAction": "{key} to open the file in the code editor",
"duplicate": "{key} to duplicate focused file",
"export": "{key} to export the focused file",
"delete": "{key} to delete the focused file"
}
@@ -20,7 +21,8 @@
}
},
"treeItem": {
"deleteTooltip": "Delete {fileName}",
"exportTooltip": "Export {fileName}"
"duplicateTooltip": "Duplicate {fileName}",
"exportTooltip": "Export {fileName}",
"deleteTooltip": "Delete {fileName}"
}
}