mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-12 17:45:22 +00:00
explorer: new delete alert dialog
This replaces the delete notification with a new alert dialog. This makes for better keyboard interaction and forces the user to make a decision before doing anything else. Also fixes closing the editor before deleting the file.
This commit is contained in:
@@ -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 = () => {
|
||||
<FileTree i18n={i18n} />
|
||||
<NewFileWizard />
|
||||
<RenameFileDialog />
|
||||
<DeleteFileAlert />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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(<DeleteFileAlert />, {
|
||||
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(<DeleteFileAlert />, {
|
||||
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(<DeleteFileAlert />, {
|
||||
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(<DeleteFileAlert />, {
|
||||
explorer: { deleteFileAlert: { fileName: 'test.file', isOpen: true } },
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(dialog.getByRole('button', { name: 'Delete' })).toHaveFocus(),
|
||||
);
|
||||
userEvent.keyboard('{esc}');
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(deleteFileAlertDidCancel());
|
||||
});
|
||||
});
|
||||
@@ -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<HTMLButtonElement>(
|
||||
`button.${Classes.INTENT_DANGER}`,
|
||||
);
|
||||
|
||||
// istanbul ignore if: bug if reached
|
||||
if (!button) {
|
||||
console.error('bug: could not find accept button');
|
||||
return;
|
||||
}
|
||||
|
||||
button.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Alert
|
||||
canEscapeKeyCancel={true}
|
||||
canOutsideClickCancel={true}
|
||||
isOpen={isOpen}
|
||||
icon="trash"
|
||||
intent={Intent.DANGER}
|
||||
confirmButtonText={i18n.translate(I18nId.Accept)}
|
||||
cancelButtonText={i18n.translate(I18nId.Cancel)}
|
||||
onConfirm={() => dispatch(deleteFileAlertDidAccept())}
|
||||
onCancel={() => dispatch(deleteFileAlertDidCancel())}
|
||||
onOpened={handleOpened}
|
||||
>
|
||||
{i18n.translate(I18nId.Message, { fileName })}
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteFileAlert;
|
||||
@@ -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',
|
||||
}));
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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<boolean> = (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<string> = (state = '', action) => {
|
||||
if (deleteFileAlertShow.matches(action)) {
|
||||
return action.fileName;
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
export default combineReducers({ isOpen, fileName });
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"message": "The file '{fileName}' will be permanently deleted. This cannot be undone.",
|
||||
"action": {
|
||||
"accept": "Delete",
|
||||
"cancel": "Keep"
|
||||
}
|
||||
}
|
||||
@@ -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<readonly ExplorerFileInfo[]> = (state = [], action) => {
|
||||
return state;
|
||||
};
|
||||
|
||||
export default combineReducers({ files, newFileWizard, renameFileDialog });
|
||||
export default combineReducers({
|
||||
files,
|
||||
deleteFileAlert,
|
||||
newFileWizard,
|
||||
renameFileDialog,
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<typeof explorerDeleteFile>) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<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 === 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();
|
||||
});
|
||||
});
|
||||
|
||||
+17
-43
@@ -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<typeof explorerDeleteFile>) {
|
||||
const ch = channel<React.MouseEvent<HTMLElement>>();
|
||||
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<NotificationContext>('notification');
|
||||
toaster.dismiss(I18nId.ExplorerDeleteFileMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
// this only runs if userAction is dispatched
|
||||
yield* put(fileStorageDeleteFile(action.fileName));
|
||||
}
|
||||
|
||||
function* showExplorerFailToImportFiles(
|
||||
action: ReturnType<typeof explorerDidFailToImportFiles>,
|
||||
): Generator {
|
||||
@@ -474,6 +435,19 @@ function* showEditorDidFailToOpenFile(
|
||||
yield* showUnexpectedError(I18nId.EditorFailedToOpenFile, action.error);
|
||||
}
|
||||
|
||||
function* showExplorerFailToDelete(
|
||||
action: ReturnType<typeof explorerDidFailToDeleteFile>,
|
||||
): 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);
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user