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. * 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 React from 'react';
import { ArrowHeadType, EdgeProps, Position } from 'react-flow-renderer'; import { ArrowHeadType, EdgeProps, Position } from 'react-flow-renderer';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
@ -40,6 +45,7 @@ const mockCustomEdgeProp = {
id: 'node1', id: 'node1',
}, },
}, },
selected: true,
} as EdgeProps; } as EdgeProps;
describe('Test CustomEdge Component', () => { describe('Test CustomEdge Component', () => {
@ -49,27 +55,24 @@ describe('Test CustomEdge Component', () => {
}); });
const deleteButton = await findByTestId(container, 'delete-button'); const deleteButton = await findByTestId(container, 'delete-button');
const edgePathElement = await findByTestId( const edgePathElement = await findAllByTestId(
container, container,
'react-flow-edge-path' 'react-flow-edge-path'
); );
expect(deleteButton).toBeInTheDocument(); 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( const { container } = render(
<CustomEdge <CustomEdge {...mockCustomEdgeProp} selected={false} />,
{...mockCustomEdgeProp}
data={{ ...mockCustomEdgeProp.data, selectedNode: {} }}
/>,
{ {
wrapper: MemoryRouter, wrapper: MemoryRouter,
} }
); );
const edgePathElement = await findByTestId( const edgePathElement = await findAllByTestId(
container, container,
'react-flow-edge-path' 'react-flow-edge-path'
); );
@ -77,6 +80,6 @@ describe('Test CustomEdge Component', () => {
const deleteButton = queryByTestId(container, 'delete-button'); const deleteButton = queryByTestId(container, 'delete-button');
expect(deleteButton).not.toBeInTheDocument(); expect(deleteButton).not.toBeInTheDocument();
expect(edgePathElement).toBeInTheDocument(); expect(edgePathElement).toHaveLength(edgePathElement.length);
}); });
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import { capitalize } from 'lodash'; import { capitalize } from 'lodash';
import { ElementLoadingState } from '../components/EntityLineage/EntityLineage.interface';
import { EntityType } from '../enums/entity.enum'; import { EntityType } from '../enums/entity.enum';
export const foreignObjectSize = 40; export const foreignObjectSize = 40;
@ -17,3 +18,8 @@ export const positionY = 60;
export const nodeWidth = 300; export const nodeWidth = 300;
export const nodeHeight = 40; 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; @apply tw-border-main;
box-shadow: 0 0 0 0.5px #e2dce4; box-shadow: 0 0 0 0.5px #e2dce4;
} }
.leaf-node.selected-node, .leaf-node.selected,
.leaf-node.selected-node:hover { .leaf-node.selected:hover {
@apply tw-border-primary-active; @apply tw-border-primary-active;
box-shadow: 0 0 0 0.5px #7147e8; 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 { .leaf-node.core .react-flow__handle {
background-color: #7147e8; 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 { .react-flow__edge.selected .react-flow__edge-path {
stroke: #7147e8; stroke: #7147e8;
} }

View File

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