Commit f2352018 authored by Robert Knight's avatar Robert Knight

Convert existing `window.confirm` calls to use `confirm` prompt

Convert existing uses of `window.confirm` to use our own `confirm`
utility which uses the standard Dialog from our pattern library.

Using our own prompt avoids an immediate problem that browsers, starting
with Chrome v91, are starting to block the use of `window.confirm` in
third-party iframe. It also makes the visual design of the prompt match
the rest of the application.
parent 0d2dc975
...@@ -7,6 +7,7 @@ import { isPrivate, permits } from '../../helpers/permissions'; ...@@ -7,6 +7,7 @@ import { isPrivate, permits } from '../../helpers/permissions';
import { withServices } from '../../service-context'; import { withServices } from '../../service-context';
import { IconButton } from '../../../shared/components/buttons'; import { IconButton } from '../../../shared/components/buttons';
import { confirm } from '../../../shared/prompts';
import AnnotationShareControl from './AnnotationShareControl'; import AnnotationShareControl from './AnnotationShareControl';
...@@ -57,11 +58,19 @@ function AnnotationActionBar({ ...@@ -57,11 +58,19 @@ function AnnotationActionBar({
const shareLink = const shareLink =
sharingEnabled(settings) && annotationSharingLink(annotation); sharingEnabled(settings) && annotationSharingLink(annotation);
const onDelete = () => { const onDelete = async () => {
if (window.confirm('Are you sure you want to delete this annotation?')) { if (
annotationsService.delete(annotation).catch(err => { await confirm({
title: 'Delete annotation?',
message: 'Are you sure you want to delete this annotation?',
confirmAction: 'Delete',
})
) {
try {
await annotationsService.delete(annotation);
} catch (err) {
toastMessenger.error(err.message); toastMessenger.error(err.message);
}); }
} }
}; };
......
...@@ -12,8 +12,8 @@ import { waitFor } from '../../../../test-util/wait'; ...@@ -12,8 +12,8 @@ import { waitFor } from '../../../../test-util/wait';
describe('AnnotationActionBar', () => { describe('AnnotationActionBar', () => {
let fakeAnnotation; let fakeAnnotation;
let fakeConfirm;
let fakeOnReply; let fakeOnReply;
let fakeUserProfile; let fakeUserProfile;
// Fake services // Fake services
...@@ -87,6 +87,8 @@ describe('AnnotationActionBar', () => { ...@@ -87,6 +87,8 @@ describe('AnnotationActionBar', () => {
profile: sinon.stub().returns(fakeUserProfile), profile: sinon.stub().returns(fakeUserProfile),
}; };
fakeConfirm = sinon.stub().resolves(false);
$imports.$mock(mockImportedComponents()); $imports.$mock(mockImportedComponents());
$imports.$mock({ $imports.$mock({
'../../helpers/annotation-sharing': { '../../helpers/annotation-sharing': {
...@@ -95,12 +97,11 @@ describe('AnnotationActionBar', () => { ...@@ -95,12 +97,11 @@ describe('AnnotationActionBar', () => {
}, },
'../../helpers/permissions': { permits: fakePermits }, '../../helpers/permissions': { permits: fakePermits },
'../../store/use-store': { useStoreProxy: () => fakeStore }, '../../store/use-store': { useStoreProxy: () => fakeStore },
'../../../shared/prompts': { confirm: fakeConfirm },
}); });
sinon.stub(window, 'confirm').returns(false);
}); });
afterEach(() => { afterEach(() => {
window.confirm.restore();
$imports.$restore(); $imports.$restore();
}); });
...@@ -145,25 +146,25 @@ describe('AnnotationActionBar', () => { ...@@ -145,25 +146,25 @@ describe('AnnotationActionBar', () => {
assert.isTrue(getButton(wrapper, 'trash').exists()); assert.isTrue(getButton(wrapper, 'trash').exists());
}); });
it('asks for confirmation before deletion', () => { it('asks for confirmation before deletion', async () => {
allowOnly('delete'); allowOnly('delete');
const button = getButton(createComponent(), 'trash'); const button = getButton(createComponent(), 'trash');
act(() => { await act(async () => {
button.props().onClick(); await button.props().onClick();
}); });
assert.calledOnce(confirm); assert.calledOnce(fakeConfirm);
assert.notCalled(fakeAnnotationsService.delete); assert.notCalled(fakeAnnotationsService.delete);
}); });
it('invokes delete on service when confirmed', () => { it('invokes delete on service when confirmed', async () => {
allowOnly('delete'); allowOnly('delete');
window.confirm.returns(true); fakeConfirm.resolves(true);
const button = getButton(createComponent(), 'trash'); const button = getButton(createComponent(), 'trash');
act(() => { await act(async () => {
button.props().onClick(); await button.props().onClick();
}); });
assert.calledWith(fakeAnnotationsService.delete, fakeAnnotation); assert.calledWith(fakeAnnotationsService.delete, fakeAnnotation);
...@@ -171,12 +172,12 @@ describe('AnnotationActionBar', () => { ...@@ -171,12 +172,12 @@ describe('AnnotationActionBar', () => {
it('sets a flash message if there is an error with deletion', async () => { it('sets a flash message if there is an error with deletion', async () => {
allowOnly('delete'); allowOnly('delete');
window.confirm.returns(true); fakeConfirm.resolves(true);
fakeAnnotationsService.delete.rejects(); fakeAnnotationsService.delete.rejects();
const button = getButton(createComponent(), 'trash'); const button = getButton(createComponent(), 'trash');
act(() => { await act(async () => {
button.props().onClick(); await button.props().onClick();
}); });
await waitFor(() => fakeToastMessenger.error.called); await waitFor(() => fakeToastMessenger.error.called);
...@@ -275,8 +276,6 @@ describe('AnnotationActionBar', () => { ...@@ -275,8 +276,6 @@ describe('AnnotationActionBar', () => {
}); });
it('invokes flag on service when clicked', () => { it('invokes flag on service when clicked', () => {
window.confirm.returns(true);
const button = getButton(createComponent(), 'flag'); const button = getButton(createComponent(), 'flag');
act(() => { act(() => {
...@@ -287,7 +286,6 @@ describe('AnnotationActionBar', () => { ...@@ -287,7 +286,6 @@ describe('AnnotationActionBar', () => {
}); });
it('sets flash error message if flagging fails on service', async () => { it('sets flash error message if flagging fails on service', async () => {
window.confirm.returns(true);
fakeAnnotationsService.flag.rejects(); fakeAnnotationsService.flag.rejects();
const button = getButton(createComponent(), 'flag'); const button = getButton(createComponent(), 'flag');
......
...@@ -2,6 +2,7 @@ import { orgName } from '../../helpers/group-list-item-common'; ...@@ -2,6 +2,7 @@ import { orgName } from '../../helpers/group-list-item-common';
import { withServices } from '../../service-context'; import { withServices } from '../../service-context';
import { useStoreProxy } from '../../store/use-store'; import { useStoreProxy } from '../../store/use-store';
import { copyText } from '../../util/copy-to-clipboard'; import { copyText } from '../../util/copy-to-clipboard';
import { confirm } from '../../../shared/prompts';
import MenuItem from '../MenuItem'; import MenuItem from '../MenuItem';
...@@ -49,9 +50,15 @@ function GroupListItem({ ...@@ -49,9 +50,15 @@ function GroupListItem({
groupsService.focus(group.id); groupsService.focus(group.id);
}; };
const leaveGroup = () => { const leaveGroup = async () => {
const message = `Are you sure you want to leave the group "${group.name}"?`; const message = `Are you sure you want to leave the group "${group.name}"?`;
if (window.confirm(message)) { if (
await confirm({
title: 'Leave group?',
message,
confirmAction: 'Leave',
})
) {
groupsService.leave(group.id); groupsService.leave(group.id);
} }
}; };
......
...@@ -3,7 +3,12 @@ import { act } from 'preact/test-utils'; ...@@ -3,7 +3,12 @@ import { act } from 'preact/test-utils';
import GroupListItem, { $imports } from '../GroupListItem'; import GroupListItem, { $imports } from '../GroupListItem';
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
describe('GroupListItem', () => { describe('GroupListItem', () => {
let fakeConfirm;
let fakeCopyText; let fakeCopyText;
let fakeToastMessenger; let fakeToastMessenger;
let fakeGroupsService; let fakeGroupsService;
...@@ -57,6 +62,8 @@ describe('GroupListItem', () => { ...@@ -57,6 +62,8 @@ describe('GroupListItem', () => {
} }
FakeSlider.displayName = 'Slider'; FakeSlider.displayName = 'Slider';
fakeConfirm = sinon.stub().resolves(false);
$imports.$mock({ $imports.$mock({
'../MenuItem': FakeMenuItem, '../MenuItem': FakeMenuItem,
'../../util/copy-to-clipboard': { '../../util/copy-to-clipboard': {
...@@ -64,14 +71,12 @@ describe('GroupListItem', () => { ...@@ -64,14 +71,12 @@ describe('GroupListItem', () => {
}, },
'../../helpers/group-list-item-common': fakeGroupListItemCommon, '../../helpers/group-list-item-common': fakeGroupListItemCommon,
'../../store/use-store': { useStoreProxy: () => fakeStore }, '../../store/use-store': { useStoreProxy: () => fakeStore },
'../../../shared/prompts': { confirm: fakeConfirm },
}); });
sinon.stub(window, 'confirm').returns(false);
}); });
afterEach(() => { afterEach(() => {
$imports.$restore(); $imports.$restore();
window.confirm.restore();
}); });
const createGroupListItem = (fakeGroup, props = {}) => { const createGroupListItem = (fakeGroup, props = {}) => {
...@@ -243,28 +248,30 @@ describe('GroupListItem', () => { ...@@ -243,28 +248,30 @@ describe('GroupListItem', () => {
assert.isTrue(submenu.exists('MenuItem[label="Leave group"]')); assert.isTrue(submenu.exists('MenuItem[label="Leave group"]'));
}); });
it('prompts to leave group if "Leave" action is clicked', () => { it('prompts to leave group if "Leave" action is clicked', async () => {
const wrapper = createGroupListItem(fakeGroup, { const wrapper = createGroupListItem(fakeGroup, {
isExpanded: true, isExpanded: true,
}); });
const submenu = getSubmenu(wrapper); const submenu = getSubmenu(wrapper);
clickMenuItem(submenu, 'Leave group'); clickMenuItem(submenu, 'Leave group');
await delay(0);
assert.called(window.confirm); assert.called(fakeConfirm);
assert.notCalled(fakeGroupsService.leave); assert.notCalled(fakeGroupsService.leave);
}); });
it('leaves group if "Leave" is clicked and user confirms', () => { it('leaves group if "Leave" is clicked and user confirms', async () => {
const wrapper = createGroupListItem(fakeGroup, { const wrapper = createGroupListItem(fakeGroup, {
isExpanded: true, isExpanded: true,
}); });
window.confirm.returns(true); fakeConfirm.resolves(true);
const submenu = getSubmenu(wrapper); const submenu = getSubmenu(wrapper);
clickMenuItem(submenu, 'Leave group'); clickMenuItem(submenu, 'Leave group');
await delay(0);
assert.called(window.confirm); assert.called(fakeConfirm);
assert.calledWith(fakeGroupsService.leave, fakeGroup.id); assert.calledWith(fakeGroupsService.leave, fakeGroup.id);
}); });
......
...@@ -2,6 +2,7 @@ import classnames from 'classnames'; ...@@ -2,6 +2,7 @@ import classnames from 'classnames';
import { useEffect, useMemo } from 'preact/hooks'; import { useEffect, useMemo } from 'preact/hooks';
import bridgeEvents from '../../shared/bridge-events'; import bridgeEvents from '../../shared/bridge-events';
import { confirm } from '../../shared/prompts';
import serviceConfig from '../config/service-config'; import serviceConfig from '../config/service-config';
import { useStoreProxy } from '../store/use-store'; import { useStoreProxy } from '../store/use-store';
import { parseAccountID } from '../helpers/account-id'; import { parseAccountID } from '../helpers/account-id';
...@@ -129,26 +130,33 @@ function HypothesisApp({ ...@@ -129,26 +130,33 @@ function HypothesisApp({
window.open(serviceUrl('signup')); window.open(serviceUrl('signup'));
}; };
const promptToLogout = () => { const promptToLogout = async () => {
// TODO - Replace this with a UI which doesn't look terrible.
let text = '';
const drafts = store.countDrafts(); const drafts = store.countDrafts();
if (drafts === 0) {
return true;
}
let message = '';
if (drafts === 1) { if (drafts === 1) {
text = message =
'You have an unsaved annotation.\n' + 'You have an unsaved annotation.\n' +
'Do you really want to discard this draft?'; 'Do you really want to discard this draft?';
} else if (drafts > 1) { } else if (drafts > 1) {
text = message =
'You have ' + 'You have ' +
drafts + drafts +
' unsaved annotations.\n' + ' unsaved annotations.\n' +
'Do you really want to discard these drafts?'; 'Do you really want to discard these drafts?';
} }
return drafts === 0 || window.confirm(text); return confirm({
title: 'Discard drafts?',
message,
confirmAction: 'Discard',
});
}; };
const logout = () => { const logout = async () => {
if (!promptToLogout()) { if (!(await promptToLogout())) {
return; return;
} }
store.clearGroups(); store.clearGroups();
......
...@@ -10,6 +10,7 @@ describe('HypothesisApp', () => { ...@@ -10,6 +10,7 @@ describe('HypothesisApp', () => {
let fakeStore = null; let fakeStore = null;
let fakeAuth = null; let fakeAuth = null;
let fakeBridge = null; let fakeBridge = null;
let fakeConfirm;
let fakeServiceConfig = null; let fakeServiceConfig = null;
let fakeSession = null; let fakeSession = null;
let fakeShouldAutoDisplayTutorial = null; let fakeShouldAutoDisplayTutorial = null;
...@@ -77,6 +78,8 @@ describe('HypothesisApp', () => { ...@@ -77,6 +78,8 @@ describe('HypothesisApp', () => {
notice: sinon.stub(), notice: sinon.stub(),
}; };
fakeConfirm = sinon.stub().resolves(false);
$imports.$mock(mockImportedComponents()); $imports.$mock(mockImportedComponents());
$imports.$mock({ $imports.$mock({
'../config/service-config': fakeServiceConfig, '../config/service-config': fakeServiceConfig,
...@@ -85,6 +88,7 @@ describe('HypothesisApp', () => { ...@@ -85,6 +88,7 @@ describe('HypothesisApp', () => {
shouldAutoDisplayTutorial: fakeShouldAutoDisplayTutorial, shouldAutoDisplayTutorial: fakeShouldAutoDisplayTutorial,
}, },
'../helpers/theme': { applyTheme: fakeApplyTheme }, '../helpers/theme': { applyTheme: fakeApplyTheme },
'../../shared/prompts': { confirm: fakeConfirm },
}); });
}); });
...@@ -305,43 +309,41 @@ describe('HypothesisApp', () => { ...@@ -305,43 +309,41 @@ describe('HypothesisApp', () => {
}); });
describe('"Log out" action', () => { describe('"Log out" action', () => {
const clickLogOut = wrapper => wrapper.find('TopBar').props().onLogout();
beforeEach(() => { beforeEach(() => {
sinon.stub(window, 'confirm'); fakeConfirm.resolves(true);
}); });
afterEach(() => { const clickLogOut = async wrapper => {
window.confirm.restore(); await wrapper.find('TopBar').props().onLogout();
}); };
// Tests used by both the first and third-party account scenarios. // Tests used by both the first and third-party account scenarios.
function addCommonLogoutTests() { function addCommonLogoutTests() {
// nb. Slightly different messages are shown depending on the draft count. // nb. Slightly different messages are shown depending on the draft count.
[1, 2].forEach(draftCount => { [1, 2].forEach(draftCount => {
it('prompts the user if there are drafts', () => { it('prompts the user if there are drafts', async () => {
fakeStore.countDrafts.returns(draftCount); fakeStore.countDrafts.returns(draftCount);
const wrapper = createComponent(); const wrapper = createComponent();
clickLogOut(wrapper); await clickLogOut(wrapper);
assert.equal(window.confirm.callCount, 1); assert.equal(fakeConfirm.callCount, 1);
}); });
}); });
it('clears groups', () => { it('clears groups', async () => {
const wrapper = createComponent(); const wrapper = createComponent();
clickLogOut(wrapper); await clickLogOut(wrapper);
assert.called(fakeStore.clearGroups); assert.called(fakeStore.clearGroups);
}); });
it('removes unsaved annotations', () => { it('removes unsaved annotations', async () => {
fakeStore.unsavedAnnotations = sinon fakeStore.unsavedAnnotations = sinon
.stub() .stub()
.returns(['draftOne', 'draftTwo', 'draftThree']); .returns(['draftOne', 'draftTwo', 'draftThree']);
const wrapper = createComponent(); const wrapper = createComponent();
clickLogOut(wrapper); await clickLogOut(wrapper);
assert.calledWith(fakeStore.removeAnnotations, [ assert.calledWith(fakeStore.removeAnnotations, [
'draftOne', 'draftOne',
...@@ -350,49 +352,49 @@ describe('HypothesisApp', () => { ...@@ -350,49 +352,49 @@ describe('HypothesisApp', () => {
]); ]);
}); });
it('discards drafts', () => { it('discards drafts', async () => {
const wrapper = createComponent(); const wrapper = createComponent();
clickLogOut(wrapper); await clickLogOut(wrapper);
assert(fakeStore.discardAllDrafts.calledOnce); assert(fakeStore.discardAllDrafts.calledOnce);
}); });
it('does not remove unsaved annotations if the user cancels the prompt', () => { it('does not remove unsaved annotations if the user cancels the prompt', async () => {
const wrapper = createComponent(); const wrapper = createComponent();
fakeStore.countDrafts.returns(1); fakeStore.countDrafts.returns(1);
window.confirm.returns(false); fakeConfirm.resolves(false);
clickLogOut(wrapper); await clickLogOut(wrapper);
assert.notCalled(fakeStore.removeAnnotations); assert.notCalled(fakeStore.removeAnnotations);
}); });
it('does not discard drafts if the user cancels the prompt', () => { it('does not discard drafts if the user cancels the prompt', async () => {
const wrapper = createComponent(); const wrapper = createComponent();
fakeStore.countDrafts.returns(1); fakeStore.countDrafts.returns(1);
window.confirm.returns(false); fakeConfirm.resolves(false);
clickLogOut(wrapper); await clickLogOut(wrapper);
assert(fakeStore.discardAllDrafts.notCalled); assert(fakeStore.discardAllDrafts.notCalled);
}); });
it('does not prompt if there are no drafts', () => { it('does not prompt if there are no drafts', async () => {
const wrapper = createComponent(); const wrapper = createComponent();
fakeStore.countDrafts.returns(0); fakeStore.countDrafts.returns(0);
clickLogOut(wrapper); await clickLogOut(wrapper);
assert.notCalled(window.confirm); assert.notCalled(fakeConfirm);
}); });
} }
context('when no third-party service is in use', () => { context('when no third-party service is in use', () => {
addCommonLogoutTests(); addCommonLogoutTests();
it('calls session.logout()', () => { it('calls session.logout()', async () => {
const wrapper = createComponent(); const wrapper = createComponent();
clickLogOut(wrapper); await clickLogOut(wrapper);
assert.called(fakeSession.logout); assert.called(fakeSession.logout);
}); });
}); });
...@@ -404,9 +406,9 @@ describe('HypothesisApp', () => { ...@@ -404,9 +406,9 @@ describe('HypothesisApp', () => {
addCommonLogoutTests(); addCommonLogoutTests();
it('sends LOGOUT_REQUESTED', () => { it('sends LOGOUT_REQUESTED', async () => {
const wrapper = createComponent(); const wrapper = createComponent();
clickLogOut(wrapper); await clickLogOut(wrapper);
assert.calledOnce(fakeBridge.call); assert.calledOnce(fakeBridge.call);
assert.calledWithExactly( assert.calledWithExactly(
...@@ -415,19 +417,19 @@ describe('HypothesisApp', () => { ...@@ -415,19 +417,19 @@ describe('HypothesisApp', () => {
); );
}); });
it('does not send LOGOUT_REQUESTED if the user cancels the prompt', () => { it('does not send LOGOUT_REQUESTED if the user cancels the prompt', async () => {
fakeStore.countDrafts.returns(1); fakeStore.countDrafts.returns(1);
window.confirm.returns(false); fakeConfirm.returns(false);
const wrapper = createComponent(); const wrapper = createComponent();
clickLogOut(wrapper); await clickLogOut(wrapper);
assert.notCalled(fakeBridge.call); assert.notCalled(fakeBridge.call);
}); });
it('does not call session.logout()', () => { it('does not call session.logout()', async () => {
const wrapper = createComponent(); const wrapper = createComponent();
clickLogOut(wrapper); await clickLogOut(wrapper);
assert.notCalled(fakeSession.logout); assert.notCalled(fakeSession.logout);
}); });
}); });
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment