From fd54b36dfef83a03f1689a65e27bc5a8e3cf9d53 Mon Sep 17 00:00:00 2001 From: Harshit Shah Date: Sat, 1 Aug 2026 18:08:08 +0530 Subject: [PATCH] perf(ui): lazy load activity feed and split editor formatting (#29178) (#30790) * perf(ui): lazy load activity feed editor surfaces * fix(ui): remove activity feed cache split dependencies * fix(ui): apply activity feed checkstyle * fix(ui): consolidate block editor content formatting * fix(ui): clarify block editor server formatting (cherry picked from commit 0f76ecf69f1de427c3d1ec37526f2ff519079082) --- .../FeedCardBody/FeedCardBody.tsx | 19 +- .../FeedCardBody/FeedCardBodyNew.tsx | 28 ++- .../ActivityThreadPanel/ActivityThread.tsx | 14 +- .../ActivityThreadList.tsx | 24 ++- .../ActivityThreadPanelBody.tsx | 33 ++- .../components/BlockEditor/BlockEditor.tsx | 10 +- .../ContractDetailTab/ContractDetail.test.tsx | 2 +- .../common/RichTextEditor/RichTextEditor.tsx | 4 +- .../RichTextEditorPreviewNew.test.tsx | 4 +- .../RichTextEditorPreviewNew.tsx | 8 +- .../RichTextEditorPreviewerV1.test.tsx | 12 +- .../RichTextEditorPreviewerV1.tsx | 8 +- .../TaskDescriptionPreviewer.test.tsx | 46 +++-- .../TaskDescriptionPreviewer.tsx | 17 +- .../ColumnGrid/ColumnGrid.component.tsx | 4 +- .../ui/src/utils/BlockEditorPureUtils.ts | 109 ++++++++++ .../ui/src/utils/BlockEditorUtils.test.ts | 13 +- .../ui/src/utils/BlockEditorUtils.ts | 189 ++++-------------- 18 files changed, 322 insertions(+), 222 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCard/FeedCardBody/FeedCardBody.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCard/FeedCardBody/FeedCardBody.tsx index a91d9f0010d6..f3e58d6f40a4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCard/FeedCardBody/FeedCardBody.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCard/FeedCardBody/FeedCardBody.tsx @@ -14,17 +14,28 @@ import { Button, Space, Typography } from 'antd'; import classNames from 'classnames'; import { isUndefined } from 'lodash'; -import { FC, useEffect, useMemo, useState } from 'react'; +import { FC, lazy, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { formatDateTime } from '../../../../utils/date-time/DateTimeUtils'; import { getFrontEndFormat, MarkdownToHTMLConverter, } from '../../../../utils/FeedUtilsPure'; -import RichTextEditorPreviewerV1 from '../../../common/RichTextEditor/RichTextEditorPreviewerV1'; -import ActivityFeedEditor from '../../ActivityFeedEditor/ActivityFeedEditor'; -import Reactions from '../../Reactions/Reactions'; +import withSuspenseFallback from '../../../AppRouter/withSuspenseFallback'; import { FeedBodyProp } from '../ActivityFeedCard.interface'; + +const RichTextEditorPreviewerV1 = withSuspenseFallback( + lazy(() => import('../../../common/RichTextEditor/RichTextEditorPreviewerV1')) +); + +const Reactions = withSuspenseFallback( + lazy(() => import('../../Reactions/Reactions')) +); + +const ActivityFeedEditor = withSuspenseFallback( + lazy(() => import('../../ActivityFeedEditor/ActivityFeedEditor')) +); + const FeedCardBody: FC = ({ message, announcementDetails, diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCard/FeedCardBody/FeedCardBodyNew.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCard/FeedCardBody/FeedCardBodyNew.tsx index 64b1f16b38b5..28b905520e8f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCard/FeedCardBody/FeedCardBodyNew.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCard/FeedCardBody/FeedCardBodyNew.tsx @@ -24,12 +24,32 @@ import { getFrontEndFormat, MarkdownToHTMLConverter, } from '../../../../utils/FeedUtilsPure'; -import RichTextEditorPreviewerNew from '../../../common/RichTextEditor/RichTextEditorPreviewNew'; -import DescriptionFeedNew from '../../ActivityFeedCardV2/FeedCardBody/DescriptionFeed/DescriptionFeedNew'; -import OwnersFeed from '../../ActivityFeedCardV2/FeedCardBody/OwnerFeed/OwnersFeed'; -import TagsFeed from '../../ActivityFeedCardV2/FeedCardBody/TagsFeed/TagsFeed'; import './feed-card-body-v1.less'; import { FeedCardBodyV1Props } from './FeedCardBodyV1.interface'; + +const RichTextEditorPreviewerNew = withSuspenseFallback( + lazy(() => import('../../../common/RichTextEditor/RichTextEditorPreviewNew')) +); + +const DescriptionFeedNew = withSuspenseFallback( + lazy( + () => + import( + '../../ActivityFeedCardV2/FeedCardBody/DescriptionFeed/DescriptionFeedNew' + ) + ) +); + +const OwnersFeed = withSuspenseFallback( + lazy( + () => import('../../ActivityFeedCardV2/FeedCardBody/OwnerFeed/OwnersFeed') + ) +); + +const TagsFeed = withSuspenseFallback( + lazy(() => import('../../ActivityFeedCardV2/FeedCardBody/TagsFeed/TagsFeed')) +); + const ActivityFeedEditor = withSuspenseFallback( lazy(() => import('../../ActivityFeedEditor/ActivityFeedEditor')) ); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityThreadPanel/ActivityThread.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityThreadPanel/ActivityThread.tsx index a9c42dfb6e16..7a04f36e385c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityThreadPanel/ActivityThread.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityThreadPanel/ActivityThread.tsx @@ -13,7 +13,7 @@ import { Divider } from 'antd'; import { AxiosError } from 'axios'; -import { FC, useEffect, useState } from 'react'; +import { FC, lazy, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Post, @@ -23,9 +23,17 @@ import { import { getFeedById } from '../../../rest/feedsAPI'; import { getReplyText } from '../../../utils/FeedUtilsPure'; import { showErrorToast } from '../../../utils/ToastUtils'; -import ActivityFeedCard from '../ActivityFeedCard/ActivityFeedCard'; -import ActivityFeedEditor from '../ActivityFeedEditor/ActivityFeedEditor'; +import withSuspenseFallback from '../../AppRouter/withSuspenseFallback'; import { ActivityThreadProp } from './ActivityThreadPanel.interface'; + +const ActivityFeedCard = withSuspenseFallback( + lazy(() => import('../ActivityFeedCard/ActivityFeedCard')) +); + +const ActivityFeedEditor = withSuspenseFallback( + lazy(() => import('../ActivityFeedEditor/ActivityFeedEditor')) +); + const ActivityThread: FC = ({ className, selectedThread, diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityThreadPanel/ActivityThreadList.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityThreadPanel/ActivityThreadList.tsx index d4cadd4b5156..8f3810544483 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityThreadPanel/ActivityThreadList.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityThreadPanel/ActivityThreadList.tsx @@ -12,7 +12,7 @@ */ import { Card, Typography } from 'antd'; import { isEqual } from 'lodash'; -import { FC, Fragment } from 'react'; +import { FC, Fragment, lazy } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { @@ -29,14 +29,28 @@ import { } from '../../../generated/entity/feed/thread'; import { getFeedListWithRelativeDays } from '../../../utils/FeedUtilsPure'; import { getTaskDetailPath } from '../../../utils/TasksUtils'; +import withSuspenseFallback from '../../AppRouter/withSuspenseFallback'; import { OwnerLabel } from '../../common/OwnerLabel/OwnerLabel.component'; -import ActivityFeedCard from '../ActivityFeedCard/ActivityFeedCard'; -import FeedCardFooter from '../ActivityFeedCard/FeedCardFooter/FeedCardFooter'; -import ActivityFeedEditor from '../ActivityFeedEditor/ActivityFeedEditor'; -import FeedListSeparator from '../FeedListSeparator/FeedListSeparator'; import AnnouncementBadge from '../Shared/AnnouncementBadge'; import TaskBadge from '../Shared/TaskBadge'; import { ActivityThreadListProp } from './ActivityThreadPanel.interface'; + +const ActivityFeedCard = withSuspenseFallback( + lazy(() => import('../ActivityFeedCard/ActivityFeedCard')) +); + +const FeedCardFooter = withSuspenseFallback( + lazy(() => import('../ActivityFeedCard/FeedCardFooter/FeedCardFooter')) +); + +const FeedListSeparator = withSuspenseFallback( + lazy(() => import('../FeedListSeparator/FeedListSeparator')) +); + +const ActivityFeedEditor = withSuspenseFallback( + lazy(() => import('../ActivityFeedEditor/ActivityFeedEditor')) +); + const ActivityThreadList: FC = ({ className, threads, diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityThreadPanel/ActivityThreadPanelBody.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityThreadPanel/ActivityThreadPanelBody.tsx index 0a568e315c3d..421a5c992631 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityThreadPanel/ActivityThreadPanelBody.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityThreadPanel/ActivityThreadPanelBody.tsx @@ -16,7 +16,7 @@ import { AxiosError } from 'axios'; import classNames from 'classnames'; import { Operation } from 'fast-json-patch'; import { isEqual, isUndefined } from 'lodash'; -import { FC, Fragment, RefObject, useEffect, useState } from 'react'; +import { FC, Fragment, lazy, RefObject, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { confirmStateInitialValue } from '../../../constants/Feeds.constants'; import { observerOptions } from '../../../constants/Mydata.constants'; @@ -31,16 +31,35 @@ import { Paging } from '../../../generated/type/paging'; import { useElementInView } from '../../../hooks/useElementInView'; import { getAllFeeds } from '../../../rest/feedsAPI'; import { showErrorToast } from '../../../utils/ToastUtils'; -import ErrorPlaceHolder from '../../common/ErrorWithPlaceholder/ErrorPlaceHolder'; +import withSuspenseFallback from '../../AppRouter/withSuspenseFallback'; import Loader from '../../common/Loader/Loader'; -import ConfirmationModal from '../../Modals/ConfirmationModal/ConfirmationModal'; import { ConfirmState } from '../ActivityFeedCard/ActivityFeedCard.interface'; -import ActivityFeedEditor from '../ActivityFeedEditor/ActivityFeedEditor'; -import FeedPanelHeader from '../ActivityFeedPanel/FeedPanelHeader'; -import ActivityThread from './ActivityThread'; -import ActivityThreadList from './ActivityThreadList'; import { ActivityThreadPanelBodyProp } from './ActivityThreadPanel.interface'; +const ErrorPlaceHolder = withSuspenseFallback( + lazy(() => import('../../common/ErrorWithPlaceholder/ErrorPlaceHolder')) +); + +const FeedPanelHeader = withSuspenseFallback( + lazy(() => import('../ActivityFeedPanel/FeedPanelHeader')) +); + +const ActivityThread = withSuspenseFallback( + lazy(() => import('./ActivityThread')) +); + +const ActivityThreadList = withSuspenseFallback( + lazy(() => import('./ActivityThreadList')) +); + +const ActivityFeedEditor = withSuspenseFallback( + lazy(() => import('../ActivityFeedEditor/ActivityFeedEditor')) +); + +const ConfirmationModal = withSuspenseFallback( + lazy(() => import('../../Modals/ConfirmationModal/ConfirmationModal')) +); + const ActivityThreadPanelBody: FC = ({ threadLink, onCancel, 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 5502775157a2..fe99f423fb63 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 @@ -22,7 +22,11 @@ import { TEXT_TYPES, } from '../../constants/BlockEditor.constants'; import blockEditorExtensionsClassBase from '../../utils/BlockEditorExtensionsClassBase'; -import { formatContent, setEditorContent } from '../../utils/BlockEditorUtils'; +import { formatClientContent } from '../../utils/BlockEditorPureUtils'; +import { + formatServerContent, + setEditorContent, +} from '../../utils/BlockEditorUtils'; import Banner from '../common/Banner/Banner'; import { useEntityAttachment } from '../common/EntityDescription/EntityAttachmentProvider/EntityAttachmentProvider'; import BarMenu from './BarMenu/BarMenu'; @@ -74,7 +78,7 @@ const BlockEditor = forwardRef( onUpdate({ editor }) { handleErrorMessage?.(undefined); const htmlContent = editor.getHTML(); - const backendFormat = formatContent(htmlContent, 'server'); + const backendFormat = formatServerContent(htmlContent); onChange?.(backendFormat); }, onFocus() { @@ -200,7 +204,7 @@ const BlockEditor = forwardRef( // mentioned here https://github.com/ueberdosis/tiptap/issues/3764#issuecomment-1546854730 setTimeout(() => { if (content !== undefined) { - const htmlContent = formatContent(content, 'client'); + const htmlContent = formatClientContent(content); setEditorContent(editor, htmlContent); } }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractDetailTab/ContractDetail.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractDetailTab/ContractDetail.test.tsx index a86f99b3580a..d1b732158e97 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractDetailTab/ContractDetail.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractDetailTab/ContractDetail.test.tsx @@ -55,7 +55,7 @@ jest.mock('../../../utils/DataContract/DataContractUtils', () => ({ })); jest.mock('../../../utils/BlockEditorUtils', () => ({ - formatContent: jest.fn().mockReturnValue('formatted content'), + formatServerContent: jest.fn().mockReturnValue('formatted content'), })); jest.mock('../../../utils/BlockEditorPureUtils', () => ({ diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/RichTextEditor.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/RichTextEditor.tsx index cbc22edc47be..39d98567c7c2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/RichTextEditor.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/RichTextEditor.tsx @@ -16,7 +16,7 @@ import classNames from 'classnames'; import { forwardRef, useImperativeHandle, useRef } from 'react'; import { - formatContent, + formatServerContent, formatValueBasedOnContent, setEditorContent, } from '../../../utils/BlockEditorUtils'; @@ -53,7 +53,7 @@ const RichTextEditor = forwardRef( useImperativeHandle(ref, () => ({ getEditorContent() { const htmlContent = editorRef.current?.editor?.getHTML() ?? ''; - const backendFormat = formatContent(htmlContent, 'server'); + const backendFormat = formatServerContent(htmlContent); return formatValueBasedOnContent(backendFormat); }, diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/RichTextEditorPreviewNew.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/RichTextEditorPreviewNew.test.tsx index d9dcd7004601..7971afd99b3b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/RichTextEditorPreviewNew.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/RichTextEditorPreviewNew.test.tsx @@ -28,8 +28,8 @@ jest.mock('../../BlockEditor/BlockEditor', () => { )); }); -jest.mock('../../../utils/BlockEditorUtils', () => ({ - formatContent: jest.fn((content) => content), +jest.mock('../../../utils/BlockEditorPureUtils', () => ({ + formatClientContent: jest.fn((content) => content), isDescriptionContentEmpty: jest.fn((content) => !content || content === ''), })); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/RichTextEditorPreviewNew.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/RichTextEditorPreviewNew.tsx index de2723e55201..68472bbe2dc6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/RichTextEditorPreviewNew.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/RichTextEditorPreviewNew.tsx @@ -14,8 +14,10 @@ import { Button } from 'antd'; import classNames from 'classnames'; import { FC, lazy, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { isDescriptionContentEmpty } from '../../../utils/BlockEditorPureUtils'; -import { formatContent } from '../../../utils/BlockEditorUtils'; +import { + formatClientContent, + isDescriptionContentEmpty, +} from '../../../utils/BlockEditorPureUtils'; import withSuspenseFallback from '../../AppRouter/withSuspenseFallback'; import './rich-text-editor-previewerV1.less'; import { PreviewerProp } from './RichTextEditor.interface'; @@ -56,7 +58,7 @@ const RichTextEditorPreviewerNew: FC = ({ const handleReadMoreToggle = () => setReadMore((prev) => !prev); useEffect(() => { - setContent(formatContent(markdown, 'client')); + setContent(formatClientContent(markdown)); setIsContentLoaded(false); setIsOverflowing(false); }, [markdown]); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/RichTextEditorPreviewerV1.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/RichTextEditorPreviewerV1.test.tsx index 34c7d73e7a4e..a164ab229dd4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/RichTextEditorPreviewerV1.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/RichTextEditorPreviewerV1.test.tsx @@ -23,8 +23,8 @@ jest.mock('../../BlockEditor/BlockEditor', () => { )); }); -jest.mock('../../../utils/BlockEditorUtils', () => ({ - formatContent: jest.fn((content) => content), +jest.mock('../../../utils/BlockEditorPureUtils', () => ({ + formatClientContent: jest.fn((content) => content), isDescriptionContentEmpty: jest.fn((content) => !content || content === ''), })); @@ -219,11 +219,13 @@ describe('RichTextEditorPreviewerV1', () => { ); }); - it('should format content using formatContent utility', () => { - const { formatContent } = require('../../../utils/BlockEditorUtils'); + it('should format content using formatClientContent utility', () => { + const { + formatClientContent, + } = require('../../../utils/BlockEditorPureUtils'); render(); - expect(formatContent).toHaveBeenCalledWith(mockLongMarkdown, 'client'); + expect(formatClientContent).toHaveBeenCalledWith(mockLongMarkdown); }); it('should initialize with expanded state based on isDescriptionExpanded prop', () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/RichTextEditorPreviewerV1.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/RichTextEditorPreviewerV1.tsx index d374a6eb6e00..f7689e017977 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/RichTextEditorPreviewerV1.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/RichTextEditorPreviewerV1.tsx @@ -15,8 +15,10 @@ import classNames from 'classnames'; import { FC, lazy, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { DESCRIPTION_MAX_PREVIEW_CHARACTERS } from '../../../constants/constants'; -import { isDescriptionContentEmpty } from '../../../utils/BlockEditorPureUtils'; -import { formatContent } from '../../../utils/BlockEditorUtils'; +import { + formatClientContent, + isDescriptionContentEmpty, +} from '../../../utils/BlockEditorPureUtils'; import { getTrimmedContent } from '../../../utils/StringUtils'; import withSuspenseFallback from '../../AppRouter/withSuspenseFallback'; import './rich-text-editor-previewerV1.less'; @@ -63,7 +65,7 @@ const RichTextEditorPreviewerV1: FC = ({ }, [hasReadMore, readMore, maxLength, content]); useEffect(() => { - setContent(formatContent(markdown, 'client')); + setContent(formatClientContent(markdown)); }, [markdown]); useEffect(() => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/TaskDescriptionPreviewer.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/TaskDescriptionPreviewer.test.tsx index 7de247a92115..d338193ce95b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/TaskDescriptionPreviewer.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/TaskDescriptionPreviewer.test.tsx @@ -10,7 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { act, render, screen } from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; import { PreviewerProp } from './RichTextEditor.interface'; import TaskDescriptionPreviewer from './TaskDescriptionPreviewer'; @@ -22,8 +22,8 @@ jest.mock('../../BlockEditor/BlockEditor', () => { )); }); -jest.mock('../../../utils/BlockEditorUtils', () => ({ - formatContent: jest.fn((content) => content), +jest.mock('../../../utils/BlockEditorPureUtils', () => ({ + formatClientContent: jest.fn((content) => content), isDescriptionContentEmpty: jest.fn((content) => !content || content === ''), })); @@ -48,20 +48,20 @@ describe('TaskDescriptionPreviewer', () => { afterEach(() => { // reset mocks - jest.mock('../../../utils/BlockEditorUtils', () => ({ - formatContent: jest.fn((content) => content), + jest.mock('../../../utils/BlockEditorPureUtils', () => ({ + formatClientContent: jest.fn((content) => content), isDescriptionContentEmpty: jest.fn( (content) => !content || content === '' ), })); }); - it('should render the component with markdown content', () => { + it('should render the component with markdown content', async () => { render(); expect(screen.getByTestId('viewer-container')).toBeInTheDocument(); expect(screen.getByTestId('markdown-parser')).toBeInTheDocument(); - expect(screen.getByTestId('block-editor')).toBeInTheDocument(); + expect(await screen.findByTestId('block-editor')).toBeInTheDocument(); }); it('should render no-description placeholder when markdown is empty', () => { @@ -185,12 +185,12 @@ describe('TaskDescriptionPreviewer', () => { }); }); - it('should update content when markdown prop changes', () => { + it('should update content when markdown prop changes', async () => { const { rerender } = render( ); - expect(screen.getByTestId('block-editor')).toHaveTextContent( + expect(await screen.findByTestId('block-editor')).toHaveTextContent( 'Initial content' ); @@ -198,16 +198,18 @@ describe('TaskDescriptionPreviewer', () => { ); - expect(screen.getByTestId('block-editor')).toHaveTextContent( + expect(await screen.findByTestId('block-editor')).toHaveTextContent( 'Updated content' ); }); - it('should format content using formatContent utility', () => { - const { formatContent } = require('../../../utils/BlockEditorUtils'); + it('should format content using formatClientContent utility', () => { + const { + formatClientContent, + } = require('../../../utils/BlockEditorPureUtils'); render(); - expect(formatContent).toHaveBeenCalledWith(mockLongMarkdown, 'client'); + expect(formatClientContent).toHaveBeenCalledWith(mockLongMarkdown); }); it('should render with default props', () => { @@ -237,17 +239,19 @@ describe('TaskDescriptionPreviewer', () => { }); }); - it('should set BlockEditor to non-editable mode', () => { + it('should set BlockEditor to non-editable mode', async () => { const BlockEditor = require('../../BlockEditor/BlockEditor'); render(); - expect(BlockEditor).toHaveBeenCalledWith( - expect.objectContaining({ - editable: false, - autoFocus: false, - }), - {} - ); + await waitFor(() => { + expect(BlockEditor).toHaveBeenCalledWith( + expect.objectContaining({ + editable: false, + autoFocus: false, + }), + {} + ); + }); }); it('should handle empty content after formatting', async () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/TaskDescriptionPreviewer.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/TaskDescriptionPreviewer.tsx index 7fdf6f833b6e..f8614b2069f7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/TaskDescriptionPreviewer.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/TaskDescriptionPreviewer.tsx @@ -12,13 +12,20 @@ */ import { Button } from 'antd'; import classNames from 'classnames'; -import { FC, useEffect, useRef, useState } from 'react'; +import { FC, lazy, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { isDescriptionContentEmpty } from '../../../utils/BlockEditorPureUtils'; -import { formatContent } from '../../../utils/BlockEditorUtils'; -import BlockEditor from '../../BlockEditor/BlockEditor'; +import { + formatClientContent, + isDescriptionContentEmpty, +} from '../../../utils/BlockEditorPureUtils'; +import withSuspenseFallback from '../../AppRouter/withSuspenseFallback'; import './rich-text-editor-previewerV1.less'; import { PreviewerProp } from './RichTextEditor.interface'; + +const BlockEditor = withSuspenseFallback( + lazy(() => import('../../BlockEditor/BlockEditor')) +); + const TaskDescriptionPreviewer: FC = ({ markdown = '', className = '', @@ -33,7 +40,7 @@ const TaskDescriptionPreviewer: FC = ({ const contentRef = useRef(null); useEffect(() => { - setContent(formatContent(markdown, 'client')); + setContent(formatClientContent(markdown)); }, [markdown]); useEffect(() => { diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ColumnBulkOperations/ColumnGrid/ColumnGrid.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/ColumnBulkOperations/ColumnGrid/ColumnGrid.component.tsx index b5f6cc24cd68..11db35e1dd93 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/ColumnBulkOperations/ColumnGrid/ColumnGrid.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ColumnBulkOperations/ColumnGrid/ColumnGrid.component.tsx @@ -86,7 +86,7 @@ import { TagSource, } from '../../../generated/type/tagLabel'; import { bulkUpdateColumnsAsync } from '../../../rest/columnAPI'; -import { formatContent } from '../../../utils/BlockEditorUtils'; +import { formatClientContent } from '../../../utils/BlockEditorPureUtils'; import { getTableFQNFromColumnFQN } from '../../../utils/FqnUtils'; import { getEntityDetailsPath } from '../../../utils/RouterUtils'; import { getSanitizeContent } from '../../../utils/sanitize.utils'; @@ -249,7 +249,7 @@ const getDescriptionPreview = (description?: string): string => { } return ( - stringToDOMElement(getSanitizeContent(formatContent(description, 'client'))) + stringToDOMElement(getSanitizeContent(formatClientContent(description))) .textContent ?? '' ).slice(0, 100); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/BlockEditorPureUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/BlockEditorPureUtils.ts index 2a4af92a70a1..87eee3b0f761 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/BlockEditorPureUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/BlockEditorPureUtils.ts @@ -12,6 +12,115 @@ */ import { isEmpty } from 'lodash'; +import { ENTITY_URL_MAP } from '../constants/Feeds.constants'; +import { + getEntityDetail, + getHashTagList, + getMentionList, +} from './FeedUtilsPure'; +import { getSanitizeContent } from './sanitize.utils'; + +export const convertMarkdownFormatToHtmlString = (markdown: string) => { + let updatedMessage = markdown; + const urlEntries = Object.entries(ENTITY_URL_MAP); + + const mentionList = getMentionList(markdown) ?? []; + const hashTagList = getHashTagList(markdown) ?? []; + + const mentionMap = new Map( + mentionList.map((mention) => [mention, getEntityDetail(mention)]) + ); + + const hashTagMap = new Map( + hashTagList.map((hashTag) => [hashTag, getEntityDetail(hashTag)]) + ); + + [...mentionMap.entries()] + .sort(([leftKey], [rightKey]) => rightKey.length - leftKey.length) + .forEach(([key, value]) => { + if (value) { + const [, href, rawEntityType, fqn] = value; + const entityType = urlEntries.find((e) => e[1] === rawEntityType)?.[0]; + + if (entityType) { + const entityLink = `@${fqn}`; + updatedMessage = updatedMessage.replaceAll(key, () => entityLink); + } + } + }); + + [...hashTagMap.entries()] + .sort(([leftKey], [rightKey]) => rightKey.length - leftKey.length) + .forEach(([key, value]) => { + if (value) { + const [, href, rawEntityType, fqn] = value; + + const entityLink = `#${fqn}`; + updatedMessage = updatedMessage.replaceAll(key, () => entityLink); + } + }); + + return updatedMessage; +}; + +export const isHTMLString = (content: string) => { + const commonHtmlTags = + /<(p|div|span|a|ul|ol|li|h[1-6]|br|strong|em|code|pre)[>\s]/i; + + if (!commonHtmlTags.test(content)) { + return false; + } + + try { + const parser = new DOMParser(); + const parsedDocument = parser.parseFromString(content, 'text/html'); + + const hasHtmlElements = Array.from(parsedDocument.body.childNodes).some( + (node) => node.nodeType === Node.ELEMENT_NODE + ); + + const markdownPatterns = [ + /^#{1,6}\s/, + /^\s*[-*+]\s/, + /^\s*\d+\.\s/, + /^\s*>{1,}\s/, + /^---|\*\*\*|___/, + /`{1,3}[^`]+`{1,3}/, + /(\*\*)[^*]+(\*\*)|(__)[^_]+(__)/, + ]; + + const hasMarkdownSyntax = markdownPatterns.some((pattern) => + pattern.test(content) + ); + + return hasHtmlElements && !hasMarkdownSyntax; + } catch { + return false; + } +}; + +export const formatClientContent = (htmlString: string) => { + const parser = new DOMParser(); + const processedContent = isHTMLString(htmlString) + ? htmlString + : convertMarkdownFormatToHtmlString(htmlString); + + const doc = parser.parseFromString(processedContent, 'text/html'); + + const anchorTags = doc.querySelectorAll( + 'a[data-type="mention"], a[data-type="hashtag"]' + ); + + anchorTags.forEach((tag) => { + const label = tag.getAttribute('data-label'); + const type = tag.getAttribute('data-type'); + const prefix = type === 'mention' ? '@' : '#'; + + tag.textContent = `${prefix}${label}`; + }); + + return getSanitizeContent(doc.body.innerHTML); +}; /** * Checks whether a block-editor HTML string represents empty content. diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/BlockEditorUtils.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/BlockEditorUtils.test.ts index 77072a5b7d1e..ca9491d09cf7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/BlockEditorUtils.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/BlockEditorUtils.test.ts @@ -12,14 +12,15 @@ */ import { Editor } from '@tiptap/react'; import { + formatClientContent, getTextFromHtmlString, isDescriptionContentEmpty, + isHTMLString, } from './BlockEditorPureUtils'; import { - formatContent, + formatServerContent, formatValueBasedOnContent, getHtmlStringFromMarkdownString, - isHTMLString, setEditorContent, transformImgTagsToFileAttachment, } from './BlockEditorUtils'; @@ -141,23 +142,25 @@ describe('formatValueBasedOnContent', () => { }); }); -describe('formatContent', () => { +describe('formatClientContent', () => { it('should format mention for client display correctly', () => { const input = '

This @Infrastructure team

'; - const result = formatContent(input, 'client'); + const result = formatClientContent(input); // Should replace the anchor tag content with just @Infrastructure expect(result).toContain('@Infrastructure'); expect(result.match(/@Infrastructure/g) || []).toHaveLength(1); }); +}); +describe('formatServerContent', () => { it('should format mention for server storage correctly', () => { const input = '

This @Infrastructure team

'; - const result = formatContent(input, 'server'); + const result = formatServerContent(input); // Should convert to server format with markdown link structure expect(result).toContain( diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/BlockEditorUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/BlockEditorUtils.ts index 71fb5baf8cfc..242633668ba9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/BlockEditorUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/BlockEditorUtils.ts @@ -20,14 +20,12 @@ import { ReactComponent as IconFormatAudio } from '../assets/svg/ic-format-audio import { ReactComponent as IconFormatImage } from '../assets/svg/ic-format-image.svg'; import { ReactComponent as IconFormatVideo } from '../assets/svg/ic-format-video.svg'; import { FileType } from '../components/BlockEditor/BlockEditor.interface'; -import { ENTITY_URL_MAP } from '../constants/Feeds.constants'; import blockEditorExtensionsClassBase from './BlockEditorExtensionsClassBase'; -import { ENTITY_LINK_SEPARATOR } from './EntityPureUtils'; import { - getEntityDetail, - getHashTagList, - getMentionList, -} from './FeedUtilsPure'; + convertMarkdownFormatToHtmlString, + isHTMLString, +} from './BlockEditorPureUtils'; +import { ENTITY_LINK_SEPARATOR } from './EntityPureUtils'; import { getSanitizeContent } from './sanitize.utils'; export const getSelectedText = (state: EditorState) => { @@ -49,47 +47,6 @@ export const isInViewport = (ele: HTMLElement, container: HTMLElement) => { return eleTop >= containerTop && eleBottom <= containerBottom; }; -const _convertMarkdownFormatToHtmlString = (markdown: string) => { - let updatedMessage = markdown; - const urlEntries = Object.entries(ENTITY_URL_MAP); - - const mentionList = getMentionList(markdown) ?? []; - const hashTagList = getHashTagList(markdown) ?? []; - - const mentionMap = new Map( - mentionList.map((mention) => [mention, getEntityDetail(mention)]) - ); - - const hashTagMap = new Map( - hashTagList.map((hashTag) => [hashTag, getEntityDetail(hashTag)]) - ); - - mentionMap.forEach((value, key) => { - if (value) { - const [, href, rawEntityType, fqn] = value; - const entityType = urlEntries.find((e) => e[1] === rawEntityType)?.[0]; - - if (entityType) { - const entityLink = `@${fqn}`; - updatedMessage = updatedMessage.replaceAll(key, entityLink); - } - } - }); - - hashTagMap.forEach((value, key) => { - if (value) { - const [, href, rawEntityType, fqn] = value; - - const entityLink = `#${fqn}`; - updatedMessage = updatedMessage.replaceAll(key, entityLink); - } - }); - - return updatedMessage; -}; - -export type FormatContentFor = 'server' | 'client'; - // Unique marker prefix used to temporarily replace entity links during HTML serialization // This avoids HTML encoding of < and > characters in entity links const ENTITY_LINK_MARKER_PREFIX = '__ENTITY_LINK_MARKER_'; @@ -100,61 +57,14 @@ const escapeMarkdownLinkText = (text: string): string => const sanitizeEntityLinkField = (value: string): string => value.replace(/[<>|]/g, ''); -export const isHTMLString = (content: string) => { - // Quick check for common HTML tags - const commonHtmlTags = - /<(p|div|span|a|ul|ol|li|h[1-6]|br|strong|em|code|pre)[>\s]/i; - - // If content doesn't have any HTML-like structure, return false early - if (!commonHtmlTags.test(content)) { - return false; - } - - try { - const parser = new DOMParser(); - const parsedDocument = parser.parseFromString(content, 'text/html'); - - // Check if there are any actual HTML elements (not just text nodes) - const hasHtmlElements = Array.from(parsedDocument.body.childNodes).some( - (node) => node.nodeType === Node.ELEMENT_NODE - ); - - // Check if the content has markdown-specific patterns - const markdownPatterns = [ - /^#{1,6}\s/, // Headers - /^\s*[-*+]\s/, // Lists - /^\s*\d+\.\s/, // Numbered lists - /^\s*>{1,}\s/, // Blockquotes - /^---|\*\*\*|___/, // Horizontal rules - /`{1,3}[^`]+`{1,3}/, // Code blocks - /(\*\*)[^*]+(\*\*)|(__)[^_]+(__)/, // Bold/Strong text - ]; - - const hasMarkdownSyntax = markdownPatterns.some((pattern) => - pattern.test(content) - ); - - // If it has markdown syntax but also parsed as HTML, prefer markdown interpretation - return hasHtmlElements && !hasMarkdownSyntax; - } catch (e) { - // eslint-disable-next-line no-console - console.warn('Error parsing content to check HTML string:', e); - - return false; - } -}; - -export const formatContent = ( - htmlString: string, - formatFor: FormatContentFor -) => { +export const formatServerContent = (htmlString: string) => { // Create a new DOMParser const parser = new DOMParser(); // Only convert markdown to HTML if the content is not already HTML const processedContent = isHTMLString(htmlString) ? htmlString - : _convertMarkdownFormatToHtmlString(htmlString); + : convertMarkdownFormatToHtmlString(htmlString); const doc = parser.parseFromString(processedContent, 'text/html'); @@ -166,60 +76,45 @@ export const formatContent = ( // Store entity links with markers to avoid HTML encoding during serialization const entityLinkMap = new Map(); - if (formatFor === 'server') { - anchorTags.forEach((tag, index) => { - const rawHref = tag.getAttribute('href'); - const text = tag.textContent; - const fqn = tag.getAttribute('data-fqn'); - const entityType = tag.getAttribute('data-entityType'); - - // Validate href to only allow safe protocols before embedding into entity link string. - // This prevents unsafe URLs from bypassing DOMPurify via the post-sanitization replacement. - const href = - rawHref && - (rawHref.startsWith('http://') || - rawHref.startsWith('https://') || - rawHref.startsWith('/') || - rawHref.startsWith('#')) - ? rawHref - : ''; - - const safeEntityType = sanitizeEntityLinkField(entityType ?? ''); - const safeFqn = sanitizeEntityLinkField(fqn ?? ''); - const safeText = escapeMarkdownLinkText(text ?? ''); - const entityLink = `<#E${ENTITY_LINK_SEPARATOR}${safeEntityType}${ENTITY_LINK_SEPARATOR}${safeFqn}|[${safeText}](${href})>`; - const marker = `${ENTITY_LINK_MARKER_PREFIX}${index}__`; - - entityLinkMap.set(marker, entityLink); - tag.textContent = marker; - }); - } else { - anchorTags.forEach((tag) => { - const label = tag.getAttribute('data-label'); - const type = tag.getAttribute('data-type'); - const prefix = type === 'mention' ? '@' : '#'; - - tag.textContent = `${prefix}${label}`; - }); - } + anchorTags.forEach((tag, index) => { + const rawHref = tag.getAttribute('href'); + const text = tag.textContent; + const fqn = tag.getAttribute('data-fqn'); + const entityType = tag.getAttribute('data-entityType'); + + // Validate href to only allow safe protocols before embedding into entity link string. + // This prevents unsafe URLs from bypassing DOMPurify via the post-sanitization replacement. + const href = + rawHref && + (rawHref.startsWith('http://') || + rawHref.startsWith('https://') || + rawHref.startsWith('/') || + rawHref.startsWith('#')) + ? rawHref + : ''; + + const safeEntityType = sanitizeEntityLinkField(entityType ?? ''); + const safeFqn = sanitizeEntityLinkField(fqn ?? ''); + const safeText = escapeMarkdownLinkText(text ?? ''); + const entityLink = `<#E${ENTITY_LINK_SEPARATOR}${safeEntityType}${ENTITY_LINK_SEPARATOR}${safeFqn}|[${safeText}](${href})>`; + const marker = `${ENTITY_LINK_MARKER_PREFIX}${index}__`; + + entityLinkMap.set(marker, entityLink); + tag.textContent = marker; + }); let modifiedHtmlString = doc.body.innerHTML; - // Apply additional transformations based on format - if (formatFor === 'server') { - modifiedHtmlString = getSanitizeContent( - blockEditorExtensionsClassBase.serializeContentForBackend( - modifiedHtmlString - ) - ); - - // Replace markers with actual entity links - entityLinkMap.forEach((entityLink, marker) => { - modifiedHtmlString = modifiedHtmlString.replace(marker, entityLink); - }); - } else { - modifiedHtmlString = getSanitizeContent(modifiedHtmlString); - } + modifiedHtmlString = getSanitizeContent( + blockEditorExtensionsClassBase.serializeContentForBackend( + modifiedHtmlString + ) + ); + + // Replace markers with actual entity links + entityLinkMap.forEach((entityLink, marker) => { + modifiedHtmlString = modifiedHtmlString.replace(marker, entityLink); + }); return modifiedHtmlString; };