feat: expanding complex text area (#257)
This commit is contained in:
@@ -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 (
|
||||
<Collapsible.Advanced
|
||||
open={isFeedbackVisible}
|
||||
@@ -48,15 +48,21 @@ export const AnswerOption = ({
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-1 flex-grow-1">
|
||||
<Form.Control
|
||||
<ExpandableTextArea
|
||||
value={answer.title}
|
||||
setContent={setAnswerTitle}
|
||||
placeholder={intl.formatMessage(messages.answerTextboxPlaceholder)}
|
||||
id={`answer-${answer.id}`}
|
||||
/>
|
||||
{/* <Form.Control
|
||||
as="textarea"
|
||||
className="answer-option-textarea text-gray-500 small"
|
||||
autoResize
|
||||
rows={1}
|
||||
value={answer.title}
|
||||
onChange={(e) => { setAnswer({ title: e.target.value }); }}
|
||||
onChange={(e) => { setAnswerTitle(e.target.value) }}
|
||||
placeholder={intl.formatMessage(messages.answerTextboxPlaceholder)}
|
||||
/>
|
||||
/> */}
|
||||
<Collapsible.Body>
|
||||
<FeedbackBox
|
||||
problemType={problemType}
|
||||
|
||||
@@ -26,13 +26,12 @@ exports[`AnswerOption render snapshot: renders correct option with feedback 1`]
|
||||
<div
|
||||
className="ml-1 flex-grow-1"
|
||||
>
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
autoResize={true}
|
||||
className="answer-option-textarea text-gray-500 small"
|
||||
onChange={[Function]}
|
||||
<ExpandableTextArea
|
||||
error={false}
|
||||
errorMessage={null}
|
||||
id="answer-A"
|
||||
placeholder="Enter an answer"
|
||||
rows={1}
|
||||
setContent={[Function]}
|
||||
value="Answer 1"
|
||||
/>
|
||||
<Body>
|
||||
@@ -102,13 +101,12 @@ exports[`AnswerOption render snapshot: renders correct option with numeric input
|
||||
<div
|
||||
className="ml-1 flex-grow-1"
|
||||
>
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
autoResize={true}
|
||||
className="answer-option-textarea text-gray-500 small"
|
||||
onChange={[Function]}
|
||||
<ExpandableTextArea
|
||||
error={false}
|
||||
errorMessage={null}
|
||||
id="answer-A"
|
||||
placeholder="Enter an answer"
|
||||
rows={1}
|
||||
setContent={[Function]}
|
||||
value="Answer 1"
|
||||
/>
|
||||
<Body>
|
||||
@@ -179,13 +177,12 @@ exports[`AnswerOption render snapshot: renders correct option with selected unse
|
||||
<div
|
||||
className="ml-1 flex-grow-1"
|
||||
>
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
autoResize={true}
|
||||
className="answer-option-textarea text-gray-500 small"
|
||||
onChange={[Function]}
|
||||
<ExpandableTextArea
|
||||
error={false}
|
||||
errorMessage={null}
|
||||
id="answer-A"
|
||||
placeholder="Enter an answer"
|
||||
rows={1}
|
||||
setContent={[Function]}
|
||||
value="Answer 1"
|
||||
/>
|
||||
<Body>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,18 +13,13 @@ exports[`QuestionWidget render snapshot: renders correct default 1`] = `
|
||||
id="authoring.questionwidget.question.questionWidgetTitle"
|
||||
/>
|
||||
</div>
|
||||
<TinyMceWidget
|
||||
assets={Object {}}
|
||||
editorRef={null}
|
||||
editorType="problem"
|
||||
isLibrary={false}
|
||||
lmsEndpointUrl="sOmEvaLue.cOm"
|
||||
<[object Object]
|
||||
editorType="question"
|
||||
id="question"
|
||||
minHeight={150}
|
||||
placeholder="Enter your question"
|
||||
setEditorRef={[MockFunction hooks.prepareEditorRef.setEditorRef]}
|
||||
studioEndpointUrl="sOmEoThERvaLue.cOm"
|
||||
setEditorRef={[MockFunction prepareEditorRef.setEditorRef]}
|
||||
textValue="This is my question"
|
||||
updateQuestion={[MockFunction]}
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -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 (
|
||||
<div className="question-widget">
|
||||
@@ -28,42 +23,26 @@ export const QuestionWidget = ({
|
||||
<FormattedMessage {...messages.questionWidgetTitle} />
|
||||
</div>
|
||||
<TinyMceWidget
|
||||
editorType="problem"
|
||||
id="question"
|
||||
editorType="question"
|
||||
editorRef={editorRef}
|
||||
textValue={question}
|
||||
isLibrary={isLibrary}
|
||||
updateQuestion={updateQuestion}
|
||||
setEditorRef={setEditorRef}
|
||||
minHeight={150}
|
||||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
assets={assets}
|
||||
lmsEndpointUrl={lmsEndpointUrl}
|
||||
studioEndpointUrl={studioEndpointUrl}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 = {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -42,9 +42,7 @@ exports[`EditorProblemView component renders simple view 1`] = `
|
||||
<span
|
||||
className="flex-grow-1"
|
||||
>
|
||||
<injectIntl(ShimmedIntlComponent)
|
||||
assets={Object {}}
|
||||
/>
|
||||
<injectIntl(ShimmedIntlComponent) />
|
||||
<injectIntl(ShimmedIntlComponent)
|
||||
problemType="multiplechoiceresponse"
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = ({
|
||||
</Container>
|
||||
) : (
|
||||
<span className="flex-grow-1">
|
||||
<QuestionWidget assets={assets} />
|
||||
<QuestionWidget />
|
||||
<AnswerWidget problemType={problemType} />
|
||||
</span>
|
||||
)}
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -34,14 +34,7 @@ exports[`TextEditor snapshots block failed to load, Toast is shown 1`] = `
|
||||
id="authoring.texteditor.load.error"
|
||||
/>
|
||||
</Toast>
|
||||
<TinyMceWidget
|
||||
assets={
|
||||
Object {
|
||||
"sOmEaSsET": Object {
|
||||
"staTICUrl": "/assets/sOmEaSsET",
|
||||
},
|
||||
}
|
||||
}
|
||||
<[object Object]
|
||||
editorRef={
|
||||
Object {
|
||||
"current": Object {
|
||||
@@ -52,11 +45,8 @@ exports[`TextEditor snapshots block failed to load, Toast is shown 1`] = `
|
||||
editorType="text"
|
||||
height="100%"
|
||||
initializeEditor={[MockFunction args.intializeEditor]}
|
||||
isLibrary={false}
|
||||
lmsEndpointUrl="sOmEvaLue.cOm"
|
||||
minHeight={500}
|
||||
setEditorRef={[MockFunction hooks.prepareEditorRef.setEditorRef]}
|
||||
studioEndpointUrl="sOmEoThERvaLue.cOm"
|
||||
textValue="eDiTablE Text"
|
||||
/>
|
||||
</div>
|
||||
@@ -199,14 +189,7 @@ exports[`TextEditor snapshots renders as expected with default behavior 1`] = `
|
||||
id="authoring.texteditor.load.error"
|
||||
/>
|
||||
</Toast>
|
||||
<TinyMceWidget
|
||||
assets={
|
||||
Object {
|
||||
"sOmEaSsET": Object {
|
||||
"staTICUrl": "/assets/sOmEaSsET",
|
||||
},
|
||||
}
|
||||
}
|
||||
<[object Object]
|
||||
editorRef={
|
||||
Object {
|
||||
"current": Object {
|
||||
@@ -217,11 +200,8 @@ exports[`TextEditor snapshots renders as expected with default behavior 1`] = `
|
||||
editorType="text"
|
||||
height="100%"
|
||||
initializeEditor={[MockFunction args.intializeEditor]}
|
||||
isLibrary={false}
|
||||
lmsEndpointUrl="sOmEvaLue.cOm"
|
||||
minHeight={500}
|
||||
setEditorRef={[MockFunction hooks.prepareEditorRef.setEditorRef]}
|
||||
studioEndpointUrl="sOmEoThERvaLue.cOm"
|
||||
textValue="eDiTablE Text"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = ({
|
||||
</div>
|
||||
) : (selectEditor())}
|
||||
</div>
|
||||
|
||||
</EditorContainer>
|
||||
);
|
||||
};
|
||||
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),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M3.5 22C3.08333 22 2.72917 21.8542 2.4375 21.5625C2.14583 21.2708 2 20.9167 2 20.5V3.5C2 3.08333 2.14583 2.72917 2.4375 2.4375C2.72917 2.14583 3.08333 2 3.5 2H13L11.5 3.5H3.5V20.5H15.5V17H17V20.5C17 20.9167 16.8542 21.2708 16.5625 21.5625C16.2708 21.8542 15.9167 22 15.5 22H3.5ZM6 17.75V16.25H13V17.75H6ZM6 14.75V13.25H11V14.75H6ZM15.5 15L11.5 11H8V6H11.5L15.5 2V15ZM17 12.7V4.05C17.9333 4.4 18.6667 5.01667 19.2 5.9C19.7333 6.78333 20 7.65 20 8.5C20 9.35 19.7083 10.1917 19.125 11.025C18.5417 11.8583 17.8333 12.4167 17 12.7ZM17 16.25V14.7C18.1667 14.2833 19.2083 13.5333 20.125 12.45C21.0417 11.3667 21.5 10.05 21.5 8.5C21.5 6.95 21.0417 5.63333 20.125 4.55C19.2083 3.46667 18.1667 2.71667 17 2.3V0.75C18.7 1.2 20.125 2.1375 21.275 3.5625C22.425 4.9875 23 6.63333 23 8.5C23 10.3667 22.425 12.0125 21.275 13.4375C20.125 14.8625 18.7 15.8 17 16.25Z" fill="black"/></svg>';
|
||||
|
||||
@@ -142,6 +142,7 @@ export const apiMethods = {
|
||||
metadata: { display_name: title },
|
||||
};
|
||||
} else if (blockType === 'problem') {
|
||||
// console.log(type);
|
||||
response = {
|
||||
data: content.olx,
|
||||
category: blockType,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ExpandableTextArea snapshots renders as expected with default behavior 1`] = `
|
||||
<Fragment>
|
||||
<div
|
||||
className="expandable-mce error"
|
||||
>
|
||||
<TinyMceWidget
|
||||
editorRef={
|
||||
Object {
|
||||
"current": null,
|
||||
}
|
||||
}
|
||||
editorType="expandable"
|
||||
placeholder={null}
|
||||
setEditorRef={[Function]}
|
||||
textValue="text"
|
||||
updateContent={[MockFunction]}
|
||||
/>
|
||||
</div>
|
||||
</Fragment>
|
||||
`;
|
||||
|
||||
exports[`ExpandableTextArea snapshots renders error message 1`] = `
|
||||
<Fragment>
|
||||
<div
|
||||
className="expandable-mce error"
|
||||
>
|
||||
<TinyMceWidget
|
||||
editorRef={
|
||||
Object {
|
||||
"current": null,
|
||||
}
|
||||
}
|
||||
editorType="expandable"
|
||||
placeholder={null}
|
||||
setEditorRef={[Function]}
|
||||
textValue="text"
|
||||
updateContent={[MockFunction]}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="text-danger-500 x-small"
|
||||
/>
|
||||
</Fragment>
|
||||
`;
|
||||
52
src/editors/sharedComponents/ExpandableTextArea/index.jsx
Normal file
52
src/editors/sharedComponents/ExpandableTextArea/index.jsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<div className="expandable-mce error">
|
||||
<TinyMceWidget
|
||||
textValue={value}
|
||||
editorRef={editorRef}
|
||||
editorType="expandable"
|
||||
setEditorRef={setEditorRef}
|
||||
updateContent={setContent}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="text-danger-500 x-small">
|
||||
{props.errorMessage}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
25
src/editors/sharedComponents/ExpandableTextArea/index.scss
Normal file
25
src/editors/sharedComponents/ExpandableTextArea/index.scss
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(<ExpandableTextArea {...props} />)).toMatchSnapshot();
|
||||
});
|
||||
test('renders error message', () => {
|
||||
const wrapper = shallow(<ExpandableTextArea {...props} error errorMessage="eRRormeSsaGE" />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 `<img ${propsString(props)} />`;
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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(<ImageUploadModal {...props} selection={null} />)).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({});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`] = `
|
||||
<Fragment>
|
||||
<SourceCodeModal
|
||||
close={[MockFunction modal.closeModal]}
|
||||
@@ -14,6 +14,7 @@ exports[`TinyMceEditor snapshots ImageUploadModal is not rendered 1`] = `
|
||||
isOpen={false}
|
||||
/>
|
||||
<Editor
|
||||
disabled={false}
|
||||
editorConfig={
|
||||
Object {
|
||||
"clearSelection": [MockFunction hooks.selectedImage.clearSelection],
|
||||
@@ -36,11 +37,12 @@ exports[`TinyMceEditor snapshots ImageUploadModal is not rendered 1`] = `
|
||||
"studioEndpointUrl": "sOmEoThERvaLue.cOm",
|
||||
}
|
||||
}
|
||||
id="sOMeiD"
|
||||
/>
|
||||
</Fragment>
|
||||
`;
|
||||
|
||||
exports[`TinyMceEditor snapshots SourcecodeModal is not rendered 1`] = `
|
||||
exports[`TinyMceWidget snapshots SourcecodeModal is not rendered 1`] = `
|
||||
<Fragment>
|
||||
<ImageUploadModal
|
||||
clearSelection={[MockFunction hooks.selectedImage.clearSelection]}
|
||||
@@ -52,6 +54,7 @@ exports[`TinyMceEditor snapshots SourcecodeModal is not rendered 1`] = `
|
||||
},
|
||||
}
|
||||
}
|
||||
editorType="problem"
|
||||
images={
|
||||
Array [
|
||||
Object {
|
||||
@@ -60,10 +63,12 @@ exports[`TinyMceEditor snapshots SourcecodeModal is not rendered 1`] = `
|
||||
]
|
||||
}
|
||||
isOpen={false}
|
||||
lmsEndpointUrl="sOmEvaLue.cOm"
|
||||
selection="hooks.selectedImage.selection"
|
||||
setSelection={[MockFunction hooks.selectedImage.setSelection]}
|
||||
/>
|
||||
<Editor
|
||||
disabled={false}
|
||||
editorConfig={
|
||||
Object {
|
||||
"clearSelection": [MockFunction hooks.selectedImage.clearSelection],
|
||||
@@ -86,11 +91,12 @@ exports[`TinyMceEditor snapshots SourcecodeModal is not rendered 1`] = `
|
||||
"studioEndpointUrl": "sOmEoThERvaLue.cOm",
|
||||
}
|
||||
}
|
||||
id="sOMeiD"
|
||||
/>
|
||||
</Fragment>
|
||||
`;
|
||||
|
||||
exports[`TinyMceEditor snapshots renders as expected with default behavior 1`] = `
|
||||
exports[`TinyMceWidget snapshots renders as expected with default behavior 1`] = `
|
||||
<Fragment>
|
||||
<ImageUploadModal
|
||||
clearSelection={[MockFunction hooks.selectedImage.clearSelection]}
|
||||
@@ -102,6 +108,7 @@ exports[`TinyMceEditor snapshots renders as expected with default behavior 1`] =
|
||||
},
|
||||
}
|
||||
}
|
||||
editorType="text"
|
||||
images={
|
||||
Array [
|
||||
Object {
|
||||
@@ -110,6 +117,7 @@ exports[`TinyMceEditor snapshots renders as expected with default behavior 1`] =
|
||||
]
|
||||
}
|
||||
isOpen={false}
|
||||
lmsEndpointUrl="sOmEvaLue.cOm"
|
||||
selection="hooks.selectedImage.selection"
|
||||
setSelection={[MockFunction hooks.selectedImage.setSelection]}
|
||||
/>
|
||||
@@ -125,6 +133,7 @@ exports[`TinyMceEditor snapshots renders as expected with default behavior 1`] =
|
||||
isOpen={false}
|
||||
/>
|
||||
<Editor
|
||||
disabled={false}
|
||||
editorConfig={
|
||||
Object {
|
||||
"clearSelection": [MockFunction hooks.selectedImage.clearSelection],
|
||||
@@ -147,6 +156,7 @@ exports[`TinyMceEditor snapshots renders as expected with default behavior 1`] =
|
||||
"studioEndpointUrl": "sOmEoThERvaLue.cOm",
|
||||
}
|
||||
}
|
||||
id="sOMeiD"
|
||||
/>
|
||||
</Fragment>
|
||||
`;
|
||||
|
||||
@@ -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(/<label>|<\/label>/gm);
|
||||
let updatedContent;
|
||||
parsedLabels.forEach((label, i) => {
|
||||
let updatedLabel = label;
|
||||
if (!label.startsWith('<') && !label.endsWith('>')) {
|
||||
let previousLabel = parsedLabels[i - 1];
|
||||
let nextLabel = parsedLabels[i + 1];
|
||||
@@ -29,51 +30,64 @@ export const parseContentForLabels = ({ editor, updateQuestion }) => {
|
||||
previousLabel = `${previousLabel}</p><p>`;
|
||||
updatedContent = content.replace(parsedLabels[i - 1], previousLabel);
|
||||
content = updatedContent;
|
||||
}
|
||||
if (previousLabel.endsWith('</p>') && !label.startWith('<p>')) {
|
||||
updatedLabel = `<p>${label}`;
|
||||
updatedContent = content.replace(label, updatedLabel);
|
||||
content = updatedContent;
|
||||
updateContent(content);
|
||||
}
|
||||
if (!nextLabel.startsWith('</p>')) {
|
||||
nextLabel = `</p><p>${nextLabel}`;
|
||||
updatedContent = content.replace(parsedLabels[i + 1], nextLabel);
|
||||
content = updatedContent;
|
||||
updateContent(content);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
updateContent(content);
|
||||
}
|
||||
updateQuestion(content);
|
||||
};
|
||||
|
||||
export const replaceStaticwithAsset = (editor, imageUrls) => {
|
||||
const content = editor.getContent();
|
||||
export const replaceStaticwithAsset = ({
|
||||
editor,
|
||||
imageUrls,
|
||||
editorType,
|
||||
lmsEndpointUrl,
|
||||
updateContent,
|
||||
}) => {
|
||||
let content = editor.getContent();
|
||||
const imageSrcs = content.split('src="');
|
||||
imageSrcs.forEach(src => {
|
||||
const currentContent = content;
|
||||
let staticFullUrl;
|
||||
if (src.startsWith('/static/') && imageUrls.length > 0) {
|
||||
const imgName = src.substring(8, src.indexOf('"'));
|
||||
let staticFullUrl;
|
||||
imageUrls.forEach((url) => {
|
||||
if (imgName === url.displayName) {
|
||||
staticFullUrl = url.staticFullUrl;
|
||||
if (editorType === 'expandable') {
|
||||
staticFullUrl = `${lmsEndpointUrl}${url.staticFullUrl}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (staticFullUrl) {
|
||||
const currentSrc = src.substring(0, src.indexOf('"'));
|
||||
const updatedContent = content.replace(currentSrc, staticFullUrl);
|
||||
editor.setContent(updatedContent);
|
||||
content = currentContent.replace(currentSrc, staticFullUrl);
|
||||
if (editorType === 'expandable') {
|
||||
updateContent(content);
|
||||
} else {
|
||||
editor.setContent(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const setupCustomBehavior = ({
|
||||
updateQuestion,
|
||||
updateContent,
|
||||
openImgModal,
|
||||
openSourceCodeModal,
|
||||
setImage,
|
||||
editorType,
|
||||
imageUrls,
|
||||
lmsEndpointUrl,
|
||||
}) => (editor) => {
|
||||
// image upload button
|
||||
editor.ui.registry.addButton(tinyMCE.buttons.imageUploadButton, {
|
||||
@@ -122,17 +136,20 @@ export const setupCustomBehavior = ({
|
||||
tooltip: 'Apply a "Question" label to specific text, recognized by screen readers. Recommended to improve accessibility.',
|
||||
onAction: toggleLabelFormatting,
|
||||
});
|
||||
editor.on('blur', () => {
|
||||
if (editorType === 'problem') {
|
||||
module.parseContentForLabels({
|
||||
if (editorType === 'expandable') {
|
||||
editor.on('init', () => {
|
||||
module.replaceStaticwithAsset({
|
||||
editor,
|
||||
updateQuestion,
|
||||
imageUrls,
|
||||
editorType,
|
||||
lmsEndpointUrl,
|
||||
updateContent,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
editor.on('ExecCommand', (e) => {
|
||||
if (e.command === 'mceFocus') {
|
||||
module.replaceStaticwithAsset(editor, imageUrls);
|
||||
if (editorType === 'text' && e.command === 'mceFocus') {
|
||||
module.replaceStaticwithAsset({ editor, imageUrls });
|
||||
}
|
||||
if (e.command === 'RemoveFormat') {
|
||||
editor.formatter.remove('blockquote');
|
||||
@@ -157,7 +174,7 @@ export const editorConfig = ({
|
||||
openImgModal,
|
||||
openSourceCodeModal,
|
||||
setSelection,
|
||||
updateQuestion,
|
||||
updateContent,
|
||||
minHeight,
|
||||
}) => {
|
||||
const {
|
||||
@@ -165,6 +182,8 @@ export const editorConfig = ({
|
||||
config,
|
||||
plugins,
|
||||
imageToolbar,
|
||||
quickbarsInsertToolbar,
|
||||
quickbarsSelectionToolbar,
|
||||
} = pluginConfig({ isLibrary, placeholder, editorType });
|
||||
return {
|
||||
onInit: (evt, editor) => {
|
||||
@@ -187,12 +206,15 @@ export const editorConfig = ({
|
||||
formats: { label: { inline: 'label' } },
|
||||
setup: module.setupCustomBehavior({
|
||||
editorType,
|
||||
updateQuestion,
|
||||
updateContent,
|
||||
openImgModal,
|
||||
openSourceCodeModal,
|
||||
lmsEndpointUrl,
|
||||
setImage: setSelection,
|
||||
imageUrls: module.fetchImageUrls(images),
|
||||
}),
|
||||
quickbars_insert_toolbar: quickbarsInsertToolbar,
|
||||
quickbars_selection_toolbar: quickbarsSelectionToolbar,
|
||||
toolbar,
|
||||
plugins,
|
||||
valid_children: '+body[style]',
|
||||
@@ -202,6 +224,16 @@ export const editorConfig = ({
|
||||
};
|
||||
};
|
||||
|
||||
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 imgModalToggle = () => {
|
||||
const [isImgOpen, setIsOpen] = module.state.isImageModalOpen(false);
|
||||
return {
|
||||
@@ -243,22 +275,27 @@ export const filterAssets = ({ assets }) => {
|
||||
return images;
|
||||
};
|
||||
|
||||
export const setAssetToStaticUrl = ({ editorValue, assets }) => {
|
||||
export const setAssetToStaticUrl = ({ editorValue, assets, lmsEndpointUrl }) => {
|
||||
/* For assets to remain usable across course instances, we convert their url to be course-agnostic.
|
||||
* For example, /assets/course/<asset hash>/filename gets converted to /static/filename. This is
|
||||
* important for rerunning courses and importing/exporting course as the /static/ part of the url
|
||||
* allows the asset to be mapped to the new course run.
|
||||
*/
|
||||
let content = editorValue;
|
||||
|
||||
// TODO: should probably move this to when the assets are being looped through in the off chance that
|
||||
// some of the text in the editor contains the lmsEndpointUrl
|
||||
const regExLmsEndpointUrl = RegExp(lmsEndpointUrl, 'g');
|
||||
let content = editorValue.replace(regExLmsEndpointUrl, '');
|
||||
|
||||
const assetUrls = [];
|
||||
const assetsList = Object.values(assets);
|
||||
assetsList.forEach(asset => {
|
||||
assetUrls.push({ portableUrl: asset.portableUrl, displayName: asset.displayName });
|
||||
});
|
||||
const assetSrcs = typeof content === 'string' ? content.split(/(src="|href=")/g) : [];
|
||||
const assetSrcs = typeof content === 'string' ? content.split(/(src="|src="|href="|href=")/g) : [];
|
||||
assetSrcs.forEach(src => {
|
||||
if (src.startsWith('/asset') && assetUrls.length > 0) {
|
||||
const assetBlockName = src.substring(src.indexOf('@') + 1, src.indexOf('"'));
|
||||
const assetBlockName = src.substring(src.indexOf('@') + 1, src.search(/("|")/));
|
||||
const nameFromEditorSrc = assetBlockName.substring(assetBlockName.indexOf('@') + 1);
|
||||
const nameFromStudioSrc = assetBlockName.substring(assetBlockName.indexOf('/') + 1);
|
||||
let portableUrl;
|
||||
@@ -269,7 +306,7 @@ export const setAssetToStaticUrl = ({ editorValue, assets }) => {
|
||||
}
|
||||
});
|
||||
if (portableUrl) {
|
||||
const currentSrc = src.substring(0, src.indexOf('"'));
|
||||
const currentSrc = src.substring(0, src.search(/("|")/));
|
||||
const updatedContent = content.replace(currentSrc, portableUrl);
|
||||
content = updatedContent;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,14 @@ import { keyStore } from '../../utils';
|
||||
import pluginConfig from './pluginConfig';
|
||||
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);
|
||||
const moduleKeys = keyStore(module);
|
||||
|
||||
@@ -40,8 +48,9 @@ describe('TinyMceEditor hooks', () => {
|
||||
const openImgModal = jest.fn();
|
||||
const openSourceCodeModal = jest.fn();
|
||||
const setImage = jest.fn();
|
||||
const updateQuestion = jest.fn();
|
||||
const updateContent = jest.fn();
|
||||
const editorType = 'SOmeEDitor';
|
||||
const lmsEndpointUrl = 'sOmEvaLue.cOm';
|
||||
const editor = {
|
||||
ui: { registry: { addButton, addToggleButton, addIcon } },
|
||||
on: jest.fn(),
|
||||
@@ -55,10 +64,11 @@ describe('TinyMceEditor hooks', () => {
|
||||
.mockImplementationOnce(mockOpenModalWithImage);
|
||||
output = module.setupCustomBehavior({
|
||||
editorType,
|
||||
updateQuestion,
|
||||
updateContent,
|
||||
openImgModal,
|
||||
openSourceCodeModal,
|
||||
setImage,
|
||||
lmsEndpointUrl,
|
||||
})(editor);
|
||||
expect(addIcon.mock.calls).toEqual([['textToSpeech', tinyMCE.textToSpeechIcon]]);
|
||||
expect(addButton.mock.calls).toEqual([
|
||||
@@ -84,30 +94,47 @@ describe('TinyMceEditor hooks', () => {
|
||||
describe('parseContentForLabels', () => {
|
||||
test('it calls getContent and updateQuestion for some content', () => {
|
||||
const editor = { getContent: jest.fn(() => '<p><label>Some question label</label></p><p>some content <label>around a label</label> followed by more text</p><img src="/static/soMEImagEURl1.jpeg"/>') };
|
||||
const updateQuestion = jest.fn();
|
||||
const updateContent = jest.fn();
|
||||
const content = '<p><label>Some question label</label></p><p>some content </p><p><label>around a label</label></p><p> followed by more text</p><img src="/static/soMEImagEURl1.jpeg"/>';
|
||||
module.parseContentForLabels({ editor, updateQuestion });
|
||||
module.parseContentForLabels({ editor, updateContent });
|
||||
expect(editor.getContent).toHaveBeenCalled();
|
||||
expect(updateQuestion).toHaveBeenCalledWith(content);
|
||||
expect(updateContent).toHaveBeenCalledWith(content);
|
||||
});
|
||||
test('it calls getContent and updateQuestion for empty content', () => {
|
||||
const editor = { getContent: jest.fn(() => '') };
|
||||
const updateQuestion = jest.fn();
|
||||
const updateContent = jest.fn();
|
||||
const content = '';
|
||||
module.parseContentForLabels({ editor, updateQuestion });
|
||||
module.parseContentForLabels({ editor, updateContent });
|
||||
expect(editor.getContent).toHaveBeenCalled();
|
||||
expect(updateQuestion).toHaveBeenCalledWith(content);
|
||||
expect(updateContent).toHaveBeenCalledWith(content);
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceStaticwithAsset', () => {
|
||||
test('it calls getContent and setContent', () => {
|
||||
test('it calls getContent and setContent for text editor', () => {
|
||||
const editor = { getContent: jest.fn(() => '<img src="/static/soMEImagEURl1.jpeg"/>'), setContent: jest.fn() };
|
||||
const imageUrls = [{ staticFullUrl: '/assets/soMEImagEURl1.jpeg', displayName: 'soMEImagEURl1.jpeg' }];
|
||||
module.replaceStaticwithAsset(editor, imageUrls);
|
||||
const lmsEndpointUrl = 'sOmEvaLue.cOm';
|
||||
module.replaceStaticwithAsset({ editor, imageUrls, lmsEndpointUrl });
|
||||
expect(editor.getContent).toHaveBeenCalled();
|
||||
expect(editor.setContent).toHaveBeenCalled();
|
||||
});
|
||||
test('it calls getContent and updateContent for expandable editor', () => {
|
||||
const editor = { getContent: jest.fn(() => '<img src="/static/soMEImagEURl1.jpeg"/>') };
|
||||
const imageUrls = [{ staticFullUrl: '/assets/soMEImagEURl1.jpeg', displayName: 'soMEImagEURl1.jpeg' }];
|
||||
const lmsEndpointUrl = 'sOmEvaLue.cOm';
|
||||
const editorType = 'expandable';
|
||||
const updateContent = jest.fn();
|
||||
module.replaceStaticwithAsset({
|
||||
editor,
|
||||
imageUrls,
|
||||
editorType,
|
||||
lmsEndpointUrl,
|
||||
updateContent,
|
||||
});
|
||||
expect(editor.getContent).toHaveBeenCalled();
|
||||
expect(updateContent).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe('setAssetToStaticUrl', () => {
|
||||
it('returns content with updated img links', () => {
|
||||
@@ -116,7 +143,8 @@ describe('TinyMceEditor hooks', () => {
|
||||
{ portableUrl: '/static/soMEImagEURl', displayName: 'soMEImagEURl' },
|
||||
{ portableUrl: '/static/soME_ImagE_URl1', displayName: 'soME ImagE URl1' },
|
||||
];
|
||||
const content = module.setAssetToStaticUrl({ editorValue, assets });
|
||||
const lmsEndpointUrl = 'sOmEvaLue.cOm';
|
||||
const content = module.setAssetToStaticUrl({ editorValue, assets, lmsEndpointUrl });
|
||||
expect(content).toEqual('<img src="/static/soME_ImagE_URl1"/> <a href="/static/soMEImagEURl">testing link</a>');
|
||||
});
|
||||
});
|
||||
@@ -138,18 +166,22 @@ describe('TinyMceEditor hooks', () => {
|
||||
props.openImgModal = jest.fn();
|
||||
props.openSourceCodeModal = jest.fn();
|
||||
props.initializeEditor = jest.fn();
|
||||
props.updateQuestion = jest.fn();
|
||||
props.updateContent = jest.fn();
|
||||
jest.spyOn(module, moduleKeys.setupCustomBehavior)
|
||||
.mockImplementationOnce(setupCustomBehavior);
|
||||
output = module.editorConfig(props);
|
||||
});
|
||||
describe('text editor plugins and toolbar', () => {
|
||||
test('It configures plugins and toolbars correctly', () => {
|
||||
expect(output.init.plugins).toEqual(pluginConfig({ isLibrary: props.isLibrary }).plugins);
|
||||
expect(output.init.imagetools_toolbar).toEqual(pluginConfig({ isLibrary: props.isLibrary }).imageToolbar);
|
||||
expect(output.init.toolbar).toEqual(pluginConfig({ isLibrary: props.isLibrary }).toolbar);
|
||||
Object.keys(pluginConfig({ isLibrary: props.isLibrary }).config).forEach(key => {
|
||||
expect(output.init[key]).toEqual(pluginConfig({ isLibrary: props.isLibrary }).config[key]);
|
||||
const pluginProps = {
|
||||
isLibrary: props.isLibrary,
|
||||
editorType: props.editorType,
|
||||
};
|
||||
expect(output.init.plugins).toEqual(pluginConfig(pluginProps).plugins);
|
||||
expect(output.init.imagetools_toolbar).toEqual(pluginConfig(pluginProps).imageToolbar);
|
||||
expect(output.init.toolbar).toEqual(pluginConfig(pluginProps).toolbar);
|
||||
Object.keys(pluginConfig(pluginProps).config).forEach(key => {
|
||||
expect(output.init[key]).toEqual(pluginConfig(pluginProps).config[key]);
|
||||
});
|
||||
// Commented out as we investigate whether this is only needed for image proxy
|
||||
// expect(output.init.imagetools_cors_hosts).toMatchObject([props.lmsEndpointUrl]);
|
||||
@@ -159,31 +191,59 @@ describe('TinyMceEditor hooks', () => {
|
||||
test('It configures plugins and toolbars correctly', () => {
|
||||
const pluginProps = {
|
||||
isLibrary: true,
|
||||
editorType: props.editorType,
|
||||
};
|
||||
output = module.editorConfig({ ...props, isLibrary: true });
|
||||
expect(output.init.plugins).toEqual(pluginConfig(pluginProps).plugins);
|
||||
expect(output.init.imagetools_toolbar).toEqual(pluginConfig(pluginProps).imageToolbar);
|
||||
expect(output.init.toolbar).toEqual(pluginConfig(pluginProps).toolbar);
|
||||
expect(output.init.quickbars_insert_toolbar).toEqual(pluginConfig(pluginProps).quickbarsInsertToolbar);
|
||||
expect(output.init.quickbars_selection_toolbar).toEqual(pluginConfig(pluginProps).quickbarsSelectionToolbar);
|
||||
Object.keys(pluginConfig(pluginProps).config).forEach(key => {
|
||||
expect(output.init[key]).toEqual(pluginConfig(pluginProps).config[key]);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('problem editor plugins and toolbar', () => {
|
||||
describe('problem editor question plugins and toolbar', () => {
|
||||
test('It configures plugins and toolbars correctly', () => {
|
||||
const pluginProps = {
|
||||
isLibrary: props.isLibrary,
|
||||
editorType: 'problem',
|
||||
editorType: 'question',
|
||||
placeholder: 'soMEtExT',
|
||||
};
|
||||
output = module.editorConfig({
|
||||
...props,
|
||||
editorType: 'problem',
|
||||
editorType: 'question',
|
||||
placeholder: 'soMEtExT',
|
||||
});
|
||||
expect(output.init.plugins).toEqual(pluginConfig(pluginProps).plugins);
|
||||
expect(output.init.imagetools_toolbar).toEqual(pluginConfig(pluginProps).imageToolbar);
|
||||
expect(output.init.toolbar).toEqual(pluginConfig(pluginProps).toolbar);
|
||||
expect(output.init.quickbars_insert_toolbar).toEqual(pluginConfig(pluginProps).quickbarsInsertToolbar);
|
||||
expect(output.init.quickbars_selection_toolbar).toEqual(pluginConfig(pluginProps).quickbarsSelectionToolbar);
|
||||
Object.keys(pluginConfig(pluginProps).config).forEach(key => {
|
||||
expect(output.init[key]).toEqual(pluginConfig(pluginProps).config[key]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandable text area plugins and toolbar', () => {
|
||||
test('It configures plugins, toolbars, and quick toolbars correctly', () => {
|
||||
const pluginProps = {
|
||||
isLibrary: props.isLibrary,
|
||||
editorType: 'expandable',
|
||||
placeholder: 'soMEtExT',
|
||||
};
|
||||
output = module.editorConfig({
|
||||
...props,
|
||||
editorType: 'expandable',
|
||||
placeholder: 'soMEtExT',
|
||||
});
|
||||
expect(output.init.plugins).toEqual(pluginConfig(pluginProps).plugins);
|
||||
expect(output.init.imagetools_toolbar).toEqual(pluginConfig(pluginProps).imageToolbar);
|
||||
expect(output.init.toolbar).toEqual(pluginConfig(pluginProps).toolbar);
|
||||
expect(output.init.quickbars_insert_toolbar).toEqual(pluginConfig(pluginProps).quickbarsInsertToolbar);
|
||||
expect(output.init.quickbars_selection_toolbar).toEqual(pluginConfig(pluginProps).quickbarsSelectionToolbar);
|
||||
Object.keys(pluginConfig(pluginProps).config).forEach(key => {
|
||||
expect(output.init[key]).toEqual(pluginConfig(pluginProps).config[key]);
|
||||
});
|
||||
@@ -207,16 +267,30 @@ describe('TinyMceEditor hooks', () => {
|
||||
expect(output.init.setup).toEqual(
|
||||
setupCustomBehavior({
|
||||
editorType: props.editorType,
|
||||
updateQuestion: props.updateQuestion,
|
||||
updateContent: props.updateContent,
|
||||
openImgModal: props.openImgModal,
|
||||
openSourceCodeModal: props.openSourceCodeModal,
|
||||
setImage: props.setSelection,
|
||||
imageUrls: module.fetchImageUrls(props.images),
|
||||
lmsEndpointUrl: props.lmsEndpointUrl,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterAssets', () => {
|
||||
const emptyAssets = {};
|
||||
const assets = { sOmEaSsET: { contentType: 'image/' } };
|
||||
test('returns an empty array', () => {
|
||||
const emptyFilterAssets = module.filterAssets({ assets: emptyAssets });
|
||||
expect(emptyFilterAssets).toEqual([]);
|
||||
});
|
||||
test('returns filtered array of images', () => {
|
||||
const FilteredAssets = module.filterAssets({ assets });
|
||||
expect(FilteredAssets).toEqual([{ contentType: 'image/' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('imgModalToggle', () => {
|
||||
const hookKey = state.keys.isImageModalOpen;
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Editor } from '@tinymce/tinymce-react';
|
||||
|
||||
@@ -18,7 +19,9 @@ import 'tinymce/plugins/code';
|
||||
import 'tinymce/plugins/autoresize';
|
||||
import 'tinymce/plugins/image';
|
||||
import 'tinymce/plugins/imagetools';
|
||||
import 'tinymce/plugins/quickbars';
|
||||
|
||||
import { selectors } from '../../data/redux';
|
||||
import ImageUploadModal from '../ImageUploadModal';
|
||||
import SourceCodeModal from '../SourceCodeModal';
|
||||
import * as hooks from './hooks';
|
||||
@@ -26,8 +29,13 @@ import * as hooks from './hooks';
|
||||
export const TinyMceWidget = ({
|
||||
editorType,
|
||||
editorRef,
|
||||
disabled,
|
||||
id,
|
||||
// redux
|
||||
assets,
|
||||
isLibrary,
|
||||
lmsEndpointUrl,
|
||||
studioEndpointUrl,
|
||||
...props
|
||||
}) => {
|
||||
const { isImgOpen, openImgModal, closeImgModal } = hooks.imgModalToggle();
|
||||
@@ -42,6 +50,9 @@ export const TinyMceWidget = ({
|
||||
close={closeImgModal}
|
||||
editorRef={editorRef}
|
||||
images={images}
|
||||
editorType={editorType}
|
||||
lmsEndpointUrl={lmsEndpointUrl}
|
||||
// bookmark={editorRef.current.selection.getBookmark()}
|
||||
{...imageSelection}
|
||||
/>
|
||||
)}
|
||||
@@ -53,6 +64,8 @@ export const TinyMceWidget = ({
|
||||
/>
|
||||
) : null}
|
||||
<Editor
|
||||
id={id}
|
||||
disabled={disabled}
|
||||
{
|
||||
...hooks.editorConfig({
|
||||
openImgModal,
|
||||
@@ -60,6 +73,8 @@ export const TinyMceWidget = ({
|
||||
editorType,
|
||||
editorRef,
|
||||
isLibrary,
|
||||
lmsEndpointUrl,
|
||||
studioEndpointUrl,
|
||||
images,
|
||||
setSelection: imageSelection.setSelection,
|
||||
clearSelection: imageSelection.clearSelection,
|
||||
@@ -72,15 +87,31 @@ export const TinyMceWidget = ({
|
||||
};
|
||||
TinyMceWidget.defaultProps = {
|
||||
isLibrary: null,
|
||||
assets: null,
|
||||
editorType: null,
|
||||
editorRef: null,
|
||||
lmsEndpointUrl: null,
|
||||
studioEndpointUrl: null,
|
||||
assets: null,
|
||||
id: null,
|
||||
disabled: false,
|
||||
};
|
||||
TinyMceWidget.propTypes = {
|
||||
editorType: PropTypes.string,
|
||||
isLibrary: PropTypes.bool,
|
||||
assets: PropTypes.shape({}),
|
||||
editorRef: PropTypes.shape({}),
|
||||
lmsEndpointUrl: PropTypes.string,
|
||||
studioEndpointUrl: PropTypes.string,
|
||||
id: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default TinyMceWidget;
|
||||
// should we call these items for
|
||||
export const mapStateToProps = (state) => ({
|
||||
assets: selectors.app.assets(state),
|
||||
lmsEndpointUrl: selectors.app.lmsEndpointUrl(state),
|
||||
studioEndpointUrl: selectors.app.studioEndpointUrl(state),
|
||||
isLibrary: selectors.app.isLibrary(state),
|
||||
});
|
||||
|
||||
export default (connect(mapStateToProps)(TinyMceWidget));
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { selectors } from '../../data/redux';
|
||||
import SourceCodeModal from '../SourceCodeModal';
|
||||
import ImageUploadModal from '../ImageUploadModal';
|
||||
import { imgModalToggle, sourceCodeModalToggle } from './hooks';
|
||||
import TinyMceEditor from '.';
|
||||
import { TinyMceWidget, mapStateToProps } 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.
|
||||
@@ -19,6 +20,17 @@ jest.mock('@tinymce/tinymce-react', () => {
|
||||
jest.mock('../ImageUploadModal', () => 'ImageUploadModal');
|
||||
jest.mock('../SourceCodeModal', () => 'SourceCodeModal');
|
||||
|
||||
jest.mock('../../data/redux', () => ({
|
||||
selectors: {
|
||||
app: {
|
||||
lmsEndpointUrl: jest.fn(state => ({ lmsEndpointUrl: state })),
|
||||
studioEndpointUrl: jest.fn(state => ({ studioEndpointUrl: state })),
|
||||
isLibrary: jest.fn(state => ({ isLibrary: state })),
|
||||
assets: jest.fn(state => ({ assets: state })),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('./hooks', () => ({
|
||||
editorConfig: jest.fn(args => ({ editorConfig: args })),
|
||||
imgModalToggle: jest.fn(() => ({
|
||||
@@ -39,7 +51,7 @@ jest.mock('./hooks', () => ({
|
||||
filterAssets: jest.fn(() => [{ staTICUrl: '/assets/sOmEaSsET' }]),
|
||||
}));
|
||||
|
||||
describe('TinyMceEditor', () => {
|
||||
describe('TinyMceWidget', () => {
|
||||
const props = {
|
||||
editorType: 'text',
|
||||
editorRef: { current: { value: 'something' } },
|
||||
@@ -47,6 +59,8 @@ describe('TinyMceEditor', () => {
|
||||
assets: { sOmEaSsET: { staTICUrl: '/assets/sOmEaSsET' } },
|
||||
lmsEndpointUrl: 'sOmEvaLue.cOm',
|
||||
studioEndpointUrl: 'sOmEoThERvaLue.cOm',
|
||||
disabled: false,
|
||||
id: 'sOMeiD',
|
||||
};
|
||||
describe('snapshots', () => {
|
||||
imgModalToggle.mockReturnValue({
|
||||
@@ -60,17 +74,40 @@ describe('TinyMceEditor', () => {
|
||||
closeSourceCodeModal: jest.fn().mockName('modal.closeModal'),
|
||||
});
|
||||
test('renders as expected with default behavior', () => {
|
||||
expect(shallow(<TinyMceEditor {...props} />)).toMatchSnapshot();
|
||||
expect(shallow(<TinyMceWidget {...props} />)).toMatchSnapshot();
|
||||
});
|
||||
test('SourcecodeModal is not rendered', () => {
|
||||
const wrapper = shallow(<TinyMceEditor {...props} editorType="problem" />);
|
||||
const wrapper = shallow(<TinyMceWidget {...props} editorType="problem" />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
expect(wrapper.find(SourceCodeModal).length).toBe(0);
|
||||
});
|
||||
test('ImageUploadModal is not rendered', () => {
|
||||
const wrapper = shallow(<TinyMceEditor {...props} isLibrary />);
|
||||
const wrapper = shallow(<TinyMceWidget {...props} isLibrary />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
expect(wrapper.find(ImageUploadModal).length).toBe(0);
|
||||
});
|
||||
});
|
||||
describe('mapStateToProps', () => {
|
||||
const testState = { A: 'pple', B: 'anana', C: 'ucumber' };
|
||||
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,
|
||||
).toEqual(selectors.app.assets(testState));
|
||||
});
|
||||
test('isLibrary from app.isLibrary', () => {
|
||||
expect(
|
||||
mapStateToProps(testState).isLibrary,
|
||||
).toEqual(selectors.app.isLibrary(testState));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,9 +8,12 @@ const pluginConfig = ({ isLibrary, placeholder, editorType }) => {
|
||||
const imageTools = isLibrary ? '' : plugins.imagetools;
|
||||
const imageUploadButton = isLibrary ? '' : buttons.imageUploadButton;
|
||||
const editImageSettings = isLibrary ? '' : buttons.editImageSettings;
|
||||
const codePlugin = editorType === 'problem' ? '' : plugins.code;
|
||||
const codeButton = editorType === 'problem' ? '' : buttons.code;
|
||||
const labelButton = editorType === 'problem' ? buttons.customLabelButton : '';
|
||||
const codePlugin = editorType === 'text' ? plugins.code : '';
|
||||
const codeButton = editorType === 'text' ? buttons.code : '';
|
||||
const labelButton = editorType === 'question' ? buttons.customLabelButton : '';
|
||||
const quickToolbar = editorType === 'expandable' ? plugins.quickbars : '';
|
||||
const inline = editorType === 'expandable';
|
||||
const toolbar = editorType !== 'expandable';
|
||||
|
||||
return (
|
||||
StrictDict({
|
||||
@@ -26,9 +29,10 @@ const pluginConfig = ({ isLibrary, placeholder, editorType }) => {
|
||||
plugins.autoresize,
|
||||
image,
|
||||
imageTools,
|
||||
quickToolbar,
|
||||
].join(' '),
|
||||
menubar: false,
|
||||
toolbar: mapToolbars([
|
||||
toolbar: toolbar ? mapToolbars([
|
||||
[buttons.undo, buttons.redo],
|
||||
[buttons.formatSelect],
|
||||
[labelButton],
|
||||
@@ -48,21 +52,47 @@ const pluginConfig = ({ isLibrary, placeholder, editorType }) => {
|
||||
[imageUploadButton, buttons.link, buttons.unlink, buttons.blockQuote, buttons.codeBlock],
|
||||
[buttons.table, buttons.emoticons, buttons.charmap, buttons.hr],
|
||||
[buttons.removeFormat, codeButton],
|
||||
]),
|
||||
]) : false,
|
||||
imageToolbar: mapToolbars([
|
||||
// [buttons.rotate.left, buttons.rotate.right],
|
||||
// [buttons.flip.horiz, buttons.flip.vert],
|
||||
[editImageSettings],
|
||||
]),
|
||||
quickbarsInsertToolbar: toolbar ? false : mapToolbars([
|
||||
[buttons.undo, buttons.redo],
|
||||
[buttons.formatSelect],
|
||||
[buttons.bold, buttons.italic, buttons.underline, buttons.foreColor],
|
||||
[
|
||||
buttons.align.justify,
|
||||
buttons.bullist,
|
||||
buttons.numlist,
|
||||
],
|
||||
[imageUploadButton, buttons.blockQuote, buttons.codeBlock],
|
||||
[buttons.table, buttons.emoticons, buttons.charmap, buttons.removeFormat],
|
||||
]),
|
||||
quickbarsSelectionToolbar: toolbar ? false : mapToolbars([
|
||||
[buttons.undo, buttons.redo],
|
||||
[buttons.formatSelect],
|
||||
[buttons.bold, buttons.italic, buttons.underline, buttons.foreColor],
|
||||
[
|
||||
buttons.align.justify,
|
||||
buttons.bullist,
|
||||
buttons.numlist,
|
||||
],
|
||||
[imageUploadButton, buttons.blockQuote, buttons.codeBlock],
|
||||
[buttons.table, buttons.emoticons, buttons.charmap, buttons.removeFormat],
|
||||
]),
|
||||
config: {
|
||||
branding: false,
|
||||
height: '100%',
|
||||
menubar: false,
|
||||
toolbar_mode: 'sliding',
|
||||
toolbar_sticky: true,
|
||||
toolbar_sticky_offset: 76,
|
||||
relative_urls: true,
|
||||
convert_urls: false,
|
||||
placeholder,
|
||||
inline,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user