diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/__snapshots__/index.test.jsx.snap b/src/editors/containers/ProblemEditor/components/EditProblemView/__snapshots__/index.test.jsx.snap index e4f96de36..26398552e 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/__snapshots__/index.test.jsx.snap +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/__snapshots__/index.test.jsx.snap @@ -4,6 +4,49 @@ exports[`EditorProblemView component renders raw editor 1`] = ` + + + + + } + isOpen={false} + onClose={[Function]} + title="No answer specified" + > +
+ +
+
+ +
+
@@ -36,6 +79,49 @@ exports[`EditorProblemView component renders simple view 1`] = ` + + + + + } + isOpen={false} + onClose={[Function]} + title="No answer specified" + > +
+ +
+
+ +
+
diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/hooks.js b/src/editors/containers/ProblemEditor/components/EditProblemView/hooks.js index 39e6e3a74..c38be5e9a 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/hooks.js +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/hooks.js @@ -1,7 +1,23 @@ +import { useState } from 'react'; import 'tinymce'; +import { StrictDict } from '../../../../utils'; import ReactStateSettingsParser from '../../data/ReactStateSettingsParser'; import ReactStateOLXParser from '../../data/ReactStateOLXParser'; import { setAssetToStaticUrl } from '../../../../sharedComponents/TinyMceWidget/hooks'; +import { ProblemTypeKeys } from '../../../../data/constants/problem'; + +export const state = StrictDict({ + isNoAnswerModalOpen: (val) => useState(val), +}); + +export const noAnswerModalToggle = () => { + const [isNoAnswerModalOpen, setIsOpen] = state.isNoAnswerModalOpen(false); + return { + isNoAnswerModalOpen, + openNoAnswerModal: () => setIsOpen(true), + closeNoAnswerModal: () => setIsOpen(false), + }; +}; export const fetchEditorContent = ({ format }) => { const editorObject = { hints: [] }; @@ -52,3 +68,77 @@ export const parseState = ({ olx: isAdvanced ? rawOLX : reactBuiltOlx, }; }; + +export const checkForNoAnswers = ({ openNoAnswerModal, problem }) => { + const simpleTextAreaProblems = [ProblemTypeKeys.DROPDOWN, ProblemTypeKeys.NUMERIC, ProblemTypeKeys.TEXTINPUT]; + const editorObject = fetchEditorContent({ format: '' }); + const { problemType } = problem; + const { answers } = problem; + const answerTitles = simpleTextAreaProblems.includes(problemType) ? {} : editorObject.answers; + + const hasTitle = () => { + const titles = []; + answers.forEach(answer => { + const title = simpleTextAreaProblems.includes(problemType) ? answer.title : answerTitles[answer.id]; + if (title.length > 0) { + titles.push(title); + } + }); + if (titles.length > 0) { + return true; + } + return false; + }; + + const hasNoCorrectAnswer = () => { + let correctAnswer; + answers.forEach(answer => { + if (answer.correct) { + const title = simpleTextAreaProblems.includes(problemType) ? answer.title : answerTitles[answer.id]; + if (title.length > 0) { + correctAnswer = true; + } + } + }); + if (correctAnswer) { + return true; + } + return false; + }; + + if (problemType === ProblemTypeKeys.NUMERIC && !hasTitle()) { + openNoAnswerModal(); + return true; + } + if (!hasNoCorrectAnswer()) { + openNoAnswerModal(); + return true; + } + return false; +}; + +export const getContent = ({ + problemState, + openNoAnswerModal, + isAdvancedProblemType, + editorRef, + assets, + lmsEndpointUrl, +}) => { + const problem = problemState; + const hasNoAnswers = checkForNoAnswers({ + problem, + openNoAnswerModal, + }); + if (!hasNoAnswers) { + const data = parseState({ + isAdvanced: isAdvancedProblemType, + ref: editorRef, + problem, + assets, + lmsEndpointUrl, + })(); + return data; + } + return null; +}; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/hooks.test.js b/src/editors/containers/ProblemEditor/components/EditProblemView/hooks.test.js index 810049adb..7906566b6 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/hooks.test.js +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/hooks.test.js @@ -1,8 +1,13 @@ +import { ProblemTypeKeys } from '../../../../data/constants/problem'; import * as hooks from './hooks'; +import { MockUseState } from '../../../../../testUtils'; const mockRawOLX = 'rawOLX'; const mockBuiltOLX = 'builtOLX'; +const toStringMock = () => mockRawOLX; +const refMock = { current: { state: { doc: { toString: toStringMock } } } }; + jest.mock('../../data/ReactStateOLXParser', () => ( jest.fn().mockImplementation(() => ({ buildOLX: () => mockBuiltOLX, @@ -10,6 +15,44 @@ jest.mock('../../data/ReactStateOLXParser', () => ( )); jest.mock('../../data/ReactStateSettingsParser'); +const hookState = new MockUseState(hooks); + +describe('noAnswerModalToggle', () => { + const hookKey = hookState.keys.isNoAnswerModalOpen; + beforeEach(() => { + jest.clearAllMocks(); + }); + describe('state hook', () => { + hookState.testGetter(hookKey); + }); + describe('using state', () => { + beforeEach(() => { + hookState.mock(); + }); + afterEach(() => { + hookState.restore(); + }); + + describe('noAnswerModalToggle', () => { + let hook; + beforeEach(() => { + hook = hooks.noAnswerModalToggle(); + }); + test('isNoAnswerModalOpen: state value', () => { + expect(hook.isNoAnswerModalOpen).toEqual(hookState.stateVals[hookKey]); + }); + test('openCancelConfirmModal: calls setter with true', () => { + hook.openNoAnswerModal(); + expect(hookState.setState[hookKey]).toHaveBeenCalledWith(true); + }); + test('closeCancelConfirmModal: calls setter with false', () => { + hook.closeNoAnswerModal(); + expect(hookState.setState[hookKey]).toHaveBeenCalledWith(false); + }); + }); + }); +}); + describe('EditProblemView hooks parseState', () => { describe('fetchEditorContent', () => { const getContent = () => '

testString

'; @@ -50,9 +93,6 @@ describe('EditProblemView hooks parseState', () => { }); }); describe('parseState', () => { - const toStringMock = () => mockRawOLX; - const refMock = { current: { state: { doc: { toString: toStringMock } } } }; - test('default problem', () => { const res = hooks.parseState({ problem: 'problem', @@ -72,4 +112,117 @@ describe('EditProblemView hooks parseState', () => { expect(res.olx).toBe(mockRawOLX); }); }); + describe('checkNoAnswers', () => { + const openNoAnswerModal = jest.fn(); + describe('hasNoTitle', () => { + const problem = { + problemType: ProblemTypeKeys.NUMERIC, + }; + beforeEach(() => { + jest.clearAllMocks(); + }); + it('returns true for numerical problem with empty title', () => { + const expected = hooks.checkForNoAnswers({ + openNoAnswerModal, + problem: { + ...problem, + answers: [{ id: 'A', title: '', correct: true }], + }, + }); + expect(openNoAnswerModal).toHaveBeenCalled(); + expect(expected).toEqual(true); + }); + it('returns false for numerical problem with title', () => { + const expected = hooks.checkForNoAnswers({ + openNoAnswerModal, + problem: { + ...problem, + answers: [{ id: 'A', title: 'sOmevALUe', correct: true }], + }, + }); + expect(openNoAnswerModal).not.toHaveBeenCalled(); + expect(expected).toEqual(false); + }); + }); + describe('hasNoCorrectAnswer', () => { + const problem = { + problemType: ProblemTypeKeys.SINGLESELECT, + }; + beforeEach(() => { + jest.clearAllMocks(); + }); + it('returns true for single select problem with empty title', () => { + window.tinymce.editors = { 'answer-A': { getContent: () => '' }, 'answer-B': { getContent: () => 'sOmevALUe' } }; + const expected = hooks.checkForNoAnswers({ + openNoAnswerModal, + problem: { + ...problem, + answers: [{ id: 'A', title: '', correct: true }, { id: 'B', title: 'sOmevALUe', correct: false }], + }, + }); + expect(openNoAnswerModal).toHaveBeenCalled(); + expect(expected).toEqual(true); + }); + it('returns true for single select with title but no correct answer', () => { + window.tinymce.editors = { 'answer-A': { getContent: () => 'sOmevALUe' } }; + const expected = hooks.checkForNoAnswers({ + openNoAnswerModal, + problem: { + ...problem, + answers: [{ id: 'A', title: 'sOmevALUe', correct: false }, { id: 'B', title: '', correct: false }], + }, + }); + expect(openNoAnswerModal).toHaveBeenCalled(); + expect(expected).toEqual(true); + }); + it('returns true for single select with title and correct answer', () => { + window.tinymce.editors = { 'answer-A': { getContent: () => 'sOmevALUe' } }; + const expected = hooks.checkForNoAnswers({ + openNoAnswerModal, + problem: { + ...problem, + answers: [{ id: 'A', title: 'sOmevALUe', correct: true }], + }, + }); + expect(openNoAnswerModal).not.toHaveBeenCalled(); + expect(expected).toEqual(false); + }); + }); + }); + describe('getContent', () => { + const problemState = { problemType: ProblemTypeKeys.NUMERIC, answers: [{ id: 'A', title: 'problem', correct: true }] }; + const isAdvancedProblem = false; + const assets = {}; + const lmsEndpointUrl = 'someUrl'; + const editorRef = refMock; + const openNoAnswerModal = jest.fn(); + + test('default save and returns parseState data', () => { + const content = hooks.getContent({ + problemState, + isAdvancedProblem, + editorRef, + assets, + lmsEndpointUrl, + openNoAnswerModal, + }); + expect(content).toEqual({ + olx: 'builtOLX', + settings: undefined, + }); + }); + test('returns null', () => { + const problem = { ...problemState, answers: [{ id: 'A', title: '', correct: true }] }; + const content = hooks.getContent({ + problemState: problem, + isAdvancedProblem, + editorRef, + assets, + lmsEndpointUrl, + openNoAnswerModal, + }); + expect(openNoAnswerModal).toHaveBeenCalled(); + expect(content).toEqual(null); + }); + }); }); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/index.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/index.jsx index 1c9bdb49e..f5ac4ab04 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/index.jsx +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/index.jsx @@ -1,7 +1,14 @@ import React, { useRef } from 'react'; import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; -import { Container } from '@edx/paragon'; +import { connect, useDispatch } from 'react-redux'; +import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n'; + +import { + Container, + Button, + AlertModal, + ActionRow, +} from '@edx/paragon'; import AnswerWidget from './AnswerWidget'; import SettingsWidget from './SettingsWidget'; import QuestionWidget from './QuestionWidget'; @@ -10,9 +17,12 @@ import { selectors } from '../../../../data/redux'; import RawEditor from '../../../../sharedComponents/RawEditor'; import { ProblemTypeKeys } from '../../../../data/constants/problem'; -import { parseState } from './hooks'; +import { parseState, noAnswerModalToggle, getContent } from './hooks'; import './index.scss'; +import messages from './messages'; + import ExplanationWidget from './ExplanationWidget'; +import { saveBlock } from '../../../../hooks'; export const EditProblemView = ({ // redux @@ -20,20 +30,62 @@ export const EditProblemView = ({ problemState, assets, lmsEndpointUrl, + returnUrl, + analytics, + // injected + intl, }) => { const editorRef = useRef(null); const isAdvancedProblemType = problemType === ProblemTypeKeys.ADVANCED; - - const getContent = parseState({ - problem: problemState, - isAdvanced: isAdvancedProblemType, - ref: editorRef, - assets, - lmsEndpointUrl, - }); + const { isNoAnswerModalOpen, openNoAnswerModal, closeNoAnswerModal } = noAnswerModalToggle(); + const dispatch = useDispatch(); return ( - + getContent({ + problemState, + openNoAnswerModal, + isAdvancedProblemType, + editorRef, + assets, + lmsEndpointUrl, + })} + > + + + + + )} + > +
+ +
+
+ +
+
{isAdvancedProblemType ? ( @@ -64,14 +116,20 @@ EditProblemView.propTypes = { // eslint-disable-next-line problemState: PropTypes.any.isRequired, assets: PropTypes.shape({}), + analytics: PropTypes.shape({}).isRequired, lmsEndpointUrl: PropTypes.string, + returnUrl: PropTypes.string.isRequired, + // injected + intl: intlShape.isRequired, }; export const mapStateToProps = (state) => ({ assets: selectors.app.assets(state), + analytics: selectors.app.analytics(state), lmsEndpointUrl: selectors.app.lmsEndpointUrl(state), + returnUrl: selectors.app.returnUrl(state), problemType: selectors.problem.problemType(state), problemState: selectors.problem.completeState(state), }); -export default connect(mapStateToProps)(EditProblemView); +export default injectIntl(connect(mapStateToProps)(EditProblemView)); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/index.test.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/index.test.jsx index 394296a96..b3198a9e6 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/index.test.jsx +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/index.test.jsx @@ -4,6 +4,7 @@ import { EditProblemView } from '.'; import AnswerWidget from './AnswerWidget'; import { ProblemTypeKeys } from '../../../../data/constants/problem'; import RawEditor from '../../../../sharedComponents/RawEditor'; +import { formatMessage } from '../../../../../testUtils'; describe('EditorProblemView component', () => { test('renders simple view', () => { @@ -11,6 +12,7 @@ describe('EditorProblemView component', () => { problemType={ProblemTypeKeys.SINGLESELECT} problemState={{}} assets={{}} + intl={{ formatMessage }} />); expect(wrapper).toMatchSnapshot(); expect(wrapper.find(AnswerWidget).length).toBe(1); @@ -18,7 +20,12 @@ describe('EditorProblemView component', () => { }); test('renders raw editor', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); expect(wrapper.find(AnswerWidget).length).toBe(0); expect(wrapper.find(RawEditor).length).toBe(1); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/messages.js b/src/editors/containers/ProblemEditor/components/EditProblemView/messages.js new file mode 100644 index 000000000..9685ff77c --- /dev/null +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/messages.js @@ -0,0 +1,31 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + noAnswerCancelButtonLabel: { + id: 'authoring.problemEditor.editProblemView.noAnswerModal.cancelButton.label', + defaultMessage: 'Cancel', + description: 'Label for cancel button in the no answer modal', + }, + noAnswerSaveButtonLabel: { + id: 'authoring.problemEditor.editProblemView.noAnswerModal.saveButton.label', + defaultMessage: 'Ok', + description: 'Label for save button in the no answer modal', + }, + noAnswerModalTitle: { + id: 'authoring.problemEditor.editProblemView.noAnswerModal.title', + defaultMessage: 'No answer specified', + description: 'Title for no answer modal', + }, + noAnswerModalBodyQuestion: { + id: 'authoring.problemEditor.editProblemView.noAnswerModal.body.question', + defaultMessage: 'Are you sure you want to exit the editor?', + description: 'Question in body of no answer modal', + }, + noAnswerModalBodyExplanation: { + id: 'authoring.problemEditor.editProblemView.noAnswerModal.body.explanation', + defaultMessage: 'No correct answer has been specified.', + description: 'Explanation in body of no answer modal', + }, +}); + +export default messages; diff --git a/src/editors/hooks.js b/src/editors/hooks.js index 93d927224..b9c99e617 100644 --- a/src/editors/hooks.js +++ b/src/editors/hooks.js @@ -36,6 +36,9 @@ export const saveBlock = ({ dispatch, validateEntry, }) => { + if (!content) { + return; + } let attemptSave = false; if (validateEntry) { if (validateEntry()) { diff --git a/src/editors/hooks.test.jsx b/src/editors/hooks.test.jsx index 0098b9afb..18e19f096 100644 --- a/src/editors/hooks.test.jsx +++ b/src/editors/hooks.test.jsx @@ -109,13 +109,27 @@ describe('hooks', () => { }); describe('saveBlock', () => { - it('dispatches thunkActions.app.saveBlock with navigateCallback, and passed content', () => { - const navigateCallback = (args) => ({ navigateCallback: args }); - const dispatch = jest.fn(); - const destination = 'uRLwhENsAved'; - const analytics = 'dATAonEveNT'; - const content = 'myContent'; + const navigateCallback = (args) => ({ navigateCallback: args }); + const dispatch = jest.fn(); + const destination = 'uRLwhENsAved'; + const analytics = 'dATAonEveNT'; + + beforeEach(() => { + jest.clearAllMocks(); jest.spyOn(hooks, hookKeys.navigateCallback).mockImplementationOnce(navigateCallback); + }); + it('returns null when content is null', () => { + const content = null; + const expected = hooks.saveBlock({ + content, + destination, + analytics, + dispatch, + }); + expect(expected).toEqual(undefined); + }); + it('dispatches thunkActions.app.saveBlock with navigateCallback, and passed content', () => { + const content = 'myContent'; hooks.saveBlock({ content, destination,