Feat raw olx editing. TNL-10218 (#182)

* refactor: move CodeEditor to shared components and remove circular dependency

* feat: add code editor to problem editor

* fix: typo

* feat: add save function to raw olx editor and add highlighting

* feat: simplify and add tests to edit problem view

* feat: add tests to problem edit view

* fix: update raw editor tests

* fix: code editor tests

* fix: package-lock

* fix: lint
This commit is contained in:
Jesper Hodge
2023-01-11 14:23:06 -05:00
committed by GitHub
parent f81b0ee925
commit 2c6679fe06
22 changed files with 830 additions and 1532 deletions

View File

@@ -0,0 +1,66 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`EditorProblemView component renders raw editor 1`] = `
<EditorContainer
getContent={[Function]}
onClose={null}
validateEntry={null}
>
<Container
fluid={true}
>
<Row>
<Component
xs={9}
>
<RawEditor
content={null}
editorRef={
Object {
"current": null,
}
}
lang="xml"
/>
</Component>
<Component
xs={3}
>
<injectIntl(ShimmedIntlComponent)
problemType="advanced"
/>
</Component>
</Row>
</Container>
</EditorContainer>
`;
exports[`EditorProblemView component renders simple view 1`] = `
<EditorContainer
getContent={[Function]}
onClose={null}
validateEntry={null}
>
<Container
fluid={true}
>
<Row>
<Component
xs={9}
>
<injectIntl(ShimmedIntlComponent) />
<AnswerWidget
problemType="multiplechoiceresponse"
/>
</Component>
<Component
xs={3}
>
<injectIntl(ShimmedIntlComponent)
problemType="multiplechoiceresponse"
/>
</Component>
</Row>
</Container>
</EditorContainer>
`;

View File

@@ -0,0 +1,14 @@
import ReactStateSettingsParser from '../../data/ReactStateSettingsParser';
import ReactStateOLXParser from '../../data/ReactStateOLXParser';
// eslint-disable-next-line import/prefer-default-export
export const parseState = (problem, isAdvanced, ref) => () => {
const reactSettingsParser = new ReactStateSettingsParser(problem);
const reactOLXParser = new ReactStateOLXParser({ problem });
const rawOLX = ref?.current?.state.doc.toString();
return {
settings: reactSettingsParser.getSettings(),
olx: isAdvanced ? rawOLX : reactOLXParser.buildOLX(),
};
};

View File

@@ -0,0 +1,25 @@
import * as hooks from './hooks';
const mockRawOLX = 'rawOLX';
const mockBuiltOLX = 'builtOLX';
jest.mock('../../data/ReactStateOLXParser', () => (
jest.fn().mockImplementation(() => ({
buildOLX: () => mockBuiltOLX,
}))
));
jest.mock('../../data/ReactStateSettingsParser');
describe('EditProblemView hooks parseState', () => {
const toStringMock = () => mockRawOLX;
const refMock = { current: { state: { doc: { toString: toStringMock } } } };
test('default problem', () => {
const res = hooks.parseState('problem', false, refMock)();
expect(res.olx).toBe(mockBuiltOLX);
});
test('advanced problem', () => {
const res = hooks.parseState('problem', true, refMock)();
expect(res.olx).toBe(mockRawOLX);
});
});

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useRef } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
@@ -8,32 +8,33 @@ import SettingsWidget from './SettingsWidget';
import QuestionWidget from './QuestionWidget';
import { EditorContainer } from '../../../EditorContainer';
import { selectors } from '../../../../data/redux';
import ReactStateSettingsParser from '../../data/ReactStateSettingsParser';
import ReactStateOLXParser from '../../data/ReactStateOLXParser';
import RawEditor from '../../../../sharedComponents/RawEditor';
import { ProblemTypeKeys } from '../../../../data/constants/problem';
import { parseState } from './hooks';
export const EditProblemView = ({
problemType,
problemState,
}) => {
const parseState = (problem) => () => {
const reactSettingsParser = new ReactStateSettingsParser(problem);
const reactOLXParser = new ReactStateOLXParser({ problem });
return {
settings: reactSettingsParser.getSettings(),
olx: reactOLXParser.buildOLX(),
};
};
if (problemType === ProblemTypeKeys.ADVANCED) {
return 'hello raw editor';
}
const editorRef = useRef(null);
const isAdvancedProblemType = problemType === ProblemTypeKeys.ADVANCED;
const getContent = parseState(problemState, isAdvancedProblemType, editorRef);
return (
<EditorContainer getContent={parseState(problemState)}>
<EditorContainer getContent={getContent}>
<Container fluid>
<Row>
<Col xs={9}>
<QuestionWidget />
<AnswerWidget problemType={problemType} />
{isAdvancedProblemType ? (
<RawEditor editorRef={editorRef} lang="xml" content={problemState.rawOLX} />
) : (
<>
<QuestionWidget />
<AnswerWidget problemType={problemType} />
</>
)}
</Col>
<Col xs={3}>
<SettingsWidget problemType={problemType} />

View File

@@ -0,0 +1,20 @@
import { shallow } from 'enzyme';
import { EditProblemView } from '.';
import AnswerWidget from './AnswerWidget';
import { ProblemTypeKeys } from '../../../../data/constants/problem';
import RawEditor from '../../../../sharedComponents/RawEditor';
describe('EditorProblemView component', () => {
test('renders simple view', () => {
const wrapper = shallow(<EditProblemView problemType={ProblemTypeKeys.SINGLESELECT} problemState={{}} />);
expect(wrapper).toMatchSnapshot();
expect(wrapper.find(AnswerWidget).length).toBe(1);
});
test('renders raw editor', () => {
const wrapper = shallow(<EditProblemView problemType={ProblemTypeKeys.ADVANCED} problemState={{}} />);
expect(wrapper).toMatchSnapshot();
expect(wrapper.find(AnswerWidget).length).toBe(0);
expect(wrapper.find(RawEditor).length).toBe(1);
});
});

View File

@@ -236,6 +236,13 @@ exports[`TextEditor snapshots loaded, raw editor 1`] = `
/>
</Toast>
<RawEditor
content={
Object {
"data": Object {
"data": "eDiTablE Text",
},
}
}
editorRef={
Object {
"current": Object {
@@ -243,13 +250,7 @@ exports[`TextEditor snapshots loaded, raw editor 1`] = `
},
}
}
text={
Object {
"data": Object {
"data": "eDiTablE Text",
},
}
}
lang="html"
/>
</div>
</EditorContainer>

View File

@@ -1,109 +0,0 @@
import React, { useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import {
Button,
} from '@edx/paragon';
import { basicSetup } from 'codemirror';
import { EditorState } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { html } from '@codemirror/lang-html';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import alphanumericMap from './constants';
import * as module from './index';
import messages from './messages';
import './index.scss';
export const hooks = {
state: {
showBtnEscapeHTML: (val) => React.useState(val),
},
prepareShowBtnEscapeHTML: () => {
const [visibility, setVisibility] = hooks.state.showBtnEscapeHTML(true);
const hide = () => setVisibility(false);
return { showBtnEscapeHTML: visibility, hideBtn: hide };
},
createCodeMirrorDomNode: ({ ref, initialText, upstreamRef }) => {
useEffect(() => {
const cleanText = hooks.cleanHTML({ initialText });
const state = EditorState.create({
doc: cleanText,
extensions: [basicSetup, html(), EditorView.lineWrapping],
});
const view = new EditorView({ state, parent: ref.current });
// eslint-disable-next-line no-param-reassign
upstreamRef.current = view;
view.focus();
return () => {
// called on cleanup
view.destroy();
};
}, []);
},
cleanHTML: ({ initialText }) => {
const translateRegex = new RegExp(`&(${Object.keys(alphanumericMap).join('|')});`, 'g');
const translator = ($0, $1) => alphanumericMap[$1];
return initialText.replace(translateRegex, translator);
},
escapeHTMLSpecialChars: ({ ref, hideBtn }) => {
const text = ref.current.state.doc.toString(); let
pos = 0;
const changes = [];
Object.keys(alphanumericMap).forEach(
(escapedKeyword) => {
// eslint-disable-next-line no-cond-assign
for (let next; (next = text.indexOf(alphanumericMap[escapedKeyword], pos)) > -1;) {
changes.push({ from: next, to: next + 1, insert: `&${escapedKeyword};` });
pos = next + 1;
}
},
);
ref.current.dispatch({ changes });
hideBtn();
},
};
export const CodeEditor = ({
innerRef,
value,
// injected
intl,
}) => {
const DOMref = useRef();
const btnRef = useRef();
module.hooks.createCodeMirrorDomNode({ ref: DOMref, initialText: value, upstreamRef: innerRef });
const { showBtnEscapeHTML, hideBtn } = module.hooks.prepareShowBtnEscapeHTML();
return (
<div>
<div id="CodeMirror" ref={DOMref} />
{showBtnEscapeHTML && (
<Button
variant="tertiary"
aria-label={intl.formatMessage(messages.escapeHTMLButtonLabel)}
ref={btnRef}
onClick={() => module.hooks.escapeHTMLSpecialChars({ ref: innerRef, hideBtn })}
>
<FormattedMessage {...messages.escapeHTMLButtonLabel} />
</Button>
)}
</div>
);
};
CodeEditor.propTypes = {
innerRef: PropTypes.oneOfType([
PropTypes.func,
PropTypes.shape({ current: PropTypes.any }),
]).isRequired,
value: PropTypes.string.isRequired,
intl: intlShape.isRequired,
};
export default injectIntl(CodeEditor);

View File

@@ -1,38 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Alert } from '@edx/paragon';
import CodeEditor from '../CodeEditor';
export const RawEditor = ({
editorRef,
text,
}) => (
<div style={{ padding: '10px 30px', height: '600px' }}>
<Alert variant="danger">
You are using the raw HTML editor.
</Alert>
{ text && text.data.data ? (
<CodeEditor
innerRef={editorRef}
value={text.data.data}
/>
) : null}
</div>
);
RawEditor.defaultProps = {
editorRef: null,
text: null,
};
RawEditor.propTypes = {
editorRef: PropTypes.oneOfType([
PropTypes.func,
PropTypes.shape({ current: PropTypes.any }),
]),
text: PropTypes.shape({
data: PropTypes.shape({ data: PropTypes.string }),
}),
};
export default RawEditor;

View File

@@ -12,7 +12,7 @@ import messages from './messages';
import hooks from './hooks';
import BaseModal from '../BaseModal';
import CodeEditor from '../CodeEditor';
import CodeEditor from '../../../../sharedComponents/CodeEditor';
export const SourceCodeModal = ({
isOpen,

View File

@@ -32,7 +32,7 @@ import { RequestKeys } from '../../data/constants/requests';
import EditorContainer from '../EditorContainer';
import ImageUploadModal from './components/ImageUploadModal';
import SourceCodeModal from './components/SourceCodeModal';
import RawEditor from './components/RawEditor';
import RawEditor from '../../sharedComponents/RawEditor';
import * as hooks from './hooks';
import messages from './messages';
@@ -64,7 +64,7 @@ export const TextEditor = ({
return (
<RawEditor
editorRef={editorRef}
text={blockValue}
content={blockValue}
/>
);
}

View File

@@ -0,0 +1,71 @@
import React, { useEffect } from 'react';
import { basicSetup } from 'codemirror';
import { EditorState } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { html } from '@codemirror/lang-html';
import { xml } from '@codemirror/lang-xml';
import alphanumericMap from './constants';
import './index.scss';
const CODEMIRROR_LANGUAGES = { HTML: 'html', XML: 'xml' };
export const state = {
showBtnEscapeHTML: (val) => React.useState(val),
};
export const prepareShowBtnEscapeHTML = () => {
const [visibility, setVisibility] = state.showBtnEscapeHTML(true);
const hide = () => setVisibility(false);
return { showBtnEscapeHTML: visibility, hideBtn: hide };
};
export const cleanHTML = ({ initialText }) => {
const translateRegex = new RegExp(`&(${Object.keys(alphanumericMap).join('|')});`, 'g');
const translator = ($0, $1) => alphanumericMap[$1];
return initialText.replace(translateRegex, translator);
};
export const createCodeMirrorDomNode = ({
ref,
initialText,
upstreamRef,
lang,
}) => {
useEffect(() => {
const languageExtension = lang === CODEMIRROR_LANGUAGES.HTML ? html() : xml();
const cleanText = cleanHTML({ initialText });
const newState = EditorState.create({
doc: cleanText,
extensions: [basicSetup, languageExtension, EditorView.lineWrapping],
});
const view = new EditorView({ state: newState, parent: ref.current });
// eslint-disable-next-line no-param-reassign
upstreamRef.current = view;
view.focus();
return () => {
// called on cleanup
view.destroy();
};
}, []);
};
export const escapeHTMLSpecialChars = ({ ref, hideBtn }) => {
const text = ref.current.state.doc.toString();
let pos = 0;
const changes = [];
Object.keys(alphanumericMap).forEach(
(escapedKeyword) => {
// eslint-disable-next-line no-cond-assign
for (let next; (next = text.indexOf(alphanumericMap[escapedKeyword], pos)) > -1;) {
changes.push({ from: next, to: next + 1, insert: `&${escapedKeyword};` });
pos = next + 1;
}
},
);
ref.current.dispatch({ changes });
hideBtn();
};

View File

@@ -0,0 +1,55 @@
import React, { useRef } from 'react';
import PropTypes from 'prop-types';
import {
Button,
} from '@edx/paragon';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './messages';
import './index.scss';
import * as hooks from './hooks';
export const CodeEditor = ({
innerRef,
value,
lang,
// injected
intl,
}) => {
const DOMref = useRef();
const btnRef = useRef();
hooks.createCodeMirrorDomNode({
ref: DOMref, initialText: value, upstreamRef: innerRef, lang,
});
const { showBtnEscapeHTML, hideBtn } = hooks.prepareShowBtnEscapeHTML();
return (
<div>
<div id="CodeMirror" ref={DOMref} />
{showBtnEscapeHTML && (
<Button
variant="tertiary"
aria-label={intl.formatMessage(messages.escapeHTMLButtonLabel)}
ref={btnRef}
onClick={() => hooks.escapeHTMLSpecialChars({ ref: innerRef, hideBtn })}
>
<FormattedMessage {...messages.escapeHTMLButtonLabel} />
</Button>
)}
</div>
);
};
CodeEditor.propTypes = {
innerRef: PropTypes.oneOfType([
PropTypes.func,
PropTypes.shape({ current: PropTypes.any }),
]).isRequired,
value: PropTypes.string.isRequired,
intl: intlShape.isRequired,
lang: PropTypes.string.isRequired,
};
export default injectIntl(CodeEditor);

View File

@@ -4,9 +4,10 @@ import { shallow } from 'enzyme';
import { EditorState } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { html } from '@codemirror/lang-html';
import { formatMessage, MockUseState } from '../../../../../testUtils';
import { formatMessage, MockUseState } from '../../../testUtils';
import alphanumericMap from './constants';
import * as module from './index';
import * as hooks from './hooks';
jest.mock('@codemirror/view');
@@ -28,11 +29,15 @@ jest.mock('@codemirror/lang-html', () => ({
html: jest.fn(),
}));
jest.mock('@codemirror/lang-xml', () => ({
xml: jest.fn(),
}));
jest.mock('codemirror', () => ({
basicSetup: 'bAsiCSetUp',
}));
const state = new MockUseState(module.hooks);
const state = new MockUseState(hooks);
describe('CodeEditor', () => {
describe('Hooks', () => {
@@ -45,7 +50,7 @@ describe('CodeEditor', () => {
state.mock();
});
it('prepareShowBtnEscapeHTML', () => {
const hook = module.hooks.prepareShowBtnEscapeHTML();
const hook = hooks.prepareShowBtnEscapeHTML();
expect(state.stateVals.showBtnEscapeHTML).toEqual(hook.showBtnEscapeHTML);
hook.hideBtn();
expect(state.setState.showBtnEscapeHTML).toHaveBeenCalledWith(false);
@@ -58,7 +63,7 @@ describe('CodeEditor', () => {
const cleanText = `${Object.values(alphanumericMap).join(' , ')}`;
it('escapes alphanumerics and sets them to be literals', () => {
expect(module.hooks.cleanHTML({ initialText: dirtyText })).toEqual(cleanText);
expect(hooks.cleanHTML({ initialText: dirtyText })).toEqual(cleanText);
});
});
@@ -79,7 +84,7 @@ describe('CodeEditor', () => {
};
const mockHideBtn = jest.fn();
it('unescapes literals and sets them to be alphanumerics', () => {
module.hooks.escapeHTMLSpecialChars({ ref, hideBtn: mockHideBtn });
hooks.escapeHTMLSpecialChars({ ref, hideBtn: mockHideBtn });
expect(mockDispatch).toHaveBeenCalled();
expect(mockHideBtn).toHaveBeenCalled();
});
@@ -90,13 +95,14 @@ describe('CodeEditor', () => {
ref: {
current: 'sOmEvAlUe',
},
lang: 'html',
initialText: 'sOmEhTmL',
upstreamRef: {
current: 'sOmEotHERvAlUe',
},
};
beforeEach(() => {
module.hooks.createCodeMirrorDomNode(props);
hooks.createCodeMirrorDomNode(props);
});
it('calls useEffect and sets up codemirror objects', () => {
const [cb, prereqs] = React.useEffect.mock.calls[0];
@@ -118,18 +124,19 @@ describe('CodeEditor', () => {
innerRef: {
current: 'sOmEvALUE',
},
lang: 'html',
value: 'mOcKhTmL',
};
jest.spyOn(module.hooks, 'createCodeMirrorDomNode').mockImplementation(() => ({}));
jest.spyOn(hooks, 'createCodeMirrorDomNode').mockImplementation(() => ({}));
});
afterAll(() => {
jest.clearAllMocks();
});
test('Renders and calls Hooks ', () => {
jest.spyOn(module.hooks, 'prepareShowBtnEscapeHTML').mockImplementation(() => ({ showBtnEscapeHTML: true, hideBtn: mockHideBtn }));
jest.spyOn(hooks, 'prepareShowBtnEscapeHTML').mockImplementation(() => ({ showBtnEscapeHTML: true, hideBtn: mockHideBtn }));
// Note: ref won't show up as it is not acutaly a DOM attribute.
expect(shallow(<module.CodeEditor {...props} />)).toMatchSnapshot();
expect(module.hooks.createCodeMirrorDomNode).toHaveBeenCalled();
expect(hooks.createCodeMirrorDomNode).toHaveBeenCalled();
});
});
});

View File

@@ -12,7 +12,9 @@ exports[`RawEditor renders as expected with default behavior 1`] = `
<Alert
variant="danger"
>
You are using the raw HTML editor.
You are using the raw
html
editor.
</Alert>
<injectIntl(ShimmedIntlComponent)
innerRef={
@@ -22,6 +24,7 @@ exports[`RawEditor renders as expected with default behavior 1`] = `
},
}
}
lang="html"
value="eDiTablE Text"
/>
</div>

View File

@@ -0,0 +1,55 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Alert } from '@edx/paragon';
import CodeEditor from '../CodeEditor';
function getValue(content) {
if (!content) { return null; }
if (typeof content === 'string') { return content; }
return content.data?.data;
}
export const RawEditor = ({
editorRef,
content,
lang,
}) => {
const value = getValue(content);
return (
<div style={{ padding: '10px 30px', height: '600px' }}>
<Alert variant="danger">
You are using the raw {lang} editor.
</Alert>
{ value ? (
<CodeEditor
innerRef={editorRef}
value={value}
lang={lang}
/>
) : null}
</div>
);
};
RawEditor.defaultProps = {
editorRef: null,
content: null,
lang: 'html',
};
RawEditor.propTypes = {
editorRef: PropTypes.oneOfType([
PropTypes.func,
PropTypes.shape({ current: PropTypes.any }),
]),
content: PropTypes.oneOfType([
PropTypes.string,
PropTypes.shape({
data: PropTypes.shape({ data: PropTypes.string }),
}),
]),
lang: PropTypes.string,
};
export default RawEditor;

View File

@@ -10,7 +10,7 @@ describe('RawEditor', () => {
value: 'Ref Value',
},
},
text: { data: { data: 'eDiTablE Text' } },
content: { data: { data: 'eDiTablE Text' } },
};
test('renders as expected with default behavior', () => {
expect(shallow(<RawEditor {...props} />)).toMatchSnapshot();