From 3f303a718d5c727efff51d0958a4227060d67490 Mon Sep 17 00:00:00 2001 From: connorhaugh <49422820+connorhaugh@users.noreply.github.com> Date: Fri, 9 Sep 2022 11:09:53 -0400 Subject: [PATCH] feat: unescape alphanumerics in code Editor (#112) * feat: unescape alphanumerics in code Editor * fix: replace disable with not render --- .../__snapshots__/index.test.jsx.snap | 13 +++- .../components/CodeEditor/constants.js | 36 ++++++++++ .../components/CodeEditor/index.jsx | 65 +++++++++++++++++-- .../components/CodeEditor/index.test.jsx | 53 +++++++++++++++ .../components/CodeEditor/messages.js | 9 +++ .../__snapshots__/index.test.jsx.snap | 2 +- .../__snapshots__/index.test.jsx.snap | 2 +- 7 files changed, 173 insertions(+), 7 deletions(-) create mode 100644 src/editors/containers/TextEditor/components/CodeEditor/constants.js create mode 100644 src/editors/containers/TextEditor/components/CodeEditor/messages.js diff --git a/src/editors/containers/TextEditor/components/CodeEditor/__snapshots__/index.test.jsx.snap b/src/editors/containers/TextEditor/components/CodeEditor/__snapshots__/index.test.jsx.snap index dc2e3ceec..877112366 100644 --- a/src/editors/containers/TextEditor/components/CodeEditor/__snapshots__/index.test.jsx.snap +++ b/src/editors/containers/TextEditor/components/CodeEditor/__snapshots__/index.test.jsx.snap @@ -3,7 +3,18 @@ exports[`CodeEditor Component Snapshots Renders and calls Hooks 1`] = `
+
`; diff --git a/src/editors/containers/TextEditor/components/CodeEditor/constants.js b/src/editors/containers/TextEditor/components/CodeEditor/constants.js new file mode 100644 index 000000000..bb390430c --- /dev/null +++ b/src/editors/containers/TextEditor/components/CodeEditor/constants.js @@ -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; diff --git a/src/editors/containers/TextEditor/components/CodeEditor/index.jsx b/src/editors/containers/TextEditor/components/CodeEditor/index.jsx index b72b4324c..f4a465bd2 100644 --- a/src/editors/containers/TextEditor/components/CodeEditor/index.jsx +++ b/src/editors/containers/TextEditor/components/CodeEditor/index.jsx @@ -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 (
-
+
+ {showBtnEscapeHTML && ( + + )}
); }; @@ -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); diff --git a/src/editors/containers/TextEditor/components/CodeEditor/index.test.jsx b/src/editors/containers/TextEditor/components/CodeEditor/index.test.jsx index caa475a63..918818b99 100644 --- a/src/editors/containers/TextEditor/components/CodeEditor/index.test.jsx +++ b/src/editors/containers/TextEditor/components/CodeEditor/index.test.jsx @@ -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()).toMatchSnapshot(); expect(module.hooks.createCodeMirrorDomNode).toHaveBeenCalled(); diff --git a/src/editors/containers/TextEditor/components/CodeEditor/messages.js b/src/editors/containers/TextEditor/components/CodeEditor/messages.js new file mode 100644 index 000000000..9df01970a --- /dev/null +++ b/src/editors/containers/TextEditor/components/CodeEditor/messages.js @@ -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; diff --git a/src/editors/containers/TextEditor/components/RawEditor/__snapshots__/index.test.jsx.snap b/src/editors/containers/TextEditor/components/RawEditor/__snapshots__/index.test.jsx.snap index d049d36f1..32899800f 100644 --- a/src/editors/containers/TextEditor/components/RawEditor/__snapshots__/index.test.jsx.snap +++ b/src/editors/containers/TextEditor/components/RawEditor/__snapshots__/index.test.jsx.snap @@ -14,7 +14,7 @@ exports[`RawEditor renders as expected with default behavior 1`] = ` > You are using the raw HTML editor. - -