mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-12 01:23:52 +00:00
@@ -15,6 +15,7 @@ import {
|
||||
explorerDuplicateFile,
|
||||
explorerExportFile,
|
||||
explorerImportFiles,
|
||||
explorerRenameFile,
|
||||
explorerUserActivateFile,
|
||||
} from './actions';
|
||||
|
||||
@@ -119,6 +120,36 @@ describe('tree item', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('rename', () => {
|
||||
it('should dispatch action when button is clicked', async () => {
|
||||
jest.mocked(useFileStorageMetadata).mockReturnValue([testFile]);
|
||||
const [explorer, dispatch] = testRender(<Explorer />);
|
||||
|
||||
// NB: this button is intentionally not accessible (by role) since
|
||||
// there is a keyboard shortcut.
|
||||
const button = explorer.getByTitle('Rename test.file');
|
||||
|
||||
userEvent.click(button);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(explorerRenameFile('test.file'));
|
||||
|
||||
// should not propagate to treeitem
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should dispatch action when key is pressed', async () => {
|
||||
jest.mocked(useFileStorageMetadata).mockReturnValue([testFile]);
|
||||
const [explorer, dispatch] = testRender(<Explorer />);
|
||||
|
||||
const treeItem = explorer.getByRole('treeitem', { name: 'test.file' });
|
||||
|
||||
userEvent.click(treeItem);
|
||||
userEvent.keyboard('{f2}');
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(explorerRenameFile('test.file'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('export', () => {
|
||||
it('should dispatch export action when button is clicked', async () => {
|
||||
jest.mocked(useFileStorageMetadata).mockReturnValue([testFile]);
|
||||
|
||||
@@ -36,12 +36,14 @@ import {
|
||||
explorerDuplicateFile,
|
||||
explorerExportFile,
|
||||
explorerImportFiles,
|
||||
explorerRenameFile,
|
||||
explorerUserActivateFile,
|
||||
} from './actions';
|
||||
import DeleteFileAlert from './deleteFileAlert/DeleteFileAlert';
|
||||
import DuplicateFileDialog from './duplicateFileDialog/DuplicateFileDialog';
|
||||
import { I18nId, useI18n } from './i18n';
|
||||
import NewFileWizard from './newFileWizard/NewFileWizard';
|
||||
import RenameFileDialog from './renameFileDialog/RenameFileDialog';
|
||||
|
||||
type ActionButtonProps = {
|
||||
/** The DOM id for this instance. */
|
||||
@@ -101,6 +103,7 @@ const FileActionButtonGroup: React.VoidFunctionComponent<ActionButtonGroupProps>
|
||||
|
||||
const fileName = environment.getItemTitle(item);
|
||||
|
||||
const renameButtonId = useId();
|
||||
const duplicateButtonId = useId();
|
||||
const exportButtonId = useId();
|
||||
const deleteButtonId = useId();
|
||||
@@ -112,6 +115,12 @@ const FileActionButtonGroup: React.VoidFunctionComponent<ActionButtonGroupProps>
|
||||
minimal={true}
|
||||
>
|
||||
<Toolbar firstFocusableItemId={duplicateButtonId}>
|
||||
<ActionButton
|
||||
id={renameButtonId}
|
||||
icon="edit"
|
||||
tooltip={i18n.translate(I18nId.TreeItemRenameTooltip, { fileName })}
|
||||
onClick={() => dispatch(explorerRenameFile(fileName))}
|
||||
/>
|
||||
<ActionButton
|
||||
id={duplicateButtonId}
|
||||
icon="duplicate"
|
||||
@@ -200,6 +209,10 @@ function useLiveDescriptors(): LiveDescriptors {
|
||||
I18nId.TreeLiveDescriptorIntroKeybindingsPrimaryAction,
|
||||
{ key: '{keybinding:primaryAction}' },
|
||||
)}</li>
|
||||
<li>${i18n.translate(
|
||||
I18nId.TreeLiveDescriptorIntroKeybindingsRename,
|
||||
{ key: 'f2' },
|
||||
)}</li>
|
||||
<li>${i18n.translate(
|
||||
I18nId.TreeLiveDescriptorIntroKeybindingsDuplicate,
|
||||
{ key: `${isMacOS() ? 'cmd' : 'ctrl'}+d` },
|
||||
@@ -239,6 +252,13 @@ const renderTreeContainer: typeof renderers.renderTreeContainer = (props) => {
|
||||
const hotKeyActive =
|
||||
isActiveTree; /* && !dnd.isProgrammaticallyDragging && !isRenaming */
|
||||
|
||||
const handleRenameKeyDown = useCallback(() => {
|
||||
if (focusedItem !== undefined) {
|
||||
const fileName = environment.getItemTitle(environment.items[focusedItem]);
|
||||
dispatch(explorerRenameFile(fileName));
|
||||
}
|
||||
}, [environment]);
|
||||
|
||||
const handleDuplicateKeyDown = useCallback(() => {
|
||||
if (focusedItem !== undefined) {
|
||||
const fileName = environment.getItemTitle(environment.items[focusedItem]);
|
||||
@@ -262,6 +282,14 @@ const renderTreeContainer: typeof renderers.renderTreeContainer = (props) => {
|
||||
|
||||
const hotkeys = useMemo<readonly HotkeyConfig[]>(
|
||||
() => [
|
||||
{
|
||||
combo: 'f2',
|
||||
label: 'Rename',
|
||||
disabled: !hotKeyActive,
|
||||
preventDefault: true,
|
||||
stopPropagation: true,
|
||||
onKeyDown: handleRenameKeyDown,
|
||||
},
|
||||
{
|
||||
combo: 'mod+d',
|
||||
label: 'Duplicate',
|
||||
@@ -379,6 +407,7 @@ const Explorer: React.VFC = () => {
|
||||
<Divider />
|
||||
<FileTree />
|
||||
<NewFileWizard />
|
||||
<RenameFileDialog />
|
||||
<DuplicateFileDialog />
|
||||
<DeleteFileAlert />
|
||||
</div>
|
||||
|
||||
@@ -89,6 +89,29 @@ export const explorerUserDidActivateFile = createAction((fileName: string) => ({
|
||||
fileName,
|
||||
}));
|
||||
|
||||
/**
|
||||
* 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 duplicate a file.
|
||||
* @param fileName The file name.
|
||||
|
||||
+10
-8
@@ -12,19 +12,21 @@ export function useI18n(): I18n {
|
||||
}
|
||||
|
||||
export enum I18nId {
|
||||
HeaderToolbarTitle = 'header.toolbar.title',
|
||||
HeaderToolbarAddNew = 'header.toolbar.addNew',
|
||||
HeaderToolbarExportAll = 'header.toolbar.exportAll',
|
||||
HeaderToolbarImport = 'header.toolbar.import',
|
||||
HeaderToolbarAddNew = 'header.toolbar.addNew',
|
||||
HeaderToolbarTitle = 'header.toolbar.title',
|
||||
TreeItemDeleteTooltip = 'treeItem.deleteTooltip',
|
||||
TreeItemDuplicateTooltip = 'treeItem.duplicateTooltip',
|
||||
TreeItemExportTooltip = 'treeItem.exportTooltip',
|
||||
TreeItemRenameTooltip = 'treeItem.renameTooltip',
|
||||
TreeLabel = 'tree.label',
|
||||
TreeLiveDescriptorIntroAccessibilityGuide = 'tree.liveDescriptor.intro.accessibilityGuide',
|
||||
TreeLiveDescriptorIntroNavigation = 'tree.liveDescriptor.intro.navigation',
|
||||
TreeLiveDescriptorIntroKeybindingsPrimaryAction = 'tree.liveDescriptor.intro.keybindings.primaryAction',
|
||||
TreeLiveDescriptorIntroKeybindingsDelete = 'tree.liveDescriptor.intro.keybindings.delete',
|
||||
TreeLiveDescriptorIntroKeybindingsDuplicate = 'tree.liveDescriptor.intro.keybindings.duplicate',
|
||||
TreeLiveDescriptorIntroKeybindingsExport = 'tree.liveDescriptor.intro.keybindings.export',
|
||||
TreeLiveDescriptorIntroKeybindingsDelete = 'tree.liveDescriptor.intro.keybindings.delete',
|
||||
TreeLiveDescriptorIntroKeybindingsPrimaryAction = 'tree.liveDescriptor.intro.keybindings.primaryAction',
|
||||
TreeLiveDescriptorIntroKeybindingsRename = 'tree.liveDescriptor.intro.keybindings.rename',
|
||||
TreeLiveDescriptorIntroNavigation = 'tree.liveDescriptor.intro.navigation',
|
||||
TreeLiveDescriptorSearching = 'tree.liveDescriptor.searching',
|
||||
TreeItemDeleteTooltip = 'treeItem.deleteTooltip',
|
||||
TreeItemExportTooltip = 'treeItem.exportTooltip',
|
||||
TreeItemDuplicateTooltip = 'treeItem.duplicateTooltip',
|
||||
}
|
||||
|
||||
@@ -12,8 +12,7 @@ import {
|
||||
import { useSelector } from '../../reducers';
|
||||
import FileNameFormGroup from '../fileNameFormGroup/FileNameFormGroup';
|
||||
import { renameFileDialogDidAccept, renameFileDialogDidCancel } from './actions';
|
||||
import { useI18n } from './i18n';
|
||||
import { I18nId } from './i18n';
|
||||
import { I18nId, useI18n } from './i18n';
|
||||
|
||||
const RenameFileDialog: React.VFC = () => {
|
||||
const i18n = useI18n();
|
||||
|
||||
@@ -24,10 +24,13 @@ import {
|
||||
fileStorageDidFailToDeleteFile,
|
||||
fileStorageDidFailToDumpAllFiles,
|
||||
fileStorageDidFailToReadFile,
|
||||
fileStorageDidFailToRenameFile,
|
||||
fileStorageDidReadFile,
|
||||
fileStorageDidRenameFile,
|
||||
fileStorageDidWriteFile,
|
||||
fileStorageDumpAllFiles,
|
||||
fileStorageReadFile,
|
||||
fileStorageRenameFile,
|
||||
fileStorageWriteFile,
|
||||
} from '../fileStorage/actions';
|
||||
import { pythonFileExtension } from '../pybricksMicropython/lib';
|
||||
@@ -46,10 +49,13 @@ import {
|
||||
explorerDidFailToDuplicateFile,
|
||||
explorerDidFailToExportFile,
|
||||
explorerDidFailToImportFiles,
|
||||
explorerDidFailToRenameFile,
|
||||
explorerDidImportFiles,
|
||||
explorerDidRenameFile,
|
||||
explorerDuplicateFile,
|
||||
explorerExportFile,
|
||||
explorerImportFiles,
|
||||
explorerRenameFile,
|
||||
explorerUserActivateFile,
|
||||
explorerUserDidActivateFile,
|
||||
} from './actions';
|
||||
@@ -69,6 +75,11 @@ import {
|
||||
newFileWizardDidCancel,
|
||||
newFileWizardShow,
|
||||
} from './newFileWizard/actions';
|
||||
import {
|
||||
renameFileDialogDidAccept,
|
||||
renameFileDialogDidCancel,
|
||||
renameFileDialogShow,
|
||||
} from './renameFileDialog/actions';
|
||||
import explorer from './sagas';
|
||||
|
||||
jest.mock('browser-fs-access');
|
||||
@@ -286,6 +297,52 @@ describe('handleExplorerActivateFile', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleExplorerRenameFile', () => {
|
||||
let saga: AsyncSaga;
|
||||
|
||||
beforeEach(async () => {
|
||||
saga = new AsyncSaga(explorer);
|
||||
|
||||
saga.put(explorerRenameFile('old.file'));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(renameFileDialogShow('old.file'));
|
||||
});
|
||||
|
||||
it('should dispatch action if canceled', async () => {
|
||||
saga.put(renameFileDialogDidCancel());
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(explorerDidFailToRenameFile());
|
||||
});
|
||||
|
||||
describe('should dispatch fileStorageOpenFile action if accepted', () => {
|
||||
beforeEach(async () => {
|
||||
saga.put(renameFileDialogDidAccept('old.file', 'new.file'));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageRenameFile('old.file', 'new.file'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should dispatch action on fileStorageRenameFile failure', async () => {
|
||||
saga.put(
|
||||
fileStorageDidFailToRenameFile('old.file', new Error('test error')),
|
||||
);
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(explorerDidFailToRenameFile());
|
||||
});
|
||||
|
||||
it('should dispatch action on fileStorageRenameFile success', async () => {
|
||||
saga.put(fileStorageDidRenameFile('old.file'));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(explorerDidRenameFile());
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await saga.end();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleExplorerDuplicateFile', () => {
|
||||
let saga: AsyncSaga;
|
||||
|
||||
|
||||
@@ -24,11 +24,14 @@ import {
|
||||
fileStorageDidFailToDeleteFile,
|
||||
fileStorageDidFailToDumpAllFiles,
|
||||
fileStorageDidFailToReadFile,
|
||||
fileStorageDidFailToRenameFile,
|
||||
fileStorageDidFailToWriteFile,
|
||||
fileStorageDidReadFile,
|
||||
fileStorageDidRenameFile,
|
||||
fileStorageDidWriteFile,
|
||||
fileStorageDumpAllFiles,
|
||||
fileStorageReadFile,
|
||||
fileStorageRenameFile,
|
||||
fileStorageWriteFile,
|
||||
} from '../fileStorage/actions';
|
||||
import {
|
||||
@@ -54,10 +57,13 @@ import {
|
||||
explorerDidFailToDuplicateFile,
|
||||
explorerDidFailToExportFile,
|
||||
explorerDidFailToImportFiles,
|
||||
explorerDidFailToRenameFile,
|
||||
explorerDidImportFiles,
|
||||
explorerDidRenameFile,
|
||||
explorerDuplicateFile,
|
||||
explorerExportFile,
|
||||
explorerImportFiles,
|
||||
explorerRenameFile,
|
||||
explorerUserActivateFile,
|
||||
explorerUserDidActivateFile,
|
||||
} from './actions';
|
||||
@@ -76,6 +82,11 @@ import {
|
||||
newFileWizardDidCancel,
|
||||
newFileWizardShow,
|
||||
} from './newFileWizard/actions';
|
||||
import {
|
||||
renameFileDialogDidAccept,
|
||||
renameFileDialogDidCancel,
|
||||
renameFileDialogShow,
|
||||
} from './renameFileDialog/actions';
|
||||
|
||||
function* handleExplorerArchiveAllFiles(): Generator {
|
||||
try {
|
||||
@@ -302,6 +313,43 @@ function* handleExplorerDuplicateFile(
|
||||
}
|
||||
}
|
||||
|
||||
/** 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(action.fileName, accepted.newName));
|
||||
|
||||
const didRename = yield* race({
|
||||
succeeded: take(
|
||||
fileStorageDidRenameFile.when((a) => a.fileName === action.fileName),
|
||||
),
|
||||
failed: take(
|
||||
fileStorageDidFailToRenameFile.when((a) => a.fileName === action.fileName),
|
||||
),
|
||||
});
|
||||
|
||||
if (didRename.failed) {
|
||||
yield* put(explorerDidFailToRenameFile());
|
||||
return;
|
||||
}
|
||||
|
||||
yield* put(explorerDidRenameFile());
|
||||
}
|
||||
|
||||
function* handleExplorerExportFile(
|
||||
action: ReturnType<typeof explorerExportFile>,
|
||||
): Generator {
|
||||
@@ -387,6 +435,7 @@ export default function* (): Generator {
|
||||
yield* takeEvery(explorerImportFiles, handleExplorerImportFiles);
|
||||
yield* takeEvery(explorerCreateNewFile, handleExplorerCreateNewFile);
|
||||
yield* takeEvery(explorerUserActivateFile, handleExplorerActivateFile);
|
||||
yield* takeEvery(explorerRenameFile, handleExplorerRenameFile);
|
||||
yield* takeEvery(explorerDuplicateFile, handleExplorerDuplicateFile);
|
||||
yield* takeEvery(explorerExportFile, handleExplorerExportFile);
|
||||
yield* takeEvery(explorerDeleteFile, handleExplorerDeleteFile);
|
||||
|
||||
@@ -15,6 +15,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",
|
||||
"rename": "{key} to start renaming the focused file",
|
||||
"duplicate": "{key} to duplicate focused file",
|
||||
"export": "{key} to export the focused file",
|
||||
"delete": "{key} to delete the focused file"
|
||||
@@ -24,6 +25,7 @@
|
||||
}
|
||||
},
|
||||
"treeItem": {
|
||||
"renameTooltip": "Rename {fileName}",
|
||||
"duplicateTooltip": "Duplicate {fileName}",
|
||||
"exportTooltip": "Export {fileName}",
|
||||
"deleteTooltip": "Delete {fileName}"
|
||||
|
||||
Reference in New Issue
Block a user