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:
David Lechner
2022-03-10 13:48:57 -06:00
parent a995c52e04
commit 38b9bb6656
7 changed files with 134 additions and 5 deletions
+2 -2
View File
@@ -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 () => {
+2 -2
View File
@@ -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>
);
+9
View File
@@ -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,
}));
+6
View File
@@ -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.",
+2
View 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',
+61
View File
@@ -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();
});
});
+52 -1
View File
@@ -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);
}