Chirag Madlani c89bcb51b4
feat(ui): breadcrumb style updates (#10974)
* feat(ui): breadcrumb style updates
entity title styling

* fix localisation

* update explore card & summary panel breadcrumbs

* fix breadcrumbs for container, glossary & tags

* update breadcrumb styling at all the places

* fix service type missing from version components

* fix unit tests

* fix breadcrumb styling
fix icons related changes
fix link related issue

* fixed assets related feedbacks

* add external link for assets

* fix cypress

* fix breadcrumbs on the glossary

* fix glossary breadcrumb issue

* update databaseSchema page layout

* fix code smells

* fix code smells

* fix unit tests failing

* update tag icon and fix lineage cypress

* fix restore entity

* fix restore entity spec

* fix test id issue
2023-04-14 11:28:11 +05:30

832 lines
27 KiB
TypeScript

/*
* Copyright 2022 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 { Card, Col, Row, Skeleton, Space, Table } from 'antd';
import { ColumnsType } from 'antd/lib/table';
import { AxiosError } from 'axios';
import ActivityFeedList from 'components/ActivityFeed/ActivityFeedList/ActivityFeedList';
import ActivityThreadPanel from 'components/ActivityFeed/ActivityThreadPanel/ActivityThreadPanel';
import Description from 'components/common/description/Description';
import ManageButton from 'components/common/entityPageInfo/ManageButton/ManageButton';
import EntitySummaryDetails from 'components/common/EntitySummaryDetails/EntitySummaryDetails';
import ErrorPlaceHolder from 'components/common/error-with-placeholder/ErrorPlaceHolder';
import NextPrevious from 'components/common/next-previous/NextPrevious';
import RichTextEditorPreviewer from 'components/common/rich-text-editor/RichTextEditorPreviewer';
import TabsPane from 'components/common/TabsPane/TabsPane';
import { TitleBreadcrumbProps } from 'components/common/title-breadcrumb/title-breadcrumb.interface';
import PageContainerV1 from 'components/containers/PageContainerV1';
import PageLayoutV1 from 'components/containers/PageLayoutV1';
import { EntityHeader } from 'components/Entity/EntityHeader/EntityHeader.component';
import Loader from 'components/Loader/Loader';
import { usePermissionProvider } from 'components/PermissionProvider/PermissionProvider';
import {
OperationPermission,
ResourceEntity,
} from 'components/PermissionProvider/PermissionProvider.interface';
import { compare, Operation } from 'fast-json-patch';
import { isNil, startCase } from 'lodash';
import { observer } from 'mobx-react';
import { ExtraInfo } from 'Models';
import React, {
Fragment,
FunctionComponent,
RefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useHistory, useParams } from 'react-router-dom';
import {
getDatabaseDetailsByFQN,
getDatabaseSchemas,
patchDatabaseDetails,
} from 'rest/databaseAPI';
import {
getAllFeeds,
getFeedCount,
postFeedById,
postThread,
} from 'rest/feedsAPI';
import { default as AppState, default as appState } from '../../AppState';
import { FQN_SEPARATOR_CHAR } from '../../constants/char.constants';
import {
getDatabaseDetailsPath,
getDatabaseSchemaDetailsPath,
getExplorePath,
getServiceDetailsPath,
getTeamAndUserDetailsPath,
PAGE_SIZE,
pagingObject,
} from '../../constants/constants';
import { EntityField } from '../../constants/Feeds.constants';
import { GlobalSettingsMenuCategory } from '../../constants/GlobalSettings.constants';
import { observerOptions } from '../../constants/Mydata.constants';
import { EntityType, TabSpecificField } from '../../enums/entity.enum';
import { ServiceCategory } from '../../enums/service.enum';
import { OwnerType } from '../../enums/user.enum';
import { CreateThread } from '../../generated/api/feed/createThread';
import { Database } from '../../generated/entity/data/database';
import { DatabaseSchema } from '../../generated/entity/data/databaseSchema';
import { Post, Thread } from '../../generated/entity/feed/thread';
import { EntityReference } from '../../generated/entity/teams/user';
import { UsageDetails } from '../../generated/type/entityUsage';
import { Paging } from '../../generated/type/paging';
import { useInfiniteScroll } from '../../hooks/useInfiniteScroll';
import { EntityFieldThreadCount } from '../../interface/feed.interface';
import jsonData from '../../jsons/en';
import {
databaseDetailsTabs,
getCurrentDatabaseDetailsTab,
} from '../../utils/DatabaseDetailsUtils';
import { getEntityFeedLink, getEntityName } from '../../utils/EntityUtils';
import {
deletePost,
getEntityFieldThreadCounts,
updateThreadData,
} from '../../utils/FeedUtils';
import { DEFAULT_ENTITY_PERMISSION } from '../../utils/PermissionsUtils';
import { getSettingPath } from '../../utils/RouterUtils';
import {
getServiceRouteFromServiceType,
serviceTypeLogo,
} from '../../utils/ServiceUtils';
import { getErrorText } from '../../utils/StringsUtils';
import { getUsagePercentile } from '../../utils/TableUtils';
import { showErrorToast } from '../../utils/ToastUtils';
const DatabaseDetails: FunctionComponent = () => {
const { t } = useTranslation();
const [slashedDatabaseName, setSlashedDatabaseName] = useState<
TitleBreadcrumbProps['titleLinks']
>([]);
const { getEntityPermissionByFqn } = usePermissionProvider();
const { databaseFQN, tab } = useParams() as Record<string, string>;
const [isLoading, setIsLoading] = useState(true);
const [database, setDatabase] = useState<Database>();
const [serviceType, setServiceType] = useState<string>();
const [schemaData, setSchemaData] = useState<Array<DatabaseSchema>>([]);
const [schemaDataLoading, setSchemaDataLoading] = useState<boolean>(true);
const [databaseName, setDatabaseName] = useState<string>(
databaseFQN.split(FQN_SEPARATOR_CHAR).slice(-1).pop() || ''
);
const [isDatabaseDetailsLoading, setIsDatabaseDetailsLoading] =
useState<boolean>(true);
const [isEdit, setIsEdit] = useState(false);
const [description, setDescription] = useState('');
const [databaseId, setDatabaseId] = useState('');
const [databaseSchemaPaging, setSchemaPaging] =
useState<Paging>(pagingObject);
const [databaseSchemaInstanceCount, setSchemaInstanceCount] =
useState<number>(0);
const [activeTab, setActiveTab] = useState<number>(
getCurrentDatabaseDetailsTab(tab)
);
const [error, setError] = useState('');
const [entityThread, setEntityThread] = useState<Thread[]>([]);
const [isentityThreadLoading, setIsentityThreadLoading] =
useState<boolean>(false);
const [feedCount, setFeedCount] = useState<number>(0);
const [entityFieldThreadCount, setEntityFieldThreadCount] = useState<
EntityFieldThreadCount[]
>([]);
const [threadLink, setThreadLink] = useState<string>('');
const [paging, setPaging] = useState<Paging>({} as Paging);
const [elementRef, isInView] = useInfiniteScroll(observerOptions);
const [currentPage, setCurrentPage] = useState(1);
const history = useHistory();
const isMounting = useRef(true);
const [databasePermission, setDatabasePermission] =
useState<OperationPermission>(DEFAULT_ENTITY_PERMISSION);
const fetchDatabasePermission = async () => {
setIsLoading(true);
try {
const response = await getEntityPermissionByFqn(
ResourceEntity.DATABASE,
databaseFQN
);
setDatabasePermission(response);
} catch (error) {
showErrorToast(error as AxiosError);
} finally {
setIsLoading(false);
}
};
const tabs = [
{
name: t('label.schema-plural'),
icon: {
alt: 'schemas',
name: 'schema-grey',
title: 'Schemas',
selectedName: 'schemas',
},
count: databaseSchemaInstanceCount,
isProtected: false,
position: 1,
},
{
name: t('label.activity-feed-plural'),
icon: {
alt: 'activity_feed',
name: 'activity_feed',
title: 'Activity Feed',
selectedName: 'activity-feed-color',
},
isProtected: false,
position: 2,
count: feedCount,
},
];
const extraInfo: Array<ExtraInfo> = [
{
key: 'Owner',
value:
database?.owner?.type === 'team'
? getTeamAndUserDetailsPath(
database?.owner?.displayName || database?.owner?.name || ''
)
: database?.owner?.displayName || database?.owner?.name || '',
placeholderText:
database?.owner?.displayName || database?.owner?.name || '',
isLink: database?.owner?.type === 'team',
openInNewTab: false,
profileName:
database?.owner?.type === OwnerType.USER
? database?.owner?.name
: undefined,
},
];
const fetchDatabaseSchemas = (pagingObj?: string) => {
return new Promise<void>((resolve, reject) => {
setSchemaDataLoading(true);
getDatabaseSchemas(databaseFQN, pagingObj, ['owner', 'usageSummary'])
.then((res) => {
if (res.data) {
setSchemaData(res.data);
setSchemaPaging(res.paging);
setSchemaInstanceCount(res.paging.total);
} else {
setSchemaData([]);
setSchemaPaging(pagingObject);
throw jsonData['api-error-messages']['unexpected-server-response'];
}
resolve();
})
.catch((err: AxiosError) => {
showErrorToast(
err,
jsonData['api-error-messages']['fetch-database-schemas-error']
);
reject();
})
.finally(() => {
setSchemaDataLoading(false);
});
});
};
const fetchDatabaseSchemasAndDBTModels = () => {
setIsLoading(true);
Promise.allSettled([fetchDatabaseSchemas()]).finally(() => {
setIsLoading(false);
});
};
const onThreadLinkSelect = (link: string) => {
setThreadLink(link);
};
const onThreadPanelClose = () => {
setThreadLink('');
};
const getEntityFeedCount = () => {
getFeedCount(getEntityFeedLink(EntityType.DATABASE, databaseFQN))
.then((res) => {
if (res) {
setFeedCount(res.totalCount);
setEntityFieldThreadCount(res.counts);
} else {
throw jsonData['api-error-messages']['unexpected-server-response'];
}
})
.catch((err: AxiosError) => {
showErrorToast(
err,
jsonData['api-error-messages']['fetch-entity-feed-count-error']
);
});
};
const getDetailsByFQN = () => {
setIsDatabaseDetailsLoading(true);
getDatabaseDetailsByFQN(databaseFQN, ['owner'])
.then((res) => {
if (res) {
const { description, id, name, service, serviceType } = res;
setDatabase(res);
setDescription(description ?? '');
setDatabaseId(id ?? '');
setDatabaseName(name);
setServiceType(serviceType);
setSlashedDatabaseName([
{
name: startCase(ServiceCategory.DATABASE_SERVICES),
url: getSettingPath(
GlobalSettingsMenuCategory.SERVICES,
getServiceRouteFromServiceType(
ServiceCategory.DATABASE_SERVICES
)
),
},
{
name: service.name ?? '',
url: service.name
? getServiceDetailsPath(
service.name,
ServiceCategory.DATABASE_SERVICES
)
: '',
},
]);
fetchDatabaseSchemasAndDBTModels();
} else {
throw jsonData['api-error-messages']['unexpected-server-response'];
}
})
.catch((err: AxiosError) => {
const errMsg = getErrorText(
err,
jsonData['api-error-messages']['fetch-database-details-error']
);
setError(errMsg);
showErrorToast(errMsg);
})
.finally(() => {
setIsLoading(false);
setIsDatabaseDetailsLoading(false);
});
};
const onCancel = () => {
setIsEdit(false);
};
const saveUpdatedDatabaseData = (updatedData: Database) => {
let jsonPatch: Operation[] = [];
if (database) {
jsonPatch = compare(database, updatedData);
}
return patchDatabaseDetails(databaseId, jsonPatch);
};
const onDescriptionUpdate = async (updatedHTML: string) => {
if (description !== updatedHTML && database) {
const updatedDatabaseDetails = {
...database,
description: updatedHTML,
};
try {
const response = await saveUpdatedDatabaseData(updatedDatabaseDetails);
if (response) {
setDatabase(updatedDatabaseDetails);
setDescription(updatedHTML);
getEntityFeedCount();
} else {
throw jsonData['api-error-messages']['unexpected-server-response'];
}
} catch (error) {
showErrorToast(error as AxiosError);
} finally {
setIsEdit(false);
}
} else {
setIsEdit(false);
}
};
const onDescriptionEdit = (): void => {
setIsEdit(true);
};
const activeTabHandler = (tabValue: number) => {
const currentTabIndex = tabValue - 1;
if (databaseDetailsTabs[currentTabIndex].path !== tab) {
setActiveTab(tabValue);
history.push({
pathname: getDatabaseDetailsPath(
databaseFQN,
databaseDetailsTabs[currentTabIndex].path
),
});
}
};
const databaseSchemaPagingHandler = (
cursorType: string | number,
activePage?: number
) => {
const pagingString = `&${cursorType}=${
databaseSchemaPaging[cursorType as keyof typeof databaseSchemaPaging]
}`;
setIsLoading(true);
fetchDatabaseSchemas(pagingString).finally(() => {
setIsLoading(false);
});
setCurrentPage(activePage ?? 1);
};
const handleUpdateOwner = useCallback(
(owner: Database['owner']) => {
const updatedData = {
...database,
owner: owner ? { ...database?.owner, ...owner } : undefined,
};
return new Promise<void>((_, reject) => {
saveUpdatedDatabaseData(updatedData as Database)
.then((res) => {
if (res) {
setDatabase(res);
reject();
} else {
reject();
throw jsonData['api-error-messages'][
'unexpected-server-response'
];
}
})
.catch((err: AxiosError) => {
showErrorToast(
err,
jsonData['api-error-messages']['update-database-error']
);
reject();
});
});
},
[database, database?.owner]
);
const fetchActivityFeed = (after?: string) => {
setIsentityThreadLoading(true);
getAllFeeds(getEntityFeedLink(EntityType.DATABASE, databaseFQN), after)
.then((res) => {
const { data, paging: pagingObj } = res;
if (data) {
setPaging(pagingObj);
setEntityThread((prevData) => [...prevData, ...data]);
} else {
throw jsonData['api-error-messages']['unexpected-server-response'];
}
})
.catch((err: AxiosError) => {
showErrorToast(
err,
jsonData['api-error-messages']['fetch-entity-feed-error']
);
})
.finally(() => setIsentityThreadLoading(false));
};
const postFeedHandler = (value: string, id: string) => {
const currentUser = AppState.userDetails?.name ?? AppState.users[0]?.name;
const data = {
message: value,
from: currentUser,
} as Post;
postFeedById(id, data)
.then((res) => {
if (res) {
const { id, posts } = res;
setEntityThread((pre) => {
return pre.map((thread) => {
if (thread.id === id) {
return { ...res, posts: posts?.slice(-3) };
} else {
return thread;
}
});
});
getEntityFeedCount();
} else {
throw jsonData['api-error-messages']['unexpected-server-response'];
}
})
.catch((err: AxiosError) => {
showErrorToast(err, jsonData['api-error-messages']['add-feed-error']);
});
};
const createThread = (data: CreateThread) => {
postThread(data)
.then((res) => {
if (res) {
setEntityThread((pre) => [...pre, res]);
getEntityFeedCount();
} else {
showErrorToast(
jsonData['api-error-messages']['unexpected-server-response']
);
}
})
.catch((err: AxiosError) => {
showErrorToast(
err,
jsonData['api-error-messages']['create-conversation-error']
);
});
};
const deletePostHandler = (
threadId: string,
postId: string,
isThread: boolean
) => {
deletePost(threadId, postId, isThread, setEntityThread);
};
const updateThreadHandler = (
threadId: string,
postId: string,
isThread: boolean,
data: Operation[]
) => {
updateThreadData(threadId, postId, isThread, data, setEntityThread);
};
const getLoader = () => {
return isentityThreadLoading ? <Loader /> : null;
};
const fetchMoreFeed = (
isElementInView: boolean,
pagingObj: Paging,
isFeedLoading: boolean
) => {
if (isElementInView && pagingObj?.after && !isFeedLoading) {
fetchActivityFeed(pagingObj.after);
}
};
useEffect(() => {
getEntityFeedCount();
}, []);
useEffect(() => {
if (!isMounting.current && appState.inPageSearchText) {
history.push(
getExplorePath({
search: appState.inPageSearchText,
extraParameters: {
facetFilter: {
serviceType: [serviceType],
'database.name.keyword': [databaseName],
},
},
})
);
}
}, [appState.inPageSearchText]);
useEffect(() => {
if (databasePermission.ViewAll || databasePermission.ViewBasic) {
const currentTab = getCurrentDatabaseDetailsTab(tab);
const currentTabIndex = currentTab - 1;
if (tabs[currentTabIndex].isProtected) {
activeTabHandler(1);
}
getDetailsByFQN();
}
}, [databasePermission, databaseFQN]);
useEffect(() => {
if (TabSpecificField.ACTIVITY_FEED === tab) {
fetchActivityFeed();
} else {
setEntityThread([]);
}
}, [tab]);
useEffect(() => {
fetchMoreFeed(isInView as boolean, paging, isentityThreadLoading);
}, [isInView, paging, isentityThreadLoading]);
useEffect(() => {
fetchDatabasePermission();
}, [databaseFQN]);
// alwyas Keep this useEffect at the end...
useEffect(() => {
isMounting.current = false;
appState.inPageSearchText = '';
}, []);
const tableColumn: ColumnsType<DatabaseSchema> = useMemo(
() => [
{
title: t('label.schema-name'),
dataIndex: 'name',
key: 'name',
render: (_, record: DatabaseSchema) => (
<Link
to={
record.fullyQualifiedName
? getDatabaseSchemaDetailsPath(record.fullyQualifiedName)
: ''
}>
{getEntityName(record)}
</Link>
),
},
{
title: t('label.description'),
dataIndex: 'description',
key: 'description',
render: (text: string) =>
text?.trim() ? (
<RichTextEditorPreviewer markdown={text} />
) : (
<span className="text-grey-muted">
{t('label.no-entity', { entity: t('label.description') })}
</span>
),
},
{
title: t('label.owner'),
dataIndex: 'owner',
key: 'owner',
render: (text: EntityReference) => getEntityName(text) || '--',
},
{
title: t('label.usage'),
dataIndex: 'usageSummary',
key: 'usageSummary',
render: (text: UsageDetails) =>
getUsagePercentile(text?.weeklyStats?.percentileRank || 0),
},
],
[]
);
return (
<>
{isLoading ? (
<Loader />
) : error ? (
<ErrorPlaceHolder>
<p data-testid="error-message">{error}</p>
</ErrorPlaceHolder>
) : (
<>
{databasePermission.ViewAll || databasePermission.ViewBasic ? (
<PageContainerV1>
<PageLayoutV1
pageTitle={t('label.entity-detail-plural', {
entity: getEntityName(database),
})}>
{isDatabaseDetailsLoading ? (
<Skeleton
active
paragraph={{
rows: 3,
width: ['20%', '80%', '60%'],
}}
/>
) : (
<>
{database && (
<EntityHeader
breadcrumb={slashedDatabaseName}
entityData={database}
entityType={EntityType.DATABASE}
extra={
<ManageButton
isRecursiveDelete
allowSoftDelete={false}
canDelete={databasePermission.Delete}
entityFQN={databaseFQN}
entityId={databaseId}
entityName={databaseName}
entityType={EntityType.DATABASE}
/>
}
icon={
<img
className="h-8"
src={serviceTypeLogo(serviceType ?? '')}
/>
}
/>
)}
<Col span={24}>
{extraInfo.map((info, index) => (
<Space key={index}>
<EntitySummaryDetails
currentOwner={database?.owner}
data={info}
updateOwner={
databasePermission.EditOwner ||
databasePermission.EditAll
? handleUpdateOwner
: undefined
}
/>
</Space>
))}
</Col>
<Col data-testid="description-container" span={24}>
<Description
description={description}
entityFieldThreads={getEntityFieldThreadCounts(
EntityField.DESCRIPTION,
entityFieldThreadCount
)}
entityFqn={databaseFQN}
entityName={databaseName}
entityType={EntityType.DATABASE}
hasEditAccess={
databasePermission.EditDescription ||
databasePermission.EditAll
}
isEdit={isEdit}
onCancel={onCancel}
onDescriptionEdit={onDescriptionEdit}
onDescriptionUpdate={onDescriptionUpdate}
onThreadLinkSelect={onThreadLinkSelect}
/>
</Col>
</>
)}
<Col span={24}>
<Row className="m-t-md">
<Col span={24}>
<TabsPane
activeTab={activeTab}
className="flex-initial"
setActiveTab={activeTabHandler}
tabs={tabs}
/>
</Col>
<Col className="p-y-md" span={24}>
{activeTab === 1 && (
<Fragment>
<Table
bordered
className="table-shadow"
columns={tableColumn}
data-testid="database-databaseSchemas"
dataSource={schemaData}
loading={{
spinning: schemaDataLoading,
indicator: <Loader size="small" />,
}}
pagination={false}
rowKey="id"
size="small"
/>
{Boolean(
!isNil(databaseSchemaPaging.after) ||
!isNil(databaseSchemaPaging.before)
) && (
<NextPrevious
currentPage={currentPage}
pageSize={PAGE_SIZE}
paging={databaseSchemaPaging}
pagingHandler={databaseSchemaPagingHandler}
totalCount={databaseSchemaPaging.total}
/>
)}
</Fragment>
)}
{activeTab === 2 && (
<Card className="p-t-xss p-b-md">
<Row className="entity-feed-list" id="activityfeed">
<Col offset={4} span={16}>
<ActivityFeedList
hideFeedFilter
hideThreadFilter
isEntityFeed
withSidePanel
className=""
deletePostHandler={deletePostHandler}
entityName={databaseName}
feedList={entityThread}
postFeedHandler={postFeedHandler}
updateThreadHandler={updateThreadHandler}
/>
</Col>
</Row>
</Card>
)}
<Col
data-testid="observer-element"
id="observer-element"
ref={elementRef as RefObject<HTMLDivElement>}
span={24}>
{getLoader()}
</Col>
</Col>
</Row>
</Col>
<Col span={24}>
{threadLink ? (
<ActivityThreadPanel
createThread={createThread}
deletePostHandler={deletePostHandler}
open={Boolean(threadLink)}
postFeedHandler={postFeedHandler}
threadLink={threadLink}
updateThreadHandler={updateThreadHandler}
onCancel={onThreadPanelClose}
/>
) : null}
</Col>
</PageLayoutV1>
</PageContainerV1>
) : (
<ErrorPlaceHolder>
{t('message.no-permission-to-view')}
</ErrorPlaceHolder>
)}
</>
)}
</>
);
};
export default observer(DatabaseDetails);