diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.jsx index 270295ab1..2abfd4a45 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.jsx +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.jsx @@ -5,11 +5,10 @@ import { Collapsible, Icon, IconButton, - Form, + // Form, } from '@edx/paragon'; import { FeedbackOutline, DeleteOutline } from '@edx/paragon/icons'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; - import messages from './messages'; import { selectors } from '../../../../../data/redux'; import { answerOptionProps } from '../../../../../data/services/cms/types'; @@ -17,6 +16,7 @@ import Checker from './components/Checker'; import { FeedbackBox } from './components/Feedback'; import * as hooks from './hooks'; import { ProblemTypeKeys } from '../../../../../data/constants/problem'; +import ExpandableTextArea from '../../../../../sharedComponents/ExpandableTextArea'; export const AnswerOption = ({ answer, @@ -29,10 +29,10 @@ export const AnswerOption = ({ const dispatch = useDispatch(); const removeAnswer = hooks.removeAnswer({ answer, dispatch }); const setAnswer = hooks.setAnswer({ answer, hasSingleAnswer, dispatch }); + const setAnswerTitle = hooks.setAnswerTitle({ answer, hasSingleAnswer, dispatch }); const setSelectedFeedback = hooks.setSelectedFeedback({ answer, hasSingleAnswer, dispatch }); const setUnselectedFeedback = hooks.setUnselectedFeedback({ answer, hasSingleAnswer, dispatch }); const { isFeedbackVisible, toggleFeedback } = hooks.useFeedback(answer); - return (
- + {/* { setAnswer({ title: e.target.value }); }} + onChange={(e) => { setAnswerTitle(e.target.value) }} placeholder={intl.formatMessage(messages.answerTextboxPlaceholder)} - /> + /> */} - @@ -102,13 +101,12 @@ exports[`AnswerOption render snapshot: renders correct option with numeric input
- @@ -179,13 +177,12 @@ exports[`AnswerOption render snapshot: renders correct option with selected unse
- diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/hooks.js b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/hooks.js index 061683fa0..eab5e6e4c 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/hooks.js +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/hooks.js @@ -16,6 +16,10 @@ export const setAnswer = ({ answer, hasSingleAnswer, dispatch }) => (payload) => dispatch(actions.problem.updateAnswer({ id: answer.id, hasSingleAnswer, ...payload })); }; +export const setAnswerTitle = ({ answer, hasSingleAnswer, dispatch }) => (updatedTitle) => { + dispatch(actions.problem.updateAnswer({ id: answer.id, hasSingleAnswer, title: updatedTitle })); +}; + export const setSelectedFeedback = ({ answer, hasSingleAnswer, dispatch }) => (e) => { dispatch(actions.problem.updateAnswer({ id: answer.id, @@ -71,5 +75,5 @@ export const useAnswerContainer = ({ answers, updateField }) => { }; export default { - state, removeAnswer, setAnswer, useFeedback, isSingleAnswerProblem, useAnswerContainer, + state, removeAnswer, setAnswer, setAnswerTitle, useFeedback, isSingleAnswerProblem, useAnswerContainer, }; 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 9e588d99c..93efeb5ae 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/hooks.test.js +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/hooks.test.js @@ -64,6 +64,20 @@ describe('Answer Options Hooks', () => { })); }); }); + describe('setAnswerTitle', () => { + test('it dispatches actions.problem.updateAnswer', () => { + const answer = { id: 'A' }; + const hasSingleAnswer = false; + const dispatch = useDispatch(); + const updatedTitle = 'string'; + module.setAnswerTitle({ answer, hasSingleAnswer, dispatch })(updatedTitle); + expect(dispatch).toHaveBeenCalledWith(actions.problem.updateAnswer({ + id: answer.id, + hasSingleAnswer, + title: updatedTitle, + })); + }); + }); describe('setSelectedFeedback', () => { test('it dispatches actions.problem.updateAnswer', () => { const answer = { id: 'A' }; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/index.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/index.jsx index a347bb1b8..afc75ae63 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/index.jsx +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/index.jsx @@ -5,7 +5,6 @@ import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/ import messages from './messages'; import { ProblemTypes } from '../../../../../data/constants/problem'; import AnswersContainer from './AnswersContainer'; -import './index.scss'; // This widget should be connected, grab all answers from store, update them as needed. const AnswerWidget = ({ diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/index.scss b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/index.scss deleted file mode 100644 index 74fbe5320..000000000 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/index.scss +++ /dev/null @@ -1,20 +0,0 @@ -.answer-option { - .answer-option-textarea { - textarea { - border: none; - resize: none; - - &:active, &:hover, &:focus, &:focus-visible { - border: none; - border-right: none; - border-left: none; - border-radius: 0; - box-shadow: none; - } - - &:focus, &:hover { - border-bottom: 1px solid #000; - } - } - } -} diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/QuestionWidget/__snapshots__/index.test.jsx.snap b/src/editors/containers/ProblemEditor/components/EditProblemView/QuestionWidget/__snapshots__/index.test.jsx.snap index 206c3ca41..47e48b425 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/QuestionWidget/__snapshots__/index.test.jsx.snap +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/QuestionWidget/__snapshots__/index.test.jsx.snap @@ -13,18 +13,13 @@ exports[`QuestionWidget render snapshot: renders correct default 1`] = ` id="authoring.questionwidget.question.questionWidgetTitle" />
-
`; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/QuestionWidget/index.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/QuestionWidget/index.jsx index 94535e18d..224211517 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/QuestionWidget/index.jsx +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/QuestionWidget/index.jsx @@ -2,25 +2,20 @@ import React from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n'; -import * as hooks from '../../../hooks'; import { selectors, actions } from '../../../../../data/redux'; import { messages } from './messages'; import './index.scss'; import TinyMceWidget from '../../../../../sharedComponents/TinyMceWidget'; +import { prepareEditorRef } from '../../../../../sharedComponents/TinyMceWidget/hooks'; export const QuestionWidget = ({ - assets, // redux - isLibrary, question, - updateQuestion, - lmsEndpointUrl, - studioEndpointUrl, // injected intl, }) => { - const { editorRef, refReady, setEditorRef } = hooks.prepareEditorRef(); + const { editorRef, refReady, setEditorRef } = prepareEditorRef(); if (!refReady) { return null; } return (
@@ -28,42 +23,26 @@ export const QuestionWidget = ({
); }; -QuestionWidget.defaultProps = { - isLibrary: null, -}; - QuestionWidget.propTypes = { - assets: PropTypes.shape({}).isRequired, // redux - isLibrary: PropTypes.bool, - lmsEndpointUrl: PropTypes.string.isRequired, - studioEndpointUrl: PropTypes.string.isRequired, question: PropTypes.string.isRequired, - updateQuestion: PropTypes.func.isRequired, // injected intl: intlShape.isRequired, }; export const mapStateToProps = (state) => ({ - isLibrary: selectors.app.isLibrary(state), question: selectors.problem.question(state), - lmsEndpointUrl: selectors.app.lmsEndpointUrl(state), - studioEndpointUrl: selectors.app.studioEndpointUrl(state), }); export const mapDispatchToProps = { diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/QuestionWidget/index.test.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/QuestionWidget/index.test.jsx index 6856a442c..50e99f27a 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/QuestionWidget/index.test.jsx +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/QuestionWidget/index.test.jsx @@ -23,22 +23,18 @@ jest.mock('../../../../../data/redux', () => ({ }, })); -jest.mock('../../../hooks', () => ({ +jest.mock('../../../../../sharedComponents/TinyMceWidget/hooks', () => ({ prepareEditorRef: jest.fn(() => ({ refReady: true, - setEditorRef: jest.fn().mockName('hooks.prepareEditorRef.setEditorRef'), + setEditorRef: jest.fn().mockName('prepareEditorRef.setEditorRef'), })), - problemEditorConfig: jest.fn(args => ({ problemEditorConfig: args })), + // problemEditorConfig: jest.fn(args => ({ problemEditorConfig: args })), })); describe('QuestionWidget', () => { const props = { - isLibrary: false, question: 'This is my question', updateQuestion: jest.fn(), - lmsEndpointUrl: 'sOmEvaLue.cOm', - studioEndpointUrl: 'sOmEoThERvaLue.cOm', - assets: {}, // injected intl: { formatMessage }, }; @@ -49,22 +45,9 @@ describe('QuestionWidget', () => { }); describe('mapStateToProps', () => { const testState = { A: 'pple', B: 'anana', C: 'ucumber' }; - test('isLibrary from app.isLibrary', () => { - expect(mapStateToProps(testState).isLibrary).toEqual(selectors.app.isLibrary(testState)); - }); test('question from problem.question', () => { expect(mapStateToProps(testState).question).toEqual(selectors.problem.question(testState)); }); - test('lmsEndpointUrl from app.lmsEndpointUrl', () => { - expect( - mapStateToProps(testState).lmsEndpointUrl, - ).toEqual(selectors.app.lmsEndpointUrl(testState)); - }); - test('studioEndpointUrl from app.studioEndpointUrl', () => { - expect( - mapStateToProps(testState).studioEndpointUrl, - ).toEqual(selectors.app.studioEndpointUrl(testState)); - }); }); describe('mapDispatchToProps', () => { test('updateField from actions.problem.updateQuestion', () => { 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 61f4687f4..245bb0ad1 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 @@ -42,9 +42,7 @@ exports[`EditorProblemView component renders simple view 1`] = ` - + diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/hooks.js b/src/editors/containers/ProblemEditor/components/EditProblemView/hooks.js index 6eda62f91..4b694b517 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/hooks.js +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/hooks.js @@ -1,19 +1,37 @@ +import 'tinymce'; import ReactStateSettingsParser from '../../data/ReactStateSettingsParser'; import ReactStateOLXParser from '../../data/ReactStateOLXParser'; import { setAssetToStaticUrl } from '../../../../sharedComponents/TinyMceWidget/hooks'; -// eslint-disable-next-line import/prefer-default-export +export const fetchEditorContent = () => { + const editorObject = {}; + const EditorsArray = window.tinymce.editors; + Object.entries(EditorsArray).forEach(([id, editor]) => { + if (Number.isNaN(parseInt(id))) { + if (id.startsWith('answer')) { + const { answers } = editorObject; + const answerId = id.substring(id.indexOf('-') + 1); + editorObject.answers = { ...answers, [answerId]: editor.getContent() }; + } else { + editorObject[id] = editor.getContent(); + } + } + }); + return editorObject; +}; + export const parseState = ({ problem, isAdvanced, ref, assets, + lmsEndpointUrl, }) => () => { + const editorObject = fetchEditorContent(); const reactSettingsParser = new ReactStateSettingsParser(problem); - const reactOLXParser = new ReactStateOLXParser({ problem }); - const reactBuiltOlx = setAssetToStaticUrl({ editorValue: reactOLXParser.buildOLX(), assets }); + const reactOLXParser = new ReactStateOLXParser({ problem, editorObject }); + const reactBuiltOlx = setAssetToStaticUrl({ editorValue: reactOLXParser.buildOLX(), assets, lmsEndpointUrl }); const rawOLX = ref?.current?.state.doc.toString(); - return { settings: reactSettingsParser.getSettings(), olx: isAdvanced ? rawOLX : reactBuiltOlx, diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/index.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/index.jsx index c11a329a6..6da23a957 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/index.jsx +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/index.jsx @@ -1,9 +1,7 @@ import React, { useRef } from 'react'; import PropTypes from 'prop-types'; - import { connect } from 'react-redux'; import { Container } from '@edx/paragon'; - import AnswerWidget from './AnswerWidget'; import SettingsWidget from './SettingsWidget'; import QuestionWidget from './QuestionWidget'; @@ -20,6 +18,7 @@ export const EditProblemView = ({ problemType, problemState, assets, + lmsEndpointUrl, }) => { const editorRef = useRef(null); const isAdvancedProblemType = problemType === ProblemTypeKeys.ADVANCED; @@ -29,6 +28,7 @@ export const EditProblemView = ({ isAdvanced: isAdvancedProblemType, ref: editorRef, assets, + lmsEndpointUrl, }); return ( @@ -40,7 +40,7 @@ export const EditProblemView = ({ ) : ( - + )} @@ -54,6 +54,7 @@ export const EditProblemView = ({ EditProblemView.defaultProps = { assets: null, + lmsEndpointUrl: null, }; EditProblemView.propTypes = { @@ -61,10 +62,12 @@ EditProblemView.propTypes = { // eslint-disable-next-line problemState: PropTypes.any.isRequired, assets: PropTypes.shape({}), + lmsEndpointUrl: PropTypes.string, }; export const mapStateToProps = (state) => ({ assets: selectors.app.assets(state), + lmsEndpointUrl: selectors.app.lmsEndpointUrl(state), problemType: selectors.problem.problemType(state), problemState: selectors.problem.completeState(state), }); diff --git a/src/editors/containers/ProblemEditor/data/ReactStateOLXParser.js b/src/editors/containers/ProblemEditor/data/ReactStateOLXParser.js index e066797f7..8f00c41d4 100644 --- a/src/editors/containers/ProblemEditor/data/ReactStateOLXParser.js +++ b/src/editors/containers/ProblemEditor/data/ReactStateOLXParser.js @@ -4,10 +4,6 @@ import { ProblemTypeKeys } from '../../../data/constants/problem'; class ReactStateOLXParser { constructor(problemState) { - // const parserOptions = { - // ignoreAttributes: false, - // alwaysCreateTextNode: true, - // }; const questionParserOptions = { ignoreAttributes: false, alwaysCreateTextNode: true, @@ -27,9 +23,9 @@ class ReactStateOLXParser { format: true, }; this.questionParser = new XMLParser(questionParserOptions); - // this.parser = new XMLParser(parserOptions); this.builder = new XMLBuilder(builderOptions); this.questionBuilder = new XMLBuilder(questionBuilderOptions); + this.editorObject = problemState.editorObject; this.problemState = problemState.problem; } @@ -67,6 +63,7 @@ class ReactStateOLXParser { let widget = {}; // eslint-disable-next-line prefer-const let { answers, generalFeedback } = this.problemState; + const answerTitles = this.editorObject?.answers; // general feedback replaces selected feedback if all incorrect selected feedback is the same. if (generalFeedback !== '' && answers.every( @@ -83,6 +80,7 @@ class ReactStateOLXParser { answers.forEach((answer) => { const feedback = []; let singleAnswer = {}; + const title = answerTitles ? answerTitles[answer.id] : answer.title; if (this.hasAttributeWithValue(answer, 'title')) { if (this.hasAttributeWithValue(answer, 'selectedFeedback')) { feedback.push({ @@ -105,7 +103,7 @@ class ReactStateOLXParser { singleAnswer[`${option}hint`] = feedback; } singleAnswer = { - '#text': answer.title, + '#text': title, '@_correct': answer.correct, ...singleAnswer, }; @@ -136,7 +134,7 @@ class ReactStateOLXParser { } addQuestion() { - const { question } = this.problemState; + const { question } = this.editorObject || this.problemState; const questionObject = this.questionParser.parse(question); return questionObject; } @@ -206,6 +204,7 @@ class ReactStateOLXParser { buildTextInputAnswersFeedback() { const { answers } = this.problemState; + const answerTitles = this.editorObject?.answers; let answerObject = {}; const additionAnswers = []; const wrongAnswers = []; @@ -213,20 +212,21 @@ class ReactStateOLXParser { answers.forEach((answer) => { const correcthint = this.getAnswerHints(answer); if (this.hasAttributeWithValue(answer, 'title')) { + const title = answerTitles ? answerTitles[answer.id] : answer.title; if (answer.correct && firstCorrectAnswerParsed) { additionAnswers.push({ - '@_answer': answer.title, + '@_answer': title, ...correcthint, }); } else if (answer.correct && !firstCorrectAnswerParsed) { firstCorrectAnswerParsed = true; answerObject = { - '@_answer': answer.title, + '@_answer': title, ...correcthint, }; } else if (!answer.correct) { wrongAnswers.push({ - '@_answer': answer.title, + '@_answer': title, '#text': answer.selectedFeedback, }); } @@ -271,12 +271,14 @@ class ReactStateOLXParser { buildNumericalResponse() { const { answers } = this.problemState; + const answerTitles = this.editorObject?.answers; let answerObject = {}; const additionalAnswers = []; let firstCorrectAnswerParsed = false; answers.forEach((answer) => { const correcthint = this.getAnswerHints(answer); if (this.hasAttributeWithValue(answer, 'title')) { + const title = answerTitles ? answerTitles[answer.id] : answer.title; if (answer.correct && !firstCorrectAnswerParsed) { firstCorrectAnswerParsed = true; let responseParam = {}; @@ -289,13 +291,13 @@ class ReactStateOLXParser { }; } answerObject = { - '@_answer': answer.title, + '@_answer': title, ...responseParam, ...correcthint, }; } else if (answer.correct && firstCorrectAnswerParsed) { additionalAnswers.push({ - '@_answer': answer.title, + '@_answer': title, ...correcthint, }); } diff --git a/src/editors/containers/ProblemEditor/hooks.js b/src/editors/containers/ProblemEditor/hooks.js deleted file mode 100644 index fcdd28863..000000000 --- a/src/editors/containers/ProblemEditor/hooks.js +++ /dev/null @@ -1,19 +0,0 @@ -import { - useRef, useCallback, useState, useEffect, -} from 'react'; -import { StrictDict } from '../../utils'; -import * as module from './hooks'; - -export const state = StrictDict({ - refReady: (val) => useState(val), -}); - -export const prepareEditorRef = () => { - const editorRef = useRef(null); - const setEditorRef = useCallback((ref) => { - editorRef.current = ref; - }, []); - const [refReady, setRefReady] = module.state.refReady(false); - useEffect(() => setRefReady(true), [setRefReady]); - return { editorRef, refReady, setEditorRef }; -}; diff --git a/src/editors/containers/ProblemEditor/hooks.test.js b/src/editors/containers/ProblemEditor/hooks.test.js deleted file mode 100644 index a804adac2..000000000 --- a/src/editors/containers/ProblemEditor/hooks.test.js +++ /dev/null @@ -1,53 +0,0 @@ -import { useEffect } from 'react'; -import { MockUseState } from '../../../testUtils'; - -import * as module from './hooks'; - -jest.mock('react', () => ({ - ...jest.requireActual('react'), - createRef: jest.fn(val => ({ ref: val })), - useRef: jest.fn(val => ({ current: val })), - useEffect: jest.fn(), - useCallback: (cb, prereqs) => ({ cb, prereqs }), -})); - -const state = new MockUseState(module); - -let hook; - -describe('Problem editor hooks', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - describe('state hooks', () => { - state.testGetter(state.keys.refReady); - }); - - describe('non-state hooks', () => { - beforeEach(() => { state.mock(); }); - afterEach(() => { state.restore(); }); - describe('prepareEditorRef', () => { - beforeEach(() => { - hook = module.prepareEditorRef(); - }); - const key = state.keys.refReady; - test('sets refReady to false by default, ref is null', () => { - expect(state.stateVals[key]).toEqual(false); - expect(hook.editorRef.current).toBe(null); - }); - test('when useEffect triggers, refReady is set to true', () => { - expect(state.setState[key]).not.toHaveBeenCalled(); - const [cb, prereqs] = useEffect.mock.calls[0]; - expect(prereqs).toStrictEqual([state.setState[key]]); - cb(); - expect(state.setState[key]).toHaveBeenCalledWith(true); - }); - test('calling setEditorRef sets the ref value', () => { - const fakeEditor = { editor: 'faKe Editor' }; - expect(hook.editorRef.current).not.toBe(fakeEditor); - hook.setEditorRef.cb(fakeEditor); - expect(hook.editorRef.current).toBe(fakeEditor); - }); - }); - }); -}); diff --git a/src/editors/containers/TextEditor/__snapshots__/index.test.jsx.snap b/src/editors/containers/TextEditor/__snapshots__/index.test.jsx.snap index da6b8b20c..a7171499c 100644 --- a/src/editors/containers/TextEditor/__snapshots__/index.test.jsx.snap +++ b/src/editors/containers/TextEditor/__snapshots__/index.test.jsx.snap @@ -34,14 +34,7 @@ exports[`TextEditor snapshots block failed to load, Toast is shown 1`] = ` id="authoring.texteditor.load.error" /> - @@ -199,14 +189,7 @@ exports[`TextEditor snapshots renders as expected with default behavior 1`] = ` id="authoring.texteditor.load.error" /> - diff --git a/src/editors/containers/TextEditor/hooks.js b/src/editors/containers/TextEditor/hooks.js index dc986d033..2f4d74b89 100644 --- a/src/editors/containers/TextEditor/hooks.js +++ b/src/editors/containers/TextEditor/hooks.js @@ -1,28 +1,8 @@ -import { - useRef, useEffect, useCallback, useState, -} from 'react'; - -import { StrictDict } from '../../utils'; import * as appHooks from '../../hooks'; -import * as module from './hooks'; import { setAssetToStaticUrl } from '../../sharedComponents/TinyMceWidget/hooks'; export const { nullMethod, navigateCallback, navigateTo } = appHooks; -export const state = StrictDict({ - refReady: (val) => useState(val), -}); - -export const prepareEditorRef = () => { - const editorRef = useRef(null); - const setEditorRef = useCallback((ref) => { - editorRef.current = ref; - }, []); - const [refReady, setRefReady] = module.state.refReady(false); - useEffect(() => setRefReady(true), []); - return { editorRef, refReady, setEditorRef }; -}; - export const getContent = ({ editorRef, isRaw, assets }) => () => { const content = (isRaw && editorRef && editorRef.current ? editorRef.current.state.doc.toString() diff --git a/src/editors/containers/TextEditor/hooks.test.jsx b/src/editors/containers/TextEditor/hooks.test.jsx index aadbad8c8..e2fa7722f 100644 --- a/src/editors/containers/TextEditor/hooks.test.jsx +++ b/src/editors/containers/TextEditor/hooks.test.jsx @@ -1,28 +1,14 @@ -import { useEffect } from 'react'; -import { MockUseState } from '../../../testUtils'; - +import { keyStore } from '../../utils'; import * as appHooks from '../../hooks'; import * as module from './hooks'; +import * as tinyMceHooks from '../../sharedComponents/TinyMceWidget/hooks'; -jest.mock('react', () => ({ - ...jest.requireActual('react'), - createRef: jest.fn(val => ({ ref: val })), - useRef: jest.fn(val => ({ current: val })), - useEffect: jest.fn(), - useCallback: (cb, prereqs) => ({ cb, prereqs }), -})); - -const state = new MockUseState(module); - -let hook; +const tinyMceHookKeys = keyStore(tinyMceHooks); describe('TextEditor hooks', () => { beforeEach(() => { jest.clearAllMocks(); }); - describe('state hooks', () => { - state.testGetter(state.keys.refReady); - }); describe('appHooks', () => { it('forwards navigateCallback from appHooks', () => { @@ -37,32 +23,6 @@ describe('TextEditor hooks', () => { }); describe('non-state hooks', () => { - beforeEach(() => { state.mock(); }); - afterEach(() => { state.restore(); }); - describe('prepareEditorRef', () => { - beforeEach(() => { - hook = module.prepareEditorRef(); - }); - const key = state.keys.refReady; - test('sets refReady to false by default, ref is null', () => { - expect(state.stateVals[key]).toEqual(false); - expect(hook.editorRef.current).toBe(null); - }); - test('when useEffect triggers, refReady is set to true', () => { - expect(state.setState[key]).not.toHaveBeenCalled(); - const [cb, prereqs] = useEffect.mock.calls[0]; - expect(prereqs).toStrictEqual([]); - cb(); - expect(state.setState[key]).toHaveBeenCalledWith(true); - }); - test('calling setEditorRef sets the ref value', () => { - const fakeEditor = { editor: 'faKe Editor' }; - expect(hook.editorRef.current).not.toBe(fakeEditor); - hook.setEditorRef.cb(fakeEditor); - expect(hook.editorRef.current).toBe(fakeEditor); - }); - }); - describe('getContent', () => { const visualContent = 'sOmEViSualContent'; const rawContent = 'soMeRawContent'; @@ -74,10 +34,28 @@ describe('TextEditor hooks', () => { }, }, }; + const spies = {}; + spies.visualHtml = jest.spyOn( + tinyMceHooks, + tinyMceHookKeys.setAssetToStaticUrl, + ).mockReturnValueOnce(visualContent); + spies.rawHtml = jest.spyOn( + tinyMceHooks, + tinyMceHookKeys.setAssetToStaticUrl, + ).mockReturnValueOnce(rawContent); const assets = []; - test('returns correct content based on isRaw', () => { - expect(module.getContent({ editorRef, isRaw: false, assets })()).toEqual(visualContent); - expect(module.getContent({ editorRef, isRaw: true, assets })()).toEqual(rawContent); + test('returns correct content based on isRaw equals false', () => { + const getContent = module.getContent({ editorRef, isRaw: false, assets })(); + expect(spies.visualHtml.mock.calls.length).toEqual(1); + expect(spies.visualHtml).toHaveBeenCalledWith({ editorValue: visualContent, assets }); + expect(getContent).toEqual(visualContent); + }); + test('returns correct content based on isRaw equals true', () => { + jest.clearAllMocks(); + const getContent = module.getContent({ editorRef, isRaw: true, assets })(); + expect(spies.rawHtml.mock.calls.length).toEqual(1); + expect(spies.rawHtml).toHaveBeenCalledWith({ editorValue: rawContent, assets }); + expect(getContent).toEqual(rawContent); }); }); }); diff --git a/src/editors/containers/TextEditor/index.jsx b/src/editors/containers/TextEditor/index.jsx index 3f3753c1e..905929f97 100644 --- a/src/editors/containers/TextEditor/index.jsx +++ b/src/editors/containers/TextEditor/index.jsx @@ -16,15 +16,13 @@ import RawEditor from '../../sharedComponents/RawEditor'; import * as hooks from './hooks'; import messages from './messages'; import TinyMceWidget from '../../sharedComponents/TinyMceWidget'; +import { prepareEditorRef } from '../../sharedComponents/TinyMceWidget/hooks'; export const TextEditor = ({ onClose, // redux isRaw, - isLibrary, blockValue, - lmsEndpointUrl, - studioEndpointUrl, blockFailed, initializeEditor, assetsFinished, @@ -32,7 +30,7 @@ export const TextEditor = ({ // inject intl, }) => { - const { editorRef, refReady, setEditorRef } = hooks.prepareEditorRef(); + const { editorRef, refReady, setEditorRef } = prepareEditorRef(); if (!refReady) { return null; } @@ -54,10 +52,6 @@ export const TextEditor = ({ minHeight={500} height="100%" initializeEditor={initializeEditor} - isLibrary={isLibrary} - lmsEndpointUrl={lmsEndpointUrl} - studioEndpointUrl={studioEndpointUrl} - assets={assets} /> ); }; @@ -83,16 +77,12 @@ export const TextEditor = ({ ) : (selectEditor())} - ); }; TextEditor.defaultProps = { blockValue: null, isRaw: null, - isLibrary: null, - lmsEndpointUrl: null, - studioEndpointUrl: null, assetsFinished: null, assets: null, }; @@ -102,12 +92,9 @@ TextEditor.propTypes = { blockValue: PropTypes.shape({ data: PropTypes.shape({ data: PropTypes.string }), }), - lmsEndpointUrl: PropTypes.string, - studioEndpointUrl: PropTypes.string, blockFailed: PropTypes.bool.isRequired, initializeEditor: PropTypes.func.isRequired, isRaw: PropTypes.bool, - isLibrary: PropTypes.bool, assetsFinished: PropTypes.bool, assets: PropTypes.shape({}), // inject @@ -116,11 +103,8 @@ TextEditor.propTypes = { export const mapStateToProps = (state) => ({ blockValue: selectors.app.blockValue(state), - lmsEndpointUrl: selectors.app.lmsEndpointUrl(state), - studioEndpointUrl: selectors.app.studioEndpointUrl(state), blockFailed: selectors.requests.isFailed(state, { requestKey: RequestKeys.fetchBlock }), isRaw: selectors.app.isRaw(state), - isLibrary: selectors.app.isLibrary(state), assetsFinished: selectors.requests.isFinished(state, { requestKey: RequestKeys.fetchAssets }), assets: selectors.app.assets(state), }); diff --git a/src/editors/containers/TextEditor/index.test.jsx b/src/editors/containers/TextEditor/index.test.jsx index e49844cf9..2ee14c75a 100644 --- a/src/editors/containers/TextEditor/index.test.jsx +++ b/src/editors/containers/TextEditor/index.test.jsx @@ -20,9 +20,11 @@ jest.mock('@tinymce/tinymce-react', () => { jest.mock('../EditorContainer', () => 'EditorContainer'); jest.mock('./hooks', () => ({ - editorConfig: jest.fn(args => ({ editorConfig: args })), getContent: jest.fn(args => ({ getContent: args })), nullMethod: jest.fn().mockName('hooks.nullMethod'), +})); + +jest.mock('../../sharedComponents/TinyMceWidget/hooks', () => ({ prepareEditorRef: jest.fn(() => ({ editorRef: { current: { value: 'something' } }, refReady: true, @@ -66,12 +68,9 @@ describe('TextEditor', () => { onClose: jest.fn().mockName('props.onClose'), // redux blockValue: { data: { data: 'eDiTablE Text' } }, - lmsEndpointUrl: 'sOmEvaLue.cOm', - studioEndpointUrl: 'sOmEoThERvaLue.cOm', blockFailed: false, initializeEditor: jest.fn().mockName('args.intializeEditor'), isRaw: false, - isLibrary: false, assetsFinished: true, assets: { sOmEaSsET: { staTICUrl: '/assets/sOmEaSsET' } }, // inject @@ -99,16 +98,6 @@ describe('TextEditor', () => { mapStateToProps(testState).blockValue, ).toEqual(selectors.app.blockValue(testState)); }); - test('lmsEndpointUrl from app.lmsEndpointUrl', () => { - expect( - mapStateToProps(testState).lmsEndpointUrl, - ).toEqual(selectors.app.lmsEndpointUrl(testState)); - }); - test('studioEndpointUrl from app.studioEndpointUrl', () => { - expect( - mapStateToProps(testState).studioEndpointUrl, - ).toEqual(selectors.app.studioEndpointUrl(testState)); - }); test('assets from app.assets', () => { expect( mapStateToProps(testState).assets, @@ -125,6 +114,7 @@ describe('TextEditor', () => { ).toEqual(selectors.requests.isFinished(testState, { requestKey: RequestKeys.fetchAssets })); }); }); + describe('mapDispatchToProps', () => { test('initializeEditor from actions.app.initializeEditor', () => { expect(mapDispatchToProps.initializeEditor).toEqual(actions.app.initializeEditor); diff --git a/src/editors/data/constants/tinyMCE.js b/src/editors/data/constants/tinyMCE.js index 16d7e0e34..f640d4c5e 100644 --- a/src/editors/data/constants/tinyMCE.js +++ b/src/editors/data/constants/tinyMCE.js @@ -47,6 +47,7 @@ export const buttons = StrictDict({ left: 'rotateleft', right: 'rotateright', }), + quickLink: 'quicklink', table: 'table', undo: 'undo', underline: 'underline', @@ -64,6 +65,7 @@ export const plugins = listKeyStore([ 'autoresize', 'image', 'imagetools', + 'quickbars', ]); export const textToSpeechIcon = ''; diff --git a/src/editors/data/services/cms/api.js b/src/editors/data/services/cms/api.js index db0a4a5b4..3c7493a28 100644 --- a/src/editors/data/services/cms/api.js +++ b/src/editors/data/services/cms/api.js @@ -142,6 +142,7 @@ export const apiMethods = { metadata: { display_name: title }, }; } else if (blockType === 'problem') { + // console.log(type); response = { data: content.olx, category: blockType, diff --git a/src/editors/sharedComponents/ExpandableTextArea/__snapshots__/index.test.jsx.snap b/src/editors/sharedComponents/ExpandableTextArea/__snapshots__/index.test.jsx.snap new file mode 100644 index 000000000..7e00c918b --- /dev/null +++ b/src/editors/sharedComponents/ExpandableTextArea/__snapshots__/index.test.jsx.snap @@ -0,0 +1,46 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ExpandableTextArea snapshots renders as expected with default behavior 1`] = ` + +
+ +
+
+`; + +exports[`ExpandableTextArea snapshots renders error message 1`] = ` + +
+ +
+
+ +`; diff --git a/src/editors/sharedComponents/ExpandableTextArea/index.jsx b/src/editors/sharedComponents/ExpandableTextArea/index.jsx new file mode 100644 index 000000000..5bf5f56e5 --- /dev/null +++ b/src/editors/sharedComponents/ExpandableTextArea/index.jsx @@ -0,0 +1,52 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import TinyMceWidget from '../TinyMceWidget'; +import { prepareEditorRef } from '../TinyMceWidget/hooks'; +import './index.scss'; + +export const ExpandableTextArea = ({ + value, + setContent, + error, + errorMessage, + ...props +}) => { + const { editorRef, setEditorRef } = prepareEditorRef(); + + return ( + <> +
+ +
+ {error && ( +
+ {props.errorMessage} +
+ )} + + ); +}; + +ExpandableTextArea.defaultProps = { + value: null, + placeholder: null, + error: false, + errorMessage: null, +}; + +ExpandableTextArea.propTypes = { + value: PropTypes.string, + setContent: PropTypes.func.isRequired, + placeholder: PropTypes.string, + error: PropTypes.bool, + errorMessage: PropTypes.string, +}; + +export default ExpandableTextArea; diff --git a/src/editors/sharedComponents/ExpandableTextArea/index.scss b/src/editors/sharedComponents/ExpandableTextArea/index.scss new file mode 100644 index 000000000..0a5c5820d --- /dev/null +++ b/src/editors/sharedComponents/ExpandableTextArea/index.scss @@ -0,0 +1,25 @@ +.expandable-mce { + + .error { + outline: 2px solid #CA3A2F; + } + .mce-content-body { + padding: 10px; + p { + margin: 0; + } + blockquote { + margin: 16px 40px; + } + } + + *[contentEditable=false] { + outline: 1px solid #D7D3D1; + } + *[contentEditable=true] { + outline: 1px solid #707070; + &:focus, &:active { + outline: 2px solid #000; + } + } +} diff --git a/src/editors/sharedComponents/ExpandableTextArea/index.test.jsx b/src/editors/sharedComponents/ExpandableTextArea/index.test.jsx new file mode 100644 index 000000000..ed227294d --- /dev/null +++ b/src/editors/sharedComponents/ExpandableTextArea/index.test.jsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import ExpandableTextArea from '.'; + +// Per https://github.com/tinymce/tinymce-react/issues/91 React unit testing in JSDOM is not supported by tinymce. +// Consequently, mock the Editor out. +jest.mock('@tinymce/tinymce-react', () => { + const originalModule = jest.requireActual('@tinymce/tinymce-react'); + return { + __esModule: true, + ...originalModule, + Editor: () => 'TiNYmCE EDitOR', + }; +}); + +jest.mock('../TinyMceWidget', () => 'TinyMceWidget'); + +describe('ExpandableTextArea', () => { + const props = { + value: 'text', + setContent: jest.fn(), + error: false, + errorMessage: null, + }; + describe('snapshots', () => { + test('renders as expected with default behavior', () => { + expect(shallow()).toMatchSnapshot(); + }); + test('renders error message', () => { + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + }); +}); diff --git a/src/editors/sharedComponents/ImageUploadModal/index.jsx b/src/editors/sharedComponents/ImageUploadModal/index.jsx index a6877da10..6fe573efb 100644 --- a/src/editors/sharedComponents/ImageUploadModal/index.jsx +++ b/src/editors/sharedComponents/ImageUploadModal/index.jsx @@ -1,10 +1,7 @@ import React from 'react'; -import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import { injectIntl } from '@edx/frontend-platform/i18n'; - -import { selectors } from '../../data/redux'; import tinyMCEKeys from '../../data/constants/tinyMCE'; import ImageSettingsModal from './ImageSettingsModal'; import SelectImageModal from './SelectImageModal'; @@ -18,9 +15,10 @@ export const imgProps = ({ settings, selection, lmsEndpointUrl, + editorType, }) => { let url = selection.externalUrl; - if (url.startsWith(lmsEndpointUrl)) { + if (url.startsWith(lmsEndpointUrl) && editorType !== 'expandable') { const sourceEndIndex = lmsEndpointUrl.length; url = url.substring(sourceEndIndex); } @@ -36,6 +34,7 @@ export const hooks = { createSaveCallback: ({ close, editorRef, + editorType, setSelection, selection, lmsEndpointUrl, @@ -49,6 +48,7 @@ export const hooks = { settings, selection, lmsEndpointUrl, + editorType, }), ); setSelection(null); @@ -58,8 +58,18 @@ export const hooks = { clearSelection(); close(); }, - imgTag: ({ settings, selection, lmsEndpointUrl }) => { - const props = module.imgProps({ settings, selection, lmsEndpointUrl }); + imgTag: ({ + settings, + selection, + lmsEndpointUrl, + editorType, + }) => { + const props = module.imgProps({ + settings, + selection, + lmsEndpointUrl, + editorType, + }); return ``; }, }; @@ -73,7 +83,7 @@ export const ImageUploadModal = ({ selection, setSelection, images, - // redux + editorType, lmsEndpointUrl, }) => { if (selection) { @@ -86,6 +96,7 @@ export const ImageUploadModal = ({ saveToEditor: module.hooks.createSaveCallback({ close, editorRef, + editorType, selection, setSelection, lmsEndpointUrl, @@ -110,6 +121,7 @@ export const ImageUploadModal = ({ ImageUploadModal.defaultProps = { editorRef: null, + editorType: null, selection: null, }; ImageUploadModal.propTypes = { @@ -128,12 +140,7 @@ ImageUploadModal.propTypes = { setSelection: PropTypes.func.isRequired, images: PropTypes.shape({}).isRequired, lmsEndpointUrl: PropTypes.string.isRequired, + editorType: PropTypes.string, }; -export const mapStateToProps = (state) => ({ - lmsEndpointUrl: selectors.app.lmsEndpointUrl(state), -}); - -export const mapDispatchToProps = {}; - -export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ImageUploadModal)); +export default injectIntl(ImageUploadModal); diff --git a/src/editors/sharedComponents/ImageUploadModal/index.test.jsx b/src/editors/sharedComponents/ImageUploadModal/index.test.jsx index 59ec3870c..bf5053951 100644 --- a/src/editors/sharedComponents/ImageUploadModal/index.test.jsx +++ b/src/editors/sharedComponents/ImageUploadModal/index.test.jsx @@ -2,7 +2,6 @@ import React from 'react'; import { shallow } from 'enzyme'; import { keyStore } from '../../utils'; -import { selectors } from '../../data/redux'; import tinyMCEKeys from '../../data/constants/tinyMCE'; import * as module from '.'; @@ -10,15 +9,7 @@ import * as module from '.'; jest.mock('./ImageSettingsModal', () => 'ImageSettingsModal'); jest.mock('./SelectImageModal', () => 'SelectImageModal'); -const { ImageUploadModal, mapStateToProps, mapDispatchToProps } = module; - -jest.mock('../../data/redux', () => ({ - selectors: { - app: { - lmsEndpointUrl: jest.fn(state => ({ lmsEndpointUrl: state })), - }, - }, -})); +const { ImageUploadModal } = module; const hookKeys = keyStore(module.hooks); const settings = { @@ -131,18 +122,4 @@ describe('ImageUploadModal', () => { expect(shallow()).toMatchSnapshot(); }); }); - - describe('mapStateToProps', () => { - const testState = { A: 'pple', B: 'anana', C: 'ucumber' }; - test('lmsEndpointUrl from app.lmsEndpointUrl', () => { - expect( - mapStateToProps(testState).lmsEndpointUrl, - ).toEqual(selectors.app.lmsEndpointUrl(testState)); - }); - }); - describe('mapDispatchToProps', () => { - it('equals an empty object', () => { - expect(mapDispatchToProps).toEqual({}); - }); - }); }); diff --git a/src/editors/sharedComponents/TinyMceWidget/__snapshots__/index.test.jsx.snap b/src/editors/sharedComponents/TinyMceWidget/__snapshots__/index.test.jsx.snap index 59ae8b86d..0a080b7f4 100644 --- a/src/editors/sharedComponents/TinyMceWidget/__snapshots__/index.test.jsx.snap +++ b/src/editors/sharedComponents/TinyMceWidget/__snapshots__/index.test.jsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`TinyMceEditor snapshots ImageUploadModal is not rendered 1`] = ` +exports[`TinyMceWidget snapshots ImageUploadModal is not rendered 1`] = ` `; -exports[`TinyMceEditor snapshots SourcecodeModal is not rendered 1`] = ` +exports[`TinyMceWidget snapshots SourcecodeModal is not rendered 1`] = ` `; -exports[`TinyMceEditor snapshots renders as expected with default behavior 1`] = ` +exports[`TinyMceWidget snapshots renders as expected with default behavior 1`] = ` @@ -125,6 +133,7 @@ exports[`TinyMceEditor snapshots renders as expected with default behavior 1`] = isOpen={false} /> `; diff --git a/src/editors/sharedComponents/TinyMceWidget/hooks.js b/src/editors/sharedComponents/TinyMceWidget/hooks.js index ce6500fa2..51f816eec 100644 --- a/src/editors/sharedComponents/TinyMceWidget/hooks.js +++ b/src/editors/sharedComponents/TinyMceWidget/hooks.js @@ -1,27 +1,28 @@ -import { useState } from 'react'; +import { + useState, + useRef, + useCallback, + useEffect, +} from 'react'; import tinyMCEStyles from '../../data/constants/tinyMCEStyles'; import { StrictDict } from '../../utils'; import pluginConfig from './pluginConfig'; import * as module from './hooks'; import tinyMCE from '../../data/constants/tinyMCE'; -import * as appHooks from '../../hooks'; - -export const { nullMethod, navigateCallback, navigateTo } = appHooks; - export const state = StrictDict({ isImageModalOpen: (val) => useState(val), isSourceCodeModalOpen: (val) => useState(val), imageSelection: (val) => useState(val), + refReady: (val) => useState(val), }); -export const parseContentForLabels = ({ editor, updateQuestion }) => { +export const parseContentForLabels = ({ editor, updateContent }) => { let content = editor.getContent(); if (content && content?.length > 0) { const parsedLabels = content.split(/