Fix #3202 UI Markdown text editor is broken (#3890)

* Fix #3202 UI Markdown text editor is broken

* Move markdownwithpreview component to rich-text-editor folder

* Remove unused component files

* Fixed component import error on test files

* Fix Failing test

* Remove Markdownwith preview component.

* Fix failing tests

* Add support for readonly

* Add unit test

Co-authored-by: Vivek Ratnavel Subramanian <vivekratnavel90@gmail.com>
This commit is contained in:
Sachin Chaurasiya 2022-04-07 17:20:29 +05:30 committed by GitHub
parent 4dce0a061a
commit d429f0b868
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 1929 additions and 823 deletions

View File

@ -23,6 +23,7 @@
"@fortawesome/react-fontawesome": "^0.1.17",
"@okta/okta-auth-js": "^6.1.0",
"@okta/okta-react": "^6.4.2",
"@toast-ui/react-editor": "^3.1.3",
"autoprefixer": "^9.8.6",
"axios": "^0.21.1",
"babel-plugin-named-asset-import": "^0.3.6",

View File

@ -5,7 +5,7 @@ import ColumnTestForm from './ColumnTestForm';
const mockFunction = jest.fn();
jest.mock('../../common/editor/MarkdownWithPreview', () => {
jest.mock('../../common/rich-text-editor/RichTextEditor', () => {
return jest.fn().mockReturnValue(<div>MarkdownWithPreview component</div>);
});

View File

@ -35,7 +35,7 @@ import {
import SVGIcons from '../../../utils/SvgUtils';
import { getDataTypeString } from '../../../utils/TableUtils';
import { Button } from '../../buttons/Button/Button';
import MarkdownWithPreview from '../../common/editor/MarkdownWithPreview';
import RichTextEditor from '../../common/rich-text-editor/RichTextEditor';
type Props = {
data?: ColumnTest;
@ -657,10 +657,10 @@ const ColumnTestForm = ({
htmlFor="description">
Description:
</label>
<MarkdownWithPreview
<RichTextEditor
data-testid="description"
initialValue={description}
ref={markdownRef}
value={description}
/>
</Field>

View File

@ -5,7 +5,7 @@ import TableTestForm from './TableTestForm';
const mockFunction = jest.fn();
jest.mock('../../common/editor/MarkdownWithPreview', () => {
jest.mock('../../common/rich-text-editor/RichTextEditor', () => {
return jest.fn().mockReturnValue(<div>MarkdownWithPreview component</div>);
});

View File

@ -27,7 +27,7 @@ import {
requiredField,
} from '../../../utils/CommonUtils';
import { Button } from '../../buttons/Button/Button';
import MarkdownWithPreview from '../../common/editor/MarkdownWithPreview';
import RichTextEditor from '../../common/rich-text-editor/RichTextEditor';
type Props = {
data?: TableTest;
@ -319,10 +319,10 @@ const TableTestForm = ({
htmlFor="description">
Description:
</label>
<MarkdownWithPreview
<RichTextEditor
data-testid="description"
initialValue={description}
ref={markdownRef}
value={description}
/>
</Field>

View File

@ -25,7 +25,7 @@ import {
requiredField,
} from '../../utils/CommonUtils';
import { Button } from '../buttons/Button/Button';
import MarkdownWithPreview from '../common/editor/MarkdownWithPreview';
import RichTextEditor from '../common/rich-text-editor/RichTextEditor';
import PageLayout from '../containers/PageLayout';
import Loader from '../Loader/Loader';
import ReviewerModal from '../Modals/ReviewerModal/ReviewerModal.component';
@ -212,11 +212,11 @@ const AddGlossary = ({
htmlFor="description">
Description:
</label>
<MarkdownWithPreview
<RichTextEditor
data-testid="description"
initialValue={description}
readonly={!allowAccess}
ref={markdownRef}
value={description}
/>
</Field>

View File

@ -32,6 +32,10 @@ jest.mock('../common/rich-text-editor/RichTextEditorPreviewer', () => {
return jest.fn().mockReturnValue(<p>RichTextEditorPreviewer</p>);
});
jest.mock('../common/rich-text-editor/RichTextEditor', () => {
return jest.fn().mockReturnValue(<p>RichTextEditor</p>);
});
jest.mock('../../axiosAPIs/glossaryAPI', () => ({
addGlossaries: jest.fn().mockImplementation(() => Promise.resolve()),
}));

View File

@ -31,7 +31,7 @@ import {
} from '../../utils/CommonUtils';
import SVGIcons from '../../utils/SvgUtils';
import { Button } from '../buttons/Button/Button';
import MarkdownWithPreview from '../common/editor/MarkdownWithPreview';
import RichTextEditor from '../common/rich-text-editor/RichTextEditor';
import PageLayout from '../containers/PageLayout';
import Loader from '../Loader/Loader';
import RelatedTermsModal from '../Modals/RelatedTermsModal/RelatedTermsModal';
@ -325,11 +325,11 @@ const AddGlossaryTerm = ({
htmlFor="description">
Description:
</label>
<MarkdownWithPreview
<RichTextEditor
data-testid="description"
initialValue={description}
readonly={!allowAccess}
ref={markdownRef}
value={description}
/>
</Field>

View File

@ -40,6 +40,10 @@ jest.mock('../common/rich-text-editor/RichTextEditorPreviewer', () => {
return jest.fn().mockReturnValue(<p>RichTextEditorPreviewer</p>);
});
jest.mock('../common/rich-text-editor/RichTextEditor', () => {
return jest.fn().mockReturnValue(<p>RichTextEditor</p>);
});
const mockOnCancel = jest.fn();
const mockOnSave = jest.fn();

View File

@ -38,7 +38,7 @@ import {
import SVGIcons, { Icons } from '../../utils/SvgUtils';
import { Button } from '../buttons/Button/Button';
import CopyToClipboardButton from '../buttons/CopyToClipboardButton/CopyToClipboardButton';
import MarkdownWithPreview from '../common/editor/MarkdownWithPreview';
import RichTextEditor from '../common/rich-text-editor/RichTextEditor';
import PageLayout from '../containers/PageLayout';
import DropDown from '../dropdown/DropDown';
import Loader from '../Loader/Loader';
@ -502,11 +502,11 @@ const AddWebhook: FunctionComponent<AddWebhookProps> = ({
htmlFor="description">
Description:
</label>
<MarkdownWithPreview
<RichTextEditor
data-testid="description"
initialValue={description}
readonly={!allowAccess}
ref={markdownRef}
value={description}
/>
</Field>
<Field>

View File

@ -23,7 +23,7 @@ import { EntityReference as UserTeams } from '../../generated/entity/teams/user'
import jsonData from '../../jsons/en';
import { errorMsg, requiredField } from '../../utils/CommonUtils';
import { Button } from '../buttons/Button/Button';
import MarkdownWithPreview from '../common/editor/MarkdownWithPreview';
import RichTextEditor from '../common/rich-text-editor/RichTextEditor';
import PageLayout from '../containers/PageLayout';
import DropDown from '../dropdown/DropDown';
import { DropDownListItem } from '../dropdown/types';
@ -246,7 +246,7 @@ const CreateUser = ({
<label className="tw-block tw-form-label tw-mb-0" htmlFor="description">
Description:
</label>
<MarkdownWithPreview ref={markdownRef} value={description} />
<RichTextEditor initialValue={description} ref={markdownRef} />
</Field>
<Field>
<label className="tw-block tw-form-label tw-mb-0">Teams:</label>

View File

@ -33,7 +33,7 @@ jest.mock('../dropdown/DropDown', () => {
return jest.fn().mockReturnValue(<p>Dropdown component</p>);
});
jest.mock('../common/editor/MarkdownWithPreview', () => {
jest.mock('../common/rich-text-editor/RichTextEditor', () => {
return jest.fn().mockReturnValue(<p>MarkdownWithPreview component</p>);
});

View File

@ -36,7 +36,7 @@ const mockOnCancel = jest.fn();
const mockServiceList: Array<ServiceDataObj> = [{ name: mockData.name }];
jest.mock('../../common/editor/MarkdownWithPreview', () => {
jest.mock('../../common/rich-text-editor/RichTextEditor', () => {
return jest
.fn()
.mockReturnValue(<p data-testid="description">MarkdownWithPreview</p>);

View File

@ -69,7 +69,7 @@ import {
import SVGIcons, { Icons } from '../../../utils/SvgUtils';
import { Button } from '../../buttons/Button/Button';
import CronEditor from '../../common/CronEditor/CronEditor';
import MarkdownWithPreview from '../../common/editor/MarkdownWithPreview';
import RichTextEditor from '../../common/rich-text-editor/RichTextEditor';
import RichTextEditorPreviewer from '../../common/rich-text-editor/RichTextEditorPreviewer';
import IngestionStepper from '../../IngestionStepper/IngestionStepper.component';
@ -1681,10 +1681,10 @@ export const AddServiceModal: FunctionComponent<Props> = ({
<label className="tw-block tw-form-label" htmlFor="description">
Description:
</label>
<MarkdownWithPreview
<RichTextEditor
data-testid="description"
initialValue={description}
ref={markdownRef}
value={description}
/>
</Field>
</Fragment>

View File

@ -19,7 +19,7 @@ const mockOnSave = jest.fn();
const mockOnCancel = jest.fn();
const mockValue = 'Test value';
jest.mock('../../common/editor/MarkdownWithPreview', () => {
jest.mock('../../common/rich-text-editor/RichTextEditor', () => {
return () => jest.fn().mockImplementation(() => mockValue);
});

View File

@ -11,15 +11,15 @@
* limitations under the License.
*/
import {
faWindowMaximize,
faWindowMinimize,
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import classnames from 'classnames';
import React, { FunctionComponent, useRef, useState } from 'react';
import { Button } from '../../buttons/Button/Button';
import MarkdownWithPreview from '../../common/editor/MarkdownWithPreview';
import {
faWindowMinimize,
faWindowMaximize,
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import RichTextEditor from '../../common/rich-text-editor/RichTextEditor';
type EditorContentRef = {
getEditorContent: () => string;
@ -98,11 +98,7 @@ export const ModalWithMarkdownEditor: FunctionComponent<Props> = ({
)}
</div>
<div className="tw-modal-body tw-pt-0 tw-pb-1">
<MarkdownWithPreview
data-testid="markdown-with-preview"
ref={markdownRef}
value={value}
/>
<RichTextEditor initialValue={value} ref={markdownRef} />
</div>
<div className="tw-modal-footer">
<Button

View File

@ -1,60 +0,0 @@
/*
* Copyright 2021 Collate
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
.field-wrapper {
display: flex;
flex-direction: column;
gap: 2px;
margin-left: 15px;
border-left: 3px solid #d9ceee;
padding-top: 10px;
padding-bottom: 10px;
}
.field-child {
display: flex;
gap: 4px;
}
.field-child::before {
content: '';
display: inline-block;
width: 32px;
height: 3px;
background: #d9ceee;
position: relative;
top: 11px;
}
.field-child-icon {
cursor: pointer;
color: #7147e8;
}
.field-child-icon i {
vertical-align: sub;
margin-right: 2px;
margin-left: 2px;
}
.field-label {
display: flex;
gap: 4px;
}
.field-label-name {
padding: 4px 6px;
}
.child-fields-wrapper {
display: flex;
flex-direction: column;
/* margin-top: -11px; */
}

View File

@ -1,122 +0,0 @@
/*
* Copyright 2021 Collate
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { CSSProperties, useCallback, useState } from 'react';
import './SchemaTreeStructure.css';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPlusCircle, faMinusCircle } from '@fortawesome/free-solid-svg-icons';
type Props = {
positions?: Array<number>;
name: string;
type: string;
fields?: Array<Props>;
isCollapsed?: boolean;
};
export const getStyle = (type: string) => {
const sharedStyles = {
padding: '4px 8px',
borderRadius: '5px',
minWidth: '60px',
textAlign: 'center',
display: 'inline-block',
};
switch (type) {
case 'double':
return {
backgroundColor: '#B02AAC33',
color: '#B02AAC',
...sharedStyles,
};
case 'string':
return {
backgroundColor: '#51c41a33',
color: '#51c41a',
...sharedStyles,
};
case 'int':
return {
backgroundColor: '#1890FF33',
color: '#1890FF',
...sharedStyles,
};
default:
return {
backgroundColor: '#EEEAF8',
...sharedStyles,
};
}
};
const SchemaTreeStructure = ({
name,
type,
fields,
isCollapsed = false,
// to track position of element [L0,L1,L2,...Ln]
positions = [],
}: Props) => {
const [showChildren, setShowChildren] = useState<boolean>(!isCollapsed);
const flag = (fields ?? []).length > 0;
const showChildrenHandler = useCallback(() => {
setShowChildren(!showChildren);
}, [showChildren, setShowChildren]);
const getIcon = () => {
return (
flag &&
(showChildren ? (
<FontAwesomeIcon icon={faMinusCircle} />
) : (
<FontAwesomeIcon icon={faPlusCircle} />
))
);
};
return (
<div
className="field-wrapper"
style={{ paddingLeft: flag ? '26px' : '0px' }}>
<div
className="field-child"
style={{ marginLeft: flag ? '-26px' : '0px' }}>
<p className="field-child-icon" onClick={showChildrenHandler}>
{getIcon()}
</p>
<p className="field-label">
<span style={getStyle(type) as CSSProperties}>{type}</span>
<span className="field-label-name">{name}</span>
</p>
</div>
{flag && showChildren && (
<div className="child-fields-wrapper">
{(fields ?? []).map((field, index) => (
<SchemaTreeStructure
isCollapsed
key={index}
positions={[...positions, index]}
{...field}
/>
))}
</div>
)}
</div>
);
};
export default SchemaTreeStructure;

View File

@ -1,126 +0,0 @@
/*
* Copyright 2021 Collate
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
} from 'react';
import RichTextEditor from '../rich-text-editor/RichTextEditor';
import { editorRef } from '../rich-text-editor/RichTextEditor.interface';
import RichTextEditorPreviewer from '../rich-text-editor/RichTextEditorPreviewer';
type EditorContentRef = {
getEditorContent: (value: string) => string;
};
type Props = {
value: string;
readonly?: boolean;
};
const MarkdownWithPreview = forwardRef<editorRef, Props>(
({ value, readonly }: Props, ref) => {
const [activeTab, setActiveTab] = useState<number>(1);
const [preview, setPreview] = useState<string>('');
const [initValue, setInitValue] = useState<string>(value ?? '');
const editorRef = useRef<EditorContentRef>();
const getTabClasses = (tab: number, activeTab: number) => {
return (
'tw-gh-tabs tw-cursor-pointer' + (activeTab === tab ? ' active' : '')
);
};
const updateInternalValue = () => {
if (editorRef.current) {
setInitValue(editorRef.current?.getEditorContent('markdown'));
setPreview(editorRef.current?.getEditorContent('markdown'));
}
};
const getPreview = () => {
if (preview.length < 1) {
return 'Nothing to preview';
}
return (
<RichTextEditorPreviewer
enableSeeMoreVariant={false}
markdown={preview}
/>
);
};
useImperativeHandle(ref, () => ({
getEditorContent() {
return activeTab === 2
? initValue
: editorRef.current?.getEditorContent('markdown');
},
}));
useEffect(() => {
setInitValue(value ?? '');
}, [value]);
return (
<div>
<div className="tw-bg-transparent">
<nav className="tw-flex tw-flex-row tw-gh-tabs-container tw-px-6">
<p
className={getTabClasses(1, activeTab)}
data-testid="tab"
onClick={(e) => {
e.stopPropagation();
setActiveTab(1);
}}>
{'Write '}
</p>
<p
className={getTabClasses(2, activeTab)}
data-testid="tab"
onClick={(e) => {
e.stopPropagation();
setActiveTab(2);
updateInternalValue();
}}>
{'View '}
</p>
</nav>
</div>
<div className="tw-my-5 tw-bg-white">
{activeTab === 1 && (
<RichTextEditor
format="markdown"
initvalue={initValue}
readonly={readonly}
ref={editorRef}
/>
)}
{activeTab === 2 && (
<div className="editor-wrapper tw-flex tw-flex-col tw-flex-1 tw-overflow-y-auto tw-p-3 tw-pl-6 tw-min-h-32 tw-border tw-border-main tw-rounded tw-max-h-none">
{getPreview()}
</div>
)}
</div>
</div>
);
}
);
MarkdownWithPreview.displayName = 'MarkdownWithPreview';
export default MarkdownWithPreview;

File diff suppressed because one or more lines are too long

View File

@ -11,7 +11,7 @@
* limitations under the License.
*/
import { ReactNode } from 'react';
import { HTMLAttributes, ReactNode } from 'react';
export type editorRef = ReactNode | HTMLElement | string;
export enum Format {
@ -35,3 +35,19 @@ export interface PreviewerProp {
maxLen?: number;
enableSeeMoreVariant?: boolean;
}
export type PreviewStyle = 'tab' | 'vertical';
export type EditorType = 'markdown' | 'wysiwyg';
export interface RichTextEditorProp extends HTMLAttributes<HTMLDivElement> {
initialValue: string;
placeHolder?: string;
previewStyle?: PreviewStyle;
editorType?: EditorType;
previewHighlight?: boolean;
extendedAutolinks?: boolean;
hideModeSwitch?: boolean;
useCommandShortcut?: boolean;
readonly?: boolean;
}

View File

@ -0,0 +1,77 @@
/*
* Copyright 2021 Collate
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { findByTestId, queryByTestId, render } from '@testing-library/react';
import React, { Component } from 'react';
import { MemoryRouter } from 'react-router-dom';
import RichTextEditor from './RichTextEditor';
jest.mock('@toast-ui/react-editor', () => {
class Editor extends Component {
// eslint-disable-next-line @typescript-eslint/no-empty-function
getInstance() {}
// eslint-disable-next-line @typescript-eslint/no-empty-function
getRootElement() {}
render() {
return <p>Editor</p>;
}
}
class Viewer extends Component {
// eslint-disable-next-line @typescript-eslint/no-empty-function
getInstance() {}
// eslint-disable-next-line @typescript-eslint/no-empty-function
getRootElement() {}
render() {
return <p>Viewer</p>;
}
}
return {
Editor,
Viewer,
};
});
const mockProp = {
initialValue: '',
readonly: false,
};
describe('Test RichText Editor', () => {
it('Should render rich text editor', async () => {
const { container } = render(<RichTextEditor {...mockProp} />, {
wrapper: MemoryRouter,
});
const editor = await findByTestId(container, 'editor');
expect(editor).toBeInTheDocument();
});
it('Should render viewer if readOnly is true', async () => {
const { container } = render(<RichTextEditor {...mockProp} readonly />, {
wrapper: MemoryRouter,
});
const editor = queryByTestId(container, 'editor');
const viewer = await findByTestId(container, 'viewer');
expect(editor).not.toBeInTheDocument();
expect(viewer).toBeInTheDocument();
});
});

View File

@ -11,122 +11,87 @@
* limitations under the License.
*/
import { convertFromRaw, convertToRaw, EditorState } from 'draft-js';
import { draftjsToMd } from 'draftjs-md-converter';
import { markdownToDraft } from 'markdown-draft-js';
/* eslint-disable */
import { Editor, Viewer } from '@toast-ui/react-editor';
import React, {
createRef,
forwardRef,
useEffect,
useImperativeHandle,
useState,
} from 'react';
import { Editor } from 'react-draft-wysiwyg';
import 'react-draft-wysiwyg/dist/react-draft-wysiwyg.css';
import ListUl from '../../../assets/svg/list-ul.svg';
import { EditorProp, editorRef, Format } from './RichTextEditor.interface';
import { Bold, Info, Italic, Link } from './ToolBarOptions';
import './RichTextEditor.css';
import { editorRef, RichTextEditorProp } from './RichTextEditor.interface';
const getIntialContent = (format: string, content?: string) => {
/*eslint-disable */
if (content) {
switch (format) {
case Format.MARKDOWN:
const rawData = markdownToDraft(content, {
remarkablePreset: 'commonmark',
remarkableOptions: {
html: false,
disable: {
inline: ['links', 'emphasis'],
block: ['heading', 'code', 'list'],
},
enable: {
block: 'table',
core: ['abbr'],
},
},
preserveNewlines: true,
});
const state = convertFromRaw({ ...rawData });
return EditorState.createWithContent(state);
default:
return EditorState.createEmpty();
}
} else {
return EditorState.createEmpty();
}
};
const RichTextEditor = forwardRef<editorRef, EditorProp>(
const RichTextEditor = forwardRef<editorRef, RichTextEditorProp>(
(
{
format = 'markdown',
initvalue,
readonly = false,
customOptions,
}: EditorProp,
placeHolder = 'Write your description',
previewStyle = 'tab',
editorType = 'markdown',
previewHighlight = false,
useCommandShortcut = false,
extendedAutolinks = true,
hideModeSwitch = true,
initialValue = '',
readonly,
}: RichTextEditorProp,
ref
) => {
const [editorState, setEditorState] = useState(
getIntialContent(format, initvalue)
);
const onEditorStateChange = (newState: typeof editorState) => {
setEditorState(newState);
const richTextEditorRef = createRef<Editor>();
const [editorValue, setEditorValue] = useState(initialValue);
const onChangeHandler = () => {
const value = richTextEditorRef.current
?.getInstance()
.getMarkdown() as string;
setEditorValue(value);
};
useImperativeHandle(ref, () => ({
getEditorContent(_format: 'json' | 'markdown') {
// use switch case for multiple format support
return draftjsToMd(convertToRaw(editorState.getCurrentContent()));
getEditorContent() {
return editorValue;
},
}));
useEffect(() => {
setEditorState(getIntialContent(format, initvalue));
}, [initvalue, format]);
setEditorValue(initialValue);
}, [initialValue]);
return (
<>
<div
className="tw-min-h-32 tw-border tw-border-main tw-rounded tw-overflow-y-auto"
data-testid="enterDescription">
<Editor
editorClassName="tw-px-1 tw-min-h-32"
editorState={editorState}
readOnly={readonly}
toolbar={{
options: ['list'],
list: {
className: 'my-list tw-order-4',
options: ['unordered'],
unordered: {
icon: ListUl,
className: 'list-option ',
},
},
}}
toolbarClassName="tw-py-2 tw-border-0 tw-border-b tw-border-main"
toolbarCustomButtons={
customOptions ?? [
<Bold key="bold" />,
<Italic key="italic" />,
<Link key="link" />,
<Info key="info" />,
]
}
toolbarHidden={readonly}
wrapperClassName="editor-wrapper"
onEditorStateChange={onEditorStateChange}
/>
</div>
</>
<div className="tw-my-4">
{readonly ? (
<div
className="tw-border tw-border-main tw-p-2 tw-rounded"
data-testid="viewer">
<Viewer
extendedAutolinks={extendedAutolinks}
initialValue={editorValue}
ref={richTextEditorRef}
/>
</div>
) : (
<div data-testid="editor">
<Editor
extendedAutolinks={extendedAutolinks}
hideModeSwitch={hideModeSwitch}
initialEditType={editorType}
initialValue={editorValue}
placeholder={placeHolder}
previewHighlight={previewHighlight}
previewStyle={previewStyle}
ref={richTextEditorRef}
toolbarItems={[['bold', 'italic'], ['ul', 'ol'], ['link']]}
useCommandShortcut={useCommandShortcut}
onChange={onChangeHandler}
/>
</div>
)}
</div>
);
}
);
RichTextEditor.displayName = 'RichTextEditor';
export default RichTextEditor;

View File

@ -1,378 +0,0 @@
/*
* Copyright 2021 Collate
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { EditorState, Modifier, SelectionState } from 'draft-js';
import {
faLink,
faListUl,
faListOl,
faItalic,
faInfoCircle,
} from '@fortawesome/free-solid-svg-icons';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import PopOver from '../popover/PopOver';
const getSelectedText = (editorState: any) => {
const selection = editorState.getSelection();
const anchorKey = selection.getAnchorKey();
const currentContent = editorState.getCurrentContent();
const currentBlock = currentContent.getBlockForKey(anchorKey);
const start = selection.getStartOffset();
const end = selection.getEndOffset();
const selectedText = currentBlock.getText().slice(start, end);
return selectedText;
};
const updateEditorSelection = (eState: any, offsetDiff: any) => {
const selection = eState.getSelection();
const newFocusOffset = selection.focusOffset + offsetDiff;
const newSelection = new SelectionState({
anchorKey: selection.anchorKey,
anchorOffset: newFocusOffset,
focusKey: selection.focusKey,
focusOffset: newFocusOffset,
});
const newEditorState = EditorState.forceSelection(eState, newSelection);
return EditorState.push(newEditorState, newEditorState.getCurrentContent());
};
export class Bold extends Component {
static propTypes = {
onChange: PropTypes.func,
editorState: PropTypes.object,
};
makeBold = () => {
const { editorState, onChange } = this.props as any;
const selectedText = getSelectedText(editorState);
const contentState = Modifier.replaceText(
editorState.getCurrentContent(),
editorState.getSelection(),
`${
selectedText.startsWith('**') && selectedText.endsWith('**')
? selectedText.replaceAll('**', '')
: `**${selectedText}**`
}`,
editorState.getCurrentInlineStyle()
);
const eState = EditorState.push(
editorState,
contentState,
'insert-characters'
);
onChange(updateEditorSelection(eState, -2));
};
render() {
return (
<div
className="rdw-option-wrapper tw-font-bold"
data-testid="boldButton"
onClick={this.makeBold}>
<PopOver
arrow={false}
position="bottom"
size="small"
title="Add bold text"
trigger="mouseenter">
<p>B</p>
</PopOver>
</div>
);
}
}
export class Link extends Component {
static propTypes = {
onChange: PropTypes.func,
editorState: PropTypes.object,
};
makeLink = () => {
const { editorState, onChange } = this.props as any;
const selectedText = getSelectedText(editorState);
const contentState = Modifier.replaceText(
editorState.getCurrentContent(),
editorState.getSelection(),
`${
selectedText.startsWith('[') && selectedText.endsWith(')')
? selectedText.replace(/ *\([^)]*\) */g, '').replace(/[\])}[{(]/g, '')
: `[${selectedText}](url)`
}`,
editorState.getCurrentInlineStyle()
);
const eState = EditorState.push(
editorState,
contentState,
'insert-characters'
);
onChange(updateEditorSelection(eState, -6));
};
render() {
return (
<div
className="rdw-option-wrapper "
data-testid="linkButton"
onClick={this.makeLink}>
<PopOver
arrow={false}
position="bottom"
size="small"
title="Add link"
trigger="mouseenter">
<FontAwesomeIcon icon={faLink} />
</PopOver>
</div>
);
}
}
export class Italic extends Component {
static propTypes = {
onChange: PropTypes.func,
editorState: PropTypes.object,
};
makeItalic = () => {
const { editorState, onChange } = this.props as any;
const selectedText = getSelectedText(editorState);
const contentState = Modifier.replaceText(
editorState.getCurrentContent(),
editorState.getSelection(),
`${
selectedText.startsWith('_') && selectedText.endsWith('_')
? selectedText.replaceAll('_', '')
: `_${selectedText}_`
}`,
editorState.getCurrentInlineStyle()
);
const eState = EditorState.push(
editorState,
contentState,
'insert-characters'
);
onChange(updateEditorSelection(eState, -1));
};
render() {
return (
<div
className="rdw-option-wrapper "
data-testid="italicButton"
onClick={this.makeItalic}>
<PopOver
arrow={false}
position="bottom"
size="small"
title="Add italic text"
trigger="mouseenter">
<FontAwesomeIcon icon={faItalic} />
</PopOver>
</div>
);
}
}
export class Heading extends Component {
static propTypes = {
onChange: PropTypes.func,
editorState: PropTypes.object,
};
makeHeading = () => {
const { editorState, onChange } = this.props as any;
const selectedText = getSelectedText(editorState);
const contentState = Modifier.replaceText(
editorState.getCurrentContent(),
editorState.getSelection(),
`${
selectedText.startsWith('### ')
? selectedText.replaceAll('### ', '')
: `### ${selectedText}`
}`,
editorState.getCurrentInlineStyle()
);
const eState = EditorState.push(
editorState,
contentState,
'insert-characters'
);
onChange(updateEditorSelection(eState, 0));
};
render() {
return (
<div className="rdw-option-wrapper" onClick={this.makeHeading}>
<PopOver
position="bottom"
size="small"
title="Add header text"
trigger="mouseenter">
<p>H</p>
</PopOver>
</div>
);
}
}
export class ULLIST extends Component {
static propTypes = {
onChange: PropTypes.func,
editorState: PropTypes.object,
};
makeLIST = () => {
const { editorState, onChange } = this.props as any;
const selectedText = getSelectedText(editorState);
const selection = editorState.getSelection();
const text = selectedText.startsWith('- ')
? selectedText.replaceAll('- ', '')
: `${
selection.anchorOffset > 0 && selectedText.length <= 0
? `\n\n- ${selectedText}`
: `- ${selectedText}`
}`;
const contentState = Modifier.replaceText(
editorState.getCurrentContent(),
editorState.getSelection(),
text,
editorState.getCurrentInlineStyle()
);
onChange(EditorState.push(editorState, contentState, 'insert-characters'));
};
render() {
return (
<div className="rdw-option-wrapper " onClick={this.makeLIST}>
<PopOver
position="bottom"
size="small"
title="Add unordered list"
trigger="mouseenter">
<FontAwesomeIcon icon={faListUl} />
</PopOver>
</div>
);
}
}
export class OLLIST extends Component {
static propTypes = {
onChange: PropTypes.func,
editorState: PropTypes.object,
};
makeLIST = () => {
const { editorState, onChange } = this.props as any;
const selection = editorState.getSelection();
const currentKey = selection.getStartKey();
const currentBlock = editorState
.getCurrentContent()
.getBlockForKey(currentKey);
const textArr = currentBlock.getText().split('\n') || [];
const lastText = textArr[textArr.length - 1];
const match = lastText.match(/(\d+)/)?.[0];
let len = 0;
for (const txt of textArr) {
len += txt.length;
if (len >= selection.focusOffset) {
break;
}
len++;
}
const newSelection = new SelectionState({
anchorKey: selection.anchorKey,
anchorOffset: textArr.join(',').length,
focusKey: selection.focusKey,
focusOffset: textArr.join(',').length,
});
const contentState = Modifier.replaceText(
editorState.getCurrentContent(),
newSelection,
`${
selection.anchorOffset > 0
? `\n${match ? parseInt(match) + 1 : 1}. `
: `${match ? parseInt(match) + 1 : 1}. `
}`,
editorState.getCurrentInlineStyle()
);
onChange(EditorState.push(editorState, contentState, 'insert-characters'));
};
render() {
return (
<div className="rdw-option-wrapper " onClick={this.makeLIST}>
<PopOver
position="bottom"
size="small"
title="Add unordered list"
trigger="mouseenter">
<FontAwesomeIcon icon={faListOl} />
</PopOver>
</div>
);
}
}
export class Info extends Component {
static propTypes = {
onChange: PropTypes.func,
editorState: PropTypes.object,
};
render() {
return (
<div className="rdw-option-wrapper tw-order-5 tw-font-bold tw-ml-auto">
<PopOver
arrow={false}
html={
<div className="tw-flex tw-pb-1 tw-text-white tw-text-left">
<div>
<p className="tw-pt-2">
Using headings in markdown is not allowed.
</p>
<p className="tw-pt-2">
Use{' '}
<span className=" tw-py-0.5 tw-px-1 tw-ml-1 tw-border tw-rounded tw-text-xs">{`<br/>`}</span>{' '}
tag to add empty lines.
</p>
</div>
</div>
}
position="left"
size="small"
trigger="mouseenter">
<FontAwesomeIcon icon={faInfoCircle} />
</PopOver>
</div>
);
}
}

View File

@ -10,7 +10,7 @@ const mockInitialData = {
name: '',
};
jest.mock('../../components/common/editor/MarkdownWithPreview', () => {
jest.mock('../../components/common/rich-text-editor/RichTextEditor', () => {
return jest.fn().mockReturnValue(<div>MarkdownWithPreview component</div>);
});

View File

@ -19,7 +19,7 @@ import React, {
useRef,
useState,
} from 'react';
import MarkdownWithPreview from '../../components/common/editor/MarkdownWithPreview';
import RichTextEditor from '../../components/common/rich-text-editor/RichTextEditor';
import { CreateTagCategory } from '../../generated/api/tags/createTagCategory';
import { errorMsg } from '../../utils/CommonUtils';
@ -120,7 +120,10 @@ const Form: React.FC<FormProp> = forwardRef(
</div>
<div>
<label className="tw-form-label">Description</label>
<MarkdownWithPreview ref={markdownRef} value={data.description} />
<RichTextEditor
initialValue={data.description}
ref={markdownRef}
/>
</div>
</div>
</div>

View File

@ -5,7 +5,7 @@ import Form from './Form';
const mockFunction = jest.fn();
jest.mock('../../components/common/editor/MarkdownWithPreview', () => {
jest.mock('../../components/common/rich-text-editor/RichTextEditor', () => {
return jest.fn().mockReturnValue(<div>MarkdownWithPreview component</div>);
});

View File

@ -19,7 +19,7 @@ import React, {
useRef,
useState,
} from 'react';
import MarkdownWithPreview from '../../components/common/editor/MarkdownWithPreview';
import RichTextEditor from '../../components/common/rich-text-editor/RichTextEditor';
import { errorMsg } from '../../utils/CommonUtils';
type FormProp = {
@ -114,7 +114,10 @@ const Form: React.FC<FormProp> = forwardRef(
</div>
<div>
<label className="tw-form-label">Description</label>
<MarkdownWithPreview ref={markdownRef} value={data.description} />
<RichTextEditor
initialValue={data.description}
ref={markdownRef}
/>
</div>
</div>
</div>

View File

@ -1794,6 +1794,27 @@
traverse "^0.6.6"
unified "^9.2.1"
"@toast-ui/editor@^3.1.3":
version "3.1.3"
resolved "https://registry.yarnpkg.com/@toast-ui/editor/-/editor-3.1.3.tgz#12f15dab1fc9c1db336683f51208f8ba8017abb9"
integrity sha512-4W8nKIhct4bGOKNMkYY2nGzt2k+8LUWlINwGZvCbNgIo6WKlcOarsbWD0o8stOAleaq2TeG6ixIvYK/wTG0OxA==
dependencies:
dompurify "^2.3.3"
prosemirror-commands "^1.1.9"
prosemirror-history "^1.1.3"
prosemirror-inputrules "^1.1.3"
prosemirror-keymap "^1.1.4"
prosemirror-model "^1.14.1"
prosemirror-state "^1.3.4"
prosemirror-view "^1.18.7"
"@toast-ui/react-editor@^3.1.3":
version "3.1.3"
resolved "https://registry.yarnpkg.com/@toast-ui/react-editor/-/react-editor-3.1.3.tgz#5d55ecf08df4c6a230c104f5e4dbab9212107941"
integrity sha512-k5W53y/R3cZvSH3UfDgeT8L1k8MpRri4O9hcTeuXtnbkkCtPQjt0m696tKrZvSXRNeqa4mKT0m8uNbHJAqWD4g==
dependencies:
"@toast-ui/editor" "^3.1.3"
"@tootallnate/once@1":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82"
@ -5001,6 +5022,11 @@ dompurify@^2.0.7:
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.3.4.tgz#1cf5cf0105ccb4debdf6db162525bd41e6ddacc6"
integrity sha512-6BVcgOAVFXjI0JTjEvZy901Rghm+7fDQOrNIcxB4+gdhj6Kwp6T9VBhBY/AbagKHJocRkDYGd6wvI+p4/10xtQ==
dompurify@^2.3.3:
version "2.3.6"
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.3.6.tgz#2e019d7d7617aacac07cbbe3d88ae3ad354cf875"
integrity sha512-OFP2u/3T1R5CEgWCEONuJ1a5+MFKnOYpkywpUSxv/dj1LeBT1erK+JwM7zK0ROy2BRhqVCf0LRw/kHqKuMkVGg==
domutils@^1.7.0:
version "1.7.0"
resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.7.0.tgz#56ea341e834e06e6748af7a1cb25da67ea9f8c2a"
@ -9930,6 +9956,11 @@ optionator@^0.8.1, optionator@^0.8.3:
type-check "~0.3.2"
word-wrap "~1.2.3"
orderedmap@^1.1.0:
version "1.1.5"
resolved "https://registry.yarnpkg.com/orderedmap/-/orderedmap-1.1.5.tgz#4174c90b61bd7c25294932edf789f3b5677744d0"
integrity sha512-/fzlCGKRmfayGoI9UUXvJfc2nMZlJHW30QqEvwPvlg8tsX7jyiUSomYie6mYqx7Z9bOMGoag0H/q1PS/0PjYkg==
organize-imports-cli@^0.7.0:
version "0.7.0"
resolved "https://registry.yarnpkg.com/organize-imports-cli/-/organize-imports-cli-0.7.0.tgz#a6ffcec2d31c5de60748efe8861ea8cecd727276"
@ -10570,6 +10601,70 @@ prop-types@^15.0.0, prop-types@^15.5.10, prop-types@^15.8.1:
object-assign "^4.1.1"
react-is "^16.13.1"
prosemirror-commands@^1.1.9:
version "1.2.2"
resolved "https://registry.yarnpkg.com/prosemirror-commands/-/prosemirror-commands-1.2.2.tgz#1bd167372ee20abf488aca9cece63c43fab182c9"
integrity sha512-TX+KpWudMon06frryfpO/u7hsQv2hu8L4VSVbCpi3/7wXHBgl+35mV85qfa3RpT8xD2f3MdeoTqH0vy5JdbXPg==
dependencies:
prosemirror-model "^1.0.0"
prosemirror-state "^1.0.0"
prosemirror-transform "^1.0.0"
prosemirror-history@^1.1.3:
version "1.2.0"
resolved "https://registry.yarnpkg.com/prosemirror-history/-/prosemirror-history-1.2.0.tgz#04cc4df8d2f7b2a46651a2780de191ada6d465ea"
integrity sha512-B9v9xtf4fYbKxQwIr+3wtTDNLDZcmMMmGiI3TAPShnUzvo+Rmv1GiUrsQChY1meetHl7rhML2cppF3FTs7f7UQ==
dependencies:
prosemirror-state "^1.2.2"
prosemirror-transform "^1.0.0"
rope-sequence "^1.3.0"
prosemirror-inputrules@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/prosemirror-inputrules/-/prosemirror-inputrules-1.1.3.tgz#93f9199ca02473259c30d7e352e4c14022d54638"
integrity sha512-ZaHCLyBtvbyIHv0f5p6boQTIJjlD6o2NPZiEaZWT2DA+j591zS29QQEMT4lBqwcLW3qRSf7ZvoKNbf05YrsStw==
dependencies:
prosemirror-state "^1.0.0"
prosemirror-transform "^1.0.0"
prosemirror-keymap@^1.1.4:
version "1.1.5"
resolved "https://registry.yarnpkg.com/prosemirror-keymap/-/prosemirror-keymap-1.1.5.tgz#b5984c7d30f5c75956c853126c54e9e624c0327b"
integrity sha512-8SZgPH3K+GLsHL2wKuwBD9rxhsbnVBTwpHCO4VUO5GmqUQlxd/2GtBVWTsyLq4Dp3N9nGgPd3+lZFKUDuVp+Vw==
dependencies:
prosemirror-state "^1.0.0"
w3c-keyname "^2.2.0"
prosemirror-model@^1.0.0, prosemirror-model@^1.14.1, prosemirror-model@^1.16.0:
version "1.16.1"
resolved "https://registry.yarnpkg.com/prosemirror-model/-/prosemirror-model-1.16.1.tgz#fb388270bc9609b66298d6a7e15d0cc1d6c61253"
integrity sha512-r1/w0HDU40TtkXp0DyKBnFPYwd8FSlUSJmGCGFv4DeynfeSlyQF2FD0RQbVEMOe6P3PpUSXM6LZBV7W/YNZ4mA==
dependencies:
orderedmap "^1.1.0"
prosemirror-state@^1.0.0, prosemirror-state@^1.2.2, prosemirror-state@^1.3.4:
version "1.3.4"
resolved "https://registry.yarnpkg.com/prosemirror-state/-/prosemirror-state-1.3.4.tgz#4c6b52628216e753fc901c6d2bfd84ce109e8952"
integrity sha512-Xkkrpd1y/TQ6HKzN3agsQIGRcLckUMA9u3j207L04mt8ToRgpGeyhbVv0HI7omDORIBHjR29b7AwlATFFf2GLA==
dependencies:
prosemirror-model "^1.0.0"
prosemirror-transform "^1.0.0"
prosemirror-transform@^1.0.0, prosemirror-transform@^1.1.0:
version "1.4.2"
resolved "https://registry.yarnpkg.com/prosemirror-transform/-/prosemirror-transform-1.4.2.tgz#35f56091bcab3359f1eb90e82ce9f20cc52105c1"
integrity sha512-bcIsf3uRZhfab0xRfyyxOEh6eqSszq/hJbDbmUumFnbHBoWhB/uXbpz6vvUxfk0XiEvrZDJ+5pXRrNDc1Hu3vQ==
dependencies:
prosemirror-model "^1.0.0"
prosemirror-view@^1.18.7:
version "1.23.11"
resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.23.11.tgz#297f6ef8d10e1ff78c505d9c57358e062810a80e"
integrity sha512-iBqsyrQZz9NYcJ13JC7sPZ+4PdbBbUXhs1qzbxkDQ2tplcVROwxmAn3bnxpVFst/guv+XFI5KTHHbw5stvKt0g==
dependencies:
prosemirror-model "^1.16.0"
prosemirror-state "^1.0.0"
prosemirror-transform "^1.1.0"
property-information@^6.0.0:
version "6.1.1"
resolved "https://registry.yarnpkg.com/property-information/-/property-information-6.1.1.tgz#5ca85510a3019726cb9afed4197b7b8ac5926a22"
@ -11693,6 +11788,11 @@ rimraf@^3.0.0:
dependencies:
glob "^7.1.3"
rope-sequence@^1.3.0:
version "1.3.2"
resolved "https://registry.yarnpkg.com/rope-sequence/-/rope-sequence-1.3.2.tgz#a19e02d72991ca71feb6b5f8a91154e48e3c098b"
integrity sha512-ku6MFrwEVSVmXLvy3dYph3LAMNS0890K7fabn+0YIRQ2T96T9F4gkFf0vf0WW0JUraNWwGRtInEpH7yO4tbQZg==
rsvp@^4.8.4:
version "4.8.5"
resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-4.8.5.tgz#c8f155311d167f68f21e168df71ec5b083113734"
@ -13618,6 +13718,11 @@ w3c-hr-time@^1.0.1, w3c-hr-time@^1.0.2:
dependencies:
browser-process-hrtime "^1.0.0"
w3c-keyname@^2.2.0:
version "2.2.4"
resolved "https://registry.yarnpkg.com/w3c-keyname/-/w3c-keyname-2.2.4.tgz#4ade6916f6290224cdbd1db8ac49eab03d0eef6b"
integrity sha512-tOhfEwEzFLJzf6d1ZPkYfGj+FWhIpBux9ppoP3rlclw3Z0BZv3N7b7030Z1kYth+6rDuAsXUFr+d0VE6Ed1ikw==
w3c-xmlserializer@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-1.1.2.tgz#30485ca7d70a6fd052420a3d12fd90e6339ce794"