feat: confirmation dialog for closing any editor. TNL-10280.

This commit is contained in:
Ken Clary
2022-12-20 12:05:11 -05:00
parent af4cd55390
commit 9ba0da04c3
8 changed files with 203 additions and 61 deletions

View File

@@ -1,7 +1,40 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`EditorContainer component render snapshot: initialized. enable save and pass to header 1`] = `
<div>
<div
className="position-relative zindex-0"
>
<BaseModal
close={[MockFunction closeCancelConfirmModal]}
confirmAction={
<Button
onClick={
Object {
"handleCancel": Object {
"onClose": [MockFunction props.onClose],
},
}
}
variant="primary"
>
<FormattedMessage
defaultMessage="OK"
description="Label for OK button"
id="authoring.editorContainer.okButton.label"
/>
</Button>
}
footerAction={null}
isOpen={false}
size="md"
title="Exit the editor?"
>
<FormattedMessage
defaultMessage="Are you sure you want to exit the editor? Any unsaved changes will be lost."
description="Description text for modal confirming cancellation"
id="authoring.editorContainer.cancelConfirm.description"
/>
</BaseModal>
<ModalDialog.Header
className="shadow-sm zindex-10"
>
@@ -23,13 +56,7 @@ exports[`EditorContainer component render snapshot: initialized. enable save and
>
<IconButton
iconAs="Icon"
onClick={
Object {
"handleCancelClicked": Object {
"onClose": [MockFunction props.onClose],
},
}
}
onClick={[MockFunction openCancelConfirmModal]}
src={[MockFunction icons.Close]}
/>
</div>
@@ -40,13 +67,7 @@ exports[`EditorContainer component render snapshot: initialized. enable save and
</h1>
<injectIntl(ShimmedIntlComponent)
disableSave={false}
onCancel={
Object {
"handleCancelClicked": Object {
"onClose": [MockFunction props.onClose],
},
}
}
onCancel={[MockFunction openCancelConfirmModal]}
onSave={
Object {
"handleSaveClicked": Object {
@@ -61,7 +82,40 @@ exports[`EditorContainer component render snapshot: initialized. enable save and
`;
exports[`EditorContainer component render snapshot: not initialized. disable save and pass to header 1`] = `
<div>
<div
className="position-relative zindex-0"
>
<BaseModal
close={[MockFunction closeCancelConfirmModal]}
confirmAction={
<Button
onClick={
Object {
"handleCancel": Object {
"onClose": [MockFunction props.onClose],
},
}
}
variant="primary"
>
<FormattedMessage
defaultMessage="OK"
description="Label for OK button"
id="authoring.editorContainer.okButton.label"
/>
</Button>
}
footerAction={null}
isOpen={false}
size="md"
title="Exit the editor?"
>
<FormattedMessage
defaultMessage="Are you sure you want to exit the editor? Any unsaved changes will be lost."
description="Description text for modal confirming cancellation"
id="authoring.editorContainer.cancelConfirm.description"
/>
</BaseModal>
<ModalDialog.Header
className="shadow-sm zindex-10"
>
@@ -83,13 +137,7 @@ exports[`EditorContainer component render snapshot: not initialized. disable sav
>
<IconButton
iconAs="Icon"
onClick={
Object {
"handleCancelClicked": Object {
"onClose": [MockFunction props.onClose],
},
}
}
onClick={[MockFunction openCancelConfirmModal]}
src={[MockFunction icons.Close]}
/>
</div>
@@ -97,13 +145,7 @@ exports[`EditorContainer component render snapshot: not initialized. disable sav
</ModalDialog.Header>
<injectIntl(ShimmedIntlComponent)
disableSave={true}
onCancel={
Object {
"handleCancelClicked": Object {
"onClose": [MockFunction props.onClose],
},
}
}
onCancel={[MockFunction openCancelConfirmModal]}
onSave={
Object {
"handleSaveClicked": Object {

View File

@@ -1,9 +1,12 @@
import { useSelector } from 'react-redux';
import { useState } from 'react';
import { RequestKeys } from '../../data/constants/requests';
import { selectors } from '../../data/redux';
import * as appHooks from '../../hooks';
import * as module from './hooks';
import analyticsEvt from '../../data/constants/analyticsEvt';
import { StrictDict } from '../../utils';
export const {
navigateCallback,
@@ -11,6 +14,10 @@ export const {
saveBlock,
} = appHooks;
export const state = StrictDict({
isCancelConfirmModalOpen: (val) => useState(val),
});
export const handleSaveClicked = ({ dispatch, getContent, validateEntry }) => {
const destination = useSelector(selectors.app.returnUrl);
const analytics = useSelector(selectors.app.analytics);
@@ -23,7 +30,16 @@ export const handleSaveClicked = ({ dispatch, getContent, validateEntry }) => {
validateEntry,
});
};
export const handleCancelClicked = ({ onClose }) => {
export const cancelConfirmModalToggle = () => {
const [isCancelConfirmOpen, setIsOpen] = module.state.isCancelConfirmModalOpen(false);
return {
isCancelConfirmOpen,
openCancelConfirmModal: () => setIsOpen(true),
closeCancelConfirmModal: () => setIsOpen(false),
};
};
export const handleCancel = ({ onClose }) => {
if (onClose) {
return onClose;
}
@@ -34,6 +50,6 @@ export const handleCancelClicked = ({ onClose }) => {
});
};
export const isInitialized = () => useSelector(selectors.app.isInitialized);
export const saveFailed = () => useSelector((state) => (
selectors.requests.isFailed(state, { requestKey: RequestKeys.saveBlock })
export const saveFailed = () => useSelector((rootState) => (
selectors.requests.isFailed(rootState, { requestKey: RequestKeys.saveBlock })
));

View File

@@ -1,4 +1,5 @@
import * as reactRedux from 'react-redux';
import { MockUseState } from '../../../testUtils';
import { RequestKeys } from '../../data/constants/requests';
import { selectors } from '../../data/redux';
@@ -7,6 +8,8 @@ import * as appHooks from '../../hooks';
import * as hooks from './hooks';
import analyticsEvt from '../../data/constants/analyticsEvt';
const hookState = new MockUseState(hooks);
jest.mock('../../data/redux', () => ({
selectors: {
app: {
@@ -67,9 +70,46 @@ describe('EditorContainer hooks', () => {
});
});
});
describe('handleCancelClicked', () => {
describe('cancelConfirmModalToggle', () => {
const hookKey = hookState.keys.isCancelConfirmModalOpen;
beforeEach(() => {
jest.clearAllMocks();
});
describe('state hook', () => {
hookState.testGetter(hookKey);
});
describe('using state', () => {
beforeEach(() => {
hookState.mock();
});
afterEach(() => {
hookState.restore();
});
describe('cancelConfirmModalToggle', () => {
let hook;
beforeEach(() => {
hook = hooks.cancelConfirmModalToggle();
});
test('isCancelConfirmOpen: state value', () => {
expect(hook.isCancelConfirmOpen).toEqual(hookState.stateVals[hookKey]);
});
test('openCancelConfirmModal: calls setter with true', () => {
hook.openCancelConfirmModal();
expect(hookState.setState[hookKey]).toHaveBeenCalledWith(true);
});
test('closeCancelConfirmModal: calls setter with false', () => {
hook.closeCancelConfirmModal();
expect(hookState.setState[hookKey]).toHaveBeenCalledWith(false);
});
});
});
});
describe('handleCancel', () => {
it('calls navigateCallback to returnUrl if onClose is not passed', () => {
expect(hooks.handleCancelClicked({})).toEqual(
expect(hooks.handleCancel({})).toEqual(
appHooks.navigateCallback({
destination: reactRedux.useSelector(selectors.app.returnUrl),
analyticsEvent: analyticsEvt.editorCancelClick,
@@ -79,7 +119,7 @@ describe('EditorContainer hooks', () => {
});
it('calls onClose and not navigateCallback if onClose is passed', () => {
const onClose = () => 'my close value';
expect(hooks.handleCancelClicked({ onClose })).toEqual(onClose);
expect(hooks.handleCancel({ onClose })).toEqual(onClose);
expect(appHooks.navigateCallback).not.toHaveBeenCalled();
});
});

View File

@@ -2,24 +2,50 @@ import React from 'react';
import { useDispatch } from 'react-redux';
import PropTypes from 'prop-types';
import { Icon, ModalDialog, IconButton } from '@edx/paragon';
import {
Icon, ModalDialog, IconButton, Button,
} from '@edx/paragon';
import { Close } from '@edx/paragon/icons';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import EditorFooter from './components/EditorFooter';
import TitleHeader from './components/TitleHeader';
import * as hooks from './hooks';
import BaseModal from '../TextEditor/components/BaseModal';
import messages from './messages';
export const EditorContainer = ({
children,
getContent,
onClose,
validateEntry,
// injected
intl,
}) => {
const dispatch = useDispatch();
const isInitialized = hooks.isInitialized();
const handleCancelClicked = hooks.handleCancelClicked({ onClose });
const { isCancelConfirmOpen, openCancelConfirmModal, closeCancelConfirmModal } = hooks.cancelConfirmModalToggle();
const handleCancel = hooks.handleCancel({ onClose });
return (
<div>
<div
className="position-relative zindex-0"
>
<BaseModal
size="md"
confirmAction={(
<Button
variant="primary"
onClick={handleCancel}
>
<FormattedMessage {...messages.okButtonLabel} />
</Button>
)}
isOpen={isCancelConfirmOpen}
close={closeCancelConfirmModal}
title={intl.formatMessage(messages.cancelConfirmTitle)}
>
<FormattedMessage {...messages.cancelConfirmDescription} />
</BaseModal>
<ModalDialog.Header className="shadow-sm zindex-10">
<ModalDialog.Title>
<div
@@ -31,14 +57,14 @@ export const EditorContainer = ({
<IconButton
src={Close}
iconAs={Icon}
onClick={handleCancelClicked}
onClick={openCancelConfirmModal}
/>
</div>
</ModalDialog.Title>
</ModalDialog.Header>
{isInitialized && children}
<EditorFooter
onCancel={handleCancelClicked}
onCancel={openCancelConfirmModal}
onSave={hooks.handleSaveClicked({ dispatch, getContent, validateEntry })}
disableSave={!isInitialized}
saveFailed={hooks.saveFailed()}
@@ -55,6 +81,8 @@ EditorContainer.propTypes = {
getContent: PropTypes.func.isRequired,
onClose: PropTypes.func,
validateEntry: PropTypes.func,
// injected
intl: intlShape.isRequired,
};
export default EditorContainer;
export default injectIntl(EditorContainer);

View File

@@ -1,21 +1,28 @@
import { IconButton } from '@edx/paragon';
import { shallow } from 'enzyme';
import { useDispatch } from 'react-redux';
import { EditorContainer } from '.';
import * as hooks from './hooks';
import { formatMessage } from '../../../testUtils';
const props = {
getContent: jest.fn().mockName('props.getContent'),
onClose: jest.fn().mockName('props.onClose'),
validateEntry: jest.fn().mockName('props.validateEntry'),
// inject
intl: { formatMessage },
};
jest.mock('./hooks', () => ({
isInitialized: jest.fn().mockReturnValue(true),
handleCancelClicked: (args) => ({ handleCancelClicked: args }),
handleCancel: (args) => ({ handleCancel: args }),
handleSaveClicked: (args) => ({ handleSaveClicked: args }),
saveFailed: jest.fn().mockName('hooks.saveFailed'),
cancelConfirmModalToggle: jest.fn(() => ({
isCancelConfirmOpen: false,
openCancelConfirmModal: jest.fn().mockName('openCancelConfirmModal'),
closeCancelConfirmModal: jest.fn().mockName('closeCancelConfirmModal'),
})),
}));
let el;
@@ -39,23 +46,13 @@ describe('EditorContainer component', () => {
el = shallow(<EditorContainer {...props}>{testContent}</EditorContainer>);
});
test('close behavior is linked to modal onClose', () => {
const expected = hooks.handleCancelClicked({ onClose: props.onClose });
expect(el.find(IconButton)
.props().onClick).toEqual(expected);
});
test('close behavior is linked to footer onCancel', () => {
const expected = hooks.handleCancelClicked({ onClose: props.onClose });
expect(el.children().at(2)
.props().onCancel).toEqual(expected);
});
test('save behavior is linked to footer onSave', () => {
const expected = hooks.handleSaveClicked({
dispatch: useDispatch(),
getContent: props.getContent,
validateEntry: props.validateEntry,
});
expect(el.children().at(2)
expect(el.children().at(3)
.props().onSave).toEqual(expected);
});
});

View File

@@ -0,0 +1,19 @@
export const messages = {
cancelConfirmTitle: {
id: 'authoring.editorContainer.cancelConfirm.title',
defaultMessage: 'Exit the editor?',
description: 'Label for modal confirming cancellation',
},
cancelConfirmDescription: {
id: 'authoring.editorContainer.cancelConfirm.description',
defaultMessage: 'Are you sure you want to exit the editor? Any unsaved changes will be lost.',
description: 'Description text for modal confirming cancellation',
},
okButtonLabel: {
id: 'authoring.editorContainer.okButton.label',
defaultMessage: 'OK',
description: 'Label for OK button',
},
};
export default messages;

View File

@@ -12,7 +12,7 @@ export const SelectTypeWrapper = ({
onClose,
selected,
}) => {
const handleCancelClicked = hooks.handleCancelClicked({ onClose });
const handleCancel = hooks.handleCancel({ onClose });
return (
<div>
@@ -23,7 +23,7 @@ export const SelectTypeWrapper = ({
<IconButton
src={Close}
iconAs={Icon}
onClick={handleCancelClicked}
onClick={handleCancel}
/>
</div>
</ModalDialog.Title>
@@ -33,7 +33,7 @@ export const SelectTypeWrapper = ({
</ModalDialog.Body>
<SelectTypeFooter
selected={selected}
onCancel={handleCancelClicked}
onCancel={handleCancel}
/>
</div>
);

View File

@@ -2,10 +2,10 @@ import React from 'react';
import { shallow } from 'enzyme';
import { IconButton } from '@edx/paragon';
import * as module from '.';
import { handleCancelClicked } from '../../../../EditorContainer/hooks';
import { handleCancel } from '../../../../EditorContainer/hooks';
jest.mock('../../../../EditorContainer/hooks', () => ({
handleCancelClicked: jest.fn().mockName('handleCancelClicked'),
handleCancel: jest.fn().mockName('handleCancel'),
}));
describe('SelectTypeWrapper', () => {
@@ -25,7 +25,7 @@ describe('SelectTypeWrapper', () => {
el = shallow(<module.SelectTypeWrapper {...props} />);
});
test('close behavior is linked to modal onClose', () => {
const expected = handleCancelClicked({ onClose: props.onClose });
const expected = handleCancel({ onClose: props.onClose });
expect(el.find(IconButton).props().onClick)
.toEqual(expected);
});