895 lines
27 KiB
TypeScript

/*
* Copyright 2021 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.
*/
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { AxiosResponse } from 'axios';
import classNames from 'classnames';
import { isEmpty, isUndefined, lowerCase, uniqueId, upperCase } from 'lodash';
import { LoadingState } from 'Models';
import React, {
DragEvent,
Fragment,
FunctionComponent,
useEffect,
useRef,
useState,
} from 'react';
import ReactFlow, {
addEdge,
ArrowHeadType,
Background,
BackgroundVariant,
Connection,
Edge,
Elements,
FlowElement,
OnLoadParams,
ReactFlowProvider,
removeElements,
} from 'react-flow-renderer';
import { useAuthContext } from '../../auth-provider/AuthProvider';
import { getTableDetails } from '../../axiosAPIs/tableAPI';
import { Column } from '../../generated/entity/data/table';
import { Operation } from '../../generated/entity/policies/accessControl/rule';
import {
Edge as EntityEdge,
EntityLineage,
} from '../../generated/type/entityLineage';
import { EntityReference } from '../../generated/type/entityReference';
import { withLoader } from '../../hoc/withLoader';
import { useAuth } from '../../hooks/authHooks';
import useToastContext from '../../hooks/useToastContext';
import {
dragHandle,
getDataLabel,
getDeletedLineagePlaceholder,
getLayoutedElements,
getLineageData,
getModalBodyText,
onLoad,
onNodeContextMenu,
onNodeMouseEnter,
onNodeMouseLeave,
onNodeMouseMove,
} from '../../utils/EntityLineageUtils';
import SVGIcons from '../../utils/SvgUtils';
import { getEntityIcon } from '../../utils/TableUtils';
import NonAdminAction from '../common/non-admin-action/NonAdminAction';
import EntityInfoDrawer from '../EntityInfoDrawer/EntityInfoDrawer.component';
import Loader from '../Loader/Loader';
import ConfirmationModal from '../Modals/ConfirmationModal/ConfirmationModal';
import CustomControls, { ControlButton } from './CustomControls.component';
import { CustomEdge } from './CustomEdge.component';
import CustomNode from './CustomNode.component';
import {
CustomEdgeData,
Edge as NewEdge,
EdgeData,
EntityLineageProp,
SelectedEdge,
SelectedNode,
} from './EntityLineage.interface';
import EntityLineageSidebar from './EntityLineageSidebar.component';
import NodeSuggestions from './NodeSuggestions.component';
const Entitylineage: FunctionComponent<EntityLineageProp> = ({
entityLineage,
loadNodeHandler,
lineageLeafNodes,
isNodeLoading,
deleted,
addLineageHandler,
removeLineageHandler,
entityLineageHandler,
}: EntityLineageProp) => {
const showToast = useToastContext();
const { userPermissions, isAdminUser } = useAuth();
const { isAuthDisabled } = useAuthContext();
const reactFlowWrapper = useRef<HTMLDivElement>(null);
const [lineageData, setLineageData] = useState<EntityLineage>(entityLineage);
const [reactFlowInstance, setReactFlowInstance] = useState<OnLoadParams>();
const [isDrawerOpen, setIsDrawerOpen] = useState<boolean>(false);
const [selectedNode, setSelectedNode] = useState<SelectedNode>(
{} as SelectedNode
);
const expandButton = useRef<HTMLButtonElement | null>(null);
const [expandNode, setExpandNode] = useState<EntityReference | undefined>(
undefined
);
const [isEditMode, setEditMode] = useState<boolean>(false);
const [tableColumns, setTableColumns] = useState<Column[]>([] as Column[]);
const [newAddedNode, setNewAddedNode] = useState<FlowElement>(
{} as FlowElement
);
const [selectedEntity, setSelectedEntity] = useState<EntityReference>(
{} as EntityReference
);
const [confirmDelete, setConfirmDelete] = useState<boolean>(false);
const [showdeleteModal, setShowDeleteModal] = useState<boolean>(false);
const [selectedEdge, setSelectedEdge] = useState<SelectedEdge>(
{} as SelectedEdge
);
const [loading, setLoading] = useState<boolean>(false);
const [status, setStatus] = useState<LoadingState>('initial');
const [deletionState, setDeletionState] = useState<{
loading: boolean;
status: Exclude<LoadingState, 'waiting'>;
}>({
loading: false,
status: 'initial',
});
/**
* take node as input and check if node is main entity or not
* @param node
* @returns class `leaf-node core` for main node and `leaf-node` for leaf node
*/
const getNodeClass = (node: FlowElement) => {
return `${
node.id.includes(lineageData.entity?.id) && !isEditMode
? 'leaf-node core'
: 'leaf-node'
}`;
};
/**
* take entity as input and set it as selected entity
* @param entity
*/
const selectedEntityHandler = (entity: EntityReference) => {
setSelectedEntity(entity);
};
/**
* take state and value to set selected node
* @param state
* @param value
*/
const selectNodeHandler = (state: boolean, value: SelectedNode) => {
setIsDrawerOpen(state);
setSelectedNode(value);
};
/**
*
* @param node
* @returns lable for given node
*/
const getNodeLable = (node: EntityReference) => {
return (
<>
{node.type === 'table' && !isEditMode ? (
<button
className="tw-absolute tw--top-4 tw--left-5 tw-cursor-pointer tw-z-9999"
onClick={(e) => {
expandButton.current = expandButton.current
? null
: e.currentTarget;
setExpandNode(expandNode ? undefined : node);
setIsDrawerOpen(false);
}}>
<SVGIcons
alt="plus"
icon={expandNode?.id === node.id ? 'icon-minus' : 'icon-plus'}
width="16px"
/>
</button>
) : null}
<p className="tw-flex">
<span className="tw-mr-2">{getEntityIcon(node.type)}</span>
{getDataLabel(node.displayName, node.name, '.', false, node.type)}
</p>
</>
);
};
/**
*
* @param data selected edge
* @param confirmDelete confirmation state for deleting seslected edge
*/
const removeEdgeHandler = (data: SelectedEdge, confirmDelete: boolean) => {
if (confirmDelete) {
const edgeData: EdgeData = {
fromEntity: data.source.type,
fromId: data.source.id,
toEntity: data.target.type,
toId: data.target.id,
};
removeLineageHandler(edgeData);
// eslint-disable-next-line @typescript-eslint/no-use-before-define
setElements((es) => es.filter((e) => e.id !== data.id));
/**
* Get new downstreamEdges
*/
const newDownStreamEdges = lineageData.downstreamEdges?.filter(
(dn) =>
!lineageData.downstreamEdges?.find(
() =>
edgeData.fromId === dn.fromEntity && edgeData.toId === dn.toEntity
)
);
/**
* Get new upstreamEdges
*/
const newUpStreamEdges = lineageData.upstreamEdges?.filter(
(up) =>
!lineageData.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,
downstreamEdges: newDownStreamEdges,
upstreamEdges: newUpStreamEdges,
});
}
};
/**
* take edge data and set it as selected edge
* @param evt
* @param data
*/
const onEdgeClick = (
evt: React.MouseEvent<HTMLButtonElement>,
data: CustomEdgeData
) => {
setShowDeleteModal(true);
evt.stopPropagation();
setSelectedEdge(() => {
let targetNode = lineageData.nodes?.find((n) =>
data.target?.includes(n.id)
);
let sourceNode = lineageData.nodes?.find((n) =>
data.source?.includes(n.id)
);
if (isUndefined(targetNode)) {
targetNode = isEmpty(selectedEntity)
? lineageData.entity
: selectedEntity;
}
if (isUndefined(sourceNode)) {
sourceNode = isEmpty(selectedEntity)
? lineageData.entity
: selectedEntity;
}
return { id: data.id, source: sourceNode, target: targetNode };
});
};
/**
*
* @returns unique flow elements
*/
const setElementsHandle = () => {
const flag: { [x: string]: boolean } = {};
const uniqueElements: Elements = [];
if (!isEmpty(lineageData)) {
const graphElements = getLineageData(
lineageData,
selectNodeHandler,
loadNodeHandler,
lineageLeafNodes,
isNodeLoading,
getNodeLable,
isEditMode,
'buttonedge',
onEdgeClick
) as Elements;
graphElements.forEach((elem) => {
if (!flag[elem.id]) {
flag[elem.id] = true;
uniqueElements.push(elem);
}
});
}
return uniqueElements;
};
const [elements, setElements] = useState<Elements>(
getLayoutedElements(setElementsHandle())
);
/**
* take boolean value as input and reset selected node
* @param value
*/
const closeDrawer = (value: boolean) => {
setIsDrawerOpen(value);
setElements((prevElements) => {
return prevElements.map((el) => {
if (el.id === selectedNode.id) {
return {
...el,
className: getNodeClass(el),
};
} else {
return el;
}
});
});
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) => {
setStatus('waiting');
setLoading(true);
const { target, source } = params;
const nodes = [
...(lineageData.nodes as EntityReference[]),
lineageData.entity,
];
const sourceDownstreamNode = lineageData.downstreamEdges?.find((d) =>
source?.includes(d.toEntity as string)
);
const sourceUpStreamNode = lineageData.upstreamEdges?.find((u) =>
source?.includes(u.fromEntity as string)
);
const targetDownStreamNode = lineageData.downstreamEdges?.find((d) =>
target?.includes(d.toEntity as string)
);
const targetUpStreamNode = lineageData.upstreamEdges?.find((u) =>
target?.includes(u.fromEntity as string)
);
let targetNode = nodes?.find((n) => target?.includes(n.id));
let sourceNode = nodes?.find((n) => source?.includes(n.id));
if (isUndefined(targetNode)) {
targetNode = target?.includes(lineageData.entity?.id)
? lineageData.entity
: selectedEntity;
}
if (isUndefined(sourceNode)) {
sourceNode = source?.includes(lineageData.entity?.id)
? lineageData.entity
: selectedEntity;
}
const newEdge: NewEdge = {
edge: {
fromEntity: {
id: sourceNode.id,
type: sourceNode.type,
},
toEntity: {
id: targetNode.id,
type: targetNode.type,
},
},
};
setElements((els) =>
addEdge({ ...params, arrowHeadType: ArrowHeadType.ArrowClosed }, els)
);
setTimeout(() => {
addLineageHandler(newEdge)
.then(() => {
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,
});
setStatus('initial');
}, 100);
setNewAddedNode({} as FlowElement);
setSelectedEntity({} as EntityReference);
})
.catch(() => {
setStatus('initial');
setLoading(false);
});
}, 500);
};
/**
* take element and perform onClick logic
* @param el
*/
const onElementClick = (el: FlowElement) => {
const node = [
...(lineageData.nodes as Array<EntityReference>),
lineageData.entity,
].find((n) => el.id.includes(n.id));
if (!expandButton.current) {
selectNodeHandler(true, {
name: node?.name as string,
id: el.id,
displayName: node?.displayName,
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;
}
};
/**
* this method is used for table entity to show table columns
* @param tableColumns
*/
const onNodeExpand = (tableColumns?: Column[]) => {
const elements = getLayoutedElements(setElementsHandle());
setElements(
elements.map((preEl) => {
if (preEl.id.includes(expandNode?.id as string)) {
return {
...preEl,
className: `${preEl.className} selected-node`,
data: { ...preEl.data, columns: tableColumns },
};
} else {
return {
...preEl,
className: getNodeClass(preEl),
};
}
})
);
};
/**
* take node and get the columns for that node
* @param expandNode
*/
const getTableColumns = (expandNode?: EntityReference) => {
if (expandNode) {
getTableDetails(expandNode.id, ['columns'])
.then((res: AxiosResponse) => {
const { columns } = res.data;
setTableColumns(columns);
})
.catch(() => {
showToast({
variant: 'error',
body: `Error while fetching ${getDataLabel(
expandNode.displayName,
expandNode.name,
'.',
true
)} columns`,
});
});
}
};
/**
* take node and remove it from the graph
* @param node
*/
const removeNodeHandler = (node: FlowElement) => {
setElements((es) => es.filter((n) => n.id !== node.id));
setNewAddedNode({} as FlowElement);
};
/**
* handle node drag event
* @param event
*/
const onDragOver = (event: DragEvent) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
};
/**
* handle node drop event
* @param event
*/
const onDrop = (event: DragEvent) => {
event.preventDefault();
const reactFlowBounds = reactFlowWrapper.current?.getBoundingClientRect();
const type = event.dataTransfer.getData('application/reactflow');
if (type.trim()) {
const position = reactFlowInstance?.project({
x: event.clientX - (reactFlowBounds?.left ?? 0),
y: event.clientY - (reactFlowBounds?.top ?? 0),
});
const [lable, nodeType] = type.split('-');
const newNode = {
id: uniqueId(),
nodeType,
position,
className: 'leaf-node',
connectable: 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>
<div className="tw-flex">
<SVGIcons
alt="entity-icon"
className="tw-mr-2"
icon={`${lowerCase(lable)}-grey`}
width="16px"
/>
<NodeSuggestions
entityType={upperCase(lable)}
onSelectHandler={selectedEntityHandler}
/>
</div>
</div>
),
isNewNode: true,
},
};
setNewAddedNode(newNode as FlowElement);
setElements((es) => es.concat(newNode as FlowElement));
}
};
/**
* handle onNode select logic
*/
const onEntitySelect = () => {
if (!isEmpty(selectedEntity)) {
const isExistingNode = elements.some((n) =>
n.id.includes(selectedEntity.id)
);
if (isExistingNode) {
setElements((es) =>
es
.map((n) =>
n.id.includes(selectedEntity.id)
? { ...n, className: `${n.className} selected-node` }
: n
)
.filter((es) => es.id !== newAddedNode.id)
);
setNewAddedNode({} as FlowElement);
setSelectedEntity({} as EntityReference);
} else {
setElements((es) => {
return es.map((el) => {
if (el.id === newAddedNode.id) {
return {
...el,
connectable: true,
id: selectedEntity.id,
data: {
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={() => {
removeNodeHandler({
...el,
id: selectedEntity.id,
} as FlowElement);
}}>
<SVGIcons
alt="times-circle"
icon="icon-times-circle"
width="16px"
/>
</button>
</Fragment>
),
},
};
} else {
return el;
}
});
});
}
}
};
const onRemove = () => {
setDeletionState({ loading: true, status: 'initial' });
setTimeout(() => {
setDeletionState({ loading: false, status: 'success' });
setTimeout(() => {
setShowDeleteModal(false);
setConfirmDelete(true);
setDeletionState((pre) => ({ ...pre, status: 'initial' }));
}, 500);
}, 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]);
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={
<>
<p>You do not have permission to edit the lineage</p>
</>
}
permission={Operation.UpdateLineage}>
<ControlButton
className={classNames(
'tw-h-9 tw-w-9 tw-rounded-full tw-px-1 tw-shadow-lg tw-cursor-pointer',
{
'tw-bg-primary': isEditMode,
'tw-bg-primary-hover-lite': !isEditMode,
},
{
'tw-opacity-40':
!userPermissions[Operation.UpdateLineage] &&
!isAuthDisabled &&
!isAdminUser,
}
)}
onClick={() => {
setEditMode((pre) => !pre && !deleted);
setSelectedNode({} as SelectedNode);
setIsDrawerOpen(false);
setNewAddedNode({} as FlowElement);
}}>
{loading ? (
<Loader size="small" type="white" />
) : status === 'success' ? (
<FontAwesomeIcon
className="tw-text-white"
icon="check"
/>
) : (
<SVGIcons
alt="icon-edit-lineag"
className="tw--mt-1"
data-testid="edit-lineage"
icon={
!isEditMode
? 'icon-edit-lineage-color'
: 'icon-edit-lineage'
}
width="14"
/>
)}
</ControlButton>
</NonAdminAction>
)}
</CustomControls>
{isEditMode ? (
<Background
gap={12}
size={1}
variant={BackgroundVariant.Lines}
/>
) : null}
</ReactFlow>
</ReactFlowProvider>
</div>
<EntityInfoDrawer
isMainNode={selectedNode.name === lineageData.entity?.name}
selectedNode={selectedNode}
show={isDrawerOpen && !isEditMode}
onCancel={closeDrawer}
/>
<EntityLineageSidebar newAddedNode={newAddedNode} show={isEditMode} />
{showdeleteModal ? (
<ConfirmationModal
bodyText={getModalBodyText(selectedEdge)}
cancelText={
<span
className={classNames({
'tw-pointer-events-none tw-opacity-70':
deletionState.loading,
})}>
Cancel
</span>
}
confirmText={
deletionState.loading ? (
<Loader size="small" type="white" />
) : deletionState.status === 'success' ? (
<FontAwesomeIcon className="tw-text-white" icon="check" />
) : (
'Confirm'
)
}
header="Remove lineage edge"
onCancel={() => {
setShowDeleteModal(false);
}}
onConfirm={onRemove}
/>
) : null}
</div>
) : (
getDeletedLineagePlaceholder()
)}
</Fragment>
);
};
export default withLoader<EntityLineageProp>(Entitylineage);