diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/BlockEditor.tsx b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/BlockEditor.tsx index d8e03358687..e49f7d63122 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/BlockEditor.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/BlockEditor.tsx @@ -15,29 +15,53 @@ import { EDITOR_OPTIONS } from 'constants/BlockEditor.constants'; import { isEmpty, isNil } from 'lodash'; import React, { FC, useEffect, useState } from 'react'; import tippy, { Instance, Props } from 'tippy.js'; +import { + getBackendFormat, + getFrontEndFormat, + HTMLToMarkdown, + MarkdownToHTMLConverter, +} from 'utils/FeedUtils'; import './block-editor.less'; import BubbleMenu from './BubbleMenu/BubbleMenu'; +import ImageModal, { ImageData } from './ImageModal/ImageModal'; import LinkModal, { LinkData } from './LinkModal/LinkModal'; import LinkPopup from './LinkPopup/LinkPopup'; export interface BlockEditorProps { + // should be markdown string content?: string; editable?: boolean; + // will be call with markdown content + onChange?: (content: string) => void; } const BlockEditor: FC = ({ content = '', editable = true, + onChange, }) => { const [isLinkModalOpen, setIsLinkModalOpen] = useState(false); + const [isImageModalOpen, setIsImageModalOpen] = useState(false); const editor = useEditor({ ...EDITOR_OPTIONS, + onUpdate({ editor }) { + const htmlContent = editor.getHTML(); + + const markdown = HTMLToMarkdown.turndown(htmlContent); + + const backendFormat = getBackendFormat(markdown); + + onChange?.(backendFormat); + }, }); const handleLinkToggle = () => { setIsLinkModalOpen((prev) => !prev); }; + const handleImageToggle = () => { + setIsImageModalOpen((prev) => !prev); + }; const handleLinkCancel = () => { handleLinkToggle(); @@ -132,6 +156,16 @@ const BlockEditor: FC = ({ } }; + const handleAddImage = (values: ImageData) => { + if (isNil(editor)) { + return; + } + + editor.chain().focus().setImage({ src: values.src }).run(); + + handleImageToggle(); + }; + const menus = !isNil(editor) && ( ); @@ -145,7 +179,10 @@ const BlockEditor: FC = ({ // mentioned here https://github.com/ueberdosis/tiptap/issues/3764#issuecomment-1546854730 setTimeout(() => { if (content !== undefined) { - editor.commands.setContent(content); + const htmlContent = MarkdownToHTMLConverter.makeHtml( + getFrontEndFormat(content) + ); + editor.commands.setContent(htmlContent); } }); }, [content, editor]); @@ -175,6 +212,13 @@ const BlockEditor: FC = ({ } /> )} + {isImageModalOpen && ( + + )}
{menus} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/diffView/index.ts b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/diffView/index.ts new file mode 100644 index 00000000000..0aa240200fc --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/diffView/index.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2023 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 { mergeAttributes, Node } from '@tiptap/core'; + +export default Node.create({ + name: 'diffView', + content: 'inline*', + group: 'inline', + inline: true, + + addAttributes() { + return { + class: { + default: '', + }, + }; + }, + + parseHTML() { + return [ + { + tag: 'diff-view', + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ['diff-view', mergeAttributes(HTMLAttributes), 0]; + }, +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/image/index.ts b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/image/index.ts new file mode 100644 index 00000000000..70790238416 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/image/index.ts @@ -0,0 +1,114 @@ +/* + * Copyright 2023 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 { mergeAttributes, Node, nodeInputRule } from '@tiptap/core'; +import { IMAGE_INPUT_REGEX } from 'constants/BlockEditor.constants'; + +export interface ImageOptions { + inline: boolean; + allowBase64: boolean; + HTMLAttributes: Record; +} + +declare module '@tiptap/core' { + interface Commands { + image: { + /** + * Add an image + */ + setImage: (options: { + src: string; + alt?: string; + title?: string; + }) => ReturnType; + }; + } +} + +export const Image = Node.create({ + name: 'image', + selectable: false, + + addOptions() { + return { + inline: false, + allowBase64: false, + HTMLAttributes: {}, + }; + }, + + inline() { + return this.options.inline; + }, + + group() { + return this.options.inline ? 'inline' : 'block'; + }, + + addAttributes() { + return { + src: { + default: null, + }, + alt: { + default: null, + }, + title: { + default: null, + }, + }; + }, + + parseHTML() { + return [ + { + tag: this.options.allowBase64 + ? 'img[src]' + : 'img[src]:not([src^="data:"])', + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'img', + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), + ]; + }, + + addCommands() { + return { + setImage: + (options) => + ({ commands }) => { + return commands.insertContent({ + type: this.name, + attrs: options, + }); + }, + }; + }, + + addInputRules() { + return [ + nodeInputRule({ + find: IMAGE_INPUT_REGEX, + type: this.type, + getAttributes: (match) => { + const [, , alt, src, title] = match; + + return { src, alt, title }; + }, + }), + ]; + }, +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/mention/mentionSuggestions.ts b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/mention/mentionSuggestions.ts index abe7a3b6e8a..6f71db475ce 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/mention/mentionSuggestions.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/mention/mentionSuggestions.ts @@ -15,8 +15,9 @@ import tippy, { Instance, Props } from 'tippy.js'; import { SuggestionKeyDownProps, SuggestionProps } from '@tiptap/suggestion'; import { WILD_CARD_CHAR } from 'constants/char.constants'; -import { getTeamAndUserDetailsPath, getUserPath } from 'constants/constants'; +import { EntityUrlMapType, ENTITY_URL_MAP } from 'constants/Feeds.constants'; import { getSearchedUsers, getUserSuggestions } from 'rest/miscAPI'; +import { buildMentionLink } from 'utils/FeedUtils'; import { ExtensionRef } from '../BlockEditor.interface'; import MentionList from './MentionList'; @@ -31,10 +32,10 @@ export const mentionSuggestion = () => ({ name: hit._source.name, label: hit._source.displayName, fqn: hit._source.fullyQualifiedName, - href: - hit._source.entityType === 'user' - ? getUserPath(hit._source.fullyQualifiedName ?? '') - : getTeamAndUserDetailsPath(hit._source.fullyQualifiedName ?? ''), + href: buildMentionLink( + ENTITY_URL_MAP[hit._source.entityType as EntityUrlMapType], + hit._source.name + ), type: hit._source.entityType, })); } else { @@ -46,10 +47,10 @@ export const mentionSuggestion = () => ({ name: hit._source.name, label: hit._source.displayName, fqn: hit._source.fullyQualifiedName, - href: - hit._source.entityType === 'user' - ? getUserPath(hit._source.fullyQualifiedName ?? '') - : getTeamAndUserDetailsPath(hit._source.fullyQualifiedName ?? ''), + href: buildMentionLink( + ENTITY_URL_MAP[hit._source.entityType as EntityUrlMapType], + hit._source.name + ), type: hit._source.entityType, })); } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/ImageModal/ImageModal.tsx b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/ImageModal/ImageModal.tsx new file mode 100644 index 00000000000..a36581072f0 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/ImageModal/ImageModal.tsx @@ -0,0 +1,56 @@ +/* + * Copyright 2023 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 { Form, FormProps, Input, Modal } from 'antd'; +import React, { FC } from 'react'; + +export interface ImageData { + src: string; +} + +export interface LinkModalProps { + isOpen: boolean; + onSave: (data: ImageData) => void; + onCancel: () => void; +} + +const ImageModal: FC = ({ isOpen, onSave, onCancel }) => { + const handleSubmit: FormProps['onFinish'] = (values) => { + onSave(values); + }; + + return ( + +
+ + + +
+
+ ); +}; + +export default ImageModal; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/LinkModal/LinkModal.tsx b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/LinkModal/LinkModal.tsx index f1ac090e9e7..0ada6aea193 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/LinkModal/LinkModal.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/LinkModal/LinkModal.tsx @@ -32,6 +32,7 @@ const LinkModal: FC = ({ isOpen, data, onSave, onCancel }) => { return ( = { Hashtag.configure({ suggestion: hashtagSuggestion(), }), + DiffView, + Image.configure({ + allowBase64: true, + inline: true, + }), ], enableInputRules: [ @@ -112,4 +119,10 @@ export const EDITOR_OPTIONS: Partial = { 'orderedList', 'strike', ], + parseOptions: { + preserveWhitespace: 'full', + }, }; + +export const IMAGE_INPUT_REGEX = + /(?:^|\s)(!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\))$/; diff --git a/openmetadata-ui/src/main/resources/ui/src/constants/Feeds.constants.ts b/openmetadata-ui/src/main/resources/ui/src/constants/Feeds.constants.ts index 20853dc7e36..b6c0b90d1a5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/constants/Feeds.constants.ts +++ b/openmetadata-ui/src/main/resources/ui/src/constants/Feeds.constants.ts @@ -22,11 +22,13 @@ export const teamsLinkRegEx = /\((.+?\/\/.+?)\/(.+?\/.+?\/.+?)\/(.+?)\)/; export const entityLinkRegEx = /<#E::([^<>]+?)::([^<>]+?)>/g; export const entityRegex = /<#E::([^<>]+?)::([^<>]+?)\|(\[(.+?)?\]\((.+?)?\))>/; -export const entityUrlMap = { +export const ENTITY_URL_MAP = { team: 'settings/members/teams', user: 'users', }; +export type EntityUrlMapType = keyof typeof ENTITY_URL_MAP; + export const confirmStateInitialValue = { state: false, threadId: undefined, diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/EntityLink.ts b/openmetadata-ui/src/main/resources/ui/src/utils/EntityLink.ts index 2929a3f5cb1..d8f0ef0c1a4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/EntityLink.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/EntityLink.ts @@ -109,7 +109,11 @@ export default class EntityLink { * @param string entityFqn * @returns entityLink */ - static getEntityLink(entityType: string, entityFqn: string) { + static getEntityLink(entityType: string, entityFqn: string, field?: string) { + if (field) { + return `<#E${ENTITY_LINK_SEPARATOR}${entityType}${ENTITY_LINK_SEPARATOR}${entityFqn}${ENTITY_LINK_SEPARATOR}${field}>`; + } + return `<#E${ENTITY_LINK_SEPARATOR}${entityType}${ENTITY_LINK_SEPARATOR}${entityFqn}>`; } } diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/FeedElementUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/FeedElementUtils.tsx index b0b3f194e5d..ead2c3f0c2a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/FeedElementUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/FeedElementUtils.tsx @@ -14,12 +14,9 @@ import { Tooltip } from 'antd'; import { DE_ACTIVE_COLOR } from 'constants/constants'; import { t } from 'i18next'; -import { isUndefined } from 'lodash'; import React from 'react'; import { ReactComponent as IconComments } from '../assets/svg/comment.svg'; -import { entityUrlMap } from '../constants/Feeds.constants'; import { ThreadType } from '../generated/entity/feed/thread'; -import { EntityReference } from '../generated/entity/teams/user'; import { getEntityFeedLink } from './EntityUtils'; const iconsProps = { @@ -58,19 +55,3 @@ export const getFieldThreadElement = ( ); }; - -export const getDefaultValue = (owner: EntityReference) => { - const message = t('message.can-you-add-a-description'); - if (isUndefined(owner)) { - return `${message}`; - } else { - const name = owner.name; - const displayName = owner.displayName; - const entityType = owner.type; - const mention = `@${displayName}`; - - return `${mention} ${message}`; - } -}; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/FeedUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/FeedUtils.tsx index 793c65ab9d8..b371d616fa6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/FeedUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/FeedUtils.tsx @@ -42,7 +42,8 @@ import { entityLinkRegEx, EntityRegEx, entityRegex, - entityUrlMap, + EntityUrlMapType, + ENTITY_URL_MAP, hashtagRegEx, linkRegEx, mentionRegEx, @@ -219,7 +220,7 @@ export async function suggestions( id: hit._id, value: name, link: buildMentionLink( - entityUrlMap[entityType as keyof typeof entityUrlMap], + ENTITY_URL_MAP[entityType as EntityUrlMapType], hit._source.name ), name: hit._source.name, @@ -246,7 +247,7 @@ export async function suggestions( id: hit._id, value: name, link: buildMentionLink( - entityUrlMap[entityType as keyof typeof entityUrlMap], + ENTITY_URL_MAP[entityType as EntityUrlMapType], hit._source.name ), name: hit._source.name, @@ -352,7 +353,7 @@ export const getBackendFormat = (message: string) => { const hashtagList = [...new Set(getHashTagList(message) ?? [])]; const mentionDetails = mentionList.map((m) => getEntityDetail(m) ?? []); const hashtagDetails = hashtagList.map((h) => getEntityDetail(h) ?? []); - const urlEntries = Object.entries(entityUrlMap); + const urlEntries = Object.entries(ENTITY_URL_MAP); mentionList.forEach((m, i) => { const updatedDetails = mentionDetails[i].slice(-2); @@ -594,6 +595,9 @@ export const entityDisplayName = (entityType: string, entityFQN: string) => { export const MarkdownToHTMLConverter = new Showdown.Converter({ strikethrough: true, + tables: true, + tasklists: true, + simpleLineBreaks: true, }); export const getFeedPanelHeaderText = (