Add Design: Entity detail drawer for lineage tab. (#927)

* Add Design: Entity detail drawer for lineage tab.

* integrated apis for pipeline and dashboard to show data on drawer.

* fixed mainentity should not be clickable.

* added support for outside click

* addressing review comments

* minor tweaks
This commit is contained in:
Sachin Chaurasiya 2021-10-27 13:00:19 +05:30 committed by GitHub
parent f15c430023
commit 451b96e15e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 639 additions and 36 deletions

View File

@ -0,0 +1,245 @@
import { AxiosError, AxiosResponse } from 'axios';
import classNames from 'classnames';
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { getDatabase } from '../../axiosAPIs/databaseAPI';
import { getPipelineByFqn } from '../../axiosAPIs/pipelineAPI';
import { getServiceById } from '../../axiosAPIs/serviceAPI';
import { getTableDetailsByFQN } from '../../axiosAPIs/tableAPI';
import { EntityType } from '../../enums/entity.enum';
import { Dashboard } from '../../generated/entity/data/dashboard';
import { Pipeline } from '../../generated/entity/data/pipeline';
import { Table } from '../../generated/entity/data/table';
import { Topic } from '../../generated/entity/data/topic';
import useToastContext from '../../hooks/useToastContext';
import { getEntityOverview, getEntityTags } from '../../utils/EntityUtils';
import { getEntityIcon, getEntityLink } from '../../utils/TableUtils';
import { SelectedNode } from '../EntityLineage/EntityLineage.interface';
import Loader from '../Loader/Loader';
import Tags from '../tags/tags';
import { LineageDrawerProps } from './EntityInfoDrawer.interface';
import './EntityInfoDrawer.style.css';
const getHeaderLabel = (
v = '',
type: string,
isMainNode: boolean,
separator = '.'
) => {
const length = v.split(separator).length;
return (
<>
{isMainNode ? (
<span
className="tw-break-words description-text tw-self-center tw-font-normal"
data-testid="lineage-entity">
{v.split(separator)[length - 1]}
</span>
) : (
<span
className="tw-break-words description-text tw-self-center link-text tw-font-normal"
data-testid="lineage-entity">
<Link to={getEntityLink(type, v)}>
{v.split(separator)[length - 1]}
</Link>
</span>
)}
</>
);
};
const EntityInfoDrawer = ({
show,
onCancel,
selectedNode,
isMainNode = false,
}: LineageDrawerProps) => {
const showToast = useToastContext();
const [entityDetail, setEntityDetail] = useState<
Partial<Table> & Partial<Pipeline> & Partial<Dashboard> & Partial<Topic>
>(
{} as Partial<Table> &
Partial<Pipeline> &
Partial<Dashboard> &
Partial<Topic>
);
const [serviceType, setServiceType] = useState<string>('');
const [isLoading, setIsLoading] = useState<boolean>(false);
const fetchEntityDetail = (selectedNode: SelectedNode) => {
switch (selectedNode.type) {
case EntityType.TABLE: {
setIsLoading(true);
getTableDetailsByFQN(selectedNode.name, [
'tags',
'owner',
'columns',
'usageSummary',
'tableProfile',
'database',
])
.then((res: AxiosResponse) => {
getDatabase(res.data.database.id, 'service')
.then((resDB: AxiosResponse) => {
getServiceById('databaseServices', resDB.data.service?.id).then(
(resService: AxiosResponse) => {
setServiceType(resService.data.serviceType);
}
);
})
.catch((err: AxiosError) => {
const msg = err.message;
showToast({
variant: 'error',
body:
msg ?? `Error while getting ${selectedNode.name} details`,
});
});
setEntityDetail(res.data);
setIsLoading(false);
})
.catch((err: AxiosError) => {
const msg = err.message;
showToast({
variant: 'error',
body: msg ?? `Error while getting ${selectedNode.name} details`,
});
});
break;
}
case EntityType.PIPELINE: {
setIsLoading(true);
getPipelineByFqn(selectedNode.name, ['tags', 'owner', 'service'])
.then((res: AxiosResponse) => {
getServiceById('pipelineServices', res.data.service?.id)
.then((serviceRes: AxiosResponse) => {
setServiceType(serviceRes.data.serviceType);
})
.catch((err: AxiosError) => {
const msg = err.message;
showToast({
variant: 'error',
body:
msg ?? `Error while getting ${selectedNode.name} service`,
});
});
setEntityDetail(res.data);
setIsLoading(false);
})
.catch((err: AxiosError) => {
const msg = err.message;
showToast({
variant: 'error',
body: msg ?? `Error while getting ${selectedNode.name} details`,
});
});
break;
}
default:
break;
}
};
useEffect(() => {
fetchEntityDetail(selectedNode);
}, [selectedNode]);
return (
<div className={classNames('side-drawer', { open: show })}>
<header className="tw-flex tw-justify-between">
<p className="tw-flex">
<span className="tw-mr-2">{getEntityIcon(selectedNode.type)}</span>
{getHeaderLabel(selectedNode.name, selectedNode.type, isMainNode)}
</p>
<div className="tw-flex">
<svg
className="tw-w-5 tw-h-5 tw-ml-1 tw-cursor-pointer"
data-testid="closeDrawer"
fill="none"
stroke="#6B7280"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
onClick={() => onCancel(false)}>
<path
d="M6 18L18 6M6 6l12 12"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
</svg>
</div>
</header>
<hr className="tw-mt-3 tw-border-primary-hover-lite" />
{isLoading ? (
<Loader />
) : (
<>
<section className="tw-mt-1">
<span className="tw-text-grey-muted">Overview</span>
<div className="tw-flex tw-flex-col">
{getEntityOverview(
selectedNode.type,
entityDetail,
serviceType
).map((d) => {
return (
<p className="tw-py-1.5" key={d.name}>
{d.name && <span>{d.name}:</span>}
<span
className={classNames(
{ 'tw-ml-2': d.name },
{
'link-text': d.isLink,
}
)}>
{d.isLink ? (
<Link
target={d.isExternal ? '_blank' : '_self'}
to={{ pathname: d.url }}>
{d.value}
</Link>
) : (
d.value
)}
</span>
</p>
);
})}
</div>
</section>
<hr className="tw-mt-3 tw-border-primary-hover-lite" />
<section className="tw-mt-1">
<span className="tw-text-grey-muted">Tags</span>
<div className="tw-flex tw-flex-wrap tw-pt-1.5">
{getEntityTags(selectedNode.type, entityDetail).length > 0 ? (
getEntityTags(selectedNode.type, entityDetail).map((t) => {
return <Tags key={t} tag={`#${t}`} />;
})
) : (
<p className="tw-text-xs tw-text-grey-muted">No Tags added</p>
)}
</div>
</section>
<hr className="tw-mt-3 tw-border-primary-hover-lite" />
<section className="tw-mt-1">
<span className="tw-text-grey-muted">Description</span>
<div>
{entityDetail.description ?? (
<p className="tw-text-xs tw-text-grey-muted">
No description added
</p>
)}
</div>
</section>
</>
)}
</div>
);
};
export default EntityInfoDrawer;

View File

@ -0,0 +1,8 @@
import { SelectedNode } from '../EntityLineage/EntityLineage.interface';
export interface LineageDrawerProps {
show: boolean;
onCancel: (value: boolean) => void;
selectedNode: SelectedNode;
isMainNode: boolean;
}

View File

@ -0,0 +1,29 @@
.side-drawer {
height: 100%;
background: white;
position: absolute;
top: 0;
right: 0;
width: 325px;
z-index: 200;
margin-right: -16px;
overflow-y: auto;
padding: 16px;
transform: translateX(100%);
border-left: 1px solid #d9ceee;
transition: transform 0.3s ease-out;
}
.backdrop {
position: absolute;
width: 100%;
height: 100%;
background-color: rgba(48, 46, 54, 0.6);
z-index: 100;
top: 0;
right: 0;
}
.side-drawer.open {
transform: translateX(0);
}

View File

@ -23,7 +23,9 @@ import {
EntityLineage,
} from '../../generated/type/entityLineage';
import { EntityReference } from '../../generated/type/entityReference';
import { getEntityIcon } from '../../utils/TableUtils';
import EntityInfoDrawer from '../EntityInfoDrawer/EntityInfoDrawer.component';
import { SelectedNode } from './EntityLineage.interface';
const onLoad = (reactFlowInstance: OnLoadParams) => {
reactFlowInstance.fitView();
reactFlowInstance.zoomTo(1);
@ -49,16 +51,21 @@ const getDataLabel = (v = '', separator = '.') => {
const length = v.split(separator).length;
return (
<p className="tw-break-words description-text" data-testid="lineage-entity">
<span
className="tw-break-words description-text tw-self-center"
data-testid="lineage-entity">
{v.split(separator)[length - 1]}
</p>
</span>
);
};
const positionX = 150;
const positionY = 60;
const getLineageData = (entityLineage: EntityLineage) => {
const getLineageData = (
entityLineage: EntityLineage,
onSelect: (state: boolean, value: SelectedNode) => void
) => {
const [x, y] = [0, 0];
const nodes = entityLineage['nodes'];
let upstreamEdges: Array<LineageEdge & { isMapped: boolean }> =
@ -96,7 +103,22 @@ const getLineageData = (entityLineage: EntityLineage) => {
targetPosition: Position.Left,
type: 'default',
className: 'leaf-node',
data: { label: getDataLabel(node.name as string) },
data: {
label: (
<p
className="tw-flex"
onClick={() =>
onSelect(true, {
name: node.name as string,
type: node.type,
id: `node-${node.id}-${depth}`,
})
}>
<span className="tw-mr-2">{getEntityIcon(node.type)}</span>
{getDataLabel(node.name as string)}
</p>
),
},
position: {
x: -positionX * 2 * depth,
y: y + positionY * upDepth,
@ -141,7 +163,22 @@ const getLineageData = (entityLineage: EntityLineage) => {
targetPosition: Position.Left,
type: 'default',
className: 'leaf-node',
data: { label: getDataLabel(node.name as string) },
data: {
label: (
<span
className="tw-flex"
onClick={() =>
onSelect(true, {
name: node.name as string,
type: node.type,
id: `node-${node.id}-${depth}`,
})
}>
<span className="tw-mr-2">{getEntityIcon(node.type)}</span>
{getDataLabel(node.name as string)}
</span>
),
},
position: {
x: positionX * 2 * depth,
y: y + positionY * downDepth,
@ -229,7 +266,21 @@ const getLineageData = (entityLineage: EntityLineage) => {
: 'output'
: 'input',
className: 'leaf-node core',
data: { label: getDataLabel(mainNode.name as string) },
data: {
label: (
<p
className="tw-flex"
onClick={() =>
onSelect(true, {
name: mainNode.name as string,
type: mainNode.type,
})
}>
<span className="tw-mr-2">{getEntityIcon(mainNode.type)}</span>
{getDataLabel(mainNode.name as string)}
</p>
),
},
position: { x: x, y: y },
},
...UPStreamNodes.map((up) => {
@ -257,45 +308,90 @@ const Entitylineage: FunctionComponent<{ entityLineage: EntityLineage }> = ({
}: {
entityLineage: EntityLineage;
}) => {
const [elements, setElements] = useState<Elements>(
getLineageData(entityLineage) as Elements
const [isDrawerOpen, setIsDrawerOpen] = useState<boolean>(false);
const [selectedNode, setSelectedNode] = useState<SelectedNode>(
{} as SelectedNode
);
const selectNodeHandler = (state: boolean, value: SelectedNode) => {
setIsDrawerOpen(state);
setSelectedNode(value);
};
const [elements, setElements] = useState<Elements>(
getLineageData(entityLineage, selectNodeHandler) as Elements
);
const closeDrawer = (value: boolean) => {
setIsDrawerOpen(value);
setElements((prevElements) => {
return prevElements.map((el) => {
if (el.id === selectedNode.id) {
return { ...el, className: 'leaf-node' };
} else {
return el;
}
});
});
};
const onElementsRemove = (elementsToRemove: Elements) =>
setElements((els) => removeElements(elementsToRemove, els));
const onConnect = (params: Edge | Connection) =>
setElements((els) => addEdge(params, els));
const onElementClick = (el: FlowElement) => {
setElements((prevElements) => {
return prevElements.map((preEl) => {
if (preEl.id === el.id) {
return { ...preEl, className: `${preEl.className} selected-node` };
} else {
return { ...preEl, className: 'leaf-node' };
}
});
});
};
useEffect(() => {
setElements(getLineageData(entityLineage) as Elements);
setElements(getLineageData(entityLineage, selectNodeHandler) as Elements);
}, [entityLineage]);
return (
<div className="tw-w-full tw-h-full">
{(entityLineage?.downstreamEdges ?? []).length > 0 ||
(entityLineage.upstreamEdges ?? []).length ? (
<ReactFlowProvider>
<ReactFlow
panOnScroll
elements={elements as Elements}
nodesConnectable={false}
onConnect={onConnect}
onElementsRemove={onElementsRemove}
onLoad={onLoad}
onNodeContextMenu={onNodeContextMenu}
onNodeMouseEnter={onNodeMouseEnter}
onNodeMouseLeave={onNodeMouseLeave}
onNodeMouseMove={onNodeMouseMove}>
<Controls
className="tw-top-1 tw-left-1 tw-bottom-full"
showInteractive={false}
/>
</ReactFlow>
</ReactFlowProvider>
) : (
<div className="tw-flex tw-justify-center tw-font-medium tw-items-center tw-border tw-border-main tw-rounded-md tw-p-8">
No Lineage data available
</div>
)}
<div className="tw-relative tw-h-full tw--ml-4">
<div className="tw-w-full tw-h-full">
{(entityLineage?.downstreamEdges ?? []).length > 0 ||
(entityLineage.upstreamEdges ?? []).length ? (
<ReactFlowProvider>
<ReactFlow
panOnScroll
elements={elements as Elements}
nodesConnectable={false}
onConnect={onConnect}
onElementClick={(_e, el) => onElementClick(el)}
onElementsRemove={onElementsRemove}
onLoad={onLoad}
onNodeContextMenu={onNodeContextMenu}
onNodeMouseEnter={onNodeMouseEnter}
onNodeMouseLeave={onNodeMouseLeave}
onNodeMouseMove={onNodeMouseMove}>
<Controls
className="tw-top-1 tw-left-1 tw-bottom-full tw-ml-4 tw-mt-4"
showInteractive={false}
/>
</ReactFlow>
</ReactFlowProvider>
) : (
<div className="tw-flex tw-justify-center tw-font-medium tw-items-center tw-border tw-border-main tw-rounded-md tw-p-8">
No Lineage data available
</div>
)}
</div>
<EntityInfoDrawer
isMainNode={selectedNode.name === entityLineage.entity.name}
selectedNode={selectedNode}
show={isDrawerOpen}
onCancel={closeDrawer}
/>
</div>
);
};

View File

@ -0,0 +1,5 @@
export interface SelectedNode {
name: string;
type: string;
id?: string;
}

View File

@ -308,6 +308,11 @@
}
.leaf-node.selected,
.leaf-node.selected:hover {
@apply tw-border-main;
box-shadow: 0 0 0 0.5px #e2dce4;
}
.leaf-node.selected-node,
.leaf-node.selected-node:hover {
@apply tw-border-primary-active;
box-shadow: 0 0 0 0.5px #7147e8;
}

View File

@ -0,0 +1,206 @@
import { isNil } from 'lodash';
import {
getDatabaseDetailsPath,
getServiceDetailsPath,
getTeamDetailsPath,
} from '../constants/constants';
import { EntityType } from '../enums/entity.enum';
import { Dashboard } from '../generated/entity/data/dashboard';
import { Pipeline } from '../generated/entity/data/pipeline';
import { Table } from '../generated/entity/data/table';
import { Topic } from '../generated/entity/data/topic';
import { TagLabel } from '../generated/type/tagLabel';
import { getPartialNameFromFQN } from './CommonUtils';
import {
getOwnerFromId,
getTierFromTableTags,
getUsagePercentile,
} from './TableUtils';
import { getTableTags } from './TagsUtils';
import { getRelativeDay } from './TimeUtils';
export const getEntityTags = (
type: string,
entityDetail: Partial<Table> &
Partial<Pipeline> &
Partial<Dashboard> &
Partial<Topic>
): Array<string | undefined> => {
switch (type) {
case EntityType.TABLE: {
const tableTags: Array<TagLabel> = [
...getTableTags(entityDetail.columns || []),
...(entityDetail.tags || []),
];
return tableTags.map((t) => t.tagFQN);
}
case EntityType.PIPELINE: {
return entityDetail.tags?.map((t) => t.tagFQN) || [];
}
default:
return [];
}
};
export const getEntityOverview = (
type: string,
entityDetail: Partial<Table> &
Partial<Pipeline> &
Partial<Dashboard> &
Partial<Topic>,
serviceType: string
): Array<{
name: string;
value: string;
isLink: boolean;
isExternal?: boolean;
url?: string;
}> => {
switch (type) {
case EntityType.TABLE: {
const { fullyQualifiedName, owner, tags, usageSummary, tableProfile } =
entityDetail;
const [service, database] = getPartialNameFromFQN(
fullyQualifiedName ?? '',
['service', 'database'],
'.'
).split('.');
const ownerValue = getOwnerFromId(owner?.id);
const tier = getTierFromTableTags(tags || []);
const usage = !isNil(usageSummary?.weeklyStats?.percentileRank)
? getUsagePercentile(usageSummary?.weeklyStats?.percentileRank || 0)
: '--';
const queries = usageSummary?.weeklyStats?.count.toLocaleString() || '--';
const getProfilerRowDiff = (tableProfile: Table['tableProfile']) => {
let retDiff;
if (tableProfile && tableProfile.length > 0) {
let rowDiff: string | number = tableProfile[0].rowCount || 0;
const dayDiff = getRelativeDay(
tableProfile[0].profileDate
? new Date(tableProfile[0].profileDate).getTime()
: Date.now()
);
if (tableProfile.length > 1) {
rowDiff = rowDiff - (tableProfile[1].rowCount || 0);
}
retDiff = `${(rowDiff >= 0 ? '+' : '') + rowDiff} rows ${dayDiff}`;
}
return retDiff;
};
const profilerRowDiff = getProfilerRowDiff(tableProfile);
const overview = [
{
name: 'Service',
value: service,
url: getServiceDetailsPath(service, serviceType),
isLink: true,
},
{
name: 'Database',
value: database,
url: getDatabaseDetailsPath(
getPartialNameFromFQN(
fullyQualifiedName ?? '',
['service', 'database'],
'.'
)
),
isLink: true,
},
{
name: 'Owner',
value: ownerValue?.displayName || ownerValue?.name || '--',
url: getTeamDetailsPath(owner?.name || ''),
isLink: ownerValue
? ownerValue.type === 'team'
? true
: false
: false,
},
{
name: 'Tier',
value: tier ? tier.split('.')[1] : '--',
isLink: false,
},
{
name: 'Usage',
value: usage,
isLink: false,
},
{
name: 'Queries',
value: `${queries} past week`,
isLink: false,
},
{
name: 'Rows',
value:
tableProfile && tableProfile[0]?.rowCount
? tableProfile[0].rowCount
: '--',
isLink: false,
},
{
name: 'Columns',
value:
tableProfile && tableProfile[0]?.columnCount
? tableProfile[0].columnCount
: '--',
isLink: false,
},
];
if (!isNil(profilerRowDiff)) {
overview.push({ value: profilerRowDiff, name: '', isLink: false });
}
return overview;
}
case EntityType.PIPELINE: {
const { owner, tags, pipelineUrl, service, fullyQualifiedName } =
entityDetail;
const ownerValue = getOwnerFromId(owner?.id);
const tier = getTierFromTableTags(tags || []);
const overview = [
{
name: 'Service',
value: service?.name as string,
url: getServiceDetailsPath(service?.name as string, serviceType),
isLink: true,
},
{
name: 'Owner',
value: ownerValue?.displayName || ownerValue?.name || '--',
url: getTeamDetailsPath(owner?.name || ''),
isLink: ownerValue
? ownerValue.type === 'team'
? true
: false
: false,
},
{
name: 'Tier',
value: tier ? tier.split('.')[1] : '--',
isLink: false,
},
{
name: `${serviceType} url`,
value: fullyQualifiedName?.split('.')[1] as string,
url: pipelineUrl as string,
isLink: true,
isExternal: true,
},
];
return overview;
}
default:
return [];
}
};

View File

@ -8,6 +8,7 @@ import {
getPipelineDetailsPath,
getTopicDetailsPath,
} from '../constants/constants';
import { EntityType } from '../enums/entity.enum';
import { SearchIndex } from '../enums/search.enum';
import { ConstraintTypes } from '../enums/table.enum';
import { Column, Table } from '../generated/entity/data/table';
@ -164,15 +165,19 @@ export const getEntityLink = (
) => {
switch (indexType) {
case SearchIndex.TOPIC:
case EntityType.TOPIC:
return getTopicDetailsPath(fullyQualifiedName);
case SearchIndex.DASHBOARD:
case EntityType.DASHBOARD:
return getDashboardDetailsPath(fullyQualifiedName);
case SearchIndex.PIPELINE:
case EntityType.PIPELINE:
return getPipelineDetailsPath(fullyQualifiedName);
case SearchIndex.TABLE:
case EntityType.TABLE:
default:
return getDatasetDetailsPath(fullyQualifiedName);
}
@ -182,20 +187,24 @@ export const getEntityIcon = (indexType: string) => {
let icon = '';
switch (indexType) {
case SearchIndex.TOPIC:
case EntityType.TOPIC:
icon = 'topic';
break;
case SearchIndex.DASHBOARD:
case EntityType.DASHBOARD:
icon = 'dashboard';
break;
case SearchIndex.PIPELINE:
case EntityType.PIPELINE:
icon = 'pipeline';
break;
case SearchIndex.TABLE:
case EntityType.TABLE:
default:
icon = 'table';