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,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>
`;

View 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;

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

@@ -0,0 +1,2 @@
.cm-editor { height: 100% }
.cm-scroller { overflow: auto }

View 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();
});
});
});
});

View 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;