Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,32 @@ Feature: New React XBlock Editors

New React editors for the HTML, Video, and Problem XBlocks are provided here and are rendered by this MFE instead of by the XBlock's authoring view.

Configuration
-------------

The rich text editor is `TinyMCE <https://www.tiny.cloud/>`_ (the open source, GPL-licensed version). Operators who have
licensed premium TinyMCE plugins such as the Accessibility Checker (``a11ychecker``) or PowerPaste (``powerpaste``) can
load them by adding the following to ``env.config.jsx``:

* ``TINYMCE_LICENSE_KEY``: your commercial TinyMCE license key (defaults to ``gpl``).
* ``TINYMCE_EXTERNAL_PLUGINS``: an object mapping each plugin name to the URL of its ``plugin.min.js``. The plugins are
loaded into every editor; ``a11ychecker`` additionally gets its toolbar button, and ``powerpaste`` gets sensible defaults.
* ``TINYMCE_PLUGIN_OPTIONS`` (optional): an object of extra TinyMCE init options to pass to those plugins.

.. code-block:: javascript

const config = {
...process.env,
TINYMCE_LICENSE_KEY: 'your-license-key',
TINYMCE_EXTERNAL_PLUGINS: {
a11ychecker: 'https://cdn.example.com/tinymce/plugins/a11ychecker/plugin.min.js',
powerpaste: 'https://cdn.example.com/tinymce/plugins/powerpaste/plugin.min.js',
},
TINYMCE_PLUGIN_OPTIONS: {
powerpaste_word_import: 'clean',
},
};

Feature: New Proctoring Exams View
==================================

Expand Down
20 changes: 5 additions & 15 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@
"fast-xml-parser": "5.11.1",
"file-saver": "^2.0.5",
"formik": "2.4.9",
"frontend-components-tinymce-advanced-plugins": "^1.0.3",
"jszip": "^3.10.1",
"lodash": "4.18.1",
"meilisearch": "^0.41.0",
Expand All @@ -99,7 +98,7 @@
"redux-logger": "^3.0.6",
"redux-thunk": "^2.4.1",
"reselect": "^4.1.5",
"tinymce": "^5.10.4",
"tinymce": "^7.0.0",
"universal-cookie": "^8.0.0",
"uuid": "^11.1.0",
"xmlchecker": "^0.1.0",
Expand Down
2 changes: 0 additions & 2 deletions src/editors/EditorContainer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,6 @@ jest.mock('react-redux', () => ({
}),
}));

// Mock this plugins component:
jest.mock('frontend-components-tinymce-advanced-plugins', () => ({ a11ycheckerCss: '' }));
// Always mock out the "fetch course images" endpoint:
jest.spyOn(editorCmsApi, 'fetchCourseImages').mockImplementation(async () => ( // eslint-disable-next-line
{ data: { assets: [], start: 0, end: 0, page: 0, pageSize: 50, totalCount: 0 } }
Expand Down
2 changes: 0 additions & 2 deletions src/editors/EditorPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ import editorCmsApi from './data/services/cms/api';

import EditorPage from './EditorPage';

// Mock this plugins component:
jest.mock('frontend-components-tinymce-advanced-plugins', () => ({ a11ycheckerCss: '' }));
// Always mock out the "fetch course images" endpoint:
jest.spyOn(editorCmsApi, 'fetchCourseImages').mockImplementation(async () => ( // eslint-disable-next-line
{ data: { assets: [], start: 0, end: 0, page: 0, pageSize: 50, totalCount: 0 } }
Expand Down
2 changes: 0 additions & 2 deletions src/editors/containers/EditorContainer/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@ import editorCmsApi from '../../data/services/cms/api';
import EditorPage from '../../EditorPage';
import * as hooks from './hooks';

// Mock this plugins component:
jest.mock('frontend-components-tinymce-advanced-plugins', () => ({ a11ycheckerCss: '' }));
// Always mock out the "fetch course images" endpoint:
jest.spyOn(editorCmsApi, 'fetchCourseImages').mockImplementation(async () => ( // eslint-disable-next-line
{ data: { assets: [], start: 0, end: 0, page: 0, pageSize: 50, totalCount: 0 } }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ describe('Answer Options Hooks', () => {
const answer = { id: 'A', correct: false };
const dispatch = useDispatch();
it('dispatches actions.problem.deleteAnswer', () => {
windowSpy.mockImplementation(() => ({ tinymce: { editors: { 'answer-A': { getContent: () => 'string' } } } }));
windowSpy.mockImplementation(() => ({
tinymce: { get: () => [{ id: 'answer-A', getContent: () => 'string' }] },
}));
module.removeAnswer({
answer,
dispatch,
Expand Down Expand Up @@ -180,7 +182,7 @@ describe('Answer Options Hooks', () => {
const key = state.keys.isFeedbackVisible;
output = module.useFeedback(answerWithOnlyFeedback);
windowSpy.mockImplementation(() => ({
tinymce: { editors: { 'selectedFeedback-A': { getContent: () => 'string' } } },
tinymce: { get: () => [{ id: 'selectedFeedback-A', getContent: () => 'string' }] },
}));
output.toggleFeedback(false);
expect(state.setState[key]).toHaveBeenCalledWith(true);
Expand All @@ -189,15 +191,17 @@ describe('Answer Options Hooks', () => {
const key = state.keys.isFeedbackVisible;
output = module.useFeedback(answerWithOnlyFeedback);
windowSpy.mockImplementation(() => ({
tinymce: { editors: { 'unselectedFeedback-A': { getContent: () => 'string' } } },
tinymce: { get: () => [{ id: 'unselectedFeedback-A', getContent: () => 'string' }] },
}));
output.toggleFeedback(false);
expect(state.setState[key]).toHaveBeenCalledWith(true);
});
test('toggleFeedback with unselected feedback', () => {
const key = state.keys.isFeedbackVisible;
output = module.useFeedback(answerWithOnlyFeedback);
windowSpy.mockImplementation(() => ({ tinymce: { editors: { 'answer-A': { getContent: () => 'string' } } } }));
windowSpy.mockImplementation(() => ({
tinymce: { get: () => [{ id: 'answer-A', getContent: () => 'string' }] },
}));
output.toggleFeedback(false);
expect(state.setState[key]).toHaveBeenCalledWith(false);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,46 +20,37 @@ export const saveWarningModalToggle = () => {
};
};

/** Returns every tinymce editor instance currently on the page */
const getEditors = () => window.tinymce.get();

/** Checks if any tinymce editor in window is dirty */
export const checkIfEditorsDirty = () => {
const EditorsArray = window.tinymce.editors;
return Object.entries(EditorsArray).some(([id, editor]) => {
if (Number.isNaN(parseInt(id, 10))) {
if (!editor.isNotDirty) {
return true;
}
}
return false;
});
};
export const checkIfEditorsDirty = () => getEditors().some((editor) => editor.isDirty());

export const fetchEditorContent = ({ format }) => {
const editorObject = { hints: [] };
const EditorsArray = window.tinymce.editors;
Object.entries(EditorsArray).forEach(([id, editor]) => {
if (Number.isNaN(parseInt(id, 10))) {
if (id.startsWith('answer')) {
const { answers } = editorObject;
const answerId = id.substring(id.indexOf('-') + 1);
editorObject.answers = { ...answers, [answerId]: editor.getContent({ format }) };
} else if (id.includes('Feedback')) {
const { selectedFeedback, unselectedFeedback, groupFeedback } = editorObject;
const feedbackId = id.substring(id.indexOf('-') + 1);
if (id.startsWith('selected')) {
editorObject.selectedFeedback = { ...selectedFeedback, [feedbackId]: editor.getContent() };
}
if (id.startsWith('unselected')) {
editorObject.unselectedFeedback = { ...unselectedFeedback, [feedbackId]: editor.getContent() };
}
if (id.startsWith('group')) {
editorObject.groupFeedback = { ...groupFeedback, [feedbackId]: editor.getContent() };
}
} else if (id.startsWith('hint')) {
const { hints } = editorObject;
hints.push(editor.getContent());
} else {
editorObject[id] = editor.getContent();
getEditors().forEach((editor) => {
const { id } = editor;
if (id.startsWith('answer')) {
const { answers } = editorObject;
const answerId = id.substring(id.indexOf('-') + 1);
editorObject.answers = { ...answers, [answerId]: editor.getContent({ format }) };
} else if (id.includes('Feedback')) {
const { selectedFeedback, unselectedFeedback, groupFeedback } = editorObject;
const feedbackId = id.substring(id.indexOf('-') + 1);
if (id.startsWith('selected')) {
editorObject.selectedFeedback = { ...selectedFeedback, [feedbackId]: editor.getContent() };
}
if (id.startsWith('unselected')) {
editorObject.unselectedFeedback = { ...unselectedFeedback, [feedbackId]: editor.getContent() };
}
if (id.startsWith('group')) {
editorObject.groupFeedback = { ...groupFeedback, [feedbackId]: editor.getContent() };
}
} else if (id.startsWith('hint')) {
const { hints } = editorObject;
hints.push(editor.getContent());
} else {
editorObject[id] = editor.getContent();
}
});
return editorObject;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ import { ProblemTypeKeys, ShowAnswerTypesKeys } from '../../../../data/constants
import * as hooks from './hooks';
import { MockUseState } from '../../../../testUtils';

/** Mock the tinymce editor registry with the given `{ [editorId]: editorMock }` entries */
const mockEditors = (editors) => {
window.tinymce.get = jest.fn(() => Object.entries(editors).map(([id, editor]) => ({ id, ...editor })));
};

const mockRawMarkdown = 'Raw Markdown';
const mockMarkdownToXML = '<problem>Raw Markdown</problem>';
const mockRawOLX = '<problem>rawOLX</problem>';
Expand Down Expand Up @@ -106,37 +111,37 @@ describe('EditProblemView hooks parseState', () => {
describe('fetchEditorContent', () => {
const getContent = () => '<p>testString</p>';
test('returns answers', () => {
window.tinymce.editors = { 'answer-A': { getContent } };
mockEditors({ 'answer-A': { getContent } });
const editorObject = hooks.fetchEditorContent({ format: '' });
expect(editorObject).toEqual({ answers: { A: '<p>testString</p>' }, hints: [] });
});
test('returns hints', () => {
window.tinymce.editors = { 'hint-0': { getContent } };
mockEditors({ 'hint-0': { getContent } });
const editorObject = hooks.fetchEditorContent({ format: '' });
expect(editorObject).toEqual({ hints: ['<p>testString</p>'] });
});
test('returns question', () => {
window.tinymce.editors = { question: { getContent } };
mockEditors({ question: { getContent } });
const editorObject = hooks.fetchEditorContent({ format: '' });
expect(editorObject).toEqual({ question: '<p>testString</p>', hints: [] });
});
test('returns selectedFeedback', () => {
window.tinymce.editors = { 'selectedFeedback-A': { getContent } };
mockEditors({ 'selectedFeedback-A': { getContent } });
const editorObject = hooks.fetchEditorContent({ format: '' });
expect(editorObject).toEqual({ selectedFeedback: { A: '<p>testString</p>' }, hints: [] });
});
test('returns unselectedFeedback', () => {
window.tinymce.editors = { 'unselectedFeedback-A': { getContent } };
mockEditors({ 'unselectedFeedback-A': { getContent } });
const editorObject = hooks.fetchEditorContent({ format: '' });
expect(editorObject).toEqual({ unselectedFeedback: { A: '<p>testString</p>' }, hints: [] });
});
test('returns groupFeedback', () => {
window.tinymce.editors = { 'groupFeedback-0': { getContent } };
mockEditors({ 'groupFeedback-0': { getContent } });
const editorObject = hooks.fetchEditorContent({ format: '' });
expect(editorObject).toEqual({ groupFeedback: { 0: '<p>testString</p>' }, hints: [] });
});
test('returns groupFeedback', () => {
window.tinymce.editors = {};
mockEditors({});
const editorObject = hooks.fetchEditorContent({ format: '' });
expect(editorObject).toEqual({ hints: [] });
});
Expand Down Expand Up @@ -219,10 +224,10 @@ describe('EditProblemView hooks parseState', () => {
jest.clearAllMocks();
});
it('should call openSaveWarningModal for single select problem with empty title', () => {
window.tinymce.editors = {
mockEditors({
'answer-A': { getContent: () => '' },
'answer-B': { getContent: () => 'sOmevALUe' },
};
});
const expected = hooks.checkForNoAnswers({
openSaveWarningModal,
problem: {
Expand All @@ -234,7 +239,7 @@ describe('EditProblemView hooks parseState', () => {
expect(expected).toEqual(true);
});
it('returns true for single select with title but no correct answer', () => {
window.tinymce.editors = { 'answer-A': { getContent: () => 'sOmevALUe' } };
mockEditors({ 'answer-A': { getContent: () => 'sOmevALUe' } });
const expected = hooks.checkForNoAnswers({
openSaveWarningModal,
problem: {
Expand All @@ -246,7 +251,7 @@ describe('EditProblemView hooks parseState', () => {
expect(expected).toEqual(true);
});
it('returns true for single select with title and correct answer', () => {
window.tinymce.editors = { 'answer-A': { getContent: () => 'sOmevALUe' } };
mockEditors({ 'answer-A': { getContent: () => 'sOmevALUe' } });
const expected = hooks.checkForNoAnswers({
openSaveWarningModal,
problem: {
Expand Down Expand Up @@ -417,27 +422,27 @@ describe('checkIfEditorsDirty', () => {
test('should return false if none of editors are dirty', () => {
windowSpy.mockImplementation(() => ({
tinymce: {
editors: {
some_id: { isNotDirty: true },
some_id2: { isNotDirty: true },
some_id3: { isNotDirty: true },
some_id4: { isNotDirty: true },
some_id5: { isNotDirty: true },
},
get: () => [
{ id: 'some_id', isDirty: () => false },
{ id: 'some_id2', isDirty: () => false },
{ id: 'some_id3', isDirty: () => false },
{ id: 'some_id4', isDirty: () => false },
{ id: 'some_id5', isDirty: () => false },
],
},
}));
expect(hooks.checkIfEditorsDirty()).toEqual(false);
});
test('should return true if any editor is dirty', () => {
windowSpy.mockImplementation(() => ({
tinymce: {
editors: {
some_id: { isNotDirty: true },
some_id2: { isNotDirty: true },
some_id3: { isNotDirty: false },
some_id4: { isNotDirty: true },
some_id5: { isNotDirty: false },
},
get: () => [
{ id: 'some_id', isDirty: () => false },
{ id: 'some_id2', isDirty: () => false },
{ id: 'some_id3', isDirty: () => true },
{ id: 'some_id4', isDirty: () => false },
{ id: 'some_id5', isDirty: () => true },
],
},
}));
expect(hooks.checkIfEditorsDirty()).toEqual(true);
Expand Down
Loading