feat: unescape alphanumerics in code Editor (#112)

* feat: unescape alphanumerics in code Editor

* fix: replace disable with not render
This commit is contained in:
connorhaugh
2022-09-09 11:09:53 -04:00
committed by GitHub
parent 03a609ea98
commit 3f303a718d
7 changed files with 173 additions and 7 deletions

View File

@@ -3,7 +3,18 @@
exports[`CodeEditor Component Snapshots Renders and calls Hooks 1`] = `
<div>
<div
id="editor"
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

@@ -1,19 +1,38 @@
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: initialText,
doc: cleanText,
extensions: [basicSetup, html(), EditorView.lineWrapping],
});
const view = new EditorView({ state, parent: ref.current });
@@ -26,17 +45,54 @@ export const hooks = {
};
}, []);
},
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,
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="editor" ref={DOMref} />
<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>
);
};
@@ -47,6 +103,7 @@ CodeEditor.propTypes = {
PropTypes.shape({ current: PropTypes.any }),
]).isRequired,
value: PropTypes.string.isRequired,
intl: intlShape.isRequired,
};
export default CodeEditor;
export default injectIntl(CodeEditor);

View File

@@ -4,6 +4,8 @@ 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');
@@ -30,11 +32,59 @@ 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: {
@@ -60,9 +110,11 @@ describe('CodeEditor', () => {
});
describe('Component', () => {
describe('Snapshots', () => {
const mockHideBtn = jest.fn().mockName('mockHidebtn');
let props;
beforeAll(() => {
props = {
intl: { formatMessage },
innerRef: {
current: 'sOmEvALUE',
},
@@ -74,6 +126,7 @@ describe('CodeEditor', () => {
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

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

View File

@@ -14,7 +14,7 @@ exports[`RawEditor renders as expected with default behavior 1`] = `
>
You are using the raw HTML editor.
</Alert>
<CodeEditor
<injectIntl(ShimmedIntlComponent)
innerRef={
Object {
"current": Object {

View File

@@ -36,7 +36,7 @@ exports[`SourceCodeModal renders as expected with default behavior 1`] = `
}
}
>
<CodeEditor
<injectIntl(ShimmedIntlComponent)
innerRef="moCKrEf"
value="mOckHtMl"
/>