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:
@@ -0,0 +1,20 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`CodeEditor Component Snapshots Renders and calls Hooks 1`] = `
|
||||
<div>
|
||||
<div
|
||||
id="CodeMirror"
|
||||
/>
|
||||
<Button
|
||||
aria-label="Unescape HTML Literals"
|
||||
onClick={[Function]}
|
||||
variant="tertiary"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Unescape HTML Literals"
|
||||
description="Label For escape special html charectars button"
|
||||
id="authoring.texteditor.codeEditor.escapeHTMLButton"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
`;
|
||||
36
src/editors/sharedComponents/CodeEditor/constants.js
Normal file
36
src/editors/sharedComponents/CodeEditor/constants.js
Normal file
@@ -0,0 +1,36 @@
|
||||
// HTML symbols to unescape in a Alphanumeric value : Literal mapping.
|
||||
const alphanumericMap = {
|
||||
cent: '¢',
|
||||
pound: '£',
|
||||
sect: '§',
|
||||
copy: '©',
|
||||
laquo: '«',
|
||||
raquo: '»',
|
||||
reg: '®',
|
||||
deg: '°',
|
||||
plusmn: '±',
|
||||
para: '¶',
|
||||
middot: '·',
|
||||
frac12: '½',
|
||||
mdash: '—',
|
||||
ndash: '–',
|
||||
lsquo: '‘',
|
||||
rsquo: '’',
|
||||
sbquo: '‚',
|
||||
rdquo: '”',
|
||||
ldquo: '“',
|
||||
dagger: '†',
|
||||
Dagger: '‡',
|
||||
bull: '•',
|
||||
hellip: '…',
|
||||
prime: '′',
|
||||
Prime: '″',
|
||||
euro: '€',
|
||||
trade: '™',
|
||||
asymp: '≈',
|
||||
ne: '≠',
|
||||
le: '≤',
|
||||
ge: '≥',
|
||||
quot: '"',
|
||||
};
|
||||
export default alphanumericMap;
|
||||
71
src/editors/sharedComponents/CodeEditor/hooks.js
Normal file
71
src/editors/sharedComponents/CodeEditor/hooks.js
Normal 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();
|
||||
};
|
||||
55
src/editors/sharedComponents/CodeEditor/index.jsx
Normal file
55
src/editors/sharedComponents/CodeEditor/index.jsx
Normal 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);
|
||||
2
src/editors/sharedComponents/CodeEditor/index.scss
Normal file
2
src/editors/sharedComponents/CodeEditor/index.scss
Normal file
@@ -0,0 +1,2 @@
|
||||
.cm-editor { height: 100% }
|
||||
.cm-scroller { overflow: auto }
|
||||
143
src/editors/sharedComponents/CodeEditor/index.test.jsx
Normal file
143
src/editors/sharedComponents/CodeEditor/index.test.jsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import React from 'react';
|
||||
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 alphanumericMap from './constants';
|
||||
import * as module from './index';
|
||||
import * as hooks from './hooks';
|
||||
|
||||
jest.mock('@codemirror/view');
|
||||
|
||||
jest.mock('react', () => ({
|
||||
...jest.requireActual('react'),
|
||||
useRef: jest.fn(val => ({ current: val })),
|
||||
useEffect: jest.fn(),
|
||||
useCallback: (cb, prereqs) => ({ cb, prereqs }),
|
||||
}));
|
||||
|
||||
jest.mock('@codemirror/state', () => ({
|
||||
...jest.requireActual('@codemirror/state'),
|
||||
EditorState: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
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(hooks);
|
||||
|
||||
describe('CodeEditor', () => {
|
||||
describe('Hooks', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
state.testGetter(state.keys.showBtnEscapeHTML);
|
||||
describe('stateHooks', () => {
|
||||
beforeEach(() => {
|
||||
state.mock();
|
||||
});
|
||||
it('prepareShowBtnEscapeHTML', () => {
|
||||
const hook = hooks.prepareShowBtnEscapeHTML();
|
||||
expect(state.stateVals.showBtnEscapeHTML).toEqual(hook.showBtnEscapeHTML);
|
||||
hook.hideBtn();
|
||||
expect(state.setState.showBtnEscapeHTML).toHaveBeenCalledWith(false);
|
||||
});
|
||||
afterEach(() => { state.restore(); });
|
||||
});
|
||||
|
||||
describe('cleanHTML', () => {
|
||||
const dirtyText = `&${Object.keys(alphanumericMap).join('; , &')};`;
|
||||
const cleanText = `${Object.values(alphanumericMap).join(' , ')}`;
|
||||
|
||||
it('escapes alphanumerics and sets them to be literals', () => {
|
||||
expect(hooks.cleanHTML({ initialText: dirtyText })).toEqual(cleanText);
|
||||
});
|
||||
});
|
||||
|
||||
describe('escapeHTMLSpecialChars', () => {
|
||||
const cleanText = `${Object.values(alphanumericMap).join(' , ')}`;
|
||||
|
||||
const mockDispatch = jest.fn((args) => ({ mockDispatch: args }));
|
||||
|
||||
const ref = {
|
||||
current: {
|
||||
dispatch: mockDispatch,
|
||||
state: {
|
||||
doc: {
|
||||
toString: () => cleanText,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const mockHideBtn = jest.fn();
|
||||
it('unescapes literals and sets them to be alphanumerics', () => {
|
||||
hooks.escapeHTMLSpecialChars({ ref, hideBtn: mockHideBtn });
|
||||
expect(mockDispatch).toHaveBeenCalled();
|
||||
expect(mockHideBtn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCodeMirrorDomNode', () => {
|
||||
const props = {
|
||||
ref: {
|
||||
current: 'sOmEvAlUe',
|
||||
},
|
||||
lang: 'html',
|
||||
initialText: 'sOmEhTmL',
|
||||
upstreamRef: {
|
||||
current: 'sOmEotHERvAlUe',
|
||||
},
|
||||
};
|
||||
beforeEach(() => {
|
||||
hooks.createCodeMirrorDomNode(props);
|
||||
});
|
||||
it('calls useEffect and sets up codemirror objects', () => {
|
||||
const [cb, prereqs] = React.useEffect.mock.calls[0];
|
||||
expect(prereqs).toStrictEqual([]);
|
||||
cb();
|
||||
expect(EditorState.create).toHaveBeenCalled();
|
||||
expect(EditorView).toHaveBeenCalled();
|
||||
expect(html).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('Component', () => {
|
||||
describe('Snapshots', () => {
|
||||
const mockHideBtn = jest.fn().mockName('mockHidebtn');
|
||||
let props;
|
||||
beforeAll(() => {
|
||||
props = {
|
||||
intl: { formatMessage },
|
||||
innerRef: {
|
||||
current: 'sOmEvALUE',
|
||||
},
|
||||
lang: 'html',
|
||||
value: 'mOcKhTmL',
|
||||
};
|
||||
jest.spyOn(hooks, 'createCodeMirrorDomNode').mockImplementation(() => ({}));
|
||||
});
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
test('Renders and calls Hooks ', () => {
|
||||
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(hooks.createCodeMirrorDomNode).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
9
src/editors/sharedComponents/CodeEditor/messages.js
Normal file
9
src/editors/sharedComponents/CodeEditor/messages.js
Normal file
@@ -0,0 +1,9 @@
|
||||
export const messages = {
|
||||
escapeHTMLButtonLabel: {
|
||||
id: 'authoring.texteditor.codeEditor.escapeHTMLButton',
|
||||
defaultMessage: 'Unescape HTML Literals',
|
||||
description: 'Label For escape special html charectars button',
|
||||
},
|
||||
};
|
||||
|
||||
export default messages;
|
||||
Reference in New Issue
Block a user