fix: delete answers without changing expandable (#328)

This commit is contained in:
Raymond Zhou
2023-05-17 11:22:51 -07:00
committed by GitHub
parent be8f9ecc86
commit 741b83bdf2
9 changed files with 378 additions and 98 deletions

View File

@@ -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 })),
},
},

View File

@@ -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

View File

@@ -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);
});

View File

@@ -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();
}
}

View File

@@ -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; }

View File

@@ -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];

View File

@@ -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) => {

View File

@@ -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,
}],
});
});
});

View File

@@ -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') {