feat: add no answer confirmation alert (#310)

This commit is contained in:
Kristin Aoki
2023-04-20 12:49:07 -04:00
committed by GitHub
parent 8f15480c28
commit b674cd0cb0
8 changed files with 465 additions and 23 deletions

View File

@@ -4,6 +4,49 @@ exports[`EditorProblemView component renders raw editor 1`] = `
<injectIntl(ShimmedIntlComponent)
getContent={[Function]}
>
<Component
footerNode={
<ActionRow>
<Button
onClick={[Function]}
variant="tertiary"
>
<FormattedMessage
defaultMessage="Cancel"
description="Label for cancel button in the no answer modal"
id="authoring.problemEditor.editProblemView.noAnswerModal.cancelButton.label"
/>
</Button>
<Button
onClick={[Function]}
>
<FormattedMessage
defaultMessage="Ok"
description="Label for save button in the no answer modal"
id="authoring.problemEditor.editProblemView.noAnswerModal.saveButton.label"
/>
</Button>
</ActionRow>
}
isOpen={false}
onClose={[Function]}
title="No answer specified"
>
<div>
<FormattedMessage
defaultMessage="Are you sure you want to exit the editor?"
description="Question in body of no answer modal"
id="authoring.problemEditor.editProblemView.noAnswerModal.body.question"
/>
</div>
<div>
<FormattedMessage
defaultMessage="No correct answer has been specified."
description="Explanation in body of no answer modal"
id="authoring.problemEditor.editProblemView.noAnswerModal.body.explanation"
/>
</div>
</Component>
<div
className="editProblemView d-flex flex-row flex-nowrap justify-content-end"
>
@@ -36,6 +79,49 @@ exports[`EditorProblemView component renders simple view 1`] = `
<injectIntl(ShimmedIntlComponent)
getContent={[Function]}
>
<Component
footerNode={
<ActionRow>
<Button
onClick={[Function]}
variant="tertiary"
>
<FormattedMessage
defaultMessage="Cancel"
description="Label for cancel button in the no answer modal"
id="authoring.problemEditor.editProblemView.noAnswerModal.cancelButton.label"
/>
</Button>
<Button
onClick={[Function]}
>
<FormattedMessage
defaultMessage="Ok"
description="Label for save button in the no answer modal"
id="authoring.problemEditor.editProblemView.noAnswerModal.saveButton.label"
/>
</Button>
</ActionRow>
}
isOpen={false}
onClose={[Function]}
title="No answer specified"
>
<div>
<FormattedMessage
defaultMessage="Are you sure you want to exit the editor?"
description="Question in body of no answer modal"
id="authoring.problemEditor.editProblemView.noAnswerModal.body.question"
/>
</div>
<div>
<FormattedMessage
defaultMessage="No correct answer has been specified."
description="Explanation in body of no answer modal"
id="authoring.problemEditor.editProblemView.noAnswerModal.body.explanation"
/>
</div>
</Component>
<div
className="editProblemView d-flex flex-row flex-nowrap justify-content-end"
>

View File

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

View File

@@ -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 = () => '<p>testString</p>';
@@ -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);
});
});
});

View File

@@ -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 (
<EditorContainer getContent={getContent}>
<EditorContainer
getContent={() => getContent({
problemState,
openNoAnswerModal,
isAdvancedProblemType,
editorRef,
assets,
lmsEndpointUrl,
})}
>
<AlertModal
title={intl.formatMessage(messages.noAnswerModalTitle)}
isOpen={isNoAnswerModalOpen}
onClose={closeNoAnswerModal}
footerNode={(
<ActionRow>
<Button variant="tertiary" onClick={closeNoAnswerModal}>
<FormattedMessage {...messages.noAnswerCancelButtonLabel} />
</Button>
<Button
onClick={() => saveBlock({
content: parseState({
problem: problemState,
isAdvanced: isAdvancedProblemType,
ref: editorRef,
assets,
lmsEndpointUrl,
})(),
destination: returnUrl,
dispatch,
analytics,
})}
>
<FormattedMessage {...messages.noAnswerSaveButtonLabel} />
</Button>
</ActionRow>
)}
>
<div>
<FormattedMessage {...messages.noAnswerModalBodyQuestion} />
</div>
<div>
<FormattedMessage {...messages.noAnswerModalBodyExplanation} />
</div>
</AlertModal>
<div className="editProblemView d-flex flex-row flex-nowrap justify-content-end">
{isAdvancedProblemType ? (
<Container fluid className="advancedEditorTopMargin p-0">
@@ -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));

View File

@@ -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(<EditProblemView problemType={ProblemTypeKeys.ADVANCED} problemState={{}} assets={{}} />);
const wrapper = shallow(<EditProblemView
problemType={ProblemTypeKeys.ADVANCED}
problemState={{}}
assets={{}}
intl={{ formatMessage }}
/>);
expect(wrapper).toMatchSnapshot();
expect(wrapper.find(AnswerWidget).length).toBe(0);
expect(wrapper.find(RawEditor).length).toBe(1);

View File

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

View File

@@ -36,6 +36,9 @@ export const saveBlock = ({
dispatch,
validateEntry,
}) => {
if (!content) {
return;
}
let attemptSave = false;
if (validateEntry) {
if (validateEntry()) {

View File

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