mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-12 09:36:27 +00:00
explorer: confirm file deletion
Since deleting files cannot be undone, we need to be extra sure before actually deleting the file.
This commit is contained in:
@@ -7,10 +7,10 @@ import React from 'react';
|
||||
import { testRender } from '../../test';
|
||||
import {
|
||||
fileStorageArchiveAllFiles,
|
||||
fileStorageDeleteFile,
|
||||
fileStorageExportFile,
|
||||
} from '../fileStorage/actions';
|
||||
import Explorer from './Explorer';
|
||||
import { explorerDeleteFile } from './actions';
|
||||
|
||||
describe('archive button', () => {
|
||||
it('should be enabled if there are files', () => {
|
||||
@@ -94,7 +94,7 @@ describe('list item', () => {
|
||||
|
||||
userEvent.click(button);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(fileStorageDeleteFile('test.file'));
|
||||
expect(dispatch).toHaveBeenCalledWith(explorerDeleteFile('test.file'));
|
||||
});
|
||||
|
||||
it('should dispatch export action when button is clicked', async () => {
|
||||
|
||||
@@ -16,11 +16,11 @@ import React, { forwardRef, useImperativeHandle, useMemo, useState } from 'react
|
||||
import { useDispatch } from 'react-redux';
|
||||
import {
|
||||
fileStorageArchiveAllFiles,
|
||||
fileStorageDeleteFile,
|
||||
fileStorageExportFile,
|
||||
} from '../fileStorage/actions';
|
||||
import { useSelector } from '../reducers';
|
||||
import NewFileWizard from './NewFileWizard';
|
||||
import { explorerDeleteFile } from './actions';
|
||||
import { ExplorerStringId } from './i18n';
|
||||
import en from './i18n.en.json';
|
||||
|
||||
@@ -94,7 +94,7 @@ const FileActionButtonGroup = forwardRef<
|
||||
icon="trash"
|
||||
toolTipId={ExplorerStringId.TreeItemDeleteTooltip}
|
||||
toolTipReplacements={{ fileName: props.fileName }}
|
||||
onClick={() => dispatch(fileStorageDeleteFile(props.fileName))}
|
||||
onClick={() => dispatch(explorerDeleteFile(props.fileName))}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
);
|
||||
|
||||
@@ -39,3 +39,12 @@ export const explorerCreateNewFile = createAction(
|
||||
hub,
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Action that requests to delete a file.
|
||||
* @param fileName The file name.
|
||||
*/
|
||||
export const explorerDeleteFile = createAction((fileName: string) => ({
|
||||
type: 'explorer.action.deleteFile',
|
||||
fileName,
|
||||
}));
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
"editor": {
|
||||
"failedToSaveFile": "Failed to save the program."
|
||||
},
|
||||
"explorer": {
|
||||
"deleteFile": {
|
||||
"message": "The file {fileName} will be permanently deleted. This cannot be undone.",
|
||||
"action": "Delete"
|
||||
}
|
||||
},
|
||||
"fileStorage": {
|
||||
"failedToInitialize": "Failed to initial file storage. Changes will not be automatically saved.",
|
||||
"failedToRead": "Failed to read file.",
|
||||
|
||||
@@ -13,6 +13,8 @@ export enum MessageId {
|
||||
BleNoWebBluetooth = 'ble.noWebBluetooth',
|
||||
BleNoBluetooth = 'ble.noBluetooth',
|
||||
EditorFailedToSaveFile = 'editor.failedToSaveFile',
|
||||
ExplorerDeleteFileMessage = 'explorer.deleteFile.message',
|
||||
ExplorerDeleteFileAction = 'explorer.deleteFile.action',
|
||||
FileStorageFailedToInitialize = 'fileStorage.failedToInitialize',
|
||||
FileStorageFailedToRead = 'fileStorage.failedToRead',
|
||||
FileStorageFailedToWrite = 'fileStorage.failedToWrite',
|
||||
|
||||
@@ -17,13 +17,16 @@ import {
|
||||
didFailToConnect as bleDidFailToConnect,
|
||||
} from '../ble/actions';
|
||||
import { didFailToSaveAs } from '../editor/actions';
|
||||
import { explorerDeleteFile } from '../explorer/actions';
|
||||
import {
|
||||
fileStorageDeleteFile,
|
||||
fileStorageDidFailToArchiveAllFiles,
|
||||
fileStorageDidFailToDeleteFile,
|
||||
fileStorageDidFailToExportFile,
|
||||
fileStorageDidFailToInitialize,
|
||||
fileStorageDidFailToReadFile,
|
||||
fileStorageDidFailToWriteFile,
|
||||
fileStorageDidRemoveItem,
|
||||
} from '../fileStorage/actions';
|
||||
import {
|
||||
FailToFinishReasonType,
|
||||
@@ -167,3 +170,61 @@ test.each([[didCompile(new Uint8Array()), MessageId.MpyError]])(
|
||||
await saga.end();
|
||||
},
|
||||
);
|
||||
|
||||
describe('delete file saga', () => {
|
||||
it('should not delete the file if the user closes the notification', async () => {
|
||||
const { toaster, saga } = createTestToasterSaga();
|
||||
|
||||
saga.put(explorerDeleteFile('test.file'));
|
||||
|
||||
toaster.dismiss(MessageId.ExplorerDeleteFileMessage);
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
|
||||
it('should delete the file if the user clicks the delete button', async () => {
|
||||
const { toaster, saga } = createTestToasterSaga();
|
||||
|
||||
saga.put(explorerDeleteFile('test.file'));
|
||||
|
||||
const toast = toaster
|
||||
.getToasts()
|
||||
.find((t) => t.key === MessageId.ExplorerDeleteFileMessage);
|
||||
|
||||
expect(toast).toBeDefined();
|
||||
expect(toast?.action).toBeDefined();
|
||||
expect(toast?.action?.onClick).toBeDefined();
|
||||
|
||||
toast?.action?.onClick?.call(
|
||||
toast?.action,
|
||||
{} as React.MouseEvent<HTMLElement>,
|
||||
);
|
||||
|
||||
const action = await saga.take();
|
||||
expect(action).toEqual(fileStorageDeleteFile('test.file'));
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
|
||||
it('should close automatically if the file is deleted without user action', async () => {
|
||||
const { toaster, saga } = createTestToasterSaga();
|
||||
|
||||
saga.put(explorerDeleteFile('test.file'));
|
||||
|
||||
expect(
|
||||
toaster
|
||||
.getToasts()
|
||||
.find((t) => t.key === MessageId.ExplorerDeleteFileMessage),
|
||||
).toBeDefined();
|
||||
|
||||
saga.put(fileStorageDidRemoveItem('test.file'));
|
||||
|
||||
expect(
|
||||
toaster
|
||||
.getToasts()
|
||||
.find((t) => t.key === MessageId.ExplorerDeleteFileMessage),
|
||||
).toBeUndefined();
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Replacements } from '@shopify/react-i18n';
|
||||
import React from 'react';
|
||||
import { channel } from 'redux-saga';
|
||||
import * as semver from 'semver';
|
||||
import { delay, getContext, put, take, takeEvery } from 'typed-redux-saga/macro';
|
||||
import { delay, getContext, put, race, take, takeEvery } from 'typed-redux-saga/macro';
|
||||
import { appDidCheckForUpdate, appReload } from '../app/actions';
|
||||
import { appName } from '../app/constants';
|
||||
import { bleDIServiceDidReceiveFirmwareRevision } from '../ble-device-info-service/actions';
|
||||
@@ -18,12 +18,16 @@ import {
|
||||
didFailToConnect as bleDeviceDidFailToConnect,
|
||||
} from '../ble/actions';
|
||||
import { didFailToSaveAs } from '../editor/actions';
|
||||
import { explorerDeleteFile } from '../explorer/actions';
|
||||
import {
|
||||
fileStorageDeleteFile,
|
||||
fileStorageDidFailToArchiveAllFiles,
|
||||
fileStorageDidFailToDeleteFile,
|
||||
fileStorageDidFailToExportFile,
|
||||
fileStorageDidFailToInitialize,
|
||||
fileStorageDidFailToReadFile,
|
||||
fileStorageDidFailToWriteFile,
|
||||
fileStorageDidRemoveItem,
|
||||
} from '../fileStorage/actions';
|
||||
import { FailToFinishReasonType, didFailToFinish } from '../firmware/actions';
|
||||
import {
|
||||
@@ -409,6 +413,12 @@ function* showFileStorageFailToWrite(
|
||||
yield* showUnexpectedError(MessageId.FileStorageFailedToWrite, action.error);
|
||||
}
|
||||
|
||||
function* showFileStorageFailToDelete(
|
||||
action: ReturnType<typeof fileStorageDidFailToDeleteFile>,
|
||||
): Generator {
|
||||
yield* showUnexpectedError(MessageId.FileStorageFailedToDelete, action.error);
|
||||
}
|
||||
|
||||
function* showFileStorageFailToExport(
|
||||
action: ReturnType<typeof fileStorageDidFailToExportFile>,
|
||||
): Generator {
|
||||
@@ -431,6 +441,45 @@ function* showFileStorageFailToArchive(
|
||||
yield* showUnexpectedError(MessageId.FileStorageFailedToExport, action.error);
|
||||
}
|
||||
|
||||
function* showDeleteFileWarning(action: ReturnType<typeof explorerDeleteFile>) {
|
||||
const ch = channel<React.MouseEvent<HTMLElement>>();
|
||||
const userAction = dispatchAction(
|
||||
MessageId.ExplorerDeleteFileAction,
|
||||
ch.put,
|
||||
'trash',
|
||||
);
|
||||
|
||||
// TODO: this should probably not be a singleton
|
||||
yield* showSingleton(
|
||||
Level.Warning,
|
||||
MessageId.ExplorerDeleteFileMessage,
|
||||
{
|
||||
fileName: React.createElement('strong', undefined, action.fileName),
|
||||
},
|
||||
userAction,
|
||||
ch.close,
|
||||
);
|
||||
|
||||
// task is terminated here if channel is closed (triggered by closing the notification)
|
||||
const { didRemoveFile } = yield* race({
|
||||
userActionEvent: take(ch),
|
||||
didRemoveFile: take(
|
||||
fileStorageDidRemoveItem.when((a) => a.fileName === action.fileName),
|
||||
),
|
||||
});
|
||||
|
||||
// if the file was removed by other means while the notification was being
|
||||
// shown, close the notification
|
||||
if (didRemoveFile) {
|
||||
const { toaster } = yield* getContext<NotificationContext>('notification');
|
||||
toaster.dismiss(MessageId.ExplorerDeleteFileMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
// this only runs if userAction is dispatched
|
||||
yield* put(fileStorageDeleteFile(action.fileName));
|
||||
}
|
||||
|
||||
export default function* (): Generator {
|
||||
yield* takeEvery(bleDeviceDidFailToConnect, showBleDeviceDidFailToConnectError);
|
||||
yield* takeEvery(bootloaderDidFailToConnect, showBootloaderDidFailToConnectError);
|
||||
@@ -445,6 +494,8 @@ export default function* (): Generator {
|
||||
yield* takeEvery(fileStorageDidFailToInitialize, showFileStorageFailToInitialize);
|
||||
yield* takeEvery(fileStorageDidFailToReadFile, showFileStorageFailToRead);
|
||||
yield* takeEvery(fileStorageDidFailToWriteFile, showFileStorageFailToWrite);
|
||||
yield* takeEvery(fileStorageDidFailToDeleteFile, showFileStorageFailToDelete);
|
||||
yield* takeEvery(fileStorageDidFailToExportFile, showFileStorageFailToExport);
|
||||
yield* takeEvery(fileStorageDidFailToArchiveAllFiles, showFileStorageFailToArchive);
|
||||
yield* takeEvery(explorerDeleteFile, showDeleteFileWarning);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user