diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.test.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.test.jsx index 5df664cea..59a0be48b 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.test.jsx +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.test.jsx @@ -7,6 +7,7 @@ import { AnswerOption, mapStateToProps } from './AnswerOption'; jest.mock('../../../../../data/redux', () => ({ selectors: { problem: { + answers: jest.fn(state => ({ answers: state })), problemType: jest.fn(state => ({ problemType: state })), }, }, diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/hooks.js b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/hooks.js index 3b6366f13..ab6481831 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/hooks.js +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/hooks.js @@ -9,8 +9,15 @@ export const state = StrictDict({ isFeedbackVisible: (val) => useState(val), }); -export const removeAnswer = ({ answer, dispatch }) => () => { - dispatch(actions.problem.deleteAnswer({ id: answer.id, correct: answer.correct })); +export const removeAnswer = ({ + answer, + dispatch, +}) => () => { + dispatch(actions.problem.deleteAnswer({ + id: answer.id, + correct: answer.correct, + editorState: fetchEditorContent({ format: '' }), + })); }; export const setAnswer = ({ answer, hasSingleAnswer, dispatch }) => (payload) => { @@ -52,7 +59,7 @@ export const useFeedback = (answer) => { // Show feedback fields if feedback is present const isVisible = !!answer.selectedFeedback || !!answer.unselectedFeedback; setIsFeedbackVisible(isVisible); - }, []); + }, [answer]); const toggleFeedback = (open) => { // Do not allow to hide if feedback is added diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/hooks.test.js b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/hooks.test.js index 6fce1220a..6aa791c6a 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/hooks.test.js +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/hooks.test.js @@ -13,11 +13,9 @@ jest.mock('react', () => { useState: jest.fn(val => ([{ state: val }, (newVal) => updateState({ val, newVal })])), }; }); - jest.mock('@edx/frontend-platform/i18n', () => ({ defineMessages: m => m, })); - jest.mock('../../../../../data/redux', () => ({ actions: { problem: { @@ -36,20 +34,37 @@ const answerWithOnlyFeedback = { correct: true, selectedFeedback: 'some feedback', }; +let windowSpy; describe('Answer Options Hooks', () => { - beforeEach(() => { jest.clearAllMocks(); }); + beforeEach(() => { + jest.clearAllMocks(); + }); describe('state hooks', () => { state.testGetter(state.keys.isFeedbackVisible); }); describe('removeAnswer', () => { - test('it dispatches actions.problem.deleteAnswer', () => { - const answer = { id: 'A', correct: false }; - const dispatch = useDispatch(); - module.removeAnswer({ answer, dispatch })(); + beforeEach(() => { + windowSpy = jest.spyOn(window, 'window', 'get'); + }); + afterEach(() => { + windowSpy.mockRestore(); + }); + const answer = { id: 'A', correct: false }; + const dispatch = useDispatch(); + it('dispatches actions.problem.deleteAnswer', () => { + windowSpy.mockImplementation(() => ({ tinymce: { editors: { 'answer-A': { getContent: () => 'string' } } } })); + module.removeAnswer({ + answer, + dispatch, + })(); expect(dispatch).toHaveBeenCalledWith(actions.problem.deleteAnswer({ id: answer.id, correct: answer.correct, + editorState: { + answers: { A: 'string' }, + hints: [], + }, })); }); }); @@ -134,9 +149,15 @@ describe('Answer Options Hooks', () => { }); }); describe('useFeedback hook', () => { - beforeEach(() => { state.mock(); }); - afterEach(() => { state.restore(); }); - test('test default state is false', () => { + beforeEach(() => { + state.mock(); + windowSpy = jest.spyOn(window, 'window', 'get'); + }); + afterEach(() => { + state.restore(); + windowSpy.mockRestore(); + }); + test('default state is false', () => { output = module.useFeedback(answerWithOnlyFeedback); expect(output.isFeedbackVisible).toBeFalsy(); }); @@ -148,24 +169,24 @@ describe('Answer Options Hooks', () => { cb(); expect(state.setState[key]).toHaveBeenCalledWith(true); }); - test('test toggleFeedback with selected feedback', () => { + test('toggleFeedback with selected feedback', () => { const key = state.keys.isFeedbackVisible; output = module.useFeedback(answerWithOnlyFeedback); - window.tinymce.editors = { 'selectedFeedback-A': { getContent: () => 'string' } }; + windowSpy.mockImplementation(() => ({ tinymce: { editors: { 'selectedFeedback-A': { getContent: () => 'string' } } } })); output.toggleFeedback(false); expect(state.setState[key]).toHaveBeenCalledWith(true); }); - test('test toggleFeedback with unselected feedback', () => { + test('toggleFeedback with unselected feedback', () => { const key = state.keys.isFeedbackVisible; output = module.useFeedback(answerWithOnlyFeedback); - window.tinymce.editors = { 'unselectedFeedback-A': { getContent: () => 'string' } }; + windowSpy.mockImplementation(() => ({ tinymce: { editors: { 'unselectedFeedback-A': { getContent: () => 'string' } } } })); output.toggleFeedback(false); expect(state.setState[key]).toHaveBeenCalledWith(true); }); - test('test toggleFeedback with unselected feedback', () => { + test('toggleFeedback with unselected feedback', () => { const key = state.keys.isFeedbackVisible; output = module.useFeedback(answerWithOnlyFeedback); - window.tinymce.editors = { 'answer-A': { getContent: () => 'string' } }; + windowSpy.mockImplementation(() => ({ tinymce: { editors: { 'answer-A': { getContent: () => 'string' } } } })); output.toggleFeedback(false); expect(state.setState[key]).toHaveBeenCalledWith(false); }); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/hooks.js b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/hooks.js index 66ce9c6fd..fdf827b68 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/hooks.js +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/hooks.js @@ -3,7 +3,12 @@ import { useState, useEffect } from 'react'; import _ from 'lodash-es'; import * as module from './hooks'; import messages from './messages'; -import { ProblemTypeKeys, ProblemTypes, ShowAnswerTypesKeys } from '../../../../../data/constants/problem'; +import { + ProblemTypeKeys, + ProblemTypes, + RichTextProblems, + ShowAnswerTypesKeys, +} from '../../../../../data/constants/problem'; import { fetchEditorContent } from '../hooks'; export const state = { @@ -214,11 +219,9 @@ export const typeRowHooks = ({ updateField, updateAnswer, }) => { - const richTextProblems = [ProblemTypeKeys.SINGLESELECT, ProblemTypeKeys.MULTISELECT]; - const clearPreviouslySelectedAnswers = () => { let currentAnswerTitles; - if (richTextProblems.includes(problemType)) { + if (RichTextProblems.includes(problemType)) { currentAnswerTitles = fetchEditorContent({ format: 'text' }).answers; } answers.forEach(answer => { @@ -233,7 +236,7 @@ export const typeRowHooks = ({ const updateAnswersToCorrect = () => { let currentAnswerTitles; - if (richTextProblems.includes(problemType)) { + if (RichTextProblems.includes(problemType)) { currentAnswerTitles = fetchEditorContent({ format: 'text' }).answers; } answers.forEach(answer => { @@ -252,7 +255,7 @@ export const typeRowHooks = ({ const onClick = () => { // Numeric, text, and dropdowns cannot render HTML as answer values, so if switching from a single select // or multi-select problem the rich text needs to covert to plain text - if (typeKey === ProblemTypeKeys.TEXTINPUT && richTextProblems.includes(problemType)) { + if (typeKey === ProblemTypeKeys.TEXTINPUT && RichTextProblems.includes(problemType)) { convertToPlainText(); } // Dropdown problems can only have one correct answer. When there is more than one correct answer @@ -260,7 +263,7 @@ export const typeRowHooks = ({ if (typeKey === ProblemTypeKeys.DROPDOWN) { if (correctAnswerCount > 1) { clearPreviouslySelectedAnswers(); - } else if (richTextProblems.includes(problemType)) { + } else if (RichTextProblems.includes(problemType)) { convertToPlainText(); } } diff --git a/src/editors/containers/ProblemEditor/data/OLXParser.js b/src/editors/containers/ProblemEditor/data/OLXParser.js index 8c667e99e..a33c41ea8 100644 --- a/src/editors/containers/ProblemEditor/data/OLXParser.js +++ b/src/editors/containers/ProblemEditor/data/OLXParser.js @@ -630,6 +630,8 @@ export class OLXParser { } else { settings.tolerance = { value: parseInt(toleranceValue), type: 'Number' }; } + } else { + settings.tolerance = { value: null, type: 'None' }; } if (solutionExplanation) { settings.solutionExplanation = solutionExplanation; } diff --git a/src/editors/data/constants/problem.js b/src/editors/data/constants/problem.js index da4db7831..13f3e1cde 100644 --- a/src/editors/data/constants/problem.js +++ b/src/editors/data/constants/problem.js @@ -196,17 +196,16 @@ export const RandomizationTypesKeys = StrictDict({ ONRESET: 'on_reset', PERSTUDENT: 'per_student', }); + export const RandomizationTypes = StrictDict({ - [RandomizationTypesKeys.ALWAYS]: - { - id: 'authoring.problemeditor.settings.RandomizationTypes.always', - defaultMessage: 'Always', - }, - [RandomizationTypesKeys.NEVER]: - { - id: 'authoring.problemeditor.settings.RandomizationTypes.never', - defaultMessage: 'Never', - }, + [RandomizationTypesKeys.ALWAYS]: { + id: 'authoring.problemeditor.settings.RandomizationTypes.always', + defaultMessage: 'Always', + }, + [RandomizationTypesKeys.NEVER]: { + id: 'authoring.problemeditor.settings.RandomizationTypes.never', + defaultMessage: 'Never', + }, [RandomizationTypesKeys.ONRESET]: { id: 'authoring.problemeditor.settings.RandomizationTypes.onreset', defaultMessage: 'On Reset', @@ -216,3 +215,5 @@ export const RandomizationTypes = StrictDict({ defaultMessage: 'Per Student', }, }); + +export const RichTextProblems = [ProblemTypeKeys.SINGLESELECT, ProblemTypeKeys.MULTISELECT]; diff --git a/src/editors/data/redux/problem/reducers.js b/src/editors/data/redux/problem/reducers.js index 8d8cfa317..8c0683560 100644 --- a/src/editors/data/redux/problem/reducers.js +++ b/src/editors/data/redux/problem/reducers.js @@ -2,7 +2,7 @@ import _ from 'lodash-es'; import { createSlice } from '@reduxjs/toolkit'; import { indexToLetterMap } from '../../../containers/ProblemEditor/data/OLXParser'; import { StrictDict } from '../../../utils'; -import { ProblemTypeKeys, ShowAnswerTypesKeys } from '../../constants/problem'; +import { ProblemTypeKeys, RichTextProblems, ShowAnswerTypesKeys } from '../../constants/problem'; import { ToleranceTypes } from '../../../containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Tolerance/constants'; const nextAlphaId = (lastId) => String.fromCharCode(lastId.charCodeAt(0) + 1); @@ -82,41 +82,66 @@ const problem = createSlice({ }; }, deleteAnswer: (state, { payload }) => { - const { id, correct } = payload; - if (state.answers.length <= 1) { - if (state.answers.length > 0 && state.answers[0].isAnswerRange) { - return { - ...state, - correctAnswerCount: 1, - answers: [{ - id: 'A', - title: '', - selectedFeedback: '', - unselectedFeedback: '', - correct: state.problemType === ProblemTypeKeys.NUMERIC, - isAnswerRange: false, - }, - ], - }; - } - return state; - } - - let { correctAnswerCount } = state; - if (correct) { - correctAnswerCount -= 1; + const { id, correct, editorState } = payload; + const EditorsArray = window.tinymce.editors; + if (state.answers.length === 1) { + return { + ...state, + correctAnswerCount: state.problemType === ProblemTypeKeys.NUMERIC ? 1 : 0, + answers: [{ + id: 'A', + title: '', + selectedFeedback: '', + unselectedFeedback: '', + correct: state.problemType === ProblemTypeKeys.NUMERIC, + isAnswerRange: false, + }], + }; } const answers = state.answers.filter(obj => obj.id !== id).map((answer, index) => { const newId = indexToLetterMap[index]; if (answer.id === newId) { return answer; } - return { ...answer, id: newId }; + let newAnswer = { + ...answer, + id: newId, + selectedFeedback: editorState.selectedFeedback ? editorState.selectedFeedback[answer.id] : '', + unselectedFeedback: editorState.unselectedFeedback ? editorState.unselectedFeedback[answer.id] : '', + }; + if (RichTextProblems.includes(state.problemType)) { + newAnswer = { + ...newAnswer, + title: editorState.answers[answer.id], + }; + if (EditorsArray[`answer-${newId}`]) { + EditorsArray[`answer-${newId}`].setContent(newAnswer.title ?? ''); + } + } + // Note: The following assumes selectedFeedback and unselectedFeedback is using ExpandedTextArea + // Content only needs to be set here when the 'next' feedback fields are shown. + if (EditorsArray[`selectedFeedback-${newId}`]) { + EditorsArray[`selectedFeedback-${newId}`].setContent(newAnswer.selectedFeedback ?? ''); + } + if (EditorsArray[`unselectedFeedback-${newId}`]) { + EditorsArray[`unselectedFeedback-${newId}`].setContent(newAnswer.unselectedFeedback ?? ''); + } + return newAnswer; + }); + const groupFeedbackList = state.groupFeedbackList.map(feedback => { + const newAnswers = feedback.answers.filter(obj => obj !== id).map(letter => { + if (letter.charCodeAt(0) > id.charCodeAt(0)) { + return String.fromCharCode(letter.charCodeAt(0) - 1); + } + return letter; + }); + return { ...feedback, answers: newAnswers }; }); return { ...state, - correctAnswerCount, answers, + correctAnswerCount: correct ? state.correctAnswerCount - 1 : state.correctAnswerCount, + groupFeedbackList, }; }, addAnswer: (state) => { diff --git a/src/editors/data/redux/problem/reducers.test.js b/src/editors/data/redux/problem/reducers.test.js index 4466eb852..b503206de 100644 --- a/src/editors/data/redux/problem/reducers.test.js +++ b/src/editors/data/redux/problem/reducers.test.js @@ -118,7 +118,6 @@ describe('problem reducer', () => { }); }); }); - describe('addAnswerRange', () => { const answerRange = { id: 'A', @@ -137,7 +136,6 @@ describe('problem reducer', () => { }); }); }); - describe('updateAnswer', () => { it('sets answers, as well as setting the correctAnswerCount ', () => { const answer = { id: 'A', correct: true }; @@ -158,63 +156,287 @@ describe('problem reducer', () => { }); }); describe('deleteAnswer', () => { - it('sets answers, as well as setting the correctAnswerCount ', () => { - const answer = { id: 'A', correct: false }; + let windowSpy; + beforeEach(() => { + windowSpy = jest.spyOn(window, 'window', 'get'); + }); + afterEach(() => { + windowSpy.mockRestore(); + }); + it('sets a default when deleting the last answer', () => { + windowSpy.mockImplementation(() => ({ + tinymce: { + editors: 'mock-editors', + }, + })); + const payload = { + id: 'A', + correct: false, + editorState: 'empty', + }; + expect(reducer( + { + ...testingState, + correctAnswerCount: 0, + answers: [{ id: 'A', correct: false }], + }, + actions.deleteAnswer(payload), + )).toEqual({ + ...testingState, + correctAnswerCount: 0, + answers: [{ + id: 'A', + title: '', + selectedFeedback: '', + unselectedFeedback: '', + correct: false, + isAnswerRange: false, + }], + }); + }); + it('sets answers and correctAnswerCount', () => { + windowSpy.mockImplementation(() => ({ + tinymce: { + editors: 'mock-editors', + }, + })); + const payload = { + id: 'A', + correct: false, + editorState: { + answers: { A: 'mockA' }, + }, + }; expect(reducer( { ...testingState, correctAnswerCount: 1, - answers: [{ - id: 'A', - correct: false, - }, - { - id: 'B', - correct: true, - }], + answers: [ + { id: 'A', correct: false }, + { id: 'B', correct: true }, + ], }, - actions.deleteAnswer(answer), + actions.deleteAnswer(payload), )).toEqual({ ...testingState, correctAnswerCount: 1, - answers: [ - { - id: 'A', - correct: true, + answers: [{ + id: 'A', + correct: true, + selectedFeedback: '', + unselectedFeedback: '', + }], + }); + }); + it('sets answers and correctAnswerCount with editorState for RichTextProblems', () => { + const setContent = jest.fn(); + windowSpy.mockImplementation(() => ({ + tinymce: { + editors: { + 'answer-A': { setContent }, + 'answer-B': { setContent }, + }, + }, + })); + const payload = { + id: 'A', + correct: false, + editorState: { + answers: { A: 'editorAnsA', B: 'editorAnsB' }, + }, + }; + expect(reducer( + { + ...testingState, + problemType: ProblemTypeKeys.SINGLESELECT, + correctAnswerCount: 1, + answers: [ + { id: 'A', correct: false }, + { id: 'B', correct: true }, + ], + }, + actions.deleteAnswer(payload), + )).toEqual({ + ...testingState, + problemType: ProblemTypeKeys.SINGLESELECT, + correctAnswerCount: 1, + answers: [{ + id: 'A', + correct: true, + title: 'editorAnsB', + selectedFeedback: '', + unselectedFeedback: '', + }], + }); + }); + it('sets selectedFeedback and unselectedFeedback with editorState', () => { + windowSpy.mockImplementation(() => ({ + tinymce: { + editors: { + 'answer-A': 'mockEditor', + 'answer-B': 'mockEditor', + }, + }, + })); + const payload = { + id: 'A', + correct: false, + editorState: { + answers: { A: 'editorAnsA', B: 'editorAnsB' }, + selectedFeedback: { A: 'editSelFA', B: 'editSelFB' }, + unselectedFeedback: { A: 'editUnselFA', B: 'editUnselFB' }, + }, + }; + expect(reducer( + { + ...testingState, + correctAnswerCount: 1, + answers: [ + { id: 'A', correct: false }, + { id: 'B', correct: true }, + ], + }, + actions.deleteAnswer(payload), + )).toEqual({ + ...testingState, + correctAnswerCount: 1, + answers: [{ + id: 'A', + correct: true, + selectedFeedback: 'editSelFB', + unselectedFeedback: 'editUnselFB', + }], + }); + }); + it('calls editor setContent to set answer and feedback fields', () => { + const setContent = jest.fn(); + windowSpy.mockImplementation(() => ({ + tinymce: { + editors: { + 'answer-A': { setContent }, + 'answer-B': { setContent }, + 'selectedFeedback-A': { setContent }, + 'selectedFeedback-B': { setContent }, + 'unselectedFeedback-A': { setContent }, + 'unselectedFeedback-B': { setContent }, + }, + }, + })); + const payload = { + id: 'A', + correct: false, + editorState: { + answers: { A: 'editorAnsA', B: 'editorAnsB' }, + selectedFeedback: { A: 'editSelFA', B: 'editSelFB' }, + unselectedFeedback: { A: 'editUnselFA', B: 'editUnselFB' }, + }, + }; + reducer( + { + ...testingState, + problemType: ProblemTypeKeys.SINGLESELECT, + correctAnswerCount: 1, + answers: [ + { id: 'A', correct: false }, + { id: 'B', correct: true }, + ], + }, + actions.deleteAnswer(payload), + ); + expect(window.tinymce.editors['answer-A'].setContent).toHaveBeenCalled(); + expect(window.tinymce.editors['answer-A'].setContent).toHaveBeenCalledWith('editorAnsB'); + expect(window.tinymce.editors['selectedFeedback-A'].setContent).toHaveBeenCalledWith('editSelFB'); + expect(window.tinymce.editors['unselectedFeedback-A'].setContent).toHaveBeenCalledWith('editUnselFB'); + }); + it('sets groupFeedbackList by removing the checked item in the groupFeedback', () => { + windowSpy.mockImplementation(() => ({ + tinymce: { + editors: 'mock-editors', + }, + })); + const payload = { + id: 'A', + correct: false, + editorState: { + answer: { A: 'aNSwERA', B: 'anSWeRB' }, + }, + }; + expect(reducer( + { + ...testingState, + correctAnswerCount: 1, + answers: [ + { id: 'A', correct: false }, + { id: 'B', correct: true }, + { id: 'C', correct: false }, + ], + groupFeedbackList: [{ + id: 0, + answers: ['A', 'C'], + feedback: 'fake feedback', }], + }, + actions.deleteAnswer(payload), + )).toEqual({ + ...testingState, + correctAnswerCount: 1, + answers: [{ + id: 'A', + correct: true, + selectedFeedback: '', + unselectedFeedback: '', + }, + { + id: 'B', + correct: false, + selectedFeedback: '', + unselectedFeedback: '', + }], + groupFeedbackList: [{ + id: 0, + answers: ['B'], + feedback: 'fake feedback', + }], }); }); it('if you delete an answer range, it will be replaced with a blank answer', () => { - const answer = { + windowSpy.mockImplementation(() => ({ + tinymce: { + editors: 'mock-editors', + }, + })); + const payload = { id: 'A', correct: true, - selectedFeedback: '', - title: '', - isAnswerRange: false, - unselectedFeedback: '', + editorState: 'mockEditoRStAte', }; - const answerRange = { - id: 'A', - correct: false, - selectedFeedback: '', - title: '', - isAnswerRange: true, - unselectedFeedback: '', - }; - expect(reducer( { ...testingState, problemType: ProblemTypeKeys.NUMERIC, correctAnswerCount: 1, - answers: [{ ...answerRange }], + answers: [{ + id: 'A', + correct: false, + selectedFeedback: '', + title: '', + isAnswerRange: true, + unselectedFeedback: '', + }], }, - actions.deleteAnswer(answer), + actions.deleteAnswer(payload), )).toEqual({ ...testingState, problemType: ProblemTypeKeys.NUMERIC, correctAnswerCount: 1, - answers: [{ ...answer }], + answers: [{ + id: 'A', + title: '', + selectedFeedback: '', + unselectedFeedback: '', + correct: true, + isAnswerRange: false, + }], }); }); }); diff --git a/src/editors/sharedComponents/TinyMceWidget/hooks.js b/src/editors/sharedComponents/TinyMceWidget/hooks.js index 34a6ac634..9fe5a44d0 100644 --- a/src/editors/sharedComponents/TinyMceWidget/hooks.js +++ b/src/editors/sharedComponents/TinyMceWidget/hooks.js @@ -152,8 +152,6 @@ export const setupCustomBehavior = ({ updateContent, }); }); - // TODO: consider using tinyMCE onblur for all react state updates - editor.on('blur', () => updateContent(editor.getContent())); } editor.on('ExecCommand', (e) => { if (editorType === 'text' && e.command === 'mceFocus') {