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:
Shailesh Parmar 2022-11-07 17:00:51 +05:30 committed by GitHub
parent 8d15985117
commit 5fee438b87
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 777 additions and 92 deletions

View File

@ -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 &&

View File

@ -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>

View File

@ -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}

View File

@ -75,6 +75,9 @@ export interface CustomEdgeData {
isColumnLineage: boolean;
sourceHandle: string;
targetHandle: string;
selectedColumns?: string[];
isTraced?: boolean;
selected?: boolean;
}
export interface SelectedEdge {

View File

@ -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);
}

View File

@ -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,

View File

@ -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,
},
},
],
};

View File

@ -81,6 +81,9 @@
.w-32 {
width: 8rem;
}
.w-72 {
width: 288px;
}
.w-48 {
width: 12rem;
}

View File

@ -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();
});
});

View File

@ -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,
};
};

View File

@ -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) => {