diff --git a/src/explorer/Explorer.tsx b/src/explorer/Explorer.tsx index 897f236c..8397cb1e 100644 --- a/src/explorer/Explorer.tsx +++ b/src/explorer/Explorer.tsx @@ -37,6 +37,7 @@ import { explorerImportFiles, explorerRenameFile, } from './actions'; +import DeleteFileAlert from './deleteFileAlert/DeleteFileAlert'; import { I18nId } from './i18n'; import NewFileWizard from './newFileWizard/NewFileWizard'; import RenameFileDialog from './renameFileDialog/RenameFileDialog'; @@ -380,6 +381,7 @@ const Explorer: React.VFC = () => { + ); }; diff --git a/src/explorer/actions.ts b/src/explorer/actions.ts index 52833867..62b58829 100644 --- a/src/explorer/actions.ts +++ b/src/explorer/actions.ts @@ -164,3 +164,24 @@ export const explorerDeleteFile = createAction((fileName: string) => ({ type: 'explorer.action.deleteFile', fileName, })); + +/** + * Action that indicates that {@link explorerDeleteFile} succeeded. + * @param fileName The file name. + */ +export const explorerDidDeleteFile = createAction((fileName: string) => ({ + type: 'explorer.action.didDeleteFile', + fileName, +})); + +/** + * Action that indicates that {@link explorerDeleteFile} failed. + * @param fileName The file name. + */ +export const explorerDidFailToDeleteFile = createAction( + (fileName: string, error: Error) => ({ + type: 'explorer.action.didFailToDeleteFile', + fileName, + error, + }), +); diff --git a/src/explorer/deleteFileAlert/DeleteFileAlert.test.tsx b/src/explorer/deleteFileAlert/DeleteFileAlert.test.tsx new file mode 100644 index 00000000..713cb1c3 --- /dev/null +++ b/src/explorer/deleteFileAlert/DeleteFileAlert.test.tsx @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { cleanup, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { testRender } from '../../../test'; +import DeleteFileAlert from './DeleteFileAlert'; +import { deleteFileAlertDidAccept, deleteFileAlertDidCancel } from './actions'; + +afterEach(() => { + cleanup(); +}); + +describe('accept', () => { + it('should dispatch accept action when delete button is clicked', async () => { + const [dialog, dispatch] = testRender(, { + explorer: { deleteFileAlert: { fileName: 'test.file', isOpen: true } }, + }); + + userEvent.click(dialog.getByRole('button', { name: 'Delete' })); + expect(dispatch).toHaveBeenCalledWith(deleteFileAlertDidAccept()); + }); + + it('should dispatch accept action when enter is pressed ', async () => { + const [dialog, dispatch] = testRender(, { + explorer: { deleteFileAlert: { fileName: 'test.file', isOpen: true } }, + }); + + await waitFor(() => + expect(dialog.getByRole('button', { name: 'Delete' })).toHaveFocus(), + ); + userEvent.keyboard('{enter}'); + + expect(dispatch).toHaveBeenCalledWith(deleteFileAlertDidAccept()); + }); +}); + +describe('cancel', () => { + it('should dispatch cancel when keep button is clicked', () => { + const [dialog, dispatch] = testRender(, { + explorer: { deleteFileAlert: { fileName: 'test.file', isOpen: true } }, + }); + + userEvent.click(dialog.getByRole('button', { name: 'Keep' })); + + expect(dispatch).toHaveBeenCalledWith(deleteFileAlertDidCancel()); + }); + + it('should dispatch cancel when escape button is pressed', async () => { + const [dialog, dispatch] = testRender(, { + explorer: { deleteFileAlert: { fileName: 'test.file', isOpen: true } }, + }); + + await waitFor(() => + expect(dialog.getByRole('button', { name: 'Delete' })).toHaveFocus(), + ); + userEvent.keyboard('{esc}'); + + expect(dispatch).toHaveBeenCalledWith(deleteFileAlertDidCancel()); + }); +}); diff --git a/src/explorer/deleteFileAlert/DeleteFileAlert.tsx b/src/explorer/deleteFileAlert/DeleteFileAlert.tsx new file mode 100644 index 00000000..7c35be81 --- /dev/null +++ b/src/explorer/deleteFileAlert/DeleteFileAlert.tsx @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { Alert, Classes, Intent } from '@blueprintjs/core'; +import { useI18n } from '@shopify/react-i18n'; +import React, { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; +import { useSelector } from '../../reducers'; +import { deleteFileAlertDidAccept, deleteFileAlertDidCancel } from './actions'; +import { I18nId } from './i18n'; + +const DeleteFileAlert: React.VoidFunctionComponent = () => { + const { isOpen, fileName } = useSelector((s) => s.explorer.deleteFileAlert); + const dispatch = useDispatch(); + // istanbul ignore next: babel rewrites this line + const [i18n] = useI18n(); + + // a11y: focus primary button when dialog is opened + const handleOpened = useCallback((node: HTMLElement) => { + // HACK: get the accept button + // there doesn't seem to be a nice way to access it via props/ref/etc + const button = node.querySelector( + `button.${Classes.INTENT_DANGER}`, + ); + + // istanbul ignore if: bug if reached + if (!button) { + console.error('bug: could not find accept button'); + return; + } + + button.focus(); + }, []); + + return ( + dispatch(deleteFileAlertDidAccept())} + onCancel={() => dispatch(deleteFileAlertDidCancel())} + onOpened={handleOpened} + > + {i18n.translate(I18nId.Message, { fileName })} + + ); +}; + +export default DeleteFileAlert; diff --git a/src/explorer/deleteFileAlert/actions.ts b/src/explorer/deleteFileAlert/actions.ts new file mode 100644 index 00000000..5cac4fd1 --- /dev/null +++ b/src/explorer/deleteFileAlert/actions.ts @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { createAction } from '../../actions'; + +/** + * Requests to show the delete file alert dialog. + * @param fileName The file name to display to the user. + */ +export const deleteFileAlertShow = createAction((fileName: string) => ({ + type: 'explorer.deleteFileAlert.action.show', + fileName, +})); + +/** + * Indicates that the user accepted the delete file alert dialog. + */ +export const deleteFileAlertDidAccept = createAction(() => ({ + type: 'explorer.deleteFileAlert.action.didAccept', +})); + +/** + * Indicates that the user canceled the delete file alert dialog. + */ +export const deleteFileAlertDidCancel = createAction(() => ({ + type: 'explorer.deleteFileAlert.action.didCancel', +})); diff --git a/src/explorer/deleteFileAlert/i18n.test.ts b/src/explorer/deleteFileAlert/i18n.test.ts new file mode 100644 index 00000000..e706ba28 --- /dev/null +++ b/src/explorer/deleteFileAlert/i18n.test.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { lookup } from '../../../test'; +import { I18nId } from './i18n'; +import en from './translations/en.json'; + +describe('Ensure .json file has matches for I18nId', () => { + test.each(Object.values(I18nId))('%s', (id) => { + expect(lookup(en, id)).toBeDefined(); + }); +}); diff --git a/src/explorer/deleteFileAlert/i18n.ts b/src/explorer/deleteFileAlert/i18n.ts new file mode 100644 index 00000000..718931cf --- /dev/null +++ b/src/explorer/deleteFileAlert/i18n.ts @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +export enum I18nId { + Accept = 'action.accept', + Cancel = 'action.cancel', + Message = 'message', +} diff --git a/src/explorer/deleteFileAlert/reducers.ts b/src/explorer/deleteFileAlert/reducers.ts new file mode 100644 index 00000000..f7ac77dd --- /dev/null +++ b/src/explorer/deleteFileAlert/reducers.ts @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { Reducer, combineReducers } from 'redux'; +import { + deleteFileAlertDidAccept, + deleteFileAlertDidCancel, + deleteFileAlertShow, +} from './actions'; + +/** Controls the delete file alert dialog isOpen state. */ +const isOpen: Reducer = (state = false, action) => { + if (deleteFileAlertShow.matches(action)) { + return true; + } + + if ( + deleteFileAlertDidAccept.matches(action) || + deleteFileAlertDidCancel.matches(action) + ) { + return false; + } + + return state; +}; + +/** Controls the file name displayed in the file alert dialog. */ +const fileName: Reducer = (state = '', action) => { + if (deleteFileAlertShow.matches(action)) { + return action.fileName; + } + + return state; +}; + +export default combineReducers({ isOpen, fileName }); diff --git a/src/explorer/deleteFileAlert/translations/en.json b/src/explorer/deleteFileAlert/translations/en.json new file mode 100644 index 00000000..261eac23 --- /dev/null +++ b/src/explorer/deleteFileAlert/translations/en.json @@ -0,0 +1,7 @@ +{ + "message": "The file '{fileName}' will be permanently deleted. This cannot be undone.", + "action": { + "accept": "Delete", + "cancel": "Keep" + } +} diff --git a/src/explorer/reducers.ts b/src/explorer/reducers.ts index 89c18690..616aae5b 100644 --- a/src/explorer/reducers.ts +++ b/src/explorer/reducers.ts @@ -10,6 +10,7 @@ import { fileStorageDidRemoveItem, } from '../fileStorage/actions'; +import deleteFileAlert from './deleteFileAlert/reducers'; import newFileWizard from './newFileWizard/reducers'; import renameFileDialog from './renameFileDialog/reducers'; @@ -52,4 +53,9 @@ const files: Reducer = (state = [], action) => { return state; }; -export default combineReducers({ files, newFileWizard, renameFileDialog }); +export default combineReducers({ + files, + deleteFileAlert, + newFileWizard, + renameFileDialog, +}); diff --git a/src/explorer/sagas.test.ts b/src/explorer/sagas.test.ts index 3eb3727e..c022cd54 100644 --- a/src/explorer/sagas.test.ts +++ b/src/explorer/sagas.test.ts @@ -4,18 +4,24 @@ import * as browserFsAccess from 'browser-fs-access'; import { FileWithHandle } from 'browser-fs-access'; import { mock } from 'jest-mock-extended'; -import { AsyncSaga } from '../../test'; +import { AsyncSaga, uuid } from '../../test'; import { editorActivateFile, + editorCloseFile, editorDidActivateFile, + editorDidCloseFile, editorDidFailToActivateFile, } from '../editor/actions'; import { + fileStorageDeleteFile, + fileStorageDidDeleteFile, fileStorageDidDumpAllFiles, + fileStorageDidFailToDeleteFile, fileStorageDidFailToDumpAllFiles, fileStorageDidFailToReadFile, fileStorageDidFailToRenameFile, fileStorageDidReadFile, + fileStorageDidRemoveItem, fileStorageDidRenameFile, fileStorageDidWriteFile, fileStorageDumpAllFiles, @@ -28,13 +34,16 @@ import { explorerActivateFile, explorerArchiveAllFiles, explorerCreateNewFile, + explorerDeleteFile, explorerDidActivateFile, explorerDidArchiveAllFiles, explorerDidCreateNewFile, + explorerDidDeleteFile, explorerDidExportFile, explorerDidFailToActivateFile, explorerDidFailToArchiveAllFiles, explorerDidFailToCreateNewFile, + explorerDidFailToDeleteFile, explorerDidFailToExportFile, explorerDidFailToImportFiles, explorerDidFailToRenameFile, @@ -44,6 +53,11 @@ import { explorerImportFiles, explorerRenameFile, } from './actions'; +import { + deleteFileAlertDidAccept, + deleteFileAlertDidCancel, + deleteFileAlertShow, +} from './deleteFileAlert/actions'; import { Hub, newFileWizardDidAccept, @@ -344,3 +358,73 @@ describe('handleExplorerExportFile', () => { await saga.end(); }); }); + +describe('handleExplorerDeleteFile', () => { + let saga: AsyncSaga; + const testFile = 'test.file'; + + beforeEach(async () => { + saga = new AsyncSaga(explorer); + + saga.put(explorerDeleteFile(testFile)); + + await expect(saga.take()).resolves.toEqual(deleteFileAlertShow(testFile)); + }); + + it('should fail with AbortError if canceled', async () => { + saga.put(deleteFileAlertDidCancel()); + + await expect(saga.take()).resolves.toEqual( + explorerDidFailToDeleteFile( + testFile, + new DOMException('user canceled', 'AbortError'), + ), + ); + }); + + it('should fail with AbortError if file was removed before user accept/cancel', async () => { + saga.put( + fileStorageDidRemoveItem({ path: testFile, uuid: uuid(0), sha256: '' }), + ); + + // should programmatically cancel the dialog + await expect(saga.take()).resolves.toEqual(deleteFileAlertDidCancel()); + + await expect(saga.take()).resolves.toEqual( + explorerDidFailToDeleteFile( + testFile, + new DOMException('file was removed', 'AbortError'), + ), + ); + }); + + describe('accepted', () => { + beforeEach(async () => { + saga.put(deleteFileAlertDidAccept()); + + // should close the editor first + await expect(saga.take()).resolves.toEqual(editorCloseFile(testFile)); + saga.put(editorDidCloseFile(testFile)); + + // then delete the file + await expect(saga.take()).resolves.toEqual(fileStorageDeleteFile(testFile)); + }); + + it('should propagate error', async () => { + const testError = new Error('test error'); + saga.put(fileStorageDidFailToDeleteFile(testFile, testError)); + await expect(saga.take()).resolves.toEqual( + explorerDidFailToDeleteFile(testFile, testError), + ); + }); + + it('should succeed', async () => { + saga.put(fileStorageDidDeleteFile(testFile)); + await expect(saga.take()).resolves.toEqual(explorerDidDeleteFile(testFile)); + }); + }); + + afterEach(async () => { + await saga.end(); + }); +}); diff --git a/src/explorer/sagas.ts b/src/explorer/sagas.ts index 6f0e3170..9f890822 100644 --- a/src/explorer/sagas.ts +++ b/src/explorer/sagas.ts @@ -14,17 +14,23 @@ import { } from 'typed-redux-saga/macro'; import { editorActivateFile, + editorCloseFile, editorDidActivateFile, + editorDidCloseFile, editorDidFailToActivateFile, } from '../editor/actions'; import { getPybricksMicroPythonFileTemplate } from '../editor/pybricksMicroPython'; import { + fileStorageDeleteFile, + fileStorageDidDeleteFile, fileStorageDidDumpAllFiles, + fileStorageDidFailToDeleteFile, fileStorageDidFailToDumpAllFiles, fileStorageDidFailToReadFile, fileStorageDidFailToRenameFile, fileStorageDidFailToWriteFile, fileStorageDidReadFile, + fileStorageDidRemoveItem, fileStorageDidRenameFile, fileStorageDidWriteFile, fileStorageDumpAllFiles, @@ -45,13 +51,16 @@ import { explorerActivateFile, explorerArchiveAllFiles, explorerCreateNewFile, + explorerDeleteFile, explorerDidActivateFile, explorerDidArchiveAllFiles, explorerDidCreateNewFile, + explorerDidDeleteFile, explorerDidExportFile, explorerDidFailToActivateFile, explorerDidFailToArchiveAllFiles, explorerDidFailToCreateNewFile, + explorerDidFailToDeleteFile, explorerDidFailToExportFile, explorerDidFailToImportFiles, explorerDidFailToRenameFile, @@ -61,6 +70,11 @@ import { explorerImportFiles, explorerRenameFile, } from './actions'; +import { + deleteFileAlertDidAccept, + deleteFileAlertDidCancel, + deleteFileAlertShow, +} from './deleteFileAlert/actions'; import { newFileWizardDidAccept, newFileWizardDidCancel, @@ -328,6 +342,56 @@ function* handleExplorerExportFile( } } +function* handleExplorerDeleteFile(action: ReturnType) { + try { + yield* put(deleteFileAlertShow(action.fileName)); + + const { didCancel, didRemove } = yield* race({ + didAccept: take(deleteFileAlertDidAccept), + didCancel: take(deleteFileAlertDidCancel), + didRemove: take( + fileStorageDidRemoveItem.when((a) => a.file.path === action.fileName), + ), + }); + + if (didCancel) { + throw new DOMException('user canceled', 'AbortError'); + } + + // automatically cancel the dialog, if the file was removed, e.g. it was + // deleted in a different window + if (didRemove) { + yield* put(deleteFileAlertDidCancel()); + throw new DOMException('file was removed', 'AbortError'); + } + + // at this point we know the user accepted + + // have to close editor before deleting, otherwise we get "in use" error + yield* put(editorCloseFile(action.fileName)); + yield* take(editorDidCloseFile.when((a) => a.fileName === action.fileName)); + + yield* put(fileStorageDeleteFile(action.fileName)); + + const { didFailToDelete } = yield* race({ + didDelete: take( + fileStorageDidDeleteFile.when((a) => a.path === action.fileName), + ), + didFailToDelete: take( + fileStorageDidFailToDeleteFile.when((a) => a.path === action.fileName), + ), + }); + + if (didFailToDelete) { + throw didFailToDelete.error; + } + + yield* put(explorerDidDeleteFile(action.fileName)); + } catch (err) { + yield* put(explorerDidFailToDeleteFile(action.fileName, ensureError(err))); + } +} + export default function* (): Generator { yield* takeEvery(explorerArchiveAllFiles, handleExplorerArchiveAllFiles); yield* takeEvery(explorerImportFiles, handleExplorerImportFiles); @@ -338,4 +402,5 @@ export default function* (): Generator { // this to happen in practice though. yield* takeLatest(explorerRenameFile, handleExplorerRenameFile); yield* takeLatest(explorerExportFile, handleExplorerExportFile); + yield* takeLatest(explorerDeleteFile, handleExplorerDeleteFile); } diff --git a/src/notifications/i18n.ts b/src/notifications/i18n.ts index dd8bf92b..f6793149 100644 --- a/src/notifications/i18n.ts +++ b/src/notifications/i18n.ts @@ -15,10 +15,9 @@ export enum I18nId { BleNoBluetooth = 'ble.noBluetooth', EditorFailedToOpenFile = 'editor.failedToOpenFile', EditorFailedToSaveFile = 'editor.failedToSaveFile', - ExplorerDeleteFileMessage = 'explorer.deleteFile.message', - ExplorerDeleteFileAction = 'explorer.deleteFile.action', ExplorerFailedToImportFiles = 'explorer.failedToImportFiles', ExplorerFailedToCreate = 'explorer.failedToCreate', + ExplorerFailedToDelete = 'explorer.failedToDelete', ExplorerFailedToExport = 'explorer.failedToExport', ExplorerFailedToArchive = 'explorer.failedToArchive', FileStorageFailedToInitialize = 'fileStorage.failedToInitialize', diff --git a/src/notifications/sagas.test.ts b/src/notifications/sagas.test.ts index 3f86f825..905aca92 100644 --- a/src/notifications/sagas.test.ts +++ b/src/notifications/sagas.test.ts @@ -9,7 +9,7 @@ import { } from '@pybricks/firmware'; import { I18nManager } from '@shopify/react-i18n'; import { AnyAction } from 'redux'; -import { AsyncSaga, uuid } from '../../test'; +import { AsyncSaga } from '../../test'; import { appDidCheckForUpdate } from '../app/actions'; import { bleDIServiceDidReceiveFirmwareRevision } from '../ble-device-info-service/actions'; import { @@ -18,17 +18,13 @@ import { } from '../ble/actions'; import { editorDidFailToOpenFile } from '../editor/actions'; import { - explorerDeleteFile, explorerDidFailToArchiveAllFiles, explorerDidFailToCreateNewFile, + explorerDidFailToDeleteFile, explorerDidFailToExportFile, explorerDidFailToImportFiles, } from '../explorer/actions'; -import { - fileStorageDeleteFile, - fileStorageDidFailToInitialize, - fileStorageDidRemoveItem, -} from '../fileStorage/actions'; +import { fileStorageDidFailToInitialize } from '../fileStorage/actions'; import { FailToFinishReasonType, HubError, @@ -117,6 +113,7 @@ test.each([ explorerDidFailToImportFiles(new Error('test error')), explorerDidFailToCreateNewFile(new Error('test error')), explorerDidFailToExportFile('test.file', new Error('test error')), + explorerDidFailToDeleteFile('test.file', new Error('test error')), editorDidFailToOpenFile('test.file', new Error('test error')), ])('actions that should show notification: %o', async (action: AnyAction) => { const { toaster, saga } = createTestToasterSaga(); @@ -144,6 +141,10 @@ test.each([ 'test.file', new DOMException('test message', 'AbortError'), ), + explorerDidFailToDeleteFile( + 'test.file', + new DOMException('test message', 'AbortError'), + ), ])('actions that should not show a notification: %o', async (action: AnyAction) => { const { toaster, saga } = createTestToasterSaga(); @@ -171,59 +172,3 @@ test.each([[didCompile(new Uint8Array()), I18nId.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(I18nId.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 === I18nId.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 === I18nId.ExplorerDeleteFileMessage), - ).toBeDefined(); - - saga.put( - fileStorageDidRemoveItem({ uuid: uuid(0), path: 'test.file', sha256: '' }), - ); - - expect( - toaster.getToasts().find((t) => t.key === I18nId.ExplorerDeleteFileMessage), - ).toBeUndefined(); - - await saga.end(); - }); -}); diff --git a/src/notifications/sagas.ts b/src/notifications/sagas.ts index b80d14ae..afab9d62 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, race, take, takeEvery } from 'typed-redux-saga/macro'; +import { delay, getContext, put, 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'; @@ -19,17 +19,13 @@ import { } from '../ble/actions'; import { editorDidFailToOpenFile } from '../editor/actions'; import { - explorerDeleteFile, explorerDidFailToArchiveAllFiles, explorerDidFailToCreateNewFile, + explorerDidFailToDeleteFile, explorerDidFailToExportFile, explorerDidFailToImportFiles, } from '../explorer/actions'; -import { - fileStorageDeleteFile, - fileStorageDidFailToInitialize, - fileStorageDidRemoveItem, -} from '../fileStorage/actions'; +import { fileStorageDidFailToInitialize } from '../fileStorage/actions'; import { FailToFinishReasonType, didFailToFinish } from '../firmware/actions'; import { BootloaderConnectionFailureReason, @@ -399,41 +395,6 @@ function* showFileStorageFailToArchive( yield* showUnexpectedError(I18nId.ExplorerFailedToArchive, action.error); } -function* showDeleteFileWarning(action: ReturnType) { - const ch = channel>(); - const userAction = dispatchAction(I18nId.ExplorerDeleteFileAction, ch.put, 'trash'); - - // TODO: this should probably not be a singleton - yield* showSingleton( - Level.Warning, - I18nId.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.file.path === 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(I18nId.ExplorerDeleteFileMessage); - return; - } - - // this only runs if userAction is dispatched - yield* put(fileStorageDeleteFile(action.fileName)); -} - function* showExplorerFailToImportFiles( action: ReturnType, ): Generator { @@ -474,6 +435,19 @@ function* showEditorDidFailToOpenFile( yield* showUnexpectedError(I18nId.EditorFailedToOpenFile, action.error); } +function* showExplorerFailToDelete( + action: ReturnType, +): Generator { + if (action.error.name === 'AbortError') { + // user clicked cancel button - not an error + return; + } + + // TODO: add a specific error message for when file in use (e.g. open in another window) + + yield* showUnexpectedError(I18nId.ExplorerFailedToDelete, action.error); +} + export default function* (): Generator { yield* takeEvery(bleDeviceDidFailToConnect, showBleDeviceDidFailToConnectError); yield* takeEvery(bootloaderDidFailToConnect, showBootloaderDidFailToConnectError); @@ -486,9 +460,9 @@ export default function* (): Generator { yield* takeEvery(bleDIServiceDidReceiveFirmwareRevision, checkVersion); yield* takeEvery(fileStorageDidFailToInitialize, showFileStorageFailToInitialize); yield* takeEvery(explorerDidFailToArchiveAllFiles, showFileStorageFailToArchive); - yield* takeEvery(explorerDeleteFile, showDeleteFileWarning); yield* takeEvery(explorerDidFailToImportFiles, showExplorerFailToImportFiles); yield* takeEvery(explorerDidFailToCreateNewFile, showExplorerFailToCreateFile); yield* takeEvery(explorerDidFailToExportFile, showExplorerFailToExport); + yield* takeEvery(explorerDidFailToDeleteFile, showExplorerFailToDelete); yield* takeEvery(editorDidFailToOpenFile, showEditorDidFailToOpenFile); } diff --git a/src/notifications/translations/en.json b/src/notifications/translations/en.json index 83db5cc2..d5e00ce1 100644 --- a/src/notifications/translations/en.json +++ b/src/notifications/translations/en.json @@ -17,13 +17,10 @@ "failedToSaveFile": "Failed to save the program." }, "explorer": { - "deleteFile": { - "message": "The file {fileName} will be permanently deleted. This cannot be undone.", - "action": "Delete" - }, "failedToImportFiles": "Failed to import file(s).", "failedToCreate": "Failed to create file.", "failedToExport": "Failed to export file.", + "failedToDelete": "Failed to delete file.", "failedToArchive": "Failed to archive files.'" }, "fileStorage": {