+
{
insertMarkdown(
markdown,
textareaRef.current,
- (text, caretPos) => {
- onChange(text)
- textareaRef.current.focus()
- textareaRef.current.selectionEnd = caretPos
- }
+ insertMarkdownCallback
)
if (markdown === MENTION) {
@@ -231,20 +262,18 @@ export const RichTextEditor = forwardRef(
/>
{previewMode ? (
) : (
-
+
-
+ {errorText && (
+ {errorText}
+ )}
+ {helpText && {helpText}}
+
)}
@@ -265,13 +301,20 @@ export const RichTextEditor = forwardRef(
}
)
-RichTextEditor.displayName = 'RichTextEditor'
+Editor.displayName = 'Editor'
+
+Editor.defaultProps = {
+ initialFocus: true,
+ resizable: true,
+}
-RichTextEditor.propTypes = {
+Editor.propTypes = {
value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
disabled: PropTypes.bool,
errorText: PropTypes.string,
helpText: PropTypes.string,
+ initialFocus: PropTypes.bool,
inputPlaceholder: PropTypes.string,
+ resizable: PropTypes.bool,
}
diff --git a/src/components/RichText/Editor/__tests__/Editor.spec.js b/src/components/RichText/Editor/__tests__/Editor.spec.js
new file mode 100644
index 000000000..97eaf217b
--- /dev/null
+++ b/src/components/RichText/Editor/__tests__/Editor.spec.js
@@ -0,0 +1,47 @@
+import '@testing-library/jest-dom'
+import { render, screen, fireEvent } from '@testing-library/react'
+import React from 'react'
+import { Editor } from '../Editor.js'
+
+const mockConvertCtrlKey = jest.fn()
+jest.mock('../markdownHandler.js', () => ({
+ convertCtrlKey: () => mockConvertCtrlKey(),
+}))
+
+jest.mock('../../../UserMention/UserMentionWrapper.js', () => ({
+ UserMentionWrapper: jest.fn((props) => <>{props.children}>),
+}))
+
+describe('RichText: Editor component', () => {
+ const componentProps = {
+ value: '',
+ onChange: jest.fn(),
+ }
+
+ beforeEach(() => {
+ mockConvertCtrlKey.mockClear()
+ })
+
+ const renderComponent = (props) => {
+ return render(
)
+ }
+
+ it('renders a result', () => {
+ renderComponent(componentProps)
+
+ expect(
+ screen.getByTestId('@dhis2-analytics-richtexteditor')
+ ).toBeVisible()
+ })
+
+ it('calls convertCtrlKey on keydown', () => {
+ renderComponent(componentProps)
+
+ fireEvent.keyDown(screen.getByRole('textbox'), {
+ key: 'A',
+ code: 'keyA',
+ })
+
+ expect(mockConvertCtrlKey).toHaveBeenCalled()
+ })
+})
diff --git a/src/components/RichText/Editor/__tests__/convertCtrlKey.spec.js b/src/components/RichText/Editor/__tests__/convertCtrlKey.spec.js
new file mode 100644
index 000000000..5ebc93a2b
--- /dev/null
+++ b/src/components/RichText/Editor/__tests__/convertCtrlKey.spec.js
@@ -0,0 +1,230 @@
+import { convertCtrlKey } from '../markdownHandler.js'
+
+describe('convertCtrlKey', () => {
+ it('does not trigger callback if no ctrl key', () => {
+ const cb = jest.fn()
+ const e = { key: 'j', preventDefault: () => {} }
+
+ convertCtrlKey(e, cb)
+
+ expect(cb).not.toHaveBeenCalled()
+ })
+
+ describe('when ctrl key + "b" pressed', () => {
+ it('triggers callback with open/close markers and caret pos in between', () => {
+ const cb = jest.fn()
+ const e = {
+ key: 'b',
+ ctrlKey: true,
+ target: {
+ selectionStart: 0,
+ selectionEnd: 0,
+ value: 'rainbow dash',
+ },
+ preventDefault: () => {},
+ }
+
+ convertCtrlKey(e, cb)
+
+ expect(cb).toHaveBeenCalled()
+ expect(cb).toHaveBeenCalledWith('** rainbow dash', 1)
+ })
+
+ it('triggers callback with open/close markers and caret pos in between (end of text)', () => {
+ const cb = jest.fn()
+ const e = {
+ key: 'b',
+ ctrlKey: true,
+ target: {
+ selectionStart: 22,
+ selectionEnd: 22,
+ value: 'rainbow dash is purple',
+ },
+ preventDefault: () => {},
+ }
+
+ convertCtrlKey(e, cb)
+
+ expect(cb).toHaveBeenCalled()
+ expect(cb).toHaveBeenCalledWith('rainbow dash is purple **', 24)
+ })
+
+ it('triggers callback with open/close markers mid-text with surrounding spaces (1)', () => {
+ const cb = jest.fn()
+ const e = {
+ key: 'b',
+ metaKey: true,
+ target: {
+ selectionStart: 4, // caret located just before "quick"
+ selectionEnd: 4,
+ value: 'the quick brown fox',
+ },
+ preventDefault: () => {},
+ }
+
+ convertCtrlKey(e, cb)
+
+ expect(cb).toHaveBeenCalled()
+ expect(cb).toHaveBeenCalledWith('the ** quick brown fox', 5)
+ })
+
+ it('triggers callback with open/close markers mid-text with surrounding spaces (2)', () => {
+ const cb = jest.fn()
+ const e = {
+ key: 'b',
+ metaKey: true,
+ target: {
+ selectionStart: 3, // caret located just after "the"
+ selectionEnd: 3,
+ value: 'the quick brown fox',
+ },
+ preventDefault: () => {},
+ }
+
+ convertCtrlKey(e, cb)
+
+ expect(cb).toHaveBeenCalled()
+ expect(cb).toHaveBeenCalledWith('the ** quick brown fox', 5)
+ })
+
+ it('triggers callback with correct double markers and padding', () => {
+ const cb = jest.fn()
+ const e = {
+ key: 'b',
+ metaKey: true,
+ target: {
+ selectionStart: 9, // between the underscores
+ selectionEnd: 9,
+ value: 'rainbow __',
+ },
+ preventDefault: () => {},
+ }
+
+ convertCtrlKey(e, cb)
+
+ expect(cb).toHaveBeenCalled()
+ expect(cb).toHaveBeenCalledWith('rainbow _**_', 10)
+ })
+
+ describe('selected text', () => {
+ it('triggers callback with open/close markers around text and caret pos after closing marker', () => {
+ const cb = jest.fn()
+ const e = {
+ key: 'b',
+ metaKey: true,
+ target: {
+ selectionStart: 5, // "ow da" is selected
+ selectionEnd: 10,
+ value: 'rainbow dash is purple',
+ },
+ preventDefault: () => {},
+ }
+
+ convertCtrlKey(e, cb)
+
+ expect(cb).toHaveBeenCalled()
+ expect(cb).toHaveBeenCalledWith(
+ 'rainb *ow da* sh is purple',
+ 13
+ )
+ })
+
+ it('triggers callback with open/close markers around text when starting at beginning of line', () => {
+ const cb = jest.fn()
+ const e = {
+ key: 'b',
+ metaKey: true,
+ target: {
+ selectionStart: 0, // "rainbow" is selected
+ selectionEnd: 7,
+ value: 'rainbow dash is purple',
+ },
+ preventDefault: () => {},
+ }
+
+ convertCtrlKey(e, cb)
+
+ expect(cb).toHaveBeenCalled()
+ expect(cb).toHaveBeenCalledWith('*rainbow* dash is purple', 9)
+ })
+
+ it('triggers callback with open/close markers around text when ending at end of line', () => {
+ const cb = jest.fn()
+ const e = {
+ key: 'b',
+ metaKey: true,
+ target: {
+ selectionStart: 16, // "purple" is selected
+ selectionEnd: 22,
+ value: 'rainbow dash is purple',
+ },
+ preventDefault: () => {},
+ }
+
+ convertCtrlKey(e, cb)
+
+ expect(cb).toHaveBeenCalled()
+ expect(cb).toHaveBeenCalledWith('rainbow dash is *purple*', 24)
+ })
+
+ it('triggers callback with open/close markers around word', () => {
+ const cb = jest.fn()
+ const e = {
+ key: 'b',
+ metaKey: true,
+ target: {
+ selectionStart: 8, // "dash" is selected
+ selectionEnd: 12,
+ value: 'rainbow dash is purple',
+ },
+ preventDefault: () => {},
+ }
+
+ convertCtrlKey(e, cb)
+
+ expect(cb).toHaveBeenCalled()
+ expect(cb).toHaveBeenCalledWith('rainbow *dash* is purple', 14)
+ })
+
+ it('triggers callback with leading/trailing spaces trimmed from selection', () => {
+ const cb = jest.fn()
+ const e = {
+ key: 'b',
+ metaKey: true,
+ target: {
+ selectionStart: 8, // " dash " is selected (note leading and trailing space)
+ selectionEnd: 13,
+ value: 'rainbow dash is purple',
+ },
+ preventDefault: () => {},
+ }
+
+ convertCtrlKey(e, cb)
+
+ expect(cb).toHaveBeenCalled()
+ expect(cb).toHaveBeenCalledWith('rainbow *dash* is purple', 14)
+ })
+ })
+ })
+
+ describe('when ctrl key + "i" pressed', () => {
+ it('triggers callback with open/close italics markers and caret pos in between', () => {
+ const cb = jest.fn()
+ const e = {
+ key: 'i',
+ ctrlKey: true,
+ target: {
+ selectionStart: 0,
+ selectionEnd: 0,
+ value: '',
+ },
+ preventDefault: () => {},
+ }
+
+ convertCtrlKey(e, cb)
+
+ expect(cb).toHaveBeenCalled()
+ expect(cb).toHaveBeenCalledWith('__', 1)
+ })
+ })
+})
diff --git a/src/components/Interpretations/common/RichTextEditor/markdownHandler.js b/src/components/RichText/Editor/markdownHandler.js
similarity index 86%
rename from src/components/Interpretations/common/RichTextEditor/markdownHandler.js
rename to src/components/RichText/Editor/markdownHandler.js
index e32a771a5..f9b01895f 100644
--- a/src/components/Interpretations/common/RichTextEditor/markdownHandler.js
+++ b/src/components/RichText/Editor/markdownHandler.js
@@ -89,14 +89,20 @@ export const insertMarkdown = (markdown, target, cb) => {
if (start === end) {
//no text
const valueArr = value.split('')
- let markdown = marker.prefix
+ let markdownString = marker.prefix
if (marker.postfix) {
- markdown += marker.postfix
+ markdownString += marker.postfix
}
- valueArr.splice(start, 0, padMarkers(markdown))
+ valueArr.splice(start, 0, padMarkers(markdownString))
newValue = valueArr.join('')
+
+ // for smileys, put the caret after a space
+ if (Object.keys(emojis).includes(markdown)) {
+ newValue += ' '
+ caretPos = caretPos + newValue.length - 1
+ }
} else {
const text = value.slice(start, end)
const trimmedText = trim(text) // TODO really needed?
@@ -104,15 +110,15 @@ export const insertMarkdown = (markdown, target, cb) => {
// adjust caretPos based on trimmed text selection
caretPos = caretPos - (text.length - trimmedText.length) + 1
- let markdown = `${marker.prefix}${trimmedText}`
+ let markdownString = `${marker.prefix}${trimmedText}`
if (marker.postfix) {
- markdown += marker.postfix
+ markdownString += marker.postfix
}
newValue = [
value.slice(0, start),
- padMarkers(markdown),
+ padMarkers(markdownString),
value.slice(end),
].join('')
}
diff --git a/src/components/Interpretations/common/RichTextEditor/styles/RichTextEditor.style.js b/src/components/RichText/Editor/styles/Editor.style.js
similarity index 81%
rename from src/components/Interpretations/common/RichTextEditor/styles/RichTextEditor.style.js
rename to src/components/RichText/Editor/styles/Editor.style.js
index 53b9a5457..607be4c8d 100644
--- a/src/components/Interpretations/common/RichTextEditor/styles/RichTextEditor.style.js
+++ b/src/components/RichText/Editor/styles/Editor.style.js
@@ -6,18 +6,29 @@ export const mainClasses = css`
display: flex;
flex-direction: column;
width: 100%;
+ height: 100%;
}
.preview {
+ padding: ${spacers.dp8} ${spacers.dp12};
font-size: 14px;
- line-height: 19px;
+ line-height: ${spacers.dp16};
color: ${colors.grey900};
+ overflow-y: auto;
+ scroll-behavior: smooth;
+ }
+
+ .edit {
+ width: 100%;
+ height: 100%;
+ scroll-behavior: smooth;
}
.textarea {
width: 100%;
+ height: 100%;
box-sizing: border-box;
- padding: ${spacers.dp8} ${spacers.dp12};
+ padding: ${spacers.dp8} 15px;
color: ${colors.grey900};
background-color: ${colors.white};
@@ -31,12 +42,20 @@ export const mainClasses = css`
font-size: 14px;
line-height: ${spacers.dp16};
user-select: text;
+ resize: none;
+ }
+
+ .textarea.resizable {
+ resize: vertical;
}
.textarea:focus {
outline: none;
box-shadow: 0 0 0 3px ${theme.focus};
- width: calc(100% - 3px);
+ width: calc(100% - 6px);
+ height: calc(100% - 3px);
+ padding: ${spacers.dp8} ${spacers.dp12};
+ margin-left: 3px;
}
.textarea:disabled {
diff --git a/src/components/RichText/Parser/MdParser.js b/src/components/RichText/Parser/MdParser.js
new file mode 100644
index 000000000..0ec97a5f6
--- /dev/null
+++ b/src/components/RichText/Parser/MdParser.js
@@ -0,0 +1,125 @@
+import MarkdownIt from 'markdown-it'
+
+const emojiDb = {
+ ':-)': '\u{1F642}',
+ ':)': '\u{1F642}',
+ ':-(': '\u{1F641}',
+ ':(': '\u{1F641}',
+ ':+1': '\u{1F44D}',
+ ':-1': '\u{1F44E}',
+}
+
+const codes = {
+ bold: {
+ name: 'bold',
+ char: '*',
+ domEl: 'strong',
+ encodedChar: 0x2a,
+ // see https://regex101.com/r/evswdV/8 for explanation of regexp
+ regexString: '\\B\\*((?!\\s)[^*]+(?:\\b|[^*\\s]))\\*\\B',
+ contentFn: (val) => val,
+ },
+ italic: {
+ name: 'italic',
+ char: '_',
+ domEl: 'em',
+ encodedChar: 0x5f,
+ // see https://regex101.com/r/p6LpjK/6 for explanation of regexp
+ regexString: '\\b_((?!\\s)[^_]+(?:\\B|[^_\\s]))_\\b',
+ contentFn: (val) => val,
+ },
+ emoji: {
+ name: 'emoji',
+ char: ':',
+ domEl: 'span',
+ encodedChar: 0x3a,
+ regexString: '^(:-\\)|:\\)|:\\(|:-\\(|:\\+1|:-1)',
+ contentFn: (val) => emojiDb[val],
+ },
+}
+
+let linksInText
+
+const markerIsInLinkText = (pos) =>
+ linksInText.some((link) => pos >= link.index && pos <= link.lastIndex)
+
+const parse = (code) => (state, silent) => {
+ if (silent) {
+ return false
+ }
+
+ const start = state.pos
+
+ // skip parsing emphasis if marker is within a link
+ if (markerIsInLinkText(start)) {
+ return false
+ }
+
+ const marker = state.src.charCodeAt(start)
+
+ // marker character: "_", "*", ":"
+ if (marker !== codes[code].encodedChar) {
+ return false
+ }
+
+ const MARKER_REGEX = new RegExp(codes[code].regexString)
+ const token = state.src.slice(start)
+
+ if (MARKER_REGEX.test(token)) {
+ const markerMatch = token.match(MARKER_REGEX)
+
+ // skip parsing sections where the marker is not at the start of the token
+ if (markerMatch.index !== 0) {
+ return false
+ }
+
+ const text = markerMatch[1]
+
+ state.push(`${codes[code].domEl}_open`, codes[code].domEl, 1)
+
+ const t = state.push('text', '', 0)
+ t.content = codes[code].contentFn(text)
+
+ state.push(`${codes.bold.domEl}_close`, codes[code].domEl, -1)
+ state.pos += markerMatch[0].length
+
+ return true
+ }
+
+ return false
+}
+
+export class MdParser {
+ constructor() {
+ // disable all rules, enable autolink for URLs and email addresses
+ const md = new MarkdownIt('zero', { linkify: true, breaks: true })
+
+ // *bold* ->
bold
+ md.inline.ruler.push('strong', parse(codes.bold.name))
+
+ // _italic_ ->
italic
+ md.inline.ruler.push('italic', parse(codes.italic.name))
+
+ // :-) :) :-( :( :+1 :-1 ->
[unicode]
+ md.inline.ruler.push('emoji', parse(codes.emoji.name))
+
+ md.enable([
+ 'heading',
+ 'link',
+ 'linkify',
+ 'list',
+ 'newline',
+ 'strong',
+ 'italic',
+ 'emoji',
+ ])
+
+ this.md = md
+ }
+
+ render(text) {
+ linksInText = this.md.linkify.match(text) || []
+
+ return this.md.render(text)
+ }
+}
diff --git a/src/components/RichText/Parser/Parser.js b/src/components/RichText/Parser/Parser.js
new file mode 100644
index 000000000..172049bc1
--- /dev/null
+++ b/src/components/RichText/Parser/Parser.js
@@ -0,0 +1,28 @@
+import PropTypes from 'prop-types'
+import React, { useMemo } from 'react'
+import { MdParser } from './MdParser.js'
+
+export const Parser = ({ children, style }) => {
+ const MdParserInstance = useMemo(() => new MdParser(), [])
+
+ return children ? (
+
+ ) : null
+}
+
+Parser.defaultProps = {
+ style: null,
+}
+
+Parser.propTypes = {
+ children: PropTypes.oneOfType([
+ PropTypes.arrayOf(PropTypes.node),
+ PropTypes.node,
+ ]),
+ style: PropTypes.object,
+}
diff --git a/src/components/RichText/Parser/__tests__/MdParser.spec.js b/src/components/RichText/Parser/__tests__/MdParser.spec.js
new file mode 100644
index 000000000..397b27e76
--- /dev/null
+++ b/src/components/RichText/Parser/__tests__/MdParser.spec.js
@@ -0,0 +1,166 @@
+import { MdParser } from '../MdParser.js'
+
+const Parser = new MdParser()
+
+describe('MdParser class', () => {
+ it('converts text into HTML', () => {
+ const inlineTests = [
+ ['_italic_', '
italic'],
+ ['*bold*', '
bold'],
+ [
+ '_ not italic because there is a space _',
+ '_ not italic because there is a space _',
+ ],
+ [':-)', '
\u{1F642}'],
+ [':)', '
\u{1F642}'],
+ [':-(', '
\u{1F641}'],
+ [':(', '
\u{1F641}'],
+ [':+1', '
\u{1F44D}'],
+ [':-1', '
\u{1F44E}'],
+ [
+ 'mixed _italic_ *bold* and :+1',
+ 'mixed
italic bold and
\u{1F44D}',
+ ],
+ ['_italic with * inside_', '
italic with * inside'],
+ ['*bold with _ inside*', '
bold with _ inside'],
+
+ // italic marker followed by : should work
+ ['_italic_:', '
italic:'],
+ [
+ '_italic_: some text, *bold*: some other text',
+ '
italic: some text,
bold: some other text',
+ ],
+ // bold marker followed by : should work
+ ['*bold*:', '
bold:'],
+ [
+ '*bold*: some text, _italic_: some other text',
+ '
bold: some text,
italic: some other text',
+ ],
+
+ // italic marker inside an italic string not allowed
+ ['_italic with _ inside_', '_italic with _ inside_'],
+ // bold marker inside a bold string not allowed
+ ['*bold with * inside*', '*bold with * inside*'],
+ [
+ '_multiple_ italic in the _same line_',
+ '
multiple italic in the
same line',
+ ],
+ // nested italic/bold combinations not allowed
+ [
+ '_italic with *bold* inside_',
+ '
italic with *bold* inside',
+ ],
+ [
+ '*bold with _italic_ inside*',
+ '
bold with _italic_ inside',
+ ],
+ ['text with : and :)', 'text with : and
\u{1F642}'],
+ [
+ '(parenthesis and :))',
+ '(parenthesis and
\u{1F642})',
+ ],
+ [
+ ':((parenthesis:))',
+ '
\u{1F641}(parenthesis
\u{1F642})',
+ ],
+ [':+1+1', '
\u{1F44D}+1'],
+ ['-1:-1', '-1
\u{1F44E}'],
+
+ // links
+ [
+ 'example.com/path',
+ '
example.com/path',
+ ],
+
+ // not recognized links with italic marker inside not converted
+ [
+ 'example_with_underscore.com/path',
+ 'example_with_underscore.com/path',
+ ],
+ [
+ 'example_with_underscore.com/path_with_underscore',
+ 'example_with_underscore.com/path_with_underscore',
+ ],
+
+ // markers around non-recognized links
+ [
+ 'link example_with_underscore.com/path should _not_ be converted',
+ 'link example_with_underscore.com/path should
not be converted',
+ ],
+ [
+ 'link example_with_underscore.com/path should *not* be converted',
+ 'link example_with_underscore.com/path should
not be converted',
+ ],
+
+ // italic marker inside links not converted
+ [
+ 'example.com/path_with_underscore',
+ '
example.com/path_with_underscore',
+ ],
+ [
+ '_italic_ and *bold* with a example.com/link_with_underscore',
+ '
italic and
bold with a
example.com/link_with_underscore',
+ ],
+ [
+ 'example.com/path with *bold* after :)',
+ '
example.com/path with
bold after
\u{1F642}',
+ ],
+ [
+ '_before_ example.com/path_with_underscore *after* :)',
+ '
before example.com/path_with_underscore after \u{1F642}',
+ ],
+
+ // italic/bold markers right after non-word characters
+ [
+ '_If % of ART retention rate after 12 months >90(%)_: Sustain the efforts.',
+ '
If % of ART retention rate after 12 months >90(%): Sustain the efforts.',
+ ],
+ [
+ '*If % of ART retention rate after 12 months >90(%)*: Sustain the efforts.',
+ '
If % of ART retention rate after 12 months >90(%): Sustain the efforts.',
+ ],
+ ]
+
+ inlineTests.forEach((test) => {
+ const renderedText = Parser.render(test[0])
+
+ expect(renderedText).toEqual(`
${test[1]}
\n`)
+ })
+
+ const blockTests = [
+ // heading
+ ['# Heading 1', '
Heading 1
'],
+ ['## Heading 2', '
Heading 2
'],
+ ['### Heading 3', '
Heading 3
'],
+ ['#### Heading 4', '
Heading 4
'],
+ ['##### Heading 5', '
Heading 5
'],
+ ['###### Heading 6', '
Heading 6
'],
+ ['# *Bold head*', '
Bold head
'],
+ ['## _Italic title_', '
Italic title
'],
+ [
+ '### *Bold* and _italic_ title',
+ '
Bold and italic title
',
+ ],
+
+ // lists
+ [
+ '* first\n* second\n* third',
+ '
',
+ ],
+ [
+ '1. one\n1. two\n1. three\n',
+ '
\n- one
\n- two
\n- three
\n
',
+ ],
+ [
+ '* *first*\n* second\n* _third_',
+ '
',
+ ],
+ ]
+
+ blockTests.forEach((test) => {
+ const renderedText = Parser.render(test[0])
+
+ expect(renderedText).toEqual(`${test[1]}\n`)
+ })
+ })
+})
diff --git a/src/components/RichText/Parser/__tests__/Parser.spec.js b/src/components/RichText/Parser/__tests__/Parser.spec.js
new file mode 100644
index 000000000..26011d492
--- /dev/null
+++ b/src/components/RichText/Parser/__tests__/Parser.spec.js
@@ -0,0 +1,43 @@
+import { shallow } from 'enzyme'
+import React from 'react'
+import { Parser } from '../Parser.js'
+
+jest.mock('../MdParser.js', () => ({
+ MdParser: jest.fn().mockImplementation(() => {
+ return { render: () => 'converted text' }
+ }),
+}))
+
+describe('RichText: Parser component', () => {
+ let richTextParser
+ const defaultProps = {
+ style: { color: 'blue', whiteSpace: 'pre-line' },
+ }
+
+ const renderComponent = (props, text) => {
+ return shallow(
{text})
+ }
+
+ it('should have rendered a result', () => {
+ richTextParser = renderComponent({}, 'test')
+
+ expect(richTextParser).toHaveLength(1)
+ })
+
+ it('should have rendered a result with the style prop', () => {
+ richTextParser = renderComponent(defaultProps, 'test prop')
+
+ expect(richTextParser.props().style).toEqual(defaultProps.style)
+ })
+
+ it('should have rendered content', () => {
+ richTextParser = renderComponent({}, 'plain text')
+
+ expect(richTextParser.html()).toEqual('
converted text
')
+ })
+
+ it('should return null if no children is passed', () => {
+ richTextParser = renderComponent({}, undefined)
+ expect(richTextParser.html()).toBe(null)
+ })
+})
diff --git a/src/components/RichText/index.js b/src/components/RichText/index.js
new file mode 100644
index 000000000..6d9ff0e75
--- /dev/null
+++ b/src/components/RichText/index.js
@@ -0,0 +1,3 @@
+export { Editor as RichTextEditor } from './Editor/Editor.js'
+export { Parser as RichTextParser } from './Parser/Parser.js'
+export { MdParser as RichTextMdParser } from './Parser/MdParser.js'
diff --git a/src/components/Interpretations/common/UserMention/UserList.js b/src/components/UserMention/UserList.js
similarity index 100%
rename from src/components/Interpretations/common/UserMention/UserList.js
rename to src/components/UserMention/UserList.js
diff --git a/src/components/Interpretations/common/UserMention/UserMentionWrapper.js b/src/components/UserMention/UserMentionWrapper.js
similarity index 88%
rename from src/components/Interpretations/common/UserMention/UserMentionWrapper.js
rename to src/components/UserMention/UserMentionWrapper.js
index 394cc39bc..4550d5513 100644
--- a/src/components/Interpretations/common/UserMention/UserMentionWrapper.js
+++ b/src/components/UserMention/UserMentionWrapper.js
@@ -2,12 +2,12 @@ import i18n from '@dhis2/d2-i18n'
import {
CenteredContent,
CircularLoader,
+ Layer,
Menu,
MenuSectionHeader,
MenuItem,
Popper,
Card,
- Portal,
} from '@dhis2/ui'
import PropTypes from 'prop-types'
import React, { useState, useRef } from 'react'
@@ -43,6 +43,7 @@ export const UserMentionWrapper = ({
inputReference,
onUserSelect,
}) => {
+ const [listIsOpen, setListIsOpen] = useState(false)
const [captureText, setCaptureText] = useState(false)
const [capturedText, setCapturedText] = useState('')
const [cloneText, setCloneText] = useState('')
@@ -54,6 +55,7 @@ export const UserMentionWrapper = ({
})
const reset = () => {
+ setListIsOpen(false)
setCaptureText(false)
setCapturedText('')
setCloneText('')
@@ -63,6 +65,12 @@ export const UserMentionWrapper = ({
clear()
}
+ // focus the input/textarea when the user list is closed by clicking above the input/textarea
+ const onClick = () => inputReference.current.focus()
+
+ // close the user list when clicking in the input/textarea or outside of it (input/textarea blur)
+ const onUserListClose = () => reset()
+
// event bubbles up from the input/textarea
const onInput = ({ target }) => {
const { selectionEnd, value } = target
@@ -72,10 +80,12 @@ export const UserMentionWrapper = ({
const spacePosition = value.indexOf(' ', captureStartPosition - 1)
- const filterValue = value.substring(
- captureStartPosition,
- spacePosition > 0 ? spacePosition : selectionEnd + 1
- )
+ const filterValue = value
+ .substring(
+ captureStartPosition,
+ spacePosition > 0 ? spacePosition : selectionEnd + 1
+ )
+ .replace(/\n+/, '')
if (filterValue !== capturedText) {
setCapturedText(filterValue)
@@ -91,6 +101,7 @@ export const UserMentionWrapper = ({
const { selectionStart } = target
if (!captureText && key === '@') {
+ setListIsOpen(true)
setCaptureText(true)
setCaptureStartPosition(selectionStart + 1)
setCloneText(target.value.substring(0, selectionStart) + '@')
@@ -159,16 +170,21 @@ export const UserMentionWrapper = ({
)
}
- const onClick = (user) => () => onSelect(user)
+ const onUserClick = (user) => () => onSelect(user)
return (
-
+
{children}
-
{cloneText}
+
{cloneText}
- {captureText && (
-
+ {listIsOpen && (
+
)}
@@ -228,7 +244,7 @@ export const UserMentionWrapper = ({
-
+
)}
{resolvedHeaderStyle.styles}
@@ -245,5 +261,3 @@ UserMentionWrapper.propTypes = {
onUserSelect: PropTypes.func.isRequired,
children: PropTypes.node,
}
-
-export default UserMentionWrapper
diff --git a/src/components/Interpretations/common/UserMention/styles/UserMentionWrapper.style.js b/src/components/UserMention/styles/UserMentionWrapper.style.js
similarity index 88%
rename from src/components/Interpretations/common/UserMention/styles/UserMentionWrapper.style.js
rename to src/components/UserMention/styles/UserMentionWrapper.style.js
index e98517db1..a075ff11d 100644
--- a/src/components/Interpretations/common/UserMention/styles/UserMentionWrapper.style.js
+++ b/src/components/UserMention/styles/UserMentionWrapper.style.js
@@ -8,6 +8,8 @@ import css from 'styled-jsx/css'
*/
export const userMentionWrapperClasses = css`
.wrapper {
+ width: 100%;
+ height: 100%;
position: relative;
}
.clone {
@@ -15,19 +17,20 @@ export const userMentionWrapperClasses = css`
visibility: hidden;
inset: 0;
box-sizing: border-box;
- padding: ${spacers.dp8} ${spacers.dp12};
+ padding: ${spacers.dp8} 15px;
border: 1px solid ${colors.grey500};
font-size: 14px;
line-height: ${spacers.dp16};
z-index: 1;
pointer-events: none;
}
- .clone > pre {
+ .clone > p {
display: inline;
word-wrap: break-word;
overflow-wrap: break-word;
font: inherit;
margin: 0;
+ white-space: break-spaces;
}
.container {
background-color: ${colors.white};
diff --git a/src/components/Interpretations/common/UserMention/useUserSearchResults.js b/src/components/UserMention/useUserSearchResults.js
similarity index 94%
rename from src/components/Interpretations/common/UserMention/useUserSearchResults.js
rename to src/components/UserMention/useUserSearchResults.js
index b9d46b46d..9adfc4613 100644
--- a/src/components/Interpretations/common/UserMention/useUserSearchResults.js
+++ b/src/components/UserMention/useUserSearchResults.js
@@ -32,10 +32,10 @@ export const useUserSearchResults = ({ searchText }) => {
}, [searchText, debouncedRefetch])
useEffect(() => {
- if (data) {
+ if (fetching === false && data) {
setData(data.users)
}
- }, [data])
+ }, [data, fetching])
return {
users,
diff --git a/src/index.js b/src/index.js
index 0fb8fe59b..1202981b5 100644
--- a/src/index.js
+++ b/src/index.js
@@ -47,6 +47,8 @@ export {
useCachedDataQuery,
} from './components/CachedDataQueryProvider.js'
+export * from './components/RichText/index.js'
+
// Api
export { default as Analytics } from './api/analytics/Analytics.js'
diff --git a/yarn.lock b/yarn.lock
index ca4038043..814fd22bd 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2453,15 +2453,6 @@
i18next "^10.3"
moment "^2.24.0"
-"@dhis2/d2-ui-rich-text@^7.4.1":
- version "7.4.1"
- resolved "https://registry.yarnpkg.com/@dhis2/d2-ui-rich-text/-/d2-ui-rich-text-7.4.1.tgz#8764208c59c6758bf34765b1dbe01762ce435d11"
- integrity sha512-/n5nE0b4EDI/kX0/aN+vFDOswoWT5JQ3lwtHsUxailvnEHMu4/3l27Q38Z+5qhKwl+jYNB9GOFxWoSiymUgBbw==
- dependencies:
- babel-runtime "^6.26.0"
- markdown-it "^8.4.2"
- prop-types "^15.6.2"
-
"@dhis2/multi-calendar-dates@1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@dhis2/multi-calendar-dates/-/multi-calendar-dates-1.0.0.tgz#bf7f49aecdffa9781837a5d60d56a094b74ab4df"
@@ -6090,6 +6081,11 @@ argparse@^1.0.7:
dependencies:
sprintf-js "~1.0.2"
+argparse@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
+ integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
+
aria-query@^4.2.2:
version "4.2.2"
resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-4.2.2.tgz#0d2ca6c9aceb56b8977e9fed6aed7e15bbd2f83b"
@@ -9537,6 +9533,11 @@ entities@^2.0.0:
resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55"
integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==
+entities@~3.0.1:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/entities/-/entities-3.0.1.tgz#2b887ca62585e96db3903482d336c1006c3001d4"
+ integrity sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==
+
enzyme-adapter-react-16@^1.15.6:
version "1.15.6"
resolved "https://registry.yarnpkg.com/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.15.6.tgz#fd677a658d62661ac5afd7f7f541f141f8085901"
@@ -14194,10 +14195,10 @@ lines-and-columns@^1.1.6:
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
-linkify-it@^2.0.0:
- version "2.2.0"
- resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-2.2.0.tgz#e3b54697e78bf915c70a38acd78fd09e0058b1cf"
- integrity sha512-GnAl/knGn+i1U/wjBz3akz2stz+HrHLsxMwHQGofCDfPvlf+gDKN58UtfmUquTY4/MXeE2x7k19KQmeoZi94Iw==
+linkify-it@^4.0.1:
+ version "4.0.1"
+ resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-4.0.1.tgz#01f1d5e508190d06669982ba31a7d9f56a5751ec"
+ integrity sha512-C7bfi1UZmoj8+PQx22XyeXCuBlokoyWQL5pWSP+EI6nzRylyThouddufc2c1NDIcP9k5agmN9fLpA7VNJfIiqw==
dependencies:
uc.micro "^1.0.1"
@@ -14644,14 +14645,14 @@ markdown-escapes@^1.0.0:
resolved "https://registry.yarnpkg.com/markdown-escapes/-/markdown-escapes-1.0.4.tgz#c95415ef451499d7602b91095f3c8e8975f78535"
integrity sha512-8z4efJYk43E0upd0NbVXwgSTQs6cT3T06etieCMEg7dRbzCbxUCK/GHlX8mhHRDcp+OLlHkPKsvqQTCvsRl2cg==
-markdown-it@^8.4.2:
- version "8.4.2"
- resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-8.4.2.tgz#386f98998dc15a37722aa7722084f4020bdd9b54"
- integrity sha512-GcRz3AWTqSUphY3vsUqQSFMbgR38a4Lh3GWlHRh/7MRwz8mcu9n2IO7HOh+bXHrR9kOPDl5RNCaEsrneb+xhHQ==
+markdown-it@^13.0.1:
+ version "13.0.1"
+ resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-13.0.1.tgz#c6ecc431cacf1a5da531423fc6a42807814af430"
+ integrity sha512-lTlxriVoy2criHP0JKRhO2VDG9c2ypWCsT237eDiLqi09rmbKoUetyGHq2uOIRoRS//kfoJckS0eUzzkDR+k2Q==
dependencies:
- argparse "^1.0.7"
- entities "~1.1.1"
- linkify-it "^2.0.0"
+ argparse "^2.0.1"
+ entities "~3.0.1"
+ linkify-it "^4.0.1"
mdurl "^1.0.1"
uc.micro "^1.0.5"
@@ -14736,7 +14737,7 @@ mdn-data@2.0.6:
mdurl@^1.0.0, mdurl@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e"
- integrity sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4=
+ integrity sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==
media-typer@0.3.0:
version "0.3.0"