Fix(#14560): fix breadcrumb not getting updating properly when clicking on any breadcrumb link in container page (#14621)

* reset the state of DataAssetHeaderComponent using key property when dataAsset get change to update the breadcrumb properly

* fix DataAssetHeader component getting render when contianerData is fetching in container page component

* wip: unit test for conitaner page

* added accidentally removed silent flag from jest test

* added test for check container data fetch with expected params

* added more testcase in ContainerPage unit test

* added switch tab testcase

* added more test case

* address comments

* address comments 1

* revert accidentally removed --silent from test command

* fix: unit test after conflict resolve

* fix page content shake on breadcrumb loading

* fix some type issues

* fix skeleton loading on detabase-schema-page

* fix LogsViewer Skeleton
This commit is contained in:
Abhishek Porwal 2024-01-16 13:07:07 +05:30 committed by GitHub
parent b4b1b2e224
commit 2414cfe387
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 385 additions and 223 deletions

View File

@ -20,18 +20,16 @@ const TitleBreadcrumbSkeleton = ({
children,
}: TitleBreadcrumbSkeletonProps) =>
loading ? (
<Row gutter={16}>
<Row>
{Array(3)
.fill(null)
.map(() => (
<Col key={uniqueId()}>
<Skeleton
active
className="m-l-xs"
paragraph={{ rows: 0 }}
title={{
width: 150,
}}
className="m-r-xs m-b-xss"
paragraph={{ rows: 1, width: 150 }}
title={false}
/>
</Col>
))}

View File

@ -323,7 +323,7 @@ const AddAlertPage = () => {
data-testid={`${condition}-select`}
mode="multiple"
options={
func.paramAdditionalContext?.data?.map((d) => ({
func.paramAdditionalContext?.data?.map((d: string) => ({
label: startCase(d),
value: d,
})) ?? []

View File

@ -88,3 +88,8 @@ export const CONTAINER_DATA = {
],
deleted: false,
};
export const CONTAINER_DATA_1 = {
...CONTAINER_DATA,
dataModel: {},
};

View File

@ -10,122 +10,171 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { act, render, screen } from '@testing-library/react';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { getContainerByName } from '../../rest/storageAPI';
import {
act,
render,
screen,
waitForElementToBeRemoved,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React, { ReactNode } from 'react';
import { ERROR_PLACEHOLDER_TYPE } from '../../enums/common.enum';
import { Include } from '../../generated/type/include';
import {
addContainerFollower,
getContainerByName,
} from '../../rest/storageAPI';
import ContainerPage from './ContainerPage';
import { CONTAINER_DATA } from './ContainerPage.mock';
import { CONTAINER_DATA, CONTAINER_DATA_1 } from './ContainerPage.mock';
jest.mock('../../components/PermissionProvider/PermissionProvider', () => ({
usePermissionProvider: jest.fn().mockImplementation(() => ({
getEntityPermissionByFqn: jest.fn().mockResolvedValue({
Create: true,
Delete: true,
EditAll: true,
EditCustomFields: true,
EditDataProfile: true,
EditDescription: true,
EditDisplayName: true,
EditLineage: true,
EditOwner: true,
EditQueries: true,
EditSampleData: true,
EditTags: true,
EditTests: true,
EditTier: true,
ViewAll: true,
ViewDataProfile: true,
ViewQueries: true,
ViewSampleData: true,
ViewTests: true,
ViewUsage: true,
}),
})),
const mockGetEntityPermissionByFqn = jest.fn().mockResolvedValue({
ViewBasic: true,
});
const mockGetContainerByName = jest.fn().mockResolvedValue(CONTAINER_DATA);
jest.mock(
'../../components/ActivityFeed/ActivityFeedProvider/ActivityFeedProvider',
() => ({
useActivityFeedProvider: jest.fn().mockImplementation(() => ({
postFeed: jest.fn(),
deleteFeed: jest.fn(),
updateFeed: jest.fn(),
})),
__esModule: true,
default: (props: { children: ReactNode }) => (
<div data-testid="activity-feed-provider">{props.children}</div>
),
})
);
jest.mock('../../components/AppRouter/withActivityFeed', () => ({
withActivityFeed: jest.fn().mockImplementation((ui) => ui),
}));
jest.mock(
'../../components/ActivityFeed/ActivityFeedTab/ActivityFeedTab.component',
() => ({
ActivityFeedTab: jest.fn().mockReturnValue(<>ActivityFeedTab</>),
})
);
jest.mock(
'../../components/ActivityFeed/ActivityThreadPanel/ActivityThreadPanel',
() => jest.fn().mockImplementation(() => <>ActivityThreadPanel</>)
);
jest.mock('../../components/Auth/AuthProviders/AuthProvider', () => ({
useAuthContext: jest.fn().mockReturnValue({
id: 'userid',
}),
}));
jest.mock(
'../../components/common/CustomPropertyTable/CustomPropertyTable',
() => {
return {
CustomPropertyTable: jest
.fn()
.mockReturnValue(
<div data-testid="custom-properties-table">CustomPropertyTable</div>
),
};
}
() => ({
CustomPropertyTable: jest.fn().mockReturnValue(<>CustomPropertyTable</>),
})
);
jest.mock('../../components/common/EntityDescription/Description', () => {
return jest
jest.mock('../../components/common/EntityDescription/DescriptionV1', () =>
jest
.fn()
.mockReturnValue(<div data-testid="description">Description</div>);
});
.mockImplementation(({ onThreadLinkSelect }) => (
<button onClick={onThreadLinkSelect}>DescriptionV1</button>
))
);
jest.mock('../../components/Tag/TagsContainerV2/TagsContainerV2', () => {
return jest
.fn()
.mockReturnValue(<div data-testid="entity-page-info">TagsContainerV2</div>);
});
jest.mock('../../components/FeedEditor/FeedEditor', () => {
return jest.fn().mockReturnValue(<p>ActivityFeedEditor</p>);
});
jest.mock(
'../../components/common/ErrorWithPlaceholder/ErrorPlaceHolder',
() => {
return jest
.fn()
.mockReturnValue(
<div data-testid="error-placeholder">ErrorPlaceHolder</div>
);
}
jest.mock('../../components/common/ErrorWithPlaceholder/ErrorPlaceHolder', () =>
jest.fn().mockImplementation(({ type, children }) => (
<div>
ErrorPlaceHolder
<span>{type}</span>
<div>{children}</div>
</div>
))
);
jest.mock(
'../../components/ContainerDetail/ContainerChildren/ContainerChildren',
() => {
return jest
.fn()
.mockReturnValue(
<div data-testid="containers-children">ContainerChildren</div>
() =>
jest.fn().mockImplementation(({ isLoading }) => {
getContainerByName(CONTAINER_DATA_1.fullyQualifiedName, {
fields: 'children',
});
return (
<>
<div>ContainerChildren</div>
{isLoading && <span>ContainerChildrenLoader</span>}
</>
);
}
})
);
jest.mock(
'../../components/ContainerDetail/ContainerDataModel/ContainerDataModel',
() => {
return jest
.fn()
.mockReturnValue(
<div data-testid="container-data-model">ContainerDataModel</div>
);
}
() => jest.fn().mockReturnValue(<span>ContainerDataModel</span>)
);
jest.mock('../../components/Lineage/Lineage.component', () => {
return jest
.fn()
.mockReturnValue(<div data-testid="entity-lineage">EntityLineage</div>);
});
jest.mock(
'../../components/DataAssets/DataAssetsHeader/DataAssetsHeader.component',
() => ({
DataAssetsHeader: jest
.fn()
.mockImplementation(({ afterDeleteAction, onFollowClick }) => (
<div data-testid="data-asset-header">
<button onClick={() => afterDeleteAction()}>Hard Delete</button>
<button onClick={onFollowClick}>Follow Container</button>
</div>
)),
})
);
jest.mock('../../components/Loader/Loader', () => {
return jest.fn().mockReturnValue(<div data-testid="loader">Loader</div>);
});
jest.mock('../../components/Entity/EntityRightPanel/EntityRightPanel', () =>
jest.fn().mockReturnValue(<>EntityRightPanel</>)
);
jest.mock('../../rest/miscAPI', () => ({
deleteLineageEdge: jest.fn().mockImplementation(() => Promise.resolve()),
addLineage: jest.fn().mockImplementation(() => Promise.resolve()),
jest.mock('../../components/Lineage/Lineage.component', () =>
jest.fn().mockReturnValue(<>EntityLineage</>)
);
jest.mock('../../components/LineageProvider/LineageProvider', () =>
jest.fn().mockReturnValue(<>LineageProvider</>)
);
jest.mock('../../components/Loader/Loader', () =>
jest.fn().mockReturnValue(<div>Loader</div>)
);
jest.mock('../../components/PageLayoutV1/PageLayoutV1', () =>
jest.fn().mockImplementation(({ children }) => <>{children}</>)
);
jest.mock('../../components/PermissionProvider/PermissionProvider', () => ({
usePermissionProvider: jest.fn().mockImplementation(() => ({
getEntityPermissionByFqn: mockGetEntityPermissionByFqn,
})),
}));
jest.mock('../../components/TabsLabel/TabsLabel.component', () =>
jest.fn().mockImplementation(({ name }) => <div>{name}</div>)
);
jest.mock('../../constants/constants', () => ({
getContainerDetailPath: jest.fn().mockReturnValue('/container-detail-path'),
getVersionPath: jest.fn().mockReturnValue('/version-path'),
}));
jest.mock('../../rest/feedsAPI', () => ({
postThread: jest.fn().mockImplementation(() => Promise.resolve()),
}));
jest.mock('../../rest/storageAPI', () => ({
addContainerFollower: jest.fn().mockImplementation(() => Promise.resolve()),
addContainerFollower: jest.fn(),
getContainerByName: jest
.fn()
.mockImplementation(() => Promise.resolve(CONTAINER_DATA)),
.mockImplementation((...params) => mockGetContainerByName(params)),
patchContainerDetails: jest.fn().mockImplementation(() => Promise.resolve()),
removeContainerFollower: jest
.fn()
@ -133,117 +182,212 @@ jest.mock('../../rest/storageAPI', () => ({
restoreContainer: jest.fn().mockImplementation(() => Promise.resolve()),
}));
jest.mock(
'../../components/ActivityFeed/ActivityFeedTab/ActivityFeedTab.component',
() => ({
ActivityFeedTab: jest.fn().mockImplementation(() => <>ActivityFeedTab</>),
})
);
let mockParams = {
entityFQN: 'entityTypeFQN',
tab: 'schema',
};
jest.mock('react-router-dom', () => ({
useHistory: jest.fn(),
useParams: jest.fn().mockImplementation(() => mockParams),
useLocation: jest.fn().mockReturnValue({ pathname: 'pathname' }),
jest.mock('../../utils/CommonUtils', () => ({
addToRecentViewed: jest.fn(),
getEntityMissingError: jest.fn().mockImplementation(() => <div>Error</div>),
getFeedCounts: jest.fn().mockReturnValue(0),
sortTagsCaseInsensitive: jest.fn().mockImplementation((tags) => tags),
}));
// TODO: need to re-write tests as we have changed flow completely
describe.skip('Container Page Component', () => {
it('Should render the child components', async () => {
await act(async () => {
render(<ContainerPage />, {
wrapper: MemoryRouter,
});
jest.mock('../../utils/EntityUtils', () => ({
getEntityName: jest
.fn()
.mockImplementation((entity) => entity?.name ?? 'entityName'),
}));
jest.mock('../../utils/FeedUtils', () => ({
getEntityFieldThreadCounts: jest.fn().mockReturnValue(0),
}));
jest.mock('../../utils/PermissionsUtils', () => ({
DEFAULT_ENTITY_PERMISSION: {},
}));
jest.mock('../../utils/StringsUtils', () => ({
getDecodedFqn: jest.fn().mockImplementation((fqn) => fqn),
}));
jest.mock('../../utils/TableUtils', () => ({
getTagsWithoutTier: jest.fn().mockReturnValue([]),
getTierTags: jest.fn().mockReturnValue([]),
}));
jest.mock('../../utils/TagsUtils', () => ({
createTagObject: jest.fn().mockImplementation((tagObject) => tagObject),
updateTierTag: jest.fn().mockImplementation((tagObject) => tagObject),
}));
jest.mock('../../utils/ToastUtils', () => ({
showErrorToast: jest.fn(),
showSuccessToast: jest.fn(),
}));
const mockUseParams = jest.fn().mockReturnValue({
fqn: CONTAINER_DATA.fullyQualifiedName,
tab: 'schema',
});
const mockPush = jest.fn();
jest.mock('react-router-dom', () => ({
useHistory: jest.fn().mockImplementation(() => ({
push: mockPush,
})),
useParams: jest.fn().mockImplementation(() => mockUseParams()),
}));
describe('Container Page Component', () => {
it('should show error-placeholder, if not have view permission', async () => {
mockGetEntityPermissionByFqn.mockResolvedValueOnce({
ViewBasic: false,
});
const pageTopInfo = screen.getByTestId('entity-page-info');
const tabs = screen.getAllByRole('tab');
await act(() => {
render(<ContainerPage />);
expect(pageTopInfo).toBeInTheDocument();
expect(tabs).toHaveLength(4);
});
it('Should render the schema tab component', async () => {
await act(async () => {
render(<ContainerPage />, {
wrapper: MemoryRouter,
});
});
const tabs = screen.getAllByRole('tab');
const schemaTab = tabs[0];
expect(schemaTab).toHaveAttribute('aria-selected', 'true');
const description = screen.getByTestId('description');
expect(description).toBeInTheDocument();
const dataModel = screen.getByTestId('container-data-model');
expect(dataModel).toBeInTheDocument();
});
it('Should render the children tab component', async () => {
mockParams = { ...mockParams, tab: 'children' };
await act(async () => {
render(<ContainerPage />, {
wrapper: MemoryRouter,
});
expect(screen.getByText('Loader')).toBeVisible();
});
const containerChildren = screen.getByTestId('containers-children');
expect(mockGetEntityPermissionByFqn).toHaveBeenCalled();
expect(containerChildren).toBeInTheDocument();
expect(getContainerByName).not.toHaveBeenCalled();
await waitForElementToBeRemoved(() => screen.getByText('Loader'));
expect(
screen.getByText(ERROR_PLACEHOLDER_TYPE.PERMISSION)
).toBeInTheDocument();
});
it('Should render the lineage tab component', async () => {
mockParams = { ...mockParams, tab: 'lineage' };
it('fetch container data, if have view permission', async () => {
await act(async () => {
render(<ContainerPage />, {
wrapper: MemoryRouter,
});
render(<ContainerPage />);
expect(screen.getByText('Loader')).toBeVisible();
});
const lineage = screen.getByTestId('entity-lineage');
expect(lineage).toBeInTheDocument();
expect(mockGetEntityPermissionByFqn).toHaveBeenCalled();
expect(getContainerByName).toHaveBeenCalledWith(
CONTAINER_DATA.fullyQualifiedName,
{
fields:
'parent,dataModel,owner,tags,followers,extension,domain,dataProducts,votes',
include: Include.All,
}
);
});
it('Should render the custom properties tab component', async () => {
mockParams = { ...mockParams, tab: 'custom-properties' };
await act(async () => {
render(<ContainerPage />, {
wrapper: MemoryRouter,
});
});
const customPropertyTable = screen.getByTestId('custom_properties');
expect(customPropertyTable).toBeInTheDocument();
});
it('Should render error placeholder on API fail', async () => {
mockParams = { ...mockParams, tab: 'schema' };
(getContainerByName as jest.Mock).mockImplementationOnce(() =>
Promise.reject()
it('show ErrorPlaceHolder if container data fetch fail', async () => {
(getContainerByName as jest.Mock).mockRejectedValueOnce(
'failed to fetch container data'
);
await act(async () => {
render(<ContainerPage />, {
wrapper: MemoryRouter,
});
render(<ContainerPage />);
expect(screen.getByText('Loader')).toBeVisible();
});
const errorPlaceholder = screen.getByTestId('error-placeholder');
expect(mockGetEntityPermissionByFqn).toHaveBeenCalled();
expect(getContainerByName).toHaveBeenCalled();
expect(errorPlaceholder).toBeInTheDocument();
expect(screen.getByText('ErrorPlaceHolder')).toBeInTheDocument();
});
it('should render the page container data, with the schema tab selected', async () => {
await act(async () => {
render(<ContainerPage />);
expect(screen.getByText('Loader')).toBeVisible();
});
expect(mockGetEntityPermissionByFqn).toHaveBeenCalled();
expect(getContainerByName).toHaveBeenCalled();
expect(screen.getByTestId('data-asset-header')).toBeInTheDocument();
const tabs = screen.getAllByRole('tab');
expect(tabs).toHaveLength(5);
expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
expect(screen.getByText('DescriptionV1')).toBeVisible();
expect(screen.getByText('ContainerDataModel')).toBeVisible();
expect(screen.getByText('EntityRightPanel')).toBeVisible();
});
it('activity thread panel should render after selecting thread link', async () => {
await act(async () => {
render(<ContainerPage />);
expect(screen.getByText('Loader')).toBeVisible();
});
const DescriptionV1 = screen.getByText('DescriptionV1');
expect(DescriptionV1).toBeVisible();
expect(screen.queryByText('ActivityThreadPanel')).not.toBeInTheDocument();
userEvent.click(DescriptionV1);
expect(screen.getByText('ActivityThreadPanel')).toBeInTheDocument();
});
it('onClick of follow container should call addContainerFollower', async () => {
await act(async () => {
render(<ContainerPage />);
expect(screen.getByText('Loader')).toBeVisible();
});
const followButton = screen.getByRole('button', {
name: 'Follow Container',
});
userEvent.click(followButton);
expect(addContainerFollower).toHaveBeenCalled();
});
it('tab switch should work', async () => {
await act(async () => {
render(<ContainerPage />);
expect(screen.getByText('Loader')).toBeVisible();
});
const childrenTab = screen.getByRole('tab', {
name: 'label.children',
});
userEvent.click(childrenTab);
expect(mockPush).toHaveBeenCalled();
});
it('children should render on children tab', async () => {
mockUseParams.mockReturnValue({
fqn: CONTAINER_DATA_1.fullyQualifiedName,
tab: 'children',
});
await act(async () => {
render(<ContainerPage />);
expect(screen.getByText('Loader')).toBeVisible();
});
const childrenTab = screen.getByRole('tab', { name: 'label.children' });
expect(childrenTab).toHaveAttribute('aria-selected', 'true');
expect(screen.getByText('ContainerChildren')).toBeVisible();
expect(getContainerByName).toHaveBeenCalledWith(
CONTAINER_DATA_1.fullyQualifiedName,
{
fields: 'children',
}
);
});
});

View File

@ -84,11 +84,11 @@ const ContainerPage = () => {
const { currentUser } = useAuthContext();
const { getEntityPermissionByFqn } = usePermissionProvider();
const { postFeed, deleteFeed, updateFeed } = useActivityFeedProvider();
const { fqn: containerName, tab } =
const { fqn: containerFQN, tab } =
useParams<{ fqn: string; tab: EntityTabs }>();
// Local states
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isChildrenLoading, setIsChildrenLoading] = useState<boolean>(false);
const [hasError, setHasError] = useState<boolean>(false);
const [isEditDescription, setIsEditDescription] = useState<boolean>(false);
@ -108,8 +108,8 @@ const ContainerPage = () => {
);
const decodedContainerName = useMemo(
() => getDecodedFqn(containerName),
[containerName]
() => getDecodedFqn(containerFQN),
[containerFQN]
);
const fetchContainerDetail = async (containerFQN: string) => {
@ -143,7 +143,7 @@ const ContainerPage = () => {
const fetchContainerChildren = async () => {
setIsChildrenLoading(true);
try {
const { children } = await getContainerByName(containerName, {
const { children } = await getContainerByName(containerFQN, {
fields: 'children',
});
setContainerChildrenData(children);
@ -154,14 +154,24 @@ const ContainerPage = () => {
}
};
const getEntityFeedCount = () =>
getFeedCounts(EntityType.CONTAINER, decodedContainerName, setFeedCount);
const fetchResourcePermission = async (containerFQN: string) => {
setIsLoading(true);
try {
const entityPermission = await getEntityPermissionByFqn(
ResourceEntity.CONTAINER,
containerFQN
);
setContainerPermissions(entityPermission);
const viewBasicPermission =
entityPermission.ViewAll || entityPermission.ViewBasic;
if (viewBasicPermission) {
await fetchContainerDetail(containerFQN);
getEntityFeedCount();
}
} catch (error) {
showErrorToast(
t('server.fetch-entity-permissions-error', {
@ -238,9 +248,6 @@ const ContainerPage = () => {
[containerData]
);
const getEntityFeedCount = () =>
getFeedCounts(EntityType.CONTAINER, decodedContainerName, setFeedCount);
const handleTabChange = (tabValue: string) => {
if (tabValue !== tab) {
history.push({
@ -507,7 +514,7 @@ const ContainerPage = () => {
const versionHandler = () =>
history.push(
getVersionPath(EntityType.CONTAINER, containerName, toString(version))
getVersionPath(EntityType.CONTAINER, containerFQN, toString(version))
);
const onThreadLinkSelect = (link: string, threadType?: ThreadType) => {
@ -654,7 +661,7 @@ const ContainerPage = () => {
entityType={EntityType.CONTAINER}
fqn={decodedContainerName}
onFeedUpdate={getEntityFeedCount}
onUpdateEntityDetails={() => fetchContainerDetail(containerName)}
onUpdateEntityDetails={() => fetchContainerDetail(containerFQN)}
/>
),
},
@ -694,7 +701,7 @@ const ContainerPage = () => {
isDataModelEmpty,
containerData,
description,
containerName,
containerFQN,
decodedContainerName,
entityName,
editDescriptionPermission,
@ -721,9 +728,11 @@ const ContainerPage = () => {
const updateVote = async (data: QueryVote, id: string) => {
try {
await updateContainerVotes(id, data);
const details = await getContainerByName(containerName, {
const details = await getContainerByName(containerFQN, {
fields: 'parent,dataModel,owner,tags,followers,extension,votes',
});
setContainerData(details);
} catch (error) {
showErrorToast(error as AxiosError);
@ -732,20 +741,8 @@ const ContainerPage = () => {
// Effects
useEffect(() => {
if (viewBasicPermission) {
fetchContainerDetail(containerName);
}
}, [containerName, viewBasicPermission]);
useEffect(() => {
fetchResourcePermission(containerName);
}, [containerName]);
useEffect(() => {
if (viewBasicPermission) {
getEntityFeedCount();
}
}, [containerName, viewBasicPermission]);
fetchResourcePermission(containerFQN);
}, [containerFQN]);
// Rendering
if (isLoading) {
@ -755,7 +752,7 @@ const ContainerPage = () => {
if (hasError) {
return (
<ErrorPlaceHolder>
{getEntityMissingError(t('label.container'), containerName)}
{getEntityMissingError(t('label.container'), containerFQN)}
</ErrorPlaceHolder>
);
}

View File

@ -692,9 +692,10 @@ const DatabaseSchemaPage: FunctionComponent = () => {
{isSchemaDetailsLoading ? (
<Skeleton
active
className="m-b-md"
paragraph={{
rows: 3,
width: ['20%', '80%', '60%'],
rows: 2,
width: ['20%', '80%'],
}}
/>
) : (

View File

@ -16,15 +16,32 @@ import React from 'react';
const LogViewerSkeleton = () => {
return (
<Row gutter={[16, 16]}>
<Col span={18}>
<div className="flex justify-between m-md">
<Skeleton active paragraph={{ rows: 1 }} />
<Row className="border-top justify-between" gutter={[16, 16]}>
<Col className="p-md border-right" span={18}>
<div className="flex items-center gap-2 justify-end">
<Skeleton.Button active />
<Skeleton.Button active shape="circle" />
</div>
<Skeleton active className="m-md" paragraph={{ rows: 12 }} />
<Skeleton
active
className="p-l-md p-t-md"
paragraph={{ rows: 16 }}
title={false}
/>
</Col>
<Col span={6}>
<Skeleton active paragraph={{ rows: 6 }} />
<Col className="p-md" span={6}>
<Skeleton active paragraph={{ rows: 1, width: '60%' }} />
<Row className="m-t-lg" gutter={[16, 48]}>
<Col span={11}>
<Skeleton
paragraph={{ rows: 3, width: ['70%', '80%', '90%'] }}
title={false}
/>
</Col>
<Col span={12}>
<Skeleton paragraph={{ rows: 3, width: '90%' }} title={false} />
</Col>
</Row>
</Col>
</Row>
);