diff --git a/src/explorer/Explorer.test.tsx b/src/explorer/Explorer.test.tsx index 0805a31d..6d99075b 100644 --- a/src/explorer/Explorer.test.tsx +++ b/src/explorer/Explorer.test.tsx @@ -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 () => { diff --git a/src/explorer/Explorer.tsx b/src/explorer/Explorer.tsx index 2343e6ad..c71af072 100644 --- a/src/explorer/Explorer.tsx +++ b/src/explorer/Explorer.tsx @@ -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))} /> ); diff --git a/src/explorer/actions.ts b/src/explorer/actions.ts index 4f79e190..405b4ac9 100644 --- a/src/explorer/actions.ts +++ b/src/explorer/actions.ts @@ -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, +})); diff --git a/src/notifications/i18n.en.json b/src/notifications/i18n.en.json index 16d316f1..063e8ec8 100644 --- a/src/notifications/i18n.en.json +++ b/src/notifications/i18n.en.json @@ -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.", diff --git a/src/notifications/i18n.ts b/src/notifications/i18n.ts index a6676d7b..e04f2388 100644 --- a/src/notifications/i18n.ts +++ b/src/notifications/i18n.ts @@ -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', diff --git a/src/notifications/sagas.test.ts b/src/notifications/sagas.test.ts index 04e0d450..4d69966d 100644 --- a/src/notifications/sagas.test.ts +++ b/src/notifications/sagas.test.ts @@ -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, + ); + + 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(); + }); +}); diff --git a/src/notifications/sagas.ts b/src/notifications/sagas.ts index 76447f8a..f109b0c3 100644 --- a/src/notifications/sagas.ts +++ b/src/notifications/sagas.ts @@ -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, +): Generator { + yield* showUnexpectedError(MessageId.FileStorageFailedToDelete, action.error); +} + function* showFileStorageFailToExport( action: ReturnType, ): Generator { @@ -431,6 +441,45 @@ function* showFileStorageFailToArchive( yield* showUnexpectedError(MessageId.FileStorageFailedToExport, action.error); } +function* showDeleteFileWarning(action: ReturnType) { + const ch = channel>(); + 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('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); }