From 9ba0da04c3e66faf187db4bc70c1913ac5b4db5b Mon Sep 17 00:00:00 2001 From: Ken Clary Date: Tue, 20 Dec 2022 12:05:11 -0500 Subject: [PATCH] feat: confirmation dialog for closing any editor. TNL-10280. --- .../__snapshots__/index.test.jsx.snap | 102 ++++++++++++------ .../containers/EditorContainer/hooks.js | 22 +++- .../containers/EditorContainer/hooks.test.jsx | 46 +++++++- .../containers/EditorContainer/index.jsx | 40 +++++-- .../containers/EditorContainer/index.test.jsx | 23 ++-- .../containers/EditorContainer/messages.js | 19 ++++ .../SelectTypeWrapper/index.jsx | 6 +- .../SelectTypeWrapper/index.test.jsx | 6 +- 8 files changed, 203 insertions(+), 61 deletions(-) create mode 100644 src/editors/containers/EditorContainer/messages.js diff --git a/src/editors/containers/EditorContainer/__snapshots__/index.test.jsx.snap b/src/editors/containers/EditorContainer/__snapshots__/index.test.jsx.snap index abc5ee84b..368526098 100644 --- a/src/editors/containers/EditorContainer/__snapshots__/index.test.jsx.snap +++ b/src/editors/containers/EditorContainer/__snapshots__/index.test.jsx.snap @@ -1,7 +1,40 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`EditorContainer component render snapshot: initialized. enable save and pass to header 1`] = ` -
+
+ + + + } + footerAction={null} + isOpen={false} + size="md" + title="Exit the editor?" + > + + @@ -23,13 +56,7 @@ exports[`EditorContainer component render snapshot: initialized. enable save and >
@@ -40,13 +67,7 @@ exports[`EditorContainer component render snapshot: initialized. enable save and +
+ + + + } + footerAction={null} + isOpen={false} + size="md" + title="Exit the editor?" + > + + @@ -83,13 +137,7 @@ exports[`EditorContainer component render snapshot: not initialized. disable sav >
@@ -97,13 +145,7 @@ exports[`EditorContainer component render snapshot: not initialized. disable sav 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 }) )); diff --git a/src/editors/containers/EditorContainer/hooks.test.jsx b/src/editors/containers/EditorContainer/hooks.test.jsx index 098302fba..01b9412a7 100644 --- a/src/editors/containers/EditorContainer/hooks.test.jsx +++ b/src/editors/containers/EditorContainer/hooks.test.jsx @@ -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(); }); }); diff --git a/src/editors/containers/EditorContainer/index.jsx b/src/editors/containers/EditorContainer/index.jsx index b8e5fc9f4..21d7edd2b 100644 --- a/src/editors/containers/EditorContainer/index.jsx +++ b/src/editors/containers/EditorContainer/index.jsx @@ -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 ( -
+
+ + + + )} + isOpen={isCancelConfirmOpen} + close={closeCancelConfirmModal} + title={intl.formatMessage(messages.cancelConfirmTitle)} + > + +
{isInitialized && children} ({ 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({testContent}); }); - 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); }); }); diff --git a/src/editors/containers/EditorContainer/messages.js b/src/editors/containers/EditorContainer/messages.js new file mode 100644 index 000000000..c9eb65dce --- /dev/null +++ b/src/editors/containers/EditorContainer/messages.js @@ -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; diff --git a/src/editors/containers/ProblemEditor/components/SelectTypeModal/SelectTypeWrapper/index.jsx b/src/editors/containers/ProblemEditor/components/SelectTypeModal/SelectTypeWrapper/index.jsx index b8c63786d..edc54d6c4 100644 --- a/src/editors/containers/ProblemEditor/components/SelectTypeModal/SelectTypeWrapper/index.jsx +++ b/src/editors/containers/ProblemEditor/components/SelectTypeModal/SelectTypeWrapper/index.jsx @@ -12,7 +12,7 @@ export const SelectTypeWrapper = ({ onClose, selected, }) => { - const handleCancelClicked = hooks.handleCancelClicked({ onClose }); + const handleCancel = hooks.handleCancel({ onClose }); return (
@@ -23,7 +23,7 @@ export const SelectTypeWrapper = ({
@@ -33,7 +33,7 @@ export const SelectTypeWrapper = ({
); diff --git a/src/editors/containers/ProblemEditor/components/SelectTypeModal/SelectTypeWrapper/index.test.jsx b/src/editors/containers/ProblemEditor/components/SelectTypeModal/SelectTypeWrapper/index.test.jsx index 342d06ced..e5e6c2c49 100644 --- a/src/editors/containers/ProblemEditor/components/SelectTypeModal/SelectTypeWrapper/index.test.jsx +++ b/src/editors/containers/ProblemEditor/components/SelectTypeModal/SelectTypeWrapper/index.test.jsx @@ -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(); }); 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); });