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:
David Lechner
2022-04-08 12:53:12 -05:00
parent 1c30683b93
commit d238e68deb
16 changed files with 412 additions and 114 deletions
@@ -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;
+27
View File
@@ -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',
}));
+12
View File
@@ -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();
});
});
+8
View File
@@ -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',
}
+36
View File
@@ -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"
}
}