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

@@ -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,20 +0,0 @@
// 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

@@ -1,36 +0,0 @@
// 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

@@ -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,2 +0,0 @@
.cm-editor { height: 100% }
.cm-scroller { overflow: auto }

View File

@@ -1,136 +0,0 @@
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';
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', () => ({
basicSetup: 'bAsiCSetUp',
}));
const state = new MockUseState(module.hooks);
describe('CodeEditor', () => {
describe('Hooks', () => {
beforeEach(() => {
jest.clearAllMocks();
});
state.testGetter(state.keys.showBtnEscapeHTML);
describe('stateHooks', () => {
beforeEach(() => {
state.mock();
});
it('prepareShowBtnEscapeHTML', () => {
const hook = module.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(module.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', () => {
module.hooks.escapeHTMLSpecialChars({ ref, hideBtn: mockHideBtn });
expect(mockDispatch).toHaveBeenCalled();
expect(mockHideBtn).toHaveBeenCalled();
});
});
describe('createCodeMirrorDomNode', () => {
const props = {
ref: {
current: 'sOmEvAlUe',
},
initialText: 'sOmEhTmL',
upstreamRef: {
current: 'sOmEotHERvAlUe',
},
};
beforeEach(() => {
module.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',
},
value: 'mOcKhTmL',
};
jest.spyOn(module.hooks, 'createCodeMirrorDomNode').mockImplementation(() => ({}));
});
afterAll(() => {
jest.clearAllMocks();
});
test('Renders and calls Hooks ', () => {
jest.spyOn(module.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();
});
});
});
});

View File

@@ -1,9 +0,0 @@
export const messages = {
escapeHTMLButtonLabel: {
id: 'authoring.texteditor.codeEditor.escapeHTMLButton',
defaultMessage: 'Unescape HTML Literals',
description: 'Label For escape special html charectars button',
},
};
export default messages;

View File

@@ -1,28 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`RawEditor renders as expected with default behavior 1`] = `
<div
style={
Object {
"height": "600px",
"padding": "10px 30px",
}
}
>
<Alert
variant="danger"
>
You are using the raw HTML editor.
</Alert>
<injectIntl(ShimmedIntlComponent)
innerRef={
Object {
"current": Object {
"value": "Ref Value",
},
}
}
value="eDiTablE Text"
/>
</div>
`;

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

@@ -1,18 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import { RawEditor } from '.';
describe('RawEditor', () => {
const props = {
editorRef: {
current: {
value: 'Ref Value',
},
},
text: { data: { data: 'eDiTablE Text' } },
};
test('renders as expected with default behavior', () => {
expect(shallow(<RawEditor {...props} />)).toMatchSnapshot();
});
});

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}
/>
);
}