Skip to content

Commit 6a9405e

Browse files
committed
explorer: add support for importing ZIP
Also add a new dialog to get user input when file names conflict. Previously, we were not supplying a list of existing files, so existing files were silently written over. Fixes: pybricks/support#833
1 parent d9e8a79 commit 6a9405e

File tree

15 files changed

+790
-59
lines changed

15 files changed

+790
-59
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@
44

55
## [Unreleased]
66

7+
### Added
8+
- Added ability to import ZIP files containing Python files ([support#833]).
9+
10+
### Fixed
11+
- Fixed importing file with same name overwrites existing without asking user.
12+
13+
[support#833]: https://github.com/pybricks/support/issues/833
14+
715
## [2.1.1] - 2023-02-17
816

917
### Changed

src/explorer/Explorer.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// SPDX-License-Identifier: MIT
2-
// Copyright (c) 2022 The Pybricks Authors
2+
// Copyright (c) 2022-2023 The Pybricks Authors
33

44
// A file explorer control.
55

@@ -58,6 +58,7 @@ import { useI18n } from './i18n';
5858
import NewFileWizard from './newFileWizard/NewFileWizard';
5959
import RenameFileDialog from './renameFileDialog/RenameFileDialog';
6060
import RenameImportDialog from './renameImportDialog/RenameImportDialog';
61+
import ReplaceImportDialog from './replaceImportDialog/ReplaceImportDialog';
6162

6263
type ActionButtonProps = {
6364
/** The DOM id for this instance. */
@@ -455,6 +456,7 @@ const Explorer: React.VFC = () => {
455456
<NewFileWizard />
456457
<RenameFileDialog />
457458
<RenameImportDialog />
459+
<ReplaceImportDialog />
458460
<DuplicateFileDialog />
459461
<DeleteFileAlert />
460462
</div>

src/explorer/alerts/NoPyFiles.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// SPDX-License-Identifier: MIT
2+
// Copyright (c) 2023 The Pybricks Authors
3+
4+
import { Intent } from '@blueprintjs/core';
5+
import React from 'react';
6+
import { pythonFileExtension } from '../../pybricksMicropython/lib';
7+
import type { CreateToast } from '../../toasterTypes';
8+
import { useI18n } from './i18n';
9+
10+
const NoPyFiles: React.VoidFunctionComponent = () => {
11+
const i18n = useI18n();
12+
return (
13+
<>
14+
{i18n.translate('noPyFiles.message', {
15+
py: <code>{pythonFileExtension}</code>,
16+
zip: 'ZIP',
17+
})}
18+
</>
19+
);
20+
};
21+
22+
export const noPyFiles: CreateToast = (onAction) => ({
23+
message: <NoPyFiles />,
24+
icon: 'info-sign',
25+
intent: Intent.PRIMARY,
26+
onDismiss: () => onAction('dismiss'),
27+
});

src/explorer/alerts/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
// SPDX-License-Identifier: MIT
2-
// Copyright (c) 2022 The Pybricks Authors
2+
// Copyright (c) 2022-2023 The Pybricks Authors
33

44
import { fileInUse } from './FileInUseAlert';
55
import { noFilesToBackup } from './NoFilesToBackup';
6+
import { noPyFiles } from './NoPyFiles';
67

78
// gathers all of the alert creation functions for passing up to the top level
8-
export default { fileInUse, noFilesToBackup };
9+
export default { fileInUse, noFilesToBackup, noPyFiles };

src/explorer/alerts/translations/en.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,8 @@
44
},
55
"noFilesToBackup": {
66
"message": "There are no files to backup. Create a new file first by clicking the {icon} icon."
7+
},
8+
"noPyFiles": {
9+
"message": "There were no {py} files in the {zip} file."
710
}
811
}

src/explorer/reducers.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// SPDX-License-Identifier: MIT
2-
// Copyright (c) 2022 The Pybricks Authors
2+
// Copyright (c) 2022-2023 The Pybricks Authors
33

44
import { combineReducers } from 'redux';
55

@@ -8,11 +8,13 @@ import duplicateFileDialog from './duplicateFileDialog/reducers';
88
import newFileWizard from './newFileWizard/reducers';
99
import renameFileDialog from './renameFileDialog/reducers';
1010
import renameImportDialog from './renameImportDialog/reducers';
11+
import replaceImportDialog from './replaceImportDialog/reducers';
1112

1213
export default combineReducers({
1314
duplicateFileDialog,
1415
deleteFileAlert,
1516
newFileWizard,
1617
renameFileDialog,
1718
renameImportDialog,
19+
replaceImportDialog,
1820
});
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// SPDX-License-Identifier: MIT
2+
// Copyright (c) 2022-2023 The Pybricks Authors
3+
4+
import { waitFor } from '@testing-library/dom';
5+
import React from 'react';
6+
import { testRender } from '../../../test';
7+
import RenameImportDialog from './ReplaceImportDialog';
8+
import {
9+
ReplaceImportDialogAction,
10+
replaceImportDialogDidAccept,
11+
replaceImportDialogDidCancel,
12+
} from './actions';
13+
14+
describe('replace button', () => {
15+
it.each([
16+
[/skip/i, ReplaceImportDialogAction.Skip, false],
17+
[/skip/i, ReplaceImportDialogAction.Skip, true],
18+
[/replace/i, ReplaceImportDialogAction.Replace, false],
19+
[/replace/i, ReplaceImportDialogAction.Replace, true],
20+
[/rename/i, ReplaceImportDialogAction.Rename, false],
21+
[/rename/i, ReplaceImportDialogAction.Rename, true],
22+
])(
23+
'should accept when %c%s button is clicked and remember checkbox is %s',
24+
async (buttonName, action, remember) => {
25+
const [user, dialog, dispatch] = testRender(<RenameImportDialog />, {
26+
explorer: {
27+
replaceImportDialog: { isOpen: true, fileName: 'old.file' },
28+
},
29+
});
30+
31+
if (remember) {
32+
const rememberCheckBox = dialog.getByRole('checkbox', {
33+
name: /remember/i,
34+
});
35+
await user.click(rememberCheckBox);
36+
}
37+
38+
const button = dialog.getByRole('button', { name: buttonName });
39+
await user.click(button);
40+
41+
expect(dispatch).toHaveBeenCalledWith(
42+
replaceImportDialogDidAccept(action, remember),
43+
);
44+
},
45+
);
46+
47+
it('should cancel when close button is clicked', async () => {
48+
const [user, dialog, dispatch] = testRender(<RenameImportDialog />, {
49+
explorer: { replaceImportDialog: { isOpen: true } },
50+
});
51+
52+
const button = dialog.getByRole('button', { name: 'Close' });
53+
54+
await waitFor(() => expect(button).toBeVisible());
55+
56+
await user.click(button);
57+
expect(dispatch).toHaveBeenCalledWith(replaceImportDialogDidCancel());
58+
});
59+
});
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// SPDX-License-Identifier: MIT
2+
// Copyright (c) 2022-2023 The Pybricks Authors
3+
4+
import './replaceImportDialog.scss';
5+
import { Button, Checkbox, Classes, Dialog, Intent } from '@blueprintjs/core';
6+
import React, { useCallback, useState } from 'react';
7+
import { useDispatch } from 'react-redux';
8+
import { useSelector } from '../../reducers';
9+
import {
10+
ReplaceImportDialogAction,
11+
replaceImportDialogDidAccept,
12+
replaceImportDialogDidCancel,
13+
} from './actions';
14+
import { useI18n } from './i18n';
15+
16+
const RenameImportDialog: React.VFC = () => {
17+
const i18n = useI18n();
18+
const dispatch = useDispatch();
19+
const isOpen = useSelector((s) => s.explorer.replaceImportDialog.isOpen);
20+
const fileName = useSelector((s) => s.explorer.replaceImportDialog.fileName);
21+
const [remember, setRemember] = useState(false);
22+
23+
const handleSubmit = useCallback<React.FormEventHandler>(
24+
(e) => {
25+
e.preventDefault();
26+
dispatch(
27+
replaceImportDialogDidAccept(
28+
((e.nativeEvent as SubmitEvent).submitter as HTMLButtonElement)
29+
.value as ReplaceImportDialogAction,
30+
remember,
31+
),
32+
);
33+
},
34+
[dispatch, remember],
35+
);
36+
37+
const handleClose = useCallback(() => {
38+
dispatch(replaceImportDialogDidCancel());
39+
}, [dispatch]);
40+
41+
return (
42+
<Dialog
43+
className="pb-explorer-replaceImportDialog"
44+
title={i18n.translate('title')}
45+
isOpen={isOpen}
46+
onOpening={() => setRemember(false)}
47+
onClose={handleClose}
48+
>
49+
<form onSubmit={handleSubmit} method="dialog">
50+
<div className={Classes.DIALOG_BODY}>
51+
<p>{i18n.translate('message', { fileName })}</p>
52+
</div>
53+
<div className={Classes.DIALOG_FOOTER}>
54+
<Checkbox
55+
checked={remember}
56+
onChange={(e) =>
57+
setRemember((e.target as HTMLInputElement).checked)
58+
}
59+
>
60+
{i18n.translate('option.remember')}
61+
</Checkbox>
62+
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
63+
<Button
64+
intent="none"
65+
type="submit"
66+
value={ReplaceImportDialogAction.Skip}
67+
>
68+
{i18n.translate('action.skip')}
69+
</Button>
70+
<Button
71+
intent={Intent.DANGER}
72+
type="submit"
73+
value={ReplaceImportDialogAction.Replace}
74+
>
75+
{i18n.translate('action.replace')}
76+
</Button>
77+
<Button
78+
intent={Intent.PRIMARY}
79+
type="submit"
80+
value={ReplaceImportDialogAction.Rename}
81+
>
82+
{i18n.translate('action.rename')}
83+
</Button>
84+
</div>
85+
</div>
86+
</form>
87+
</Dialog>
88+
);
89+
};
90+
91+
export default RenameImportDialog;
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// SPDX-License-Identifier: MIT
2+
// Copyright (c) 2023 The Pybricks Authors
3+
4+
import { createAction } from '../../actions';
5+
6+
/**
7+
* Action that requests to show the replace file dialog.
8+
* @param fileName The file name.
9+
*/
10+
export const replaceImportDialogShow = createAction((fileName: string) => ({
11+
type: 'explorer.replaceImportDialog.action.show',
12+
fileName,
13+
}));
14+
15+
export enum ReplaceImportDialogAction {
16+
Skip = 'skip',
17+
Replace = 'replace',
18+
Rename = 'rename',
19+
}
20+
21+
/**
22+
* Action that indicates the replace file dialog was accepted.
23+
*/
24+
export const replaceImportDialogDidAccept = createAction(
25+
(action: ReplaceImportDialogAction, remember: boolean) => ({
26+
type: 'explorer.replaceImportDialog.action.didAccept',
27+
action,
28+
remember,
29+
}),
30+
);
31+
32+
/**
33+
* Action that indicates the replace file dialog was canceled.
34+
*/
35+
export const replaceImportDialogDidCancel = createAction(() => ({
36+
type: 'explorer.replaceImportDialog.action.didCancel',
37+
}));
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// SPDX-License-Identifier: MIT
2+
// Copyright (c) 2022 The Pybricks Authors
3+
4+
import { useI18n as useShopifyI18n } from '@shopify/react-i18n';
5+
import type { TypedI18n } from '../../i18n';
6+
import type translations from './translations/en.json';
7+
8+
export function useI18n(): TypedI18n<typeof translations> {
9+
// istanbul ignore next: babel-loader rewrites this line
10+
const [i18n] = useShopifyI18n();
11+
return i18n;
12+
}

0 commit comments

Comments
 (0)