mirror of
https://github.com/open-metadata/OpenMetadata.git
synced 2025-10-17 11:43:54 +00:00
Feat UI: Added Tracing for lineage and Column lineage (#8542)
* Feat: Added tracing feature for lineage * fixed stack overflow issue for tracing logic * restrict highlighing column lineage on click of node * added support for column lineage tracing * added unit test for util function * updated icon color as per font color
This commit is contained in:
parent
8d15985117
commit
5fee438b87
@ -103,31 +103,30 @@ export const CustomEdge = ({
|
||||
width={PIPELINE_EDGE_WIDTH}
|
||||
x={edgeCenterX - PIPELINE_EDGE_WIDTH / 2}
|
||||
y={edgeCenterY - FOREIGN_OBJECT_SIZE / 2}>
|
||||
<body
|
||||
<div
|
||||
className="tw-flex-center tw-bg-body-main tw-gap-2 tw-border tw-rounded tw-p-2"
|
||||
onClick={(event) =>
|
||||
data.isEditMode &&
|
||||
addPipelineClick?.(event, rest as CustomEdgeData)
|
||||
}>
|
||||
<div className="tw-flex-center tw-bg-body-main tw-gap-2 tw-border tw-rounded tw-p-2">
|
||||
<div className="tw-flex tw-items-center tw-gap-2">
|
||||
<div className="tw-flex tw-items-center tw-gap-2">
|
||||
<SVGIcons
|
||||
alt="times-circle"
|
||||
icon={Icons.PIPELINE_GREY}
|
||||
width="14px"
|
||||
/>
|
||||
<span data-testid="pipeline-name">{data.label}</span>
|
||||
</div>
|
||||
{data.isEditMode && (
|
||||
<button className="tw-cursor-pointer tw-flex tw-z-9999">
|
||||
<SVGIcons
|
||||
alt="times-circle"
|
||||
icon={Icons.PIPELINE_GREY}
|
||||
width="14px"
|
||||
icon={Icons.EDIT_OUTLINE_PRIMARY}
|
||||
width="16px"
|
||||
/>
|
||||
<span data-testid="pipeline-name">{data.label}</span>
|
||||
</div>
|
||||
{data.isEditMode && (
|
||||
<button className="tw-cursor-pointer tw-flex tw-z-9999">
|
||||
<SVGIcons
|
||||
alt="times-circle"
|
||||
icon={Icons.EDIT_OUTLINE_PRIMARY}
|
||||
width="16px"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</body>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</foreignObject>
|
||||
) : (
|
||||
selected &&
|
||||
|
@ -105,8 +105,11 @@ const CustomNode = (props: NodeProps) => {
|
||||
columns,
|
||||
isNewNode,
|
||||
removeNodeHandler,
|
||||
handleColumnClick,
|
||||
isEditMode,
|
||||
isExpanded,
|
||||
isTraced,
|
||||
selectedColumns = [],
|
||||
} = data;
|
||||
|
||||
useEffect(() => {
|
||||
@ -117,7 +120,13 @@ const CustomNode = (props: NodeProps) => {
|
||||
<div className="nowheel">
|
||||
{/* Node label could be simple text or reactNode */}
|
||||
<div
|
||||
className="tw--mx-2 tw--my-0.5 tw-px-2 tw-bg-primary-lite tw-relative tw-border tw-border-primary-hover tw-rounded-md"
|
||||
className={classNames(
|
||||
'custom-node-header',
|
||||
selected || data.selected
|
||||
? 'custom-node-header-active'
|
||||
: 'custom-node-header-normal',
|
||||
{ 'custom-node-header-tracing': isTraced }
|
||||
)}
|
||||
data-testid="node-label">
|
||||
{getHandle(type, isConnectable, isNewNode)}
|
||||
{label}{' '}
|
||||
@ -130,27 +139,48 @@ const CustomNode = (props: NodeProps) => {
|
||||
|
||||
{isExpanded && (
|
||||
<div
|
||||
className={classNames('tw-bg-border-lite-60 tw-border', {
|
||||
'tw-py-3': !isEmpty(columns),
|
||||
})}>
|
||||
<section className={classNames('tw-px-3')} id="table-columns">
|
||||
<div className="tw-flex tw-flex-col tw-gap-y-1 tw-relative">
|
||||
className={classNames(
|
||||
'custom-node-column-lineage',
|
||||
selected || isTraced
|
||||
? 'custom-node-column-lineage-active'
|
||||
: 'custom-node-column-lineage-normal',
|
||||
{
|
||||
'p-y-sm': !isEmpty(columns),
|
||||
}
|
||||
)}>
|
||||
<section className="p-x-sm" id="table-columns">
|
||||
<div className="custom-node-column-lineage-body">
|
||||
{(Object.values(columns || {}) as ModifiedColumn[])?.map(
|
||||
(c, i) => (
|
||||
<div
|
||||
className="tw-p-1 tw-rounded tw-border tw-text-grey-body tw-relative tw-bg-white"
|
||||
data-testid="column"
|
||||
key={i}>
|
||||
{getHandle(
|
||||
c.type,
|
||||
isConnectable,
|
||||
isNewNode,
|
||||
c.fullyQualifiedName
|
||||
)}
|
||||
{getConstraintIcon(c.constraint, 'tw-')}
|
||||
<p className="tw-m-0">{c.name}</p>
|
||||
</div>
|
||||
)
|
||||
(column, index) => {
|
||||
const isColumnTraced = selectedColumns.includes(
|
||||
column.fullyQualifiedName
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'custom-node-column-container',
|
||||
isColumnTraced
|
||||
? 'custom-node-header-tracing'
|
||||
: 'custom-node-column-lineage-normal tw-bg-white'
|
||||
)}
|
||||
data-testid="column"
|
||||
key={index}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleColumnClick(column.fullyQualifiedName);
|
||||
}}>
|
||||
{getHandle(
|
||||
column.type,
|
||||
isConnectable,
|
||||
isNewNode,
|
||||
column.fullyQualifiedName
|
||||
)}
|
||||
{getConstraintIcon(column.constraint, 'tw-')}
|
||||
<p className="m-0">{column.name}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
@ -76,9 +76,13 @@ import {
|
||||
createNewEdge,
|
||||
dragHandle,
|
||||
findUpstreamDownStreamEdge,
|
||||
getAllTracedColumnEdge,
|
||||
getAllTracedNodes,
|
||||
getClassifiedEdge,
|
||||
getColumnType,
|
||||
getDataLabel,
|
||||
getDeletedLineagePlaceholder,
|
||||
getEdgeStyle,
|
||||
getEdgeType,
|
||||
getLayoutedElements,
|
||||
getLineageData,
|
||||
@ -93,6 +97,8 @@ import {
|
||||
getUpdatedEdgeWithPipeline,
|
||||
getUpdatedUpstreamDownStreamEdgeArr,
|
||||
getUpStreamDownStreamColumnLineageArr,
|
||||
isColumnLineageTraced,
|
||||
isTracedEdge,
|
||||
onLoad,
|
||||
onNodeContextMenu,
|
||||
onNodeMouseEnter,
|
||||
@ -122,6 +128,8 @@ import {
|
||||
import EntityLineageSidebar from './EntityLineageSidebar.component';
|
||||
import NodeSuggestions from './NodeSuggestions.component';
|
||||
|
||||
import './entityLineage.style.less';
|
||||
|
||||
const EntityLineageComponent: FunctionComponent<EntityLineageProp> = ({
|
||||
entityLineage,
|
||||
loadNodeHandler,
|
||||
@ -171,6 +179,7 @@ const EntityLineageComponent: FunctionComponent<EntityLineageProp> = ({
|
||||
const [selectedPipelineId, setSelectedPipelineId] = useState<
|
||||
string | undefined
|
||||
>();
|
||||
const [isTracingActive, setIsTracingActive] = useState(false);
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
||||
@ -217,7 +226,7 @@ const EntityLineageComponent: FunctionComponent<EntityLineageProp> = ({
|
||||
*/
|
||||
const getNodeLabel = (node: EntityReference) => {
|
||||
return (
|
||||
<p className="tw-flex tw-m-0 tw-py-3">
|
||||
<p className="tw-flex tw-items-center tw-m-0 tw-py-3">
|
||||
<span className="tw-mr-2">{getEntityIcon(node.type)}</span>
|
||||
{getDataLabel(
|
||||
node.displayName,
|
||||
@ -229,6 +238,26 @@ const EntityLineageComponent: FunctionComponent<EntityLineageProp> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const handleNodeSelection = (node: Node) => {
|
||||
const selectedNode = [
|
||||
...(updatedLineageData.nodes || []),
|
||||
updatedLineageData.entity,
|
||||
].find((n) => node.id.includes(n.id));
|
||||
|
||||
if (!expandButton.current) {
|
||||
selectNodeHandler(true, {
|
||||
name: selectedNode?.name as string,
|
||||
fqn: selectedNode?.fullyQualifiedName as string,
|
||||
id: node.id,
|
||||
displayName: selectedNode?.displayName,
|
||||
type: selectedNode?.type as string,
|
||||
entityId: selectedNode?.id as string,
|
||||
});
|
||||
} else {
|
||||
expandButton.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param data selected edge
|
||||
@ -243,10 +272,10 @@ const EntityLineageComponent: FunctionComponent<EntityLineageProp> = ({
|
||||
toId: data.target.id,
|
||||
};
|
||||
removeLineageHandler(edgeData);
|
||||
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
||||
setEdges((es) => {
|
||||
return es.filter(
|
||||
(e) => e.source !== data.source.id && e.target !== data.target.id
|
||||
setEdges((prevEdges) => {
|
||||
return prevEdges.filter(
|
||||
(edge) =>
|
||||
edge.source !== data.source.id && edge.target !== data.target.id
|
||||
);
|
||||
});
|
||||
const newDownStreamEdges = getSelectedEdgeArr(
|
||||
@ -340,6 +369,47 @@ const EntityLineageComponent: FunctionComponent<EntityLineageProp> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleColumnClick = (column: string) => {
|
||||
const { columnEdge } = getClassifiedEdge(edges);
|
||||
const { incomingColumnEdges, outGoingColumnEdges, connectedColumnEdges } =
|
||||
getAllTracedColumnEdge(column, columnEdge);
|
||||
|
||||
setNodes((prevNodes) => {
|
||||
return prevNodes.map((prevNode) => {
|
||||
const nodeTraced = prevNode.data.columns[column];
|
||||
prevNode.data = {
|
||||
...prevNode.data,
|
||||
selected: !isUndefined(nodeTraced),
|
||||
isTraced: !isUndefined(nodeTraced),
|
||||
selectedColumns: connectedColumnEdges,
|
||||
};
|
||||
if (!isUndefined(nodeTraced)) {
|
||||
handleNodeSelection(prevNode);
|
||||
}
|
||||
|
||||
return prevNode;
|
||||
});
|
||||
});
|
||||
setIsTracingActive(true);
|
||||
|
||||
setEdges((prevEdges) => {
|
||||
return prevEdges.map((edge) => {
|
||||
const isTraced = isColumnLineageTraced(
|
||||
column,
|
||||
edge,
|
||||
incomingColumnEdges,
|
||||
outGoingColumnEdges
|
||||
);
|
||||
edge.style = {
|
||||
...edge.style,
|
||||
...getEdgeStyle(isTraced),
|
||||
};
|
||||
|
||||
return edge;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* take edge data and set it as selected edge
|
||||
* @param evt
|
||||
@ -442,7 +512,8 @@ const EntityLineageComponent: FunctionComponent<EntityLineageProp> = ({
|
||||
removeNodeHandler,
|
||||
tableColumnsRef.current,
|
||||
currentData,
|
||||
addPipelineClick
|
||||
addPipelineClick,
|
||||
handleColumnClick
|
||||
) as CustomElement;
|
||||
|
||||
const uniqueElements: CustomElement = {
|
||||
@ -792,33 +863,103 @@ const EntityLineageComponent: FunctionComponent<EntityLineageProp> = ({
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* take element and perform onClick logic
|
||||
* @param el
|
||||
*/
|
||||
const onNodeClick = (el: Node) => {
|
||||
if (isNode(el)) {
|
||||
const node = [
|
||||
...(updatedLineageData.nodes as Array<EntityReference>),
|
||||
updatedLineageData.entity,
|
||||
].find((n) => el.id.includes(n.id));
|
||||
const handleLineageTracing = (selectedNode: Node) => {
|
||||
const { normalEdge } = getClassifiedEdge(edges);
|
||||
const incomingNode = getAllTracedNodes(
|
||||
selectedNode,
|
||||
nodes,
|
||||
normalEdge,
|
||||
[],
|
||||
true
|
||||
);
|
||||
const outgoingNode = getAllTracedNodes(
|
||||
selectedNode,
|
||||
nodes,
|
||||
normalEdge,
|
||||
[],
|
||||
false
|
||||
);
|
||||
const incomerIds = incomingNode.map((incomer) => incomer.id);
|
||||
const outgoerIds = outgoingNode.map((outGoer) => outGoer.id);
|
||||
setIsTracingActive(true);
|
||||
|
||||
if (!expandButton.current) {
|
||||
selectNodeHandler(true, {
|
||||
name: node?.name as string,
|
||||
fqn: node?.fullyQualifiedName as string,
|
||||
id: el.id,
|
||||
displayName: node?.displayName,
|
||||
type: node?.type as string,
|
||||
entityId: node?.id as string,
|
||||
});
|
||||
} else {
|
||||
expandButton.current = null;
|
||||
}
|
||||
}
|
||||
setEdges((prevEdges) => {
|
||||
return prevEdges.map((edge) => {
|
||||
const isStrokeNeeded = isTracedEdge(
|
||||
selectedNode,
|
||||
edge,
|
||||
incomerIds,
|
||||
outgoerIds
|
||||
);
|
||||
edge.style = {
|
||||
...edge.style,
|
||||
...getEdgeStyle(isStrokeNeeded),
|
||||
};
|
||||
|
||||
return edge;
|
||||
});
|
||||
});
|
||||
|
||||
setNodes((prevNodes) => {
|
||||
return prevNodes.map((prevNode) => {
|
||||
const highlight =
|
||||
prevNode.id === selectedNode.id ||
|
||||
incomerIds.includes(prevNode.id) ||
|
||||
outgoerIds.includes(prevNode.id);
|
||||
|
||||
prevNode.data = {
|
||||
...prevNode.data,
|
||||
isTraced: highlight,
|
||||
selected: false,
|
||||
selectedColumns: [],
|
||||
};
|
||||
|
||||
return prevNode;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// ToDo: remove below code once design flow finalized for column expand and colaps
|
||||
/**
|
||||
* take element and perform onClick logic
|
||||
* @param node
|
||||
*/
|
||||
const onNodeClick = (node: Node) => {
|
||||
if (isNode(node)) {
|
||||
handleLineageTracing(node);
|
||||
handleNodeSelection(node);
|
||||
}
|
||||
};
|
||||
const onPaneClick = () => {
|
||||
if (isTracingActive) {
|
||||
setEdges((prevEdges) => {
|
||||
return prevEdges.map((edge) => {
|
||||
edge.style = {
|
||||
...edge.style,
|
||||
opacity: undefined,
|
||||
stroke: undefined,
|
||||
strokeWidth: undefined,
|
||||
};
|
||||
|
||||
return edge;
|
||||
});
|
||||
});
|
||||
|
||||
setNodes((prevNodes) => {
|
||||
return prevNodes.map((prevNode) => {
|
||||
prevNode.data = {
|
||||
...prevNode.data,
|
||||
isTraced: false,
|
||||
selectedColumns: [],
|
||||
selected: false,
|
||||
};
|
||||
|
||||
return prevNode;
|
||||
});
|
||||
});
|
||||
setIsTracingActive(false);
|
||||
setIsDrawerOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateColumnsToNode = (columns: Column[], id: string) => {
|
||||
setNodes((prevNodes) => {
|
||||
@ -1014,11 +1155,18 @@ const EntityLineageComponent: FunctionComponent<EntityLineageProp> = ({
|
||||
const toggleColumnView = (value: boolean) => {
|
||||
setExpandAllColumns(value);
|
||||
setNodes((prevNodes) => {
|
||||
return prevNodes.map((node) => {
|
||||
const updatedNode = prevNodes.map((node) => {
|
||||
node.data.isExpanded = value;
|
||||
|
||||
return node;
|
||||
});
|
||||
const { edge, node } = getLayoutedElements({
|
||||
node: updatedNode,
|
||||
edge: edges,
|
||||
});
|
||||
setEdges(edge);
|
||||
|
||||
return node;
|
||||
});
|
||||
};
|
||||
|
||||
@ -1155,6 +1303,7 @@ const EntityLineageComponent: FunctionComponent<EntityLineageProp> = ({
|
||||
<div className="w-full h-full" ref={reactFlowWrapper}>
|
||||
<ReactFlowProvider>
|
||||
<ReactFlow
|
||||
className="custom-react-flow"
|
||||
data-testid="react-flow-component"
|
||||
edgeTypes={customEdges}
|
||||
edges={edges}
|
||||
@ -1181,7 +1330,8 @@ const EntityLineageComponent: FunctionComponent<EntityLineageProp> = ({
|
||||
onNodeMouseEnter={onNodeMouseEnter}
|
||||
onNodeMouseLeave={onNodeMouseLeave}
|
||||
onNodeMouseMove={onNodeMouseMove}
|
||||
onNodesChange={onNodesChange}>
|
||||
onNodesChange={onNodesChange}
|
||||
onPaneClick={onPaneClick}>
|
||||
<CustomControlsComponent
|
||||
className="absolute top-1 right-1 bottom-full"
|
||||
deleted={deleted}
|
||||
@ -1205,7 +1355,7 @@ const EntityLineageComponent: FunctionComponent<EntityLineageProp> = ({
|
||||
</ReactFlow>
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
{(!isEmpty(selectedNode) || !isEditMode) && (
|
||||
{!isEmpty(selectedNode) && !isEditMode && (
|
||||
<EntityInfoDrawer
|
||||
isMainNode={selectedNode.name === updatedLineageData.entity?.name}
|
||||
selectedNode={selectedNode}
|
||||
|
@ -75,6 +75,9 @@ export interface CustomEdgeData {
|
||||
isColumnLineage: boolean;
|
||||
sourceHandle: string;
|
||||
targetHandle: string;
|
||||
selectedColumns?: string[];
|
||||
isTraced?: boolean;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
export interface SelectedEdge {
|
||||
|
@ -0,0 +1,74 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@white: #fff;
|
||||
@body-color: #f8f9fa;
|
||||
@gray: #d9ceee;
|
||||
@secondary-color: #b02aac;
|
||||
|
||||
.custom-react-flow {
|
||||
.react-flow__node-input.selectable.selected {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-node-header {
|
||||
margin: -2px -8px;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.24);
|
||||
position: relative;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.custom-node-header-normal {
|
||||
background-color: @body-color;
|
||||
border: 1px solid @gray;
|
||||
}
|
||||
.custom-node-header-tracing {
|
||||
background: rgba(176, 42, 172, 0.1);
|
||||
border: 1px solid @secondary-color;
|
||||
}
|
||||
.custom-node-header-active {
|
||||
background: @secondary-color;
|
||||
border: 1px solid @secondary-color;
|
||||
color: @white;
|
||||
}
|
||||
|
||||
.custom-node-column-lineage {
|
||||
background: @body-color;
|
||||
border: 1px solid;
|
||||
box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.16);
|
||||
border-radius: 4px;
|
||||
|
||||
.custom-node-column-lineage-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 10px;
|
||||
position: relative;
|
||||
|
||||
.custom-node-column-container {
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
}
|
||||
.custom-node-column-lineage-normal {
|
||||
border: 1px solid @gray;
|
||||
}
|
||||
.custom-node-column-lineage-active {
|
||||
border: 1px solid rgba(176, 42, 172, 0.6);
|
||||
}
|
@ -27,6 +27,7 @@ export const POSITION_Y = 60;
|
||||
|
||||
export const NODE_WIDTH = 600;
|
||||
export const NODE_HEIGHT = 70;
|
||||
export const EXPANDED_NODE_HEIGHT = 300;
|
||||
|
||||
export const ELEMENT_DELETE_STATE = {
|
||||
loading: false,
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { MarkerType, Position } from 'reactflow';
|
||||
|
||||
/* eslint-disable max-len */
|
||||
export const MOCK_LINEAGE_DATA = {
|
||||
entity: {
|
||||
@ -437,3 +439,133 @@ export const UPDATED_NORMAL_LINEAGE = {
|
||||
type: 'pipeline',
|
||||
},
|
||||
};
|
||||
|
||||
export const MOCK_NODES_AND_EDGES = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'a0f3199f-5fea-4c41-af43-bb66ef3c845e',
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
type: 'default',
|
||||
className: 'leaf-node core',
|
||||
data: {
|
||||
label: 'ecommerce_db.shopify.raw_product_catalog',
|
||||
isEditMode: false,
|
||||
columns: {
|
||||
'sample_data.ecommerce_db.shopify.raw_product_catalog.comments': {
|
||||
name: 'comments',
|
||||
dataType: 'STRING',
|
||||
dataLength: 1,
|
||||
dataTypeDisplay: 'string',
|
||||
fullyQualifiedName:
|
||||
'sample_data.ecommerce_db.shopify.raw_product_catalog.comments',
|
||||
constraint: 'NULL',
|
||||
ordinalPosition: 1,
|
||||
type: 'input',
|
||||
},
|
||||
},
|
||||
isExpanded: true,
|
||||
node: {
|
||||
id: 'a0f3199f-5fea-4c41-af43-bb66ef3c845e',
|
||||
type: 'table',
|
||||
name: 'raw_product_catalog',
|
||||
fullyQualifiedName:
|
||||
'sample_data.ecommerce_db.shopify.raw_product_catalog',
|
||||
description: 'description ',
|
||||
deleted: false,
|
||||
href: 'href',
|
||||
},
|
||||
},
|
||||
position: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'f52acb5f-2b2c-440c-91c1-90b46d138fad',
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
type: 'output',
|
||||
className: 'leaf-node',
|
||||
data: {
|
||||
label: 'ecommerce_db.shopify.dim_location',
|
||||
entityType: 'table',
|
||||
isEditMode: false,
|
||||
isExpanded: true,
|
||||
columns: {
|
||||
'sample_data.ecommerce_db.shopify.dim_location.location_id': {
|
||||
name: 'location_id',
|
||||
dataType: 'NUMERIC',
|
||||
dataTypeDisplay: 'numeric',
|
||||
fullyQualifiedName:
|
||||
'sample_data.ecommerce_db.shopify.dim_location.location_id',
|
||||
constraint: 'PRIMARY_KEY',
|
||||
ordinalPosition: 1,
|
||||
type: 'output',
|
||||
},
|
||||
},
|
||||
node: {
|
||||
id: 'f52acb5f-2b2c-440c-91c1-90b46d138fad',
|
||||
type: 'table',
|
||||
name: 'dim_location',
|
||||
fullyQualifiedName: 'sample_data.ecommerce_db.shopify.dim_location',
|
||||
description: 'description',
|
||||
deleted: false,
|
||||
href: 'href',
|
||||
},
|
||||
},
|
||||
position: {
|
||||
x: 650,
|
||||
y: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: 'column-sample_data.ecommerce_db.shopify.raw_product_catalog.comments-sample_data.ecommerce_db.shopify.dim_location.location_id-edge-a0f3199f-5fea-4c41-af43-bb66ef3c845e-f52acb5f-2b2c-440c-91c1-90b46d138fad',
|
||||
source: 'a0f3199f-5fea-4c41-af43-bb66ef3c845e',
|
||||
target: 'f52acb5f-2b2c-440c-91c1-90b46d138fad',
|
||||
targetHandle: 'sample_data.ecommerce_db.shopify.dim_location.location_id',
|
||||
sourceHandle:
|
||||
'sample_data.ecommerce_db.shopify.raw_product_catalog.comments',
|
||||
type: 'buttonedge',
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
},
|
||||
data: {
|
||||
id: 'column-sample_data.ecommerce_db.shopify.raw_product_catalog.comments-sample_data.ecommerce_db.shopify.dim_location.location_id-edge-a0f3199f-5fea-4c41-af43-bb66ef3c845e-f52acb5f-2b2c-440c-91c1-90b46d138fad',
|
||||
source: 'a0f3199f-5fea-4c41-af43-bb66ef3c845e',
|
||||
target: 'f52acb5f-2b2c-440c-91c1-90b46d138fad',
|
||||
targetHandle:
|
||||
'sample_data.ecommerce_db.shopify.dim_location.location_id',
|
||||
sourceHandle:
|
||||
'sample_data.ecommerce_db.shopify.raw_product_catalog.comments',
|
||||
isEditMode: false,
|
||||
isColumnLineage: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'edge-a0f3199f-5fea-4c41-af43-bb66ef3c845e-f52acb5f-2b2c-440c-91c1-90b46d138fad',
|
||||
source: 'a0f3199f-5fea-4c41-af43-bb66ef3c845e',
|
||||
target: 'f52acb5f-2b2c-440c-91c1-90b46d138fad',
|
||||
type: 'buttonedge',
|
||||
animated: false,
|
||||
style: {
|
||||
strokeWidth: '2px',
|
||||
},
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
},
|
||||
data: {
|
||||
id: 'edge-a0f3199f-5fea-4c41-af43-bb66ef3c845e-f52acb5f-2b2c-440c-91c1-90b46d138fad',
|
||||
label: '',
|
||||
source: 'a0f3199f-5fea-4c41-af43-bb66ef3c845e',
|
||||
target: 'f52acb5f-2b2c-440c-91c1-90b46d138fad',
|
||||
sourceType: 'table',
|
||||
targetType: 'table',
|
||||
isEditMode: false,
|
||||
isColumnLineage: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
@ -81,6 +81,9 @@
|
||||
.w-32 {
|
||||
width: 8rem;
|
||||
}
|
||||
.w-72 {
|
||||
width: 288px;
|
||||
}
|
||||
.w-48 {
|
||||
width: 12rem;
|
||||
}
|
||||
|
@ -25,6 +25,7 @@ import {
|
||||
EDGE_TO_BE_REMOVED,
|
||||
MOCK_COLUMN_LINEAGE_EDGE,
|
||||
MOCK_LINEAGE_DATA,
|
||||
MOCK_NODES_AND_EDGES,
|
||||
MOCK_NORMAL_LINEAGE_EDGE,
|
||||
MOCK_PARAMS_FOR_DOWN_STREAM,
|
||||
MOCK_PARAMS_FOR_UP_STREAM,
|
||||
@ -40,12 +41,18 @@ import {
|
||||
import {
|
||||
createNewEdge,
|
||||
findUpstreamDownStreamEdge,
|
||||
getAllTracedColumnEdge,
|
||||
getAllTracedEdges,
|
||||
getAllTracedNodes,
|
||||
getClassifiedEdge,
|
||||
getEdgeType,
|
||||
getRemovedNodeData,
|
||||
getUpdatedEdge,
|
||||
getUpdatedEdgeWithPipeline,
|
||||
getUpdatedUpstreamDownStreamEdgeArr,
|
||||
getUpStreamDownStreamColumnLineageArr,
|
||||
isColumnLineageTraced,
|
||||
isTracedEdge,
|
||||
} from './EntityLineageUtils';
|
||||
|
||||
describe('Test EntityLineageUtils utility', () => {
|
||||
@ -206,4 +213,90 @@ describe('Test EntityLineageUtils utility', () => {
|
||||
expect(lineageEdge).toMatchObject(updatedData);
|
||||
expect(noData).toMatchObject([]);
|
||||
});
|
||||
|
||||
it('getAllTracedNodes & isTracedEdge function should work properly', () => {
|
||||
const { nodes, edges } = MOCK_NODES_AND_EDGES;
|
||||
const incomerNode = getAllTracedNodes(nodes[1], nodes, edges, [], true);
|
||||
const outGoverNode = getAllTracedNodes(nodes[1], nodes, edges, [], false);
|
||||
const noData = getAllTracedNodes(nodes[0], [], [], [], true);
|
||||
|
||||
const incomerNodeId = incomerNode.map((node) => node.id);
|
||||
const outGoverNodeId = outGoverNode.map((node) => node.id);
|
||||
const isTracedTruthy = isTracedEdge(
|
||||
nodes[1],
|
||||
edges[1],
|
||||
incomerNodeId,
|
||||
outGoverNodeId
|
||||
);
|
||||
const isTracedFalsy = isTracedEdge(
|
||||
nodes[1],
|
||||
edges[0],
|
||||
incomerNodeId,
|
||||
outGoverNodeId
|
||||
);
|
||||
|
||||
expect(incomerNode).toStrictEqual([nodes[0]]);
|
||||
expect(outGoverNode).toStrictEqual([]);
|
||||
expect(isTracedTruthy).toBeTruthy();
|
||||
expect(isTracedFalsy).toBeFalsy();
|
||||
expect(noData).toMatchObject([]);
|
||||
});
|
||||
|
||||
it('getAllTracedEdges function should work properly', () => {
|
||||
const { edges } = MOCK_NODES_AND_EDGES;
|
||||
const selectedIncomerColumn =
|
||||
'sample_data.ecommerce_db.shopify.dim_location.location_id';
|
||||
const incomerNode = getAllTracedEdges(
|
||||
selectedIncomerColumn,
|
||||
edges,
|
||||
[],
|
||||
true
|
||||
);
|
||||
const noData = getAllTracedEdges(selectedIncomerColumn, [], [], true);
|
||||
|
||||
expect(incomerNode).toStrictEqual([
|
||||
'sample_data.ecommerce_db.shopify.raw_product_catalog.comments',
|
||||
]);
|
||||
expect(noData).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('getClassifiedEdge & getAllTracedColumnEdge function should work properly', () => {
|
||||
const { edges } = MOCK_NODES_AND_EDGES;
|
||||
const selectedColumn =
|
||||
'sample_data.ecommerce_db.shopify.dim_location.location_id';
|
||||
const classifiedEdges = getClassifiedEdge(edges);
|
||||
const allTracedEdges = getAllTracedColumnEdge(
|
||||
selectedColumn,
|
||||
classifiedEdges.columnEdge
|
||||
);
|
||||
const isColumnTracedTruthy = isColumnLineageTraced(
|
||||
selectedColumn,
|
||||
edges[0],
|
||||
allTracedEdges.incomingColumnEdges,
|
||||
allTracedEdges.outGoingColumnEdges
|
||||
);
|
||||
const isColumnTracedFalsy = isColumnLineageTraced(
|
||||
selectedColumn,
|
||||
edges[1],
|
||||
allTracedEdges.incomingColumnEdges,
|
||||
allTracedEdges.outGoingColumnEdges
|
||||
);
|
||||
|
||||
expect(classifiedEdges).toStrictEqual({
|
||||
normalEdge: [edges[1]],
|
||||
columnEdge: [edges[0]],
|
||||
});
|
||||
expect(allTracedEdges).toStrictEqual({
|
||||
incomingColumnEdges: [
|
||||
'sample_data.ecommerce_db.shopify.raw_product_catalog.comments',
|
||||
],
|
||||
outGoingColumnEdges: [],
|
||||
connectedColumnEdges: [
|
||||
'sample_data.ecommerce_db.shopify.dim_location.location_id',
|
||||
'sample_data.ecommerce_db.shopify.raw_product_catalog.comments',
|
||||
],
|
||||
});
|
||||
expect(isColumnTracedTruthy).toBeTruthy();
|
||||
expect(isColumnTracedFalsy).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
@ -24,6 +24,7 @@ import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Connection,
|
||||
Edge,
|
||||
isNode,
|
||||
MarkerType,
|
||||
Node,
|
||||
Position,
|
||||
@ -41,7 +42,9 @@ import {
|
||||
} from '../components/EntityLineage/EntityLineage.interface';
|
||||
import Loader from '../components/Loader/Loader';
|
||||
import { FQN_SEPARATOR_CHAR } from '../constants/char.constants';
|
||||
import { SECONDARY_COLOR } from '../constants/constants';
|
||||
import {
|
||||
EXPANDED_NODE_HEIGHT,
|
||||
NODE_HEIGHT,
|
||||
NODE_WIDTH,
|
||||
ZOOM_VALUE,
|
||||
@ -177,7 +180,8 @@ export const getLineageData = (
|
||||
addPipelineClick?: (
|
||||
evt: React.MouseEvent<HTMLButtonElement>,
|
||||
data: CustomEdgeData
|
||||
) => void
|
||||
) => void,
|
||||
handleColumnClick?: (value: string) => void
|
||||
) => {
|
||||
const [x, y] = [0, 0];
|
||||
const nodes = [...(entityLineage['nodes'] || []), entityLineage['entity']];
|
||||
@ -328,6 +332,8 @@ export const getLineageData = (
|
||||
isEditMode,
|
||||
isExpanded: currentNode?.data?.isExpanded || false,
|
||||
columns: cols,
|
||||
handleColumnClick,
|
||||
node,
|
||||
},
|
||||
position: {
|
||||
x: x,
|
||||
@ -354,13 +360,15 @@ export const getLineageData = (
|
||||
sourcePosition: 'right',
|
||||
targetPosition: 'left',
|
||||
type: getNodeType(entityLineage, mainNode.id),
|
||||
className: `leaf-node ${!isEditMode ? 'core' : ''}`,
|
||||
className: `leaf-node core`,
|
||||
data: {
|
||||
label: getNodeLabel(mainNode),
|
||||
isEditMode,
|
||||
removeNodeHandler,
|
||||
handleColumnClick,
|
||||
columns: mainCols,
|
||||
isExpanded: currentNode || false,
|
||||
node: mainNode,
|
||||
},
|
||||
position: { x: x, y: y },
|
||||
},
|
||||
@ -392,7 +400,7 @@ export const getDataLabel = (
|
||||
} else {
|
||||
return (
|
||||
<span
|
||||
className="tw-break-words tw-self-center tw-w-60"
|
||||
className="tw-break-words tw-self-center w-72"
|
||||
data-testid="lineage-entity">
|
||||
{type === 'table'
|
||||
? databaseName && schemaName
|
||||
@ -443,9 +451,10 @@ export const getLayoutedElements = (
|
||||
dagreGraph.setGraph({ rankdir: direction });
|
||||
|
||||
node.forEach((el) => {
|
||||
const isExpanded = el.data.isExpanded;
|
||||
dagreGraph.setNode(el.id, {
|
||||
width: NODE_WIDTH,
|
||||
height: NODE_HEIGHT,
|
||||
height: isExpanded ? EXPANDED_NODE_HEIGHT : NODE_HEIGHT,
|
||||
});
|
||||
});
|
||||
|
||||
@ -456,12 +465,14 @@ export const getLayoutedElements = (
|
||||
dagre.layout(dagreGraph);
|
||||
|
||||
const uNode = node.map((el) => {
|
||||
const isExpanded = el.data.isExpanded;
|
||||
const nodeHight = isExpanded ? EXPANDED_NODE_HEIGHT : NODE_HEIGHT;
|
||||
const nodeWithPosition = dagreGraph.node(el.id);
|
||||
el.targetPosition = isHorizontal ? Position.Left : Position.Top;
|
||||
el.sourcePosition = isHorizontal ? Position.Right : Position.Bottom;
|
||||
el.position = {
|
||||
x: nodeWithPosition.x - NODE_WIDTH / 2,
|
||||
y: nodeWithPosition.y - NODE_HEIGHT / 2,
|
||||
y: nodeWithPosition.y - nodeHight / 2,
|
||||
};
|
||||
|
||||
return el;
|
||||
@ -897,3 +908,197 @@ export const getLoadingStatusValue = (
|
||||
return defaultState;
|
||||
}
|
||||
};
|
||||
|
||||
const getTracedNode = (
|
||||
node: Node,
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
isIncomer: boolean
|
||||
) => {
|
||||
if (!isNode(node)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const tracedEdgeIds = edges
|
||||
.filter((e) => {
|
||||
const id = isIncomer ? e.target : e.source;
|
||||
|
||||
return id === node.id;
|
||||
})
|
||||
.map((e) => (isIncomer ? e.source : e.target));
|
||||
|
||||
return nodes.filter((n) =>
|
||||
tracedEdgeIds
|
||||
.map((id) => {
|
||||
const matches = /([\w-^]+)__([\w-]+)/.exec(id);
|
||||
if (matches === null) {
|
||||
return id;
|
||||
}
|
||||
|
||||
return matches[1];
|
||||
})
|
||||
.includes(n.id)
|
||||
);
|
||||
};
|
||||
|
||||
export const getAllTracedNodes = (
|
||||
node: Node,
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
prevTraced = [] as Node[],
|
||||
isIncomer: boolean
|
||||
) => {
|
||||
const tracedNodes = getTracedNode(node, nodes, edges, isIncomer);
|
||||
|
||||
return tracedNodes.reduce((memo, tracedNode) => {
|
||||
memo.push(tracedNode);
|
||||
|
||||
if (prevTraced.findIndex((n) => n.id == tracedNode.id) === -1) {
|
||||
prevTraced.push(tracedNode);
|
||||
|
||||
getAllTracedNodes(
|
||||
tracedNode,
|
||||
nodes,
|
||||
edges,
|
||||
prevTraced,
|
||||
isIncomer
|
||||
).forEach((foundNode) => {
|
||||
memo.push(foundNode);
|
||||
|
||||
if (prevTraced.findIndex((n) => n.id == foundNode.id) === -1) {
|
||||
prevTraced.push(foundNode);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return memo;
|
||||
}, [] as Node[]);
|
||||
};
|
||||
|
||||
export const getClassifiedEdge = (edges: Edge[]) => {
|
||||
return edges.reduce(
|
||||
(acc, edge) => {
|
||||
if (isUndefined(edge.sourceHandle) && isUndefined(edge.targetHandle)) {
|
||||
acc.normalEdge.push(edge);
|
||||
} else {
|
||||
acc.columnEdge.push(edge);
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
normalEdge: [] as Edge[],
|
||||
columnEdge: [] as Edge[],
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const isTracedEdge = (
|
||||
selectedNode: Node,
|
||||
edge: Edge,
|
||||
incomerIds: string[],
|
||||
outgoerIds: string[]
|
||||
) => {
|
||||
const incomerEdges =
|
||||
incomerIds.includes(edge.source) &&
|
||||
(incomerIds.includes(edge.target) || selectedNode.id === edge.target);
|
||||
const outgoersEdges =
|
||||
outgoerIds.includes(edge.target) &&
|
||||
(outgoerIds.includes(edge.source) || selectedNode.id === edge.source);
|
||||
|
||||
return (
|
||||
(incomerEdges || outgoersEdges) &&
|
||||
isUndefined(edge.sourceHandle) &&
|
||||
isUndefined(edge.targetHandle)
|
||||
);
|
||||
};
|
||||
|
||||
const getTracedEdge = (
|
||||
selectedColumn: string,
|
||||
edges: Edge[],
|
||||
isIncomer: boolean
|
||||
) => {
|
||||
if (isEmpty(selectedColumn)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const tracedEdgeIds = edges
|
||||
.filter((e) => {
|
||||
const id = isIncomer ? e.targetHandle : e.sourceHandle;
|
||||
|
||||
return id === selectedColumn;
|
||||
})
|
||||
.map((e) => (isIncomer ? `${e.sourceHandle}` : `${e.targetHandle}`));
|
||||
|
||||
return tracedEdgeIds;
|
||||
};
|
||||
|
||||
export const getAllTracedEdges = (
|
||||
selectedColumn: string,
|
||||
edges: Edge[],
|
||||
prevTraced = [] as string[],
|
||||
isIncomer: boolean
|
||||
) => {
|
||||
const tracedNodes = getTracedEdge(selectedColumn, edges, isIncomer);
|
||||
|
||||
return tracedNodes.reduce((memo, tracedNode) => {
|
||||
memo.push(tracedNode);
|
||||
|
||||
if (prevTraced.findIndex((n) => n == tracedNode) === -1) {
|
||||
prevTraced.push(tracedNode);
|
||||
|
||||
getAllTracedEdges(tracedNode, edges, prevTraced, isIncomer).forEach(
|
||||
(foundNode) => {
|
||||
memo.push(foundNode);
|
||||
|
||||
if (prevTraced.findIndex((n) => n == foundNode) === -1) {
|
||||
prevTraced.push(foundNode);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return memo;
|
||||
}, [] as string[]);
|
||||
};
|
||||
|
||||
export const getAllTracedColumnEdge = (column: string, columnEdge: Edge[]) => {
|
||||
const incomingColumnEdges = getAllTracedEdges(column, columnEdge, [], true);
|
||||
const outGoingColumnEdges = getAllTracedEdges(column, columnEdge, [], false);
|
||||
|
||||
return {
|
||||
incomingColumnEdges,
|
||||
outGoingColumnEdges,
|
||||
connectedColumnEdges: [
|
||||
column,
|
||||
...incomingColumnEdges,
|
||||
...outGoingColumnEdges,
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
export const isColumnLineageTraced = (
|
||||
column: string,
|
||||
edge: Edge,
|
||||
incomingColumnEdges: string[],
|
||||
outGoingColumnEdges: string[]
|
||||
) => {
|
||||
const incomerEdges =
|
||||
incomingColumnEdges.includes(`${edge.sourceHandle}`) &&
|
||||
(incomingColumnEdges.includes(`${edge.targetHandle}`) ||
|
||||
column === edge.targetHandle);
|
||||
const outgoersEdges =
|
||||
outGoingColumnEdges.includes(`${edge.targetHandle}`) &&
|
||||
(outGoingColumnEdges.includes(`${edge.sourceHandle}`) ||
|
||||
column === edge.sourceHandle);
|
||||
|
||||
return incomerEdges || outgoersEdges;
|
||||
};
|
||||
|
||||
export const getEdgeStyle = (value: boolean) => {
|
||||
return {
|
||||
opacity: value ? 1 : 0.25,
|
||||
strokeWidth: value ? 2 : 1,
|
||||
stroke: value ? SECONDARY_COLOR : undefined,
|
||||
};
|
||||
};
|
||||
|
@ -15,6 +15,11 @@ import classNames from 'classnames';
|
||||
import { upperCase } from 'lodash';
|
||||
import { EntityTags } from 'Models';
|
||||
import React from 'react';
|
||||
import { ReactComponent as DashboardIcon } from '../assets/svg/dashboard-grey.svg';
|
||||
import { ReactComponent as MlModelIcon } from '../assets/svg/mlmodal.svg';
|
||||
import { ReactComponent as PipelineIcon } from '../assets/svg/pipeline-grey.svg';
|
||||
import { ReactComponent as TableIcon } from '../assets/svg/table-grey.svg';
|
||||
import { ReactComponent as TopicIcon } from '../assets/svg/topic-grey.svg';
|
||||
import PopOver from '../components/common/popover/PopOver';
|
||||
import { FQN_SEPARATOR_CHAR } from '../constants/char.constants';
|
||||
import {
|
||||
@ -226,38 +231,28 @@ export const getEntityLink = (
|
||||
};
|
||||
|
||||
export const getEntityIcon = (indexType: string) => {
|
||||
let icon = '';
|
||||
switch (indexType) {
|
||||
case SearchIndex.TOPIC:
|
||||
case EntityType.TOPIC:
|
||||
icon = 'topic-grey';
|
||||
|
||||
break;
|
||||
return <TopicIcon />;
|
||||
|
||||
case SearchIndex.DASHBOARD:
|
||||
case EntityType.DASHBOARD:
|
||||
icon = 'dashboard-grey';
|
||||
return <DashboardIcon />;
|
||||
|
||||
break;
|
||||
case SearchIndex.MLMODEL:
|
||||
case EntityType.MLMODEL:
|
||||
icon = 'mlmodel-grey';
|
||||
return <MlModelIcon />;
|
||||
|
||||
break;
|
||||
case SearchIndex.PIPELINE:
|
||||
case EntityType.PIPELINE:
|
||||
icon = 'pipeline-grey';
|
||||
return <PipelineIcon />;
|
||||
|
||||
break;
|
||||
case SearchIndex.TABLE:
|
||||
case EntityType.TABLE:
|
||||
default:
|
||||
icon = 'table-grey';
|
||||
|
||||
break;
|
||||
return <TableIcon />;
|
||||
}
|
||||
|
||||
return <SVGIcons alt={icon} icon={icon} width="14" />;
|
||||
};
|
||||
|
||||
export const makeRow = (column: Column) => {
|
||||
|
Loading…
x
Reference in New Issue
Block a user