fix(#11530): markdown viewer enableSeeMoreVariant is not working as expected (#11532)

* fix(#11530): markdown viewer enableSeeMoreVariant is not working as expected

* add unit test

* remove unused component

* update the comment
This commit is contained in:
Sachin Chaurasiya 2023-05-11 15:42:09 +05:30 committed by GitHub
parent f22d604c54
commit 1da5dd9b9b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 125 additions and 364 deletions

View File

@ -40,10 +40,6 @@ jest.mock('../common/ServiceDocPanel/ServiceDocPanel', () => {
return jest.fn().mockReturnValue(<div>ServiceDocPanel</div>);
});
jest.mock('../common/ServiceRightPanel/ServiceRightPanel', () => {
return jest.fn().mockReturnValue(<div>Right Panel</div>);
});
jest.mock('components/common/ResizablePanels/ResizablePanels', () =>
jest.fn().mockImplementation(({ firstPanel, secondPanel }) => (
<>

View File

@ -33,7 +33,6 @@ const ChangeLogs = ({ data }: Props) => {
<RichTextEditorPreviewer
enableSeeMoreVariant={false}
markdown={data[log]}
maxLength={data[log].length}
/>
</div>
))}

View File

@ -132,7 +132,6 @@ const ServiceDocPanel: FC<ServiceDocPanelProp> = ({
<RichTextEditorPreviewer
enableSeeMoreVariant={false}
markdown={markdownContent}
maxLength={markdownContent.length}
/>
</Col>
</Row>

View File

@ -1,34 +0,0 @@
/*
* 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 { ServiceCategory } from 'enums/service.enum';
import { PipelineType } from 'generated/api/services/ingestionPipelines/createIngestionPipeline';
export type ExcludedPipelineType = Exclude<
PipelineType,
| PipelineType.DataInsight
| PipelineType.ElasticSearchReindex
| PipelineType.TestSuite
>;
export interface RightPanelProps {
activeStep: number;
isIngestion: boolean;
serviceName: string;
isUpdating: boolean;
selectedService: string;
selectedServiceCategory: ServiceCategory;
showDeployedTitle?: boolean;
pipelineType?: ExcludedPipelineType;
ingestionName?: string;
activeField?: string;
}

View File

@ -1,84 +0,0 @@
/*
* 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 { act, render, screen } from '@testing-library/react';
import { ServiceCategory } from 'enums/service.enum';
import { PipelineType } from 'generated/api/services/ingestionPipelines/createIngestionPipeline';
import React from 'react';
import RightPanel from './ServiceRightPanel';
import { ExcludedPipelineType } from './ServiceRightPanel.interface';
jest.mock('components/common/rich-text-editor/RichTextEditorPreviewer', () =>
jest
.fn()
.mockImplementation(({ markdown }) => (
<div data-testid="requirement-text">{markdown}</div>
))
);
jest.mock('rest/miscAPI', () => ({
fetchMarkdownFile: jest
.fn()
.mockImplementation(() => Promise.resolve('markdown text')),
}));
const mockProps = {
isIngestion: false,
pipelineType: PipelineType.Metadata as ExcludedPipelineType,
activeStep: 1,
isAirflowRunning: true,
showDeployedTitle: true,
isUpdating: false,
ingestionName: 'service_ingestion',
serviceName: 'service',
activeField: 'root_username',
selectedServiceCategory: ServiceCategory.DATABASE_SERVICES,
selectedService: 'Mysql',
};
describe('Right Panel Component', () => {
it('Should render the active field doc', async () => {
await act(async () => {
render(<RightPanel {...mockProps} />);
});
const activeFieldName = screen.getByTestId('active-field-name');
expect(activeFieldName).toBeInTheDocument();
expect(activeFieldName).toHaveTextContent('Username');
const activeFieldDocumentElement = screen.getByTestId('requirement-text');
expect(activeFieldDocumentElement).toBeInTheDocument();
expect(activeFieldDocumentElement).toHaveTextContent('markdown text');
});
it('Should render the current step guide if active field is empty', async () => {
await act(async () => {
render(<RightPanel {...mockProps} activeField={undefined} />);
});
expect(screen.queryByTestId('active-field-name')).not.toBeInTheDocument();
const activeFieldDocumentElement = screen.queryByTestId('requirement-text');
expect(activeFieldDocumentElement).not.toBeInTheDocument();
expect(screen.getByText('label.add-a-new-service')).toBeInTheDocument();
expect(
screen.getByText('message.add-new-service-description')
).toBeInTheDocument();
});
});

View File

@ -1,219 +0,0 @@
/*
* 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 { Affix, Card } from 'antd';
import RichTextEditorPreviewer from 'components/common/rich-text-editor/RichTextEditorPreviewer';
import Loader from 'components/Loader/Loader';
import { oneofOrEndsWithNumberRegex } from 'constants/regex.constants';
import {
addServiceGuide,
addServiceGuideWOAirflow,
EMAIL_CONFIG_SERVICE_CATEGORY,
} from 'constants/service-guide.constant';
import { INGESTION_GUIDE_MAP } from 'constants/Services.constant';
import { useAirflowStatus } from 'hooks/useAirflowStatus';
import { first, last, startCase } from 'lodash';
import React, { FC, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { fetchMarkdownFile } from 'rest/miscAPI';
import { SupportedLocales } from 'utils/i18next/i18nextUtil';
import { getEmailConfigStepGuide } from 'utils/ServiceRightPanelUtils';
import { getFormattedGuideText, getServiceType } from 'utils/ServiceUtils';
import { RightPanelProps } from './ServiceRightPanel.interface';
const RightPanel: FC<RightPanelProps> = ({
isIngestion,
pipelineType,
activeStep,
isUpdating,
ingestionName,
serviceName,
activeField,
selectedServiceCategory,
selectedService,
showDeployedTitle = false,
}) => {
const panelContainerRef = useRef<HTMLDivElement>(null);
const { isAirflowAvailable } = useAirflowStatus();
const { t, i18n } = useTranslation();
const [activeFieldDocument, setActiveFieldDocument] = useState<string>('');
const [isLoading, setIsLoading] = useState<boolean>(false);
const isEmailConfigPage = useMemo(
() => selectedService === EMAIL_CONFIG_SERVICE_CATEGORY,
[selectedService]
);
const activeStepGuide = useMemo(() => {
let guideTemp;
if (isIngestion && pipelineType) {
guideTemp = INGESTION_GUIDE_MAP[pipelineType]?.find(
(item) => item.step === activeStep
);
} else {
guideTemp =
!isAirflowAvailable && activeStep === 4
? addServiceGuideWOAirflow
: addServiceGuide.find((item) => item.step === activeStep);
}
return guideTemp;
}, [isIngestion, pipelineType, isAirflowAvailable, activeStep]);
const activeFieldName = useMemo(() => {
/**
* active field is like root_fieldName
* so we need to split and get the fieldName
*/
const fieldNameArr = activeField?.split('/');
const fieldName = last(fieldNameArr);
// check if activeField is select or list field
if (oneofOrEndsWithNumberRegex.test(fieldName ?? '')) {
return first(fieldName?.split('_'));
} else {
return fieldName;
}
}, [activeField]);
const showActiveFieldElement = Boolean(
activeFieldName && activeFieldDocument
);
const getActiveStepTitle = (title: string) => {
const deployMessage = showDeployedTitle ? ` & ${t('label.deployed')}` : '';
const updateTitle = title.replace(
t('label.added'),
`${t('label.updated')}${deployMessage}`
);
const newTitle = showDeployedTitle
? title.replace(t('label.added'), `${t('label.added')}${deployMessage}`)
: title;
return isUpdating ? updateTitle : newTitle;
};
const getActiveStepDescription = (description: string) => {
const replaceText = isIngestion
? `<${t('label.ingestion-pipeline-name')}>`
: `<${t('label.service-name')}>`;
const replacement = isIngestion ? ingestionName || '' : serviceName;
return getFormattedGuideText(description, replaceText, replacement);
};
const fetchFieldDocument = async () => {
const serviceType = isEmailConfigPage
? selectedServiceCategory
: getServiceType(selectedServiceCategory);
setIsLoading(true);
try {
let response = '';
const isEnglishLanguage = i18n.language === SupportedLocales.English;
const filePath = `${i18n.language}/${serviceType}/${selectedService}/fields/${activeFieldName}.md`;
const fallbackFilePath = `${SupportedLocales.English}/${serviceType}/${selectedService}/fields/${activeFieldName}.md`;
const [translation, fallbackTranslation] = await Promise.allSettled([
fetchMarkdownFile(filePath),
isEnglishLanguage
? Promise.reject('')
: fetchMarkdownFile(fallbackFilePath),
]);
if (translation.status === 'fulfilled') {
response = translation.value;
}
if (isEnglishLanguage && fallbackTranslation.status === 'fulfilled') {
response = fallbackTranslation.value;
}
setActiveFieldDocument(response);
} catch (error) {
setActiveFieldDocument('');
} finally {
setIsLoading(false);
}
};
const handleAffixTarget = () => document.getElementById('page-container-v1');
useEffect(() => {
const shouldFetchFieldDoc = Boolean(
selectedService && selectedServiceCategory && activeFieldName
);
// only fetch file when required fields are present
if (shouldFetchFieldDoc) {
fetchFieldDocument();
}
}, [
selectedService,
selectedServiceCategory,
activeFieldName,
isEmailConfigPage,
]);
const activeStepGuideElement = activeStepGuide ? (
<>
<h6 className="tw-heading tw-text-base">
{getActiveStepTitle(activeStepGuide.title)}
</h6>
<div className="tw-mb-5" data-test="current-step-guide">
{getActiveStepDescription(activeStepGuide.description)}
</div>
</>
) : null;
const activeFieldDocumentElement = activeFieldName ? (
<>
<h6 className="tw-heading tw-text-base" data-testid="active-field-name">
{startCase(activeFieldName)}
</h6>
<RichTextEditorPreviewer
enableSeeMoreVariant={false}
markdown={activeFieldDocument}
maxLength={activeFieldDocument.length}
/>
</>
) : null;
const renderElement = useMemo(() => {
if (showActiveFieldElement) {
return activeFieldDocumentElement;
} else {
return isEmailConfigPage
? getEmailConfigStepGuide()
: activeStepGuideElement;
}
}, [
showActiveFieldElement,
activeFieldDocumentElement,
activeStepGuideElement,
selectedService,
isEmailConfigPage,
]);
return (
<div id="service-right-panel" ref={panelContainerRef}>
<Affix offsetTop={5} target={handleAffixTarget}>
<Card>{isLoading ? <Loader /> : renderElement}</Card>
</Affix>
</div>
);
};
export default RightPanel;

View File

@ -11,22 +11,28 @@
* limitations under the License.
*/
import { findByTestId, fireEvent, render } from '@testing-library/react';
import {
act,
findByTestId,
fireEvent,
render,
screen,
} from '@testing-library/react';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { act } from 'react-test-renderer';
import userEvent from '@testing-library/user-event';
import { PreviewerProp } from './RichTextEditor.interface';
import RichTextEditorPreviewer from './RichTextEditorPreviewer';
const mockDescription =
// eslint-disable-next-line max-len
'**Headings**\n\n# H1\n## H2\n### H3\n\n***\n**Bold**\n\n**bold text**\n\n\n***\n**Italic**\n\n*italic*\n\n***\n**BlockQuote**\n\n> blockquote\n\n***\n**Ordered List**\n\n1. First item\n2. Second item\n3. Third item\n\n\n***\n**Unordered List**\n\n- First item\n- Second item\n- Third item\n\n\n***\n**Code**\n\n`code`\n\n\n***\n**Horizontal Rule**\n\n---\n\n\n***\n**Link**\n[title](https://www.example.com)\n\n\n***\n**Image**\n\n![alt text](https://github.com/open-metadata/OpenMetadata/blob/main/docs/.gitbook/assets/openmetadata-banner.png?raw=true)\n\n\n***\n**Table**\n\n| Syntax | Description |\n| ----------- | ----------- |\n| Header | Title |\n| Paragraph | Text |\n***\n\n**Fenced Code Block**\n\n```\n{\n "firstName": "John",\n "lastName": "Smith",\n "age": 25\n}\n```\n\n\n***\n**Strikethrough**\n~~The world is flat.~~\n';
const mockProp = {
const mockProp: PreviewerProp = {
markdown: mockDescription,
className: '',
blurClasses: 'see-more-blur',
maxHtClass: 'tw-h-24',
maxLen: 300,
maxLength: 300,
enableSeeMoreVariant: true,
};
@ -344,4 +350,89 @@ describe('Test RichTextEditor Previewer Component', () => {
expect(markdownParser).toBeInTheDocument();
});
it('Should render read more button if enableSeeMoreVariant is true and max length is less than content length', () => {
render(<RichTextEditorPreviewer {...mockProp} />, {
wrapper: MemoryRouter,
});
expect(screen.getByTestId('read-more-button')).toBeInTheDocument();
});
it('Read more toggling should work', async () => {
render(<RichTextEditorPreviewer {...mockProp} />, {
wrapper: MemoryRouter,
});
const readMoreButton = screen.getByTestId('read-more-button');
await act(async () => {
userEvent.click(readMoreButton);
});
const readLessButton = screen.getByTestId('read-less-button');
expect(readLessButton).toBeInTheDocument();
await act(async () => {
userEvent.click(readLessButton);
});
expect(screen.getByTestId('read-more-button')).toBeInTheDocument();
});
it('Should render the whole content if enableSeeMoreVariant is false', () => {
const markdown = 'This is a simple paragraph text';
render(
<RichTextEditorPreviewer
{...mockProp}
enableSeeMoreVariant={false}
markdown={markdown}
/>,
{
wrapper: MemoryRouter,
}
);
expect(screen.getByText(markdown)).toBeInTheDocument();
expect(screen.queryByTestId('read-more-button')).toBeNull();
});
it('Should render the clipped content if enableSeeMoreVariant is true', () => {
const markdown = 'This is a simple paragraph text';
render(
<RichTextEditorPreviewer
{...mockProp}
enableSeeMoreVariant
markdown={markdown}
maxLength={20}
/>,
{
wrapper: MemoryRouter,
}
);
expect(screen.getByText('This is a simple...')).toBeInTheDocument();
expect(screen.queryByTestId('read-more-button')).toBeInTheDocument();
});
it('Should not clipped content if enableSeeMoreVariant is true and markdown length is less than max length', () => {
const markdown = 'This is a simple paragraph text';
render(
<RichTextEditorPreviewer
{...mockProp}
enableSeeMoreVariant
markdown={markdown}
/>,
{
wrapper: MemoryRouter,
}
);
expect(screen.getByText(markdown)).toBeInTheDocument();
expect(screen.queryByTestId('read-more-button')).toBeNull();
});
});

View File

@ -15,7 +15,7 @@ import { Viewer } from '@toast-ui/react-editor';
import { Button } from 'antd';
import classNames from 'classnames';
import { uniqueId } from 'lodash';
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { getTrimmedContent } from 'utils/CommonUtils';
import { DESCRIPTION_MAX_PREVIEW_CHARACTERS } from '../../../constants/constants';
@ -32,13 +32,30 @@ const RichTextEditorPreviewer = ({
}: PreviewerProp) => {
const { t } = useTranslation();
const [content, setContent] = useState<string>('');
const [hideReadMoreText, setHideReadMoreText] = useState<boolean>(
markdown.length <= maxLength
// initially read more will be false
const [readMore, setReadMore] = useState<boolean>(false);
// read more toggle handler
const handleReadMoreToggle = () => setReadMore((pre) => !pre);
// whether has read more content or not
const hasReadMore = useMemo(
() => enableSeeMoreVariant && markdown.length > maxLength,
[enableSeeMoreVariant, markdown, maxLength]
);
const displayMoreHandler = () => {
setHideReadMoreText((pre) => !pre);
};
/**
* if hasReadMore is true then value will be based on read more state
* else value will be content
*/
const viewerValue = useMemo(() => {
if (hasReadMore) {
return readMore ? content : `${getTrimmedContent(content, maxLength)}...`;
}
return content;
}, [hasReadMore, readMore, maxLength, content]);
useEffect(() => {
setContent(markdown);
@ -83,22 +100,18 @@ const RichTextEditorPreviewer = ({
<Viewer
extendedAutolinks
customHTMLRenderer={customHTMLRenderer}
initialValue={
hideReadMoreText
? content
: `${getTrimmedContent(content, maxLength)}...`
}
initialValue={viewerValue}
key={uniqueId()}
linkAttributes={{ target: '_blank' }}
/>
</div>
{enableSeeMoreVariant && markdown.length > maxLength && (
{hasReadMore && (
<Button
className="leading-0"
data-testid="read-more-button"
data-testid={`read-${readMore ? 'less' : 'more'}-button`}
type="link"
onClick={displayMoreHandler}>
{hideReadMoreText
onClick={handleReadMoreToggle}>
{readMore
? t('label.read-type-lowercase', {
type: t('label.less-lowercase'),
})