Fix : UI manual Lineage Editor Issues (#4532)

* Fix : UI manual Lineage Editor Issues

* Keep node if only edge is deleted.

* Adding hidden handler

* Add invisible handle on custom node

* Fix funtion name typo

* Fix node overlapping issue

* Fix #3508 Manual Lineage Editor: Do not reorganize the graph as the user is connecting the nodes

* Fix code smell

* Minor Fix

* Styling fix

* Fix Flaky state issue

* Refactor onConnect Method

* Fix duplicate edge and node issue

* Fix Failing Unit test

* Fix confirmation modal source and target node name issue

* Add check for isNode in Element Click Handler

* Add makeEdge Helper

* Add JSDoc for helper methods

* Remove onElementsRemove prop

* Refactor node remove button

* Move util method to util file

* Allow users to delete edge and node separately

* Add unit test

* Fix Node Styling

* Minor Fix

* Add invisble edges
This commit is contained in:
Sachin Chaurasiya 2022-05-20 10:08:31 +05:30 committed by GitHub
parent 8511f9f0b2
commit 241df76cae
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 767 additions and 429 deletions

View File

@ -11,7 +11,12 @@
* limitations under the License.
*/
import { findByTestId, queryByTestId, render } from '@testing-library/react';
import {
findAllByTestId,
findByTestId,
queryByTestId,
render,
} from '@testing-library/react';
import React from 'react';
import { ArrowHeadType, EdgeProps, Position } from 'react-flow-renderer';
import { MemoryRouter } from 'react-router-dom';
@ -40,6 +45,7 @@ const mockCustomEdgeProp = {
id: 'node1',
},
},
selected: true,
} as EdgeProps;
describe('Test CustomEdge Component', () => {
@ -49,27 +55,24 @@ describe('Test CustomEdge Component', () => {
});
const deleteButton = await findByTestId(container, 'delete-button');
const edgePathElement = await findByTestId(
const edgePathElement = await findAllByTestId(
container,
'react-flow-edge-path'
);
expect(deleteButton).toBeInTheDocument();
expect(edgePathElement).toBeInTheDocument();
expect(edgePathElement).toHaveLength(edgePathElement.length);
});
it('Check if CustomEdge has selectedNode as empty object', async () => {
it('Check if CustomEdge has selected as false', async () => {
const { container } = render(
<CustomEdge
{...mockCustomEdgeProp}
data={{ ...mockCustomEdgeProp.data, selectedNode: {} }}
/>,
<CustomEdge {...mockCustomEdgeProp} selected={false} />,
{
wrapper: MemoryRouter,
}
);
const edgePathElement = await findByTestId(
const edgePathElement = await findAllByTestId(
container,
'react-flow-edge-path'
);
@ -77,6 +80,6 @@ describe('Test CustomEdge Component', () => {
const deleteButton = queryByTestId(container, 'delete-button');
expect(deleteButton).not.toBeInTheDocument();
expect(edgePathElement).toBeInTheDocument();
expect(edgePathElement).toHaveLength(edgePathElement.length);
});
});

View File

@ -34,8 +34,10 @@ export const CustomEdge = ({
arrowHeadType,
markerEndId,
data,
selected,
}: EdgeProps) => {
const { onEdgeClick, selectedNode, ...rest } = data;
const { onEdgeClick, ...rest } = data;
const offset = 4;
const edgePath = getBezierPath({
sourceX,
@ -45,6 +47,22 @@ export const CustomEdge = ({
targetY,
targetPosition,
});
const invisibleEdgePath = getBezierPath({
sourceX: sourceX + offset,
sourceY: sourceY + offset,
sourcePosition,
targetX: targetX + offset,
targetY: targetY + offset,
targetPosition,
});
const invisibleEdgePath1 = getBezierPath({
sourceX: sourceX - offset,
sourceY: sourceY - offset,
sourcePosition,
targetX: targetX - offset,
targetY: targetY - offset,
targetPosition,
});
const markerEnd = getMarkerEnd(arrowHeadType, markerEndId);
const [edgeCenterX, edgeCenterY] = getEdgeCenter({
sourceX,
@ -53,6 +71,19 @@ export const CustomEdge = ({
targetY,
});
const getInvisiblePath = (path: string) => {
return (
<path
className="react-flow__edge-path"
d={path}
data-testid="react-flow-edge-path"
id={id}
markerEnd={markerEnd}
style={{ ...style, strokeWidth: '6px', opacity: 0 }}
/>
);
};
return (
<Fragment>
<path
@ -63,15 +94,17 @@ export const CustomEdge = ({
markerEnd={markerEnd}
style={style}
/>
{(rest as CustomEdgeData)?.source?.includes(selectedNode?.id) ||
(rest as CustomEdgeData)?.target?.includes(selectedNode?.id) ? (
{getInvisiblePath(invisibleEdgePath)}
{getInvisiblePath(invisibleEdgePath1)}
{selected ? (
<foreignObject
data-testid="delete-button"
height={foreignObjectSize}
requiredExtensions="http://www.w3.org/1999/xhtml"
width={foreignObjectSize}
x={edgeCenterX - foreignObjectSize / 4}
y={edgeCenterY - foreignObjectSize / 4}>
x={edgeCenterX - foreignObjectSize / offset}
y={edgeCenterY - foreignObjectSize / offset}>
<button
className="tw-cursor-pointer tw-flex tw-z-9999"
onClick={(event) => onEdgeClick?.(event, rest as CustomEdgeData)}>

View File

@ -88,7 +88,9 @@ const mockTableColumns = [
];
const mockCustomNodeProp = {
id: 'node-1',
type: 'default',
selected: false,
isConnectable: false,
data: {
label: <p>label</p>,

View File

@ -13,7 +13,8 @@
import classNames from 'classnames';
import React, { CSSProperties, Fragment } from 'react';
import { Handle, HandleProps, Position } from 'react-flow-renderer';
import { Handle, HandleProps, NodeProps, Position } from 'react-flow-renderer';
import { getNodeRemoveButton } from '../../utils/EntityLineageUtils';
import { getConstraintIcon } from '../../utils/TableUtils';
const handleStyles = {
@ -29,23 +30,97 @@ const getHandle = (
isConnectable: HandleProps['isConnectable'],
isNewNode = false
) => {
const getLeftRightHandleStyles = () => {
return {
opacity: 0,
borderRadius: '0px',
height: '162%',
};
};
const getTopBottomHandleStyles = () => {
return {
opacity: 0,
borderRadius: '0px',
width: '110%',
};
};
if (nodeType === 'output') {
return (
<Fragment>
<Handle
isConnectable={isConnectable}
position={Position.Left}
style={{ ...handleStyles, left: '-14px' } as CSSProperties}
type="target"
/>
<Handle
isConnectable={isConnectable}
position={Position.Left}
style={{
...getLeftRightHandleStyles(),
marginLeft: '-10px',
}}
type="target"
/>
<Handle
isConnectable={isConnectable}
position={Position.Bottom}
style={{
...getTopBottomHandleStyles(),
marginBottom: '-6px',
}}
type="target"
/>
<Handle
isConnectable={isConnectable}
position={Position.Top}
style={{
...getTopBottomHandleStyles(),
marginTop: '-6px',
}}
type="target"
/>
</Fragment>
);
} else if (nodeType === 'input') {
return (
<Fragment>
<Handle
isConnectable={isConnectable}
position={Position.Right}
style={{ ...handleStyles, right: '-14px' } as CSSProperties}
type="source"
/>
<Handle
isConnectable={isConnectable}
position={Position.Right}
style={{
...getLeftRightHandleStyles(),
marginRight: '-10px',
}}
type="source"
/>
<Handle
isConnectable={isConnectable}
position={Position.Bottom}
style={{
...getTopBottomHandleStyles(),
marginBottom: '-6px',
}}
type="target"
/>
<Handle
isConnectable={isConnectable}
position={Position.Top}
style={{
...getTopBottomHandleStyles(),
marginTop: '-6px',
}}
type="target"
/>
</Fragment>
);
} else {
return (
@ -74,24 +149,63 @@ const getHandle = (
}
type="source"
/>
<Handle
isConnectable={isConnectable}
position={Position.Left}
style={{
...getLeftRightHandleStyles(),
marginLeft: '-10px',
}}
type="target"
/>
<Handle
isConnectable={isConnectable}
position={Position.Right}
style={{
...getLeftRightHandleStyles(),
marginRight: '-10px',
}}
type="source"
/>
<Handle
isConnectable={isConnectable}
position={Position.Bottom}
style={{
...getTopBottomHandleStyles(),
marginBottom: '-6px',
}}
type="target"
/>
<Handle
isConnectable={isConnectable}
position={Position.Top}
style={{
...getTopBottomHandleStyles(),
marginTop: '-6px',
}}
type="target"
/>
</Fragment>
);
}
};
const CustomNode = (props: NodeProps) => {
const { data, type, isConnectable, selected } = props;
/* eslint-disable-next-line */
const CustomNode = (props: any) => {
/* eslint-disable-next-line */
const { data, type, isConnectable } = props;
/* eslint-disable-next-line */
const { label, columns, isNewNode } = data;
const { label, columns, isNewNode, removeNodeHandler, isEditMode } = data;
return (
<div className="tw-relative nowheel ">
{getHandle(type, isConnectable, isNewNode)}
{/* Node label could be simple text or reactNode */}
<div className={classNames('tw-px-2')} data-testid="node-label">
{label}
{label}{' '}
{selected && isEditMode
? getNodeRemoveButton(() => {
removeNodeHandler?.(props);
})
: null}
</div>
{columns?.length ? (

View File

@ -20,7 +20,9 @@ import React, {
DragEvent,
Fragment,
FunctionComponent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
@ -33,12 +35,16 @@ import ReactFlow, {
Edge,
Elements,
FlowElement,
getConnectedEdges,
isEdge,
isNode,
Node,
OnLoadParams,
ReactFlowProvider,
removeElements,
} from 'react-flow-renderer';
import { useAuthContext } from '../../authentication/auth-provider/AuthProvider';
import { getTableDetails } from '../../axiosAPIs/tableAPI';
import { ELEMENT_DELETE_STATE } from '../../constants/Lineage.constants';
import { Column } from '../../generated/entity/data/table';
import { Operation } from '../../generated/entity/policies/accessControl/rule';
import {
@ -55,6 +61,8 @@ import {
getLayoutedElements,
getLineageData,
getModalBodyText,
getNodeRemoveButton,
getUniqueFlowElements,
onLoad,
onNodeContextMenu,
onNodeMouseEnter,
@ -75,6 +83,7 @@ import {
CustomEdgeData,
Edge as NewEdge,
EdgeData,
ElementLoadingState,
EntityLineageProp,
SelectedEdge,
SelectedNode,
@ -126,11 +135,27 @@ const Entitylineage: FunctionComponent<EntityLineageProp> = ({
const [status, setStatus] = useState<LoadingState>('initial');
const [deletionState, setDeletionState] = useState<{
loading: boolean;
status: Exclude<LoadingState, 'waiting'>;
}>({
loading: false,
status: 'initial',
});
status: ElementLoadingState;
}>(ELEMENT_DELETE_STATE);
/**
* this state will maintain the updated state and
* it will be sent back to parent when the user came out from edit mode to view mode
*/
const [updatedLineageData, setUpdatedLineageData] =
useState<EntityLineage>(entityLineage);
/**
* Custom Node Type Object
*/
const nodeTypes = useMemo(
() => ({
output: CustomNode,
input: CustomNode,
default: CustomNode,
}),
[]
);
/**
* take node as input and check if node is main entity or not
@ -139,7 +164,7 @@ const Entitylineage: FunctionComponent<EntityLineageProp> = ({
*/
const getNodeClass = (node: FlowElement) => {
return `${
node.id.includes(lineageData.entity?.id) && !isEditMode
node.id.includes(updatedLineageData.entity?.id) && !isEditMode
? 'leaf-node core'
: 'leaf-node'
}`;
@ -166,9 +191,9 @@ const Entitylineage: FunctionComponent<EntityLineageProp> = ({
/**
*
* @param node
* @returns lable for given node
* @returns label for given node
*/
const getNodeLable = (node: EntityReference) => {
const getNodeLabel = (node: EntityReference) => {
return (
<Fragment>
{node.type === 'table' && !isEditMode ? (
@ -217,14 +242,16 @@ const Entitylineage: FunctionComponent<EntityLineageProp> = ({
removeLineageHandler(edgeData);
// eslint-disable-next-line @typescript-eslint/no-use-before-define
setElements((es) => es.filter((e) => e.id !== data.id));
setElements((es) =>
getUniqueFlowElements(es.filter((e) => e.id !== data.id))
);
/**
* Get new downstreamEdges
*/
const newDownStreamEdges = lineageData.downstreamEdges?.filter(
const newDownStreamEdges = updatedLineageData.downstreamEdges?.filter(
(dn) =>
!lineageData.downstreamEdges?.find(
!updatedLineageData.downstreamEdges?.find(
() =>
edgeData.fromId === dn.fromEntity && edgeData.toId === dn.toEntity
)
@ -233,31 +260,22 @@ const Entitylineage: FunctionComponent<EntityLineageProp> = ({
/**
* Get new upstreamEdges
*/
const newUpStreamEdges = lineageData.upstreamEdges?.filter(
const newUpStreamEdges = updatedLineageData.upstreamEdges?.filter(
(up) =>
!lineageData.upstreamEdges?.find(
!updatedLineageData.upstreamEdges?.find(
() =>
edgeData.fromId === up.fromEntity && edgeData.toId === up.toEntity
)
);
/**
* Get new nodes that have either downstreamEdge or upstreamEdge
*/
const newNodes = lineageData.nodes?.filter(
(n) =>
!isUndefined(newDownStreamEdges?.find((d) => d.toEntity === n.id)) ||
!isUndefined(newUpStreamEdges?.find((u) => u.fromEntity === n.id))
);
setNewAddedNode({} as FlowElement);
setSelectedEntity({} as EntityReference);
entityLineageHandler({
...lineageData,
nodes: newNodes,
setUpdatedLineageData({
...updatedLineageData,
downstreamEdges: newDownStreamEdges,
upstreamEdges: newUpStreamEdges,
});
setConfirmDelete(false);
}
};
@ -273,22 +291,22 @@ const Entitylineage: FunctionComponent<EntityLineageProp> = ({
setShowDeleteModal(true);
evt.stopPropagation();
setSelectedEdge(() => {
let targetNode = lineageData.nodes?.find((n) =>
let targetNode = updatedLineageData.nodes?.find((n) =>
data.target?.includes(n.id)
);
let sourceNode = lineageData.nodes?.find((n) =>
let sourceNode = updatedLineageData.nodes?.find((n) =>
data.source?.includes(n.id)
);
if (isUndefined(targetNode)) {
targetNode = isEmpty(selectedEntity)
? lineageData.entity
? updatedLineageData.entity
: selectedEntity;
}
if (isUndefined(sourceNode)) {
sourceNode = isEmpty(selectedEntity)
? lineageData.entity
? updatedLineageData.entity
: selectedEntity;
}
@ -301,27 +319,23 @@ const Entitylineage: FunctionComponent<EntityLineageProp> = ({
* @returns unique flow elements
*/
const setElementsHandle = () => {
const flag: { [x: string]: boolean } = {};
const uniqueElements: Elements = [];
if (!isEmpty(lineageData)) {
let uniqueElements: Elements = [];
if (!isEmpty(updatedLineageData)) {
const graphElements = getLineageData(
lineageData,
updatedLineageData,
selectNodeHandler,
loadNodeHandler,
lineageLeafNodes,
isNodeLoading,
getNodeLable,
getNodeLabel,
isEditMode,
'buttonedge',
onEdgeClick
onEdgeClick,
// eslint-disable-next-line @typescript-eslint/no-use-before-define
removeNodeHandler
) as Elements;
graphElements.forEach((elem) => {
if (!flag[elem.id]) {
flag[elem.id] = true;
uniqueElements.push(elem);
}
});
uniqueElements = getUniqueFlowElements(graphElements);
}
return uniqueElements;
@ -352,41 +366,34 @@ const Entitylineage: FunctionComponent<EntityLineageProp> = ({
setSelectedNode({} as SelectedNode);
};
/**
* take list of elements to remove it from the graph
* @param elementsToRemove
* @returns updated elements list
*/
const onElementsRemove = (elementsToRemove: Elements) =>
setElements((els) => removeElements(elementsToRemove, els));
/**
* take edge or connection to add new element in the graph
* @param params
*/
const onConnect = (params: Edge | Connection) => {
const onConnect = useCallback(
(params: Edge | Connection) => {
setStatus('waiting');
setLoading(true);
const { target, source } = params;
const nodes = [
...(lineageData.nodes as EntityReference[]),
lineageData.entity,
...(updatedLineageData.nodes as EntityReference[]),
updatedLineageData.entity,
];
const sourceDownstreamNode = lineageData.downstreamEdges?.find((d) =>
source?.includes(d.toEntity as string)
const sourceDownstreamNode = updatedLineageData.downstreamEdges?.find(
(d) => source?.includes(d.toEntity as string)
);
const sourceUpStreamNode = lineageData.upstreamEdges?.find((u) =>
const sourceUpStreamNode = updatedLineageData.upstreamEdges?.find((u) =>
source?.includes(u.fromEntity as string)
);
const targetDownStreamNode = lineageData.downstreamEdges?.find((d) =>
target?.includes(d.toEntity as string)
const targetDownStreamNode = updatedLineageData.downstreamEdges?.find(
(d) => target?.includes(d.toEntity as string)
);
const targetUpStreamNode = lineageData.upstreamEdges?.find((u) =>
const targetUpStreamNode = updatedLineageData.upstreamEdges?.find((u) =>
target?.includes(u.fromEntity as string)
);
@ -395,13 +402,13 @@ const Entitylineage: FunctionComponent<EntityLineageProp> = ({
let sourceNode = nodes?.find((n) => source?.includes(n.id));
if (isUndefined(targetNode)) {
targetNode = target?.includes(lineageData.entity?.id)
? lineageData.entity
targetNode = target?.includes(updatedLineageData.entity?.id)
? updatedLineageData.entity
: selectedEntity;
}
if (isUndefined(sourceNode)) {
sourceNode = source?.includes(lineageData.entity?.id)
? lineageData.entity
sourceNode = source?.includes(updatedLineageData.entity?.id)
? updatedLineageData.entity
: selectedEntity;
}
@ -418,9 +425,64 @@ const Entitylineage: FunctionComponent<EntityLineageProp> = ({
},
};
setElements((els) =>
addEdge({ ...params, arrowHeadType: ArrowHeadType.ArrowClosed }, els)
);
setElements((els) => {
const newEdgeData = {
id: `edge-${params.source}-${params.target}`,
source: `${params.source}`,
target: `${params.target}`,
type: isEditMode ? 'buttonedge' : 'custom',
arrowHeadType: ArrowHeadType.ArrowClosed,
data: {
id: `edge-${params.source}-${params.target}`,
source: `${params.source}`,
target: `${params.target}`,
sourceType: sourceNode?.type,
targetType: targetNode?.type,
onEdgeClick,
},
};
return getUniqueFlowElements(addEdge(newEdgeData, els));
});
const updatedDownStreamEdges = () => {
return !isUndefined(sourceUpStreamNode) ||
!isUndefined(targetUpStreamNode) ||
targetNode?.id === selectedEntity.id ||
nodes?.find((n) => targetNode?.id === n.id)
? [
...(updatedLineageData.downstreamEdges as EntityEdge[]),
{
fromEntity: sourceNode?.id,
toEntity: targetNode?.id,
},
]
: updatedLineageData.downstreamEdges;
};
const updatedUpStreamEdges = () => {
return !isUndefined(sourceDownstreamNode) ||
!isUndefined(targetDownStreamNode) ||
sourceNode?.id === selectedEntity.id ||
nodes?.find((n) => sourceNode?.id === n.id)
? [
...(updatedLineageData.upstreamEdges as EntityEdge[]),
{
fromEntity: sourceNode?.id,
toEntity: targetNode?.id,
},
]
: updatedLineageData.upstreamEdges;
};
const getUpdatedNodes = () => {
return !isEmpty(selectedEntity)
? [
...(updatedLineageData.nodes as Array<EntityReference>),
selectedEntity,
]
: updatedLineageData.nodes;
};
setTimeout(() => {
addLineageHandler(newEdge)
@ -428,38 +490,11 @@ const Entitylineage: FunctionComponent<EntityLineageProp> = ({
setStatus('success');
setLoading(false);
setTimeout(() => {
entityLineageHandler({
...lineageData,
nodes: selectedEntity
? [
...(lineageData.nodes as Array<EntityReference>),
selectedEntity,
]
: lineageData.nodes,
downstreamEdges:
!isUndefined(sourceUpStreamNode) ||
!isUndefined(targetUpStreamNode) ||
targetNode?.id === selectedEntity.id
? [
...(lineageData.downstreamEdges as EntityEdge[]),
{
fromEntity: sourceNode?.id,
toEntity: targetNode?.id,
},
]
: lineageData.downstreamEdges,
upstreamEdges:
!isUndefined(sourceDownstreamNode) ||
!isUndefined(targetDownStreamNode) ||
sourceNode?.id === selectedEntity.id
? [
...(lineageData.upstreamEdges as EntityEdge[]),
{
fromEntity: sourceNode?.id,
toEntity: targetNode?.id,
},
]
: lineageData.upstreamEdges,
setUpdatedLineageData({
...updatedLineageData,
nodes: getUpdatedNodes(),
downstreamEdges: updatedDownStreamEdges(),
upstreamEdges: updatedUpStreamEdges(),
});
setStatus('initial');
}, 100);
@ -471,17 +506,21 @@ const Entitylineage: FunctionComponent<EntityLineageProp> = ({
setLoading(false);
});
}, 500);
};
},
[selectedNode, updatedLineageData, selectedEntity]
);
/**
* take element and perform onClick logic
* @param el
*/
const onElementClick = (el: FlowElement) => {
if (isNode(el)) {
const node = [
...(lineageData.nodes as Array<EntityReference>),
lineageData.entity,
...(updatedLineageData.nodes as Array<EntityReference>),
updatedLineageData.entity,
].find((n) => el.id.includes(n.id));
if (!expandButton.current) {
selectNodeHandler(true, {
name: node?.name as string,
@ -491,21 +530,10 @@ const Entitylineage: FunctionComponent<EntityLineageProp> = ({
type: node?.type as string,
entityId: node?.id as string,
});
setElements((prevElements) => {
return prevElements.map((preEl) => {
if (preEl.id === el.id) {
return { ...preEl, className: `${preEl.className} selected-node` };
} else {
return {
...preEl,
className: getNodeClass(preEl),
};
}
});
});
} else {
expandButton.current = null;
}
}
};
/**
@ -519,7 +547,6 @@ const Entitylineage: FunctionComponent<EntityLineageProp> = ({
if (preEl.id.includes(expandNode?.id as string)) {
return {
...preEl,
className: `${preEl.className} selected-node`,
data: { ...preEl.data, columns: tableColumns },
};
} else {
@ -560,10 +587,51 @@ const Entitylineage: FunctionComponent<EntityLineageProp> = ({
* take node and remove it from the graph
* @param node
*/
const removeNodeHandler = (node: FlowElement) => {
setElements((es) => es.filter((n) => n.id !== node.id));
const removeNodeHandler = useCallback(
(node: Node) => {
// Get all edges for the flow
const edges = elements.filter((element) => isEdge(element));
// Get edges connected to selected node
const edgesToRemove = getConnectedEdges([node], edges as Edge[]);
edgesToRemove.forEach((edge) => {
let targetNode = updatedLineageData.nodes?.find((n) =>
edge.target?.includes(n.id)
);
let sourceNode = updatedLineageData.nodes?.find((n) =>
edge.source?.includes(n.id)
);
if (isUndefined(targetNode)) {
targetNode = isEmpty(selectedEntity)
? updatedLineageData.entity
: selectedEntity;
}
if (isUndefined(sourceNode)) {
sourceNode = isEmpty(selectedEntity)
? updatedLineageData.entity
: selectedEntity;
}
removeEdgeHandler(
{
id: edge.id,
source: sourceNode,
target: targetNode,
},
true
);
});
setElements((es) =>
getUniqueFlowElements(es.filter((n) => n.id !== node.id))
);
setNewAddedNode({} as FlowElement);
};
},
[elements, updatedLineageData]
);
/**
* handle node drag event
@ -588,64 +656,64 @@ const Entitylineage: FunctionComponent<EntityLineageProp> = ({
x: event.clientX - (reactFlowBounds?.left ?? 0),
y: event.clientY - (reactFlowBounds?.top ?? 0),
});
const [lable, nodeType] = type.split('-');
const [label, nodeType] = type.split('-');
const newNode = {
id: uniqueId(),
nodeType,
position,
className: 'leaf-node',
connectable: false,
selectable: false,
data: {
label: (
<div className="tw-relative">
<button
className="tw-absolute tw--top-4 tw--right-6 tw-cursor-pointer tw-z-9999 tw-bg-body-hover tw-rounded-full"
onClick={() => {
removeNodeHandler(newNode as FlowElement);
}}>
<SVGIcons
alt="times-circle"
icon="icon-times-circle"
width="16px"
/>
</button>
{getNodeRemoveButton(() => {
removeNodeHandler(newNode as Node);
})}
<div className="tw-flex">
<SVGIcons
alt="entity-icon"
className="tw-mr-2"
icon={`${lowerCase(lable)}-grey`}
icon={`${lowerCase(label)}-grey`}
width="16px"
/>
<NodeSuggestions
entityType={upperCase(lable)}
entityType={upperCase(label)}
onSelectHandler={selectedEntityHandler}
/>
</div>
</div>
),
removeNodeHandler,
isEditMode,
isNewNode: true,
},
};
setNewAddedNode(newNode as FlowElement);
setElements((es) => es.concat(newNode as FlowElement));
setElements((es) =>
getUniqueFlowElements(es.concat(newNode as FlowElement))
);
}
};
/**
* handle onNode select logic
* After dropping node to graph user will search and select entity
* and this method will take care of changing node information based on selected entity.
*/
const onEntitySelect = () => {
if (!isEmpty(selectedEntity)) {
const isExistingNode = elements.some((n) =>
n.id.includes(selectedEntity.id)
);
const isExistingNode = elements.some((n) => n.id === selectedEntity.id);
if (isExistingNode) {
setElements((es) =>
es
.map((n) =>
n.id.includes(selectedEntity.id)
? { ...n, className: `${n.className} selected-node` }
? {
...n,
selectable: true,
className: `${n.className} selected`,
}
: n
)
.filter((es) => es.id !== newAddedNode.id)
@ -659,25 +727,21 @@ const Entitylineage: FunctionComponent<EntityLineageProp> = ({
return {
...el,
connectable: true,
selectable: true,
id: selectedEntity.id,
data: {
...el.data,
removeNodeHandler,
isEditMode,
label: (
<Fragment>
{getNodeLable(selectedEntity)}
<button
className="tw-absolute tw--top-5 tw--right-4 tw-cursor-pointer tw-z-9999 tw-bg-body-hover tw-rounded-full"
onClick={() => {
{getNodeLabel(selectedEntity)}
{getNodeRemoveButton(() => {
removeNodeHandler({
...el,
id: selectedEntity.id,
} as FlowElement);
}}>
<SVGIcons
alt="times-circle"
icon="icon-times-circle"
width="16px"
/>
</button>
} as Node);
})}
</Fragment>
),
},
@ -691,10 +755,13 @@ const Entitylineage: FunctionComponent<EntityLineageProp> = ({
}
};
/**
* This method will handle the delete edge modal confirmation
*/
const onRemove = () => {
setDeletionState({ loading: true, status: 'initial' });
setDeletionState({ ...ELEMENT_DELETE_STATE, loading: true });
setTimeout(() => {
setDeletionState({ loading: false, status: 'success' });
setDeletionState({ ...ELEMENT_DELETE_STATE, status: 'success' });
setTimeout(() => {
setShowDeleteModal(false);
setConfirmDelete(true);
@ -703,99 +770,21 @@ const Entitylineage: FunctionComponent<EntityLineageProp> = ({
}, 500);
};
useEffect(() => {
setElements(getLayoutedElements(setElementsHandle()));
setExpandNode(undefined);
setTableColumns([]);
setConfirmDelete(false);
}, [lineageData, isNodeLoading, isEditMode]);
useEffect(() => {
onNodeExpand();
getTableColumns(expandNode);
}, [expandNode]);
useEffect(() => {
if (!isEmpty(selectedNode)) {
setExpandNode(undefined);
}
setElements((pre) => {
return pre.map((el) => ({ ...el, data: { ...el.data, selectedNode } }));
});
}, [selectedNode]);
useEffect(() => {
if (tableColumns.length) {
onNodeExpand(tableColumns);
}
}, [tableColumns]);
useEffect(() => {
onEntitySelect();
}, [selectedEntity]);
useEffect(() => {
removeEdgeHandler(selectedEdge, confirmDelete);
}, [selectedEdge, confirmDelete]);
useEffect(() => {
if (!isEmpty(entityLineage)) {
setLineageData(entityLineage);
}
}, [entityLineage]);
/**
*
* @returns Custom control elements
*/
const getCustomControlElements = () => {
return (
<Fragment>
{!deleted ? (
<div
className={classNames(
'tw-relative tw-h-full tw--ml-4 tw--mr-7 tw--mt-4'
)}
data-testid="lineage-container">
<div className="tw-w-full tw-h-full" ref={reactFlowWrapper}>
<ReactFlowProvider>
<ReactFlow
data-testid="react-flow-component"
edgeTypes={{ buttonedge: CustomEdge }}
elements={elements as Elements}
elementsSelectable={!isEditMode}
maxZoom={2}
minZoom={0.5}
nodeTypes={{
output: CustomNode,
input: CustomNode,
default: CustomNode,
}}
nodesConnectable={isEditMode}
selectNodesOnDrag={false}
zoomOnDoubleClick={false}
zoomOnPinch={false}
zoomOnScroll={false}
onConnect={onConnect}
onDragOver={onDragOver}
onDrop={onDrop}
onElementClick={(_e, el) => onElementClick(el)}
onElementsRemove={onElementsRemove}
onLoad={(reactFlowInstance: OnLoadParams) => {
onLoad(reactFlowInstance);
setReactFlowInstance(reactFlowInstance);
}}
onNodeContextMenu={onNodeContextMenu}
onNodeDrag={dragHandle}
onNodeDragStart={dragHandle}
onNodeDragStop={dragHandle}
onNodeMouseEnter={onNodeMouseEnter}
onNodeMouseLeave={onNodeMouseLeave}
onNodeMouseMove={onNodeMouseMove}>
<CustomControls
className="tw-absolute tw-top-1 tw-right-3 tw-bottom-full tw-ml-4 tw-mt-4"
fitViewParams={{ minZoom: 0.5, maxZoom: 2.5 }}>
{!deleted && (
<NonAdminAction
html={
<>
<Fragment>
<p>You do not have permission to edit the lineage</p>
</>
</Fragment>
}
permission={Operation.UpdateLineage}>
<ControlButton
@ -821,10 +810,7 @@ const Entitylineage: FunctionComponent<EntityLineageProp> = ({
{loading ? (
<Loader size="small" type="white" />
) : status === 'success' ? (
<FontAwesomeIcon
className="tw-text-white"
icon="check"
/>
<FontAwesomeIcon className="tw-text-white" icon="check" />
) : (
<SVGIcons
alt="icon-edit-lineag"
@ -842,33 +828,51 @@ const Entitylineage: FunctionComponent<EntityLineageProp> = ({
</NonAdminAction>
)}
</CustomControls>
{isEditMode ? (
<Background
gap={12}
size={1}
variant={BackgroundVariant.Lines}
/>
) : null}
</ReactFlow>
</ReactFlowProvider>
</div>
{!isEmpty(selectedNode) && !isEditMode ? (
);
};
/**
*
* @returns Grid background if editmode is enabled otherwise null
*/
const getGraphBackGround = () => {
if (!isEditMode) {
return null;
} else {
return <Background gap={12} size={1} variant={BackgroundVariant.Lines} />;
}
};
/**
*
* @returns Side drawer if node is selected and view mode is enabled otherwise null
*/
const getEntityDrawer = () => {
if (isEmpty(selectedNode) || isEditMode) {
return null;
} else {
return (
<EntityInfoDrawer
isMainNode={selectedNode.name === lineageData.entity?.name}
isMainNode={selectedNode.name === updatedLineageData.entity?.name}
selectedNode={selectedNode}
show={isDrawerOpen && !isEditMode}
onCancel={closeDrawer}
/>
) : null}
<EntityLineageSidebar newAddedNode={newAddedNode} show={isEditMode} />
{showdeleteModal ? (
);
}
};
const getConfirmationModal = () => {
if (!showdeleteModal) {
return null;
} else {
return (
<ConfirmationModal
bodyText={getModalBodyText(selectedEdge)}
cancelText={
<span
className={classNames({
'tw-pointer-events-none tw-opacity-70':
deletionState.loading,
'tw-pointer-events-none tw-opacity-70': deletionState.loading,
})}>
Cancel
</span>
@ -888,11 +892,144 @@ const Entitylineage: FunctionComponent<EntityLineageProp> = ({
}}
onConfirm={onRemove}
/>
) : null}
</div>
) : (
);
}
};
/**
* Reset State between view and edit mode toggle
*/
const resetViewEditState = () => {
setExpandNode(undefined);
setTableColumns([]);
setConfirmDelete(false);
};
/**
* Handle updated linegae nodes
* Change newly added node label based on entity:EntityReference
*/
const handleUpdatedLineageNode = () => {
const nodes = updatedLineageData.nodes;
const newlyAddedNodeElement = elements.find((el) => el?.data?.isNewNode);
const newlyAddedNode = nodes?.find(
(node) => node.id === newlyAddedNodeElement?.id
);
setElements((els) => {
return els.map((el) => {
if (el.id === newlyAddedNode?.id) {
return {
...el,
data: { ...el.data, label: getNodeLabel(newlyAddedNode) },
};
} else {
return el;
}
});
});
};
useEffect(() => {
setElements(getLayoutedElements(setElementsHandle()));
resetViewEditState();
}, [lineageData, isNodeLoading, isEditMode]);
useEffect(() => {
const newNodes = updatedLineageData.nodes?.filter(
(n) =>
!isUndefined(
updatedLineageData.downstreamEdges?.find((d) => d.toEntity === n.id)
) ||
!isUndefined(
updatedLineageData.upstreamEdges?.find((u) => u.fromEntity === n.id)
)
);
entityLineageHandler({ ...updatedLineageData, nodes: newNodes });
}, [isEditMode]);
useEffect(() => {
handleUpdatedLineageNode();
}, [updatedLineageData]);
useEffect(() => {
onNodeExpand();
getTableColumns(expandNode);
}, [expandNode]);
useEffect(() => {
if (!isEmpty(selectedNode)) {
setExpandNode(undefined);
}
}, [selectedNode]);
useEffect(() => {
if (tableColumns.length) {
onNodeExpand(tableColumns);
}
}, [tableColumns]);
useEffect(() => {
onEntitySelect();
}, [selectedEntity]);
useEffect(() => {
removeEdgeHandler(selectedEdge, confirmDelete);
}, [selectedEdge, confirmDelete]);
useEffect(() => {
if (!isEmpty(entityLineage)) {
setLineageData(entityLineage);
setUpdatedLineageData(entityLineage);
}
}, [entityLineage]);
return deleted ? (
getDeletedLineagePlaceholder()
) : (
<Fragment>
<div
className={classNames(
'tw-relative tw-h-full tw--ml-4 tw--mr-7 tw--mt-4'
)}
data-testid="lineage-container">
<div className="tw-w-full tw-h-full" ref={reactFlowWrapper}>
<ReactFlowProvider>
<ReactFlow
data-testid="react-flow-component"
edgeTypes={{ buttonedge: CustomEdge }}
elements={elements as Elements}
maxZoom={2}
minZoom={0.5}
nodeTypes={nodeTypes}
nodesConnectable={isEditMode}
selectNodesOnDrag={false}
zoomOnDoubleClick={false}
zoomOnScroll={false}
onConnect={onConnect}
onDragOver={onDragOver}
onDrop={onDrop}
onElementClick={(_e, el) => onElementClick(el)}
onLoad={(reactFlowInstance: OnLoadParams) => {
onLoad(reactFlowInstance);
setReactFlowInstance(reactFlowInstance);
}}
onNodeContextMenu={onNodeContextMenu}
onNodeDrag={dragHandle}
onNodeDragStart={dragHandle}
onNodeDragStop={dragHandle}
onNodeMouseEnter={onNodeMouseEnter}
onNodeMouseLeave={onNodeMouseLeave}
onNodeMouseMove={onNodeMouseMove}>
{getCustomControlElements()}
{getGraphBackGround()}
</ReactFlow>
</ReactFlowProvider>
</div>
{getEntityDrawer()}
<EntityLineageSidebar newAddedNode={newAddedNode} show={isEditMode} />
{getConfirmationModal()}
</div>
</Fragment>
);
};

View File

@ -11,7 +11,7 @@
* limitations under the License.
*/
import { LeafNodes, LineagePos, LoadingNodeState } from 'Models';
import { LeafNodes, LineagePos, LoadingNodeState, LoadingState } from 'Models';
import {
EntityLineage,
EntityReference,
@ -71,3 +71,5 @@ export interface SelectedEdge {
source: EntityReference;
target: EntityReference;
}
export type ElementLoadingState = Exclude<LoadingState, 'waiting'>;

View File

@ -146,6 +146,7 @@ jest.mock('../../utils/EntityLineageUtils', () => ({
onNodeMouseEnter: jest.fn(),
onNodeMouseLeave: jest.fn(),
onNodeMouseMove: jest.fn(),
getUniqueFlowElements: jest.fn().mockReturnValue([]),
}));
jest.mock('../../utils/TableUtils', () => ({

View File

@ -1,4 +1,5 @@
import { capitalize } from 'lodash';
import { ElementLoadingState } from '../components/EntityLineage/EntityLineage.interface';
import { EntityType } from '../enums/entity.enum';
export const foreignObjectSize = 40;
@ -17,3 +18,8 @@ export const positionY = 60;
export const nodeWidth = 300;
export const nodeHeight = 40;
export const ELEMENT_DELETE_STATE = {
loading: false,
status: 'initial' as ElementLoadingState,
};

View File

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

View File

@ -741,6 +741,15 @@ body .profiler-graph .recharts-active-dot circle {
.leaf-node.core .react-flow__handle {
background-color: #7147e8;
}
.react-flow__edge {
pointer-events: all;
cursor: pointer;
}
.react-flow__edge .react-flow__edge-path {
stroke-width: 2px;
}
.react-flow__edge.selected .react-flow__edge-path {
stroke: #7147e8;
}

View File

@ -59,6 +59,7 @@ import {
prepareLabel,
} from './CommonUtils';
import { isLeafNode } from './EntityUtils';
import SVGIcons from './SvgUtils';
import { getEntityLink } from './TableUtils';
export const getHeaderLabel = (
@ -119,13 +120,14 @@ export const getLineageData = (
loadNodeHandler: (node: EntityReference, pos: LineagePos) => void,
lineageLeafNodes: LeafNodes,
isNodeLoading: LoadingNodeState,
getNodeLable: (node: EntityReference) => React.ReactNode,
getNodeLabel: (node: EntityReference) => React.ReactNode,
isEditMode: boolean,
edgeType: string,
onEdgeClick: (
evt: React.MouseEvent<HTMLButtonElement>,
data: CustomEdgeData
) => void
) => void,
removeNodeHandler: (node: Node) => void
) => {
const [x, y] = [0, 0];
const nodes = [
@ -140,6 +142,7 @@ export const getLineageData = (
isMapped: false,
...down,
})) || [];
const mainNode = entityLineage['entity'];
const UPStreamNodes: Elements = [];
@ -161,8 +164,10 @@ export const getLineageData = (
type: 'default',
className: 'leaf-node',
data: {
label: getNodeLable(node),
label: getNodeLabel(node),
entityType: node.type,
removeNodeHandler,
isEditMode,
},
position: {
x: pos === 'from' ? -xVal : xVal,
@ -171,6 +176,10 @@ export const getLineageData = (
};
};
const makeEdge = (edge: FlowElement) => {
lineageEdges.push(edge);
};
const getNodes = (
id: string,
pos: LineagePos,
@ -187,7 +196,7 @@ export const getLineageData = (
if (node) {
UPNodes.push(node);
UPStreamNodes.push(makeNode(node, 'from', depth, upDepth));
lineageEdges.push({
makeEdge({
id: `edge-${up.fromEntity}-${id}-${depth}`,
source: `${node.id}`,
target: edg ? edg.id : `${id}`,
@ -229,7 +238,7 @@ export const getLineageData = (
if (node) {
DOWNNodes.push(node);
DOWNStreamNodes.push(makeNode(node, 'to', depth, downDepth));
lineageEdges.push({
makeEdge({
id: `edge-${id}-${down.toEntity}`,
source: edg ? edg.id : `${id}`,
target: `${node.id}`,
@ -328,7 +337,9 @@ export const getLineageData = (
: 'input',
className: `leaf-node ${!isEditMode ? 'core' : ''}`,
data: {
label: getNodeLable(mainNode),
label: getNodeLabel(mainNode),
isEditMode,
removeNodeHandler,
},
position: { x: x, y: y },
},
@ -343,6 +354,7 @@ export const getLineageData = (
...up,
type: isEditMode ? 'default' : 'input',
data: {
...up.data,
label: (
<div className="tw-flex">
<div
@ -387,6 +399,7 @@ export const getLineageData = (
...down,
type: isEditMode ? 'default' : 'output',
data: {
...down.data,
label: (
<div className="tw-flex tw-justify-between">
<div>{down?.data?.label}</div>
@ -444,7 +457,7 @@ export const getDataLabel = (
} else {
return (
<span
className="tw-break-words description-text tw-self-center"
className="tw-break-words tw-self-center tw-w-60"
data-testid="lineage-entity">
{type === 'table'
? databaseName && schemaName
@ -496,8 +509,8 @@ export const getLayoutedElements = (
elements.forEach((el) => {
if (isNode(el)) {
dagreGraph.setNode(el.id, {
width: el?.__rf?.width ?? nodeWidth,
height: el?.__rf?.height ?? nodeHeight,
width: nodeWidth,
height: nodeHeight,
});
} else {
dagreGraph.setEdge(el.source, el.target);
@ -512,11 +525,8 @@ export const getLayoutedElements = (
el.targetPosition = isHorizontal ? Position.Left : Position.Top;
el.sourcePosition = isHorizontal ? Position.Right : Position.Bottom;
el.position = {
x:
nodeWithPosition.x -
(el?.__rf?.width ?? nodeWidth) / 2 +
Math.random() / 1000,
y: nodeWithPosition.y - (el?.__rf?.height ?? nodeHeight) / 2,
x: nodeWithPosition.x - nodeWidth / 2,
y: nodeWithPosition.y - nodeHeight / 2,
};
}
@ -527,27 +537,19 @@ export const getLayoutedElements = (
export const getModalBodyText = (selectedEdge: SelectedEdge) => {
let sourceEntity = '';
let targetEntity = '';
const sourceFQN = selectedEdge.source.fullyQualifiedName || '';
const targetFQN = selectedEdge.target.fullyQualifiedName || '';
if (selectedEdge.source.type === EntityType.TABLE) {
sourceEntity = getPartialNameFromTableFQN(
selectedEdge.source.name as string,
[FqnPart.Table]
);
sourceEntity = getPartialNameFromTableFQN(sourceFQN, [FqnPart.Table]);
} else {
sourceEntity = getPartialNameFromFQN(selectedEdge.source.name as string, [
'database',
]);
sourceEntity = getPartialNameFromFQN(sourceFQN, ['database']);
}
if (selectedEdge.target.type === EntityType.TABLE) {
targetEntity = getPartialNameFromTableFQN(
selectedEdge.target.name as string,
[FqnPart.Table]
);
targetEntity = getPartialNameFromTableFQN(targetFQN, [FqnPart.Table]);
} else {
targetEntity = getPartialNameFromFQN(selectedEdge.target.name as string, [
'database',
]);
targetEntity = getPartialNameFromFQN(targetFQN, ['database']);
}
return `Are you sure you want to remove the edge between "${
@ -560,3 +562,32 @@ export const getModalBodyText = (selectedEdge: SelectedEdge) => {
: targetEntity
}"?`;
};
export const getUniqueFlowElements = (elements: FlowElement[]) => {
const flag: { [x: string]: boolean } = {};
const uniqueElements: Elements = [];
elements.forEach((elem) => {
if (!flag[elem.id]) {
flag[elem.id] = true;
uniqueElements.push(elem);
}
});
return uniqueElements;
};
/**
*
* @param onClick - callback
* @returns - Button element with attach callback
*/
export const getNodeRemoveButton = (onClick: () => void) => {
return (
<button
className="tw-absolute tw--top-4 tw--right-6 tw-cursor-pointer tw-z-9999 tw-bg-body-hover tw-rounded-full"
onClick={() => onClick()}>
<SVGIcons alt="times-circle" icon="icon-times-circle" width="16px" />
</button>
);
};