Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extend OLE Jira feature by implementing a compatible wysiwyg #543

Merged
merged 9 commits into from
Oct 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Version History
v5.26.0
-------

* Extend OLE Jira feature by implementing a compatible wysiwyg `<https://github.com/lsst-ts/LOVE-frontend/pull/543>`_
* Final adjustments for EnvironmentSummary `<https://github.com/lsst-ts/LOVE-frontend/pull/545>`_
* Bump @babel/traverse from 7.22.5 to 7.23.2 in /love `<https://github.com/lsst-ts/LOVE-frontend/pull/544>`_
* Add Simonyi Interlock Signals `<https://github.com/lsst-ts/LOVE-frontend/pull/542>`_
Expand Down
2 changes: 1 addition & 1 deletion docker/run-tests.sh
Original file line number Diff line number Diff line change
@@ -1 +1 @@
CI=true yarn test --testPathPattern=redux
CI=true yarn test --testPathPattern="(redux|Utils.test.js)"
1 change: 1 addition & 0 deletions love/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"react-grid-layout": "^0.18.2",
"react-json-pretty": "^2.1.0",
"react-modal": "^3.11.1",
"react-quill": "^2.0.0",
"react-redux": "^8.0.1",
"react-rnd": "^10.0.0",
"react-router-dom": "^5.0.0",
Expand Down
193 changes: 193 additions & 0 deletions love/src/Utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -1840,3 +1840,196 @@ export function trimString(string, length = 100) {
}
return string;
}

/**
* Function to parse HTML, generated by react-quill, to Jira Markdown.
* Check https://jira.atlassian.com/secure/WikiRendererHelpAction.jspa?section=all for more info
* @param {string} html html to be parsed
* @returns {string} markdown string
*/
export function htmlToJiraMarkdown(html) {
let markdown = html;

// Parse text formats
markdown = markdown.replace(/<strong>(.*)<\/strong>/g, (match, p1) => {
return `*${p1}*`;
});
markdown = markdown.replace(/<em>(.*)<\/em>/g, (match, p1) => {
return `_${p1}_`;
});
markdown = markdown.replace(/<u>(.*)<\/u>/g, (match, p1) => {
return `+${p1}+`;
});
markdown = markdown.replace(/<s>(.*)<\/s>/g, (match, p1) => {
return `-${p1}-`;
});

// Parse indentations
markdown = markdown.replace(/<p class="ql-indent-1">(.*)<\/p>/g, (match, p1) => {
return `\t${p1}\r\n`;
});
markdown = markdown.replace(/<p class="ql-indent-2">(.*)<\/p>/g, (match, p1) => {
return `\t\t${p1}\r\n`;
});
markdown = markdown.replace(/<p class="ql-indent-3">(.*)<\/p>/g, (match, p1) => {
return `\t\t\t${p1}\r\n`;
});
markdown = markdown.replace(/<p class="ql-indent-4">(.*)<\/p>/g, (match, p1) => {
return `\t\t\t\t${p1}\r\n`;
});
markdown = markdown.replace(/<p class="ql-indent-5">(.*)<\/p>/g, (match, p1) => {
return `\t\t\t\t\t${p1}\r\n`;
});
markdown = markdown.replace(/<p class="ql-indent-6">(.*)<\/p>/g, (match, p1) => {
return `\t\t\t\t\t\t${p1}\r\n`;
});
markdown = markdown.replace(/<p class="ql-indent-7">(.*)<\/p>/g, (match, p1) => {
return `\t\t\t\t\t\t\t${p1}\r\n`;
});
markdown = markdown.replace(/<p class="ql-indent-8">(.*)<\/p>/g, (match, p1) => {
return `\t\t\t\t\t\t\t\t${p1}\r\n`;
});

// Parse headings
markdown = markdown.replace(/<h1>(.*)<\/h1>/g, (match, p1) => {
return `h1. ${p1}\r\n`;
});
markdown = markdown.replace(/<h2>(.*)<\/h2>/g, (match, p1) => {
return `h2. ${p1}\r\n`;
});
markdown = markdown.replace(/<h3>(.*)<\/h3>/g, (match, p1) => {
return `h3. ${p1}\r\n`;
});
markdown = markdown.replace(/<h4>(.*)<\/h4>/g, (match, p1) => {
return `h4. ${p1}\r\n`;
});
markdown = markdown.replace(/<h5>(.*)<\/h5>/g, (match, p1) => {
return `h5. ${p1}\r\n`;
});
markdown = markdown.replace(/<h6>(.*)<\/h6>/g, (match, p1) => {
return `h6. ${p1}\r\n`;
});

// Parse links
markdown = markdown.replace(
/<a href="(.*)" rel="noopener noreferrer" target="_blank">(.*)<\/a>/g,
(match, p1, p2) => {
return `[${p2}|${p1}]`;
},
);

// Parse code blocks
markdown = markdown.replace(/<code>(.*)<\/code>/g, (match, p1) => {
return `{code}${p1}{code}`;
});
markdown = markdown.replace(/<pre>(.*)<\/pre>/g, (match, p1) => {
return `{code}${p1}{code}`;
});

// TODO: DM-41265
// markdown = markdown.replace(/<ul>|<\/ul>|<ol>|<\/ol>|<li>/g, '');
// markdown = markdown.replace(/<\/li>/g, '\n');

// Parse rest of stuff
markdown = markdown.replace(/<p>/g, '');
markdown = markdown.replace(/<\/p>/g, '\r\n');
markdown = markdown.replace(/<br>/g, '\r\n');

return markdown;
}

/**
* Function to parse Jira Markdown to HTML in the react-quill format
* Check https://jira.atlassian.com/secure/WikiRendererHelpAction.jspa?section=all for more info
* @param {string} markdown markdown to be parsed
* @params {object} options options to be used on the parser, default: { codeFriendly: true }
* @params {boolean} options.codeFriendly if true, text formatting is not applied
* @returns {string} html string
*/
export function jiraMarkdownToHtml(markdown, options = { codeFriendly: true }) {
if (!markdown) return '';

const { codeFriendly } = options;
let html = markdown;

// Parse text formats
if (!codeFriendly) {
html = html.replace(/\*(.*)\*/g, (match, p1) => {
return `<strong>${p1}</strong>`;
});
html = html.replace(/_(.*)_/g, (match, p1) => {
return `<em>${p1}</em>`;
});
html = html.replace(/\+(.*)\+/g, (match, p1) => {
return `<u>${p1}</u>`;
});
html = html.replace(/-(.*)-/g, (match, p1) => {
return `<s>${p1}</s>`;
});
}

// Parse headings
html = html.replace(/h1\.\s(.*)/g, (match, p1) => {
return `<h1>${p1}</h1>`;
});
html = html.replace(/h2\.\s(.*)/g, (match, p1) => {
return `<h2>${p1}</h2>`;
});
html = html.replace(/h3\.\s(.*)/g, (match, p1) => {
return `<h3>${p1}</h3>`;
});
html = html.replace(/h4\.\s(.*)/g, (match, p1) => {
return `<h4>${p1}</h4>`;
});
html = html.replace(/h5\.\s(.*)/g, (match, p1) => {
return `<h5>${p1}</h5>`;
});
html = html.replace(/h6\.\s(.*)/g, (match, p1) => {
return `<h6>${p1}</h6>`;
});

// Parse links
html = html.replace(/\[(.*)\|(.*)\]/g, (match, p1, p2) => {
return `<a href="${p2}" rel="noopener noreferrer" target="_blank">${p1}</a>`;
});

// Parse code blocks
html = html.replace(/\{code\}(.*)\{code\}/g, (match, p1) => {
return `<code>${p1}</code>`;
});

// Parse full lines
html = html.replace(/^(\s*)(.*)\r\n/gm, (match, p1, p2) => {
return `<p>${[...p1].map((e) => '&nbsp;').join('')}${p2}</p>`;
});

return html;
}

/**
* Function to transform an html string to an array of tokens
* @param {string} html html string to be transformed
* @returns {Array} array of tokens
*/
export function simpleHtmlTokenizer(html) {
let tokens = [];
let currentToken = '';
let insideTag = false;
for (let i = 0; i < html.length; i++) {
const char = html[i];
if (char === '<') {
insideTag = true;
currentToken += char;
} else if (char === '>') {
insideTag = false;
currentToken += char;
tokens.push(currentToken);
currentToken = '';
} else if (insideTag) {
currentToken += char;
} else {
tokens.push(char);
}
}
return tokens;
}
46 changes: 46 additions & 0 deletions love/src/Utils.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { htmlToJiraMarkdown, jiraMarkdownToHtml } from './Utils';

describe('htmlToJiraMarkdown', () => {
it('should handle links', () => {
const input = '<p>This is a <a href="https://example.com" rel="noopener noreferrer" target="_blank">link</a>.</p>';
const expectedOutput = 'This is a [link|https://example.com].\r\n';
expect(htmlToJiraMarkdown(input)).toEqual(expectedOutput);
});

it('should handle headings', () => {
const input = '<h1>This is a heading.</h1>';
const expectedOutput = 'h1. This is a heading.\r\n';
expect(htmlToJiraMarkdown(input)).toEqual(expectedOutput);
});

it('should convert HTML to Jira markdown', () => {
const input =
'<p>This is a string with mixed content</p><h1>This is a heading.</h1><p>This is a <a href="http://google.com/" rel="noopener noreferrer" target="_blank">link</a>.</p>';
const expectedOutput =
'This is a string with mixed content\r\nh1. This is a heading.\r\nThis is a [link|http://google.com/].\r\n';
expect(htmlToJiraMarkdown(input)).toEqual(expectedOutput);
});
});

describe('jiraMarkdownToHtml', () => {
it('should handle links', () => {
const input = 'This is a [link|https://example.com].\r\n';
const expectedOutput =
'<p>This is a <a href="https://example.com" rel="noopener noreferrer" target="_blank">link</a>.</p>';
expect(jiraMarkdownToHtml(input)).toEqual(expectedOutput);
});

it('should handle headings', () => {
const input = 'h1. This is a heading.\r\n';
const expectedOutput = '<p><h1>This is a heading.</h1></p>';
expect(jiraMarkdownToHtml(input)).toEqual(expectedOutput);
});

it('should convert Jira markdown to HTML', () => {
const input =
'This is a string with mixed content\r\nh1. This is a heading.\r\nThis is a [link|http://google.com/].\r\n';
const expectedOutput =
'<p>This is a string with mixed content</p><p><h1>This is a heading.</h1></p><p>This is a <a href="http://google.com/" rel="noopener noreferrer" target="_blank">link</a>.</p>';
expect(jiraMarkdownToHtml(input)).toEqual(expectedOutput);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import React, { useState, useEffect, useRef, memo, forwardRef, useImperativeHandle } from 'react';
import PropTypes from 'prop-types';
import ReactQuill from 'react-quill';
import styles from './RichTextEditor.module.css';
import 'react-quill/dist/quill.snow.css';

const modules = {
toolbar: [
[{ header: [1, 2, 3, false] }],
['link'],
// TODO: DM-41265
// ['bold', 'italic', 'underline', 'strike'],
// [{ list: 'ordered' }, { list: 'bullet' }, { indent: '-1' }, { indent: '+1' }],
// ['clean'],
],
};

function RichTextEditor({ defaultValue, className, onChange = () => {} }, ref) {
const [value, setValue] = useState(defaultValue);
const reactQuillRef = useRef(null);

const handleChange = (value) => {
setValue(value);
onChange(value);
};

useImperativeHandle(ref, () => ({
cleanContent() {
setValue('');
},
}));

const attachQuillRefs = () => {
if (typeof reactQuillRef?.current?.getEditor !== 'function') return;
const quillRef = reactQuillRef.current.getEditor();
quillRef.clipboard.addMatcher(Node.ELEMENT_NODE, (node, delta) => {
let ops = [];
delta.ops.forEach((op) => {
if (op.insert && typeof op.insert === 'string') {
ops.push({
insert: op.insert,
});
}
});
delta.ops = ops;
return delta;
});
};

useEffect(() => {
attachQuillRefs();
}, []);

return (
<div className={[className ?? '', styles.container].join(' ')}>
<ReactQuill ref={reactQuillRef} modules={modules} theme="snow" value={value} onChange={handleChange} />
</div>
);
}

RichTextEditor.propTypes = {
/** Default value for the editor */
defaultValue: PropTypes.string,
/** Class name to apply to the component */
className: PropTypes.string,
/** Function to handle ReactQuill onChange */
onChange: PropTypes.func,
};

export default memo(forwardRef(RichTextEditor));
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
.container {
text-align: left;
background: var(--secondary-background-dimmed-color);
}

.container :global(.quill) {
height: 100%;
}

.container :global(.ql-container) {
height: calc(100% - 3em);
}

.container :global(.ql-editor) {
min-height: 12ch;
}

.container :global(.ql-snow .ql-stroke),
.container :global(.ql-snow .ql-stroke-miter) {
stroke: var(--base-font-color);
}

.container :global(.ql-snow .ql-fill) {
fill: var(--base-font-color);
}

.container :global(.ql-snow .ql-picker),
.container :global(.ql-snow .ql-tooltip) {
color: var(--base-font-color);
}

.container :global(.ql-toolbar.ql-snow + .ql-container.ql-snow),
.container :global(.ql-toolbar.ql-snow) {
border: none;
}

.container :global(.ql-snow .ql-picker-options) {
background-color: var(--secondary-background-dimmed-color);
}

.container :global(.ql-snow .ql-tooltip) {
background-color: var(--secondary-background-dimmed-color);
border: 1px solid var(--secondary-font-dimmed-color);
box-shadow: 0 0 5px var(--secondary-font-dimmed-color);
}
Loading
Loading